Dev #46

Merged
DeepnaPooja merged 2 commits from dev into production 2026-09-25 14:08:13 +05:30
3 changed files with 526 additions and 63 deletions
Showing only changes of commit d6fd685b3c - Show all commits
@@ -64,15 +64,14 @@ public class LoadingSheetService(
if (sheet.Status != LoadingSheetStatus.Planned)
throw new InvalidOperationException("Items can only be changed while the sheet is Planned");
// Upsert by (item, warehouse, shop PO item): a matching row gets its quantity
// updated in place, a new combination is added. Rows for combinations not present
// in this request are left untouched, so a later save can top up the same PO
// item's remaining quantity from a different warehouse without losing what was
// already planned from the first one.
// The request is the full desired item list: a row matching (item, warehouse,
// shop PO item) gets its quantity updated in place, a new combination is added,
// and rows for combinations not present in the request are removed.
await uow.BeginAsync(ct);
try
{
var newItems = new List<LoadingSheetItem>();
var keptItems = new List<LoadingSheetItem>();
foreach (var input in request.Items)
{
var existing = sheet.Items.FirstOrDefault(i =>
@@ -81,7 +80,10 @@ public class LoadingSheetService(
i.FK_ShopPOItemId == input.FK_ShopPOItemId);
if (existing is not null)
{
existing.QuantityDispatched = input.Quantity;
keptItems.Add(existing);
}
else
newItems.Add(new LoadingSheetItem
{
@@ -92,10 +94,14 @@ public class LoadingSheetService(
QuantityDispatched = input.Quantity
});
}
var removedItems = sheet.Items.Where(i => !keptItems.Contains(i)).ToList();
if (removedItems.Count > 0)
repo.RemoveItems(removedItems);
if (newItems.Count > 0)
await repo.AddItemsAsync(newItems);
var shopPOItemIds = sheet.Items.Select(i => i.FK_ShopPOItemId)
var shopPOItemIds = keptItems.Select(i => i.FK_ShopPOItemId)
.Concat(newItems.Select(i => i.FK_ShopPOItemId))
.Where(x => x.HasValue)
.Select(x => x!.Value)
@@ -201,8 +201,9 @@ export function LoadingSheetsClient() {
},
{
action: 'edit',
label: target.status === 'Confirmed' ? 'Add items (new sheet)' : 'Set items',
onClick: handleSetItems,
label: 'Edit sheet',
onClick: (row) => router.push(`/operations/loading/new?sheetId=${row.id}`),
visible: (target) => target.status === 'Planned',
},
{
action: 'confirm',
@@ -1,7 +1,7 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { useRouter } from 'next/navigation'
import { Fragment, Suspense, useEffect, useMemo, useRef, useState } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import { HugeiconsIcon } from '@hugeicons/react'
import { ArrowLeft01Icon, ViewIcon } from '@hugeicons/core-free-icons'
import {
@@ -34,10 +34,13 @@ import {
rides as ridesApi,
users as usersApi,
loadingSheets as loadingSheetsApi,
stock as stockApi,
items as itemsApi,
getApiError,
getPaginatedItems,
} from '@/lib/api/index'
import type {
ItemSummaryResponse,
ShopPOResponse,
RegionResponse,
RouteResponse,
@@ -50,6 +53,19 @@ import type { LoadingSheetItemInput } from '@/lib/api/operations'
const ALL = '__all__'
interface ExtraAllocation {
warehouseId: string
quantity: number
}
// An item added straight to the sheet, not tied to any Shop PO
interface ManualItem {
key: string
itemId: string
warehouseId: string
quantity: number
}
interface PickedLine {
shopPOItemId: string
itemId: string
@@ -58,13 +74,32 @@ interface PickedLine {
storeName: string
shopPOId: string
orderedQty: number
// already on loading sheets (or delivered)
loadedQty: number
// still open on the PO, i.e. what a sheet can still pick
remainingQty: number
checked: boolean
// quantity taken from the sheet's main warehouse
quantity: number
// optional top-ups taken from other warehouses
extras: ExtraAllocation[]
}
export default function NewLoadingSheetPage() {
return (
<Suspense>
<LoadingSheetForm />
</Suspense>
)
}
function LoadingSheetForm() {
const router = useRouter()
const sheetId = useSearchParams().get('sheetId')
const isEdit = !!sheetId
// shopPOItemId -> quantity from the sheet being edited, applied once its route's POs load
const prefillRef = useRef<{ warehouseId: string; byItem: Record<string, ExtraAllocation[]> } | null>(null)
const requestedStockRef = useRef(new Set<string>())
const [regionsList, setRegionsList] = useState<RegionResponse[]>([])
const [routesList, setRoutesList] = useState<RouteResponse[]>([])
@@ -81,27 +116,66 @@ export default function NewLoadingSheetPage() {
const [populatedByUserId, setPopulatedByUserId] = useState('')
const [viewStoreId, setViewStoreId] = useState('')
const [itemsList, setItemsList] = useState<ItemSummaryResponse[]>([])
const [manualItems, setManualItems] = useState<ManualItem[]>([])
const [pos, setPos] = useState<ShopPOResponse[]>([])
const [loadedByPoItem, setLoadedByPoItem] = useState<Record<string, number>>({})
const [loadingPos, setLoadingPos] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [lines, setLines] = useState<Record<string, PickedLine>>({})
// warehouseId -> itemId -> available quantity
const [stockByWh, setStockByWh] = useState<Record<string, Record<string, number>>>({})
useEffect(() => {
Promise.all([
regionsApi.list({ pageSize: 200 }),
warehousesApi.list({ pageSize: 200 }),
ridesApi.list({ status: 'Open', pageSize: 200 }),
ridesApi.list({ status: isEdit ? undefined : 'Open', pageSize: 200 }),
usersApi.list({ pageSize: 200 }),
itemsApi.list({ pageSize: 500 }),
])
.then(([regionsRes, warehousesRes, ridesRes, usersRes]) => {
.then(([regionsRes, warehousesRes, ridesRes, usersRes, itemsRes]) => {
setItemsList(getPaginatedItems(itemsRes))
setRegionsList(getPaginatedItems(regionsRes))
setWarehousesList(getPaginatedItems(warehousesRes))
setRidesList(getPaginatedItems(ridesRes))
setUsersList(getPaginatedItems(usersRes))
})
.catch((err) => toast.add({ title: 'Failed to load reference data', description: getApiError(err), type: 'error' }))
}, [])
}, [isEdit])
useEffect(() => {
if (!sheetId) return
loadingSheetsApi.get(sheetId)
.then(async (sheet) => {
if (sheet.status !== 'Planned') {
toast.add({ title: 'Only planned loading sheets can be edited', type: 'error' })
router.replace('/operations/loading')
return
}
setRideId(sheet.fK_RideId)
setPopulatedByUserId(sheet.populatedBy)
if (sheet.items[0]) setWarehouseId(sheet.items[0].fK_WarehouseId)
const byItem: Record<string, ExtraAllocation[]> = {}
const manual: ManualItem[] = []
for (const i of sheet.items) {
if (!i.fK_ShopPOItemId) {
manual.push({ key: i.id, itemId: i.fK_ItemId, warehouseId: i.fK_WarehouseId, quantity: i.quantityDispatched })
continue
}
;(byItem[i.fK_ShopPOItemId] ??= []).push({ warehouseId: i.fK_WarehouseId, quantity: i.quantityDispatched })
}
setManualItems(manual)
prefillRef.current = { warehouseId: sheet.items[0]?.fK_WarehouseId ?? '', byItem }
const firstStoreId = sheet.items.find((i) => i.storeId)?.storeId
if (firstStoreId) {
const store = await storesApi.get(firstStoreId)
setRouteId(store.routeId)
}
})
.catch((err) => toast.add({ title: 'Failed to load loading sheet', description: getApiError(err), type: 'error' }))
}, [sheetId, router])
useEffect(() => {
setRouteId('')
@@ -116,7 +190,7 @@ export default function NewLoadingSheetPage() {
useEffect(() => {
setStoreId('')
setViewStoreId('')
setLines({})
if (!prefillRef.current) setLines({})
if (!routeId) {
setStoresList([])
setPos([])
@@ -127,21 +201,108 @@ export default function NewLoadingSheetPage() {
.catch((err) => toast.add({ title: 'Failed to load shops', description: getApiError(err), type: 'error' }))
setLoadingPos(true)
shopPosApi.list({ routeId, confirmed: true, pageSize: 200 })
.then((res) => {
Promise.all([
shopPosApi.list({ routeId, confirmed: true, pageSize: 200 }),
loadingSheetsApi.list({ routeId, pageSize: 200 }),
])
.then(([res, sheetsRes]) => {
const items = getPaginatedItems(res).filter((po) => po.status === 'Pending' || po.status === 'PartiallyFulfilled')
// Quantity of each PO item already planned/loaded on other sheets. The sheet being
// edited is excluded because its own items are re-picked on this form.
const loaded: Record<string, number> = {}
for (const sheet of getPaginatedItems(sheetsRes)) {
if (sheet.id === sheetId) continue
for (const i of sheet.items) {
if (i.fK_ShopPOItemId) loaded[i.fK_ShopPOItemId] = (loaded[i.fK_ShopPOItemId] ?? 0) + i.quantityDispatched
}
}
setLoadedByPoItem(loaded)
setPos(items)
const prefill = prefillRef.current
if (prefill) {
prefillRef.current = null
const picked: Record<string, PickedLine> = {}
for (const po of items) {
for (const item of po.items) {
const allocs = prefill.byItem[item.id]
if (!allocs) continue
picked[item.id] = {
shopPOItemId: item.id,
itemId: item.itemId,
itemName: item.itemName,
storeId: po.storeId,
storeName: po.storeName,
shopPOId: po.id,
orderedQty: item.quantity,
loadedQty: Math.max(item.quantityFulfilled, loaded[item.id] ?? 0),
remainingQty: item.quantity - Math.max(item.quantityFulfilled, loaded[item.id] ?? 0),
checked: true,
quantity: allocs.find((a) => a.warehouseId === prefill.warehouseId)?.quantity ?? 0,
extras: allocs.filter((a) => a.warehouseId !== prefill.warehouseId),
}
}
}
setLines(picked)
}
})
.catch((err) => toast.add({ title: 'Failed to load Shop POs', description: getApiError(err), type: 'error' }))
.finally(() => setLoadingPos(false))
}, [routeId])
}, [routeId, sheetId])
const neededWarehouses = useMemo(() => {
const ids = new Set<string>()
if (warehouseId) ids.add(warehouseId)
for (const l of Object.values(lines)) for (const e of l.extras) if (e.warehouseId) ids.add(e.warehouseId)
for (const m of manualItems) if (m.warehouseId) ids.add(m.warehouseId)
return Array.from(ids).sort().join(',')
}, [warehouseId, lines, manualItems])
useEffect(() => {
for (const id of neededWarehouses.split(',').filter(Boolean)) {
if (requestedStockRef.current.has(id)) continue
requestedStockRef.current.add(id)
stockApi.balances({ locationId: id, pageSize: 1000 })
.then((res) => {
const byItem: Record<string, number> = {}
for (const b of getPaginatedItems(res)) byItem[b.itemId] = b.quantity
setStockByWh((prev) => ({ ...prev, [id]: byItem }))
})
.catch((err) => {
requestedStockRef.current.delete(id)
toast.add({ title: 'Failed to load warehouse stock', description: getApiError(err), type: 'error' })
})
}
}, [neededWarehouses])
const stockLoaded = !!warehouseId && !!stockByWh[warehouseId]
const availIn = (whId: string, itemId: string) => stockByWh[whId]?.[itemId] ?? 0
const extrasTotal = (l: PickedLine) => l.extras.reduce((sum, e) => sum + (Number(e.quantity) || 0), 0)
// Keep quantities within stock when the main warehouse changes or its stock loads
useEffect(() => {
if (!stockLoaded) return
setLines((prev) => {
let changed = false
const next: Record<string, PickedLine> = {}
for (const [key, l] of Object.entries(prev)) {
const extras = l.extras.filter((e) => e.warehouseId !== warehouseId)
const avail = stockByWh[warehouseId]?.[l.itemId] ?? 0
const quantity = Math.min(l.quantity, avail)
const checked = l.checked && (quantity > 0 || extras.length > 0)
if (extras.length !== l.extras.length || quantity !== l.quantity || checked !== l.checked) changed = true
next[key] = { ...l, extras, quantity, checked }
}
return changed ? next : prev
})
}, [warehouseId, stockLoaded, stockByWh])
const rows = useMemo<PickedLine[]>(() => {
const result: PickedLine[] = []
for (const po of pos) {
if (storeId && po.storeId !== storeId) continue
for (const item of po.items) {
const remaining = item.quantity - item.quantityFulfilled
const loadedQty = Math.max(item.quantityFulfilled, loadedByPoItem[item.id] ?? 0)
const remaining = item.quantity - loadedQty
if (remaining <= 0) continue
result.push(
lines[item.id] ?? {
@@ -152,23 +313,28 @@ export default function NewLoadingSheetPage() {
storeName: po.storeName,
shopPOId: po.id,
orderedQty: item.quantity,
loadedQty,
remainingQty: remaining,
checked: false,
quantity: remaining,
extras: [],
}
)
}
}
return result
}, [pos, storeId, lines])
}, [pos, storeId, lines, loadedByPoItem])
const selectedRows = useMemo(
() => rows.filter((r) => (lines[r.shopPOItemId]?.checked ?? false)),
[rows, lines]
)
const totalQuantity = useMemo(
() => selectedRows.reduce((sum, r) => sum + (Number(lines[r.shopPOItemId]?.quantity) || 0), 0),
[selectedRows, lines]
() => selectedRows.reduce((sum, r) => {
const l = lines[r.shopPOItemId]
return sum + (Number(l?.quantity) || 0) + (l ? extrasTotal(l) : 0)
}, 0) + manualItems.reduce((sum, m) => sum + (Number(m.quantity) || 0), 0),
[selectedRows, lines, manualItems]
)
const shopGroups = useMemo(() => {
@@ -187,25 +353,96 @@ export default function NewLoadingSheetPage() {
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 l = lines[row.shopPOItemId]
const qty = (Number(l?.quantity) || 0) + (l ? extrasTotal(l) : 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)
}
for (const m of manualItems) {
if (!m.itemId) continue
const e = map.get(m.itemId) ?? { itemName: itemsList.find((i) => i.id === m.itemId)?.name ?? '', qty: 0, shops: new Set<string>() }
e.qty += Number(m.quantity) || 0
map.set(m.itemId, e)
}
return Array.from(map.values()).sort((a, b) => a.itemName.localeCompare(b.itemName))
}, [selectedRows, lines])
}, [selectedRows, lines, manualItems, itemsList])
const stockOverview = useMemo(() => {
const map = new Map<string, { itemId: string; itemName: string; open: number }>()
for (const row of rows) {
const e = map.get(row.itemId) ?? { itemId: row.itemId, itemName: row.itemName, open: 0 }
e.open += row.remainingQty
map.set(row.itemId, e)
}
return Array.from(map.values()).sort((a, b) => a.itemName.localeCompare(b.itemName))
}, [rows])
function addManualItem() {
setManualItems((prev) => [...prev, { key: crypto.randomUUID(), itemId: '', warehouseId, quantity: 0 }])
}
function updateManualItem(key: string, patch: Partial<ManualItem>) {
setManualItems((prev) => prev.map((m) => {
if (m.key !== key) return m
const next = { ...m, ...patch }
const max = availIn(next.warehouseId, next.itemId)
// item/warehouse change resets to the available quantity; a typed quantity is capped by stock
next.quantity = patch.quantity === undefined
? (next.itemId && stockByWh[next.warehouseId] ? Math.min(max, next.quantity || max) : next.quantity)
: Math.max(0, Math.min(patch.quantity, max))
return next
}))
}
function removeManualItem(key: string) {
setManualItems((prev) => prev.filter((m) => m.key !== key))
}
function toggleLine(line: PickedLine, checked: boolean) {
if (checked) {
const avail = availIn(warehouseId, line.itemId)
if (!stockLoaded || avail <= 0) return
const quantity = Math.max(0, Math.min(line.remainingQty - extrasTotal(line), avail))
setLines((prev) => ({ ...prev, [line.shopPOItemId]: { ...line, checked, quantity } }))
return
}
setLines((prev) => ({ ...prev, [line.shopPOItemId]: { ...line, checked } }))
}
function setLineQuantity(line: PickedLine, quantity: number) {
setLines((prev) => ({ ...prev, [line.shopPOItemId]: { ...line, quantity } }))
const max = Math.max(0, Math.min(line.remainingQty - extrasTotal(line), availIn(warehouseId, line.itemId)))
setLines((prev) => ({ ...prev, [line.shopPOItemId]: { ...line, quantity: Math.min(Math.max(0, quantity), max) } }))
}
function updateExtras(line: PickedLine, extras: ExtraAllocation[]) {
setLines((prev) => ({ ...prev, [line.shopPOItemId]: { ...line, extras } }))
}
function addExtra(line: PickedLine) {
updateExtras(line, [...line.extras, { warehouseId: '', quantity: 0 }])
}
function setExtraWarehouse(line: PickedLine, index: number, whId: string) {
const remaining = line.remainingQty - line.quantity - line.extras.reduce((s, e, i) => (i === index ? s : s + (Number(e.quantity) || 0)), 0)
const quantity = Math.max(0, Math.min(remaining, availIn(whId, line.itemId)))
updateExtras(line, line.extras.map((e, i) => (i === index ? { warehouseId: whId, quantity } : e)))
}
function setExtraQuantity(line: PickedLine, index: number, quantity: number) {
const e = line.extras[index]
const others = line.quantity + line.extras.reduce((s, x, i) => (i === index ? s : s + (Number(x.quantity) || 0)), 0)
const max = Math.max(0, Math.min(line.remainingQty - others, availIn(e.warehouseId, line.itemId)))
updateExtras(line, line.extras.map((x, i) => (i === index ? { ...x, quantity: Math.min(Math.max(0, quantity), max) } : x)))
}
function removeExtra(line: PickedLine, index: number) {
updateExtras(line, line.extras.filter((_, i) => i !== index))
}
async function handleSubmit() {
if (!routeId) {
if (!routeId && manualItems.length === 0) {
toast.add({ title: 'Select a route first', type: 'error' })
return
}
@@ -213,7 +450,7 @@ export default function NewLoadingSheetPage() {
toast.add({ title: 'Select a warehouse', type: 'error' })
return
}
if (selectedRows.length === 0) {
if (selectedRows.length === 0 && manualItems.length === 0) {
toast.add({ title: 'Select at least one item', type: 'error' })
return
}
@@ -226,25 +463,65 @@ export default function NewLoadingSheetPage() {
return
}
const items: LoadingSheetItemInput[] = selectedRows.map((r) => {
const items: LoadingSheetItemInput[] = []
for (const r of selectedRows) {
const line = lines[r.shopPOItemId]
return {
fK_ItemId: line.itemId,
fK_WarehouseId: warehouseId,
quantity: line.quantity,
fK_ShopPOItemId: line.shopPOItemId,
const allocations = [{ warehouseId, quantity: line.quantity }, ...line.extras]
for (const a of allocations) {
if (!a.warehouseId || !(a.quantity > 0)) continue
if (a.quantity > availIn(a.warehouseId, line.itemId)) {
toast.add({ title: `Not enough stock for ${line.itemName} in the selected warehouse`, type: 'error' })
return
}
items.push({
fK_ItemId: line.itemId,
fK_WarehouseId: a.warehouseId,
quantity: a.quantity,
fK_ShopPOItemId: line.shopPOItemId,
})
}
})
}
const seen = new Set<string>()
for (const m of manualItems) {
const name = itemsList.find((i) => i.id === m.itemId)?.name ?? 'item'
if (!m.itemId || !m.warehouseId || !(m.quantity > 0)) {
toast.add({ title: 'Complete or remove the manually added items', description: 'Each needs an item, a warehouse and a quantity above zero.', type: 'error' })
return
}
if (m.quantity > availIn(m.warehouseId, m.itemId)) {
toast.add({ title: `Not enough stock for ${name} in the selected warehouse`, type: 'error' })
return
}
const dupKey = m.itemId + '|' + m.warehouseId
if (seen.has(dupKey)) {
toast.add({ title: `${name} is added twice for the same warehouse`, type: 'error' })
return
}
seen.add(dupKey)
items.push({ fK_ItemId: m.itemId, fK_WarehouseId: m.warehouseId, quantity: m.quantity, fK_ShopPOItemId: null })
}
if (items.length === 0) {
toast.add({ title: 'Enter a quantity greater than zero', type: 'error' })
return
}
setSubmitting(true)
try {
const existing = getPaginatedItems(
await loadingSheetsApi.list({ rideId, status: 'Planned', pageSize: 1 })
)[0]
const existing = sheetId
? await loadingSheetsApi.get(sheetId)
: getPaginatedItems(await loadingSheetsApi.list({ rideId, status: 'Planned', pageSize: 1 }))[0]
const sheet = existing ?? await loadingSheetsApi.create({ fK_RideId: rideId, populatedByUserId })
// The backend replaces the sheet's items with what is sent, so when topping up an
// existing planned sheet keep the rows it already has.
if (!isEdit && existing) {
for (const e of existing.items) {
if (items.some((i) => i.fK_ItemId === e.fK_ItemId && i.fK_WarehouseId === e.fK_WarehouseId && (i.fK_ShopPOItemId ?? null) === e.fK_ShopPOItemId)) continue
items.push({ fK_ItemId: e.fK_ItemId, fK_WarehouseId: e.fK_WarehouseId, quantity: e.quantityDispatched, fK_ShopPOItemId: e.fK_ShopPOItemId })
}
}
await loadingSheetsApi.updateItems(sheet.id, items)
toast.add({
title: existing ? 'Items added to the ride\'s existing planned loading sheet' : 'Loading sheet created',
title: isEdit ? 'Loading sheet updated' : existing ? 'Items added to the ride\'s existing planned loading sheet' : 'Loading sheet created',
type: 'success',
})
router.push('/operations/loading')
@@ -263,7 +540,7 @@ export default function NewLoadingSheetPage() {
Back
</Button>
<div>
<h1 className="text-2xl font-semibold text-foreground">New Loading Sheet</h1>
<h1 className="text-2xl font-semibold text-foreground">{isEdit ? 'Edit Loading Sheet' : 'New Loading Sheet'}</h1>
<p className="text-sm text-muted-foreground">
Select a route first. Confirmed Shop POs for that route&apos;s shops will then be
available to pick items from. Pick the ride to load last.
@@ -358,12 +635,14 @@ export default function NewLoadingSheetPage() {
) : (
shopGroups.map((group) => {
const groupLines = group.rows.map((r) => lines[r.shopPOItemId] ?? r)
const allChecked = groupLines.every((l) => l.checked)
const tickable = groupLines.filter((l) => stockLoaded && availIn(warehouseId, l.itemId) > 0)
const allChecked = tickable.length > 0 && tickable.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}
disabled={tickable.length === 0}
indeterminate={someChecked && !allChecked}
onCheckedChange={(v) => group.rows.forEach((r) => toggleLine(lines[r.shopPOItemId] ?? r, v === true))}
/>
@@ -386,6 +665,48 @@ export default function NewLoadingSheetPage() {
)}
</div>
{routeId && stockOverview.length > 0 && (
<div className="mt-4 rounded-xl border">
<div className="border-b bg-muted/50 px-3 py-2 text-sm font-medium">
Stock in {warehousesList.find((w) => w.id === warehouseId)?.name ?? 'selected warehouse'}
</div>
{!warehouseId ? (
<p className="px-3 py-4 text-center text-sm text-muted-foreground">Select a warehouse to see available stock.</p>
) : (
<table className="w-full text-sm">
<thead>
<tr className="text-muted-foreground">
<th className="px-3 py-2 text-left font-medium">Item</th>
<th className="w-28 px-3 py-2 text-right font-medium">Open on POs</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">Can load</th>
</tr>
</thead>
<tbody>
{stockOverview.map((o) => {
const avail = availIn(warehouseId, o.itemId)
return (
<tr key={o.itemId} className="border-t">
<td className="px-3 py-2">{o.itemName}</td>
<td className="px-3 py-2 text-right text-muted-foreground">{o.open}</td>
<td className={`px-3 py-2 text-right ${stockLoaded && avail <= 0 ? 'text-destructive' : ''}`}>
{!stockLoaded ? '…' : avail <= 0 ? 'No stock' : avail}
</td>
<td className="px-3 py-2 text-right font-medium">
{stockLoaded ? Math.min(o.open, avail) : '…'}
{stockLoaded && avail < o.open && (
<span className="ml-1 text-xs font-normal text-muted-foreground">({o.open - Math.min(o.open, avail)} short)</span>
)}
</td>
</tr>
)
})}
</tbody>
</table>
)}
</div>
)}
<Dialog open={!!viewGroup} onOpenChange={(v) => !v && setViewStoreId('')}>
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-2xl">
<DialogHeader>
@@ -394,6 +715,9 @@ export default function NewLoadingSheetPage() {
Confirmed PO items. Tick the items to load and set the quantity.
</DialogDescription>
</DialogHeader>
{!warehouseId && (
<p className="text-sm text-muted-foreground">Select a warehouse first to see available stock.</p>
)}
<div className="overflow-x-auto rounded-xl border">
<table className="w-full text-sm">
<thead className="bg-muted/50">
@@ -402,34 +726,93 @@ export default function NewLoadingSheetPage() {
<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-24 px-3 py-2 text-right font-medium">Loaded</th>
<th className="w-24 px-3 py-2 text-right font-medium">Open</th>
<th className="w-24 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>
{viewGroup?.rows.map((row) => {
const line = lines[row.shopPOItemId] ?? row
const avail = availIn(warehouseId, row.itemId)
const noStock = !warehouseId || !stockLoaded || avail <= 0
const shortBy = row.remainingQty - line.quantity - extrasTotal(line)
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>
<Fragment key={row.shopPOItemId}>
<tr className="border-t">
<td className="px-3 py-2">
<Checkbox
checked={line.checked}
disabled={noStock && !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.orderedQty}</td>
<td className="px-3 py-2 text-right text-muted-foreground">{row.loadedQty}</td>
<td className="px-3 py-2 text-right text-muted-foreground">{row.remainingQty}</td>
<td className={`px-3 py-2 text-right ${warehouseId && stockLoaded && avail <= 0 ? 'text-destructive' : 'text-muted-foreground'}`}>
{!warehouseId ? '—' : !stockLoaded ? '…' : avail <= 0 ? 'No stock' : avail}
</td>
<td className="px-3 py-2">
<Input
type="number"
min={0}
max={Math.min(row.remainingQty - extrasTotal(line), avail)}
value={line.quantity}
disabled={!line.checked}
onChange={(e) => setLineQuantity(row, Number(e.target.value))}
/>
</td>
</tr>
{line.checked && line.extras.map((extra, idx) => (
<tr key={idx} className="bg-muted/20">
<td className="px-3 py-2"></td>
<td className="px-3 py-2" colSpan={5}>
<Select value={extra.warehouseId} onValueChange={(v) => setExtraWarehouse(line, idx, v ?? '')}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Take from another warehouse..." />
</SelectTrigger>
<SelectContent className="w-auto min-w-(--anchor-width)">
{warehousesList
.filter((w) => w.id !== warehouseId && !line.extras.some((e, i) => i !== idx && e.warehouseId === w.id))
.map((w) => (
<SelectItem key={w.id} value={w.id}>{w.name}</SelectItem>
))}
</SelectContent>
</Select>
</td>
<td className="px-3 py-2 text-right text-muted-foreground">
{extra.warehouseId ? (stockByWh[extra.warehouseId] ? (availIn(extra.warehouseId, row.itemId) || 'No stock') : '…') : '—'}
</td>
<td className="px-3 py-2">
<div className="flex items-center gap-1">
<Input
type="number"
min={0}
value={extra.quantity}
disabled={!extra.warehouseId}
onChange={(e) => setExtraQuantity(line, idx, Number(e.target.value))}
/>
<Button type="button" variant="ghost" size="sm" onClick={() => removeExtra(line, idx)}>✕</Button>
</div>
</td>
</tr>
))}
{line.checked && shortBy > 0 && (
<tr>
<td className="px-3 pb-2"></td>
<td className="px-3 pb-2" colSpan={7}>
<span className="mr-3 text-xs text-muted-foreground">{shortBy} will stay open on the PO for another sheet</span>
<Button type="button" variant="outline" size="sm" onClick={() => addExtra(line)}>
Take remaining from another warehouse
</Button>
</td>
</tr>
)}
</Fragment>
)
})}
</tbody>
@@ -442,13 +825,86 @@ export default function NewLoadingSheetPage() {
</Dialog>
</div>
<div className="rounded-2xl border p-6">
<div className="mb-4 flex items-center justify-between gap-4">
<div>
<h3 className="text-sm font-medium text-foreground">Additional Items</h3>
<p className="text-sm text-muted-foreground">
Load items that are not on any Shop PO, such as extra stock for the vehicle.
</p>
</div>
<Button type="button" variant="outline" size="sm" onClick={addManualItem}>
Add item
</Button>
</div>
{manualItems.length === 0 ? (
<p className="rounded-xl border px-3 py-4 text-center text-sm text-muted-foreground">No additional items.</p>
) : (
<div className="flex flex-col gap-3">
{manualItems.map((m) => {
const avail = availIn(m.warehouseId, m.itemId)
const loaded = !!stockByWh[m.warehouseId]
return (
<div key={m.key} className="grid items-end gap-3 rounded-xl border p-3 sm:grid-cols-[2fr_2fr_auto_1fr_auto]">
<div className="flex flex-col gap-1">
<label className="text-xs text-muted-foreground">Item</label>
<Select value={m.itemId} onValueChange={(v) => updateManualItem(m.key, { itemId: v ?? '' })}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select item..." />
</SelectTrigger>
<SelectContent className="w-auto min-w-(--anchor-width)">
{itemsList.map((i) => (
<SelectItem key={i.id} value={i.id}>{i.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs text-muted-foreground">Warehouse</label>
<Select value={m.warehouseId} onValueChange={(v) => updateManualItem(m.key, { warehouseId: v ?? '' })}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select warehouse..." />
</SelectTrigger>
<SelectContent className="w-auto min-w-(--anchor-width)">
{warehousesList.map((w) => (
<SelectItem key={w.id} value={w.id}>{w.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1 text-sm">
<span className="text-xs text-muted-foreground">In stock</span>
<span className={m.itemId && m.warehouseId && loaded && avail <= 0 ? 'py-2 text-destructive' : 'py-2'}>
{!m.itemId || !m.warehouseId ? '—' : !loaded ? '…' : avail <= 0 ? 'No stock' : avail}
</span>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs text-muted-foreground">Quantity</label>
<Input
type="number"
min={0}
max={avail}
value={m.quantity}
disabled={!m.itemId || !m.warehouseId || avail <= 0}
onChange={(e) => updateManualItem(m.key, { quantity: Number(e.target.value) })}
/>
</div>
<Button type="button" variant="ghost" size="sm" onClick={() => removeManualItem(m.key)}>✕</Button>
</div>
)
})}
</div>
)}
</div>
<div className="rounded-2xl border p-6">
<h3 className="mb-4 text-sm font-medium text-foreground">Ride &amp; Assignment</h3>
<FieldGroup>
<div className="grid gap-6 sm:grid-cols-2">
<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 ?? '')}>
<Select value={rideId} onValueChange={(v) => setRideId(v ?? '')} disabled={isEdit}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select open ride..." />
</SelectTrigger>
@@ -498,7 +954,7 @@ export default function NewLoadingSheetPage() {
<span>
{e.itemName}
<span className="ml-2 text-xs text-muted-foreground">
{e.shops.size} shop{e.shops.size === 1 ? '' : 's'}
{e.shops.size > 0 ? `${e.shops.size} shop${e.shops.size === 1 ? '' : 's'}` : 'added manually'}
</span>
</span>
<span className="font-semibold">{e.qty}</span>
@@ -519,7 +975,7 @@ export default function NewLoadingSheetPage() {
Cancel
</Button>
<Button type="button" disabled={submitting} onClick={handleSubmit}>
{submitting ? 'Creating…' : 'Create Loading Sheet'}
{submitting ? 'Saving…' : isEdit ? 'Save Changes' : 'Create Loading Sheet'}
</Button>
</div>
</div>