mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 00:45:41 +00:00
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:
@@ -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={
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 & 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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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 }>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user