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:
@@ -3,6 +3,7 @@
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class DashboardController extends Controller
|
||||
@@ -12,28 +13,45 @@ public function index()
|
||||
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();
|
||||
$totalProducts = DB::table('STK_STOCKITEM')->where('ISACTVE', 'Y')->count();
|
||||
$totalCustomers = DB::table('SL_CUSTOMERACCOUNT')->count();
|
||||
|
||||
$purchaseSpend = DB::table('PL_BILLTRAN')
|
||||
->selectRaw('COALESCE(SUM(QTYTOINVOICE * COSTPRICE / COSTPRICEPER), 0) as total')
|
||||
->value('total');
|
||||
$purchaseQ = DB::table('PL_BILLTRAN as il')
|
||||
->join('PL_BILL as i', 'il.REFNO', '=', 'i.REFNO');
|
||||
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')
|
||||
->selectRaw('COALESCE(SUM(QTYTOINVOICE * SELLINGPRICE / SELLINGPRICEPER), 0) as total')
|
||||
->value('total');
|
||||
$salesQ = DB::table('SL_SALESINVOICETRAN as sl')
|
||||
->join('SL_SALESINVOICE as si', 'sl.REFNO', '=', 'si.REFNO');
|
||||
if ($from) $salesQ->where('si.DOCDTETME', '>=', $from);
|
||||
if ($to) $salesQ->where('si.DOCDTETME', '<=', $to);
|
||||
|
||||
$salesCost = DB::table('SL_SALESINVOICETRAN')
|
||||
->selectRaw('COALESCE(SUM(QTYTOINVOICE * COSTPRICE / COSTPRICEPER), 0) as total')
|
||||
->value('total');
|
||||
$salesRevenue = (clone $salesQ)->selectRaw('COALESCE(SUM(sl.QTYTOINVOICE * sl.SELLINGPRICE / sl.SELLINGPRICEPER), 0) as total')->value('total');
|
||||
$salesCost = (clone $salesQ)->selectRaw('COALESCE(SUM(sl.QTYTOINVOICE * sl.COSTPRICE / sl.COSTPRICEPER), 0) as total')->value('total');
|
||||
|
||||
$grossProfit = $salesRevenue - $salesCost;
|
||||
$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([
|
||||
'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')
|
||||
->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')
|
||||
->orderByDesc('total_spend')
|
||||
->limit(10)
|
||||
->get();
|
||||
->limit(10);
|
||||
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('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')
|
||||
->groupBy('sl.STOCKCODE', 'p.DESCRIPTION')
|
||||
->orderByDesc('total_revenue')
|
||||
->limit(10)
|
||||
->get()
|
||||
->map(function ($item) {
|
||||
->limit(10);
|
||||
if ($from) $q->where('si.DOCDTETME', '>=', $from);
|
||||
if ($to) $q->where('si.DOCDTETME', '<=', $to);
|
||||
|
||||
$products = $q->get()->map(function ($item) {
|
||||
$item->margin = $item->total_revenue > 0
|
||||
? round((($item->total_revenue - $item->total_cost) / $item->total_revenue) * 100, 1)
|
||||
: 0;
|
||||
@@ -79,29 +105,36 @@ public function topProducts(): JsonResponse
|
||||
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')
|
||||
->selectRaw("DATE_FORMAT(i.DOCDTETME, '%Y-%m') as month, SUM(il.QTYTOINVOICE * il.COSTPRICE / il.COSTPRICEPER) as total_spend")
|
||||
->whereNotNull('i.DOCDTETME')
|
||||
->groupByRaw("DATE_FORMAT(i.DOCDTETME, '%Y-%m')")
|
||||
->orderBy('month')
|
||||
->get();
|
||||
->orderBy('month');
|
||||
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')
|
||||
->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')
|
||||
->groupByRaw("DATE_FORMAT(si.DOCDTETME, '%Y-%m')")
|
||||
->orderBy('month')
|
||||
->get()
|
||||
->map(function ($item) {
|
||||
->orderBy('month');
|
||||
if ($from) $q->where('si.DOCDTETME', '>=', $from);
|
||||
if ($to) $q->where('si.DOCDTETME', '<=', $to);
|
||||
|
||||
$data = $q->get()->map(function ($item) {
|
||||
$item->gross_profit = round($item->total_revenue - $item->total_cost, 2);
|
||||
return $item;
|
||||
});
|
||||
@@ -109,17 +142,21 @@ public function salesOverTime(): JsonResponse
|
||||
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('SL_SALESINVOICE as si', 'sl.REFNO', '=', 'si.REFNO')
|
||||
->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")
|
||||
->groupByRaw("COALESCE(c.STCKCTGRYDESC, 'Uncategorized')")
|
||||
->orderByDesc('revenue')
|
||||
->get();
|
||||
->orderByDesc('revenue');
|
||||
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
|
||||
@@ -154,4 +191,61 @@ public function supplierProducts(string $supplierCode): JsonResponse
|
||||
'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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
// ── 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(),
|
||||
|
||||
@@ -14,4 +14,5 @@
|
||||
Route::get('/sales-over-time', [DashboardController::class, 'salesOverTime']);
|
||||
Route::get('/category-breakdown', [DashboardController::class, 'categoryBreakdown']);
|
||||
Route::get('/supplier/{code}', [DashboardController::class, 'supplierProducts']);
|
||||
Route::get('/product-details', [DashboardController::class, 'productDetails']);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user