/* Refill PWA — for sjåfører som fyller automatene. * * Optimalisert for Android nettbrett-landscape. 3 hovedmodi: * 1. Oversikt — alle automater med tomme flasker, kryss av hvilke å fylle * 2. Plan — viser bil-last + ADR + rute + Google Maps-link * 3. Stop — utfør én og én automat-besøk, registrer fyllinger, signer */ const RefillPage = ({ lang, tenant, dataVersion, onPickTenant }) => { const [mode, setMode] = React.useState('overview'); // overview | plan | stop | list | route const [activePlan, setActivePlan] = React.useState(null); const [activeStop, setActiveStop] = React.useState(null); const [routeIds, setRouteIds] = React.useState(null); // manuelt valgte → kjørerute // Superadmin med "alle tenants" — vis tenant-velger inline i stedet for å blokkere if (!tenant || tenant.id === '__all__') { const tenants = (DATA.TENANTS || []).filter(t => t.id !== '__all__'); return (

{lang === 'no' ? 'Påfylling' : 'Refill'}

{lang === 'no' ? 'Velg hvilken tenant du vil planlegge påfylling for' : 'Pick the tenant to plan refill for'}
{tenants.length === 0 ? (
{lang === 'no' ? 'Ingen tenants enda' : 'No tenants yet'}
) : (
{tenants.map(tn => { const automats = (DATA.machines || []).filter(m => m.tenantId === tn.id); const empties = automats.reduce((s, m) => s + (m.emptyCount || 0), 0); return (
onPickTenant && onPickTenant(tn)} style={{ padding: 14, cursor: 'pointer', background: 'var(--surface-2)', border: '1px solid var(--border)', borderRadius: 8 }}>
{tn.name}
{automats.length} {lang === 'no' ? 'automater' : 'automats'} {empties > 0 && ( · {empties} {lang === 'no' ? 'tomme flasker' : 'empty bottles'} )}
); })}
)}
); } return (

{lang === 'no' ? 'Fylling' : 'Refill'} {tenant.name}

{lang === 'no' ? 'Påfyll-rutine for automater med tomme flasker' : 'Refill routine'}
{mode === 'route' && ( { setActivePlan(p); setMode('plan'); }} onBack={() => { setRouteIds(null); setMode('overview'); }} /> )} {mode === 'overview' && ( { setActivePlan(p); setMode('plan'); }} onMakeRoute={ids => { setRouteIds(ids); setMode('route'); }} /> )} {mode === 'plan' && activePlan && ( { setActiveStop(s); setMode('stop'); }} onBack={() => setMode('overview')} /> )} {mode === 'stop' && activeStop && activePlan && ( setMode('plan')} /> )} {mode === 'list' && ( { setActivePlan(p); setMode('plan'); }} onBack={() => setMode('overview')} /> )}
); }; /* === Oversikt: tomme flasker per automat → velg hvilke å fylle === */ const RefillOverview = ({ tenant, lang, dataVersion, onPlanCreated, onMakeRoute }) => { const [data, setData] = React.useState(null); const [selected, setSelected] = React.useState(new Set()); const [truckLoaded, setTruckLoaded] = React.useState({}); const [estimate, setEstimate] = React.useState(null); // backend estimate-respons const [startGPS, setStartGPS] = React.useState(null); const [busy, setBusy] = React.useState(false); const [estimating, setEstimating] = React.useState(false); const [err, setErr] = React.useState(null); // Filter/sortering av utkjøringslista + åpen service-seksjon const [areaFilter, setAreaFilter] = React.useState(''); const [sortKey, setSortKey] = React.useState('priority'); // priority|location|empties const [sortDir, setSortDir] = React.useState(1); // 1 stigende, -1 synkende const [serviceOpen, setServiceOpen] = React.useState(null); // automat_id | null const refresh = React.useCallback(async () => { try { setData(await window.api.refillOverview(tenant.id)); } catch (e) { setErr(e.message); } }, [tenant.id]); React.useEffect(() => { refresh(); }, [refresh]); // Når automatene endres: nullstill estimate så bruker MÅ trykke "Beregn" React.useEffect(() => { setEstimate(null); }, [selected]); // GPS: hent posisjon const getGPS = () => { if (!navigator.geolocation) return alert('GPS ikke tilgjengelig'); navigator.geolocation.getCurrentPosition( pos => setStartGPS({ lat: pos.coords.latitude, lon: pos.coords.longitude, address: 'Min posisjon' }), err => alert('GPS feil: ' + err.message), { enableHighAccuracy: true, timeout: 10000 }, ); }; const toggle = (aid) => { const next = new Set(selected); if (next.has(aid)) next.delete(aid); else next.add(aid); setSelected(next); }; const updateLoad = (code, delta) => { setTruckLoaded(prev => { const n = Math.max(0, (prev[code] || 0) + delta); return n === 0 ? Object.fromEntries(Object.entries(prev).filter(([k]) => k !== code)) : { ...prev, [code]: n }; }); }; const runEstimate = async () => { if (selected.size === 0) { alert(lang === 'no' ? 'Velg minst én automat' : 'Pick at least one'); return; } setEstimating(true); setErr(null); try { const r = await window.api.refillEstimate({ tenant_id: tenant.id, automat_ids: [...selected], start: startGPS || undefined, }); setEstimate(r); // Auto-fyll truckLoaded med anbefalt last setTruckLoaded(r.recommended || {}); } catch (e) { setErr(e.message); } setEstimating(false); }; const createPlan = async () => { if (selected.size === 0) return alert(lang === 'no' ? 'Velg minst én automat' : 'Pick at least one'); setBusy(true); setErr(null); try { const p = await window.api.refillCreatePlan({ tenant_id: tenant.id, automat_ids: [...selected], truck_loaded: truckLoaded, start: startGPS || undefined, }); onPlanCreated(p); } catch (e) { setErr(e.message); } setBusy(false); }; if (!data) return
⏳ Laster…
; // Filter (område) + sortering (lokasjon/tomme, stigende/synkende) const areas = [...new Map((data.automats || []) .filter(a => a.area_id) .map(a => [a.area_id, a.area_name])).entries()]; let list = data.automats; if (areaFilter === '__none__') list = list.filter(a => !a.area_id); else if (areaFilter) list = list.filter(a => a.area_id === areaFilter); list = [...list].sort((x, y) => { if (sortKey === 'location') return sortDir * String(x.location).localeCompare(String(y.location), 'no'); if (sortKey === 'empties') return sortDir * ((x.empty_count || 0) - (y.empty_count || 0)); return -((x.fill_priority || 0) - (y.fill_priority || 0)); }); const sortBtn = (key, label) => ( ); return ( // Mobil: .refill-grid stabler Lastebilen-panelet under lista (styles.css)

{lang === 'no' ? 'Automater som trenger påfyll' : 'Automats needing refill'}

{data.totals.automats_with_empties} av {data.automats.length}
{/* Filter + sortering */}
{sortBtn('location', lang === 'no' ? 'Lokasjon' : 'Location')} {sortBtn('empties', lang === 'no' ? 'Tomme' : 'Empties')}
{onMakeRoute && ( )}
{list.map(a => (
toggle(a.automat_id)} style={{ padding: 12, marginBottom: 8, borderRadius: 6, cursor: 'pointer', background: selected.has(a.automat_id) ? 'var(--accent-soft)' : 'var(--surface-2)', border: '1px solid ' + (selected.has(a.automat_id) ? 'var(--accent)' : 'var(--border)'), }}>
{selected.has(a.automat_id) ? '✓ ' : '○ '}{a.location} {a.area_name && {a.area_name}}
{a.automat_id}
80 ? 'danger' : a.fill_priority > 40 ? 'warn' : 'empty')}> {a.fill_priority} {a.empty_count} {lang === 'no' ? 'tomme' : 'empty'}
{Object.keys(a.empty_by_code || {}).length > 0 && (
{Object.entries(a.empty_by_code).map(([code, n]) => ( {n}× {code} ))}
)} {/* Alarmer → automaten trenger SERVICE på utkjøringen */} {(a.alarms || []).length > 0 && (
{a.alarms.map((al, i) => (
⚠ {al.message}
))}
)} {/* Gassensor-frist (årlig kalibrering) */} {a.gas_sensor_days_left != null && a.gas_sensor_days_left <= 60 && (
🔧 {a.gas_sensor_days_left < 0 ? (lang === 'no' ? `Gassensor-kalibrering FORFALT (${-a.gas_sensor_days_left} dager siden)` : 'Gas sensor calibration OVERDUE') : (lang === 'no' ? `Gassensor-kalibrering om ${a.gas_sensor_days_left} dager` : `Gas sensor cal. in ${a.gas_sensor_days_left} days`)}
)} {/* Service: registrer + historikk */}
e.stopPropagation()}> {serviceOpen === a.automat_id && ( )}
))}

{lang === 'no' ? 'Lastebilen' : 'The truck'}

{lang === 'no' ? 'Startpunkt (lager)' : 'Start point'}
{startGPS && (
{startGPS.lat.toFixed(5)}, {startGPS.lon.toFixed(5)}
)}
{/* Beregn-behov-knapp */} {!estimate && ( )} {/* Breakdown-tabell etter estimate */} {estimate && (
{lang === 'no' ? 'Forslag basert på historikk' : 'Suggestion'}
{Object.keys(estimate.recommended || {}).sort().map(code => ( ))}
Type {lang === 'no' ? 'Tomme' : 'Empty'} {lang === 'no' ? 'Overhead' : 'Overhead'} {lang === 'no' ? 'Anbefalt' : 'Recommend'}
{code} {estimate.empties_now[code] || 0} +{estimate.overhead[code] || 0} {estimate.recommended[code]}
{lang === 'no' ? `Basert på snitt-salg samme ukedag siste 4 uker · +10% sikkerhet` : 'Based on 4-week avg same weekday + 10% safety'}
{estimate.adr_estimate?.adr_warning && (
⚠ {estimate.adr_estimate.message}
)} {estimate.route?.estimated_drive_min > 0 && (
{lang === 'no' ? 'Estimert kjøretid' : 'Estimated drive'}: {' '}{Math.round(estimate.route.estimated_drive_min / 60 * 10) / 10}t {' · '}{estimate.route.total_km} km
)}
)}
{lang === 'no' ? 'Flasker på bilen (juster fritt)' : 'Truck load (edit freely)'}
{(window.DATA?.BOTTLE_TYPES || []).map(b => { const n = truckLoaded[b.id] || 0; const rec = estimate?.recommended?.[b.id] || 0; const diff = n - rec; return (
{b.name} {estimate && rec > 0 && ( (anbefalt {rec}) )}
0 && diff < 0 ? 'var(--warn)' : 'inherit', }}>{n}
); })} {/* Live ADR-status */} {err &&
❌ {err}
}
); }; const ADRWidget = ({ loaded, lang }) => { // Backend gjør faktisk beregning, men frontend gjør en rask estimat const WEIGHTS = { stal5kg:5, stal11kg:11, lettstal10kg:10, komp5kg:5, komp10kg:10, aluminium11kg:11, comf10kg:10, stal5kg_i:5, stal11kg_i:11, komp5kg_i:5, komp10kg_i:10, }; let gas = 0; // Vekt utledes fra v2-koden (11kg_steel, 5kg_comp …) via bottleHelpers — // WEIGHTS er kun fallback for gamle v1-koder, deretter 10 kg (2026-09-05; // før falt alle v2-koder til 10 kg og ADR-prosenten ble feil for 5/11 kg). Object.entries(loaded).forEach(([code, n]) => { const kg = (window.bottleHelpers && window.bottleHelpers.weightKg(code)) || WEIGHTS[code] || 10; gas += kg * n; }); const LIMIT = 333; const pct = (gas / LIMIT) * 100; const tone = pct > 100 ? 'danger' : pct > 80 ? 'warn' : 'ok'; return (
ADR-status
{gas} kg gass {pct.toFixed(0)}% av LQ ({LIMIT} kg)
{pct > 100 && (
⚠ Over LQ-grensen — krever full ADR-dokumentasjon
)}
); }; /* === Plan-view: rute, kart-link, stops, ADR === */ const RefillPlanView = ({ planId, lang, onStartStop, onBack }) => { const [plan, setPlan] = React.useState(null); const [showManifest, setShowManifest] = React.useState(false); const refresh = React.useCallback(async () => { try { setPlan(await window.api.refillGetPlan(planId)); } catch (e) { console.error(e); } }, [planId]); React.useEffect(() => { refresh(); const id = setInterval(refresh, 15000); return () => clearInterval(id); }, [refresh]); if (!plan) return
⏳ Laster plan…
; return (
{plan.google_maps_url && ( 🗺 {lang === 'no' ? 'Åpne i Google Maps' : 'Open in Google Maps'} )}
{plan.status}

{lang === 'no' ? 'Stop-rekkefølge' : 'Stop order'}

{plan.stops.map((s, i) => (
{i + 1}. {s.location}
{s.status === 'pending' ? ( ) : s.status === 'in_progress' ? ( ) : ( ✓ Fullført )}
{s.planned_empty_shelves.length} {lang === 'no' ? 'tomme hyller' : 'empty shelves'} {' · '} {Object.entries(s.suggested_bottles || {}).map(([c, n]) => `${n}× ${c}`).join(', ')}
))}
{/* Digitalt fraktbrev — vis ved kontroll */} {showManifest && ( setShowManifest(false)} /> )} {/* Rute-sammendrag */}

Rute

{plan.schedule?.needs_break && (
⚠ {plan.schedule.warning}
)}
{/* ADR */}

ADR

{plan.adr.message}
{/* Sign-knapp hvis alt fullført */} {plan.stops.every(s => s.status === 'completed') && !plan.signed_at && ( )} {plan.signed_at && (
✓ Signert {new Date(plan.signed_at).toLocaleString('no-NO')}
)}
); }; /* === Stop-view: åpne dører + registrer fylling + ferdigstill === */ const RefillStopView = ({ plan, stop, lang, onDone }) => { const [phase, setPhase] = React.useState('arrive'); // arrive | filling | done const [fillEvents, setFillEvents] = React.useState([]); const [busy, setBusy] = React.useState(false); const [manifest, setManifest] = React.useState(null); const [showManifest, setShowManifest] = React.useState(false); const [fillStatus, setFillStatus] = React.useState(null); // dør-for-dør + sum const [rescanBusy, setRescanBusy] = React.useState(null); // dørnr | null // Live under fylling: dør-for-dør-status (deteksjon pr. lukket dør) + // fraktbrev/bil-beholdning — poll hvert 3. sekund. React.useEffect(() => { if (phase !== 'filling') return; let alive = true; const tick = async () => { try { const [m, fs] = await Promise.all([ window.api.refillManifest(plan.plan_id), window.api.refillFillStatus(plan.plan_id, stop.stop_id), ]); if (!alive) return; setManifest(m); setFillStatus(fs); setFillEvents((m.events || []).filter(e => e.stop_id === stop.stop_id)); } catch { /* neste poll */ } }; tick(); const iv = setInterval(tick, 3000); return () => { alive = false; clearInterval(iv); }; }, [phase, plan.plan_id, stop.stop_id]); const rescan = async (door) => { setRescanBusy(door); try { const r = await window.api.refillRescan(plan.plan_id, stop.stop_id, door); alert(r.instruction || 'Dør åpnet for re-skann'); } catch (e) { alert(e.message); } finally { setRescanBusy(null); } }; const arrive = async () => { setBusy(true); let coords = {}; if (navigator.geolocation) { await new Promise((res) => { navigator.geolocation.getCurrentPosition( p => { coords = { arrived_lat: p.coords.latitude, arrived_lon: p.coords.longitude }; res(); }, () => res(), { timeout: 5000 } ); }); } try { const r = await window.api.refillArrive(plan.plan_id, stop.stop_id, coords); alert((lang === 'no' ? 'Åpnet ' : 'Opened ') + r.doors_opened.length + ' dører'); setPhase('filling'); } catch (e) { alert(e.message); } setBusy(false); }; const completeStop = async () => { setBusy(true); try { // SIGNERING: sjåføren bekrefter avstemmingssummen med denne knappen const body = fillStatus && fillStatus.summary ? { confirm_empties_out: fillStatus.summary.empties_out, confirm_fulls_in: fillStatus.summary.fulls_in } : {}; await window.api.refillCompleteStop(plan.plan_id, stop.stop_id, body); setPhase('done'); setTimeout(onDone, 1500); } catch (e) { alert(e.message); } setBusy(false); }; return (

{stop.location} {stop.automat_id}

{phase === 'arrive' && ( <>
{lang === 'no' ? 'Ved automaten?' : 'At the machine?'}
{lang === 'no' ? `Trykk under for å åpne ${stop.planned_empty_shelves.length} tomme dører.` : `Press to open ${stop.planned_empty_shelves.length} empty doors.`}
)} {phase === 'filling' && ( <>
📦 {lang === 'no' ? 'Bytt ut flaskene' : 'Replace bottles'}
{lang === 'no' ? 'Når du lukker en dør registreres flasken automatisk. Sjekk listen under.' : 'Closing a door auto-registers the new bottle.'}
{lang === 'no' ? 'SKAL INN (forslag etter salgsfaktor)' : 'Fill suggestion'}:
{Object.entries(stop.suggested_bottles || {}).map(([code, n]) => ( {n}× {code.replace('_', ' ')} ))}
{/* Dør-for-dør: hva automaten DETEKTERTE da hver dør ble lukket. Feil? → «Skann på nytt» åpner døra og erstatter registreringen. */}
{lang === 'no' ? 'Dører (deteksjon ved lukking)' : 'Doors'}:
{!fillStatus && (
⏳
)} {fillStatus && fillStatus.doors.map(d => (
{lang === 'no' ? 'Dør' : 'Door'} {d.door}{' '} {d.was === 'empty_bottle' ? ({lang === 'no' ? 'tomflaske' : 'empty bottle'}: {d.was_bottle}) : ({lang === 'no' ? 'ledig' : 'vacant'})}
{d.removed_empty && 📤 {lang === 'no' ? 'tom ut' : 'empty out'}: {d.removed_empty} · } {d.done ? 📥 {d.inserted} {d.detection_score != null && window.isSuperView && window.isSuperView() && ` (${Math.round(d.detection_score * 100)}%)`} : ⏳ {lang === 'no' ? 'åpen — venter på lukking' : 'open'}}
{d.done && !fillStatus.confirmed && ( )}
))}
{/* Avstemming: sum når alle dører er lukket → SIGNERING */} {fillStatus && (
{lang === 'no' ? 'Avstemming' : 'Reconciliation'}:{' '} 📤 {fillStatus.summary.empties_out} {lang === 'no' ? 'tomme hentet ut' : 'empties out'} · {' '} 📥 {fillStatus.summary.fulls_in} {lang === 'no' ? 'fulle fylt på' : 'fulls in'} {!fillStatus.all_done && (
{lang === 'no' ? `${fillStatus.doors.filter(d => !d.done).length} dør(er) gjenstår — lukk alle før signering.` : 'Close all doors before signing.'}
)}
)} {/* Bil-beholdning nå (digitalt fraktbrev, kortversjon) */} {manifest && (
🚚 {lang === 'no' ? 'På bilen nå' : 'On truck'}:{' '} {Object.entries(manifest.current.full).map(([c, n]) => `${n}× ${c}`).join(', ') || (lang === 'no' ? 'ingen fulle' : 'none')} {Object.keys(manifest.current.empties).length > 0 && ( <> · {lang === 'no' ? 'tomme' : 'empties'}: {Object.entries(manifest.current.empties).map(([c, n]) => `${n}× ${c}`).join(', ')} )} · {manifest.current.gas_kg} kg gass
)}
{lang === 'no' ? 'Stemmer ikke tallene? Trykk «Skann på nytt» på døra som er feil — ny deteksjon erstatter registreringen i fraktbrevet.' : 'Wrong numbers? Rescan the door — new detection replaces the entry.'}
)} {showManifest && ( setShowManifest(false)} /> )} {phase === 'done' && (
✓
{lang === 'no' ? 'Stop fullført' : 'Stop completed'}
)}
); }; /* === DIGITALT FRAKTBREV — vis ved kontroll av mengde på bilen === Live beholdning (fulle + tomme per type, kg gass, ADR-status), startlast, og full hendelseslogg: hver flaske inn/ut med automat, dør og tidspunkt. */ const FraktbrevModal = ({ planId, lang, onClose }) => { const no = lang === 'no'; const [m, setM] = React.useState(null); React.useEffect(() => { let alive = true; window.api.refillManifest(planId) .then(x => { if (alive) setM(x); }) .catch(e => { if (alive) setM({ error: e.message }); }); return () => { alive = false; }; }, [planId]); const Row = ({ k, v }) => (
{k}{v}
); const LoadTable = ({ title, full, empties }) => (
{title}
{Object.entries(full || {}).map(([c, n]) => ( ))} {Object.entries(empties || {}).map(([c, n]) => ( ))} {Object.keys(full || {}).length === 0 && Object.keys(empties || {}).length === 0 && (
—
)}
); return ( <>

📄 {no ? 'Digitalt fraktbrev' : 'Digital manifest'}

{!m ?
⏳
: m.error ?
❌ {m.error}
: ( <>
{m.current.gas_kg} kg {m.current.adr_over_limit ? '· OVER ADR-fritak (333 kg)' : '· innenfor ADR-fritak'} } />
{no ? `Hendelseslogg (${m.events.length})` : `Event log (${m.events.length})`}
{m.events.length === 0 &&
{no ? 'Ingen hendelser ennå.' : 'No events yet.'}
} {m.events.map((e, i) => (
{(e.occurred_at || '').replace('T', ' ').slice(0, 16)} · {e.location} · {no ? 'dør' : 'door'} {e.door} · {' '} {e.event_type === 'insert_full' ? (no ? 'FULL INN' : 'FULL IN') : e.event_type === 'remove_empty' ? (no ? 'TOM UT' : 'EMPTY OUT') : e.event_type} · {e.bottle_code || ''}
))}
)}
); }; /* === Signatur-pad === */ const SignaturePad = ({ planId, lang, onSigned }) => { const canvasRef = React.useRef(null); const [drawing, setDrawing] = React.useState(false); const [busy, setBusy] = React.useState(false); React.useEffect(() => { const c = canvasRef.current; if (!c) return; const ctx = c.getContext('2d'); ctx.lineWidth = 2; ctx.strokeStyle = '#fff'; ctx.lineCap = 'round'; }, []); // Canvas-koordinatrommet er 400x120, men CSS-bredden er 100 % — skaler // pekerkoordinatene, ellers tegnes signaturen forskjøvet på smal skjerm. const _pos = (e, c, r) => { const cx = (e.touches?.[0]?.clientX ?? e.clientX) - r.left; const cy = (e.touches?.[0]?.clientY ?? e.clientY) - r.top; return [cx * (c.width / r.width), cy * (c.height / r.height)]; }; const start = (e) => { setDrawing(true); const c = canvasRef.current; const r = c.getBoundingClientRect(); const ctx = c.getContext('2d'); const [x, y] = _pos(e, c, r); ctx.beginPath(); ctx.moveTo(x, y); }; const move = (e) => { if (!drawing) return; // (ingen preventDefault: touchAction:'none' på canvas hindrer scrolling, // og React 18 registrerer touchmove passivt — kallet ville bare støye) const c = canvasRef.current; const r = c.getBoundingClientRect(); const ctx = c.getContext('2d'); const [x, y] = _pos(e, c, r); ctx.lineTo(x, y); ctx.stroke(); }; const end = () => setDrawing(false); const clear = () => { const c = canvasRef.current; c.getContext('2d').clearRect(0, 0, c.width, c.height); }; const sign = async () => { setBusy(true); try { const dataUrl = canvasRef.current.toDataURL('image/png'); await window.api.refillSignPlan(planId, { signature_data_url: dataUrl }); onSigned(); } catch (e) { alert(e.message); } setBusy(false); }; return (
{lang === 'no' ? 'Signer for å fullføre runden' : 'Sign to complete'}
); }; /* === Liste av tidligere planer === */ const RefillPlanList = ({ tenant, lang, onOpen, onBack }) => { const [plans, setPlans] = React.useState([]); React.useEffect(() => { window.api.refillListPlans({ tenant_id: tenant.id }) .then(setPlans).catch(console.error); }, [tenant.id]); return (

Tidligere planer

{plans.map(p => ( ))}
OpprettetStatusStopsAvstandSignert
{new Date(p.created_at).toLocaleString('no-NO', { dateStyle: 'short', timeStyle: 'short' })} {p.status} {p.n_stops} {p.estimated_distance_km ? `${p.estimated_distance_km} km` : '—'} {p.signed ? '✓' : '—'}
); }; /* === Service pr. automat: historikk + registrering (fra utkjøringslista) === Gassensor-kalibrering setter automatisk neste frist (+12 mnd, årlig test) — service_watch varsler når fristen nærmer seg. */ const ServicePanel = ({ automatId, lang, onChanged }) => { const no = lang === 'no'; const [hist, setHist] = React.useState(null); const [kind, setKind] = React.useState('service'); const [notes, setNotes] = React.useState(''); const [busy, setBusy] = React.useState(false); const load = React.useCallback(async () => { try { setHist(await window.api.serviceHistory(automatId)); } catch (e) { setHist({ error: e.message, entries: [] }); } }, [automatId]); React.useEffect(() => { load(); }, [load]); const KIND_LABELS = { gassensor_kalibrering: no ? 'Gassensor-kalibrering (årlig)' : 'Gas sensor calibration', service: no ? 'Generell service' : 'Service', reparasjon: no ? 'Reparasjon' : 'Repair', annet: no ? 'Annet' : 'Other', }; const submit = async () => { setBusy(true); try { await window.api.serviceCreate(automatId, { kind, notes: notes || null }); setNotes(''); await load(); if (onChanged) await onChanged(); } catch (e) { alert(e.message); } finally { setBusy(false); } }; return (
{hist && hist.gas_sensor_next_service && (
🔧 {no ? 'Neste gassensor-kalibrering' : 'Next gas sensor cal.'}:{' '} {hist.gas_sensor_next_service.slice(0, 10)} {hist.gas_sensor_days_left != null && ` (${hist.gas_sensor_days_left} ${no ? 'dager' : 'days'})`}
)} {/* Registrer utført service */}
setNotes(e.target.value)} />
{/* Historikk */} {hist === null ?
⏳
: (hist.entries || []).length === 0 ?
{no ? 'Ingen service registrert ennå.' : 'No service yet.'}
: (
{hist.entries.map(e => (
{(e.performed_at || '').slice(0, 10)} · {KIND_LABELS[e.kind] || e.kind} {e.next_due && → {no ? 'neste' : 'next'} {e.next_due.slice(0, 10)}}
{e.notes || ''} {e.performed_by ? `— ${e.performed_by}` : ''}
))}
)}
); }; /* === Kjørerute-planlegger === Velg OMRÅDE → automatene sorteres nærmeste-nabo fra depotet, hvert stopp får en påfyllingsmiks etter automatens salgsfaktor, og navigasjonen deles i Google Maps-etapper. To modus: ≤330 kg pr. tur (ADR-fri, retur til depot mellom turene) eller ADR full last (kjøre-/hviletidsplan). */ const RoutePlanner = ({ tenant, lang, dataVersion, onBack, presetIds, onPlanCreated }) => { const no = lang === 'no'; const areas = React.useMemo( () => (DATA.AREAS || []).filter(a => a.tenant_id === tenant.id), [tenant.id, dataVersion]); const depotAreas = areas.filter(a => a.depot_lat != null && a.depot_lon != null); const [areaId, setAreaId] = React.useState(''); const [startBase, setStartBase] = React.useState(''); // area_id for START-base const [endBase, setEndBase] = React.useState(''); // area_id for SLUTT-base const [planMode, setPlanMode] = React.useState('light'); // light | adr const [maxKg, setMaxKg] = React.useState(330); const [plan, setPlan] = React.useState(null); const [busy, setBusy] = React.useState(false); const [err, setErr] = React.useState(null); const area = areas.find(a => a.id === areaId); const hasDepot = area && area.depot_lat != null && area.depot_lon != null; const hasSelection = presetIds && presetIds.length > 0; const load = async (m) => { if (!areaId && !hasSelection) { setErr(no ? 'Velg område (eller automater i lista) først' : 'Pick an area first'); return; } if (hasSelection && !areaId && !startBase) { setErr(no ? 'Velg start-base for utvalget' : 'Pick a start base'); return; } setBusy(true); setErr(null); setPlanMode(m); try { const params = { mode: m, max_load_kg: maxKg }; if (areaId) params.area_id = areaId; if (hasSelection) params.automat_ids = presetIds.join(','); if (startBase) params.start_area_id = startBase; if (endBase) params.end_area_id = endBase; const p = await window.api.routePlan(tenant.id, params); setPlan(p); } catch (e) { setErr(e.message); setPlan(null); } finally { setBusy(false); } }; const fmtT = (min) => { const h = Math.floor(min / 60), m = Math.round(min % 60); return h > 0 ? `${h} t ${m} min` : `${m} min`; }; return (
{hasSelection && (
{presetIds.length} {no ? 'manuelt valgte automater fra lista' : 'manually selected'}
)}
{!hasSelection && (
{no ? 'Ansvarsområde' : 'Area'}
)}
{no ? 'Start-base' : 'Start base'}
{no ? 'Slutt-base (omlasting)' : 'End base'}
{no ? 'Maks kg gass pr. tur (ADR-fri)' : 'Max kg per trip'}
setMaxKg(Number(e.target.value) || 330)} />
{area && (
{hasDepot ? `${no ? 'Depot' : 'Depot'}: ${area.depot_address || `${area.depot_lat}, ${area.depot_lon}`}` : (no ? '⚠ Området mangler depot/fyllestasjon — ruta starter på første automat. Sett depot under Områder.' : '⚠ Area has no depot — route starts at first machine.')} {area.responsible_name && ` · ${no ? 'Ansvarlig' : 'Responsible'}: ${area.responsible_name}${area.responsible_mobile ? ` (${area.responsible_mobile})` : ''}`}
)} {err &&
❌ {err}
}
{plan && ( <> {/* Totaler — inkl. estimert kjøre-/arbeidstid for kjøre-/hviletid */}
{no ? 'Stopp' : 'Stops'}
{plan.totals.stops}
{no ? 'Flasker å fylle' : 'Bottles'}
{plan.totals.bottles_total} ({plan.totals.empties_to_collect} {no ? 'tomme hentes' : 'empties'})
{no ? 'Last (gass)' : 'Load (gas)'}
{plan.totals.weight_kg} kg
{no ? 'Distanse' : 'Distance'}
{plan.totals.distance_km} km
{no ? 'Kjøretid' : 'Drive time'}
{fmtT(plan.totals.drive_min)}
{plan.timing_source === 'google_routes' ? ⏱ Google Routes : ≈ {no ? 'estimat' : 'estimate'}}
{no ? 'Arbeidstid (kjøring + lasting)' : 'Work time'}
{fmtT(plan.totals.work_min)}
{no ? 'maks 9 t kjøring / 12 t arbeidsdag' : 'max 9h drive / 12h work'}
{plan.note && (
ℹ️ {plan.note}
)} {/* Start utkjøringen: oppretter fylleplan med digitalt fraktbrev — bilen lastes med totalene, hvert stopp får forslags-miksen. */} {onPlanCreated && (
{no ? `Bilen lastes med ${plan.totals.bottles_total} flasker (${plan.totals.weight_kg} kg). Fraktbrevet oppdateres automatisk når automatene detekterer inn-/utlevering.` : 'Truck loaded with plan totals; manifest auto-updates on detection.'}
)} {/* Navigasjon: turer (light) eller etapper + hvileplan (ADR) */} {plan.mode === 'light' && plan.trips && (

{no ? `Turer (maks ${maxKg} kg)` : 'Trips'}

{plan.trips.map((t, i) => (
{no ? 'Tur' : 'Trip'} {i + 1} {t.stops} {no ? 'stopp' : 'stops'} · {t.bottles} {no ? 'flasker' : 'bottles'} · {t.weight_kg} kg
{t.nav_urls.map((u, j) => ( ))}
))}
{no ? 'Hver tur starter og slutter på depotet (hent ny last). Google Maps tar maks 9 stopp pr. lenke — etappene fortsetter der forrige sluttet.' : 'Each trip starts/ends at the depot. Google Maps caps ~9 stops per link.'}
)} {plan.mode === 'adr' && (

{no ? 'Navigasjon + kjøre-/hviletid' : 'Navigation + rest plan'}

{(plan.nav_urls || []).map((u, j) => ( ))} {plan.days_needed > 1 && ( {no ? `Estimert ${plan.days_needed} dager` : `${plan.days_needed} days`} )}
{(plan.rest_plan || []).length > 0 ? (
{plan.rest_plan.map((r, i) => (
{r.type === 'pause45' ? '☕' : '🛏'} {r.type === 'pause45' ? (no ? '45 min pause' : '45 min break') : (no ? 'Døgnhvile' : 'Daily rest')} {' '}{no ? 'etter stopp' : 'after stop'} #{r.after_stop} ({r.at}) — {r.reason}
))}
) : (
✓ {no ? 'Ruta er innenfor 4,5 t kjøring — ingen pålagte pauser.' : 'Within 4.5h driving — no mandated breaks.'}
)}
)} {/* Stoppliste i kjørerekkefølge med påfyllingsmiks pr. automat */}

{no ? 'Kjørerekkefølge' : 'Stop order'}

{plan.stops.map((s, i) => ( ))}
#{no ? 'Automat' : 'Machine'} {no ? 'Fra forrige' : 'From prev'} {no ? 'Fyll (etter salgsfaktor)' : 'Fill (by sales factor)'} kg {no ? 'Tomme' : 'Empties'} {no ? 'Lasting' : 'Service'}
{i + 1}
{s.location}
{no ? 'faktor' : 'factor'}: {Object.entries(s.factor).slice(0, 3) .map(([c, f]) => `${Math.round(f * 100)}% ${c.replace('_', ' ')}`).join(', ')}
{s.distance_km_from_prev} km · {s.drive_min_from_prev} min
{s.fill.map(f => ( {f.count}× {f.bottle_code.replace('_', ' ')} ))} {s.fill.length === 0 && {no ? 'full' : 'full'}}
{s.weight_kg} 0 ? 'var(--danger)' : 'inherit' }}> {s.empties_to_collect} {s.service_min} min
{(plan.skipped || []).length > 0 && (
⚠ {plan.skipped.map(s => `${s.automat_id}: ${s.reason}`).join(' · ')}
)}
)}
); }; Object.assign(window, { RefillPage, RoutePlanner });