Merge pull request #1475 from Tria-plc/Emty-container-return

feat(import-operations): record empty container return per booking
This commit is contained in:
Hagernesh Tadesse
2026-09-02 13:20:09 +03:00
committed by GitHub
8 changed files with 778 additions and 1 deletions

View File

@@ -0,0 +1,98 @@
import {
assembleEmptyReturnBookings,
type EmptyReturnBookingUnitRow,
} from './empty-return-bookings.util';
const booking = {
bookingId: 'b1',
bookingReference: 'BK-2026-000263',
bookingStatus: 'IN_TRANSIT',
equipmentReturn: 'WITH_RETURN',
customerId: 'c1',
companyName: 'Afri Software Solutions',
};
const unit = (
overrides: Partial<EmptyReturnBookingUnitRow> & { unitId: string; containerNumber: string },
): EmptyReturnBookingUnitRow => ({
...booking,
containerSize: '40ft',
containerType: '40FT',
returnId: null,
returnStatus: null,
...overrides,
});
describe('assembleEmptyReturnBookings', () => {
it('groups a bookings flagged containers onto one row, all pending', () => {
const rows = assembleEmptyReturnBookings([
unit({ unitId: 'u1', containerNumber: 'MSFH8596324' }),
unit({ unitId: 'u2', containerNumber: 'SDJU8596324' }),
]);
expect(rows).toHaveLength(1);
expect(rows[0].bookingReference).toBe('BK-2026-000263');
expect(rows[0].companyName).toBe('Afri Software Solutions');
expect(rows[0].containers.map((c) => c.containerNumber)).toEqual([
'MSFH8596324',
'SDJU8596324',
]);
expect(rows[0]).toMatchObject({ expectedCount: 2, recordedCount: 0, pendingCount: 2 });
});
it('keeps an already-recorded container visible but out of the pending count', () => {
const rows = assembleEmptyReturnBookings([
unit({
unitId: 'u1',
containerNumber: 'MSFH8596324',
returnId: 'r1',
returnStatus: 'ASSIGNED_STORAGE',
}),
unit({ unitId: 'u2', containerNumber: 'SDJU8596324' }),
]);
expect(rows[0]).toMatchObject({ expectedCount: 2, recordedCount: 1, pendingCount: 1 });
expect(rows[0].containers[0].returnStatus).toBe('ASSIGNED_STORAGE');
});
it('drops a booking once every container is recorded', () => {
const rows = assembleEmptyReturnBookings([
unit({
unitId: 'u1',
containerNumber: 'MSFH8596324',
returnId: 'r1',
returnStatus: 'RETURNED',
}),
unit({
unitId: 'u2',
containerNumber: 'SDJU8596324',
returnId: 'r2',
returnStatus: 'COMPLETED',
}),
]);
expect(rows).toEqual([]);
});
it('keeps each booking on its own row, in query order', () => {
const other = {
...booking,
bookingId: 'b2',
bookingReference: 'BK-2026-000286',
companyName: 'DE BE KE',
};
const rows = assembleEmptyReturnBookings([
unit({ unitId: 'u1', containerNumber: 'MSFH8596324' }),
{ ...unit({ unitId: 'u2', containerNumber: 'ASDS1234567' }), ...other },
unit({ unitId: 'u3', containerNumber: 'SDJU8596324' }),
]);
expect(rows.map((r) => r.bookingReference)).toEqual(['BK-2026-000263', 'BK-2026-000286']);
expect(rows[0].containers).toHaveLength(2);
expect(rows[1].containers).toHaveLength(1);
});
it('returns nothing when no booking owes an empty', () => {
expect(assembleEmptyReturnBookings([])).toEqual([]);
});
});

View File

@@ -0,0 +1,107 @@
import type { EmptyContainerReturnStatus } from './entities/empty-container-return.entity';
/**
* `WITH_RETURN` is the current value; `RETURN` is what older bookings were
* written with. Both mean the same thing — the booking owes empties back.
*/
export const WITH_RETURN_EQUIPMENT_VALUES = ['WITH_RETURN', 'RETURN'];
/** Bookings in these statuses never ship, so they never owe an empty back. */
export const EMPTY_RETURN_CLOSED_BOOKING_STATUSES = ['DRAFT', 'CANCELLED', 'REJECTED', 'EXPIRED'];
/**
* One flagged return container of a booking, as the query hands it over: the
* booking columns repeat on every row, and `returnId` is set when this exact
* container already has an empty return recorded against the booking.
*/
export interface EmptyReturnBookingUnitRow {
bookingId: string;
bookingReference: string;
bookingStatus: string;
equipmentReturn: string;
customerId: string | null;
companyName: string | null;
unitId: string;
containerNumber: string;
containerSize: string | null;
containerType: string | null;
returnId: string | null;
returnStatus: EmptyContainerReturnStatus | null;
}
/** One container a 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 the empty return for this container has been recorded. */
returnId: string | null;
returnStatus: EmptyContainerReturnStatus | null;
}
/** A booking that ships with empty-container return and still owes empties. */
export interface EmptyReturnBookingRow {
bookingId: string;
bookingReference: string;
bookingStatus: string;
equipmentReturn: string;
customerId: string | null;
companyName: string | null;
containers: EmptyReturnBookingContainer[];
expectedCount: number;
recordedCount: number;
pendingCount: number;
}
/**
* Groups a booking's flagged return containers onto one row per booking.
*
* A container whose empty return is already recorded keeps its row — the
* screen shows what has been done — but stops counting as pending, and a
* booking with nothing left pending drops off the list entirely.
*
* Row order follows the query (newest booking first, containers in booking
* order), so the caller decides the ordering, not this function.
*/
export function assembleEmptyReturnBookings(
units: EmptyReturnBookingUnitRow[],
): EmptyReturnBookingRow[] {
const rows = new Map<string, EmptyReturnBookingRow>();
for (const unit of units) {
const row = rows.get(unit.bookingId) ?? {
bookingId: unit.bookingId,
bookingReference: unit.bookingReference,
bookingStatus: unit.bookingStatus,
equipmentReturn: unit.equipmentReturn,
customerId: unit.customerId,
companyName: unit.companyName,
containers: [],
expectedCount: 0,
recordedCount: 0,
pendingCount: 0,
};
row.containers.push({
key: unit.unitId,
unitId: unit.unitId,
containerNumber: unit.containerNumber,
containerSize: unit.containerSize,
containerType: unit.containerType,
returnId: unit.returnId,
returnStatus: unit.returnStatus,
});
rows.set(unit.bookingId, row);
}
return [...rows.values()]
.map((row) => ({
...row,
expectedCount: row.containers.length,
recordedCount: row.containers.filter((container) => container.returnId).length,
pendingCount: row.containers.filter((container) => !container.returnId).length,
}))
.filter((row) => row.pendingCount > 0);
}

View File

@@ -119,6 +119,15 @@ export class ImportOperationsController {
return this.service.listEmptyReturns();
}
@Get('empty-return-bookings')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({
summary: 'Bookings shipping with empty-container return that still owe empties, with their containers',
})
listEmptyReturnBookings() {
return this.service.listEmptyReturnBookings();
}
@Post('empty-container-returns')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ summary: 'Batch 16: create an empty container return record' })

View File

@@ -25,6 +25,13 @@ import {
type DjiboutiIncidentType,
} from './entities/djibouti-incident.entity';
import { assertWagonLoad } from './empty-container-wagon.util';
import {
assembleEmptyReturnBookings,
EMPTY_RETURN_CLOSED_BOOKING_STATUSES,
WITH_RETURN_EQUIPMENT_VALUES,
type EmptyReturnBookingRow,
type EmptyReturnBookingUnitRow,
} from './empty-return-bookings.util';
import {
EmptyContainerReturn,
type EmptyContainerReturnListItem,
@@ -207,6 +214,51 @@ export class ImportOperationsService {
return this.emptyReturns.find({ where: { bookingId }, order: { createdAt: 'DESC' } as never });
}
/**
* Bookings that ship WITH empty-container return and still owe empties, each
* with the containers that are to be returned — the ones the booking flagged
* `is_return`, carrying the empty return already recorded against each, if
* any.
*/
async listEmptyReturnBookings(): Promise<EmptyReturnBookingRow[]> {
const units: EmptyReturnBookingUnitRow[] = await this.emptyReturns.manager.query(
`SELECT b.id AS "bookingId",
b.reference AS "bookingReference",
b.status AS "bookingStatus",
b.equipment_return AS "equipmentReturn",
b.company_id AS "customerId",
c.name AS "companyName",
u.id AS "unitId",
u.container_number AS "containerNumber",
COALESCE(bc.container_size, ct.code) AS "containerSize",
ct.label AS "containerType",
r.id AS "returnId",
r.status AS "returnStatus"
FROM freight.booking_container_units u
JOIN freight.booking_container bc ON bc.id = u.booking_container_id AND bc.deleted_at IS NULL
JOIN freight.bookings b ON b.id = bc.booking_id AND b.deleted_at IS NULL
LEFT JOIN freight.companies c ON c.id = b.company_id
LEFT JOIN freight.container_types ct ON ct.id = bc.container_type_id
LEFT JOIN LATERAL (
SELECT er.id, er.status
FROM freight.empty_container_returns er
WHERE er.deleted_at IS NULL
AND er.booking_id = b.id
AND upper(er.container_number) = upper(u.container_number)
ORDER BY er.created_at DESC
LIMIT 1
) r ON TRUE
WHERE u.deleted_at IS NULL
AND u.is_return = true
AND b.equipment_return = ANY($1)
AND b.status <> ALL($2)
ORDER BY b.created_at DESC, u.sort_order ASC`,
[WITH_RETURN_EQUIPMENT_VALUES, EMPTY_RETURN_CLOSED_BOOKING_STATUSES],
);
return assembleEmptyReturnBookings(units);
}
async createEmptyReturn(dto: CreateEmptyContainerReturnDto) {
const returnDate = dto.returnDate ? new Date(dto.returnDate) : new Date();
const saved = await this.emptyReturns.save(

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;