mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 02:58:11 +00:00
Merge branch 'dev' of github.com:Tria-plc/edr-platform into freight_feature/usermanagement
This commit is contained in:
@@ -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";
|
||||
|
||||
@@ -59,6 +59,13 @@ const FIELD_LABELS: Record<string, string> = {
|
||||
woreda: "Woreda",
|
||||
kebele: "Kebele",
|
||||
houseNo: "House no.",
|
||||
statusDescription: "eTrade status",
|
||||
dateRegistered: "Date registered",
|
||||
renewedFrom: "Renewed from",
|
||||
renewalDate: "Renewal date",
|
||||
renewedTo: "Renewed to",
|
||||
etradePhone: "eTrade phone",
|
||||
ownerPassportNumber: "Owner passport number",
|
||||
};
|
||||
|
||||
/** Best-effort current value on the live company for a proposed field key. */
|
||||
@@ -85,6 +92,74 @@ function currentValue(company: Company, key: string): string {
|
||||
return v === null || v === undefined || v === "" ? "—" : String(v);
|
||||
}
|
||||
|
||||
/** Subject a staged `snapshot.faydaIdentity` blob belongs to, from which of its `*FaydaSub` keys is present. */
|
||||
function faydaIdentitySubject(
|
||||
snapshot: Record<string, unknown>,
|
||||
): "owner" | "poa" | null {
|
||||
if ("ownerFaydaSub" in snapshot) return "owner";
|
||||
if ("poaFaydaSub" in snapshot) return "poa";
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* `stageIdentityChange` writes a nested `snapshot.faydaIdentity` object
|
||||
* (attrs-key names like `ownerEmail`, not top-level DTO keys), so the generic
|
||||
* `DiffRow` loop below can't render it — it would just stringify to
|
||||
* `[object Object]`. Render it as its own before/after block instead, using
|
||||
* the company's current `identity.owner`/`identity.poa` as the "before" side.
|
||||
*/
|
||||
function FaydaIdentityDiff({
|
||||
company,
|
||||
snapshot,
|
||||
}: {
|
||||
company: Company;
|
||||
snapshot: Record<string, unknown>;
|
||||
}) {
|
||||
const subject = faydaIdentitySubject(snapshot);
|
||||
if (!subject) return null;
|
||||
const current =
|
||||
subject === "owner" ? company.identity?.owner : company.identity?.poa;
|
||||
const read = (key: string) => snapshot[`${subject}${key}`] as string | undefined;
|
||||
const verifiedAt = read("FaydaVerifiedAt");
|
||||
const fields: { label: string; from?: string | null; to?: string }[] = [
|
||||
{ label: "Name", from: current?.name, to: read("Name") },
|
||||
{ label: "Email", from: current?.email, to: read("Email") },
|
||||
{ label: "Phone", from: current?.phone, to: read("Phone") },
|
||||
{ label: "Address", from: current?.address, to: read("Address") },
|
||||
].filter((f) => f.to !== undefined);
|
||||
|
||||
return (
|
||||
<Stack gap={8}>
|
||||
<Group gap={8}>
|
||||
<Text size="sm" fw={600} c="edr-text">
|
||||
{subject === "owner" ? "Owner re-verification" : "PoA re-verification"}
|
||||
</Text>
|
||||
{verifiedAt && (
|
||||
<Text size="xs" c="dimmed">
|
||||
Verified {formatDate(verifiedAt)}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
{fields.length > 0 ? (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg">
|
||||
{fields.map((f) => (
|
||||
<DiffRow
|
||||
key={f.label}
|
||||
label={f.label}
|
||||
from={f.from?.trim() ? f.from : "—"}
|
||||
to={f.to?.trim() ? f.to : "—"}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed">
|
||||
Identity re-verified — no name/email/phone/address change.
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function DiffRow({
|
||||
label,
|
||||
from,
|
||||
@@ -153,8 +228,11 @@ export function ChangeRequestReview({ company }: { company: Company }) {
|
||||
if (!pending && history.length === 0) return null;
|
||||
|
||||
const proposedKeys = pending
|
||||
? Object.keys(pending.snapshot ?? {})
|
||||
? Object.keys(pending.snapshot ?? {}).filter((k) => k !== "faydaIdentity")
|
||||
: ([] as string[]);
|
||||
const faydaIdentitySnapshot = pending?.snapshot?.faydaIdentity as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
const docCount = pending?.documentFileIds?.length ?? 0;
|
||||
const licenseChanges = pending?.licenseChanges ?? [];
|
||||
const documentChanges = pending?.documentChanges ?? [];
|
||||
@@ -209,10 +287,14 @@ export function ChangeRequestReview({ company }: { company: Company }) {
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
) : (
|
||||
) : !faydaIdentitySnapshot ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
No field changes — document uploads only.
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
{faydaIdentitySnapshot && (
|
||||
<FaydaIdentityDiff company={company} snapshot={faydaIdentitySnapshot} />
|
||||
)}
|
||||
|
||||
{documentChanges.length > 0 && (
|
||||
|
||||
@@ -43,21 +43,56 @@ function Stat({ label, value, strong }: { label: string; value: React.ReactNode;
|
||||
);
|
||||
}
|
||||
|
||||
type TruckRow = {
|
||||
vehicleId: string;
|
||||
label: string;
|
||||
arrived: Date | null;
|
||||
returned: Date | null;
|
||||
};
|
||||
|
||||
const plateOf = (a: NonNullable<LastMileRecord['vehicleAssignments']>[number]) =>
|
||||
[a.vehicle?.code, a.vehicle?.plateNumber].filter(Boolean).join(' · ') || a.vehicleId;
|
||||
|
||||
/**
|
||||
* View/override the detention clock (arrival + delivery/return) for a last-mile
|
||||
* leg, preview the per-truck-per-day charge, and generate the detention invoice.
|
||||
* Detention is PER TRUCK: every truck reaches the destination and is released at
|
||||
* its own time, so each row carries its own clock, days and amount. Legs with no
|
||||
* trucks assigned fall back to the single leg-level window.
|
||||
*/
|
||||
export function TruckDetentionModal({ opened, onClose, record }: TruckDetentionModalProps) {
|
||||
const { toast } = useToast();
|
||||
const qc = useQueryClient();
|
||||
const id = record?.id ?? null;
|
||||
const assignments = record?.vehicleAssignments ?? [];
|
||||
const perTruck = assignments.length > 0;
|
||||
|
||||
const [rows, setRows] = useState<TruckRow[]>([]);
|
||||
// Leg-level fallback (no trucks assigned yet).
|
||||
const [arrived, setArrived] = useState<Date | null>(null);
|
||||
const [delivered, setDelivered] = useState<Date | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setRows(
|
||||
assignments.map((a) => ({
|
||||
vehicleId: a.vehicleId,
|
||||
label: plateOf(a),
|
||||
// Fall back to the leg-level pair so a truck without its own window
|
||||
// shows what it is actually being billed on today.
|
||||
arrived: a.destinationArrivedAt
|
||||
? new Date(a.destinationArrivedAt)
|
||||
: record?.arrivedAt
|
||||
? new Date(record.arrivedAt)
|
||||
: null,
|
||||
returned: a.returnedAt
|
||||
? new Date(a.returnedAt)
|
||||
: record?.deliveredAt
|
||||
? new Date(record.deliveredAt)
|
||||
: null,
|
||||
})),
|
||||
);
|
||||
setArrived(record?.arrivedAt ? new Date(record.arrivedAt) : null);
|
||||
setDelivered(record?.deliveredAt ? new Date(record.deliveredAt) : null);
|
||||
}, [record?.id, record?.arrivedAt, record?.deliveredAt, opened]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [record?.id, record?.arrivedAt, record?.deliveredAt, assignments.length, opened]);
|
||||
|
||||
const previewQuery = useQuery({
|
||||
queryKey: ['truck-detention-preview', id],
|
||||
@@ -65,19 +100,35 @@ export function TruckDetentionModal({ opened, onClose, record }: TruckDetentionM
|
||||
enabled: opened && Boolean(id),
|
||||
});
|
||||
const preview = previewQuery.data;
|
||||
// With several trucks the header rule is null by design (each truck resolves
|
||||
// its own) — only warn when NO truck matched a rule.
|
||||
const hasAnyRule = Boolean(preview?.ruleId) || (preview?.groups ?? []).some((g) => g.ruleId);
|
||||
const byVehicle = new Map((preview?.groups ?? []).map((g) => [g.vehicleId ?? '', g]));
|
||||
|
||||
const saveTimes = useMutation({
|
||||
mutationFn: () =>
|
||||
lastMileService.update(id as string, {
|
||||
arrivedAt: arrived ? arrived.toISOString() : null,
|
||||
deliveredAt: delivered ? delivered.toISOString() : null,
|
||||
}),
|
||||
perTruck
|
||||
? lastMileService.setDetentionTimes(
|
||||
id as string,
|
||||
rows.map((r) => ({
|
||||
vehicleId: r.vehicleId,
|
||||
destinationArrivedAt: r.arrived ? r.arrived.toISOString() : null,
|
||||
returnedAt: r.returned ? r.returned.toISOString() : null,
|
||||
})),
|
||||
)
|
||||
: lastMileService.update(id as string, {
|
||||
arrivedAt: arrived ? arrived.toISOString() : null,
|
||||
deliveredAt: delivered ? delivered.toISOString() : null,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE.ROOT });
|
||||
void previewQuery.refetch();
|
||||
toast({ title: 'Detention times saved' });
|
||||
},
|
||||
onError: () => toast({ title: 'Save failed', variant: 'destructive' }),
|
||||
onError: (e: unknown) => {
|
||||
const description = (e as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||
toast({ title: 'Save failed', description, variant: 'destructive' });
|
||||
},
|
||||
});
|
||||
|
||||
const generate = useMutation({
|
||||
@@ -93,12 +144,40 @@ export function TruckDetentionModal({ opened, onClose, record }: TruckDetentionM
|
||||
},
|
||||
});
|
||||
|
||||
const handleSave = () => {
|
||||
const values = perTruck
|
||||
? rows.flatMap((r) => [r.arrived, r.returned])
|
||||
: [arrived, delivered];
|
||||
// No backdating: detention times are recorded as they happen.
|
||||
if (values.some((v) => isBackdated(v))) {
|
||||
toast({ variant: 'destructive', title: 'Detention times cannot be in the past' });
|
||||
return;
|
||||
}
|
||||
const reversed = perTruck
|
||||
? rows.find((r) => r.arrived && r.returned && r.returned < r.arrived)
|
||||
: arrived && delivered && delivered < arrived
|
||||
? { label: 'this delivery' }
|
||||
: undefined;
|
||||
if (reversed) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Return time is before arrival',
|
||||
description: `Check the times for ${reversed.label}.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
saveTimes.mutate();
|
||||
};
|
||||
|
||||
const patchRow = (vehicleId: string, patch: Partial<TruckRow>) =>
|
||||
setRows((prev) => prev.map((r) => (r.vehicleId === vehicleId ? { ...r, ...patch } : r)));
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
centered
|
||||
size="lg"
|
||||
size="xl"
|
||||
title={
|
||||
<Text fw={700}>
|
||||
Truck detention{record?.booking?.reference ? ` · ${record.booking.reference}` : ''}
|
||||
@@ -106,40 +185,94 @@ export function TruckDetentionModal({ opened, onClose, record }: TruckDetentionM
|
||||
}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Group grow align="flex-start">
|
||||
<DateTimePicker
|
||||
label="Arrived at"
|
||||
description="Detention clock start"
|
||||
value={arrived}
|
||||
onChange={(v) => setArrived(v ? new Date(v) : null)}
|
||||
minDate={new Date()}
|
||||
clearable
|
||||
/>
|
||||
<DateTimePicker
|
||||
label="Delivered / returned at"
|
||||
description="Clock end (blank = still out)"
|
||||
value={delivered}
|
||||
onChange={(v) => setDelivered(v ? new Date(v) : null)}
|
||||
minDate={new Date()}
|
||||
clearable
|
||||
/>
|
||||
</Group>
|
||||
{perTruck ? (
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" c="dimmed">
|
||||
Each truck has its own detention clock — record when it reached the destination and
|
||||
when it was released. Days and charges are calculated per truck.
|
||||
</Text>
|
||||
{rows.map((r) => {
|
||||
const g = byVehicle.get(r.vehicleId);
|
||||
return (
|
||||
<Paper key={r.vehicleId} withBorder p="sm" radius="md">
|
||||
<Group justify="space-between" mb={6} wrap="nowrap">
|
||||
<Group gap={8}>
|
||||
<Text size="sm" fw={600}>
|
||||
{r.label}
|
||||
</Text>
|
||||
{g?.vehicleType && (
|
||||
<Badge size="xs" variant="light" color="gray">
|
||||
{g.vehicleType}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
{g && (
|
||||
<Group gap={10} wrap="nowrap">
|
||||
<Text size="xs" c={g.endIsOpen ? 'orange' : 'dimmed'}>
|
||||
{g.chargeableDays} day{g.chargeableDays === 1 ? '' : 's'}
|
||||
{g.endIsOpen ? ' · still out' : ''}
|
||||
</Text>
|
||||
<Text size="sm" fw={700}>
|
||||
{money(g.amount, preview?.currency ?? 'USD')}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
<Group grow align="flex-start">
|
||||
<DateTimePicker
|
||||
label="Arrived at destination"
|
||||
description="Detention clock start"
|
||||
value={r.arrived}
|
||||
onChange={(v) => patchRow(r.vehicleId, { arrived: v ? new Date(v) : null })}
|
||||
minDate={new Date()}
|
||||
clearable
|
||||
/>
|
||||
<DateTimePicker
|
||||
label="Released / returned at"
|
||||
description="Clock end (blank = still out)"
|
||||
value={r.returned}
|
||||
onChange={(v) => patchRow(r.vehicleId, { returned: v ? new Date(v) : null })}
|
||||
minDate={new Date()}
|
||||
clearable
|
||||
/>
|
||||
</Group>
|
||||
{g && !g.ruleId && (
|
||||
<Text size="xs" c="red" mt={4}>
|
||||
No detention rule matches this truck type — it will not be billed.
|
||||
</Text>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
) : (
|
||||
<>
|
||||
<Text size="sm" c="dimmed">
|
||||
No trucks assigned yet — this records the delivery-level detention window. Assign
|
||||
trucks to track each one separately.
|
||||
</Text>
|
||||
<Group grow align="flex-start">
|
||||
<DateTimePicker
|
||||
label="Arrived at"
|
||||
description="Detention clock start"
|
||||
value={arrived}
|
||||
onChange={(v) => setArrived(v ? new Date(v) : null)}
|
||||
minDate={new Date()}
|
||||
clearable
|
||||
/>
|
||||
<DateTimePicker
|
||||
label="Delivered / returned at"
|
||||
description="Clock end (blank = still out)"
|
||||
value={delivered}
|
||||
onChange={(v) => setDelivered(v ? new Date(v) : null)}
|
||||
minDate={new Date()}
|
||||
clearable
|
||||
/>
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
variant="light"
|
||||
loading={saveTimes.isPending}
|
||||
onClick={() => {
|
||||
// No backdating: detention times are recorded as they happen.
|
||||
if (isBackdated(arrived) || isBackdated(delivered)) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Detention times cannot be in the past',
|
||||
});
|
||||
return;
|
||||
}
|
||||
saveTimes.mutate();
|
||||
}}
|
||||
>
|
||||
<Button variant="light" loading={saveTimes.isPending} onClick={handleSave}>
|
||||
Save times
|
||||
</Button>
|
||||
</Group>
|
||||
@@ -154,7 +287,7 @@ export function TruckDetentionModal({ opened, onClose, record }: TruckDetentionM
|
||||
<Alert color="gray" variant="light">
|
||||
No preview available.
|
||||
</Alert>
|
||||
) : !preview.ruleId ? (
|
||||
) : !hasAnyRule ? (
|
||||
<Alert color="orange" variant="light">
|
||||
No active Truck Detention rule matches this booking. Create one under Warehouse → Fee rules
|
||||
(rule type "Truck Detention Cost").
|
||||
@@ -162,39 +295,47 @@ export function TruckDetentionModal({ opened, onClose, record }: TruckDetentionM
|
||||
) : (
|
||||
<Stack gap="sm">
|
||||
<Group grow>
|
||||
<Stat label="Chargeable days" value={preview.chargeableDays} />
|
||||
<Stat label="Longest detention" value={`${preview.chargeableDays} day(s)`} />
|
||||
<Stat label="Trucks" value={preview.containerCount} />
|
||||
<Stat label="Amount" value={money(preview.amount, preview.currency)} strong />
|
||||
<Stat label="Total amount" value={money(preview.amount, preview.currency)} strong />
|
||||
</Group>
|
||||
{preview.endIsOpen && (
|
||||
<Text size="xs" c="orange">
|
||||
Still accruing — no delivery/return time yet. The amount grows until the vehicle is returned.
|
||||
Still accruing — at least one truck has no release time yet. The amount grows until
|
||||
every truck is returned.
|
||||
</Text>
|
||||
)}
|
||||
{preview.groups && preview.groups.length > 1 ? (
|
||||
{preview.groups && preview.groups.length > 0 ? (
|
||||
<Table withTableBorder withColumnBorders verticalSpacing="xs" fz="xs">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Truck type</Table.Th>
|
||||
<Table.Th>Trucks</Table.Th>
|
||||
<Table.Th>Truck</Table.Th>
|
||||
<Table.Th>Type</Table.Th>
|
||||
<Table.Th>Days</Table.Th>
|
||||
<Table.Th ta="right">Rate / truck / day</Table.Th>
|
||||
<Table.Th ta="right">Rate / day</Table.Th>
|
||||
<Table.Th ta="right">Amount</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{preview.groups.map((g, i) => (
|
||||
<Table.Tr key={i}>
|
||||
<Table.Tr key={g.assignmentId ?? i}>
|
||||
<Table.Td>
|
||||
{g.vehicleType ?? 'Unknown'}
|
||||
{g.plateNumber ?? 'Unassigned'}
|
||||
{!g.ruleId && (
|
||||
<Text span size="xs" c="red">
|
||||
{' '}· no rule
|
||||
</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>{g.truckCount}</Table.Td>
|
||||
<Table.Td>{g.chargeableDays}</Table.Td>
|
||||
<Table.Td>{g.vehicleType ?? 'Unknown'}</Table.Td>
|
||||
<Table.Td>
|
||||
{g.chargeableDays}
|
||||
{g.endIsOpen && (
|
||||
<Text span size="xs" c="orange">
|
||||
{' '}· open
|
||||
</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td ta="right">{money(g.ratePerDay, preview.currency)}</Table.Td>
|
||||
<Table.Td ta="right">{money(g.amount, preview.currency)}</Table.Td>
|
||||
</Table.Tr>
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import {
|
||||
Button,
|
||||
Divider,
|
||||
Group,
|
||||
Modal,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
} from '@mantine/core';
|
||||
import { DateTimePicker } from '@mantine/dates';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { lastMileService, type LastMileRecord } from '@/services/last-mile.service';
|
||||
|
||||
interface WarehouseGateTimesModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
record: LastMileRecord | null;
|
||||
}
|
||||
|
||||
const plateOf = (a: NonNullable<LastMileRecord['vehicleAssignments']>[number]) =>
|
||||
[a.vehicle?.code, a.vehicle?.plateNumber].filter(Boolean).join(' · ') || a.vehicleId;
|
||||
|
||||
export function WarehouseGateTimesModal({ opened, onClose, record }: WarehouseGateTimesModalProps) {
|
||||
const { toast } = useToast();
|
||||
const qc = useQueryClient();
|
||||
const id = record?.id ?? null;
|
||||
const assignments = record?.vehicleAssignments ?? [];
|
||||
|
||||
interface TruckRow {
|
||||
vehicleId: string;
|
||||
label: string;
|
||||
arrivedAt: Date | null;
|
||||
departedAt: Date | null;
|
||||
}
|
||||
|
||||
const [rows, setRows] = useState<Array<TruckRow>>([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (assignments.length > 0) {
|
||||
setRows(
|
||||
assignments.map((a) => ({
|
||||
vehicleId: a.vehicleId,
|
||||
label: plateOf(a),
|
||||
arrivedAt: a.arrivedAt ? new Date(a.arrivedAt) : null,
|
||||
departedAt: a.departedAt ? new Date(a.departedAt) : null,
|
||||
})),
|
||||
);
|
||||
}
|
||||
}, [assignments, opened]);
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: () => {
|
||||
if (!id) return Promise.resolve(null);
|
||||
return lastMileService.setWarehouseGateTimes(id, rows.map(r => ({
|
||||
vehicleId: r.vehicleId,
|
||||
arrivedAt: r.arrivedAt?.toISOString() ?? null,
|
||||
departedAt: r.departedAt?.toISOString() ?? null,
|
||||
})));
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: 'Warehouse gate times updated',
|
||||
});
|
||||
qc.invalidateQueries({ queryKey: ['last-mile-record-import-trucks', id] });
|
||||
onClose();
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Failed to update warehouse gate times',
|
||||
description: error?.response?.data?.message || error?.message,
|
||||
});
|
||||
},
|
||||
onSettled: () => {
|
||||
setSaving(false);
|
||||
},
|
||||
});
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
await updateMutation.mutateAsync();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title="Warehouse Gate Times" size="lg">
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
Set arrival (gate-in) and departure (gate-out) times for each truck.
|
||||
</Text>
|
||||
|
||||
{/* @ts-ignore - DateTimePicker type inference issue with row state */}
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Plate</Table.Th>
|
||||
<Table.Th>Arrived At (Gate-In)</Table.Th>
|
||||
<Table.Th>Departed At (Gate-Out)</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{rows.map((row: TruckRow, idx: number) => (
|
||||
<Table.Tr key={row.vehicleId}>
|
||||
<Table.Td>
|
||||
<Text size="sm" fw={600}>
|
||||
{row.label}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<DateTimePicker
|
||||
placeholder="Select arrival time"
|
||||
value={(row.arrivedAt as unknown) as Date | null}
|
||||
onChange={(date) => {
|
||||
const newRows = [...rows];
|
||||
newRows[idx] = { ...row, arrivedAt: date };
|
||||
setRows(newRows);
|
||||
}}
|
||||
clearable
|
||||
size="sm"
|
||||
/>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<DateTimePicker
|
||||
placeholder="Select departure time"
|
||||
value={(row.departedAt as unknown) as Date | null}
|
||||
onChange={(date) => {
|
||||
const newRows = [...rows];
|
||||
newRows[idx] = { ...row, departedAt: date };
|
||||
setRows(newRows);
|
||||
}}
|
||||
clearable
|
||||
size="sm"
|
||||
/>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" onClick={onClose} disabled={saving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave} loading={saving}>
|
||||
Save Times
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -37,6 +37,14 @@ function fmtDate(iso: string | null) {
|
||||
return new Date(iso).toLocaleDateString();
|
||||
}
|
||||
|
||||
const UNIT_LABEL_PLURAL: Record<string, string> = {
|
||||
container: 'Containers',
|
||||
truck: 'Trucks',
|
||||
ton: 'Tons',
|
||||
item: 'Items',
|
||||
};
|
||||
const unitLabelPlural = (unitLabel?: string) => UNIT_LABEL_PLURAL[unitLabel ?? 'container'] ?? 'Containers';
|
||||
|
||||
const money = (amount: number, currency: string) =>
|
||||
`${Number(amount).toLocaleString()} ${currency === 'ETB' ? 'Birr (ETB)' : currency}`;
|
||||
|
||||
@@ -72,8 +80,11 @@ function FeeCard({ fee }: { fee: FeePreview }) {
|
||||
<Row label="Period" value={`${fmtDate(fee.startDate)} → ${fmtDate(fee.endDate)}${fee.endIsOpen ? ' (today)' : ''}`} />
|
||||
<Row label="Elapsed days" value={String(fee.elapsedDays)} />
|
||||
<Row label="Chargeable days" value={`${fee.chargeableDays} (after ${fee.freeDays} free)`} />
|
||||
<Row label="Containers" value={String(fee.containerCount ?? 1)} />
|
||||
<Row label="Billable units" value={`${fee.billableUnits ?? fee.chargeableDays} container-day(s)`} />
|
||||
<Row label={unitLabelPlural(fee.unitLabel)} value={String(fee.containerCount ?? 1)} />
|
||||
<Row
|
||||
label="Billable units"
|
||||
value={`${fee.billableUnits ?? fee.chargeableDays} ${fee.unitLabel ?? 'container'}-day(s)`}
|
||||
/>
|
||||
{(fee.tiers ?? []).map((tier) => (
|
||||
<Row
|
||||
key={`${tier.appliedFromDay}-${tier.appliedToDay}-${tier.ratePerDay}`}
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
ArrowRightLeft,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
ClipboardCheck,
|
||||
@@ -29,6 +30,7 @@ import {
|
||||
FileText,
|
||||
History,
|
||||
Info,
|
||||
Layers,
|
||||
MapPin,
|
||||
MoreHorizontal,
|
||||
PackageCheck,
|
||||
@@ -78,7 +80,7 @@ import { MoveInventoryModal } from './MoveInventoryModal';
|
||||
import { ReleaseOrderModal } from './ReleaseOrderModal';
|
||||
import { StoreInventoryModal } from './StoreInventoryModal';
|
||||
import { WarehouseInquiryTable } from './WarehouseInquiryTable';
|
||||
import { extractDownloadErrorMessage, extractErrorMessage, formatDate, formatNumber, inventoryStatusOptions } from './options';
|
||||
import { extractDownloadErrorMessage, extractErrorMessage, formatDate, formatNumber, inventoryStatusOptions, warehousesAtStation, yardsForBooking } from './options';
|
||||
import { openPdfBlob } from './pdf';
|
||||
import '@/components/overview/overview.css';
|
||||
|
||||
@@ -1976,14 +1978,6 @@ function LoadedExportTab({
|
||||
);
|
||||
}
|
||||
|
||||
const importLocationTypesForFreight = (freightType: string | null | undefined) => {
|
||||
const normalized = (freightType ?? '').toUpperCase();
|
||||
if (normalized === 'CONTAINER') {
|
||||
return { yardTypes: ['CONTAINER_YARD', 'GENERAL_CARGO_YARD'], zoneTypes: ['CONTAINER_ZONE', 'GENERAL_CARGO_ZONE'] };
|
||||
}
|
||||
return { yardTypes: ['BULK_YARD', 'GENERAL_CARGO_YARD'], zoneTypes: ['BULK_ZONE', 'GENERAL_CARGO_ZONE'] };
|
||||
};
|
||||
|
||||
const isImportContainerFreight = (freightType: string | null | undefined) =>
|
||||
(freightType ?? '').toUpperCase() === 'CONTAINER';
|
||||
|
||||
@@ -2037,10 +2031,60 @@ function ImportTrainDetailTable({
|
||||
enabled: Boolean(train.scheduleId),
|
||||
}),
|
||||
);
|
||||
const warehouseOptions = useMemo(
|
||||
() => warehouses.map((warehouse) => ({ value: warehouse.id, label: `${warehouse.name} (${warehouse.code})` })),
|
||||
[warehouses],
|
||||
// A train only ever unloads at the warehouse actually sitting at its
|
||||
// destination station — Indode's train never offers Sebeta's warehouse.
|
||||
const scopedWarehouses = useMemo(
|
||||
() => warehousesAtStation(warehouses, train.destinationStationId),
|
||||
[warehouses, train.destinationStationId],
|
||||
);
|
||||
const warehouseOptions = useMemo(
|
||||
() => scopedWarehouses.map((warehouse) => ({ value: warehouse.id, label: `${warehouse.name} (${warehouse.code})` })),
|
||||
[scopedWarehouses],
|
||||
);
|
||||
// With exactly one warehouse at the station there is nothing to choose —
|
||||
// pre-fill it so staff only has to pick yard/zone, not re-discover Indode.
|
||||
useEffect(() => {
|
||||
if (scopedWarehouses.length !== 1) return;
|
||||
const onlyWarehouseId = scopedWarehouses[0].id;
|
||||
items.filter(isImportUnloadPending).forEach((item) => {
|
||||
if (!assignments[item.bookingId]?.warehouseId) {
|
||||
onAssignmentChange(item.bookingId, { ...assignments[item.bookingId], warehouseId: onlyWarehouseId });
|
||||
}
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [scopedWarehouses, items]);
|
||||
|
||||
// Once a booking's warehouse is known, its yard (and then zone) follow from
|
||||
// what the cargo actually is — a Wheat booking only ever has one candidate
|
||||
// yard (Dry Bulk) once Indode's real yard layout is configured, so staff
|
||||
// never see a picker for something that isn't actually a choice.
|
||||
useEffect(() => {
|
||||
items.filter(isImportUnloadPending).forEach((item) => {
|
||||
const draft = assignments[item.bookingId];
|
||||
if (!draft?.warehouseId) return;
|
||||
|
||||
if (!draft.yardId) {
|
||||
const candidateYards = yardsForBooking(yards, {
|
||||
warehouseId: draft.warehouseId,
|
||||
freightType: item.freightType,
|
||||
tradeDirection: 'IMPORT',
|
||||
cargoTypeCode: item.cargoTypeCode,
|
||||
});
|
||||
if (candidateYards.length === 1) {
|
||||
onAssignmentChange(item.bookingId, { ...draft, yardId: candidateYards[0].id });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!draft.zoneId) {
|
||||
const candidateZones = zones.filter((zone) => zone.yardId === draft.yardId);
|
||||
if (candidateZones.length === 1) {
|
||||
onAssignmentChange(item.bookingId, { ...draft, zoneId: candidateZones[0].id });
|
||||
}
|
||||
}
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [assignments, items, yards, zones]);
|
||||
|
||||
useEffect(() => {
|
||||
const pending = items.filter(isImportUnloadPending);
|
||||
@@ -2091,12 +2135,17 @@ function ImportTrainDetailTable({
|
||||
<Table.Tbody>
|
||||
{items.map((it: ImportTrainItem) => {
|
||||
const draft = assignments[it.bookingId] ?? {};
|
||||
const { yardTypes, zoneTypes } = importLocationTypesForFreight(it.freightType);
|
||||
const yardOptions = yards
|
||||
.filter((yard) => yard.warehouseId === draft.warehouseId && yardTypes.includes(yard.type))
|
||||
.map((yard) => ({ value: yard.id, label: `${yard.name} (${yard.code})` }));
|
||||
const yardOptions = yardsForBooking(yards, {
|
||||
warehouseId: draft.warehouseId,
|
||||
freightType: it.freightType,
|
||||
tradeDirection: 'IMPORT',
|
||||
cargoTypeCode: it.cargoTypeCode,
|
||||
}).map((yard) => ({ value: yard.id, label: `${yard.name} (${yard.code})` }));
|
||||
// The yard is already scoped to what this cargo can go into — a
|
||||
// zone's own type always matches its parent yard's purpose (see the
|
||||
// Indode seed migration), so no separate zone-type filter is needed.
|
||||
const zoneOptions = zones
|
||||
.filter((zone) => zone.yardId === draft.yardId && zoneTypes.includes(zone.type))
|
||||
.filter((zone) => zone.yardId === draft.yardId)
|
||||
.map((zone) => ({ value: zone.id, label: `${zone.name} (${zone.code})` }));
|
||||
const pending = isImportUnloadPending(it);
|
||||
|
||||
@@ -2721,6 +2770,40 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Item onClick={() => setInspectId(r.id)}>Inspect / report</Menu.Item>
|
||||
{/* Double handling is decided once the goods are off
|
||||
the wagon (every row here is unloaded) — Yes is
|
||||
what makes the fee rule bill this booking. */}
|
||||
<Menu.Divider />
|
||||
<Menu.Label>
|
||||
Double handling —{' '}
|
||||
{r.doubleHandling == null ? 'not set' : r.doubleHandling ? 'Yes' : 'No'}
|
||||
</Menu.Label>
|
||||
<Menu.Item
|
||||
leftSection={
|
||||
r.doubleHandling === true ? <Check size={14} /> : <Layers size={14} />
|
||||
}
|
||||
disabled={!r.bookingId || r.doubleHandling === true}
|
||||
onClick={() =>
|
||||
runRowAction(r, 'Double handling: Yes — fee rule applies', () =>
|
||||
warehouseService.setDoubleHandling(r.bookingId as string, true),
|
||||
)
|
||||
}
|
||||
>
|
||||
Yes — apply fee
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={
|
||||
r.doubleHandling === false ? <Check size={14} /> : <Layers size={14} />
|
||||
}
|
||||
disabled={!r.bookingId || r.doubleHandling === false}
|
||||
onClick={() =>
|
||||
runRowAction(r, 'Double handling: No', () =>
|
||||
warehouseService.setDoubleHandling(r.bookingId as string, false),
|
||||
)
|
||||
}
|
||||
>
|
||||
No
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Item leftSection={<PackageCheck size={14} />} onClick={() => setFeeItem(toInventoryItem(r))}>
|
||||
Storage / fee preview
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { warehousesAtStation, yardsForBooking } from "./options";
|
||||
import type { Warehouse, WarehouseYard } from "@/types/warehouse";
|
||||
|
||||
// Mirrors Indode's real 11-yard layout at a reduced scale, so these cases read
|
||||
// against the actual booking-routing decisions staff rely on.
|
||||
const yard = (overrides: Partial<WarehouseYard>): WarehouseYard =>
|
||||
({
|
||||
id: overrides.code,
|
||||
warehouseId: "indode",
|
||||
name: overrides.code,
|
||||
code: overrides.code,
|
||||
type: "GENERAL_CARGO_YARD",
|
||||
capacityWeight: null,
|
||||
capacityContainers: null,
|
||||
maxWeight: null,
|
||||
maxVolume: null,
|
||||
currentWeight: 0,
|
||||
currentContainers: 0,
|
||||
currentVolume: 0,
|
||||
status: "ACTIVE",
|
||||
isActive: true,
|
||||
...overrides,
|
||||
}) as WarehouseYard;
|
||||
|
||||
const YARDS: WarehouseYard[] = [
|
||||
yard({ code: "Y2", type: "GENERAL_CARGO_YARD", cargoTypes: [{ id: "1", code: "STEEL_BILLET" }] }),
|
||||
yard({ code: "Y3", type: "GENERAL_CARGO_YARD", cargoTypes: [{ id: "2", code: "AUTOMOBILE" }, { id: "3", code: "TRUCK" }] }),
|
||||
yard({ code: "Y4", type: "BULK_YARD", status: "INACTIVE", isActive: false, cargoTypes: [{ id: "4", code: "WHEAT" }] }),
|
||||
yard({ code: "Y5", type: "CONTAINER_YARD", direction: "IMPORT" }),
|
||||
yard({ code: "Y6", type: "CONTAINER_YARD", direction: "EXPORT" }),
|
||||
yard({ code: "Y10", type: "CONTAINER_YARD", direction: "BOTH" }), // service yard
|
||||
yard({ code: "Y11", type: "CONTAINER_YARD", direction: "BOTH" }), // equipment yard
|
||||
];
|
||||
|
||||
describe("yardsForBooking", () => {
|
||||
it("container import narrows to exactly the import stack", () => {
|
||||
const result = yardsForBooking(YARDS, {
|
||||
warehouseId: "indode",
|
||||
freightType: "CONTAINER",
|
||||
tradeDirection: "IMPORT",
|
||||
cargoTypeCode: null,
|
||||
});
|
||||
expect(result.map((y) => y.code)).toEqual(["Y5"]);
|
||||
});
|
||||
|
||||
it("container export narrows to exactly the export stack", () => {
|
||||
const result = yardsForBooking(YARDS, {
|
||||
warehouseId: "indode",
|
||||
freightType: "CONTAINER",
|
||||
tradeDirection: "EXPORT",
|
||||
cargoTypeCode: null,
|
||||
});
|
||||
expect(result.map((y) => y.code)).toEqual(["Y6"]);
|
||||
});
|
||||
|
||||
it("never offers a BOTH-direction container yard (service/equipment) for ordinary cargo", () => {
|
||||
const result = yardsForBooking(YARDS, {
|
||||
warehouseId: "indode",
|
||||
freightType: "CONTAINER",
|
||||
tradeDirection: "IMPORT",
|
||||
cargoTypeCode: null,
|
||||
});
|
||||
expect(result.map((y) => y.code)).not.toContain("Y10");
|
||||
expect(result.map((y) => y.code)).not.toContain("Y11");
|
||||
});
|
||||
|
||||
it("bulk cargo narrows to the yard configured for that exact cargo type", () => {
|
||||
const automobile = yardsForBooking(YARDS, {
|
||||
warehouseId: "indode",
|
||||
freightType: "BULK",
|
||||
tradeDirection: "IMPORT",
|
||||
cargoTypeCode: "AUTOMOBILE",
|
||||
});
|
||||
expect(automobile.map((y) => y.code)).toEqual(["Y3"]);
|
||||
|
||||
const steel = yardsForBooking(YARDS, {
|
||||
warehouseId: "indode",
|
||||
freightType: "BULK",
|
||||
tradeDirection: "IMPORT",
|
||||
cargoTypeCode: "STEEL_BILLET",
|
||||
});
|
||||
expect(steel.map((y) => y.code)).toEqual(["Y2"]);
|
||||
});
|
||||
|
||||
it("falls back to every non-container yard when the one configured for this cargo type is closed", () => {
|
||||
// Y4 (Dry Bulk, WHEAT) is inactive — never strand staff with an empty
|
||||
// picker just because the ideal yard is closed; same safety net as
|
||||
// warehousesAtStation falling back when a station has no mapped warehouse.
|
||||
const result = yardsForBooking(YARDS, {
|
||||
warehouseId: "indode",
|
||||
freightType: "BULK",
|
||||
tradeDirection: "IMPORT",
|
||||
cargoTypeCode: "WHEAT",
|
||||
});
|
||||
expect(result.map((y) => y.code).sort()).toEqual(["Y2", "Y3"]);
|
||||
});
|
||||
|
||||
it("falls back to every non-container yard when no yard is configured for that cargo type yet", () => {
|
||||
const result = yardsForBooking(YARDS, {
|
||||
warehouseId: "indode",
|
||||
freightType: "BULK",
|
||||
tradeDirection: "IMPORT",
|
||||
cargoTypeCode: "SOMETHING_UNMAPPED",
|
||||
});
|
||||
expect(result.map((y) => y.code).sort()).toEqual(["Y2", "Y3"]);
|
||||
});
|
||||
|
||||
it("a yard with no configured cargo types is open to anything (unconfigured, not restrictive)", () => {
|
||||
const openYard = yard({ code: "GENERIC", type: "BULK_YARD" });
|
||||
const result = yardsForBooking([...YARDS, openYard], {
|
||||
warehouseId: "indode",
|
||||
freightType: "BULK",
|
||||
tradeDirection: "IMPORT",
|
||||
cargoTypeCode: "STEEL_BILLET",
|
||||
});
|
||||
expect(result.map((y) => y.code).sort()).toEqual(["GENERIC", "Y2"]);
|
||||
});
|
||||
|
||||
it("only offers yards at the requested warehouse", () => {
|
||||
const otherWarehouseYard = yard({ code: "SEBETA-Y1", warehouseId: "sebeta", type: "GENERAL_CARGO_YARD" });
|
||||
const result = yardsForBooking([...YARDS, otherWarehouseYard], {
|
||||
warehouseId: "indode",
|
||||
freightType: "BULK",
|
||||
tradeDirection: "IMPORT",
|
||||
cargoTypeCode: null,
|
||||
});
|
||||
expect(result.map((y) => y.code)).not.toContain("SEBETA-Y1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("warehousesAtStation", () => {
|
||||
const warehouse = (id: string, stationId: string | null): Warehouse =>
|
||||
({ id, stationId, name: id, code: id } as Warehouse);
|
||||
|
||||
it("restricts to the warehouse at the given station", () => {
|
||||
const warehouses = [warehouse("indode", "station-a"), warehouse("sebeta", "station-b")];
|
||||
const result = warehousesAtStation(warehouses, "station-a");
|
||||
expect(result.map((w) => w.id)).toEqual(["indode"]);
|
||||
});
|
||||
|
||||
it("falls back to every warehouse when the station has no match", () => {
|
||||
const warehouses = [warehouse("indode", "station-a"), warehouse("sebeta", "station-b")];
|
||||
const result = warehousesAtStation(warehouses, "station-unknown");
|
||||
expect(result).toEqual(warehouses);
|
||||
});
|
||||
|
||||
it("falls back to every warehouse when the station is null", () => {
|
||||
const warehouses = [warehouse("indode", "station-a")];
|
||||
expect(warehousesAtStation(warehouses, null)).toEqual(warehouses);
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
WAREHOUSE_ZONE_TYPES,
|
||||
WAREHOUSE_STATUSES,
|
||||
INVENTORY_STATUSES,
|
||||
type Warehouse,
|
||||
type WarehouseYard,
|
||||
} from '@/types/warehouse';
|
||||
|
||||
export const humanizeEnum = (value: string) =>
|
||||
@@ -16,6 +18,58 @@ export const humanizeEnum = (value: string) =>
|
||||
const toOptions = (values: readonly string[]) =>
|
||||
values.map((value) => ({ value, label: humanizeEnum(value) }));
|
||||
|
||||
/**
|
||||
* Warehouses actually located at a train's station — e.g. a train destined for
|
||||
* Indode should only offer Indode's own warehouse, not Sebeta's or Modjo's.
|
||||
* Falls back to every warehouse when the station is unmapped (no `stationId`
|
||||
* match anywhere), so unusual/legacy data never blocks the unload flow entirely.
|
||||
*/
|
||||
export const warehousesAtStation = (warehouses: Warehouse[], stationId: string | null | undefined) => {
|
||||
if (!stationId) return warehouses;
|
||||
const atStation = warehouses.filter((w) => w.stationId === stationId);
|
||||
return atStation.length ? atStation : warehouses;
|
||||
};
|
||||
|
||||
/**
|
||||
* Yards at ONE warehouse eligible to receive a booking, given what it actually
|
||||
* is — e.g. at Indode: container import always narrows to Yard 5, export to
|
||||
* Yard 6; a Wheat booking narrows to Yard 4 (Dry Bulk), not Break Bulk or
|
||||
* Coffee/Tea. Mirrors `warehousesAtStation`'s fallback philosophy: an
|
||||
* unconfigured yard (no cargo types set) stays open rather than disappearing,
|
||||
* but a yard that IS configured for other cargo never shows for a mismatch.
|
||||
*
|
||||
* Container yards are the one case with no such fallback: a CONTAINER_YARD
|
||||
* left at direction BOTH/null (Indode's Yard 10 service yard, Yard 11
|
||||
* equipment yard) is a service/equipment yard, not a customer cargo yard, and
|
||||
* must never be offered just because the exact-direction stack is missing.
|
||||
*/
|
||||
export const yardsForBooking = (
|
||||
yards: WarehouseYard[],
|
||||
params: {
|
||||
warehouseId: string | null | undefined;
|
||||
freightType: string | null | undefined;
|
||||
tradeDirection: string | null | undefined;
|
||||
cargoTypeCode: string | null | undefined;
|
||||
},
|
||||
): WarehouseYard[] => {
|
||||
const atWarehouse = yards.filter((y) => y.warehouseId === params.warehouseId && y.isActive);
|
||||
const isContainer = (params.freightType ?? '').toUpperCase() === 'CONTAINER';
|
||||
|
||||
if (isContainer) {
|
||||
const direction = (params.tradeDirection ?? '').toUpperCase();
|
||||
return atWarehouse.filter((y) => y.type === 'CONTAINER_YARD' && y.direction === direction);
|
||||
}
|
||||
|
||||
const nonContainer = atWarehouse.filter((y) => y.type !== 'CONTAINER_YARD');
|
||||
if (!params.cargoTypeCode) return nonContainer;
|
||||
|
||||
const cargoMatched = nonContainer.filter((y) => {
|
||||
const codes = (y.cargoTypes ?? []).map((c) => c.code);
|
||||
return codes.length === 0 || codes.includes(params.cargoTypeCode as string);
|
||||
});
|
||||
return cargoMatched.length ? cargoMatched : nonContainer;
|
||||
};
|
||||
|
||||
export const warehouseTypeOptions = toOptions(WAREHOUSE_TYPES);
|
||||
export const yardTypeOptions = toOptions(WAREHOUSE_YARD_TYPES);
|
||||
export const zoneTypeOptions = toOptions(WAREHOUSE_ZONE_TYPES);
|
||||
|
||||
Reference in New Issue
Block a user