Merge pull request 'Refactor/main flow' (#50) from refactor/main_flow into dev
Reviewed-on: #50
This commit was merged in pull request #50.
This commit is contained in:
@@ -14,6 +14,8 @@ public record ShopPOResponse(
|
||||
Guid Id,
|
||||
Guid StoreId,
|
||||
string StoreName,
|
||||
string? StoreAddress,
|
||||
string? StoreContactNumber,
|
||||
Guid RaisedById,
|
||||
Guid? InvoiceId,
|
||||
decimal TotalAmount,
|
||||
|
||||
@@ -14,6 +14,7 @@ public class ShopPOService(
|
||||
{
|
||||
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,
|
||||
p.Store?.StoreAddress, p.Store?.ContactNumber,
|
||||
p.RaisedById, p.InvoiceId, p.TotalAmount, p.Status,
|
||||
p.ConfirmedAt, p.ConfirmedBy, p.FulfilledAt,
|
||||
p.Items.Where(i => i.IsActive).Select(i =>
|
||||
|
||||
+403
-215
@@ -20,14 +20,16 @@ import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Button } from '@/components/ui/button'
|
||||
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 { cn } from '@/lib/utils'
|
||||
import {
|
||||
shopPos as shopPosApi,
|
||||
regions as regionsApi,
|
||||
routes as routesApi,
|
||||
warehouses as warehousesApi,
|
||||
loadingSheets as loadingSheetsApi,
|
||||
stock as stockApi,
|
||||
getApiError,
|
||||
getPaginatedItems,
|
||||
} from '@/lib/api/index'
|
||||
@@ -35,6 +37,26 @@ import type { LoadingSheetResponse, ShopPOResponse, RegionResponse, RouteRespons
|
||||
import type { LoadingSheetItemInput } from '@/lib/api/operations'
|
||||
|
||||
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 {
|
||||
open: boolean
|
||||
@@ -57,6 +79,8 @@ interface PickedLine {
|
||||
}
|
||||
|
||||
export function LoadingSheetItemsDialog({ open, onClose, onSuccess, sheet }: LoadingSheetItemsDialogProps) {
|
||||
const [step, setStep] = useState<1 | 2 | 3>(1)
|
||||
|
||||
const [regionsList, setRegionsList] = useState<RegionResponse[]>([])
|
||||
const [routesList, setRoutesList] = useState<RouteResponse[]>([])
|
||||
const [warehousesList, setWarehousesList] = useState<WarehouseResponse[]>([])
|
||||
@@ -65,22 +89,27 @@ export function LoadingSheetItemsDialog({ open, onClose, onSuccess, sheet }: Loa
|
||||
const [routeId, setRouteId] = useState('')
|
||||
const [warehouseId, setWarehouseId] = useState('')
|
||||
const [shopId, setShopId] = useState('')
|
||||
const [viewStoreId, setViewStoreId] = useState('')
|
||||
const [expandedStores, setExpandedStores] = useState<Set<string>>(new Set())
|
||||
|
||||
const [pos, setPos] = useState<ShopPOResponse[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [lines, setLines] = useState<Record<string, PickedLine>>({})
|
||||
|
||||
const [stockByItem, setStockByItem] = useState<Record<string, number>>({})
|
||||
const [stockLoading, setStockLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setStep(1)
|
||||
setRegionId('')
|
||||
setRouteId('')
|
||||
setWarehouseId('')
|
||||
setShopId('')
|
||||
setViewStoreId('')
|
||||
setExpandedStores(new Set())
|
||||
setPos([])
|
||||
setLines({})
|
||||
setStockByItem({})
|
||||
|
||||
Promise.all([
|
||||
regionsApi.list({ pageSize: 200 }),
|
||||
@@ -108,7 +137,6 @@ export function LoadingSheetItemsDialog({ open, onClose, onSuccess, sheet }: Loa
|
||||
if (!open) return
|
||||
setLines({})
|
||||
setShopId('')
|
||||
setViewStoreId('')
|
||||
if (!routeId) {
|
||||
setPos([])
|
||||
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' }))
|
||||
.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 result: PickedLine[] = []
|
||||
@@ -164,22 +216,25 @@ export function LoadingSheetItemsDialog({ open, onClose, onSuccess, sheet }: Loa
|
||||
[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 map = new Map<string, { itemName: string; qty: number; shops: Set<string> }>()
|
||||
for (const row of rows) {
|
||||
const line = lines[row.shopPOItemId] ?? row
|
||||
if (!line.checked || !(line.quantity > 0)) continue
|
||||
const e = map.get(row.itemId) ?? { itemName: row.itemName, qty: 0, shops: new Set<string>() }
|
||||
const map = new Map<string, { itemId: string; itemName: string; qty: number; available: number; shops: Set<string> }>()
|
||||
for (const row of selectedRows) {
|
||||
const line = lines[row.shopPOItemId]
|
||||
const e = map.get(row.itemId) ?? { itemId: row.itemId, itemName: row.itemName, qty: 0, available: stockByItem[row.itemId] ?? 0, shops: new Set<string>() }
|
||||
e.qty += line.quantity
|
||||
e.shops.add(row.storeId)
|
||||
map.set(row.itemId, e)
|
||||
}
|
||||
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 shortItems = summary.filter((e) => e.qty > e.available)
|
||||
|
||||
function toggleLine(line: PickedLine, checked: boolean) {
|
||||
setLines((prev) => ({ ...prev, [line.shopPOItemId]: { ...line, checked } }))
|
||||
@@ -189,23 +244,45 @@ export function LoadingSheetItemsDialog({ open, onClose, onSuccess, sheet }: Loa
|
||||
setLines((prev) => ({ ...prev, [line.shopPOItemId]: { ...line, quantity } }))
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!sheet) return
|
||||
if (!routeId) {
|
||||
toast.add({ title: 'Select a route first', type: 'error' })
|
||||
return
|
||||
}
|
||||
if (!warehouseId) {
|
||||
toast.add({ title: 'Select a warehouse first', type: 'error' })
|
||||
return
|
||||
}
|
||||
const selected = rows.filter((r) => (lines[r.shopPOItemId]?.checked ?? false) && lines[r.shopPOItemId].quantity > 0)
|
||||
if (selected.length === 0) {
|
||||
toast.add({ title: 'Select at least one item', type: 'error' })
|
||||
return
|
||||
}
|
||||
function toggleExpanded(storeId: string) {
|
||||
setExpandedStores((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(storeId)) next.delete(storeId)
|
||||
else next.add(storeId)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const items: LoadingSheetItemInput[] = selected.map((r) => {
|
||||
function goNext() {
|
||||
if (step === 1) {
|
||||
if (!routeId) {
|
||||
toast.add({ title: 'Select a route first', type: 'error' })
|
||||
return
|
||||
}
|
||||
if (!warehouseId) {
|
||||
toast.add({ title: 'Select a warehouse first', type: 'error' })
|
||||
return
|
||||
}
|
||||
setStep(2)
|
||||
return
|
||||
}
|
||||
if (step === 2) {
|
||||
if (selectedRows.length === 0) {
|
||||
toast.add({ title: 'Select at least one item', type: 'error' })
|
||||
return
|
||||
}
|
||||
setStep(3)
|
||||
}
|
||||
}
|
||||
|
||||
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]
|
||||
return {
|
||||
fK_ItemId: line.itemId,
|
||||
@@ -247,203 +324,314 @@ export function LoadingSheetItemsDialog({ open, onClose, onSuccess, sheet }: Loa
|
||||
|
||||
if (!sheet) return null
|
||||
|
||||
const selectedWarehouseName = warehousesList.find((w) => w.id === warehouseId)?.name ?? ''
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||
<DialogContent className="overflow-y-auto sm:max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Set Loading Sheet Items</DialogTitle>
|
||||
<DialogDescription>
|
||||
Select a route first. Confirmed Shop POs for that route will then be available to pick
|
||||
items from.
|
||||
{sheet.status === 'Confirmed' && (
|
||||
<> This sheet is already confirmed, so the items you pick are saved on a new planned sheet for the same ride.</>
|
||||
)}
|
||||
{sheet.status === 'Confirmed'
|
||||
? 'This sheet is already confirmed, so the items you pick are saved on a new planned sheet for the same ride.'
|
||||
: 'Work through the steps below to build the loading sheet.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs text-muted-foreground">Region</label>
|
||||
<Select value={regionId || ALL} onValueChange={(v) => setRegionId(!v || v === ALL ? '' : v)}>
|
||||
<SelectTrigger className="w-48">
|
||||
<SelectValue placeholder="All regions" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL}>All regions</SelectItem>
|
||||
{regionsList.map((r) => (
|
||||
<SelectItem key={r.id} value={r.id}>{r.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs text-muted-foreground">Route *</label>
|
||||
<Select value={routeId} onValueChange={(v) => setRouteId(v ?? '')}>
|
||||
<SelectTrigger className="w-48">
|
||||
<SelectValue placeholder="Select route..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{routesList.map((r) => (
|
||||
<SelectItem key={r.id} value={r.id}>{r.routeName}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</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">
|
||||
<label className="text-xs text-muted-foreground">Warehouse *</label>
|
||||
<Select value={warehouseId} onValueChange={(v) => setWarehouseId(v ?? '')}>
|
||||
<SelectTrigger className="w-52">
|
||||
<SelectValue placeholder="Select warehouse..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{warehousesList.map((w) => (
|
||||
<SelectItem key={w.id} value={w.id}>{w.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border">
|
||||
{!routeId ? (
|
||||
<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>
|
||||
) : 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>
|
||||
) : (
|
||||
shopGroups.map((group) => {
|
||||
const groupLines = group.rows.map((r) => lines[r.shopPOItemId] ?? r)
|
||||
const allChecked = groupLines.every((l) => l.checked)
|
||||
const someChecked = groupLines.some((l) => l.checked)
|
||||
return (
|
||||
<div key={group.storeId} className="flex items-center gap-3 border-t px-3 py-2 first:border-t-0">
|
||||
<Checkbox
|
||||
checked={allChecked}
|
||||
indeterminate={someChecked && !allChecked}
|
||||
onCheckedChange={(v) => group.rows.forEach((r) => toggleLine(lines[r.shopPOItemId] ?? r, v === true))}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate font-medium">{group.storeName}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{group.poIds.size} PO{group.poIds.size === 1 ? '' : 's'}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={`View confirmed PO items for ${group.storeName}`}
|
||||
onClick={() => setViewStoreId(group.storeId)}
|
||||
>
|
||||
<HugeiconsIcon icon={ViewIcon} size={18} />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</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">
|
||||
<tbody>
|
||||
{summary.map((e) => (
|
||||
<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="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-32 px-3 py-2 text-right font-medium">Qty to load</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{viewGroup?.rows.map((row) => {
|
||||
const line = lines[row.shopPOItemId] ?? row
|
||||
return (
|
||||
<tr key={row.shopPOItemId} className="border-t">
|
||||
<td className="px-3 py-2">
|
||||
<Checkbox
|
||||
checked={line.checked}
|
||||
onCheckedChange={(v) => toggleLine(row, v === true)}
|
||||
/>
|
||||
</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">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={row.remainingQty}
|
||||
value={line.quantity}
|
||||
disabled={!line.checked}
|
||||
onChange={(e) => setLineQuantity(row, Number(e.target.value))}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
{/* 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>
|
||||
<DialogFooter>
|
||||
<Button type="button" onClick={() => setViewStoreId('')}>Done</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
))}
|
||||
</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">
|
||||
<label className="text-xs text-muted-foreground">Region</label>
|
||||
<Select value={regionId || ALL} onValueChange={(v) => setRegionId(!v || v === ALL ? '' : v)}>
|
||||
<SelectTrigger className="w-48">
|
||||
<SelectValue placeholder="All regions" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL}>All regions</SelectItem>
|
||||
{regionsList.map((r) => (
|
||||
<SelectItem key={r.id} value={r.id}>{r.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs text-muted-foreground">Route *</label>
|
||||
<Select value={routeId} onValueChange={(v) => setRouteId(v ?? '')}>
|
||||
<SelectTrigger className="w-48">
|
||||
<SelectValue placeholder="Select route..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{routesList.map((r) => (
|
||||
<SelectItem key={r.id} value={r.id}>{r.routeName}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs text-muted-foreground">Warehouse *</label>
|
||||
<Select value={warehouseId} onValueChange={(v) => setWarehouseId(v ?? '')}>
|
||||
<SelectTrigger className="w-52">
|
||||
<SelectValue placeholder="Select warehouse..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{warehousesList.map((w) => (
|
||||
<SelectItem key={w.id} value={w.id}>{w.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</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">
|
||||
{loading ? (
|
||||
<p className="px-3 py-6 text-center text-sm text-muted-foreground">Loading…</p>
|
||||
) : 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>
|
||||
) : (
|
||||
shopGroups.map((group) => {
|
||||
const groupLines = group.rows.map((r) => lines[r.shopPOItemId] ?? r)
|
||||
const allChecked = groupLines.every((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 (
|
||||
<div key={group.storeId} className="border-t first:border-t-0">
|
||||
<div className="flex items-center gap-3 px-3 py-2">
|
||||
<Checkbox
|
||||
checked={allChecked}
|
||||
indeterminate={someChecked && !allChecked}
|
||||
title="Select all items for this shop"
|
||||
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>
|
||||
{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">
|
||||
{group.poIds.size} PO{group.poIds.size === 1 ? '' : 's'}
|
||||
</span>
|
||||
<span className="hidden text-xs text-muted-foreground sm:inline">
|
||||
{expanded ? 'Hide items' : 'View items'}
|
||||
</span>
|
||||
<HugeiconsIcon icon={expanded ? ArrowUp01Icon : ArrowDown01Icon} size={16} />
|
||||
</button>
|
||||
</div>
|
||||
{expanded && (
|
||||
<div className="overflow-x-auto border-t bg-muted/20">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-xs text-muted-foreground">
|
||||
<th className="w-10 px-3 py-2"></th>
|
||||
<th className="px-3 py-2 text-left font-medium">Item</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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{group.rows.map((row) => {
|
||||
const line = lines[row.shopPOItemId] ?? row
|
||||
const available = stockByItem[row.itemId] ?? 0
|
||||
const short = line.checked && line.quantity > available
|
||||
return (
|
||||
<tr key={row.shopPOItemId} className="border-t">
|
||||
<td className="px-3 py-2">
|
||||
<Checkbox
|
||||
checked={line.checked}
|
||||
onCheckedChange={(v) => toggleLine(row, v === true)}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2">{row.itemName}</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">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={row.remainingQty}
|
||||
value={line.quantity}
|
||||
disabled={!line.checked}
|
||||
className={cn('w-24', short && 'border-amber-500 focus-visible:ring-amber-500')}
|
||||
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>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</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's
|
||||
in stock at {selectedWarehouseName}. Saving will still work, but confirming this sheet will fail until the
|
||||
quantities are reduced to what'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>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
|
||||
@@ -25,6 +25,7 @@ import { Button } from '@/components/ui/button'
|
||||
import { FieldGroup } from '@/components/ui/field'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { toast } from '@/components/ui/toast'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
shopPos as shopPosApi,
|
||||
regions as regionsApi,
|
||||
@@ -53,6 +54,39 @@ import type { LoadingSheetItemInput } from '@/lib/api/operations'
|
||||
|
||||
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 {
|
||||
warehouseId: string
|
||||
quantity: number
|
||||
@@ -115,6 +149,8 @@ function LoadingSheetForm() {
|
||||
const [rideId, setRideId] = useState('')
|
||||
const [populatedByUserId, setPopulatedByUserId] = useState('')
|
||||
|
||||
const [step, setStep] = useState<1 | 2 | 3 | 4 | 5>(1)
|
||||
|
||||
const [viewStoreId, setViewStoreId] = useState('')
|
||||
const [itemsList, setItemsList] = useState<ItemSummaryResponse[]>([])
|
||||
const [manualItems, setManualItems] = useState<ManualItem[]>([])
|
||||
@@ -441,6 +477,34 @@ function LoadingSheetForm() {
|
||||
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() {
|
||||
if (!routeId && manualItems.length === 0) {
|
||||
toast.add({ title: 'Select a route first', type: 'error' })
|
||||
@@ -541,13 +605,43 @@ function LoadingSheetForm() {
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-foreground">{isEdit ? 'Edit Loading Sheet' : 'New Loading Sheet'}</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Select a route first. Confirmed Shop POs for that route's shops will then be
|
||||
available to pick items from. Pick the ride to load last.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">Work through the steps below to build the loading sheet.</p>
|
||||
</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">
|
||||
<h3 className="mb-4 text-sm font-medium text-foreground">Route & Warehouse</h3>
|
||||
<FieldGroup>
|
||||
@@ -616,7 +710,9 @@ function LoadingSheetForm() {
|
||||
</div>
|
||||
</FieldGroup>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 3 && (
|
||||
<div className="rounded-2xl border p-6">
|
||||
<div className="mb-4">
|
||||
<h3 className="text-sm font-medium text-foreground">Items</h3>
|
||||
@@ -824,7 +920,9 @@ function LoadingSheetForm() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 4 && (
|
||||
<div className="rounded-2xl border p-6">
|
||||
<div className="mb-4 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
@@ -897,7 +995,9 @@ function LoadingSheetForm() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<div className="rounded-2xl border p-6">
|
||||
<h3 className="mb-4 text-sm font-medium text-foreground">Ride & Assignment</h3>
|
||||
<FieldGroup>
|
||||
@@ -935,7 +1035,9 @@ function LoadingSheetForm() {
|
||||
</div>
|
||||
</FieldGroup>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 5 && (
|
||||
<div>
|
||||
<div className="w-full rounded-2xl border p-6">
|
||||
<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-xl font-semibold text-foreground">{totalQuantity}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6 flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={() => router.push('/operations/loading')}>
|
||||
Cancel
|
||||
<div className="flex items-center justify-between rounded-2xl border bg-muted/20 p-4">
|
||||
<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
|
||||
</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}>
|
||||
{submitting ? 'Saving…' : isEdit ? 'Save Changes' : 'Create Loading Sheet'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -151,6 +151,20 @@ export function SalesClient() {
|
||||
fields: [{ key: 'storeName', primary: true }],
|
||||
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',
|
||||
title: 'Total',
|
||||
@@ -172,7 +186,7 @@ export function SalesClient() {
|
||||
{
|
||||
key: 'createdBy',
|
||||
title: 'Created By',
|
||||
fields: ['raisedByName'],
|
||||
fields: ['raisedByName', 'createdAt'],
|
||||
minWidth: 160,
|
||||
cellRender: (row) => (
|
||||
<div className="flex flex-col">
|
||||
@@ -180,6 +194,7 @@ export function SalesClient() {
|
||||
{row.raisedByRole && (
|
||||
<span className="text-xs text-muted-foreground">{row.raisedByRole}</span>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground">{fmtDate(row.createdAt)}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -221,12 +236,6 @@ export function SalesClient() {
|
||||
<span className="text-sm text-muted-foreground">—</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'createdAt',
|
||||
title: 'Created',
|
||||
fields: [{ key: 'createdAt', transform: (v) => fmtDate(v as string) }],
|
||||
width: 140,
|
||||
},
|
||||
],
|
||||
rowActions: {
|
||||
actions: () => [
|
||||
|
||||
@@ -639,6 +639,8 @@ export interface ShopPOResponse {
|
||||
id: string
|
||||
storeId: string
|
||||
storeName: string
|
||||
storeAddress: string | null
|
||||
storeContactNumber: string | null
|
||||
raisedById: string
|
||||
invoiceId: string | null
|
||||
totalAmount: number
|
||||
|
||||
Reference in New Issue
Block a user