feat(empty-return-requests): customer-requested empty return, priced and paid

A booking sold WITHOUT the return service had no way to send its empties
back: the containers were the customer's problem and nothing in the
system priced, billed or planned the movement.

Adds the request flow end to end. The customer opens the booking, says
how many containers are coming back and types each number; the request
lands in a new backoffice queue (Empty Return Requests). Approval prices
it off the same live WITH_RETURN route rate the rule engine bills when
the service IS bought up front — per container, converted to birr, and
overridable by the reviewer — and issues the invoice there and then.
Payment settles through the normal invoice path, whose
`empty_return_request.invoice.paid` event moves the request to PAID; the
customer then books the return date and the truck.

Scheduled requests surface on Container Returns as Planned Empty
Returns, where confirming the arrival records the containers through the
existing empty-container-return flow — which in turn closes the request
once its last container is in.

Container freight only, never a booking that already ships WITH_RETURN,
and only from IN_TRANSIT onward: the empty comes back after delivery, so
the option has to outlive ARRIVED.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hager
2026-09-02 11:05:03 +00:00
parent 2e51342d1e
commit 3f7b744987
24 changed files with 2770 additions and 10 deletions

View File

@@ -96,6 +96,7 @@ import TrainBuilderDetailPage from "./pages/trainBuilder/TrainBuilderDetailPage"
import TrainBuilderListPage from "./pages/trainBuilder/TrainBuilderListPage";
import ArrivalQueuePage from "./pages/warehouses/ArrivalQueuePage";
import ContainerReturnsPage from "./pages/warehouses/ContainerReturnsPage";
import EmptyReturnRequestsPage from "./pages/warehouses/EmptyReturnRequestsPage";
import RegisterFullContainersPage from "./pages/warehouses/RegisterFullContainersPage";
import DispatchQueuePage from "./pages/warehouses/DispatchQueuePage";
import ExportDjiboutiUnloadingQueuePage from "./pages/warehouses/ExportDjiboutiUnloadingQueuePage";
@@ -709,6 +710,16 @@ const App = () => {
</RequirePermission>
}
/>
<Route
path="empty-return-requests"
element={
<RequirePermission
permission={FREIGHT_PERMS.emptyReturnRequests.view}
>
<EmptyReturnRequestsPage />
</RequirePermission>
}
/>
<Route
path="register-full-containers"
element={

View File

@@ -31,6 +31,7 @@ import {
SlidersHorizontal,
Train,
Truck,
Undo2,
Users,
Wallet,
LifeBuoy,
@@ -367,6 +368,12 @@ export const buildSidebarSections = (
icon: <Container />,
permission: FREIGHT_PERMS.warehouseInventory.view,
},
{
label: "Empty Return Requests",
href: "/dashboard/empty-return-requests",
icon: <Undo2 />,
permission: FREIGHT_PERMS.emptyReturnRequests.view,
},
{
label: "Register Full Containers",
href: "/dashboard/register-full-containers",

View File

@@ -800,6 +800,15 @@ export const URL_CONSTANTS = {
CANCEL: (id: string) => `/interchange-documents/${id}/cancel`,
},
EMPTY_RETURN_REQUESTS: {
BASE: "/empty-return-requests",
PLANNED: "/empty-return-requests/planned",
ELIGIBILITY: (bookingId: string) => `/empty-return-requests/eligibility/${bookingId}`,
BY_BOOKING: (bookingId: string) => `/empty-return-requests/by-booking/${bookingId}`,
APPROVE: (id: string) => `/empty-return-requests/${id}/approve`,
REJECT: (id: string) => `/empty-return-requests/${id}/reject`,
},
IMPORT_OPERATIONS: {
DJIBOUTI_INCIDENTS: "/import-operations/djibouti-incidents",
CUSTOMS: (bookingId: string) => `/import-operations/customs/${bookingId}`,

View File

@@ -358,6 +358,10 @@ export const FREIGHT_PERMS = {
send: "edr_freight_app:additional_charges:send",
cancel: "edr_freight_app:additional_charges:cancel",
},
emptyReturnRequests: {
view: "edr_freight_app:empty_return_requests:view",
review: "edr_freight_app:empty_return_requests:review",
},
/**
* Audit trail. View-only — the API exposes no write routes for audit rows,
* so there is no manage/delete counterpart to grant.

View File

@@ -40,11 +40,13 @@ import { useWarehouseYards, useWarehouseZones } from "@/hooks/useWarehouses";
import { api } from "@/services/api";
import { warehouseService } from "@/services/warehouse.service";
import { importOperationsService } from "@/services/importOperations.service";
import { emptyReturnRequestsService } from "@/services/emptyReturnRequests.service";
import type {
EmptyContainerReturn,
EmptyContainerReturnStatus,
EmptyContainerSize,
EmptyReturnBooking,
PlannedEmptyReturn,
} from "@/types/importOperations";
import type { TrainScheduleListItem } from "@/types/trainScheduling";
import { formatDateTime, localNowForInput } from "@/lib/format";
@@ -78,6 +80,32 @@ const RETURNED_BY_SERIES = [
{ key: "customer", label: "Customer Self-Haul", color: "#b45309" },
];
/**
* A scheduled request seen as the booking shape `BookingEmptyReturnModal`
* takes, so confirming an arrival runs through exactly the same recording
* path as any other empty return.
*/
const plannedAsBooking = (planned: PlannedEmptyReturn): EmptyReturnBooking => ({
bookingId: planned.bookingId,
bookingReference: planned.bookingReference ?? planned.bookingId,
bookingStatus: "SCHEDULED_RETURN",
equipmentReturn: "REQUESTED",
customerId: planned.companyId,
companyName: planned.companyName,
containers: planned.containers.map((container) => ({
key: `${planned.requestId}-${container.containerNumber}`,
unitId: `${planned.requestId}-${container.containerNumber}`,
containerNumber: container.containerNumber,
containerSize: null,
containerType: null,
returnId: container.returnId,
returnStatus: null,
})),
expectedCount: planned.containers.length,
recordedCount: planned.containers.filter((c) => c.returnId).length,
pendingCount: planned.containers.filter((c) => !c.returnId).length,
});
interface ContainerReturnRow {
key: string;
containerNumber: string;
@@ -114,6 +142,7 @@ export default function ContainerReturnsPage() {
const [allocateRow, setAllocateRow] = useState<EmptyContainerReturn | null>(null);
const [emptyReturnBooking, setEmptyReturnBooking] = useState<EmptyReturnBooking | null>(null);
const [expandedBooking, setExpandedBooking] = useState<string | null>(null);
const [arrivingReturn, setArrivingReturn] = useState<PlannedEmptyReturn | null>(null);
const [documentBusyId, setDocumentBusyId] = useState<string | null>(null);
const viewInterchangeDocument = async (ret: EmptyContainerReturn) => {
@@ -159,6 +188,14 @@ export default function ContainerReturnsPage() {
});
const emptyReturnBookings = emptyReturnBookingsQuery.data ?? [];
// Requests the customer already paid for and booked a truck against — the
// warehouse confirms these on arrival, which is what records the containers.
const plannedReturnsQuery = useQuery({
queryKey: ["planned-empty-returns"],
queryFn: () => emptyReturnRequestsService.planned(),
});
const plannedReturns = plannedReturnsQuery.data ?? [];
const bookingIds = unloadedQueue.map((item) => item.bookingId).filter(Boolean) as string[];
const containerReturnsQuery = useQuery({
queryKey: ["container-returns", bookingIds],
@@ -350,10 +387,12 @@ export default function ContainerReturnsPage() {
qc.invalidateQueries({ queryKey: ["container-returns", bookingIds] });
qc.invalidateQueries({ queryKey: ["empty-container-returns"] });
qc.invalidateQueries({ queryKey: ["empty-return-bookings"] });
qc.invalidateQueries({ queryKey: ["planned-empty-returns"] });
setReturnModalOpen(false);
setStandaloneModalOpen(false);
setActiveKey(null);
setEmptyReturnBooking(null);
setArrivingReturn(null);
},
onError: (error: any) => {
toast({
@@ -567,6 +606,83 @@ export default function ContainerReturnsPage() {
</Group>
</Group>
{plannedReturns.length > 0 && (
<Card withBorder radius="lg" p="md" mb="lg">
<Stack gap="md">
<Group justify="space-between" align="flex-start">
<div>
<Text fw={600}>Planned Empty Returns</Text>
<Text size="sm" c="dimmed">
Customers who paid for an empty return and booked a truck. Confirm the arrival
to record the containers.
</Text>
</div>
<Badge variant="light" size="lg" color="orange">
{plannedReturns.length} expected
</Badge>
</Group>
<Table.ScrollContainer minWidth={900}>
<Table highlightOnHover verticalSpacing="xs">
<Table.Thead>
<Table.Tr>
<Table.Th>Booking Ref</Table.Th>
<Table.Th>Company</Table.Th>
<Table.Th>Return Date</Table.Th>
<Table.Th>Truck</Table.Th>
<Table.Th>Containers</Table.Th>
<Table.Th ta="right">Action</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{plannedReturns.map((planned) => {
const outstanding = planned.containers.filter((c) => !c.returnId);
return (
<Table.Tr key={planned.requestId}>
<Table.Td>
<Text fw={600}>{planned.bookingReference ?? planned.bookingId}</Text>
</Table.Td>
<Table.Td>{planned.companyName ?? "—"}</Table.Td>
<Table.Td>{planned.requestedReturnDate ?? "—"}</Table.Td>
<Table.Td>
<Stack gap={2}>
<Text size="sm">{planned.truckPlateNumber ?? "—"}</Text>
<Text size="xs" c="dimmed">
{planned.truckDriverName ?? "—"}
{planned.truckType ? ` · ${planned.truckType}` : ""}
</Text>
</Stack>
</Table.Td>
<Table.Td>
<Stack gap={2}>
<Badge color={outstanding.length ? "orange" : "edr-green"}>
{outstanding.length} of {planned.containers.length} outstanding
</Badge>
<Text size="xs" c="dimmed" lineClamp={2}>
{planned.containers.map((c) => c.containerNumber).join(", ")}
</Text>
</Stack>
</Table.Td>
<Table.Td ta="right">
<Button
size="xs"
variant="light"
disabled={outstanding.length === 0}
onClick={() => setArrivingReturn(planned)}
>
Confirm Arrival
</Button>
</Table.Td>
</Table.Tr>
);
})}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
</Stack>
</Card>
)}
<Card withBorder radius="lg" p="md" mb="lg">
<Stack gap="md">
<Group justify="space-between" align="flex-start">
@@ -904,6 +1020,24 @@ export default function ContainerReturnsPage() {
loading={createReturnsMutation.isPending}
/>
{/* A scheduled return arrives on the customer's own truck, so the modal
opens pre-set to self-haul with that truck already noted. */}
<BookingEmptyReturnModal
title="Confirm Empty Return Arrival"
booking={arrivingReturn ? plannedAsBooking(arrivingReturn) : null}
onClose={() => setArrivingReturn(null)}
onSubmit={(payload) => createReturnsMutation.mutate(payload)}
loading={createReturnsMutation.isPending}
defaultReturnedBy="CUSTOMER"
defaultHandoverNote={
arrivingReturn
? `Scheduled empty return · truck ${arrivingReturn.truckPlateNumber ?? "—"}${
arrivingReturn.truckDriverName ? ` · driver ${arrivingReturn.truckDriverName}` : ""
}`
: undefined
}
/>
<BulkContainerReturnModal
opened={bulkModalOpen}
onClose={() => setBulkModalOpen(false)}
@@ -1217,6 +1351,11 @@ interface BookingEmptyReturnModalProps {
onClose: () => void;
onSubmit: (payload: any) => void;
loading: boolean;
/** Pre-set for a scheduled return, where the truck type is already known. */
defaultReturnedBy?: "EDR" | "CUSTOMER";
/** Pre-set for a scheduled return — the truck the customer told us about. */
defaultHandoverNote?: string;
title?: string;
}
/**
@@ -1226,7 +1365,15 @@ interface BookingEmptyReturnModalProps {
* 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) {
function BookingEmptyReturnModal({
booking,
onClose,
onSubmit,
loading,
defaultReturnedBy,
defaultHandoverNote,
title = "Empty Container Return",
}: BookingEmptyReturnModalProps) {
const [selected, setSelected] = useState<string[]>([]);
const [returnedBy, setReturnedBy] = useState<"EDR" | "CUSTOMER" | null>(null);
const [returnDate, setReturnDate] = useState<string>(localNowForInput());
@@ -1242,14 +1389,14 @@ function BookingEmptyReturnModal({ booking, onClose, onSubmit, loading }: Bookin
// ticks, typed numbers, or placement.
useEffect(() => {
setSelected([]);
setReturnedBy(null);
setReturnedBy(defaultReturnedBy ?? null);
setReturnDate(localNowForInput());
setWarehouse(null);
setYardId(null);
setZoneId(null);
setCondition("");
setHandoverNote("");
}, [bookingId]);
setHandoverNote(defaultHandoverNote ?? "");
}, [bookingId, defaultReturnedBy, defaultHandoverNote]);
const { data: warehousesResponse } = useQuery({
queryKey: ["warehouses-list"],
@@ -1332,12 +1479,7 @@ function BookingEmptyReturnModal({ booking, onClose, onSubmit, loading }: Bookin
};
return (
<Modal
opened={!!booking}
onClose={onClose}
title="Empty Container Return"
size="lg"
>
<Modal opened={!!booking} onClose={onClose} title={title} size="lg">
{booking && (
<Stack gap="md">
<Group gap="sm">

View File

@@ -0,0 +1,477 @@
import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Alert,
Badge,
Button,
Card,
Divider,
Group,
Loader,
Modal,
NumberInput,
Select,
SimpleGrid,
Stack,
Text,
Textarea,
} from "@mantine/core";
import { DataTable, type ColumnDef } from "@edr/ui-common";
import { PageContainer, PageHeader } from "@/components/page";
import ListControls from "@/components/common/ListControls";
import { extractErrorMessage } from "@/components/warehouses/options";
import { useListControls } from "@/hooks/useListControls";
import { useToast } from "@/hooks/use-toast";
import { emptyReturnRequestsService } from "@/services/emptyReturnRequests.service";
import type {
EmptyReturnRequest,
EmptyReturnRequestStatus,
} from "@/types/importOperations";
import { formatDateTime } from "@/lib/format";
const STATUS_META: Record<EmptyReturnRequestStatus, { label: string; color: string }> = {
SUBMITTED: { label: "Awaiting review", color: "orange" },
APPROVED: { label: "Awaiting payment", color: "yellow" },
REJECTED: { label: "Rejected", color: "red" },
PAID: { label: "Paid — awaiting date", color: "blue" },
SCHEDULED: { label: "Scheduled", color: "edr-green" },
COMPLETED: { label: "Returned", color: "gray" },
CANCELLED: { label: "Cancelled", color: "gray" },
};
const money = (amount: number | null | undefined, currency: string | null | undefined) =>
amount == null
? "—"
: `${Number(amount).toLocaleString(undefined, { minimumFractionDigits: 2 })} ${currency ?? ""}`.trim();
/**
* The queue for customer-initiated empty container returns: a booking sold
* WITHOUT the return service, whose customer now wants to send the empties
* back. Staff price and approve — which invoices the customer — or reject with
* a reason. Everything after payment (date, truck) happens in the portal, and
* the containers themselves are recorded on Container Returns.
*/
export default function EmptyReturnRequestsPage() {
const { toast } = useToast();
const qc = useQueryClient();
const [statusFilter, setStatusFilter] = useState<string | null>(null);
const [approving, setApproving] = useState<EmptyReturnRequest | null>(null);
const [rejecting, setRejecting] = useState<EmptyReturnRequest | null>(null);
const requestsQuery = useQuery({
queryKey: ["empty-return-requests"],
queryFn: () => emptyReturnRequestsService.list(),
});
const requests = useMemo(() => {
const rows = requestsQuery.data ?? [];
return statusFilter ? rows.filter((row) => row.status === statusFilter) : rows;
}, [requestsQuery.data, statusFilter]);
const controls = useListControls(requests, {
dateKey: "submittedAt",
searchValue: (row) =>
`${row.bookingReference ?? ""} ${row.companyName ?? ""} ${row.containerNumbers.join(" ")}`,
});
const invalidate = () => {
qc.invalidateQueries({ queryKey: ["empty-return-requests"] });
qc.invalidateQueries({ queryKey: ["planned-empty-returns"] });
};
const approveMutation = useMutation({
mutationFn: ({ id, unitAmount }: { id: string; unitAmount?: number }) =>
emptyReturnRequestsService.approve(id, { unitAmount }),
onSuccess: () => {
toast({ title: "Approved — invoice sent to the customer" });
invalidate();
setApproving(null);
},
onError: (error: unknown) => {
toast({
variant: "destructive",
title: "Could not approve the request",
description: extractErrorMessage(error),
});
},
});
const rejectMutation = useMutation({
mutationFn: ({ id, reason }: { id: string; reason: string }) =>
emptyReturnRequestsService.reject(id, reason),
onSuccess: () => {
toast({ title: "Request rejected" });
invalidate();
setRejecting(null);
},
onError: (error: unknown) => {
toast({
variant: "destructive",
title: "Could not reject the request",
description: extractErrorMessage(error),
});
},
});
const columns: ColumnDef<EmptyReturnRequest>[] = [
{
id: "booking",
header: "Booking",
cell: ({ row }) => (
<Stack gap={2}>
<Text fw={600} size="sm">
{row.original.bookingReference ?? row.original.bookingId}
</Text>
<Text size="xs" c="dimmed">
{row.original.companyName ?? "—"}
</Text>
</Stack>
),
},
{
id: "containers",
header: "Containers",
cell: ({ row }) => (
<Stack gap={2}>
<Badge size="sm">{row.original.containerCount}</Badge>
<Text size="xs" c="dimmed" lineClamp={2}>
{row.original.containerNumbers.join(", ")}
</Text>
</Stack>
),
},
{
id: "submittedAt",
header: "Requested",
cell: ({ row }) => formatDateTime(row.original.submittedAt),
},
{
id: "price",
header: "Price",
cell: ({ row }) =>
row.original.quotedTotalAmount == null ? (
"—"
) : (
<Stack gap={2}>
<Text size="sm" fw={600}>
{money(row.original.quotedTotalAmount, row.original.currency)}
</Text>
<Text size="xs" c="dimmed">
{money(row.original.quotedUnitAmount, row.original.currency)} × {row.original.containerCount}
</Text>
</Stack>
),
},
{
id: "return",
header: "Return",
cell: ({ row }) =>
row.original.requestedReturnDate ? (
<Stack gap={2}>
<Text size="sm">{row.original.requestedReturnDate}</Text>
<Text size="xs" c="dimmed">
{row.original.truckPlateNumber ?? "—"}
{row.original.truckDriverName ? ` · ${row.original.truckDriverName}` : ""}
</Text>
</Stack>
) : (
"—"
),
},
{
id: "status",
header: "Status",
cell: ({ row }) => {
const meta = STATUS_META[row.original.status];
return (
<Stack gap={2}>
<Badge size="sm" color={meta?.color ?? "gray"} variant="light">
{meta?.label ?? row.original.status}
</Badge>
{row.original.rejectionReason && (
<Text size="xs" c="dimmed" lineClamp={2}>
{row.original.rejectionReason}
</Text>
)}
</Stack>
);
},
},
{
id: "action",
header: "Action",
cell: ({ row }) =>
row.original.status === "SUBMITTED" ? (
<Group gap="xs" justify="flex-end" wrap="nowrap">
<Button size="xs" variant="subtle" color="red" onClick={() => setRejecting(row.original)}>
Reject
</Button>
<Button size="xs" variant="light" onClick={() => setApproving(row.original)}>
Approve
</Button>
</Group>
) : (
<Text size="xs" c="dimmed" ta="right">
{row.original.status === "APPROVED" ? "Awaiting customer payment" : "No action"}
</Text>
),
},
];
const pending = (requestsQuery.data ?? []).filter((row) => row.status === "SUBMITTED").length;
return (
<PageContainer>
<PageHeader
title="Empty Return Requests"
subtitle="Customers asking to send empty containers back on bookings sold without equipment return"
/>
<Card withBorder radius="lg" p="md">
<Stack gap="md">
<Group justify="space-between">
<Text fw={600}>
Requests
{pending > 0 && (
<Badge ml="sm" color="orange" variant="light">
{pending} awaiting review
</Badge>
)}
</Text>
</Group>
<ListControls
search={controls.search}
onSearchChange={controls.setSearch}
searchPlaceholder="Search booking, company, container…"
dateFrom={controls.dateFrom}
onDateFromChange={controls.setDateFrom}
dateTo={controls.dateTo}
onDateToChange={controls.setDateTo}
dateLabel="Requested"
hasFilters={controls.hasFilters || Boolean(statusFilter)}
onReset={() => {
controls.reset();
setStatusFilter(null);
}}
>
<Select
placeholder="Status"
value={statusFilter}
onChange={setStatusFilter}
data={Object.entries(STATUS_META).map(([value, meta]) => ({
value,
label: meta.label,
}))}
clearable
w={220}
/>
</ListControls>
{requestsQuery.isLoading ? (
<Group justify="center" py="lg">
<Loader size="sm" />
</Group>
) : requestsQuery.isError ? (
<Alert color="red">
Could not load empty return requests. {extractErrorMessage(requestsQuery.error)}
</Alert>
) : controls.pagedRows.length === 0 ? (
<Alert color="gray">No empty return requests.</Alert>
) : (
<DataTable
columns={columns}
data={controls.pagedRows}
containerClassName="border-0 shadow-none"
{...controls.tableProps}
/>
)}
</Stack>
</Card>
<ApproveModal
request={approving}
onClose={() => setApproving(null)}
onApprove={(unitAmount) =>
approving && approveMutation.mutate({ id: approving.id, unitAmount })
}
loading={approveMutation.isPending}
/>
<Modal
opened={!!rejecting}
onClose={() => setRejecting(null)}
title="Reject empty return request"
size="md"
>
{rejecting && (
<RejectForm
request={rejecting}
loading={rejectMutation.isPending}
onCancel={() => setRejecting(null)}
onReject={(reason) => rejectMutation.mutate({ id: rejecting.id, reason })}
/>
)}
</Modal>
</PageContainer>
);
}
/**
* The pricing step. The per-container price is prefilled from the booking's
* route WITH_RETURN rate; the reviewer can override it before approving, and
* approving is what issues the customer's invoice.
*/
function ApproveModal({
request,
onClose,
onApprove,
loading,
}: {
request: EmptyReturnRequest | null;
onClose: () => void;
onApprove: (unitAmount?: number) => void;
loading: boolean;
}) {
const [unitAmount, setUnitAmount] = useState<number | "">("");
const quoteQuery = useQuery({
queryKey: ["empty-return-quote", request?.bookingId],
queryFn: () => emptyReturnRequestsService.quote(request!.bookingId),
enabled: Boolean(request),
});
// Prefill from the route rate as soon as it lands, and start clean whenever
// a different request is opened.
useEffect(() => {
setUnitAmount(quoteQuery.data?.unitAmount ?? "");
}, [quoteQuery.data?.unitAmount, request?.id]);
const count = request?.containerCount ?? 0;
const total = typeof unitAmount === "number" ? unitAmount * count : null;
const currency = quoteQuery.data?.currency ?? "ETB";
return (
<Modal opened={!!request} onClose={onClose} title="Approve empty return" size="md">
{request && (
<Stack gap="md">
<Group gap="sm">
<Text fw={600}>{request.bookingReference ?? request.bookingId}</Text>
<Text c="dimmed">{request.companyName ?? "—"}</Text>
</Group>
<div>
<Text size="sm" fw={600} mb={4}>
Containers coming back
</Text>
<Text size="sm" c="dimmed">
{request.containerNumbers.join(", ")}
</Text>
</div>
{quoteQuery.isLoading ? (
<Group justify="center" py="sm">
<Loader size="sm" />
</Group>
) : (
<>
{quoteQuery.data?.unavailableReason && (
<Alert color="yellow">{quoteQuery.data.unavailableReason}</Alert>
)}
<NumberInput
label={`Price per container (${currency})`}
description={
quoteQuery.data?.sourceRateUsd
? `Contract route rate: ${quoteQuery.data.sourceRateUsd} USD per container`
: "No route rate found — enter the amount to bill."
}
value={unitAmount}
onChange={(value) =>
setUnitAmount(typeof value === "number" ? value : value === "" ? "" : Number(value))
}
min={0}
decimalScale={2}
thousandSeparator=","
required
/>
<Divider />
<SimpleGrid cols={2}>
<Text size="sm" c="dimmed">
{count} container{count === 1 ? "" : "s"} ×{" "}
{typeof unitAmount === "number" ? unitAmount.toLocaleString() : "—"}
</Text>
<Text size="lg" fw={700} ta="right">
{total == null
? "—"
: `${total.toLocaleString(undefined, { minimumFractionDigits: 2 })} ${currency}`}
</Text>
</SimpleGrid>
<Text size="xs" c="dimmed">
Approving issues this invoice to the customer. They pay it in the portal, then
choose the return date and give the truck details.
</Text>
</>
)}
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={onClose} disabled={loading}>
Cancel
</Button>
<Button
onClick={() => onApprove(typeof unitAmount === "number" ? unitAmount : undefined)}
disabled={typeof unitAmount !== "number" || unitAmount <= 0}
loading={loading}
>
Approve &amp; invoice
</Button>
</Group>
</Stack>
)}
</Modal>
);
}
function RejectForm({
request,
loading,
onCancel,
onReject,
}: {
request: EmptyReturnRequest;
loading: boolean;
onCancel: () => void;
onReject: (reason: string) => void;
}) {
const [reason, setReason] = useState("");
return (
<Stack gap="md">
<Text size="sm">
{request.bookingReference ?? request.bookingId} {request.containerCount} container
{request.containerCount === 1 ? "" : "s"}
</Text>
<Textarea
label="Reason"
description="Shown to the customer."
placeholder="Why this return cannot be accepted"
value={reason}
onChange={(event) => setReason(event.currentTarget.value)}
rows={3}
required
/>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={onCancel} disabled={loading}>
Cancel
</Button>
<Button color="red" onClick={() => onReject(reason.trim())} disabled={reason.trim().length < 3} loading={loading}>
Reject request
</Button>
</Group>
</Stack>
);
}

View File

@@ -0,0 +1,62 @@
import { api as client } from '../auth/http';
import { URL_CONSTANTS } from '@/constants/URLS';
import { unwrap } from '@/utils/endpoint';
import type {
EmptyReturnQuote,
EmptyReturnRequest,
EmptyReturnRequestStatus,
PlannedEmptyReturn,
} from '@/types/importOperations';
/**
* Empty container return requests — the customer-initiated path for a booking
* that was sold WITHOUT the return service. Staff price and approve them here;
* the customer pays and books the truck from the portal.
*/
export const emptyReturnRequestsService = {
list: async (params: {
status?: EmptyReturnRequestStatus;
bookingId?: string;
} = {}): Promise<EmptyReturnRequest[]> => {
const response = await client.get<EmptyReturnRequest[]>(
URL_CONSTANTS.EMPTY_RETURN_REQUESTS.BASE,
{ params },
);
return unwrap(response.data);
},
/** Scheduled returns the warehouse is expecting, with date and truck. */
planned: async (): Promise<PlannedEmptyReturn[]> => {
const response = await client.get<PlannedEmptyReturn[]>(
URL_CONSTANTS.EMPTY_RETURN_REQUESTS.PLANNED,
);
return unwrap(response.data);
},
/** The route price staff see prefilled at approval. */
quote: async (bookingId: string): Promise<EmptyReturnQuote> => {
const response = await client.get<{ quote: EmptyReturnQuote }>(
URL_CONSTANTS.EMPTY_RETURN_REQUESTS.ELIGIBILITY(bookingId),
);
return unwrap(response.data).quote;
},
approve: async (
id: string,
payload: { unitAmount?: number; currency?: string } = {},
): Promise<EmptyReturnRequest> => {
const response = await client.post<EmptyReturnRequest>(
URL_CONSTANTS.EMPTY_RETURN_REQUESTS.APPROVE(id),
payload,
);
return unwrap(response.data);
},
reject: async (id: string, reason: string): Promise<EmptyReturnRequest> => {
const response = await client.post<EmptyReturnRequest>(
URL_CONSTANTS.EMPTY_RETURN_REQUESTS.REJECT(id),
{ reason },
);
return unwrap(response.data);
},
};

View File

@@ -179,3 +179,60 @@ export interface UpdateEmptyContainerReturnStatusPayload extends ImportOperation
wagonAllocationReference?: string;
handoverNote?: string;
}
export type EmptyReturnRequestStatus =
| 'SUBMITTED'
| 'APPROVED'
| 'REJECTED'
| 'PAID'
| 'SCHEDULED'
| 'COMPLETED'
| 'CANCELLED';
/** A customer's request to send empties back on a booking sold without return. */
export interface EmptyReturnRequest {
id: string;
bookingId: string;
bookingReference: string | null;
companyId: string | null;
companyName: string | null;
status: EmptyReturnRequestStatus;
containerNumbers: string[];
containerCount: number;
quotedUnitAmount: number | null;
quotedTotalAmount: number | null;
currency: string | null;
invoiceId: string | null;
paidAt: string | null;
requestedReturnDate: string | null;
truckPlateNumber: string | null;
truckDriverName: string | null;
truckType: string | null;
scheduledAt: string | null;
submittedAt: string;
reviewedAt: string | null;
rejectionReason: string | null;
completedAt: string | null;
}
/** Per-container price for an empty return, off the route's WITH_RETURN rate. */
export interface EmptyReturnQuote {
unitAmount: number | null;
currency: string;
sourceRateUsd: number | null;
unavailableReason: string | null;
}
/** A scheduled empty return the warehouse is waiting on. */
export interface PlannedEmptyReturn {
requestId: string;
bookingId: string;
bookingReference: string | null;
companyName: string | null;
companyId: string | null;
requestedReturnDate: string | null;
truckPlateNumber: string | null;
truckDriverName: string | null;
truckType: string | null;
containers: Array<{ containerNumber: string; returnId: string | null }>;
}