mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-09 10:58:14 +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:
@@ -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