fix the loading sheet view

This commit is contained in:
2026-09-24 17:23:31 +05:30
parent be38777ebb
commit e8692b2474
8 changed files with 470 additions and 164 deletions
@@ -19,6 +19,8 @@ import {
import { Checkbox } from '@/components/ui/checkbox' 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 { ViewIcon } from '@hugeicons/core-free-icons'
import { toast } from '@/components/ui/toast' import { toast } from '@/components/ui/toast'
import { import {
shopPos as shopPosApi, shopPos as shopPosApi,
@@ -45,7 +47,9 @@ interface PickedLine {
shopPOItemId: string shopPOItemId: string
itemId: string itemId: string
itemName: string itemName: string
storeId: string
storeName: string storeName: string
shopPOId: string
orderedQty: number orderedQty: number
remainingQty: number remainingQty: number
checked: boolean checked: boolean
@@ -60,6 +64,8 @@ export function LoadingSheetItemsDialog({ open, onClose, onSuccess, sheet }: Loa
const [regionId, setRegionId] = useState('') const [regionId, setRegionId] = useState('')
const [routeId, setRouteId] = useState('') const [routeId, setRouteId] = useState('')
const [warehouseId, setWarehouseId] = useState('') const [warehouseId, setWarehouseId] = useState('')
const [shopId, setShopId] = useState('')
const [viewStoreId, setViewStoreId] = useState('')
const [pos, setPos] = useState<ShopPOResponse[]>([]) const [pos, setPos] = useState<ShopPOResponse[]>([])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
@@ -71,6 +77,8 @@ export function LoadingSheetItemsDialog({ open, onClose, onSuccess, sheet }: Loa
setRegionId('') setRegionId('')
setRouteId('') setRouteId('')
setWarehouseId('') setWarehouseId('')
setShopId('')
setViewStoreId('')
setPos([]) setPos([])
setLines({}) setLines({})
@@ -88,6 +96,7 @@ export function LoadingSheetItemsDialog({ open, onClose, onSuccess, sheet }: Loa
useEffect(() => { useEffect(() => {
if (!open) return if (!open) return
setRouteId('') setRouteId('')
setShopId('')
setPos([]) setPos([])
setLines({}) setLines({})
routesApi.list({ regionId: regionId || undefined, pageSize: 200 }) routesApi.list({ regionId: regionId || undefined, pageSize: 200 })
@@ -98,6 +107,8 @@ export function LoadingSheetItemsDialog({ open, onClose, onSuccess, sheet }: Loa
useEffect(() => { useEffect(() => {
if (!open) return if (!open) return
setLines({}) setLines({})
setShopId('')
setViewStoreId('')
if (!routeId) { if (!routeId) {
setPos([]) setPos([])
return return
@@ -123,7 +134,9 @@ export function LoadingSheetItemsDialog({ open, onClose, onSuccess, sheet }: Loa
shopPOItemId: item.id, shopPOItemId: item.id,
itemId: item.itemId, itemId: item.itemId,
itemName: item.itemName, itemName: item.itemName,
storeId: po.storeId,
storeName: po.storeName, storeName: po.storeName,
shopPOId: po.id,
orderedQty: item.quantity, orderedQty: item.quantity,
remainingQty: remaining, remainingQty: remaining,
checked: false, checked: false,
@@ -135,6 +148,39 @@ export function LoadingSheetItemsDialog({ open, onClose, onSuccess, sheet }: Loa
return result return result
}, [pos, lines]) }, [pos, lines])
const allShopGroups = useMemo(() => {
const map = new Map<string, { storeId: string; storeName: string; poIds: Set<string>; rows: PickedLine[] }>()
for (const row of rows) {
const g = map.get(row.storeId) ?? { storeId: row.storeId, storeName: row.storeName, poIds: new Set<string>(), rows: [] }
g.rows.push(row)
g.poIds.add(row.shopPOId)
map.set(row.storeId, g)
}
return Array.from(map.values()).sort((a, b) => a.storeName.localeCompare(b.storeName))
}, [rows])
const shopGroups = useMemo(
() => (shopId ? allShopGroups.filter((g) => g.storeId === shopId) : allShopGroups),
[allShopGroups, shopId]
)
const viewGroup = allShopGroups.find((g) => g.storeId === viewStoreId)
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>() }
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])
const summaryTotal = summary.reduce((s, e) => s + e.qty, 0)
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 } }))
} }
@@ -153,7 +199,7 @@ 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)) const selected = rows.filter((r) => (lines[r.shopPOItemId]?.checked ?? false) && lines[r.shopPOItemId].quantity > 0)
if (selected.length === 0) { if (selected.length === 0) {
toast.add({ title: 'Select at least one item', type: 'error' }) toast.add({ title: 'Select at least one item', type: 'error' })
return return
@@ -171,8 +217,25 @@ export function LoadingSheetItemsDialog({ open, onClose, onSuccess, sheet }: Loa
setSubmitting(true) setSubmitting(true)
try { try {
await loadingSheetsApi.updateItems(sheet.id, items) let target = sheet
toast.add({ title: 'Loading sheet items saved', type: 'success' }) if (sheet.status === 'Confirmed') {
// A confirmed sheet is locked (stock already moved), so extra items go on a
// follow-up Planned sheet for the same ride.
const existing = getPaginatedItems(
await loadingSheetsApi.list({ rideId: sheet.fK_RideId, status: 'Planned', pageSize: 1 })
)[0]
target = existing ?? await loadingSheetsApi.create({
fK_RideId: sheet.fK_RideId,
populatedByUserId: sheet.populatedBy,
})
}
await loadingSheetsApi.updateItems(target.id, items)
toast.add({
title: sheet.status === 'Confirmed'
? 'Items saved on a new planned loading sheet — confirm it to load them'
: 'Loading sheet items saved',
type: 'success',
})
onSuccess() onSuccess()
onClose() onClose()
} catch (err) { } catch (err) {
@@ -192,6 +255,9 @@ export function LoadingSheetItemsDialog({ open, onClose, onSuccess, sheet }: Loa
<DialogDescription> <DialogDescription>
Select a route first. Confirmed Shop POs for that route will then be available to pick Select a route first. Confirmed Shop POs for that route will then be available to pick
items from. 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.</>
)}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@@ -225,6 +291,21 @@ 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 ?? '')}>
@@ -240,54 +321,64 @@ export function LoadingSheetItemsDialog({ open, onClose, onSuccess, sheet }: Loa
</div> </div>
</div> </div>
<div className="overflow-x-auto rounded-xl border"> <div className="rounded-xl border">
<table className="w-full text-sm"> {!routeId ? (
<thead className="bg-muted/50"> <p className="px-3 py-6 text-center text-sm text-muted-foreground">Select a route to see shops with confirmed POs.</p>
<tr> ) : loading ? (
<th className="w-10 px-3 py-2"></th> <p className="px-3 py-6 text-center text-sm text-muted-foreground">Loading…</p>
<th className="px-3 py-2 text-left font-medium">Store</th> ) : shopGroups.length === 0 ? (
<th className="px-3 py-2 text-left font-medium">Item</th> <p className="px-3 py-6 text-center text-sm text-muted-foreground">No shops with confirmed POs found for this selection.</p>
<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> shopGroups.map((group) => {
</tr> const groupLines = group.rows.map((r) => lines[r.shopPOItemId] ?? r)
</thead> const allChecked = groupLines.every((l) => l.checked)
<tbody> const someChecked = groupLines.some((l) => l.checked)
{!routeId ? ( return (
<tr><td colSpan={5} className="text-center text-muted-foreground px-3 py-6">Select a route to see its confirmed Shop POs.</td></tr> <div key={group.storeId} className="flex items-center gap-3 border-t px-3 py-2 first:border-t-0">
) : loading ? ( <Checkbox
<tr><td colSpan={5} className="text-center text-muted-foreground px-3 py-6">Loading…</td></tr> checked={allChecked}
) : rows.length === 0 ? ( indeterminate={someChecked && !allChecked}
<tr><td colSpan={5} className="text-center text-muted-foreground px-3 py-6">No eligible Shop PO items found for this route.</td></tr> onCheckedChange={(v) => group.rows.forEach((r) => toggleLine(lines[r.shopPOItemId] ?? r, v === true))}
) : ( />
rows.map((row) => { <span className="min-w-0 flex-1 truncate font-medium">{group.storeName}</span>
const line = lines[row.shopPOItemId] ?? row <span className="text-xs text-muted-foreground">
return ( {group.poIds.size} PO{group.poIds.size === 1 ? '' : 's'}
<tr key={row.shopPOItemId} className="border-t"> </span>
<td className="px-3 py-2"> <Button
<Checkbox type="button"
checked={line.checked} variant="ghost"
onCheckedChange={(v) => toggleLine(row, v === true)} size="icon"
/> aria-label={`View confirmed PO items for ${group.storeName}`}
</td> onClick={() => setViewStoreId(group.storeId)}
<td className="px-3 py-2">{row.storeName}</td> >
<td className="px-3 py-2">{row.itemName}</td> <HugeiconsIcon icon={ViewIcon} size={18} />
<td className="px-3 py-2 text-right text-muted-foreground">{row.remainingQty}</td> </Button>
<td className="px-3 py-2"> </div>
<Input )
type="number" })
min={1} )}
max={row.remainingQty} </div>
value={line.quantity}
disabled={!line.checked} <div className="rounded-xl border">
onChange={(e) => setLineQuantity(row, Number(e.target.value))} <div className="flex items-center justify-between bg-muted/50 px-3 py-2 text-sm font-medium">
/> <span>Loading Sheet Summary</span>
</td> <span className="text-muted-foreground">{summary.length} item{summary.length === 1 ? '' : 's'} · {summaryTotal} units</span>
</tr> </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>
)} ) : (
</tbody> <table className="w-full text-sm">
</table> <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> </div>
<DialogFooter> <DialogFooter>
@@ -298,6 +389,61 @@ export function LoadingSheetItemsDialog({ open, onClose, onSuccess, sheet }: Loa
{submitting ? 'Saving…' : 'Save Items'} {submitting ? 'Saving…' : 'Save Items'}
</Button> </Button>
</DialogFooter> </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>
</div>
<DialogFooter>
<Button type="button" onClick={() => setViewStoreId('')}>Done</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
) )
@@ -1,5 +1,6 @@
'use client' 'use client'
import { useMemo } from 'react'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { import {
Dialog, Dialog,
@@ -8,7 +9,11 @@ import {
DialogTitle, DialogTitle,
} from '@/components/ui/dialog' } from '@/components/ui/dialog'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import type { LoadingSheetResponse, LoadingSheetStatus } from '@/types/api' import type {
LoadingSheetItemResponse,
LoadingSheetResponse,
LoadingSheetStatus,
} from '@/types/api'
const LS_STATUS_VARIANT: Record<LoadingSheetStatus, 'default' | 'secondary' | 'destructive'> = { const LS_STATUS_VARIANT: Record<LoadingSheetStatus, 'default' | 'secondary' | 'destructive'> = {
Planned: 'secondary', Planned: 'secondary',
@@ -24,6 +29,35 @@ const fmtDateTime = (v: string | null) =>
const truncId = (id: string) => id.substring(0, 8) const truncId = (id: string) => id.substring(0, 8)
const UNASSIGNED = '__unassigned__'
interface SummaryRow {
itemId: string
itemName: string
total: number
shops: number
warehouses: { name: string; qty: number }[]
}
function buildSummary(items: LoadingSheetItemResponse[]): SummaryRow[] {
const map = new Map<string, SummaryRow & { shopSet: Set<string> }>()
for (const item of items) {
let row = map.get(item.fK_ItemId)
if (!row) {
row = { itemId: item.fK_ItemId, itemName: item.itemName, total: 0, shops: 0, warehouses: [], shopSet: new Set() }
map.set(item.fK_ItemId, row)
}
row.total += item.quantityDispatched
row.shopSet.add(item.storeId ?? UNASSIGNED)
const wh = row.warehouses.find((w) => w.name === item.warehouseName)
if (wh) wh.qty += item.quantityDispatched
else row.warehouses.push({ name: item.warehouseName, qty: item.quantityDispatched })
}
return Array.from(map.values())
.map(({ shopSet, ...r }) => ({ ...r, shops: shopSet.size }))
.sort((a, b) => a.itemName.localeCompare(b.itemName))
}
interface LoadingSheetViewSheetProps { interface LoadingSheetViewSheetProps {
open: boolean open: boolean
onClose: () => void onClose: () => void
@@ -40,11 +74,15 @@ function Row({ label, value }: { label: string; value: React.ReactNode }) {
} }
export function LoadingSheetViewSheet({ open, onClose, sheet }: LoadingSheetViewSheetProps) { export function LoadingSheetViewSheet({ open, onClose, sheet }: LoadingSheetViewSheetProps) {
const summary = useMemo(() => (sheet ? buildSummary(sheet.items) : []), [sheet])
if (!sheet) return null if (!sheet) return null
const totalQty = sheet.items.reduce((s, i) => s + i.quantityDispatched, 0)
return ( return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}> <Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="overflow-y-auto sm:max-w-2xl"> <DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-3xl">
<DialogHeader> <DialogHeader>
<DialogTitle>Loading Sheet Details</DialogTitle> <DialogTitle>Loading Sheet Details</DialogTitle>
</DialogHeader> </DialogHeader>
@@ -55,40 +93,51 @@ export function LoadingSheetViewSheet({ open, onClose, sheet }: LoadingSheetView
<Row label="Status" value={<Badge variant={LS_STATUS_VARIANT[sheet.status]}>{sheet.status}</Badge>} /> <Row label="Status" value={<Badge variant={LS_STATUS_VARIANT[sheet.status]}>{sheet.status}</Badge>} />
<Row label="Populated At" value={fmtDateTime(sheet.populatedAt)} /> <Row label="Populated At" value={fmtDateTime(sheet.populatedAt)} />
<Row label="Confirmed At" value={fmtDateTime(sheet.confirmedAt)} /> <Row label="Confirmed At" value={fmtDateTime(sheet.confirmedAt)} />
<Row label="Source POs" value={sheet.orders.length > 0 ? sheet.orders.map(o => truncId(o.shopPOId)).join(', ') : '—'} />
<Row label="Created At" value={fmtDateTime(sheet.createdAt)} /> <Row label="Created At" value={fmtDateTime(sheet.createdAt)} />
</div> </div>
<Separator /> <Separator />
<div> <div>
<p className="text-sm font-medium mb-2">Items ({sheet.items.length})</p> <p className="text-sm font-medium mb-2">Loading Summary (item-wise)</p>
<div className="overflow-x-auto rounded-md border"> <div className="overflow-x-auto rounded-md border">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead className="bg-muted/50"> <thead className="bg-muted/50">
<tr> <tr>
<th className="text-left font-medium px-3 py-2">Item</th> <th className="text-left font-medium px-3 py-2">Item</th>
<th className="text-left font-medium px-3 py-2">Warehouse</th> <th className="text-left font-medium px-3 py-2">Warehouse</th>
<th className="text-right font-medium px-3 py-2">Qty</th> <th className="text-right font-medium px-3 py-2">Shops</th>
<th className="text-right font-medium px-3 py-2">Total Qty</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{sheet.items.length === 0 ? ( {summary.length === 0 ? (
<tr> <tr>
<td colSpan={3} className="text-center text-muted-foreground px-3 py-4"> <td colSpan={4} className="text-center text-muted-foreground px-3 py-4">
No items on this sheet. No items on this sheet.
</td> </td>
</tr> </tr>
) : ( ) : (
sheet.items.map((item) => ( summary.map((row) => (
<tr key={item.id} className="border-t"> <tr key={row.itemId} className="border-t">
<td className="px-3 py-2">{item.itemName}</td> <td className="px-3 py-2 font-medium">{row.itemName}</td>
<td className="px-3 py-2 text-muted-foreground">{item.warehouseName}</td> <td className="px-3 py-2 text-muted-foreground">
<td className="text-right px-3 py-2">{item.quantityDispatched}</td> {row.warehouses.map((w) => `${w.name} (${w.qty})`).join(', ')}
</td>
<td className="px-3 py-2 text-right text-muted-foreground">{row.shops}</td>
<td className="px-3 py-2 text-right font-semibold">{row.total}</td>
</tr> </tr>
)) ))
)} )}
</tbody> </tbody>
{summary.length > 0 && (
<tfoot className="border-t bg-muted/50">
<tr>
<td colSpan={3} className="px-3 py-2 text-right font-medium">Grand total</td>
<td className="px-3 py-2 text-right font-semibold">{totalQty}</td>
</tr>
</tfoot>
)}
</table> </table>
</div> </div>
</div> </div>
@@ -193,7 +193,7 @@ export function LoadingSheetsClient() {
}, },
], ],
rowActions: { rowActions: {
actions: () => [ actions: (target) => [
{ {
action: 'view', action: 'view',
label: 'View sheet', label: 'View sheet',
@@ -201,9 +201,8 @@ export function LoadingSheetsClient() {
}, },
{ {
action: 'edit', action: 'edit',
label: 'Set items', label: target.status === 'Confirmed' ? 'Add items (new sheet)' : 'Set items',
onClick: handleSetItems, onClick: handleSetItems,
visible: (target) => target.status === 'Planned',
}, },
{ {
action: 'confirm', action: 'confirm',
@@ -3,7 +3,15 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { HugeiconsIcon } from '@hugeicons/react' import { HugeiconsIcon } from '@hugeicons/react'
import { ArrowLeft01Icon } from '@hugeicons/core-free-icons' import { ArrowLeft01Icon, ViewIcon } from '@hugeicons/core-free-icons'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog'
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -46,7 +54,9 @@ interface PickedLine {
shopPOItemId: string shopPOItemId: string
itemId: string itemId: string
itemName: string itemName: string
storeId: string
storeName: string storeName: string
shopPOId: string
orderedQty: number orderedQty: number
remainingQty: number remainingQty: number
checked: boolean checked: boolean
@@ -70,6 +80,8 @@ export default function NewLoadingSheetPage() {
const [rideId, setRideId] = useState('') const [rideId, setRideId] = useState('')
const [populatedByUserId, setPopulatedByUserId] = useState('') const [populatedByUserId, setPopulatedByUserId] = useState('')
const [viewStoreId, setViewStoreId] = useState('')
const [pos, setPos] = useState<ShopPOResponse[]>([]) const [pos, setPos] = useState<ShopPOResponse[]>([])
const [loadingPos, setLoadingPos] = useState(false) const [loadingPos, setLoadingPos] = useState(false)
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
@@ -103,6 +115,7 @@ export default function NewLoadingSheetPage() {
useEffect(() => { useEffect(() => {
setStoreId('') setStoreId('')
setViewStoreId('')
setLines({}) setLines({})
if (!routeId) { if (!routeId) {
setStoresList([]) setStoresList([])
@@ -135,7 +148,9 @@ export default function NewLoadingSheetPage() {
shopPOItemId: item.id, shopPOItemId: item.id,
itemId: item.itemId, itemId: item.itemId,
itemName: item.itemName, itemName: item.itemName,
storeId: po.storeId,
storeName: po.storeName, storeName: po.storeName,
shopPOId: po.id,
orderedQty: item.quantity, orderedQty: item.quantity,
remainingQty: remaining, remainingQty: remaining,
checked: false, checked: false,
@@ -156,6 +171,31 @@ export default function NewLoadingSheetPage() {
[selectedRows, lines] [selectedRows, lines]
) )
const shopGroups = useMemo(() => {
const map = new Map<string, { storeId: string; storeName: string; poIds: Set<string>; rows: PickedLine[] }>()
for (const row of rows) {
const g = map.get(row.storeId) ?? { storeId: row.storeId, storeName: row.storeName, poIds: new Set<string>(), rows: [] }
g.rows.push(row)
g.poIds.add(row.shopPOId)
map.set(row.storeId, g)
}
return Array.from(map.values()).sort((a, b) => a.storeName.localeCompare(b.storeName))
}, [rows])
const viewGroup = shopGroups.find((g) => g.storeId === viewStoreId)
const itemSummary = useMemo(() => {
const map = new Map<string, { itemName: string; qty: number; shops: Set<string> }>()
for (const row of selectedRows) {
const qty = Number(lines[row.shopPOItemId]?.quantity) || 0
const e = map.get(row.itemId) ?? { itemName: row.itemName, qty: 0, shops: new Set<string>() }
e.qty += qty
e.shops.add(row.storeId)
map.set(row.itemId, e)
}
return Array.from(map.values()).sort((a, b) => a.itemName.localeCompare(b.itemName))
}, [selectedRows, lines])
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 } }))
} }
@@ -198,9 +238,15 @@ export default function NewLoadingSheetPage() {
setSubmitting(true) setSubmitting(true)
try { try {
const sheet = await loadingSheetsApi.create({ fK_RideId: rideId, populatedByUserId }) const existing = getPaginatedItems(
await loadingSheetsApi.list({ rideId, status: 'Planned', pageSize: 1 })
)[0]
const sheet = existing ?? await loadingSheetsApi.create({ fK_RideId: rideId, populatedByUserId })
await loadingSheetsApi.updateItems(sheet.id, items) await loadingSheetsApi.updateItems(sheet.id, items)
toast.add({ title: 'Loading sheet created', type: 'success' }) toast.add({
title: existing ? 'Items added to the ride\'s existing planned loading sheet' : 'Loading sheet created',
type: 'success',
})
router.push('/operations/loading') router.push('/operations/loading')
} catch (err) { } catch (err) {
toast.add({ title: 'Failed to create loading sheet', description: getApiError(err), type: 'error' }) toast.add({ title: 'Failed to create loading sheet', description: getApiError(err), type: 'error' })
@@ -232,10 +278,10 @@ export default function NewLoadingSheetPage() {
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label className="text-sm font-medium text-foreground">Region</label> <label className="text-sm font-medium text-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)}>
<SelectTrigger> <SelectTrigger className="w-full">
<SelectValue placeholder="All regions" /> <SelectValue placeholder="All regions" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent className="w-auto min-w-(--anchor-width)">
<SelectItem value={ALL}>All regions</SelectItem> <SelectItem value={ALL}>All regions</SelectItem>
{regionsList.map((r) => ( {regionsList.map((r) => (
<SelectItem key={r.id} value={r.id}>{r.name}</SelectItem> <SelectItem key={r.id} value={r.id}>{r.name}</SelectItem>
@@ -247,10 +293,10 @@ export default function NewLoadingSheetPage() {
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label className="text-sm font-medium text-foreground">Route *</label> <label className="text-sm font-medium text-foreground">Route *</label>
<Select value={routeId} onValueChange={(v) => setRouteId(v ?? '')}> <Select value={routeId} onValueChange={(v) => setRouteId(v ?? '')}>
<SelectTrigger> <SelectTrigger className="w-full">
<SelectValue placeholder="Select route..." /> <SelectValue placeholder="Select route..." />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent className="w-auto min-w-(--anchor-width)">
{routesList.map((r) => ( {routesList.map((r) => (
<SelectItem key={r.id} value={r.id}>{r.routeName}</SelectItem> <SelectItem key={r.id} value={r.id}>{r.routeName}</SelectItem>
))} ))}
@@ -265,10 +311,10 @@ export default function NewLoadingSheetPage() {
onValueChange={(v) => setStoreId(!v || v === ALL ? '' : v)} onValueChange={(v) => setStoreId(!v || v === ALL ? '' : v)}
disabled={!routeId} disabled={!routeId}
> >
<SelectTrigger> <SelectTrigger className="w-full">
<SelectValue placeholder="All shops" /> <SelectValue placeholder="All shops" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent className="w-auto min-w-(--anchor-width)">
<SelectItem value={ALL}>All shops on route</SelectItem> <SelectItem value={ALL}>All shops on route</SelectItem>
{storesList.map((s) => ( {storesList.map((s) => (
<SelectItem key={s.id} value={s.id}>{s.storeName}</SelectItem> <SelectItem key={s.id} value={s.id}>{s.storeName}</SelectItem>
@@ -280,10 +326,10 @@ export default function NewLoadingSheetPage() {
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label className="text-sm font-medium text-foreground">Warehouse *</label> <label className="text-sm font-medium text-foreground">Warehouse *</label>
<Select value={warehouseId} onValueChange={(v) => setWarehouseId(v ?? '')}> <Select value={warehouseId} onValueChange={(v) => setWarehouseId(v ?? '')}>
<SelectTrigger> <SelectTrigger className="w-full">
<SelectValue placeholder="Select warehouse..." /> <SelectValue placeholder="Select warehouse..." />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent className="w-auto min-w-(--anchor-width)">
{warehousesList.map((w) => ( {warehousesList.map((w) => (
<SelectItem key={w.id} value={w.id}>{w.name}</SelectItem> <SelectItem key={w.id} value={w.id}>{w.name}</SelectItem>
))} ))}
@@ -302,55 +348,98 @@ export default function NewLoadingSheetPage() {
</p> </p>
</div> </div>
<div className="overflow-x-auto rounded-xl border"> <div className="rounded-xl border">
<table className="w-full text-sm"> {!routeId ? (
<thead className="bg-muted/50"> <p className="px-3 py-6 text-center text-sm text-muted-foreground">Select a route to see its shops with confirmed Shop POs.</p>
<tr> ) : loadingPos ? (
<th className="w-10 px-3 py-2"></th> <p className="px-3 py-6 text-center text-sm text-muted-foreground">Loading…</p>
<th className="px-3 py-2 text-left font-medium">Shop</th> ) : shopGroups.length === 0 ? (
<th className="px-3 py-2 text-left font-medium">Item</th> <p className="px-3 py-6 text-center text-sm text-muted-foreground">No shops with confirmed Shop POs found for this route.</p>
<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> shopGroups.map((group) => {
</tr> const groupLines = group.rows.map((r) => lines[r.shopPOItemId] ?? r)
</thead> const allChecked = groupLines.every((l) => l.checked)
<tbody> const someChecked = groupLines.some((l) => l.checked)
{!routeId ? ( return (
<tr><td colSpan={5} className="text-center text-muted-foreground px-3 py-6">Select a route to see its confirmed Shop POs.</td></tr> <div key={group.storeId} className="flex items-center gap-3 border-t px-3 py-2 first:border-t-0">
) : loadingPos ? ( <Checkbox
<tr><td colSpan={5} className="text-center text-muted-foreground px-3 py-6">Loading…</td></tr> checked={allChecked}
) : rows.length === 0 ? ( indeterminate={someChecked && !allChecked}
<tr><td colSpan={5} className="text-center text-muted-foreground px-3 py-6">No eligible Shop PO items found for this route.</td></tr> onCheckedChange={(v) => group.rows.forEach((r) => toggleLine(lines[r.shopPOItemId] ?? r, v === true))}
) : ( />
rows.map((row) => { <span className="min-w-0 flex-1 truncate font-medium">{group.storeName}</span>
const line = lines[row.shopPOItemId] ?? row <span className="text-xs text-muted-foreground">
return ( {group.poIds.size} PO{group.poIds.size === 1 ? '' : 's'}
<tr key={row.shopPOItemId} className="border-t"> </span>
<td className="px-3 py-2"> <Button
<Checkbox type="button"
checked={line.checked} variant="ghost"
onCheckedChange={(v) => toggleLine(row, v === true)} size="icon"
/> aria-label={`View confirmed PO items for ${group.storeName}`}
</td> onClick={() => setViewStoreId(group.storeId)}
<td className="px-3 py-2">{row.storeName}</td> >
<td className="px-3 py-2">{row.itemName}</td> <HugeiconsIcon icon={ViewIcon} size={18} />
<td className="px-3 py-2 text-right text-muted-foreground">{row.remainingQty}</td> </Button>
<td className="px-3 py-2"> </div>
<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>
</div> </div>
<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>
</div>
<DialogFooter>
<Button type="button" onClick={() => setViewStoreId('')}>Done</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div> </div>
<div className="rounded-2xl border p-6"> <div className="rounded-2xl border p-6">
@@ -360,10 +449,10 @@ export default function NewLoadingSheetPage() {
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label className="text-sm font-medium text-foreground">Ride (vehicle to load) *</label> <label className="text-sm font-medium text-foreground">Ride (vehicle to load) *</label>
<Select value={rideId} onValueChange={(v) => setRideId(v ?? '')}> <Select value={rideId} onValueChange={(v) => setRideId(v ?? '')}>
<SelectTrigger> <SelectTrigger className="w-full">
<SelectValue placeholder="Select open ride..." /> <SelectValue placeholder="Select open ride..." />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent className="w-auto min-w-(--anchor-width)">
{ridesList.map((r) => ( {ridesList.map((r) => (
<SelectItem key={r.id} value={r.id}> <SelectItem key={r.id} value={r.id}>
{r.vehicleNumberPlate} — {r.driverFullName} {r.vehicleNumberPlate} — {r.driverFullName}
@@ -376,10 +465,10 @@ export default function NewLoadingSheetPage() {
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label className="text-sm font-medium text-foreground">Populated By *</label> <label className="text-sm font-medium text-foreground">Populated By *</label>
<Select value={populatedByUserId} onValueChange={(v) => setPopulatedByUserId(v ?? '')}> <Select value={populatedByUserId} onValueChange={(v) => setPopulatedByUserId(v ?? '')}>
<SelectTrigger> <SelectTrigger className="w-full">
<SelectValue placeholder="Select user..." /> <SelectValue placeholder="Select user..." />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent className="w-auto min-w-(--anchor-width)">
{usersList.map((u) => ( {usersList.map((u) => (
<SelectItem key={u.id} value={u.id}>{u.fullName}</SelectItem> <SelectItem key={u.id} value={u.id}>{u.fullName}</SelectItem>
))} ))}
@@ -391,8 +480,8 @@ export default function NewLoadingSheetPage() {
</FieldGroup> </FieldGroup>
</div> </div>
<div className="flex justify-end"> <div>
<div className="w-full rounded-2xl border p-6 sm:w-96"> <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>
<p className="mt-1 text-sm text-muted-foreground"> <p className="mt-1 text-sm text-muted-foreground">
{selectedRows.length} item{selectedRows.length === 1 ? '' : 's'} selected. {selectedRows.length} item{selectedRows.length === 1 ? '' : 's'} selected.
@@ -400,18 +489,38 @@ export default function NewLoadingSheetPage() {
<Separator className="my-4" /> <Separator className="my-4" />
<div className="mb-4 flex flex-col gap-1 text-sm">
{itemSummary.length === 0 ? (
<p className="text-muted-foreground">Tick shops or items to build the summary.</p>
) : (
itemSummary.map((e) => (
<div key={e.itemName} className="flex items-center justify-between">
<span>
{e.itemName}
<span className="ml-2 text-xs text-muted-foreground">
{e.shops.size} shop{e.shops.size === 1 ? '' : 's'}
</span>
</span>
<span className="font-semibold">{e.qty}</span>
</div>
))
)}
</div>
<Separator className="mb-4" />
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<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 className="mt-6 flex flex-col gap-2"> <div className="mt-6 flex justify-end gap-2">
<Button type="button" disabled={submitting} onClick={handleSubmit}>
{submitting ? 'Creating…' : 'Create Loading Sheet'}
</Button>
<Button type="button" variant="outline" onClick={() => router.push('/operations/loading')}> <Button type="button" variant="outline" onClick={() => router.push('/operations/loading')}>
Cancel Cancel
</Button> </Button>
<Button type="button" disabled={submitting} onClick={handleSubmit}>
{submitting ? 'Creating…' : 'Create Loading Sheet'}
</Button>
</div> </div>
</div> </div>
</div> </div>
+10 -10
View File
@@ -50,15 +50,6 @@ export const DASHBOARD_ITEM: NavItem = {
} }
export const NAV_GROUPS: NavGroup[] = [ export const NAV_GROUPS: NavGroup[] = [
{
title: 'Operations',
icon: TruckIcon,
items: [
{ title: 'Rides', href: '/operations/rides', icon: TruckIcon, description: 'Delivery/Sales runs assigned to a driver and vehicle', permission: 'rides.view' },
{ title: 'Loading Sheets', href: '/operations/loading', icon: DeliveryBox01Icon, description: 'Track goods loaded onto a vehicle before dispatch', permission: 'loading.view' },
{ title: 'Delivery Tasks', href: '/operations/deliveries', icon: DeliveryTruck01Icon, description: 'Individual store drop-offs within a ride', permission: 'rides.view' },
],
},
{ {
title: 'Shop POs & Invoices', title: 'Shop POs & Invoices',
icon: DollarSquareIcon, icon: DollarSquareIcon,
@@ -68,11 +59,20 @@ export const NAV_GROUPS: NavGroup[] = [
{ title: 'Payments', href: '/sales/payments', icon: BankIcon, description: 'Cash and credit payments collected against invoices', permission: 'orders.view' }, { title: 'Payments', href: '/sales/payments', icon: BankIcon, description: 'Cash and credit payments collected against invoices', permission: 'orders.view' },
], ],
}, },
{
title: 'Operations',
icon: TruckIcon,
items: [
{ title: 'Rides', href: '/operations/rides', icon: TruckIcon, description: 'Delivery/Sales runs assigned to a driver and vehicle', permission: 'rides.view' },
{ title: 'Loading Sheets', href: '/operations/loading', icon: DeliveryBox01Icon, description: 'Track goods loaded onto a vehicle before dispatch', permission: 'loading.view' },
{ title: 'Delivery Tasks', href: '/operations/deliveries', icon: DeliveryTruck01Icon, description: 'Individual store drop-offs within a ride', permission: 'rides.view' },
],
},
{ {
title: 'Stores', title: 'Stores',
icon: Store01Icon, icon: Store01Icon,
items: [ items: [
{ title: 'Stores', href: '/stores', icon: Store01Icon, description: 'Retail store accounts, credit limits, and KYC status', permission: 'stores.view' }, { title: 'Stores', href: '/stores', icon: Store01Icon, description: 'Retail store accounts, credit limits and balances', permission: 'stores.view' },
{ title: 'Sales Visits', href: '/sales-visits', icon: Location01Icon, description: 'Check in and out of scheduled store visits', permission: 'sales_visits.checkin' }, { title: 'Sales Visits', href: '/sales-visits', icon: Location01Icon, description: 'Check in and out of scheduled store visits', permission: 'sales_visits.checkin' },
], ],
}, },
+3 -3
View File
@@ -128,9 +128,9 @@ export const regions = {
export interface RouteBody { export interface RouteBody {
regionId: string regionId: string
routeName: string routeName: string
startPoint: string startPoint?: string | null
endPoint: string endPoint?: string | null
length: number length?: number | null
isActive?: boolean isActive?: boolean
} }
+2 -2
View File
@@ -58,8 +58,8 @@ export const shopPos = {
confirmAndCreateInvoice: (id: string, confirmedByUserId?: string | null) => confirmAndCreateInvoice: (id: string, confirmedByUserId?: string | null) =>
api.post<ShopPOResponse>(`/shop-pos/${id}/confirm-and-create-invoice`, { confirmedByUserId }).then(r => r.data), api.post<ShopPOResponse>(`/shop-pos/${id}/confirm-and-create-invoice`, { confirmedByUserId }).then(r => r.data),
cancel: (id: string) => cancel: (id: string, reason: string, cancelledByUserId?: string | null) =>
api.post<ShopPOResponse>(`/shop-pos/${id}/cancel`).then(r => r.data), api.post<ShopPOResponse>(`/shop-pos/${id}/cancel`, { reason, cancelledByUserId }).then(r => r.data),
} }
// ─── Invoices ───────────────────────────────────────────────────────────────── // ─── Invoices ─────────────────────────────────────────────────────────────────
+17 -14
View File
@@ -1,6 +1,5 @@
// ─── Enums ──────────────────────────────────────────────────────────────────── // ─── Enums ────────────────────────────────────────────────────────────────────
export type KycStatus = 'Pending' | 'Verified' | 'Rejected'
export type LocationType = 'Warehouse' | 'Vehicle' | 'External' export type LocationType = 'Warehouse' | 'Vehicle' | 'External'
export type MovementType = export type MovementType =
| 'Sale' | 'CustomerReturn' | 'Transfer' | 'Damage' | 'Sale' | 'CustomerReturn' | 'Transfer' | 'Damage'
@@ -81,9 +80,9 @@ export interface RouteResponse {
id: string id: string
regionId: string regionId: string
routeName: string routeName: string
startPoint: string startPoint: string | null
endPoint: string endPoint: string | null
length: number length: number | null
ownerUserId: string | null ownerUserId: string | null
isActive: boolean isActive: boolean
createdAt: string createdAt: string
@@ -200,7 +199,8 @@ export interface WarehouseResponse {
export interface StoreResponse { export interface StoreResponse {
id: string id: string
routeId: string routeId: string
ownerName: string ownerName: string | null
contactNumber: string | null
storeName: string storeName: string
storeAddress: string storeAddress: string
latitude: number latitude: number
@@ -210,19 +210,10 @@ export interface StoreResponse {
creditLimit: number creditLimit: number
outstandingBalance: number outstandingBalance: number
segment: string | null segment: string | null
kycStatus: KycStatus
isActive: boolean isActive: boolean
createdAt: string createdAt: string
} }
export interface KycDocumentResponse {
id: string
storeId: string
documentType: string
fileReference: string
uploadedAt: string
}
export interface OutstandingBalanceResponse { export interface OutstandingBalanceResponse {
storeId: string storeId: string
creditLimit: number creditLimit: number
@@ -576,6 +567,9 @@ export interface LoadingSheetItemResponse {
warehouseName: string warehouseName: string
quantityDispatched: number quantityDispatched: number
fK_ShopPOItemId: string | null fK_ShopPOItemId: string | null
shopPOId: string | null
storeId: string | null
storeName: string | null
} }
export interface LoadingSheetOrderRef { export interface LoadingSheetOrderRef {
@@ -646,6 +640,15 @@ export interface ShopPOResponse {
items: ShopPOItemResponse[] items: ShopPOItemResponse[]
isActive: boolean isActive: boolean
createdAt: string createdAt: string
raisedByName: string | null
raisedByRole: string | null
confirmedByName: string | null
confirmedByRole: string | null
cancelledBy: string | null
cancelledAt: string | null
cancelReason: string | null
cancelledByName: string | null
cancelledByRole: string | null
} }
export interface InvoiceResponse { export interface InvoiceResponse {