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:
Hagernesh
2026-08-29 06:37:53 +00:00
parent abfd55d43e
commit 312c1f1da3
22 changed files with 1074 additions and 27 deletions

View File

@@ -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,

View File

@@ -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")),

View File

@@ -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>

View File

@@ -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;

View File

@@ -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

View File

@@ -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;

View File

@@ -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';

View File

@@ -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([]);
});
});

View File

@@ -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",

View File

@@ -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)}

View File

@@ -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>(

View File

@@ -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) =>

View File

@@ -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;
}

View File

@@ -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 }}

View File

@@ -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.");
});
});

View File

@@ -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")),

View File

@@ -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.")