Merge pull request 'Refactor/main flow' (#50) from refactor/main_flow into dev
CI/CD - ERP Distribution / Build & Deploy Frontend (pull_request) Failing after 11m24s
CI/CD - ERP Distribution / Build & Deploy Backend (pull_request) Failing after 12m16s

Reviewed-on: #50
This commit was merged in pull request #50.
This commit is contained in:
2026-09-29 12:04:26 +00:00
6 changed files with 550 additions and 230 deletions
@@ -14,6 +14,8 @@ public record ShopPOResponse(
Guid Id, Guid Id,
Guid StoreId, Guid StoreId,
string StoreName, string StoreName,
string? StoreAddress,
string? StoreContactNumber,
Guid RaisedById, Guid RaisedById,
Guid? InvoiceId, Guid? InvoiceId,
decimal TotalAmount, decimal TotalAmount,
@@ -14,6 +14,7 @@ public class ShopPOService(
{ {
private static ShopPOResponse ToResponse(Models.ShopPO p, Dictionary<Guid, (string Name, string Role)>? users = null) => private static ShopPOResponse ToResponse(Models.ShopPO p, Dictionary<Guid, (string Name, string Role)>? users = null) =>
new(p.Id, p.FK_StoreId, p.Store?.StoreName ?? string.Empty, new(p.Id, p.FK_StoreId, p.Store?.StoreName ?? string.Empty,
p.Store?.StoreAddress, p.Store?.ContactNumber,
p.RaisedById, p.InvoiceId, p.TotalAmount, p.Status, p.RaisedById, p.InvoiceId, p.TotalAmount, p.Status,
p.ConfirmedAt, p.ConfirmedBy, p.FulfilledAt, p.ConfirmedAt, p.ConfirmedBy, p.FulfilledAt,
p.Items.Where(i => i.IsActive).Select(i => p.Items.Where(i => i.IsActive).Select(i =>
@@ -20,14 +20,16 @@ import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { HugeiconsIcon } from '@hugeicons/react' import { HugeiconsIcon } from '@hugeicons/react'
import { ViewIcon } from '@hugeicons/core-free-icons' import { ArrowDown01Icon, ArrowUp01Icon, Alert02Icon } from '@hugeicons/core-free-icons'
import { toast } from '@/components/ui/toast' import { toast } from '@/components/ui/toast'
import { cn } from '@/lib/utils'
import { import {
shopPos as shopPosApi, shopPos as shopPosApi,
regions as regionsApi, regions as regionsApi,
routes as routesApi, routes as routesApi,
warehouses as warehousesApi, warehouses as warehousesApi,
loadingSheets as loadingSheetsApi, loadingSheets as loadingSheetsApi,
stock as stockApi,
getApiError, getApiError,
getPaginatedItems, getPaginatedItems,
} from '@/lib/api/index' } from '@/lib/api/index'
@@ -35,6 +37,26 @@ import type { LoadingSheetResponse, ShopPOResponse, RegionResponse, RouteRespons
import type { LoadingSheetItemInput } from '@/lib/api/operations' import type { LoadingSheetItemInput } from '@/lib/api/operations'
const ALL = '__all__' const ALL = '__all__'
const STEPS = [
{
n: 1,
label: 'Route & Warehouse',
title: 'Where is this load coming from?',
help: 'Pick the route this ride will deliver, and the warehouse the vehicle will be loaded from. Every item on this sheet will be dispatched from that one warehouse.',
},
{
n: 2,
label: 'Pick Items',
title: 'What should be loaded?',
help: "Click a shop to open its pending orders, tick the items to load, and adjust quantities if needed. The \"In stock\" column shows what's on hand at the warehouse you picked.",
},
{
n: 3,
label: 'Review & Confirm',
title: 'Double-check before saving',
help: 'This is everything that will be saved to the sheet. Items marked "Short" have more requested than is in stock — confirming the sheet will fail until those are fixed.',
},
] as const
interface LoadingSheetItemsDialogProps { interface LoadingSheetItemsDialogProps {
open: boolean open: boolean
@@ -57,6 +79,8 @@ interface PickedLine {
} }
export function LoadingSheetItemsDialog({ open, onClose, onSuccess, sheet }: LoadingSheetItemsDialogProps) { export function LoadingSheetItemsDialog({ open, onClose, onSuccess, sheet }: LoadingSheetItemsDialogProps) {
const [step, setStep] = useState<1 | 2 | 3>(1)
const [regionsList, setRegionsList] = useState<RegionResponse[]>([]) const [regionsList, setRegionsList] = useState<RegionResponse[]>([])
const [routesList, setRoutesList] = useState<RouteResponse[]>([]) const [routesList, setRoutesList] = useState<RouteResponse[]>([])
const [warehousesList, setWarehousesList] = useState<WarehouseResponse[]>([]) const [warehousesList, setWarehousesList] = useState<WarehouseResponse[]>([])
@@ -65,22 +89,27 @@ export function LoadingSheetItemsDialog({ open, onClose, onSuccess, sheet }: Loa
const [routeId, setRouteId] = useState('') const [routeId, setRouteId] = useState('')
const [warehouseId, setWarehouseId] = useState('') const [warehouseId, setWarehouseId] = useState('')
const [shopId, setShopId] = useState('') const [shopId, setShopId] = useState('')
const [viewStoreId, setViewStoreId] = useState('') const [expandedStores, setExpandedStores] = useState<Set<string>>(new Set())
const [pos, setPos] = useState<ShopPOResponse[]>([]) const [pos, setPos] = useState<ShopPOResponse[]>([])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
const [lines, setLines] = useState<Record<string, PickedLine>>({}) const [lines, setLines] = useState<Record<string, PickedLine>>({})
const [stockByItem, setStockByItem] = useState<Record<string, number>>({})
const [stockLoading, setStockLoading] = useState(false)
useEffect(() => { useEffect(() => {
if (!open) return if (!open) return
setStep(1)
setRegionId('') setRegionId('')
setRouteId('') setRouteId('')
setWarehouseId('') setWarehouseId('')
setShopId('') setShopId('')
setViewStoreId('') setExpandedStores(new Set())
setPos([]) setPos([])
setLines({}) setLines({})
setStockByItem({})
Promise.all([ Promise.all([
regionsApi.list({ pageSize: 200 }), regionsApi.list({ pageSize: 200 }),
@@ -108,7 +137,6 @@ export function LoadingSheetItemsDialog({ open, onClose, onSuccess, sheet }: Loa
if (!open) return if (!open) return
setLines({}) setLines({})
setShopId('') setShopId('')
setViewStoreId('')
if (!routeId) { if (!routeId) {
setPos([]) setPos([])
return return
@@ -121,7 +149,31 @@ export function LoadingSheetItemsDialog({ open, onClose, onSuccess, sheet }: Loa
}) })
.catch((err) => toast.add({ title: 'Failed to load Shop POs', description: getApiError(err), type: 'error' })) .catch((err) => toast.add({ title: 'Failed to load Shop POs', description: getApiError(err), type: 'error' }))
.finally(() => setLoading(false)) .finally(() => setLoading(false))
}, [open, routeId]) }, [open, routeId, regionId])
// Stock in the chosen warehouse is fetched once the user moves past step 1, so
// step 2 can show — and warn against — quantities that exceed what's on hand.
useEffect(() => {
if (!open || !warehouseId || step < 2) return
setStockLoading(true)
stockApi.balances({ locationId: warehouseId, locationType: 'Warehouse', pageSize: 1000 })
.then((res) => {
const map: Record<string, number> = {}
for (const b of getPaginatedItems(res)) map[b.itemId] = b.quantity
setStockByItem(map)
})
.catch((err) => toast.add({ title: 'Failed to load warehouse stock', description: getApiError(err), type: 'error' }))
.finally(() => setStockLoading(false))
}, [open, warehouseId, step])
// First-time users land on step 2 with everything collapsed and no obvious next
// move, so the first shop is opened automatically to show what "pick items" means.
useEffect(() => {
if (step !== 2 || pos.length === 0 || expandedStores.size > 0) return
const firstStoreId = pos[0]?.storeId
if (firstStoreId) setExpandedStores(new Set([firstStoreId]))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [step, pos])
const rows = useMemo<PickedLine[]>(() => { const rows = useMemo<PickedLine[]>(() => {
const result: PickedLine[] = [] const result: PickedLine[] = []
@@ -164,22 +216,25 @@ export function LoadingSheetItemsDialog({ open, onClose, onSuccess, sheet }: Loa
[allShopGroups, shopId] [allShopGroups, shopId]
) )
const viewGroup = allShopGroups.find((g) => g.storeId === viewStoreId) const selectedRows = useMemo(
() => rows.filter((r) => (lines[r.shopPOItemId]?.checked ?? false) && lines[r.shopPOItemId].quantity > 0),
[rows, lines]
)
const summary = useMemo(() => { const summary = useMemo(() => {
const map = new Map<string, { itemName: string; qty: number; shops: Set<string> }>() const map = new Map<string, { itemId: string; itemName: string; qty: number; available: number; shops: Set<string> }>()
for (const row of rows) { for (const row of selectedRows) {
const line = lines[row.shopPOItemId] ?? row const line = lines[row.shopPOItemId]
if (!line.checked || !(line.quantity > 0)) continue const e = map.get(row.itemId) ?? { itemId: row.itemId, itemName: row.itemName, qty: 0, available: stockByItem[row.itemId] ?? 0, shops: new Set<string>() }
const e = map.get(row.itemId) ?? { itemName: row.itemName, qty: 0, shops: new Set<string>() }
e.qty += line.quantity e.qty += line.quantity
e.shops.add(row.storeId) e.shops.add(row.storeId)
map.set(row.itemId, e) map.set(row.itemId, e)
} }
return Array.from(map.values()).sort((a, b) => a.itemName.localeCompare(b.itemName)) return Array.from(map.values()).sort((a, b) => a.itemName.localeCompare(b.itemName))
}, [rows, lines]) }, [selectedRows, lines, stockByItem])
const summaryTotal = summary.reduce((s, e) => s + e.qty, 0) const summaryTotal = summary.reduce((s, e) => s + e.qty, 0)
const shortItems = summary.filter((e) => e.qty > e.available)
function toggleLine(line: PickedLine, checked: boolean) { function toggleLine(line: PickedLine, checked: boolean) {
setLines((prev) => ({ ...prev, [line.shopPOItemId]: { ...line, checked } })) setLines((prev) => ({ ...prev, [line.shopPOItemId]: { ...line, checked } }))
@@ -189,8 +244,17 @@ export function LoadingSheetItemsDialog({ open, onClose, onSuccess, sheet }: Loa
setLines((prev) => ({ ...prev, [line.shopPOItemId]: { ...line, quantity } })) setLines((prev) => ({ ...prev, [line.shopPOItemId]: { ...line, quantity } }))
} }
async function handleSubmit() { function toggleExpanded(storeId: string) {
if (!sheet) return setExpandedStores((prev) => {
const next = new Set(prev)
if (next.has(storeId)) next.delete(storeId)
else next.add(storeId)
return next
})
}
function goNext() {
if (step === 1) {
if (!routeId) { if (!routeId) {
toast.add({ title: 'Select a route first', type: 'error' }) toast.add({ title: 'Select a route first', type: 'error' })
return return
@@ -199,13 +263,26 @@ export function LoadingSheetItemsDialog({ open, onClose, onSuccess, sheet }: Loa
toast.add({ title: 'Select a warehouse first', type: 'error' }) toast.add({ title: 'Select a warehouse first', type: 'error' })
return return
} }
const selected = rows.filter((r) => (lines[r.shopPOItemId]?.checked ?? false) && lines[r.shopPOItemId].quantity > 0) setStep(2)
if (selected.length === 0) { return
}
if (step === 2) {
if (selectedRows.length === 0) {
toast.add({ title: 'Select at least one item', type: 'error' }) toast.add({ title: 'Select at least one item', type: 'error' })
return return
} }
setStep(3)
}
}
const items: LoadingSheetItemInput[] = selected.map((r) => { function goBack() {
setStep((s) => (s === 3 ? 2 : 1) as 1 | 2)
}
async function handleConfirmSave() {
if (!sheet) return
const items: LoadingSheetItemInput[] = selectedRows.map((r) => {
const line = lines[r.shopPOItemId] const line = lines[r.shopPOItemId]
return { return {
fK_ItemId: line.itemId, fK_ItemId: line.itemId,
@@ -247,21 +324,55 @@ export function LoadingSheetItemsDialog({ open, onClose, onSuccess, sheet }: Loa
if (!sheet) return null if (!sheet) return null
const selectedWarehouseName = warehousesList.find((w) => w.id === warehouseId)?.name ?? ''
return ( return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}> <Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="overflow-y-auto sm:max-w-3xl"> <DialogContent className="overflow-y-auto sm:max-w-3xl">
<DialogHeader> <DialogHeader>
<DialogTitle>Set Loading Sheet Items</DialogTitle> <DialogTitle>Set Loading Sheet Items</DialogTitle>
<DialogDescription> <DialogDescription>
Select a route first. Confirmed Shop POs for that route will then be available to pick {sheet.status === 'Confirmed'
items from. ? 'This sheet is already confirmed, so the items you pick are saved on a new planned sheet for the same ride.'
{sheet.status === 'Confirmed' && ( : 'Work through the steps below to build the loading sheet.'}
<> This sheet is already confirmed, so the items you pick are saved on a new planned sheet for the same ride.</>
)}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="flex flex-wrap items-end gap-3"> {/* Step indicator */}
<div className="flex items-center gap-2 text-xs">
{STEPS.map((s, idx) => (
<div key={s.n} className="flex items-center gap-2">
<button
type="button"
disabled={s.n > step}
onClick={() => s.n < step && setStep(s.n)}
className={cn(
'flex h-6 w-6 items-center justify-center rounded-full border font-semibold transition-colors',
step === s.n
? 'border-primary bg-primary text-primary-foreground'
: step > s.n
? 'border-primary/40 bg-primary/10 text-primary hover:bg-primary/20'
: 'border-muted-foreground/30 text-muted-foreground',
s.n < step && 'cursor-pointer'
)}
>
{step > s.n ? '✓' : s.n}
</button>
<span className={cn(step === s.n ? 'font-medium text-foreground' : 'text-muted-foreground')}>{s.label}</span>
{idx < STEPS.length - 1 && <div className="mx-1 h-px w-8 bg-border" />}
</div>
))}
</div>
{/* Step title & guidance */}
<div className="rounded-lg bg-muted/40 px-3 py-2">
<p className="text-sm font-medium">{STEPS[step - 1].title}</p>
<p className="mt-0.5 text-xs text-muted-foreground">{STEPS[step - 1].help}</p>
</div>
{/* Step 1: Route & Warehouse */}
{step === 1 && (
<div className="flex flex-wrap items-end gap-3 py-2">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label className="text-xs text-muted-foreground">Region</label> <label className="text-xs text-muted-foreground">Region</label>
<Select value={regionId || ALL} onValueChange={(v) => setRegionId(!v || v === ALL ? '' : v)}> <Select value={regionId || ALL} onValueChange={(v) => setRegionId(!v || v === ALL ? '' : v)}>
@@ -291,21 +402,6 @@ export function LoadingSheetItemsDialog({ open, onClose, onSuccess, sheet }: Loa
</Select> </Select>
</div> </div>
<div className="flex flex-col gap-1">
<label className="text-xs text-muted-foreground">Shop</label>
<Select value={shopId || ALL} onValueChange={(v) => setShopId(!v || v === ALL ? '' : v)} disabled={!routeId}>
<SelectTrigger className="w-48">
<SelectValue placeholder="All shops" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL}>All shops</SelectItem>
{allShopGroups.map((g) => (
<SelectItem key={g.storeId} value={g.storeId}>{g.storeName}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label className="text-xs text-muted-foreground">Warehouse *</label> <label className="text-xs text-muted-foreground">Warehouse *</label>
<Select value={warehouseId} onValueChange={(v) => setWarehouseId(v ?? '')}> <Select value={warehouseId} onValueChange={(v) => setWarehouseId(v ?? '')}>
@@ -320,11 +416,31 @@ export function LoadingSheetItemsDialog({ open, onClose, onSuccess, sheet }: Loa
</Select> </Select>
</div> </div>
</div> </div>
)}
{/* Step 2: Pick Items */}
{step === 2 && (
<div className="flex flex-col gap-3 py-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-sm text-muted-foreground">
Route <span className="font-medium text-foreground">{routesList.find((r) => r.id === routeId)?.routeName}</span>
{' · '}Warehouse <span className="font-medium text-foreground">{selectedWarehouseName}</span>
</div>
<Select value={shopId || ALL} onValueChange={(v) => setShopId(!v || v === ALL ? '' : v)}>
<SelectTrigger className="w-48">
<SelectValue placeholder="All shops" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL}>All shops</SelectItem>
{allShopGroups.map((g) => (
<SelectItem key={g.storeId} value={g.storeId}>{g.storeName}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="rounded-xl border"> <div className="rounded-xl border">
{!routeId ? ( {loading ? (
<p className="px-3 py-6 text-center text-sm text-muted-foreground">Select a route to see shops with confirmed POs.</p>
) : loading ? (
<p className="px-3 py-6 text-center text-sm text-muted-foreground">Loading…</p> <p className="px-3 py-6 text-center text-sm text-muted-foreground">Loading…</p>
) : shopGroups.length === 0 ? ( ) : shopGroups.length === 0 ? (
<p className="px-3 py-6 text-center text-sm text-muted-foreground">No shops with confirmed POs found for this selection.</p> <p className="px-3 py-6 text-center text-sm text-muted-foreground">No shops with confirmed POs found for this selection.</p>
@@ -333,85 +449,54 @@ export function LoadingSheetItemsDialog({ open, onClose, onSuccess, sheet }: Loa
const groupLines = group.rows.map((r) => lines[r.shopPOItemId] ?? r) const groupLines = group.rows.map((r) => lines[r.shopPOItemId] ?? r)
const allChecked = groupLines.every((l) => l.checked) const allChecked = groupLines.every((l) => l.checked)
const someChecked = groupLines.some((l) => l.checked) const someChecked = groupLines.some((l) => l.checked)
const checkedCount = groupLines.filter((l) => l.checked).length
const expanded = expandedStores.has(group.storeId)
return ( return (
<div key={group.storeId} className="flex items-center gap-3 border-t px-3 py-2 first:border-t-0"> <div key={group.storeId} className="border-t first:border-t-0">
<div className="flex items-center gap-3 px-3 py-2">
<Checkbox <Checkbox
checked={allChecked} checked={allChecked}
indeterminate={someChecked && !allChecked} indeterminate={someChecked && !allChecked}
title="Select all items for this shop"
onCheckedChange={(v) => group.rows.forEach((r) => toggleLine(lines[r.shopPOItemId] ?? r, v === true))} onCheckedChange={(v) => group.rows.forEach((r) => toggleLine(lines[r.shopPOItemId] ?? r, v === true))}
/> />
<button
type="button"
className="flex min-w-0 flex-1 items-center gap-2 text-left"
onClick={() => toggleExpanded(group.storeId)}
>
<span className="min-w-0 flex-1 truncate font-medium">{group.storeName}</span> <span className="min-w-0 flex-1 truncate font-medium">{group.storeName}</span>
{checkedCount > 0 && (
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
{checkedCount} selected
</span>
)}
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{group.poIds.size} PO{group.poIds.size === 1 ? '' : 's'} {group.poIds.size} PO{group.poIds.size === 1 ? '' : 's'}
</span> </span>
<Button <span className="hidden text-xs text-muted-foreground sm:inline">
type="button" {expanded ? 'Hide items' : 'View items'}
variant="ghost" </span>
size="icon" <HugeiconsIcon icon={expanded ? ArrowUp01Icon : ArrowDown01Icon} size={16} />
aria-label={`View confirmed PO items for ${group.storeName}`} </button>
onClick={() => setViewStoreId(group.storeId)}
>
<HugeiconsIcon icon={ViewIcon} size={18} />
</Button>
</div> </div>
) {expanded && (
}) <div className="overflow-x-auto border-t bg-muted/20">
)}
</div>
<div className="rounded-xl border">
<div className="flex items-center justify-between bg-muted/50 px-3 py-2 text-sm font-medium">
<span>Loading Sheet Summary</span>
<span className="text-muted-foreground">{summary.length} item{summary.length === 1 ? '' : 's'} · {summaryTotal} units</span>
</div>
{summary.length === 0 ? (
<p className="px-3 py-4 text-center text-sm text-muted-foreground">Tick items above to build the summary.</p>
) : (
<table className="w-full text-sm"> <table className="w-full text-sm">
<tbody> <thead>
{summary.map((e) => ( <tr className="text-xs text-muted-foreground">
<tr key={e.itemName} className="border-t first:border-t-0">
<td className="px-3 py-2">{e.itemName}</td>
<td className="px-3 py-2 text-muted-foreground">{e.shops.size} shop{e.shops.size === 1 ? '' : 's'}</td>
<td className="px-3 py-2 text-right font-semibold">{e.qty}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>
Cancel
</Button>
<Button type="button" disabled={submitting} onClick={handleSubmit}>
{submitting ? 'Saving…' : 'Save Items'}
</Button>
</DialogFooter>
<Dialog open={!!viewGroup} onOpenChange={(v) => !v && setViewStoreId('')}>
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{viewGroup?.storeName}</DialogTitle>
<DialogDescription>
Confirmed PO items. Tick the items to load and set the quantity.
</DialogDescription>
</DialogHeader>
<div className="overflow-x-auto rounded-xl border">
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr>
<th className="w-10 px-3 py-2"></th> <th className="w-10 px-3 py-2"></th>
<th className="px-3 py-2 text-left font-medium">Item</th> <th className="px-3 py-2 text-left font-medium">Item</th>
<th className="w-24 px-3 py-2 text-left font-medium">PO</th>
<th className="w-24 px-3 py-2 text-right font-medium">Ordered</th> <th className="w-24 px-3 py-2 text-right font-medium">Ordered</th>
<th className="w-28 px-3 py-2 text-right font-medium">In stock</th>
<th className="w-32 px-3 py-2 text-right font-medium">Qty to load</th> <th className="w-32 px-3 py-2 text-right font-medium">Qty to load</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{viewGroup?.rows.map((row) => { {group.rows.map((row) => {
const line = lines[row.shopPOItemId] ?? row const line = lines[row.shopPOItemId] ?? row
const available = stockByItem[row.itemId] ?? 0
const short = line.checked && line.quantity > available
return ( return (
<tr key={row.shopPOItemId} className="border-t"> <tr key={row.shopPOItemId} className="border-t">
<td className="px-3 py-2"> <td className="px-3 py-2">
@@ -421,17 +506,30 @@ export function LoadingSheetItemsDialog({ open, onClose, onSuccess, sheet }: Loa
/> />
</td> </td>
<td className="px-3 py-2">{row.itemName}</td> <td className="px-3 py-2">{row.itemName}</td>
<td className="px-3 py-2 font-mono text-xs text-muted-foreground">{row.shopPOId.substring(0, 8)}</td>
<td className="px-3 py-2 text-right text-muted-foreground">{row.remainingQty}</td> <td className="px-3 py-2 text-right text-muted-foreground">{row.remainingQty}</td>
<td className={cn('px-3 py-2 text-right', stockLoading ? 'text-muted-foreground' : available === 0 ? 'text-destructive' : 'text-muted-foreground')}>
{stockLoading ? '…' : available}
</td>
<td className="px-3 py-2"> <td className="px-3 py-2">
<div className="flex items-center justify-end gap-1">
<Input <Input
type="number" type="number"
min={1} min={1}
max={row.remainingQty} max={row.remainingQty}
value={line.quantity} value={line.quantity}
disabled={!line.checked} disabled={!line.checked}
className={cn('w-24', short && 'border-amber-500 focus-visible:ring-amber-500')}
onChange={(e) => setLineQuantity(row, Number(e.target.value))} onChange={(e) => setLineQuantity(row, Number(e.target.value))}
/> />
{short && (
<HugeiconsIcon icon={Alert02Icon} size={16} className="shrink-0 text-amber-500" />
)}
</div>
{short && (
<p className="mt-1 text-right text-[11px] text-amber-600">
Only {available} available — rest stays pending
</p>
)}
</td> </td>
</tr> </tr>
) )
@@ -439,12 +537,102 @@ export function LoadingSheetItemsDialog({ open, onClose, onSuccess, sheet }: Loa
</tbody> </tbody>
</table> </table>
</div> </div>
<DialogFooter> )}
<Button type="button" onClick={() => setViewStoreId('')}>Done</Button> </div>
)
})
)}
</div>
<p className="text-xs text-muted-foreground">
{selectedRows.length} item line{selectedRows.length === 1 ? '' : 's'} selected. Expand a shop to tick items and set quantities.
</p>
</div>
)}
{/* Step 3: Review & Confirm */}
{step === 3 && (
<div className="flex flex-col gap-3 py-2">
<div className="text-sm text-muted-foreground">
Route <span className="font-medium text-foreground">{routesList.find((r) => r.id === routeId)?.routeName}</span>
{' · '}Warehouse <span className="font-medium text-foreground">{selectedWarehouseName}</span>
</div>
{shortItems.length > 0 && (
<div className="flex items-start gap-2 rounded-lg border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-700 dark:text-amber-400">
<HugeiconsIcon icon={Alert02Icon} size={16} className="mt-0.5 shrink-0" />
<span>
{shortItems.length} item{shortItems.length === 1 ? '' : 's'} exceed{shortItems.length === 1 ? 's' : ''} what&apos;s
in stock at {selectedWarehouseName}. Saving will still work, but confirming this sheet will fail until the
quantities are reduced to what&apos;s available — go back to adjust them.
</span>
</div>
)}
<div className="rounded-xl border">
<div className="flex items-center justify-between bg-muted/50 px-3 py-2 text-sm font-medium">
<span>Loading Sheet Summary</span>
<span className="text-muted-foreground">{summary.length} item{summary.length === 1 ? '' : 's'} · {summaryTotal} units</span>
</div>
{summary.length === 0 ? (
<p className="px-3 py-4 text-center text-sm text-muted-foreground">No items selected.</p>
) : (
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-muted-foreground">
<th className="px-3 py-2 text-left font-medium">Item</th>
<th className="px-3 py-2 text-left font-medium">Shops</th>
<th className="px-3 py-2 text-right font-medium">In stock</th>
<th className="px-3 py-2 text-right font-medium">Qty to load</th>
<th className="px-3 py-2 text-right font-medium">Short</th>
</tr>
</thead>
<tbody>
{summary.map((e) => {
const shortQty = Math.max(0, e.qty - e.available)
return (
<tr key={e.itemId} className="border-t">
<td className="px-3 py-2">{e.itemName}</td>
<td className="px-3 py-2 text-muted-foreground">{e.shops.size} shop{e.shops.size === 1 ? '' : 's'}</td>
<td className="px-3 py-2 text-right text-muted-foreground">{e.available}</td>
<td className="px-3 py-2 text-right font-semibold">{e.qty}</td>
<td className={cn('px-3 py-2 text-right', shortQty > 0 ? 'font-medium text-amber-600' : 'text-muted-foreground')}>
{shortQty > 0 ? shortQty : '—'}
</td>
</tr>
)
})}
</tbody>
</table>
)}
</div>
</div>
)}
<DialogFooter className="flex items-center justify-between sm:justify-between">
<div>
{step > 1 && (
<Button type="button" variant="outline" onClick={goBack}>
Back
</Button>
)}
</div>
<div className="flex gap-2">
<Button type="button" variant="ghost" onClick={onClose}>
Cancel
</Button>
{step < 3 ? (
<Button type="button" onClick={goNext}>
{step === 1 ? 'Next: Pick items' : `Next: Review${selectedRows.length > 0 ? ` (${selectedRows.length})` : ''}`}
</Button>
) : (
<Button type="button" disabled={submitting} onClick={handleConfirmSave}>
{submitting ? 'Saving…' : 'Save Items'}
</Button>
)}
</div>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</DialogContent>
</Dialog>
) )
} }
@@ -25,6 +25,7 @@ import { Button } from '@/components/ui/button'
import { FieldGroup } from '@/components/ui/field' import { FieldGroup } from '@/components/ui/field'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import { toast } from '@/components/ui/toast' import { toast } from '@/components/ui/toast'
import { cn } from '@/lib/utils'
import { import {
shopPos as shopPosApi, shopPos as shopPosApi,
regions as regionsApi, regions as regionsApi,
@@ -53,6 +54,39 @@ import type { LoadingSheetItemInput } from '@/lib/api/operations'
const ALL = '__all__' const ALL = '__all__'
const STEPS = [
{
n: 1,
label: 'Route & Warehouse',
title: 'Where is this load coming from?',
help: "Pick the route this ride will deliver (optional if you're only adding items manually below) and the warehouse every item will be dispatched from.",
},
{
n: 2,
label: 'Ride & Assignment',
title: 'Which vehicle, and who is populating this sheet?',
help: 'Choose the open ride whose vehicle gets loaded, and who is creating this sheet.',
},
{
n: 3,
label: 'Items',
title: 'What should be loaded from Shop POs?',
help: "Expand a shop to tick items and set quantities. If the main warehouse doesn't have enough stock, use \"Take remaining from another warehouse\" to top up the rest.",
},
{
n: 4,
label: 'Additional Items',
title: "Anything to load that's not on a PO?",
help: "Optional — add extra stock for the vehicle that isn't tied to any shop order.",
},
{
n: 5,
label: 'Review & Confirm',
title: 'Double-check before saving',
help: 'This is everything that will be saved to the sheet. Fix anything short before confirming later on the loading sheet list.',
},
] as const
interface ExtraAllocation { interface ExtraAllocation {
warehouseId: string warehouseId: string
quantity: number quantity: number
@@ -115,6 +149,8 @@ function LoadingSheetForm() {
const [rideId, setRideId] = useState('') const [rideId, setRideId] = useState('')
const [populatedByUserId, setPopulatedByUserId] = useState('') const [populatedByUserId, setPopulatedByUserId] = useState('')
const [step, setStep] = useState<1 | 2 | 3 | 4 | 5>(1)
const [viewStoreId, setViewStoreId] = useState('') const [viewStoreId, setViewStoreId] = useState('')
const [itemsList, setItemsList] = useState<ItemSummaryResponse[]>([]) const [itemsList, setItemsList] = useState<ItemSummaryResponse[]>([])
const [manualItems, setManualItems] = useState<ManualItem[]>([]) const [manualItems, setManualItems] = useState<ManualItem[]>([])
@@ -441,6 +477,34 @@ function LoadingSheetForm() {
updateExtras(line, line.extras.filter((_, i) => i !== index)) updateExtras(line, line.extras.filter((_, i) => i !== index))
} }
function goNext() {
if (step === 1) {
if (!warehouseId) {
toast.add({ title: 'Select a warehouse first', type: 'error' })
return
}
setStep(2)
return
}
if (step === 2) {
if (!rideId) {
toast.add({ title: 'Select a ride to load the vehicle', type: 'error' })
return
}
if (!populatedByUserId) {
toast.add({ title: 'Select who is populating this sheet', type: 'error' })
return
}
setStep(3)
return
}
if (step < 5) setStep((s) => (s + 1) as typeof step)
}
function goBack() {
setStep((s) => Math.max(1, s - 1) as typeof step)
}
async function handleSubmit() { async function handleSubmit() {
if (!routeId && manualItems.length === 0) { if (!routeId && manualItems.length === 0) {
toast.add({ title: 'Select a route first', type: 'error' }) toast.add({ title: 'Select a route first', type: 'error' })
@@ -541,13 +605,43 @@ function LoadingSheetForm() {
</Button> </Button>
<div> <div>
<h1 className="text-2xl font-semibold text-foreground">{isEdit ? 'Edit Loading Sheet' : 'New Loading Sheet'}</h1> <h1 className="text-2xl font-semibold text-foreground">{isEdit ? 'Edit Loading Sheet' : 'New Loading Sheet'}</h1>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">Work through the steps below to build the loading sheet.</p>
Select a route first. Confirmed Shop POs for that route&apos;s shops will then be
available to pick items from. Pick the ride to load last.
</p>
</div> </div>
</div> </div>
{/* Step indicator */}
<div className="flex flex-wrap items-center gap-2 text-xs">
{STEPS.map((s, idx) => (
<div key={s.n} className="flex items-center gap-2">
<button
type="button"
disabled={s.n > step}
onClick={() => s.n < step && setStep(s.n)}
className={cn(
'flex h-6 w-6 items-center justify-center rounded-full border font-semibold transition-colors',
step === s.n
? 'border-primary bg-primary text-primary-foreground'
: step > s.n
? 'border-primary/40 bg-primary/10 text-primary hover:bg-primary/20'
: 'border-muted-foreground/30 text-muted-foreground',
s.n < step && 'cursor-pointer'
)}
>
{step > s.n ? '✓' : s.n}
</button>
<span className={cn(step === s.n ? 'font-medium text-foreground' : 'text-muted-foreground')}>{s.label}</span>
{idx < STEPS.length - 1 && <div className="mx-1 h-px w-8 bg-border" />}
</div>
))}
</div>
{/* Step title & guidance */}
<div className="rounded-lg bg-muted/40 px-3 py-2">
<p className="text-sm font-medium">{STEPS[step - 1].title}</p>
<p className="mt-0.5 text-xs text-muted-foreground">{STEPS[step - 1].help}</p>
</div>
{step === 1 && (
<div className="rounded-2xl border p-6"> <div className="rounded-2xl border p-6">
<h3 className="mb-4 text-sm font-medium text-foreground">Route &amp; Warehouse</h3> <h3 className="mb-4 text-sm font-medium text-foreground">Route &amp; Warehouse</h3>
<FieldGroup> <FieldGroup>
@@ -616,7 +710,9 @@ function LoadingSheetForm() {
</div> </div>
</FieldGroup> </FieldGroup>
</div> </div>
)}
{step === 3 && (
<div className="rounded-2xl border p-6"> <div className="rounded-2xl border p-6">
<div className="mb-4"> <div className="mb-4">
<h3 className="text-sm font-medium text-foreground">Items</h3> <h3 className="text-sm font-medium text-foreground">Items</h3>
@@ -824,7 +920,9 @@ function LoadingSheetForm() {
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</div> </div>
)}
{step === 4 && (
<div className="rounded-2xl border p-6"> <div className="rounded-2xl border p-6">
<div className="mb-4 flex items-center justify-between gap-4"> <div className="mb-4 flex items-center justify-between gap-4">
<div> <div>
@@ -897,7 +995,9 @@ function LoadingSheetForm() {
</div> </div>
)} )}
</div> </div>
)}
{step === 2 && (
<div className="rounded-2xl border p-6"> <div className="rounded-2xl border p-6">
<h3 className="mb-4 text-sm font-medium text-foreground">Ride &amp; Assignment</h3> <h3 className="mb-4 text-sm font-medium text-foreground">Ride &amp; Assignment</h3>
<FieldGroup> <FieldGroup>
@@ -935,7 +1035,9 @@ function LoadingSheetForm() {
</div> </div>
</FieldGroup> </FieldGroup>
</div> </div>
)}
{step === 5 && (
<div> <div>
<div className="w-full rounded-2xl border p-6"> <div className="w-full rounded-2xl border p-6">
<h3 className="text-sm font-medium text-foreground">Loading Sheet Summary</h3> <h3 className="text-sm font-medium text-foreground">Loading Sheet Summary</h3>
@@ -969,15 +1071,31 @@ function LoadingSheetForm() {
<p className="text-sm font-medium text-foreground">Total Quantity</p> <p className="text-sm font-medium text-foreground">Total Quantity</p>
<p className="text-xl font-semibold text-foreground">{totalQuantity}</p> <p className="text-xl font-semibold text-foreground">{totalQuantity}</p>
</div> </div>
</div>
</div>
)}
<div className="mt-6 flex justify-end gap-2"> <div className="flex items-center justify-between rounded-2xl border bg-muted/20 p-4">
<Button type="button" variant="outline" onClick={() => router.push('/operations/loading')}> <div>
{step > 1 && (
<Button type="button" variant="outline" onClick={goBack}>
Back
</Button>
)}
</div>
<div className="flex gap-2">
<Button type="button" variant="ghost" onClick={() => router.push('/operations/loading')}>
Cancel Cancel
</Button> </Button>
{step < 5 ? (
<Button type="button" onClick={goNext}>
{step === 1 ? 'Next: Ride & Assignment' : step === 4 ? `Next: Review${(selectedRows.length + manualItems.length) > 0 ? ` (${totalQuantity})` : ''}` : 'Next'}
</Button>
) : (
<Button type="button" disabled={submitting} onClick={handleSubmit}> <Button type="button" disabled={submitting} onClick={handleSubmit}>
{submitting ? 'Saving…' : isEdit ? 'Save Changes' : 'Create Loading Sheet'} {submitting ? 'Saving…' : isEdit ? 'Save Changes' : 'Create Loading Sheet'}
</Button> </Button>
</div> )}
</div> </div>
</div> </div>
</div> </div>
@@ -151,6 +151,20 @@ export function SalesClient() {
fields: [{ key: 'storeName', primary: true }], fields: [{ key: 'storeName', primary: true }],
minWidth: 180, minWidth: 180,
}, },
{
key: 'storeDetails',
title: 'Store Contact',
fields: ['storeAddress', 'storeContactNumber'],
minWidth: 200,
cellRender: (row) => (
<div className="flex flex-col">
<span className="text-sm">{row.storeAddress ?? '—'}</span>
{row.storeContactNumber && (
<span className="text-xs text-muted-foreground">{row.storeContactNumber}</span>
)}
</div>
),
},
{ {
key: 'totalAmount', key: 'totalAmount',
title: 'Total', title: 'Total',
@@ -172,7 +186,7 @@ export function SalesClient() {
{ {
key: 'createdBy', key: 'createdBy',
title: 'Created By', title: 'Created By',
fields: ['raisedByName'], fields: ['raisedByName', 'createdAt'],
minWidth: 160, minWidth: 160,
cellRender: (row) => ( cellRender: (row) => (
<div className="flex flex-col"> <div className="flex flex-col">
@@ -180,6 +194,7 @@ export function SalesClient() {
{row.raisedByRole && ( {row.raisedByRole && (
<span className="text-xs text-muted-foreground">{row.raisedByRole}</span> <span className="text-xs text-muted-foreground">{row.raisedByRole}</span>
)} )}
<span className="text-xs text-muted-foreground">{fmtDate(row.createdAt)}</span>
</div> </div>
), ),
}, },
@@ -221,12 +236,6 @@ export function SalesClient() {
<span className="text-sm text-muted-foreground">—</span> <span className="text-sm text-muted-foreground">—</span>
), ),
}, },
{
key: 'createdAt',
title: 'Created',
fields: [{ key: 'createdAt', transform: (v) => fmtDate(v as string) }],
width: 140,
},
], ],
rowActions: { rowActions: {
actions: () => [ actions: () => [
+2
View File
@@ -639,6 +639,8 @@ export interface ShopPOResponse {
id: string id: string
storeId: string storeId: string
storeName: string storeName: string
storeAddress: string | null
storeContactNumber: string | null
raisedById: string raisedById: string
invoiceId: string | null invoiceId: string | null
totalAmount: number totalAmount: number