Fe changes
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
+236
@@ -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 />
|
||||
}
|
||||
+217
@@ -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',
|
||||
}
|
||||
+153
@@ -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>
|
||||
}
|
||||
+226
@@ -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>
|
||||
)
|
||||
}
|
||||
+235
@@ -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>
|
||||
)
|
||||
}
|
||||
+8
-1
@@ -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 (
|
||||
<>
|
||||
|
||||
Reference in New Issue
Block a user