Merge pull request 'Dev' (#46) from dev into production
Reviewed-on: #46
This commit was merged in pull request #46.
This commit is contained in:
@@ -64,15 +64,14 @@ public class LoadingSheetService(
|
|||||||
if (sheet.Status != LoadingSheetStatus.Planned)
|
if (sheet.Status != LoadingSheetStatus.Planned)
|
||||||
throw new InvalidOperationException("Items can only be changed while the sheet is 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
|
// The request is the full desired item list: a row matching (item, warehouse,
|
||||||
// updated in place, a new combination is added. Rows for combinations not present
|
// shop PO item) gets its quantity updated in place, a new combination is added,
|
||||||
// in this request are left untouched, so a later save can top up the same PO
|
// and rows for combinations not present in the request are removed.
|
||||||
// item's remaining quantity from a different warehouse without losing what was
|
|
||||||
// already planned from the first one.
|
|
||||||
await uow.BeginAsync(ct);
|
await uow.BeginAsync(ct);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var newItems = new List<LoadingSheetItem>();
|
var newItems = new List<LoadingSheetItem>();
|
||||||
|
var keptItems = new List<LoadingSheetItem>();
|
||||||
foreach (var input in request.Items)
|
foreach (var input in request.Items)
|
||||||
{
|
{
|
||||||
var existing = sheet.Items.FirstOrDefault(i =>
|
var existing = sheet.Items.FirstOrDefault(i =>
|
||||||
@@ -81,7 +80,10 @@ public class LoadingSheetService(
|
|||||||
i.FK_ShopPOItemId == input.FK_ShopPOItemId);
|
i.FK_ShopPOItemId == input.FK_ShopPOItemId);
|
||||||
|
|
||||||
if (existing is not null)
|
if (existing is not null)
|
||||||
|
{
|
||||||
existing.QuantityDispatched = input.Quantity;
|
existing.QuantityDispatched = input.Quantity;
|
||||||
|
keptItems.Add(existing);
|
||||||
|
}
|
||||||
else
|
else
|
||||||
newItems.Add(new LoadingSheetItem
|
newItems.Add(new LoadingSheetItem
|
||||||
{
|
{
|
||||||
@@ -92,10 +94,14 @@ public class LoadingSheetService(
|
|||||||
QuantityDispatched = input.Quantity
|
QuantityDispatched = input.Quantity
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var removedItems = sheet.Items.Where(i => !keptItems.Contains(i)).ToList();
|
||||||
|
if (removedItems.Count > 0)
|
||||||
|
repo.RemoveItems(removedItems);
|
||||||
if (newItems.Count > 0)
|
if (newItems.Count > 0)
|
||||||
await repo.AddItemsAsync(newItems);
|
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))
|
.Concat(newItems.Select(i => i.FK_ShopPOItemId))
|
||||||
.Where(x => x.HasValue)
|
.Where(x => x.HasValue)
|
||||||
.Select(x => x!.Value)
|
.Select(x => x!.Value)
|
||||||
|
|||||||
+3
-2
@@ -201,8 +201,9 @@ export function LoadingSheetsClient() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
action: 'edit',
|
action: 'edit',
|
||||||
label: target.status === 'Confirmed' ? 'Add items (new sheet)' : 'Set items',
|
label: 'Edit sheet',
|
||||||
onClick: handleSetItems,
|
onClick: (row) => router.push(`/operations/loading/new?sheetId=${row.id}`),
|
||||||
|
visible: (target) => target.status === 'Planned',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
action: 'confirm',
|
action: 'confirm',
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
import { Fragment, Suspense, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter, useSearchParams } from 'next/navigation'
|
||||||
import { HugeiconsIcon } from '@hugeicons/react'
|
import { HugeiconsIcon } from '@hugeicons/react'
|
||||||
import { ArrowLeft01Icon, ViewIcon } from '@hugeicons/core-free-icons'
|
import { ArrowLeft01Icon, ViewIcon } from '@hugeicons/core-free-icons'
|
||||||
import {
|
import {
|
||||||
@@ -34,10 +34,13 @@ import {
|
|||||||
rides as ridesApi,
|
rides as ridesApi,
|
||||||
users as usersApi,
|
users as usersApi,
|
||||||
loadingSheets as loadingSheetsApi,
|
loadingSheets as loadingSheetsApi,
|
||||||
|
stock as stockApi,
|
||||||
|
items as itemsApi,
|
||||||
getApiError,
|
getApiError,
|
||||||
getPaginatedItems,
|
getPaginatedItems,
|
||||||
} from '@/lib/api/index'
|
} from '@/lib/api/index'
|
||||||
import type {
|
import type {
|
||||||
|
ItemSummaryResponse,
|
||||||
ShopPOResponse,
|
ShopPOResponse,
|
||||||
RegionResponse,
|
RegionResponse,
|
||||||
RouteResponse,
|
RouteResponse,
|
||||||
@@ -50,6 +53,19 @@ import type { LoadingSheetItemInput } from '@/lib/api/operations'
|
|||||||
|
|
||||||
const ALL = '__all__'
|
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 {
|
interface PickedLine {
|
||||||
shopPOItemId: string
|
shopPOItemId: string
|
||||||
itemId: string
|
itemId: string
|
||||||
@@ -58,13 +74,32 @@ interface PickedLine {
|
|||||||
storeName: string
|
storeName: string
|
||||||
shopPOId: string
|
shopPOId: string
|
||||||
orderedQty: number
|
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
|
remainingQty: number
|
||||||
checked: boolean
|
checked: boolean
|
||||||
|
// quantity taken from the sheet's main warehouse
|
||||||
quantity: number
|
quantity: number
|
||||||
|
// optional top-ups taken from other warehouses
|
||||||
|
extras: ExtraAllocation[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function NewLoadingSheetPage() {
|
export default function NewLoadingSheetPage() {
|
||||||
|
return (
|
||||||
|
<Suspense>
|
||||||
|
<LoadingSheetForm />
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoadingSheetForm() {
|
||||||
const router = useRouter()
|
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 [regionsList, setRegionsList] = useState<RegionResponse[]>([])
|
||||||
const [routesList, setRoutesList] = useState<RouteResponse[]>([])
|
const [routesList, setRoutesList] = useState<RouteResponse[]>([])
|
||||||
@@ -81,27 +116,66 @@ export default function NewLoadingSheetPage() {
|
|||||||
const [populatedByUserId, setPopulatedByUserId] = useState('')
|
const [populatedByUserId, setPopulatedByUserId] = useState('')
|
||||||
|
|
||||||
const [viewStoreId, setViewStoreId] = useState('')
|
const [viewStoreId, setViewStoreId] = useState('')
|
||||||
|
const [itemsList, setItemsList] = useState<ItemSummaryResponse[]>([])
|
||||||
|
const [manualItems, setManualItems] = useState<ManualItem[]>([])
|
||||||
|
|
||||||
const [pos, setPos] = useState<ShopPOResponse[]>([])
|
const [pos, setPos] = useState<ShopPOResponse[]>([])
|
||||||
|
const [loadedByPoItem, setLoadedByPoItem] = useState<Record<string, number>>({})
|
||||||
const [loadingPos, setLoadingPos] = useState(false)
|
const [loadingPos, setLoadingPos] = 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>>({})
|
||||||
|
// warehouseId -> itemId -> available quantity
|
||||||
|
const [stockByWh, setStockByWh] = useState<Record<string, Record<string, number>>>({})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Promise.all([
|
Promise.all([
|
||||||
regionsApi.list({ pageSize: 200 }),
|
regionsApi.list({ pageSize: 200 }),
|
||||||
warehousesApi.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 }),
|
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))
|
setRegionsList(getPaginatedItems(regionsRes))
|
||||||
setWarehousesList(getPaginatedItems(warehousesRes))
|
setWarehousesList(getPaginatedItems(warehousesRes))
|
||||||
setRidesList(getPaginatedItems(ridesRes))
|
setRidesList(getPaginatedItems(ridesRes))
|
||||||
setUsersList(getPaginatedItems(usersRes))
|
setUsersList(getPaginatedItems(usersRes))
|
||||||
})
|
})
|
||||||
.catch((err) => toast.add({ title: 'Failed to load reference data', description: getApiError(err), type: 'error' }))
|
.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(() => {
|
useEffect(() => {
|
||||||
setRouteId('')
|
setRouteId('')
|
||||||
@@ -116,7 +190,7 @@ export default function NewLoadingSheetPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setStoreId('')
|
setStoreId('')
|
||||||
setViewStoreId('')
|
setViewStoreId('')
|
||||||
setLines({})
|
if (!prefillRef.current) setLines({})
|
||||||
if (!routeId) {
|
if (!routeId) {
|
||||||
setStoresList([])
|
setStoresList([])
|
||||||
setPos([])
|
setPos([])
|
||||||
@@ -127,21 +201,108 @@ export default function NewLoadingSheetPage() {
|
|||||||
.catch((err) => toast.add({ title: 'Failed to load shops', description: getApiError(err), type: 'error' }))
|
.catch((err) => toast.add({ title: 'Failed to load shops', description: getApiError(err), type: 'error' }))
|
||||||
|
|
||||||
setLoadingPos(true)
|
setLoadingPos(true)
|
||||||
shopPosApi.list({ routeId, confirmed: true, pageSize: 200 })
|
Promise.all([
|
||||||
.then((res) => {
|
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')
|
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)
|
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' }))
|
.catch((err) => toast.add({ title: 'Failed to load Shop POs', description: getApiError(err), type: 'error' }))
|
||||||
.finally(() => setLoadingPos(false))
|
.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 rows = useMemo<PickedLine[]>(() => {
|
||||||
const result: PickedLine[] = []
|
const result: PickedLine[] = []
|
||||||
for (const po of pos) {
|
for (const po of pos) {
|
||||||
if (storeId && po.storeId !== storeId) continue
|
if (storeId && po.storeId !== storeId) continue
|
||||||
for (const item of po.items) {
|
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
|
if (remaining <= 0) continue
|
||||||
result.push(
|
result.push(
|
||||||
lines[item.id] ?? {
|
lines[item.id] ?? {
|
||||||
@@ -152,23 +313,28 @@ export default function NewLoadingSheetPage() {
|
|||||||
storeName: po.storeName,
|
storeName: po.storeName,
|
||||||
shopPOId: po.id,
|
shopPOId: po.id,
|
||||||
orderedQty: item.quantity,
|
orderedQty: item.quantity,
|
||||||
|
loadedQty,
|
||||||
remainingQty: remaining,
|
remainingQty: remaining,
|
||||||
checked: false,
|
checked: false,
|
||||||
quantity: remaining,
|
quantity: remaining,
|
||||||
|
extras: [],
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}, [pos, storeId, lines])
|
}, [pos, storeId, lines, loadedByPoItem])
|
||||||
|
|
||||||
const selectedRows = useMemo(
|
const selectedRows = useMemo(
|
||||||
() => rows.filter((r) => (lines[r.shopPOItemId]?.checked ?? false)),
|
() => rows.filter((r) => (lines[r.shopPOItemId]?.checked ?? false)),
|
||||||
[rows, lines]
|
[rows, lines]
|
||||||
)
|
)
|
||||||
const totalQuantity = useMemo(
|
const totalQuantity = useMemo(
|
||||||
() => selectedRows.reduce((sum, r) => sum + (Number(lines[r.shopPOItemId]?.quantity) || 0), 0),
|
() => selectedRows.reduce((sum, r) => {
|
||||||
[selectedRows, lines]
|
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(() => {
|
const shopGroups = useMemo(() => {
|
||||||
@@ -187,25 +353,96 @@ export default function NewLoadingSheetPage() {
|
|||||||
const itemSummary = useMemo(() => {
|
const itemSummary = useMemo(() => {
|
||||||
const map = new Map<string, { itemName: string; qty: number; shops: Set<string> }>()
|
const map = new Map<string, { itemName: string; qty: number; shops: Set<string> }>()
|
||||||
for (const row of selectedRows) {
|
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>() }
|
const e = map.get(row.itemId) ?? { itemName: row.itemName, qty: 0, shops: new Set<string>() }
|
||||||
e.qty += qty
|
e.qty += qty
|
||||||
e.shops.add(row.storeId)
|
e.shops.add(row.storeId)
|
||||||
map.set(row.itemId, e)
|
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))
|
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) {
|
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 } }))
|
setLines((prev) => ({ ...prev, [line.shopPOItemId]: { ...line, checked } }))
|
||||||
}
|
}
|
||||||
|
|
||||||
function setLineQuantity(line: PickedLine, quantity: number) {
|
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() {
|
async function handleSubmit() {
|
||||||
if (!routeId) {
|
if (!routeId && manualItems.length === 0) {
|
||||||
toast.add({ title: 'Select a route first', type: 'error' })
|
toast.add({ title: 'Select a route first', type: 'error' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -213,7 +450,7 @@ export default function NewLoadingSheetPage() {
|
|||||||
toast.add({ title: 'Select a warehouse', type: 'error' })
|
toast.add({ title: 'Select a warehouse', type: 'error' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (selectedRows.length === 0) {
|
if (selectedRows.length === 0 && manualItems.length === 0) {
|
||||||
toast.add({ title: 'Select at least one item', type: 'error' })
|
toast.add({ title: 'Select at least one item', type: 'error' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -226,25 +463,65 @@ export default function NewLoadingSheetPage() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const items: LoadingSheetItemInput[] = selectedRows.map((r) => {
|
const items: LoadingSheetItemInput[] = []
|
||||||
|
for (const r of selectedRows) {
|
||||||
const line = lines[r.shopPOItemId]
|
const line = lines[r.shopPOItemId]
|
||||||
return {
|
const allocations = [{ warehouseId, quantity: line.quantity }, ...line.extras]
|
||||||
fK_ItemId: line.itemId,
|
for (const a of allocations) {
|
||||||
fK_WarehouseId: warehouseId,
|
if (!a.warehouseId || !(a.quantity > 0)) continue
|
||||||
quantity: line.quantity,
|
if (a.quantity > availIn(a.warehouseId, line.itemId)) {
|
||||||
fK_ShopPOItemId: line.shopPOItemId,
|
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)
|
setSubmitting(true)
|
||||||
try {
|
try {
|
||||||
const existing = getPaginatedItems(
|
const existing = sheetId
|
||||||
await loadingSheetsApi.list({ rideId, status: 'Planned', pageSize: 1 })
|
? await loadingSheetsApi.get(sheetId)
|
||||||
)[0]
|
: getPaginatedItems(await loadingSheetsApi.list({ rideId, status: 'Planned', pageSize: 1 }))[0]
|
||||||
const sheet = existing ?? await loadingSheetsApi.create({ fK_RideId: rideId, populatedByUserId })
|
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)
|
await loadingSheetsApi.updateItems(sheet.id, items)
|
||||||
toast.add({
|
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',
|
type: 'success',
|
||||||
})
|
})
|
||||||
router.push('/operations/loading')
|
router.push('/operations/loading')
|
||||||
@@ -263,7 +540,7 @@ export default function NewLoadingSheetPage() {
|
|||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
<div>
|
<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">
|
<p className="text-sm text-muted-foreground">
|
||||||
Select a route first. Confirmed Shop POs for that route's shops will then be
|
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.
|
available to pick items from. Pick the ride to load last.
|
||||||
@@ -358,12 +635,14 @@ export default function NewLoadingSheetPage() {
|
|||||||
) : (
|
) : (
|
||||||
shopGroups.map((group) => {
|
shopGroups.map((group) => {
|
||||||
const groupLines = group.rows.map((r) => lines[r.shopPOItemId] ?? r)
|
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)
|
const someChecked = groupLines.some((l) => l.checked)
|
||||||
return (
|
return (
|
||||||
<div key={group.storeId} className="flex items-center gap-3 border-t px-3 py-2 first:border-t-0">
|
<div key={group.storeId} className="flex items-center gap-3 border-t px-3 py-2 first:border-t-0">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={allChecked}
|
checked={allChecked}
|
||||||
|
disabled={tickable.length === 0}
|
||||||
indeterminate={someChecked && !allChecked}
|
indeterminate={someChecked && !allChecked}
|
||||||
onCheckedChange={(v) => group.rows.forEach((r) => toggleLine(lines[r.shopPOItemId] ?? r, v === true))}
|
onCheckedChange={(v) => group.rows.forEach((r) => toggleLine(lines[r.shopPOItemId] ?? r, v === true))}
|
||||||
/>
|
/>
|
||||||
@@ -386,6 +665,48 @@ export default function NewLoadingSheetPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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('')}>
|
<Dialog open={!!viewGroup} onOpenChange={(v) => !v && setViewStoreId('')}>
|
||||||
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-2xl">
|
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-2xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
@@ -394,6 +715,9 @@ export default function NewLoadingSheetPage() {
|
|||||||
Confirmed PO items. Tick the items to load and set the quantity.
|
Confirmed PO items. Tick the items to load and set the quantity.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</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">
|
<div className="overflow-x-auto rounded-xl border">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="bg-muted/50">
|
<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="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-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">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>
|
<th className="w-32 px-3 py-2 text-right font-medium">Qty to load</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{viewGroup?.rows.map((row) => {
|
{viewGroup?.rows.map((row) => {
|
||||||
const line = lines[row.shopPOItemId] ?? 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 (
|
return (
|
||||||
<tr key={row.shopPOItemId} className="border-t">
|
<Fragment key={row.shopPOItemId}>
|
||||||
|
<tr className="border-t">
|
||||||
<td className="px-3 py-2">
|
<td className="px-3 py-2">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={line.checked}
|
checked={line.checked}
|
||||||
|
disabled={noStock && !line.checked}
|
||||||
onCheckedChange={(v) => toggleLine(row, v === true)}
|
onCheckedChange={(v) => toggleLine(row, v === true)}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2">{row.itemName}</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 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 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">
|
<td className="px-3 py-2">
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={0}
|
||||||
max={row.remainingQty}
|
max={Math.min(row.remainingQty - extrasTotal(line), avail)}
|
||||||
value={line.quantity}
|
value={line.quantity}
|
||||||
disabled={!line.checked}
|
disabled={!line.checked}
|
||||||
onChange={(e) => setLineQuantity(row, Number(e.target.value))}
|
onChange={(e) => setLineQuantity(row, Number(e.target.value))}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</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>
|
</tbody>
|
||||||
@@ -442,13 +825,86 @@ export default function NewLoadingSheetPage() {
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</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">
|
<div className="rounded-2xl border p-6">
|
||||||
<h3 className="mb-4 text-sm font-medium text-foreground">Ride & Assignment</h3>
|
<h3 className="mb-4 text-sm font-medium text-foreground">Ride & Assignment</h3>
|
||||||
<FieldGroup>
|
<FieldGroup>
|
||||||
<div className="grid gap-6 sm:grid-cols-2">
|
<div className="grid gap-6 sm:grid-cols-2">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<label className="text-sm font-medium text-foreground">Ride (vehicle to load) *</label>
|
<label className="text-sm font-medium text-foreground">Ride (vehicle to load) *</label>
|
||||||
<Select value={rideId} onValueChange={(v) => setRideId(v ?? '')}>
|
<Select value={rideId} onValueChange={(v) => setRideId(v ?? '')} disabled={isEdit}>
|
||||||
<SelectTrigger className="w-full">
|
<SelectTrigger className="w-full">
|
||||||
<SelectValue placeholder="Select open ride..." />
|
<SelectValue placeholder="Select open ride..." />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@@ -498,7 +954,7 @@ export default function NewLoadingSheetPage() {
|
|||||||
<span>
|
<span>
|
||||||
{e.itemName}
|
{e.itemName}
|
||||||
<span className="ml-2 text-xs text-muted-foreground">
|
<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>
|
</span>
|
||||||
<span className="font-semibold">{e.qty}</span>
|
<span className="font-semibold">{e.qty}</span>
|
||||||
@@ -519,7 +975,7 @@ export default function NewLoadingSheetPage() {
|
|||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="button" disabled={submitting} onClick={handleSubmit}>
|
<Button type="button" disabled={submitting} onClick={handleSubmit}>
|
||||||
{submitting ? 'Creating…' : 'Create Loading Sheet'}
|
{submitting ? 'Saving…' : isEdit ? 'Save Changes' : 'Create Loading Sheet'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user