mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user