feat(CG5): replace buildSvc with grouped renderServiceRows + SKU drill-down expander
This commit is contained in:
parent
62e742b9ba
commit
d2a4eaf4bc
|
|
@ -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)}
|
||||
.chart-wrap{position:relative;height:200px}
|
||||
.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.expanded{background:var(--surface-3);border-radius:8px;padding:8px 6px}
|
||||
.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-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}
|
||||
|
|
@ -382,8 +388,8 @@ footer{padding:10px 20px 16px;font-size:11px;color:var(--faint);text-align:cente
|
|||
|
||||
<div class="bottom-row">
|
||||
<div class="card">
|
||||
<div class="card-hdr"><span class="card-title">Kostnad per tjeneste</span><span class="card-sub">Rangert</span></div>
|
||||
<div id="svc-list"></div>
|
||||
<div class="card-hdr"><span class="card-title">Kostnad per tjeneste</span><span class="card-sub">Klikk for detaljer</span></div>
|
||||
<div id="services-body"></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-hdr">
|
||||
|
|
@ -487,7 +493,6 @@ async function apiFetch(url, options = {}) {
|
|||
}
|
||||
|
||||
function switchProvider(provider) {
|
||||
// AWS-støtte er parkert; vi holder oss på GCP
|
||||
currentProvider = 'gcp';
|
||||
localStorage.setItem('costguard_provider', 'gcp');
|
||||
document.querySelectorAll('#provider-toggle .sel-opt').forEach(btn => {
|
||||
|
|
@ -501,11 +506,9 @@ async function checkAuth() {
|
|||
const response = await apiFetch(API_BASE + '/auth/me');
|
||||
if (!response.ok) throw new Error('Not logged in');
|
||||
userProfile = await response.json();
|
||||
|
||||
document.getElementById('user-name').textContent = userProfile.name;
|
||||
document.getElementById('user-avatar').src = userProfile.picture;
|
||||
document.getElementById('user-profile').style.display = 'flex';
|
||||
|
||||
switchProvider(currentProvider);
|
||||
await fetchBudget();
|
||||
setInterval(fetchAll, REFRESH_MS);
|
||||
|
|
@ -532,7 +535,6 @@ async function saveBudget() {
|
|||
const originalText = btn.textContent;
|
||||
btn.textContent = 'Lagrer...';
|
||||
btn.disabled = true;
|
||||
|
||||
const input = document.getElementById('budget-input');
|
||||
const newBudgetValue = parseFloat(input.value);
|
||||
if (isNaN(newBudgetValue) || newBudgetValue <= 0) {
|
||||
|
|
@ -541,7 +543,6 @@ async function saveBudget() {
|
|||
btn.disabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await apiFetch(API_BASE + '/billing/budget', {
|
||||
method: 'POST',
|
||||
|
|
@ -573,98 +574,87 @@ function closeBudgetModal() {
|
|||
async function fetchAll(){
|
||||
const dot=document.getElementById('live-dot');
|
||||
dot.style.background='var(--warn)';
|
||||
|
||||
try {
|
||||
const gcpSummary = apiFetch(API_BASE+'/billing/summary');
|
||||
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 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: [] };
|
||||
if (gcpHR.ok) {
|
||||
try {
|
||||
gcpH = await gcpHR.json();
|
||||
} catch (e) {
|
||||
console.warn('Could not parse history JSON:', e.message);
|
||||
}
|
||||
} else {
|
||||
console.warn('History endpoint returned', gcpHR.status);
|
||||
}
|
||||
if (gcpHR.ok) { try { gcpH = await gcpHR.json(); } catch(e){ console.warn('history parse error',e); } }
|
||||
|
||||
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');
|
||||
dot.style.background='var(--ok)';
|
||||
} catch(err) {
|
||||
console.warn('API feil, demo-data:',err.message);
|
||||
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-banner').classList.add('show');
|
||||
dot.style.background='var(--red)';
|
||||
}
|
||||
|
||||
renderAll();
|
||||
}
|
||||
|
||||
function getVisibleData() {
|
||||
// AWS er parkert; all visning baseres på GCP-data
|
||||
return currentData.gcp;
|
||||
}
|
||||
|
||||
function calculateDailyCosts(history) {
|
||||
if (!history || history.length === 0) {
|
||||
return { labels: [], costs: [] };
|
||||
}
|
||||
|
||||
const dailyCosts = [];
|
||||
const labels = [];
|
||||
|
||||
if (!history || history.length === 0) return { labels: [], costs: [] };
|
||||
const dailyCosts = [], labels = [];
|
||||
for (let i = 0; i < history.length; i++) {
|
||||
const entry = history[i];
|
||||
const currentMtd = entry.mtd || 0;
|
||||
let dailyCost;
|
||||
|
||||
const entryDate = new Date(entry.date);
|
||||
if (entryDate.getDate() === 1) {
|
||||
dailyCost = currentMtd;
|
||||
} else {
|
||||
const prevDayEntry = history[i-1];
|
||||
const prevMtd = prevDayEntry ? prevDayEntry.mtd : 0;
|
||||
dailyCost = currentMtd - prevMtd;
|
||||
const prevMtd = (i > 0 && entryDate.getDate() !== 1) ? (history[i-1].mtd || 0) : 0;
|
||||
dailyCosts.push(Math.max(0, currentMtd - prevMtd));
|
||||
labels.push(entryDate.toLocaleDateString('no-NO', {day:'numeric',month:'short'}));
|
||||
}
|
||||
|
||||
dailyCosts.push(Math.max(0, dailyCost));
|
||||
labels.push(entryDate.toLocaleDateString('no-NO', {day: 'numeric', month: 'short'}));
|
||||
}
|
||||
|
||||
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(){
|
||||
const data = getVisibleData();
|
||||
if (!data) return;
|
||||
|
||||
const{summary,forecast,anomalies,history}=data;
|
||||
|
||||
const {summary, forecast, anomalies, history, serviceGroups} = data;
|
||||
const { labels, costs } = calculateDailyCosts(history);
|
||||
|
||||
buildBar(labels, costs);
|
||||
buildDonut(summary);
|
||||
// CG5: bruk serviceGroups fra /billing/by-service hvis tilgjengelig, ellers fallback til summary
|
||||
if (serviceGroups && serviceGroups.length) {
|
||||
renderServiceRows(serviceGroups);
|
||||
} else {
|
||||
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'});
|
||||
document.getElementById('footer-txt').textContent='Sist oppdatert '+ts;
|
||||
document.getElementById('last-updated').textContent='Sist oppdatert '+ts;
|
||||
|
|
@ -675,24 +665,18 @@ function renderAll(){
|
|||
function buildBar(labels, costs){
|
||||
const ctx=document.getElementById('bar-chart').getContext('2d');
|
||||
if(barChart)barChart.destroy();
|
||||
|
||||
if (!costs || costs.length === 0) {
|
||||
document.getElementById('bar-empty').style.display = 'flex';
|
||||
document.getElementById('bar-chart').style.display = 'none';
|
||||
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';
|
||||
|
||||
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){
|
||||
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){
|
||||
document.getElementById('donut-empty').style.display='flex';
|
||||
document.getElementById('donut-chart').style.display='none';
|
||||
|
|
@ -703,31 +687,63 @@ function buildDonut(summary){
|
|||
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 ?? 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}]},
|
||||
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)}}}}});
|
||||
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: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)}}}}});
|
||||
}
|
||||
|
||||
// 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){
|
||||
const el=document.getElementById('svc-list');
|
||||
const top=[...summary].sort((a,b)=>{
|
||||
const costA=a.daily_cost ?? a.total_cost ?? a.cost ?? 0;
|
||||
const costB=b.daily_cost ?? b.total_cost ?? b.cost ?? 0;
|
||||
return costB-costA;
|
||||
const el = document.getElementById('services-body');
|
||||
const top = [...summary].sort((a,b) => {
|
||||
return (b.daily_cost??b.total_cost??b.cost??0) - (a.daily_cost??a.total_cost??a.cost??0);
|
||||
}).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>';
|
||||
return;
|
||||
}
|
||||
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)=>{
|
||||
const c=s.daily_cost ?? s.total_cost ?? s.cost ?? 0;
|
||||
const pct=(Math.abs(c)/max*100).toFixed(1);
|
||||
return`<div class="svc-row">
|
||||
<div class="svc-dot" style="background:${['#7c5cff','#00d4a8','#ffb454','#ff5c7c','#a78bfa','#5eead4','#c4b5fd'][i%7]}"></div>
|
||||
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) => {
|
||||
const c = s.daily_cost??s.total_cost??s.cost??0;
|
||||
const pct = (Math.abs(c)/max*100).toFixed(1);
|
||||
const color = serviceColor(s.service, i);
|
||||
return `<div class="svc-row" data-idx="${i}">
|
||||
<div class="svc-dot" style="background:${color}"></div>
|
||||
<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>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
|
@ -736,15 +752,12 @@ function buildSvc(summary){
|
|||
function buildAnom(anomalies){
|
||||
const tb=document.getElementById('top-badge');
|
||||
if(!tb) return;
|
||||
|
||||
if(!anomalies || !anomalies.length){
|
||||
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';
|
||||
return;
|
||||
}
|
||||
|
||||
const maxRatio=Math.max(...anomalies.map(a=>a.ratio||0));
|
||||
|
||||
if(maxRatio<1.5){
|
||||
tb.className='tb-badge ok';
|
||||
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);
|
||||
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`;
|
||||
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)+' gjenstår':'Budsjett overskredet';
|
||||
|
|
@ -775,13 +788,10 @@ function updateKPIs(forecast,anomCount){
|
|||
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());
|
||||
const left=forecast.remaining_days_in_month??(31-new Date().getDate());
|
||||
animN(document.getElementById('k-mtd'),mtd,v=>fmtNOK(v));
|
||||
if (daily !== null && daily > 0) {
|
||||
animN(document.getElementById('k-daily'),daily,v=>fmtNOK(v, 4));
|
||||
} else {
|
||||
document.getElementById('k-daily').textContent = 'kr —';
|
||||
}
|
||||
if(daily!==null&&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 gjenstår';
|
||||
buildGauge(mtd);
|
||||
|
|
@ -790,51 +800,42 @@ function updateKPIs(forecast,anomCount){
|
|||
function updatePeriod(n){
|
||||
period=n;
|
||||
document.getElementById('period-lbl').textContent=n+' dager';
|
||||
const data = getVisibleData();
|
||||
if (data && data.history) {
|
||||
const { labels, costs } = calculateDailyCosts(data.history);
|
||||
buildBar(labels, costs);
|
||||
const data=getVisibleData();
|
||||
if(data&&data.history){
|
||||
const{labels,costs}=calculateDailyCosts(data.history);
|
||||
buildBar(labels,costs);
|
||||
}
|
||||
}
|
||||
|
||||
function exportCSV() {
|
||||
const data = getVisibleData();
|
||||
if (!data || !data.summary) return;
|
||||
|
||||
const headers = "Tjeneste,Kostnad (NOK)";
|
||||
const rows = data.summary.map(service => {
|
||||
const cost = (service.daily_cost ?? service.total_cost ?? service.cost ?? 0).toFixed(2);
|
||||
const data=getVisibleData();
|
||||
if(!data||!data.summary) return;
|
||||
const headers="Tjeneste,Kostnad (NOK)";
|
||||
const rows=data.summary.map(service=>{
|
||||
const cost=(service.daily_cost??service.total_cost??service.cost??0).toFixed(2);
|
||||
return `"${service.service}",${cost}`;
|
||||
});
|
||||
|
||||
const csvContent = "data:text/csv;charset=utf-8," + [headers, ...rows].join("\n");
|
||||
|
||||
const encodedUri = encodeURI(csvContent);
|
||||
const link = document.createElement("a");
|
||||
link.setAttribute("href", encodedUri);
|
||||
const date = new Date().toISOString().split('T')[0];
|
||||
link.setAttribute("download", `costguard_export_${date}.csv`);
|
||||
const csvContent="data:text/csv;charset=utf-8,"+[headers,...rows].join("\n");
|
||||
const link=document.createElement("a");
|
||||
link.setAttribute("href",encodeURI(csvContent));
|
||||
link.setAttribute("download",`costguard_export_${new Date().toISOString().split('T')[0]}.csv`);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
|
||||
function exportPDF() {
|
||||
window.print();
|
||||
}
|
||||
|
||||
function exportPDF() { window.print(); }
|
||||
|
||||
document.addEventListener('DOMContentLoaded', checkAuth);
|
||||
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker.register('/sw.js').then(registration => {
|
||||
console.log('ServiceWorker registration successful with scope: ', registration.scope);
|
||||
}, err => {
|
||||
console.log('ServiceWorker registration failed: ', err);
|
||||
});
|
||||
navigator.serviceWorker.register('/sw.js').then(r=>{
|
||||
console.log('ServiceWorker registered:', r.scope);
|
||||
}, err=>console.log('ServiceWorker failed:', err));
|
||||
});
|
||||
}
|
||||
|
||||
// CG4: hent anbefalinger
|
||||
(function(){
|
||||
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 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>`: ''}
|
||||
${r.estimated_savings_nok>0?`<span class="svc-cost">-${r.estimated_savings_nok.toLocaleString('no-NO',{minimumFractionDigits:2})} kr</span>`:''}
|
||||
</div>
|
||||
`).join('');
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user