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

@@ -3,6 +3,7 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
class DashboardController extends Controller class DashboardController extends Controller
@@ -12,28 +13,45 @@ public function index()
return view('dashboard'); return view('dashboard');
} }
public function summary(): JsonResponse private function dateRange(Request $request): array
{ {
$from = $request->query('from');
$to = $request->query('to');
return [
$from ?: null,
$to ? $to . ' 23:59:59' : null,
];
}
public function summary(Request $request): JsonResponse
{
[$from, $to] = $this->dateRange($request);
$totalSuppliers = DB::table('PL_SUPPLIERACCOUNT')->count(); $totalSuppliers = DB::table('PL_SUPPLIERACCOUNT')->count();
$totalProducts = DB::table('STK_STOCKITEM')->where('ISACTVE', 'Y')->count(); $totalProducts = DB::table('STK_STOCKITEM')->where('ISACTVE', 'Y')->count();
$totalCustomers = DB::table('SL_CUSTOMERACCOUNT')->count(); $totalCustomers = DB::table('SL_CUSTOMERACCOUNT')->count();
$purchaseSpend = DB::table('PL_BILLTRAN') $purchaseQ = DB::table('PL_BILLTRAN as il')
->selectRaw('COALESCE(SUM(QTYTOINVOICE * COSTPRICE / COSTPRICEPER), 0) as total') ->join('PL_BILL as i', 'il.REFNO', '=', 'i.REFNO');
->value('total'); if ($from) $purchaseQ->where('i.DOCDTETME', '>=', $from);
if ($to) $purchaseQ->where('i.DOCDTETME', '<=', $to);
$purchaseSpend = $purchaseQ->selectRaw('COALESCE(SUM(il.QTYTOINVOICE * il.COSTPRICE / il.COSTPRICEPER), 0) as total')->value('total');
$salesRevenue = DB::table('SL_SALESINVOICETRAN') $salesQ = DB::table('SL_SALESINVOICETRAN as sl')
->selectRaw('COALESCE(SUM(QTYTOINVOICE * SELLINGPRICE / SELLINGPRICEPER), 0) as total') ->join('SL_SALESINVOICE as si', 'sl.REFNO', '=', 'si.REFNO');
->value('total'); if ($from) $salesQ->where('si.DOCDTETME', '>=', $from);
if ($to) $salesQ->where('si.DOCDTETME', '<=', $to);
$salesCost = DB::table('SL_SALESINVOICETRAN') $salesRevenue = (clone $salesQ)->selectRaw('COALESCE(SUM(sl.QTYTOINVOICE * sl.SELLINGPRICE / sl.SELLINGPRICEPER), 0) as total')->value('total');
->selectRaw('COALESCE(SUM(QTYTOINVOICE * COSTPRICE / COSTPRICEPER), 0) as total') $salesCost = (clone $salesQ)->selectRaw('COALESCE(SUM(sl.QTYTOINVOICE * sl.COSTPRICE / sl.COSTPRICEPER), 0) as total')->value('total');
->value('total');
$grossProfit = $salesRevenue - $salesCost; $grossProfit = $salesRevenue - $salesCost;
$margin = $salesRevenue > 0 ? round(($grossProfit / $salesRevenue) * 100, 1) : 0; $margin = $salesRevenue > 0 ? round(($grossProfit / $salesRevenue) * 100, 1) : 0;
$totalInvoices = DB::table('PL_BILL')->count(); $invoiceQ = DB::table('PL_BILL');
if ($from) $invoiceQ->where('DOCDTETME', '>=', $from);
if ($to) $invoiceQ->where('DOCDTETME', '<=', $to);
$totalInvoices = $invoiceQ->count();
return response()->json([ return response()->json([
'total_suppliers' => $totalSuppliers, 'total_suppliers' => $totalSuppliers,
@@ -47,29 +65,37 @@ public function summary(): JsonResponse
]); ]);
} }
public function topSuppliers(): JsonResponse public function topSuppliers(Request $request): JsonResponse
{ {
$suppliers = DB::table('PL_BILLTRAN as il') [$from, $to] = $this->dateRange($request);
$q = DB::table('PL_BILLTRAN as il')
->join('PL_BILL as i', 'il.REFNO', '=', 'i.REFNO') ->join('PL_BILL as i', 'il.REFNO', '=', 'i.REFNO')
->selectRaw('i.ACCNO as code, i.SUPLNME as name, COUNT(DISTINCT i.REFNO) as invoice_count, SUM(il.QTYTOINVOICE * il.COSTPRICE / il.COSTPRICEPER) as total_spend, SUM(il.QTYTOINVOICE) as total_qty') ->selectRaw('i.ACCNO as code, i.SUPLNME as name, COUNT(DISTINCT i.REFNO) as invoice_count, SUM(il.QTYTOINVOICE * il.COSTPRICE / il.COSTPRICEPER) as total_spend, SUM(il.QTYTOINVOICE) as total_qty')
->groupBy('i.ACCNO', 'i.SUPLNME') ->groupBy('i.ACCNO', 'i.SUPLNME')
->orderByDesc('total_spend') ->orderByDesc('total_spend')
->limit(10) ->limit(10);
->get(); if ($from) $q->where('i.DOCDTETME', '>=', $from);
if ($to) $q->where('i.DOCDTETME', '<=', $to);
return response()->json($suppliers); return response()->json($q->get());
} }
public function topProducts(): JsonResponse public function topProducts(Request $request): JsonResponse
{ {
$products = DB::table('SL_SALESINVOICETRAN as sl') [$from, $to] = $this->dateRange($request);
$q = DB::table('SL_SALESINVOICETRAN as sl')
->join('STK_STOCKITEM as p', 'sl.STOCKCODE', '=', 'p.STOCKCODE') ->join('STK_STOCKITEM as p', 'sl.STOCKCODE', '=', 'p.STOCKCODE')
->join('SL_SALESINVOICE as si', 'sl.REFNO', '=', 'si.REFNO')
->selectRaw('sl.STOCKCODE as code, p.DESCRIPTION as name, SUM(sl.QTYTOINVOICE) as total_qty_sold, SUM(sl.QTYTOINVOICE * sl.SELLINGPRICE / sl.SELLINGPRICEPER) as total_revenue, SUM(sl.QTYTOINVOICE * sl.COSTPRICE / sl.COSTPRICEPER) as total_cost') ->selectRaw('sl.STOCKCODE as code, p.DESCRIPTION as name, SUM(sl.QTYTOINVOICE) as total_qty_sold, SUM(sl.QTYTOINVOICE * sl.SELLINGPRICE / sl.SELLINGPRICEPER) as total_revenue, SUM(sl.QTYTOINVOICE * sl.COSTPRICE / sl.COSTPRICEPER) as total_cost')
->groupBy('sl.STOCKCODE', 'p.DESCRIPTION') ->groupBy('sl.STOCKCODE', 'p.DESCRIPTION')
->orderByDesc('total_revenue') ->orderByDesc('total_revenue')
->limit(10) ->limit(10);
->get() if ($from) $q->where('si.DOCDTETME', '>=', $from);
->map(function ($item) { if ($to) $q->where('si.DOCDTETME', '<=', $to);
$products = $q->get()->map(function ($item) {
$item->margin = $item->total_revenue > 0 $item->margin = $item->total_revenue > 0
? round((($item->total_revenue - $item->total_cost) / $item->total_revenue) * 100, 1) ? round((($item->total_revenue - $item->total_cost) / $item->total_revenue) * 100, 1)
: 0; : 0;
@@ -79,29 +105,36 @@ public function topProducts(): JsonResponse
return response()->json($products); return response()->json($products);
} }
public function spendOverTime(): JsonResponse public function spendOverTime(Request $request): JsonResponse
{ {
$data = DB::table('PL_BILLTRAN as il') [$from, $to] = $this->dateRange($request);
$q = DB::table('PL_BILLTRAN as il')
->join('PL_BILL as i', 'il.REFNO', '=', 'i.REFNO') ->join('PL_BILL as i', 'il.REFNO', '=', 'i.REFNO')
->selectRaw("DATE_FORMAT(i.DOCDTETME, '%Y-%m') as month, SUM(il.QTYTOINVOICE * il.COSTPRICE / il.COSTPRICEPER) as total_spend") ->selectRaw("DATE_FORMAT(i.DOCDTETME, '%Y-%m') as month, SUM(il.QTYTOINVOICE * il.COSTPRICE / il.COSTPRICEPER) as total_spend")
->whereNotNull('i.DOCDTETME') ->whereNotNull('i.DOCDTETME')
->groupByRaw("DATE_FORMAT(i.DOCDTETME, '%Y-%m')") ->groupByRaw("DATE_FORMAT(i.DOCDTETME, '%Y-%m')")
->orderBy('month') ->orderBy('month');
->get(); if ($from) $q->where('i.DOCDTETME', '>=', $from);
if ($to) $q->where('i.DOCDTETME', '<=', $to);
return response()->json($data); return response()->json($q->get());
} }
public function salesOverTime(): JsonResponse public function salesOverTime(Request $request): JsonResponse
{ {
$data = DB::table('SL_SALESINVOICETRAN as sl') [$from, $to] = $this->dateRange($request);
$q = DB::table('SL_SALESINVOICETRAN as sl')
->join('SL_SALESINVOICE as si', 'sl.REFNO', '=', 'si.REFNO') ->join('SL_SALESINVOICE as si', 'sl.REFNO', '=', 'si.REFNO')
->selectRaw("DATE_FORMAT(si.DOCDTETME, '%Y-%m') as month, SUM(sl.QTYTOINVOICE * sl.SELLINGPRICE / sl.SELLINGPRICEPER) as total_revenue, SUM(sl.QTYTOINVOICE * sl.COSTPRICE / sl.COSTPRICEPER) as total_cost") ->selectRaw("DATE_FORMAT(si.DOCDTETME, '%Y-%m') as month, SUM(sl.QTYTOINVOICE * sl.SELLINGPRICE / sl.SELLINGPRICEPER) as total_revenue, SUM(sl.QTYTOINVOICE * sl.COSTPRICE / sl.COSTPRICEPER) as total_cost")
->whereNotNull('si.DOCDTETME') ->whereNotNull('si.DOCDTETME')
->groupByRaw("DATE_FORMAT(si.DOCDTETME, '%Y-%m')") ->groupByRaw("DATE_FORMAT(si.DOCDTETME, '%Y-%m')")
->orderBy('month') ->orderBy('month');
->get() if ($from) $q->where('si.DOCDTETME', '>=', $from);
->map(function ($item) { if ($to) $q->where('si.DOCDTETME', '<=', $to);
$data = $q->get()->map(function ($item) {
$item->gross_profit = round($item->total_revenue - $item->total_cost, 2); $item->gross_profit = round($item->total_revenue - $item->total_cost, 2);
return $item; return $item;
}); });
@@ -109,17 +142,21 @@ public function salesOverTime(): JsonResponse
return response()->json($data); return response()->json($data);
} }
public function categoryBreakdown(): JsonResponse public function categoryBreakdown(Request $request): JsonResponse
{ {
$data = DB::table('SL_SALESINVOICETRAN as sl') [$from, $to] = $this->dateRange($request);
$q = DB::table('SL_SALESINVOICETRAN as sl')
->join('STK_STOCKITEM as p', 'sl.STOCKCODE', '=', 'p.STOCKCODE') ->join('STK_STOCKITEM as p', 'sl.STOCKCODE', '=', 'p.STOCKCODE')
->join('SL_SALESINVOICE as si', 'sl.REFNO', '=', 'si.REFNO')
->leftJoin('STK_STOCKCATEGORY as c', 'p.CATEGORY', '=', 'c.STCKCTGRYCDE') ->leftJoin('STK_STOCKCATEGORY as c', 'p.CATEGORY', '=', 'c.STCKCTGRYCDE')
->selectRaw("COALESCE(c.STCKCTGRYDESC, 'Uncategorized') as category, SUM(sl.QTYTOINVOICE * sl.SELLINGPRICE / sl.SELLINGPRICEPER) as revenue, SUM(sl.QTYTOINVOICE * sl.COSTPRICE / sl.COSTPRICEPER) as cost, SUM(sl.QTYTOINVOICE) as qty") ->selectRaw("COALESCE(c.STCKCTGRYDESC, 'Uncategorized') as category, SUM(sl.QTYTOINVOICE * sl.SELLINGPRICE / sl.SELLINGPRICEPER) as revenue, SUM(sl.QTYTOINVOICE * sl.COSTPRICE / sl.COSTPRICEPER) as cost, SUM(sl.QTYTOINVOICE) as qty")
->groupByRaw("COALESCE(c.STCKCTGRYDESC, 'Uncategorized')") ->groupByRaw("COALESCE(c.STCKCTGRYDESC, 'Uncategorized')")
->orderByDesc('revenue') ->orderByDesc('revenue');
->get(); if ($from) $q->where('si.DOCDTETME', '>=', $from);
if ($to) $q->where('si.DOCDTETME', '<=', $to);
return response()->json($data); return response()->json($q->get());
} }
public function supplierProducts(string $supplierCode): JsonResponse public function supplierProducts(string $supplierCode): JsonResponse
@@ -154,4 +191,61 @@ public function supplierProducts(string $supplierCode): JsonResponse
'timeline' => $timeline, 'timeline' => $timeline,
]); ]);
} }
public function productDetails(Request $request): JsonResponse
{
$stockCode = $request->query('code', '');
if (!$stockCode) {
return response()->json(['error' => 'Missing code parameter'], 400);
}
$product = DB::table('STK_STOCKITEM as p')
->leftJoin('STK_STOCKCATEGORY as c', 'p.CATEGORY', '=', 'c.STCKCTGRYCDE')
->where('p.STOCKCODE', $stockCode)
->selectRaw("p.STOCKCODE as code, p.DESCRIPTION as name, COALESCE(c.STCKCTGRYDESC, 'Uncategorized') as category")
->first();
// Sales timeline
$salesTimeline = DB::table('SL_SALESINVOICETRAN as sl')
->join('SL_SALESINVOICE as si', 'sl.REFNO', '=', 'si.REFNO')
->where('sl.STOCKCODE', $stockCode)
->whereNotNull('si.DOCDTETME')
->selectRaw("DATE_FORMAT(si.DOCDTETME, '%Y-%m') as month, SUM(sl.QTYTOINVOICE) as qty_sold, SUM(sl.QTYTOINVOICE * sl.SELLINGPRICE / sl.SELLINGPRICEPER) as revenue, SUM(sl.QTYTOINVOICE * sl.COSTPRICE / sl.COSTPRICEPER) as cost")
->groupByRaw("DATE_FORMAT(si.DOCDTETME, '%Y-%m')")
->orderBy('month')
->get()
->map(function ($item) {
$item->profit = round($item->revenue - $item->cost, 2);
return $item;
});
// Top customers buying this product
$topCustomers = DB::table('SL_SALESINVOICETRAN as sl')
->join('SL_SALESINVOICE as si', 'sl.REFNO', '=', 'si.REFNO')
->join('SL_CUSTOMERACCOUNT as ca', 'si.ACCNO', '=', 'ca.DBTRCDE')
->where('sl.STOCKCODE', $stockCode)
->selectRaw('ca.DBTRCDE as code, ca.DBTRNME as name, SUM(sl.QTYTOINVOICE) as total_qty, SUM(sl.QTYTOINVOICE * sl.SELLINGPRICE / sl.SELLINGPRICEPER) as total_revenue, COUNT(DISTINCT si.REFNO) as invoice_count')
->groupBy('ca.DBTRCDE', 'ca.DBTRNME')
->orderByDesc('total_revenue')
->limit(15)
->get();
// Suppliers providing this product
$suppliers = DB::table('PL_BILLTRAN as il')
->join('PL_BILL as i', 'il.REFNO', '=', 'i.REFNO')
->join('PL_SUPPLIERACCOUNT as sa', 'i.ACCNO', '=', 'sa.SUPLCDE')
->where('il.STOCKCODE', $stockCode)
->selectRaw('sa.SUPLCDE as code, sa.SUPLNME as name, SUM(il.QTYTOINVOICE) as total_qty, SUM(il.QTYTOINVOICE * il.COSTPRICE / il.COSTPRICEPER) as total_cost, COUNT(DISTINCT i.REFNO) as invoice_count')
->groupBy('sa.SUPLCDE', 'sa.SUPLNME')
->orderByDesc('total_cost')
->limit(10)
->get();
return response()->json([
'product' => $product,
'sales_timeline' => $salesTimeline,
'top_customers' => $topCustomers,
'suppliers' => $suppliers,
]);
}
} }

View File

@@ -117,6 +117,17 @@
</button> </button>
</div> </div>
</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> </header>
<main class="max-w-[1600px] mx-auto px-6 py-8 space-y-8"> <main class="max-w-[1600px] mx-auto px-6 py-8 space-y-8">
@@ -273,12 +284,12 @@
<!-- Supplier Detail Modal --> <!-- Supplier Detail Modal -->
<div id="supplier-modal" class="fixed inset-0 z-50 hidden"> <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="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="p-6">
<div class="flex items-center justify-between mb-6"> <div class="flex items-center justify-between mb-6">
<h2 class="text-lg font-bold" id="modal-supplier-name">Supplier Details</h2> <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> <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> </button>
</div> </div>
@@ -294,6 +305,29 @@
</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> <script>
// ── Helpers ── // ── Helpers ──
const fmt = (n) => new Intl.NumberFormat('en-ZA', { style: 'currency', currency: 'ZAR', maximumFractionDigits: 0 }).format(n); 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.defaults.animation.duration = 800;
// ── Chart instances ── // ── 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 ── // ── Data loading ──
async function fetchJson(url) { async function fetchJson(url) {
const res = await fetch(url); const qs = getDateParams();
if (!res.ok) throw new Error(`Failed to fetch ${url}`); 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(); return res.json();
} }
@@ -529,8 +601,9 @@ function update(currentTime) {
const maxRev = data.length > 0 ? Math.max(...data.map(d => d.total_revenue)) : 1; const maxRev = data.length > 0 ? Math.max(...data.map(d => d.total_revenue)) : 1;
container.innerHTML = data.map((p, i) => ` 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" <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"> 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'}"> <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} ${i + 1}
</div> </div>
@@ -548,6 +621,7 @@ function update(currentTime) {
<div class="text-sm font-semibold">${fmt(p.total_revenue)}</div> <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 class="text-xs text-slate-500">${fmtNum(p.total_qty_sold)} units</div>
</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> </div>
`).join(''); `).join('');
} }
@@ -671,15 +745,152 @@ function openSupplierModal(code) {
}); });
} }
function closeModal() { // ── Product Modal ──
const panel = document.getElementById('modal-panel'); function openProductModal(code) {
panel.classList.add('translate-x-full'); const modal = document.getElementById('product-modal');
setTimeout(() => document.getElementById('supplier-modal').classList.add('hidden'), 300); 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 ── // ── Load everything ──
async function loadAllData() { async function loadAllData() {
document.getElementById('last-updated').textContent = 'Loading...'; document.getElementById('last-updated').textContent = 'Loading...';
updateFilterStatus();
await Promise.all([ await Promise.all([
loadSummary(), loadSummary(),
loadSpendOverTime(), loadSpendOverTime(),

View File

@@ -14,4 +14,5 @@
Route::get('/sales-over-time', [DashboardController::class, 'salesOverTime']); Route::get('/sales-over-time', [DashboardController::class, 'salesOverTime']);
Route::get('/category-breakdown', [DashboardController::class, 'categoryBreakdown']); Route::get('/category-breakdown', [DashboardController::class, 'categoryBreakdown']);
Route::get('/supplier/{code}', [DashboardController::class, 'supplierProducts']); Route::get('/supplier/{code}', [DashboardController::class, 'supplierProducts']);
Route::get('/product-details', [DashboardController::class, 'productDetails']);
}); });