mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge pull request #1004 from Tria-plc/warehouseselection
feat(booking-trucks-tab): show truck-level detention, gate times, ins…
This commit is contained in:
@@ -327,6 +327,8 @@ export class LastMileService {
|
||||
*/
|
||||
async arrivalTrucksForBooking(bookingId: string): Promise<
|
||||
Array<{
|
||||
/** The last-mile leg this truck belongs to — lets a caller chain straight into truck-detention-preview without a separate lookup. */
|
||||
lastMileId: string;
|
||||
vehicleId: string;
|
||||
truckPlateNumber: string | null;
|
||||
trailerPlateNumber: string | null;
|
||||
@@ -359,6 +361,7 @@ export class LastMileService {
|
||||
: [];
|
||||
|
||||
const out: Array<{
|
||||
lastMileId: string;
|
||||
vehicleId: string;
|
||||
truckPlateNumber: string | null;
|
||||
trailerPlateNumber: string | null;
|
||||
@@ -386,6 +389,7 @@ export class LastMileService {
|
||||
}
|
||||
}
|
||||
out.push({
|
||||
lastMileId: lm.id,
|
||||
vehicleId: vehicle.id,
|
||||
truckPlateNumber: vehicle.powerPlateNo || vehicle.plateNumber || null,
|
||||
trailerPlateNumber: vehicle.trailerPlateNo || null,
|
||||
|
||||
@@ -3679,6 +3679,7 @@ export class WarehouseInventoryService {
|
||||
contractId: string | null;
|
||||
hasLastMile: boolean;
|
||||
handoverSigned: boolean;
|
||||
inspectionStatus: string | null;
|
||||
}>
|
||||
> {
|
||||
const rows: Array<{
|
||||
@@ -3696,6 +3697,7 @@ export class WarehouseInventoryService {
|
||||
contractId: string | null;
|
||||
hasLastMile: boolean;
|
||||
delivered: boolean;
|
||||
inspectionStatus: string | null;
|
||||
}> = await this.dataSource.query(
|
||||
`SELECT bcu.container_number AS "containerNumber",
|
||||
COALESCE(ct.cargo_type_name, b.cargo_free_text) AS goods,
|
||||
@@ -3710,7 +3712,8 @@ export class WarehouseInventoryService {
|
||||
b.reference AS "bookingReference",
|
||||
b.contract_id AS "contractId",
|
||||
(b.last_mile_delivery_address IS NOT NULL) AS "hasLastMile",
|
||||
COALESCE(inv.status = 'DELIVERED', false) AS delivered
|
||||
COALESCE(inv.status = 'DELIVERED', false) AS delivered,
|
||||
inv.inspection_status AS "inspectionStatus"
|
||||
FROM freight.booking_container_units bcu
|
||||
JOIN freight.booking_container bc
|
||||
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
|
||||
@@ -3762,6 +3765,7 @@ export class WarehouseInventoryService {
|
||||
bookingReference: r.bookingReference,
|
||||
contractId: r.contractId,
|
||||
hasLastMile: r.hasLastMile,
|
||||
inspectionStatus: r.inspectionStatus,
|
||||
handoverSigned,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useQueries, useQuery } from "@tanstack/react-query";
|
||||
import { Badge, Button, Center, Group, Loader, SimpleGrid, Stack, Table, Text } from "@mantine/core";
|
||||
import { Coins, Truck } from "lucide-react";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import { warehouseService } from "@/services/warehouse.service";
|
||||
import { lastMileService } from "@/services/last-mile.service";
|
||||
import { FeePreviewModal } from "@/components/warehouses/FeePreviewModal";
|
||||
import { TruckDetentionModal } from "@/components/operations/TruckDetentionModal";
|
||||
|
||||
import { SectionCard } from "./SectionCard";
|
||||
import { MetricTile } from "./MetricTile";
|
||||
|
||||
const money = (amount: number, currency: string) =>
|
||||
`${Number(amount).toLocaleString()} ${currency === "ETB" ? "Birr (ETB)" : currency}`;
|
||||
|
||||
const fmt = (iso: string | null | undefined) => (iso ? new Date(iso).toLocaleString() : "—");
|
||||
|
||||
function inspectionLabel(status: string | null | undefined): { text: string; color: string } {
|
||||
if (!status) return { text: "Pending", color: "gray" };
|
||||
if (status === "PASSED") return { text: "Passed", color: "edr-green" };
|
||||
if (status === "FAILED") return { text: "Failed", color: "red" };
|
||||
return { text: status, color: "gray" };
|
||||
}
|
||||
|
||||
interface TruckRow {
|
||||
key: string;
|
||||
plate: string;
|
||||
driver: string | null;
|
||||
truckType: string | null;
|
||||
containers: string[];
|
||||
warehouseArrived: string | null;
|
||||
warehouseDeparted: string | null;
|
||||
destinationArrived: string | null;
|
||||
returned: string | null;
|
||||
detentionOpen: boolean;
|
||||
detentionDays: number | null;
|
||||
detentionAmount: number | null;
|
||||
hasDetentionRule: boolean;
|
||||
inspection: { text: string; color: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* Every truck tied to a booking's last mile — EDR-dispatched or customer
|
||||
* self-haul (a booking only ever uses one), each with its own warehouse-gate
|
||||
* and destination-detention clocks, plus the booking's cargo-side cost totals
|
||||
* (storage/demurrage/double handling — billed per row internally, always
|
||||
* shown here as one booking-level total). Detention stays EDR-only; customer
|
||||
* self-haul rows show "—" since EDR only bills detention on its own fleet.
|
||||
*/
|
||||
export function BookingTrucksPanel({ bookingId }: { bookingId: string }) {
|
||||
const [feeModalOpen, setFeeModalOpen] = useState(false);
|
||||
const [detentionModalOpen, setDetentionModalOpen] = useState(false);
|
||||
|
||||
const inventoryQuery = useQuery(
|
||||
api.warehouses.listInventory.queryOptions({ input: { filter: { bookingId } } }),
|
||||
);
|
||||
const inventoryItems = inventoryQuery.data ?? [];
|
||||
const latestInventory = inventoryItems[0] ?? null;
|
||||
|
||||
const edrTrucksQuery = useQuery({
|
||||
queryKey: ["booking-edr-trucks", bookingId],
|
||||
queryFn: () => warehouseService.getLastMileTrucks(bookingId),
|
||||
});
|
||||
const edrTrucks = edrTrucksQuery.data ?? [];
|
||||
|
||||
const customerTrucksQuery = useQuery({
|
||||
queryKey: ["booking-customer-trucks", bookingId],
|
||||
queryFn: () => warehouseService.getCustomerTrucks(bookingId),
|
||||
enabled: edrTrucksQuery.isSuccess && edrTrucks.length === 0,
|
||||
});
|
||||
const customerTrucks = customerTrucksQuery.data ?? [];
|
||||
|
||||
const mode: "EDR" | "CUSTOMER" | "NONE" =
|
||||
edrTrucks.length > 0 ? "EDR" : customerTrucks.length > 0 ? "CUSTOMER" : "NONE";
|
||||
|
||||
const containerItemsQuery = useQuery({
|
||||
queryKey: ["booking-container-items-for-trucks", bookingId],
|
||||
queryFn: () => warehouseService.getContainerItems(bookingId),
|
||||
});
|
||||
const inspectionByContainer = new Map(
|
||||
(containerItemsQuery.data ?? []).map((c) => [c.containerNumber, c.inspectionStatus]),
|
||||
);
|
||||
|
||||
const lastMileId = edrTrucks[0]?.lastMileId ?? null;
|
||||
|
||||
const detentionPreviewQuery = useQuery({
|
||||
queryKey: ["truck-detention-preview-for-trucks-tab", lastMileId],
|
||||
queryFn: () => lastMileService.truckDetentionPreview(lastMileId as string).then((r) => r.data),
|
||||
enabled: Boolean(lastMileId),
|
||||
});
|
||||
const detentionPreview = detentionPreviewQuery.data;
|
||||
const detentionByVehicle = new Map(
|
||||
(detentionPreview?.groups ?? []).map((g) => [g.vehicleId ?? "", g]),
|
||||
);
|
||||
|
||||
const lastMileRecordQuery = useQuery({
|
||||
queryKey: ["last-mile-record-for-trucks-tab", lastMileId],
|
||||
queryFn: () => lastMileService.getById(lastMileId as string).then((r) => r.data),
|
||||
enabled: Boolean(lastMileId),
|
||||
});
|
||||
|
||||
// Booking-level cost strip: same per-row fee preview the accrual dashboard
|
||||
// and FeePreviewModal already use, summed across every inventory row on
|
||||
// this booking rather than duplicated per row.
|
||||
const feeQueries = useQueries({
|
||||
queries: inventoryItems.map((item) =>
|
||||
api.warehouses.feePreview.queryOptions({ input: { inventoryId: item.id, billingCurrency: "USD" } }),
|
||||
),
|
||||
});
|
||||
const allFees = feeQueries.flatMap((q) => q.data ?? []);
|
||||
const feeCurrency = allFees[0]?.currency ?? "USD";
|
||||
const sumByType = (type: string) =>
|
||||
allFees.filter((f) => f.ruleType === type).reduce((sum, f) => sum + Number(f.amount || 0), 0);
|
||||
|
||||
const rows: TruckRow[] = useMemo(() => {
|
||||
if (mode === "EDR") {
|
||||
return edrTrucks.map((t) => {
|
||||
const g = detentionByVehicle.get(t.vehicleId);
|
||||
return {
|
||||
key: t.vehicleId,
|
||||
plate: [t.truckPlateNumber, t.trailerPlateNumber].filter(Boolean).join(" + ") || "—",
|
||||
driver: t.driverName,
|
||||
truckType: t.truckType,
|
||||
containers: t.containerNumber ? [t.containerNumber] : [],
|
||||
warehouseArrived: t.arrivedAt,
|
||||
warehouseDeparted: t.departedAt,
|
||||
destinationArrived: g?.startDate ?? null,
|
||||
returned: g?.endIsOpen ? null : g?.endDate ?? null,
|
||||
detentionOpen: Boolean(g?.endIsOpen),
|
||||
detentionDays: g?.chargeableDays ?? null,
|
||||
detentionAmount: g?.amount ?? null,
|
||||
hasDetentionRule: Boolean(g?.ruleId),
|
||||
inspection: inspectionLabel(t.containerNumber ? inspectionByContainer.get(t.containerNumber) : undefined),
|
||||
};
|
||||
});
|
||||
}
|
||||
if (mode === "CUSTOMER") {
|
||||
return customerTrucks.map((t) => {
|
||||
const containers = (t.containers ?? []).map((c) => c.containerNumber);
|
||||
const statuses = new Set(containers.map((cn) => inspectionByContainer.get(cn) ?? null));
|
||||
const inspection =
|
||||
containers.length === 0
|
||||
? inspectionLabel(undefined)
|
||||
: statuses.size > 1
|
||||
? { text: "Mixed", color: "yellow" }
|
||||
: inspectionLabel([...statuses][0]);
|
||||
return {
|
||||
key: t.id,
|
||||
plate: t.plateNumber,
|
||||
driver: t.driverName,
|
||||
truckType: t.truckType,
|
||||
containers,
|
||||
warehouseArrived: t.arrivedAt ?? null,
|
||||
warehouseDeparted: t.departedAt ?? null,
|
||||
destinationArrived: null,
|
||||
returned: null,
|
||||
detentionOpen: false,
|
||||
detentionDays: null,
|
||||
detentionAmount: null,
|
||||
hasDetentionRule: false,
|
||||
inspection,
|
||||
};
|
||||
});
|
||||
}
|
||||
return [];
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [mode, edrTrucks, customerTrucks, inspectionByContainer, detentionByVehicle]);
|
||||
|
||||
if (inventoryQuery.isLoading || edrTrucksQuery.isLoading) {
|
||||
return (
|
||||
<Center py={60}>
|
||||
<Group gap={10}>
|
||||
<Loader color="edr-green" />
|
||||
<Text c="dimmed">Loading trucks…</Text>
|
||||
</Group>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<SectionCard
|
||||
icon={Coins}
|
||||
title="Cargo costs"
|
||||
subtitle="Storage, demurrage & double handling — booking total"
|
||||
accent="teal"
|
||||
extra={
|
||||
latestInventory && (
|
||||
<Button size="xs" variant="light" onClick={() => setFeeModalOpen(true)}>
|
||||
View breakdown
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
>
|
||||
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="sm">
|
||||
<MetricTile label="Storage" value={money(sumByType("STORAGE_FEE"), feeCurrency)} />
|
||||
<MetricTile label="Demurrage" value={money(sumByType("DEMURRAGE_FEE"), feeCurrency)} />
|
||||
<MetricTile label="Double handling" value={money(sumByType("DOUBLE_HANDLING_FEE"), feeCurrency)} />
|
||||
</SimpleGrid>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard
|
||||
icon={Truck}
|
||||
title="Trucks"
|
||||
subtitle={
|
||||
mode === "EDR" ? "EDR Last Mile" : mode === "CUSTOMER" ? "Customer Self-Haul" : undefined
|
||||
}
|
||||
accent="grape"
|
||||
extra={
|
||||
mode === "EDR" && (
|
||||
<Button size="xs" variant="light" onClick={() => setDetentionModalOpen(true)}>
|
||||
Detention times
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
>
|
||||
{rows.length === 0 ? (
|
||||
<Text size="sm" c="dimmed" ta="center" py="md">
|
||||
No trucks assigned to this booking's last mile yet.
|
||||
</Text>
|
||||
) : (
|
||||
<Table.ScrollContainer minWidth={1000}>
|
||||
<Table verticalSpacing="xs" fz="xs">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Plate</Table.Th>
|
||||
<Table.Th>Driver</Table.Th>
|
||||
<Table.Th>Type</Table.Th>
|
||||
<Table.Th>Container(s)</Table.Th>
|
||||
<Table.Th>Wh. arrived</Table.Th>
|
||||
<Table.Th>Wh. departed</Table.Th>
|
||||
<Table.Th>Dest. arrived</Table.Th>
|
||||
<Table.Th>Returned</Table.Th>
|
||||
<Table.Th>Detention</Table.Th>
|
||||
<Table.Th>Inspection</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{rows.map((r) => (
|
||||
<Table.Tr key={r.key}>
|
||||
<Table.Td>{r.plate}</Table.Td>
|
||||
<Table.Td>{r.driver ?? "—"}</Table.Td>
|
||||
<Table.Td>{r.truckType ?? "—"}</Table.Td>
|
||||
<Table.Td>{r.containers.length ? r.containers.join(", ") : "—"}</Table.Td>
|
||||
<Table.Td>{fmt(r.warehouseArrived)}</Table.Td>
|
||||
<Table.Td>{fmt(r.warehouseDeparted)}</Table.Td>
|
||||
<Table.Td>{fmt(r.destinationArrived)}</Table.Td>
|
||||
<Table.Td>
|
||||
{r.detentionOpen ? (
|
||||
<Badge size="xs" color="orange" variant="light">
|
||||
still out
|
||||
</Badge>
|
||||
) : (
|
||||
fmt(r.returned)
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{mode !== "EDR" || r.detentionDays == null ? (
|
||||
"—"
|
||||
) : (
|
||||
<>
|
||||
{r.detentionDays}d · {money(r.detentionAmount ?? 0, detentionPreview?.currency ?? "USD")}
|
||||
{!r.hasDetentionRule && (
|
||||
<Text span size="xs" c="red">
|
||||
{" "}
|
||||
· no rule
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge size="xs" variant="light" color={r.inspection.color}>
|
||||
{r.inspection.text}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
<FeePreviewModal
|
||||
opened={feeModalOpen}
|
||||
onClose={() => setFeeModalOpen(false)}
|
||||
inventoryId={latestInventory?.id ?? null}
|
||||
/>
|
||||
{mode === "EDR" && (
|
||||
<TruckDetentionModal
|
||||
opened={detentionModalOpen}
|
||||
onClose={() => setDetentionModalOpen(false)}
|
||||
record={lastMileRecordQuery.data ?? null}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ export * from "./booking-detail.styles";
|
||||
export * from "./SectionCard";
|
||||
export * from "./ClearanceReviewSection";
|
||||
export * from "./BookingDocumentsPanel";
|
||||
export * from "./BookingTrucksPanel";
|
||||
export * from "./ContractOrdersPanel";
|
||||
export * from "./MetricTile";
|
||||
export * from "./BookingDetailToolbar";
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
LayoutGrid,
|
||||
Milestone,
|
||||
Package,
|
||||
Truck,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Container,
|
||||
@@ -36,6 +37,7 @@ import {
|
||||
BookingContractSummaryCard,
|
||||
BookingContainerUnitsCard,
|
||||
BookingDocumentsPanel,
|
||||
BookingTrucksPanel,
|
||||
ContractOrdersPanel,
|
||||
} from "@/components/bookings/detail";
|
||||
import { WarehouseInfoCard } from "@/components/warehouses";
|
||||
@@ -141,7 +143,9 @@ export default function BookingRequestDetailPage() {
|
||||
? "orders"
|
||||
: requestedTab === "documents"
|
||||
? "documents"
|
||||
: "overview";
|
||||
: requestedTab === "trucks"
|
||||
? "trucks"
|
||||
: "overview";
|
||||
const setActiveTab = (tab: string | null) => {
|
||||
const next = new URLSearchParams(searchParams);
|
||||
if (tab && tab !== "overview") next.set("tab", tab);
|
||||
@@ -207,6 +211,9 @@ export default function BookingRequestDetailPage() {
|
||||
>
|
||||
Documents
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="trucks" leftSection={<Truck size={16} />}>
|
||||
Trucks
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="overview">
|
||||
@@ -223,6 +230,9 @@ export default function BookingRequestDetailPage() {
|
||||
<Tabs.Panel value="documents">
|
||||
<BookingDocumentsPanel bookingId={booking.id} />
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="trucks">
|
||||
<BookingTrucksPanel bookingId={booking.id} />
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
</Grid.Col>
|
||||
|
||||
|
||||
@@ -91,6 +91,7 @@ export interface ContainerItem {
|
||||
contractId: string | null;
|
||||
hasLastMile: boolean;
|
||||
handoverSigned: boolean;
|
||||
inspectionStatus: string | null;
|
||||
}
|
||||
|
||||
/** A pre-dispatch EXPORT train that has inventory waiting to be loaded. */
|
||||
@@ -136,6 +137,8 @@ const cleanParams = (params: object) =>
|
||||
|
||||
/** An assigned EDR last-mile truck, shaped for the arrival/exit weighing prefill. */
|
||||
export interface LastMileArrivalTruck {
|
||||
/** The last-mile leg this truck belongs to — feed straight into lastMileService.truckDetentionPreview(lastMileId). */
|
||||
lastMileId: string;
|
||||
vehicleId: string;
|
||||
truckPlateNumber: string | null;
|
||||
trailerPlateNumber: string | null;
|
||||
|
||||
Reference in New Issue
Block a user