diff --git a/apps/edr-freight-api/src/modules/import-operations/empty-return-bookings.util.spec.ts b/apps/edr-freight-api/src/modules/import-operations/empty-return-bookings.util.spec.ts new file mode 100644 index 000000000..848732f47 --- /dev/null +++ b/apps/edr-freight-api/src/modules/import-operations/empty-return-bookings.util.spec.ts @@ -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 & { unitId: string; containerNumber: string }, +): EmptyReturnBookingUnitRow => ({ + ...booking, + containerSize: '40ft', + containerType: '40FT', + returnId: null, + returnStatus: null, + ...overrides, +}); + +describe('assembleEmptyReturnBookings', () => { + it('groups a booking’s 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([]); + }); +}); diff --git a/apps/edr-freight-api/src/modules/import-operations/empty-return-bookings.util.ts b/apps/edr-freight-api/src/modules/import-operations/empty-return-bookings.util.ts new file mode 100644 index 000000000..e2f56df9d --- /dev/null +++ b/apps/edr-freight-api/src/modules/import-operations/empty-return-bookings.util.ts @@ -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(); + + 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); +} diff --git a/apps/edr-freight-api/src/modules/import-operations/import-operations.controller.ts b/apps/edr-freight-api/src/modules/import-operations/import-operations.controller.ts index 1116e9440..53875d64d 100644 --- a/apps/edr-freight-api/src/modules/import-operations/import-operations.controller.ts +++ b/apps/edr-freight-api/src/modules/import-operations/import-operations.controller.ts @@ -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' }) diff --git a/apps/edr-freight-api/src/modules/import-operations/import-operations.service.ts b/apps/edr-freight-api/src/modules/import-operations/import-operations.service.ts index 9a84a0551..e9c53f882 100644 --- a/apps/edr-freight-api/src/modules/import-operations/import-operations.service.ts +++ b/apps/edr-freight-api/src/modules/import-operations/import-operations.service.ts @@ -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 { + 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( diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index d09b9f4c5..1e8a1c602 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -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`, diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/ContainerReturnsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/ContainerReturnsPage.tsx index c3896ca31..4b931d9cd 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/ContainerReturnsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/ContainerReturnsPage.tsx @@ -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(null); const [historyRow, setHistoryRow] = useState(null); const [allocateRow, setAllocateRow] = useState(null); + const [emptyReturnBooking, setEmptyReturnBooking] = useState(null); + const [expandedBooking, setExpandedBooking] = useState(null); const [documentBusyId, setDocumentBusyId] = useState(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() { + + + +
+ Bookings With Empty Container Return + + 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. + +
+ + {emptyReturnBookings.length} booking{emptyReturnBookings.length !== 1 ? "s" : ""} + +
+ + + + {emptyReturnBookingsQuery.isLoading ? ( + + + + ) : emptyReturnBookingsQuery.isError ? ( + + Could not load bookings with empty container return.{" "} + {extractErrorMessage(emptyReturnBookingsQuery.error)} + + ) : bookingReturnControls.pagedRows.length === 0 ? ( + + No booking is waiting on an empty container return. + + ) : ( + <> + + + + + + Booking Ref + Company + Booking Status + Containers To Return + Action + + + + {bookingReturnControls.pagedRows.map((booking) => { + const isOpen = expandedBooking === booking.bookingId; + return ( + + + + + setExpandedBooking(isOpen ? null : booking.bookingId) + } + title={isOpen ? "Hide containers" : "Show containers"} + > + {isOpen ? : } + + + + {booking.bookingReference} + + {booking.companyName ?? "—"} + + + {booking.bookingStatus.replaceAll("_", " ")} + + + + + {booking.pendingCount} pending + {booking.recordedCount > 0 && ( + + {booking.recordedCount} recorded + + )} + + + + + + + {isOpen && ( + + +
+ + + Container + Size + Type + Return Status + + + + {booking.containers.map((container) => ( + + {container.containerNumber} + {container.containerSize ?? "—"} + {container.containerType ?? "—"} + + {container.returnStatus ? ( + + {RETURN_STATUS_LABEL[container.returnStatus] ?? + container.returnStatus} + + ) : ( + + Awaiting return + + )} + + + ))} + +
+ + + )} + + ); + })} + + +
+ + + )} +
+
+ {returnedContainers.length > 0 && ( @@ -721,6 +897,13 @@ export default function ContainerReturnsPage() { loading={createReturnsMutation.isPending} /> + setEmptyReturnBooking(null)} + onSubmit={(payload) => createReturnsMutation.mutate(payload)} + loading={createReturnsMutation.isPending} + /> + 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([]); + const [returnedBy, setReturnedBy] = useState<"EDR" | "CUSTOMER" | null>(null); + const [returnDate, setReturnDate] = useState(localNowForInput()); + const [warehouse, setWarehouse] = useState(null); + const [yardId, setYardId] = useState(null); + const [zoneId, setZoneId] = useState(null); + const [condition, setCondition] = useState(""); + const [handoverNote, setHandoverNote] = useState(""); + + 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 ( + + {booking && ( + + + {booking.bookingReference} + {booking.companyName && {booking.companyName}} + + {booking.bookingStatus.replaceAll("_", " ")} + + + +
+ + + Containers to return + + + + + + + + + Container + Size + Status + + + + {booking.containers.map((container) => { + const recorded = Boolean(container.returnId); + return ( + + + toggle(container.key, e.currentTarget.checked)} + /> + + + {container.containerNumber} + + {container.containerSize ?? "—"} + + {recorded ? ( + + {(container.returnStatus && + RETURN_STATUS_LABEL[container.returnStatus]) ?? + "Recorded"} + + ) : ( + + Awaiting return + + )} + + + ); + })} + +
+
+ + + + + + + setReturnDate(e.target.value)} + style={{ + padding: "8px", + borderRadius: "4px", + border: "1px solid #ced4da", + width: "100%", + }} + required + /> + + +