mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 02:58:11 +00:00
feat(warehouses): backoffice UI for container stack positions
Surfaces the physical stack/slot model in the staff app. - ZoneLayoutModal: stacks drawn level by level with occupancy colours, configured-vs-built-vs-occupied counts, and stack create/delete plus block/reserve/free on empty levels - SlotPicker in the store and move modals, offering only the next fillable level of each stack so the form cannot suggest a position the API will refuse - move modal warns when a container is buried, lists the blockers, and disables the action instead of firing a 409 - fix: move() now asserts accessibility server-side, matching release — both are exits from a stack
This commit is contained in:
@@ -1875,9 +1875,12 @@ export class BookingWagonCancellationService {
|
||||
// the same cargo); number/seal/VGM come from the override when given.
|
||||
units: sized.map((u, i) => ({
|
||||
containerNumber: replacement?.[i]?.containerNumber ?? u.containerNumber,
|
||||
// A credit snapshot taken before seals were mandatory can carry
|
||||
// none; the booking service normalizes the blank back to null
|
||||
// rather than blocking the rebook of already-paid cargo.
|
||||
sealNumber: replacement
|
||||
? (replacement[i]?.sealNumber ?? undefined)
|
||||
: (u.sealNumber ?? undefined),
|
||||
? (replacement[i]?.sealNumber ?? '')
|
||||
: (u.sealNumber ?? ''),
|
||||
vgmTons: replacement?.[i]?.vgmTons ?? u.vgmTons,
|
||||
isHazardous: u.isHazardous,
|
||||
isReefer: u.isReefer,
|
||||
|
||||
@@ -2250,7 +2250,9 @@ export class ContractBookingService {
|
||||
unitRepo.create({
|
||||
bookingContainerId: containerRow.id,
|
||||
containerNumber: unit.containerNumber,
|
||||
sealNumber: unit.sealNumber ?? null,
|
||||
// Legacy units recovered by the remainder placement can still
|
||||
// arrive sealless — keep those null rather than empty-string.
|
||||
sealNumber: unit.sealNumber?.trim() || null,
|
||||
vgmTons: unit.vgmTons,
|
||||
isHazardous: unit.isHazardous ?? false,
|
||||
isReefer: unit.isReefer ?? false,
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
IsEmail,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsNotEmpty,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
@@ -32,10 +33,12 @@ export class CreateContainerUnitDto {
|
||||
})
|
||||
containerNumber!: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@ApiProperty({ description: 'Seal number — required on every container, import and export alike.' })
|
||||
@IsString()
|
||||
sealNumber?: string;
|
||||
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
||||
@IsNotEmpty({ message: 'sealNumber is required' })
|
||||
@MaxLength(64)
|
||||
sealNumber!: string;
|
||||
|
||||
@ApiProperty({ description: 'VGM in tons', minimum: 0 })
|
||||
@IsNumber()
|
||||
|
||||
@@ -333,7 +333,9 @@ export class RemainderPlacementService {
|
||||
|
||||
return deferred.map((u) => ({
|
||||
containerNumber: u.containerNumber,
|
||||
sealNumber: u.sealNumber ?? undefined,
|
||||
// Deferred units predate the seal requirement; the booking service
|
||||
// normalizes the blank back to null rather than rejecting the re-book.
|
||||
sealNumber: u.sealNumber ?? '',
|
||||
vgmTons: Number(u.vgmTons),
|
||||
isHazardous: u.isHazardous,
|
||||
isReefer: u.isReefer,
|
||||
|
||||
@@ -3140,6 +3140,13 @@ export class WarehouseInventoryService {
|
||||
|
||||
const { warehouse, yard, zone } = await this.validateLocation(manager, dto);
|
||||
|
||||
// Taking a box out of a stack is physically impossible while others stand
|
||||
// on top of it — the same rule the release path enforces. Moving is one of
|
||||
// those exits, so it is checked here rather than only at release.
|
||||
if (item.slotId) {
|
||||
await this.placement.assertAccessible(item.id, manager);
|
||||
}
|
||||
|
||||
// A move that names a slot is validated against the hierarchy it claims;
|
||||
// one that does not clears the old slot, because the box has left it.
|
||||
if (dto.slotId) {
|
||||
|
||||
@@ -118,6 +118,7 @@ const blockNegative = (event: KeyboardEvent<HTMLInputElement>) => {
|
||||
|
||||
interface UnitErrors {
|
||||
containerNumber?: string;
|
||||
sealNumber?: string;
|
||||
vgmTons?: string;
|
||||
}
|
||||
|
||||
@@ -886,6 +887,9 @@ export default function GlCreateBookingForm() {
|
||||
} else if ((numberCounts.get(key) ?? 0) > 1) {
|
||||
errs.containerNumber = "Duplicate container number in this shipment.";
|
||||
}
|
||||
if (u.sealNumber.trim() === "") {
|
||||
errs.sealNumber = "Seal number is required.";
|
||||
}
|
||||
const vgm = Number(u.vgmTons);
|
||||
if (u.vgmTons.trim() === "" || Number.isNaN(vgm) || vgm <= 0) {
|
||||
errs.vgmTons = "Enter a valid VGM.";
|
||||
@@ -1185,7 +1189,7 @@ export default function GlCreateBookingForm() {
|
||||
: {}),
|
||||
units: l.units.map((u) => ({
|
||||
containerNumber: u.containerNumber.trim().toUpperCase(),
|
||||
...(u.sealNumber ? { sealNumber: u.sealNumber } : {}),
|
||||
sealNumber: u.sealNumber.trim(),
|
||||
vgmTons: Number(u.vgmTons) || 0,
|
||||
// Per-container handling — the server rolls these into the line
|
||||
// counts and bills each surcharge on the ticked containers only.
|
||||
@@ -1247,7 +1251,7 @@ export default function GlCreateBookingForm() {
|
||||
reeferQuantity: Number(l.reeferQuantity || 0) || undefined,
|
||||
units: l.units.map((u) => ({
|
||||
containerNumber: u.containerNumber.trim().toUpperCase(),
|
||||
...(u.sealNumber ? { sealNumber: u.sealNumber } : {}),
|
||||
sealNumber: u.sealNumber.trim(),
|
||||
vgmTons: Number(u.vgmTons) || 0,
|
||||
isHazardous: Boolean(u.isHazardous),
|
||||
isReefer: Boolean(u.isReefer),
|
||||
@@ -1857,7 +1861,7 @@ export default function GlCreateBookingForm() {
|
||||
Container number *
|
||||
</Text>
|
||||
<Text fz={12} fw={600} c="#10202F" style={{ flex: 1 }}>
|
||||
Seal number
|
||||
Seal number *
|
||||
</Text>
|
||||
<Text fz={12} fw={600} c="#10202F" style={{ flex: 1 }}>
|
||||
VGM (tons) *
|
||||
@@ -1901,8 +1905,13 @@ export default function GlCreateBookingForm() {
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<TextInput
|
||||
placeholder="Optional"
|
||||
placeholder="e.g. SL-0099231"
|
||||
value={unit.sealNumber}
|
||||
error={
|
||||
showErrors
|
||||
? unitErrors[lineIdx]?.[unitIdx]?.sealNumber
|
||||
: undefined
|
||||
}
|
||||
onChange={(e) =>
|
||||
patchUnit(lineIdx, unitIdx, {
|
||||
sealNumber: e.currentTarget.value,
|
||||
|
||||
@@ -151,6 +151,11 @@ export async function parseContainerExcel(
|
||||
numberCounts.set(containerNumber, (numberCounts.get(containerNumber) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const sealNumber = cell("sealNumber");
|
||||
if (!sealNumber) {
|
||||
errors.push(`Row ${rowNo}: seal number is required.`);
|
||||
}
|
||||
|
||||
const vgmRaw = cell("vgmTons");
|
||||
const vgm = Number(vgmRaw);
|
||||
if (!vgmRaw || Number.isNaN(vgm) || vgm <= 0) {
|
||||
@@ -160,7 +165,7 @@ export async function parseContainerExcel(
|
||||
rows.push({
|
||||
containerSize: size ?? "",
|
||||
containerNumber,
|
||||
sealNumber: cell("sealNumber"),
|
||||
sealNumber,
|
||||
vgmTons: vgmRaw,
|
||||
hazardous: opts.includeHazardous && parseFlag(cell("hazardous")),
|
||||
reefer: opts.includeReefer && parseFlag(cell("reefer")),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Button, Group, Modal, Select, Stack, Textarea } from '@mantine/core';
|
||||
import { Alert, Button, Group, List, Modal, Select, Stack, Textarea, Text } from '@mantine/core';
|
||||
import { Layers } from 'lucide-react';
|
||||
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
|
||||
@@ -7,6 +8,7 @@ import { api } from '@/services/api';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import type { WarehouseInventoryItem } from '@/types/warehouse';
|
||||
import { extractErrorMessage } from './options';
|
||||
import { SlotPicker } from './SlotPicker';
|
||||
|
||||
interface MoveInventoryModalProps {
|
||||
opened: boolean;
|
||||
@@ -20,6 +22,7 @@ export function MoveInventoryModal({ opened, onClose, item }: MoveInventoryModal
|
||||
const [warehouseId, setWarehouseId] = useState('');
|
||||
const [yardId, setYardId] = useState('');
|
||||
const [zoneId, setZoneId] = useState('');
|
||||
const [slotId, setSlotId] = useState('');
|
||||
const [remarks, setRemarks] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
@@ -27,10 +30,22 @@ export function MoveInventoryModal({ opened, onClose, item }: MoveInventoryModal
|
||||
setWarehouseId('');
|
||||
setYardId('');
|
||||
setZoneId('');
|
||||
setSlotId('');
|
||||
setRemarks('');
|
||||
}
|
||||
}, [opened]);
|
||||
|
||||
// A container with boxes stacked on top of it cannot be lifted out — the API
|
||||
// refuses the move, so the button says why instead of firing a 409.
|
||||
const accessibilityQuery = useQuery(
|
||||
api.warehouses.containerAccessibility.queryOptions({
|
||||
input: { id: item?.id ?? '' },
|
||||
enabled: opened && Boolean(item?.id),
|
||||
}),
|
||||
);
|
||||
const accessibility = accessibilityQuery.data;
|
||||
const blocked = accessibility ? !accessibility.accessible : false;
|
||||
|
||||
const warehousesQuery = useQuery(
|
||||
api.warehouses.list.queryOptions({ input: { filter: { status: 'ACTIVE' } } }),
|
||||
);
|
||||
@@ -69,7 +84,13 @@ export function MoveInventoryModal({ opened, onClose, item }: MoveInventoryModal
|
||||
try {
|
||||
await moveMutation.mutateAsync({
|
||||
id: item.id,
|
||||
payload: { warehouseId, yardId, zoneId, remarks: remarks.trim() || undefined },
|
||||
payload: {
|
||||
warehouseId,
|
||||
yardId,
|
||||
zoneId,
|
||||
slotId: slotId || undefined,
|
||||
remarks: remarks.trim() || undefined,
|
||||
},
|
||||
});
|
||||
toast({ title: 'Inventory moved' });
|
||||
onClose();
|
||||
@@ -81,6 +102,22 @@ export function MoveInventoryModal({ opened, onClose, item }: MoveInventoryModal
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title="Move inventory" centered size="lg">
|
||||
<Stack gap="md">
|
||||
{blocked && accessibility ? (
|
||||
<Alert icon={<Layers size={16} />} color="orange" variant="light" title="Container is buried">
|
||||
<Text size="sm">
|
||||
It sits at {accessibility.stackCode} level {accessibility.level} with{' '}
|
||||
{accessibility.blockingContainers.length} container(s) stacked on top. Move these out
|
||||
first:
|
||||
</Text>
|
||||
<List size="sm" mt={4}>
|
||||
{accessibility.blockingContainers.map((b) => (
|
||||
<List.Item key={b.inventoryId}>
|
||||
{b.containerNumber ?? 'Container'} — level {b.level}
|
||||
</List.Item>
|
||||
))}
|
||||
</List>
|
||||
</Alert>
|
||||
) : null}
|
||||
<Select
|
||||
label="Destination warehouse"
|
||||
placeholder="Select warehouse"
|
||||
@@ -115,8 +152,13 @@ export function MoveInventoryModal({ opened, onClose, item }: MoveInventoryModal
|
||||
disabled={!yardId}
|
||||
data={zoneOptions}
|
||||
value={zoneId || null}
|
||||
onChange={(v) => setZoneId(v ?? '')}
|
||||
onChange={(v) => {
|
||||
setZoneId(v ?? '');
|
||||
setSlotId('');
|
||||
}}
|
||||
/>
|
||||
{/* Container yards only — the picker hides itself where no stacks exist. */}
|
||||
<SlotPicker zoneId={zoneId} value={slotId} onChange={setSlotId} label="Destination stack position" />
|
||||
<Textarea
|
||||
label="Remarks"
|
||||
placeholder="Reason for the move"
|
||||
@@ -129,7 +171,12 @@ export function MoveInventoryModal({ opened, onClose, item }: MoveInventoryModal
|
||||
<Button variant="default" onClick={onClose} disabled={moveMutation.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} loading={moveMutation.isPending}>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
loading={moveMutation.isPending}
|
||||
disabled={blocked}
|
||||
title={blocked ? 'Containers stacked above this one must be moved first' : undefined}
|
||||
>
|
||||
Move inventory
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Select, Text } from '@mantine/core';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { api } from '@/services/api';
|
||||
import type { ZoneLayout } from '@/types/warehouse';
|
||||
|
||||
interface SlotPickerProps {
|
||||
zoneId: string;
|
||||
value: string;
|
||||
onChange: (slotId: string) => void;
|
||||
label?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The stack levels a container may actually be put on right now.
|
||||
*
|
||||
* Only the next fillable level of each stack is offered: a box cannot stand on
|
||||
* level 2 while level 1 is empty, so listing level 3 of an empty stack would
|
||||
* only produce a rejected request. The server enforces the same rule — this
|
||||
* mirrors it so the operator never sees a 400 for a position the form offered.
|
||||
*/
|
||||
export function fillableLevels(layout: ZoneLayout | undefined) {
|
||||
if (!layout) return [];
|
||||
|
||||
return layout.stacks
|
||||
.filter((stack) => stack.isActive && stack.status === 'ACTIVE')
|
||||
.flatMap((stack) => {
|
||||
const occupied = stack.slots
|
||||
.filter((slot) => slot.effectiveStatus === 'OCCUPIED')
|
||||
.map((slot) => slot.level);
|
||||
const top = occupied.length > 0 ? Math.max(...occupied) : 0;
|
||||
if (top >= stack.maxStackHeight) return [];
|
||||
|
||||
const next = stack.slots.find(
|
||||
(slot) => slot.level === top + 1 && slot.effectiveStatus === 'AVAILABLE',
|
||||
);
|
||||
if (!next) return [];
|
||||
|
||||
return [
|
||||
{
|
||||
value: next.slotId,
|
||||
label: `${stack.code} — level ${next.level}${top > 0 ? ` (on ${top} container${top > 1 ? 's' : ''})` : ' (ground)'}`,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
export function SlotPicker({ zoneId, value, onChange, label = 'Stack position', disabled }: SlotPickerProps) {
|
||||
const { data, isLoading } = useQuery(
|
||||
api.warehouses.zoneLayout.queryOptions({
|
||||
input: { zoneId },
|
||||
enabled: Boolean(zoneId),
|
||||
}),
|
||||
);
|
||||
|
||||
const options = useMemo(() => fillableLevels(data), [data]);
|
||||
|
||||
// A zone with no stacks configured keeps plain zone-level placement — showing
|
||||
// an empty picker there would imply a choice that does not exist.
|
||||
if (!zoneId || (!isLoading && (data?.stacks.length ?? 0) === 0)) return null;
|
||||
|
||||
return (
|
||||
<Select
|
||||
label={label}
|
||||
description={
|
||||
options.length === 0 && !isLoading ? (
|
||||
<Text size="xs" c="orange">
|
||||
Every stack in this zone is full or blocked — the item will be stored at zone level.
|
||||
</Text>
|
||||
) : (
|
||||
'Leave blank to take the lowest free level automatically.'
|
||||
)
|
||||
}
|
||||
placeholder={isLoading ? 'Loading positions…' : 'Automatic (lowest free level)'}
|
||||
searchable
|
||||
clearable
|
||||
disabled={disabled || isLoading || options.length === 0}
|
||||
data={options}
|
||||
value={value || null}
|
||||
onChange={(v) => onChange(v ?? '')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default SlotPicker;
|
||||
@@ -8,6 +8,7 @@ import { api } from '@/services/api';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import type { WarehouseInventoryItem } from '@/types/warehouse';
|
||||
import { extractErrorMessage } from './options';
|
||||
import { SlotPicker } from './SlotPicker';
|
||||
|
||||
interface StoreInventoryModalProps {
|
||||
opened: boolean;
|
||||
@@ -25,12 +26,14 @@ export function StoreInventoryModal({ opened, onClose, item }: StoreInventoryMod
|
||||
const [warehouseId, setWarehouseId] = useState('');
|
||||
const [yardId, setYardId] = useState('');
|
||||
const [zoneId, setZoneId] = useState('');
|
||||
const [slotId, setSlotId] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (opened) {
|
||||
setWarehouseId('');
|
||||
setYardId('');
|
||||
setZoneId('');
|
||||
setSlotId('');
|
||||
}
|
||||
}, [opened]);
|
||||
|
||||
@@ -75,7 +78,7 @@ export function StoreInventoryModal({ opened, onClose, item }: StoreInventoryMod
|
||||
try {
|
||||
await storeMutation.mutateAsync({
|
||||
id: item.id,
|
||||
payload: manualComplete ? { warehouseId, yardId, zoneId } : undefined,
|
||||
payload: manualComplete ? { warehouseId, yardId, zoneId, slotId: slotId || undefined } : undefined,
|
||||
});
|
||||
toast({ title: manualComplete ? 'Inventory stored at selected location' : 'Inventory stored (auto-allocated)' });
|
||||
onClose();
|
||||
@@ -127,8 +130,13 @@ export function StoreInventoryModal({ opened, onClose, item }: StoreInventoryMod
|
||||
disabled={!yardId}
|
||||
data={zoneOptions}
|
||||
value={zoneId || null}
|
||||
onChange={(v) => setZoneId(v ?? '')}
|
||||
onChange={(v) => {
|
||||
setZoneId(v ?? '');
|
||||
setSlotId('');
|
||||
}}
|
||||
/>
|
||||
{/* Container yards only — the picker hides itself where no stacks exist. */}
|
||||
<SlotPicker zoneId={zoneId} value={slotId} onChange={setSlotId} />
|
||||
<Group justify="flex-end" mt="sm">
|
||||
<Button variant="default" onClick={onClose} disabled={storeMutation.isPending}>
|
||||
Cancel
|
||||
|
||||
@@ -0,0 +1,407 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Loader,
|
||||
Menu,
|
||||
Modal,
|
||||
NumberInput,
|
||||
Paper,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { Ban, CircleSlash, Layers, MoreVertical, Plus, Trash2, Unlock } from 'lucide-react';
|
||||
|
||||
import { useAuth } from '@/auth/useAuth';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { FREIGHT_PERMS, hasPermission } from '@/lib/permissions';
|
||||
import { api } from '@/services/api';
|
||||
import type { SlotEffectiveStatus, ZoneLayoutSlot, ZoneLayoutStack } from '@/types/warehouse';
|
||||
import { extractErrorMessage } from './options';
|
||||
import type { ZoneRef } from './ZoneContentsModal';
|
||||
|
||||
interface ZoneLayoutModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
zone: ZoneRef | null;
|
||||
}
|
||||
|
||||
/** One colour per slot state, used by both the cell and the legend. */
|
||||
const SLOT_TONE: Record<SlotEffectiveStatus, { color: string; label: string }> = {
|
||||
OCCUPIED: { color: 'blue', label: 'Occupied' },
|
||||
AVAILABLE: { color: 'teal', label: 'Free' },
|
||||
RESERVED: { color: 'orange', label: 'Reserved' },
|
||||
BLOCKED: { color: 'red', label: 'Blocked' },
|
||||
INACTIVE: { color: 'gray', label: 'Inactive' },
|
||||
};
|
||||
|
||||
/**
|
||||
* A container stack seen from the side: level 3 on top, level 1 on the ground —
|
||||
* the order the API already returns and the order the yard actually looks.
|
||||
*/
|
||||
function SlotCell({
|
||||
slot,
|
||||
onSetStatus,
|
||||
canEdit,
|
||||
}: {
|
||||
slot: ZoneLayoutSlot;
|
||||
canEdit: boolean;
|
||||
onSetStatus: (slot: ZoneLayoutSlot, status: 'AVAILABLE' | 'BLOCKED' | 'RESERVED') => void;
|
||||
}) {
|
||||
const tone = SLOT_TONE[slot.effectiveStatus];
|
||||
const occupied = slot.effectiveStatus === 'OCCUPIED';
|
||||
|
||||
return (
|
||||
<Paper
|
||||
withBorder
|
||||
radius="sm"
|
||||
px="xs"
|
||||
py={6}
|
||||
style={{ borderLeft: `4px solid var(--mantine-color-${tone.color}-6)` }}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap" gap="xs">
|
||||
<Group gap={6} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<Text size="xs" c="dimmed" fw={600} w={20}>
|
||||
L{slot.level}
|
||||
</Text>
|
||||
<Text size="sm" truncate title={slot.containerNumber ?? tone.label}>
|
||||
{occupied ? (slot.containerNumber ?? 'Container') : tone.label}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{/* An occupied level has no status to set — empty it by moving the box. */}
|
||||
{canEdit && !occupied ? (
|
||||
<Menu position="bottom-end" withinPortal>
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="subtle" size="sm" aria-label={`Level ${slot.level} actions`}>
|
||||
<MoreVertical size={14} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={<Unlock size={14} />}
|
||||
disabled={slot.effectiveStatus === 'AVAILABLE'}
|
||||
onClick={() => onSetStatus(slot, 'AVAILABLE')}
|
||||
>
|
||||
Mark free
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<CircleSlash size={14} />}
|
||||
disabled={slot.effectiveStatus === 'RESERVED'}
|
||||
onClick={() => onSetStatus(slot, 'RESERVED')}
|
||||
>
|
||||
Reserve
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<Ban size={14} />}
|
||||
color="red"
|
||||
disabled={slot.effectiveStatus === 'BLOCKED'}
|
||||
onClick={() => onSetStatus(slot, 'BLOCKED')}
|
||||
>
|
||||
Block
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
) : null}
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
function StackCard({
|
||||
stack,
|
||||
canEdit,
|
||||
canDelete,
|
||||
onSetSlotStatus,
|
||||
onDelete,
|
||||
}: {
|
||||
stack: ZoneLayoutStack;
|
||||
canEdit: boolean;
|
||||
canDelete: boolean;
|
||||
onSetSlotStatus: (slot: ZoneLayoutSlot, status: 'AVAILABLE' | 'BLOCKED' | 'RESERVED') => void;
|
||||
onDelete: (stack: ZoneLayoutStack) => void;
|
||||
}) {
|
||||
const filled = stack.slots.filter((s) => s.effectiveStatus === 'OCCUPIED').length;
|
||||
|
||||
return (
|
||||
<Card withBorder radius="md" padding="sm">
|
||||
<Stack gap={8}>
|
||||
<Group justify="space-between" wrap="nowrap" gap="xs">
|
||||
<Group gap={6} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<Text fw={600} size="sm" truncate title={stack.name ?? stack.code}>
|
||||
{stack.code}
|
||||
</Text>
|
||||
{stack.status !== 'ACTIVE' || !stack.isActive ? (
|
||||
<Badge size="xs" color="gray" variant="light">
|
||||
Inactive
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Badge size="sm" variant="light" color={filled === stack.maxStackHeight ? 'blue' : 'gray'}>
|
||||
{filled}/{stack.maxStackHeight}
|
||||
</Badge>
|
||||
{canDelete ? (
|
||||
<Tooltip label="Delete stack">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
size="sm"
|
||||
onClick={() => onDelete(stack)}
|
||||
aria-label={`Delete stack ${stack.code}`}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Stack gap={4}>
|
||||
{stack.slots.map((slot) => (
|
||||
<SlotCell key={slot.slotId} slot={slot} canEdit={canEdit} onSetStatus={onSetSlotStatus} />
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Physical layout of one zone — every ground stack with its levels, what stands
|
||||
* on each, and the capacity numbers that are routinely confused (configured vs
|
||||
* built vs full). Stacks are created and retired from here, since there is
|
||||
* nowhere else the yard layout is visible.
|
||||
*/
|
||||
export function ZoneLayoutModal({ opened, onClose, zone }: ZoneLayoutModalProps) {
|
||||
const { toast } = useToast();
|
||||
const { user } = useAuth();
|
||||
const canCreate = hasPermission(user, FREIGHT_PERMS.warehouseZones.create);
|
||||
const canEdit = hasPermission(user, FREIGHT_PERMS.warehouseZones.update);
|
||||
const canDelete = hasPermission(user, FREIGHT_PERMS.warehouseZones.delete);
|
||||
|
||||
const zoneId = zone?.id ?? '';
|
||||
const { data, isLoading, isError, refetch } = useQuery(
|
||||
api.warehouses.zoneLayout.queryOptions({
|
||||
input: { zoneId },
|
||||
enabled: opened && Boolean(zoneId),
|
||||
}),
|
||||
);
|
||||
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [code, setCode] = useState('');
|
||||
const [height, setHeight] = useState<number>(3);
|
||||
|
||||
const createStack = useMutation(api.warehouses.createStack.mutationOptions());
|
||||
const deleteStack = useMutation(api.warehouses.deleteStack.mutationOptions());
|
||||
const updateSlot = useMutation(api.warehouses.updateSlot.mutationOptions());
|
||||
|
||||
const stacks = data?.stacks ?? [];
|
||||
const summary = data?.summary;
|
||||
|
||||
const nextCode = useMemo(() => {
|
||||
// Suggest the next number in the zone's own series (ZA-001 → ZA-002) so
|
||||
// codes stay sortable, which is the order the placement engine walks.
|
||||
const numbered = stacks
|
||||
.map((s) => /^(.*?)(\d+)$/.exec(s.code))
|
||||
.filter((m): m is RegExpExecArray => Boolean(m));
|
||||
if (numbered.length === 0) return '';
|
||||
const last = numbered[numbered.length - 1];
|
||||
const width = last[2].length;
|
||||
const next = Math.max(...numbered.map((m) => Number(m[2]))) + 1;
|
||||
return `${last[1]}${String(next).padStart(width, '0')}`;
|
||||
}, [stacks]);
|
||||
|
||||
const submitStack = () => {
|
||||
const trimmed = code.trim();
|
||||
if (!trimmed) {
|
||||
toast({ variant: 'destructive', title: 'Stack code is required' });
|
||||
return;
|
||||
}
|
||||
createStack.mutate(
|
||||
{ zoneId, payload: { code: trimmed, maxStackHeight: height } },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast({ title: `Stack ${trimmed} created with ${height} level(s)` });
|
||||
setCode('');
|
||||
setCreating(false);
|
||||
},
|
||||
onError: (error) =>
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Could not create the stack',
|
||||
description: extractErrorMessage(error),
|
||||
}),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const removeStack = (stack: ZoneLayoutStack) => {
|
||||
if (!window.confirm(`Delete stack ${stack.code}? It must be empty first.`)) return;
|
||||
deleteStack.mutate(
|
||||
{ id: stack.stackId, zoneId },
|
||||
{
|
||||
onSuccess: () => toast({ title: `Stack ${stack.code} deleted` }),
|
||||
onError: (error) =>
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Could not delete the stack',
|
||||
description: extractErrorMessage(error),
|
||||
}),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const setSlotStatus = (slot: ZoneLayoutSlot, status: 'AVAILABLE' | 'BLOCKED' | 'RESERVED') => {
|
||||
updateSlot.mutate(
|
||||
{ slotId: slot.slotId, zoneId, payload: { status, isActive: true } },
|
||||
{
|
||||
onSuccess: () => toast({ title: `Level ${slot.level} set to ${SLOT_TONE[status].label}` }),
|
||||
onError: (error) =>
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Could not update the level',
|
||||
description: extractErrorMessage(error),
|
||||
}),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
size="xl"
|
||||
title={
|
||||
<Group gap="xs">
|
||||
<Layers size={18} />
|
||||
<Text fw={700}>{zone ? `${zone.name} (${zone.code}) layout` : 'Zone layout'}</Text>
|
||||
</Group>
|
||||
}
|
||||
>
|
||||
<Stack gap="md">
|
||||
{summary ? (
|
||||
<Card withBorder radius="md" padding="sm">
|
||||
<Group gap="lg" wrap="wrap">
|
||||
<Stat label="Configured capacity" value={summary.configuredCapacity ?? '—'} />
|
||||
<Stat label="Slots built" value={summary.physicalSlotCount} />
|
||||
<Stat label="Occupied" value={summary.occupiedSlotCount} color="blue" />
|
||||
<Stat label="Free" value={summary.availableSlotCount} color="teal" />
|
||||
<Stat label="Reserved" value={summary.reservedSlotCount} color="orange" />
|
||||
<Stat label="Blocked" value={summary.blockedSlotCount} color="red" />
|
||||
</Group>
|
||||
{summary.inconsistent ? (
|
||||
<Text size="xs" c="red" mt={6}>
|
||||
{summary.physicalSlotCount} slots are built but the zone is configured for{' '}
|
||||
{summary.configuredCapacity}. Raise the zone capacity or remove stacks — the
|
||||
configured figure was left as it is.
|
||||
</Text>
|
||||
) : null}
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<Group justify="space-between">
|
||||
<Group gap="xs">
|
||||
{(Object.keys(SLOT_TONE) as SlotEffectiveStatus[]).map((key) => (
|
||||
<Badge key={key} size="xs" variant="light" color={SLOT_TONE[key].color}>
|
||||
{SLOT_TONE[key].label}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
{canCreate ? (
|
||||
<Button
|
||||
size="xs"
|
||||
leftSection={<Plus size={14} />}
|
||||
variant={creating ? 'default' : 'filled'}
|
||||
onClick={() => {
|
||||
setCreating((open) => !open);
|
||||
if (!creating && !code) setCode(nextCode);
|
||||
}}
|
||||
>
|
||||
{creating ? 'Cancel' : 'Add stack'}
|
||||
</Button>
|
||||
) : null}
|
||||
</Group>
|
||||
|
||||
{creating ? (
|
||||
<Card withBorder radius="md" padding="sm">
|
||||
<Group align="flex-end" gap="sm">
|
||||
<TextInput
|
||||
label="Stack code"
|
||||
placeholder="ZA-001"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.currentTarget.value)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Levels"
|
||||
description="One slot is created per level"
|
||||
min={1}
|
||||
max={10}
|
||||
value={height}
|
||||
onChange={(v) => setHeight(Number(v) || 1)}
|
||||
w={140}
|
||||
/>
|
||||
<Button onClick={submitStack} loading={createStack.isPending}>
|
||||
Create
|
||||
</Button>
|
||||
</Group>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{isLoading ? (
|
||||
<Group justify="center" py="xl">
|
||||
<Loader />
|
||||
</Group>
|
||||
) : isError ? (
|
||||
<Stack align="center" py="xl" gap="xs">
|
||||
<Text c="red">Failed to load the zone layout.</Text>
|
||||
<Button variant="default" size="xs" onClick={() => void refetch()}>
|
||||
Retry
|
||||
</Button>
|
||||
</Stack>
|
||||
) : stacks.length === 0 ? (
|
||||
<Text c="dimmed" ta="center" py="xl" size="sm">
|
||||
No ground stacks configured in this zone yet. Containers stored here keep zone-level
|
||||
placement until stacks exist.
|
||||
</Text>
|
||||
) : (
|
||||
<SimpleGrid cols={{ base: 1, xs: 2, sm: 3, lg: 4 }} spacing="sm">
|
||||
{stacks.map((stack) => (
|
||||
<StackCard
|
||||
key={stack.stackId}
|
||||
stack={stack}
|
||||
canEdit={canEdit}
|
||||
canDelete={canDelete}
|
||||
onSetSlotStatus={setSlotStatus}
|
||||
onDelete={removeStack}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value, color }: { label: string; value: number | string; color?: string }) {
|
||||
return (
|
||||
<Stack gap={0}>
|
||||
<Text size="xs" c="dimmed">
|
||||
{label}
|
||||
</Text>
|
||||
<Text fw={700} c={color}>
|
||||
{value}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export default ZoneLayoutModal;
|
||||
@@ -10,6 +10,8 @@ export { CreateWarehouseModal } from './CreateWarehouseModal';
|
||||
export { CreateYardModal } from './CreateYardModal';
|
||||
export { CreateZoneModal } from './CreateZoneModal';
|
||||
export { ZoneContentsModal, type ZoneRef } from './ZoneContentsModal';
|
||||
export { ZoneLayoutModal } from './ZoneLayoutModal';
|
||||
export { SlotPicker } from './SlotPicker';
|
||||
export { ReceiveInventoryModal, WarehouseFlowWorkbench } from './ReceiveInventoryModal';
|
||||
export { WarehouseInfoCard } from './WarehouseInfoCard';
|
||||
export { MoveInventoryModal } from './MoveInventoryModal';
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { fillableLevels } from './SlotPicker';
|
||||
import type { SlotEffectiveStatus, ZoneLayout } from '@/types/warehouse';
|
||||
|
||||
/**
|
||||
* The picker must offer only positions the API will accept, or the operator
|
||||
* gets a 400 for a level the form itself suggested.
|
||||
*/
|
||||
function layout(
|
||||
stacks: Array<{
|
||||
code: string;
|
||||
height?: number;
|
||||
isActive?: boolean;
|
||||
levels: SlotEffectiveStatus[];
|
||||
}>,
|
||||
): ZoneLayout {
|
||||
return {
|
||||
zoneId: 'zone-1',
|
||||
zoneCode: 'L1-O-A-ZA',
|
||||
zoneName: 'Zone A',
|
||||
summary: {
|
||||
configuredCapacity: 60,
|
||||
physicalSlotCount: 60,
|
||||
occupiedSlotCount: 0,
|
||||
reservedSlotCount: 0,
|
||||
blockedSlotCount: 0,
|
||||
inactiveSlotCount: 0,
|
||||
availableSlotCount: 60,
|
||||
inconsistent: false,
|
||||
},
|
||||
stacks: stacks.map((stack) => ({
|
||||
stackId: `id-${stack.code}`,
|
||||
code: stack.code,
|
||||
name: null,
|
||||
maxStackHeight: stack.height ?? 3,
|
||||
status: stack.isActive === false ? 'INACTIVE' : 'ACTIVE',
|
||||
isActive: stack.isActive !== false,
|
||||
// The API returns the highest level first — mirror that here.
|
||||
slots: stack.levels
|
||||
.map((effectiveStatus, index) => ({
|
||||
slotId: `${stack.code}-L${index + 1}`,
|
||||
level: index + 1,
|
||||
effectiveStatus,
|
||||
inventoryId: effectiveStatus === 'OCCUPIED' ? `inv-${stack.code}-${index + 1}` : null,
|
||||
containerNumber: null,
|
||||
}))
|
||||
.reverse(),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
describe('SlotPicker.fillableLevels', () => {
|
||||
it('offers the ground level of an empty stack', () => {
|
||||
const options = fillableLevels(layout([{ code: 'ZA-001', levels: ['AVAILABLE', 'AVAILABLE', 'AVAILABLE'] }]));
|
||||
expect(options).toEqual([{ value: 'ZA-001-L1', label: 'ZA-001 — level 1 (ground)' }]);
|
||||
});
|
||||
|
||||
it('offers only the level directly above the top container', () => {
|
||||
const options = fillableLevels(layout([{ code: 'ZA-001', levels: ['OCCUPIED', 'AVAILABLE', 'AVAILABLE'] }]));
|
||||
expect(options).toEqual([{ value: 'ZA-001-L2', label: 'ZA-001 — level 2 (on 1 container)' }]);
|
||||
});
|
||||
|
||||
it('never offers a level that would float over an empty one', () => {
|
||||
const options = fillableLevels(layout([{ code: 'ZA-001', levels: ['OCCUPIED', 'OCCUPIED', 'AVAILABLE'] }]));
|
||||
expect(options.map((o) => o.value)).toEqual(['ZA-001-L3']);
|
||||
});
|
||||
|
||||
it('drops a full stack', () => {
|
||||
expect(fillableLevels(layout([{ code: 'ZA-001', levels: ['OCCUPIED', 'OCCUPIED', 'OCCUPIED'] }]))).toEqual([]);
|
||||
});
|
||||
|
||||
it('drops a stack whose next level is blocked or reserved', () => {
|
||||
expect(fillableLevels(layout([{ code: 'ZA-001', levels: ['BLOCKED', 'AVAILABLE', 'AVAILABLE'] }]))).toEqual([]);
|
||||
expect(fillableLevels(layout([{ code: 'ZA-002', levels: ['RESERVED', 'AVAILABLE', 'AVAILABLE'] }]))).toEqual([]);
|
||||
});
|
||||
|
||||
it('drops an inactive stack', () => {
|
||||
expect(
|
||||
fillableLevels(layout([{ code: 'ZA-001', isActive: false, levels: ['AVAILABLE', 'AVAILABLE', 'AVAILABLE'] }])),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('lists one position per stack across the zone', () => {
|
||||
const options = fillableLevels(
|
||||
layout([
|
||||
{ code: 'ZA-001', levels: ['OCCUPIED', 'AVAILABLE', 'AVAILABLE'] },
|
||||
{ code: 'ZA-002', levels: ['AVAILABLE', 'AVAILABLE', 'AVAILABLE'] },
|
||||
]),
|
||||
);
|
||||
expect(options.map((o) => o.value)).toEqual(['ZA-001-L2', 'ZA-002-L1']);
|
||||
});
|
||||
|
||||
it('returns nothing before the layout has loaded', () => {
|
||||
expect(fillableLevels(undefined)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -652,6 +652,16 @@ export const URL_CONSTANTS = {
|
||||
WAREHOUSE_ZONES: {
|
||||
BASE: "/warehouse-zones",
|
||||
BY_ID: (id: string) => `/warehouse-zones/${id}`,
|
||||
LAYOUT: (id: string) => `/warehouse-zones/${id}/layout`,
|
||||
SLOT_SUMMARY: (id: string) => `/warehouse-zones/${id}/slot-summary`,
|
||||
},
|
||||
|
||||
WAREHOUSE_ZONE_STACKS: {
|
||||
BASE: "/warehouse-zone-stacks",
|
||||
BY_ZONE: (zoneId: string) => `/warehouse-zone-stacks?zoneId=${zoneId}`,
|
||||
BY_ID: (id: string) => `/warehouse-zone-stacks/${id}`,
|
||||
OCCUPANCY: (id: string) => `/warehouse-zone-stacks/${id}/occupancy`,
|
||||
SLOT: (slotId: string) => `/warehouse-zone-stacks/slots/${slotId}`,
|
||||
},
|
||||
|
||||
WAREHOUSE_INVENTORY: {
|
||||
@@ -666,6 +676,10 @@ export const URL_CONSTANTS = {
|
||||
LOAD: (id: string) => `/warehouse-inventory/${id}/load`,
|
||||
DISPATCH: (id: string) => `/warehouse-inventory/${id}/dispatch`,
|
||||
MOVE: (id: string) => `/warehouse-inventory/${id}/move`,
|
||||
FIND_SLOT: "/warehouse-inventory/placement/find-slot",
|
||||
ASSIGN_SLOT: (id: string) => `/warehouse-inventory/${id}/assign-slot`,
|
||||
RELEASE_SLOT: (id: string) => `/warehouse-inventory/${id}/release-slot`,
|
||||
ACCESSIBILITY: (id: string) => `/warehouse-inventory/${id}/accessibility`,
|
||||
RESERVE: "/warehouse-inventory/reserve",
|
||||
ARRIVAL_QUEUE: "/warehouse-inventory/arrival-queue",
|
||||
OPS_STATS: "/warehouse-inventory/ops-stats",
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
Tabs,
|
||||
Text,
|
||||
} from '@mantine/core';
|
||||
import { ArrowLeft, Boxes, LayoutGrid, Package, Pencil, Plus, Trash2 } from 'lucide-react';
|
||||
import { ArrowLeft, Boxes, Layers, LayoutGrid, Package, Pencil, Plus, Trash2 } from 'lucide-react';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { DataTable, type ColumnDef } from '@edr/ui-common';
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
WarehouseTypeBadge,
|
||||
ZoneContentsModal,
|
||||
type ZoneRef,
|
||||
ZoneLayoutModal,
|
||||
ZoneOccupancyHeatmap,
|
||||
formatCapacity,
|
||||
humanizeEnum,
|
||||
@@ -63,6 +64,7 @@ export default function WarehouseDetailPage() {
|
||||
const [editingZone, setEditingZone] = useState<WarehouseZone | null>(null);
|
||||
const [selectedYardId, setSelectedYardId] = useState<string | null>(null);
|
||||
const [contentsZone, setContentsZone] = useState<ZoneRef | null>(null);
|
||||
const [layoutZone, setLayoutZone] = useState<ZoneRef | null>(null);
|
||||
|
||||
const zonesQuery = useQuery(
|
||||
api.warehouses.listZones.queryOptions({
|
||||
@@ -203,6 +205,14 @@ export default function WarehouseDetailPage() {
|
||||
meta: { headerClassName: 'text-right', cellClassName: 'text-right' },
|
||||
cell: ({ row }) => (
|
||||
<Group gap={4} justify="flex-end" wrap="nowrap" onClick={(e) => e.stopPropagation()}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
title="Stack layout"
|
||||
onClick={() => setLayoutZone(row.original)}
|
||||
>
|
||||
<Layers size={16} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
@@ -425,6 +435,12 @@ export default function WarehouseDetailPage() {
|
||||
yard={editingYard}
|
||||
/>
|
||||
)}
|
||||
<ZoneLayoutModal
|
||||
opened={Boolean(layoutZone)}
|
||||
onClose={() => setLayoutZone(null)}
|
||||
zone={layoutZone}
|
||||
/>
|
||||
|
||||
<ZoneContentsModal
|
||||
opened={Boolean(contentsZone)}
|
||||
onClose={() => setContentsZone(null)}
|
||||
|
||||
@@ -154,6 +154,14 @@ import type {
|
||||
WarehouseYard,
|
||||
WarehouseZone,
|
||||
ZoneContentItem,
|
||||
ZoneLayout,
|
||||
ZoneSlotSummary,
|
||||
WarehouseZoneStack,
|
||||
WarehouseZoneSlot,
|
||||
SaveStackPayload,
|
||||
AvailableSlot,
|
||||
ContainerAccessibility,
|
||||
FindSlotPayload,
|
||||
} from "@/types/warehouse";
|
||||
import { endpoint } from "@/utils/endpoint";
|
||||
import {
|
||||
@@ -1259,6 +1267,85 @@ export const api = {
|
||||
({ zoneId }) => ["warehouse-zones", zoneId, "contents"],
|
||||
),
|
||||
|
||||
zoneLayout: endpoint<{ zoneId: string }, ZoneLayout>(
|
||||
"warehouse-zones",
|
||||
"layout",
|
||||
({ zoneId }) => warehouseService.zoneLayout(zoneId).then((r) => r.data),
|
||||
({ zoneId }) => ["warehouse-zones", zoneId, "layout"],
|
||||
),
|
||||
|
||||
zoneSlotSummary: endpoint<{ zoneId: string }, ZoneSlotSummary>(
|
||||
"warehouse-zones",
|
||||
"slot-summary",
|
||||
({ zoneId }) => warehouseService.zoneSlotSummary(zoneId).then((r) => r.data),
|
||||
({ zoneId }) => ["warehouse-zones", zoneId, "slot-summary"],
|
||||
),
|
||||
|
||||
// ── Ground stacks and slots ────────────────────────────────────────────
|
||||
listStacks: endpoint<{ zoneId: string }, WarehouseZoneStack[]>(
|
||||
"warehouse-zone-stacks",
|
||||
"list",
|
||||
({ zoneId }) => warehouseService.listStacks(zoneId).then((r) => r.data),
|
||||
({ zoneId }) => ["warehouse-zone-stacks", zoneId],
|
||||
),
|
||||
|
||||
createStack: endpoint<
|
||||
{ zoneId: string; payload: SaveStackPayload },
|
||||
WarehouseZoneStack
|
||||
>(
|
||||
"warehouse-zone-stacks",
|
||||
"create",
|
||||
({ zoneId, payload }) =>
|
||||
warehouseService.createStack(zoneId, payload).then((r) => r.data),
|
||||
undefined,
|
||||
({ zoneId }) => [
|
||||
["warehouse-zone-stacks", zoneId],
|
||||
["warehouse-zones", zoneId, "layout"],
|
||||
["warehouse-zones", zoneId, "slot-summary"],
|
||||
],
|
||||
),
|
||||
|
||||
updateStack: endpoint<
|
||||
{ id: string; zoneId: string; payload: Partial<SaveStackPayload> },
|
||||
WarehouseZoneStack
|
||||
>(
|
||||
"warehouse-zone-stacks",
|
||||
"update",
|
||||
({ id, payload }) => warehouseService.updateStack(id, payload).then((r) => r.data),
|
||||
undefined,
|
||||
({ zoneId }) => [
|
||||
["warehouse-zone-stacks", zoneId],
|
||||
["warehouse-zones", zoneId, "layout"],
|
||||
["warehouse-zones", zoneId, "slot-summary"],
|
||||
],
|
||||
),
|
||||
|
||||
deleteStack: endpoint<{ id: string; zoneId: string }, unknown>(
|
||||
"warehouse-zone-stacks",
|
||||
"delete",
|
||||
({ id }) => warehouseService.removeStack(id).then((r) => r.data),
|
||||
undefined,
|
||||
({ zoneId }) => [
|
||||
["warehouse-zone-stacks", zoneId],
|
||||
["warehouse-zones", zoneId, "layout"],
|
||||
["warehouse-zones", zoneId, "slot-summary"],
|
||||
],
|
||||
),
|
||||
|
||||
updateSlot: endpoint<
|
||||
{ slotId: string; zoneId: string; payload: { status?: string; isActive?: boolean } },
|
||||
WarehouseZoneSlot
|
||||
>(
|
||||
"warehouse-zone-stacks",
|
||||
"update-slot",
|
||||
({ slotId, payload }) => warehouseService.updateSlot(slotId, payload).then((r) => r.data),
|
||||
undefined,
|
||||
({ zoneId }) => [
|
||||
["warehouse-zones", zoneId, "layout"],
|
||||
["warehouse-zones", zoneId, "slot-summary"],
|
||||
],
|
||||
),
|
||||
|
||||
deleteZone: endpoint<{ id: string }, unknown>(
|
||||
"warehouses",
|
||||
"deleteZone",
|
||||
@@ -1490,7 +1577,7 @@ export const api = {
|
||||
"store",
|
||||
({ id, payload }) => warehouseService.store(id, payload).then((r) => r.data),
|
||||
undefined,
|
||||
() => INVENTORY_INVALIDATIONS,
|
||||
() => [...INVENTORY_INVALIDATIONS, ["warehouse-zones"]],
|
||||
),
|
||||
|
||||
reserve: endpoint<ReserveInventoryPayload, WarehouseInventoryItem>(
|
||||
@@ -1529,6 +1616,36 @@ export const api = {
|
||||
() => INVENTORY_INVALIDATIONS,
|
||||
),
|
||||
|
||||
findAvailableSlot: endpoint<FindSlotPayload, AvailableSlot | null>(
|
||||
"warehouse-inventory",
|
||||
"find-slot",
|
||||
(payload) => warehouseService.findAvailableSlot(payload).then((r) => r.data),
|
||||
(payload) => ["warehouse-inventory", "find-slot", payload],
|
||||
),
|
||||
|
||||
containerAccessibility: endpoint<{ id: string }, ContainerAccessibility>(
|
||||
"warehouse-inventory",
|
||||
"accessibility",
|
||||
({ id }) => warehouseService.containerAccessibility(id).then((r) => r.data),
|
||||
({ id }) => ["warehouse-inventory", id, "accessibility"],
|
||||
),
|
||||
|
||||
assignSlot: endpoint<{ id: string; slotId?: string }, WarehouseInventoryItem>(
|
||||
"warehouse-inventory",
|
||||
"assign-slot",
|
||||
({ id, slotId }) => warehouseService.assignSlot(id, slotId).then((r) => r.data),
|
||||
undefined,
|
||||
() => [...INVENTORY_INVALIDATIONS, ["warehouse-zones"]],
|
||||
),
|
||||
|
||||
releaseSlot: endpoint<{ id: string }, WarehouseInventoryItem>(
|
||||
"warehouse-inventory",
|
||||
"release-slot",
|
||||
({ id }) => warehouseService.releaseSlot(id).then((r) => r.data),
|
||||
undefined,
|
||||
() => [...INVENTORY_INVALIDATIONS, ["warehouse-zones"]],
|
||||
),
|
||||
|
||||
move: endpoint<
|
||||
{ id: string; payload: MoveInventoryPayload },
|
||||
WarehouseInventoryItem
|
||||
@@ -1538,7 +1655,7 @@ export const api = {
|
||||
({ id, payload }) =>
|
||||
warehouseService.move(id, payload).then((r) => r.data),
|
||||
undefined,
|
||||
() => INVENTORY_INVALIDATIONS,
|
||||
() => [...INVENTORY_INVALIDATIONS, ["warehouse-zones"]],
|
||||
),
|
||||
|
||||
markReadyForPickup: endpoint<string, WarehouseInventoryItem>(
|
||||
|
||||
@@ -5,6 +5,14 @@ import { api as apiClient } from '../auth/http';
|
||||
import { URL_CONSTANTS } from '@/constants/URLS';
|
||||
import type {
|
||||
ZoneOccupancy,
|
||||
ZoneLayout,
|
||||
ZoneSlotSummary,
|
||||
WarehouseZoneStack,
|
||||
WarehouseZoneSlot,
|
||||
SaveStackPayload,
|
||||
AvailableSlot,
|
||||
ContainerAccessibility,
|
||||
FindSlotPayload,
|
||||
TruckOnSite,
|
||||
WarehouseOpsStats,
|
||||
WarehouseThroughputPoint,
|
||||
@@ -289,6 +297,37 @@ export const warehouseService = {
|
||||
removeZone: (id: string) => apiClient.delete(URL_CONSTANTS.WAREHOUSE_ZONES.BY_ID(id)),
|
||||
zoneContents: (zoneId: string) =>
|
||||
apiClient.get<ZoneContentItem[]>(`${URL_CONSTANTS.WAREHOUSE_ZONES.BY_ID(zoneId)}/contents`),
|
||||
zoneLayout: (zoneId: string) =>
|
||||
apiClient.get<ZoneLayout>(URL_CONSTANTS.WAREHOUSE_ZONES.LAYOUT(zoneId)),
|
||||
zoneSlotSummary: (zoneId: string) =>
|
||||
apiClient.get<ZoneSlotSummary>(URL_CONSTANTS.WAREHOUSE_ZONES.SLOT_SUMMARY(zoneId)),
|
||||
|
||||
// ── Ground stacks and slots ────────────────────────────────────────────
|
||||
listStacks: (zoneId: string) =>
|
||||
apiClient.get<WarehouseZoneStack[]>(URL_CONSTANTS.WAREHOUSE_ZONE_STACKS.BY_ZONE(zoneId)),
|
||||
createStack: (zoneId: string, payload: SaveStackPayload) =>
|
||||
apiClient.post<WarehouseZoneStack>(URL_CONSTANTS.WAREHOUSE_ZONE_STACKS.BASE, {
|
||||
...payload,
|
||||
zoneId,
|
||||
}),
|
||||
updateStack: (id: string, payload: Partial<SaveStackPayload>) =>
|
||||
apiClient.patch<WarehouseZoneStack>(URL_CONSTANTS.WAREHOUSE_ZONE_STACKS.BY_ID(id), payload),
|
||||
removeStack: (id: string) =>
|
||||
apiClient.delete(URL_CONSTANTS.WAREHOUSE_ZONE_STACKS.BY_ID(id)),
|
||||
updateSlot: (slotId: string, payload: { status?: string; isActive?: boolean }) =>
|
||||
apiClient.patch<WarehouseZoneSlot>(URL_CONSTANTS.WAREHOUSE_ZONE_STACKS.SLOT(slotId), payload),
|
||||
|
||||
// ── Physical placement ─────────────────────────────────────────────────
|
||||
findAvailableSlot: (payload: FindSlotPayload) =>
|
||||
apiClient.post<AvailableSlot | null>(URL_CONSTANTS.WAREHOUSE_INVENTORY.FIND_SLOT, payload),
|
||||
assignSlot: (id: string, slotId?: string) =>
|
||||
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.ASSIGN_SLOT(id), {
|
||||
slotId,
|
||||
}),
|
||||
releaseSlot: (id: string) =>
|
||||
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.RELEASE_SLOT(id), {}),
|
||||
containerAccessibility: (id: string) =>
|
||||
apiClient.get<ContainerAccessibility>(URL_CONSTANTS.WAREHOUSE_INVENTORY.ACCESSIBILITY(id)),
|
||||
|
||||
// ── Inventory ──────────────────────────────────────────────────────────
|
||||
listInventory: (filter?: InventoryFilter) =>
|
||||
|
||||
@@ -145,6 +145,122 @@ export interface WarehouseYard {
|
||||
zones?: WarehouseZone[];
|
||||
}
|
||||
|
||||
export const WAREHOUSE_STACK_STATUSES = ['ACTIVE', 'INACTIVE'] as const;
|
||||
export type WarehouseStackStatus = (typeof WAREHOUSE_STACK_STATUSES)[number];
|
||||
|
||||
/** Stored slot intent. OCCUPIED is never stored — it is derived from inventory. */
|
||||
export const WAREHOUSE_SLOT_STATUSES = ['AVAILABLE', 'BLOCKED', 'RESERVED', 'INACTIVE'] as const;
|
||||
export type WarehouseSlotStatus = (typeof WAREHOUSE_SLOT_STATUSES)[number];
|
||||
|
||||
export type SlotEffectiveStatus = WarehouseSlotStatus | 'OCCUPIED';
|
||||
|
||||
/** One vertical level of a ground stack. */
|
||||
export interface WarehouseZoneSlot {
|
||||
id: string;
|
||||
stackId: string;
|
||||
level: number;
|
||||
status: WarehouseSlotStatus;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
/** One ground footprint inside a zone — where containers are stacked vertically. */
|
||||
export interface WarehouseZoneStack {
|
||||
id: string;
|
||||
zoneId: string;
|
||||
code: string;
|
||||
name: string | null;
|
||||
row: string | null;
|
||||
bay: string | null;
|
||||
position: string | null;
|
||||
maxStackHeight: number;
|
||||
status: WarehouseStackStatus;
|
||||
isActive: boolean;
|
||||
slots?: WarehouseZoneSlot[];
|
||||
}
|
||||
|
||||
export interface SaveStackPayload {
|
||||
code: string;
|
||||
name?: string;
|
||||
row?: string;
|
||||
bay?: string;
|
||||
position?: string;
|
||||
maxStackHeight?: number;
|
||||
status?: WarehouseStackStatus;
|
||||
}
|
||||
|
||||
/** Configured capacity vs slots actually built vs slots actually full. */
|
||||
export interface ZoneSlotSummary {
|
||||
configuredCapacity: number | null;
|
||||
physicalSlotCount: number;
|
||||
occupiedSlotCount: number;
|
||||
reservedSlotCount: number;
|
||||
blockedSlotCount: number;
|
||||
inactiveSlotCount: number;
|
||||
availableSlotCount: number;
|
||||
/** True when more slots are built than the configured capacity allows. */
|
||||
inconsistent: boolean;
|
||||
}
|
||||
|
||||
export interface ZoneLayoutSlot {
|
||||
slotId: string;
|
||||
level: number;
|
||||
effectiveStatus: SlotEffectiveStatus;
|
||||
inventoryId: string | null;
|
||||
containerNumber: string | null;
|
||||
}
|
||||
|
||||
export interface ZoneLayoutStack {
|
||||
stackId: string;
|
||||
code: string;
|
||||
name: string | null;
|
||||
maxStackHeight: number;
|
||||
status: WarehouseStackStatus;
|
||||
isActive: boolean;
|
||||
/** Highest level first, the way the yard is seen from the side. */
|
||||
slots: ZoneLayoutSlot[];
|
||||
}
|
||||
|
||||
export interface ZoneLayout {
|
||||
zoneId: string;
|
||||
zoneCode: string;
|
||||
zoneName: string;
|
||||
stacks: ZoneLayoutStack[];
|
||||
summary: ZoneSlotSummary;
|
||||
}
|
||||
|
||||
export interface FindSlotPayload {
|
||||
yardId: string;
|
||||
zoneId?: string;
|
||||
direction?: 'IMPORT' | 'EXPORT' | 'BOTH';
|
||||
cargoTypeId?: string;
|
||||
}
|
||||
|
||||
/** The lowest free stack level the placement engine would use next. */
|
||||
export interface AvailableSlot {
|
||||
slotId: string;
|
||||
stackId: string;
|
||||
stackCode: string;
|
||||
level: number;
|
||||
zoneId: string;
|
||||
zoneCode: string;
|
||||
}
|
||||
|
||||
export interface BlockingContainer {
|
||||
inventoryId: string;
|
||||
containerNumber: string | null;
|
||||
level: number;
|
||||
status: string;
|
||||
}
|
||||
|
||||
/** Whether a container can be lifted out, and what is stacked on top of it. */
|
||||
export interface ContainerAccessibility {
|
||||
accessible: boolean;
|
||||
inventoryId: string;
|
||||
stackCode: string | null;
|
||||
level: number | null;
|
||||
blockingContainers: BlockingContainer[];
|
||||
}
|
||||
|
||||
/** One container (or bulk lot) currently sitting in a zone. */
|
||||
export interface ZoneContentItem {
|
||||
inventoryId: string;
|
||||
@@ -382,6 +498,8 @@ export interface MoveInventoryPayload {
|
||||
warehouseId: string;
|
||||
yardId: string;
|
||||
zoneId: string;
|
||||
/** Exact stack level in the destination zone. Container yards only. */
|
||||
slotId?: string;
|
||||
remarks?: string;
|
||||
}
|
||||
|
||||
@@ -657,6 +775,8 @@ export interface StoreInventoryPayload {
|
||||
warehouseId?: string;
|
||||
yardId?: string;
|
||||
zoneId?: string;
|
||||
/** Exact stack level. Omit to let the placement engine pick the lowest free one. */
|
||||
slotId?: string;
|
||||
}
|
||||
|
||||
export interface ImportTrainItem {
|
||||
@@ -1120,6 +1240,8 @@ export interface MoveInventoryPayload {
|
||||
warehouseId: string;
|
||||
yardId: string;
|
||||
zoneId: string;
|
||||
/** Exact stack level in the destination zone. Container yards only. */
|
||||
slotId?: string;
|
||||
remarks?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -595,7 +595,7 @@ function NewShipmentBookingForm({
|
||||
: {}),
|
||||
units: l.units.map((u) => ({
|
||||
containerNumber: u.containerNumber,
|
||||
sealNumber: u.sealNumber || undefined,
|
||||
sealNumber: u.sealNumber.trim(),
|
||||
vgmTons: Number(u.vgmTons),
|
||||
// Per-container handling — the server rolls these up into the
|
||||
// line counts and bills each surcharge on the ticked containers.
|
||||
@@ -2309,7 +2309,7 @@ function ContainerLineEditor({
|
||||
Container number *
|
||||
</Text>
|
||||
<Text fz={12} fw={600} c="#10202F" style={{ flex: 1 }}>
|
||||
Seal number
|
||||
Seal number *
|
||||
</Text>
|
||||
<Text fz={12} fw={600} c="#10202F" style={{ flex: 1 }}>
|
||||
VGM (tons) *
|
||||
@@ -2355,10 +2355,11 @@ function ContainerLineEditor({
|
||||
<Controller
|
||||
name={`containers.${index}.units.${u}.sealNumber`}
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
render={({ field, fieldState }) => (
|
||||
<TextInput
|
||||
{...field}
|
||||
placeholder="Optional"
|
||||
placeholder="e.g. SL-0099231"
|
||||
error={fieldState.error?.message}
|
||||
radius={10}
|
||||
styles={fieldStyles}
|
||||
style={{ flex: 1 }}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import * as XLSX from "xlsx";
|
||||
|
||||
import { parseContainerExcel } from "./container-excel";
|
||||
|
||||
// Seals became mandatory at booking time — a spreadsheet row without one must
|
||||
// reject the whole file, the same way a missing VGM already does.
|
||||
const OPTS = {
|
||||
allowedSizes: ["20ft", "40ft"],
|
||||
includeHazardous: false,
|
||||
includeReefer: false,
|
||||
};
|
||||
|
||||
function sheetFile(rows: string[][]): File {
|
||||
const workbook = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(
|
||||
workbook,
|
||||
XLSX.utils.aoa_to_sheet([
|
||||
["Container Size", "Container Number", "Seal Number", "VGM (Tons)"],
|
||||
...rows,
|
||||
]),
|
||||
"Containers",
|
||||
);
|
||||
const buffer = XLSX.write(workbook, { type: "array", bookType: "xlsx" });
|
||||
return new File([buffer], "containers.xlsx");
|
||||
}
|
||||
|
||||
describe("parseContainerExcel", () => {
|
||||
it("accepts a row carrying a seal", async () => {
|
||||
const result = await parseContainerExcel(
|
||||
sheetFile([["40ft", "MSCU1234567", "SL-0099231", "24.5"]]),
|
||||
OPTS,
|
||||
);
|
||||
|
||||
expect(result.errors).toEqual([]);
|
||||
expect(result.rows).toHaveLength(1);
|
||||
expect(result.rows[0].sealNumber).toBe("SL-0099231");
|
||||
});
|
||||
|
||||
it("rejects the file when a row has no seal", async () => {
|
||||
const result = await parseContainerExcel(
|
||||
sheetFile([
|
||||
["40ft", "MSCU1234567", "SL-0099231", "24.5"],
|
||||
["20ft", "MSCU7654321", "", "12"],
|
||||
]),
|
||||
OPTS,
|
||||
);
|
||||
|
||||
expect(result.rows).toEqual([]);
|
||||
expect(result.errors).toContain("Row 3: seal number is required.");
|
||||
});
|
||||
});
|
||||
@@ -151,6 +151,11 @@ export async function parseContainerExcel(
|
||||
numberCounts.set(containerNumber, (numberCounts.get(containerNumber) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const sealNumber = cell("sealNumber");
|
||||
if (!sealNumber) {
|
||||
errors.push(`Row ${rowNo}: seal number is required.`);
|
||||
}
|
||||
|
||||
const vgmRaw = cell("vgmTons");
|
||||
const vgm = Number(vgmRaw);
|
||||
if (!vgmRaw || Number.isNaN(vgm) || vgm <= 0) {
|
||||
@@ -160,7 +165,7 @@ export async function parseContainerExcel(
|
||||
rows.push({
|
||||
containerSize: size ?? "",
|
||||
containerNumber,
|
||||
sealNumber: cell("sealNumber"),
|
||||
sealNumber,
|
||||
vgmTons: vgmRaw,
|
||||
hazardous: opts.includeHazardous && parseFlag(cell("hazardous")),
|
||||
reefer: opts.includeReefer && parseFlag(cell("reefer")),
|
||||
|
||||
@@ -61,7 +61,9 @@ const containerUnitSchema = z.object({
|
||||
(v) => ISO_CONTAINER_NUMBER_REGEX.test(v.trim().toUpperCase()),
|
||||
"Enter a valid ISO container number (e.g. ABCD1234567).",
|
||||
),
|
||||
sealNumber: z.string().default(""),
|
||||
sealNumber: z
|
||||
.string()
|
||||
.refine((v) => v.trim().length > 0, "Seal number is required."),
|
||||
vgmTons: z
|
||||
.string()
|
||||
.refine((v) => v.trim().length > 0, "VGM is required.")
|
||||
|
||||
Reference in New Issue
Block a user