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

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