feat(import-operations): bulk Excel upload for yard-resident empty containers

Empties already sitting in an EDR yard but never entered in the system had
to be typed one at a time. Adds a bulk path: parse the sheet in the browser
(all-or-nothing, row-numbered errors), preview it, then POST one batch.

The server rejects the batch if any container already has a non-COMPLETED
return, so re-uploading the same sheet cannot duplicate boxes. No interchange
notification fires — these are historical rows, not a live handover.

Company is an Autocomplete over registered customers that also accepts a
typed name, since a backfilled box may belong to a company that is not a
customer yet. Exact name match sets customer_id; the name always lands in the
new empty_container_returns.company_name.

Also fixes the single Record Return modal, which collected Yard and Zone and
then dropped them before the API call, and did not invalidate the returns
list after a standalone return.
This commit is contained in:
Hagernesh
2026-08-28 11:31:50 +00:00
parent 132374ed03
commit 3f4fd8648a
16 changed files with 1075 additions and 44 deletions

View File

@@ -0,0 +1,354 @@
import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import {
Alert,
Anchor,
Autocomplete,
Badge,
Button,
FileInput,
Group,
List,
Modal,
ScrollArea,
Select,
Stack,
Table,
Text,
} from "@mantine/core";
import { Upload } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
import { useWarehouseYards, useWarehouseZones } from "@/hooks/useWarehouses";
import { importOperationsService } from "@/services/importOperations.service";
import { warehouseService } from "@/services/warehouse.service";
import type { CreateEmptyContainerReturnPayload } from "@/types/importOperations";
import {
downloadContainerReturnTemplate,
parseContainerReturnExcel,
type ParsedReturnRow,
} from "./container-return-excel";
import { useCompanyOptions } from "./useCompanyOptions";
interface BulkContainerReturnModalProps {
opened: boolean;
onClose: () => void;
onUploaded: () => void;
}
/**
* Backfill of empties physically in a yard but never entered in the system.
* The sheet carries per-container detail; the fields above the file are the
* defaults for every row whose cell is blank, so the common case is a sheet of
* container numbers plus one warehouse picked here.
*/
export default function BulkContainerReturnModal({
opened,
onClose,
onUploaded,
}: BulkContainerReturnModalProps) {
const { toast } = useToast();
const companies = useCompanyOptions();
const [file, setFile] = useState<File | null>(null);
const [rows, setRows] = useState<ParsedReturnRow[]>([]);
const [parseErrors, setParseErrors] = useState<string[]>([]);
const [parsing, setParsing] = useState(false);
const [company, setCompany] = useState("");
const [returnedBy, setReturnedBy] = useState<"EDR" | "CUSTOMER" | null>(null);
const [warehouseId, setWarehouseId] = useState<string | null>(null);
const [yardId, setYardId] = useState<string | null>(null);
const [zoneId, setZoneId] = useState<string | null>(null);
const [returnDate, setReturnDate] = useState(new Date().toISOString().split("T")[0]);
const { data: warehousesResponse } = useQuery({
queryKey: ["warehouses-list"],
queryFn: () => warehouseService.list({}),
});
const warehouses = ((warehousesResponse as any)?.data ?? warehousesResponse ?? []) as any[];
const { data: yards } = useWarehouseYards(warehouseId ?? undefined);
const { data: zones } = useWarehouseZones(yardId ?? undefined);
useEffect(() => {
setYardId(null);
setZoneId(null);
}, [warehouseId]);
useEffect(() => setZoneId(null), [yardId]);
const warehouseOptions = Array.isArray(warehouses)
? warehouses.map((wh) => ({ value: wh.id, label: wh.code ? `${wh.name} (${wh.code})` : wh.name }))
: [];
const yardOptions = (yards ?? [])
.filter((y) => y.status === "ACTIVE")
.map((y) => ({ value: y.id, label: `${y.name} (${y.code})` }));
const zoneOptions = (zones ?? [])
.filter((z) => z.status === "ACTIVE")
.map((z) => ({ value: z.id, label: `${z.name} (${z.code})` }));
const defaults = useMemo(
() => ({
facility: warehouses.find((wh) => wh.id === warehouseId)?.name ?? "",
yard: yards?.find((y) => y.id === yardId)?.name ?? "",
zone: zones?.find((z) => z.id === zoneId)?.name ?? "",
}),
[warehouses, warehouseId, yards, yardId, zones, zoneId],
);
const reset = () => {
setFile(null);
setRows([]);
setParseErrors([]);
};
const handleFile = async (next: File | null) => {
setFile(next);
setRows([]);
setParseErrors([]);
if (!next) return;
setParsing(true);
const result = await parseContainerReturnExcel(next);
setParsing(false);
setRows(result.rows);
setParseErrors(result.errors);
};
// Row cell wins; the field above the file fills the blanks.
const toPayload = (row: ParsedReturnRow): CreateEmptyContainerReturnPayload => {
const companyName = row.companyName || company;
return {
containerNumber: row.containerNumber,
containerSize: row.containerSize ?? undefined,
companyName: companyName || undefined,
customerId: companyName ? companies.resolveId(companyName) : undefined,
returnedBy: row.returnedBy ?? returnedBy ?? undefined,
returnDate: row.returnDate ?? new Date(returnDate).toISOString(),
facility: row.facility || defaults.facility || undefined,
yard: row.yard || defaults.yard || undefined,
zone: row.zone || defaults.zone || undefined,
condition: row.condition || undefined,
handoverNote: row.handoverNote || undefined,
};
};
const uploadMutation = useMutation({
mutationFn: () => importOperationsService.bulkCreateEmptyReturns(rows.map(toPayload)),
onSuccess: (created) => {
toast({ title: `${created.length} container return${created.length === 1 ? "" : "s"} recorded` });
reset();
onUploaded();
onClose();
},
onError: (error: any) => {
toast({
variant: "destructive",
title: "Bulk upload failed",
description: error?.response?.data?.message || error?.message,
});
},
});
// Every row needs a warehouse from somewhere — the API stores facility as
// free text, so a blank one would silently produce unplaceable containers.
const missingFacility = rows.filter((r) => !r.facility && !defaults.facility).length;
const missingReturnedBy = rows.filter((r) => !r.returnedBy && !returnedBy).length;
const blockers = [
missingFacility > 0 ? `${missingFacility} row(s) have no facility — pick a default warehouse.` : null,
missingReturnedBy > 0 ? `${missingReturnedBy} row(s) have no "Returned By" — pick a default.` : null,
].filter(Boolean) as string[];
return (
<Modal
opened={opened}
onClose={() => {
reset();
onClose();
}}
title="Bulk Upload Container Returns"
size="xl"
>
<Stack gap="md">
<Text size="sm" c="dimmed">
For empties already sitting in the yard but not yet on the system. Values below fill any
blank cell in the sheet.{" "}
<Anchor size="sm" onClick={() => downloadContainerReturnTemplate()}>
Download template
</Anchor>
</Text>
<Group grow align="flex-start">
<Autocomplete
label="Company"
description="Pick a registered customer, or type a company that is not on the system yet"
placeholder={companies.loading ? "Loading companies…" : "Search or type a company"}
data={companies.names}
value={company}
onChange={setCompany}
limit={20}
/>
<Select
label="Returned By"
placeholder="Select truck type"
value={returnedBy}
onChange={(v) => setReturnedBy(v as "EDR" | "CUSTOMER" | null)}
data={[
{ value: "EDR", label: "EDR Truck" },
{ value: "CUSTOMER", label: "Customer Truck" },
]}
/>
</Group>
<Group grow align="flex-start">
<Select
label="Warehouse"
placeholder="Select warehouse"
value={warehouseId}
onChange={setWarehouseId}
data={warehouseOptions}
searchable
/>
<Select
label="Yard"
placeholder={warehouseId ? "Select yard" : "Select warehouse first"}
value={yardId}
onChange={setYardId}
data={yardOptions}
disabled={!warehouseId}
searchable
/>
<Select
label="Zone"
placeholder={yardId ? "Select zone" : "Select yard first"}
value={zoneId}
onChange={setZoneId}
data={zoneOptions}
disabled={!yardId}
searchable
/>
</Group>
<Group grow align="flex-start">
<FileInput
label="Excel file"
placeholder="Select .xlsx or .xls"
accept=".xlsx,.xls"
leftSection={<Upload size={16} />}
value={file}
onChange={(next) => void handleFile(next)}
/>
<div>
<Text size="sm" fw={500} mb={4}>
Returned Date (default)
</Text>
<input
type="date"
value={returnDate}
onChange={(e) => setReturnDate(e.target.value)}
style={{ padding: "8px", borderRadius: "4px", border: "1px solid #ced4da", width: "100%" }}
/>
</div>
</Group>
{parsing && <Text size="sm">Reading file</Text>}
{parseErrors.length > 0 && (
<Alert color="red" title={`${parseErrors.length} problem(s) — nothing was imported`}>
<ScrollArea.Autosize mah={200}>
<List size="sm">
{parseErrors.map((err) => (
<List.Item key={err}>{err}</List.Item>
))}
</List>
</ScrollArea.Autosize>
</Alert>
)}
{blockers.length > 0 && (
<Alert color="yellow" title="Fill these in before uploading">
<List size="sm">
{blockers.map((b) => (
<List.Item key={b}>{b}</List.Item>
))}
</List>
</Alert>
)}
{rows.length > 0 && (
<Stack gap="xs">
<Group gap="xs">
<Text fw={600} size="sm">
Preview
</Text>
<Badge size="sm">{rows.length} containers</Badge>
</Group>
<ScrollArea.Autosize mah={300}>
<Table striped highlightOnHover withTableBorder>
<Table.Thead>
<Table.Tr>
<Table.Th>Container</Table.Th>
<Table.Th>Size</Table.Th>
<Table.Th>Company</Table.Th>
<Table.Th>Returned By</Table.Th>
<Table.Th>Date</Table.Th>
<Table.Th>Facility</Table.Th>
<Table.Th>Yard</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{rows.map((row) => {
const payload = toPayload(row);
return (
<Table.Tr key={row.containerNumber}>
<Table.Td>{payload.containerNumber}</Table.Td>
<Table.Td>{payload.containerSize ? `${payload.containerSize} ft` : "—"}</Table.Td>
<Table.Td>
<Group gap={4} wrap="nowrap">
<Text size="sm">{payload.companyName || "—"}</Text>
{payload.companyName && !payload.customerId && (
<Badge size="xs" color="orange" variant="light">
New
</Badge>
)}
</Group>
</Table.Td>
<Table.Td>{payload.returnedBy ?? "—"}</Table.Td>
<Table.Td>
{payload.returnDate
? new Date(payload.returnDate).toLocaleDateString()
: "—"}
</Table.Td>
<Table.Td>{payload.facility ?? "—"}</Table.Td>
<Table.Td>{payload.yard ?? "—"}</Table.Td>
</Table.Tr>
);
})}
</Table.Tbody>
</Table>
</ScrollArea.Autosize>
</Stack>
)}
<Group justify="flex-end" gap="sm">
<Button
variant="default"
onClick={() => {
reset();
onClose();
}}
disabled={uploadMutation.isPending}
>
Cancel
</Button>
<Button
onClick={() => uploadMutation.mutate()}
disabled={rows.length === 0 || blockers.length > 0}
loading={uploadMutation.isPending}
>
Upload {rows.length > 0 ? `${rows.length} containers` : ""}
</Button>
</Group>
</Stack>
</Modal>
);
}

View File

@@ -0,0 +1,75 @@
import { describe, expect, it } from "vitest";
import * as XLSX from "xlsx";
import { parseContainerReturnExcel } from "./container-return-excel";
/** Build an in-memory .xlsx and hand it back as a File, like the dropzone would. */
function sheetFile(aoa: unknown[][]): File {
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(aoa), "Sheet1");
const buf = XLSX.write(wb, { type: "array", bookType: "xlsx" }) as ArrayBuffer;
return new File([buf], "returns.xlsx");
}
const HEADERS = [
"Container Number",
"Container Size",
"Company",
"Returned By",
"Returned Date",
"Facility",
"Yard",
"Zone",
"Condition",
"Handover Note",
];
describe("parseContainerReturnExcel", () => {
it("parses a good sheet, normalizing size and returned-by", async () => {
const result = await parseContainerReturnExcel(
sheetFile([
["Yard tally — August"], // title row above the header is ignored
HEADERS,
["temu1234567", "40ft", "Acme PLC", "EDR last mile", "2026-08-14", "Gelan", "A", "1", "", ""],
["MSCU7654321", "20", "Other Trading", "Self haul", "2026-08-15", "Gelan", "", "", "Dented", "n"],
]),
);
expect(result.errors).toEqual([]);
expect(result.rows).toHaveLength(2);
expect(result.rows[0].containerNumber).toBe("TEMU1234567");
expect(result.rows[0].containerSize).toBe("40");
expect(result.rows[0].returnedBy).toBe("EDR");
expect(result.rows[0].companyName).toBe("Acme PLC");
expect(result.rows[0].returnDate?.startsWith("2026-08-14")).toBe(true);
expect(result.rows[1].containerSize).toBe("20");
expect(result.rows[1].returnedBy).toBe("CUSTOMER");
});
it("rejects the whole file when a container number is invalid", async () => {
const result = await parseContainerReturnExcel(
sheetFile([HEADERS, ["NOTACONTAINER", "40", "Acme", "EDR", "", "", "", "", "", ""]]),
);
expect(result.rows).toEqual([]);
expect(result.errors[0]).toContain("Row 2");
});
it("rejects duplicate container numbers", async () => {
const result = await parseContainerReturnExcel(
sheetFile([
HEADERS,
["TEMU1234567", "40", "Acme", "EDR", "", "", "", "", "", ""],
["temu1234567", "20", "Acme", "EDR", "", "", "", "", "", ""],
]),
);
expect(result.rows).toEqual([]);
expect(result.errors.some((e) => e.includes("appears 2 times"))).toBe(true);
});
it("errors when there is no container-number column", async () => {
const result = await parseContainerReturnExcel(sheetFile([["Company", "Yard"], ["Acme", "A"]]));
expect(result.errors[0]).toContain("Container Number");
});
});

View File

@@ -0,0 +1,231 @@
import * as XLSX from "xlsx";
// Excel import for empties already sitting in an EDR yard that were never
// entered in the system. One spreadsheet row per container. All-or-nothing —
// any bad row rejects the whole file with row-numbered errors, so a partial
// backfill can never silently drop boxes.
// ISO 6346: 4-letter prefix (owner code + category id) + 7 digits.
const ISO_CONTAINER_NUMBER_REGEX = /^[A-Z]{4}\d{7}$/;
export interface ParsedReturnRow {
containerNumber: string;
containerSize: "20" | "40" | null;
companyName: string;
returnedBy: "EDR" | "CUSTOMER" | null;
returnDate: string | null;
facility: string;
yard: string;
zone: string;
condition: string;
handoverNote: string;
}
export interface ContainerReturnExcelResult {
rows: ParsedReturnRow[];
errors: string[];
}
type ColumnKey =
| "containerNumber"
| "containerSize"
| "companyName"
| "returnedBy"
| "returnDate"
| "facility"
| "yard"
| "zone"
| "condition"
| "handoverNote";
/** Match a header cell to a known column, tolerant of casing/spacing/punctuation. */
function headerKey(raw: string): ColumnKey | null {
const h = raw.toLowerCase().replace(/[^a-z]/g, "");
if (!h) return null;
if (h.includes("size") || h.includes("type")) return "containerSize";
if (h.includes("company") || h.includes("customer") || h.includes("consignee")) return "companyName";
if (h.includes("returnedby") || h.includes("haul") || h.includes("truck")) return "returnedBy";
if (h.includes("date")) return "returnDate";
if (h.includes("facility") || h.includes("warehouse") || h.includes("terminal")) return "facility";
if (h.includes("yard")) return "yard";
if (h.includes("zone")) return "zone";
if (h.includes("condition") || h.includes("damage")) return "condition";
if (h.includes("note") || h.includes("remark")) return "handoverNote";
// Least specific last, so "Container Size" is not eaten by "container".
if (h.includes("container") || h.includes("number")) return "containerNumber";
return null;
}
/** "20", "20ft", "40 HC" … → '20' | '40' | null. */
function normalizeSize(raw: string): "20" | "40" | null {
const digits = raw.replace(/[^0-9]/g, "");
if (digits.startsWith("20")) return "20";
if (digits.startsWith("40") || digits.startsWith("45")) return "40";
return null;
}
/** "EDR", "EDR last mile", "customer", "self haul" … */
function normalizeReturnedBy(raw: string): "EDR" | "CUSTOMER" | null {
const v = raw.toLowerCase();
if (!v.trim()) return null;
if (v.includes("edr")) return "EDR";
if (v.includes("customer") || v.includes("self")) return "CUSTOMER";
return null;
}
/**
* Excel dates arrive either as a serial number (raw cells) or as text. Returns
* an ISO instant, or null when the cell is empty/unparseable.
*/
function normalizeDate(raw: string): string | null {
const v = raw.trim();
if (!v) return null;
// Excel serial: days since 1899-12-30.
if (/^\d{1,6}(\.\d+)?$/.test(v)) {
const serial = Number(v);
if (serial > 20000 && serial < 80000) {
return new Date(Math.round((serial - 25569) * 86400000)).toISOString();
}
}
const parsed = new Date(v);
return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString();
}
/**
* Parse an uploaded workbook into one row per empty container. Returns either
* the full row set or the list of row-numbered problems — never both.
*/
export async function parseContainerReturnExcel(file: File): Promise<ContainerReturnExcelResult> {
let sheet: XLSX.WorkSheet | undefined;
try {
const workbook = XLSX.read(await file.arrayBuffer(), { type: "array" });
sheet = workbook.Sheets[workbook.SheetNames[0]];
} catch {
return { rows: [], errors: ["Could not read the file — is it a valid Excel file?"] };
}
if (!sheet) return { rows: [], errors: ["The file has no sheets."] };
const grid = XLSX.utils.sheet_to_json<string[]>(sheet, { header: 1, raw: false, defval: "" });
// First row carrying a container-number column is the header; titles and
// blank rows above it are ignored.
let headerRowIdx = -1;
let columns: Array<ColumnKey | null> = [];
for (let i = 0; i < grid.length; i++) {
const mapped = (grid[i] ?? []).map((c) => headerKey(String(c ?? "")));
if (mapped.includes("containerNumber")) {
headerRowIdx = i;
columns = mapped;
break;
}
}
if (headerRowIdx < 0) {
return {
rows: [],
errors: [
'Could not find a "Container Number" column — download the template to see the expected format.',
],
};
}
const rows: ParsedReturnRow[] = [];
const errors: string[] = [];
const numberCounts = new Map<string, number>();
for (let i = headerRowIdx + 1; i < grid.length; i++) {
const cells = grid[i] ?? [];
if (cells.every((c) => String(c ?? "").trim() === "")) continue;
const rowNo = i + 1; // 1-based, as shown in Excel
const cell = (key: ColumnKey) => {
const idx = columns.indexOf(key);
return idx >= 0 ? String(cells[idx] ?? "").trim() : "";
};
const containerNumber = cell("containerNumber").toUpperCase().replace(/\s/g, "");
if (!ISO_CONTAINER_NUMBER_REGEX.test(containerNumber)) {
errors.push(
`Row ${rowNo}: "${cell("containerNumber") || "—"}" is not a valid ISO container number (e.g. TEMU1234567).`,
);
} else {
numberCounts.set(containerNumber, (numberCounts.get(containerNumber) ?? 0) + 1);
}
const sizeRaw = cell("containerSize");
const containerSize = sizeRaw ? normalizeSize(sizeRaw) : null;
if (sizeRaw && !containerSize) {
errors.push(`Row ${rowNo}: container size "${sizeRaw}" is not 20 or 40.`);
}
const returnedByRaw = cell("returnedBy");
const returnedBy = normalizeReturnedBy(returnedByRaw);
if (returnedByRaw && !returnedBy) {
errors.push(`Row ${rowNo}: returned by "${returnedByRaw}" must be EDR or CUSTOMER.`);
}
const dateRaw = cell("returnDate");
const returnDate = normalizeDate(dateRaw);
if (dateRaw && !returnDate) {
errors.push(`Row ${rowNo}: returned date "${dateRaw}" is not a date.`);
}
rows.push({
containerNumber,
containerSize,
companyName: cell("companyName"),
returnedBy,
returnDate,
facility: cell("facility"),
yard: cell("yard"),
zone: cell("zone"),
condition: cell("condition"),
handoverNote: cell("handoverNote"),
});
}
numberCounts.forEach((count, num) => {
if (count > 1) {
errors.push(`Container number ${num} appears ${count} times — numbers must be unique.`);
}
});
if (rows.length === 0 && errors.length === 0) {
errors.push("The sheet has no container rows below the header.");
}
return errors.length > 0 ? { rows: [], errors } : { rows, errors: [] };
}
/** Download the import template with one filled sample row. */
export function downloadContainerReturnTemplate() {
const headers = [
"Container Number",
"Container Size",
"Company",
"Returned By",
"Returned Date",
"Facility",
"Yard",
"Zone",
"Condition",
"Handover Note",
];
const sample = [
"TEMU1234567",
"40",
"Acme Import PLC",
"CUSTOMER",
new Date().toISOString().split("T")[0],
"Gelan Multipurpose port",
"Yard A",
"Zone 1",
"Sound",
"Backfilled from yard tally sheet",
];
const sheet = XLSX.utils.aoa_to_sheet([headers, sample]);
sheet["!cols"] = headers.map((h) => ({ wch: Math.max(h.length + 2, 18) }));
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, sheet, "Container Returns");
XLSX.writeFile(workbook, "container-return-import-template.xlsx");
}

View File

@@ -0,0 +1,26 @@
import { useQuery } from "@tanstack/react-query";
import { customersService } from "@/services/customers.service";
/**
* Registered customer companies, as Autocomplete options. The picker is an
* Autocomplete rather than a Select on purpose: a company that is not on the
* system yet is typed in, and only the name is kept.
*/
export function useCompanyOptions() {
const { data, isLoading } = useQuery({
queryKey: ["companies-autocomplete"],
queryFn: () => customersService.list({ page: 1, pageSize: 1000 }),
staleTime: 5 * 60 * 1000,
});
const companies = data?.items ?? [];
return {
loading: isLoading,
names: companies.map((c) => c.name),
/** Exact (case-insensitive) name match → company id, else undefined. */
resolveId: (name: string): string | undefined =>
companies.find((c) => c.name.trim().toLowerCase() === name.trim().toLowerCase())?.id,
};
}

View File

@@ -791,6 +791,7 @@ export const URL_CONSTANTS = {
CUSTOMS_RELEASE_PERMITTED: (bookingId: string) =>
`/import-operations/customs/${bookingId}/release-permitted`,
EMPTY_CONTAINER_RETURNS: "/import-operations/empty-container-returns",
EMPTY_CONTAINER_RETURNS_BULK: "/import-operations/empty-container-returns/bulk",
EMPTY_CONTAINER_RETURN_STATUS: (id: string) =>
`/import-operations/empty-container-returns/${id}/status`,
EMPTY_CONTAINER_RETURNS_LOAD_ON_TRAIN:

View File

@@ -18,8 +18,9 @@ import {
Textarea,
Select,
Checkbox,
Autocomplete,
} from "@mantine/core";
import { ChevronDown, ChevronRight, FileText, History } from "lucide-react";
import { ChevronDown, ChevronRight, FileText, History, Upload } from "lucide-react";
import { DataTable, type ColumnDef } from "@edr/ui-common";
import { PageContainer, PageHeader } from "@/components/page";
@@ -27,6 +28,8 @@ import ListControls from "@/components/common/ListControls";
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
import { extractDownloadErrorMessage } from "@/components/warehouses/options";
import { openPdfBlob } from "@/components/warehouses/pdf";
import BulkContainerReturnModal from "@/components/warehouses/BulkContainerReturnModal";
import { useCompanyOptions } from "@/components/warehouses/useCompanyOptions";
import { OverviewHorizontalBarChart } from "@/components/overview/OverviewHorizontalBarChart";
import { OverviewStackedBarChart } from "@/components/overview/OverviewStackedBarChart";
import { useListControls, toDayString } from "@/hooks/useListControls";
@@ -102,6 +105,7 @@ export default function ContainerReturnsPage() {
const [filterType, setFilterType] = useState<ReturnType>("all");
const [returnModalOpen, setReturnModalOpen] = useState(false);
const [standaloneModalOpen, setStandaloneModalOpen] = useState(false);
const [bulkModalOpen, setBulkModalOpen] = useState(false);
const [activeKey, setActiveKey] = useState<string | null>(null);
const [historyRow, setHistoryRow] = useState<any | null>(null);
const [allocateRow, setAllocateRow] = useState<EmptyContainerReturn | null>(null);
@@ -287,11 +291,14 @@ export default function ContainerReturnsPage() {
bookingId: string;
customerId: string | null;
returnType: "EDR" | "CUSTOMER";
companyName?: string;
containers: Array<{
containerNumber: string;
containerSize?: EmptyContainerSize;
returnDate: string;
warehouse: string;
yard?: string;
zone?: string;
condition?: string;
handoverNote?: string;
}>;
@@ -306,7 +313,10 @@ export default function ContainerReturnsPage() {
returnDate: new Date(container.returnDate).toISOString(),
bookingId: truck.bookingId,
customerId: truck.customerId ?? undefined,
companyName: truck.companyName,
facility: container.warehouse,
yard: container.yard,
zone: container.zone,
condition: container.condition,
handoverNote: container.handoverNote,
returnedBy: truck.returnType,
@@ -319,7 +329,9 @@ export default function ContainerReturnsPage() {
onSuccess: () => {
toast({ title: "Container returns recorded" });
qc.invalidateQueries({ queryKey: ["container-returns", bookingIds] });
qc.invalidateQueries({ queryKey: ["empty-container-returns"] });
setReturnModalOpen(false);
setStandaloneModalOpen(false);
setActiveKey(null);
},
onError: (error: any) => {
@@ -376,6 +388,11 @@ export default function ContainerReturnsPage() {
header: "Booking Ref",
cell: ({ row }) => (row.original.bookingId ? "Associated" : "—"),
},
{
id: "company",
header: "Company",
cell: ({ row }) => row.original.companyName || "—",
},
{
id: "returnedBy",
header: "Returned By",
@@ -510,9 +527,16 @@ export default function ContainerReturnsPage() {
{ label: "Customer Self-Haul", value: "customer" },
]}
/>
<Button onClick={() => setStandaloneModalOpen(true)}>
Record Return
</Button>
<Group gap="sm">
<Button
variant="default"
leftSection={<Upload size={16} />}
onClick={() => setBulkModalOpen(true)}
>
Bulk Upload
</Button>
<Button onClick={() => setStandaloneModalOpen(true)}>Record Return</Button>
</Group>
</Group>
{returnedContainers.length > 0 && (
@@ -688,6 +712,12 @@ export default function ContainerReturnsPage() {
loading={createReturnsMutation.isPending}
/>
<BulkContainerReturnModal
opened={bulkModalOpen}
onClose={() => setBulkModalOpen(false)}
onUploaded={() => qc.invalidateQueries({ queryKey: ["empty-container-returns"] })}
/>
<ExportTrainAllocationModal
row={allocateRow}
onClose={() => setAllocateRow(null)}
@@ -991,6 +1021,7 @@ interface StandaloneReturnModalProps {
function StandaloneReturnModal({ opened, onClose, onSubmit, loading }: StandaloneReturnModalProps) {
const [containerNumber, setContainerNumber] = useState<string>("");
const [company, setCompany] = useState<string>("");
const [containerSize, setContainerSize] = useState<EmptyContainerSize | null>(null);
const [returnedBy, setReturnedBy] = useState<"EDR" | "CUSTOMER" | null>(null);
const [returnDate, setReturnDate] = useState<string>(new Date().toISOString().split("T")[0]);
@@ -1009,6 +1040,7 @@ function StandaloneReturnModal({ opened, onClose, onSubmit, loading }: Standalon
const warehouses = (warehousesResponse as any)?.data ?? warehousesResponse ?? [];
const companies = useCompanyOptions();
const { data: yards } = useWarehouseYards(warehouse ?? undefined);
const { data: zones } = useWarehouseZones(yardId ?? undefined);
@@ -1047,7 +1079,8 @@ function StandaloneReturnModal({ opened, onClose, onSubmit, loading }: Standalon
trucks: [
{
bookingId: null,
customerId: null,
customerId: company ? (companies.resolveId(company) ?? null) : null,
companyName: company || undefined,
returnType: returnedBy,
containers: [
{
@@ -1066,6 +1099,7 @@ function StandaloneReturnModal({ opened, onClose, onSubmit, loading }: Standalon
});
setContainerNumber("");
setCompany("");
setContainerSize(null);
setReturnedBy(null);
setReturnDate(new Date().toISOString().split("T")[0]);
@@ -1092,6 +1126,16 @@ function StandaloneReturnModal({ opened, onClose, onSubmit, loading }: Standalon
required
/>
<Autocomplete
label="Company"
description="Pick a registered customer, or type a company that is not on the system yet"
placeholder={companies.loading ? "Loading companies…" : "Search or type a company"}
data={companies.names}
value={company}
onChange={setCompany}
limit={20}
/>
<Select
label="Returned By"
placeholder="Select truck type"

View File

@@ -124,6 +124,17 @@ export const importOperationsService = {
return unwrap(response.data);
},
/** Backfill empties already in the yard. All-or-nothing on the server. */
bulkCreateEmptyReturns: async (
returns: CreateEmptyContainerReturnPayload[],
): Promise<EmptyContainerReturn[]> => {
const response = await client.post<EmptyContainerReturn[]>(
URL_CONSTANTS.IMPORT_OPERATIONS.EMPTY_CONTAINER_RETURNS_BULK,
{ returns },
);
return unwrap(response.data);
},
loadEmptyContainersOnTrain: async (
payload: LoadEmptyContainersOnTrainPayload,
): Promise<EmptyContainerReturn[]> => {

View File

@@ -93,6 +93,8 @@ export interface EmptyContainerReturn {
containerNumber: string;
bookingId: string | null;
customerId: string | null;
/** Owning company as text — set for backfilled boxes whose company is unregistered. */
companyName: string | null;
returnDate: string;
facility: string | null;
yard: string | null;
@@ -122,6 +124,7 @@ export interface CreateEmptyContainerReturnPayload {
containerSize?: EmptyContainerSize;
bookingId?: string;
customerId?: string;
companyName?: string;
returnDate?: string;
facility?: string;
yard?: string;