mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-03 07:23:40 +00:00
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:
@@ -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 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([]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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' })
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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> => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user