feat(import-operations): record empty container return per booking

Container Returns only ever offered the last-mile path: a booking reached
the list once it had a truck assigned and warehouse inventory flagged as
returning. Bookings that ship WITH equipment return had no way in, so the
empties they owe were invisible until that path happened to fire.

Adds GET /import-operations/empty-return-bookings — the containers a
booking flagged is_return, carrying whichever of them already has an
empty return recorded, grouped one row per booking and dropped from the
list once nothing is pending. Covers both spellings of the booking's
equipment_return (WITH_RETURN and the older RETURN) and skips bookings
that never ship.

Backoffice grows a "Bookings With Empty Container Return" card above the
existing sections: pick the booking, tick the containers coming back,
say where they landed, and each tick becomes an empty container return
on that booking — which is what the Returned Containers table then
advances. The existing last-mile, standalone and bulk flows are
untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hager
2026-09-02 10:17:19 +00:00
parent 2e33e9aeb3
commit 2e51342d1e
8 changed files with 778 additions and 1 deletions

View File

@@ -816,6 +816,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_RETURN_BOOKINGS: "/import-operations/empty-return-bookings",
EMPTY_CONTAINER_RETURNS_BULK: "/import-operations/empty-container-returns/bulk",
EMPTY_CONTAINER_RETURN_STATUS: (id: string) =>
`/import-operations/empty-container-returns/${id}/status`,

View File

@@ -27,7 +27,7 @@ import { DataTable, type ColumnDef } from "@edr/ui-common";
import { PageContainer, PageHeader } from "@/components/page";
import ListControls from "@/components/common/ListControls";
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
import { extractDownloadErrorMessage } from "@/components/warehouses/options";
import { extractDownloadErrorMessage, extractErrorMessage } from "@/components/warehouses/options";
import { openPdfBlob } from "@/components/warehouses/pdf";
import BulkContainerReturnModal from "@/components/warehouses/BulkContainerReturnModal";
import { downloadContainerReturnTemplate } from "@/components/warehouses/container-return-excel";
@@ -44,6 +44,7 @@ import type {
EmptyContainerReturn,
EmptyContainerReturnStatus,
EmptyContainerSize,
EmptyReturnBooking,
} from "@/types/importOperations";
import type { TrainScheduleListItem } from "@/types/trainScheduling";
import { formatDateTime, localNowForInput } from "@/lib/format";
@@ -111,6 +112,8 @@ export default function ContainerReturnsPage() {
const [activeKey, setActiveKey] = useState<string | null>(null);
const [historyRow, setHistoryRow] = useState<any | null>(null);
const [allocateRow, setAllocateRow] = useState<EmptyContainerReturn | null>(null);
const [emptyReturnBooking, setEmptyReturnBooking] = useState<EmptyReturnBooking | null>(null);
const [expandedBooking, setExpandedBooking] = useState<string | null>(null);
const [documentBusyId, setDocumentBusyId] = useState<string | null>(null);
const viewInterchangeDocument = async (ret: EmptyContainerReturn) => {
@@ -146,6 +149,16 @@ export default function ContainerReturnsPage() {
},
});
// Bookings that ship WITH empty-container return and still owe empties. This
// list stands on the booking's own return flags, so it does not wait for the
// box to reach a warehouse or for a last-mile truck to be assigned — the
// queue below still covers that path.
const emptyReturnBookingsQuery = useQuery({
queryKey: ["empty-return-bookings"],
queryFn: () => importOperationsService.listEmptyReturnBookings(),
});
const emptyReturnBookings = emptyReturnBookingsQuery.data ?? [];
const bookingIds = unloadedQueue.map((item) => item.bookingId).filter(Boolean) as string[];
const containerReturnsQuery = useQuery({
queryKey: ["container-returns", bookingIds],
@@ -287,6 +300,10 @@ export default function ContainerReturnsPage() {
searchKeys: ["bookingRef", "companyName"],
});
const bookingReturnControls = useListControls(emptyReturnBookings, {
searchKeys: ["bookingReference", "companyName", "bookingStatus"],
});
const createReturnsMutation = useMutation({
mutationFn: async (payload: {
trucks: Array<{
@@ -332,9 +349,11 @@ export default function ContainerReturnsPage() {
toast({ title: "Container returns recorded" });
qc.invalidateQueries({ queryKey: ["container-returns", bookingIds] });
qc.invalidateQueries({ queryKey: ["empty-container-returns"] });
qc.invalidateQueries({ queryKey: ["empty-return-bookings"] });
setReturnModalOpen(false);
setStandaloneModalOpen(false);
setActiveKey(null);
setEmptyReturnBooking(null);
},
onError: (error: any) => {
toast({
@@ -548,6 +567,163 @@ export default function ContainerReturnsPage() {
</Group>
</Group>
<Card withBorder radius="lg" p="md" mb="lg">
<Stack gap="md">
<Group justify="space-between" align="flex-start">
<div>
<Text fw={600}>Bookings With Empty Container Return</Text>
<Text size="sm" c="dimmed">
Bookings that ship with equipment return and still owe empties. Open one to pick
the containers coming back, then record the return for that booking.
</Text>
</div>
<Badge variant="light" size="lg">
{emptyReturnBookings.length} booking{emptyReturnBookings.length !== 1 ? "s" : ""}
</Badge>
</Group>
<ListControls
search={bookingReturnControls.search}
onSearchChange={bookingReturnControls.setSearch}
searchPlaceholder="Search booking, company, status…"
dateFrom={bookingReturnControls.dateFrom}
onDateFromChange={bookingReturnControls.setDateFrom}
dateTo={bookingReturnControls.dateTo}
onDateToChange={bookingReturnControls.setDateTo}
showDateRange={false}
hasFilters={bookingReturnControls.hasFilters}
onReset={bookingReturnControls.reset}
/>
{emptyReturnBookingsQuery.isLoading ? (
<Group justify="center" py="md">
<Loader size="sm" />
</Group>
) : emptyReturnBookingsQuery.isError ? (
<Alert color="red">
Could not load bookings with empty container return.{" "}
{extractErrorMessage(emptyReturnBookingsQuery.error)}
</Alert>
) : bookingReturnControls.pagedRows.length === 0 ? (
<Alert color="gray">
No booking is waiting on an empty container return.
</Alert>
) : (
<>
<Table.ScrollContainer minWidth={900}>
<Table highlightOnHover verticalSpacing="xs">
<Table.Thead>
<Table.Tr>
<Table.Th w={40} />
<Table.Th>Booking Ref</Table.Th>
<Table.Th>Company</Table.Th>
<Table.Th>Booking Status</Table.Th>
<Table.Th>Containers To Return</Table.Th>
<Table.Th ta="right">Action</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{bookingReturnControls.pagedRows.map((booking) => {
const isOpen = expandedBooking === booking.bookingId;
return (
<Fragment key={booking.bookingId}>
<Table.Tr>
<Table.Td>
<ActionIcon
variant="subtle"
color="gray"
onClick={() =>
setExpandedBooking(isOpen ? null : booking.bookingId)
}
title={isOpen ? "Hide containers" : "Show containers"}
>
{isOpen ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</ActionIcon>
</Table.Td>
<Table.Td>
<Text fw={600}>{booking.bookingReference}</Text>
</Table.Td>
<Table.Td>{booking.companyName ?? "—"}</Table.Td>
<Table.Td>
<Badge size="sm" variant="light">
{booking.bookingStatus.replaceAll("_", " ")}
</Badge>
</Table.Td>
<Table.Td>
<Group gap="xs">
<Badge color="orange">{booking.pendingCount} pending</Badge>
{booking.recordedCount > 0 && (
<Badge color="edr-green" variant="light">
{booking.recordedCount} recorded
</Badge>
)}
</Group>
</Table.Td>
<Table.Td ta="right">
<Button
size="xs"
variant="light"
onClick={() => setEmptyReturnBooking(booking)}
>
Empty Container Return
</Button>
</Table.Td>
</Table.Tr>
{isOpen && (
<Table.Tr>
<Table.Td colSpan={6}>
<Table striped>
<Table.Thead>
<Table.Tr>
<Table.Th>Container</Table.Th>
<Table.Th>Size</Table.Th>
<Table.Th>Type</Table.Th>
<Table.Th>Return Status</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{booking.containers.map((container) => (
<Table.Tr key={container.key}>
<Table.Td>{container.containerNumber}</Table.Td>
<Table.Td>{container.containerSize ?? "—"}</Table.Td>
<Table.Td>{container.containerType ?? "—"}</Table.Td>
<Table.Td>
{container.returnStatus ? (
<Badge size="sm" color="edr-green" variant="light">
{RETURN_STATUS_LABEL[container.returnStatus] ??
container.returnStatus}
</Badge>
) : (
<Badge size="sm" color="orange" variant="light">
Awaiting return
</Badge>
)}
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.Td>
</Table.Tr>
)}
</Fragment>
);
})}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
<RuleEngineListFooter
pagination={bookingReturnControls.pagination}
pageCount={bookingReturnControls.pageCount}
totalCount={bookingReturnControls.totalCount}
itemLabel="bookings"
onPaginationChange={bookingReturnControls.setPagination}
/>
</>
)}
</Stack>
</Card>
{returnedContainers.length > 0 && (
<Card withBorder radius="lg" p="md" mb="lg">
<Stack gap="md">
@@ -721,6 +897,13 @@ export default function ContainerReturnsPage() {
loading={createReturnsMutation.isPending}
/>
<BookingEmptyReturnModal
booking={emptyReturnBooking}
onClose={() => setEmptyReturnBooking(null)}
onSubmit={(payload) => createReturnsMutation.mutate(payload)}
loading={createReturnsMutation.isPending}
/>
<BulkContainerReturnModal
opened={bulkModalOpen}
onClose={() => setBulkModalOpen(false)}
@@ -1028,6 +1211,297 @@ function ContainerReturnModal({ opened, onClose, group, onSubmit, loading }: Con
);
}
interface BookingEmptyReturnModalProps {
booking: EmptyReturnBooking | null;
onClose: () => void;
onSubmit: (payload: any) => void;
loading: boolean;
}
/**
* Records the empty return for ONE booking: tick the containers coming back,
* say where they landed, and every tick becomes an empty container return on
* that booking. Containers whose return is already recorded stay visible but
* cannot be ticked again. A legacy booking that never captured container
* numbers shows numberless slots — the number is typed here instead.
*/
function BookingEmptyReturnModal({ booking, onClose, onSubmit, loading }: BookingEmptyReturnModalProps) {
const [selected, setSelected] = useState<string[]>([]);
const [returnedBy, setReturnedBy] = useState<"EDR" | "CUSTOMER" | null>(null);
const [returnDate, setReturnDate] = useState<string>(localNowForInput());
const [warehouse, setWarehouse] = useState<string | null>(null);
const [yardId, setYardId] = useState<string | null>(null);
const [zoneId, setZoneId] = useState<string | null>(null);
const [condition, setCondition] = useState<string>("");
const [handoverNote, setHandoverNote] = useState<string>("");
const bookingId = booking?.bookingId ?? null;
// A fresh booking starts from a clean form — never inherit the last one's
// ticks, typed numbers, or placement.
useEffect(() => {
setSelected([]);
setReturnedBy(null);
setReturnDate(localNowForInput());
setWarehouse(null);
setYardId(null);
setZoneId(null);
setCondition("");
setHandoverNote("");
}, [bookingId]);
const { data: warehousesResponse } = useQuery({
queryKey: ["warehouses-list"],
queryFn: async () => {
return await warehouseService.list({});
},
});
const warehouses = (warehousesResponse as any)?.data ?? warehousesResponse ?? [];
const { data: yards } = useWarehouseYards(warehouse ?? undefined);
const { data: zones } = useWarehouseZones(yardId ?? undefined);
useEffect(() => {
setYardId(null);
setZoneId(null);
}, [warehouse]);
useEffect(() => {
setZoneId(null);
}, [yardId]);
const warehouseOptions = Array.isArray(warehouses)
? warehouses.map((wh: any) => ({
value: wh.id,
label: `${wh.name} ${wh.code ? `(${wh.code})` : ""}`,
}))
: [];
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 pending = (booking?.containers ?? []).filter((container) => !container.returnId);
const toggle = (key: string, checked: boolean) =>
setSelected((current) => (checked ? [...current, key] : current.filter((k) => k !== key)));
const handleSubmit = () => {
if (!booking || !selected.length || !warehouse || !returnedBy) return;
const selectedWarehouse = Array.isArray(warehouses)
? warehouses.find((wh: any) => wh.id === warehouse)
: null;
const selectedYard = yards?.find((y) => y.id === yardId);
const selectedZone = zones?.find((z) => z.id === zoneId);
const containers = pending
.filter((container) => selected.includes(container.key))
.map((container) => ({
containerNumber: container.containerNumber,
// The booking records "20ft"/"40ft"; the wagon rule only needs the number.
containerSize: container.containerSize?.includes("40")
? ("40" as const)
: container.containerSize?.includes("20")
? ("20" as const)
: undefined,
returnDate,
warehouse: selectedWarehouse?.name || warehouse,
yard: selectedYard?.name,
zone: selectedZone?.name,
condition: condition || undefined,
handoverNote: handoverNote || undefined,
}));
onSubmit({
trucks: [
{
bookingId: booking.bookingId,
customerId: booking.customerId,
companyName: booking.companyName ?? undefined,
returnType: returnedBy,
containers,
},
],
});
};
return (
<Modal
opened={!!booking}
onClose={onClose}
title="Empty Container Return"
size="lg"
>
{booking && (
<Stack gap="md">
<Group gap="sm">
<Text fw={600}>{booking.bookingReference}</Text>
{booking.companyName && <Text c="dimmed">{booking.companyName}</Text>}
<Badge size="sm" variant="light">
{booking.bookingStatus.replaceAll("_", " ")}
</Badge>
</Group>
<div>
<Group justify="space-between" mb="xs">
<Text size="sm" fw={600}>
Containers to return
</Text>
<Button
size="compact-xs"
variant="subtle"
onClick={() =>
setSelected(
selected.length === pending.length ? [] : pending.map((c) => c.key),
)
}
>
{selected.length === pending.length ? "Clear all" : "Select all"}
</Button>
</Group>
<Table>
<Table.Thead>
<Table.Tr>
<Table.Th w={40} />
<Table.Th>Container</Table.Th>
<Table.Th w={90}>Size</Table.Th>
<Table.Th w={150}>Status</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{booking.containers.map((container) => {
const recorded = Boolean(container.returnId);
return (
<Table.Tr key={container.key}>
<Table.Td>
<Checkbox
checked={selected.includes(container.key)}
disabled={recorded}
onChange={(e) => toggle(container.key, e.currentTarget.checked)}
/>
</Table.Td>
<Table.Td>
<Text size="sm">{container.containerNumber}</Text>
</Table.Td>
<Table.Td>{container.containerSize ?? "—"}</Table.Td>
<Table.Td>
{recorded ? (
<Badge size="sm" color="edr-green" variant="light">
{(container.returnStatus &&
RETURN_STATUS_LABEL[container.returnStatus]) ??
"Recorded"}
</Badge>
) : (
<Badge size="sm" color="orange" variant="light">
Awaiting return
</Badge>
)}
</Table.Td>
</Table.Tr>
);
})}
</Table.Tbody>
</Table>
</div>
<Select
label="Returned By"
placeholder="Select truck type"
value={returnedBy}
onChange={(val) => setReturnedBy(val as "EDR" | "CUSTOMER" | null)}
data={[
{ value: "EDR", label: "EDR Last Mile" },
{ value: "CUSTOMER", label: "Customer Self-Haul" },
]}
required
/>
<Select
label="Return Warehouse"
placeholder="Select warehouse for container return"
value={warehouse}
onChange={setWarehouse}
data={warehouseOptions}
required
searchable
/>
<Select
label="Yard"
placeholder={warehouse ? "Select yard" : "Select warehouse first"}
value={yardId}
onChange={setYardId}
data={yardOptions}
disabled={!warehouse}
searchable
/>
<Select
label="Zone"
placeholder={yardId ? "Select zone" : "Select yard first"}
value={zoneId}
onChange={setZoneId}
data={zoneOptions}
disabled={!yardId}
searchable
/>
<Input.Wrapper label="Returned Date & Time" required>
<input
type="datetime-local"
value={returnDate}
onChange={(e) => setReturnDate(e.target.value)}
style={{
padding: "8px",
borderRadius: "4px",
border: "1px solid #ced4da",
width: "100%",
}}
required
/>
</Input.Wrapper>
<Textarea
label="Condition"
placeholder="Damage, residue, or cleanliness notes"
value={condition}
onChange={(e) => setCondition(e.currentTarget.value)}
rows={3}
/>
<Textarea
label="Handover Note"
placeholder="Consignee, trucker, or authorization notes"
value={handoverNote}
onChange={(e) => setHandoverNote(e.currentTarget.value)}
rows={3}
/>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={onClose} disabled={loading}>
Cancel
</Button>
<Button
onClick={handleSubmit}
disabled={!selected.length || !warehouse || !returnedBy}
loading={loading}
>
Record Empty Return ({selected.length})
</Button>
</Group>
</Stack>
)}
</Modal>
);
}
interface StandaloneReturnModalProps {
opened: boolean;
onClose: () => void;

View File

@@ -8,6 +8,7 @@ import type {
LoadEmptyContainersOnTrainPayload,
DjiboutiIncident,
EmptyContainerReturn,
EmptyReturnBooking,
ImportCustomsFinalization,
ImportOperationActionPayload,
RecordDeclarationPayload,
@@ -114,6 +115,14 @@ export const importOperationsService = {
return unwrap(response.data);
},
/** Bookings that ship with empty-container return and still owe empties back. */
listEmptyReturnBookings: async (): Promise<EmptyReturnBooking[]> => {
const response = await client.get<EmptyReturnBooking[]>(
URL_CONSTANTS.IMPORT_OPERATIONS.EMPTY_RETURN_BOOKINGS,
);
return unwrap(response.data);
},
createEmptyReturn: async (
payload: CreateEmptyContainerReturnPayload,
): Promise<EmptyContainerReturn> => {

View File

@@ -121,6 +121,33 @@ export interface EmptyContainerReturn {
export type EmptyContainerSize = '20' | '40';
/** One container a with-return booking owes back empty. */
export interface EmptyReturnBookingContainer {
/** Stable row key — the booking container unit id. */
key: string;
unitId: string;
containerNumber: string;
containerSize: string | null;
containerType: string | null;
/** Set once this container's empty return has been recorded. */
returnId: string | null;
returnStatus: EmptyContainerReturnStatus | null;
}
/** A booking that ships with empty-container return and still owes empties. */
export interface EmptyReturnBooking {
bookingId: string;
bookingReference: string;
bookingStatus: string;
equipmentReturn: string;
customerId: string | null;
companyName: string | null;
containers: EmptyReturnBookingContainer[];
expectedCount: number;
recordedCount: number;
pendingCount: number;
}
export interface CreateEmptyContainerReturnPayload {
containerNumber: string;
containerSize?: EmptyContainerSize;