/* ============================================================================ GC · APP — kollegans skal: header, flikrad, sidväxling ---------------------------------------------------------------------------- Hans app är en enkolumnsapp: max-w-2xl som växer till max-w-4xl, egen header med "Hem" + live-prick, sju flikar under en linje. Läge "Ny" renderar HANS skal i stället för vårt — vår topbar, sidopanel och korttokens är helt ur bilden här. Flikar som ännu inte är portade visar den vy vi redan har byggt mot backend, märkt som oportad. Den ser ut som vår gamla design, för det är vad den är — hellre det än en tom flik, och märkningen gör skillnaden tydlig. ========================================================================== */ /* Banderollen om oportad markup är borttagen 2026-08-16: den var till för oss under portningen, inte för användaren, och stod kvar på varje besök. Vilka vyer som är ritade i hans markup står i DESIGN_STATE.md §8. */ function GcLegacy({ children }){ return
{children}
; } function GcApp(props){ const { day, tomorrow, units, live, settings, history, comparison, comparisonBusy, savings, now, nowIdx, onRange, onSaved, onOldDesign, liveText, homeName, slots, evLoad, tomorrowPrelim, fcShow, evWList, evUnmet, evUnmetTotal, dataStatus, peak } = props; const EMBED = window.HEMS_EMBED || null; try{ window.HEMS_DEMO_STALE = !localStorage.getItem('hems_session_token') && new URLSearchParams(location.search).get('demo_stale') === '1'; }catch(_){ window.HEMS_DEMO_STALE = false; } /* Bottenflikar överallt (André 24/9: "vi vill ha det på dator också"): flikraden ligger längst ner med ikon + etikett, Mer bär Inställningar/Hjälp/Logga ut. ?bottom_tabs=0 ger den gamla toppflikraden för jämförelse. */ const BOTTOM_TABS = (() => { try { return new URLSearchParams(location.search).get('bottom_tabs') !== '0'; } catch(_) { return true; } })(); const [moreOpen, setMoreOpen] = React.useState(false); /* Historik stängd i prod (André 4/10, förslag B med text 1): ekonomin i Historik visar fel på flera ställen. I prod (backenden är www.powergravio.se) står fliken kvar märkt SNART och öppnar en ruta i stället för sidan. Staging är som förut. ?prod=demo visar läget i granskningen (Mobilvy). Öppnas igen genom att ta bort spärren här. */ const HIST_CLOSED = (() => { try { if (new URLSearchParams(location.search).get('prod') === 'demo') return true; return /^(www\.)?powergravio\.se$/i.test(new URL(window.HEMS_BASE()).hostname); } catch(_) { return false; } })(); const [histPop, setHistPop] = React.useState(false); React.useEffect(() => { if (!histPop) return; const f = (e) => { if (e.key === 'Escape') setHistPop(false); }; window.addEventListener('keydown', f); return () => window.removeEventListener('keydown', f); }, [histPop]); const [page, setPage] = React.useState(() => { if (EMBED) return EMBED; try { return localStorage.getItem('gc.page') || 'oversikt'; } catch(e){ return 'oversikt'; } }); React.useEffect(() => { if (EMBED) return; try { localStorage.setItem('gc.page', page); } catch(e){} }, [page]); /* v598 (André 26/9): byte av flik börjar längst upp. Första visningen rörs inte. */ const gcFirstPage = React.useRef(true); React.useEffect(() => { if (gcFirstPage.current){ gcFirstPage.current = false; return; } try { window.scrollTo(0, 0); requestAnimationFrame(() => window.scrollTo(0, 0)); } catch(e){} }, [page]); /* v832 (André 7/10): "När man trycker på ikonerna skall man komma till toppen på det valet. Så är man redan på Historik så kommer man till toppen." Tryck på fliken man redan står på rullar mjukt upp; byte av flik börjar längst upp som förut (effekten ovan), också efter ett besök långt ner. */ const goTab = (id) => { if (id === page){ try { window.scrollTo({ top: 0, behavior: 'smooth' }); } catch(e){ window.scrollTo(0, 0); } return; } setPage(id); }; React.useEffect(() => { if (EMBED) document.body.classList.add('gc-embed'); }, []); /* Djuplänkar #/… (v689, Nytt i HEMS v4, knappens mål; listan är tickets.js NYTT_ROUTES): #/oversikt, #/schema, #/bil, #/last, #/historik, #/prognos, #/last/ och #/bil/ (id, eller siffra för ordningen), #/installningar[/grupp] vidare till settings.html. Hashen tas bort när målet öppnats, så en omladdning börjar där man senast var. Okänt mål: ingenting händer. */ const [navTo, setNavTo] = React.useState(null); React.useEffect(() => { if (EMBED) return; const go = () => { const h = location.hash || ''; if (h.indexOf('#/') !== 0) return; const T = window.HemsTickets, r = T && T.nyttRoute ? T.nyttRoute(h) : null; try { history.replaceState(null, '', location.pathname + location.search); } catch(_) {} if (!r) return; if (!r.page) { location.href = r.href; return; } if (r.page === 'statistik' && HIST_CLOSED) { setHistPop(true); return; } setPage(r.page); if (r.id) setNavTo({ page: r.page, id: r.id, t: Date.now() }); }; go(); window.addEventListener('hashchange', go); return () => window.removeEventListener('hashchange', go); }, []); /* Ett visst lastkort eller en viss laddbox: kortet letas fram (data-load-id / data-charger-id), fälls ut om det är ett hopfällt lastkort, och rullas fram under huvudet med en kort markering. Datan kommer asynkront, så vi letar i upp till 4 s. Finns kortet inte står fliken kvar överst. */ React.useEffect(() => { if (!navTo) return; /* v766: Bil-fliken visar en box i taget när det finns två. Målet väljer fliken innan kortet letas fram. */ if (navTo.page === 'bil'){ window.__gcBilBox = String(navTo.id); try { window.dispatchEvent(new Event('hems:bil-box')); } catch(_){} } const attr = navTo.page === 'last' ? 'data-load-id' : 'data-charger-id'; let n = 0; const iv = setInterval(() => { const all = Array.from(document.querySelectorAll('[' + attr + ']')); const el = all.find((x) => x.getAttribute(attr) === String(navTo.id)) || (/^\d+$/.test(navTo.id) ? all[Number(navTo.id) - 1] : null); if (!el && ++n < 27) return; clearInterval(iv); if (!el) return; const fold = navTo.page === 'last' ? el.querySelector('button[aria-expanded="false"]') : null; if (fold) fold.click(); setTimeout(() => { const head = document.querySelector('.gc-head'); window.scrollTo({ top: Math.max(0, el.getBoundingClientRect().top + window.scrollY - (head ? head.offsetHeight : 0) - 12), behavior: 'smooth' }); el.style.transition = 'box-shadow .3s'; el.style.boxShadow = '0 0 0 2px rgba(56,189,248,.75)'; setTimeout(() => { el.style.boxShadow = ''; }, 2400); }, 80); }, 150); return () => clearInterval(iv); }, [navTo]); const ported = { oversikt: !!window.GcOversikt, last: !!window.GcLast, schema: !!window.GcSchema, avancerat: !!window.GcAvancerat }; /* EvWeekCards renderar NULL utan laddboxar — en flik som går att klicka och sedan står tom är värre än en avstängd flik. */ const chargers = (window.evModel && window.evModel.normalize) ? window.evModel.normalize(settings) : []; /* Planspärr (kravlista 2026-09-15, K1–K3). entitlements ur /me: ev/loads säger om funktionen ingår i planen, access:blocked att prenumerationen gått ut. Saknas fältet ⇒ allt true, ingen skillnad mot i dag (K6). */ const [ent, setEnt] = React.useState(() => (window.HemsEnt ? window.HemsEnt.get() : { access:'full', ev:true, loads:true })); React.useEffect(() => { const on = (e) => setEnt(e.detail); window.addEventListener('hems:entitlements', on); return () => window.removeEventListener('hems:entitlements', on); }, []); /* Supportärenden (23/9): olästpunkt på Hjälp och banner när supporten svarat. tickets.js faller till Demoläge utan backend; saknas filen händer inget. */ const [tk, setTk] = React.useState(null); /* K2 (backend 24/9): kundingångarna följer /me.support_ui, som kommer asynkront. */ const cuiNow = () => !!(window.HemsTickets && window.HemsTickets.CUSTOMER_UI); const [cui, setCui] = React.useState(cuiNow); React.useEffect(() => { const on = () => setCui(cuiNow()); window.addEventListener('hems:entitlements', on); if (window.HemsEnt) window.HemsEnt.load().then(on, on); return () => window.removeEventListener('hems:entitlements', on); }, []); React.useEffect(() => { const T = window.HemsTickets; if (!T) return; let on = true, ivA = null, ivC = null, pollA = null; /* v706 (André 27/9): ikonen släcks när admin varit i Driftcentralen. Tillbaka i dashboarden (fliken syns igen, bakåt ur cachen, eller Driftcentralen i en annan flik skriver sin stämpel) hämtas notiserna direkt i stället för efter en minut. */ const wake = () => { if (!document.hidden && pollA) pollA(); }; const onStore = (e) => { if (e.key === 'hems_adm_seen_at' && pollA) pollA(); }; document.addEventListener('visibilitychange', wake); window.addEventListener('pageshow', wake); window.addEventListener('storage', onStore); /* Admin-notiser (André 25/9, backend §21): headset-ikonen pulserar vid notis (taggad, ärende flyttat till mig, @ i chatten) och länkar till det senaste. v597 (backend 25/9): pollingen körs för admin OAVSETT kunddelen — tidigare bara när den var av, så i staging (support_ui på) syntes notisen aldrig. Kund utan support_ui gör fortfarande inga anrop (S6). */ T.meLite().then((m) => { if (!on) return; const adm = !!(m && m.is_admin); if (!cui) setTk({ is_admin: adm, unread: 0, latest: null, adm: 0, admHref: null }); if (!adm || !T.adminNotify) return; const poll = () => T.adminNotify().then((r) => { if (on && r) setTk((cur) => ({ ...(cur || {}), is_admin: true, adm: r.unread || 0, admHref: r.href || null })); }); pollA = poll; poll(); ivA = setInterval(poll, 60000); }); if (cui){ /* Kunddelen på: sammanfattningen skrivs utan att tappa adminnotiserna. */ const go = () => T.summary().then((s) => { if (on) setTk((cur) => s ? { ...s, adm: (cur && cur.adm) || 0, admHref: (cur && cur.admHref) || null } : null); }); T.ready().then(go); ivC = setInterval(go, 60000); } return () => { on = false; if (ivA) clearInterval(ivA); if (ivC) clearInterval(ivC); document.removeEventListener('visibilitychange', wake); window.removeEventListener('pageshow', wake); window.removeEventListener('storage', onStore); }; }, [cui]); /* Nytt i HEMS (André 27/9): punkt på Mer och Hjälp när senaste godkända rapporten är nyare än den kunden senast såg på Hjälp. */ const [nyttNew, setNyttNew] = React.useState(false); React.useEffect(() => { const T = window.HemsTickets; if (EMBED || !T || !T.changelog) return; let on = true; const go = () => T.changelog().then((d) => { if (on) setNyttNew(T.nyttIsNew(d)); }); go(); const iv = setInterval(go, 1800000); return () => { on = false; clearInterval(iv); }; }, []); const tabs = [ { id:'oversikt', txt:'Översikt', enabled:true }, { id:'schema', txt:'Schema', enabled:!!(window.GcSchema || window.ClockV2) }, /* Låst flik visas KVAR i raden med hänglås — kunden ska se vad hen går miste om, inte sakna fliken (André 15/9). */ { id:'bil', txt:'Bil', enabled:!!window.GcBil && (chargers.length > 0 || !ent.ev), beta:true, locked:!ent.ev }, /* Beta-punkt i flikraden (André 7/9): märkningen måste synas INNAN man klickar sig in, inte bara på sidan man redan valt. */ { id:'last', txt:'Last', enabled:!!window.GcLast, beta:true, locked:!ent.loads }, /* Namnbyten 29/8 (André): "Statistik" bar både kWh och kr — HISTORIK är det ärliga namnet för allt som ligger bakåt i tiden. "Avancerat" sa inget om innehållet — PROGNOS är vad 48h-grafen är. Flik-ID:na behålls (localStorage gc.page + all sidväxlingslogik pekar på dem). */ /* v806 (André 6/10): Prognos före Historik, så raden blir Översikt, Schema, Bil, Last, Prognos, Historik. */ { id:'avancerat', txt:'Prognos', enabled:!!window.GcAvancerat || (!!window.ForecastCard && !!(slots && slots.length)) }, { id:'statistik', txt:'Historik', enabled:!!(window.GcHistorik || window.StatsV2) && !HIST_CLOSED, soon:!!(window.GcHistorik || window.StatsV2) && HIST_CLOSED }, { id:'installningar', txt:'Inställningar', enabled:!!window.GcInstallningar, href:'settings.html' }, /* Hjälpen är en LÄNK i flikraden, inte en flik: help.html är en egen sida (egen bundle med guider, flödesbilder, felsökning) och ska inte dras in i dashboardens React-träd. Samma utseende som flikarna så den hittas där man letar (André 31/8: "måste vara lättåtkomligt"). */ { id:'hjalp', txt:'Hjälp', enabled:true, href:'help.html' }, ]; /* Logga ut längst ut till höger i flikraden (André 13/9). Samma rensning som inställningssidans Konto-kort, så ingen gammal identitet ligger kvar. */ const logout = () => { ['hems_session_token', 'hems_user_email', 'hems_user_name', 'hems_user_id'] .forEach(k => { try { localStorage.removeItem(k); } catch (e) {} }); window.location.href = 'login.html'; }; /* href-flikar är LÄNKAR, inte sidor: de får aldrig bli `active`. Utan !t.href skulle ett gammalt localStorage-värde ('installningar' från när fliken var en sida) rendera driftvyn under en flikrad utan markering. */ const active = tabs.find((t) => t.id === page && t.enabled && !t.href) ? page : 'oversikt'; /* v703: flikraden sparas så att Hjälp (help.html) kan visa samma rad längst ner (André 27/9). */ const tabsSeen = JSON.stringify(tabs.filter((t) => (t.enabled || t.locked || t.soon) && !t.href).map((t) => ({ id:t.id, txt:t.txt, enabled:!!t.enabled || !!t.soon, locked:!!t.locked, beta:!!t.beta, soon:!!t.soon }))); React.useEffect(() => { if (EMBED) return; try { localStorage.setItem('gc.tabs', tabsSeen); } catch (_) {} }, [tabsSeen]); /* K1: utgången prenumeration ersätter HELA dashboarden. Sidan gör inga dataanrop och pollar inte. */ if (ent.access !== 'full' && window.LockedPage) return ; /* v762: antal nytt på headset-ikonen. Admin: notiser (ett per ärende eller kanal). Kund: olästa svar från supporten. */ const hsN = tk ? (Number(tk.is_admin ? tk.adm : tk.unread) || 0) : 0; return (
{/* Huvud + banners + flikrad låsta överst (André 24/9). -mx-3/px-3 så bakgrunden täcker hela bredden när innehållet rullar under. */} {/* v838: huvudet är helt täckt (inte 95 % med oskärpa), så inget som rullar under det skymtar igenom ovanför strecket (André 7/10). */}
{homeName || 'Hem'}
{liveText || 'Live · ansluten'}
{/* "Gammal design"-länken borttagen (André 29/8: "den nya fungerar bra, den gamla kan vi ta bort"). Gamla stacken finns kvar bakom ?design=gammal för felsökning, men får ingen väg dit i UI:t. */} {/* Gravio-chippen är en KNAPP hem till Översikt (André 1/9) — från vilken flik som helst, där energiflödet ligger. 9/9: chippen bär nu samma brand som landningssidan (märke + "Gravio HEMS / AI Energy Solutions") så ikonen är en överallt. */} {/* Headset-ikonen (23/9, 24/9): admin → Driftcentral; kund → Ärenden (bara när kundsidan är påslagen). v762 (André 29/9, variant A): nytt = röd siffra i hörnet, ingen puls. */} {tk && (tk.is_admin || cui) && ( 0 && tk.admHref) || 'admin.html') : (tk.latest ? `arenden.html#/t/${encodeURIComponent(tk.latest.id)}` : 'arenden.html')} title={tk.is_admin ? (tk.adm > 0 ? `Driftcentral — ${tk.adm} ${tk.adm === 1 ? 'ny notis' : 'nya notiser'}` : 'Driftcentral — ärenden, data och beslut för alla anläggningar') : 'Support — rapportera problem och följ svaren'} aria-label={(tk.is_admin ? 'Driftcentral' : 'Support') + (hsN > 0 ? `, ${hsN} ${tk.is_admin ? (hsN === 1 ? 'ny notis' : 'nya notiser') : (hsN === 1 ? 'nytt meddelande' : 'nya meddelanden')}` : '')} className={'relative flex h-8 w-8 items-center justify-center rounded-full border transition-colors hover:border-sky-400/60 hover:text-sky-200 ' + (hsN > 0 ? 'gc-hs-on' : 'border-slate-700/80 text-slate-300')}> {hsN > 0 && } )}
{/* D1 (rev 2, 20/9): utebliven betalning = öppen dashboard + banner. */} {!EMBED && window.PaymentBanner && } {/* Bannern "X har svarat i ärende" är borta (André 24/9): oläst från supporten visas i stället på Support-ikonen (v762: röd siffra). */} {/* DATAVARNINGAR (André 29/8: "hur vet användaren via dashboard att flöden fryser eller slutat skicka data?"). Backend vaktar detta i /api/data-status: status/age_minutes säger om Homey-pingarna stannat, warnings[] bär sensorvarningar (nätsensor utan negativa värden, orimliga inställningar, m.m.). GAMLA designen visade dem — nya skalet tappade dem i portningen. Frusen data ser annars ut som en lugn dag: samma sol, samma nät, inga fel någonstans. */} {(dataStatus || window.HEMS_DEMO_STALE) && (() => { /* ?demo_stale=1 (bara utan api-nyckel, lokal förhandsvisning): visar varningen med påhittad ålder så ingången till Ärenden kan granskas. */ const ds = dataStatus || { status:'disconnected', age_minutes: 42, message:'Ingen data tas emot från Homey', warnings: [] }; const stale = ds.status && ds.status !== 'connected'; const age = ds.age_minutes; const staleTxt = stale ? (ds.message || 'Ingen data tas emot') + (age != null && age > 0 ? ` — senaste mätvärdet är ${age} min gammalt` : '') : null; const ws = Array.isArray(ds.warnings_structured) ? ds.warnings_structured : Array.isArray(ds.warnings) ? ds.warnings : []; const items = ws.map((w) => typeof w === 'string' ? w : (w.text || w.message || '')).filter(Boolean); /* Effekttimmen som BANNER, inte kortrad (backend 31/8): "denna timme X av Y kWh" är åtgärdsrelevant precis när gränsen närmar sig — kortet är översikt. Tröskel 80 % av gränsen, och bara när peak shaving är aktiverat. */ const ph = peak?.current_hour, lim = peak?.peak_shaving?.max_grid_kw; if (peak?.peak_shaving?.enabled && ph && lim > 0 && (ph.accumulated_kwh || 0) >= lim * 0.8){ items.unshift(`Denna timme: ${window.gcNum(ph.accumulated_kwh, 1)} av ${window.gcNum(lim, 1)} kWh — närmar sig effektgränsen`); } if (!staleTxt && !items.length) return null; return (
{staleTxt && (
{staleTxt}. Dashboarden visar gamla värden — kontrollera Homey-flödet.{cui && <> Rapportera problem}
)} {items.map((t, i) => (
{t}
))}
); })()} {/* Flikraden går att dra med musen och rulla med hjulet (André 24/9): rullisten är dold, så utan detta fanns inget sätt att nå flikarna till höger på en smal desktop-ruta. Ett drag > 4 px svalde klicket. */} {/* Med bottenflikar: toppflikraden dold, men huvudet behåller en tunn linje och lite luft mot första kortet (André 24/9: "fälten går ihop"). */} {BOTTOM_TABS &&
}
{ const el = e.currentTarget; if (el.scrollWidth > el.clientWidth && Math.abs(e.deltaY) > Math.abs(e.deltaX)) { el.scrollLeft += e.deltaY; } }} onPointerDown={(e) => { if (e.pointerType !== 'mouse') return; const el = e.currentTarget; el._dx = e.clientX; el._sl = el.scrollLeft; el._drag = false; }} onPointerMove={(e) => { const el = e.currentTarget; if (el._dx == null || e.buttons !== 1) return; const d = e.clientX - el._dx; if (Math.abs(d) > 4) el._drag = true; if (el._drag) el.scrollLeft = el._sl - d; }} onPointerUp={(e) => { const el = e.currentTarget; el._dx = null; }} onClickCapture={(e) => { const el = e.currentTarget; if (el._drag) { e.stopPropagation(); e.preventDefault(); el._drag = false; } }}> {tabs.map((t) => t.href ? ( {t.txt} {t.id === 'hjalp' && ((tk && tk.unread > 0) || nyttNew) && 0 ? `${tk.unread} nytt i dina ärenden` : 'Nytt i HEMS'}>} ) : ( ))}
{!day && (
Väntar på schema från backend.
)} {day && active === 'oversikt' && ported.oversikt && ( {/* Peak-kortet: BARA när effektavgiftshanteringen är på — för alla andra är det brus (backend 31/8). */} {/* Prognoser skickas in som slot och läggs direkt under Energiflödet (André 24/9), före Beslut och Dagens plan. */} setPage('schema')} afterFlow={window.GcPrognoser ?
: null}/> {/* Energiflödet är ALLTID överst (André 13/9) — peak-kortet flyttat under det. */} {window.GcPeak && settings?.peak_shaving_enabled && peak && ( )}
)} {day && active === 'schema' && ( )} {active === 'bil' && (!ent.ev ? ( chargers.length > 0 ? : ) : ( ))} {active === 'installningar' && } {active === 'last' && (!ent.loads ? ( ) : ( ))} {active === 'avancerat' && (day && window.GcAvancerat ? ( ) : ( ))} {active === 'statistik' && (window.GcHistorik /* v790 (André 6/10, modell 13): Historik i hans markup, gc-historik.jsx. StatsV2 är kvar som reserv och i gravio-stat.html. */ ? : )}
{/* BOTTENFLIKAR (mockup 24/9, bara ?bottom_tabs=1): fast rad längst ner med ikon + etikett; Logga ut flyttar till toppfältet i det läget. */} {BOTTOM_TABS && !EMBED && ( )} {histPop && (
{ if (e.target === e.currentTarget) setHistPop(false); }}>

Historik förbättras

Vi förbättrar hur kostnad och besparing räknas. Historik öppnar igen när den nya versionen är klar. HEMS styr batteri, sol och bil som vanligt under tiden.

)}
); } Object.assign(window, { GcApp, GcLegacy });