Merge pull request #1014 from Tria-plc/edrmiles

implemented import truck page
This commit is contained in:
Hagernesh Tadesse
2026-07-29 17:53:31 +03:00
committed by GitHub
2 changed files with 503 additions and 0 deletions

View File

@@ -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

View File

@@ -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>
);
}