Files
supplier-dashboard/src/resources/views/dashboard.blade.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 &middot; ${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 &middot; ${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>