Add Stargas supplier dashboard with performance analytics
This commit is contained in:
698
src/resources/views/dashboard.blade.php
Normal file
698
src/resources/views/dashboard.blade.php
Normal file
@@ -0,0 +1,698 @@
|
||||
<!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">
|
||||
<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>
|
||||
</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()"></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()" 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>
|
||||
|
||||
<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;
|
||||
|
||||
// ── Data loading ──
|
||||
async function fetchJson(url) {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`Failed to fetch ${url}`);
|
||||
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 fade-in"
|
||||
style="animation-delay: ${i * 60}ms">
|
||||
<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>
|
||||
</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) } }
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
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...';
|
||||
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>
|
||||
Reference in New Issue
Block a user