Add loading sheet edit flow
This commit is contained in:
@@ -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)
|
||||
|
||||
+3
-2
@@ -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'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 & 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>
|
||||
|
||||
Reference in New Issue
Block a user