Refactor/main flow #60

Merged
DeepnaPooja merged 5 commits from refactor/main_flow into dev 2026-10-16 15:55:09 +05:30
23 changed files with 2715 additions and 89 deletions
Showing only changes of commit c5a0992b5d - Show all commits
@@ -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 <p className="text-xs text-muted-foreground">{text}</p>
}
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 <Empty text={emptyText} />
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 (
<ChartContainer config={config} className="h-full min-h-40 w-full">
<PieChart>
<ChartTooltip content={<ChartTooltipContent hideLabel nameKey="name" />} />
<Pie data={slices} dataKey="value" nameKey="name" innerRadius="55%" strokeWidth={2}>
<Label
content={({ viewBox }) => {
if (!viewBox || !('cx' in viewBox)) return null
return (
<text x={viewBox.cx} y={viewBox.cy} textAnchor="middle" dominantBaseline="middle">
<tspan x={viewBox.cx} y={(viewBox.cy ?? 0) - 6} className="fill-foreground text-base font-semibold">{fmtCompact(total)}</tspan>
<tspan x={viewBox.cx} y={(viewBox.cy ?? 0) + 12} className="fill-muted-foreground text-[11px]">{centerLabel}</tspan>
</text>
)
}}
/>
</Pie>
<ChartLegend content={<ChartLegendContent nameKey="name" />} />
</PieChart>
</ChartContainer>
)
}
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 <Empty text={emptyText} />
const config = { value: { label: 'Quantity', color } } satisfies ChartConfig
return (
<ChartContainer config={config} className="h-full min-h-40 w-full">
<BarChart data={data} layout="vertical" margin={{ left: 0, right: 8 }}>
<CartesianGrid horizontal={false} />
<XAxis type="number" tickFormatter={fmtCompact} tickLine={false} axisLine={false} />
<YAxis type="category" dataKey="name" tickLine={false} axisLine={false} width={96} />
<ChartTooltip content={<ChartTooltipContent hideLabel />} />
<Bar dataKey="value" fill="var(--color-value)" radius={4} maxBarSize={22}>
{data.map(d => <Cell key={d.name} fill={color} />)}
</Bar>
</BarChart>
</ChartContainer>
)
}
export function ColumnBarChart({ data, emptyText, label }: { data: ChartDatum[]; emptyText: string; label: string }) {
if (data.length === 0 || data.every(d => d.value === 0)) return <Empty text={emptyText} />
const config = { value: { label, color: 'var(--chart-2)' } } satisfies ChartConfig
return (
<ChartContainer config={config} className="h-full min-h-40 w-full">
<BarChart data={data}>
<CartesianGrid vertical={false} />
<XAxis dataKey="name" tickLine={false} axisLine={false} interval={0} tick={{ fontSize: 10 }} />
<YAxis tickFormatter={fmtCompact} tickLine={false} axisLine={false} width={40} />
<ChartTooltip content={<ChartTooltipContent hideLabel />} />
<Bar dataKey="value" fill="var(--color-value)" radius={4} maxBarSize={36} />
</BarChart>
</ChartContainer>
)
}
export function SalesTrendChart({ data }: { data: MonthlySalesPoint[] }) {
if (data.length === 0) return <Empty text="No invoices yet." />
const config = { invoiced: { label: 'Invoiced', color: 'var(--chart-2)' } } satisfies ChartConfig
return (
<ChartContainer config={config} className="h-full min-h-40 w-full">
<AreaChart data={data}>
<defs>
<linearGradient id="dashFillInvoiced" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="var(--color-invoiced)" stopOpacity={0.5} />
<stop offset="95%" stopColor="var(--color-invoiced)" stopOpacity={0.05} />
</linearGradient>
</defs>
<CartesianGrid vertical={false} />
<XAxis dataKey="month" tickFormatter={fmtMonth} tickLine={false} axisLine={false} />
<YAxis tickFormatter={fmtCompact} tickLine={false} axisLine={false} width={48} />
<ChartTooltip content={<ChartTooltipContent labelFormatter={(v) => fmtMonth(String(v))} />} />
<Area dataKey="invoiced" type="monotone" stroke="var(--color-invoiced)" fill="url(#dashFillInvoiced)" />
</AreaChart>
</ChartContainer>
)
}
export function RadialGauge({ pct, label, sub }: { pct: number; label: string; sub: string }) {
const config = { value: { label, color: 'var(--chart-2)' } } satisfies ChartConfig
return (
<div className="flex h-full min-h-32 items-center gap-4">
<div className="relative aspect-square h-full max-h-40 min-h-24">
<ChartContainer config={config} className="size-full">
<RadialBarChart data={[{ value: pct, fill: 'var(--color-value)' }]} startAngle={90} endAngle={-270} innerRadius="75%" outerRadius="100%">
<PolarAngleAxis type="number" domain={[0, 100]} tick={false} />
<RadialBar dataKey="value" background cornerRadius={10} />
</RadialBarChart>
</ChartContainer>
<div className="pointer-events-none absolute inset-0 flex items-center justify-center text-lg font-semibold text-foreground">{pct}%</div>
</div>
<p className="text-xs text-muted-foreground">{sub}</p>
</div>
)
}
@@ -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<Breakpoint, Record<Size, { w: number; h: number; minW: number; minH: number }>> = {
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<DashboardData> {
@@ -211,6 +256,8 @@ async function loadDashboardData(): Promise<DashboardData> {
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<DashboardData> {
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<string, number>()
for (const m of getPaginatedItems(recentMovements)) {
movementCounts.set(m.type, (movementCounts.get(m.type) ?? 0) + 1)
}
const balanceRows = getPaginatedItems(balances)
const byWarehouse = new Map<string, WarehouseBalanceRow>()
for (const b of balanceRows) {
@@ -259,6 +315,10 @@ async function loadDashboardData(): Promise<DashboardData> {
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 (
<div className="h-2 rounded-full bg-muted overflow-hidden">
<div className={`h-full rounded-full ${colorClass}`} style={{ width: `${Math.min(100, Math.max(0, pct))}%` }} />
</div>
)
}
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 (
<div>
<div className="mb-2.5 text-2xl font-bold text-foreground">{pct}%</div>
<Bar pct={pct} colorClass="bg-blue-600" />
<div className="mt-2.5 text-xs text-muted-foreground">{data.fulfilledPOCount} of {data.totalPOCount} orders fulfilled</div>
</div>
)
return <RadialGauge pct={pct} label="Fulfilled" sub={`${data.fulfilledPOCount} of ${data.totalPOCount} orders fulfilled`} />
}
case 'openRides':
return data.openRides.length === 0 ? (
@@ -321,29 +367,16 @@ function WidgetBody({ id, data }: { id: string; data: DashboardData }) {
))}
</div>
)
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 (
<div className="flex flex-col gap-3">
<div>
<div className="mb-1 flex justify-between text-xs">
<span className="text-muted-foreground">Planned</span>
<span className="font-semibold text-foreground">{data.plannedSheetsCount}</span>
</div>
<Bar pct={plannedPct} colorClass="bg-amber-600" />
</div>
<div>
<div className="mb-1 flex justify-between text-xs">
<span className="text-muted-foreground">Confirmed</span>
<span className="font-semibold text-foreground">{data.confirmedSheetsCount}</span>
</div>
<Bar pct={confirmedPct} colorClass="bg-violet-600" />
</div>
</div>
<HorizontalBarChart
data={[
{ name: 'Planned', value: data.plannedSheetsCount },
{ name: 'Confirmed', value: data.confirmedSheetsCount },
]}
emptyText="No loading sheets yet."
/>
)
}
case 'deliveryTasks':
return (
<div>
@@ -387,24 +420,8 @@ function WidgetBody({ id, data }: { id: string; data: DashboardData }) {
))}
</div>
)
case 'warehouseBalance': {
const max = Math.max(1, ...data.warehouseBalances.map(w => w.quantity))
return data.warehouseBalances.length === 0 ? (
<p className="text-xs text-muted-foreground">No warehouse stock recorded.</p>
) : (
<div className="flex flex-col gap-3">
{data.warehouseBalances.map(w => (
<div key={w.locationId}>
<div className="mb-1 flex justify-between text-xs">
<span className="text-muted-foreground">{w.name}</span>
<span className="font-semibold text-foreground">{w.quantity}</span>
</div>
<Bar pct={(w.quantity / max) * 100} colorClass="bg-amber-600" />
</div>
))}
</div>
)
}
case 'warehouseBalance':
return <HorizontalBarChart data={data.warehouseBalances.map(w => ({ name: w.name, value: w.quantity }))} emptyText="No warehouse stock recorded." />
case 'outstandingPay':
return (
<div>
@@ -412,34 +429,39 @@ function WidgetBody({ id, data }: { id: string; data: DashboardData }) {
<div className="mt-1.5 text-xs text-muted-foreground">{data.outstandingInvoiceCount} invoices</div>
</div>
)
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 (
<div>
<div className="flex h-2.5 overflow-hidden rounded-full bg-muted">
{segs.map(s => (
<div key={s.label} className={s.color} style={{ width: total > 0 ? `${(s.count / total) * 100}%` : '0%' }} />
))}
</div>
<div className="mt-3.5 flex flex-col gap-1.5">
{segs.map(s => (
<div key={s.label} className="flex items-center justify-between text-xs">
<span className="flex items-center gap-1.5 text-muted-foreground">
<span className={`inline-block h-2 w-2 rounded-full ${s.color}`} />
{s.label}
</span>
<span className="font-semibold text-foreground">{s.count}</span>
</div>
))}
</div>
</div>
<DonutChart
data={[
{ name: 'Draft', value: data.draftInvoiceCount },
{ name: 'Printed', value: data.printedInvoiceCount },
{ name: 'Finalized', value: data.finalizedInvoiceCount },
]}
centerLabel="invoices"
emptyText="No invoices yet."
/>
)
}
case 'salesTrendChart':
return <SalesTrendChart data={data.monthlySales} />
case 'paymentStatusChart':
return <DonutChart data={data.paymentStatusChart} centerLabel="invoices" emptyText="No invoices yet." />
case 'poStatusChart':
return <DonutChart data={data.poStatusChart} centerLabel="orders" emptyText="No shop POs yet." />
case 'loadingSheetsChart':
return (
<DonutChart
data={[
{ name: 'Planned', value: data.plannedSheetsCount },
{ name: 'Confirmed', value: data.confirmedSheetsCount },
]}
centerLabel="sheets"
emptyText="No loading sheets yet."
/>
)
case 'warehouseChart':
return <HorizontalBarChart data={data.warehouseBalances.map(w => ({ name: w.name, value: w.quantity }))} emptyText="No warehouse stock recorded." />
case 'movementTypeChart':
return <ColumnBarChart data={data.movementTypeChart} label="Movements" emptyText="No recent stock movements." />
default:
return null
}
@@ -653,7 +675,9 @@ export function DashboardClient() {
<div className="text-sm font-semibold text-foreground">{w.title}</div>
<div className="mt-1"><CategoryPill cat={w.cat} /></div>
</div>
<WidgetBody id={w.id} data={data} />
<div className="min-h-0 flex-1">
<WidgetBody id={w.id} data={data} />
</div>
</div>
))}
</ResponsiveGridLayout>
@@ -0,0 +1,5 @@
import { VehicleDashboard } from '../_components/dashboard/vehicle-dashboard'
export default function VehicleDashboardPage() {
return <VehicleDashboard />
}
@@ -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<CancelRequestType, string> = {
VehicleBreakdown: 'Vehicle breakdown',
DriverUnavailable: 'Driver unavailable',
RouteIssue: 'Route issue',
Other: 'Other',
}
const RIDE_BADGE: Record<RideStatus, 'default' | 'secondary' | 'destructive'> = {
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 (
<div className="flex flex-col gap-4">
<Section title="Deliveries" description="Delivery tasks completed and failed per day">
<ChartContainer config={deliveryConfig} className="h-64 w-full">
<BarChart data={data.dailyDeliveries}>
<CartesianGrid vertical={false} />
<XAxis dataKey="date" tickFormatter={fmtShortDay} tickLine={false} axisLine={false} minTickGap={24} />
<YAxis allowDecimals={false} tickLine={false} axisLine={false} width={32} />
<ChartTooltip content={<ChartTooltipContent labelFormatter={v => fmtShortDay(String(v))} />} />
<Bar dataKey="delivered" stackId="a" fill="var(--color-delivered)" />
<Bar dataKey="failed" stackId="a" fill="var(--color-failed)" radius={[4, 4, 0, 0]} />
<ChartLegend content={<ChartLegendContent />} />
</BarChart>
</ChartContainer>
</Section>
<Section
title="Ride history"
description={`Latest ${rides.length} of ${kpis.rides} rides in this period`}
href="/operations/rides"
hrefLabel="All rides"
>
{rides.length === 0 ? (
<EmptyNote>No rides in this period.</EmptyNote>
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Started</TableHead>
<TableHead>Driver</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Distance</TableHead>
<TableHead className="text-right">Delivered</TableHead>
<TableHead className="text-right">Failed</TableHead>
<TableHead>Notes</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rides.map(r => (
<TableRow key={r.id}>
<TableCell className="whitespace-nowrap">{fmtDateTime(r.startedAt ?? r.createdAt)}</TableCell>
<TableCell>{r.driverName}</TableCell>
<TableCell><Badge variant={RIDE_BADGE[r.status]}>{r.status}</Badge></TableCell>
<TableCell className="text-right tabular-nums">{r.distanceKm > 0 ? `${fmtNum(r.distanceKm)} km` : '—'}</TableCell>
<TableCell className="text-right tabular-nums">{r.delivered} / {r.tasks}</TableCell>
<TableCell className="text-right tabular-nums">{r.failed}</TableCell>
<TableCell className="text-sm text-muted-foreground">
{r.status === 'Cancelled'
? `${r.cancelType ? CANCEL_LABEL[r.cancelType] : 'Cancelled'}${r.cancelReason ? ` — ${r.cancelReason}` : ''}`
: r.closedAt ? `Closed ${fmtDateTime(r.closedAt)}` : '—'}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</Section>
<div className="grid gap-4 lg:grid-cols-2">
<Section title="Top stores served" description="By value delivered from this vehicle">
{topStores.length === 0 ? (
<EmptyNote>No completed deliveries in this period.</EmptyNote>
) : (
<ul className="flex flex-col gap-2">
{topStores.map(s => (
<li key={s.storeId} className="flex items-center justify-between gap-2 text-sm">
<span className="truncate font-medium">{s.storeName}</span>
<span className="shrink-0 tabular-nums text-muted-foreground">
{s.deliveries} {s.deliveries === 1 ? 'delivery' : 'deliveries'} · {fmtMoney(s.value)}
</span>
</li>
))}
</ul>
)}
</Section>
<Section title="Cancelled rides" description="Why rides on this vehicle were cancelled">
{cancelReasons.length === 0 ? (
<EmptyNote>No cancelled rides in this period.</EmptyNote>
) : (
<ul className="flex flex-col gap-2">
{cancelReasons.map(c => (
<li key={c.type} className="flex items-center justify-between text-sm">
<span>{CANCEL_LABEL[c.type]}</span>
<Badge variant="outline">{c.count}</Badge>
</li>
))}
</ul>
)}
</Section>
</div>
</div>
)
}
// ── Stock on board ──────────────────────────────────────────────────────────
export function StockSection({ data }: { data: VehicleDashboardResponse }) {
const { stockOnBoard, kpis } = data
return (
<Section
title="Stock on board"
description={`${stockOnBoard.length} items currently on this vehicle`}
href="/stock"
hrefLabel="Stock balances"
>
<div className="mb-3 grid grid-cols-2 gap-3 sm:grid-cols-4">
<Metric label="Units on board" value={fmtNum(kpis.stockOnBoardUnits)} />
<Metric label="Value at sale price" value={fmtMoney(kpis.stockOnBoardValue)} />
<Metric label="Loaded" value={fmtNum(kpis.unitsLoaded)} sub="units this period" />
<Metric label="Sold" value={fmtNum(kpis.unitsSold)} sub="units this period" />
</div>
{stockOnBoard.length === 0 ? (
<EmptyNote>The vehicle is empty.</EmptyNote>
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Item</TableHead>
<TableHead>Category</TableHead>
<TableHead className="text-right">On board</TableHead>
<TableHead className="text-right">Value</TableHead>
<TableHead>Updated</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{stockOnBoard.map(r => (
<TableRow key={r.itemId}>
<TableCell>
<p className="font-medium">{r.name}</p>
<p className="text-xs text-muted-foreground">{r.sku}</p>
</TableCell>
<TableCell>{r.category}</TableCell>
<TableCell className="text-right tabular-nums">{fmtNum(r.quantity)} {r.uom}</TableCell>
<TableCell className="text-right tabular-nums">{fmtMoney(r.value)}</TableCell>
<TableCell className="whitespace-nowrap text-muted-foreground">{daysAgo(r.updatedAt)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</Section>
)
}
// ── 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 (
<div className="flex flex-col gap-4">
<Section title="Stock movement" description="Units loaded onto and leaving this vehicle, per day">
<ChartContainer config={flowConfig} className="h-64 w-full">
<BarChart data={data.dailyMovement}>
<CartesianGrid vertical={false} />
<XAxis dataKey="date" tickFormatter={fmtShortDay} tickLine={false} axisLine={false} minTickGap={24} />
<YAxis tickFormatter={fmtCompact} tickLine={false} axisLine={false} width={44} />
<ChartTooltip content={<ChartTooltipContent labelFormatter={v => fmtShortDay(String(v))} />} />
<Bar dataKey="in" fill="var(--color-in)" radius={[4, 4, 0, 0]} />
<Bar dataKey="out" fill="var(--color-out)" radius={[4, 4, 0, 0]} />
<ChartLegend content={<ChartLegendContent />} />
</BarChart>
</ChartContainer>
</Section>
<Section title="By movement type" description="Loads from warehouses, sales to customers, returns and transfers">
{data.movementByType.length === 0 ? (
<EmptyNote>No movements in this period.</EmptyNote>
) : (
<ul className="flex flex-col gap-2">
{data.movementByType.map(r => (
<li key={`${r.type}-${r.direction}`} className="flex items-center justify-between text-sm">
<span className="flex items-center gap-2">
<Badge variant={r.direction === 'In' ? 'secondary' : 'outline'}>{r.direction}</Badge>
{MOVEMENT_LABEL[r.type]}
</span>
<span className="tabular-nums text-muted-foreground">{fmtNum(r.units)} units · {r.count}×</span>
</li>
))}
</ul>
)}
</Section>
<Section
title="Movement ledger"
description={`Latest ${data.ledger.length} of ${total} movements in this period`}
href="/stock/movements"
hrefLabel="Full movement history"
>
<div className="mb-3 flex gap-1">
{(['all', 'In', 'Out'] as const).map(d => (
<Button key={d} size="sm" variant={direction === d ? 'default' : 'outline'} onClick={() => setDirection(d)}>
{d === 'all' ? 'All' : d}
</Button>
))}
</div>
{ledger.length === 0 ? (
<EmptyNote>No movements to show.</EmptyNote>
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>When</TableHead>
<TableHead>Item</TableHead>
<TableHead>Type</TableHead>
<TableHead className="text-right">Qty</TableHead>
<TableHead>From → To</TableHead>
<TableHead>By</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{ledger.map(r => (
<TableRow key={r.id}>
<TableCell className="whitespace-nowrap text-muted-foreground">{fmtDateTime(r.createdAt)}</TableCell>
<TableCell>
<p className="font-medium">{r.itemName}</p>
<p className="text-xs text-muted-foreground">{r.sku}</p>
</TableCell>
<TableCell>
<div className="flex items-center gap-1.5">
<Badge variant={r.direction === 'In' ? 'secondary' : 'outline'}>{r.direction}</Badge>
<span className="text-sm">{MOVEMENT_LABEL[r.type]}</span>
</div>
</TableCell>
<TableCell className="text-right tabular-nums">{r.direction === 'In' ? '+' : '−'}{fmtNum(r.quantity)}</TableCell>
<TableCell className="whitespace-nowrap">{r.fromName} → {r.toName}</TableCell>
<TableCell className="whitespace-nowrap">{r.createdByName}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</Section>
</div>
)
}
// ── Drivers, collections and returns ────────────────────────────────────────
function ExpiryBadge({ date }: { date: string }) {
const state = expiryState(date)
return (
<span className="flex items-center gap-1.5">
{fmtDate(date)}
{state === 'expired' && <Badge variant="destructive">Expired</Badge>}
{state === 'soon' && <Badge variant="outline">Expiring</Badge>}
</span>
)
}
export function PeopleMoneySection({ data }: { data: VehicleDashboardResponse }) {
const { drivers, collectionsByMethod, returns, kpis } = data
return (
<div className="flex flex-col gap-4">
<Section title="Drivers" description="Drivers who have operated this vehicle in the period, with compliance dates" href="/drivers" hrefLabel="All drivers">
{drivers.length === 0 ? (
<EmptyNote>No driver has used this vehicle in this period.</EmptyNote>
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Driver</TableHead>
<TableHead className="text-right">Rides</TableHead>
<TableHead className="text-right">Distance</TableHead>
<TableHead>Last ride</TableHead>
<TableHead>Licence expiry</TableHead>
<TableHead>Medical clearance</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{drivers.map(d => (
<TableRow key={d.id}>
<TableCell>
<p className="font-medium">{d.name}{!d.isActive && <Badge variant="secondary" className="ml-2">Inactive</Badge>}</p>
<p className="text-xs text-muted-foreground">{d.licenseNumber}</p>
</TableCell>
<TableCell className="text-right tabular-nums">{d.rides}</TableCell>
<TableCell className="text-right tabular-nums">{d.distanceKm > 0 ? `${fmtNum(d.distanceKm)} km` : '—'}</TableCell>
<TableCell className="whitespace-nowrap text-muted-foreground">{d.lastRideAt ? daysAgo(d.lastRideAt) : '—'}</TableCell>
<TableCell><ExpiryBadge date={d.licenseExpiry} /></TableCell>
<TableCell><ExpiryBadge date={d.medicalClearanceExpiry} /></TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</Section>
<div className="grid gap-4 lg:grid-cols-2">
<Section title="Collections" description="Payments received against orders delivered by this vehicle" href="/sales/invoices" hrefLabel="Invoices">
<div className="mb-3 grid grid-cols-2 gap-3">
<Metric label="Collected" value={fmtMoney(kpis.collections)} />
<Metric label="Delivered value" value={fmtMoney(kpis.deliveredValue)} />
</div>
{collectionsByMethod.length === 0 ? (
<EmptyNote>No payments collected in this period.</EmptyNote>
) : (
<ul className="flex flex-col gap-2">
{collectionsByMethod.map(c => (
<li key={c.method} className="flex items-center justify-between text-sm">
<span>{c.method}</span>
<span className="tabular-nums text-muted-foreground">{fmtMoney(c.amount)} · {c.count} {c.count === 1 ? 'payment' : 'payments'}</span>
</li>
))}
</ul>
)}
</Section>
<Section title="Returns" description="Sale returns on orders delivered by this vehicle" href="/returns-claims/order-returns" hrefLabel="Order returns">
<div className="grid grid-cols-2 gap-3">
<Metric label="Returns" value={String(returns.returnCount)} />
<Metric label="Units returned" value={fmtNum(returns.returnedUnits)} />
<Metric label="Credit issued" value={fmtMoney(returns.creditValue)} />
<Metric label="Received on vehicle" value={fmtNum(returns.customerReturnUnitsOnVehicle)} sub="units from customers" />
</div>
</Section>
</div>
</div>
)
}
@@ -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<VehicleDashboardResponse | null>(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 (
<div className="flex flex-col gap-6">
{/* Header */}
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="flex items-center gap-4">
<Button variant="outline" size="sm" onClick={() => router.push('/network/vehicles')}>
<HugeiconsIcon icon={ArrowLeft01Icon} size={16} strokeWidth={2} />
Back
</Button>
<div>
{!vehicle ? (
<Skeleton className="h-7 w-48 rounded-lg" />
) : (
<div className="flex flex-wrap items-center gap-2">
<h1 className="text-2xl font-semibold text-foreground">{vehicle.numberPlate}</h1>
<Badge variant="outline">{vehicle.type}</Badge>
<Badge variant={vehicle.isActive ? 'default' : 'secondary'}>
{vehicle.isActive ? 'Active' : 'Inactive'}
</Badge>
{data?.currentRide && <Badge>On a ride</Badge>}
</div>
)}
<p className="mt-0.5 text-sm text-muted-foreground">
{vehicle ? `Reg. ${vehicle.regNumber} · Added ${fmtDate(vehicle.createdAt)}` : 'Vehicle overview'}
{data?.defaultWarehouse && (
<> · Home warehouse: <Link href={`/network/warehouses/${data.defaultWarehouse.id}`} className="text-primary hover:underline">{data.defaultWarehouse.name}</Link></>
)}
{data?.mobileWarehouse && (
<> · Mobile warehouse: <Link href={`/network/warehouses/${data.mobileWarehouse.id}`} className="text-primary hover:underline">{data.mobileWarehouse.name}</Link></>
)}
</p>
</div>
</div>
{vehicle && (
<Button onClick={() => setEditOpen(true)}>
<HugeiconsIcon icon={PencilEdit01Icon} size={16} strokeWidth={2} />
Edit vehicle
</Button>
)}
</div>
{/* Period */}
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm text-muted-foreground">Activity period</span>
{PRESETS.map(p => (
<Button key={p.days} variant="outline" size="sm" onClick={() => applyPreset(p.days)}>
{p.label}
</Button>
))}
<Input type="date" value={from} max={to} onChange={e => e.target.value && setFrom(e.target.value)} className="w-40" />
<span className="text-sm text-muted-foreground">to</span>
<Input type="date" value={to} min={from} max={daysFromToday(0)} onChange={e => e.target.value && setTo(e.target.value)} className="w-40" />
</div>
{!data && loading && (
<div className="flex flex-col gap-4">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
{Array.from({ length: 6 }).map((_, i) => <Skeleton key={i} className="h-16 rounded-xl" />)}
</div>
<Skeleton className="h-72 rounded-2xl" />
</div>
)}
{data && k && (
<div className={`flex flex-col gap-6 transition-opacity ${loading ? 'opacity-60' : ''}`}>
{/* KPIs */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
<Metric label="Rides" value={String(k.rides)} sub={`${k.closedRides} closed · ${k.cancelledRides} cancelled`} />
<Metric label="Distance" value={`${fmtNum(k.distanceKm)} km`} sub="closed rides" />
<Metric
label="Deliveries"
value={`${k.deliveredCount} / ${k.deliveries}`}
sub={`${k.successRatePercent}% success · ${k.failedCount} failed`}
tone={k.failedCount > 0 ? 'warning' : undefined}
/>
<Metric label="Delivered value" value={fmtMoney(k.deliveredValue)} sub={`${fmtMoney(k.collections)} collected`} />
<Metric label="Stock on board" value={fmtNum(k.stockOnBoardUnits)} sub={fmtMoney(k.stockOnBoardValue)} />
<Metric label="Loaded / sold" value={`${fmtNum(k.unitsLoaded)} / ${fmtNum(k.unitsSold)}`} sub="units this period" />
</div>
{/* Alerts */}
<Card>
<CardHeader>
<CardTitle>Alerts</CardTitle>
</CardHeader>
<CardContent>
{alerts.length === 0 ? (
<p className="text-sm text-muted-foreground">Nothing needs attention right now.</p>
) : (
<ul className="grid gap-2 lg:grid-cols-2">
{alerts.map((a, i) => (
<li key={i} className="flex items-start gap-2 text-sm">
<Badge variant={ALERT_BADGE[a.severity].variant} className="mt-0.5 shrink-0">
{ALERT_BADGE[a.severity].label}
</Badge>
<span>{a.text}</span>
</li>
))}
</ul>
)}
</CardContent>
</Card>
<Tabs defaultValue="rides">
<TabsList>
<TabsTrigger value="rides">Rides & deliveries</TabsTrigger>
<TabsTrigger value="stock">Stock on board</TabsTrigger>
<TabsTrigger value="movement">Stock movement</TabsTrigger>
<TabsTrigger value="people">Drivers & collections</TabsTrigger>
</TabsList>
<TabsContent value="rides"><RidesSection data={data} /></TabsContent>
<TabsContent value="stock"><StockSection data={data} /></TabsContent>
<TabsContent value="movement"><MovementSection data={data} /></TabsContent>
<TabsContent value="people"><PeopleMoneySection data={data} /></TabsContent>
</Tabs>
</div>
)}
<VehicleSheet
open={editOpen}
onClose={() => setEditOpen(false)}
vehicle={vehicle ?? null}
onSuccess={reload}
/>
</div>
)
}
@@ -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<VehicleResponse[]>([])
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 (
<>
@@ -0,0 +1,5 @@
import { WarehouseDashboard } from '../_components/dashboard/warehouse-dashboard'
export default function WarehouseDashboardPage() {
return <WarehouseDashboard />
}
@@ -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 (
<div className="flex flex-col gap-4">
<div className="grid gap-4 lg:grid-cols-2">
<Section
title="Inbound · Procurement"
description="Goods receipts (GRN) received into this warehouse"
href="/procurement/grn"
hrefLabel="All GRNs"
>
<div className="mb-3 grid grid-cols-2 gap-3 sm:grid-cols-4">
<Metric label="GRNs" value={String(inbound.grnCount)} />
<Metric label="Accepted" value={fmtNum(inbound.acceptedUnits)} sub="units" />
<Metric label="Rejected" value={fmtNum(inbound.rejectedUnits)} sub="units" tone={inbound.rejectedUnits > 0 ? 'warning' : undefined} />
<Metric
label="Rejection rate"
value={`${inbound.rejectionRatePercent}%`}
tone={inbound.rejectionRatePercent > 5 ? 'danger' : undefined}
/>
</div>
{inbound.recentGrns.length === 0 ? (
<EmptyNote>No goods received in this period.</EmptyNote>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Received</TableHead>
<TableHead>Supplier</TableHead>
<TableHead className="text-right">Accepted</TableHead>
<TableHead className="text-right">Rejected</TableHead>
<TableHead className="text-right">Cost</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{inbound.recentGrns.map(g => (
<TableRow key={g.id}>
<TableCell className="whitespace-nowrap">{fmtDate(g.receivedAt)}</TableCell>
<TableCell>{g.supplierName ?? <span className="text-muted-foreground">Direct receipt</span>}</TableCell>
<TableCell className="text-right tabular-nums">{fmtNum(g.acceptedQuantity)}</TableCell>
<TableCell className="text-right tabular-nums">{fmtNum(g.rejectedQuantity)}</TableCell>
<TableCell className="text-right tabular-nums">{fmtMoney(g.costValue)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</Section>
<Section
title="Outbound · Loading sheets"
description="Stock dispatched from this warehouse onto delivery vehicles"
href="/operations/loading"
hrefLabel="All loading sheets"
>
<div className="mb-3 grid grid-cols-2 gap-3">
<Metric label="Dispatched" value={fmtNum(outbound.unitsDispatched)} sub="units this period" />
<Metric label="Pending dispatch" value={fmtNum(outbound.pendingDispatchUnits)} sub="on planned sheets" />
</div>
{outbound.recentLoadingSheets.length === 0 ? (
<EmptyNote>No loading sheets in this period.</EmptyNote>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Prepared</TableHead>
<TableHead>Vehicle</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Lines</TableHead>
<TableHead className="text-right">Units</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{outbound.recentLoadingSheets.map(s => (
<TableRow key={s.id}>
<TableCell className="whitespace-nowrap">{fmtDate(s.populatedAt)}</TableCell>
<TableCell>{s.vehiclePlate}</TableCell>
<TableCell>
<Badge variant={s.status === 'Confirmed' ? 'default' : 'secondary'}>{s.status}</Badge>
</TableCell>
<TableCell className="text-right tabular-nums">{s.lines}</TableCell>
<TableCell className="text-right tabular-nums">{fmtNum(s.units)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</Section>
</div>
<div className="grid gap-4 lg:grid-cols-2">
<Section title="Vehicles" description="Stock currently on vehicles linked to or loaded from this warehouse">
{vehicles.length === 0 ? (
<EmptyNote>No vehicles are linked to this warehouse.</EmptyNote>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Vehicle</TableHead>
<TableHead className="text-right">Units on board</TableHead>
<TableHead className="text-right">SKUs</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{vehicles.map(v => (
<TableRow key={v.vehicleId}>
<TableCell>
<span className="font-medium">{v.numberPlate}</span>
<span className="ml-2 text-xs text-muted-foreground">{v.regNumber}</span>
{v.isLinked && <Badge variant="outline" className="ml-2">Linked</Badge>}
</TableCell>
<TableCell className="text-right tabular-nums">{fmtNum(v.units)}</TableCell>
<TableCell className="text-right tabular-nums">{v.skus}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</Section>
<Section
title="Returns & losses"
description="Stock leaving without a sale, plus open supplier claims"
href="/returns-claims/supplier-claims"
hrefLabel="Supplier claims"
>
<div className="mb-3 grid grid-cols-2 gap-3 sm:grid-cols-4">
<Metric label="Purchase returns" value={fmtNum(losses.purchaseReturnUnits)} sub="units" />
<Metric label="Damaged" value={fmtNum(losses.damageUnits)} sub="units" tone={losses.damageUnits > 0 ? 'warning' : undefined} />
<Metric label="Adjusted down" value={fmtNum(losses.negativeAdjustmentUnits)} sub="units" tone={losses.negativeAdjustmentUnits > 0 ? 'warning' : undefined} />
<Metric label="Adjusted up" value={fmtNum(losses.positiveAdjustmentUnits)} sub="units" />
</div>
{losses.openClaims.length === 0 ? (
<EmptyNote>No open supplier claims for goods received here.</EmptyNote>
) : (
<ul className="flex flex-col gap-2">
{losses.openClaims.map(c => (
<li key={c.id} className="flex items-start justify-between gap-2 text-sm">
<div className="min-w-0">
<p className="truncate font-medium">{c.supplierName ?? 'Supplier'}</p>
<p className="truncate text-xs text-muted-foreground">{c.details}</p>
</div>
<span className="shrink-0 text-xs text-muted-foreground">{fmtNum(c.units)} units · {fmtDate(c.createdAt)}</span>
</li>
))}
</ul>
)}
</Section>
</div>
<div className="grid gap-4 lg:grid-cols-2">
<Section title="Top movers" description="Most units loaded onto vehicles in this period">
{topMovers.length === 0 ? (
<EmptyNote>Nothing was dispatched in this period.</EmptyNote>
) : (
<ul className="flex flex-col gap-3">
{topMovers.map(m => (
<li key={m.itemId} className="flex flex-col gap-1">
<div className="flex items-center justify-between text-sm">
<span className="truncate font-medium">{m.name} <span className="text-xs font-normal text-muted-foreground">{m.sku}</span></span>
<span className="shrink-0 tabular-nums text-muted-foreground">{fmtNum(m.unitsOut)} units</span>
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-muted">
<div className="h-full rounded-full" style={{ width: `${(m.unitsOut / maxMover) * 100}%`, background: 'var(--chart-2)' }} />
</div>
</li>
))}
</ul>
)}
</Section>
<Section title="Dead stock" description={`In stock but not sent out for ${data.deadStockDays}+ days`}>
{deadStock.length === 0 ? (
<EmptyNote>No idle stock found.</EmptyNote>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Item</TableHead>
<TableHead className="text-right">Qty</TableHead>
<TableHead className="text-right">Cost value</TableHead>
<TableHead>Last sent out</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{deadStock.map(d => (
<TableRow key={d.itemId}>
<TableCell>
<p className="font-medium">{d.name}</p>
<p className="text-xs text-muted-foreground">{d.sku}</p>
</TableCell>
<TableCell className="text-right tabular-nums">{fmtNum(d.quantity)}</TableCell>
<TableCell className="text-right tabular-nums">{fmtMoney(d.costValue)}</TableCell>
<TableCell className="whitespace-nowrap text-muted-foreground">
{d.lastOutboundAt ? daysAgo(d.lastOutboundAt) : 'Never'}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</Section>
</div>
</div>
)
}
@@ -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<MovementType, string> = {
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<WarehouseType, string> = {
DistributionCenter: 'Distribution Center',
FulfillmentCenter: 'Fulfillment Center',
CrossDocking: 'Cross Docking',
ColdStorage: 'Cold Storage',
BondedWarehouse: 'Bonded Warehouse',
AutomatedWarehouse: 'Automated Warehouse',
}
@@ -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 (
<Section title={title}>
{rows.length === 0 ? (
<EmptyNote>No movements in this period.</EmptyNote>
) : (
<ul className="flex flex-col gap-3">
{rows.map(r => (
<li key={r.type} className="flex flex-col gap-1">
<div className="flex items-center justify-between text-sm">
<span>{MOVEMENT_LABEL[r.type]}</span>
<span className="tabular-nums text-muted-foreground">
{fmtNum(r.units)} units · {r.count} {r.count === 1 ? 'entry' : 'entries'}
</span>
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full"
style={{
width: `${total > 0 ? (r.units / total) * 100 : 0}%`,
background: r.direction === 'In' ? 'var(--chart-2)' : 'var(--chart-1)',
}}
/>
</div>
</li>
))}
</ul>
)}
</Section>
)
}
export function MovementSection({ data }: { data: WarehouseDashboardResponse }) {
const [direction, setDirection] = useState<DirectionFilter>('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 (
<div className="flex flex-col gap-4">
<Section title="Stock movement" description="Units received into and sent out of this warehouse, per day">
<ChartContainer config={flowConfig} className="h-64 w-full">
<BarChart data={data.dailyMovement}>
<CartesianGrid vertical={false} />
<XAxis dataKey="date" tickFormatter={fmtShortDay} tickLine={false} axisLine={false} minTickGap={24} />
<YAxis tickFormatter={fmtCompact} tickLine={false} axisLine={false} width={44} />
<ChartTooltip content={<ChartTooltipContent labelFormatter={v => fmtShortDay(String(v))} />} />
<Bar dataKey="in" fill="var(--color-in)" radius={[4, 4, 0, 0]} />
<Bar dataKey="out" fill="var(--color-out)" radius={[4, 4, 0, 0]} />
<ChartLegend content={<ChartLegendContent />} />
</BarChart>
</ChartContainer>
</Section>
<div className="grid gap-4 lg:grid-cols-2">
<TypeBreakdown title="Where stock comes from (In)" rows={inbound} />
<TypeBreakdown title="Where stock goes (Out)" rows={outbound} />
</div>
<Section
title="Movement ledger"
description={`Latest ${data.ledger.length} of ${totalEntries} movements in this period`}
href="/stock/movements"
hrefLabel="Full movement history"
>
<div className="mb-3 flex gap-1">
{(['all', 'In', 'Out'] as const).map(d => (
<Button key={d} size="sm" variant={direction === d ? 'default' : 'outline'} onClick={() => setDirection(d)}>
{d === 'all' ? 'All' : d}
</Button>
))}
</div>
{ledger.length === 0 ? (
<EmptyNote>No movements to show.</EmptyNote>
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>When</TableHead>
<TableHead>Item</TableHead>
<TableHead>Type</TableHead>
<TableHead className="text-right">Qty</TableHead>
<TableHead>From → To</TableHead>
<TableHead>Reference</TableHead>
<TableHead>By</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{ledger.map(r => (
<TableRow key={r.id}>
<TableCell className="whitespace-nowrap text-muted-foreground">{fmtDateTime(r.createdAt)}</TableCell>
<TableCell>
<p className="font-medium">{r.itemName}</p>
<p className="text-xs text-muted-foreground">{r.sku}</p>
</TableCell>
<TableCell>
<div className="flex items-center gap-1.5">
<Badge variant={r.direction === 'In' ? 'secondary' : 'outline'}>{r.direction}</Badge>
<span className="text-sm">{MOVEMENT_LABEL[r.type]}</span>
</div>
{r.reasonCode && <p className="mt-0.5 text-xs text-muted-foreground">{r.reasonCode}</p>}
</TableCell>
<TableCell className="text-right tabular-nums">
{r.direction === 'In' ? '+' : '−'}{fmtNum(r.quantity)}
</TableCell>
<TableCell className="whitespace-nowrap">{r.fromName} → {r.toName}</TableCell>
<TableCell className="text-sm text-muted-foreground">
{r.referenceKind ? `${r.referenceKind} · ${r.referenceId?.slice(0, 8)}` : '—'}
</TableCell>
<TableCell className="whitespace-nowrap">{r.createdByName}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</Section>
</div>
)
}
@@ -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 (
<div className="flex flex-col gap-0.5 rounded-xl border px-3 py-2">
<span className="truncate text-[11px] uppercase tracking-wide text-muted-foreground">{label}</span>
<span className={`truncate text-base font-semibold leading-tight ${valueClass}`}>{value}</span>
{sub && <span className="truncate text-[11px] text-muted-foreground">{sub}</span>}
</div>
)
}
export function Section({
title,
description,
href,
hrefLabel,
children,
}: {
title: string
description?: string
href?: string
hrefLabel?: string
children: React.ReactNode
}) {
return (
<Card>
<CardHeader>
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle>{title}</CardTitle>
{description && <CardDescription>{description}</CardDescription>}
</div>
{href && (
<Link href={href} className="shrink-0 text-xs font-medium text-primary hover:underline">
{hrefLabel ?? 'View all'}
</Link>
)}
</div>
</CardHeader>
<CardContent>{children}</CardContent>
</Card>
)
}
export function EmptyNote({ children }: { children: React.ReactNode }) {
return <p className="py-4 text-center text-sm text-muted-foreground">{children}</p>
}
@@ -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<WarehouseStockStatus, { label: string; variant: 'default' | 'secondary' | 'destructive' }> = {
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<string | null>(null)
const [adjusting, setAdjusting] = useState<StockBalanceResponse | null>(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 (
<div className="flex flex-col gap-4">
<div className="grid gap-4 lg:grid-cols-3">
<div className="lg:col-span-2">
<Section title="Stock value by category" description="At cost price, items currently on hand">
{data.stockByCategory.length === 0 ? (
<EmptyNote>No stock on hand.</EmptyNote>
) : (
<ChartContainer config={categoryConfig} className="w-full" style={{ height: Math.max(140, data.stockByCategory.length * 36) }}>
<BarChart data={data.stockByCategory} layout="vertical" margin={{ left: 8 }}>
<CartesianGrid horizontal={false} />
<XAxis type="number" tickFormatter={fmtCompact} tickLine={false} axisLine={false} />
<YAxis type="category" dataKey="category" tickLine={false} axisLine={false} width={110} />
<ChartTooltip content={<ChartTooltipContent hideLabel formatter={(v) => fmtMoney(Number(v))} />} />
<Bar dataKey="value" fill="var(--color-value)" radius={6} />
</BarChart>
</ChartContainer>
)}
</Section>
</div>
<Section title="Expiry watch" description="Batches expired or expiring within 30 days">
{data.expiringBatches.length === 0 ? (
<EmptyNote>No batches at risk.</EmptyNote>
) : (
<ul className="flex max-h-56 flex-col gap-2 overflow-y-auto">
{data.expiringBatches.map(b => (
<li key={`${b.itemId}-${b.batchNumber}-${b.expiryDate}`} className="flex items-start justify-between gap-2 text-sm">
<div className="min-w-0">
<p className="truncate font-medium">{b.itemName}</p>
<p className="text-xs text-muted-foreground">
Batch {b.batchNumber ?? '—'} · {fmtNum(b.quantity)} units · {fmtDate(b.expiryDate)}
</p>
</div>
<Badge variant={b.status === 'Expired' ? 'destructive' : 'outline'}>
{b.status === 'Expired' ? 'Expired' : 'Expiring'}
</Badge>
</li>
))}
</ul>
)}
</Section>
</div>
<Section title="Stock on hand" description={`${data.stock.length} items held in this warehouse`}>
<div className="mb-3 flex flex-wrap items-center gap-2">
<Input
value={search}
onChange={e => { setSearch(e.target.value); setVisible(PAGE_SIZE) }}
placeholder="Search item or SKU"
className="w-56"
/>
<Select value={category} onValueChange={v => { setCategory(v ?? ALL); setVisible(PAGE_SIZE) }}>
<SelectTrigger className="w-44"><SelectValue placeholder="Category" /></SelectTrigger>
<SelectContent>
<SelectItem value={ALL}>All categories</SelectItem>
{categories.map(c => <SelectItem key={c} value={c}>{c}</SelectItem>)}
</SelectContent>
</Select>
<Select value={status} onValueChange={v => { setStatus(v ?? ALL); setVisible(PAGE_SIZE) }}>
<SelectTrigger className="w-40"><SelectValue placeholder="Status" /></SelectTrigger>
<SelectContent>
<SelectItem value={ALL}>All statuses</SelectItem>
<SelectItem value="OK">In stock</SelectItem>
<SelectItem value="Low">Low stock</SelectItem>
<SelectItem value="Out">Out of stock</SelectItem>
</SelectContent>
</Select>
</div>
{rows.length === 0 ? (
<EmptyNote>No items match the current filters.</EmptyNote>
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Item</TableHead>
<TableHead>Category</TableHead>
<TableHead className="text-right">On hand</TableHead>
<TableHead className="text-right">Reorder at</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Cost value</TableHead>
<TableHead className="text-right">Sale value</TableHead>
<TableHead>Updated</TableHead>
<TableHead />
</TableRow>
</TableHeader>
<TableBody>
{rows.slice(0, visible).map(r => {
const badge = STATUS_BADGE[r.status]
const hasBatches = r.batches.length > 0
return (
<Fragment key={r.itemId}>
<TableRow
className={hasBatches ? 'cursor-pointer' : undefined}
onClick={hasBatches ? () => setExpanded(expanded === r.itemId ? null : r.itemId) : undefined}
>
<TableCell>
<p className="font-medium">{r.name}</p>
<p className="text-xs text-muted-foreground">
{r.sku}{r.brand ? ` · ${r.brand}` : ''}{hasBatches ? ` · ${r.batches.length} batch${r.batches.length > 1 ? 'es' : ''}` : ''}
</p>
</TableCell>
<TableCell>{r.category}</TableCell>
<TableCell className="text-right tabular-nums">{fmtNum(r.quantity)} {r.uom}</TableCell>
<TableCell className="text-right tabular-nums">{r.reorderLevel != null ? fmtNum(r.reorderLevel) : '—'}</TableCell>
<TableCell><Badge variant={badge.variant}>{badge.label}</Badge></TableCell>
<TableCell className="text-right tabular-nums">{fmtMoney(r.costValue)}</TableCell>
<TableCell className="text-right tabular-nums">{fmtMoney(r.saleValue)}</TableCell>
<TableCell className="whitespace-nowrap text-muted-foreground">{daysAgo(r.updatedAt)}</TableCell>
<TableCell className="text-right">
{!hasBatches && (
<Button variant="ghost" size="xs" onClick={e => { e.stopPropagation(); openAdjust(r) }}>
Adjust
</Button>
)}
</TableCell>
</TableRow>
{expanded === r.itemId && r.batches.map(b => (
<TableRow key={b.batchId} className="bg-muted/40">
<TableCell colSpan={2} className="pl-8 text-xs text-muted-foreground">
Batch {b.batchNumber ?? '—'}
</TableCell>
<TableCell className="text-right text-xs tabular-nums">{fmtNum(b.quantity)} {r.uom}</TableCell>
<TableCell colSpan={6} className="text-xs text-muted-foreground">
{b.expiryDate ? `Expires ${fmtDate(b.expiryDate)}` : 'No expiry date'}
</TableCell>
</TableRow>
))}
</Fragment>
)
})}
</TableBody>
</Table>
</div>
)}
{rows.length > visible && (
<div className="mt-3 flex justify-center">
<Button variant="outline" size="sm" onClick={() => setVisible(v => v + PAGE_SIZE)}>
Show more ({rows.length - visible} remaining)
</Button>
</div>
)}
</Section>
<StockAdjustDialog
open={adjusting !== null}
onClose={() => setAdjusting(null)}
onSuccess={() => { setAdjusting(null); onChanged() }}
balance={adjusting}
/>
</div>
)
}
@@ -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<WarehouseDashboardResponse | null>(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 (
<div className="flex flex-col gap-6">
{/* Header */}
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="flex items-center gap-4">
<Button variant="outline" size="sm" onClick={() => router.push('/network/warehouses')}>
<HugeiconsIcon icon={ArrowLeft01Icon} size={16} strokeWidth={2} />
Back
</Button>
<div>
{!warehouse ? (
<Skeleton className="h-7 w-48 rounded-lg" />
) : (
<div className="flex flex-wrap items-center gap-2">
<h1 className="text-2xl font-semibold text-foreground">{warehouse.name}</h1>
<Badge variant="outline">{WAREHOUSE_TYPE_LABEL[warehouse.type] ?? warehouse.type}</Badge>
<Badge variant={warehouse.isActive ? 'default' : 'secondary'}>
{warehouse.isActive ? 'Active' : 'Inactive'}
</Badge>
</div>
)}
<p className="mt-0.5 text-sm text-muted-foreground">
{warehouse
? `${warehouse.location} · Created ${fmtDate(warehouse.createdAt)}`
: 'Warehouse overview'}
{data && data.linkedVehicles.length > 0 && (
<> · Vehicles: {data.linkedVehicles.map(v => v.numberPlate).join(', ')}</>
)}
</p>
</div>
</div>
{warehouse && (
<Button onClick={() => setEditOpen(true)}>
<HugeiconsIcon icon={PencilEdit01Icon} size={16} strokeWidth={2} />
Edit warehouse
</Button>
)}
</div>
{/* Period */}
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm text-muted-foreground">Activity period</span>
{PRESETS.map(p => (
<Button key={p.days} variant="outline" size="sm" onClick={() => applyPreset(p.days)}>
{p.label}
</Button>
))}
<Input type="date" value={from} max={to} onChange={e => e.target.value && setFrom(e.target.value)} className="w-40" />
<span className="text-sm text-muted-foreground">to</span>
<Input type="date" value={to} min={from} max={daysFromToday(0)} onChange={e => e.target.value && setTo(e.target.value)} className="w-40" />
</div>
{!data && loading && (
<div className="flex flex-col gap-4">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
{Array.from({ length: 6 }).map((_, i) => <Skeleton key={i} className="h-16 rounded-xl" />)}
</div>
<Skeleton className="h-72 rounded-2xl" />
</div>
)}
{data && k && (
<div className={`flex flex-col gap-6 transition-opacity ${loading ? 'opacity-60' : ''}`}>
{/* KPIs */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
<Metric label="Stock value" value={fmtMoney(k.stockValueCost)} sub={`${fmtMoney(k.stockValueSale)} at sale price`} />
<Metric label="Units on hand" value={fmtNum(k.totalUnits)} sub={`${k.skuCount} SKUs`} />
<Metric
label="Low / out of stock"
value={`${k.lowStockCount} / ${k.outOfStockCount}`}
tone={k.outOfStockCount > 0 ? 'danger' : k.lowStockCount > 0 ? 'warning' : undefined}
sub="items"
/>
<Metric
label="Expiry risk"
value={`${k.nearExpiryCount} / ${k.expiredCount}`}
tone={k.expiredCount > 0 ? 'danger' : k.nearExpiryCount > 0 ? 'warning' : undefined}
sub="near / expired batches"
/>
<Metric label="Units in" value={fmtNum(k.unitsIn)} sub="this period" />
<Metric label="Units out" value={fmtNum(k.unitsOut)} sub="this period" />
</div>
{/* Alerts */}
<Card>
<CardHeader>
<CardTitle>Alerts</CardTitle>
</CardHeader>
<CardContent>
{alerts.length === 0 ? (
<p className="text-sm text-muted-foreground">Nothing needs attention right now.</p>
) : (
<ul className="grid gap-2 lg:grid-cols-2">
{alerts.map((a, i) => (
<li key={i} className="flex items-start gap-2 text-sm">
<Badge variant={ALERT_BADGE[a.severity].variant} className="mt-0.5 shrink-0">
{ALERT_BADGE[a.severity].label}
</Badge>
<span>{a.text}</span>
</li>
))}
</ul>
)}
</CardContent>
</Card>
<Tabs defaultValue="stock">
<TabsList>
<TabsTrigger value="stock">Stock on hand</TabsTrigger>
<TabsTrigger value="movement">Stock movement</TabsTrigger>
<TabsTrigger value="activity">Module activity</TabsTrigger>
</TabsList>
<TabsContent value="stock"><StockSection data={data} onChanged={reload} /></TabsContent>
<TabsContent value="movement"><MovementSection data={data} /></TabsContent>
<TabsContent value="activity"><ActivitySection data={data} /></TabsContent>
</Tabs>
</div>
)}
<WarehouseSheet
open={editOpen}
onClose={() => setEditOpen(false)}
warehouse={warehouse ?? null}
onSuccess={reload}
/>
</div>
)
}
@@ -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<WarehouseResponse[]>([])
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 (
<>
@@ -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<ProductReturnRow>['columns'] = [
},
]
const STOCK_EXPORT: ExportColumn<ProductStockRow>[] = [
{ 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<ProductSalesRow>[] = [
{ 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<ProductReturnRow>[] = [
{ 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<Tab>('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<TableConfig<ProductStockRow>>(
@@ -197,6 +261,9 @@ export function ProductReportClient() {
Low stock only
</Button>
)}
<div className="ml-auto flex items-center gap-2">
<ExportButtons onExport={handleExport} disabled={loading || total === 0} />
</div>
</div>
{tab === 'stock' && (
@@ -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<string, string> = {
OK: 'text-emerald-600',
Low: 'text-amber-600',
Out: 'text-red-600',
}
const ledgerColumns: TableConfig<StockLedgerRow>['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 => <span className={`text-sm font-medium ${STATUS_CLASS[row.status] ?? ''}`}>{row.status}</span>,
},
]
const expiryColumns: TableConfig<StockExpiryRow>['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 => (
<span className={`text-sm font-medium ${row.daysLeft < 0 ? 'text-red-600' : row.daysLeft <= 7 ? 'text-amber-600' : ''}`}>
{row.daysLeft < 0 ? `Expired ${-row.daysLeft}d ago` : `${row.daysLeft}d`}
</span>
),
},
]
const LEDGER_EXPORT: ExportColumn<StockLedgerRow>[] = [
{ 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<StockExpiryRow>[] = [
{ 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<Tab>('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<StockOverview | null>(null)
const [ledgerData, setLedgerData] = useState<StockLedgerRow[]>([])
const [expiryData, setExpiryData] = useState<StockExpiryRow[]>([])
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<TableConfig<StockLedgerRow>>(
() => ({ ...common, rowKey: r => r.itemId, emptyMessage: 'No products found.', columns: ledgerColumns }),
[pageSize],
)
const expiryConfig = useMemo<TableConfig<StockExpiryRow>>(
() => ({
...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 (
<>
<div>
<h1 className="text-2xl font-semibold text-foreground">Stock Report</h1>
<p className="text-sm text-muted-foreground mt-0.5">
Opening, in, out and closing stock across all locations or one warehouse or vehicle, plus batches nearing expiry.
</p>
</div>
{overview && (
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-7 gap-3">
{cards.map(([label, value]) => (
<Card key={label} className="rounded-3xl border-border/60">
<CardContent className="space-y-1">
<CardDescription>{label}</CardDescription>
<CardTitle className="text-2xl">{value}</CardTitle>
</CardContent>
</Card>
))}
</div>
)}
<div className="flex flex-wrap items-center gap-2">
{TABS.map(t => (
<Button key={t.key} size="sm" variant={tab === t.key ? 'default' : 'outline'} onClick={() => changeTab(t.key)}>
{t.label}
</Button>
))}
<Select value={locationId} onValueChange={v => { setLocationId(v ?? ALL); setPage(1) }}>
<SelectTrigger className="w-56"><SelectValue placeholder="Location" /></SelectTrigger>
<SelectContent>
<SelectItem value={ALL}>All locations</SelectItem>
{locationOptions.map(o => <SelectItem key={o.id} value={o.id}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
<Input
className="w-56"
placeholder="Search name or SKU"
value={search}
onChange={e => { setSearch(e.target.value); setPage(1) }}
/>
{tab === 'ledger' ? (
<>
<Select value={categoryId} onValueChange={v => { setCategoryId(v ?? ALL); setPage(1) }}>
<SelectTrigger className="w-44"><SelectValue placeholder="Category" /></SelectTrigger>
<SelectContent>
<SelectItem value={ALL}>All categories</SelectItem>
{categoryOptions.map(c => <SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>)}
</SelectContent>
</Select>
<Select value={status} onValueChange={v => { setStatus(v ?? ALL); setPage(1) }}>
<SelectTrigger className="w-40"><SelectValue placeholder="Status" /></SelectTrigger>
<SelectContent>
<SelectItem value={ALL}>All statuses</SelectItem>
<SelectItem value="low">Low stock</SelectItem>
<SelectItem value="out">Out of stock</SelectItem>
</SelectContent>
</Select>
<Input className="w-40" type="date" value={from} onChange={e => { setFrom(e.target.value); setPage(1) }} />
<Input className="w-40" type="date" value={to} onChange={e => { setTo(e.target.value); setPage(1) }} />
</>
) : (
<Select value={String(expiryDays)} onValueChange={v => { setExpiryDays(Number(v)); setPage(1) }}>
<SelectTrigger className="w-44"><SelectValue placeholder="Window" /></SelectTrigger>
<SelectContent>
{EXPIRY_WINDOWS.map(d => <SelectItem key={d} value={String(d)}>Within {d} days</SelectItem>)}
</SelectContent>
</Select>
)}
<div className="ml-auto flex items-center gap-2">
<ExportButtons onExport={handleExport} disabled={loading || total === 0} />
</div>
</div>
{tab === 'ledger' && (
<DataTable config={ledgerConfig} data={ledgerData} loading={loading} totalCount={total} onServerSideChange={onServerSideChange} />
)}
{tab === 'expiry' && (
<DataTable config={expiryConfig} data={expiryData} loading={loading} totalCount={total} onServerSideChange={onServerSideChange} />
)}
</>
)
}
@@ -0,0 +1,5 @@
import { StockReportClient } from './_components/stock-report-client'
export default function StockReportPage() {
return <StockReportClient />
}
@@ -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<void>
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 (
<>
<Button size="sm" variant="outline" disabled={disabled || busy !== null} onClick={() => run('excel')}>
{busy === 'excel' ? 'Exporting…' : 'Export Excel'}
</Button>
<Button size="sm" variant="outline" disabled={disabled || busy !== null} onClick={() => run('pdf')}>
{busy === 'pdf' ? 'Exporting…' : 'Export PDF'}
</Button>
</>
)
}
+1
View File
@@ -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' },
],
},
]
+9
View File
@@ -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<VehicleResponse>(`/vehicles/${id}`).then(r => normalizeVehicle(r.data)),
dashboard: (id: string, params?: { from?: string; to?: string }) =>
api.get<VehicleDashboardResponse>(`/vehicles/${id}/dashboard`, { params })
.then(r => ({ ...r.data, vehicle: normalizeVehicle(r.data.vehicle) })),
create: (body: VehicleBody) =>
api.post<VehicleResponse>('/vehicles', serializeVehicleBody(body)).then(r => normalizeVehicle(r.data)),
@@ -226,6 +232,9 @@ export const warehouses = {
get: (id: string) =>
api.get<WarehouseResponse>(`/warehouses/${id}`).then(r => r.data),
dashboard: (id: string, params?: { from?: string; to?: string; deadStockDays?: number }) =>
api.get<WarehouseDashboardResponse>(`/warehouses/${id}/dashboard`, { params }).then(r => r.data),
create: (body: WarehouseBody) =>
api.post<WarehouseResponse>('/warehouses', body).then(r => r.data),
+74
View File
@@ -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<StockOverview>('/reports/stock/overview', { params }).then(r => r.data),
ledger: (params?: StockLedgerParams) =>
api.get<PaginatedResponse<StockLedgerRow>>('/reports/stock/ledger', { params }).then(r => r.data),
expiry: (params?: StockExpiryParams) =>
api.get<PaginatedResponse<StockExpiryRow>>('/reports/stock/expiry', { params }).then(r => r.data),
}
export const productReports = {
stock: (params?: ProductStockParams) =>
api.get<PaginatedResponse<ProductStockRow>>('/reports/products/stock', { params }).then(r => r.data),
+81
View File
@@ -0,0 +1,81 @@
export interface ExportColumn<T> {
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<T>(
fetchPage: (page: number, pageSize: number) => Promise<{ items: T[]; total: number }>,
): Promise<T[]> {
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<T>(name: string, columns: ExportColumn<T>[], 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<T>(name: string, title: string, columns: ExportColumn<T>[], 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`)
}
+259
View File
@@ -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 {