From c5a0992b5d5dab298500ed5d6c32644d0de34bce Mon Sep 17 00:00:00 2001 From: DeepnaPooja Date: Fri, 16 Oct 2026 15:47:23 +0530 Subject: [PATCH] Fe changes --- .../_components/dashboard-charts.tsx | 142 +++++++ .../_components/dashboard-client.tsx | 198 +++++---- .../network/vehicles/[id]/page.tsx | 5 + .../_components/dashboard/sections.tsx | 385 ++++++++++++++++++ .../dashboard/vehicle-dashboard.tsx | 236 +++++++++++ .../vehicles/_components/vehicles-client.tsx | 9 +- .../network/warehouses/[id]/page.tsx | 5 + .../dashboard/activity-section.tsx | 217 ++++++++++ .../_components/dashboard/format.ts | 56 +++ .../dashboard/movement-section.tsx | 153 +++++++ .../_components/dashboard/shared.tsx | 62 +++ .../_components/dashboard/stock-section.tsx | 226 ++++++++++ .../dashboard/warehouse-dashboard.tsx | 235 +++++++++++ .../_components/warehouses-client.tsx | 9 +- .../_components/product-report-client.tsx | 67 +++ .../stock/_components/stock-report-client.tsx | 331 +++++++++++++++ .../app/(dashboard)/reports/stock/page.tsx | 5 + .../components/shared/export-buttons.tsx | 39 ++ distribution_fe/config/nav.ts | 1 + distribution_fe/lib/api/master.ts | 9 + distribution_fe/lib/api/reports.ts | 74 ++++ distribution_fe/lib/export.ts | 81 ++++ distribution_fe/types/api.ts | 259 ++++++++++++ 23 files changed, 2715 insertions(+), 89 deletions(-) create mode 100644 distribution_fe/app/(dashboard)/_components/dashboard-charts.tsx create mode 100644 distribution_fe/app/(dashboard)/network/vehicles/[id]/page.tsx create mode 100644 distribution_fe/app/(dashboard)/network/vehicles/_components/dashboard/sections.tsx create mode 100644 distribution_fe/app/(dashboard)/network/vehicles/_components/dashboard/vehicle-dashboard.tsx create mode 100644 distribution_fe/app/(dashboard)/network/warehouses/[id]/page.tsx create mode 100644 distribution_fe/app/(dashboard)/network/warehouses/_components/dashboard/activity-section.tsx create mode 100644 distribution_fe/app/(dashboard)/network/warehouses/_components/dashboard/format.ts create mode 100644 distribution_fe/app/(dashboard)/network/warehouses/_components/dashboard/movement-section.tsx create mode 100644 distribution_fe/app/(dashboard)/network/warehouses/_components/dashboard/shared.tsx create mode 100644 distribution_fe/app/(dashboard)/network/warehouses/_components/dashboard/stock-section.tsx create mode 100644 distribution_fe/app/(dashboard)/network/warehouses/_components/dashboard/warehouse-dashboard.tsx create mode 100644 distribution_fe/app/(dashboard)/reports/stock/_components/stock-report-client.tsx create mode 100644 distribution_fe/app/(dashboard)/reports/stock/page.tsx create mode 100644 distribution_fe/components/shared/export-buttons.tsx create mode 100644 distribution_fe/lib/export.ts diff --git a/distribution_fe/app/(dashboard)/_components/dashboard-charts.tsx b/distribution_fe/app/(dashboard)/_components/dashboard-charts.tsx new file mode 100644 index 0000000..9b1b3ed --- /dev/null +++ b/distribution_fe/app/(dashboard)/_components/dashboard-charts.tsx @@ -0,0 +1,142 @@ +'use client' + +import { Area, AreaChart, Bar, BarChart, CartesianGrid, Cell, Label, PolarAngleAxis, Pie, PieChart, RadialBar, RadialBarChart, XAxis, YAxis } from 'recharts' + +import { + ChartContainer, + ChartLegend, + ChartLegendContent, + ChartTooltip, + ChartTooltipContent, + type ChartConfig, +} from '@/components/ui/chart' + +export interface ChartDatum { + name: string + value: number +} + +export interface MonthlySalesPoint { + month: string // yyyy-MM + invoiced: number +} + +const fmtCompact = (v: number) => v.toLocaleString('en-LK', { notation: 'compact', maximumFractionDigits: 1 }) +const fmtMonth = (v: string) => new Date(`${v}-01`).toLocaleDateString('en-GB', { month: 'short', year: '2-digit' }) + +const SLICE_COLORS = ['var(--chart-2)', 'var(--chart-4)', 'var(--chart-1)', 'var(--chart-3)', 'var(--destructive)'] + +function Empty({ text }: { text: string }) { + return

{text}

+} + +export function DonutChart({ data, centerLabel, emptyText }: { data: ChartDatum[]; centerLabel: string; emptyText: string }) { + const total = data.reduce((sum, d) => sum + d.value, 0) + if (total === 0) return + + const slices = data.map((d, i) => ({ ...d, fill: SLICE_COLORS[i % SLICE_COLORS.length] })) + const config = Object.fromEntries(slices.map(s => [s.name, { label: s.name, color: s.fill }])) as ChartConfig + + return ( + + + } /> + + + } /> + + + ) +} + +export function HorizontalBarChart({ data, emptyText, color = 'var(--chart-2)' }: { data: ChartDatum[]; emptyText: string; color?: string }) { + if (data.length === 0 || data.every(d => d.value === 0)) return + + const config = { value: { label: 'Quantity', color } } satisfies ChartConfig + + return ( + + + + + + } /> + + {data.map(d => )} + + + + ) +} + +export function ColumnBarChart({ data, emptyText, label }: { data: ChartDatum[]; emptyText: string; label: string }) { + if (data.length === 0 || data.every(d => d.value === 0)) return + + const config = { value: { label, color: 'var(--chart-2)' } } satisfies ChartConfig + + return ( + + + + + + } /> + + + + ) +} + +export function SalesTrendChart({ data }: { data: MonthlySalesPoint[] }) { + if (data.length === 0) return + + const config = { invoiced: { label: 'Invoiced', color: 'var(--chart-2)' } } satisfies ChartConfig + + return ( + + + + + + + + + + + + fmtMonth(String(v))} />} /> + + + + ) +} + +export function RadialGauge({ pct, label, sub }: { pct: number; label: string; sub: string }) { + const config = { value: { label, color: 'var(--chart-2)' } } satisfies ChartConfig + + return ( +
+
+ + + + + + +
{pct}%
+
+

{sub}

+
+ ) +} diff --git a/distribution_fe/app/(dashboard)/_components/dashboard-client.tsx b/distribution_fe/app/(dashboard)/_components/dashboard-client.tsx index c2d1997..f681a93 100644 --- a/distribution_fe/app/(dashboard)/_components/dashboard-client.tsx +++ b/distribution_fe/app/(dashboard)/_components/dashboard-client.tsx @@ -22,6 +22,8 @@ import { getPaginatedItems, } from '@/lib/api/index' import type { LowStockItemResponse, StockMovementResponse } from '@/types/api' +import { ColumnBarChart, DonutChart, HorizontalBarChart, RadialGauge, SalesTrendChart } from './dashboard-charts' +import type { ChartDatum, MonthlySalesPoint } from './dashboard-charts' const STORAGE_KEY = 'distribution-dashboard-widgets-v3' const ROW_HEIGHT = 36 @@ -31,7 +33,7 @@ type Breakpoint = keyof typeof DEFAULT_BREAKPOINTS const BREAKPOINT_ORDER = Object.keys(DEFAULT_BREAKPOINTS) as Breakpoint[] type Category = 'sales' | 'ops' | 'stock' | 'finance' -type Size = 'sm' | 'md' | 'lg' +type Size = 'sm' | 'md' | 'lg' | 'chart' | 'chartWide' interface WidgetMeta { id: string @@ -59,11 +61,18 @@ const WIDGET_META: WidgetMeta[] = [ { id: 'warehouseBalance', cat: 'stock', title: 'Warehouse Balances', size: 'md' }, { id: 'outstandingPay', cat: 'finance', title: 'Outstanding Payments', size: 'sm' }, { id: 'invoiceStatus', cat: 'finance', title: 'Invoices by Status', size: 'md' }, + { id: 'salesTrendChart', cat: 'finance', title: 'Sales Trend (6 months)', size: 'chartWide' }, + { id: 'paymentStatusChart', cat: 'finance', title: 'Payment Status', size: 'chart' }, + { id: 'poStatusChart', cat: 'sales', title: 'Shop POs by Status', size: 'chart' }, + { id: 'loadingSheetsChart', cat: 'ops', title: 'Loading Sheets Split', size: 'chart' }, + { id: 'warehouseChart', cat: 'stock', title: 'Warehouse Stock Chart', size: 'chart' }, + { id: 'movementTypeChart', cat: 'stock', title: 'Movements by Type', size: 'chart' }, ] const DEFAULT_ACTIVE = [ 'pendingPO', 'awaitingConfirm', 'fulfillRate', 'openRides', 'loadingSheetsByStatus', 'deliveryTasks', 'lowStock', 'outstandingPay', 'invoiceStatus', + 'salesTrendChart', 'poStatusChart', 'paymentStatusChart', 'warehouseChart', ] /** Width (in that breakpoint's own columns), height, and minimums per widget size, per breakpoint. Narrower breakpoints @@ -71,28 +80,38 @@ const DEFAULT_ACTIVE = [ const SIZE_CONFIG: Record> = { lg: { sm: { w: 3, h: 4, minW: 2, minH: 3 }, - md: { w: 6, h: 5, minW: 3, minH: 3 }, + md: { w: 6, h: 6, minW: 3, minH: 3 }, lg: { w: 12, h: 7, minW: 4, minH: 4 }, + chart: { w: 4, h: 7, minW: 3, minH: 5 }, + chartWide: { w: 8, h: 7, minW: 4, minH: 5 }, }, md: { sm: { w: 5, h: 4, minW: 3, minH: 3 }, - md: { w: 10, h: 5, minW: 4, minH: 3 }, + md: { w: 10, h: 6, minW: 4, minH: 3 }, lg: { w: 10, h: 7, minW: 4, minH: 4 }, + chart: { w: 5, h: 7, minW: 3, minH: 5 }, + chartWide: { w: 10, h: 7, minW: 4, minH: 5 }, }, sm: { sm: { w: 3, h: 4, minW: 2, minH: 3 }, - md: { w: 6, h: 5, minW: 3, minH: 3 }, + md: { w: 6, h: 6, minW: 3, minH: 3 }, lg: { w: 6, h: 7, minW: 3, minH: 4 }, + chart: { w: 6, h: 7, minW: 3, minH: 5 }, + chartWide: { w: 6, h: 7, minW: 3, minH: 5 }, }, xs: { sm: { w: 4, h: 4, minW: 2, minH: 3 }, - md: { w: 4, h: 5, minW: 2, minH: 3 }, + md: { w: 4, h: 6, minW: 2, minH: 3 }, lg: { w: 4, h: 7, minW: 2, minH: 4 }, + chart: { w: 4, h: 7, minW: 2, minH: 5 }, + chartWide: { w: 4, h: 7, minW: 2, minH: 5 }, }, xxs: { sm: { w: 2, h: 4, minW: 1, minH: 3 }, - md: { w: 2, h: 5, minW: 1, minH: 3 }, + md: { w: 2, h: 6, minW: 1, minH: 3 }, lg: { w: 2, h: 7, minW: 1, minH: 4 }, + chart: { w: 2, h: 7, minW: 1, minH: 5 }, + chartWide: { w: 2, h: 7, minW: 1, minH: 5 }, }, } @@ -198,6 +217,32 @@ interface DashboardData { draftInvoiceCount: number printedInvoiceCount: number finalizedInvoiceCount: number + poStatusChart: ChartDatum[] + paymentStatusChart: ChartDatum[] + movementTypeChart: ChartDatum[] + monthlySales: MonthlySalesPoint[] +} + +const PO_STATUSES = ['Pending', 'PartiallyFulfilled', 'Fulfilled', 'Cancelled'] as const +const PAYMENT_STATUSES = ['Pending', 'PartiallyPaid', 'Paid'] as const +const SALES_MONTHS = 6 +/** Recent-activity charts aggregate this many latest records client-side. */ +const CHART_SAMPLE_SIZE = 200 + +const splitWords = (s: string) => s.replace(/([a-z])([A-Z])/g, '$1 $2') + +function buildMonthlySales(rows: Array<{ createdAt: string; totalAmount: number }>): MonthlySalesPoint[] { + const now = new Date() + const months: MonthlySalesPoint[] = [] + for (let i = SALES_MONTHS - 1; i >= 0; i--) { + const d = new Date(now.getFullYear(), now.getMonth() - i, 1) + months.push({ month: `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`, invoiced: 0 }) + } + for (const r of rows) { + const bucket = months.find(m => m.month === r.createdAt.slice(0, 7)) + if (bucket) bucket.invoiced += r.totalAmount + } + return months } async function loadDashboardData(): Promise { @@ -211,6 +256,8 @@ async function loadDashboardData(): Promise { balances, outstanding, draftInv, printedInv, finalizedInv, + poStatusCounts, paymentStatusCounts, + recentInvoices, recentMovements, ] = await Promise.all([ shopPos.list({ status: 'Pending', pageSize: 1 }), shopPos.list({ confirmed: true, status: 'Pending', pageSize: 1 }), @@ -228,8 +275,17 @@ async function loadDashboardData(): Promise { invoices.list({ status: 'Draft', pageSize: 1 }), invoices.list({ status: 'Printed', pageSize: 1 }), invoices.list({ status: 'Finalized', pageSize: 1 }), + Promise.all(PO_STATUSES.map(status => shopPos.list({ status, pageSize: 1 }))), + Promise.all(PAYMENT_STATUSES.map(paymentStatus => invoices.list({ paymentStatus, pageSize: 1 }))), + invoices.list({ pageSize: CHART_SAMPLE_SIZE }), + stock.movements({ pageSize: CHART_SAMPLE_SIZE }), ]) + const movementCounts = new Map() + for (const m of getPaginatedItems(recentMovements)) { + movementCounts.set(m.type, (movementCounts.get(m.type) ?? 0) + 1) + } + const balanceRows = getPaginatedItems(balances) const byWarehouse = new Map() for (const b of balanceRows) { @@ -259,6 +315,10 @@ async function loadDashboardData(): Promise { draftInvoiceCount: draftInv.total, printedInvoiceCount: printedInv.total, finalizedInvoiceCount: finalizedInv.total, + poStatusChart: PO_STATUSES.map((s, i) => ({ name: splitWords(s), value: poStatusCounts[i].total })), + paymentStatusChart: PAYMENT_STATUSES.map((s, i) => ({ name: splitWords(s), value: paymentStatusCounts[i].total })), + movementTypeChart: Array.from(movementCounts, ([type, value]) => ({ name: splitWords(type), value })).sort((a, b) => b.value - a.value), + monthlySales: buildMonthlySales(getPaginatedItems(recentInvoices)), } } @@ -271,14 +331,6 @@ function CategoryPill({ cat }: { cat: Category }) { ) } -function Bar({ pct, colorClass }: { pct: number; colorClass: string }) { - return ( -
-
-
- ) -} - function WidgetBody({ id, data }: { id: string; data: DashboardData }) { switch (id) { case 'pendingPO': @@ -297,13 +349,7 @@ function WidgetBody({ id, data }: { id: string; data: DashboardData }) { ) case 'fulfillRate': { const pct = data.totalPOCount > 0 ? Math.round((data.fulfilledPOCount / data.totalPOCount) * 100) : 0 - return ( -
-
{pct}%
- -
{data.fulfilledPOCount} of {data.totalPOCount} orders fulfilled
-
- ) + return } case 'openRides': return data.openRides.length === 0 ? ( @@ -321,29 +367,16 @@ function WidgetBody({ id, data }: { id: string; data: DashboardData }) { ))}
) - case 'loadingSheetsByStatus': { - const total = data.plannedSheetsCount + data.confirmedSheetsCount - const plannedPct = total > 0 ? (data.plannedSheetsCount / total) * 100 : 0 - const confirmedPct = total > 0 ? (data.confirmedSheetsCount / total) * 100 : 0 + case 'loadingSheetsByStatus': return ( -
-
-
- Planned - {data.plannedSheetsCount} -
- -
-
-
- Confirmed - {data.confirmedSheetsCount} -
- -
-
+ ) - } case 'deliveryTasks': return (
@@ -387,24 +420,8 @@ function WidgetBody({ id, data }: { id: string; data: DashboardData }) { ))}
) - case 'warehouseBalance': { - const max = Math.max(1, ...data.warehouseBalances.map(w => w.quantity)) - return data.warehouseBalances.length === 0 ? ( -

No warehouse stock recorded.

- ) : ( -
- {data.warehouseBalances.map(w => ( -
-
- {w.name} - {w.quantity} -
- -
- ))} -
- ) - } + case 'warehouseBalance': + return ({ name: w.name, value: w.quantity }))} emptyText="No warehouse stock recorded." /> case 'outstandingPay': return (
@@ -412,34 +429,39 @@ function WidgetBody({ id, data }: { id: string; data: DashboardData }) {
{data.outstandingInvoiceCount} invoices
) - case 'invoiceStatus': { - const total = data.draftInvoiceCount + data.printedInvoiceCount + data.finalizedInvoiceCount - const segs = [ - { label: 'Draft', count: data.draftInvoiceCount, color: 'bg-slate-400' }, - { label: 'Printed', count: data.printedInvoiceCount, color: 'bg-amber-600' }, - { label: 'Finalized', count: data.finalizedInvoiceCount, color: 'bg-emerald-600' }, - ] + case 'invoiceStatus': return ( -
-
- {segs.map(s => ( -
0 ? `${(s.count / total) * 100}%` : '0%' }} /> - ))} -
-
- {segs.map(s => ( -
- - - {s.label} - - {s.count} -
- ))} -
-
+ ) - } + case 'salesTrendChart': + return + case 'paymentStatusChart': + return + case 'poStatusChart': + return + case 'loadingSheetsChart': + return ( + + ) + case 'warehouseChart': + return ({ name: w.name, value: w.quantity }))} emptyText="No warehouse stock recorded." /> + case 'movementTypeChart': + return default: return null } @@ -653,7 +675,9 @@ export function DashboardClient() {
{w.title}
- +
+ +
))} diff --git a/distribution_fe/app/(dashboard)/network/vehicles/[id]/page.tsx b/distribution_fe/app/(dashboard)/network/vehicles/[id]/page.tsx new file mode 100644 index 0000000..018b6aa --- /dev/null +++ b/distribution_fe/app/(dashboard)/network/vehicles/[id]/page.tsx @@ -0,0 +1,5 @@ +import { VehicleDashboard } from '../_components/dashboard/vehicle-dashboard' + +export default function VehicleDashboardPage() { + return +} diff --git a/distribution_fe/app/(dashboard)/network/vehicles/_components/dashboard/sections.tsx b/distribution_fe/app/(dashboard)/network/vehicles/_components/dashboard/sections.tsx new file mode 100644 index 0000000..3adea22 --- /dev/null +++ b/distribution_fe/app/(dashboard)/network/vehicles/_components/dashboard/sections.tsx @@ -0,0 +1,385 @@ +'use client' + +import { useState } from 'react' +import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from 'recharts' + +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { + ChartContainer, + ChartLegend, + ChartLegendContent, + ChartTooltip, + ChartTooltipContent, + type ChartConfig, +} from '@/components/ui/chart' +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' +import { + MOVEMENT_LABEL, daysAgo, fmtCompact, fmtDate, fmtDateTime, fmtMoney, fmtNum, fmtShortDay, +} from '@/app/(dashboard)/network/warehouses/_components/dashboard/format' +import { EmptyNote, Metric, Section } from '@/app/(dashboard)/network/warehouses/_components/dashboard/shared' +import type { CancelRequestType, RideStatus, VehicleDashboardResponse } from '@/types/api' + +const CANCEL_LABEL: Record = { + VehicleBreakdown: 'Vehicle breakdown', + DriverUnavailable: 'Driver unavailable', + RouteIssue: 'Route issue', + Other: 'Other', +} + +const RIDE_BADGE: Record = { + Open: 'default', + Closed: 'secondary', + Cancelled: 'destructive', +} + +const deliveryConfig = { + delivered: { label: 'Delivered', color: 'var(--chart-2)' }, + failed: { label: 'Failed', color: 'var(--destructive)' }, +} satisfies ChartConfig + +const flowConfig = { + in: { label: 'Units in', color: 'var(--chart-2)' }, + out: { label: 'Units out', color: 'var(--chart-1)' }, +} satisfies ChartConfig + +export function expiryState(date: string): 'expired' | 'soon' | 'ok' { + const days = (new Date(date).getTime() - Date.now()) / 86_400_000 + return days < 0 ? 'expired' : days <= 30 ? 'soon' : 'ok' +} + +// ── Rides & deliveries ────────────────────────────────────────────────────── + +export function RidesSection({ data }: { data: VehicleDashboardResponse }) { + const { kpis, rides, cancelReasons, topStores } = data + return ( +
+
+ + + + + + fmtShortDay(String(v))} />} /> + + + } /> + + +
+ +
+ {rides.length === 0 ? ( + No rides in this period. + ) : ( +
+ + + + Started + Driver + Status + Distance + Delivered + Failed + Notes + + + + {rides.map(r => ( + + {fmtDateTime(r.startedAt ?? r.createdAt)} + {r.driverName} + {r.status} + {r.distanceKm > 0 ? `${fmtNum(r.distanceKm)} km` : '—'} + {r.delivered} / {r.tasks} + {r.failed} + + {r.status === 'Cancelled' + ? `${r.cancelType ? CANCEL_LABEL[r.cancelType] : 'Cancelled'}${r.cancelReason ? ` — ${r.cancelReason}` : ''}` + : r.closedAt ? `Closed ${fmtDateTime(r.closedAt)}` : '—'} + + + ))} + +
+
+ )} +
+ +
+
+ {topStores.length === 0 ? ( + No completed deliveries in this period. + ) : ( +
    + {topStores.map(s => ( +
  • + {s.storeName} + + {s.deliveries} {s.deliveries === 1 ? 'delivery' : 'deliveries'} · {fmtMoney(s.value)} + +
  • + ))} +
+ )} +
+ +
+ {cancelReasons.length === 0 ? ( + No cancelled rides in this period. + ) : ( +
    + {cancelReasons.map(c => ( +
  • + {CANCEL_LABEL[c.type]} + {c.count} +
  • + ))} +
+ )} +
+
+
+ ) +} + +// ── Stock on board ────────────────────────────────────────────────────────── + +export function StockSection({ data }: { data: VehicleDashboardResponse }) { + const { stockOnBoard, kpis } = data + return ( +
+
+ + + + +
+ {stockOnBoard.length === 0 ? ( + The vehicle is empty. + ) : ( +
+ + + + Item + Category + On board + Value + Updated + + + + {stockOnBoard.map(r => ( + + +

{r.name}

+

{r.sku}

+
+ {r.category} + {fmtNum(r.quantity)} {r.uom} + {fmtMoney(r.value)} + {daysAgo(r.updatedAt)} +
+ ))} +
+
+
+ )} +
+ ) +} + +// ── Stock movement ────────────────────────────────────────────────────────── + +export function MovementSection({ data }: { data: VehicleDashboardResponse }) { + const [direction, setDirection] = useState<'all' | 'In' | 'Out'>('all') + const ledger = direction === 'all' ? data.ledger : data.ledger.filter(r => r.direction === direction) + const total = data.movementByType.reduce((sum, r) => sum + r.count, 0) + + return ( +
+
+ + + + + + fmtShortDay(String(v))} />} /> + + + } /> + + +
+ +
+ {data.movementByType.length === 0 ? ( + No movements in this period. + ) : ( +
    + {data.movementByType.map(r => ( +
  • + + {r.direction} + {MOVEMENT_LABEL[r.type]} + + {fmtNum(r.units)} units · {r.count}× +
  • + ))} +
+ )} +
+ +
+
+ {(['all', 'In', 'Out'] as const).map(d => ( + + ))} +
+ {ledger.length === 0 ? ( + No movements to show. + ) : ( +
+ + + + When + Item + Type + Qty + From → To + By + + + + {ledger.map(r => ( + + {fmtDateTime(r.createdAt)} + +

{r.itemName}

+

{r.sku}

+
+ +
+ {r.direction} + {MOVEMENT_LABEL[r.type]} +
+
+ {r.direction === 'In' ? '+' : '−'}{fmtNum(r.quantity)} + {r.fromName} → {r.toName} + {r.createdByName} +
+ ))} +
+
+
+ )} +
+
+ ) +} + +// ── Drivers, collections and returns ──────────────────────────────────────── + +function ExpiryBadge({ date }: { date: string }) { + const state = expiryState(date) + return ( + + {fmtDate(date)} + {state === 'expired' && Expired} + {state === 'soon' && Expiring} + + ) +} + +export function PeopleMoneySection({ data }: { data: VehicleDashboardResponse }) { + const { drivers, collectionsByMethod, returns, kpis } = data + return ( +
+
+ {drivers.length === 0 ? ( + No driver has used this vehicle in this period. + ) : ( +
+ + + + Driver + Rides + Distance + Last ride + Licence expiry + Medical clearance + + + + {drivers.map(d => ( + + +

{d.name}{!d.isActive && Inactive}

+

{d.licenseNumber}

+
+ {d.rides} + {d.distanceKm > 0 ? `${fmtNum(d.distanceKm)} km` : '—'} + {d.lastRideAt ? daysAgo(d.lastRideAt) : '—'} + + +
+ ))} +
+
+
+ )} +
+ +
+
+
+ + +
+ {collectionsByMethod.length === 0 ? ( + No payments collected in this period. + ) : ( +
    + {collectionsByMethod.map(c => ( +
  • + {c.method} + {fmtMoney(c.amount)} · {c.count} {c.count === 1 ? 'payment' : 'payments'} +
  • + ))} +
+ )} +
+ +
+
+ + + + +
+
+
+
+ ) +} diff --git a/distribution_fe/app/(dashboard)/network/vehicles/_components/dashboard/vehicle-dashboard.tsx b/distribution_fe/app/(dashboard)/network/vehicles/_components/dashboard/vehicle-dashboard.tsx new file mode 100644 index 0000000..e8d7877 --- /dev/null +++ b/distribution_fe/app/(dashboard)/network/vehicles/_components/dashboard/vehicle-dashboard.tsx @@ -0,0 +1,236 @@ +'use client' + +import { useEffect, useMemo, useState } from 'react' +import { useParams, useRouter } from 'next/navigation' +import Link from 'next/link' +import { HugeiconsIcon } from '@hugeicons/react' +import { ArrowLeft01Icon, PencilEdit01Icon } from '@hugeicons/core-free-icons' + +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Input } from '@/components/ui/input' +import { Skeleton } from '@/components/ui/skeleton' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { toast } from '@/components/ui/toast' +import { vehicles as vehiclesApi, getApiError } from '@/lib/api/index' +import { daysFromToday, fmtDate, fmtDateTime, fmtMoney, fmtNum } from '@/app/(dashboard)/network/warehouses/_components/dashboard/format' +import { Metric } from '@/app/(dashboard)/network/warehouses/_components/dashboard/shared' +import type { VehicleDashboardResponse } from '@/types/api' + +import { VehicleSheet } from '../vehicle-sheet' +import { MovementSection, PeopleMoneySection, RidesSection, StockSection, expiryState } from './sections' + +const PRESETS = [ + { label: '7 days', days: 7 }, + { label: '30 days', days: 30 }, + { label: '90 days', days: 90 }, +] + +type Alert = { severity: 'danger' | 'warning' | 'info'; text: string } + +function buildAlerts(d: VehicleDashboardResponse): Alert[] { + const alerts: Alert[] = [] + const { kpis, currentRide, drivers } = d + + if (!d.vehicle.isActive) + alerts.push({ severity: 'warning', text: 'This vehicle is inactive.' }) + + if (currentRide) + alerts.push({ + severity: 'info', + text: `Ride in progress with ${currentRide.driverName}${currentRide.startedAt ? ` since ${fmtDateTime(currentRide.startedAt)}` : ''} — ${currentRide.delivered}/${currentRide.tasks} stops delivered`, + }) + else if (kpis.stockOnBoardUnits > 0) + alerts.push({ + severity: 'danger', + text: `${fmtNum(kpis.stockOnBoardUnits)} units are still on the vehicle but there is no open ride — return them to a warehouse`, + }) + + for (const dr of drivers) { + const licence = expiryState(dr.licenseExpiry) + const medical = expiryState(dr.medicalClearanceExpiry) + if (licence === 'expired') alerts.push({ severity: 'danger', text: `${dr.name}'s licence expired on ${fmtDate(dr.licenseExpiry)}` }) + else if (licence === 'soon') alerts.push({ severity: 'warning', text: `${dr.name}'s licence expires on ${fmtDate(dr.licenseExpiry)}` }) + if (medical === 'expired') alerts.push({ severity: 'danger', text: `${dr.name}'s medical clearance expired on ${fmtDate(dr.medicalClearanceExpiry)}` }) + else if (medical === 'soon') alerts.push({ severity: 'warning', text: `${dr.name}'s medical clearance expires on ${fmtDate(dr.medicalClearanceExpiry)}` }) + } + + if (kpis.failedCount > 0) + alerts.push({ severity: 'warning', text: `${kpis.failedCount} failed deliver${kpis.failedCount > 1 ? 'ies' : 'y'} this period (${kpis.successRatePercent}% success rate)` }) + if (kpis.cancelledRides > 0) + alerts.push({ severity: 'warning', text: `${kpis.cancelledRides} ride${kpis.cancelledRides > 1 ? 's' : ''} cancelled this period` }) + + return alerts +} + +const ALERT_BADGE = { + danger: { label: 'Critical', variant: 'destructive' }, + warning: { label: 'Attention', variant: 'outline' }, + info: { label: 'Info', variant: 'secondary' }, +} as const + +export function VehicleDashboard() { + const { id } = useParams<{ id: string }>() + const router = useRouter() + + const [from, setFrom] = useState(() => daysFromToday(-29)) + const [to, setTo] = useState(() => daysFromToday(0)) + const [data, setData] = useState(null) + const [reloadKey, setReloadKey] = useState(0) + const [loadedKey, setLoadedKey] = useState('') + const [editOpen, setEditOpen] = useState(false) + + const requestKey = `${id}|${from}|${to}|${reloadKey}` + const loading = loadedKey !== requestKey + const reload = () => setReloadKey(k => k + 1) + + useEffect(() => { + let cancelled = false + vehiclesApi.dashboard(id, { from, to }) + .then(res => { if (!cancelled) setData(res) }) + .catch(err => { + if (!cancelled) toast.add({ title: 'Failed to load vehicle dashboard', description: getApiError(err), type: 'error' }) + }) + .finally(() => { if (!cancelled) setLoadedKey(requestKey) }) + return () => { cancelled = true } + }, [id, from, to, requestKey]) + + const alerts = useMemo(() => (data ? buildAlerts(data) : []), [data]) + + function applyPreset(days: number) { + setFrom(daysFromToday(-(days - 1))) + setTo(daysFromToday(0)) + } + + const vehicle = data?.vehicle + const k = data?.kpis + + return ( +
+ {/* Header */} +
+
+ +
+ {!vehicle ? ( + + ) : ( +
+

{vehicle.numberPlate}

+ {vehicle.type} + + {vehicle.isActive ? 'Active' : 'Inactive'} + + {data?.currentRide && On a ride} +
+ )} +

+ {vehicle ? `Reg. ${vehicle.regNumber} · Added ${fmtDate(vehicle.createdAt)}` : 'Vehicle overview'} + {data?.defaultWarehouse && ( + <> · Home warehouse: {data.defaultWarehouse.name} + )} + {data?.mobileWarehouse && ( + <> · Mobile warehouse: {data.mobileWarehouse.name} + )} +

+
+
+ + {vehicle && ( + + )} +
+ + {/* Period */} +
+ Activity period + {PRESETS.map(p => ( + + ))} + e.target.value && setFrom(e.target.value)} className="w-40" /> + to + e.target.value && setTo(e.target.value)} className="w-40" /> +
+ + {!data && loading && ( +
+
+ {Array.from({ length: 6 }).map((_, i) => )} +
+ +
+ )} + + {data && k && ( +
+ {/* KPIs */} +
+ + + 0 ? 'warning' : undefined} + /> + + + +
+ + {/* Alerts */} + + + Alerts + + + {alerts.length === 0 ? ( +

Nothing needs attention right now.

+ ) : ( +
    + {alerts.map((a, i) => ( +
  • + + {ALERT_BADGE[a.severity].label} + + {a.text} +
  • + ))} +
+ )} +
+
+ + + + Rides & deliveries + Stock on board + Stock movement + Drivers & collections + + + + + + +
+ )} + + setEditOpen(false)} + vehicle={vehicle ?? null} + onSuccess={reload} + /> +
+ ) +} diff --git a/distribution_fe/app/(dashboard)/network/vehicles/_components/vehicles-client.tsx b/distribution_fe/app/(dashboard)/network/vehicles/_components/vehicles-client.tsx index 5b838af..52c725e 100644 --- a/distribution_fe/app/(dashboard)/network/vehicles/_components/vehicles-client.tsx +++ b/distribution_fe/app/(dashboard)/network/vehicles/_components/vehicles-client.tsx @@ -1,6 +1,7 @@ 'use client' import { useState, useEffect, useCallback, useMemo } from 'react' +import { useRouter } from 'next/navigation' import { HugeiconsIcon } from '@hugeicons/react' import { Add01Icon } from '@hugeicons/core-free-icons' @@ -15,6 +16,7 @@ import type { VehicleResponse } from '@/types/api' import { VehicleSheet } from './vehicle-sheet' export function VehiclesClient() { + const router = useRouter() const [data, setData] = useState([]) const [loading, setLoading] = useState(true) const [total, setTotal] = useState(0) @@ -118,6 +120,11 @@ export function VehiclesClient() { ], rowActions: { actions: (row) => [ + { + action: 'view', + label: 'View dashboard', + onClick: (vehicle: VehicleResponse) => router.push(`/network/vehicles/${vehicle.id}`), + }, { action: 'edit', label: 'Edit vehicle', @@ -131,7 +138,7 @@ export function VehiclesClient() { }, ], }, - }), [pageSize, openEdit, deactivate]) + }), [pageSize, openEdit, deactivate, router]) return ( <> diff --git a/distribution_fe/app/(dashboard)/network/warehouses/[id]/page.tsx b/distribution_fe/app/(dashboard)/network/warehouses/[id]/page.tsx new file mode 100644 index 0000000..14aa090 --- /dev/null +++ b/distribution_fe/app/(dashboard)/network/warehouses/[id]/page.tsx @@ -0,0 +1,5 @@ +import { WarehouseDashboard } from '../_components/dashboard/warehouse-dashboard' + +export default function WarehouseDashboardPage() { + return +} diff --git a/distribution_fe/app/(dashboard)/network/warehouses/_components/dashboard/activity-section.tsx b/distribution_fe/app/(dashboard)/network/warehouses/_components/dashboard/activity-section.tsx new file mode 100644 index 0000000..1969507 --- /dev/null +++ b/distribution_fe/app/(dashboard)/network/warehouses/_components/dashboard/activity-section.tsx @@ -0,0 +1,217 @@ +'use client' + +import { Badge } from '@/components/ui/badge' +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' +import type { WarehouseDashboardResponse } from '@/types/api' + +import { daysAgo, fmtDate, fmtMoney, fmtNum } from './format' +import { EmptyNote, Metric, Section } from './shared' + +export function ActivitySection({ data }: { data: WarehouseDashboardResponse }) { + const { inbound, outbound, vehicles, losses, topMovers, deadStock } = data + const maxMover = Math.max(...topMovers.map(m => m.unitsOut), 1) + + return ( +
+
+
+
+ + + 0 ? 'warning' : undefined} /> + 5 ? 'danger' : undefined} + /> +
+ {inbound.recentGrns.length === 0 ? ( + No goods received in this period. + ) : ( + + + + Received + Supplier + Accepted + Rejected + Cost + + + + {inbound.recentGrns.map(g => ( + + {fmtDate(g.receivedAt)} + {g.supplierName ?? Direct receipt} + {fmtNum(g.acceptedQuantity)} + {fmtNum(g.rejectedQuantity)} + {fmtMoney(g.costValue)} + + ))} + +
+ )} +
+ +
+
+ + +
+ {outbound.recentLoadingSheets.length === 0 ? ( + No loading sheets in this period. + ) : ( + + + + Prepared + Vehicle + Status + Lines + Units + + + + {outbound.recentLoadingSheets.map(s => ( + + {fmtDate(s.populatedAt)} + {s.vehiclePlate} + + {s.status} + + {s.lines} + {fmtNum(s.units)} + + ))} + +
+ )} +
+
+ +
+
+ {vehicles.length === 0 ? ( + No vehicles are linked to this warehouse. + ) : ( + + + + Vehicle + Units on board + SKUs + + + + {vehicles.map(v => ( + + + {v.numberPlate} + {v.regNumber} + {v.isLinked && Linked} + + {fmtNum(v.units)} + {v.skus} + + ))} + +
+ )} +
+ +
+
+ + 0 ? 'warning' : undefined} /> + 0 ? 'warning' : undefined} /> + +
+ {losses.openClaims.length === 0 ? ( + No open supplier claims for goods received here. + ) : ( +
    + {losses.openClaims.map(c => ( +
  • +
    +

    {c.supplierName ?? 'Supplier'}

    +

    {c.details}

    +
    + {fmtNum(c.units)} units · {fmtDate(c.createdAt)} +
  • + ))} +
+ )} +
+
+ +
+
+ {topMovers.length === 0 ? ( + Nothing was dispatched in this period. + ) : ( +
    + {topMovers.map(m => ( +
  • +
    + {m.name} {m.sku} + {fmtNum(m.unitsOut)} units +
    +
    +
    +
    +
  • + ))} +
+ )} +
+ +
+ {deadStock.length === 0 ? ( + No idle stock found. + ) : ( + + + + Item + Qty + Cost value + Last sent out + + + + {deadStock.map(d => ( + + +

{d.name}

+

{d.sku}

+
+ {fmtNum(d.quantity)} + {fmtMoney(d.costValue)} + + {d.lastOutboundAt ? daysAgo(d.lastOutboundAt) : 'Never'} + +
+ ))} +
+
+ )} +
+
+
+ ) +} diff --git a/distribution_fe/app/(dashboard)/network/warehouses/_components/dashboard/format.ts b/distribution_fe/app/(dashboard)/network/warehouses/_components/dashboard/format.ts new file mode 100644 index 0000000..dd77a83 --- /dev/null +++ b/distribution_fe/app/(dashboard)/network/warehouses/_components/dashboard/format.ts @@ -0,0 +1,56 @@ +import type { MovementType, WarehouseType } from '@/types/api' + +export const fmtMoney = (v: number) => + `Rs. ${v.toLocaleString('en-LK', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` + +export const fmtNum = (v: number) => + v.toLocaleString('en-LK', { maximumFractionDigits: 2 }) + +export const fmtCompact = (v: number) => + v.toLocaleString('en-LK', { notation: 'compact', maximumFractionDigits: 1 }) + +export const fmtDate = (v: string) => + new Date(v).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' }) + +export const fmtDateTime = (v: string) => + new Date(v).toLocaleString('en-GB', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' }) + +export const fmtShortDay = (v: string) => + new Date(`${v}T00:00:00`).toLocaleDateString('en-GB', { day: '2-digit', month: 'short' }) + +/** yyyy-MM-dd in local time, the format the dashboard endpoint and date inputs use. */ +export function toIsoDate(d: Date) { + const pad = (n: number) => String(n).padStart(2, '0') + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` +} + +export function daysFromToday(offset: number) { + const d = new Date() + d.setDate(d.getDate() + offset) + return toIsoDate(d) +} + +export const daysAgo = (v: string) => { + const d = Math.floor((Date.now() - new Date(v).getTime()) / 86_400_000) + return d <= 0 ? 'Today' : d === 1 ? '1 day ago' : `${d} days ago` +} + +export const MOVEMENT_LABEL: Record = { + Sale: 'Sale', + CustomerReturn: 'Customer return', + Transfer: 'Transfer', + Damage: 'Damage write-off', + Adjustment: 'Adjustment', + Load: 'Loaded to vehicle', + PurchaseReceipt: 'Purchase receipt (GRN)', + PurchaseReturn: 'Purchase return', +} + +export const WAREHOUSE_TYPE_LABEL: Record = { + DistributionCenter: 'Distribution Center', + FulfillmentCenter: 'Fulfillment Center', + CrossDocking: 'Cross Docking', + ColdStorage: 'Cold Storage', + BondedWarehouse: 'Bonded Warehouse', + AutomatedWarehouse: 'Automated Warehouse', +} diff --git a/distribution_fe/app/(dashboard)/network/warehouses/_components/dashboard/movement-section.tsx b/distribution_fe/app/(dashboard)/network/warehouses/_components/dashboard/movement-section.tsx new file mode 100644 index 0000000..372e188 --- /dev/null +++ b/distribution_fe/app/(dashboard)/network/warehouses/_components/dashboard/movement-section.tsx @@ -0,0 +1,153 @@ +'use client' + +import { useMemo, useState } from 'react' +import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from 'recharts' + +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { + ChartContainer, + ChartLegend, + ChartLegendContent, + ChartTooltip, + ChartTooltipContent, + type ChartConfig, +} from '@/components/ui/chart' +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' +import type { WarehouseDashboardResponse, WarehouseDashboardMovementTypeRow, WarehouseMovementDirection } from '@/types/api' + +import { MOVEMENT_LABEL, fmtCompact, fmtDateTime, fmtNum, fmtShortDay } from './format' +import { EmptyNote, Section } from './shared' + +const flowConfig = { + in: { label: 'Units in', color: 'var(--chart-2)' }, + out: { label: 'Units out', color: 'var(--chart-1)' }, +} satisfies ChartConfig + +type DirectionFilter = 'all' | WarehouseMovementDirection + +function TypeBreakdown({ title, rows }: { title: string; rows: WarehouseDashboardMovementTypeRow[] }) { + const total = rows.reduce((sum, r) => sum + r.units, 0) + return ( +
+ {rows.length === 0 ? ( + No movements in this period. + ) : ( +
    + {rows.map(r => ( +
  • +
    + {MOVEMENT_LABEL[r.type]} + + {fmtNum(r.units)} units · {r.count} {r.count === 1 ? 'entry' : 'entries'} + +
    +
    +
    0 ? (r.units / total) * 100 : 0}%`, + background: r.direction === 'In' ? 'var(--chart-2)' : 'var(--chart-1)', + }} + /> +
    +
  • + ))} +
+ )} +
+ ) +} + +export function MovementSection({ data }: { data: WarehouseDashboardResponse }) { + const [direction, setDirection] = useState('all') + + const inbound = useMemo(() => data.movementByType.filter(r => r.direction === 'In'), [data.movementByType]) + const outbound = useMemo(() => data.movementByType.filter(r => r.direction === 'Out'), [data.movementByType]) + const totalEntries = data.movementByType.reduce((sum, r) => sum + r.count, 0) + const ledger = direction === 'all' ? data.ledger : data.ledger.filter(r => r.direction === direction) + + return ( +
+
+ + + + + + fmtShortDay(String(v))} />} /> + + + } /> + + +
+ +
+ + +
+ +
+
+ {(['all', 'In', 'Out'] as const).map(d => ( + + ))} +
+ + {ledger.length === 0 ? ( + No movements to show. + ) : ( +
+ + + + When + Item + Type + Qty + From → To + Reference + By + + + + {ledger.map(r => ( + + {fmtDateTime(r.createdAt)} + +

{r.itemName}

+

{r.sku}

+
+ +
+ {r.direction} + {MOVEMENT_LABEL[r.type]} +
+ {r.reasonCode &&

{r.reasonCode}

} +
+ + {r.direction === 'In' ? '+' : '−'}{fmtNum(r.quantity)} + + {r.fromName} → {r.toName} + + {r.referenceKind ? `${r.referenceKind} · ${r.referenceId?.slice(0, 8)}` : '—'} + + {r.createdByName} +
+ ))} +
+
+
+ )} +
+
+ ) +} diff --git a/distribution_fe/app/(dashboard)/network/warehouses/_components/dashboard/shared.tsx b/distribution_fe/app/(dashboard)/network/warehouses/_components/dashboard/shared.tsx new file mode 100644 index 0000000..8018a68 --- /dev/null +++ b/distribution_fe/app/(dashboard)/network/warehouses/_components/dashboard/shared.tsx @@ -0,0 +1,62 @@ +import Link from 'next/link' + +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' + +export function Metric({ + label, + value, + sub, + tone, +}: { + label: string + value: string + sub?: string + tone?: 'danger' | 'warning' +}) { + const valueClass = + tone === 'danger' ? 'text-destructive' : tone === 'warning' ? 'text-amber-600 dark:text-amber-400' : '' + return ( +
+ {label} + {value} + {sub && {sub}} +
+ ) +} + +export function Section({ + title, + description, + href, + hrefLabel, + children, +}: { + title: string + description?: string + href?: string + hrefLabel?: string + children: React.ReactNode +}) { + return ( + + +
+
+ {title} + {description && {description}} +
+ {href && ( + + {hrefLabel ?? 'View all'} + + )} +
+
+ {children} +
+ ) +} + +export function EmptyNote({ children }: { children: React.ReactNode }) { + return

{children}

+} diff --git a/distribution_fe/app/(dashboard)/network/warehouses/_components/dashboard/stock-section.tsx b/distribution_fe/app/(dashboard)/network/warehouses/_components/dashboard/stock-section.tsx new file mode 100644 index 0000000..65e9ef7 --- /dev/null +++ b/distribution_fe/app/(dashboard)/network/warehouses/_components/dashboard/stock-section.tsx @@ -0,0 +1,226 @@ +'use client' + +import { Fragment, useMemo, useState } from 'react' +import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from 'recharts' + +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from '@/components/ui/chart' +import { Input } from '@/components/ui/input' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' +import { StockAdjustDialog } from '@/app/(dashboard)/stock/_components/stock-adjust-dialog' +import type { + StockBalanceResponse, + WarehouseDashboardResponse, + WarehouseDashboardStockRow, + WarehouseStockStatus, +} from '@/types/api' + +import { daysAgo, fmtCompact, fmtDate, fmtMoney, fmtNum } from './format' +import { EmptyNote, Section } from './shared' + +const PAGE_SIZE = 25 +const ALL = 'all' + +const categoryConfig = { value: { label: 'Stock value', color: 'var(--chart-2)' } } satisfies ChartConfig + +const STATUS_BADGE: Record = { + OK: { label: 'In stock', variant: 'secondary' }, + Low: { label: 'Low', variant: 'destructive' }, + Out: { label: 'Out of stock', variant: 'destructive' }, +} + +export function StockSection({ + data, + onChanged, +}: { + data: WarehouseDashboardResponse + onChanged: () => void +}) { + const [search, setSearch] = useState('') + const [category, setCategory] = useState(ALL) + const [status, setStatus] = useState(ALL) + const [visible, setVisible] = useState(PAGE_SIZE) + const [expanded, setExpanded] = useState(null) + const [adjusting, setAdjusting] = useState(null) + + const categories = useMemo(() => [...new Set(data.stock.map(r => r.category))].sort(), [data.stock]) + + const rows = useMemo(() => { + const q = search.trim().toLowerCase() + return data.stock.filter(r => + (category === ALL || r.category === category) && + (status === ALL || r.status === status) && + (!q || r.name.toLowerCase().includes(q) || r.sku.toLowerCase().includes(q)), + ) + }, [data.stock, search, category, status]) + + function openAdjust(row: WarehouseDashboardStockRow) { + setAdjusting({ + itemId: row.itemId, + itemName: row.name, + locationId: data.warehouse.id, + locationType: 'Warehouse', + quantity: row.quantity, + updatedAt: row.updatedAt, + locationName: data.warehouse.name, + }) + } + + return ( +
+
+
+
+ {data.stockByCategory.length === 0 ? ( + No stock on hand. + ) : ( + + + + + + fmtMoney(Number(v))} />} /> + + + + )} +
+
+ +
+ {data.expiringBatches.length === 0 ? ( + No batches at risk. + ) : ( +
    + {data.expiringBatches.map(b => ( +
  • +
    +

    {b.itemName}

    +

    + Batch {b.batchNumber ?? '—'} · {fmtNum(b.quantity)} units · {fmtDate(b.expiryDate)} +

    +
    + + {b.status === 'Expired' ? 'Expired' : 'Expiring'} + +
  • + ))} +
+ )} +
+
+ +
+
+ { setSearch(e.target.value); setVisible(PAGE_SIZE) }} + placeholder="Search item or SKU" + className="w-56" + /> + + +
+ + {rows.length === 0 ? ( + No items match the current filters. + ) : ( +
+ + + + Item + Category + On hand + Reorder at + Status + Cost value + Sale value + Updated + + + + + {rows.slice(0, visible).map(r => { + const badge = STATUS_BADGE[r.status] + const hasBatches = r.batches.length > 0 + return ( + + setExpanded(expanded === r.itemId ? null : r.itemId) : undefined} + > + +

{r.name}

+

+ {r.sku}{r.brand ? ` · ${r.brand}` : ''}{hasBatches ? ` · ${r.batches.length} batch${r.batches.length > 1 ? 'es' : ''}` : ''} +

+
+ {r.category} + {fmtNum(r.quantity)} {r.uom} + {r.reorderLevel != null ? fmtNum(r.reorderLevel) : '—'} + {badge.label} + {fmtMoney(r.costValue)} + {fmtMoney(r.saleValue)} + {daysAgo(r.updatedAt)} + + {!hasBatches && ( + + )} + +
+ {expanded === r.itemId && r.batches.map(b => ( + + + Batch {b.batchNumber ?? '—'} + + {fmtNum(b.quantity)} {r.uom} + + {b.expiryDate ? `Expires ${fmtDate(b.expiryDate)}` : 'No expiry date'} + + + ))} +
+ ) + })} +
+
+
+ )} + + {rows.length > visible && ( +
+ +
+ )} +
+ + setAdjusting(null)} + onSuccess={() => { setAdjusting(null); onChanged() }} + balance={adjusting} + /> +
+ ) +} diff --git a/distribution_fe/app/(dashboard)/network/warehouses/_components/dashboard/warehouse-dashboard.tsx b/distribution_fe/app/(dashboard)/network/warehouses/_components/dashboard/warehouse-dashboard.tsx new file mode 100644 index 0000000..a5c46ee --- /dev/null +++ b/distribution_fe/app/(dashboard)/network/warehouses/_components/dashboard/warehouse-dashboard.tsx @@ -0,0 +1,235 @@ +'use client' + +import { useEffect, useMemo, useState } from 'react' +import { useParams, useRouter } from 'next/navigation' +import { HugeiconsIcon } from '@hugeicons/react' +import { ArrowLeft01Icon, PencilEdit01Icon } from '@hugeicons/core-free-icons' + +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Input } from '@/components/ui/input' +import { Skeleton } from '@/components/ui/skeleton' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { toast } from '@/components/ui/toast' +import { warehouses as warehousesApi, getApiError } from '@/lib/api/index' +import type { WarehouseDashboardResponse } from '@/types/api' + +import { WarehouseSheet } from '../warehouse-sheet' +import { ActivitySection } from './activity-section' +import { WAREHOUSE_TYPE_LABEL, daysFromToday, fmtDate, fmtMoney, fmtNum } from './format' +import { MovementSection } from './movement-section' +import { Metric } from './shared' +import { StockSection } from './stock-section' + +const PRESETS = [ + { label: '7 days', days: 7 }, + { label: '30 days', days: 30 }, + { label: '90 days', days: 90 }, +] + +type Alert = { severity: 'danger' | 'warning' | 'info'; text: string } + +function buildAlerts(d: WarehouseDashboardResponse): Alert[] { + const alerts: Alert[] = [] + const { kpis, losses, inbound, outbound } = d + + if (kpis.outOfStockCount > 0) + alerts.push({ severity: 'danger', text: `${kpis.outOfStockCount} item${kpis.outOfStockCount > 1 ? 's' : ''} out of stock` }) + + const low = d.stock.filter(r => r.status === 'Low') + if (low.length > 0) { + const names = low.slice(0, 3).map(r => `${r.name} (${fmtNum(r.quantity)}/${fmtNum(r.reorderLevel ?? 0)})`).join(', ') + alerts.push({ severity: 'warning', text: `${low.length} item${low.length > 1 ? 's' : ''} below reorder level: ${names}${low.length > 3 ? '…' : ''}` }) + } + + if (kpis.expiredCount > 0) + alerts.push({ severity: 'danger', text: `${kpis.expiredCount} expired batch${kpis.expiredCount > 1 ? 'es' : ''} still in stock` }) + if (kpis.nearExpiryCount > 0) + alerts.push({ severity: 'warning', text: `${kpis.nearExpiryCount} batch${kpis.nearExpiryCount > 1 ? 'es' : ''} expiring within 30 days` }) + + if (losses.openClaimCount > 0) + alerts.push({ severity: 'warning', text: `${losses.openClaimCount} open supplier claim${losses.openClaimCount > 1 ? 's' : ''} on goods received here` }) + if (inbound.rejectionRatePercent > 5) + alerts.push({ severity: 'warning', text: `GRN rejection rate is ${inbound.rejectionRatePercent}% this period` }) + if (losses.negativeAdjustmentUnits > 0) + alerts.push({ severity: 'warning', text: `${fmtNum(losses.negativeAdjustmentUnits)} units adjusted down this period` }) + if (losses.damageUnits > 0) + alerts.push({ severity: 'warning', text: `${fmtNum(losses.damageUnits)} units written off as damaged this period` }) + if (outbound.pendingDispatchUnits > 0) + alerts.push({ severity: 'info', text: `${fmtNum(outbound.pendingDispatchUnits)} units reserved on planned loading sheets` }) + + return alerts +} + +const ALERT_BADGE = { + danger: { label: 'Critical', variant: 'destructive' }, + warning: { label: 'Attention', variant: 'outline' }, + info: { label: 'Info', variant: 'secondary' }, +} as const + +export function WarehouseDashboard() { + const { id } = useParams<{ id: string }>() + const router = useRouter() + + const [from, setFrom] = useState(() => daysFromToday(-29)) + const [to, setTo] = useState(() => daysFromToday(0)) + const [data, setData] = useState(null) + const [reloadKey, setReloadKey] = useState(0) + const [loadedKey, setLoadedKey] = useState('') + const [editOpen, setEditOpen] = useState(false) + + const requestKey = `${id}|${from}|${to}|${reloadKey}` + const loading = loadedKey !== requestKey + const reload = () => setReloadKey(k => k + 1) + + useEffect(() => { + let cancelled = false + warehousesApi.dashboard(id, { from, to }) + .then(res => { if (!cancelled) setData(res) }) + .catch(err => { + if (!cancelled) toast.add({ title: 'Failed to load warehouse dashboard', description: getApiError(err), type: 'error' }) + }) + .finally(() => { if (!cancelled) setLoadedKey(requestKey) }) + return () => { cancelled = true } + }, [id, from, to, requestKey]) + + const alerts = useMemo(() => (data ? buildAlerts(data) : []), [data]) + + function applyPreset(days: number) { + setFrom(daysFromToday(-(days - 1))) + setTo(daysFromToday(0)) + } + + const warehouse = data?.warehouse + const k = data?.kpis + + return ( +
+ {/* Header */} +
+
+ +
+ {!warehouse ? ( + + ) : ( +
+

{warehouse.name}

+ {WAREHOUSE_TYPE_LABEL[warehouse.type] ?? warehouse.type} + + {warehouse.isActive ? 'Active' : 'Inactive'} + +
+ )} +

+ {warehouse + ? `${warehouse.location} · Created ${fmtDate(warehouse.createdAt)}` + : 'Warehouse overview'} + {data && data.linkedVehicles.length > 0 && ( + <> · Vehicles: {data.linkedVehicles.map(v => v.numberPlate).join(', ')} + )} +

+
+
+ + {warehouse && ( + + )} +
+ + {/* Period */} +
+ Activity period + {PRESETS.map(p => ( + + ))} + e.target.value && setFrom(e.target.value)} className="w-40" /> + to + e.target.value && setTo(e.target.value)} className="w-40" /> +
+ + {!data && loading && ( +
+
+ {Array.from({ length: 6 }).map((_, i) => )} +
+ +
+ )} + + {data && k && ( +
+ {/* KPIs */} +
+ + + 0 ? 'danger' : k.lowStockCount > 0 ? 'warning' : undefined} + sub="items" + /> + 0 ? 'danger' : k.nearExpiryCount > 0 ? 'warning' : undefined} + sub="near / expired batches" + /> + + +
+ + {/* Alerts */} + + + Alerts + + + {alerts.length === 0 ? ( +

Nothing needs attention right now.

+ ) : ( +
    + {alerts.map((a, i) => ( +
  • + + {ALERT_BADGE[a.severity].label} + + {a.text} +
  • + ))} +
+ )} +
+
+ + + + Stock on hand + Stock movement + Module activity + + + + + +
+ )} + + setEditOpen(false)} + warehouse={warehouse ?? null} + onSuccess={reload} + /> +
+ ) +} diff --git a/distribution_fe/app/(dashboard)/network/warehouses/_components/warehouses-client.tsx b/distribution_fe/app/(dashboard)/network/warehouses/_components/warehouses-client.tsx index f6e88a6..a70857c 100644 --- a/distribution_fe/app/(dashboard)/network/warehouses/_components/warehouses-client.tsx +++ b/distribution_fe/app/(dashboard)/network/warehouses/_components/warehouses-client.tsx @@ -1,6 +1,7 @@ 'use client' import { useState, useEffect, useCallback, useMemo } from 'react' +import { useRouter } from 'next/navigation' import { HugeiconsIcon } from '@hugeicons/react' import { Add01Icon } from '@hugeicons/core-free-icons' @@ -15,6 +16,7 @@ import type { WarehouseResponse } from '@/types/api' import { WarehouseSheet } from './warehouse-sheet' export function WarehousesClient() { + const router = useRouter() const [data, setData] = useState([]) const [loading, setLoading] = useState(true) const [total, setTotal] = useState(0) @@ -118,6 +120,11 @@ export function WarehousesClient() { ], rowActions: { actions: () => [ + { + action: 'view', + label: 'View dashboard', + onClick: (warehouse: WarehouseResponse) => router.push(`/network/warehouses/${warehouse.id}`), + }, { action: 'edit', label: 'Edit warehouse', @@ -137,7 +144,7 @@ export function WarehousesClient() { }, ], }, - }), [pageSize, openEdit, toggleActive]) + }), [pageSize, openEdit, toggleActive, router]) return ( <> diff --git a/distribution_fe/app/(dashboard)/reports/products/_components/product-report-client.tsx b/distribution_fe/app/(dashboard)/reports/products/_components/product-report-client.tsx index 369d090..2e3ff30 100644 --- a/distribution_fe/app/(dashboard)/reports/products/_components/product-report-client.tsx +++ b/distribution_fe/app/(dashboard)/reports/products/_components/product-report-client.tsx @@ -3,12 +3,14 @@ import { useState, useEffect, useCallback, useMemo } from 'react' import { DataTable } from '@/components/shared/data-table' +import { ExportButtons } from '@/components/shared/export-buttons' import type { TableConfig } from '@/components/shared/data-table' import { Button } from '@/components/ui/button' import { Card, CardContent, CardDescription, CardTitle } from '@/components/ui/card' import { Input } from '@/components/ui/input' import { toast } from '@/components/ui/toast' import { getApiError, getPaginatedItems } from '@/lib/api/index' +import { exportExcel, exportPdf, fetchAllPages, type ExportColumn } from '@/lib/export' import { productReports, type ProductReportSummary, @@ -71,6 +73,44 @@ const returnsColumns: TableConfig['columns'] = [ }, ] +const STOCK_EXPORT: ExportColumn[] = [ + { header: 'SKU', value: r => r.sku }, + { header: 'Item', value: r => r.name }, + { header: 'Brand', value: r => r.brand ?? '' }, + { header: 'Category', value: r => r.category }, + { header: 'UOM', value: r => r.uom }, + { header: 'Warehouse', value: r => r.warehouseQty, numeric: true }, + { header: 'Vehicles', value: r => r.vehicleQty, numeric: true }, + { header: 'Total', value: r => r.totalQty, numeric: true }, + { header: 'Stock Value', value: r => r.stockValue, numeric: true }, + { header: 'Low Stock', value: r => (r.isLowStock ? 'Yes' : 'No') }, +] + +const SALES_EXPORT: ExportColumn[] = [ + { header: 'SKU', value: r => r.sku }, + { header: 'Item', value: r => r.name }, + { header: 'Orders', value: r => r.orders, numeric: true }, + { header: 'Stores', value: r => r.stores, numeric: true }, + { header: 'Qty Ordered', value: r => r.qtyOrdered, numeric: true }, + { header: 'Qty Fulfilled', value: r => r.qtyFulfilled, numeric: true }, + { header: 'Sales', value: r => r.salesValue, numeric: true }, + { header: 'Margin', value: r => r.margin, numeric: true }, + { header: 'Margin %', value: r => r.marginPercent, numeric: true }, +] + +const RETURNS_EXPORT: ExportColumn[] = [ + { header: 'SKU', value: r => r.sku }, + { header: 'Item', value: r => r.name }, + { header: 'Returned Qty', value: r => r.returnedQty, numeric: true }, + { header: 'Returned Value', value: r => r.returnedValue, numeric: true }, + { header: 'Sold Qty', value: r => r.soldQty, numeric: true }, + { header: 'Return %', value: r => r.returnPercent, numeric: true }, + { + header: 'By Reason', + value: r => Object.entries(r.qtyByReason).map(([reason, qty]) => `${reason}: ${num(qty)}`).join(', '), + }, +] + export function ProductReportClient() { const [tab, setTab] = useState('stock') const [search, setSearch] = useState('') @@ -128,6 +168,30 @@ export function ProductReportClient() { setTotal(0) } + async function handleExport(format: 'excel' | 'pdf') { + const filters = (p: number, s: number) => ({ + page: p, pageSize: s, search: search || undefined, from: from || undefined, to: to || undefined, + }) + const range = `${from || 'start'} to ${to || 'today'}` + + if (tab === 'stock') { + const rows = await fetchAllPages((p, s) => + productReports.stock({ ...filters(p, s), lowStockOnly }).then(r => ({ items: getPaginatedItems(r), total: r.total }))) + if (format === 'excel') await exportExcel('product-stock', STOCK_EXPORT, rows) + else await exportPdf('product-stock', 'Product Report: Stock', STOCK_EXPORT, rows, lowStockOnly ? 'Low stock only' : undefined) + } else if (tab === 'sales') { + const rows = await fetchAllPages((p, s) => + productReports.sales(filters(p, s)).then(r => ({ items: getPaginatedItems(r), total: r.total }))) + if (format === 'excel') await exportExcel('product-sales', SALES_EXPORT, rows) + else await exportPdf('product-sales', 'Product Report: Sales', SALES_EXPORT, rows, range) + } else { + const rows = await fetchAllPages((p, s) => + productReports.returns(filters(p, s)).then(r => ({ items: getPaginatedItems(r), total: r.total }))) + if (format === 'excel') await exportExcel('product-returns', RETURNS_EXPORT, rows) + else await exportPdf('product-returns', 'Product Report: Returns', RETURNS_EXPORT, rows, range) + } + } + const common = { serverSide: true, stickyHeader: true, pagination: { enabled: true, pageSize, pageSizeOptions: [10, 20, 50] } } const stockConfig = useMemo>( @@ -197,6 +261,9 @@ export function ProductReportClient() { Low stock only )} +
+ +
{tab === 'stock' && ( diff --git a/distribution_fe/app/(dashboard)/reports/stock/_components/stock-report-client.tsx b/distribution_fe/app/(dashboard)/reports/stock/_components/stock-report-client.tsx new file mode 100644 index 0000000..3506c83 --- /dev/null +++ b/distribution_fe/app/(dashboard)/reports/stock/_components/stock-report-client.tsx @@ -0,0 +1,331 @@ +'use client' + +import { useState, useEffect, useCallback, useMemo } from 'react' + +import { DataTable } from '@/components/shared/data-table' +import { ExportButtons } from '@/components/shared/export-buttons' +import type { TableConfig } from '@/components/shared/data-table' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardDescription, CardTitle } from '@/components/ui/card' +import { Input } from '@/components/ui/input' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { toast } from '@/components/ui/toast' +import { getApiError, getPaginatedItems } from '@/lib/api/index' +import { exportExcel, exportPdf, fetchAllPages, type ExportColumn } from '@/lib/export' +import { categories } from '@/lib/api/catalogue' +import { vehicles, warehouses } from '@/lib/api/master' +import { + stockReports, + type StockExpiryRow, + type StockLedgerRow, + type StockOverview, +} from '@/lib/api/reports' + +type Tab = 'ledger' | 'expiry' + +const TABS: { key: Tab; label: string }[] = [ + { key: 'ledger', label: 'Stock Ledger' }, + { key: 'expiry', label: 'Expiry' }, +] + +const ALL = 'all' +const EXPIRY_WINDOWS = [7, 30, 60, 90] + +const num = (v: number | null | undefined) => + v == null ? '—' : v.toLocaleString('en-IN', { maximumFractionDigits: 2 }) + +const fmtDate = (v: string) => new Date(v).toLocaleDateString('en-GB') + +const STATUS_CLASS: Record = { + OK: 'text-emerald-600', + Low: 'text-amber-600', + Out: 'text-red-600', +} + +const ledgerColumns: TableConfig['columns'] = [ + { key: 'name', title: 'Item', fields: [{ key: 'name', primary: true }, { key: 'sku' }], minWidth: 220 }, + { key: 'category', title: 'Category', fields: ['category'], width: 140 }, + { key: 'uom', title: 'UOM', fields: ['uom'], width: 70 }, + { key: 'openingQty', title: 'Opening', fields: [{ key: 'openingQty', transform: v => num(v as number) }], width: 100 }, + { key: 'qtyIn', title: 'In', fields: [{ key: 'qtyIn', transform: v => num(v as number) }], width: 90 }, + { key: 'qtyOut', title: 'Out', fields: [{ key: 'qtyOut', transform: v => num(v as number) }], width: 90 }, + { key: 'closingQty', title: 'Closing', fields: [{ key: 'closingQty', transform: v => num(v as number) }], width: 100 }, + { key: 'currentQty', title: 'On Hand', fields: [{ key: 'currentQty', transform: v => num(v as number) }], width: 100 }, + { key: 'stockValue', title: 'Value', fields: [{ key: 'stockValue', transform: v => num(v as number) }], width: 110 }, + { + key: 'status', + title: 'Status', + fields: ['status'], + width: 90, + cellRender: row => {row.status}, + }, +] + +const expiryColumns: TableConfig['columns'] = [ + { key: 'name', title: 'Item', fields: [{ key: 'name', primary: true }, { key: 'sku' }], minWidth: 220 }, + { key: 'batchNumber', title: 'Batch', fields: [{ key: 'batchNumber', transform: v => (v as string | null) ?? '—' }], width: 130 }, + { key: 'locationName', title: 'Location', fields: [{ key: 'locationName', primary: true }, { key: 'locationType' }], width: 150 }, + { key: 'quantity', title: 'Qty', fields: [{ key: 'quantity', transform: v => num(v as number) }], width: 90 }, + { key: 'value', title: 'Value', fields: [{ key: 'value', transform: v => num(v as number) }], width: 110 }, + { key: 'expiryDate', title: 'Expiry', fields: [{ key: 'expiryDate', transform: v => fmtDate(v as string) }], width: 110 }, + { + key: 'daysLeft', + title: 'Days Left', + fields: ['daysLeft'], + width: 110, + cellRender: row => ( + + {row.daysLeft < 0 ? `Expired ${-row.daysLeft}d ago` : `${row.daysLeft}d`} + + ), + }, +] + +const LEDGER_EXPORT: ExportColumn[] = [ + { header: 'SKU', value: r => r.sku }, + { header: 'Item', value: r => r.name }, + { header: 'Category', value: r => r.category }, + { header: 'UOM', value: r => r.uom }, + { header: 'Opening', value: r => r.openingQty, numeric: true }, + { header: 'In', value: r => r.qtyIn, numeric: true }, + { header: 'Out', value: r => r.qtyOut, numeric: true }, + { header: 'Closing', value: r => r.closingQty, numeric: true }, + { header: 'On Hand', value: r => r.currentQty, numeric: true }, + { header: 'Value', value: r => r.stockValue, numeric: true }, + { header: 'Status', value: r => r.status }, +] + +const EXPIRY_EXPORT: ExportColumn[] = [ + { header: 'SKU', value: r => r.sku }, + { header: 'Item', value: r => r.name }, + { header: 'Batch', value: r => r.batchNumber ?? '' }, + { header: 'Location', value: r => r.locationName }, + { header: 'Qty', value: r => r.quantity, numeric: true }, + { header: 'Value', value: r => r.value, numeric: true }, + { header: 'Expiry', value: r => fmtDate(r.expiryDate) }, + { header: 'Days Left', value: r => r.daysLeft, numeric: true }, +] + +export function StockReportClient() { + const [tab, setTab] = useState('ledger') + const [search, setSearch] = useState('') + const [from, setFrom] = useState('') + const [to, setTo] = useState('') + const [locationId, setLocationId] = useState(ALL) + const [categoryId, setCategoryId] = useState(ALL) + const [status, setStatus] = useState(ALL) + const [expiryDays, setExpiryDays] = useState(30) + + const [locationOptions, setLocationOptions] = useState<{ id: string; label: string }[]>([]) + const [categoryOptions, setCategoryOptions] = useState<{ id: string; name: string }[]>([]) + const [overview, setOverview] = useState(null) + const [ledgerData, setLedgerData] = useState([]) + const [expiryData, setExpiryData] = useState([]) + const [loading, setLoading] = useState(true) + const [total, setTotal] = useState(0) + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(20) + + useEffect(() => { + Promise.all([warehouses.list({ pageSize: 200 }), vehicles.list({ pageSize: 200 }), categories.list()]) + .then(([w, v, c]) => { + setLocationOptions([ + ...getPaginatedItems(w).map(x => ({ id: x.id, label: `Warehouse · ${x.name}` })), + ...getPaginatedItems(v).map(x => ({ id: x.id, label: `Vehicle · ${x.numberPlate}` })), + ]) + setCategoryOptions(c.map(x => ({ id: x.id, name: x.name }))) + }) + .catch(err => toast.add({ title: 'Failed to load filters', description: getApiError(err), type: 'error' })) + }, []) + + const location = locationId === ALL ? undefined : locationId + + const ledgerParams = useCallback( + (p: number, s: number) => ({ + page: p, + pageSize: s, + search: search || undefined, + locationId: location, + from: from || undefined, + to: to || undefined, + categoryId: categoryId === ALL ? undefined : categoryId, + status: status === ALL ? undefined : (status as 'low' | 'out'), + }), + [search, location, from, to, categoryId, status], + ) + + const expiryParams = useCallback( + (p: number, s: number) => ({ page: p, pageSize: s, search: search || undefined, locationId: location, days: expiryDays }), + [search, location, expiryDays], + ) + + const fetchData = useCallback(async () => { + setLoading(true) + try { + if (tab === 'ledger') { + const res = await stockReports.ledger(ledgerParams(page, pageSize)) + setLedgerData(getPaginatedItems(res)) + setTotal(res.total) + } else { + const res = await stockReports.expiry(expiryParams(page, pageSize)) + setExpiryData(getPaginatedItems(res)) + setTotal(res.total) + } + } catch (err) { + toast.add({ title: 'Failed to load stock report', description: getApiError(err), type: 'error' }) + } finally { + setLoading(false) + } + }, [tab, page, pageSize, ledgerParams, expiryParams]) + + useEffect(() => { + fetchData() + }, [fetchData]) + + useEffect(() => { + stockReports + .overview({ locationId: location, expiryDays }) + .then(setOverview) + .catch(err => toast.add({ title: 'Failed to load summary', description: getApiError(err), type: 'error' })) + }, [location, expiryDays]) + + function changeTab(next: Tab) { + setTab(next) + setPage(1) + setTotal(0) + } + + async function handleExport(format: 'excel' | 'pdf') { + const locationLabel = locationOptions.find(o => o.id === locationId)?.label ?? 'All locations' + if (tab === 'ledger') { + const rows = await fetchAllPages((p, s) => + stockReports.ledger(ledgerParams(p, s)).then(r => ({ items: getPaginatedItems(r), total: r.total })), + ) + const range = `${from || 'last 30 days'} to ${to || 'today'}` + if (format === 'excel') await exportExcel('stock-ledger', LEDGER_EXPORT, rows) + else await exportPdf('stock-ledger', 'Stock Ledger', LEDGER_EXPORT, rows, `${locationLabel} · ${range}`) + } else { + const rows = await fetchAllPages((p, s) => + stockReports.expiry(expiryParams(p, s)).then(r => ({ items: getPaginatedItems(r), total: r.total })), + ) + if (format === 'excel') await exportExcel('stock-expiry', EXPIRY_EXPORT, rows) + else await exportPdf('stock-expiry', 'Stock Expiry', EXPIRY_EXPORT, rows, `${locationLabel} · within ${expiryDays} days`) + } + } + + const common = { serverSide: true, stickyHeader: true, pagination: { enabled: true, pageSize, pageSizeOptions: [10, 20, 50] } } + + const ledgerConfig = useMemo>( + () => ({ ...common, rowKey: r => r.itemId, emptyMessage: 'No products found.', columns: ledgerColumns }), + [pageSize], + ) + const expiryConfig = useMemo>( + () => ({ + ...common, + rowKey: r => `${r.batchId}-${r.locationId}`, + emptyMessage: 'No batches expiring in this window.', + columns: expiryColumns, + }), + [pageSize], + ) + + const onServerSideChange = ({ page: p }: { page: { page: number; pageSize: number } }) => { + setPage(p.page) + setPageSize(p.pageSize) + } + + const cards: [string, string][] = overview + ? [ + ['Products in stock', num(overview.totalSkus)], + ['Total qty', num(overview.totalQty)], + ['Stock value', num(overview.totalValue)], + ['Low stock', num(overview.lowStockCount)], + ['Out of stock', num(overview.outOfStockCount)], + [`Expiring in ${expiryDays}d`, num(overview.expiringSoonCount)], + ['Expired', num(overview.expiredCount)], + ] + : [] + + return ( + <> +
+

Stock Report

+

+ Opening, in, out and closing stock across all locations or one warehouse or vehicle, plus batches nearing expiry. +

+
+ + {overview && ( +
+ {cards.map(([label, value]) => ( + + + {label} + {value} + + + ))} +
+ )} + +
+ {TABS.map(t => ( + + ))} + + { setSearch(e.target.value); setPage(1) }} + /> + {tab === 'ledger' ? ( + <> + + + { setFrom(e.target.value); setPage(1) }} /> + { setTo(e.target.value); setPage(1) }} /> + + ) : ( + + )} +
+ +
+
+ + {tab === 'ledger' && ( + + )} + {tab === 'expiry' && ( + + )} + + ) +} diff --git a/distribution_fe/app/(dashboard)/reports/stock/page.tsx b/distribution_fe/app/(dashboard)/reports/stock/page.tsx new file mode 100644 index 0000000..64ff8b6 --- /dev/null +++ b/distribution_fe/app/(dashboard)/reports/stock/page.tsx @@ -0,0 +1,5 @@ +import { StockReportClient } from './_components/stock-report-client' + +export default function StockReportPage() { + return +} diff --git a/distribution_fe/components/shared/export-buttons.tsx b/distribution_fe/components/shared/export-buttons.tsx new file mode 100644 index 0000000..4280961 --- /dev/null +++ b/distribution_fe/components/shared/export-buttons.tsx @@ -0,0 +1,39 @@ +'use client' + +import { useState } from 'react' + +import { Button } from '@/components/ui/button' +import { toast } from '@/components/ui/toast' +import { getApiError } from '@/lib/api/index' + +interface ExportButtonsProps { + /** Runs the export for the chosen format; fetch the full filtered data inside. */ + onExport: (format: 'excel' | 'pdf') => Promise + disabled?: boolean +} + +export function ExportButtons({ onExport, disabled }: ExportButtonsProps) { + const [busy, setBusy] = useState<'excel' | 'pdf' | null>(null) + + async function run(format: 'excel' | 'pdf') { + setBusy(format) + try { + await onExport(format) + } catch (err) { + toast.add({ title: 'Export failed', description: getApiError(err), type: 'error' }) + } finally { + setBusy(null) + } + } + + return ( + <> + + + + ) +} diff --git a/distribution_fe/config/nav.ts b/distribution_fe/config/nav.ts index 9d86b5d..8d81d68 100644 --- a/distribution_fe/config/nav.ts +++ b/distribution_fe/config/nav.ts @@ -168,6 +168,7 @@ export const NAV_GROUPS: NavGroup[] = [ icon: ClipboardListIcon, items: [ { title: 'Product Report', href: '/reports/products', icon: ClipboardListIcon, description: 'Stock, sales and returns per product', permission: 'warehouses.view' }, + { title: 'Stock Report', href: '/reports/stock', icon: Package01Icon, description: 'Opening, in, out and closing stock, low stock and expiry', permission: 'warehouses.view' }, ], }, ] diff --git a/distribution_fe/lib/api/master.ts b/distribution_fe/lib/api/master.ts index a13bf59..fcbdf82 100644 --- a/distribution_fe/lib/api/master.ts +++ b/distribution_fe/lib/api/master.ts @@ -12,6 +12,8 @@ import type { RouteVisitResponse, VehicleResponse, WarehouseResponse, + WarehouseDashboardResponse, + VehicleDashboardResponse, VehicleType, WarehouseType, } from '@/types/api' @@ -199,6 +201,10 @@ export const vehicles = { get: (id: string) => api.get(`/vehicles/${id}`).then(r => normalizeVehicle(r.data)), + dashboard: (id: string, params?: { from?: string; to?: string }) => + api.get(`/vehicles/${id}/dashboard`, { params }) + .then(r => ({ ...r.data, vehicle: normalizeVehicle(r.data.vehicle) })), + create: (body: VehicleBody) => api.post('/vehicles', serializeVehicleBody(body)).then(r => normalizeVehicle(r.data)), @@ -226,6 +232,9 @@ export const warehouses = { get: (id: string) => api.get(`/warehouses/${id}`).then(r => r.data), + dashboard: (id: string, params?: { from?: string; to?: string; deadStockDays?: number }) => + api.get(`/warehouses/${id}/dashboard`, { params }).then(r => r.data), + create: (body: WarehouseBody) => api.post('/warehouses', body).then(r => r.data), diff --git a/distribution_fe/lib/api/reports.ts b/distribution_fe/lib/api/reports.ts index a3dcdd9..8d5122e 100644 --- a/distribution_fe/lib/api/reports.ts +++ b/distribution_fe/lib/api/reports.ts @@ -72,6 +72,80 @@ export interface ProductStockParams extends ProductReportParams { lowStockOnly?: boolean } +export interface StockOverview { + totalSkus: number + totalQty: number + totalValue: number + lowStockCount: number + outOfStockCount: number + expiringSoonCount: number + expiredCount: number +} + +export type StockLedgerStatus = 'OK' | 'Low' | 'Out' + +export interface StockLedgerRow { + itemId: string + sku: string + name: string + brand: string | null + category: string + uom: string + openingQty: number + qtyIn: number + qtyOut: number + closingQty: number + currentQty: number + costPrice: number | null + stockValue: number + reorderLevel: number | null + status: StockLedgerStatus +} + +export interface StockExpiryRow { + itemId: string + sku: string + name: string + batchId: string + batchNumber: string | null + expiryDate: string + daysLeft: number + quantity: number + costPrice: number | null + value: number + locationId: string + locationType: string + locationName: string +} + +export interface StockLedgerParams extends PageParams { + locationId?: string + categoryId?: string + brandId?: string + from?: string + to?: string + status?: 'low' | 'out' + activeOnly?: boolean + search?: string +} + +export interface StockExpiryParams extends PageParams { + locationId?: string + days?: number + search?: string +} + +export const stockReports = { + overview: (params?: { locationId?: string; expiryDays?: number }) => + api.get('/reports/stock/overview', { params }).then(r => r.data), + + ledger: (params?: StockLedgerParams) => + api.get>('/reports/stock/ledger', { params }).then(r => r.data), + + expiry: (params?: StockExpiryParams) => + api.get>('/reports/stock/expiry', { params }).then(r => r.data), +} + export const productReports = { stock: (params?: ProductStockParams) => api.get>('/reports/products/stock', { params }).then(r => r.data), diff --git a/distribution_fe/lib/export.ts b/distribution_fe/lib/export.ts new file mode 100644 index 0000000..3f82403 --- /dev/null +++ b/distribution_fe/lib/export.ts @@ -0,0 +1,81 @@ +export interface ExportColumn { + header: string + value: (row: T) => string | number | null | undefined + /** Right-align and format as a number in Excel. */ + numeric?: boolean +} + +const MAX_PAGE_SIZE = 200 + +/** Walks every page of a server-paged endpoint and returns all rows. */ +export async function fetchAllPages( + fetchPage: (page: number, pageSize: number) => Promise<{ items: T[]; total: number }>, +): Promise { + const rows: T[] = [] + for (let page = 1; ; page++) { + const res = await fetchPage(page, MAX_PAGE_SIZE) + rows.push(...res.items) + if (res.items.length === 0 || rows.length >= res.total) return rows + } +} + +function stamp() { + return new Date().toISOString().slice(0, 10) +} + +function download(blob: Blob, filename: string) { + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = filename + a.click() + URL.revokeObjectURL(url) +} + +export async function exportExcel(name: string, columns: ExportColumn[], rows: T[]) { + const { Workbook } = await import('exceljs') + const wb = new Workbook() + const ws = wb.addWorksheet(name.slice(0, 31)) + + ws.columns = columns.map(c => ({ header: c.header, width: Math.max(c.header.length + 4, 14) })) + ws.getRow(1).font = { bold: true } + ws.views = [{ state: 'frozen', ySplit: 1 }] + + rows.forEach(r => ws.addRow(columns.map(c => c.value(r) ?? ''))) + columns.forEach((c, i) => { + if (c.numeric) { + const col = ws.getColumn(i + 1) + col.numFmt = '#,##0.##' + col.alignment = { horizontal: 'right' } + } + }) + + const buffer = await wb.xlsx.writeBuffer() + download( + new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }), + `${name}-${stamp()}.xlsx`, + ) +} + +export async function exportPdf(name: string, title: string, columns: ExportColumn[], rows: T[], subtitle?: string) { + const [{ jsPDF }, { default: autoTable }] = await Promise.all([import('jspdf'), import('jspdf-autotable')]) + const doc = new jsPDF({ orientation: columns.length > 6 ? 'landscape' : 'portrait', unit: 'pt', format: 'a4' }) + + doc.setFontSize(14) + doc.text(title, 40, 40) + doc.setFontSize(9) + doc.text(subtitle ? `${subtitle} · Generated ${stamp()}` : `Generated ${stamp()}`, 40, 56) + + autoTable(doc, { + startY: 68, + head: [columns.map(c => c.header)], + body: rows.map(r => columns.map(c => String(c.value(r) ?? ''))), + styles: { fontSize: 8 }, + headStyles: { fillColor: [55, 65, 81] }, + columnStyles: Object.fromEntries( + columns.map((c, i) => [i, c.numeric ? { halign: 'right' as const } : {}]), + ), + }) + + doc.save(`${name}-${stamp()}.pdf`) +} diff --git a/distribution_fe/types/api.ts b/distribution_fe/types/api.ts index 22dc773..a9fe150 100644 --- a/distribution_fe/types/api.ts +++ b/distribution_fe/types/api.ts @@ -185,6 +185,97 @@ export interface VehicleResponse { createdAt: string } +// ─── Vehicle dashboard ──────────────────────────────────────────────────────── + +export interface VehicleDashboardKpis { + rides: number + closedRides: number + cancelledRides: number + distanceKm: number + deliveries: number + deliveredCount: number + failedCount: number + pendingCount: number + successRatePercent: number + deliveredValue: number + collections: number + stockOnBoardUnits: number + stockOnBoardValue: number + unitsLoaded: number + unitsSold: number +} + +export interface VehicleDashboardStockRow { + itemId: string + sku: string + name: string + category: string + uom: string + quantity: number + value: number + updatedAt: string +} + +export interface VehicleDashboardRide { + id: string + status: RideStatus + driverName: string + startedAt: string | null + closedAt: string | null + createdAt: string + distanceKm: number + tasks: number + delivered: number + failed: number + cancelType: CancelRequestType | null + cancelReason: string | null +} + +export interface VehicleDashboardDriver { + id: string + name: string + licenseNumber: string + licenseExpiry: string + medicalClearanceExpiry: string + isActive: boolean + rides: number + distanceKm: number + lastRideAt: string | null +} + +export interface VehicleDashboardResponse { + vehicle: VehicleResponse + defaultWarehouse: { id: string; name: string } | null + mobileWarehouse: { id: string; name: string } | null + currentRide: { + id: string + driverName: string + startedAt: string | null + meterStart: number + tasks: number + delivered: number + failed: number + } | null + period: { from: string; to: string } + kpis: VehicleDashboardKpis + stockOnBoard: VehicleDashboardStockRow[] + rides: VehicleDashboardRide[] + dailyDeliveries: { date: string; delivered: number; failed: number }[] + dailyMovement: { date: string; in: number; out: number }[] + movementByType: WarehouseDashboardMovementTypeRow[] + ledger: WarehouseDashboardLedgerRow[] + cancelReasons: { type: CancelRequestType; count: number }[] + collectionsByMethod: { method: PaymentMethod; amount: number; count: number }[] + returns: { + returnCount: number + creditValue: number + returnedUnits: number + customerReturnUnitsOnVehicle: number + } + topStores: { storeId: string; storeName: string; deliveries: number; value: number }[] + drivers: VehicleDashboardDriver[] +} + export interface WarehouseResponse { id: string name: string @@ -195,6 +286,174 @@ export interface WarehouseResponse { createdAt: string } +// ─── Warehouse dashboard ────────────────────────────────────────────────────── + +export type WarehouseStockStatus = 'OK' | 'Low' | 'Out' +export type WarehouseMovementDirection = 'In' | 'Out' + +export interface WarehouseDashboardKpis { + stockValueCost: number + stockValueSale: number + totalUnits: number + skuCount: number + lowStockCount: number + outOfStockCount: number + nearExpiryCount: number + expiredCount: number + unitsIn: number + unitsOut: number +} + +export interface WarehouseDashboardBatch { + batchId: string + batchNumber: string | null + expiryDate: string | null + quantity: number +} + +export interface WarehouseDashboardStockRow { + itemId: string + sku: string + name: string + category: string + brand: string | null + uom: string + quantity: number + reorderLevel: number | null + status: WarehouseStockStatus + costValue: number + saleValue: number + updatedAt: string + batches: WarehouseDashboardBatch[] +} + +export interface WarehouseDashboardCategoryStock { + category: string + units: number + value: number +} + +export interface WarehouseDashboardExpiringBatch { + itemId: string + itemName: string + batchNumber: string | null + expiryDate: string + quantity: number + status: 'NearExpiry' | 'Expired' +} + +export interface WarehouseDashboardDailyPoint { + date: string + in: number + out: number +} + +export interface WarehouseDashboardMovementTypeRow { + type: MovementType + direction: WarehouseMovementDirection + units: number + count: number +} + +export interface WarehouseDashboardLedgerRow { + id: string + createdAt: string + itemId: string + itemName: string + sku: string + type: MovementType + direction: WarehouseMovementDirection + quantity: number + fromName: string + toName: string + referenceId: string | null + referenceKind: string | null + reasonCode: string | null + createdByName: string +} + +export interface WarehouseDashboardGrnRow { + id: string + receivedAt: string + purchaseOrderId: string | null + supplierName: string | null + acceptedQuantity: number + rejectedQuantity: number + costValue: number +} + +export interface WarehouseDashboardLoadingSheetRow { + id: string + rideId: string + vehiclePlate: string + status: LoadingSheetStatus + populatedAt: string + confirmedAt: string | null + lines: number + units: number +} + +export interface WarehouseDashboardVehicle { + vehicleId: string + regNumber: string + numberPlate: string + isLinked: boolean + units: number + skus: number +} + +export interface WarehouseDashboardClaimRow { + id: string + createdAt: string + supplierName: string | null + details: string + units: number +} + +export interface WarehouseDashboardResponse { + warehouse: WarehouseResponse + linkedVehicles: { id: string; regNumber: string; numberPlate: string; isPrimary: boolean }[] + period: { from: string; to: string } + deadStockDays: number + kpis: WarehouseDashboardKpis + stock: WarehouseDashboardStockRow[] + stockByCategory: WarehouseDashboardCategoryStock[] + expiringBatches: WarehouseDashboardExpiringBatch[] + dailyMovement: WarehouseDashboardDailyPoint[] + movementByType: WarehouseDashboardMovementTypeRow[] + ledger: WarehouseDashboardLedgerRow[] + inbound: { + grnCount: number + acceptedUnits: number + rejectedUnits: number + rejectionRatePercent: number + recentGrns: WarehouseDashboardGrnRow[] + } + outbound: { + unitsDispatched: number + pendingDispatchUnits: number + recentLoadingSheets: WarehouseDashboardLoadingSheetRow[] + } + vehicles: WarehouseDashboardVehicle[] + losses: { + purchaseReturnUnits: number + damageUnits: number + negativeAdjustmentUnits: number + positiveAdjustmentUnits: number + openClaimCount: number + openClaims: WarehouseDashboardClaimRow[] + } + topMovers: { itemId: string; sku: string; name: string; unitsOut: number }[] + deadStock: { + itemId: string + sku: string + name: string + quantity: number + costValue: number + lastOutboundAt: string | null + }[] +} + // ─── Stores ─────────────────────────────────────────────────────────────────── export interface StoreResponse {