pelagia-portal/Wireframe/reports-mockup.html

829 lines
53 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="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PPMS · Reports — UX Mockup</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
primary: { 50:"#eff6ff",100:"#dbeafe",500:"#3b82f6",600:"#2563eb",700:"#1d4ed8" },
neutral: { 50:"#fafafa",100:"#f5f5f5",200:"#e5e5e5",300:"#d4d4d4",400:"#a3a3a3",500:"#737373",600:"#525252",700:"#404040",800:"#262626",900:"#171717" },
},
},
},
};
</script>
<style>
body { font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; }
.nav-active { background:#eff6ff; color:#1d4ed8; }
::-webkit-scrollbar { width:8px; height:8px; }
::-webkit-scrollbar-thumb { background:#d4d4d4; border-radius:4px; }
</style>
</head>
<body class="bg-neutral-50 text-neutral-900">
<div class="flex h-screen overflow-hidden">
<!-- ───────────────────────── Sidebar ───────────────────────── -->
<aside class="flex h-screen w-60 shrink-0 flex-col border-r border-neutral-200 bg-white">
<div class="flex h-16 items-center gap-2.5 border-b border-neutral-200 px-4">
<div class="flex h-8 w-8 items-center justify-center rounded-lg bg-primary-600">
<svg class="h-4 w-4 text-white" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="22" x2="12" y2="8"/><path d="M5 12H2a10 10 0 0 0 20 0h-3"/><circle cx="12" cy="5" r="3"/></svg>
</div>
<span class="text-sm font-semibold text-neutral-900">PPMS</span>
</div>
<nav class="flex-1 overflow-y-auto px-3 py-4 space-y-0.5 text-sm">
<a class="flex items-center gap-3 rounded-md px-3 py-2 font-medium text-neutral-400 cursor-default">
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="9"/><rect x="14" y="3" width="7" height="5"/><rect x="14" y="12" width="7" height="9"/><rect x="3" y="16" width="7" height="5"/></svg>
Dashboard
</a>
<a class="flex items-center gap-3 rounded-md px-3 py-2 font-medium text-neutral-400 cursor-default">
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
Closed Purchase Orders
</a>
<div class="pt-4 pb-1 px-3"><p class="text-xs font-semibold text-neutral-400 uppercase tracking-wider">Reports</p></div>
<a onclick="go('cc')" id="nav-cc" class="nav-link flex items-center gap-3 rounded-md px-3 py-2 font-medium text-neutral-600 hover:bg-neutral-100 cursor-pointer">
<svg class="h-4 w-4 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22V8"/><path d="M5 12H2a10 10 0 0 0 20 0h-3"/><circle cx="12" cy="5" r="3"/></svg>
Cost Centres
</a>
<a onclick="go('ac')" id="nav-ac" class="nav-link flex items-center gap-3 rounded-md px-3 py-2 font-medium text-neutral-600 hover:bg-neutral-100 cursor-pointer">
<svg class="h-4 w-4 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21h18"/><path d="M6 21V8l6-4 6 4v13"/><path d="M9 21v-6h6v6"/></svg>
Accounting Codes
</a>
<div class="pt-4 pb-1 px-3"><p class="text-xs font-semibold text-neutral-400 uppercase tracking-wider">Administration</p></div>
<a class="flex items-center gap-3 rounded-md px-3 py-2 font-medium text-neutral-400 cursor-default">
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/></svg>
Users
</a>
</nav>
</aside>
<!-- ───────────────────────── Main ───────────────────────── -->
<main class="flex-1 overflow-y-auto">
<header class="sticky top-0 z-30 flex h-16 items-center justify-between border-b border-neutral-200 bg-white/90 px-8 backdrop-blur">
<div class="flex items-center gap-2 text-sm text-neutral-500">
<span>Reports</span>
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>
<span id="crumb-section" class="text-neutral-900 font-medium">Cost Centres</span>
<span id="crumb-detail-wrap" class="hidden items-center gap-2">
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>
<span id="crumb-detail" class="text-neutral-900 font-medium"></span>
</span>
</div>
<div class="h-8 w-8 rounded-full bg-neutral-200"></div>
</header>
<!-- ── PINNED FILTER TOOLBAR ── -->
<div class="sticky top-16 z-20 border-b border-neutral-200 bg-neutral-50/95 px-8 py-3 backdrop-blur">
<div class="flex flex-wrap items-center gap-4">
<div class="flex items-center gap-2">
<span class="text-xs font-semibold uppercase tracking-wider text-neutral-400">Granularity</span>
<div class="inline-flex rounded-lg border border-neutral-200 bg-white p-0.5 text-sm">
<button data-gran="yearly" class="gran-btn rounded-md px-3 py-1 font-medium text-neutral-500">Yearly</button>
<button data-gran="monthly" class="gran-btn rounded-md px-3 py-1 font-medium text-neutral-500">Monthly</button>
<button data-gran="weekly" class="gran-btn rounded-md px-3 py-1 font-medium text-neutral-500">Weekly</button>
</div>
</div>
<div id="fy-wrap" class="flex items-center gap-2">
<span class="text-xs font-semibold uppercase tracking-wider text-neutral-400">Financial Year</span>
<select id="fy-select" onchange="render()" class="rounded-lg border border-neutral-200 bg-white px-3 py-1.5 text-sm font-medium text-neutral-700 focus:border-primary-600 focus:outline-none">
<option value="2025">FY 202526</option>
<option value="2024">FY 202425</option>
<option value="2023">FY 202324</option>
</select>
</div>
<div id="fyscope-wrap" class="hidden items-center gap-2">
<span class="text-xs font-semibold uppercase tracking-wider text-neutral-400">Years</span>
<select id="fyscope-select" onchange="onFyScopeChange(this.value)" class="rounded-lg border border-neutral-200 bg-white px-3 py-1.5 text-sm font-medium text-neutral-700 focus:border-primary-600 focus:outline-none">
<option value="all">All years</option>
<option value="top2">Top 2</option>
<option value="last2">Last 2</option>
<option value="custom">Custom…</option>
</select>
</div>
<div id="month-wrap" class="hidden items-center gap-2">
<span class="text-xs font-semibold uppercase tracking-wider text-neutral-400">Month</span>
<select id="month-select" onchange="render()" class="rounded-lg border border-neutral-200 bg-white px-3 py-1.5 text-sm font-medium text-neutral-700 focus:border-primary-600 focus:outline-none"></select>
</div>
<div id="scope-wrap" class="flex items-center gap-2">
<span id="scope-label" class="text-xs font-semibold uppercase tracking-wider text-neutral-400">Show</span>
<select id="scope-select" onchange="onScopeChange(this.value)" class="rounded-lg border border-neutral-200 bg-white px-3 py-1.5 text-sm font-medium text-neutral-700 focus:border-primary-600 focus:outline-none">
<option value="top5">Top 5</option>
<option value="top10">Top 10</option>
<option value="last5">Bottom 5</option>
<option value="all">All</option>
<option value="custom">Custom…</option>
</select>
</div>
<div class="ml-auto flex items-center gap-2">
<button id="addgraph-btn" onclick="goCustom()" class="hidden items-center gap-1.5 rounded-lg bg-primary-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-primary-700">
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
<span id="addgraph-label">Add to graph</span>
</button>
<button class="inline-flex items-center gap-1.5 rounded-lg border border-neutral-200 bg-white px-3 py-1.5 text-sm font-medium text-neutral-600 hover:bg-neutral-50">
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
Export
</button>
</div>
</div>
</div>
<!-- ════════════ INDEX VIEW ════════════ -->
<section id="view-index" class="px-8 py-6">
<button id="idx-back" onclick="idxBack()" class="mb-4 hidden items-center gap-1.5 text-sm font-medium text-primary-600 hover:text-primary-700">
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg>
<span id="idx-back-label">Back</span>
</button>
<div class="mb-6 flex items-start justify-between gap-4">
<div>
<div class="flex items-center gap-3">
<h1 id="idx-title" class="text-2xl font-semibold text-neutral-900">Cost Centres</h1>
<span id="idx-badge" class="hidden rounded-full px-2.5 py-0.5 text-xs font-medium"></span>
</div>
<p id="idx-sub" class="mt-1 text-sm text-neutral-500"></p>
</div>
</div>
<!-- Custom (years) selection panel -->
<div id="scope-panel" class="mb-6 hidden rounded-lg border border-dashed border-neutral-300 bg-white p-4"></div>
<div id="kpi-strip" class="mb-6 grid grid-cols-2 gap-4 sm:grid-cols-4"></div>
<div class="mb-6 rounded-lg border border-neutral-200 bg-white p-5">
<div class="mb-4 flex items-center justify-between">
<p id="cmp-title" class="text-sm font-semibold text-neutral-900">Spend by cost centre</p>
<span id="cmp-period" class="text-xs text-neutral-400">FY 202526</span>
</div>
<div style="height:340px"><canvas id="chart-compare"></canvas></div>
</div>
<!-- Cost-centre flat table -->
<div id="cc-table-wrap" class="overflow-hidden rounded-lg border border-neutral-200 bg-white">
<table class="w-full text-sm">
<thead class="border-b border-neutral-200 bg-neutral-50 text-left text-xs font-semibold uppercase tracking-wider text-neutral-400">
<tr>
<th class="px-5 py-3" id="th-name">Cost Centre</th>
<th class="px-5 py-3">Trend</th>
<th class="px-5 py-3 text-right">Total Spend</th>
<th class="px-5 py-3 text-right" id="th-pct">% of Total</th>
<th class="px-5 py-3 text-right" id="th-count">POs</th>
<th class="px-5 py-3"></th>
</tr>
</thead>
<tbody id="index-rows" class="divide-y divide-neutral-100"></tbody>
</table>
</div>
<!-- Accounting-code accordion tree -->
<div id="ac-accordion" class="hidden"></div>
</section>
<!-- ════════════ DETAIL VIEW ════════════ -->
<section id="view-detail" class="hidden px-8 py-6">
<button onclick="backFromDetail()" class="mb-4 inline-flex items-center gap-1.5 text-sm font-medium text-primary-600 hover:text-primary-700">
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg>
<span id="back-label">Back</span>
</button>
<div class="mb-6 flex items-start justify-between">
<div>
<div class="flex items-center gap-3">
<h1 id="det-title" class="text-2xl font-semibold text-neutral-900"></h1>
<span id="det-badge" class="rounded-full px-2.5 py-0.5 text-xs font-medium"></span>
</div>
<p id="det-sub" class="mt-1 text-sm text-neutral-500"></p>
</div>
</div>
<div id="det-kpis" class="mb-6 grid grid-cols-2 gap-4 sm:grid-cols-4"></div>
<div class="mb-6 rounded-lg border border-neutral-200 bg-white p-5">
<div class="mb-4 flex items-center justify-between">
<p id="trend-title" class="text-sm font-semibold text-neutral-900">Spend trend</p>
<span id="trend-gran" class="text-xs text-neutral-400"></span>
</div>
<div style="height:300px"><canvas id="chart-trend"></canvas></div>
</div>
<div class="rounded-lg border border-neutral-200 bg-white p-5">
<div class="mb-4 flex flex-wrap items-center justify-between gap-3">
<p id="topn-title" class="text-sm font-semibold text-neutral-900">Top accounting codes</p>
<div id="topn-controls" class="flex flex-wrap items-center gap-2"></div>
</div>
<div class="grid grid-cols-1 gap-6 lg:grid-cols-5">
<div class="lg:col-span-3" style="height:300px"><canvas id="chart-topn"></canvas></div>
<div class="lg:col-span-2">
<table class="w-full text-sm">
<thead class="border-b border-neutral-200 text-left text-xs font-semibold uppercase tracking-wider text-neutral-400">
<tr><th class="py-2" id="topn-th"></th><th class="py-2 text-right">Spend</th><th class="py-2 text-right">%</th></tr>
</thead>
<tbody id="topn-rows" class="divide-y divide-neutral-100"></tbody>
</table>
</div>
</div>
</div>
</section>
</main>
</div>
<script>
/* ════════════════════════════════════════════════════════════════
MOCK DATA
════════════════════════════════════════════════════════════════ */
const COST_CENTRES = [
{ id:"v1", name:"MV Pelagia Star", type:"Vessel" },
{ id:"v2", name:"MV Ocean Dawn", type:"Vessel" },
{ id:"v3", name:"MV Coral Trident", type:"Vessel" },
{ id:"v4", name:"MV Northern Light", type:"Vessel" },
{ id:"s1", name:"Mumbai HQ", type:"Site" },
{ id:"s2", name:"Kharghar Office", type:"Site" },
{ id:"s3", name:"Chennai Depot", type:"Site" },
];
// 3-tier chart of accounts (Account.parentId hierarchy: Heading → Sub-heading → Leaf)
const ACCOUNTS = [
// ── Headings (tier 1) ──
{ id:"5000", code:"5000", name:"Operating Expenses", parent:null, tier:"Heading" },
{ id:"6000", code:"6000", name:"Administrative Expenses", parent:null, tier:"Heading" },
// ── Sub-headings (tier 2) ──
{ id:"5100", code:"5100", name:"Vessel Running Costs", parent:"5000", tier:"Sub-heading" },
{ id:"5200", code:"5200", name:"Crew Costs", parent:"5000", tier:"Sub-heading" },
{ id:"5300", code:"5300", name:"Port & Logistics", parent:"5000", tier:"Sub-heading" },
{ id:"6100", code:"6100", name:"Office & IT", parent:"6000", tier:"Sub-heading" },
{ id:"6200", code:"6200", name:"Professional Fees", parent:"6000", tier:"Sub-heading" },
// ── Leaves (tier 3) — these carry actual PO spend ──
{ id:"5110", code:"5110", name:"Fuel & Lubricants", parent:"5100", tier:"Leaf" },
{ id:"5120", code:"5120", name:"Spares & Repairs", parent:"5100", tier:"Leaf" },
{ id:"5130", code:"5130", name:"Lube Oils", parent:"5100", tier:"Leaf" },
{ id:"5210", code:"5210", name:"Provisions & Stores", parent:"5200", tier:"Leaf" },
{ id:"5220", code:"5220", name:"Crew Welfare", parent:"5200", tier:"Leaf" },
{ id:"5230", code:"5230", name:"Safety Equipment", parent:"5200", tier:"Leaf" },
{ id:"5310", code:"5310", name:"Port & Canal Dues", parent:"5300", tier:"Leaf" },
{ id:"5320", code:"5320", name:"Freight & Cartage", parent:"5300", tier:"Leaf" },
{ id:"6110", code:"6110", name:"IT & Communications", parent:"6100", tier:"Leaf" },
{ id:"6120", code:"6120", name:"Office Supplies", parent:"6100", tier:"Leaf" },
{ id:"6210", code:"6210", name:"Audit & Legal", parent:"6200", tier:"Leaf" },
{ id:"6220", code:"6220", name:"Consultancy", parent:"6200", tier:"Leaf" },
];
const ACC_BY_ID = Object.fromEntries(ACCOUNTS.map(a=>[a.id,a]));
const acc = (id)=> ACC_BY_ID[id];
const childrenOf = (id)=> ACCOUNTS.filter(a=>a.parent===id); // id=null → headings
const isLeaf = (id)=> childrenOf(id).length===0;
function leavesUnder(id){ const k=childrenOf(id); return k.length? k.flatMap(c=>leavesUnder(c.id)) : [id]; }
function pathTo(id){ if(!id) return []; return [...pathTo(acc(id).parent), id]; }
const LEAF_IDS = ACCOUNTS.filter(a=>a.tier==="Leaf").map(a=>a.id);
const FYS = ["2023","2024","2025"];
const FY_LABEL = { "2023":"FY 202324","2024":"FY 202425","2025":"FY 202526" };
const MONTHS = ["Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec","Jan","Feb","Mar"];
function seeded(a){ return function(){ a|=0; a=a+0x6D2B79F5|0; let t=Math.imul(a^a>>>15,1|a); t=t+Math.imul(t^t>>>7,61|t)^t; return ((t^t>>>14)>>>0)/4294967296; }; }
const rnd = seeded(42);
const ccW={}; COST_CENTRES.forEach(c=> ccW[c.id]=0.5+rnd()*1.6);
const acW={}; LEAF_IDS.forEach(l=> acW[l]=0.4+rnd()*1.8);
// DATA[ccId][leafId][fy] = [12 months]
const DATA={};
COST_CENTRES.forEach(cc=>{ DATA[cc.id]={};
LEAF_IDS.forEach(lf=>{ DATA[cc.id][lf]={};
FYS.forEach((fy,fi)=>{ const growth=1+fi*0.18; const arr=[];
for(let m=0;m<12;m++){ const seasonal=1+0.35*Math.sin((m/12)*Math.PI*2+ccW[cc.id]);
let v=6*ccW[cc.id]*acW[lf]*growth*seasonal*(0.7+rnd()*0.6);
if(rnd()<0.18) v*=0.05; arr.push(Math.round(v*10)/10); }
DATA[cc.id][lf][fy]=arr; }); }); });
/* ── helpers ──────────────────────────────────────────────── */
const r1 = n=>Math.round(n*10)/10;
const fmt = n => "₹"+n.toLocaleString("en-IN",{maximumFractionDigits:1})+"L";
const fmtShort = n => n>=100 ? "₹"+(n/100).toFixed(1)+"Cr" : "₹"+n.toFixed(0)+"L";
const shorten=(s,n=14)=> s.length>n? s.slice(0,n-1)+"…":s;
const WEEKS=52;
// full-FY trend series (used by the detail trend chart — unaffected by month/week focus)
function ccTotalFY(cc,fy){ let s=0; LEAF_IDS.forEach(lf=>DATA[cc][lf][fy].forEach(v=>s+=v)); return r1(s); }
function ccMonthly(cc,fy){ return MONTHS.map((_,m)=>{ let s=0; LEAF_IDS.forEach(lf=>s+=DATA[cc][lf][fy][m]); return r1(s); }); }
function ccYearly(cc){ return FYS.map(fy=>ccTotalFY(cc,fy)); }
function ccWeeksOfMonth(cc,fy){ return weeksOfMonth(ccMonthly(cc,fy)[state.month]); }
function nodeTotalFY(id,fy){ let s=0; const lv=leavesUnder(id); COST_CENTRES.forEach(cc=>lv.forEach(lf=>DATA[cc.id][lf][fy].forEach(v=>s+=v))); return r1(s); }
function nodeMonthly(id,fy){ const lv=leavesUnder(id); return MONTHS.map((_,m)=>{ let s=0; lv.forEach(lf=>COST_CENTRES.forEach(cc=>s+=DATA[cc.id][lf][fy][m])); return r1(s); }); }
function nodeYearly(id){ return FYS.map(fy=>nodeTotalFY(id,fy)); }
function nodeWeeksOfMonth(id,fy){ return weeksOfMonth(nodeMonthly(id,fy)[state.month]); }
// Weekly granularity = the weeks of ONE month. Split a month's value into ~4 weekly buckets
// (deterministic noise, normalised so the weeks sum back to the month total).
const WEEKS_PER_MONTH=4;
function weeksOfMonth(monthVal){ const w=seeded(7); const p=[]; let t=0;
for(let k=0;k<WEEKS_PER_MONTH;k++){ const x=0.6+w()*0.8; p.push(x); t+=x; }
return p.map(x=>r1(monthVal*x/t)); }
function weekOfMonthLabels(){ return Array.from({length:WEEKS_PER_MONTH},(_,k)=>`${MONTHS[state.month]} W${k+1}`); }
/* ── focus period ── Monthly → whole FY (all 12 months shown) · Weekly → the selected month */
function fyMonthYear(fy,mIdx){ return (+fy) + (mIdx>=9 ? 1 : 0); } // AprDec → start yr, JanMar → +1
function monthLabel(fy,mIdx){ return `${MONTHS[mIdx]} '${String(fyMonthYear(fy,mIdx)).slice(2)}`; }
function periodLabel(){ return state.gran==="weekly" ? `${monthLabel(state.fy,state.month)} · ${FY_LABEL[state.fy]}` : FY_LABEL[state.fy]; }
// per-leaf spend for the snapshot scope (KPIs / top-N / accordion totals)
function leafSpend(lf,cc,fy){ return state.gran==="weekly" ? DATA[cc][lf][fy][state.month] : DATA[cc][lf][fy].reduce((a,b)=>a+b,0); }
function ccPeriod(cc,fy){ let s=0; LEAF_IDS.forEach(lf=>s+=leafSpend(lf,cc,fy)); return r1(s); }
function nodePeriodCC(id,cc,fy){ let s=0; leavesUnder(id).forEach(lf=>s+=leafSpend(lf,cc,fy)); return s; }
function nodePeriod(id,fy){ let s=0; COST_CENTRES.forEach(cc=>s+=nodePeriodCC(id,cc.id,fy)); return r1(s); }
// breakdowns (period-aware via leafSpend)
function topCCsForNode(id,fy){ return COST_CENTRES.map(cc=>({label:cc.name, value:r1(nodePeriodCC(id,cc.id,fy))})).sort((a,b)=>b.value-a.value); }
function childBreakdown(id,fy){ return childrenOf(id).map(c=>({label:`${c.code} · ${c.name}`, value:nodePeriod(c.id,fy)})).sort((a,b)=>b.value-a.value); }
function topAccountsForCC(cc,fy,tier){ return ACCOUNTS.filter(a=>a.tier===tier).map(a=>({label:`${a.code} · ${a.name}`, value:r1(nodePeriodCC(a.id,cc,fy))})).sort((a,b)=>b.value-a.value); }
/* ════════════════════════════════════════════════════════════════
STATE + RENDER
════════════════════════════════════════════════════════════════ */
const COLORS=["#2563eb","#16a34a","#9333ea","#ea580c","#0891b2","#dc2626","#ca8a04","#4f46e5"];
let state={ section:"cc", gran:"monthly", fy:"2025", view:"index", itemId:null,
month:6, topn:5, ccTier:"Leaf", breakMode:"children",
acPath:[], // accounting drill-down path (ancestor ids)
sel:new Set(), // selected ids for "Add to graph" → custom view
scopeMode:"top5", // entity scope: top5/top10/last5/all
fyScopeMode:"all", fyCustom:new Set(FYS) }; // financial-year scope (yearly mode)
let charts={};
const destroy=id=>{ if(charts[id]){ charts[id].destroy(); delete charts[id]; } };
function tierBadge(tier){
const m={ "Heading":"bg-primary-50 text-primary-700","Sub-heading":"bg-violet-50 text-violet-700","Leaf":"bg-neutral-100 text-neutral-600",
"Vessel":"bg-primary-50 text-primary-700","Site":"bg-emerald-50 text-emerald-700" };
return `<span class="rounded-full px-2 py-0.5 text-xs font-medium ${m[tier]||'bg-neutral-100 text-neutral-600'}">${tier}</span>`;
}
function seg(name,opts,cur){
return `<div class="inline-flex rounded-lg border border-neutral-200 bg-white p-0.5 text-xs">`+
opts.map(o=>`<button data-seg="${name}" data-val="${o.val}" class="seg-btn rounded-md px-2.5 py-1 font-medium ${String(o.val)===String(cur)?'bg-primary-600 text-white':'text-neutral-500 hover:text-neutral-800'}">${o.label}</button>`).join('')+`</div>`;
}
/* ── scope: Top N / Last N for entities (current page) + financial years ── */
function fyGrand(fy){ let s=0; COST_CENTRES.forEach(cc=>LEAF_IDS.forEach(lf=>DATA[cc.id][lf][fy].forEach(v=>s+=v))); return s; }
function acGraphLabel(node){ return node.tier==="Leaf" ? node.code : shorten(node.name,18); } // names for heading/sub, codes for leaf
function scopeLabel(){ return {top5:"Top 5",top10:"Top 10",last5:"Bottom 5",all:"All"}[state.scopeMode]; }
// current accounting drill level: parent whose children we compare (null = top-level headings)
function currentParent(){ return state.acPath.length ? state.acPath[state.acPath.length-1] : null; }
function levelChildren(){ return childrenOf(currentParent()); }
// the entities compared on the page you're at right now
function scopedEntities(isCC){ const fy=state.fy;
if(state.view==="custom"){
return [...state.sel].map(id=> isCC ? COST_CENTRES.find(c=>c.id===id) : acc(id)).filter(Boolean)
.map(it=>({it, rank: isCC?ccPeriod(it.id,fy):nodePeriod(it.id,fy)})).sort((a,b)=>b.rank-a.rank);
}
let pool = isCC ? COST_CENTRES.map(c=>({it:c, rank:ccPeriod(c.id,fy)}))
: levelChildren().map(n=>({it:n, rank:nodePeriod(n.id,fy)}));
pool.sort((a,b)=>b.rank-a.rank);
if(state.scopeMode==="top5") return pool.slice(0,5);
if(state.scopeMode==="top10") return pool.slice(0,10);
if(state.scopeMode==="last5") return pool.slice(-5).reverse();
return pool;
}
function scopedFYs(){
if(state.fyScopeMode==="custom"){ const s=FYS.filter(f=>state.fyCustom.has(f)); return s.length?s:FYS; }
if(state.fyScopeMode==="top2") return [...FYS].sort((a,b)=>fyGrand(b)-fyGrand(a)).slice(0,2).sort();
if(state.fyScopeMode==="last2") return FYS.slice(-2);
return FYS;
}
function onScopeChange(v){ state.scopeMode=v; render(); }
function onFyScopeChange(v){ state.fyScopeMode=v;
if(v==="custom" && state.fyCustom.size===0) FYS.forEach(f=>state.fyCustom.add(f));
render(); }
function toggleCustomFy(fy){ state.fyCustom.has(fy)?state.fyCustom.delete(fy):state.fyCustom.add(fy); render(); }
/* ── drill-down navigation (accounting) + selection / add-to-graph ── */
function acDrill(id){ state.acPath.push(id); state.view="index"; render(); }
function acRowClick(id){ isLeaf(id) ? openItem(id) : acDrill(id); }
function idxBack(){
if(state.view==="custom") state.view="index";
else if(state.section==="ac" && state.acPath.length) state.acPath.pop();
render();
}
function toggleSel(id){ state.sel.has(id)?state.sel.delete(id):state.sel.add(id);
if(state.view==="custom" && state.sel.size===0) state.view="index"; render(); }
function goCustom(){ if(state.sel.size) { state.view="custom"; render(); } }
function chip(active,label,onclick){
return `<button onclick="${onclick}" class="rounded-full border px-2.5 py-1 text-xs font-medium ${active?'border-primary-600 bg-primary-50 text-primary-700':'border-neutral-200 bg-white text-neutral-500 hover:bg-neutral-50'}">${active?'✓ ':''}${label}</button>`;
}
function renderScopePanel(){
const el=document.getElementById("scope-panel");
const fyCustom=state.view==="index"&&state.gran==="yearly"&&state.fyScopeMode==="custom";
if(!fyCustom){ el.classList.add("hidden"); return; }
el.classList.remove("hidden");
el.innerHTML = `<p class="mb-2 text-xs font-semibold uppercase tracking-wider text-neutral-400">Pick financial years</p>
<div class="flex flex-wrap gap-2">`+FYS.map(f=>chip(state.fyCustom.has(f),FY_LABEL[f],`toggleCustomFy('${f}')`)).join("")+`</div>`;
}
function setScopeUI(){
document.getElementById("scope-select").value=state.scopeMode;
document.getElementById("fyscope-select").value=state.fyScopeMode;
// Add-to-graph button reflects current selection
const btn=document.getElementById("addgraph-btn"), custom=state.view==="custom";
btn.style.display = (state.sel.size>0 && !custom) ? "inline-flex" : "none";
document.getElementById("addgraph-label").textContent = `Add to graph (${state.sel.size})`;
}
function setGranUI(){
document.querySelectorAll(".gran-btn").forEach(b=>{ const on=b.dataset.gran===state.gran;
b.className="gran-btn rounded-md px-3 py-1 font-medium "+(on?"bg-primary-600 text-white shadow-sm":"text-neutral-500 hover:text-neutral-800"); });
const yearly=state.gran==="yearly", weekly=state.gran==="weekly", scoped=state.view!=="detail"&&state.view!=="custom";
document.getElementById("fy-wrap").style.display = yearly ? "none" : "flex";
document.getElementById("fyscope-wrap").style.display = yearly ? "flex" : "none";
document.getElementById("month-wrap").style.display = weekly ? "flex" : "none";
document.getElementById("scope-wrap").style.display = scoped ? "flex" : "none"; // Top/Last applies to a browsable page only
populateMonths();
}
function populateMonths(){
const sel=document.getElementById("month-select");
sel.innerHTML = MONTHS.map((m,i)=>`<option value="${i}">${monthLabel(state.fy,i)}</option>`).join("");
sel.value=String(state.month);
}
function setNavUI(){
document.querySelectorAll(".nav-link").forEach(n=>n.classList.remove("nav-active"));
document.getElementById(state.section==="cc"?"nav-cc":"nav-ac").classList.add("nav-active");
}
document.querySelectorAll(".gran-btn").forEach(b=> b.onclick=()=>{ state.gran=b.dataset.gran; render(); });
/* navigation */
function go(section){ state.section=section; state.view="index"; state.itemId=null; state.acPath=[]; state.sel.clear(); render(); }
function openItem(id){ state.view="detail"; state.itemId=id; state.topn=5; state.breakMode="children";
if(state.section==="ac") state.acPath=pathTo(acc(id).parent); // so Back returns to this code's level
render(); }
function backFromDetail(){ state.view="index"; state.itemId=null; render(); }
function render(){
state.fy=document.getElementById("fy-select").value;
const ms=document.getElementById("month-select");
if(state.gran==="weekly" && ms && ms.value!=="") state.month=+ms.value; // pick up a new month choice
setGranUI(); setScopeUI(); setNavUI();
const isCC=state.section==="cc";
document.getElementById("crumb-section").textContent=isCC?"Cost Centres":"Accounting Codes";
if(state.view==="detail"){ document.getElementById("scope-panel").classList.add("hidden"); renderDetail(isCC); }
else { renderScopePanel(); renderIndex(isCC); } // "index" or "custom"
}
/* ── INDEX (also serves drill levels + custom comparison) ────── */
function renderIndex(isCC){
document.getElementById("view-index").classList.remove("hidden");
document.getElementById("view-detail").classList.add("hidden");
document.getElementById("crumb-detail-wrap").classList.add("hidden");
document.getElementById("idx-badge").classList.add("hidden");
const fy=state.fy, yearly=state.gran==="yearly", weekly=state.gran==="weekly", custom=state.view==="custom";
const useTable = isCC && !custom;
document.getElementById("cc-table-wrap").classList.toggle("hidden", !useTable);
document.getElementById("ac-accordion").classList.toggle("hidden", useTable);
const parent = (!isCC && !custom) ? currentParent() : null;
const pNode = parent ? acc(parent) : null;
// ── Back button + title/sub ──
const backBtn=document.getElementById("idx-back");
if(custom){ backBtn.style.display="inline-flex"; document.getElementById("idx-back-label").textContent="Back to browse"; }
else if(!isCC && state.acPath.length){ backBtn.style.display="inline-flex";
document.getElementById("idx-back-label").textContent = state.acPath.length>1 ? `Back to ${acc(state.acPath[state.acPath.length-2]).name}` : "Back to Accounting Codes"; }
else backBtn.style.display="none";
// entities compared on this page (current level / custom set)
const ents=scopedEntities(isCC);
let rows=ents.map(e=>({ it:e.it, total: isCC?ccPeriod(e.it.id,fy):nodePeriod(e.it.id,fy), series: isCC?ccYearly(e.it.id):nodeYearly(e.it.id) }));
rows.sort((a,b)=>b.total-a.total);
const labelFn = isCC ? (r=>shorten(r.it.name,14)) : (r=>acGraphLabel(r.it));
const childTier = (!isCC && !custom) ? (levelChildren()[0]?.tier || "Heading") : null;
let title, sub;
if(custom){ title="Custom comparison"; sub=`Graphing ${rows.length} selected ${isCC?'cost centres':'accounting codes'} together. Use the ✕ to remove, or Back to add more.`; }
else if(isCC){ title="Cost Centres"; sub="Spend comparison across cost centres. Click a row for its report, or tick rows and Add to graph."; }
else if(!pNode){ title="Accounting Codes"; sub="Comparing top-level headings. Click a heading to drill into its sub-headings, or tick rows and Add to graph."; }
else { title=`${pNode.code} · ${pNode.name}`; sub=`Comparing the ${childTier.toLowerCase()}s of ${pNode.name}. Click a row to ${childTier==="Leaf"?"open its report":"drill deeper"}, or tick rows and Add to graph.`; }
document.getElementById("idx-title").textContent=title;
document.getElementById("idx-sub").textContent=sub;
if(useTable){ document.getElementById("th-name").textContent="Cost Centre"; document.getElementById("th-pct").textContent="% of Shown"; document.getElementById("th-count").textContent="POs"; }
const grand=rows.reduce((s,r)=>s+r.total,0);
const top=rows[0]||{it:{name:"—",code:""},total:0,series:[0,0,0]};
const curT=rows.reduce((s,r)=>s+(r.series[2]||0),0), prevT=rows.reduce((s,r)=>s+(r.series[1]||0),0);
const yoy=prevT?((curT-prevT)/prevT*100):0;
const scLabel = custom? `${rows.length} selected` : scopeLabel();
const kpiCount = custom? "Selected" : isCC? "Cost centres" : pNode? `${childTier}s` : "Headings";
document.getElementById("kpi-strip").innerHTML=[
kpi("Total spend", yearly? fmtShort(rows.reduce((s,r)=>s+scopedFYs().reduce((a,f)=>a+r.series[FYS.indexOf(f)],0),0)) : fmtShort(grand), yearly?scopedFYs().map(f=>FY_LABEL[f].replace("FY ","")).join(", "):periodLabel()),
kpi(kpiCount, String(rows.length), custom?"in this graph":scLabel+" shown"),
kpi("Highest spender", shorten(isCC?top.it.name:`${top.it.code} ${top.it.name}`,16), fmtShort(top.total)),
kpi("YoY change",(yoy>=0?"+":"")+yoy.toFixed(1)+"%","vs prior FY",yoy>=0),
].join("");
const what = isCC? "cost centre" : custom? "code" : (childTier||"heading").toLowerCase();
const base = yearly ? `Spend by ${what} — year over year`
: weekly ? `Weekly spend by ${what}${monthLabel(fy,state.month)}`
: `Monthly spend by ${what}`;
document.getElementById("cmp-title").textContent = `${base} · ${scLabel}`;
document.getElementById("cmp-period").textContent = yearly? scopedFYs().map(f=>FY_LABEL[f]).join(" · ") : periodLabel();
destroy("compare");
const ctx=document.getElementById("chart-compare");
if(yearly){
const fys=scopedFYs();
charts.compare=new Chart(ctx,{ type:"bar",
data:{ labels:rows.map(labelFn),
datasets:fys.map(f=>{ const i=FYS.indexOf(f); return { label:FY_LABEL[f], data:rows.map(r=>r.series[i]), backgroundColor:COLORS[i], borderRadius:3 }; }) },
options:barOpts(true) });
} else {
const labels = weekly? weekOfMonthLabels() : MONTHS;
const datasets = rows.map((r,i)=>{
const data = isCC ? (weekly? ccWeeksOfMonth(r.it.id,fy) : ccMonthly(r.it.id,fy))
: (weekly? nodeWeeksOfMonth(r.it.id,fy) : nodeMonthly(r.it.id,fy));
const c=COLORS[i%COLORS.length];
return { label: isCC? shorten(r.it.name,18) : acGraphLabel(r.it),
data, borderColor:c, backgroundColor:c, tension:0.35, borderWidth:2, pointRadius: weekly?3:2, pointHoverRadius:5 };
});
charts.compare=new Chart(ctx,{ type:"line", data:{labels,datasets}, options:lineMultiOpts() });
}
if(custom) renderSelList(rows, grand, isCC);
else if(isCC) renderCcTable(rows, grand, fy);
else renderAcList(rows, grand, fy);
requestAnimationFrame(drawSparks);
}
/* selection checkbox + generic metric cells */
function selBox(id){ const on=state.sel.has(id);
return `<button onclick="event.stopPropagation();toggleSel('${id}')" title="Select to graph" class="shrink-0 flex h-4 w-4 items-center justify-center rounded border ${on?'border-primary-600 bg-primary-600 text-white':'border-neutral-300 bg-white hover:border-primary-500'}">${on?'<svg class="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><polyline points="20 6 9 17 4 12"/></svg>':''}</button>`;
}
function metricCellsRaw(total, series, denom){ const pct=denom?total/denom*100:0;
return `<canvas class="spark shrink-0 hidden sm:block" data-series="${series.join(',')}" width="80" height="24"></canvas>
<span class="w-24 text-right font-medium tabular-nums text-sm text-neutral-900">${fmt(total)}</span>
<div class="hidden md:flex items-center gap-2 w-28 justify-end">
<div class="h-1.5 w-14 overflow-hidden rounded-full bg-neutral-200/70"><div class="h-full rounded-full bg-primary-600" style="width:${Math.min(pct,100)}%"></div></div>
<span class="w-9 text-right tabular-nums text-xs text-neutral-500">${pct.toFixed(0)}%</span></div>`;
}
/* accounting drill-level list (replaces the old accordion) */
function renderAcList(rows, grand, fy){
const html=rows.map(r=>{ const n=r.it, leaf=isLeaf(n.id);
const tail = leaf
? `<svg class="h-4 w-4 shrink-0 text-neutral-300" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 3v18h18"/><rect x="7" y="9" width="3" height="9"/><rect x="14" y="5" width="3" height="13"/></svg>`
: `<svg class="h-4 w-4 shrink-0 text-neutral-300" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>`;
return `<div class="group/row flex items-center gap-3 border-b border-neutral-100 bg-white px-4 py-3 last:border-0 hover:bg-primary-50/40">
${selBox(n.id)}
<div onclick="acRowClick('${n.id}')" class="flex flex-1 items-center gap-3 min-w-0 cursor-pointer">
<span class="font-mono text-xs text-neutral-500 w-14 shrink-0">${n.code}</span>
<span class="flex-1 truncate text-sm font-medium text-neutral-900 group-hover/row:text-primary-700">${n.name}</span>
${tierBadge(n.tier)}
${metricCellsRaw(nodePeriod(n.id,fy), nodeMonthly(n.id,fy), grand)}
${tail}
</div>
</div>`; }).join("");
document.getElementById("ac-accordion").innerHTML = `<div class="overflow-hidden rounded-lg border border-neutral-200">${html}</div>`;
}
/* custom comparison list (selected items, with remove) */
function renderSelList(rows, grand, isCC){
const html=rows.map(r=>{ const n=r.it; const fy=state.fy;
const total=isCC?ccPeriod(n.id,fy):nodePeriod(n.id,fy), series=isCC?ccMonthly(n.id,fy):nodeMonthly(n.id,fy);
const label=isCC? n.name : `${n.code} · ${n.name}`;
return `<div class="flex items-center gap-3 border-b border-neutral-100 bg-white px-4 py-3 last:border-0">
<span class="flex-1 truncate text-sm font-medium text-neutral-900">${label}</span>
${isCC?`<span class="rounded-full bg-neutral-100 px-2 py-0.5 text-xs font-medium text-neutral-600">${n.type}</span>`:tierBadge(n.tier)}
${metricCellsRaw(total, series, grand)}
<button onclick="toggleSel('${n.id}')" title="Remove" class="shrink-0 text-neutral-400 hover:text-red-600"><svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
</div>`; }).join("");
document.getElementById("ac-accordion").innerHTML = `<div class="overflow-hidden rounded-lg border border-neutral-200">${html}</div>`;
}
/* ── Cost-centre flat table ──────────────────────────────────── */
function renderCcTable(rows, grand, fy){
document.getElementById("index-rows").innerHTML=rows.map(r=>{
const pct=grand?(r.total/grand*100):0;
const poCount=4+Math.round(r.total/9);
return `<tr onclick="openItem('${r.it.id}')" class="cursor-pointer hover:bg-primary-50/40">
<td class="px-5 py-3"><div class="flex items-center gap-3">${selBox(r.it.id)}<div><div class="font-medium text-neutral-900">${r.it.name}</div><div class="text-xs text-neutral-400">${r.it.type}</div></div></div></td>
<td class="px-5 py-3"><canvas class="spark" data-series="${r.series.join(',')}" width="90" height="28"></canvas></td>
<td class="px-5 py-3 text-right font-medium tabular-nums">${fmt(r.total)}</td>
<td class="px-5 py-3 text-right"><div class="flex items-center justify-end gap-2">
<div class="h-1.5 w-16 overflow-hidden rounded-full bg-neutral-100"><div class="h-full rounded-full bg-primary-600" style="width:${Math.min(pct,100)}%"></div></div>
<span class="tabular-nums text-neutral-500 w-10 text-right">${pct.toFixed(0)}%</span></div></td>
<td class="px-5 py-3 text-right tabular-nums text-neutral-500">${poCount}</td>
<td class="px-5 py-3 text-right"><svg class="inline h-4 w-4 text-neutral-300" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg></td>
</tr>`;
}).join("");
}
/* ── Accounting-code accordion (mirrors the admin tree) ──────────
• Click a row / its title → open that account's report graph
• Click the ▸ caret beside the title → expand its sub-accounts inline
─────────────────────────────────────────────────────────────────*/
function byTotal(fy){ return (a,b)=>nodePeriod(b.id,fy)-nodePeriod(a.id,fy); }
function caretEl(node){
if(isLeaf(node.id)) return `<span class="w-4 shrink-0"></span>`;
const open=state.expanded.has(node.id);
return `<button onclick="event.stopPropagation();toggleNode('${node.id}')" aria-label="expand"
class="w-5 shrink-0 text-center text-neutral-500 hover:text-neutral-900">${open?'▾':'▸'}</button>`;
}
function metricCells(node, fy, denom){
const total=nodePeriod(node.id,fy), series=nodeMonthly(node.id,fy);
const pct=denom? total/denom*100 : 0;
return `<canvas class="spark shrink-0 hidden sm:block" data-series="${series.join(',')}" width="80" height="24"></canvas>
<span class="w-24 text-right font-medium tabular-nums text-sm text-neutral-900">${fmt(total)}</span>
<div class="hidden md:flex items-center gap-2 w-28 justify-end">
<div class="h-1.5 w-14 overflow-hidden rounded-full bg-neutral-200/70"><div class="h-full rounded-full bg-primary-600" style="width:${Math.min(pct,100)}%"></div></div>
<span class="w-9 text-right tabular-nums text-xs text-neutral-500">${pct.toFixed(0)}%</span>
</div>`;
}
function titleSpan(node, cls){
return `<span class="${cls} cursor-pointer group-hover/row:text-primary-700 group-hover/row:underline">${node.name}</span>`;
}
function renderAcAccordion(fy){
const grand=childrenOf(null).reduce((s,h)=>s+nodePeriod(h.id,fy),0)||1;
document.getElementById("ac-accordion").innerHTML =
childrenOf(null).slice().sort(byTotal(fy)).map(h=>acHeading(h,fy,grand)).join("");
}
function acHeading(node, fy, grand){
const open=state.expanded.has(node.id);
const kids=childrenOf(node.id).slice().sort(byTotal(fy));
const parentTotal=nodePeriod(node.id,fy);
return `<div class="mb-2 overflow-hidden rounded-lg border border-neutral-200 bg-white">
<div onclick="openItem('${node.id}')" class="group/row flex items-center gap-3 bg-neutral-100 px-4 py-3 cursor-pointer hover:bg-neutral-200/60">
${caretEl(node)}
<span class="font-mono text-xs font-semibold text-neutral-600 w-14 shrink-0">${node.code}</span>
${titleSpan(node,'flex-1 text-sm font-semibold uppercase tracking-wide text-neutral-900')}
<span class="hidden lg:inline text-xs text-neutral-400">${kids.length} sub-headings</span>
${metricCells(node,fy,grand)}
</div>
${open? `<div>${kids.map(s=>acSub(s,fy,parentTotal)).join("")}</div>`:""}
</div>`;
}
function acSub(node, fy, parentTotal){
const open=state.expanded.has(node.id);
const kids=childrenOf(node.id).slice().sort(byTotal(fy));
const subTotal=nodePeriod(node.id,fy);
return `<div class="border-t border-neutral-100">
<div onclick="openItem('${node.id}')" class="group/row flex items-center gap-3 bg-neutral-50 px-4 py-2.5 pl-9 cursor-pointer hover:bg-neutral-100/70">
${caretEl(node)}
<span class="font-mono text-xs text-neutral-500 w-14 shrink-0">${node.code}</span>
${titleSpan(node,'flex-1 text-sm font-medium text-neutral-700')}
<span class="hidden lg:inline text-xs text-neutral-400">${kids.length} codes</span>
${metricCells(node,fy,parentTotal)}
</div>
${open? `<div>${kids.map(l=>acLeaf(l,fy,subTotal)).join("")}</div>`:""}
</div>`;
}
function acLeaf(node, fy, parentTotal){
return `<div onclick="openItem('${node.id}')" class="group/row flex items-center gap-3 border-t border-neutral-100 px-4 py-2.5 pl-16 cursor-pointer hover:bg-neutral-50">
<span class="w-4 shrink-0"></span>
<span class="font-mono text-xs text-neutral-400 w-14 shrink-0">${node.code}</span>
${titleSpan(node,'flex-1 text-sm text-neutral-800')}
${metricCells(node,fy,parentTotal)}
</div>`;
}
function toggleNode(id){ state.expanded.has(id)? state.expanded.delete(id) : state.expanded.add(id); render(); }
/* ── DETAIL ────────────────────────────────────────────────── */
function renderDetail(isCC){
document.getElementById("view-index").classList.add("hidden");
document.getElementById("view-detail").classList.remove("hidden");
const fy=state.fy;
let node,name,badge,id;
if(isCC){ node=COST_CENTRES.find(x=>x.id===state.itemId); id=node.id; name=node.name; badge=node.type;
document.getElementById("back-label").textContent="Back to Cost Centres"; }
else { node=acc(state.itemId); id=node.id; name=`${node.code} · ${node.name}`; badge=node.tier;
document.getElementById("back-label").textContent="Back to Accounting Codes"; }
document.getElementById("crumb-detail-wrap").classList.remove("hidden");
document.getElementById("crumb-detail-wrap").classList.add("flex");
document.getElementById("crumb-detail").textContent= isCC? node.name : node.code;
document.getElementById("det-title").textContent=name;
const db=document.getElementById("det-badge");
db.className="rounded-full px-2.5 py-0.5 text-xs font-medium "+
(badge==="Heading"?"bg-primary-50 text-primary-700":badge==="Sub-heading"?"bg-violet-50 text-violet-700":badge==="Vessel"?"bg-primary-50 text-primary-700":badge==="Site"?"bg-emerald-50 text-emerald-700":"bg-neutral-100 text-neutral-600");
db.textContent=badge;
document.getElementById("det-sub").textContent= state.gran==="yearly"
? "Year-over-year spend across all financial years"
: (isCC? `Spend detail · ${periodLabel()}` : `Aggregates all spend under this ${badge.toLowerCase()} · ${periodLabel()}`);
// trend
let labels,series,gl;
if(state.gran==="yearly"){ const fys=scopedFYs(); const yr=isCC?ccYearly(id):nodeYearly(id); labels=fys.map(f=>FY_LABEL[f]); series=fys.map(f=>yr[FYS.indexOf(f)]); gl="Per financial year"; }
else if(state.gran==="weekly"){ labels=weekOfMonthLabels(); series=isCC?ccWeeksOfMonth(id,fy):nodeWeeksOfMonth(id,fy); gl=`Weekly · ${monthLabel(fy,state.month)}`; }
else { labels=MONTHS; series=isCC?ccMonthly(id,fy):nodeMonthly(id,fy); gl=`Monthly · ${FY_LABEL[fy]}`; }
const total=r1(series.reduce((a,b)=>a+b,0)), avg=total/series.length, peak=series.indexOf(Math.max(...series));
const yr=isCC?ccYearly(id):nodeYearly(id); const yoy=yr[1]?((yr[2]-yr[1])/yr[1]*100):0;
document.getElementById("det-kpis").innerHTML=[
kpi(state.gran==="yearly"?"Total (all yrs)":"Total spend", fmtShort(total), state.gran==="yearly"?"3 FYs":periodLabel()),
kpi("Avg / "+(state.gran==="yearly"?"year":state.gran==="weekly"?"week":"month"), fmtShort(avg),""),
kpi("Peak "+(state.gran==="weekly"?"week":state.gran==="yearly"?"year":"month"), labels[peak], fmtShort(series[peak])),
kpi("YoY change",(yoy>=0?"+":"")+yoy.toFixed(1)+"%","vs prior FY",yoy>=0),
].join("");
document.getElementById("trend-gran").textContent=gl;
destroy("trend");
const tctx=document.getElementById("chart-trend");
if(state.gran==="yearly"){
charts.trend=new Chart(tctx,{ type:"bar", data:{ labels, datasets:[{ data:series, backgroundColor:"#2563eb", borderRadius:4 }] }, options:lineBarOpts() });
} else {
charts.trend=new Chart(tctx,{ type:"line", data:{ labels, datasets:[{ data:series, borderColor:"#2563eb", backgroundColor:"rgba(37,99,235,0.08)", fill:true, tension:0.35, pointRadius:3, borderWidth:2 }] }, options:lineBarOpts() });
}
// ── breakdown + contextual controls ──
let breakdown, thLabel, title, controls;
if(isCC){
title="Top accounting codes"; thLabel=state.ccTier;
breakdown=topAccountsForCC(id,fy,state.ccTier);
controls = `<span class="text-xs text-neutral-400">Tier</span>`
+ seg("cctier",[{val:"Heading",label:"Heading"},{val:"Sub-heading",label:"Sub-heading"},{val:"Leaf",label:"Leaf"}], state.ccTier)
+ seg("topn",[{val:5,label:"Top 5"},{val:10,label:"Top 10"},{val:99,label:"All"}], state.topn);
} else {
const leaf=isLeaf(id);
if(leaf || state.breakMode==="cc"){ title="Top cost centres"; thLabel="Cost centre"; breakdown=topCCsForNode(id,fy); }
else { const kids=childrenOf(id); title="Composition by sub-account"; thLabel=kids[0].tier; breakdown=childBreakdown(id,fy); }
const modeSeg = leaf? "" :
`<span class="text-xs text-neutral-400">Break down by</span>`+seg("breakmode",[{val:"children",label:childrenOf(id)[0].tier+"s"},{val:"cc",label:"Cost centres"}], state.breakMode);
controls = modeSeg + seg("topn",[{val:5,label:"Top 5"},{val:10,label:"Top 10"},{val:99,label:"All"}], state.topn);
}
document.getElementById("topn-title").textContent=title;
document.getElementById("topn-th").textContent=thLabel;
const cc=document.getElementById("topn-controls"); cc.innerHTML=controls;
cc.querySelectorAll(".seg-btn").forEach(b=> b.onclick=()=>handleSeg(b.dataset.seg,b.dataset.val));
const totB=breakdown.reduce((s,b)=>s+b.value,0)||1;
const shown=breakdown.slice(0,state.topn);
destroy("topn");
charts.topn=new Chart(document.getElementById("chart-topn"),{ type:"bar",
data:{ labels:shown.map(s=>shorten(s.label,22)), datasets:[{ data:shown.map(s=>s.value), backgroundColor:shown.map((_,i)=>COLORS[i%COLORS.length]), borderRadius:4 }] },
options:barOpts(false,true) });
document.getElementById("topn-rows").innerHTML=shown.map((b,i)=>`
<tr><td class="py-2"><span class="mr-2 inline-block h-2.5 w-2.5 rounded-sm align-middle" style="background:${COLORS[i%COLORS.length]}"></span>${b.label}</td>
<td class="py-2 text-right tabular-nums font-medium">${fmt(b.value)}</td>
<td class="py-2 text-right tabular-nums text-neutral-500">${(b.value/totB*100).toFixed(0)}%</td></tr>`).join("");
}
function handleSeg(name,val){
if(name==="topn") state.topn=+val;
else if(name==="cctier") state.ccTier=val;
else if(name==="breakmode") state.breakMode=val;
render();
}
/* ── chart options & helpers ───────────────────────────────── */
function barOpts(grouped,horizontal){
const money=v=>"₹"+v+"L";
const valueTicks={ font:{size:11}, color:"#737373", callback:money };
const catTicks={ font:{size:11}, color:"#737373", maxRotation:0, autoSkip:true };
return { indexAxis:horizontal?'y':'x', responsive:true, maintainAspectRatio:false,
plugins:{ legend:{ display:!!grouped, position:'bottom', labels:{ usePointStyle:true, boxWidth:8, font:{size:11} } },
tooltip:{ callbacks:{ label:c=>` ${fmt(c.parsed[horizontal?'x':'y'])}` } } },
scales:{ x:{ grid:{ display:!!horizontal, color:"#f0f0f0" }, ticks:horizontal?valueTicks:catTicks },
y:{ grid:{ display:!horizontal, color:"#f0f0f0" }, ticks:horizontal?catTicks:valueTicks } } };
}
function lineBarOpts(){
return { responsive:true, maintainAspectRatio:false,
plugins:{ legend:{ display:false }, tooltip:{ callbacks:{ label:c=>" "+fmt(c.parsed.y) } } },
scales:{ x:{ grid:{ display:false }, ticks:{ font:{size:10}, color:"#737373", maxRotation:0, autoSkip:true, maxTicksLimit:13 } },
y:{ grid:{ color:"#f0f0f0" }, ticks:{ font:{size:11}, color:"#737373", callback:v=>"₹"+v+"L" } } } };
}
// multi-line comparison (one line per cost centre / accounting code)
function lineMultiOpts(){
return { responsive:true, maintainAspectRatio:false, interaction:{ mode:'index', intersect:false },
plugins:{ legend:{ display:true, position:'bottom', labels:{ usePointStyle:true, boxWidth:8, font:{size:11}, padding:12 } },
tooltip:{ callbacks:{ label:c=>` ${c.dataset.label}: ${fmt(c.parsed.y)}` } } },
scales:{ x:{ grid:{ display:false }, ticks:{ font:{size:10}, color:"#737373", maxRotation:0, autoSkip:true, maxTicksLimit:13 } },
y:{ grid:{ color:"#f0f0f0" }, ticks:{ font:{size:11}, color:"#737373", callback:v=>"₹"+v+"L" } } } };
}
function kpi(label,value,sub,positive){
const c= positive===undefined?"text-neutral-400":(positive?"text-green-600":"text-red-600");
return `<div class="rounded-lg border border-neutral-200 bg-white p-4">
<p class="text-xs font-medium uppercase tracking-wider text-neutral-400">${label}</p>
<p class="mt-1.5 text-xl font-semibold text-neutral-900">${value}</p>
<p class="mt-0.5 text-xs ${c}">${sub||"&nbsp;"}</p></div>`;
}
function drawSparks(){
document.querySelectorAll("canvas.spark").forEach(cv=>{
const data=cv.dataset.series.split(",").map(Number), ctx=cv.getContext("2d");
ctx.clearRect(0,0,cv.width,cv.height);
const max=Math.max(...data),min=Math.min(...data),w=cv.width,h=cv.height,pad=3;
ctx.beginPath(); ctx.strokeStyle="#2563eb"; ctx.lineWidth=1.5;
data.forEach((v,i)=>{ const x=pad+(i/(data.length-1))*(w-2*pad); const y=h-pad-((v-min)/((max-min)||1))*(h-2*pad); i?ctx.lineTo(x,y):ctx.moveTo(x,y); });
ctx.stroke();
const lx=w-pad, ly=h-pad-((data[data.length-1]-min)/((max-min)||1))*(h-2*pad);
ctx.beginPath(); ctx.fillStyle="#2563eb"; ctx.arc(lx,ly,2,0,7); ctx.fill();
});
}
render();
</script>
</body>
</html>