/* Main app with login wrapper + live data from real backend */ class ErrorBoundary extends React.Component { constructor(p) { super(p); this.state = { err: null }; } static getDerivedStateFromError(err) { return { err }; } componentDidCatch(err, info) { console.error('UI crash:', err, info); } render() { if (this.state.err) { return (

Noe gikk galt

En komponent feilet under rendering. Detaljer:

            {String(this.state.err?.message || this.state.err)}
            {'\n\n'}
            {String(this.state.err?.stack || '').slice(0, 800)}
          
); } return this.props.children; } } const Root = () => { const [authed, setAuthed] = React.useState(false); const [booting, setBooting] = React.useState(true); React.useEffect(() => { (async () => { if (window.AUTH.isLoggedIn()) { try { await window.AUTH.loadAllData(); setAuthed(true); } catch { window.AUTH.clearSession(); } } setBooting(false); })(); }, []); if (booting) { return (
Laster…
); } if (!authed) return setAuthed(true)} />; return ; }; const App = () => { const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{ "theme": "dark", "density": "normal", "accent": "#f59e0b" }/*EDITMODE-END*/; const [tweaks, setTweak] = useTweaks(TWEAK_DEFAULTS); const [lang, setLang] = React.useState('no'); const me = window.AUTH.getUser() || {}; // Bestem brukerens primære rolle (superadmin > tenant_admin > worker) const tenantRoles = new Set((me.tenants || []).map(t => t.role)); const realRole = me.is_superadmin ? 'superadmin' : tenantRoles.has('tenant_admin') ? 'tenant_admin' : (tenantRoles.has('tenant_worker') || tenantRoles.has('operator')) ? 'worker' : 'unknown'; // Superadmin kan ‚se som“ tenant_admin / worker for å validere oppsett. // Null = bruk sin egen rolle. const [impersonateRole, setImpersonateRole] = React.useState(null); const effectiveRole = impersonateRole || realRole; const userRole = effectiveRole; // bakoverkompat for resten av filen // Landing page per effektiv rolle. const defaultRoute = effectiveRole === 'superadmin' ? 'platform' : effectiveRole === 'worker' ? 'operator' : 'overview'; // Superadmin starter med "alle tenants"; tenant-admin med sin første tenant. const initialTenant = me.is_superadmin ? { id: '__all__', name: 'Alle tenants', sub: `${DATA.TENANTS.length} tenants`, avatar: '★' } : (DATA.TENANTS[0] || { id: 'none', name: '—', sub: '', avatar: '—' }); const [tenant, setTenant] = React.useState(initialTenant); const [route, setRoute] = React.useState({ page: defaultRoute }); const [reloadKey, setReloadKey] = React.useState(0); // Mobil: sidemenyen som skyvbar overlay (hamburger i topbaren) const [navOpen, setNavOpen] = React.useState(false); // Superadmin «ser som» tenant-admin/operatør: skjul plattforminfo overalt // (window.isSuperView() i api-client leser dette flagget). window.__impersonating = !!(impersonateRole && impersonateRole !== 'superadmin'); window.__impersonateRole = impersonateRole; // leses av window.roleForTenant (api-client) // Når superadmin endrer impersonate-modus: hopp til den rollens landing-page // og påkrev valgt tenant hvis vi simulerer admin/worker (de har ikke __all__). React.useEffect(() => { if (impersonateRole && impersonateRole !== 'superadmin') { if (tenant.id === '__all__') { const first = DATA.TENANTS.find(t => t.id !== '__all__'); if (first) setTenant(first); } const newDefault = impersonateRole === 'worker' ? 'operator' : 'overview'; setRoute({ page: newDefault }); } else if (impersonateRole === null && me.is_superadmin) { setRoute({ page: 'platform' }); } }, [impersonateRole]); // ---- Filtrer DATA.machines basert på valgt tenant. ---- // Tidligere: vi mutert DATA.machinesView OG remountet hele App via // `key={reloadKey}` slik at komponentene fikk lest ny verdi. Det // ødela scroll-posisjon, åpne drawers og pågående arbeid. // Nå: vi bruker et lett "dataVersion"-state-tall som komponentene // bruker som useEffect-dep — ingen full remount. React.useEffect(() => { if (tenant.id === '__all__') { DATA.machinesView = DATA.machines; } else { DATA.machinesView = DATA.machines.filter(m => m.tenantId === tenant.id); } DATA.activeTenant = tenant; setReloadKey(k => k + 1); }, [tenant.id]); // Hold TENANTS-listen oppdatert med Alle-valg for superadmin React.useEffect(() => { if (me.is_superadmin) { const hasAll = DATA.TENANTS.some(t => t.id === '__all__'); if (!hasAll) { DATA.TENANTS = [ { id: '__all__', name: 'Alle tenants', sub: `${DATA.TENANTS.length} tenants`, avatar: '★' }, ...DATA.TENANTS, ]; } } }, []); React.useEffect(() => { document.documentElement.dataset.theme = tweaks.theme; document.documentElement.dataset.density = tweaks.density; document.documentElement.style.setProperty('--accent', tweaks.accent); }, [tweaks.theme, tweaks.density, tweaks.accent]); // INGEN AUTO-REFRESH. Bruker trigger refresh manuelt via knapp. // (Tidligere hadde vi 30s setInterval som remountet hele appen — ødela // scroll, drawers, pågående arbeid. Helt fjernet.) const manualRefresh = React.useCallback(async () => { try { await window.AUTH.loadAllData(); setReloadKey(k => k + 1); } catch (e) { console.warn('refresh failed:', e); } }, []); const t = I18N[lang]; const crumbs = (() => { if (route.page === 'overview') return [{ label: tenant.name }, { label: t.nav_overview }]; if (route.page === 'machines' || route.machineId) { const cs = [ { label: tenant.name }, { label: t.nav_machines, onClick: () => setRoute({ page: 'machines' }) }, ]; if (route.machineId) { const m = DATA.machines.find(mm => mm.id === route.machineId); if (m) cs.push({ label: m.name }); } return cs; } const map = { platform: lang === 'no' ? 'Plattform' : 'Platform', transactions: t.nav_transactions, events: t.nav_events, bottles: t.nav_bottles, users: t.nav_users, settings: t.nav_settings, operator: lang === 'no' ? 'Daglig drift' : 'Daily ops', refill: lang === 'no' ? 'Fylling' : 'Refill', analytics: lang === 'no' ? 'Statistikk' : 'Analytics', map: lang === 'no' ? 'Kart' : 'Map', areas: lang === 'no' ? 'Områder' : 'Areas', activity: lang === 'no' ? 'Aktivitet' : 'Activity', }; return [{ label: tenant.name }, { label: map[route.page] || '' }]; })(); // Varsel-teller i sidemenyen: KUN valgt tenants automater (2026-09-04 — // før talte den varsler fra alle tenants ved «se som tenant»/multi-tenant). const alarmsCount = (() => { const ids = new Set( (tenant.id === '__all__' ? DATA.machines : DATA.machines.filter(m => m.tenantId === tenant.id)).map(m => m.id)); return DATA.ALARMS.filter(a => a.sev !== 'info' && ids.has(a.machineId)).length; })(); const openMachine = (id) => setRoute({ page: 'machines', machineId: id }); // dataVersion brukes som useEffect-dep i child-komponenter som vil // re-fetche på tenant-skift eller manuell refresh — UTEN full remount. const dataVersion = `${tenant.id}::${reloadKey}`; return (
{ setTenant(tn); setNavOpen(false); }} route={route} onNavigate={(r) => { setRoute(r); setNavOpen(false); }} alarmsCount={alarmsCount} me={me} effectiveRole={effectiveRole} /> {/* Mobil: mørkt bakteppe bak den utskjøvne sidemenyen */} {navOpen &&
setNavOpen(false)} />}
setTweak('theme', th)} crumbs={crumbs} onRefresh={manualRefresh} onMenu={() => setNavOpen(o => !o)} tenant={tenant} onNavigate={(r) => { setRoute(r); setNavOpen(false); }} /> {/* Superadmin "viewing as"-banner — velg tenant + valgfri rolle-simulering */} {me.is_superadmin && (tenant.id !== '__all__' || impersonateRole) && (
{lang === 'no' ? 'Visning:' : 'Viewing:'}{' '} {tenant.id !== '__all__' && {tenant.name}} {impersonateRole && ( {tenant.id !== '__all__' ? ' · ' : ''} {lang === 'no' ? 'som' : 'as'}{' '} {impersonateRole === 'tenant_admin' ? 'tenant-admin' : impersonateRole === 'worker' ? (lang === 'no' ? 'operatør' : 'operator') : 'superadmin'} )}
{lang === 'no' ? 'Simuler rolle:' : 'Simulate:'}
)} {route.page === 'platform' && } {route.page === 'overview' && } {route.page === 'machines' && !route.machineId && } {route.page === 'areas' && } {route.page === 'activity' && } {route.page === 'machines' && route.machineId && ( setRoute({ page: 'machines' })} dataVersion={dataVersion} onReload={manualRefresh} /> )} {route.page === 'transactions' && } {route.page === 'events' && } {route.page === 'operator' && } {route.page === 'refill' && } {route.page === 'analytics' && } {route.page === 'map' && } {route.page === 'bottles' && } {route.page === 'users' && } {route.page === 'settings' && }
setTweak('theme', v)} /> setTweak('density', v)} /> setTweak('accent', v)} />
); }; /* Standalone machines index */ const MachinesIndex = ({ lang, onOpenMachine, tenant, dataVersion }) => { const t = I18N[lang]; // Beregn fra prop, ikke fra mutert DATA.machinesView (som ikke trigger re-render) const activeTenant = tenant || DATA.activeTenant || { id: '__all__' }; const machines = React.useMemo(() => ( activeTenant.id === '__all__' ? DATA.machines : DATA.machines.filter(m => m.tenantId === activeTenant.id) ), [activeTenant.id, dataVersion]); return (

{t.nav_machines}

{machines.length} {lang === 'no' ? 'automater i tenanten' : 'machines in tenant'}
); }; /* Tenant-wide transactions — henter pr-automat fra backend, slår sammen */ const TenantTransactions = ({ lang, tenant, dataVersion }) => { const t = I18N[lang]; const [allTx, setAllTx] = React.useState([]); const [loading, setLoading] = React.useState(true); const [sortKey, setSortKey] = React.useState('ts'); const [sortDir, setSortDir] = React.useState('desc'); const [stateFilter, setStateFilter] = React.useState('all'); // all|complete|error const [openTx, setOpenTx] = React.useState(null); const [period, setPeriod] = React.useState('10'); // '10' | '7' | '30' | '90' // Eksplisitt tenant-scope fra prop (ikke fra mutert DATA.activeTenant) const activeTenant = tenant || DATA.activeTenant || { id: '__all__', name: 'Alle' }; const machines = activeTenant.id === '__all__' ? DATA.machines : DATA.machines.filter(m => m.tenantId === activeTenant.id); const machineIds = machines.map(m => m.id).join(','); React.useEffect(() => { setLoading(true); // Map periode til API-kall: '10' = siste 10 (limit=10, days=365 så vi tar uavhengig av tid) let perAutomatLimit = 100, days = 30; if (period === '10') { perAutomatLimit = 10; days = 365; } else if (period === '7') { perAutomatLimit = 50; days = 7; } else if (period === '30') { perAutomatLimit = 100; days = 30; } else if (period === '90') { perAutomatLimit = 200; days = 90; } // Send tenant_id eksplisitt slik at backend dobbelt-validerer const tenantQ = activeTenant.id !== '__all__' ? { tenant_id: activeTenant.id } : {}; Promise.all(machines.map(m => window.api.listOrders({ automat_id: m.id, days, limit: perAutomatLimit, ...tenantQ }) .catch(() => []) )).then(perAutomat => { // Slå sammen + map til enhetlig form. Behold ALLE råfelter for detalj-view. const merged = [].concat(...perAutomat).slice(0, 2000); setAllTx(merged.map(r => { // Samme status-logikk som automat-fanen (tables.jsx orderStatusKey): // kreditert/delvis kreditert vinner over fullført, avbrutt ≠ feil. const key = orderStatusKey(r); return { ...r, // behold raw så detail-drawer kan vise alt _ts: new Date(r.created_at).getTime(), _amount: (r.price_ore || 0) / 100, _status: key, _success: key === 'fullført' && (r.price_ore || 0) > 0, _refunded: key === 'kreditert' || key === 'delvis-kreditert', _failed: key === 'feil' || key === 'avbrutt', _machineName: r.automat_location || r.automat_id, }; })); setLoading(false); }).catch(e => { console.warn(e); setLoading(false); }); }, [machineIds, activeTenant.id, period, dataVersion]); // Sorter etter sort-key først (klient-side), så slice til 10/etc hvis 'period === 10' // gjør vi allerede via API-limit, så ingen ekstra slice her. // Filtrer + sorter const filtered = React.useMemo(() => { let rows = allTx; if (stateFilter === 'complete') rows = rows.filter(x => x._success); if (stateFilter === 'error') rows = rows.filter(x => x._failed); if (stateFilter === 'refunded') rows = rows.filter(x => x._refunded); const dir = sortDir === 'asc' ? 1 : -1; const cmp = { ts: (a, b) => (a._ts - b._ts) * dir, machine: (a, b) => a._machineName.localeCompare(b._machineName) * dir, type: (a, b) => (a.kind || '').localeCompare(b.kind || '') * dir, bottle: (a, b) => (a.bottle_out || '').localeCompare(b.bottle_out || '') * dir, amount: (a, b) => (a._amount - b._amount) * dir, state: (a, b) => (a.session_state || '').localeCompare(b.session_state || '') * dir, }[sortKey] || ((a, b) => 0); return [...rows].sort(cmp); }, [allTx, stateFilter, sortKey, sortDir]); // Sum KUN av vellykkede const sumKr = filtered.filter(x => x._success).reduce((s, x) => s + x._amount, 0); const failedCount = filtered.filter(x => x._failed).length; const setSort = (key) => { if (key === sortKey) setSortDir(d => d === 'asc' ? 'desc' : 'asc'); else { setSortKey(key); setSortDir('desc'); } }; const SortTh = ({ k, children, num }) => ( setSort(k)} className={num ? 'num' : ''} style={{ cursor: 'pointer', userSelect: 'none' }}> {children} {sortKey === k && {sortDir === 'asc' ? '▲' : '▼'}} ); return (

{t.nav_transactions}

{lang === 'no' ? 'Tenant' : 'Tenant'}: {activeTenant.name} {' · '} {machines.length} {lang === 'no' ? 'automater' : 'machines'} {' · '} {activeTenant.id}
{lang === 'no' ? 'Sum (vellykket)' : 'Sum (success)'}
{fmtKr(sumKr, lang)}
{lang === 'no' ? 'Vellykket' : 'OK'}
{filtered.filter(x => x._success).length}
{failedCount > 0 && (
{lang === 'no' ? 'Feilet' : 'Failed'}
{failedCount}
)}
{lang === 'no' ? 'Totalt' : 'Total'}
{filtered.length}
{filtered.length}/{allTx.length}
{loading ? (
⏳ {lang === 'no' ? 'Henter transaksjoner…' : 'Loading transactions…'}
) : filtered.length === 0 ? (
{lang === 'no' ? (period === '10' ? 'Ingen transaksjoner blant siste 10' : `Ingen transaksjoner siste ${period} dager`) : (period === '10' ? 'No transactions in last 10' : `No transactions in last ${period} days`)}
) : ( {lang === 'no' ? 'Tid' : 'Time'}Automat{lang === 'no' ? 'Type' : 'Type'}{lang === 'no' ? 'Flaske' : 'Bottle'}Status{lang === 'no' ? 'Beløp' : 'Amount'} {filtered.slice(0, 500).map((x, i) => ( setOpenTx(x)} style={{ cursor: 'pointer' }}> ))}
{new Date(x.created_at).toLocaleString('no-NO', { dateStyle: 'short', timeStyle: 'short' })} {x._machineName} {x.kind === 'exchange' ? {t.swap} : {t.buy}} {x.bottle_out || —} {stateBadge(x)} {x._success ? fmtKr(x._amount, lang) : —}
)}
{openTx && window.TransactionDetail && ( setOpenTx(null)} /> )}
); }; /* Tenant-wide events */ const TenantEvents = ({ lang, onOpenMachine, tenant, dataVersion }) => { const t = I18N[lang]; const [evs, setEvs] = React.useState([]); const [sev, setSev] = React.useState('all'); const activeTenantId = (tenant || DATA.activeTenant || {}).id; React.useEffect(() => { window.api.recentAlarms().then(rows => { setEvs(rows.map(r => ({ id: 'EV-' + r.id, ts: { text: new Date(r.sent_at).toLocaleString('no-NO', { timeStyle: 'short' }), date: new Date(r.sent_at).toLocaleDateString('no-NO'), raw: new Date(r.sent_at).getTime() }, sev: r.severity === 'critical' || r.severity === 'high' ? 'danger' : r.severity === 'medium' ? 'warn' : 'info', msg_no: r.message, msg_en: r.message, machineName: r.automat_id, machineId: r.automat_id, }))); }).catch(console.warn); }, [activeTenantId, dataVersion]); // Filter til kun denne tenants automater const tenantMachineIds = new Set( (activeTenantId === '__all__' || !activeTenantId) ? DATA.machines.map(m => m.id) : DATA.machines.filter(m => m.tenantId === activeTenantId).map(m => m.id) ); const filtered = evs .filter(e => tenantMachineIds.has(e.machineId)) .filter(e => sev === 'all' || e.sev === sev); return (

{t.nav_events}

{lang === 'no' ? 'Hendelseslogg og alarmer på tvers av alle automater' : 'Event log and alarms across all machines'}
{lang === 'no' ? 'Alle hendelser' : 'All events'}
{filtered.length}/{evs.length}
{filtered.map(e => ( onOpenMachine(e.machineId)}> ))}
{t.ev_time}{t.ev_sev} {t.ev_msg}{t.ev_machine}ID
{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}} {lang === 'no' ? e.msg_no : e.msg_en} {e.machineName} {e.id}
); }; const AdminSettings = ({ lang, me, onReload }) => { const t = I18N[lang]; const isSuper = !!me?.is_superadmin; const no = lang === 'no'; // Faner (2026-09-04): tenant-info først; automat-tilhørighet, Stripe og // system i egne faner — automat-tabellen skal ikke være det første tenanten ser. const tabs = [ { k: 'tenant', label: no ? 'Tenant' : 'Tenant', icon: 'users' }, ...(isSuper ? [ { k: 'automats', label: no ? 'Automater & tilhørighet' : 'Automats & assignment', icon: 'machine' }, { k: 'stripe', label: 'Stripe Connect', icon: 'transaction' }, ] : []), { k: 'system', label: no ? 'System' : 'System', icon: 'settings' }, ]; const [tab, setTab] = React.useState('tenant'); return ( <>

{t.nav_settings}

{isSuper ? (no ? 'Tenant-info, automat-tilhørighet, Stripe og system' : 'Tenant info, automat assignment, Stripe, system') : (no ? 'Innstillinger' : 'Settings')}
{tabs.map(tb => (
setTab(tb.k)}> {tb.label}
))}
{tab === 'tenant' && (<> )} {tab === 'automats' && isSuper && (<> )} {tab === 'stripe' && isSuper && } {tab === 'system' && (<> )}
); }; const RolesInfoCard = ({ lang }) => (

{lang === 'no' ? 'Roller' : 'Roles'}

RolleTilgang
Superadmin {lang === 'no' ? 'Alt — tenants, automater, brukere, Stripe, kvitteringer, alle data' : 'Everything'}
Tenant_admin {lang === 'no' ? 'Sin tenant: opprett brukere, endre kvittering-info, se rapporter. Kan IKKE legge til nye automater.' : 'Their tenant: create users, edit receipt info, view reports. Cannot add new automats.'}
Tenant_worker {lang === 'no' ? 'Åpne dører + fylle automater. Tilgang til fylling-modul.' : 'Open doors + fill automats. Refill module access.'}
); const PWAInstallPanel = ({ lang }) => { const [installable, setInstallable] = React.useState(false); const [installed, setInstalled] = React.useState(false); React.useEffect(() => { const handler = (e) => { e.preventDefault(); window._deferredInstall = e; setInstallable(true); }; window.addEventListener('beforeinstallprompt', handler); const onInstalled = () => setInstalled(true); window.addEventListener('appinstalled', onInstalled); if (window.matchMedia('(display-mode: standalone)').matches) setInstalled(true); return () => { window.removeEventListener('beforeinstallprompt', handler); window.removeEventListener('appinstalled', onInstalled); }; }, []); const install = async () => { if (window._deferredInstall) { window._deferredInstall.prompt(); const r = await window._deferredInstall.userChoice; if (r.outcome === 'accepted') setInstallable(false); } }; return (

App-installasjon (PWA)

{installed ?
✓ Appen er installert
: installable ? (<>

Installer denne PWA-en på Android-nettbrett for full-skjerm bruk uten browser-chrome.

) : (<>

For å installere som app: åpne nettleser-menyen og velg "Legg til på startskjermen". PWA-en fungerer offline (siste data caches) og åpnes som vanlig app.

)}
); }; const TenantCompanyInfoForms = ({ lang, me }) => { const [openTenant, setOpenTenant] = React.useState(null); const tenants = (DATA.TENANTS || []).filter(t => t.id !== '__all__'); return (

{lang === 'no' ? 'Tenant kvittering-info' : 'Tenant receipt info'}

{lang === 'no' ? 'Påkrevd iht. Bokføringsforskriften §5-3 — vises på alle kvitteringer som sendes til kunder.' : 'Required by Norwegian regulations.'}
{tenants.map(t => (
{t.name}
))}
{openTenant && ( setOpenTenant(null)} /> )}
); }; const TenantCompanyEditModal = ({ tenantId, lang, onClose }) => { const [form, setForm] = React.useState(null); const [busy, setBusy] = React.useState(false); const [err, setErr] = React.useState(null); const [logoBusy, setLogoBusy] = React.useState(false); const logoInputRef = React.useRef(null); React.useEffect(() => { window.api.tenantGet(tenantId).then(setForm).catch(e => setErr(e.message)); }, [tenantId]); // Hvis lasting feilet (form er fortsatt null), vis en feil-skall i stedet for // en blank skjerm — ellers ville den fangede feilen aldri blitt vist. if (!form) { if (!err) return null; // laster fortsatt return ( <>
❌ {err}
); } const set = (k, v) => setForm(f => ({ ...f, [k]: v })); const uploadLogo = async (fileList) => { const file = fileList && fileList[0]; if (!file) return; setLogoBusy(true); setErr(null); try { const r = await window.api.uploadTenantLogo(tenantId, file, 'brand'); set('brand_logo_url', r.url); // cache-bar URL fra serveren } catch (e) { setErr(e.message); } setLogoBusy(false); }; // Kun superadmin kan (de)aktivere en tenant — «ser som»-simulering skjuler også const isSuper = !!(window.isSuperView && window.isSuperView()); const submit = async () => { setBusy(true); setErr(null); try { // Send kun redigerbare felt (TenantReceiptInfo) — ikke hele GET-svaret // (id, stripe_*, løpenumre …). `active` sendes bare av superadmin // (2026-09-05; før ble hele objektet sendt og `active` ignorert stille). const EDITABLE = ['name', 'business_name', 'org_number', 'vat_registered', 'address_line1', 'address_line2', 'postal_code', 'city', 'country', 'bank_account', 'invoice_email', 'receipt_footer', 'logo_url', 'contact_email', 'contact_phone', 'brand_primary_color', 'brand_logo_url']; const patch = {}; EDITABLE.forEach(k => { if (k in form) patch[k] = form[k]; }); if (isSuper) patch.active = form.active !== false; else delete patch.active; // Tomme e-postfelt → null («fjern»), ikke tom streng (ugyldig adresse → 422) ['invoice_email', 'contact_email'].forEach(k => { if (patch[k] === '') patch[k] = null; }); await window.api.tenantUpdate(tenantId, patch); onClose(); } catch (e) { setErr(e.message); } setBusy(false); }; const F = { form, set }; // felles props til TenantField (modulnivå-komponent) return ( <>

{form.name} — kvittering-info

{isSuper && ( )}