fix the loading sheet view
This commit is contained in:
+197
-51
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
+61
-12
@@ -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>
|
||||||
|
|||||||
+2
-3
@@ -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>
|
||||||
|
|||||||
@@ -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' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 ─────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user