/* 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'}
setMode('route')}>
{lang === 'no' ? 'Kjørerute' : 'Route'}
setMode('list')}>
{lang === 'no' ? 'Tidligere planer' : 'Past plans'}
{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) => (
{
if (sortKey === key) setSortDir(d => -d);
else { setSortKey(key); setSortDir(1); }
}}>
{label} {sortKey === key ? (sortDir === 1 ? '↑' : '↓') : '⇅'}
);
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 */}
setAreaFilter(e.target.value)}>
{lang === 'no' ? 'Vis alle områder' : 'All areas'}
{areas.map(([id, name]) => {name} )}
{lang === 'no' ? 'Uten område' : 'No area'}
{sortBtn('location', lang === 'no' ? 'Lokasjon' : 'Location')}
{sortBtn('empties', lang === 'no' ? 'Tomme' : 'Empties')}
{onMakeRoute && (
onMakeRoute([...selected])}>
🧭 {lang === 'no' ? `Lag kjørerute (${selected.size})` : `Make route (${selected.size})`}
)}
{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()}>
setServiceOpen(serviceOpen === a.automat_id ? null : a.automat_id)}>
🛠 Service {serviceOpen === a.automat_id ? '▴' : '▾'}
{serviceOpen === a.automat_id && (
)}
))}
{lang === 'no' ? 'Lastebilen' : 'The truck'}
{lang === 'no' ? 'Startpunkt (lager)' : 'Start point'}
📍 {startGPS ? 'GPS satt' : (lang === 'no' ? 'Bruk min posisjon' : 'Use my GPS')}
{startGPS && (
{startGPS.lat.toFixed(5)}, {startGPS.lon.toFixed(5)}
)}
{/* Beregn-behov-knapp */}
{!estimate && (
{estimating
? '⏳ ' + (lang === 'no' ? 'Beregner…' : 'Calculating…')
: '🧮 ' + (lang === 'no'
? `Beregn behov (${selected.size} automater)`
: `Calculate needs (${selected.size})`)}
)}
{/* Breakdown-tabell etter estimate */}
{estimate && (
{lang === 'no' ? 'Forslag basert på historikk' : 'Suggestion'}
setEstimate(null)}>↻
Type
{lang === 'no' ? 'Tomme' : 'Empty'}
{lang === 'no' ? 'Overhead' : 'Overhead'}
{lang === 'no' ? 'Anbefalt' : 'Recommend'}
{Object.keys(estimate.recommended || {}).sort().map(code => (
{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})
)}
updateLoad(b.id, -1)}>−
0 && diff < 0 ? 'var(--warn)' : 'inherit',
}}>{n}
updateLoad(b.id, 1)}>+
);
})}
{/* Live ADR-status */}
{err &&
❌ {err}
}
{busy ? '⏳' : (lang === 'no'
? `🚚 Lag plan (${selected.size} stops)`
: `Create plan (${selected.size} stops)`)}
);
};
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 (
{lang === 'no' ? 'Stop-rekkefølge' : 'Stop order'}
{plan.stops.map((s, i) => (
{i + 1}.
{s.location}
{s.status === 'pending' ? (
onStartStop(s)}>
Start →
) : s.status === 'in_progress' ? (
onStartStop(s)}>Fortsett →
) : (
✓ 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 */}
setShowManifest(true)}>
📄 {lang === 'no' ? 'Vis digitalt fraktbrev' : 'Show manifest'}
{showManifest && (
setShowManifest(false)} />
)}
{/* Rute-sammendrag */}
Rute
{plan.schedule?.needs_break && (
⚠ {plan.schedule.warning}
)}
{/* ADR */}
{/* 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.`}
← Avbryt
{busy ? '⏳' : '🚪 ' + (lang === 'no' ? 'Åpne dører' : 'Open 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 && (
rescan(d.door)}>
{rescanBusy === d.door ? '⏳' : '🔄'} {lang === 'no' ? 'Skann på nytt' : 'Rescan'}
)}
))}
{/* 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
setShowManifest(true)}>
📄 {lang === 'no' ? 'Fraktbrev' : 'Manifest'}
)}
{busy ? '⏳' : '✍ ' + (lang === 'no'
? 'Signer — tallene stemmer' : 'Sign — numbers correct')}
{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'}
{lang === 'no' ? 'Tøm' : 'Clear'}
{busy ? '⏳' : '✓ ' + (lang === 'no' ? 'Signer' : 'Sign')}
);
};
/* === 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 (
← Ny plan
Tidligere planer
Opprettet Status Stops Avstand Signert
{plans.map(p => (
{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 ? '✓' : '—'}
onOpen(p)}>Åpne →
))}
);
};
/* === 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 */}
setKind(e.target.value)}>
{Object.entries(KIND_LABELS).map(([k, l]) => {l} )}
setNotes(e.target.value)} />
{busy ? '⏳' : '✓'} {no ? 'Registrer utført' : 'Register'}
{/* 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'}
{ setAreaId(e.target.value); setPlan(null); }}>
{no ? '— velg område —' : '— pick area —'}
{areas.map(a => (
{a.name} ({a.automat_count}){a.responsible_name ? ` · ${a.responsible_name}` : ''}
))}
)}
{no ? 'Start-base' : 'Start base'}
{ setStartBase(e.target.value); setPlan(null); }}>
{no ? (areaId ? '(områdets base)' : '— velg —') : '— pick —'}
{depotAreas.map(a => (
{a.depot_address || a.name}
))}
{no ? 'Slutt-base (omlasting)' : 'End base'}
{ setEndBase(e.target.value); setPlan(null); }}>
{no ? '(samme som start)' : '(same as start)'}
{depotAreas.map(a => (
{a.depot_address || a.name}
))}
load('light')}>
{busy && planMode === 'light' ? '⏳ ' : ''}{no ? `Naviger ≤ ${maxKg} kg-turer` : `Navigate ≤ ${maxKg} kg trips`}
load('adr')}>
{busy && planMode === 'adr' ? '⏳ ' : ''}{no ? 'ADR-kjøring (full last)' : 'ADR (full load)'}
{no ? '← Tilbake' : '← Back'}
{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 && (
)}
{/* Start utkjøringen: oppretter fylleplan med digitalt fraktbrev —
bilen lastes med totalene, hvert stopp får forslags-miksen. */}
{onPlanCreated && (
{
setBusy(true); setErr(null);
try {
const suggested = {};
plan.stops.forEach(s => {
suggested[s.automat_id] = Object.fromEntries(
(s.fill || []).map(f => [f.bottle_code, f.count]));
});
const r = await window.api.refillCreatePlan({
tenant_id: tenant.id,
automat_ids: plan.stops.map(s => s.automat_id),
truck_loaded: { ...plan.totals.bottles_by_code },
suggested_by_automat: suggested,
truck_max_kg: planMode === 'light' ? maxKg : null,
notes: `Kjørerute (${planMode === 'light' ? `≤${maxKg} kg-turer` : 'ADR'}) — ` +
`${plan.totals.stops} stopp, ${plan.totals.weight_kg} kg gass`,
});
onPlanCreated(r);
} catch (e) { setErr(e.message); }
finally { setBusy(false); }
}}>
🚚 {no ? 'Start utkjøring (lag digitalt fraktbrev)' : 'Start route (create manifest)'}
{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) => (
window.open(u, '_blank')}>
🧭 {no ? 'Etappe' : 'Leg'} {t.nav_urls.length > 1 ? `${j + 1}/${t.nav_urls.length}` : ''}
))}
))}
{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) => (
window.open(u, '_blank')}>
🧭 {no ? 'Etappe' : 'Leg'} {j + 1}/{plan.nav_urls.length}
))}
{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'}
# {no ? 'Automat' : 'Machine'}
{no ? 'Fra forrige' : 'From prev'}
{no ? 'Fyll (etter salgsfaktor)' : 'Fill (by sales factor)'}
kg
{no ? 'Tomme' : 'Empties'}
{no ? 'Lasting' : 'Service'}
{plan.stops.map((s, i) => (
{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 });