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