feat: add EDR Last Mile Returns page with bulk/single form

New page for empty container returns: tables show EDR trucks with company
names and return containers. Modal form for processing single or bulk
returns, with fields for facility, yard, condition, return date, and
handover notes. Accessible via sidebar menu under Import Operations.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
Hagernesh
2026-07-30 13:52:59 +00:00
parent 4937bcec53
commit c9300ab4cd
2 changed files with 411 additions and 0 deletions

View File

@@ -118,6 +118,7 @@ import TrainDetailPage from "./pages/trains/TrainDetailPage";
import TrainBuilderDetailPage from "./pages/trainBuilder/TrainBuilderDetailPage";
import TrainBuilderListPage from "./pages/trainBuilder/TrainBuilderListPage";
import ArrivalQueuePage from "./pages/warehouses/ArrivalQueuePage";
import EDRLastMileReturnsPage from "./pages/warehouses/EDRLastMileReturnsPage";
import DispatchQueuePage from "./pages/warehouses/DispatchQueuePage";
import ExportDjiboutiUnloadingQueuePage from "./pages/warehouses/ExportDjiboutiUnloadingQueuePage";
import ExportWarehouseFlowPage from "./pages/warehouses/ExportWarehouseFlowPage";
@@ -411,6 +412,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
icon: <Truck />,
permission: FREIGHT_PERMS.warehouseInventory.view,
},
{
label: "EDR Last Mile Returns",
href: "/dashboard/edr-last-mile-returns",
icon: <Container />,
permission: FREIGHT_PERMS.warehouseInventory.view,
},
{
label: "Dispatch Queue",
href: "/dashboard/dispatch-queue",
@@ -1060,6 +1067,7 @@ const App = () => {
<Route path="intercity" element={<IntercityPage />} />
<Route path="trucks-on-site" element={<TrucksOnSitePage />} />
<Route path="import-trucks" element={<ImportTrucksPage />} />
<Route path="edr-last-mile-returns" element={<EDRLastMileReturnsPage />} />
<Route path="loaded-inventory" element={<LoadedInventoryPage />} />
<Route path="dispatch-queue" element={<DispatchQueuePage />} />
<Route

View File

@@ -0,0 +1,403 @@
import { Fragment, useMemo, useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
ActionIcon,
Alert,
Badge,
Button,
Group,
Loader,
Modal,
Stack,
Table,
Text,
TextInput,
Textarea,
Select,
Checkbox,
} from "@mantine/core";
import { ChevronDown, ChevronRight } from "lucide-react";
import { PageContainer, PageHeader } from "@/components/page";
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
import { useListControls } from "@/hooks/useListControls";
import { useToast } from "@/hooks/use-toast";
import { api } from "@/services/api";
import { warehouseService } from "@/services/warehouse.service";
import { importOperationsService } from "@/services/importOperations.service";
interface ReturnContainer {
containerNumber: string;
size: string | null;
type: string | null;
selected: boolean;
}
interface TruckReturn {
key: string;
plate: string;
companyName: string | null;
bookingRef: string;
bookingId: string;
customerId: string | null;
containers: ReturnContainer[];
}
const RETURN_FACILITIES = [
{ value: "Addis Ababa", label: "Addis Ababa" },
{ value: "Djibouti", label: "Djibouti" },
{ value: "Other", label: "Other" },
];
export default function EDRLastMileReturnsPage() {
const { toast } = useToast();
const qc = useQueryClient();
const [expanded, setExpanded] = useState<string | null>(null);
const [returnModalOpen, setReturnModalOpen] = useState(false);
const [activeKey, setActiveKey] = useState<string | null>(null);
const { data: unloadedQueue = [], isLoading: queueLoading } = useQuery({
queryKey: ["import-unloaded-queue"],
queryFn: async () => {
const response = await api.warehouses.importUnloadedQueue.call();
return response ?? [];
},
});
const bookingIds = unloadedQueue.map((item) => item.bookingId).filter(Boolean) as string[];
const truckReturnsQuery = useQuery({
queryKey: ["edr-last-mile-returns", bookingIds],
queryFn: async () => {
const grouped = new Map<string, TruckReturn>();
for (const item of unloadedQueue) {
if (!item.bookingId) continue;
const edrTrucks = await warehouseService.getLastMileTrucks(item.bookingId).catch(() => []);
for (const truck of edrTrucks) {
const inventory = await api.warehouses.listInventory.call({ filter: { bookingId: item.bookingId } }).catch(() => []);
const returnContainers = inventory
.filter((inv: any) => inv.isReturn)
.map((inv: any) => ({
containerNumber: inv.containerNumber || "—",
size: inv.containerSize || null,
type: inv.containerType || null,
selected: false,
}));
if (returnContainers.length > 0) {
const key = `${item.bookingId}-${truck.vehicleId}`;
grouped.set(key, {
key,
plate: [truck.truckPlateNumber, truck.trailerPlateNumber].filter(Boolean).join(" + ") || "—",
companyName: item.customerName ?? null,
bookingRef: item.bookingReference ?? item.bookingId,
bookingId: item.bookingId,
customerId: item.customerId || null,
containers: returnContainers,
});
}
}
}
return Array.from(grouped.values());
},
enabled: bookingIds.length > 0 && !queueLoading,
});
const trucksWithReturns = useMemo(() => truckReturnsQuery.data ?? [], [truckReturnsQuery.data]);
const controls = useListControls(trucksWithReturns, {
searchKeys: ["plate", "companyName", "bookingRef"],
});
const createReturnsMutation = useMutation({
mutationFn: async (payload: { trucks: Array<{ bookingId: string; customerId: string | null; containers: Array<{ containerNumber: string; returnDate: string; facility: string; yard?: string; zone?: string; condition?: string; handoverNote?: string }> }> }) => {
const results = [];
for (const truck of payload.trucks) {
for (const container of truck.containers) {
const result = await importOperationsService.createEmptyReturn({
containerNumber: container.containerNumber,
returnDate: new Date(container.returnDate).toISOString(),
bookingId: truck.bookingId,
customerId: truck.customerId ?? undefined,
facility: container.facility,
yard: container.yard,
zone: container.zone,
condition: container.condition,
handoverNote: container.handoverNote,
});
results.push(result);
}
}
return results;
},
onSuccess: () => {
toast({ title: "Empty container returns recorded" });
qc.invalidateQueries({ queryKey: ["edr-last-mile-returns", bookingIds] });
setReturnModalOpen(false);
setActiveKey(null);
},
onError: (error: any) => {
toast({
variant: "destructive",
title: "Failed to record returns",
description: error?.response?.data?.message || error?.message,
});
},
});
const activeTruck = activeKey ? trucksWithReturns.find(t => t.key === activeKey) ?? null : null;
if (queueLoading || truckReturnsQuery.isLoading) {
return (
<PageContainer>
<Group justify="center" py="lg">
<Loader size="sm" />
</Group>
</PageContainer>
);
}
return (
<PageContainer>
<PageHeader
title="EDR Last Mile Returns"
subtitle="Empty containers returned by EDR-haulage trucks — single or bulk processing"
/>
{trucksWithReturns.length === 0 ? (
<Alert color="gray">No EDR trucks with return containers found.</Alert>
) : (
<>
<Table.ScrollContainer minWidth={1000}>
<Table highlightOnHover verticalSpacing="xs">
<Table.Thead>
<Table.Tr>
<Table.Th w={40} />
<Table.Th>Plate</Table.Th>
<Table.Th>Company</Table.Th>
<Table.Th>Booking Ref</Table.Th>
<Table.Th>Return Containers</Table.Th>
<Table.Th ta="right">Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{controls.pagedRows.map((truck) => {
const isOpen = expanded === truck.key;
return (
<Fragment key={truck.key}>
<Table.Tr>
<Table.Td>
<ActionIcon
variant="subtle"
color="gray"
onClick={() => setExpanded(isOpen ? null : truck.key)}
>
{isOpen ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</ActionIcon>
</Table.Td>
<Table.Td>
<Text fw={600}>{truck.plate}</Text>
</Table.Td>
<Table.Td>{truck.companyName ?? "—"}</Table.Td>
<Table.Td>{truck.bookingRef}</Table.Td>
<Table.Td>
<Badge>{truck.containers.length} container{truck.containers.length !== 1 ? "s" : ""}</Badge>
</Table.Td>
<Table.Td ta="right">
<Button
size="xs"
variant="light"
onClick={() => {
setActiveKey(truck.key);
setReturnModalOpen(true);
}}
>
Process Returns
</Button>
</Table.Td>
</Table.Tr>
{isOpen && (
<Table.Tr>
<Table.Td colSpan={6}>
<Table striped>
<Table.Thead>
<Table.Tr>
<Table.Th w={40}>
<Checkbox disabled />
</Table.Th>
<Table.Th>Container</Table.Th>
<Table.Th>Size</Table.Th>
<Table.Th>Type</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{truck.containers.map((container, idx) => (
<Table.Tr key={idx}>
<Table.Td>
<Checkbox checked={container.selected} />
</Table.Td>
<Table.Td>{container.containerNumber}</Table.Td>
<Table.Td>{container.size ?? "—"}</Table.Td>
<Table.Td>{container.type ?? "—"}</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.Td>
</Table.Tr>
)}
</Fragment>
);
})}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
<RuleEngineListFooter
pagination={controls.pagination}
pageCount={controls.pageCount}
totalCount={controls.totalCount}
itemLabel="trucks"
onPaginationChange={controls.setPagination}
/>
</>
)}
<EmptyContainerReturnModal
opened={returnModalOpen}
onClose={() => setReturnModalOpen(false)}
truck={activeTruck}
onSubmit={(payload) => createReturnsMutation.mutate(payload)}
loading={createReturnsMutation.isPending}
/>
</PageContainer>
);
}
interface EmptyContainerReturnModalProps {
opened: boolean;
onClose: () => void;
truck: TruckReturn | null;
onSubmit: (payload: any) => void;
loading: boolean;
}
function EmptyContainerReturnModal({ opened, onClose, truck, onSubmit, loading }: EmptyContainerReturnModalProps) {
const [selectedContainers, setSelectedContainers] = useState<string[]>([]);
const [returnDate, setReturnDate] = useState<string>(new Date().toISOString().split("T")[0]);
const [facility, setFacility] = useState<string | null>(null);
const [yard, setYard] = useState<string>("");
const [condition, setCondition] = useState<string>("");
const [handoverNote, setHandoverNote] = useState<string>("");
const handleSubmit = () => {
if (!truck || !selectedContainers.length || !facility) return;
const containers = truck.containers
.filter((c) => selectedContainers.includes(c.containerNumber))
.map((c) => ({
containerNumber: c.containerNumber,
returnDate,
facility,
yard: yard || undefined,
zone: undefined,
condition: condition || undefined,
handoverNote: handoverNote || undefined,
}));
onSubmit({
trucks: [{
bookingId: truck.bookingId,
customerId: truck.customerId,
containers,
}],
});
};
return (
<Modal opened={opened} onClose={onClose} title="Process Empty Container Returns" size="lg">
{truck && (
<Stack gap="md">
<Group>
<Text fw={600}>{truck.plate}</Text>
<Text size="sm" c="dimmed">{truck.bookingRef}</Text>
</Group>
<div>
<Text size="sm" fw={600} mb="xs">Select containers to return:</Text>
<Stack gap="xs">
{truck.containers.map((container) => (
<Checkbox
key={container.containerNumber}
label={`${container.containerNumber} (${container.size || "bulk"})`}
checked={selectedContainers.includes(container.containerNumber)}
onChange={(e) => {
if (e.currentTarget.checked) {
setSelectedContainers([...selectedContainers, container.containerNumber]);
} else {
setSelectedContainers(selectedContainers.filter(c => c !== container.containerNumber));
}
}}
/>
))}
</Stack>
</div>
<Select
label="Return Facility"
placeholder="Select facility"
value={facility}
onChange={setFacility}
data={RETURN_FACILITIES}
required
/>
<TextInput
label="Yard"
placeholder="e.g., Yard A"
value={yard}
onChange={(e) => setYard(e.currentTarget.value)}
/>
<TextInput
label="Return Date"
type="date"
value={returnDate}
onChange={(e) => setReturnDate(e.currentTarget.value)}
required
/>
<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={!selectedContainers.length || !facility}
loading={loading}
>
{selectedContainers.length > 1 ? "Bulk" : "Single"} Return ({selectedContainers.length})
</Button>
</Group>
</Stack>
)}
</Modal>
);
}