Merge branch 'dev' of github.com:Tria-plc/edr-platform into freight_feature/usermanagement

This commit is contained in:
Marshal
2026-07-30 11:31:02 +00:00
215 changed files with 15300 additions and 1450 deletions

View File

@@ -121,6 +121,7 @@ import ArrivalQueuePage from "./pages/warehouses/ArrivalQueuePage";
import DispatchQueuePage from "./pages/warehouses/DispatchQueuePage";
import ExportDjiboutiUnloadingQueuePage from "./pages/warehouses/ExportDjiboutiUnloadingQueuePage";
import ExportWarehouseFlowPage from "./pages/warehouses/ExportWarehouseFlowPage";
import ImportTrucksPage from "./pages/warehouses/ImportTrucksPage";
import ImportWarehouseFlowPage from "./pages/warehouses/ImportWarehouseFlowPage";
import InterchangeDocumentsPage from "./pages/warehouses/InterchangeDocumentsPage";
import InventoryInquiryPage from "./pages/warehouses/InventoryInquiryPage";
@@ -404,6 +405,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
icon: <PackageOpen />,
permission: FREIGHT_PERMS.warehouseInventory.view,
},
{
label: "Import Trucks",
href: "/dashboard/import-trucks",
icon: <Truck />,
permission: FREIGHT_PERMS.warehouseInventory.view,
},
{
label: "Dispatch Queue",
href: "/dashboard/dispatch-queue",
@@ -1052,6 +1059,7 @@ const App = () => {
<Route path="loading-queue" element={<LoadingQueuePage />} />
<Route path="intercity" element={<IntercityPage />} />
<Route path="trucks-on-site" element={<TrucksOnSitePage />} />
<Route path="import-trucks" element={<ImportTrucksPage />} />
<Route path="loaded-inventory" element={<LoadedInventoryPage />} />
<Route path="dispatch-queue" element={<DispatchQueuePage />} />
<Route

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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": "ተቀምጧል። ፍቃዶቹ አልተቀየሩም — የድርጅት አስተዳዳሪ ሚና ቢያንስ አንድ ፍቃድ ሊኖረው ይገባል።"
}
}
}

View File

@@ -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."
}
}
}

View File

@@ -1169,6 +1169,8 @@
"notFoundError": "Nous navons 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 sest produite lors de lajout 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éé",

View File

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

View File

@@ -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) => (

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 */}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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