mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-03 08:33:39 +00:00
Merge pull request #1475 from Tria-plc/Emty-container-return
feat(import-operations): record empty container return per booking
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