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 (
+
+ )
+}
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.
+ ) : (
+
+ )}
+
+
+
+ {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.
+ ) : (
+