mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 18:55:42 +00:00
Merge branch 'dev' of github.com:Tria-plc/edr-platform into freight_feature/usermanagement
This commit is contained in:
@@ -121,6 +121,7 @@ import ArrivalQueuePage from "./pages/warehouses/ArrivalQueuePage";
|
||||
import DispatchQueuePage from "./pages/warehouses/DispatchQueuePage";
|
||||
import ExportDjiboutiUnloadingQueuePage from "./pages/warehouses/ExportDjiboutiUnloadingQueuePage";
|
||||
import ExportWarehouseFlowPage from "./pages/warehouses/ExportWarehouseFlowPage";
|
||||
import ImportTrucksPage from "./pages/warehouses/ImportTrucksPage";
|
||||
import ImportWarehouseFlowPage from "./pages/warehouses/ImportWarehouseFlowPage";
|
||||
import InterchangeDocumentsPage from "./pages/warehouses/InterchangeDocumentsPage";
|
||||
import InventoryInquiryPage from "./pages/warehouses/InventoryInquiryPage";
|
||||
@@ -404,6 +405,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
icon: <PackageOpen />,
|
||||
permission: FREIGHT_PERMS.warehouseInventory.view,
|
||||
},
|
||||
{
|
||||
label: "Import Trucks",
|
||||
href: "/dashboard/import-trucks",
|
||||
icon: <Truck />,
|
||||
permission: FREIGHT_PERMS.warehouseInventory.view,
|
||||
},
|
||||
{
|
||||
label: "Dispatch Queue",
|
||||
href: "/dashboard/dispatch-queue",
|
||||
@@ -1052,6 +1059,7 @@ const App = () => {
|
||||
<Route path="loading-queue" element={<LoadingQueuePage />} />
|
||||
<Route path="intercity" element={<IntercityPage />} />
|
||||
<Route path="trucks-on-site" element={<TrucksOnSitePage />} />
|
||||
<Route path="import-trucks" element={<ImportTrucksPage />} />
|
||||
<Route path="loaded-inventory" element={<LoadedInventoryPage />} />
|
||||
<Route path="dispatch-queue" element={<DispatchQueuePage />} />
|
||||
<Route
|
||||
|
||||
@@ -76,6 +76,10 @@ api.interceptors.request.use((config) => {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
// Tells the backend which app is asking, so /auth/login can reject
|
||||
// cross-audience credentials (EDRFREIGHT-415).
|
||||
config.headers["X-Client-App"] = "backoffice";
|
||||
|
||||
return config;
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1649,6 +1649,8 @@
|
||||
"notFoundError": "የፈለጉትን መረጃ አልተገኘም።",
|
||||
"fileTooLarge": "ፋይሉ በጣም ትልቅ ነው። እባክዎ ፋይሉን አሳንሰው ዳግም ይሞክሩ።",
|
||||
"serverError": "ከአገልጋይ በኩል ችግር አለ። እባክዎ ዳግመኛ ይሞክሩ።",
|
||||
"userRoleNotFound": "ይህ የአስተዳዳሪ ሚና ምደባ አልተገኘም — ቀደም ብሎ ተወግዶ ሊሆን ይችላል።",
|
||||
"unitEmployeeLimitReached": "ይህ ክፍል የ{{limit}} ሰራተኞች ገደብ ላይ ደርሷል።",
|
||||
"attachmentDeleted": "አባሪው በተሳካ ሁኔታ ተሰርዟል።",
|
||||
"replyAdded": "ምላሹ በተሳካ ሁኔታ ታክሏል!",
|
||||
"replyError": "ምላሹን በመጨመር ላይ ስህተት አጋጥሟል።",
|
||||
@@ -2653,6 +2655,7 @@
|
||||
"copyPermissionsHint": "የነበረ የቦታ አይነት ይምረጡ፤ ፍቃዶቹ አስቀድመው ይሞላሉ፣ ከታች ማስተካከል ይችላሉ።",
|
||||
"copyPermissionsFailed": "ፍቃዶችን መቅዳት አልተቻለም",
|
||||
"selectOrganizationToCopy": "መቅዳት የሚችሏቸውን የቦታ ዓይነቶች ለማየት መጀመሪያ ድርጅት ይምረጡ",
|
||||
"selectUnitToCopy": "መቅዳት የሚችሏቸውን የቦታ ዓይነቶች ለማየት መጀመሪያ ክፍል ይምረጡ",
|
||||
"cannotClearAllPermissions": "ተቀምጧል። ፍቃዶቹ አልተቀየሩም — ይህ የቦታ ዓይነት ቢያንስ አንድ ፍቃድ ሊኖረው ይገባል።",
|
||||
"permissionsSelected": "{{count}} ተመርጠዋል",
|
||||
"positionTypeCreated": "የቦታ ዓይነት ተፈጥሯል",
|
||||
@@ -7491,12 +7494,22 @@
|
||||
"loadError": "አስተዳዳሪዎችን መጫን አልተሳካም።",
|
||||
"pickerError": "ድርጅቶችን መጫን አልተሳካም።",
|
||||
"addAdmin": "አስተዳዳሪ ጨምር",
|
||||
"managePermissions": "ፍቃዶችን ያስተዳድሩ",
|
||||
"add": {
|
||||
"title": "አስተዳዳሪ ጨምር",
|
||||
"description": "የተጠቃሚ መለያ ይፍጠሩ እና በዚህ ድርጅት ውስጥ የአስተዳዳሪ መዳረሻ ይስጡ።",
|
||||
"submit": "አስተዳዳሪ ጨምር",
|
||||
"inviteNote": "ተጠቃሚው ይፈጠራል እና የይለፍ ቃሉን እንዲያዘጋጅ የኤስኤምኤስ ግብዣ ይደርሰዋል።",
|
||||
"noUnitsOrgAdmin": "ይህ ድርጅት ክፍሎች የሉትም — አስተዳዳሪው እንደ የድርጅት አስተዳዳሪ ይጨመራል።"
|
||||
},
|
||||
"permissions": {
|
||||
"title": "የድርጅት አስተዳዳሪ ፍቃዶች",
|
||||
"subtitle": "እያንዳንዱ የድርጅት አስተዳዳሪ በመድረኩ ላይ ምን ማድረግ እንደሚችል ይምረጡ።",
|
||||
"backToAdmins": "ወደ ድርጅት አስተዳዳሪዎች ይመለሱ",
|
||||
"roleNotFound": "የድርጅት አስተዳዳሪ ሚና ማግኘት አልተቻለም።",
|
||||
"saved": "የድርጅት አስተዳዳሪ ፍቃዶች ተዘምነዋል።",
|
||||
"saveFailed": "የድርጅት አስተዳዳሪ ፍቃዶችን ማዘመን አልተቻለም።",
|
||||
"cannotClearAll": "ተቀምጧል። ፍቃዶቹ አልተቀየሩም — የድርጅት አስተዳዳሪ ሚና ቢያንስ አንድ ፍቃድ ሊኖረው ይገባል።"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1669,6 +1669,8 @@
|
||||
"notFoundError": "We couldn't find what you were looking for.",
|
||||
"fileTooLarge": "The file is too large. Please reduce the file size and try again.",
|
||||
"serverError": "Something went wrong on our side. Please try again in a moment.",
|
||||
"userRoleNotFound": "This admin role assignment could not be found — it may have already been removed.",
|
||||
"unitEmployeeLimitReached": "This unit has reached its limit of {{limit}} employees.",
|
||||
"attachmentDeleted": "Attachment deleted successfully.",
|
||||
"replyAdded": "Reply added successfully!",
|
||||
"replyError": "An error occurred while adding the reply.",
|
||||
@@ -2762,6 +2764,7 @@
|
||||
"copyPermissionsHint": "Pick an existing position type to pre-fill its permissions, then edit below.",
|
||||
"copyPermissionsFailed": "Failed to copy permissions",
|
||||
"selectOrganizationToCopy": "Select an organization to see the position types you can copy from",
|
||||
"selectUnitToCopy": "Select a unit to see the position types you can copy from",
|
||||
"cannotClearAllPermissions": "Saved. Permissions were left unchanged — this position type must keep at least one permission.",
|
||||
"permissionsSelected": "{{count}} selected",
|
||||
"positionTypeCreated": "Position type created",
|
||||
@@ -7492,12 +7495,22 @@
|
||||
"loadError": "Failed to load admins.",
|
||||
"pickerError": "Failed to load organizations.",
|
||||
"addAdmin": "Add Admin",
|
||||
"managePermissions": "Manage Permissions",
|
||||
"add": {
|
||||
"title": "Add Admin",
|
||||
"description": "Create a user account and grant admin access in this organization.",
|
||||
"submit": "Add Admin",
|
||||
"inviteNote": "The user is created and receives an SMS invitation to set their password.",
|
||||
"noUnitsOrgAdmin": "This organization has no units — the admin will be added as an organization admin."
|
||||
},
|
||||
"permissions": {
|
||||
"title": "Organization Admin Permissions",
|
||||
"subtitle": "Choose what every Organization Admin can do across the platform.",
|
||||
"backToAdmins": "Back to Organization Admins",
|
||||
"roleNotFound": "Could not find the Organization Admin role.",
|
||||
"saved": "Organization Admin permissions updated.",
|
||||
"saveFailed": "Failed to update Organization Admin permissions.",
|
||||
"cannotClearAll": "Saved. Permissions were left unchanged — the Organization Admin role must keep at least one permission."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1169,6 +1169,8 @@
|
||||
"notFoundError": "Nous n’avons pas trouvé ce que vous cherchiez.",
|
||||
"fileTooLarge": "Le fichier est trop volumineux. Veuillez réduire sa taille et réessayer.",
|
||||
"serverError": "Un problème est survenu de notre côté. Veuillez réessayer dans un instant.",
|
||||
"userRoleNotFound": "Cette attribution de rôle d'administrateur est introuvable — elle a peut-être déjà été supprimée.",
|
||||
"unitEmployeeLimitReached": "Cette unité a atteint sa limite de {{limit}} employés.",
|
||||
"attachmentDeleted": "Pièce jointe supprimée avec succès.",
|
||||
"replyAdded": "Réponse ajoutée avec succès !",
|
||||
"replyError": "Une erreur s’est produite lors de l’ajout de la réponse.",
|
||||
@@ -1888,6 +1890,7 @@
|
||||
"copyPermissionsHint": "Choisissez un type de poste existant pour préremplir ses autorisations, puis modifiez ci-dessous.",
|
||||
"copyPermissionsFailed": "Échec de la copie des autorisations",
|
||||
"selectOrganizationToCopy": "Sélectionnez une organisation pour voir les types de poste que vous pouvez copier",
|
||||
"selectUnitToCopy": "Sélectionnez une unité pour voir les types de poste que vous pouvez copier",
|
||||
"cannotClearAllPermissions": "Enregistré. Les autorisations n'ont pas été modifiées — ce type de poste doit conserver au moins une autorisation.",
|
||||
"permissionsSelected": "{{count}} sélectionné(s)",
|
||||
"positionTypeCreated": "Type de poste créé",
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -607,8 +607,13 @@ export default function CustomerDetailPage() {
|
||||
{ label: "PoA address", value: company?.poaAddress },
|
||||
];
|
||||
const hasPoaDetails = poaFields.some((f) => f.value?.trim());
|
||||
// Shared with the portal (buildCompanyIdentityState) — same derivation, so
|
||||
// this page can never disagree with the rule the API actually enforces.
|
||||
const ownerIdentity = company?.identity?.owner;
|
||||
const poaIdentity = company?.identity?.poa;
|
||||
const hasEtradeRecord = Boolean(company?.licenceNumber?.trim());
|
||||
// A freight forwarder acts on other companies' behalf, so its PoA — details
|
||||
// and delegation letter both — is mandatory rather than optional.
|
||||
// and DARS delegation paper both — is mandatory rather than optional.
|
||||
const poaMandatory = (company?.companyProfiles ?? []).some(
|
||||
(p) => p.type === "freight_forwarder",
|
||||
);
|
||||
@@ -752,6 +757,16 @@ export default function CustomerDetailPage() {
|
||||
<InfoField label="TIN" value={company.tin} />
|
||||
<InfoField label="VAT number" value={company.vatNumber} />
|
||||
<InfoField label="FAN number" value={company.fanNumber} />
|
||||
<InfoField
|
||||
label="Owner identity"
|
||||
value={
|
||||
ownerIdentity?.verified
|
||||
? "Fayda verified"
|
||||
: ownerIdentity?.passportNumber
|
||||
? `Passport ${ownerIdentity.passportNumber}`
|
||||
: "Not verified"
|
||||
}
|
||||
/>
|
||||
<InfoField label="Country" value={company.country} />
|
||||
<InfoField label="Address" value={company.address} />
|
||||
<InfoField label="Website" value={company.website} />
|
||||
@@ -783,6 +798,97 @@ export default function CustomerDetailPage() {
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Stack gap="lg">
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Text fw={600} c="edr-text">
|
||||
eTrade registration
|
||||
</Text>
|
||||
{hasEtradeRecord ? (
|
||||
<Badge size="sm" color="edr-green" variant="light">
|
||||
Verified with eTrade
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge size="sm" color="gray" variant="light">
|
||||
No eTrade record
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
{hasEtradeRecord ? (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg">
|
||||
<InfoField
|
||||
label="License number"
|
||||
value={company.licenceNumber}
|
||||
/>
|
||||
<InfoField label="Status" value={company.statusDescription} />
|
||||
<InfoField
|
||||
label="Date registered"
|
||||
value={company.dateRegistered}
|
||||
/>
|
||||
<InfoField label="Renewed from" value={company.renewedFrom} />
|
||||
<InfoField label="Renewal date" value={company.renewalDate} />
|
||||
<InfoField label="Renewed to" value={company.renewedTo} />
|
||||
<InfoField label="Region" value={company.region} />
|
||||
<InfoField label="Zone" value={company.zone} />
|
||||
<InfoField label="Woreda" value={company.woreda} />
|
||||
<InfoField label="Kebele" value={company.kebele} />
|
||||
<InfoField label="House No" value={company.houseNo} />
|
||||
</SimpleGrid>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed">
|
||||
No eTrade registration record on file for this customer's
|
||||
TIN.
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Stack gap="lg">
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Text fw={600} c="edr-text">
|
||||
Owner identity
|
||||
</Text>
|
||||
{ownerIdentity?.verified ? (
|
||||
<Badge size="sm" color="edr-green" variant="light">
|
||||
Fayda verified
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge size="sm" color="gray" variant="light">
|
||||
Not verified
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
{ownerIdentity?.verified ? (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg">
|
||||
<InfoField label="Name" value={ownerIdentity.name} />
|
||||
<InfoField label="Phone" value={ownerIdentity.phone} />
|
||||
<InfoField label="Email" value={ownerIdentity.email} />
|
||||
<InfoField label="Address" value={ownerIdentity.address} />
|
||||
<InfoField
|
||||
label="Verified at"
|
||||
value={formatDate(ownerIdentity.verifiedAt)}
|
||||
/>
|
||||
<InfoField
|
||||
label="Birthdate"
|
||||
value={ownerIdentity.birthdate}
|
||||
/>
|
||||
<InfoField label="Gender" value={ownerIdentity.gender} />
|
||||
<InfoField
|
||||
label="Passport number"
|
||||
value={ownerIdentity.passportNumber}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed">
|
||||
{ownerIdentity?.passportNumber
|
||||
? `Not Fayda verified — identified by passport ${ownerIdentity.passportNumber}.`
|
||||
: "The company owner has not verified their identity with Fayda."}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Stack gap="lg">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
@@ -798,11 +904,11 @@ export default function CustomerDetailPage() {
|
||||
</Group>
|
||||
{delegationMissing ? (
|
||||
<Badge size="sm" color="red" variant="light">
|
||||
Delegation letter missing
|
||||
DARS delegation paper missing
|
||||
</Badge>
|
||||
) : poaLive.length > 0 ? (
|
||||
<Badge size="sm" color="edr-green" variant="light">
|
||||
Delegation letter on file
|
||||
DARS delegation paper on file
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge size="sm" color="gray" variant="light">
|
||||
@@ -820,6 +926,25 @@ export default function CustomerDetailPage() {
|
||||
value={f.value}
|
||||
/>
|
||||
))}
|
||||
<InfoField
|
||||
label="PoA Fayda"
|
||||
value={
|
||||
poaIdentity?.verified ? "Verified" : "Not verified"
|
||||
}
|
||||
/>
|
||||
{poaIdentity?.verified && (
|
||||
<>
|
||||
<InfoField
|
||||
label="PoA verified at"
|
||||
value={formatDate(poaIdentity.verifiedAt)}
|
||||
/>
|
||||
<InfoField
|
||||
label="PoA birthdate"
|
||||
value={poaIdentity.birthdate}
|
||||
/>
|
||||
<InfoField label="PoA gender" value={poaIdentity.gender} />
|
||||
</>
|
||||
)}
|
||||
</SimpleGrid>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed">
|
||||
@@ -838,7 +963,7 @@ export default function CustomerDetailPage() {
|
||||
tt="uppercase"
|
||||
style={{ letterSpacing: "0.04em" }}
|
||||
>
|
||||
Delegation letter
|
||||
DARS delegation paper
|
||||
</Text>
|
||||
|
||||
{documentsQuery.isLoading ? (
|
||||
@@ -864,7 +989,7 @@ export default function CustomerDetailPage() {
|
||||
</Group>
|
||||
) : poaDocuments.length === 0 ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
No delegation letter uploaded.
|
||||
No DARS delegation paper uploaded.
|
||||
</Text>
|
||||
) : (
|
||||
poaDocuments.map((doc) => (
|
||||
|
||||
@@ -945,6 +945,45 @@ const LastMilePage = () => {
|
||||
return out.length ? out : activeRecord ? bookingContainerNumbers(activeRecord) : [];
|
||||
}, [assignBooking, activeRecord]);
|
||||
|
||||
// Container number → size ("20ft"/"40ft"), driving the per-truck cap: a 40ft
|
||||
// fills the truck alone; two 20ft may share (no size mixing).
|
||||
const sizeByNumber = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
const lines = assignBooking?.bookingContainers?.length
|
||||
? assignBooking.bookingContainers
|
||||
: activeRecord?.booking?.bookingContainers ?? [];
|
||||
for (const line of lines) {
|
||||
// The two payload shapes differ: the list record carries `containerSize`,
|
||||
// the booking detail exposes the size on its container type.
|
||||
const c = line as {
|
||||
containerSize?: string | null;
|
||||
containerNumber?: string | null;
|
||||
containerType?: { code?: string; label?: string; sizeFt?: number };
|
||||
units?: Array<{ containerNumber?: string | null }>;
|
||||
};
|
||||
const size = String(
|
||||
c.containerSize ?? c.containerType?.sizeFt ?? c.containerType?.code ?? c.containerType?.label ?? "",
|
||||
);
|
||||
for (const u of c.units ?? []) {
|
||||
if (u.containerNumber) map.set(u.containerNumber, size);
|
||||
}
|
||||
if (c.containerNumber) map.set(c.containerNumber, size);
|
||||
}
|
||||
return map;
|
||||
}, [assignBooking, activeRecord]);
|
||||
const is40 = (n: string) => (sizeByNumber.get(n) ?? "").includes("40");
|
||||
|
||||
// Trucks that already arrived/left keep their load locked — the API rejects
|
||||
// changing or removing them; the modal greys those rows out.
|
||||
const lockedVehicles = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
for (const a of activeRecord?.vehicleAssignments ?? []) {
|
||||
if (a.departedAt) map.set(a.vehicleId, "left the warehouse");
|
||||
else if (a.arrivedAt) map.set(a.vehicleId, "arrived at the warehouse");
|
||||
}
|
||||
return map;
|
||||
}, [activeRecord]);
|
||||
|
||||
const pickupReadyByBooking = useMemo(() => {
|
||||
const map = new Map<string, ImportUnloadedItem>();
|
||||
for (const row of pickupReadyRows) {
|
||||
@@ -1101,6 +1140,22 @@ const LastMilePage = () => {
|
||||
|
||||
if (!targetIds.length) return;
|
||||
|
||||
// A 40ft container fills its truck — backstop for pre-filled reassignment
|
||||
// rows the MultiSelect guard never saw.
|
||||
const overloaded = vehicles.filter(
|
||||
(v) => v.containerNumbers.length > 1 && v.containerNumbers.some(is40),
|
||||
);
|
||||
if (overloaded.length) {
|
||||
toast({
|
||||
title: "40ft fills the truck",
|
||||
description: `${overloaded
|
||||
.map((v) => vehicleLabelFor(v.vehicleId))
|
||||
.join("; ")} — a 40ft container travels alone.`,
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Backstop for rows the Select guard never saw (pre-filled reassignments).
|
||||
const unpriced = vehicles
|
||||
.map((v) => ({ label: vehicleLabelFor(v.vehicleId), gap: pricingGapById.get(v.vehicleId) }))
|
||||
@@ -1385,7 +1440,21 @@ const LastMilePage = () => {
|
||||
status === "PAYMENT_PENDING" ||
|
||||
(status === "READY_TO_TRANSIT" && assigned) ||
|
||||
(status === "IN_TRANSIT" && hasDistance);
|
||||
const canAssignStep = !assigned && status !== "DELIVERED";
|
||||
// Assign stays active until the whole load has trucks: container
|
||||
// bookings until every container is on a truck; bulk until the
|
||||
// tonnage is drawn down (trucks depart one by one). Already-departed
|
||||
// trucks keep their rows locked in the modal.
|
||||
const totalContainers = containerCount(row.original);
|
||||
const assignedContainers = (row.original.vehicleAssignments ?? []).reduce(
|
||||
(s, a) => s + (a.containers?.length ?? (a.containerNumber ? 1 : 0)),
|
||||
0,
|
||||
);
|
||||
const containersRemain = totalContainers > 0 && assignedContainers < totalContainers;
|
||||
const bulkCargo = totalContainers === 0;
|
||||
const canAssignStep =
|
||||
status !== "DELIVERED" &&
|
||||
!row.original.invoice &&
|
||||
(!assigned || containersRemain || (bulkCargo && status !== "IN_TRANSIT"));
|
||||
const canDistance = status === "IN_TRANSIT";
|
||||
// Truck arrival/leaving are independent — each driven by its own
|
||||
// warehouse state — but both are done once the leg is IN_TRANSIT/DELIVERED.
|
||||
@@ -1822,16 +1891,22 @@ const LastMilePage = () => {
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
const ok = picked === needed;
|
||||
const coveredContainers = vehicleRows.reduce(
|
||||
(s, r) => s + (r.vehicleId ? r.containerNumbers.length : 0),
|
||||
0,
|
||||
);
|
||||
const ok = picked === needed && coveredContainers === containers;
|
||||
return (
|
||||
<Alert
|
||||
variant="light"
|
||||
color={ok ? "green" : "yellow"}
|
||||
title={`${containers} container${containers === 1 ? "" : "s"} · needs ${needed} vehicle${needed === 1 ? "" : "s"}`}
|
||||
title={`${coveredContainers} of ${containers} container${containers === 1 ? "" : "s"} on trucks · needs ${needed} vehicle${needed === 1 ? "" : "s"}`}
|
||||
>
|
||||
One truck (with trailer) carries {CONTAINERS_PER_VEHICLE} containers.
|
||||
{picked > 0 && !ok &&
|
||||
` You've selected ${picked} — ${picked < needed ? "add more" : "that's more than needed"}.`}
|
||||
One 40ft container fills a truck; two 20ft share one (no size mixing).
|
||||
{containers - coveredContainers > 0 &&
|
||||
` ${containers - coveredContainers} container${containers - coveredContainers === 1 ? "" : "s"} still unassigned — keep adding trucks.`}
|
||||
{picked > 0 && picked !== needed &&
|
||||
` You've selected ${picked} vehicle${picked === 1 ? "" : "s"} — ${picked < needed ? "add more" : "that's more than needed"}.`}
|
||||
</Alert>
|
||||
);
|
||||
})()}
|
||||
@@ -1851,7 +1926,11 @@ const LastMilePage = () => {
|
||||
)}
|
||||
<Divider />
|
||||
<Stack gap="xs">
|
||||
{vehicleRows.map((row, i) => (
|
||||
{vehicleRows.map((row, i) => {
|
||||
const lockReason = row.vehicleId ? lockedVehicles.get(row.vehicleId) : undefined;
|
||||
const rowLocked = Boolean(lockReason);
|
||||
const rowHas40 = row.containerNumbers.some(is40);
|
||||
return (
|
||||
<Group key={i} gap="xs" wrap="nowrap" align="flex-end">
|
||||
<Select
|
||||
style={{ flex: 1.4 }}
|
||||
@@ -1875,35 +1954,49 @@ const LastMilePage = () => {
|
||||
}}
|
||||
searchable
|
||||
clearable
|
||||
disabled={assignVehicleOptions.length === 0}
|
||||
disabled={assignVehicleOptions.length === 0 || rowLocked}
|
||||
/>
|
||||
<MultiSelect
|
||||
style={{ flex: 1 }}
|
||||
label={i === 0 ? "Containers (1x40ft or 2x20ft)" : undefined}
|
||||
placeholder={containerOptions.length ? "Select containers" : "No container numbers"}
|
||||
// A truck takes at most two containers; a 40ft fills it (the
|
||||
// API rejects a 40ft paired with anything).
|
||||
maxValues={2}
|
||||
description={rowLocked ? `Locked — truck ${lockReason}` : undefined}
|
||||
// A 40ft container fills the truck alone; two 20ft may share.
|
||||
maxValues={rowHas40 ? 1 : 2}
|
||||
data={[
|
||||
...containerOptions.filter(
|
||||
(n) =>
|
||||
row.containerNumbers.includes(n) ||
|
||||
// a container rides exactly one truck
|
||||
!vehicleRows.some((r, idx) => idx !== i && r.containerNumbers.includes(n)),
|
||||
// a container rides exactly one truck …
|
||||
(!vehicleRows.some((r, idx) => idx !== i && r.containerNumbers.includes(n)) &&
|
||||
// … and no size mixing: once a 20ft is picked a 40ft
|
||||
// can't join it, and a 40ft truck is already full.
|
||||
!(rowHas40 || (row.containerNumbers.length > 0 && is40(n)))),
|
||||
),
|
||||
// keep manual/legacy values selectable even if not in the booking
|
||||
...row.containerNumbers.filter((n) => !containerOptions.includes(n)),
|
||||
]}
|
||||
value={row.containerNumbers}
|
||||
onChange={(value) =>
|
||||
onChange={(value) => {
|
||||
// Guard the paste/keyboard path too — data filtering only
|
||||
// covers the dropdown.
|
||||
if (value.filter(is40).length > 0 && value.length > 1) {
|
||||
toast({
|
||||
title: "40ft fills the truck",
|
||||
description: "A 40ft container travels alone — remove the other container.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
setVehicleRows((prev) =>
|
||||
prev.map((x, idx) => (idx === i ? { ...x, containerNumbers: value } : x)),
|
||||
)
|
||||
}
|
||||
);
|
||||
}}
|
||||
searchable
|
||||
clearable
|
||||
disabled={rowLocked}
|
||||
/>
|
||||
{vehicleRows.length > 1 && (
|
||||
{vehicleRows.length > 1 && !rowLocked && (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
@@ -1914,7 +2007,8 @@ const LastMilePage = () => {
|
||||
</ActionIcon>
|
||||
)}
|
||||
</Group>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
<Button
|
||||
variant="light"
|
||||
size="xs"
|
||||
|
||||
@@ -331,6 +331,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
columns: [
|
||||
codeColumn("code"),
|
||||
{ id: "cargoTypeName", header: "Name", accessorKey: "cargoTypeName" },
|
||||
{ id: "unitOfMeasure", header: "Billed by", accessorKey: "unitOfMeasure" },
|
||||
{
|
||||
id: "requiresDirectorApproval",
|
||||
header: "Director approval",
|
||||
@@ -342,6 +343,19 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
],
|
||||
formFields: [
|
||||
{ name: "cargoTypeName", label: "Cargo type name", type: "text", required: true },
|
||||
{
|
||||
name: "unitOfMeasure",
|
||||
label: "Billed by",
|
||||
type: "select",
|
||||
optional: true,
|
||||
placeholder: "Not set (container/legacy cargo)",
|
||||
description:
|
||||
"Bulk storage/demurrage bills per ton for Tonnage cargo, per unit for Countable cargo (e.g. Machinery, Truck, Automobile, Livestock).",
|
||||
options: [
|
||||
{ label: "Tonnage (per ton)", value: "PER_TON" },
|
||||
{ label: "Countable (per item)", value: "PER_ITEM" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "parentGroupId",
|
||||
label: "Parent group",
|
||||
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
WarehouseOpsKpiStrip,
|
||||
formatDate,
|
||||
formatNumber,
|
||||
warehousesAtStation,
|
||||
yardsForBooking,
|
||||
} from '@/components/warehouses';
|
||||
import {
|
||||
useAutoUnloadArrivedBookings,
|
||||
@@ -49,14 +51,6 @@ const getPendingUnloadBookings = (train: ImportTrain) =>
|
||||
const isFullyUnloaded = (train: ImportTrain) =>
|
||||
Boolean(train.fullyUnloaded) || (train.totalBookings > 0 && getPendingUnloadBookings(train) === 0);
|
||||
|
||||
const locationTypesForFreight = (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 isContainerFreight = (freightType: string | null | undefined) =>
|
||||
(freightType ?? '').toUpperCase() === 'CONTAINER';
|
||||
|
||||
@@ -65,7 +59,7 @@ function isUnloadPending(item: ImportTrainItem) {
|
||||
}
|
||||
|
||||
function ImportTrainDetailRows({
|
||||
scheduleId,
|
||||
train,
|
||||
warehouses,
|
||||
yards,
|
||||
zones,
|
||||
@@ -73,7 +67,7 @@ function ImportTrainDetailRows({
|
||||
onAssignmentChange,
|
||||
onReadyChange,
|
||||
}: {
|
||||
scheduleId: string;
|
||||
train: ImportTrain;
|
||||
warehouses: Warehouse[];
|
||||
yards: WarehouseYard[];
|
||||
zones: WarehouseZone[];
|
||||
@@ -81,11 +75,61 @@ function ImportTrainDetailRows({
|
||||
onAssignmentChange: (bookingId: string, draft: AssignmentDraft) => void;
|
||||
onReadyChange: (ready: boolean) => void;
|
||||
}) {
|
||||
const { data: items = [], isLoading } = useImportTrainItems(scheduleId);
|
||||
const warehouseOptions = useMemo(
|
||||
() => warehouses.map((warehouse) => ({ value: warehouse.id, label: `${warehouse.name} (${warehouse.code})` })),
|
||||
[warehouses],
|
||||
const { data: items = [], isLoading } = useImportTrainItems(train.scheduleId);
|
||||
// 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(isUnloadPending).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(isUnloadPending).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(isUnloadPending);
|
||||
@@ -135,12 +179,17 @@ function ImportTrainDetailRows({
|
||||
<Table.Tbody>
|
||||
{items.map((item: ImportTrainItem) => {
|
||||
const draft = assignments[item.bookingId] ?? {};
|
||||
const { yardTypes, zoneTypes } = locationTypesForFreight(item.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: item.freightType,
|
||||
tradeDirection: 'IMPORT',
|
||||
cargoTypeCode: item.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 = isUnloadPending(item);
|
||||
|
||||
@@ -395,7 +444,7 @@ export default function ArrivalQueuePage() {
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={10} bg="var(--mantine-color-gray-0)">
|
||||
<ImportTrainDetailRows
|
||||
scheduleId={train.scheduleId}
|
||||
train={train}
|
||||
warehouses={warehouses}
|
||||
yards={yards}
|
||||
zones={zones}
|
||||
|
||||
@@ -0,0 +1,528 @@
|
||||
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 { WarehouseGateTimesModal } from "@/components/operations/WarehouseGateTimesModal";
|
||||
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 = 11;
|
||||
|
||||
const money = (amount: number, currency: string) =>
|
||||
`${Number(amount).toLocaleString(undefined, { maximumFractionDigits: 2 })} ${currency === "ETB" ? "ETB" : currency}`;
|
||||
|
||||
const formatTime = (iso: string | null | undefined) =>
|
||||
iso ? new Date(iso).toLocaleString() : "—";
|
||||
|
||||
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;
|
||||
arrivedAt: string | null;
|
||||
departedAt: 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 [gateTimesOpen, setGateTimesOpen] = 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,
|
||||
arrivedAt: t.arrivedAt,
|
||||
departedAt: t.departedAt,
|
||||
...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,
|
||||
arrivedAt: t.arrivedAt ?? null,
|
||||
departedAt: t.departedAt ?? 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>
|
||||
<Text size="xs">{formatTime(t.arrivedAt)}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="xs">{formatTime(t.departedAt)}</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.Item
|
||||
leftSection={<FileText size={14} />}
|
||||
disabled={!lastMileId}
|
||||
onClick={() => setGateTimesOpen(true)}
|
||||
>
|
||||
Warehouse gate 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}
|
||||
/>
|
||||
<WarehouseGateTimesModal
|
||||
opened={gateTimesOpen}
|
||||
onClose={() => setGateTimesOpen(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>Truck Arrival</Table.Th>
|
||||
<Table.Th>Truck Leaving</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>
|
||||
);
|
||||
}
|
||||
@@ -835,6 +835,12 @@ function FeeRules() {
|
||||
{FEE_RULE_BASIS_LABELS[form.basis]}). No free days or progressive tiers.
|
||||
</Text>
|
||||
)}
|
||||
{isBulkRule && !isDoubleHandling && (
|
||||
<Text size="xs" c="dimmed">
|
||||
Bulk rate is per day, scaled by quantity — tons for tonnage cargo, item count for
|
||||
countable cargo (Machinery, Truck, Automobile, Livestock…), set on each cargo type.
|
||||
</Text>
|
||||
)}
|
||||
{!isDoubleHandling && (
|
||||
<Stack gap="xs">
|
||||
<Group justify="space-between">
|
||||
|
||||
@@ -75,6 +75,9 @@ export interface LastMileRecord {
|
||||
/** Per-truck arrival / exit, stamped by the warehouse weighing steps. */
|
||||
arrivedAt?: string | null;
|
||||
departedAt?: string | null;
|
||||
/** This truck's own detention window (destination arrival → released). */
|
||||
destinationArrivedAt?: string | null;
|
||||
returnedAt?: string | null;
|
||||
grossWeightTons?: number | null;
|
||||
netWeightTons?: number | null;
|
||||
vehicle?: LastMileVehicle | null;
|
||||
@@ -143,4 +146,22 @@ export const lastMileService = {
|
||||
/** Preview the truck-detention charge for a last-mile leg. */
|
||||
truckDetentionPreview: (id: string) =>
|
||||
api.get<FeePreview>(`${LM.BASE}/${id}/truck-detention-preview`),
|
||||
/** Per-truck detention windows — each truck has its own clock. */
|
||||
setDetentionTimes: (
|
||||
id: string,
|
||||
trucks: Array<{
|
||||
vehicleId: string;
|
||||
destinationArrivedAt?: string | null;
|
||||
returnedAt?: string | null;
|
||||
}>,
|
||||
) => api.post<LastMileRecord>(`${LM.BASE}/${id}/detention-times`, { trucks }),
|
||||
/** Set warehouse gate arrival/departure times for each truck. */
|
||||
setWarehouseGateTimes: (
|
||||
id: string,
|
||||
trucks: Array<{
|
||||
vehicleId: string;
|
||||
arrivedAt?: string | null;
|
||||
departedAt?: string | null;
|
||||
}>,
|
||||
) => api.post<LastMileRecord>(`${LM.BASE}/${id}/warehouse-gate-times`, { trucks }),
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
@@ -334,6 +337,13 @@ export const warehouseService = {
|
||||
deliver: (id: string, payload: DeliverInventoryPayload) =>
|
||||
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.DELIVER(id), payload),
|
||||
|
||||
/** Post-unloading Yes/No — Yes makes the double-handling fee rule bill this booking. */
|
||||
setDoubleHandling: (bookingId: string, doubleHandling: boolean) =>
|
||||
apiClient.patch<{ bookingId: string; doubleHandling: boolean; setAt: string }>(
|
||||
`/warehouse-inventory/bookings/${bookingId}/double-handling`,
|
||||
{ doubleHandling },
|
||||
),
|
||||
|
||||
// ── Receive (Import/Export bulk) ─────────────────────────────────────────
|
||||
eligibleBookings: (direction?: 'IMPORT' | 'EXPORT') =>
|
||||
apiClient.get<EligibleBooking[]>(URL_CONSTANTS.WAREHOUSE_INVENTORY.ELIGIBLE_BOOKINGS(direction)),
|
||||
|
||||
@@ -54,6 +54,28 @@ const extractMessage = (value: unknown): string | null => {
|
||||
}
|
||||
};
|
||||
|
||||
// IAM (@tria-plc/iamapi-common) throws BadRequestException with a raw,
|
||||
// untranslated code string as the message — no i18n on that side — so it
|
||||
// would otherwise reach the UI verbatim (e.g. "user_role_not_found"). Map
|
||||
// known codes to a friendly, translated message before falling back to the
|
||||
// raw text. `unit_employee_limit_reached` carries its configured limit after
|
||||
// a colon (e.g. "unit_employee_limit_reached:5").
|
||||
const UNIT_EMPLOYEE_LIMIT_PREFIX = "unit_employee_limit_reached:";
|
||||
|
||||
const mapIamErrorCode = (
|
||||
raw: string | null,
|
||||
t: (key: string, options?: Record<string, unknown>) => string,
|
||||
): string | null => {
|
||||
if (!raw) return null;
|
||||
if (raw.startsWith(UNIT_EMPLOYEE_LIMIT_PREFIX)) {
|
||||
return t("msg.unitEmployeeLimitReached", {
|
||||
limit: raw.slice(UNIT_EMPLOYEE_LIMIT_PREFIX.length),
|
||||
});
|
||||
}
|
||||
if (raw === "user_role_not_found") return t("msg.userRoleNotFound");
|
||||
return null;
|
||||
};
|
||||
|
||||
// Maps an HTTP status code to the i18n key used when no backend message is available.
|
||||
const statusKeyFor = (status: number | undefined): string => {
|
||||
if (status === 400 || status === 422) return "msg.validationError";
|
||||
@@ -111,7 +133,7 @@ const parseBlobBody = async (blob: Blob): Promise<unknown> => {
|
||||
};
|
||||
|
||||
export const useErrorHandler = (
|
||||
t: (key: string) => string,
|
||||
t: (key: string, options?: Record<string, unknown>) => string,
|
||||
) => {
|
||||
const getErrorMessage = useCallback(
|
||||
async (err: unknown): Promise<string> => {
|
||||
@@ -134,15 +156,15 @@ export const useErrorHandler = (
|
||||
const fromException =
|
||||
extractMessage((data as any)?.exception?.response) ??
|
||||
extractMessage((data as any)?.exception);
|
||||
if (fromException) return fromException;
|
||||
if (fromException) return mapIamErrorCode(fromException, t) ?? fromException;
|
||||
|
||||
const fromData = extractMessage(data);
|
||||
if (fromData) return fromData;
|
||||
if (fromData) return mapIamErrorCode(fromData, t) ?? fromData;
|
||||
}
|
||||
|
||||
if (err instanceof Error) {
|
||||
const fromError = extractMessage(err.message);
|
||||
if (fromError) return fromError;
|
||||
if (fromError) return mapIamErrorCode(fromError, t) ?? fromError;
|
||||
}
|
||||
|
||||
return t(statusKeyFor(status));
|
||||
@@ -186,7 +208,7 @@ export const useErrorHandler = (
|
||||
};
|
||||
|
||||
export const useClientErrorHandler = (
|
||||
t: (key: string) => string,
|
||||
t: (key: string, options?: Record<string, unknown>) => string,
|
||||
) => {
|
||||
const getErrorMessage = useCallback(
|
||||
(err: unknown): string => {
|
||||
@@ -205,13 +227,13 @@ export const useClientErrorHandler = (
|
||||
const fromException =
|
||||
extractMessage(data?.exception?.response) ??
|
||||
extractMessage(data?.exception);
|
||||
if (fromException) return fromException;
|
||||
if (fromException) return mapIamErrorCode(fromException, t) ?? fromException;
|
||||
|
||||
const fromData = extractMessage(data);
|
||||
if (fromData) return fromData;
|
||||
if (fromData) return mapIamErrorCode(fromData, t) ?? fromData;
|
||||
|
||||
const fromError = extractMessage((err as any).message);
|
||||
if (fromError) return fromError;
|
||||
if (fromError) return mapIamErrorCode(fromError, t) ?? fromError;
|
||||
}
|
||||
|
||||
return t(statusKeyFor(status));
|
||||
|
||||
@@ -25,6 +25,9 @@ axiosInstance.interceptors.request.use((config) => {
|
||||
}
|
||||
// X-Requested-With prevents CSRF via browser-native form/fetch without custom headers
|
||||
config.headers["X-Requested-With"] = "XMLHttpRequest";
|
||||
// Tells the backend which app is asking, so /auth/login can reject
|
||||
// cross-audience credentials (EDRFREIGHT-415).
|
||||
config.headers["X-Client-App"] = "backoffice";
|
||||
return config;
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { Link } from "react-router-dom";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/common/ui/card";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/common/ui/select";
|
||||
import { useLocalizedName } from "@/shared/common/localizedName";
|
||||
import { useApplications } from "@/user-management/hooks/useApplications";
|
||||
import { PermissionSearch } from "@/user-management/components/position-management/PermissionSearch";
|
||||
import { getRoles } from "@/super-admin/services/api/roleService";
|
||||
import {
|
||||
assignPermissionsToRole,
|
||||
getPermissionsByRoleId,
|
||||
} from "@/super-admin/services/api/rolePermissionService";
|
||||
import { ORG_ADMIN_ROLE_KEY } from "./OrgAdminsColumnDefn";
|
||||
|
||||
// The Organization Admin role is a fixed, singleton role (unlike position
|
||||
// types, which are org/unit-scoped) — so this page has no picker, just the
|
||||
// one role's permission set.
|
||||
export default function OrgAdminPermissionsPage() {
|
||||
const { t } = useTranslation();
|
||||
const localizedName = useLocalizedName();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [selectedApplicationId, setSelectedApplicationId] = useState("");
|
||||
const [permissions, setPermissions] = useState<string[]>([]);
|
||||
const hasLoadedPermissions = useRef(false);
|
||||
// Permissions the role had when the page opened. Needed because the API
|
||||
// cannot represent "no permissions" (see the save handler).
|
||||
const loadedPermissionCount = useRef(0);
|
||||
|
||||
const { applications, isLoading: isLoadingApplications } = useApplications();
|
||||
|
||||
const {
|
||||
data: rolesResponse,
|
||||
isLoading: isLoadingRoles,
|
||||
isError: isRolesError,
|
||||
} = useQuery({ queryKey: ["roles"], queryFn: getRoles });
|
||||
|
||||
const orgAdminRole = useMemo(
|
||||
() => rolesResponse?.data?.items?.find((r) => r.key === ORG_ADMIN_ROLE_KEY),
|
||||
[rolesResponse],
|
||||
);
|
||||
|
||||
const {
|
||||
data: rolePermissionsResponse,
|
||||
isSuccess: isPermissionsSuccess,
|
||||
isError: isPermissionsError,
|
||||
isLoading: isLoadingPermissions,
|
||||
} = useQuery({
|
||||
queryKey: ["role-permissions", orgAdminRole?.id],
|
||||
queryFn: () => getPermissionsByRoleId(orgAdminRole!.id),
|
||||
enabled: !!orgAdminRole?.id,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (hasLoadedPermissions.current) return;
|
||||
if (!isPermissionsSuccess && !isPermissionsError) return;
|
||||
const ids = rolePermissionsResponse?.data?.items?.map((p) => p.id) ?? [];
|
||||
loadedPermissionCount.current = ids.length;
|
||||
setPermissions(ids);
|
||||
hasLoadedPermissions.current = true;
|
||||
}, [isPermissionsSuccess, isPermissionsError, rolePermissionsResponse]);
|
||||
|
||||
const handlePermissionChange = (permissionId: string, checked: boolean) => {
|
||||
setPermissions((prev) =>
|
||||
checked ? [...prev, permissionId] : prev.filter((id) => id !== permissionId),
|
||||
);
|
||||
};
|
||||
|
||||
const mustClearAll =
|
||||
permissions.length === 0 && loadedPermissionCount.current > 0;
|
||||
|
||||
const { mutate: save, isPending: isSaving } = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!orgAdminRole?.id || permissions.length === 0) return;
|
||||
await assignPermissionsToRole({
|
||||
firstId: orgAdminRole.id,
|
||||
secondIds: permissions,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
loadedPermissionCount.current = permissions.length;
|
||||
queryClient.invalidateQueries({ queryKey: ["role-permissions"] });
|
||||
toast[mustClearAll ? "warning" : "success"](
|
||||
t(
|
||||
mustClearAll
|
||||
? "orgAdmins.permissions.cannotClearAll"
|
||||
: "orgAdmins.permissions.saved",
|
||||
),
|
||||
);
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t("orgAdmins.permissions.saveFailed"));
|
||||
},
|
||||
});
|
||||
|
||||
const selectedPermissionCount = permissions.length;
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<Card className="shadow-none border-none bg-transparent px-0">
|
||||
<CardHeader className="px-0 space-y-1">
|
||||
<Link
|
||||
to="/user-management/organization_admins"
|
||||
className="inline-flex w-fit items-center gap-1 text-sm text-muted-foreground hover:text-foreground">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
{t("orgAdmins.permissions.backToAdmins")}
|
||||
</Link>
|
||||
<CardTitle className="text-2xl font-bold text-slate-800 dark:text-slate-100">
|
||||
{t("orgAdmins.permissions.title")}
|
||||
</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("orgAdmins.permissions.subtitle")}
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="px-0 space-y-6">
|
||||
{isLoadingRoles ? (
|
||||
<div className="py-8 text-center text-muted-foreground">
|
||||
{t("common.loading")}
|
||||
</div>
|
||||
) : isRolesError || !orgAdminRole ? (
|
||||
<div className="py-8 text-center text-red-500">
|
||||
{t("orgAdmins.permissions.roleNotFound")}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="w-full sm:w-1/2">
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
{t("contentManagement.selectApplication")}
|
||||
</label>
|
||||
<Select
|
||||
value={selectedApplicationId}
|
||||
onValueChange={setSelectedApplicationId}
|
||||
disabled={isLoadingApplications}>
|
||||
<SelectTrigger className="mt-1 block w-full border-gray-300 rounded-md shadow-sm">
|
||||
<SelectValue
|
||||
placeholder={
|
||||
isLoadingApplications
|
||||
? t("common.loading")
|
||||
: t("contentManagement.selectApplication")
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-60 overflow-y-auto">
|
||||
{applications?.map((app) => (
|
||||
<SelectItem key={app.id} value={app.id}>
|
||||
{localizedName(app.name)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
{t("contentManagement.permission")}
|
||||
{selectedPermissionCount > 0 && (
|
||||
<span className="ml-2 font-normal text-muted-foreground">
|
||||
(
|
||||
{t("contentManagement.permissionsSelected", {
|
||||
count: selectedPermissionCount,
|
||||
})}
|
||||
)
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
<PermissionSearch
|
||||
selectedPermissions={permissions}
|
||||
onPermissionChange={handlePermissionChange}
|
||||
applicationId={selectedApplicationId}
|
||||
disabled={isLoadingPermissions}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
disabled={isSaving || isLoadingPermissions}
|
||||
onClick={() => save()}>
|
||||
{isSaving ? t("common.saving") : t("delegation.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -32,13 +32,15 @@ export interface AdminRoleInfo {
|
||||
/**
|
||||
* all-admins/:id returns users who are org admins of the org OR unit admins of
|
||||
* one of its units; userRoles carries every role of the user, so match the org
|
||||
* explicitly for the org-admin grant.
|
||||
* explicitly for the org-admin grant. The unit-admin grant carries no
|
||||
* organizationId of its own, only a unitId, so orgUnitIds (every unit that
|
||||
* belongs to the selected org) is required to tell a same-org unit-admin
|
||||
* grant apart from a same-user unit-admin grant in a different org.
|
||||
*/
|
||||
// ponytail: unit relation isn't loaded, so a unit_admin grant from another org
|
||||
// can't be told apart — acceptable, the server only returns admins of this org.
|
||||
export function getAdminRoleInfo(
|
||||
admin: OrgAdminUser,
|
||||
selectedOrgId: string,
|
||||
orgUnitIds: Set<string>,
|
||||
): AdminRoleInfo {
|
||||
const roles = admin.userRoles ?? [];
|
||||
const isOrgAdmin = roles.some(
|
||||
@@ -47,7 +49,10 @@ export function getAdminRoleInfo(
|
||||
r.organizationId === selectedOrgId,
|
||||
);
|
||||
const unitRole = roles.find(
|
||||
(r) => r.role?.key === UNIT_ADMIN_ROLE_KEY && r.unitId,
|
||||
(r) =>
|
||||
r.role?.key === UNIT_ADMIN_ROLE_KEY &&
|
||||
!!r.unitId &&
|
||||
orgUnitIds.has(r.unitId),
|
||||
);
|
||||
return {
|
||||
isOrgAdmin,
|
||||
@@ -58,6 +63,7 @@ export function getAdminRoleInfo(
|
||||
|
||||
interface ColumnCallbacks {
|
||||
selectedOrgId: string;
|
||||
orgUnitIds: Set<string>;
|
||||
localizedName: (name?: { am?: string; en?: string }) => string;
|
||||
onEdit: (admin: OrgAdminUser) => void;
|
||||
onResend: (admin: OrgAdminUser) => void;
|
||||
@@ -67,6 +73,7 @@ interface ColumnCallbacks {
|
||||
|
||||
export function getOrgAdminsColumnDefn({
|
||||
selectedOrgId,
|
||||
orgUnitIds,
|
||||
localizedName,
|
||||
onEdit,
|
||||
onResend,
|
||||
@@ -118,7 +125,7 @@ export function getOrgAdminsColumnDefn({
|
||||
id: "role",
|
||||
header: () => t("orgAdmins.columns.role"),
|
||||
cell: ({ row }) => {
|
||||
const info = getAdminRoleInfo(row.original, selectedOrgId);
|
||||
const info = getAdminRoleInfo(row.original, selectedOrgId, orgUnitIds);
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{info.isOrgAdmin && (
|
||||
@@ -179,7 +186,7 @@ export function getOrgAdminsColumnDefn({
|
||||
enableHiding: false,
|
||||
cell: ({ row }) => {
|
||||
const admin = row.original;
|
||||
const roleInfo = getAdminRoleInfo(admin, selectedOrgId);
|
||||
const roleInfo = getAdminRoleInfo(admin, selectedOrgId, orgUnitIds);
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { Building2, Loader2, Plus, UserPlus, Users2 } from "lucide-react";
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
Building2,
|
||||
Loader2,
|
||||
Plus,
|
||||
ShieldCheck,
|
||||
UserPlus,
|
||||
Users2,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import {
|
||||
Card,
|
||||
@@ -23,6 +31,8 @@ import { Badge } from "@/shared/common/ui/badge";
|
||||
import { AdvancedTable } from "@/shared/common/ui/table/AdvancedTable";
|
||||
import { useLocalizedName } from "@/shared/common/localizedName";
|
||||
import { OrganizationDto } from "@/shared/dto/organization/organizationDto";
|
||||
import { useUnit } from "@/user-management/hooks/useUnit";
|
||||
import { UnitDto } from "@/user-management/dto/unit/unitDto";
|
||||
import {
|
||||
OrgAdminUser,
|
||||
useOrgAdmins,
|
||||
@@ -81,6 +91,22 @@ export default function OrgAdminsPage() {
|
||||
skip: pageIndex * pageSize,
|
||||
});
|
||||
|
||||
const { getList: getUnitList } = useUnit();
|
||||
// A unit-admin grant only carries a unitId, no organizationId — this is the
|
||||
// set that tells "unit_admin of this org" apart from "unit_admin of some
|
||||
// other org the same user also administers" (see getAdminRoleInfo).
|
||||
const { data: orgUnitsResponse } = getUnitList(selectedOrg?.id ?? "", {
|
||||
take: 3000,
|
||||
skip: 0,
|
||||
});
|
||||
const orgUnitIds = useMemo(
|
||||
() =>
|
||||
new Set(
|
||||
(orgUnitsResponse?.data?.items ?? []).map((unit: UnitDto) => unit.id),
|
||||
),
|
||||
[orgUnitsResponse],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setPageIndex(0);
|
||||
}, [selectedOrg?.id, pageSize]);
|
||||
@@ -170,6 +196,7 @@ export default function OrgAdminsPage() {
|
||||
() =>
|
||||
getOrgAdminsColumnDefn({
|
||||
selectedOrgId: selectedOrg?.id ?? "",
|
||||
orgUnitIds,
|
||||
localizedName: localizedName as (name?: {
|
||||
am?: string;
|
||||
en?: string;
|
||||
@@ -183,19 +210,29 @@ export default function OrgAdminsPage() {
|
||||
onRemove: (admin, roleInfo) => setRemoveTarget({ admin, roleInfo }),
|
||||
}),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[selectedOrg?.id],
|
||||
[selectedOrg?.id, orgUnitIds],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<Card className="shadow-none border-none bg-transparent px-0">
|
||||
<CardHeader className="px-0 space-y-1">
|
||||
<CardTitle className="text-2xl font-bold text-slate-800 dark:text-slate-100">
|
||||
{t("orgAdmins.title")}
|
||||
</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("orgAdmins.subtitle")}
|
||||
</p>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-2xl font-bold text-slate-800 dark:text-slate-100">
|
||||
{t("orgAdmins.title")}
|
||||
</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("orgAdmins.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" asChild>
|
||||
<Link to="/user-management/organization_admins/permissions">
|
||||
<ShieldCheck className="h-4 w-4" />
|
||||
{t("orgAdmins.managePermissions")}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="px-0 space-y-4">
|
||||
{/* Org selector + summary */}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { withHeaders } from "@/record-management/services/api/withHeaders";
|
||||
import axiosInstance from "@/shared/services/axiosInstance";
|
||||
import { PermissionListResponse } from "@/user-management/dto/permissions/permissonDto";
|
||||
import { AxiosResponse } from "axios";
|
||||
|
||||
export interface AssignRolePermissionsPayload {
|
||||
firstId: string;
|
||||
secondIds: string[];
|
||||
}
|
||||
|
||||
// GET /role-permissions/given-first/{roleId}
|
||||
export const getPermissionsByRoleId = async (
|
||||
roleId: string,
|
||||
): Promise<AxiosResponse<PermissionListResponse>> =>
|
||||
axiosInstance.get(`/role-permissions/given-first/${roleId}`, {
|
||||
headers: withHeaders(),
|
||||
});
|
||||
|
||||
// POST /role-permissions/assign-seconds-for-first
|
||||
export const assignPermissionsToRole = async (
|
||||
payload: AssignRolePermissionsPayload,
|
||||
): Promise<AxiosResponse<void>> =>
|
||||
axiosInstance.post("/role-permissions/assign-seconds-for-first", payload, {
|
||||
headers: withHeaders(),
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { withHeaders } from "@/record-management/services/api/withHeaders";
|
||||
import axiosInstance from "@/shared/services/axiosInstance";
|
||||
import { AxiosResponse } from "axios";
|
||||
|
||||
export interface RoleDto {
|
||||
id: string;
|
||||
name: { am: string; en: string };
|
||||
key: string;
|
||||
}
|
||||
|
||||
export interface RoleListResponse {
|
||||
count: number;
|
||||
items: RoleDto[];
|
||||
}
|
||||
|
||||
export const getRoles = async (): Promise<AxiosResponse<RoleListResponse>> =>
|
||||
axiosInstance.get("/roles", {
|
||||
headers: withHeaders(),
|
||||
params: { take: 100 },
|
||||
});
|
||||
@@ -135,6 +135,36 @@ export interface CustomerResetTarget {
|
||||
phoneIsDomestic: boolean | null;
|
||||
}
|
||||
|
||||
/** One person's Fayda verification state — mirrors `IdentityVerificationStateDto`. */
|
||||
export interface IdentityVerificationState {
|
||||
verified: boolean;
|
||||
name: string | null;
|
||||
phone: string | null;
|
||||
email: string | null;
|
||||
address: string | null;
|
||||
verifiedAt: string | null;
|
||||
birthdate: string | null;
|
||||
gender: string | null;
|
||||
}
|
||||
|
||||
/** Mirrors `OwnerIdentityStateDto`. */
|
||||
export interface OwnerIdentityState extends IdentityVerificationState {
|
||||
passportNumber: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Owner/PoA Fayda verification, shared with the portal's derivation
|
||||
* (`buildCompanyIdentityState`) so backoffice never re-derives — or
|
||||
* disagrees with — the rule the API actually enforces.
|
||||
*/
|
||||
export interface CompanyIdentityState {
|
||||
faydaRequired: boolean;
|
||||
passportRequired: boolean;
|
||||
owner: OwnerIdentityState;
|
||||
poa: IdentityVerificationState;
|
||||
complete: boolean;
|
||||
}
|
||||
|
||||
/** Mirrors backend `Company` (+ its `companyProfiles`). */
|
||||
export interface Company {
|
||||
id: string;
|
||||
@@ -161,6 +191,20 @@ export interface Company {
|
||||
poaAddress?: string | null;
|
||||
website?: string | null;
|
||||
attributes?: Record<string, unknown> | null;
|
||||
// eTrade-sourced registration record — populated by the onboarding TIN
|
||||
// lookup, locked/read-only on the portal from the moment it's fetched.
|
||||
licenceNumber?: string | null;
|
||||
statusDescription?: string | null;
|
||||
dateRegistered?: string | null;
|
||||
renewedFrom?: string | null;
|
||||
renewalDate?: string | null;
|
||||
renewedTo?: string | null;
|
||||
region?: string | null;
|
||||
zone?: string | null;
|
||||
woreda?: string | null;
|
||||
kebele?: string | null;
|
||||
houseNo?: string | null;
|
||||
identity?: CompanyIdentityState;
|
||||
companyProfiles: CompanyProfile[];
|
||||
/**
|
||||
* Whether the customer submitted their onboarding application. A company row
|
||||
|
||||
@@ -118,12 +118,19 @@ export interface WarehouseZone {
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
/** IMPORT | EXPORT | BOTH | null. Only meaningful for CONTAINER_YARD — everything else takes cargo either way. */
|
||||
export type WarehouseYardDirection = 'IMPORT' | 'EXPORT' | 'BOTH';
|
||||
|
||||
export interface WarehouseYard {
|
||||
id: string;
|
||||
warehouseId: string;
|
||||
name: string;
|
||||
code: string;
|
||||
type: WarehouseYardType;
|
||||
/** For CONTAINER_YARD: which direction this stack serves. BOTH/null on a container yard means "not a customer cargo yard" (service/equipment), not "any direction". */
|
||||
direction?: WarehouseYardDirection | null;
|
||||
/** Cargo types this yard accepts. Empty/absent = open to any cargo type of this yard's structural type. */
|
||||
cargoTypes?: Array<{ id: string; code: string }>;
|
||||
capacityWeight: number | null;
|
||||
capacityContainers: number | null;
|
||||
maxWeight: number | null;
|
||||
@@ -518,6 +525,8 @@ export interface ImportTrain {
|
||||
route: string | null;
|
||||
origin: string | null;
|
||||
destination: string | null;
|
||||
/** freight.yards.id the train is heading to — matches Warehouse.stationId, so the unload picker can be scoped to the warehouse actually at this station. */
|
||||
destinationStationId: string | null;
|
||||
departureTime?: string | null;
|
||||
arrivalTime: string | null;
|
||||
totalBookings: number;
|
||||
@@ -603,6 +612,8 @@ export interface ImportUnloadedItem {
|
||||
customerTruckContainerNumber: string | null;
|
||||
customerTruckAssignedAt: string | null;
|
||||
hasAssignedTruck: boolean;
|
||||
/** Post-unloading Yes/No; null = not recorded yet (no double-handling charge). */
|
||||
doubleHandling: boolean | null;
|
||||
currentStatus: string;
|
||||
releaseDate: string | null;
|
||||
releaseOrderReference: string | null;
|
||||
@@ -630,6 +641,8 @@ export interface ImportTrainItem {
|
||||
freightType: string | null;
|
||||
containerNumber: string | null;
|
||||
cargoType: string | null;
|
||||
/** Cargo type CODE (e.g. "WHEAT"), for matching against a yard's configured cargo types — `cargoType` above is the display name. */
|
||||
cargoTypeCode: string | null;
|
||||
weight: number | null;
|
||||
arrivalTime: string | null;
|
||||
currentStatus: string | null;
|
||||
@@ -849,13 +862,21 @@ export interface FeePreview {
|
||||
elapsedDays: number;
|
||||
chargeableDays: number;
|
||||
containerCount: number;
|
||||
/** What containerCount/billableUnits are counted in: 'container' | 'truck' | 'ton' | 'item'. */
|
||||
unitLabel?: string;
|
||||
billableUnits: number;
|
||||
amount: number;
|
||||
tiers?: FeePreviewTier[];
|
||||
/** Truck detention: per-vehicle-type breakdown. */
|
||||
/** Truck detention: one row per truck — each has its own window and rule. */
|
||||
groups?: Array<{
|
||||
assignmentId?: string | null;
|
||||
vehicleId?: string | null;
|
||||
plateNumber?: string | null;
|
||||
vehicleType: string | null;
|
||||
truckCount: number;
|
||||
startDate?: string | null;
|
||||
endDate?: string | null;
|
||||
endIsOpen?: boolean;
|
||||
chargeableDays: number;
|
||||
ratePerDay: number;
|
||||
amount: number;
|
||||
|
||||
@@ -115,6 +115,7 @@ export const CreatePositionForm = ({
|
||||
});
|
||||
|
||||
const selectedOrganizationId = form.watch("organizationId");
|
||||
const selectedUnitId = form.watch("unitId");
|
||||
|
||||
const { organizationsResponse, isLoading: isLoadingOrgs } = useOrganizations(
|
||||
"Org",
|
||||
@@ -163,36 +164,32 @@ export const CreatePositionForm = ({
|
||||
enabled: mode === "edit" && !!positionTypeId,
|
||||
});
|
||||
|
||||
// A position type belongs to a unit, and a unit to an organization — IAM has
|
||||
// no organizationId on the type itself and no organization-scoped route, so
|
||||
// the picked org narrows the list through its units. isSystem types are the
|
||||
// shared "commons" and stay available to every organization.
|
||||
const orgUnitIds = useMemo(
|
||||
() =>
|
||||
new Set(
|
||||
(unitsResponse?.data?.items ?? []).map((unit: UnitDto) => unit.id),
|
||||
),
|
||||
[unitsResponse],
|
||||
);
|
||||
|
||||
// A position type belongs to a single unit — scope copy sources to the
|
||||
// selected unit, same as the "Select Unit" filter on the position list page.
|
||||
// isSystem types are the shared "commons" and stay available everywhere.
|
||||
const copyFromOptions = useMemo(() => {
|
||||
if (!selectedOrganizationId) return [];
|
||||
if (!selectedUnitId) return [];
|
||||
return positionTypes.filter(
|
||||
(type: PositionTypeDto) =>
|
||||
type.id !== positionTypeId &&
|
||||
(type.isSystem || (!!type.unitId && orgUnitIds.has(type.unitId))),
|
||||
(type.isSystem || type.unitId === selectedUnitId),
|
||||
);
|
||||
}, [positionTypes, orgUnitIds, selectedOrganizationId, positionTypeId]);
|
||||
}, [positionTypes, selectedUnitId, positionTypeId]);
|
||||
|
||||
// Reset the selected unit when the organization changes so a unit from a
|
||||
// different org can't be submitted by mistake. The copy source is cleared
|
||||
// too — it is scoped to the old organization.
|
||||
// different org can't be submitted by mistake.
|
||||
useEffect(() => {
|
||||
if (mode === "edit") return;
|
||||
form.setValue("unitId", "");
|
||||
setCopyFromPositionId("");
|
||||
}, [selectedOrganizationId, mode, form]);
|
||||
|
||||
// The copy source is scoped to the selected unit — clear it whenever the
|
||||
// unit changes (including as a side effect of the org reset above) so a
|
||||
// stale selection from a different unit can't be submitted.
|
||||
useEffect(() => {
|
||||
setCopyFromPositionId("");
|
||||
}, [selectedUnitId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== "edit" || !initialValues || !positionTypeId) return;
|
||||
if (hasLoadedEditData.current) return;
|
||||
@@ -335,11 +332,13 @@ export const CreatePositionForm = ({
|
||||
|
||||
const copyFromPlaceholder = !selectedOrganizationId
|
||||
? t("contentManagement.selectOrganizationToCopy")
|
||||
: isCopying || isLoadingPositionTypes || isLoadingUnits
|
||||
? t("common.loading")
|
||||
: isErrorPositionTypes
|
||||
? t("contentManagement.failedToLoadPositionTypes")
|
||||
: t("contentManagement.selectPositionToCopy");
|
||||
: !selectedUnitId
|
||||
? t("contentManagement.selectUnitToCopy")
|
||||
: isCopying || isLoadingPositionTypes || isLoadingUnits
|
||||
? t("common.loading")
|
||||
: isErrorPositionTypes
|
||||
? t("contentManagement.failedToLoadPositionTypes")
|
||||
: t("contentManagement.selectPositionToCopy");
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
@@ -454,6 +453,7 @@ export const CreatePositionForm = ({
|
||||
onValueChange={handleCopyFrom}
|
||||
disabled={
|
||||
!selectedOrganizationId ||
|
||||
!selectedUnitId ||
|
||||
isLoadingPositionTypes ||
|
||||
isLoadingUnits ||
|
||||
isCopying
|
||||
|
||||
@@ -19,6 +19,7 @@ import { AppLayout } from "./Applayout";
|
||||
import ActivityLogPage from "@/pages/ActivityLogPage";
|
||||
import AdminRegistrationPage from "@/pages/Organizations/AdminRegistrationPage";
|
||||
import OrganizationAdminsPage from "@/pages/OrganizationAdminsPage";
|
||||
import OrgAdminPermissionsPage from "@/super-admin/components/org-admins/OrgAdminPermissionsPage";
|
||||
import UserProfileEditPage from "@/pages/UserProfileEditPage";
|
||||
import UploadedDocumentViewPage from "@/pages/UploadedDocumentViewPage";
|
||||
import EditOrganizationPage from "@/pages/Organizations/EditOrganizationPage";
|
||||
@@ -197,6 +198,10 @@ export function UserManagementRoutes(): ReactElement {
|
||||
path="user-management/organization_admins"
|
||||
element={<OrganizationAdminsPage />}
|
||||
/>
|
||||
<Route
|
||||
path="user-management/organization_admins/permissions"
|
||||
element={<OrgAdminPermissionsPage />}
|
||||
/>
|
||||
<Route
|
||||
path="user-management/add_admin"
|
||||
element={<AdminRegistrationPage />}
|
||||
|
||||
Reference in New Issue
Block a user