/* Transactions, Events, Machine settings, Bottles, Users */ /* Henter ekte transaksjoner fra backend; støtter dato-range + filter. */ const Transactions = ({ machine, lang }) => { const t = I18N[lang]; const [period, setPeriod] = React.useState('10'); // '10' | '7' | '30' | '90' | 'custom' — default siste 10 const [from, setFrom] = React.useState(() => isoDate(daysAgo(30))); const [to, setTo] = React.useState(() => isoDate(new Date())); const [filter, setFilter] = React.useState('all'); const [rows, setRows] = React.useState([]); const [loading, setLoading] = React.useState(true); const [error, setError] = React.useState(null); const [openTx, setOpenTx] = React.useState(null); // valgt transaksjon for detalj-drawer const load = React.useCallback(async () => { setLoading(true); setError(null); try { const params = { automat_id: machine.id, limit: 500 }; if (period === '10') { params.limit = 10; params.days = 365; } else if (period === '7') params.days = 7; else if (period === '30') params.days = 30; else if (period === '90') params.days = 90; else if (period === 'custom') { params.date_from = from; params.date_to = to; } const r = await window.api.listOrders(params); setRows(r || []); } catch (e) { setError(e.message); } setLoading(false); }, [machine.id, period, from, to]); React.useEffect(() => { load(); }, [load]); const filtered = rows.filter(x => filter === 'all' || x.kind === filter); // Sum: KUN fullførte ordrer med positiv beløp (ekskluderer feilet) const successful = filtered.filter(x => x.session_state === 'complete' && (x.price_ore || 0) > 0); const sum = successful.reduce((s, x) => s + (x.price_ore || 0), 0); const failedCount = filtered.filter(x => orderStatusKey(x) === 'feil').length; const swapCount = rows.filter(x => x.kind === 'exchange' && x.session_state === 'complete').length; const swapShare = rows.length ? Math.round(swapCount / rows.filter(x => x.session_state === 'complete').length * 100) || 0 : 0; const exportCsv = () => { const lines = [ ['Tid','Type','Flaske inn','Flaske ut','Beløp (kr)','Status','Mobil','Feil'].join(';'), ...filtered.map(o => [ o.created_at?.slice(0, 19) || '', o.kind, o.bottle_in || '', o.bottle_out || '', ((o.price_ore || 0) / 100).toFixed(2), orderStatusKey(o), o.mobile || '', o.error || '', ].map(v => `"${String(v).replace(/"/g, '""')}"`).join(';')), ].join('\n'); const blob = new Blob([lines], { type: 'text/csv;charset=utf-8' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = `transaksjoner-${machine.id}-${period}.csv`; a.click(); }; return (
{t.tab_transactions}
{filtered.length}/{rows.length}
{lang === 'no' ? 'Sum (fullført)' : 'Sum (success)'}
{fmtKr(sum / 100, lang)}
{lang === 'no' ? 'Vellykket' : 'OK'}
{successful.length}/{filtered.length}
{failedCount > 0 && (
{lang === 'no' ? 'Feilet' : 'Failed'}
{failedCount}
)}
{lang === 'no' ? 'Bytte-andel' : 'Swap %'}
{swapShare}%
{/* periode + filter */}
{period === 'custom' && ( <> setFrom(e.target.value)} /> til setTo(e.target.value)} /> )}
{error && (
❌ {error}
)}
{loading && rows.length === 0 ? (
⏳ Laster transaksjoner…
) : filtered.length === 0 ? (
Ingen transaksjoner i valgt periode
) : ( {filtered.map(o => ( setOpenTx(o)} style={{ cursor: 'pointer' }}> ))}
{t.tx_time} {t.tx_type} {lang === 'no' ? 'Inn (tom)' : 'In (empty)'} {lang === 'no' ? 'Ut (full)' : 'Out (full)'} Status {lang === 'no' ? 'Mobil' : 'Mobile'} {t.tx_amount}
{formatTs(o.created_at)} {o.kind === 'exchange' ? {t.swap} : {t.buy}} {o.bottle_in || —} {o.bottle_out} {stateBadge(o)} {o.mobile || '—'} {(o.price_ore || 0) > 0 ? fmtKr((o.price_ore || 0) / 100, lang) : —}
)}
{openTx && setOpenTx(null)} />}
); }; /* Liten thumbnail i tabellrad — lazy-loadet via IntersectionObserver, * bruker backend thumbnail-endepunkt (~5-15 KB i stedet for 500 KB). * v2-ordrer: bilde via /api/admin/orders/{id}/image/*; legacy: Firestore-logg. */ const TxThumb = ({ machineId, order }) => { const legacyFname = order.out_image || order.in_image; const v2Url = order.out_image_url || order.in_image_url; const thumbUrl = v2Url ? `${v2Url}?size=96` : (legacyFname ? window.api.legacyLogImageThumbUrl(machineId, legacyFname, 96) : null); const [src, setSrc] = React.useState(null); const [visible, setVisible] = React.useState(false); const ref = React.useRef(null); // Vis først når raden er innenfor viewport React.useEffect(() => { if (!ref.current || !thumbUrl) return; const obs = new IntersectionObserver(entries => { entries.forEach(e => { if (e.isIntersecting) { setVisible(true); obs.disconnect(); } }); }, { rootMargin: '200px' }); obs.observe(ref.current); return () => obs.disconnect(); }, [thumbUrl]); React.useEffect(() => { if (!visible || !thumbUrl) return; let cancelled = false; window.authedImageUrl(thumbUrl) .then(url => { if (!cancelled) setSrc(url); }) .catch(() => { if (!cancelled) setSrc(null); }); return () => { cancelled = true; }; }, [thumbUrl, visible]); if (!thumbUrl) { return
; } return (
{src && }
); }; /* Full transaksjons-detalj med begge bilder + alle felter + tidslinje */ const TransactionDetail = ({ order, machine, lang, onClose, onRefunded }) => { const t = I18N[lang]; const [refunds, setRefunds] = React.useState([]); const [showRefund, setShowRefund] = React.useState(false); const [refundBusy, setRefundBusy] = React.useState(false); const [refundError, setRefundError] = React.useState(null); const [timeline, setTimeline] = React.useState(null); const isLegacy = machine.kind === 'legacy'; // Sjåfør/operatør (tenant_worker/operator) skal ikke se «Refunder» — backend // krever tenant_admin uansett (2026-09-05). Tenant utledes fra ordren, // automaten eller DATA.machines (tenant-transaksjonslista har bare id/navn). const tenantIdForOrder = order.tenant_id || machine.tenantId || (((window.DATA && DATA.machines) || []).find(m => m.id === (order.automat_id || machine.id)) || {}).tenantId; const roleHere = window.roleForTenant ? window.roleForTenant(tenantIdForOrder) : null; const isWorker = roleHere === 'worker'; React.useEffect(() => { if (!order.order_id) return; window.api.listRefunds(order.order_id).then(setRefunds).catch(() => setRefunds([])); // Full tidslinje (kun v2-ordrer i vår DB) — alle steg kunden gikk gjennom if (!isLegacy) { window.api.orderTimeline(order.order_id) .then(setTimeline).catch(() => setTimeline(null)); } }, [order.order_id]); const totalPriceOre = order.price_ore || 0; const alreadyRefundedOre = order.refunded_amount_ore || refunds.reduce((s, r) => s + (r.amount_ore || 0), 0); const remainingOre = totalPriceOre - alreadyRefundedOre; const canRefund = order.payment_method === 'stripe' && order.stripe_payment_intent_id && order.captured_at && remainingOre > 0 && !isWorker; return ( <>

{lang === 'no' ? 'Transaksjon' : 'Transaction'}

{order.order_id || '—'}
{/* Status-banner — skiller fullført / kreditert / avbrutt / feil */} {(() => { const key = orderStatusKey(order); const tone = key === 'fullført' ? 'var(--success)' : (key === 'kreditert' || key === 'delvis-kreditert') ? 'var(--warn)' : key === 'avbrutt' ? 'var(--text-dim)' : 'var(--danger)'; const bg = key === 'fullført' ? 'var(--success-soft)' : (key === 'kreditert' || key === 'delvis-kreditert') ? 'var(--warn-soft)' : key === 'avbrutt' ? 'var(--surface-2)' : 'var(--danger-soft)'; const kindTxt = order.kind === 'exchange' ? (lang === 'no' ? 'bytte' : 'swap') : (lang === 'no' ? 'kjøp' : 'buy'); return (
{key === 'fullført' && <>✓ Fullført {kindTxt}} {key === 'kreditert' && <>↩ Kreditert — {kindTxt}, beløpet er refundert kunden} {key === 'delvis-kreditert' && <>↩ Delvis kreditert — {fmtKr((order.refunded_amount_ore || 0) / 100, lang)} refundert} {key === 'avbrutt' && <>⊘ Avbrutt — {order.error || (lang === 'no' ? 'handelen ble avbrutt' : 'trade cancelled')}} {key !== 'fullført' && key !== 'kreditert' && key !== 'delvis-kreditert' && key !== 'avbrutt' && ( <>❌ Feilet: {order.error || order.session_state} )}
); })()} {/* Bilder side-om-side: flaske levert INN og flaske levert UT (auto-fit: stables til 1 kolonne på smal skjerm) */}
{/* Beskrivelse */} {order.description && (
{lang === 'no' ? 'Beskrivelse fra v1' : 'Description from v1'}
{order.description}
)} {/* Kvittering-handlinger (kun v2-ordrer fra DB). PDF-en krever auth-header — vanlig ga 401/blank side, derfor hentes den som blob og åpnes i ny fane (fikset 2026-09-04). */} {order.session_state === 'complete' && order.payment_method && (
{order.receipt_number && ( {lang === 'no' ? 'Kvittering-nr' : 'Receipt no'}: {order.receipt_number} )}
)} {/* Metadata-grid (mobil: .kvp-grid -> 1 kolonne via styles.css) */}
0 ? fmtKr((order.price_ore || 0) / 100, lang) : '—'} /> {order.stripe_payment_intent_id && ( )} {order.authorized_at && ( )} {order.captured_at && ( )} {(order.door_in ?? order.in_index) != null && } {(order.door_out ?? order.out_index) != null && } {(order.deposit_ore || 0) > 0 && } {order.canceled_at && }
{/* Refund-seksjon — kun for Stripe-betalte ordrer */} {(order.payment_method === 'stripe' || refunds.length > 0 || alreadyRefundedOre > 0) && (
{lang === 'no' ? 'Refusjon' : 'Refund'}
{alreadyRefundedOre > 0 ? `${fmtKr(alreadyRefundedOre / 100, lang)} ${lang === 'no' ? 'refundert' : 'refunded'}` : (lang === 'no' ? 'Ingen refusjon' : 'No refund')} {(order.credit_note_numbers || []).length > 0 && ( {lang === 'no' ? 'Kreditnota' : 'Credit note'}: {order.credit_note_numbers.join(', ')} )} {remainingOre > 0 && totalPriceOre > 0 && ( ({fmtKr(remainingOre / 100, lang)} {lang === 'no' ? 'gjenstår' : 'remaining'}) )}
{canRefund && ( )}
{refunds.length > 0 && (
)} {refundError && (
❌ {refundError}
)}
)} {/* Tidslinje: alle steg i handelen (system + kundens app-hendelser + penge-milepæler), tidsstemplet og sortert */} {timeline && timeline.items && timeline.items.length > 0 && (
{lang === 'no' ? 'Tidslinje' : 'Timeline'}
{timeline.items.map((ev, i) => (
0 ? '1px solid var(--border)' : 'none', }}> {formatTs(ev.ts)} {ev.source} {ev.label} {ev.detail && ( {ev.detail} )}
))}
)}
{showRefund && ( setShowRefund(false)} busy={refundBusy} setBusy={setRefundBusy} onDone={async (result) => { setShowRefund(false); setRefundError(null); // Refresh refunds-listen const fresh = await window.api.listRefunds(order.order_id).catch(() => []); setRefunds(fresh); if (onRefunded) onRefunded(result); }} onError={(e) => setRefundError(e)} /> )}
); }; /* Refund-modal: full eller partiell */ const RefundModal = ({ order, remainingOre, lang, onClose, busy, setBusy, onDone, onError }) => { const [amountKr, setAmountKr] = React.useState((remainingOre / 100).toFixed(2)); const [reason, setReason] = React.useState('requested_by_customer'); const [customReason, setCustomReason] = React.useState(''); const submit = async () => { setBusy(true); try { const amount_ore = Math.round(parseFloat(amountKr) * 100); if (!(amount_ore > 0) || amount_ore > remainingOre) { onError(`Beløp må være mellom 1 øre og ${remainingOre} øre`); setBusy(false); return; } const finalReason = reason === 'custom' ? customReason : reason; const result = await window.api.refundOrder(order.order_id, { amount_ore, reason: finalReason || null, }); onDone(result); } catch (e) { onError(e.message); } setBusy(false); }; return ( <>

{lang === 'no' ? 'Refunder betaling' : 'Refund payment'}

setAmountKr(e.target.value)} className="input" style={{ width: '100%' }} />
{lang === 'no' ? `Maks: ${(remainingOre / 100).toFixed(2)} kr` : `Max: ${(remainingOre / 100).toFixed(2)}`}
{reason === 'custom' && ( setCustomReason(e.target.value)} placeholder={lang === 'no' ? 'Fri tekst' : 'Free text'} className="input" style={{ width: '100%', marginTop: 6 }} /> )}
); }; /* Bilde med label + ML-score. `url` (v2-ordrebilde) vinner over legacy `filename` (Firestore-logg). */ const TxImage = ({ label, machineId, filename, url, bottleName, score }) => { const [src, setSrc] = React.useState(null); const [err, setErr] = React.useState(null); const fetchUrl = url || (filename ? window.api.legacyLogImageUrl(machineId, filename) : null); React.useEffect(() => { if (!fetchUrl) { setSrc(null); return; } let cancelled = false; window.authedImageUrl(fetchUrl) .then(u => { if (!cancelled) setSrc(u); }) .catch(e => { if (!cancelled) setErr(e.message); }); return () => { cancelled = true; }; }, [fetchUrl]); return (
{label}
{src ? ( ) : err ? (
❌ {err}
) : fetchUrl ? (
⏳ Laster…
) : (
Ingen bilde registrert
)}
{bottleName || '—'} {/* ML-konfidens er plattforminfo — kun superadmin-visning */} {score != null && score > 0 && window.isSuperView && window.isSuperView() && ( ML: {Math.round(score * 100)}% )}
); }; function isoDate(d) { return d.toISOString().slice(0, 10); } function daysAgo(n) { const d = new Date(); d.setDate(d.getDate() - n); return d; } function formatTs(iso) { if (!iso) return '—'; const d = new Date(iso); return d.toLocaleString('no-NO', { dateStyle: 'short', timeStyle: 'short' }); } /* Status for en ordre — tar hensyn til kreditering (refusjon), avbrudd og feil, ikke bare session_state. «kreditert» vinner over «fullført». */ function orderStatusKey(o) { const total = (o.captured_amount_ore != null ? o.captured_amount_ore : o.price_ore) || 0; const refunded = o.refunded_amount_ore || 0; if (refunded > 0) return refunded >= total ? 'kreditert' : 'delvis-kreditert'; const err = (o.error || '').toLowerCase(); const cancelled = err.includes('cancel') || err.includes('abort') || err.includes('avbrutt') || err.includes('avbrot'); if (o.session_state === 'complete') return 'fullført'; if (cancelled) return 'avbrutt'; if (o.session_state === 'error' || o.error) return 'feil'; if (o.session_state === 'refunded') return 'kreditert'; return o.session_state || '—'; } function stateBadge(o) { const key = orderStatusKey(o); if (key === 'kreditert') return kreditert; if (key === 'delvis-kreditert') return delvis kreditert; if (key === 'fullført') return fullført; if (key === 'avbrutt') return avbrutt; if (key === 'feil') return feil; return {key}; } /* Hendelser for ÉN automat — EKTE varsler/hendelser fra backend (2026-09-04; før: buildEvents() → tom liste, så fanen var alltid tom og eier så aldri avbrutte handler, betalingsfeil osv.). */ const EVENT_KIND_LABELS = { handel_avbrutt: 'Handel avbrutt', betaling_ikke_aktivert: 'Betaling ikke aktivert', automat_opptatt: 'Automat opptatt', bytte_avvist: 'Bytte avvist', valg_avvist: 'Flaskevalg avvist', open_door: 'Dør åpen', gas: 'Gassalarm', low_stock: 'Lavt lager', no_stock: 'Tomt for type', cam_capture_feil: 'Kamerafeil', retur_flaske_igjen: 'Returflaske gjenglemt', service_due: 'Service forfaller', }; const Events = ({ machine, lang }) => { const t = I18N[lang]; const [rows, setRows] = React.useState(null); const [feil, setFeil] = React.useState(null); const last = React.useCallback(() => { window.api.recentAlarms(machine.id, 200) .then(r => setRows(r || [])).catch(e => setFeil(e.message)); }, [machine.id]); React.useEffect(() => { last(); }, [last]); const evs = (rows || []).map(r => ({ id: 'EV-' + r.id, ts: { text: new Date(r.sent_at).toLocaleTimeString('no-NO', { timeStyle: 'short' }), date: new Date(r.sent_at).toLocaleDateString('no-NO') }, sev: (r.severity === 'critical' || r.severity === 'high') ? 'danger' : r.severity === 'medium' ? 'warn' : 'info', kind: EVENT_KIND_LABELS[r.kind] || r.kind, msg_no: r.message, msg_en: r.message, shelf: (r.message || '').match(/d[øo]r (\d+)/i)?.[1] || null, camera: (r.message || '').match(/Kamera (\d+)/i)?.[1] || null, recipient: r.recipient, })); const [sev, setSev] = React.useState('all'); const filtered = evs.filter(e => sev === 'all' || e.sev === sev); return (
{t.tab_events}
{filtered.length}/{evs.length}
{feil &&
❌ {feil}
} {rows === null && !feil &&
⏳ …
} {rows !== null && filtered.length === 0 && (
{lang === 'no' ? 'Ingen hendelser registrert på denne automaten' : 'No events for this automat'}
)}
{filtered.length > 0 && ( {filtered.map(e => ( ))}
{t.ev_time} {t.ev_sev} {lang === 'no' ? 'Type' : 'Kind'} {t.ev_msg} {t.ev_shelf} {t.ev_camera}
{e.sev === 'danger' && } {e.sev === 'warn' && } {e.sev === 'info' && } {e.ts.text}{e.ts.date} {e.sev === 'danger' && {t.sev_danger}} {e.sev === 'warn' && {t.sev_warn}} {e.sev === 'info' && {t.sev_info}} {e.kind} {lang === 'no' ? e.msg_no : e.msg_en} {e.shelf || —} {e.camera ? `CAM-${e.camera}` : —}
)}
); }; const MachineSettings = ({ machine, lang }) => { // EKTE innstillinger (mock-revisjon 2026-09-04): togglene leser/skriver // automatens config via settings-API-et; vedlikehold viser reell service- // historikk. 2026-09-04 (runde 2): flasketype-sortiment, betalings- // løsninger, tekniker-register + «Planlegg besøk» — alt ekte data. const t = I18N[lang]; const no = lang === 'no'; const isSuper = !!(window.isSuperView && window.isSuperView()); const [cfg, setCfg] = React.useState(null); const [service, setService] = React.useState(null); const [feil, setFeil] = React.useState(null); // Redigerbart navn/plassering (ID kan IKKE endres — kameraer, MQTT-emner og // firmware er bundet til den; se forklaring i UI-et) const [navn, setNavn] = React.useState(machine.location || ''); const [navnBusy, setNavnBusy] = React.useState(false); const lagreNavn = async () => { const ny = navn.trim(); if (!ny || ny === machine.location) return; setNavnBusy(true); try { await window.api.updateAutomat(machine.id, { location: ny }); if (machine._onReload) await machine._onReload(); } catch (e) { alert((no ? 'Lagring feilet: ' : 'Save failed: ') + e.message); } setNavnBusy(false); }; const lastAll = React.useCallback(() => { window.api.getSettings(machine.id).then(setCfg) .catch(e => setFeil(e.message)); window.api.serviceHistory(machine.id).then(setService).catch(() => {}); }, [machine.id]); React.useEffect(() => { lastAll(); }, [lastAll]); const lagre = async (key, value) => { try { const ny = await window.api.saveSettings(machine.id, { [key]: value }); setCfg(ny); } catch (e) { alert('Lagring feilet: ' + e.message); lastAll(); } }; const sisteService = service?.entries?.[0]; return (

{no ? 'Generelt' : 'General'}

{no ? 'Navn / plassering' : 'Name / location'}
setNavn(e.target.value)} style={{ flex: 1 }} maxLength={80} />
{no ? 'Identifikatoren kan ikke endres: kameraene, MQTT-emnene og firmwaren på automaten er bundet til den. Navnet kan du endre fritt.' : 'The identifier is fixed (cameras, MQTT topics and firmware are bound to it); the name can be changed freely.'}

{no ? 'Drift' : 'Operations'}

{feil &&
{feil}
} {!cfg && !feil &&
{no ? 'Laster…' : 'Loading…'}
} {cfg && (<> lagre('gas_alarm_enabled', v)} /> lagre('check_open_doors', v)} /> lagre('low_stock_sms_enabled', v)} /> {/* 2026-09-05: bryterne over konsumeres nå av backend (gass-SMS, dør-åpen-alarm, lav-lager-SMS). Feltene under er redigerbare — før var terskel og admin-mobiler kun visning. */} lagre('cameras_power_off_after_sale', v)} /> lagre('predict_accept_limit', v)} /> lagre('door_open_alarm_min', v)} /> lagre('admin_mobiles', v)} /> )}

{no ? 'Farlig sone' : 'Danger zone'}

); }; /* Salgslenke + QR-kode for automaten: tenanten kan kopiere lenka eller skrive ut en A4-plakat med QR som tar kunden rett til salgssiden (/app/?a=). QR genereres lokalt (qrcode-generator, lastet i index.html) — faller pent tilbake til bare lenke om biblioteket mangler. */ const SalesLinkPanel = ({ machine, lang }) => { const no = lang === 'no'; const url = `${location.origin}/app/?a=${encodeURIComponent(machine.id)}`; const qrRef = React.useRef(null); const [qrSvg, setQrSvg] = React.useState(null); const [kopiert, setKopiert] = React.useState(false); React.useEffect(() => { if (typeof window.qrcode !== 'function') { setQrSvg(null); return; } try { const qr = window.qrcode(0, 'M'); // 0 = auto-versjon, M = feilkorreksjon qr.addData(url); qr.make(); setQrSvg(qr.createSvgTag({ cellSize: 4, margin: 2, scalable: true })); } catch (e) { setQrSvg(null); } }, [url]); const kopier = async () => { try { await navigator.clipboard.writeText(url); setKopiert(true); setTimeout(() => setKopiert(false), 2000); } catch (e) { // Clipboard-API krever https/fokus — fall tilbake til prompt window.prompt(no ? 'Kopier lenka:' : 'Copy the link:', url); } }; // HTML-escape (H17, 2026-09-05): automatnavnet er tenant-styrt tekst og gikk // rett inn i document.write → lagret XSS med admin-origin (token-tyveri). const escHtmlForPrint = (s) => String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); const skrivUt = () => { const tittel = escHtmlForPrint(machine.location || machine.name || machine.id); const html = ` QR · ${tittel}

${tittel}

${no ? 'Kjøp og bytt gassflasker her — hele døgnet' : 'Buy and swap gas bottles here — 24/7'}

${qrSvg || ''}
${escHtmlForPrint(url)}

${no ? 'Skann QR-koden med mobilkameraet for å starte' : 'Scan the QR code with your phone camera'}