feat(CG5): replace buildSvc with grouped renderServiceRows + SKU drill-down expander

This commit is contained in:
chrischristiansen-glitch 2026-06-09 23:31:09 +02:00
parent 62e742b9ba
commit d2a4eaf4bc

View File

@ -86,13 +86,19 @@ body{font-family:var(--font);background:var(--bg);color:var(--text);font-size:14
.card-sub{font-size:12px;color:var(--muted)} .card-sub{font-size:12px;color:var(--muted)}
.chart-wrap{position:relative;height:200px} .chart-wrap{position:relative;height:200px}
.bottom-row{display:grid;grid-template-columns:2fr 1fr;gap:12px} .bottom-row{display:grid;grid-template-columns:2fr 1fr;gap:12px}
.svc-row{display:flex;align-items:center;gap:10px;padding:8px 0;border-bottom:1px solid var(--border)} .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-row:last-child{border-bottom:none}
.svc-row.expanded{background:var(--surface-3);border-radius:8px;padding:8px 6px}
.svc-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0} .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-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-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-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-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-wrap{display:flex;flex-direction:column;align-items:center;gap:12px;padding:8px 0} .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{position:relative;width:130px;height:72px;overflow:hidden}
.gauge-ring svg{position:absolute;top:0;left:0} .gauge-ring svg{position:absolute;top:0;left:0}
@ -382,8 +388,8 @@ footer{padding:10px 20px 16px;font-size:11px;color:var(--faint);text-align:cente
<div class="bottom-row"> <div class="bottom-row">
<div class="card"> <div class="card">
<div class="card-hdr"><span class="card-title">Kostnad per tjeneste</span><span class="card-sub">Rangert</span></div> <div class="card-hdr"><span class="card-title">Kostnad per tjeneste</span><span class="card-sub">Klikk for detaljer</span></div>
<div id="svc-list"></div> <div id="services-body"></div>
</div> </div>
<div class="card"> <div class="card">
<div class="card-hdr"> <div class="card-hdr">
@ -487,7 +493,6 @@ async function apiFetch(url, options = {}) {
} }
function switchProvider(provider) { function switchProvider(provider) {
// AWS-støtte er parkert; vi holder oss på GCP
currentProvider = 'gcp'; currentProvider = 'gcp';
localStorage.setItem('costguard_provider', 'gcp'); localStorage.setItem('costguard_provider', 'gcp');
document.querySelectorAll('#provider-toggle .sel-opt').forEach(btn => { document.querySelectorAll('#provider-toggle .sel-opt').forEach(btn => {
@ -501,11 +506,9 @@ async function checkAuth() {
const response = await apiFetch(API_BASE + '/auth/me'); const response = await apiFetch(API_BASE + '/auth/me');
if (!response.ok) throw new Error('Not logged in'); if (!response.ok) throw new Error('Not logged in');
userProfile = await response.json(); userProfile = await response.json();
document.getElementById('user-name').textContent = userProfile.name; document.getElementById('user-name').textContent = userProfile.name;
document.getElementById('user-avatar').src = userProfile.picture; document.getElementById('user-avatar').src = userProfile.picture;
document.getElementById('user-profile').style.display = 'flex'; document.getElementById('user-profile').style.display = 'flex';
switchProvider(currentProvider); switchProvider(currentProvider);
await fetchBudget(); await fetchBudget();
setInterval(fetchAll, REFRESH_MS); setInterval(fetchAll, REFRESH_MS);
@ -532,7 +535,6 @@ async function saveBudget() {
const originalText = btn.textContent; const originalText = btn.textContent;
btn.textContent = 'Lagrer...'; btn.textContent = 'Lagrer...';
btn.disabled = true; btn.disabled = true;
const input = document.getElementById('budget-input'); const input = document.getElementById('budget-input');
const newBudgetValue = parseFloat(input.value); const newBudgetValue = parseFloat(input.value);
if (isNaN(newBudgetValue) || newBudgetValue <= 0) { if (isNaN(newBudgetValue) || newBudgetValue <= 0) {
@ -541,7 +543,6 @@ async function saveBudget() {
btn.disabled = false; btn.disabled = false;
return; return;
} }
try { try {
const response = await apiFetch(API_BASE + '/billing/budget', { const response = await apiFetch(API_BASE + '/billing/budget', {
method: 'POST', method: 'POST',
@ -573,98 +574,87 @@ function closeBudgetModal() {
async function fetchAll(){ async function fetchAll(){
const dot=document.getElementById('live-dot'); const dot=document.getElementById('live-dot');
dot.style.background='var(--warn)'; dot.style.background='var(--warn)';
try { try {
const gcpSummary = apiFetch(API_BASE+'/billing/summary'); const gcpSummary = apiFetch(API_BASE+'/billing/summary');
const gcpForecast = apiFetch(API_BASE+'/billing/live'); const gcpForecast = apiFetch(API_BASE+'/billing/live');
const gcpAnomalies = apiFetch(API_BASE+'/billing/anomalies'); const gcpAnomalies= apiFetch(API_BASE+'/billing/anomalies');
const gcpHistory = apiFetch(API_BASE+'/billing/history'); const gcpHistory = apiFetch(API_BASE+'/billing/history');
const gcpServices = apiFetch(API_BASE+'/billing/by-service');
const [gcpSR, gcpLR, gcpAR, gcpHR] = await Promise.all([gcpSummary, gcpForecast, gcpAnomalies, gcpHistory]); const [gcpSR,gcpLR,gcpAR,gcpHR,gcpSvcR] = await Promise.all([gcpSummary,gcpForecast,gcpAnomalies,gcpHistory,gcpServices]);
// GCP summary/forecast/anomalies er kritiske; history er nice-to-have if (!gcpSR.ok || !gcpLR.ok || !gcpAR.ok) throw new Error('HTTP error on core billing endpoints');
if (!gcpSR.ok || !gcpLR.ok || !gcpAR.ok) {
throw new Error('HTTP error on core billing endpoints');
}
const [gcpS, gcpL, gcpA] = await Promise.all([gcpSR.json(), gcpLR.json(), gcpAR.json()]); const [gcpS,gcpL,gcpA] = await Promise.all([gcpSR.json(),gcpLR.json(),gcpAR.json()]);
let gcpH = { history: [] }; let gcpH = { history: [] };
if (gcpHR.ok) { if (gcpHR.ok) { try { gcpH = await gcpHR.json(); } catch(e){ console.warn('history parse error',e); } }
try {
gcpH = await gcpHR.json();
} catch (e) {
console.warn('Could not parse history JSON:', e.message);
}
} else {
console.warn('History endpoint returned', gcpHR.status);
}
currentData.gcp = { summary: gcpS.summary || [], forecast: gcpL, anomalies: gcpA.anomalies || [], history: gcpH.history || [] }; let gcpSvcData = null;
if (gcpSvcR.ok) { try { gcpSvcData = await gcpSvcR.json(); } catch(e){ console.warn('by-service parse error',e); } }
currentData.gcp = {
summary: gcpS.summary || [],
forecast: gcpL,
anomalies: gcpA.anomalies || [],
history: gcpH.history || [],
serviceGroups: gcpSvcData || null
};
document.getElementById('err-banner').classList.remove('show'); document.getElementById('err-banner').classList.remove('show');
dot.style.background='var(--ok)'; dot.style.background='var(--ok)';
} catch(err) { } catch(err) {
console.warn('API feil, demo-data:',err.message); console.warn('API feil, demo-data:',err.message);
if (err.message.includes("Not logged in")) return; if (err.message.includes("Not logged in")) return;
currentData.gcp = { summary: DEMO_SUMMARY, forecast: DEMO_FORECAST, anomalies: DEMO_ANOMALIES, history: [] }; currentData.gcp = { summary: DEMO_SUMMARY, forecast: DEMO_FORECAST, anomalies: DEMO_ANOMALIES, history: [], serviceGroups: null };
document.getElementById('err-msg').textContent='API utilgjengelig: '+err.message+' — demo-data vises.'; document.getElementById('err-msg').textContent='API utilgjengelig: '+err.message+' — demo-data vises.';
document.getElementById('err-banner').classList.add('show'); document.getElementById('err-banner').classList.add('show');
dot.style.background='var(--red)'; dot.style.background='var(--red)';
} }
renderAll(); renderAll();
} }
function getVisibleData() { function getVisibleData() {
// AWS er parkert; all visning baseres på GCP-data
return currentData.gcp; return currentData.gcp;
} }
function calculateDailyCosts(history) { function calculateDailyCosts(history) {
if (!history || history.length === 0) { if (!history || history.length === 0) return { labels: [], costs: [] };
return { labels: [], costs: [] }; const dailyCosts = [], labels = [];
}
const dailyCosts = [];
const labels = [];
for (let i = 0; i < history.length; i++) { for (let i = 0; i < history.length; i++) {
const entry = history[i]; const entry = history[i];
const currentMtd = entry.mtd || 0; const currentMtd = entry.mtd || 0;
let dailyCost;
const entryDate = new Date(entry.date); const entryDate = new Date(entry.date);
if (entryDate.getDate() === 1) { const prevMtd = (i > 0 && entryDate.getDate() !== 1) ? (history[i-1].mtd || 0) : 0;
dailyCost = currentMtd; dailyCosts.push(Math.max(0, currentMtd - prevMtd));
} else { labels.push(entryDate.toLocaleDateString('no-NO', {day:'numeric',month:'short'}));
const prevDayEntry = history[i-1];
const prevMtd = prevDayEntry ? prevDayEntry.mtd : 0;
dailyCost = currentMtd - prevMtd;
} }
dailyCosts.push(Math.max(0, dailyCost));
labels.push(entryDate.toLocaleDateString('no-NO', {day: 'numeric', month: 'short'}));
}
return { labels, costs: dailyCosts }; return { labels, costs: dailyCosts };
} }
const SVC_COLORS = ['#7c5cff','#00d4a8','#ffb454','#ff5c7c','#a78bfa','#5eead4','#c4b5fd'];
function serviceColor(name, i) {
if (i !== undefined) return SVC_COLORS[i % SVC_COLORS.length];
let h = 0;
for (let c of name) h = (h * 31 + c.charCodeAt(0)) & 0xffffffff;
return SVC_COLORS[Math.abs(h) % SVC_COLORS.length];
}
function renderAll(){ function renderAll(){
const data = getVisibleData(); const data = getVisibleData();
if (!data) return; if (!data) return;
const {summary, forecast, anomalies, history, serviceGroups} = data;
const{summary,forecast,anomalies,history}=data;
const { labels, costs } = calculateDailyCosts(history); const { labels, costs } = calculateDailyCosts(history);
buildBar(labels, costs); buildBar(labels, costs);
buildDonut(summary); buildDonut(summary);
// CG5: bruk serviceGroups fra /billing/by-service hvis tilgjengelig, ellers fallback til summary
if (serviceGroups && serviceGroups.length) {
renderServiceRows(serviceGroups);
} else {
buildSvc(summary); buildSvc(summary);
buildAnom(anomalies);
if (forecast) {
updateKPIs(forecast, anomalies.length);
} }
buildAnom(anomalies);
if (forecast) updateKPIs(forecast, anomalies.length);
const ts = new Date().toLocaleTimeString('no-NO',{hour:'2-digit',minute:'2-digit'}); const ts = new Date().toLocaleTimeString('no-NO',{hour:'2-digit',minute:'2-digit'});
document.getElementById('footer-txt').textContent='Sist oppdatert '+ts; document.getElementById('footer-txt').textContent='Sist oppdatert '+ts;
document.getElementById('last-updated').textContent='Sist oppdatert '+ts; document.getElementById('last-updated').textContent='Sist oppdatert '+ts;
@ -675,24 +665,18 @@ function renderAll(){
function buildBar(labels, costs){ function buildBar(labels, costs){
const ctx=document.getElementById('bar-chart').getContext('2d'); const ctx=document.getElementById('bar-chart').getContext('2d');
if(barChart)barChart.destroy(); if(barChart)barChart.destroy();
if (!costs || costs.length === 0) { if (!costs || costs.length === 0) {
document.getElementById('bar-empty').style.display = 'flex'; document.getElementById('bar-empty').style.display='flex';
document.getElementById('bar-chart').style.display = 'none'; document.getElementById('bar-chart').style.display='none';
return; return;
} }
document.getElementById('bar-empty').style.display='none'; document.getElementById('bar-empty').style.display='none';
document.getElementById('bar-chart').style.display='block'; document.getElementById('bar-chart').style.display='block';
barChart=new Chart(ctx,{type:'bar',data:{labels,datasets:[{label:'Kostnad',data:costs,backgroundColor:'rgba(124,92,255,0.55)',borderRadius:4,borderSkipped:false}]},options:{responsive:true,maintainAspectRatio:false,plugins:{legend:{display:false},tooltip:{backgroundColor:'rgba(22,21,19,.95)',borderColor:'rgba(255,255,255,.08)',borderWidth:1,titleColor:'#7a7874',bodyColor:'#e8e6e2',titleFont:{family:'Inter',size:11},bodyFont:{family:'JetBrains Mono',size:12,weight:'600'},callbacks:{label:c=>' '+fmtNOK(c.raw,2)}}},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}}}}});
barChart=new Chart(ctx,{type:'bar',data:{labels,datasets:[
{label:'Kostnad',data:costs,backgroundColor:'rgba(124,92,255,0.55)',borderRadius:4,borderSkipped:false}
]},options:{responsive:true,maintainAspectRatio:false,
plugins:{legend:{display:false},tooltip:{backgroundColor:'rgba(22,21,19,.95)',borderColor:'rgba(255,255,255,.08)',borderWidth:1,titleColor:'#7a7874',bodyColor:'#e8e6e2',titleFont:{family:'Inter',size:11},bodyFont:{family:'JetBrains Mono',size:12,weight:'600'},callbacks:{label:c=>' '+fmtNOK(c.raw,2)}}},
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){ function buildDonut(summary){
const hasData=summary.some(s=>(s.daily_cost ?? s.total_cost ?? s.cost ?? 0)>0); const hasData=summary.some(s=>(s.daily_cost??s.total_cost??s.cost??0)>0);
if(!hasData){ if(!hasData){
document.getElementById('donut-empty').style.display='flex'; document.getElementById('donut-empty').style.display='flex';
document.getElementById('donut-chart').style.display='none'; document.getElementById('donut-chart').style.display='none';
@ -703,31 +687,63 @@ function buildDonut(summary){
document.getElementById('donut-chart').style.display='block'; document.getElementById('donut-chart').style.display='block';
const ctx=document.getElementById('donut-chart').getContext('2d'); const ctx=document.getElementById('donut-chart').getContext('2d');
if(donutChart)donutChart.destroy(); if(donutChart)donutChart.destroy();
const top=summary.filter(s=>(s.daily_cost ?? s.total_cost ?? s.cost ?? 0)>0).slice(0,7); const top=summary.filter(s=>(s.daily_cost??s.total_cost??s.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 ?? s.cost ?? 0),backgroundColor:['#7c5cff','#00d4a8','#ffb454','#ff5c7c','#a78bfa','#5eead4','#c4b5fd'],borderColor:'#13131c',borderWidth:2,hoverBorderWidth:3}]}, donutChart=new Chart(ctx,{type:'doughnut',data:{labels:top.map(s=>s.service),datasets:[{data:top.map(s=>s.daily_cost??s.total_cost??s.cost??0),backgroundColor:SVC_COLORS,borderColor:'#13131c',borderWidth:2,hoverBorderWidth:3}]},options:{responsive:true,maintainAspectRatio:false,cutout:'70%',plugins:{legend:{display:false},tooltip:{backgroundColor:'rgba(22,27,34,.97)',borderColor:'rgba(139,148,158,.15)',borderWidth:1,titleColor:'#8b949e',bodyColor:'#e6edf3',callbacks:{label:c=>' '+fmtNOK(c.raw)}}}}});
options:{responsive:true,maintainAspectRatio:false,cutout:'70%',
plugins:{legend:{display:false},tooltip:{backgroundColor:'rgba(22,27,34,.97)',borderColor:'rgba(139,148,158,.15)',borderWidth:1,titleColor:'#8b949e',bodyColor:'#e6edf3',callbacks:{label:c=>' '+fmtNOK(c.raw)}}}}});
} }
// CG5: ny grouped render med SKU drill-down
function renderServiceRows(data) {
const container = document.getElementById('services-body');
if (!data || !data.length) {
container.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), 0.000001);
container.innerHTML = data.slice(0,8).map((svc, i) => {
const hasSkus = svc.skus && svc.skus.length > 1;
const color = serviceColor(svc.service, 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');
}
// Fallback: brukes når /billing/by-service ikke er tilgjengelig
function buildSvc(summary){ function buildSvc(summary){
const el=document.getElementById('svc-list'); const el = document.getElementById('services-body');
const top=[...summary].sort((a,b)=>{ const top = [...summary].sort((a,b) => {
const costA=a.daily_cost ?? a.total_cost ?? a.cost ?? 0; return (b.daily_cost??b.total_cost??b.cost??0) - (a.daily_cost??a.total_cost??a.cost??0);
const costB=b.daily_cost ?? b.total_cost ?? b.cost ?? 0;
return costB-costA;
}).slice(0,8); }).slice(0,8);
if(!top.length){ if (!top.length) {
el.innerHTML='<div class="empty-state"><span class="material-symbols-outlined">list</span>Ingen data</div>'; el.innerHTML='<div class="empty-state"><span class="material-symbols-outlined">list</span>Ingen data</div>';
return; return;
} }
const max=Math.max(...top.map(s=>Math.abs(s.daily_cost ?? s.total_cost ?? s.cost ?? 0)),0.000001); const max = Math.max(...top.map(s=>Math.abs(s.daily_cost??s.total_cost??s.cost??0)),0.000001);
el.innerHTML=top.map((s,i)=>{ el.innerHTML = top.map((s,i) => {
const c=s.daily_cost ?? s.total_cost ?? s.cost ?? 0; const c = s.daily_cost??s.total_cost??s.cost??0;
const pct=(Math.abs(c)/max*100).toFixed(1); const pct = (Math.abs(c)/max*100).toFixed(1);
return`<div class="svc-row"> const color = serviceColor(s.service, i);
<div class="svc-dot" style="background:${['#7c5cff','#00d4a8','#ffb454','#ff5c7c','#a78bfa','#5eead4','#c4b5fd'][i%7]}"></div> return `<div class="svc-row" data-idx="${i}">
<div class="svc-dot" style="background:${color}"></div>
<span class="svc-name">${s.service}</span> <span class="svc-name">${s.service}</span>
<div class="svc-bar-w"><div class="svc-bar" style="width:${pct}%;background:${['#7c5cff','#00d4a8','#ffb454','#ff5c7c','#a78bfa','#5eead4','#c4b5fd'][i%7]}"></div></div> <div class="svc-bar-w"><div class="svc-bar" style="width:${pct}%;background:${color}"></div></div>
<span class="svc-cost">${fmtNOK(c)}</span> <span class="svc-cost">${fmtNOK(c)}</span>
</div>`; </div>`;
}).join(''); }).join('');
@ -736,15 +752,12 @@ function buildSvc(summary){
function buildAnom(anomalies){ function buildAnom(anomalies){
const tb=document.getElementById('top-badge'); const tb=document.getElementById('top-badge');
if(!tb) return; if(!tb) return;
if(!anomalies||!anomalies.length){
if(!anomalies || !anomalies.length){
tb.className='tb-badge ok'; tb.className='tb-badge ok';
tb.innerHTML='<span class="material-symbols-outlined" style="font-size:13px">check_circle</span> Ingen anomalier'; tb.innerHTML='<span class="material-symbols-outlined" style="font-size:13px">check_circle</span> Ingen anomalier';
return; return;
} }
const maxRatio=Math.max(...anomalies.map(a=>a.ratio||0)); const maxRatio=Math.max(...anomalies.map(a=>a.ratio||0));
if(maxRatio<1.5){ if(maxRatio<1.5){
tb.className='tb-badge ok'; tb.className='tb-badge ok';
tb.innerHTML='<span class="material-symbols-outlined" style="font-size:13px">check_circle</span> Mild økning, innenfor normalen'; tb.innerHTML='<span class="material-symbols-outlined" style="font-size:13px">check_circle</span> Mild økning, innenfor normalen';
@ -764,7 +777,7 @@ function buildGauge(mtd){
setTimeout(()=>arc.style.strokeDashoffset=173*(1-pct),80); setTimeout(()=>arc.style.strokeDashoffset=173*(1-pct),80);
document.getElementById('gauge-pct').textContent=Math.round(pct*100)+'%'; document.getElementById('gauge-pct').textContent=Math.round(pct*100)+'%';
document.getElementById('gauge-used').textContent=fmtNOK(mtd); document.getElementById('gauge-used').textContent=fmtNOK(mtd);
document.getElementById('gauge-budget-total').textContent = `av ${fmtNOK(currentBudget, 0)} budsjett`; document.getElementById('gauge-budget-total').textContent=`av ${fmtNOK(currentBudget,0)} budsjett`;
const r=currentBudget-mtd; const r=currentBudget-mtd;
const rel=document.getElementById('gauge-remain'); const rel=document.getElementById('gauge-remain');
rel.textContent=r>0?fmtNOK(r)+' gjenstår':'Budsjett overskredet'; rel.textContent=r>0?fmtNOK(r)+' gjenstår':'Budsjett overskredet';
@ -775,13 +788,10 @@ function updateKPIs(forecast,anomCount){
const mtd=forecast.month_to_date_cost||0; const mtd=forecast.month_to_date_cost||0;
const daily=forecast.daily_average_last_7_days; const daily=forecast.daily_average_last_7_days;
const fc=forecast.total_monthly_forecast||0; const fc=forecast.total_monthly_forecast||0;
const left=forecast.remaining_days_in_month ?? (31-new Date().getDate()); const left=forecast.remaining_days_in_month??(31-new Date().getDate());
animN(document.getElementById('k-mtd'),mtd,v=>fmtNOK(v)); animN(document.getElementById('k-mtd'),mtd,v=>fmtNOK(v));
if (daily !== null && daily > 0) { if(daily!==null&&daily>0){animN(document.getElementById('k-daily'),daily,v=>fmtNOK(v,4));}
animN(document.getElementById('k-daily'),daily,v=>fmtNOK(v, 4)); else{document.getElementById('k-daily').textContent='kr —';}
} else {
document.getElementById('k-daily').textContent = 'kr —';
}
animN(document.getElementById('k-fc'),fc,v=>fmtNOK(v)); animN(document.getElementById('k-fc'),fc,v=>fmtNOK(v));
document.getElementById('k-fc-d').textContent=left+' dager gjenstår'; document.getElementById('k-fc-d').textContent=left+' dager gjenstår';
buildGauge(mtd); buildGauge(mtd);
@ -790,51 +800,42 @@ function updateKPIs(forecast,anomCount){
function updatePeriod(n){ function updatePeriod(n){
period=n; period=n;
document.getElementById('period-lbl').textContent=n+' dager'; document.getElementById('period-lbl').textContent=n+' dager';
const data = getVisibleData(); const data=getVisibleData();
if (data && data.history) { if(data&&data.history){
const { labels, costs } = calculateDailyCosts(data.history); const{labels,costs}=calculateDailyCosts(data.history);
buildBar(labels, costs); buildBar(labels,costs);
} }
} }
function exportCSV() { function exportCSV() {
const data = getVisibleData(); const data=getVisibleData();
if (!data || !data.summary) return; if(!data||!data.summary) return;
const headers="Tjeneste,Kostnad (NOK)";
const headers = "Tjeneste,Kostnad (NOK)"; const rows=data.summary.map(service=>{
const rows = data.summary.map(service => { const cost=(service.daily_cost??service.total_cost??service.cost??0).toFixed(2);
const cost = (service.daily_cost ?? service.total_cost ?? service.cost ?? 0).toFixed(2);
return `"${service.service}",${cost}`; return `"${service.service}",${cost}`;
}); });
const csvContent="data:text/csv;charset=utf-8,"+[headers,...rows].join("\n");
const csvContent = "data:text/csv;charset=utf-8," + [headers, ...rows].join("\n"); const link=document.createElement("a");
link.setAttribute("href",encodeURI(csvContent));
const encodedUri = encodeURI(csvContent); link.setAttribute("download",`costguard_export_${new Date().toISOString().split('T')[0]}.csv`);
const link = document.createElement("a");
link.setAttribute("href", encodedUri);
const date = new Date().toISOString().split('T')[0];
link.setAttribute("download", `costguard_export_${date}.csv`);
document.body.appendChild(link); document.body.appendChild(link);
link.click(); link.click();
document.body.removeChild(link); document.body.removeChild(link);
} }
function exportPDF() { function exportPDF() { window.print(); }
window.print();
}
document.addEventListener('DOMContentLoaded', checkAuth); document.addEventListener('DOMContentLoaded', checkAuth);
if ('serviceWorker' in navigator) { if ('serviceWorker' in navigator) {
window.addEventListener('load', () => { window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').then(registration => { navigator.serviceWorker.register('/sw.js').then(r=>{
console.log('ServiceWorker registration successful with scope: ', registration.scope); console.log('ServiceWorker registered:', r.scope);
}, err => { }, err=>console.log('ServiceWorker failed:', err));
console.log('ServiceWorker registration failed: ', err);
});
}); });
} }
// CG4: hent anbefalinger // CG4: hent anbefalinger
(function(){ (function(){
const ICONS = {high:'🔴', medium:'🟡', low:'🟢', anomaly:'⚡', waste:'♻️', budget:'💸'}; const ICONS = {high:'🔴', medium:'🟡', low:'🟢', anomaly:'⚡', waste:'♻️', budget:'💸'};
@ -854,7 +855,7 @@ if ('serviceWorker' in navigator) {
<span style="font-size:16px">${ICONS[r.severity]||'💡'}</span> <span style="font-size:16px">${ICONS[r.severity]||'💡'}</span>
<span class="svc-name">${r.message}</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> <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>`: ''} ${r.estimated_savings_nok>0?`<span class="svc-cost">-${r.estimated_savings_nok.toLocaleString('no-NO',{minimumFractionDigits:2})} kr</span>`:''}
</div> </div>
`).join(''); `).join('');
}) })