829 lines
53 KiB
HTML
829 lines
53 KiB
HTML
<!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 2025–26</option>
|
||
<option value="2024">FY 2024–25</option>
|
||
<option value="2023">FY 2023–24</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 2025–26</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 2023–24","2024":"FY 2024–25","2025":"FY 2025–26" };
|
||
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); } // Apr–Dec → start yr, Jan–Mar → +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||" "}</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>
|