Add date range filter and product details modal

- All dashboard API endpoints now accept ?from=&to= date parameters
- Date filter bar with From/To inputs, Apply and Clear buttons
- New product details endpoint (GET /api/dashboard/product-details?code=)
- Clickable product list items open slide-over modal with:
  - Sales over time chart (revenue + profit)
  - Top customers list
  - Suppliers list
This commit is contained in:
2026-03-06 00:02:21 +02:00
parent 23357e09f5
commit ef3efc539a
3 changed files with 353 additions and 47 deletions

View File

@@ -117,6 +117,17 @@
</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">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">
@@ -273,12 +284,12 @@
<!-- 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="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()" class="w-8 h-8 rounded-lg bg-white/5 hover:bg-white/10 flex items-center justify-center transition-colors">
<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>
@@ -294,6 +305,29 @@
</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);
@@ -339,12 +373,50 @@
Chart.defaults.animation.duration = 800;
// ── Chart instances ──
let chartSpendTime, chartSalesTime, chartCategory, chartSupplierBar, chartModalTimeline;
let chartSpendTime, chartSalesTime, chartCategory, chartSupplierBar, chartModalTimeline, chartProductTimeline;
// ── Date filter helpers ──
function getDateParams() {
const from = document.getElementById('filter-from').value;
const to = document.getElementById('filter-to').value;
const params = new URLSearchParams();
if (from) params.set('from', from);
if (to) params.set('to', to);
return params.toString();
}
function clearDateFilter() {
document.getElementById('filter-from').value = '';
document.getElementById('filter-to').value = '';
document.getElementById('filter-status').textContent = '';
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 res = await fetch(url);
if (!res.ok) throw new Error(`Failed to fetch ${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();
}
@@ -529,8 +601,9 @@ function update(currentTime) {
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="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>
@@ -548,6 +621,7 @@ function update(currentTime) {
<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('');
}
@@ -671,15 +745,152 @@ function openSupplierModal(code) {
});
}
function closeModal() {
const panel = document.getElementById('modal-panel');
panel.classList.add('translate-x-full');
setTimeout(() => document.getElementById('supplier-modal').classList.add('hidden'), 300);
// ── 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(),