Refactor/main flow #43

Merged
DeepnaPooja merged 7 commits from refactor/main_flow into dev 2026-09-24 17:30:17 +05:30
8 changed files with 470 additions and 164 deletions
Showing only changes of commit e8692b2474 - Show all commits
@@ -19,6 +19,8 @@ import {
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { HugeiconsIcon } from '@hugeicons/react'
import { ViewIcon } from '@hugeicons/core-free-icons'
import { toast } from '@/components/ui/toast'
import {
shopPos as shopPosApi,
@@ -45,7 +47,9 @@ interface PickedLine {
shopPOItemId: string
itemId: string
itemName: string
storeId: string
storeName: string
shopPOId: string
orderedQty: number
remainingQty: number
checked: boolean
@@ -60,6 +64,8 @@ export function LoadingSheetItemsDialog({ open, onClose, onSuccess, sheet }: Loa
const [regionId, setRegionId] = useState('')
const [routeId, setRouteId] = useState('')
const [warehouseId, setWarehouseId] = useState('')
const [shopId, setShopId] = useState('')
const [viewStoreId, setViewStoreId] = useState('')
const [pos, setPos] = useState<ShopPOResponse[]>([])
const [loading, setLoading] = useState(false)
@@ -71,6 +77,8 @@ export function LoadingSheetItemsDialog({ open, onClose, onSuccess, sheet }: Loa
setRegionId('')
setRouteId('')
setWarehouseId('')
setShopId('')
setViewStoreId('')
setPos([])
setLines({})
@@ -88,6 +96,7 @@ export function LoadingSheetItemsDialog({ open, onClose, onSuccess, sheet }: Loa
useEffect(() => {
if (!open) return
setRouteId('')
setShopId('')
setPos([])
setLines({})
routesApi.list({ regionId: regionId || undefined, pageSize: 200 })
@@ -98,6 +107,8 @@ export function LoadingSheetItemsDialog({ open, onClose, onSuccess, sheet }: Loa
useEffect(() => {
if (!open) return
setLines({})
setShopId('')
setViewStoreId('')
if (!routeId) {
setPos([])
return
@@ -123,7 +134,9 @@ export function LoadingSheetItemsDialog({ open, onClose, onSuccess, sheet }: Loa
shopPOItemId: item.id,
itemId: item.itemId,
itemName: item.itemName,
storeId: po.storeId,
storeName: po.storeName,
shopPOId: po.id,
orderedQty: item.quantity,
remainingQty: remaining,
checked: false,
@@ -135,6 +148,39 @@ export function LoadingSheetItemsDialog({ open, onClose, onSuccess, sheet }: Loa
return result
}, [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) {
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' })
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) {
toast.add({ title: 'Select at least one item', type: 'error' })
return
@@ -171,8 +217,25 @@ export function LoadingSheetItemsDialog({ open, onClose, onSuccess, sheet }: Loa
setSubmitting(true)
try {
await loadingSheetsApi.updateItems(sheet.id, items)
toast.add({ title: 'Loading sheet items saved', type: 'success' })
let target = sheet
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()
onClose()
} catch (err) {
@@ -192,6 +255,9 @@ export function LoadingSheetItemsDialog({ open, onClose, onSuccess, sheet }: Loa
<DialogDescription>
Select a route first. Confirmed Shop POs for that route will then be available to pick
items from.
{sheet.status === 'Confirmed' && (
<> This sheet is already confirmed, so the items you pick are saved on a new planned sheet for the same ride.</>
)}
</DialogDescription>
</DialogHeader>
@@ -225,6 +291,21 @@ export function LoadingSheetItemsDialog({ open, onClose, onSuccess, sheet }: Loa
</Select>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs text-muted-foreground">Shop</label>
<Select value={shopId || ALL} onValueChange={(v) => setShopId(!v || v === ALL ? '' : v)} disabled={!routeId}>
<SelectTrigger className="w-48">
<SelectValue placeholder="All shops" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL}>All shops</SelectItem>
{allShopGroups.map((g) => (
<SelectItem key={g.storeId} value={g.storeId}>{g.storeName}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs text-muted-foreground">Warehouse *</label>
<Select value={warehouseId} onValueChange={(v) => setWarehouseId(v ?? '')}>
@@ -240,54 +321,64 @@ export function LoadingSheetItemsDialog({ open, onClose, onSuccess, sheet }: Loa
</div>
</div>
<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">Store</th>
<th className="px-3 py-2 text-left font-medium">Item</th>
<th className="w-24 px-3 py-2 text-right font-medium">Ordered</th>
<th className="w-32 px-3 py-2 text-right font-medium">Qty to load</th>
</tr>
</thead>
<tbody>
{!routeId ? (
<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>
) : loading ? (
<tr><td colSpan={5} className="text-center text-muted-foreground px-3 py-6">Loading…</td></tr>
) : rows.length === 0 ? (
<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>
) : (
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.storeName}</td>
<td className="px-3 py-2">{row.itemName}</td>
<td className="px-3 py-2 text-right text-muted-foreground">{row.remainingQty}</td>
<td className="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 className="rounded-xl border">
{!routeId ? (
<p className="px-3 py-6 text-center text-sm text-muted-foreground">Select a route to see shops with confirmed POs.</p>
) : loading ? (
<p className="px-3 py-6 text-center text-sm text-muted-foreground">Loading…</p>
) : shopGroups.length === 0 ? (
<p className="px-3 py-6 text-center text-sm text-muted-foreground">No shops with confirmed POs found for this selection.</p>
) : (
shopGroups.map((group) => {
const groupLines = group.rows.map((r) => lines[r.shopPOItemId] ?? r)
const allChecked = groupLines.every((l) => l.checked)
const someChecked = groupLines.some((l) => l.checked)
return (
<div key={group.storeId} className="flex items-center gap-3 border-t px-3 py-2 first:border-t-0">
<Checkbox
checked={allChecked}
indeterminate={someChecked && !allChecked}
onCheckedChange={(v) => group.rows.forEach((r) => toggleLine(lines[r.shopPOItemId] ?? r, v === true))}
/>
<span className="min-w-0 flex-1 truncate font-medium">{group.storeName}</span>
<span className="text-xs text-muted-foreground">
{group.poIds.size} PO{group.poIds.size === 1 ? '' : 's'}
</span>
<Button
type="button"
variant="ghost"
size="icon"
aria-label={`View confirmed PO items for ${group.storeName}`}
onClick={() => setViewStoreId(group.storeId)}
>
<HugeiconsIcon icon={ViewIcon} size={18} />
</Button>
</div>
)
})
)}
</div>
<div className="rounded-xl border">
<div className="flex items-center justify-between bg-muted/50 px-3 py-2 text-sm font-medium">
<span>Loading Sheet Summary</span>
<span className="text-muted-foreground">{summary.length} item{summary.length === 1 ? '' : 's'} · {summaryTotal} units</span>
</div>
{summary.length === 0 ? (
<p className="px-3 py-4 text-center text-sm text-muted-foreground">Tick items above to build the summary.</p>
) : (
<table className="w-full text-sm">
<tbody>
{summary.map((e) => (
<tr key={e.itemName} className="border-t first:border-t-0">
<td className="px-3 py-2">{e.itemName}</td>
<td className="px-3 py-2 text-muted-foreground">{e.shops.size} shop{e.shops.size === 1 ? '' : 's'}</td>
<td className="px-3 py-2 text-right font-semibold">{e.qty}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<DialogFooter>
@@ -298,6 +389,61 @@ export function LoadingSheetItemsDialog({ open, onClose, onSuccess, sheet }: Loa
{submitting ? 'Saving…' : 'Save Items'}
</Button>
</DialogFooter>
<Dialog open={!!viewGroup} onOpenChange={(v) => !v && setViewStoreId('')}>
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{viewGroup?.storeName}</DialogTitle>
<DialogDescription>
Confirmed PO items. Tick the items to load and set the quantity.
</DialogDescription>
</DialogHeader>
<div className="overflow-x-auto rounded-xl border">
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr>
<th className="w-10 px-3 py-2"></th>
<th className="px-3 py-2 text-left font-medium">Item</th>
<th className="w-24 px-3 py-2 text-left font-medium">PO</th>
<th className="w-24 px-3 py-2 text-right font-medium">Ordered</th>
<th className="w-32 px-3 py-2 text-right font-medium">Qty to load</th>
</tr>
</thead>
<tbody>
{viewGroup?.rows.map((row) => {
const line = lines[row.shopPOItemId] ?? row
return (
<tr key={row.shopPOItemId} className="border-t">
<td className="px-3 py-2">
<Checkbox
checked={line.checked}
onCheckedChange={(v) => toggleLine(row, v === true)}
/>
</td>
<td className="px-3 py-2">{row.itemName}</td>
<td className="px-3 py-2 font-mono text-xs text-muted-foreground">{row.shopPOId.substring(0, 8)}</td>
<td className="px-3 py-2 text-right text-muted-foreground">{row.remainingQty}</td>
<td className="px-3 py-2">
<Input
type="number"
min={1}
max={row.remainingQty}
value={line.quantity}
disabled={!line.checked}
onChange={(e) => setLineQuantity(row, Number(e.target.value))}
/>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
<DialogFooter>
<Button type="button" onClick={() => setViewStoreId('')}>Done</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</DialogContent>
</Dialog>
)
@@ -1,5 +1,6 @@
'use client'
import { useMemo } from 'react'
import { Badge } from '@/components/ui/badge'
import {
Dialog,
@@ -8,7 +9,11 @@ import {
DialogTitle,
} from '@/components/ui/dialog'
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'> = {
Planned: 'secondary',
@@ -24,6 +29,35 @@ const fmtDateTime = (v: string | null) =>
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 {
open: boolean
onClose: () => void
@@ -40,11 +74,15 @@ function Row({ label, value }: { label: string; value: React.ReactNode }) {
}
export function LoadingSheetViewSheet({ open, onClose, sheet }: LoadingSheetViewSheetProps) {
const summary = useMemo(() => (sheet ? buildSummary(sheet.items) : []), [sheet])
if (!sheet) return null
const totalQty = sheet.items.reduce((s, i) => s + i.quantityDispatched, 0)
return (
<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>
<DialogTitle>Loading Sheet Details</DialogTitle>
</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="Populated At" value={fmtDateTime(sheet.populatedAt)} />
<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)} />
</div>
<Separator />
<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">
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr>
<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-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>
</thead>
<tbody>
{sheet.items.length === 0 ? (
{summary.length === 0 ? (
<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.
</td>
</tr>
) : (
sheet.items.map((item) => (
<tr key={item.id} className="border-t">
<td className="px-3 py-2">{item.itemName}</td>
<td className="px-3 py-2 text-muted-foreground">{item.warehouseName}</td>
<td className="text-right px-3 py-2">{item.quantityDispatched}</td>
summary.map((row) => (
<tr key={row.itemId} className="border-t">
<td className="px-3 py-2 font-medium">{row.itemName}</td>
<td className="px-3 py-2 text-muted-foreground">
{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>
))
)}
</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>
</div>
</div>
@@ -193,7 +193,7 @@ export function LoadingSheetsClient() {
},
],
rowActions: {
actions: () => [
actions: (target) => [
{
action: 'view',
label: 'View sheet',
@@ -201,9 +201,8 @@ export function LoadingSheetsClient() {
},
{
action: 'edit',
label: 'Set items',
label: target.status === 'Confirmed' ? 'Add items (new sheet)' : 'Set items',
onClick: handleSetItems,
visible: (target) => target.status === 'Planned',
},
{
action: 'confirm',
@@ -3,7 +3,15 @@
import { useEffect, useMemo, useState } from 'react'
import { useRouter } from 'next/navigation'
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 {
Select,
SelectContent,
@@ -46,7 +54,9 @@ interface PickedLine {
shopPOItemId: string
itemId: string
itemName: string
storeId: string
storeName: string
shopPOId: string
orderedQty: number
remainingQty: number
checked: boolean
@@ -70,6 +80,8 @@ export default function NewLoadingSheetPage() {
const [rideId, setRideId] = useState('')
const [populatedByUserId, setPopulatedByUserId] = useState('')
const [viewStoreId, setViewStoreId] = useState('')
const [pos, setPos] = useState<ShopPOResponse[]>([])
const [loadingPos, setLoadingPos] = useState(false)
const [submitting, setSubmitting] = useState(false)
@@ -103,6 +115,7 @@ export default function NewLoadingSheetPage() {
useEffect(() => {
setStoreId('')
setViewStoreId('')
setLines({})
if (!routeId) {
setStoresList([])
@@ -135,7 +148,9 @@ export default function NewLoadingSheetPage() {
shopPOItemId: item.id,
itemId: item.itemId,
itemName: item.itemName,
storeId: po.storeId,
storeName: po.storeName,
shopPOId: po.id,
orderedQty: item.quantity,
remainingQty: remaining,
checked: false,
@@ -156,6 +171,31 @@ export default function NewLoadingSheetPage() {
[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) {
setLines((prev) => ({ ...prev, [line.shopPOItemId]: { ...line, checked } }))
}
@@ -198,9 +238,15 @@ export default function NewLoadingSheetPage() {
setSubmitting(true)
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)
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')
} catch (err) {
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">
<label className="text-sm font-medium text-foreground">Region</label>
<Select value={regionId || ALL} onValueChange={(v) => setRegionId(!v || v === ALL ? '' : v)}>
<SelectTrigger>
<SelectTrigger className="w-full">
<SelectValue placeholder="All regions" />
</SelectTrigger>
<SelectContent>
<SelectContent className="w-auto min-w-(--anchor-width)">
<SelectItem value={ALL}>All regions</SelectItem>
{regionsList.map((r) => (
<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">
<label className="text-sm font-medium text-foreground">Route *</label>
<Select value={routeId} onValueChange={(v) => setRouteId(v ?? '')}>
<SelectTrigger>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select route..." />
</SelectTrigger>
<SelectContent>
<SelectContent className="w-auto min-w-(--anchor-width)">
{routesList.map((r) => (
<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)}
disabled={!routeId}
>
<SelectTrigger>
<SelectTrigger className="w-full">
<SelectValue placeholder="All shops" />
</SelectTrigger>
<SelectContent>
<SelectContent className="w-auto min-w-(--anchor-width)">
<SelectItem value={ALL}>All shops on route</SelectItem>
{storesList.map((s) => (
<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">
<label className="text-sm font-medium text-foreground">Warehouse *</label>
<Select value={warehouseId} onValueChange={(v) => setWarehouseId(v ?? '')}>
<SelectTrigger>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select warehouse..." />
</SelectTrigger>
<SelectContent>
<SelectContent className="w-auto min-w-(--anchor-width)">
{warehousesList.map((w) => (
<SelectItem key={w.id} value={w.id}>{w.name}</SelectItem>
))}
@@ -302,55 +348,98 @@ export default function NewLoadingSheetPage() {
</p>
</div>
<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">Shop</th>
<th className="px-3 py-2 text-left font-medium">Item</th>
<th className="w-24 px-3 py-2 text-right font-medium">Ordered</th>
<th className="w-32 px-3 py-2 text-right font-medium">Qty to load</th>
</tr>
</thead>
<tbody>
{!routeId ? (
<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>
) : loadingPos ? (
<tr><td colSpan={5} className="text-center text-muted-foreground px-3 py-6">Loading…</td></tr>
) : rows.length === 0 ? (
<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>
) : (
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.storeName}</td>
<td className="px-3 py-2">{row.itemName}</td>
<td className="px-3 py-2 text-right text-muted-foreground">{row.remainingQty}</td>
<td className="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 className="rounded-xl border">
{!routeId ? (
<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>
) : loadingPos ? (
<p className="px-3 py-6 text-center text-sm text-muted-foreground">Loading…</p>
) : shopGroups.length === 0 ? (
<p className="px-3 py-6 text-center text-sm text-muted-foreground">No shops with confirmed Shop POs found for this route.</p>
) : (
shopGroups.map((group) => {
const groupLines = group.rows.map((r) => lines[r.shopPOItemId] ?? r)
const allChecked = groupLines.every((l) => l.checked)
const someChecked = groupLines.some((l) => l.checked)
return (
<div key={group.storeId} className="flex items-center gap-3 border-t px-3 py-2 first:border-t-0">
<Checkbox
checked={allChecked}
indeterminate={someChecked && !allChecked}
onCheckedChange={(v) => group.rows.forEach((r) => toggleLine(lines[r.shopPOItemId] ?? r, v === true))}
/>
<span className="min-w-0 flex-1 truncate font-medium">{group.storeName}</span>
<span className="text-xs text-muted-foreground">
{group.poIds.size} PO{group.poIds.size === 1 ? '' : 's'}
</span>
<Button
type="button"
variant="ghost"
size="icon"
aria-label={`View confirmed PO items for ${group.storeName}`}
onClick={() => setViewStoreId(group.storeId)}
>
<HugeiconsIcon icon={ViewIcon} size={18} />
</Button>
</div>
)
})
)}
</div>
<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 className="rounded-2xl border p-6">
@@ -360,10 +449,10 @@ export default function NewLoadingSheetPage() {
<div className="flex flex-col gap-1">
<label className="text-sm font-medium text-foreground">Ride (vehicle to load) *</label>
<Select value={rideId} onValueChange={(v) => setRideId(v ?? '')}>
<SelectTrigger>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select open ride..." />
</SelectTrigger>
<SelectContent>
<SelectContent className="w-auto min-w-(--anchor-width)">
{ridesList.map((r) => (
<SelectItem key={r.id} value={r.id}>
{r.vehicleNumberPlate} — {r.driverFullName}
@@ -376,10 +465,10 @@ export default function NewLoadingSheetPage() {
<div className="flex flex-col gap-1">
<label className="text-sm font-medium text-foreground">Populated By *</label>
<Select value={populatedByUserId} onValueChange={(v) => setPopulatedByUserId(v ?? '')}>
<SelectTrigger>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select user..." />
</SelectTrigger>
<SelectContent>
<SelectContent className="w-auto min-w-(--anchor-width)">
{usersList.map((u) => (
<SelectItem key={u.id} value={u.id}>{u.fullName}</SelectItem>
))}
@@ -391,8 +480,8 @@ export default function NewLoadingSheetPage() {
</FieldGroup>
</div>
<div className="flex justify-end">
<div className="w-full rounded-2xl border p-6 sm:w-96">
<div>
<div className="w-full rounded-2xl border p-6">
<h3 className="text-sm font-medium text-foreground">Loading Sheet Summary</h3>
<p className="mt-1 text-sm text-muted-foreground">
{selectedRows.length} item{selectedRows.length === 1 ? '' : 's'} selected.
@@ -400,18 +489,38 @@ export default function NewLoadingSheetPage() {
<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">
<p className="text-sm font-medium text-foreground">Total Quantity</p>
<p className="text-xl font-semibold text-foreground">{totalQuantity}</p>
</div>
<div className="mt-6 flex flex-col gap-2">
<Button type="button" disabled={submitting} onClick={handleSubmit}>
{submitting ? 'Creating…' : 'Create Loading Sheet'}
</Button>
<div className="mt-6 flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => router.push('/operations/loading')}>
Cancel
</Button>
<Button type="button" disabled={submitting} onClick={handleSubmit}>
{submitting ? 'Creating…' : 'Create Loading Sheet'}
</Button>
</div>
</div>
</div>
+10 -10
View File
@@ -50,15 +50,6 @@ export const DASHBOARD_ITEM: NavItem = {
}
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',
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: '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',
icon: Store01Icon,
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' },
],
},
+3 -3
View File
@@ -128,9 +128,9 @@ export const regions = {
export interface RouteBody {
regionId: string
routeName: string
startPoint: string
endPoint: string
length: number
startPoint?: string | null
endPoint?: string | null
length?: number | null
isActive?: boolean
}
+2 -2
View File
@@ -58,8 +58,8 @@ export const shopPos = {
confirmAndCreateInvoice: (id: string, confirmedByUserId?: string | null) =>
api.post<ShopPOResponse>(`/shop-pos/${id}/confirm-and-create-invoice`, { confirmedByUserId }).then(r => r.data),
cancel: (id: string) =>
api.post<ShopPOResponse>(`/shop-pos/${id}/cancel`).then(r => r.data),
cancel: (id: string, reason: string, cancelledByUserId?: string | null) =>
api.post<ShopPOResponse>(`/shop-pos/${id}/cancel`, { reason, cancelledByUserId }).then(r => r.data),
}
// ─── Invoices ─────────────────────────────────────────────────────────────────
+17 -14
View File
@@ -1,6 +1,5 @@
// ─── Enums ────────────────────────────────────────────────────────────────────
export type KycStatus = 'Pending' | 'Verified' | 'Rejected'
export type LocationType = 'Warehouse' | 'Vehicle' | 'External'
export type MovementType =
| 'Sale' | 'CustomerReturn' | 'Transfer' | 'Damage'
@@ -81,9 +80,9 @@ export interface RouteResponse {
id: string
regionId: string
routeName: string
startPoint: string
endPoint: string
length: number
startPoint: string | null
endPoint: string | null
length: number | null
ownerUserId: string | null
isActive: boolean
createdAt: string
@@ -200,7 +199,8 @@ export interface WarehouseResponse {
export interface StoreResponse {
id: string
routeId: string
ownerName: string
ownerName: string | null
contactNumber: string | null
storeName: string
storeAddress: string
latitude: number
@@ -210,19 +210,10 @@ export interface StoreResponse {
creditLimit: number
outstandingBalance: number
segment: string | null
kycStatus: KycStatus
isActive: boolean
createdAt: string
}
export interface KycDocumentResponse {
id: string
storeId: string
documentType: string
fileReference: string
uploadedAt: string
}
export interface OutstandingBalanceResponse {
storeId: string
creditLimit: number
@@ -576,6 +567,9 @@ export interface LoadingSheetItemResponse {
warehouseName: string
quantityDispatched: number
fK_ShopPOItemId: string | null
shopPOId: string | null
storeId: string | null
storeName: string | null
}
export interface LoadingSheetOrderRef {
@@ -646,6 +640,15 @@ export interface ShopPOResponse {
items: ShopPOItemResponse[]
isActive: boolean
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 {