Truck operations inside each bookings

This commit is contained in:
Hagernesh
2026-08-03 13:11:02 +00:00
parent 237bb067ad
commit 5c33ea90bd
2 changed files with 38 additions and 223 deletions

View File

@@ -1,13 +1,11 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { useQueries, useQuery } from "@tanstack/react-query"; import { useQueries, useQuery } from "@tanstack/react-query";
import { Badge, Button, Center, Group, Loader, SimpleGrid, Stack, Table, Text } from "@mantine/core"; import { Button, Center, Group, Loader, SimpleGrid, Stack, Table, Text } from "@mantine/core";
import { Coins, Truck } from "lucide-react"; import { Coins, Truck } from "lucide-react";
import { api } from "@/services/api"; 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 { FeePreviewModal } from "@/components/warehouses/FeePreviewModal";
import { TruckDetentionModal } from "@/components/operations/TruckDetentionModal"; import { groupByBooking, TruckRows } from "@/pages/warehouses/ImportTrucksPage";
import { SectionCard } from "./SectionCard"; import { SectionCard } from "./SectionCard";
import { MetricTile } from "./MetricTile"; import { MetricTile } from "./MetricTile";
@@ -15,43 +13,14 @@ import { MetricTile } from "./MetricTile";
const money = (amount: number, currency: string) => const money = (amount: number, currency: string) =>
`${Number(amount).toLocaleString()} ${currency === "ETB" ? "Birr (ETB)" : currency}`; `${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 * Cargo costs (booking-level totals) plus the same truck-import block the
* self-haul (a booking only ever uses one), each with its own warehouse-gate * Unloaded Queue's "Import trucks" view uses — Truck Arrival/Leaving, Exit
* and destination-detention clocks, plus the booking's cargo-side cost totals * paper, Handover, Inspect, Detention times, Warehouse gate times — reused
* (storage/demurrage/double handling — billed per row internally, always * as-is so this tab never drifts from that queue's behavior.
* 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 }) { export function BookingTrucksPanel({ bookingId }: { bookingId: string }) {
const [feeModalOpen, setFeeModalOpen] = useState(false); const [feeModalOpen, setFeeModalOpen] = useState(false);
const [detentionModalOpen, setDetentionModalOpen] = useState(false);
const inventoryQuery = useQuery( const inventoryQuery = useQuery(
api.warehouses.listInventory.queryOptions({ input: { filter: { bookingId } } }), api.warehouses.listInventory.queryOptions({ input: { filter: { bookingId } } }),
@@ -59,47 +28,27 @@ export function BookingTrucksPanel({ bookingId }: { bookingId: string }) {
const inventoryItems = inventoryQuery.data ?? []; const inventoryItems = inventoryQuery.data ?? [];
const latestInventory = inventoryItems[0] ?? null; const latestInventory = inventoryItems[0] ?? null;
const edrTrucksQuery = useQuery({ // Same query key as the Unloaded Queue page — shares its cache instead of
queryKey: ["booking-edr-trucks", bookingId], // refetching the whole queue when it's already loaded elsewhere.
queryFn: () => warehouseService.getLastMileTrucks(bookingId), const unloadedQuery = useQuery(api.warehouses.importUnloadedQueue.queryOptions({}));
}); const bookingRows = useMemo(
const edrTrucks = edrTrucksQuery.data ?? []; () => (unloadedQuery.data ?? []).filter((row) => row.bookingId === bookingId),
[unloadedQuery.data, bookingId],
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 group = useMemo(() => {
const lastMileId = edrTrucks[0]?.lastMileId ?? null; const [existing] = groupByBooking(bookingRows);
return (
const detentionPreviewQuery = useQuery({ existing ?? {
queryKey: ["truck-detention-preview-for-trucks-tab", lastMileId], bookingId,
queryFn: () => lastMileService.truckDetentionPreview(lastMileId as string).then((r) => r.data), bookingReference: bookingId,
enabled: Boolean(lastMileId), customerName: null,
}); trainSchedule: null,
const detentionPreview = detentionPreviewQuery.data; status: "NONE",
const detentionByVehicle = new Map( arrivalTime: null,
(detentionPreview?.groups ?? []).map((g) => [g.vehicleId ?? "", g]), rows: [],
); }
);
const lastMileRecordQuery = useQuery({ }, [bookingRows, bookingId]);
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 // Booking-level cost strip: same per-row fee preview the accrual dashboard
// and FeePreviewModal already use, summed across every inventory row on // and FeePreviewModal already use, summed across every inventory row on
@@ -114,61 +63,7 @@ export function BookingTrucksPanel({ bookingId }: { bookingId: string }) {
const sumByType = (type: string) => const sumByType = (type: string) =>
allFees.filter((f) => f.ruleType === type).reduce((sum, f) => sum + Number(f.amount || 0), 0); allFees.filter((f) => f.ruleType === type).reduce((sum, f) => sum + Number(f.amount || 0), 0);
const rows: TruckRow[] = useMemo(() => { if (inventoryQuery.isLoading || unloadedQuery.isLoading) {
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 ( return (
<Center py={60}> <Center py={60}>
<Group gap={10}> <Group gap={10}>
@@ -201,87 +96,14 @@ export function BookingTrucksPanel({ bookingId }: { bookingId: string }) {
</SimpleGrid> </SimpleGrid>
</SectionCard> </SectionCard>
<SectionCard <SectionCard icon={Truck} title="Trucks" accent="grape">
icon={Truck} <Table.ScrollContainer minWidth={1100}>
title="Trucks" <Table verticalSpacing="xs" fz="xs">
subtitle={ <Table.Tbody>
mode === "EDR" ? "EDR Last Mile" : mode === "CUSTOMER" ? "Customer Self-Haul" : undefined <TruckRows group={group} />
} </Table.Tbody>
accent="grape" </Table>
extra={ </Table.ScrollContainer>
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> </SectionCard>
<FeePreviewModal <FeePreviewModal
@@ -289,13 +111,6 @@ export function BookingTrucksPanel({ bookingId }: { bookingId: string }) {
onClose={() => setFeeModalOpen(false)} onClose={() => setFeeModalOpen(false)}
inventoryId={latestInventory?.id ?? null} inventoryId={latestInventory?.id ?? null}
/> />
{mode === "EDR" && (
<TruckDetentionModal
opened={detentionModalOpen}
onClose={() => setDetentionModalOpen(false)}
record={lastMileRecordQuery.data ?? null}
/>
)}
</Stack> </Stack>
); );
} }

View File

@@ -82,7 +82,7 @@ const money = (amount: number, currency: string) =>
const formatTime = (iso: string | null | undefined) => const formatTime = (iso: string | null | undefined) =>
iso ? new Date(iso).toLocaleString() : "—"; iso ? new Date(iso).toLocaleString() : "—";
interface BookingGroup { export interface BookingGroup {
bookingId: string; bookingId: string;
bookingReference: string; bookingReference: string;
customerName: string | null; customerName: string | null;
@@ -93,7 +93,7 @@ interface BookingGroup {
} }
/** One collapsed line per booking; its inventory rows travel with it for the fee lookup. */ /** One collapsed line per booking; its inventory rows travel with it for the fee lookup. */
function groupByBooking(items: ImportUnloadedItem[]): BookingGroup[] { export function groupByBooking(items: ImportUnloadedItem[]): BookingGroup[] {
const groups = new Map<string, BookingGroup>(); const groups = new Map<string, BookingGroup>();
for (const item of items) { for (const item of items) {
if (!item.bookingId) continue; if (!item.bookingId) continue;
@@ -140,7 +140,7 @@ interface TruckRow {
* Haulage mode is decided by which list comes back non-empty — a booking is * Haulage mode is decided by which list comes back non-empty — a booking is
* either EDR last mile or customer self-haul, never both. * either EDR last mile or customer self-haul, never both.
*/ */
function TruckRows({ group }: { group: BookingGroup }) { export function TruckRows({ group }: { group: BookingGroup }) {
const { toast } = useToast(); const { toast } = useToast();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);