967 lines
50 KiB
PHP
967 lines
50 KiB
PHP
<!DOCTYPE html>
|
|
<html lang="en" class="dark">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>Supplier Dashboard — Stargas</title>
|
|
|
|
<link rel="preconnect" href="https://fonts.bunny.net">
|
|
<link href="https://fonts.bunny.net/css?family=inter:400,500,600,700,800" rel="stylesheet" />
|
|
|
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
|
|
<script src="https://cdn.tailwindcss.com"></script>
|
|
|
|
<style>
|
|
* { font-family: 'Inter', ui-sans-serif, system-ui, sans-serif; }
|
|
|
|
/* Animated gradient background — Stargas dark theme */
|
|
body {
|
|
background: linear-gradient(135deg, #0a0a0a 0%, #1a0a0a 30%, #0d0d0d 60%, #0a0a0a 100%);
|
|
min-height: 100vh;
|
|
}
|
|
|
|
/* Glass card effect */
|
|
.glass {
|
|
background: rgba(255, 255, 255, 0.04);
|
|
backdrop-filter: blur(12px);
|
|
border: 1px solid rgba(227, 25, 55, 0.12);
|
|
}
|
|
|
|
/* KPI card hover glow */
|
|
.kpi-card {
|
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
|
}
|
|
.kpi-card:hover {
|
|
transform: translateY(-2px);
|
|
box-shadow: 0 8px 30px rgba(227, 25, 55, 0.2);
|
|
border-color: rgba(227, 25, 55, 0.3);
|
|
}
|
|
|
|
/* Animated counter */
|
|
.counter-value {
|
|
transition: all 0.6s ease-out;
|
|
}
|
|
|
|
/* Chart container subtle glow */
|
|
.chart-card {
|
|
transition: all 0.3s ease;
|
|
}
|
|
.chart-card:hover {
|
|
border-color: rgba(227, 25, 55, 0.3);
|
|
}
|
|
|
|
/* Skeleton loading */
|
|
.skeleton {
|
|
background: linear-gradient(90deg, rgba(227,25,55,0.05) 25%, rgba(227,25,55,0.12) 50%, rgba(227,25,55,0.05) 75%);
|
|
background-size: 200% 100%;
|
|
animation: shimmer 1.5s infinite;
|
|
border-radius: 0.5rem;
|
|
}
|
|
@keyframes shimmer {
|
|
0% { background-position: 200% 0; }
|
|
100% { background-position: -200% 0; }
|
|
}
|
|
|
|
/* Fade in animation */
|
|
.fade-in {
|
|
animation: fadeIn 0.6s ease-out forwards;
|
|
opacity: 0;
|
|
}
|
|
@keyframes fadeIn {
|
|
to { opacity: 1; }
|
|
}
|
|
|
|
/* Scrollbar styling */
|
|
::-webkit-scrollbar { width: 6px; }
|
|
::-webkit-scrollbar-track { background: transparent; }
|
|
::-webkit-scrollbar-thumb { background: rgba(227,25,55,0.3); border-radius: 3px; }
|
|
|
|
/* Supplier modal */
|
|
.modal-backdrop {
|
|
background: rgba(0, 0, 0, 0.7);
|
|
backdrop-filter: blur(4px);
|
|
}
|
|
|
|
/* Rank badge colors — Gold, Silver, Bronze */
|
|
.rank-1 { background: linear-gradient(135deg, #FFD700, #FFA500); }
|
|
.rank-2 { background: linear-gradient(135deg, #C0C0C0, #8a8a8a); }
|
|
.rank-3 { background: linear-gradient(135deg, #CD7F32, #a0522d); }
|
|
|
|
/* Stargas accent line */
|
|
.stargas-accent {
|
|
background: linear-gradient(90deg, #E31937, #FFD700, #E31937);
|
|
height: 2px;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body class="text-white antialiased">
|
|
|
|
<!-- Stargas Accent Line -->
|
|
<div class="stargas-accent"></div>
|
|
|
|
<!-- Header -->
|
|
<header class="glass sticky top-0 z-50 border-b border-red-900/20">
|
|
<div class="max-w-[1600px] mx-auto px-6 py-3 flex items-center justify-between">
|
|
<div class="flex items-center gap-4">
|
|
<img src="/images/stargas-logo.svg" alt="Stargas Energies" class="h-12">
|
|
<div class="border-l border-white/10 pl-4">
|
|
<h1 class="text-lg font-bold tracking-tight">Supplier Dashboard</h1>
|
|
<p class="text-xs text-slate-400">Performance Analytics</p>
|
|
</div>
|
|
</div>
|
|
<div class="flex items-center gap-4">
|
|
<a href="/" class="px-3 py-2 text-xs font-medium rounded-lg bg-white/5 hover:bg-white/10 transition-colors border border-white/10 flex items-center gap-1.5 text-slate-300">
|
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"/></svg>
|
|
Hub
|
|
</a>
|
|
<span id="last-updated" class="text-xs text-slate-500"></span>
|
|
<button onclick="loadAllData()" class="px-4 py-2 text-xs font-medium rounded-lg bg-red-700 hover:bg-red-600 transition-colors flex items-center gap-2 border border-red-600/30">
|
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
|
|
Refresh
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<!-- Date Range Filter Bar -->
|
|
<div class="max-w-[1600px] mx-auto px-6 py-2 flex items-center gap-3 border-t border-white/5">
|
|
<svg class="w-4 h-4 text-slate-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>
|
|
|
|
<label class="text-xs text-slate-400">Financial Year</label>
|
|
<select id="filter-fy" onchange="applyFinancialYear()" class="bg-white/5 border border-white/10 rounded-lg px-3 py-1.5 text-xs text-white focus:border-red-500 focus:outline-none focus:ring-1 focus:ring-red-500/30">
|
|
<option value="" class="bg-slate-900 text-white">Custom / YTD</option>
|
|
<script>
|
|
const currentYear = new Date().getFullYear();
|
|
// If before March, current FY started last year
|
|
const currentFYStart = new Date().getMonth() < 2 ? currentYear - 1 : currentYear;
|
|
for (let y = currentFYStart; y >= 2020; y--) {
|
|
document.write(`<option value="${y}" class="bg-slate-900 text-white">FY ${y}/${(y+1).toString().substring(2)}</option>`);
|
|
}
|
|
</script>
|
|
</select>
|
|
|
|
<div class="h-4 border-l border-white/10 mx-1"></div>
|
|
|
|
<label class="text-xs text-slate-400">From</label>
|
|
<input type="date" id="filter-from" class="bg-white/5 border border-white/10 rounded-lg px-3 py-1.5 text-xs text-white focus:border-red-500 focus:outline-none focus:ring-1 focus:ring-red-500/30" />
|
|
<label class="text-xs text-slate-400">To</label>
|
|
<input type="date" id="filter-to" class="bg-white/5 border border-white/10 rounded-lg px-3 py-1.5 text-xs text-white focus:border-red-500 focus:outline-none focus:ring-1 focus:ring-red-500/30" />
|
|
<button onclick="loadAllData()" class="px-3 py-1.5 text-xs font-medium rounded-lg bg-red-700/80 hover:bg-red-600 transition-colors border border-red-600/30">Apply</button>
|
|
<button onclick="clearDateFilter()" class="px-3 py-1.5 text-xs font-medium rounded-lg bg-white/5 hover:bg-white/10 transition-colors border border-white/10">Clear</button>
|
|
<span id="filter-status" class="text-xs text-slate-500 ml-auto"></span>
|
|
</div>
|
|
</header>
|
|
|
|
<main class="max-w-[1600px] mx-auto px-6 py-8 space-y-8">
|
|
|
|
<!-- KPI Cards Row -->
|
|
<section id="kpi-section" class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-4 gap-4">
|
|
<div class="kpi-card glass rounded-2xl p-5">
|
|
<div class="flex items-center justify-between mb-3">
|
|
<span class="text-xs font-medium text-slate-400 uppercase tracking-wider">Total Suppliers</span>
|
|
<div class="w-8 h-8 rounded-lg bg-red-500/10 flex items-center justify-center">
|
|
<svg class="w-4 h-4 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"/></svg>
|
|
</div>
|
|
</div>
|
|
<div id="kpi-suppliers" class="text-3xl font-bold counter-value">
|
|
<div class="skeleton h-9 w-20"></div>
|
|
</div>
|
|
</div>
|
|
<div class="kpi-card glass rounded-2xl p-5">
|
|
<div class="flex items-center justify-between mb-3">
|
|
<span class="text-xs font-medium text-slate-400 uppercase tracking-wider">Active Products</span>
|
|
<div class="w-8 h-8 rounded-lg bg-yellow-500/10 flex items-center justify-center">
|
|
<svg class="w-4 h-4 text-yellow-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/></svg>
|
|
</div>
|
|
</div>
|
|
<div id="kpi-products" class="text-3xl font-bold counter-value">
|
|
<div class="skeleton h-9 w-20"></div>
|
|
</div>
|
|
</div>
|
|
<div class="kpi-card glass rounded-2xl p-5">
|
|
<div class="flex items-center justify-between mb-3">
|
|
<span class="text-xs font-medium text-slate-400 uppercase tracking-wider">Purchase Spend</span>
|
|
<div class="w-8 h-8 rounded-lg bg-red-600/10 flex items-center justify-center">
|
|
<svg class="w-4 h-4 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
|
</div>
|
|
</div>
|
|
<div id="kpi-spend" class="text-3xl font-bold counter-value">
|
|
<div class="skeleton h-9 w-32"></div>
|
|
</div>
|
|
</div>
|
|
<div class="kpi-card glass rounded-2xl p-5">
|
|
<div class="flex items-center justify-between mb-3">
|
|
<span class="text-xs font-medium text-slate-400 uppercase tracking-wider">Gross Margin</span>
|
|
<div class="w-8 h-8 rounded-lg bg-yellow-600/10 flex items-center justify-center">
|
|
<svg class="w-4 h-4 text-yellow-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"/></svg>
|
|
</div>
|
|
</div>
|
|
<div id="kpi-margin" class="text-3xl font-bold counter-value">
|
|
<div class="skeleton h-9 w-24"></div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Secondary KPI row -->
|
|
<section class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
<div class="kpi-card glass rounded-2xl p-5">
|
|
<span class="text-xs font-medium text-slate-400 uppercase tracking-wider">Sales Revenue</span>
|
|
<div id="kpi-revenue" class="text-2xl font-bold mt-2 text-red-400">
|
|
<div class="skeleton h-8 w-28"></div>
|
|
</div>
|
|
</div>
|
|
<div class="kpi-card glass rounded-2xl p-5">
|
|
<span class="text-xs font-medium text-slate-400 uppercase tracking-wider">Gross Profit</span>
|
|
<div id="kpi-profit" class="text-2xl font-bold mt-2 text-yellow-400">
|
|
<div class="skeleton h-8 w-28"></div>
|
|
</div>
|
|
</div>
|
|
<div class="kpi-card glass rounded-2xl p-5">
|
|
<span class="text-xs font-medium text-slate-400 uppercase tracking-wider">Total Customers</span>
|
|
<div id="kpi-customers" class="text-2xl font-bold mt-2 text-orange-400">
|
|
<div class="skeleton h-8 w-16"></div>
|
|
</div>
|
|
</div>
|
|
<div class="kpi-card glass rounded-2xl p-5">
|
|
<span class="text-xs font-medium text-slate-400 uppercase tracking-wider">Supplier Invoices</span>
|
|
<div id="kpi-invoices" class="text-2xl font-bold mt-2 text-red-300">
|
|
<div class="skeleton h-8 w-16"></div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Charts Row 1: Time Series -->
|
|
<section class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
<div class="chart-card glass rounded-2xl p-6">
|
|
<h3 class="text-sm font-semibold text-slate-300 mb-4 flex items-center gap-2">
|
|
<svg class="w-4 h-4 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/></svg>
|
|
Purchase Spend Over Time
|
|
</h3>
|
|
<div class="relative h-72">
|
|
<canvas id="chart-spend-time"></canvas>
|
|
</div>
|
|
</div>
|
|
<div class="chart-card glass rounded-2xl p-6">
|
|
<h3 class="text-sm font-semibold text-slate-300 mb-4 flex items-center gap-2">
|
|
<svg class="w-4 h-4 text-yellow-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"/></svg>
|
|
Sales Revenue & Profit Over Time
|
|
</h3>
|
|
<div class="relative h-72">
|
|
<canvas id="chart-sales-time"></canvas>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Charts Row 2: Rankings -->
|
|
<section class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
<!-- Top Suppliers -->
|
|
<div class="chart-card glass rounded-2xl p-6">
|
|
<h3 class="text-sm font-semibold text-slate-300 mb-4 flex items-center gap-2">
|
|
<svg class="w-4 h-4 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
|
|
Top 10 Suppliers by Spend
|
|
</h3>
|
|
<div id="top-suppliers-list" class="space-y-2 max-h-96 overflow-y-auto">
|
|
<div class="skeleton h-12 w-full mb-2"></div>
|
|
<div class="skeleton h-12 w-full mb-2"></div>
|
|
<div class="skeleton h-12 w-full mb-2"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Top Products -->
|
|
<div class="chart-card glass rounded-2xl p-6">
|
|
<h3 class="text-sm font-semibold text-slate-300 mb-4 flex items-center gap-2">
|
|
<svg class="w-4 h-4 text-yellow-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/></svg>
|
|
Top 10 Products by Revenue
|
|
</h3>
|
|
<div id="top-products-list" class="space-y-2 max-h-96 overflow-y-auto">
|
|
<div class="skeleton h-12 w-full mb-2"></div>
|
|
<div class="skeleton h-12 w-full mb-2"></div>
|
|
<div class="skeleton h-12 w-full mb-2"></div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Charts Row 3: Category + Bar Charts -->
|
|
<section class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
<div class="chart-card glass rounded-2xl p-6">
|
|
<h3 class="text-sm font-semibold text-slate-300 mb-4 flex items-center gap-2">
|
|
<svg class="w-4 h-4 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 3.055A9.001 9.001 0 1020.945 13H11V3.055z"/></svg>
|
|
Revenue by Category
|
|
</h3>
|
|
<div class="relative h-72">
|
|
<canvas id="chart-category"></canvas>
|
|
</div>
|
|
</div>
|
|
<div class="chart-card glass rounded-2xl p-6 lg:col-span-2">
|
|
<h3 class="text-sm font-semibold text-slate-300 mb-4 flex items-center gap-2">
|
|
<svg class="w-4 h-4 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/></svg>
|
|
Supplier Spend Comparison
|
|
</h3>
|
|
<div class="relative h-72">
|
|
<canvas id="chart-supplier-bar"></canvas>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</main>
|
|
|
|
<!-- Supplier Detail Modal -->
|
|
<div id="supplier-modal" class="fixed inset-0 z-50 hidden">
|
|
<div class="modal-backdrop absolute inset-0" onclick="closeModal('supplier')"></div>
|
|
<div class="absolute right-0 top-0 h-full w-full max-w-2xl glass bg-slate-900/95 border-l border-white/10 overflow-y-auto transform transition-transform duration-300 translate-x-full" id="modal-panel">
|
|
<div class="p-6">
|
|
<div class="flex items-center justify-between mb-6">
|
|
<h2 class="text-lg font-bold" id="modal-supplier-name">Supplier Details</h2>
|
|
<button onclick="closeModal('supplier')" class="w-8 h-8 rounded-lg bg-white/5 hover:bg-white/10 flex items-center justify-center transition-colors">
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
|
|
</button>
|
|
</div>
|
|
<div id="modal-content">
|
|
<div class="space-y-3">
|
|
<div class="skeleton h-8 w-48"></div>
|
|
<div class="skeleton h-64 w-full"></div>
|
|
<div class="skeleton h-12 w-full"></div>
|
|
<div class="skeleton h-12 w-full"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Product Detail Modal -->
|
|
<div id="product-modal" class="fixed inset-0 z-50 hidden">
|
|
<div class="modal-backdrop absolute inset-0" onclick="closeModal('product')"></div>
|
|
<div class="absolute right-0 top-0 h-full w-full max-w-2xl glass bg-slate-900/95 border-l border-white/10 overflow-y-auto transform transition-transform duration-300 translate-x-full" id="product-modal-panel">
|
|
<div class="p-6">
|
|
<div class="flex items-center justify-between mb-6">
|
|
<h2 class="text-lg font-bold" id="modal-product-name">Product Details</h2>
|
|
<button onclick="closeModal('product')" class="w-8 h-8 rounded-lg bg-white/5 hover:bg-white/10 flex items-center justify-center transition-colors">
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
|
|
</button>
|
|
</div>
|
|
<div id="product-modal-content">
|
|
<div class="space-y-3">
|
|
<div class="skeleton h-8 w-48"></div>
|
|
<div class="skeleton h-64 w-full"></div>
|
|
<div class="skeleton h-12 w-full"></div>
|
|
<div class="skeleton h-12 w-full"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
// ── Helpers ──
|
|
const fmt = (n) => new Intl.NumberFormat('en-ZA', { style: 'currency', currency: 'ZAR', maximumFractionDigits: 0 }).format(n);
|
|
const fmtNum = (n) => new Intl.NumberFormat('en-ZA').format(n);
|
|
const fmtMonth = (ym) => {
|
|
const [y, m] = ym.split('-');
|
|
return new Date(y, m - 1).toLocaleDateString('en-ZA', { month: 'short', year: '2-digit' });
|
|
};
|
|
|
|
// Color palette — Stargas theme
|
|
const colors = {
|
|
red: 'rgba(227, 25, 55, 1)',
|
|
redFaded: 'rgba(227, 25, 55, 0.15)',
|
|
gold: 'rgba(255, 215, 0, 1)',
|
|
goldFaded: 'rgba(255, 215, 0, 0.15)',
|
|
orange: 'rgba(255, 165, 0, 1)',
|
|
orangeFaded: 'rgba(255, 165, 0, 0.15)',
|
|
crimson: 'rgba(180, 20, 45, 1)',
|
|
amber: 'rgba(245, 158, 11, 1)',
|
|
amberFaded: 'rgba(245, 158, 11, 0.15)',
|
|
};
|
|
|
|
const chartPalette = [
|
|
'rgba(227, 25, 55, 0.85)',
|
|
'rgba(255, 215, 0, 0.85)',
|
|
'rgba(255, 165, 0, 0.85)',
|
|
'rgba(180, 20, 45, 0.85)',
|
|
'rgba(245, 158, 11, 0.85)',
|
|
'rgba(255, 99, 71, 0.85)',
|
|
'rgba(204, 51, 0, 0.85)',
|
|
'rgba(255, 200, 50, 0.85)',
|
|
'rgba(200, 80, 80, 0.85)',
|
|
'rgba(210, 150, 50, 0.85)',
|
|
];
|
|
|
|
// ── Chart.js Global Defaults ──
|
|
Chart.defaults.color = 'rgba(148, 163, 184, 0.8)';
|
|
Chart.defaults.borderColor = 'rgba(255, 255, 255, 0.06)';
|
|
Chart.defaults.font.family = "'Inter', sans-serif";
|
|
Chart.defaults.font.size = 11;
|
|
Chart.defaults.plugins.legend.labels.usePointStyle = true;
|
|
Chart.defaults.plugins.legend.labels.pointStyleWidth = 8;
|
|
Chart.defaults.animation.duration = 800;
|
|
|
|
// ── Chart instances ──
|
|
let chartSpendTime, chartSalesTime, chartCategory, chartSupplierBar, chartModalTimeline, chartProductTimeline;
|
|
|
|
// ── Date filter helpers ──
|
|
function getDateParams() {
|
|
let from = document.getElementById('filter-from').value;
|
|
let to = document.getElementById('filter-to').value;
|
|
|
|
// Default to YTD if both are empty
|
|
if (!from && !to) {
|
|
const now = new Date();
|
|
from = `${now.getFullYear()}-01-01`;
|
|
to = now.toISOString().split('T')[0];
|
|
|
|
// Update the visual inputs
|
|
document.getElementById('filter-from').value = from;
|
|
document.getElementById('filter-to').value = to;
|
|
|
|
setTimeout(updateFilterStatus, 50);
|
|
}
|
|
|
|
const params = new URLSearchParams();
|
|
if (from) params.set('from', from);
|
|
if (to) params.set('to', to);
|
|
return params.toString();
|
|
}
|
|
|
|
function clearDateFilter() {
|
|
document.getElementById('filter-fy').value = '';
|
|
document.getElementById('filter-from').value = '';
|
|
document.getElementById('filter-to').value = '';
|
|
// When cleared, getDateParams will automatically set it back to YTD
|
|
// and updateFilterStatus will be called via timeout
|
|
loadAllData();
|
|
}
|
|
|
|
function applyFinancialYear() {
|
|
const fyYear = document.getElementById('filter-fy').value;
|
|
if (!fyYear) return;
|
|
|
|
const startYear = parseInt(fyYear);
|
|
const endYear = startYear + 1;
|
|
|
|
// Financial year starts March 1st
|
|
const from = `${startYear}-03-01`;
|
|
|
|
// Calculate last day of Feb taking leap years into account
|
|
const isLeapYear = (endYear % 4 === 0 && endYear % 100 !== 0) || (endYear % 400 === 0);
|
|
const to = `${endYear}-02-${isLeapYear ? '29' : '28'}`;
|
|
|
|
document.getElementById('filter-from').value = from;
|
|
document.getElementById('filter-to').value = to;
|
|
|
|
updateFilterStatus();
|
|
loadAllData();
|
|
}
|
|
|
|
function updateFilterStatus() {
|
|
const from = document.getElementById('filter-from').value;
|
|
const to = document.getElementById('filter-to').value;
|
|
const el = document.getElementById('filter-status');
|
|
if (from || to) {
|
|
const parts = [];
|
|
if (from) parts.push('from ' + from);
|
|
if (to) parts.push('to ' + to);
|
|
el.textContent = 'Filtered: ' + parts.join(' ');
|
|
el.classList.add('text-red-400');
|
|
el.classList.remove('text-slate-500');
|
|
} else {
|
|
el.textContent = 'Showing all time';
|
|
el.classList.remove('text-red-400');
|
|
el.classList.add('text-slate-500');
|
|
}
|
|
}
|
|
|
|
// ── Data loading ──
|
|
async function fetchJson(url) {
|
|
const qs = getDateParams();
|
|
const separator = url.includes('?') ? '&' : '?';
|
|
const fullUrl = qs ? url + separator + qs : url;
|
|
const res = await fetch(fullUrl);
|
|
if (!res.ok) throw new Error(`Failed to fetch ${fullUrl}`);
|
|
return res.json();
|
|
}
|
|
|
|
async function loadSummary() {
|
|
const d = await fetchJson('/api/dashboard/summary');
|
|
animateValue('kpi-suppliers', d.total_suppliers, fmtNum);
|
|
animateValue('kpi-products', d.total_products, fmtNum);
|
|
animateValue('kpi-spend', d.purchase_spend, fmt);
|
|
animateValue('kpi-margin', d.margin_percent, (v) => v.toFixed(1) + '%');
|
|
animateValue('kpi-revenue', d.sales_revenue, fmt);
|
|
animateValue('kpi-profit', d.gross_profit, fmt);
|
|
animateValue('kpi-customers', d.total_customers, fmtNum);
|
|
animateValue('kpi-invoices', d.total_invoices, fmtNum);
|
|
}
|
|
|
|
function animateValue(id, end, formatter) {
|
|
const el = document.getElementById(id);
|
|
el.classList.add('fade-in');
|
|
const duration = 1000;
|
|
const start = 0;
|
|
const startTime = performance.now();
|
|
|
|
function update(currentTime) {
|
|
const elapsed = currentTime - startTime;
|
|
const progress = Math.min(elapsed / duration, 1);
|
|
const eased = 1 - Math.pow(1 - progress, 3); // ease-out cubic
|
|
const current = start + (end - start) * eased;
|
|
el.textContent = formatter(current);
|
|
if (progress < 1) requestAnimationFrame(update);
|
|
}
|
|
requestAnimationFrame(update);
|
|
}
|
|
|
|
async function loadSpendOverTime() {
|
|
const data = await fetchJson('/api/dashboard/spend-over-time');
|
|
const ctx = document.getElementById('chart-spend-time').getContext('2d');
|
|
if (chartSpendTime) chartSpendTime.destroy();
|
|
|
|
chartSpendTime = new Chart(ctx, {
|
|
type: 'line',
|
|
data: {
|
|
labels: data.map(d => fmtMonth(d.month)),
|
|
datasets: [{
|
|
label: 'Purchase Spend',
|
|
data: data.map(d => d.total_spend),
|
|
borderColor: colors.red,
|
|
backgroundColor: colors.redFaded,
|
|
fill: true,
|
|
tension: 0.4,
|
|
pointRadius: 0,
|
|
pointHoverRadius: 6,
|
|
borderWidth: 2,
|
|
}]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
interaction: { intersect: false, mode: 'index' },
|
|
scales: {
|
|
x: { grid: { display: false }, ticks: { maxTicksLimit: 12 } },
|
|
y: { ticks: { callback: v => fmt(v) }, grid: { color: 'rgba(255,255,255,0.03)' } }
|
|
},
|
|
plugins: {
|
|
legend: { display: false },
|
|
tooltip: { callbacks: { label: ctx => fmt(ctx.parsed.y) } }
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
async function loadSalesOverTime() {
|
|
const data = await fetchJson('/api/dashboard/sales-over-time');
|
|
const ctx = document.getElementById('chart-sales-time').getContext('2d');
|
|
if (chartSalesTime) chartSalesTime.destroy();
|
|
|
|
chartSalesTime = new Chart(ctx, {
|
|
type: 'line',
|
|
data: {
|
|
labels: data.map(d => fmtMonth(d.month)),
|
|
datasets: [
|
|
{
|
|
label: 'Revenue',
|
|
data: data.map(d => d.total_revenue),
|
|
borderColor: colors.gold,
|
|
backgroundColor: colors.goldFaded,
|
|
fill: true,
|
|
tension: 0.4,
|
|
pointRadius: 0,
|
|
pointHoverRadius: 6,
|
|
borderWidth: 2,
|
|
},
|
|
{
|
|
label: 'Gross Profit',
|
|
data: data.map(d => d.gross_profit),
|
|
borderColor: colors.orange,
|
|
backgroundColor: colors.orangeFaded,
|
|
fill: true,
|
|
tension: 0.4,
|
|
pointRadius: 0,
|
|
pointHoverRadius: 6,
|
|
borderWidth: 2,
|
|
}
|
|
]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
interaction: { intersect: false, mode: 'index' },
|
|
scales: {
|
|
x: { grid: { display: false }, ticks: { maxTicksLimit: 12 } },
|
|
y: { ticks: { callback: v => fmt(v) }, grid: { color: 'rgba(255,255,255,0.03)' } }
|
|
},
|
|
plugins: {
|
|
tooltip: { callbacks: { label: ctx => ctx.dataset.label + ': ' + fmt(ctx.parsed.y) } }
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
async function loadTopSuppliers() {
|
|
const data = await fetchJson('/api/dashboard/top-suppliers');
|
|
const container = document.getElementById('top-suppliers-list');
|
|
const maxSpend = data.length > 0 ? Math.max(...data.map(d => d.total_spend)) : 1;
|
|
|
|
container.innerHTML = data.map((s, i) => `
|
|
<div class="flex items-center gap-3 p-3 rounded-xl bg-white/[0.03] hover:bg-white/[0.06] transition-colors cursor-pointer fade-in"
|
|
style="animation-delay: ${i * 60}ms"
|
|
onclick="openSupplierModal('${encodeURIComponent(s.code)}')">
|
|
<div class="w-7 h-7 rounded-lg flex items-center justify-center text-xs font-bold shrink-0 ${i < 3 ? 'rank-' + (i+1) : 'bg-white/10'}">
|
|
${i + 1}
|
|
</div>
|
|
<div class="flex-1 min-w-0">
|
|
<div class="text-sm font-medium truncate">${s.name}</div>
|
|
<div class="mt-1 h-1.5 rounded-full bg-white/5 overflow-hidden">
|
|
<div class="h-full rounded-full bg-gradient-to-r from-red-600 to-red-500 transition-all duration-1000"
|
|
style="width: ${(s.total_spend / maxSpend * 100).toFixed(1)}%"></div>
|
|
</div>
|
|
</div>
|
|
<div class="text-right shrink-0">
|
|
<div class="text-sm font-semibold">${fmt(s.total_spend)}</div>
|
|
<div class="text-xs text-slate-500">${fmtNum(s.invoice_count)} inv</div>
|
|
</div>
|
|
<svg class="w-4 h-4 text-slate-500 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
|
|
</div>
|
|
`).join('');
|
|
|
|
// Also build bar chart
|
|
const ctx = document.getElementById('chart-supplier-bar').getContext('2d');
|
|
if (chartSupplierBar) chartSupplierBar.destroy();
|
|
|
|
chartSupplierBar = new Chart(ctx, {
|
|
type: 'bar',
|
|
data: {
|
|
labels: data.map(d => d.name.length > 18 ? d.name.substring(0, 18) + '…' : d.name),
|
|
datasets: [{
|
|
label: 'Total Spend',
|
|
data: data.map(d => d.total_spend),
|
|
backgroundColor: chartPalette,
|
|
borderRadius: 6,
|
|
borderSkipped: false,
|
|
}]
|
|
},
|
|
options: {
|
|
indexAxis: 'y',
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
scales: {
|
|
x: { ticks: { callback: v => fmt(v) }, grid: { color: 'rgba(255,255,255,0.03)' } },
|
|
y: { grid: { display: false } }
|
|
},
|
|
plugins: {
|
|
legend: { display: false },
|
|
tooltip: { callbacks: { label: ctx => fmt(ctx.parsed.x) } }
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
async function loadTopProducts() {
|
|
const data = await fetchJson('/api/dashboard/top-products');
|
|
const container = document.getElementById('top-products-list');
|
|
const maxRev = data.length > 0 ? Math.max(...data.map(d => d.total_revenue)) : 1;
|
|
|
|
container.innerHTML = data.map((p, i) => `
|
|
<div class="flex items-center gap-3 p-3 rounded-xl bg-white/[0.03] hover:bg-white/[0.06] transition-colors cursor-pointer fade-in"
|
|
style="animation-delay: ${i * 60}ms"
|
|
onclick="openProductModal('${encodeURIComponent(p.code)}')">
|
|
<div class="w-7 h-7 rounded-lg flex items-center justify-center text-xs font-bold shrink-0 ${i < 3 ? 'rank-' + (i+1) : 'bg-white/10'}">
|
|
${i + 1}
|
|
</div>
|
|
<div class="flex-1 min-w-0">
|
|
<div class="text-sm font-medium truncate">${p.name}</div>
|
|
<div class="flex items-center gap-2 mt-1">
|
|
<div class="flex-1 h-1.5 rounded-full bg-white/5 overflow-hidden">
|
|
<div class="h-full rounded-full bg-gradient-to-r from-yellow-500 to-orange-500 transition-all duration-1000"
|
|
style="width: ${(p.total_revenue / maxRev * 100).toFixed(1)}%"></div>
|
|
</div>
|
|
<span class="text-xs font-medium ${p.margin >= 20 ? 'text-yellow-400' : p.margin >= 10 ? 'text-orange-400' : 'text-red-400'}">${p.margin}%</span>
|
|
</div>
|
|
</div>
|
|
<div class="text-right shrink-0">
|
|
<div class="text-sm font-semibold">${fmt(p.total_revenue)}</div>
|
|
<div class="text-xs text-slate-500">${fmtNum(p.total_qty_sold)} units</div>
|
|
</div>
|
|
<svg class="w-4 h-4 text-slate-500 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
|
|
</div>
|
|
`).join('');
|
|
}
|
|
|
|
async function loadCategoryBreakdown() {
|
|
const data = await fetchJson('/api/dashboard/category-breakdown');
|
|
const ctx = document.getElementById('chart-category').getContext('2d');
|
|
if (chartCategory) chartCategory.destroy();
|
|
|
|
chartCategory = new Chart(ctx, {
|
|
type: 'doughnut',
|
|
data: {
|
|
labels: data.map(d => d.category),
|
|
datasets: [{
|
|
data: data.map(d => d.revenue),
|
|
backgroundColor: chartPalette,
|
|
borderWidth: 0,
|
|
hoverOffset: 8,
|
|
}]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
cutout: '65%',
|
|
plugins: {
|
|
legend: {
|
|
position: 'bottom',
|
|
labels: { padding: 12, font: { size: 10 } }
|
|
},
|
|
tooltip: {
|
|
callbacks: {
|
|
label: ctx => ctx.label + ': ' + fmt(ctx.parsed)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// ── Supplier Modal ──
|
|
function openSupplierModal(code) {
|
|
const modal = document.getElementById('supplier-modal');
|
|
const panel = document.getElementById('modal-panel');
|
|
modal.classList.remove('hidden');
|
|
setTimeout(() => panel.classList.remove('translate-x-full'), 10);
|
|
|
|
document.getElementById('modal-content').innerHTML = `
|
|
<div class="space-y-3">
|
|
<div class="skeleton h-8 w-48"></div>
|
|
<div class="skeleton h-64 w-full"></div>
|
|
<div class="skeleton h-12 w-full"></div>
|
|
<div class="skeleton h-12 w-full"></div>
|
|
</div>
|
|
`;
|
|
|
|
fetchJson('/api/dashboard/supplier/' + code).then(data => {
|
|
document.getElementById('modal-supplier-name').textContent = data.supplier_name || code;
|
|
|
|
let html = '';
|
|
|
|
// Timeline chart
|
|
html += `<div class="mb-6"><h4 class="text-xs font-semibold text-slate-400 uppercase mb-3">Spend Over Time</h4><div class="h-48"><canvas id="chart-modal-timeline"></canvas></div></div>`;
|
|
|
|
// Products table
|
|
html += `<h4 class="text-xs font-semibold text-slate-400 uppercase mb-3">Products Purchased</h4>`;
|
|
html += `<div class="space-y-2">`;
|
|
data.products.forEach((p, i) => {
|
|
const maxSpend = Math.max(...data.products.map(x => x.total_spend));
|
|
html += `
|
|
<div class="flex items-center gap-3 p-3 rounded-lg bg-white/[0.03]">
|
|
<span class="text-xs font-mono text-slate-500 w-6">${i + 1}.</span>
|
|
<div class="flex-1 min-w-0">
|
|
<div class="text-sm font-medium truncate">${p.name}</div>
|
|
<div class="mt-1 h-1 rounded-full bg-white/5 overflow-hidden">
|
|
<div class="h-full rounded-full bg-gradient-to-r from-red-600 to-orange-500"
|
|
style="width: ${(p.total_spend / maxSpend * 100).toFixed(1)}%"></div>
|
|
</div>
|
|
</div>
|
|
<div class="text-right shrink-0">
|
|
<div class="text-sm font-semibold">${fmt(p.total_spend)}</div>
|
|
<div class="text-xs text-slate-500">${fmtNum(p.total_qty)} units · ${p.invoice_count} inv</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
});
|
|
html += `</div>`;
|
|
|
|
document.getElementById('modal-content').innerHTML = html;
|
|
|
|
// Render timeline chart
|
|
if (data.timeline.length > 0) {
|
|
const tCtx = document.getElementById('chart-modal-timeline').getContext('2d');
|
|
if (chartModalTimeline) chartModalTimeline.destroy();
|
|
chartModalTimeline = new Chart(tCtx, {
|
|
type: 'bar',
|
|
data: {
|
|
labels: data.timeline.map(d => fmtMonth(d.month)),
|
|
datasets: [{
|
|
label: 'Spend',
|
|
data: data.timeline.map(d => d.total_spend),
|
|
backgroundColor: colors.redFaded,
|
|
borderColor: colors.red,
|
|
borderWidth: 1,
|
|
borderRadius: 4,
|
|
}]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
scales: {
|
|
x: { grid: { display: false }, ticks: { maxTicksLimit: 12 } },
|
|
y: { ticks: { callback: v => fmt(v) }, grid: { color: 'rgba(255,255,255,0.03)' } }
|
|
},
|
|
plugins: {
|
|
legend: { display: false },
|
|
tooltip: { callbacks: { label: ctx => fmt(ctx.parsed.y) } }
|
|
}
|
|
}
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
// ── Product Modal ──
|
|
function openProductModal(code) {
|
|
const modal = document.getElementById('product-modal');
|
|
const panel = document.getElementById('product-modal-panel');
|
|
modal.classList.remove('hidden');
|
|
setTimeout(() => panel.classList.remove('translate-x-full'), 10);
|
|
|
|
document.getElementById('product-modal-content').innerHTML = `
|
|
<div class="space-y-3">
|
|
<div class="skeleton h-8 w-48"></div>
|
|
<div class="skeleton h-64 w-full"></div>
|
|
<div class="skeleton h-12 w-full"></div>
|
|
<div class="skeleton h-12 w-full"></div>
|
|
</div>
|
|
`;
|
|
|
|
fetch('/api/dashboard/product-details?code=' + code).then(r => r.json()).then(data => {
|
|
const product = data.product || {};
|
|
document.getElementById('modal-product-name').textContent = product.name || code;
|
|
|
|
let html = '';
|
|
|
|
// Product info badge
|
|
html += `<div class="flex items-center gap-2 mb-4">
|
|
<span class="px-2 py-1 text-xs rounded-full bg-yellow-500/10 text-yellow-400">${product.category || ''}</span>
|
|
<span class="text-xs text-slate-500 font-mono">${product.code || ''}</span>
|
|
</div>`;
|
|
|
|
// Sales timeline chart
|
|
if (data.sales_timeline && data.sales_timeline.length > 0) {
|
|
html += `<div class="mb-6"><h4 class="text-xs font-semibold text-slate-400 uppercase mb-3">Sales Over Time</h4><div class="h-48"><canvas id="chart-product-timeline"></canvas></div></div>`;
|
|
}
|
|
|
|
// Top customers
|
|
if (data.top_customers && data.top_customers.length > 0) {
|
|
html += `<h4 class="text-xs font-semibold text-slate-400 uppercase mb-3">Top Customers</h4>`;
|
|
html += `<div class="space-y-2 mb-6">`;
|
|
const maxRev = Math.max(...data.top_customers.map(x => x.total_revenue));
|
|
data.top_customers.forEach((c, i) => {
|
|
html += `
|
|
<div class="flex items-center gap-3 p-3 rounded-lg bg-white/[0.03]">
|
|
<span class="text-xs font-mono text-slate-500 w-6">${i + 1}.</span>
|
|
<div class="flex-1 min-w-0">
|
|
<div class="text-sm font-medium truncate">${c.name}</div>
|
|
<div class="mt-1 h-1 rounded-full bg-white/5 overflow-hidden">
|
|
<div class="h-full rounded-full bg-gradient-to-r from-yellow-500 to-orange-500"
|
|
style="width: ${(c.total_revenue / maxRev * 100).toFixed(1)}%"></div>
|
|
</div>
|
|
</div>
|
|
<div class="text-right shrink-0">
|
|
<div class="text-sm font-semibold">${fmt(c.total_revenue)}</div>
|
|
<div class="text-xs text-slate-500">${fmtNum(c.total_qty)} units · ${c.invoice_count} inv</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
});
|
|
html += `</div>`;
|
|
}
|
|
|
|
// Suppliers providing this product
|
|
if (data.suppliers && data.suppliers.length > 0) {
|
|
html += `<h4 class="text-xs font-semibold text-slate-400 uppercase mb-3">Suppliers</h4>`;
|
|
html += `<div class="space-y-2">`;
|
|
const maxCost = Math.max(...data.suppliers.map(x => x.total_cost));
|
|
data.suppliers.forEach((s, i) => {
|
|
html += `
|
|
<div class="flex items-center gap-3 p-3 rounded-lg bg-white/[0.03]">
|
|
<span class="text-xs font-mono text-slate-500 w-6">${i + 1}.</span>
|
|
<div class="flex-1 min-w-0">
|
|
<div class="text-sm font-medium truncate">${s.name}</div>
|
|
<div class="mt-1 h-1 rounded-full bg-white/5 overflow-hidden">
|
|
<div class="h-full rounded-full bg-gradient-to-r from-red-600 to-red-500"
|
|
style="width: ${(s.total_cost / maxCost * 100).toFixed(1)}%"></div>
|
|
</div>
|
|
</div>
|
|
<div class="text-right shrink-0">
|
|
<div class="text-sm font-semibold">${fmt(s.total_cost)}</div>
|
|
<div class="text-xs text-slate-500">${fmtNum(s.total_qty)} units · ${s.invoice_count} inv</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
});
|
|
html += `</div>`;
|
|
}
|
|
|
|
document.getElementById('product-modal-content').innerHTML = html;
|
|
|
|
// Render sales timeline chart
|
|
if (data.sales_timeline && data.sales_timeline.length > 0) {
|
|
const tCtx = document.getElementById('chart-product-timeline').getContext('2d');
|
|
if (chartProductTimeline) chartProductTimeline.destroy();
|
|
chartProductTimeline = new Chart(tCtx, {
|
|
type: 'bar',
|
|
data: {
|
|
labels: data.sales_timeline.map(d => fmtMonth(d.month)),
|
|
datasets: [
|
|
{
|
|
label: 'Revenue',
|
|
data: data.sales_timeline.map(d => d.revenue),
|
|
backgroundColor: colors.goldFaded,
|
|
borderColor: colors.gold,
|
|
borderWidth: 1,
|
|
borderRadius: 4,
|
|
},
|
|
{
|
|
label: 'Profit',
|
|
data: data.sales_timeline.map(d => d.profit),
|
|
backgroundColor: colors.orangeFaded,
|
|
borderColor: colors.orange,
|
|
borderWidth: 1,
|
|
borderRadius: 4,
|
|
}
|
|
]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
scales: {
|
|
x: { grid: { display: false }, ticks: { maxTicksLimit: 12 } },
|
|
y: { ticks: { callback: v => fmt(v) }, grid: { color: 'rgba(255,255,255,0.03)' } }
|
|
},
|
|
plugins: {
|
|
tooltip: { callbacks: { label: ctx => ctx.dataset.label + ': ' + fmt(ctx.parsed.y) } }
|
|
}
|
|
}
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
function closeModal(type) {
|
|
if (type === 'product') {
|
|
const panel = document.getElementById('product-modal-panel');
|
|
panel.classList.add('translate-x-full');
|
|
setTimeout(() => document.getElementById('product-modal').classList.add('hidden'), 300);
|
|
} else {
|
|
const panel = document.getElementById('modal-panel');
|
|
panel.classList.add('translate-x-full');
|
|
setTimeout(() => document.getElementById('supplier-modal').classList.add('hidden'), 300);
|
|
}
|
|
}
|
|
|
|
// ── Load everything ──
|
|
async function loadAllData() {
|
|
document.getElementById('last-updated').textContent = 'Loading...';
|
|
updateFilterStatus();
|
|
await Promise.all([
|
|
loadSummary(),
|
|
loadSpendOverTime(),
|
|
loadSalesOverTime(),
|
|
loadTopSuppliers(),
|
|
loadTopProducts(),
|
|
loadCategoryBreakdown(),
|
|
]);
|
|
document.getElementById('last-updated').textContent = 'Updated ' + new Date().toLocaleTimeString('en-ZA');
|
|
}
|
|
|
|
// Init
|
|
loadAllData();
|
|
</script>
|
|
</body>
|
|
</html>
|