mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
implemented import truck page
This commit is contained in:
@@ -121,6 +121,7 @@ import ArrivalQueuePage from "./pages/warehouses/ArrivalQueuePage";
|
||||
import DispatchQueuePage from "./pages/warehouses/DispatchQueuePage";
|
||||
import ExportDjiboutiUnloadingQueuePage from "./pages/warehouses/ExportDjiboutiUnloadingQueuePage";
|
||||
import ExportWarehouseFlowPage from "./pages/warehouses/ExportWarehouseFlowPage";
|
||||
import ImportTrucksPage from "./pages/warehouses/ImportTrucksPage";
|
||||
import ImportWarehouseFlowPage from "./pages/warehouses/ImportWarehouseFlowPage";
|
||||
import InterchangeDocumentsPage from "./pages/warehouses/InterchangeDocumentsPage";
|
||||
import InventoryInquiryPage from "./pages/warehouses/InventoryInquiryPage";
|
||||
@@ -404,6 +405,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
icon: <PackageOpen />,
|
||||
permission: FREIGHT_PERMS.warehouseInventory.view,
|
||||
},
|
||||
{
|
||||
label: "Import Trucks",
|
||||
href: "/dashboard/import-trucks",
|
||||
icon: <Truck />,
|
||||
permission: FREIGHT_PERMS.warehouseInventory.view,
|
||||
},
|
||||
{
|
||||
label: "Dispatch Queue",
|
||||
href: "/dashboard/dispatch-queue",
|
||||
@@ -1052,6 +1059,7 @@ const App = () => {
|
||||
<Route path="loading-queue" element={<LoadingQueuePage />} />
|
||||
<Route path="intercity" element={<IntercityPage />} />
|
||||
<Route path="trucks-on-site" element={<TrucksOnSitePage />} />
|
||||
<Route path="import-trucks" element={<ImportTrucksPage />} />
|
||||
<Route path="loaded-inventory" element={<LoadedInventoryPage />} />
|
||||
<Route path="dispatch-queue" element={<DispatchQueuePage />} />
|
||||
<Route
|
||||
|
||||
@@ -0,0 +1,495 @@
|
||||
import { Fragment, useMemo, useState } from "react";
|
||||
import { useQueries, useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Badge,
|
||||
Group,
|
||||
Loader,
|
||||
Menu,
|
||||
Table,
|
||||
Text,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
ClipboardCheck,
|
||||
FileText,
|
||||
MoreHorizontal,
|
||||
Receipt,
|
||||
} from "lucide-react";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { PageContainer, PageHeader } from "@/components/page";
|
||||
import ListControls from "@/components/common/ListControls";
|
||||
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
|
||||
import { InspectionReportModal } from "@/components/warehouses/InspectionReportModal";
|
||||
import { TruckDetentionModal } from "@/components/operations/TruckDetentionModal";
|
||||
import { extractDownloadErrorMessage, formatNumber } from "@/components/warehouses/options";
|
||||
import { openPdfBlob } from "@/components/warehouses/pdf";
|
||||
import { useListControls } from "@/hooks/useListControls";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { api } from "@/services/api";
|
||||
import { lastMileService } from "@/services/last-mile.service";
|
||||
import { warehouseService, type LastMileArrivalTruck } from "@/services/warehouse.service";
|
||||
import type { ImportUnloadedItem } from "@/types/warehouse";
|
||||
|
||||
/**
|
||||
* Import trucks — the unloaded queue seen truck-first instead of item-first.
|
||||
*
|
||||
* The unloaded-queue tab lists inventory rows; the gate and the billing desk
|
||||
* ask "which truck takes this booking out, and what does it owe". So bookings
|
||||
* are the collapsed row and their trucks are the detail, each carrying its own
|
||||
* cargo costs (demurrage/storage, matched by container) and — EDR fleet only —
|
||||
* its detention clock. Customer self-haul shows "—" for detention: EDR bills
|
||||
* detention on its own trucks only.
|
||||
*
|
||||
* Per-booking truck/fee/detention queries run only while a booking is expanded;
|
||||
* the queue can hold hundreds of bookings and fetching all of them up front
|
||||
* would be several hundred requests for rows nobody opened.
|
||||
*/
|
||||
|
||||
const COLS = 9;
|
||||
|
||||
const money = (amount: number, currency: string) =>
|
||||
`${Number(amount).toLocaleString(undefined, { maximumFractionDigits: 2 })} ${currency === "ETB" ? "ETB" : currency}`;
|
||||
|
||||
interface BookingGroup {
|
||||
bookingId: string;
|
||||
bookingReference: string;
|
||||
customerName: string | null;
|
||||
trainSchedule: string | null;
|
||||
status: string;
|
||||
arrivalTime: string | null;
|
||||
rows: ImportUnloadedItem[];
|
||||
}
|
||||
|
||||
/** One collapsed line per booking; its inventory rows travel with it for the fee lookup. */
|
||||
function groupByBooking(items: ImportUnloadedItem[]): BookingGroup[] {
|
||||
const groups = new Map<string, BookingGroup>();
|
||||
for (const item of items) {
|
||||
if (!item.bookingId) continue;
|
||||
const existing = groups.get(item.bookingId);
|
||||
if (existing) {
|
||||
existing.rows.push(item);
|
||||
// Mixed statuses across a booking's rows are normal mid-pickup — show the
|
||||
// least-advanced one so the row reads as "still has work on it".
|
||||
if (existing.status !== item.currentStatus) existing.status = "MIXED";
|
||||
continue;
|
||||
}
|
||||
groups.set(item.bookingId, {
|
||||
bookingId: item.bookingId,
|
||||
bookingReference: item.bookingReference ?? item.bookingId,
|
||||
customerName: item.customerName,
|
||||
trainSchedule: item.trainSchedule,
|
||||
status: item.currentStatus,
|
||||
arrivalTime: item.arrivalTime,
|
||||
rows: [item],
|
||||
});
|
||||
}
|
||||
return [...groups.values()];
|
||||
}
|
||||
|
||||
interface TruckRow {
|
||||
key: string;
|
||||
plate: string;
|
||||
driver: string | null;
|
||||
truckType: string | null;
|
||||
containers: string[];
|
||||
/** Inventory rows this truck carries — the ids the documents and fees hang off. */
|
||||
inventoryIds: string[];
|
||||
weight: number;
|
||||
demurrage: number;
|
||||
storage: number;
|
||||
vehicleId: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Haulage mode is decided by which list comes back non-empty — a booking is
|
||||
* either EDR last mile or customer self-haul, never both.
|
||||
*/
|
||||
function TruckRows({ group }: { group: BookingGroup }) {
|
||||
const { toast } = useToast();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [inspectId, setInspectId] = useState<string | null>(null);
|
||||
const [detentionOpen, setDetentionOpen] = useState(false);
|
||||
|
||||
const edrQuery = useQuery({
|
||||
queryKey: ["booking-edr-trucks", group.bookingId],
|
||||
queryFn: () => warehouseService.getLastMileTrucks(group.bookingId),
|
||||
});
|
||||
const edrTrucks = edrQuery.data ?? [];
|
||||
|
||||
const customerQuery = useQuery({
|
||||
queryKey: ["booking-customer-trucks", group.bookingId],
|
||||
queryFn: () => warehouseService.getCustomerTrucks(group.bookingId),
|
||||
enabled: edrQuery.isSuccess && edrTrucks.length === 0,
|
||||
});
|
||||
const customerTrucks = customerQuery.data ?? [];
|
||||
const isEdr = edrTrucks.length > 0;
|
||||
|
||||
const lastMileId = edrTrucks[0]?.lastMileId ?? null;
|
||||
|
||||
const detentionQuery = useQuery({
|
||||
queryKey: ["truck-detention-preview-import-trucks", lastMileId],
|
||||
queryFn: () => lastMileService.truckDetentionPreview(lastMileId as string).then((r) => r.data),
|
||||
enabled: Boolean(lastMileId),
|
||||
});
|
||||
const detentionByVehicle = new Map(
|
||||
(detentionQuery.data?.groups ?? []).map((g) => [g.vehicleId ?? "", g]),
|
||||
);
|
||||
|
||||
const lastMileRecordQuery = useQuery({
|
||||
queryKey: ["last-mile-record-import-trucks", lastMileId],
|
||||
queryFn: () => lastMileService.getById(lastMileId as string).then((r) => r.data),
|
||||
enabled: detentionOpen && Boolean(lastMileId),
|
||||
});
|
||||
|
||||
// Same per-inventory-row preview the accrual dashboard bills off; the queue
|
||||
// rows already ARE this booking's import inventory, so no second list fetch.
|
||||
const feeQueries = useQueries({
|
||||
queries: group.rows.map((row) =>
|
||||
api.warehouses.feePreview.queryOptions({
|
||||
input: { inventoryId: row.id, billingCurrency: "USD" },
|
||||
}),
|
||||
),
|
||||
});
|
||||
const feesByInventory = new Map(group.rows.map((row, i) => [row.id, feeQueries[i]?.data ?? []]));
|
||||
const feeCurrency = feeQueries.flatMap((q) => q.data ?? [])[0]?.currency ?? "USD";
|
||||
const rowByContainer = new Map(
|
||||
group.rows.filter((r) => r.containerNumber).map((r) => [r.containerNumber as string, r]),
|
||||
);
|
||||
|
||||
/** Fees follow the container onto the truck; a bulk truck carries the whole booking. */
|
||||
const costsFor = (containers: string[]) => {
|
||||
const matched = containers.map((c) => rowByContainer.get(c)).filter(Boolean) as ImportUnloadedItem[];
|
||||
const rows = matched.length > 0 ? matched : group.rows;
|
||||
const fees = rows.flatMap((r) => feesByInventory.get(r.id) ?? []);
|
||||
const sum = (type: string) =>
|
||||
fees.filter((f) => f.ruleType === type).reduce((total, f) => total + Number(f.amount || 0), 0);
|
||||
return {
|
||||
inventoryIds: rows.map((r) => r.id),
|
||||
weight: rows.reduce((total, r) => total + (Number(r.weight) || 0), 0),
|
||||
demurrage: sum("DEMURRAGE_FEE"),
|
||||
storage: sum("STORAGE_FEE"),
|
||||
};
|
||||
};
|
||||
|
||||
const fromEdr = (t: LastMileArrivalTruck): TruckRow => {
|
||||
const containers = t.containerNumber ? [t.containerNumber] : [];
|
||||
return {
|
||||
key: t.vehicleId,
|
||||
plate: [t.truckPlateNumber, t.trailerPlateNumber].filter(Boolean).join(" + ") || "—",
|
||||
driver: t.driverName,
|
||||
truckType: t.truckType,
|
||||
containers,
|
||||
vehicleId: t.vehicleId,
|
||||
...costsFor(containers),
|
||||
};
|
||||
};
|
||||
const fromCustomer = (t: Freight.ICustomerTruck): TruckRow => {
|
||||
const containers = (t.containers ?? []).map((c) => c.containerNumber);
|
||||
return {
|
||||
key: t.id,
|
||||
plate: t.plateNumber,
|
||||
driver: t.driverName,
|
||||
truckType: t.truckType,
|
||||
containers,
|
||||
vehicleId: null,
|
||||
...costsFor(containers),
|
||||
};
|
||||
};
|
||||
const trucks: TruckRow[] = isEdr ? edrTrucks.map(fromEdr) : customerTrucks.map(fromCustomer);
|
||||
|
||||
const openDocument = async (
|
||||
kind: "release" | "handover",
|
||||
inventoryId: string,
|
||||
label: string,
|
||||
) => {
|
||||
setBusy(true);
|
||||
const pdfWindow = window.open("", "_blank");
|
||||
try {
|
||||
const response =
|
||||
kind === "release"
|
||||
? await warehouseService.downloadReleaseDocument(inventoryId)
|
||||
: await warehouseService.downloadHandoverDocument(inventoryId);
|
||||
openPdfBlob(response.data, `${kind}-${group.bookingReference}.pdf`, pdfWindow);
|
||||
} catch (error) {
|
||||
pdfWindow?.close();
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: `${label} failed`,
|
||||
description: await extractDownloadErrorMessage(error),
|
||||
});
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (edrQuery.isLoading || (customerQuery.isFetching && customerTrucks.length === 0)) {
|
||||
return (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={COLS}>
|
||||
<Group gap="xs" justify="center" py="xs">
|
||||
<Loader size="xs" />
|
||||
<Text size="xs" c="dimmed">
|
||||
Loading trucks…
|
||||
</Text>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
}
|
||||
|
||||
if (trucks.length === 0) {
|
||||
return (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={COLS}>
|
||||
<Text size="xs" c="dimmed" ta="center" py="xs">
|
||||
No truck assigned to this booking yet.
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{trucks.map((t, idx) => {
|
||||
const detention = t.vehicleId ? detentionByVehicle.get(t.vehicleId) : undefined;
|
||||
const primaryId = t.inventoryIds[0] ?? null;
|
||||
return (
|
||||
<Table.Tr key={t.key} bg="var(--mantine-color-gray-0)">
|
||||
<Table.Td />
|
||||
<Table.Td>
|
||||
<Text size="sm" fw={600}>
|
||||
{t.plate}
|
||||
</Text>
|
||||
{t.driver && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t.driver}
|
||||
</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge size="sm" radius="sm" variant="light" color={isEdr ? "edr-green" : "blue"}>
|
||||
{isEdr ? "EDR" : "Customer"}
|
||||
</Badge>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t.truckType ?? "—"}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">{t.containers.length ? t.containers.join(", ") : "Bulk"}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>{formatNumber(t.weight)}</Table.Td>
|
||||
<Table.Td>{money(t.demurrage, feeCurrency)}</Table.Td>
|
||||
<Table.Td>{money(t.storage, feeCurrency)}</Table.Td>
|
||||
<Table.Td>
|
||||
{!isEdr || !detention ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
—
|
||||
</Text>
|
||||
) : (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Text size="sm">
|
||||
{detention.chargeableDays ?? 0}d ·{" "}
|
||||
{money(Number(detention.amount ?? 0), detentionQuery.data?.currency ?? "USD")}
|
||||
</Text>
|
||||
{detention.endIsOpen && (
|
||||
<Badge size="xs" color="orange" variant="light">
|
||||
open
|
||||
</Badge>
|
||||
)}
|
||||
{!detention.ruleId && (
|
||||
<Tooltip label="No detention rule matched this truck" withArrow>
|
||||
<Text size="xs" c="red">
|
||||
no rule
|
||||
</Text>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
<Menu shadow="md" width={220} position="bottom-end" withinPortal>
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="subtle" color="gray" aria-label="Truck actions" loading={busy}>
|
||||
<MoreHorizontal size={16} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={<FileText size={14} />}
|
||||
disabled={!primaryId}
|
||||
onClick={() => primaryId && openDocument("release", primaryId, "Exit paper")}
|
||||
>
|
||||
Exit paper
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<FileText size={14} />}
|
||||
disabled={!primaryId}
|
||||
onClick={() => primaryId && openDocument("handover", primaryId, "Handover")}
|
||||
>
|
||||
Handover
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<ClipboardCheck size={14} />}
|
||||
disabled={!primaryId}
|
||||
onClick={() => setInspectId(primaryId)}
|
||||
>
|
||||
Inspect / report
|
||||
</Menu.Item>
|
||||
{isEdr && (
|
||||
<>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
leftSection={<Receipt size={14} />}
|
||||
disabled={!lastMileId}
|
||||
onClick={() => setDetentionOpen(true)}
|
||||
>
|
||||
Detention times…
|
||||
</Menu.Item>
|
||||
</>
|
||||
)}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
{/* Modals portal out of the table, so one mount for the whole
|
||||
booking hangs off the first truck's cell. */}
|
||||
{idx === 0 && (
|
||||
<>
|
||||
<InspectionReportModal
|
||||
opened={Boolean(inspectId)}
|
||||
onClose={() => setInspectId(null)}
|
||||
inventoryId={inspectId}
|
||||
/>
|
||||
{isEdr && (
|
||||
<TruckDetentionModal
|
||||
opened={detentionOpen}
|
||||
onClose={() => setDetentionOpen(false)}
|
||||
record={lastMileRecordQuery.data ?? null}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ImportTrucksPage() {
|
||||
const { data: items = [], isLoading } = useQuery(
|
||||
api.warehouses.importUnloadedQueue.queryOptions({}),
|
||||
);
|
||||
const [expanded, setExpanded] = useState<string | null>(null);
|
||||
|
||||
const groups = useMemo(() => groupByBooking(items), [items]);
|
||||
const controls = useListControls(groups, {
|
||||
searchKeys: ["bookingReference", "customerName", "trainSchedule"],
|
||||
dateKey: "arrivalTime",
|
||||
});
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="Import trucks"
|
||||
subtitle="Unloaded import bookings and the trucks taking them out — cargo costs per truck, detention on EDR fleet."
|
||||
/>
|
||||
|
||||
<ListControls
|
||||
search={controls.search}
|
||||
onSearchChange={controls.setSearch}
|
||||
searchPlaceholder="Booking, customer, train…"
|
||||
dateFrom={controls.dateFrom}
|
||||
onDateFromChange={controls.setDateFrom}
|
||||
dateTo={controls.dateTo}
|
||||
onDateToChange={controls.setDateTo}
|
||||
dateLabel="Arrived"
|
||||
hasFilters={controls.hasFilters}
|
||||
onReset={controls.reset}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<Group justify="center" py="lg">
|
||||
<Loader size="sm" />
|
||||
</Group>
|
||||
) : controls.pagedRows.length === 0 ? (
|
||||
<Alert variant="light" color="gray">
|
||||
No unloaded import bookings. They appear here after Auto Unload on an arrived train.
|
||||
</Alert>
|
||||
) : (
|
||||
<>
|
||||
<Table.ScrollContainer minWidth={1100}>
|
||||
<Table highlightOnHover verticalSpacing="xs">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th w={40} />
|
||||
<Table.Th>Plate</Table.Th>
|
||||
<Table.Th>Type</Table.Th>
|
||||
<Table.Th>Containers</Table.Th>
|
||||
<Table.Th>Weight</Table.Th>
|
||||
<Table.Th>Demurrage</Table.Th>
|
||||
<Table.Th>Storage</Table.Th>
|
||||
<Table.Th>Detention</Table.Th>
|
||||
<Table.Th ta="right">Actions</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{controls.pagedRows.map((g) => {
|
||||
const isOpen = expanded === g.bookingId;
|
||||
return (
|
||||
<Fragment key={g.bookingId}>
|
||||
<Table.Tr>
|
||||
<Table.Td>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label={isOpen ? "Hide trucks" : "Show trucks"}
|
||||
onClick={() => setExpanded(isOpen ? null : g.bookingId)}
|
||||
>
|
||||
{isOpen ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
</ActionIcon>
|
||||
</Table.Td>
|
||||
<Table.Td colSpan={COLS - 1}>
|
||||
<Group gap="md" wrap="wrap">
|
||||
<Text size="sm" fw={700}>
|
||||
{g.bookingReference}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{g.customerName ?? "—"}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
Train: {g.trainSchedule ?? "—"}
|
||||
</Text>
|
||||
<Badge size="sm" radius="sm" variant="light" color="gray">
|
||||
{g.status.replace(/_/g, " ")}
|
||||
</Badge>
|
||||
<Text size="xs" c="dimmed">
|
||||
{g.rows.length} item{g.rows.length === 1 ? "" : "s"}
|
||||
</Text>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
{isOpen && <TruckRows group={g} />}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
<RuleEngineListFooter
|
||||
pagination={controls.pagination}
|
||||
pageCount={controls.pageCount}
|
||||
totalCount={controls.totalCount}
|
||||
itemLabel="bookings"
|
||||
onPaginationChange={controls.setPagination}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user