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:
@@ -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 · ${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(),
|
||||
|
||||
Reference in New Issue
Block a user