OSVauco/static/billing-dashboard.html

790 lines
44 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="no">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CostGuard · Billing</title>
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#0a0a0f">
<link rel="apple-touch-icon" href="/static/icons/icon-192x192.png">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<style>
:root{
--bg:#0a0a0f;--surface:#13131c;--surface-2:#1a1a26;--surface-3:#242436;
--border:rgba(124,92,255,0.12);--border-h:rgba(124,92,255,0.28);
--text:#ececf5;--muted:#8a8aa3;--faint:#3a3a4d;
--blue:#7c5cff;--blue-bg:rgba(124,92,255,0.12);--blue-border:rgba(124,92,255,0.3);
--primary:#7c5cff;--accent-2:#00d4a8;
--green:#00d4a8;--green-bg:rgba(0,212,168,0.12);
--yellow:#ffb454;--yellow-bg:rgba(255,180,84,0.12);
--red:#ff5c7c;--red-bg:rgba(255,92,124,0.12);
--ok:#00d4a8;--warn:#ffb454;
--font:'Inter',-apple-system,sans-serif;--mono:'JetBrains Mono',monospace;
--r:10px;--tr:200ms cubic-bezier(0.2,0,0,1);
}
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
html{-webkit-font-smoothing:antialiased}
body{font-family:var(--font);background:var(--bg);color:var(--text);font-size:14px;line-height:1.5;min-height:100vh}
/* ── topbar ── */
.topbar{height:56px;background:var(--surface);border-bottom:1px solid var(--border);display:flex;align-items:center;padding:0 16px;gap:0;position:sticky;top:0;z-index:200}
.tb-logo{display:flex;align-items:center;gap:10px;padding:0 16px;border-right:1px solid var(--border)}
.tb-logo-icon{width:28px;height:28px;background:linear-gradient(135deg,#7c5cff,#00d4a8);border-radius:8px;display:flex;align-items:center;justify-content:center}
.tb-logo-icon svg{fill:none;stroke:#fff;stroke-width:2;width:16px;height:16px}
.tb-logo-name{font-size:15px;font-weight:500;letter-spacing:-.01em}
.tb-logo-name span{background:linear-gradient(135deg,#7c5cff,#00d4a8);-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent;font-weight:700}
.tb-project{display:flex;align-items:center;gap:6px;padding:6px 12px;margin:0 8px;font-size:13px;color:var(--muted)}
.tb-project .material-symbols-outlined{font-size:16px}
.tb-right{margin-left:auto;display:flex;align-items:center;gap:8px}
.tb-badge{display:flex;align-items:center;gap:4px;font-size:12px;font-weight:500;padding:4px 10px;border-radius:16px;border:1px solid}
.tb-badge.ok{background:var(--green-bg);color:var(--green);border-color:rgba(0,212,168,0.25)}
.tb-badge.warn{background:var(--yellow-bg);color:var(--yellow);border-color:rgba(255,180,84,0.25)}
.tb-badge.err{background:var(--red-bg);color:var(--red);border-color:rgba(255,92,124,0.25)}
.tb-period{background:var(--surface-2);border:1px solid var(--border);color:var(--text);font-size:13px;font-family:var(--font);padding:5px 12px;border-radius:6px;cursor:pointer;outline:none;transition:border-color var(--tr)}
.tb-period:hover{border-color:var(--border-h)}
.pulse{width:7px;height:7px;border-radius:50%;background:var(--green);flex-shrink:0;position:relative}
.pulse::after{content:'';position:absolute;inset:-3px;border-radius:50%;background:var(--green);opacity:.4;animation:pulse 2s ease-out infinite}
@keyframes pulse{0%{transform:scale(1);opacity:.4}100%{transform:scale(2.5);opacity:0}}
/* ── layout ── */
.layout{display:flex;min-height:calc(100vh - 56px)}
.sidebar{width:200px;flex-shrink:0;background:var(--surface);border-right:1px solid var(--border);padding:12px 0}
.nav-section{padding:8px 16px 4px;font-size:11px;font-weight:500;color:var(--muted);letter-spacing:.06em;text-transform:uppercase}
.nav-item{display:flex;align-items:center;gap:12px;padding:8px 16px;font-size:13px;color:var(--muted);cursor:pointer;transition:all var(--tr);border-radius:0 20px 20px 0;margin-right:8px}
.nav-item:hover{background:var(--surface-2);color:var(--text)}
.nav-item.active{background:var(--blue-bg);color:var(--blue);font-weight:600;border-left:2px solid var(--blue)}
.nav-item .material-symbols-outlined{font-size:18px}
.content{flex:1;padding:24px;overflow:hidden}
/* ── sel / buttons ── */
.sel{background:var(--surface-2);border:1px solid var(--border);color:var(--muted);padding:5px 10px;border-radius:6px;font-size:12px;font-family:var(--font);display:inline-flex;align-items:center;gap:6px;cursor:pointer;transition:background var(--tr),border-color var(--tr),color var(--tr)}
.sel:hover{background:var(--surface-3);border-color:var(--border-h);color:var(--text)}
.dropdown{position:relative;display:inline-block}
.dropdown-content{display:none;position:absolute;background:var(--surface-2);min-width:140px;box-shadow:0 8px 24px rgba(0,0,0,.4);z-index:99;right:0;border-radius:var(--r);border:1px solid var(--border)}
.dropdown-content a{color:var(--text);padding:8px 12px;text-decoration:none;display:block;font-size:12px}
.dropdown-content a:hover{background:var(--surface-3)}
.dropdown:hover .dropdown-content{display:block}
/* ── module grid (CG6 drag) ── */
.module-grid{display:flex;flex-direction:column;gap:12px}
.kpi-row{display:grid;grid-template-columns:repeat(3,1fr);gap:12px}
.charts-row{display:grid;grid-template-columns:2fr 1fr;gap:12px}
.bottom-row{display:grid;grid-template-columns:2fr 1fr;gap:12px}
/* ── draggable module ── */
.module{
position:relative;
background:var(--surface);
border:1px solid var(--border);
border-radius:12px;
transition:box-shadow var(--tr),border-color var(--tr),opacity .15s;
}
.module:hover{border-color:var(--border-h)}
.module.dragging{opacity:.45;box-shadow:0 8px 32px rgba(124,92,255,.25);cursor:grabbing;}
.module.drag-over{border-color:var(--blue);box-shadow:0 0 0 2px var(--blue-bg)}
.module-handle{
display:flex;align-items:center;justify-content:space-between;
padding:14px 18px 0;
cursor:grab;
user-select:none;
}
.module-handle:active{cursor:grabbing}
.module-handle-left{display:flex;align-items:center;gap:8px}
.drag-grip{color:var(--faint);font-size:16px!important;transition:color var(--tr)}
.module-handle:hover .drag-grip{color:var(--muted)}
.module-title{font-size:14px;font-weight:500}
.module-sub{font-size:12px;color:var(--muted)}
.module-actions{display:flex;align-items:center;gap:4px}
.module-btn{background:none;border:none;cursor:pointer;color:var(--muted);padding:2px 4px;border-radius:4px;transition:color var(--tr),background var(--tr);display:flex;align-items:center}
.module-btn:hover{color:var(--text);background:var(--surface-3)}
.module-btn .material-symbols-outlined{font-size:16px!important}
.module-body{padding:14px 18px 18px}
.module.collapsed .module-body{display:none}
.module.collapsed{border-color:var(--border)}
.collapse-icon{transition:transform .2s}
.module.collapsed .collapse-icon{transform:rotate(-90deg)}
/* ── kpi card ── */
.kpi{padding:0}
.kpi .module-body{display:flex;flex-direction:column;gap:4px}
.kpi-top{display:flex;align-items:center;justify-content:space-between;margin-bottom:8px}
.kpi-lbl{font-size:12px;color:var(--muted);font-weight:500}
.kpi-icon{width:32px;height:32px;border-radius:8px;display:flex;align-items:center;justify-content:center}
.kpi-icon .material-symbols-outlined{font-size:18px}
.ki-blue{background:var(--blue-bg)}.ki-blue .material-symbols-outlined{color:var(--blue)}
.ki-green{background:var(--green-bg)}.ki-green .material-symbols-outlined{color:var(--green)}
.ki-yellow{background:var(--yellow-bg)}.ki-yellow .material-symbols-outlined{color:var(--yellow)}
.kpi-val{font-size:22px;font-weight:700;font-family:var(--mono);letter-spacing:-.03em}
.kpi-delta{font-size:12px;color:var(--muted)}
/* ── chart ── */
.chart-wrap{position:relative;height:200px}
.empty-state{color:var(--muted);font-size:13px;padding:24px 0;text-align:center;display:flex;flex-direction:column;align-items:center;gap:8px}
.empty-state .material-symbols-outlined{font-size:32px;opacity:.3}
/* ── service list ── */
.svc-row{display:flex;align-items:center;gap:10px;padding:8px 0;border-bottom:1px solid var(--border);cursor:pointer;user-select:none}
.svc-row:last-child{border-bottom:none}
.svc-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0}
.svc-name{font-size:13px;flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.svc-bar-w{width:80px;height:2px;background:var(--surface-3);border-radius:2px;flex-shrink:0}
.svc-bar{height:100%;border-radius:2px;transition:width .8s cubic-bezier(.16,1,.3,1)}
.svc-cost{font-family:var(--mono);font-size:12px;color:var(--muted);width:60px;text-align:right;flex-shrink:0}
.svc-chevron{font-size:14px!important;transition:transform 0.15s;color:var(--muted);flex-shrink:0}
.svc-row.expanded .svc-chevron{transform:rotate(90deg)}
.svc-skus{display:none;padding:4px 0 4px 28px;border-left:2px solid var(--surface-3);margin:0 0 6px 18px}
.svc-skus.open{display:block}
.sku-row{display:flex;justify-content:space-between;font-size:11px;color:var(--muted);padding:3px 0}
/* ── gauge ── */
.gauge-wrap{display:flex;flex-direction:column;align-items:center;gap:12px;padding:8px 0}
.gauge-ring{position:relative;width:130px;height:72px;overflow:hidden}
.gauge-ring svg{position:absolute;top:0;left:0}
.gauge-num{position:absolute;bottom:0;left:50%;transform:translateX(-50%);font-family:var(--mono);font-size:20px;font-weight:500}
.gauge-meta{font-size:12px;color:var(--muted);text-align:center}
/* ── anomaly rows ── */
.anom-row{display:flex;align-items:center;gap:10px;padding:8px 0;border-bottom:1px solid var(--border)}
.anom-row:last-child{border-bottom:none}
.anom-badge{font-size:11px;font-weight:600;padding:2px 8px;border-radius:99px;flex-shrink:0}
.anom-badge.high{background:var(--red-bg);color:var(--red)}
.anom-badge.med{background:var(--yellow-bg);color:var(--yellow)}
/* ── misc ── */
.err-banner{background:var(--red-bg);border:1px solid rgba(255,92,124,.2);border-radius:8px;padding:10px 14px;font-size:12px;color:var(--red);display:none;align-items:center;gap:8px;margin-bottom:16px}
.err-banner.show{display:flex}
.page-hdr{display:flex;align-items:center;justify-content:space-between;margin-bottom:20px}
.page-title{font-size:22px;font-weight:600;letter-spacing:-.02em}
.page-title strong{font-weight:700;background:linear-gradient(135deg,#7c5cff,#00d4a8);-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent}
.blink{animation:blink 1.2s ease-in-out infinite}
@keyframes blink{0%,100%{opacity:1}50%{opacity:.3}}
footer{padding:10px 20px 16px;font-size:11px;color:var(--faint);text-align:center}
/* ── responsive ── */
@media(max-width:900px){.kpi-row{grid-template-columns:repeat(2,1fr)}.charts-row,.bottom-row{grid-template-columns:1fr}}
@media(max-width:720px){
.topbar{height:auto;flex-wrap:wrap;padding:8px 12px;gap:8px}
.tb-project{display:none}
.tb-right{width:100%;margin-left:0;flex-wrap:nowrap;overflow-x:auto;padding-bottom:4px;gap:6px}
.tb-right>*{flex-shrink:0}
.layout{flex-direction:column}
.sidebar{width:100%;display:flex;overflow-x:auto;border-right:none;border-bottom:1px solid var(--border);padding:8px;gap:4px}
.nav-section{display:none}
.nav-item{flex-shrink:0;border-radius:99px;border-left:none;margin-right:0;padding:8px 14px;white-space:nowrap}
.nav-item.active{border-left:none;border:1px solid var(--blue)}
.content{padding:16px 12px}
.kpi-row{grid-template-columns:1fr 1fr;gap:8px}
.page-title{font-size:18px}
}
@media(max-width:420px){.kpi-row{grid-template-columns:1fr}}
@media print{
.topbar,.sidebar,#budget-modal{display:none!important}
.module{border:1px solid #ddd}
.kpi-row,.charts-row,.bottom-row{grid-template-columns:1fr!important}
}
</style>
</head>
<body>
<header class="topbar">
<div class="tb-logo">
<div class="tb-logo-icon">
<svg viewBox="0 0 24 24"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>
</div>
<span class="tb-logo-name">Cost<span>Guard</span></span>
</div>
<div class="tb-project">
<span class="material-symbols-outlined">folder_open</span>
propane-will-491900-m5
</div>
<div class="tb-right">
<span class="pulse" id="live-dot"></span>
<span id="top-badge" class="tb-badge ok">
<span class="material-symbols-outlined" style="font-size:13px">check_circle</span>
Ingen anomalier
</span>
<select class="tb-period" onchange="updatePeriod(+this.value)">
<option value="7">7 dager</option>
<option value="30" selected>30 dager</option>
<option value="90">90 dager</option>
</select>
<button class="sel" onclick="fetchAll()" title="Oppdater">
<span class="material-symbols-outlined" style="font-size:16px">refresh</span>
</button>
<div class="dropdown">
<button class="sel">
<span class="material-symbols-outlined" style="font-size:16px">download</span>
<span>Eksport</span>
</button>
<div class="dropdown-content">
<a href="#" onclick="exportCSV()">Eksporter som CSV</a>
<a href="#" onclick="window.print()">Eksporter til PDF</a>
</div>
</div>
<div id="user-profile" style="display:none;align-items:center;gap:8px">
<img id="user-avatar" src="" alt="" style="width:28px;height:28px;border-radius:50%">
<div style="display:flex;flex-direction:column;line-height:1.2">
<span id="user-name" style="font-size:12px;font-weight:600"></span>
<a href="/auth/logout" style="font-size:10px;color:var(--muted);text-decoration:none">Logg ut</a>
</div>
</div>
</div>
</header>
<div class="layout">
<nav class="sidebar">
<div class="nav-section">Oversikt</div>
<div class="nav-item active"><span class="material-symbols-outlined">bar_chart</span>Billing</div>
<div class="nav-item"><span class="material-symbols-outlined">notifications</span>Varsler</div>
<div class="nav-item"><span class="material-symbols-outlined">trending_up</span>Prognoser</div>
<div class="nav-section" style="margin-top:12px">Innstillinger</div>
<div class="nav-item" onclick="openBudgetModal()"><span class="material-symbols-outlined">account_balance_wallet</span>Budsjett</div>
<div class="nav-item"><span class="material-symbols-outlined">settings</span>Konfigurasjon</div>
</nav>
<main class="content">
<div class="err-banner" id="err-banner">
<span class="material-symbols-outlined" style="font-size:14px">warning</span>
<span id="err-msg">Kan ikke nå API.</span>
</div>
<div class="page-hdr">
<h1 class="page-title">Billing <strong>Oversikt</strong></h1>
<span style="font-size:12px;color:var(--muted)" id="last-updated">Henter data...</span>
</div>
<!-- MODULE GRID: alle kort er draggable og collapsible -->
<div class="module-grid" id="module-grid">
<!-- KPI-rad -->
<div class="module" id="mod-kpis" draggable="true">
<div class="module-handle" ondragstart="dragStart(event,'mod-kpis')" ondragover="dragOver(event)" ondrop="drop(event,'mod-kpis')">
<div class="module-handle-left">
<span class="material-symbols-outlined drag-grip">drag_indicator</span>
<span class="module-title">Nøkkeltall</span>
<span class="module-sub" id="period-lbl">30 dager</span>
</div>
<div class="module-actions">
<button class="module-btn" onclick="toggleModule('mod-kpis')" title="Minimer">
<span class="material-symbols-outlined collapse-icon">expand_less</span>
</button>
</div>
</div>
<div class="module-body">
<div class="kpi-row">
<div class="module" style="border:none;box-shadow:none">
<div class="module-body" style="padding:14px">
<div class="kpi-top">
<span class="kpi-lbl">Totalkostnad MTD</span>
<div class="kpi-icon ki-blue"><span class="material-symbols-outlined">payments</span></div>
</div>
<div class="kpi-val" id="k-mtd"></div>
<div class="kpi-delta">hittil denne måneden</div>
</div>
</div>
<div class="module" style="border:none;box-shadow:none">
<div class="module-body" style="padding:14px">
<div class="kpi-top">
<span class="kpi-lbl">Daglig snitt (7d)</span>
<div class="kpi-icon ki-green"><span class="material-symbols-outlined">today</span></div>
</div>
<div class="kpi-val" id="k-daily"></div>
<div class="kpi-delta">snitt siste 7 dager</div>
</div>
</div>
<div class="module" style="border:none;box-shadow:none">
<div class="module-body" style="padding:14px">
<div class="kpi-top">
<span class="kpi-lbl">Prognose måneden</span>
<div class="kpi-icon ki-yellow"><span class="material-symbols-outlined">trending_up</span></div>
</div>
<div class="kpi-val" id="k-fc"></div>
<div class="kpi-delta"><span id="k-fc-d">— dager gjenbstår</span></div>
</div>
</div>
</div>
</div>
</div>
<!-- Charts-rad -->
<div class="module" id="mod-charts" draggable="true">
<div class="module-handle" ondragstart="dragStart(event,'mod-charts')" ondragover="dragOver(event)" ondrop="drop(event,'mod-charts')">
<div class="module-handle-left">
<span class="material-symbols-outlined drag-grip">drag_indicator</span>
<span class="module-title">Kostnadsutvikling</span>
</div>
<div class="module-actions">
<button class="module-btn" onclick="toggleModule('mod-charts')" title="Minimer">
<span class="material-symbols-outlined collapse-icon">expand_less</span>
</button>
</div>
</div>
<div class="module-body">
<div class="charts-row">
<div>
<div style="font-size:13px;font-weight:500;margin-bottom:12px">Daglig kostnad <span style="font-size:11px;color:var(--muted)">netto etter kreditter</span></div>
<div class="chart-wrap">
<div class="empty-state" id="bar-empty"><span class="material-symbols-outlined">bar_chart</span>Ingen daglig data</div>
<canvas id="bar-chart" style="display:none"></canvas>
</div>
</div>
<div>
<div style="font-size:13px;font-weight:500;margin-bottom:12px">Fordeling per tjeneste <span style="font-size:11px;color:var(--muted)">andel av total</span></div>
<div class="chart-wrap">
<div class="empty-state" id="donut-empty"><span class="material-symbols-outlined">donut_large</span>Ingen tjenestedata</div>
<canvas id="donut-chart" style="display:none"></canvas>
</div>
</div>
</div>
</div>
</div>
<!-- Tjenester + Budsjett -->
<div class="module" id="mod-services" draggable="true">
<div class="module-handle" ondragstart="dragStart(event,'mod-services')" ondragover="dragOver(event)" ondrop="drop(event,'mod-services')">
<div class="module-handle-left">
<span class="material-symbols-outlined drag-grip">drag_indicator</span>
<span class="module-title">Tjenester &amp; Budsjett</span>
<span class="module-sub">Klikk for SKU-detaljer</span>
</div>
<div class="module-actions">
<button class="module-btn" onclick="toggleModule('mod-services')" title="Minimer">
<span class="material-symbols-outlined collapse-icon">expand_less</span>
</button>
</div>
</div>
<div class="module-body">
<div class="bottom-row">
<div id="services-body"><div class="empty-state"><span class="material-symbols-outlined">list</span>Laster...</div></div>
<div>
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px">
<span style="font-size:13px;font-weight:500">Budsjett-status</span>
<span style="display:flex;align-items:center;gap:6px">
<span style="font-size:12px;color:var(--muted)" id="budget-month"></span>
<button class="module-btn" onclick="openBudgetModal()" title="Endre budsjett">
<span class="material-symbols-outlined">edit</span>
</button>
</span>
</div>
<div class="gauge-wrap">
<div class="gauge-ring">
<svg width="130" height="72" viewBox="0 0 130 72">
<path d="M15,65 A50,50 0 0,1 115,65" stroke="var(--surface-3)" stroke-width="10" fill="none" stroke-linecap="round"/>
<path d="M15,65 A50,50 0 0,1 115,65" stroke="var(--primary)" stroke-width="10" fill="none" stroke-linecap="round"
stroke-dasharray="157" id="gauge-arc" stroke-dashoffset="157" style="transition:stroke-dashoffset 1.2s cubic-bezier(.16,1,.3,1)"/>
</svg>
<span class="gauge-num" id="gauge-pct">0%</span>
</div>
<div>
<div style="font-family:var(--mono);font-size:13px;font-weight:600;text-align:center" id="gauge-used">kr 0,00</div>
<div class="gauge-meta" id="gauge-budget-total">av kr 500,00 budsjett</div>
<div class="gauge-meta" id="gauge-remain" style="margin-top:4px">— gjenbstår</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Anomalier (CG6) -->
<div class="module" id="mod-anomalies" draggable="true">
<div class="module-handle" ondragstart="dragStart(event,'mod-anomalies')" ondragover="dragOver(event)" ondrop="drop(event,'mod-anomalies')">
<div class="module-handle-left">
<span class="material-symbols-outlined drag-grip">drag_indicator</span>
<span class="module-title">⚡ Anomalier</span>
<span class="module-sub">Kostnadsavvik i dag vs 7d snitt</span>
</div>
<div class="module-actions">
<button class="module-btn" onclick="toggleModule('mod-anomalies')" title="Minimer">
<span class="material-symbols-outlined collapse-icon">expand_less</span>
</button>
</div>
</div>
<div class="module-body">
<div id="anomalies-body">
<div class="empty-state"><span class="material-symbols-outlined">warning</span>Laster...</div>
</div>
</div>
</div>
<!-- Anbefalinger (CG4) -->
<div class="module" id="mod-recommendations" draggable="true">
<div class="module-handle" ondragstart="dragStart(event,'mod-recommendations')" ondragover="dragOver(event)" ondrop="drop(event,'mod-recommendations')">
<div class="module-handle-left">
<span class="material-symbols-outlined drag-grip">drag_indicator</span>
<span class="module-title">💡 Anbefalinger</span>
<span class="module-sub" id="rec-savings"></span>
</div>
<div class="module-actions">
<button class="module-btn" onclick="toggleModule('mod-recommendations')" title="Minimer">
<span class="material-symbols-outlined collapse-icon">expand_less</span>
</button>
</div>
</div>
<div class="module-body">
<div id="recommendations-body">
<div class="empty-state"><span class="material-symbols-outlined">tips_and_updates</span>Laster anbefalinger...</div>
</div>
</div>
</div>
<!-- Tokenbruk (CG3e) -->
<div class="module" id="mod-tokens" draggable="true">
<div class="module-handle" ondragstart="dragStart(event,'mod-tokens')" ondragover="dragOver(event)" ondrop="drop(event,'mod-tokens')">
<div class="module-handle-left">
<span class="material-symbols-outlined drag-grip">drag_indicator</span>
<span class="module-title">⚡ Tokenbruk per agent</span>
<span class="module-sub">Siste 30 dager</span>
</div>
<div class="module-actions">
<button class="module-btn" onclick="toggleModule('mod-tokens')" title="Minimer">
<span class="material-symbols-outlined collapse-icon">expand_less</span>
</button>
</div>
</div>
<div class="module-body">
<div id="token-usage-body">
<div class="empty-state"><span class="material-symbols-outlined">bolt</span>Laster tokendata...</div>
</div>
</div>
</div>
</div><!-- /module-grid -->
</main>
</div>
<footer>
<span id="footer-txt">Henter data...</span>
&nbsp;&middot;&nbsp;
<span style="color:var(--faint)">CostGuard · Vauco · nettokostnad etter kreditter</span>
</footer>
<!-- Budget modal -->
<div id="budget-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:300;align-items:center;justify-content:center;backdrop-filter:blur(4px)">
<div style="background:var(--surface-2);padding:24px;border-radius:var(--r);border:1px solid var(--border);width:320px;display:flex;flex-direction:column;gap:16px">
<h3 style="font-size:16px;font-weight:600">Sett månedsbudsjett</h3>
<div>
<label for="budget-input" style="font-size:11px;color:var(--muted);margin-bottom:4px;display:block">Budsjett (NOK)</label>
<input id="budget-input" type="number" style="width:100%;background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:8px 10px;color:var(--text);font-family:var(--mono);font-size:14px">
</div>
<div style="display:flex;justify-content:flex-end;gap:10px">
<button onclick="closeBudgetModal()" style="background:var(--surface-3);border:1px solid var(--border);color:var(--text);padding:6px 14px;border-radius:6px;cursor:pointer">Avbryt</button>
<button id="budget-save-btn" onclick="saveBudget()" style="background:var(--primary);border:none;color:var(--bg);font-weight:600;padding:7px 16px;border-radius:6px;cursor:pointer">Lagre</button>
</div>
</div>
</div>
<script>
const API_BASE = '';
const REFRESH_MS = 5 * 60 * 1000;
const LS_BUDGET = 'cg_budget';
const LS_COLLAPSED = 'cg_collapsed';
const LS_ORDER = 'cg_order';
let currentBudget = parseFloat(localStorage.getItem(LS_BUDGET) || '500');
let barChart, donutChart, period = 30;
let currentData = null;
// TODO Fase-C: erstatt med Firestore per-bruker
const DEMO_SUMMARY = [{service:'Cloud Run',total_cost:1.82},{service:'Cloud Build',total_cost:0.61},{service:'Artifact Registry',total_cost:0.23},{service:'BigQuery',total_cost:0.18},{service:'Secret Manager',total_cost:0.09}];
const DEMO_FORECAST = {month_to_date_cost:3.00,daily_average_last_7_days:0.13,total_monthly_forecast:3.52,remaining_days_in_month:21};
const DEMO_ANOMALIES = [];
const SVC_COLORS = ['#7c5cff','#00d4a8','#ffb454','#ff5c7c','#a78bfa','#5eead4','#c4b5fd'];
function fmtNOK(v,d=2){return 'kr '+Math.abs(Number(v)||0).toLocaleString('no-NO',{minimumFractionDigits:d,maximumFractionDigits:d});}
function animN(el,end,fmt,dur=700){const t0=performance.now();const go=now=>{const p=Math.min((now-t0)/dur,1),e=1-Math.pow(1-p,3);el.textContent=fmt(e*Math.abs(end));if(p<1)requestAnimationFrame(go);};requestAnimationFrame(go);}
function serviceColor(i){return SVC_COLORS[i%SVC_COLORS.length];}
async function apiFetch(url,opts={}){const r=await fetch(url,opts);if(r.status===401){window.location.href='/auth/login';return new Promise(()=>{});}return r;}
// ── auth ──────────────────────────────────────────────────────────────────
async function checkAuth(){
try{
const r=await apiFetch(API_BASE+'/auth/me');
if(!r.ok) throw new Error('not logged in');
const u=await r.json();
document.getElementById('user-name').textContent=u.name;
document.getElementById('user-avatar').src=u.picture;
document.getElementById('user-profile').style.display='flex';
}catch(e){console.warn('auth:',e.message);}
restoreLayout();
await fetchAll();
setInterval(fetchAll,REFRESH_MS);
}
// ── budget (localStorage) ─────────────────────────────────────────────────
function openBudgetModal(){document.getElementById('budget-input').value=currentBudget;document.getElementById('budget-modal').style.display='flex';}
function closeBudgetModal(){document.getElementById('budget-modal').style.display='none';}
function saveBudget(){
const v=parseFloat(document.getElementById('budget-input').value);
if(isNaN(v)||v<=0){alert('Oppgi et gyldig beløp.');return;}
currentBudget=v;
localStorage.setItem(LS_BUDGET,v);
closeBudgetModal();
if(currentData)buildGauge(currentData.forecast.month_to_date_cost||0);
}
// ── module drag & drop ────────────────────────────────────────────────────
let dragSrc=null;
function dragStart(e,id){dragSrc=id;e.dataTransfer.effectAllowed='move';document.getElementById(id).classList.add('dragging');}
document.addEventListener('dragend',()=>{document.querySelectorAll('.module').forEach(m=>{m.classList.remove('dragging','drag-over');});saveOrder();});
function dragOver(e){e.preventDefault();e.dataTransfer.dropEffect='move';const m=e.currentTarget.closest('.module');if(m&&m.id!==dragSrc)m.classList.add('drag-over');}
function drop(e,targetId){
e.preventDefault();
if(!dragSrc||dragSrc===targetId)return;
const grid=document.getElementById('module-grid');
const src=document.getElementById(dragSrc);
const tgt=document.getElementById(targetId);
if(!src||!tgt)return;
const children=[...grid.children];
const si=children.indexOf(src),ti=children.indexOf(tgt);
if(si<ti)grid.insertBefore(src,tgt.nextSibling);
else grid.insertBefore(src,tgt);
document.querySelectorAll('.module').forEach(m=>m.classList.remove('drag-over'));
saveOrder();
}
function saveOrder(){
const ids=[...document.getElementById('module-grid').children].map(m=>m.id);
localStorage.setItem(LS_ORDER,JSON.stringify(ids));
}
function restoreLayout(){
// Restore collapsed state
const collapsed=JSON.parse(localStorage.getItem(LS_COLLAPSED)||'[]');
collapsed.forEach(id=>{const m=document.getElementById(id);if(m)m.classList.add('collapsed');});
// Restore order
const order=JSON.parse(localStorage.getItem(LS_ORDER)||'[]');
if(!order.length)return;
const grid=document.getElementById('module-grid');
order.forEach(id=>{const m=document.getElementById(id);if(m)grid.appendChild(m);});
}
function toggleModule(id){
const m=document.getElementById(id);
if(!m)return;
m.classList.toggle('collapsed');
const collapsed=[...document.querySelectorAll('.module.collapsed')].map(x=>x.id);
localStorage.setItem(LS_COLLAPSED,JSON.stringify(collapsed));
}
// ── fetch ─────────────────────────────────────────────────────────────────
async function fetchAll(){
const dot=document.getElementById('live-dot');
dot.style.background='var(--warn)';
try{
const [sr,fr,ar,hr,svcr] = await Promise.all([
apiFetch(API_BASE+'/billing/summary'),
apiFetch(API_BASE+'/billing/live'),
apiFetch(API_BASE+'/billing/anomalies'),
apiFetch(API_BASE+'/billing/history'),
apiFetch(API_BASE+'/billing/by-service'),
]);
if(!sr.ok||!fr.ok) throw new Error('Core endpoints failed');
const [s,f] = await Promise.all([sr.json(),fr.json()]);
const a = ar.ok ? await ar.json() : {anomalies:[]};
const h = hr.ok ? await hr.json() : {history:[]};
const svc = svcr.ok ? await svcr.json() : [];
currentData={summary:s.summary||[],forecast:f,anomalies:a.anomalies||[],history:h.history||[],serviceGroups:svc};
document.getElementById('err-banner').classList.remove('show');
dot.style.background='var(--ok)';
}catch(err){
console.warn('API feil, demo-data:',err.message);
currentData={summary:DEMO_SUMMARY,forecast:DEMO_FORECAST,anomalies:DEMO_ANOMALIES,history:[],serviceGroups:null};
document.getElementById('err-msg').textContent='API utilgjengelig: '+err.message;
document.getElementById('err-banner').classList.add('show');
dot.style.background='var(--red)';
}
renderAll();
fetchTokens();
fetchRecommendations();
}
// ── render ────────────────────────────────────────────────────────────────
function renderAll(){
if(!currentData)return;
const{summary,forecast,anomalies,history,serviceGroups}=currentData;
buildBar(history);
buildDonut(summary);
serviceGroups&&serviceGroups.length?renderServiceRows(serviceGroups):buildSvc(summary);
buildAnom(anomalies);
if(forecast)updateKPIs(forecast);
const ts=new Date().toLocaleTimeString('no-NO',{hour:'2-digit',minute:'2-digit'});
document.getElementById('footer-txt').textContent='Sist oppdatert '+ts;
document.getElementById('last-updated').textContent='Sist oppdatert '+ts;
const mn=new Date().toLocaleString('no-NO',{month:'long'});
document.getElementById('budget-month').textContent=mn.charAt(0).toUpperCase()+mn.slice(1)+' '+new Date().getFullYear();
}
function buildBar(history){
const ctx=document.getElementById('bar-chart').getContext('2d');
if(barChart)barChart.destroy();
if(!history||!history.length){
document.getElementById('bar-empty').style.display='flex';
document.getElementById('bar-chart').style.display='none';
return;
}
document.getElementById('bar-empty').style.display='none';
document.getElementById('bar-chart').style.display='block';
// Daglig delta fra MTD
const labels=[],costs=[];
history.forEach((e,i)=>{
const d=new Date(e.date);
labels.push(d.toLocaleDateString('no-NO',{day:'numeric',month:'short'}));
const prev=i>0&&d.getDate()!==1?(history[i-1].mtd||0):0;
costs.push(Math.max(0,(e.mtd||0)-prev));
});
barChart=new Chart(ctx,{type:'bar',data:{labels,datasets:[{label:'Kostnad',data:costs,backgroundColor:'rgba(124,92,255,.55)',borderRadius:4,borderSkipped:false}]},options:{responsive:true,maintainAspectRatio:false,plugins:{legend:{display:false},tooltip:{callbacks:{label:c=>' '+fmtNOK(c.raw)}}},scales:{x:{grid:{display:false},ticks:{color:'#3f3d3a',font:{size:10},maxTicksLimit:15}},y:{grid:{color:'rgba(255,255,255,.04)'},ticks:{color:'#3f3d3a',font:{family:'JetBrains Mono',size:10},callback:v=>'kr '+v.toFixed(2)},border:{display:false}}}}});
}
function buildDonut(summary){
const hasData=summary.some(s=>(s.daily_cost??s.total_cost??0)>0);
if(!hasData){document.getElementById('donut-empty').style.display='flex';document.getElementById('donut-chart').style.display='none';if(donutChart){donutChart.destroy();donutChart=null;}return;}
document.getElementById('donut-empty').style.display='none';document.getElementById('donut-chart').style.display='block';
const ctx=document.getElementById('donut-chart').getContext('2d');
if(donutChart)donutChart.destroy();
const top=summary.filter(s=>(s.daily_cost??s.total_cost??0)>0).slice(0,7);
donutChart=new Chart(ctx,{type:'doughnut',data:{labels:top.map(s=>s.service),datasets:[{data:top.map(s=>s.daily_cost??s.total_cost??0),backgroundColor:SVC_COLORS,borderColor:'#13131c',borderWidth:2}]},options:{responsive:true,maintainAspectRatio:false,cutout:'70%',plugins:{legend:{display:false},tooltip:{callbacks:{label:c=>' '+fmtNOK(c.raw)}}}}});
}
function renderServiceRows(data){
const el=document.getElementById('services-body');
if(!data||!data.length){el.innerHTML='<div class="empty-state"><span class="material-symbols-outlined">list</span>Ingen data</div>';return;}
const max=Math.max(...data.map(s=>s.total_cost||0),1e-6);
el.innerHTML=data.slice(0,8).map((svc,i)=>{
const hasSkus=svc.skus&&svc.skus.length>1;
const color=serviceColor(i);
const pct=((svc.total_cost||0)/max*100).toFixed(1);
const skuRows=(svc.skus||[]).map(s=>`<div class="sku-row"><span>${s.sku}</span><span>${fmtNOK(s.sku_cost)}</span></div>`).join('');
return `<div class="svc-row" data-idx="${i}" ${hasSkus?`onclick="toggleSkus(${i})"`:''}>
<div class="svc-dot" style="background:${color}"></div>
<span class="svc-name">${svc.service}</span>
<div class="svc-bar-w"><div class="svc-bar" style="width:${pct}%;background:${color}"></div></div>
<span class="svc-cost">${fmtNOK(svc.total_cost)}</span>
${hasSkus?'<span class="material-symbols-outlined svc-chevron">chevron_right</span>':''}
</div>${hasSkus?`<div class="svc-skus" id="skus-${i}">${skuRows}</div>`:''}`;}).join('');
}
function toggleSkus(i){const row=document.querySelector(`.svc-row[data-idx="${i}"]`);const skus=document.getElementById(`skus-${i}`);if(!row||!skus)return;row.classList.toggle('expanded');skus.classList.toggle('open');}
function buildSvc(summary){
const el=document.getElementById('services-body');
const top=[...summary].sort((a,b)=>(b.daily_cost??b.total_cost??0)-(a.daily_cost??a.total_cost??0)).slice(0,8);
if(!top.length){el.innerHTML='<div class="empty-state"><span class="material-symbols-outlined">list</span>Ingen data</div>';return;}
const max=Math.max(...top.map(s=>Math.abs(s.daily_cost??s.total_cost??0)),1e-6);
el.innerHTML=top.map((s,i)=>{const c=s.daily_cost??s.total_cost??0;const pct=(Math.abs(c)/max*100).toFixed(1);return `<div class="svc-row"><div class="svc-dot" style="background:${serviceColor(i)}"></div><span class="svc-name">${s.service}</span><div class="svc-bar-w"><div class="svc-bar" style="width:${pct}%;background:${serviceColor(i)}"></div></div><span class="svc-cost">${fmtNOK(c)}</span></div>`;}).join('');
}
function buildAnom(anomalies){
const tb=document.getElementById('top-badge');
const body=document.getElementById('anomalies-body');
if(!anomalies||!anomalies.length){
tb.className='tb-badge ok';tb.innerHTML='<span class="material-symbols-outlined" style="font-size:13px">check_circle</span> Ingen anomalier';
body.innerHTML='<div class="empty-state"><span class="material-symbols-outlined">check_circle</span>Ingen avvik oppdaget i dag</div>';
return;
}
const maxRatio=Math.max(...anomalies.map(a=>a.ratio||0));
if(maxRatio<2.5){tb.className='tb-badge warn';tb.innerHTML='<span class="material-symbols-outlined" style="font-size:13px">error</span> Økt bruk';}
else{tb.className='tb-badge err';tb.innerHTML='<span class="material-symbols-outlined blink" style="font-size:13px">warning</span> Kraftig avvik';}
body.innerHTML=anomalies.map(a=>{
const severity=a.ratio>4?'high':'med';
return `<div class="anom-row">
<span class="anom-badge ${severity}">×${(a.ratio||0).toFixed(1)}</span>
<span class="svc-name">${a.service}</span>
<span style="font-family:var(--mono);font-size:12px;color:var(--muted)">${fmtNOK(a.today_cost)} <span style="color:var(--faint)">vs snitt ${fmtNOK(a.avg_7d)}</span></span>
</div>`;}).join('');
}
function buildGauge(mtd){
const pct=Math.min(mtd/currentBudget,1);
const arc=document.getElementById('gauge-arc');
arc.style.stroke=pct>.8?'var(--red)':pct>.6?'var(--yellow)':'var(--blue)';
setTimeout(()=>arc.style.strokeDashoffset=157*(1-pct),80);
document.getElementById('gauge-pct').textContent=Math.round(pct*100)+'%';
document.getElementById('gauge-used').textContent=fmtNOK(mtd);
document.getElementById('gauge-budget-total').textContent=`av ${fmtNOK(currentBudget,0)} budsjett`;
const r=currentBudget-mtd;
const rel=document.getElementById('gauge-remain');
rel.textContent=r>0?fmtNOK(r)+' gjenbstår':'Budsjett overskredet';
rel.style.color=r<0?'var(--red)':'var(--muted)';
}
function updateKPIs(forecast){
const mtd=forecast.month_to_date_cost||0;
const daily=forecast.daily_average_last_7_days;
const fc=forecast.total_monthly_forecast||0;
const left=forecast.remaining_days_in_month??(31-new Date().getDate());
animN(document.getElementById('k-mtd'),mtd,v=>fmtNOK(v));
if(daily>0)animN(document.getElementById('k-daily'),daily,v=>fmtNOK(v,4));
else document.getElementById('k-daily').textContent='kr —';
animN(document.getElementById('k-fc'),fc,v=>fmtNOK(v));
document.getElementById('k-fc-d').textContent=left+' dager gjenbstår';
buildGauge(mtd);
}
function updatePeriod(n){period=n;if(currentData)buildBar(currentData.history);}
function exportCSV(){
if(!currentData||!currentData.summary)return;
const rows=currentData.summary.map(s=>`"${s.service}",${(s.daily_cost??s.total_cost??0).toFixed(2)}`);
const csv='data:text/csv;charset=utf-8,Tjeneste,Kostnad (NOK)\n'+rows.join('\n');
const a=document.createElement('a');a.href=encodeURI(csv);a.download=`costguard_${new Date().toISOString().split('T')[0]}.csv`;
document.body.appendChild(a);a.click();document.body.removeChild(a);
}
// ── anbefalinger (CG4) ────────────────────────────────────────────────────
function fetchRecommendations(){
const ICONS={high:'🔴',medium:'🟡',low:'🟢',anomaly:'⚡',waste:'♻️',budget:'💸'};
fetch(API_BASE+'/billing/recommendations')
.then(r=>r.ok?r.json():Promise.reject(r.status))
.then(data=>{
const body=document.getElementById('recommendations-body');
const sub=document.getElementById('rec-savings');
if(!data.recommendations||!data.recommendations.length){
body.innerHTML='<div class="empty-state"><span class="material-symbols-outlined">check_circle</span>Ingen anbefalinger — forbruket ser normalt ut</div>';
sub.textContent='Alt OK';
return;
}
sub.textContent=`Estimert besparelse: ${(data.total_estimated_savings_nok||0).toLocaleString('no-NO',{minimumFractionDigits:2})} kr/mnd`;
body.innerHTML=data.recommendations.map(r=>`
<div class="svc-row">
<span style="font-size:16px">${ICONS[r.severity]||'💡'}</span>
<span class="svc-name">${r.message}</span>
<span style="font-size:11px;color:var(--muted);background:var(--surface-3);padding:2px 8px;border-radius:99px;flex-shrink:0">${r.service}</span>
${r.estimated_savings_nok>0?`<span class="svc-cost">-${r.estimated_savings_nok.toLocaleString('no-NO',{minimumFractionDigits:2})} kr</span>`:''}
</div>`).join('');
})
.catch(()=>{document.getElementById('recommendations-body').innerHTML='<div class="empty-state"><span class="material-symbols-outlined">check_circle</span>Ingen anbefalinger tilgjengelig</div>';});
}
// ── tokenbruk (CG3e) ──────────────────────────────────────────────────────
function fetchTokens(){
fetch(API_BASE+'/billing/tokens/summary')
.then(r=>r.ok?r.json():Promise.reject(r.status))
.then(data=>{
const body=document.getElementById('token-usage-body');
if(!data.rows||!data.rows.length){body.innerHTML='<div class="empty-state"><span class="material-symbols-outlined">bolt</span>Ingen tokendata ennå</div>';return;}
body.innerHTML=`<table style="width:100%;border-collapse:collapse;font-size:13px">
<thead><tr style="color:var(--muted);font-size:11px;text-align:left">
<th style="padding:6px 0">Agent</th><th>Modell</th>
<th style="text-align:right">Tokens</th><th style="text-align:right">Kost (USD)</th>
</tr></thead>
<tbody>${data.rows.map(r=>`<tr style="border-top:1px solid var(--border)">
<td style="padding:7px 0">${r.agent_name}</td>
<td style="color:var(--muted)">${r.model_name}</td>
<td style="text-align:right;font-family:var(--mono)">${r.total_tokens.toLocaleString()}</td>
<td style="text-align:right;font-family:var(--mono)">$${r.total_cost_usd.toFixed(4)}</td>
</tr>`).join('')}</tbody></table>`;
})
.catch(()=>{document.getElementById('token-usage-body').innerHTML='<div class="empty-state"><span class="material-symbols-outlined">bolt</span>Ingen tokendata ennå</div>';});
}
document.addEventListener('DOMContentLoaded',checkAuth);
if('serviceWorker' in navigator){
window.addEventListener('load',()=>navigator.serviceWorker.register('/sw.js').catch(()=>{}));
}
</script>
</body>
</html>