Merge branch 'dev' into freight/nati-2

# Conflicts:
#	apps/edr-freight-api/src/app.module.ts
#	apps/edr-freight-api/src/seed/freight-permissions.registry.ts
#	apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx
#	apps/edr-freight-web/backoffice/src/constants/URLS.ts
#	apps/edr-freight-web/backoffice/src/lib/permissions.ts
This commit is contained in:
Nathnael
2026-08-20 11:29:21 +00:00
287 changed files with 25453 additions and 2339 deletions

View File

@@ -19,6 +19,7 @@ import ForgotPasswordPage from "./pages/auth/ForgotPasswordPage";
import BookingContractPage from "./pages/bookings/BookingContractPage";
import BookingRequestDetailPage from "./pages/bookings/BookingRequestDetailPage";
import BookingRequestsPage from "./pages/bookings/BookingRequestsPage";
import ConsolidationApprovalsPage from "./pages/bookings/ConsolidationApprovalsPage";
import NewBookingPage from "./pages/bookings/NewBookingPage";
import WagonCancellationsPage from "./pages/bookings/WagonCancellationsPage";
import ContractRequestsPage from "./pages/contracts/ContractRequestsPage";
@@ -87,6 +88,7 @@ import TrainSchedulingGlobalRulesPage from "./pages/trainScheduling/TrainSchedul
import TradeAccessPage from "./pages/configuration/TradeAccessPage";
import OperationsStandardsPage from "./pages/settings/OperationsStandardsPage";
import ExchangeRateSettingsCard from "./pages/settings/ExchangeRateSettingsCard";
import ManualPaymentSettingsCard from "./pages/settings/ManualPaymentSettingsCard";
import FirstMilePage from "./pages/operations/FirstMilePage";
import LastMilePage from "./pages/operations/LastMilePage";
import TrainDetailPage from "./pages/trains/TrainDetailPage";
@@ -116,6 +118,7 @@ import FaydaCallbackPage from "./pages/FaydaCallbackPage";
import { UserManagementRoutes } from "./user-management/route";
import SetPassword from "./shared/components/SetPassword";
import SupportInboxPage from "./pages/support/SupportInboxPage";
import ChatLaunchPage from "./pages/chat/ChatLaunchPage";
import {
APP_TITLE,
buildSidebarSections,
@@ -299,6 +302,14 @@ const App = () => {
</RequirePermission>
}
/>
<Route
path="chat"
element={
<RequirePermission permission={FREIGHT_PERMS.chat.view}>
<ChatLaunchPage />
</RequirePermission>
}
/>
<Route
path="customers"
element={
@@ -381,6 +392,18 @@ const App = () => {
</RequirePermission>
}
/>
{/* Shared-wagon gate: consolidated pairs wait for a human decision
before either half reaches Operations. */}
<Route
path="consolidation-approvals"
element={
<RequirePermission
permission={FREIGHT_PERMS.bookings.approveConsolidation}
>
<ConsolidationApprovalsPage />
</RequirePermission>
}
/>
<Route
path="booking-requests/:id"
element={
@@ -1148,6 +1171,18 @@ const App = () => {
</RequirePermission>
}
/>
<Route
path="configuration/manual-payments"
element={
<RequirePermission
permission={FREIGHT_PERMS.settings.manualPayment.view}
>
<div className="p-4">
<ManualPaymentSettingsCard />
</div>
</RequirePermission>
}
/>
<Route
path="configuration/exchange-rate"
element={

View File

@@ -38,6 +38,7 @@ export function BookingActionsMenu({
reference: row.reference,
schedulingStatus: row.schedulingStatus,
customsClearingEnabled: row.customsClearingEnabled,
consolidationPartnerId: row.consolidationPartnerId,
};
const flow = useBookingActionDialog(row.id, context);
@@ -92,7 +93,13 @@ export function BookingActionsMenu({
);
})}
</Group>
<ActionDialog flow={flow} pendingAction={pendingAction} onSuppressRowClick={onSuppressRowClick} />
<ActionDialog
flow={flow}
pendingAction={pendingAction}
onSuppressRowClick={onSuppressRowClick}
consolidationPartnerId={row.consolidationPartnerId}
consolidationPartnerReference={row.consolidationPartnerReference}
/>
</>
);
}
@@ -149,7 +156,13 @@ export function BookingActionsMenu({
</Menu.Dropdown>
</Menu>
<ActionDialog flow={flow} pendingAction={pendingAction} onSuppressRowClick={onSuppressRowClick} />
<ActionDialog
flow={flow}
pendingAction={pendingAction}
onSuppressRowClick={onSuppressRowClick}
consolidationPartnerId={row.consolidationPartnerId}
consolidationPartnerReference={row.consolidationPartnerReference}
/>
</Group>
);
}
@@ -158,10 +171,14 @@ function ActionDialog({
flow,
pendingAction,
onSuppressRowClick,
consolidationPartnerId,
consolidationPartnerReference,
}: {
flow: ReturnType<typeof useBookingActionDialog>;
pendingAction: ReturnType<typeof useBookingActionDialog>["pendingAction"];
onSuppressRowClick?: () => void;
consolidationPartnerId?: string | null;
consolidationPartnerReference?: string | null;
}) {
return (
<BookingConfirmDialog
@@ -182,6 +199,17 @@ function ActionDialog({
}}
isPending={flow.mutations.isPending || flow.detailLoading}
confirmDisabled={flow.confirmDisabled}
// Only the four pairable decisions land on both halves; the rest stay
// per booking, so the warning must not appear for them.
pairedWithReference={
consolidationPartnerId &&
pendingAction &&
["accept", "cancel", "operationAccept", "requestChanges"].includes(
pendingAction.id,
)
? (consolidationPartnerReference ?? "its wagon partner")
: null
}
/>
);
}

View File

@@ -1,5 +1,7 @@
import type { ReactNode } from "react";
import { Link2 } from "lucide-react";
import {
Alert,
Modal,
Group,
Stack,
@@ -37,6 +39,12 @@ interface BookingConfirmDialogProps {
isPending: boolean;
confirmDisabled?: boolean;
extra?: ReactNode;
/**
* Reference of the booking sharing this one's wagon. When set, the dialog
* warns that the decision lands on BOTH bookings — staff must not think they
* are acting on one.
*/
pairedWithReference?: string | null;
}
export function BookingConfirmDialog({
@@ -52,6 +60,7 @@ export function BookingConfirmDialog({
isPending,
confirmDisabled = false,
extra,
pairedWithReference = null,
}: BookingConfirmDialogProps) {
if (!action || !action.confirmTitle) return null;
@@ -125,6 +134,21 @@ export function BookingConfirmDialog({
{action.confirmDescription}
</Text>
)}
{pairedWithReference && (
<Alert
color="blue"
variant="light"
radius="md"
mt="sm"
icon={<Link2 size={16} />}
>
<Text size="sm">
This applies to <strong>{pairedWithReference}</strong> as well
the two bookings share a wagon and are decided together. If either
fails, neither changes.
</Text>
</Alert>
)}
</Box>
{/* Body */}

View File

@@ -14,6 +14,7 @@ import {
Text,
Textarea,
ThemeIcon,
Timeline,
Tooltip,
} from "@mantine/core";
import {
@@ -24,6 +25,7 @@ import {
FileCheck2,
FileText,
MessageSquareWarning,
RefreshCw,
Upload,
} from "lucide-react";
import toast from "react-hot-toast";
@@ -31,6 +33,7 @@ import type { Freight } from "@edr/types";
import { isViewable } from "@edr/ui-common";
import { SectionCard } from "./SectionCard";
import { formatDateTime } from "@/lib/format";
import { bookingsService } from "@/services/bookings.service";
import {
downloadBookingFile,
@@ -457,6 +460,76 @@ export function ClearanceReviewSection({
);
}
const EVENT_META: Record<
Freight.ClearanceDocumentEvent["type"],
{ color: string; icon: typeof Upload; label: (byName: string | null) => string }
> = {
UPLOADED: {
color: "blue",
icon: Upload,
label: (n) => `Uploaded by ${n ?? "customer"}`,
},
RESUBMITTED: {
color: "blue",
icon: RefreshCw,
label: (n) => `Re-submitted by ${n ?? "customer"}`,
},
QUERIED: {
color: "red",
icon: MessageSquareWarning,
label: (n) => `Query opened by ${n ?? "staff"}`,
},
APPROVED: {
color: "edr-green",
icon: CheckCircle2,
label: (n) => `Approved by ${n ?? "staff"}`,
},
};
/** Per-document audit trail: uploads, amendment responses, queries, approval. */
function DocHistoryTimeline({
history,
}: {
history: Freight.ClearanceDocumentEvent[];
}) {
return (
<Timeline
mt="sm"
ml={4}
bulletSize={20}
lineWidth={2}
active={history.length - 1}
color="gray"
>
{history.map((ev, i) => {
const meta = EVENT_META[ev.type];
const Icon = meta.icon;
return (
<Timeline.Item
key={`${ev.type}:${ev.at}:${i}`}
color={meta.color}
bullet={<Icon size={11} />}
title={
<Text fz="12.5px" fw={600} c="edr-text" lh={1.3}>
{meta.label(ev.byName)}
</Text>
}
>
<Text fz="11px" c="dimmed">
{formatDateTime(ev.at)}
</Text>
{ev.note ? (
<Text fz="11.5px" c="red.8" mt={2}>
{ev.note}
</Text>
) : null}
</Timeline.Item>
);
})}
</Timeline>
);
}
function StatPill({
color,
label,
@@ -578,9 +651,33 @@ function DocReviewCard({
</Button>
</Tooltip>
)}
{hasFile && (
<Tooltip label="Download">
<Box
component="button"
type="button"
onClick={() =>
void downloadBookingFile(doc.file!.id, doc.file!.name)
}
c="edr-green"
style={{
display: "flex",
background: "transparent",
border: "none",
cursor: "pointer",
}}
>
<Download size={15} />
</Box>
</Tooltip>
)}
</Group>
</Group>
{(doc.history?.length ?? 0) > 0 && (
<DocHistoryTimeline history={doc.history!} />
)}
{status === "QUERIED" && doc.note && (
<Alert
mt="sm"

View File

@@ -0,0 +1,82 @@
import { useQuery } from "@tanstack/react-query";
import { Badge, Box, Group, Stack, Text } from "@mantine/core";
import { Link2 } from "lucide-react";
import { bookingsService } from "@/services/bookings.service";
import { formatDateTime } from "@/lib/format";
import { SectionCard } from "./SectionCard";
const STATUS_COLOR: Record<string, string> = {
PENDING: "yellow",
APPROVED: "teal",
REJECTED: "red",
};
/**
* Audit trail for this booking's shared wagon: every approval request against
* it, who decided, when, and why. Rendered only for a booking that is actually
* consolidated — there is nothing to show otherwise.
*/
export function ConsolidationApprovalCard({ bookingId }: { bookingId: string }) {
const { data } = useQuery({
queryKey: ["consolidation-approvals", "history", bookingId],
queryFn: () => bookingsService.consolidationApprovalHistory(bookingId),
enabled: Boolean(bookingId),
});
if (!data?.length) return null;
return (
<SectionCard icon={Link2} title="Shared wagon approval">
<Stack gap="md">
{data.map((row) => (
<Box
key={row.id}
style={{
borderLeft: "3px solid var(--mantine-color-gray-3)",
paddingLeft: 12,
}}
>
<Group gap={8} align="center" wrap="wrap" mb={4}>
<Badge
color={STATUS_COLOR[row.status] ?? "gray"}
variant="light"
radius="sm"
size="sm"
>
{row.status}
</Badge>
<Text fz={13} fw={600}>
{row.bookingReference ?? "—"} + {row.partnerBookingReference ?? "—"}
</Text>
</Group>
<Text fz={12} c="dimmed">
Requested {formatDateTime(row.requestedAt)}
{row.requestedBy ? ` by ${row.requestedBy}` : ""}
</Text>
{row.decidedAt ? (
<Text fz={12} c="dimmed">
{row.status === "APPROVED" ? "Approved" : "Rejected"}{" "}
{formatDateTime(row.decidedAt)}
{row.decidedBy ? ` by ${row.decidedBy}` : ""}
</Text>
) : (
<Text fz={12} c="yellow.8">
Waiting for a decision neither booking reaches Operations until
this is approved.
</Text>
)}
{row.decisionNote ? (
<Text fz={12.5} mt={4} style={{ whiteSpace: "pre-wrap" }}>
{row.decisionNote}
</Text>
) : null}
</Box>
))}
</Stack>
</SectionCard>
);
}

View File

@@ -14,6 +14,19 @@ function isValidValidityDays(value: string): boolean {
return Number.isInteger(days) && days >= 1 && days <= 365;
}
/**
* Decisions that must be applied to BOTH halves of a consolidated pair. The two
* bookings share one wagon: accepting one alone would put half a wagon into the
* approval chain, and cancelling one alone would strand the other on a wagon it
* can no longer fill.
*/
const PAIRED_DECISIONS = {
accept: "accept",
cancel: "cancel",
operationAccept: "operationAccept",
requestChanges: "requestChanges",
} as const;
export function useBookingActionDialog(
bookingId: string,
context: BookingActionContext,
@@ -52,6 +65,30 @@ export function useBookingActionDialog(
const onSuccess = () => closeDialog();
// A booking on a shared wagon routes the four pairable decisions through the
// paired endpoint, which applies them to both halves all-or-nothing. Every
// other action stays per booking.
const pairedDecision =
PAIRED_DECISIONS[pendingAction.id as keyof typeof PAIRED_DECISIONS];
if (context.consolidationPartnerId && pairedDecision) {
if (pairedDecision === "accept") {
const days = Number(inputValue.trim());
if (!Number.isInteger(days) || days < 1 || days > 365) return;
mutations.pairedDecision.mutate(
{ decision: "accept", validityDays: days },
{ onSuccess },
);
return;
}
mutations.pairedDecision.mutate(
pairedDecision === "cancel"
? { decision: "cancel", reason: inputValue.trim() }
: { decision: pairedDecision, note: inputValue.trim() },
{ onSuccess },
);
return;
}
switch (pendingAction.id) {
case "accept": {
const days = Number(inputValue.trim());

View File

@@ -0,0 +1,525 @@
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Badge,
Box,
Button,
FileButton,
Group,
Loader,
NumberInput,
Paper,
Select,
Stack,
Text,
Tooltip,
} from "@mantine/core";
import {
CheckCircle2,
Download,
Eye,
FileText,
Receipt,
Send,
Upload,
} from "lucide-react";
import toast from "react-hot-toast";
import type { Freight } from "@edr/types";
import { isViewable } from "@edr/ui-common";
import { bookingsService } from "@/services/bookings.service";
import {
downloadBookingFile,
fetchViewableFile,
} from "@/services/files.service";
import { formatDateTime } from "@/lib/format";
import { extractErrorMessage } from "@/utils/errorExtractor";
const CURRENCIES = ["ETB", "USD"];
const STATUS_META: Record<
Freight.ClearanceChargeStatus,
{ label: string; color: string }
> = {
DOC_UPLOADED: { label: "Awaiting billing", color: "yellow" },
BILLED: { label: "Ready to send", color: "blue" },
SENT: { label: "Sent — unpaid", color: "orange" },
PAID: { label: "Paid", color: "edr-green" },
};
export interface ClearanceChargesTabProps {
bookingId: string;
/** DJ uploads the port document; ET bills, sends and creates miscellaneous. */
roleMode: "ET" | "DJ";
onViewFile: (file: { name: string; url: string }) => void;
}
/**
* Post-finalization charges billed to the customer, two levels: port charges
* (document from GL Djibouti, billed by GL Ethiopia) then miscellaneous
* (created whole by GL Ethiopia once the port charge is paid). Each level
* issues its own payable invoice — ETB settles through the portal gateway
* (CBE), other currencies through Finance's manual settlement.
*/
export function ClearanceChargesTab({
bookingId,
roleMode,
onViewFile,
}: ClearanceChargesTabProps) {
const qc = useQueryClient();
const { data: charges, isLoading } = useQuery({
queryKey: ["clearance-charges", bookingId],
queryFn: () => bookingsService.getClearanceCharges(bookingId),
});
const refresh = (next: Freight.ClearanceCharge[]) =>
qc.setQueryData(["clearance-charges", bookingId], next);
const onError = (e: unknown) =>
toast.error(extractErrorMessage(e, "Could not update the charge"));
const uploadPort = useMutation({
mutationFn: (file: File) =>
bookingsService.uploadPortChargeDocument(bookingId, file),
onSuccess: (next) => {
toast.success("Port-charges document uploaded");
refresh(next);
},
onError,
});
const bill = useMutation({
mutationFn: (p: { chargeId: string; amount: number; currency: string }) =>
bookingsService.billClearanceCharge(bookingId, p.chargeId, p),
onSuccess: (next) => {
toast.success("Charge amount saved");
refresh(next);
},
onError,
});
const send = useMutation({
mutationFn: (chargeId: string) =>
bookingsService.sendClearanceCharge(bookingId, chargeId),
onSuccess: (next) => {
toast.success("Invoice sent to the customer");
refresh(next);
},
onError,
});
const createMisc = useMutation({
mutationFn: (p: { file: File; amount: number; currency: string }) =>
bookingsService.createMiscellaneousCharge(bookingId, p.file, p),
onSuccess: (next) => {
toast.success("Miscellaneous charge created");
refresh(next);
},
onError,
});
if (isLoading) {
return (
<Group justify="center" py="xl" gap={10}>
<Loader size="sm" color="edr-green" />
<Text c="dimmed">Loading charges</Text>
</Group>
);
}
const port = (charges ?? []).find((c) => c.type === "PORT_CHARGES") ?? null;
const misc = (charges ?? []).find((c) => c.type === "MISCELLANEOUS") ?? null;
const busy =
uploadPort.isPending || bill.isPending || send.isPending || createMisc.isPending;
const totals = new Map<string, number>();
for (const c of charges ?? []) {
if (c.amount != null && c.currency)
totals.set(c.currency, (totals.get(c.currency) ?? 0) + c.amount);
}
return (
<Stack gap="md" maw={860}>
<ChargeCard
title="1 · Port charges"
charge={port}
roleMode={roleMode}
busy={busy}
emptyHint={
roleMode === "DJ"
? "Upload the port-charges document to start this charge."
: "Waiting for GL Djibouti to upload the port-charges document."
}
onViewFile={onViewFile}
onBill={(amount, currency) =>
port && bill.mutate({ chargeId: port.id, amount, currency })
}
onSend={() => port && send.mutate(port.id)}
djUpload={
roleMode === "DJ" && (!port || port.status === "DOC_UPLOADED") ? (
<FileButton
onChange={(f) => f && uploadPort.mutate(f)}
accept="application/pdf,image/*"
disabled={busy}
>
{(props) => (
<Button
{...props}
size="compact-sm"
color="edr-green"
radius="md"
leftSection={<Upload size={14} />}
loading={uploadPort.isPending}
>
{port ? "Replace document" : "Upload document"}
</Button>
)}
</FileButton>
) : null
}
/>
<ChargeCard
title="2 · Miscellaneous charges"
charge={misc}
roleMode={roleMode}
busy={busy}
emptyHint={
port?.status !== "PAID"
? "Unlocks once the port charge is paid."
: roleMode === "ET"
? "Create the miscellaneous charge with its document, amount and currency."
: "GL Ethiopia creates this charge once the port charge is paid."
}
onViewFile={onViewFile}
onBill={(amount, currency) =>
misc && bill.mutate({ chargeId: misc.id, amount, currency })
}
onSend={() => misc && send.mutate(misc.id)}
etCreate={
roleMode === "ET" && !misc && port?.status === "PAID" ? (
<MiscCreateForm
busy={createMisc.isPending}
onCreate={(file, amount, currency) =>
createMisc.mutate({ file, amount, currency })
}
/>
) : null
}
/>
{totals.size > 0 && (
<Paper withBorder radius="md" p="md">
<Group justify="space-between">
<Text fz="13px" fw={700} c="edr-text">
Total billed
</Text>
<Group gap="md">
{[...totals.entries()].map(([currency, amount]) => (
<Text key={currency} fz="14px" fw={800} c="edr-text">
{amount.toLocaleString(undefined, {
minimumFractionDigits: 2,
})}{" "}
{currency}
</Text>
))}
</Group>
</Group>
</Paper>
)}
</Stack>
);
}
function ChargeCard({
title,
charge,
roleMode,
busy,
emptyHint,
onViewFile,
onBill,
onSend,
djUpload,
etCreate,
}: {
title: string;
charge: Freight.ClearanceCharge | null;
roleMode: "ET" | "DJ";
busy: boolean;
emptyHint: string;
onViewFile: (file: { name: string; url: string }) => void;
onBill: (amount: number, currency: string) => void;
onSend: () => void;
djUpload?: React.ReactNode;
etCreate?: React.ReactNode;
}) {
const [editing, setEditing] = useState(false);
const [amount, setAmount] = useState<number | string>(charge?.amount ?? "");
const [currency, setCurrency] = useState<string>(charge?.currency ?? "ETB");
const status = charge?.status ?? null;
const meta = status ? STATUS_META[status] : null;
// ET enters/revises the amount while the charge is unpaid.
const showBillForm =
roleMode === "ET" &&
charge != null &&
(charge.status === "DOC_UPLOADED" || editing);
return (
<Paper withBorder radius="md" p="md">
<Group justify="space-between" wrap="nowrap" align="flex-start">
<Group gap={10} wrap="nowrap">
<Receipt size={18} color="var(--mantine-color-edr-green-6)" />
<Box>
<Text fz="14px" fw={700} c="edr-text">
{title}
</Text>
{charge?.uploadedAt && (
<Text fz="11.5px" c="dimmed">
Document uploaded
{charge.uploadedByName ? ` by ${charge.uploadedByName}` : ""} ·{" "}
{formatDateTime(charge.uploadedAt)}
</Text>
)}
{charge?.billedAt && (
<Text fz="11.5px" c="dimmed">
Billed{charge.billedByName ? ` by ${charge.billedByName}` : ""} ·{" "}
{formatDateTime(charge.billedAt)}
</Text>
)}
{charge?.paidAt && (
<Text fz="11.5px" c="edr-green.8" fw={600}>
Paid · {formatDateTime(charge.paidAt)}
{charge.invoiceNumber ? ` (invoice ${charge.invoiceNumber})` : ""}
</Text>
)}
</Box>
</Group>
<Group gap={8} wrap="nowrap">
{charge?.amount != null && charge.currency && (
<Text fz="14px" fw={800} c="edr-text">
{charge.amount.toLocaleString(undefined, {
minimumFractionDigits: 2,
})}{" "}
{charge.currency}
</Text>
)}
{meta && (
<Badge variant="light" color={meta.color} radius="sm">
{meta.label}
</Badge>
)}
</Group>
</Group>
{charge?.file && (
<Group gap={8} mt="sm" wrap="nowrap">
<FileText size={15} color="var(--mantine-color-edr-green-6)" />
<Text fz="12.5px" c="edr-text" truncate style={{ minWidth: 0 }}>
{charge.file.name}
</Text>
{isViewable({ name: charge.file.name, url: "" }) && (
<Tooltip label="View">
<Box
component="button"
type="button"
onClick={() =>
void fetchViewableFile(
charge.file!.id,
charge.file!.name,
).then(onViewFile)
}
c="edr-green"
style={{
display: "flex",
background: "transparent",
border: "none",
cursor: "pointer",
}}
>
<Eye size={15} />
</Box>
</Tooltip>
)}
<Tooltip label="Download">
<Box
component="button"
type="button"
onClick={() =>
void downloadBookingFile(charge.file!.id, charge.file!.name)
}
c="edr-green"
style={{
display: "flex",
background: "transparent",
border: "none",
cursor: "pointer",
}}
>
<Download size={15} />
</Box>
</Tooltip>
</Group>
)}
{!charge && (
<Text fz="12.5px" c="dimmed" mt="xs">
{emptyHint}
</Text>
)}
{djUpload && <Box mt="sm">{djUpload}</Box>}
{etCreate && <Box mt="sm">{etCreate}</Box>}
{showBillForm && (
<Group mt="sm" gap={8} align="flex-end" wrap="wrap">
<NumberInput
label="Amount"
size="xs"
radius="md"
min={0.01}
decimalScale={2}
value={amount}
onChange={setAmount}
w={160}
/>
<Select
label="Currency"
size="xs"
radius="md"
data={CURRENCIES}
value={currency}
onChange={(v) => v && setCurrency(v)}
w={100}
/>
<Button
size="compact-sm"
color="edr-green"
radius="md"
disabled={busy || !(Number(amount) > 0)}
onClick={() => {
onBill(Number(amount), currency);
setEditing(false);
}}
>
Save amount
</Button>
{editing && (
<Button
size="compact-sm"
variant="subtle"
color="gray"
radius="md"
disabled={busy}
onClick={() => setEditing(false)}
>
Cancel
</Button>
)}
</Group>
)}
{roleMode === "ET" && charge && !showBillForm && charge.status !== "PAID" && (
<Group mt="sm" gap={8} justify="flex-end">
<Button
size="compact-sm"
variant="light"
color="gray"
radius="md"
disabled={busy}
onClick={() => {
setAmount(charge.amount ?? "");
setCurrency(charge.currency ?? "ETB");
setEditing(true);
}}
>
{charge.status === "SENT" ? "Revise (cancels invoice)" : "Edit amount"}
</Button>
{charge.status === "BILLED" && (
<Tooltip label="ETB is payable online via CBE; other currencies go to Finance's manual settlement.">
<Button
size="compact-sm"
color="edr-green"
radius="md"
leftSection={<Send size={14} />}
disabled={busy}
onClick={onSend}
>
Send invoice to customer
</Button>
</Tooltip>
)}
{charge.status === "SENT" && charge.invoiceNumber && (
<Badge variant="light" color="orange" radius="sm">
Invoice {charge.invoiceNumber}
</Badge>
)}
</Group>
)}
{charge?.status === "PAID" && (
<Group mt="sm" gap={6} justify="flex-end">
<CheckCircle2 size={14} color="var(--mantine-color-edr-green-6)" />
<Text fz="12px" c="edr-green.8" fw={600}>
Settled
</Text>
</Group>
)}
</Paper>
);
}
function MiscCreateForm({
busy,
onCreate,
}: {
busy: boolean;
onCreate: (file: File, amount: number, currency: string) => void;
}) {
const [file, setFile] = useState<File | null>(null);
const [amount, setAmount] = useState<number | string>("");
const [currency, setCurrency] = useState("ETB");
return (
<Group gap={8} align="flex-end" wrap="wrap">
<FileButton onChange={setFile} accept="application/pdf,image/*" disabled={busy}>
{(props) => (
<Button
{...props}
size="compact-sm"
variant="light"
color="edr-green"
radius="md"
leftSection={<Upload size={14} />}
>
{file ? file.name : "Choose document"}
</Button>
)}
</FileButton>
<NumberInput
label="Amount"
size="xs"
radius="md"
min={0.01}
decimalScale={2}
value={amount}
onChange={setAmount}
w={160}
/>
<Select
label="Currency"
size="xs"
radius="md"
data={CURRENCIES}
value={currency}
onChange={(v) => v && setCurrency(v)}
w={100}
/>
<Button
size="compact-sm"
color="edr-green"
radius="md"
disabled={busy || !file || !(Number(amount) > 0)}
loading={busy}
onClick={() => file && onCreate(file, Number(amount), currency)}
>
Create charge
</Button>
</Group>
);
}

View File

@@ -0,0 +1,116 @@
import { useQuery } from "@tanstack/react-query";
import { Badge, Group, Loader, Paper, Text, Timeline } from "@mantine/core";
import {
CheckCircle2,
CircleDot,
FileText,
MessageSquareWarning,
Receipt,
Send,
Ship,
Upload,
UserCheck,
} from "lucide-react";
import type { Freight } from "@edr/types";
import { bookingsService } from "@/services/bookings.service";
import { formatDateTime } from "@/lib/format";
/** Icon + color per action family; unknown actions fall back to a neutral dot. */
function eventMeta(action: string): { icon: typeof Upload; color: string } {
if (action === "DOC_APPROVED" || action.endsWith("_ACCEPTED") || action.endsWith("_FINALIZED") || action.endsWith("_CONFIRMED"))
return { icon: CheckCircle2, color: "edr-green" };
if (action === "DOC_QUERIED" || action.includes("CHANGE_REQUESTED") || action.includes("AMENDMENT"))
return { icon: MessageSquareWarning, color: "red" };
if (action.startsWith("CHARGE_"))
return { icon: Receipt, color: action === "CHARGE_PAID" ? "edr-green" : "orange" };
if (action.includes("TRANSIT_ASSIGNEE")) return { icon: UserCheck, color: "blue" };
if (action.includes("ORDER")) return { icon: Ship, color: "blue" };
if (action.includes("SENT")) return { icon: Send, color: "blue" };
if (action.includes("UPLOAD") || action.includes("SUBMITTED"))
return { icon: Upload, color: "blue" };
if (action.includes("DOC")) return { icon: FileText, color: "gray" };
return { icon: CircleDot, color: "gray" };
}
const ACTOR_BADGE: Record<
Freight.ClearanceHistoryEvent["actorType"],
{ label: string; color: string }
> = {
STAFF: { label: "Staff", color: "blue" },
CUSTOMER: { label: "Customer", color: "grape" },
SYSTEM: { label: "System", color: "gray" },
};
/**
* Full per-booking clearance action trail: document reviews, phased workflow
* steps (transit, declaration, duty, DO/RO, permits) and customer charges —
* every event with who did it and when, newest first.
*/
export function ClearanceHistoryTab({ bookingId }: { bookingId: string }) {
const { data: events, isLoading } = useQuery({
queryKey: ["clearance-history", bookingId],
queryFn: () => bookingsService.getClearanceHistory(bookingId),
});
if (isLoading) {
return (
<Group justify="center" py="xl" gap={10}>
<Loader size="sm" color="edr-green" />
<Text c="dimmed">Loading history</Text>
</Group>
);
}
if (!events || events.length === 0) {
return (
<Paper withBorder radius="md" p="lg">
<Text size="sm" c="dimmed">
No clearance actions recorded yet. Actions from now on approvals,
queries, workflow steps, charges appear here automatically.
</Text>
</Paper>
);
}
return (
<Paper withBorder radius="md" p="lg" maw={760}>
<Timeline bulletSize={22} lineWidth={2} active={events.length - 1} color="gray">
{events.map((ev) => {
const meta = eventMeta(ev.action);
const Icon = meta.icon;
const actor = ACTOR_BADGE[ev.actorType];
const note =
typeof ev.metadata?.note === "string" ? ev.metadata.note : null;
return (
<Timeline.Item
key={ev.id}
color={meta.color}
bullet={<Icon size={12} />}
title={
<Group gap={8} wrap="wrap">
<Text fz="13px" fw={600} c="edr-text" lh={1.35}>
{ev.label}
</Text>
<Badge size="xs" variant="light" color={actor.color} radius="sm">
{actor.label}
</Badge>
</Group>
}
>
<Text fz="11.5px" c="dimmed">
{ev.actorName ? `${ev.actorName} · ` : ""}
{formatDateTime(ev.at)}
</Text>
{note ? (
<Text fz="12px" c="red.8" mt={2}>
{note}
</Text>
) : null}
</Timeline.Item>
);
})}
</Timeline>
</Paper>
);
}

View File

@@ -1,6 +1,6 @@
import type { ReactNode } from "react";
import { Badge, Stack, Tabs, Text } from "@mantine/core";
import { AlertTriangle, FileText, Share2, ShieldAlert } from "lucide-react";
import { AlertTriangle, FileText, History, Receipt, Share2, ShieldAlert } from "lucide-react";
import type { Freight } from "@edr/types";
import { useAuth } from "@/auth/useAuth";
@@ -9,6 +9,8 @@ import { SectionCard } from "@/components/bookings/detail/SectionCard";
import { AssignRiskCard } from "@/components/contracts/gl-actions/AssignRiskCard";
import { IncidentReportCard } from "@/components/contracts/gl-actions/IncidentReportCard";
import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/ClearanceUploadedDocumentsPanel";
import { ClearanceChargesTab } from "@/components/contracts/ClearanceChargesTab";
import { ClearanceHistoryTab } from "@/components/contracts/ClearanceHistoryTab";
import { GlExchangePanel } from "@/components/contracts/GlExchangePanel";
export interface ClearanceOpsTabsProps {
@@ -68,6 +70,12 @@ export function ClearanceOpsTabs({
Boolean(exchangeEntityId) &&
(hasPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions) ||
hasPermission(user, FREIGHT_PERMS.contracts.clearanceDjActions));
// Post-finalization customer billing. This layout is only rendered on the ET
// clearance pages — the DJ page (GlClearanceDetailPage) mounts its own tab.
const showCharges =
Boolean(bookingId) &&
Boolean(onViewFile) &&
hasPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions);
// Risk assignment + incident reporting hit bookings:operations endpoints.
const canOps = hasPermission(user, FREIGHT_PERMS.bookings.operations);
const hasTabs = (showOpsTabs && hasOps) || showDocuments || showExchange;
@@ -100,6 +108,16 @@ export function ClearanceOpsTabs({
Document exchange
</Tabs.Tab>
) : null}
{showCharges ? (
<Tabs.Tab value="charges" leftSection={<Receipt size={14} />}>
Customer charges
</Tabs.Tab>
) : null}
{bookingId && showExchange ? (
<Tabs.Tab value="history" leftSection={<History size={14} />}>
History
</Tabs.Tab>
) : null}
{showOpsTabs && canOps && riskMs ? (
<Tabs.Tab value="risk" leftSection={<ShieldAlert size={14} />}>
Risk assignment
@@ -131,6 +149,22 @@ export function ClearanceOpsTabs({
</Tabs.Panel>
) : null}
{showCharges ? (
<Tabs.Panel value="charges">
<ClearanceChargesTab
bookingId={bookingId!}
roleMode="ET"
onViewFile={onViewFile!}
/>
</Tabs.Panel>
) : null}
{bookingId && showExchange ? (
<Tabs.Panel value="history">
<ClearanceHistoryTab bookingId={bookingId} />
</Tabs.Panel>
) : null}
{showOpsTabs && canOps && riskMs && bookingId ? (
<Tabs.Panel value="risk">
<SectionCard icon={ShieldAlert} title="Customs risk" accent="edr-green">

View File

@@ -11,6 +11,7 @@ import { useMutation, useQuery } from "@tanstack/react-query";
import {
ActionIcon,
Alert,
Badge,
Box,
Button,
Center,
@@ -40,6 +41,7 @@ import {
FileText,
FileUp,
Flame,
Link2,
MapPin,
Package,
Receipt,
@@ -57,7 +59,10 @@ import {
import { api } from "@/services/api";
import { PageContainer } from "@/components/page";
import { PageHeader } from "@/components/page/PageHeader";
import { contractsService } from "@/services/contracts.service";
import {
contractsService,
type ConsolidationCandidate,
} from "@/services/contracts.service";
import { bookingsService } from "@/services/bookings.service";
import {
useContractCapacity,
@@ -80,6 +85,18 @@ import {
StepHeader,
StepLabel,
} from "./gl-booking-form/form-ui";
import {
ConsolidationPartnerPanel,
emptyPartnerLine,
} from "./gl-booking-form/ConsolidationPartnerPanel";
import { ConsolidationPartnerPicker } from "./gl-booking-form/ConsolidationPartnerPicker";
/**
* Container sizes offered on the parent-booking panel. Fixed rather than taken
* from this contract's scope: the parent booking is a different customer on a
* different contract, so its sizes are its own.
*/
const PARTNER_SIZES = ["20ft", "40ft"];
/** All booking-window times are communicated in East Africa Time. */
const EAT_TZ = "Africa/Addis_Ababa";
@@ -240,6 +257,14 @@ export default function GlCreateBookingForm() {
enabled: Boolean(copyFromParam),
});
// The booking being completed — used to name the customer on the price
// confirmation when a second booking's price is shown beside it.
const { data: completeBooking } = useQuery({
queryKey: ["gl-complete-booking", completeBookingId],
queryFn: () => bookingsService.getById(completeBookingId!),
enabled: Boolean(completeBookingId),
});
// Same window-gating the customer sees: booking is only allowed while a
// window is OPEN for one of the contract's routes. Intercity contracts are
// never window-gated — the shipment rides a passing train staff pick later.
@@ -290,6 +315,18 @@ export default function GlCreateBookingForm() {
const [withReturn, setWithReturn] = useState(false);
const [prefilled, setPrefilled] = useState(false);
const [priceOpen, setPriceOpen] = useState(false);
// ── Odd-20ft shared wagon (customs / Path B) ──────────────────────────────
// An odd 20ft total leaves one container unpaired. On a customs contract GL
// resolves that here by linking a second booking that is also odd — two odd
// counts always sum to even — completing both together onto the shared wagon.
const [consolidateOdd, setConsolidateOdd] = useState(false);
// Set once GL flips the toggle by hand, so the auto-on effect below never
// re-opens a panel GL deliberately closed.
const consolidateTouchedRef = useRef(false);
const [partnerPickerOpen, setPartnerPickerOpen] = useState(false);
const [partner, setPartner] = useState<ConsolidationCandidate | null>(null);
const [partnerLines, setPartnerLines] = useState<ContainerLineDraft[]>([]);
const [partnerCargoDescription, setPartnerCargoDescription] = useState("");
const seededRef = useRef(false);
const returnSeededRef = useRef(false);
@@ -834,6 +871,53 @@ export default function GlCreateBookingForm() {
}, [isContainer, containerLines]);
const hasOdd20ft = ft20Total % 2 === 1;
// Only a customs (Path B) instance being COMPLETED by GL can use the shared
// wagon: it is GL, not the customer, who links the two bookings. Anything else
// keeps the historical hard block on odd 20ft.
//
// Switched OFF for now: consolidation is built end to end (toggle, parent
// picker, split entry, paired pricing, approval gate) but not in use, so an
// odd 20ft total is rejected outright instead of offering the shared wagon.
// Drop the `false &&` to bring the whole flow back.
const oddConsolidationAvailable =
false &&
Boolean(completeBookingId && isContainer && contract?.customsClearingEnabled);
// Auto-on: entering an odd 20ft total opens the consolidation panel by itself,
// once. GL can still switch it off — then odd is blocked exactly as before.
useEffect(() => {
if (!oddConsolidationAvailable) return;
if (consolidateTouchedRef.current) return;
if (hasOdd20ft) setConsolidateOdd(true);
}, [oddConsolidationAvailable, hasOdd20ft]);
// Clear the partner as soon as the panel closes or stops applying, so a
// leftover selection can never ride along into a plain single-booking submit.
useEffect(() => {
if (consolidateOdd && oddConsolidationAvailable) return;
setPartner(null);
setPartnerLines([]);
setPartnerCargoDescription("");
}, [consolidateOdd, oddConsolidationAvailable]);
const consolidationActive =
oddConsolidationAvailable && consolidateOdd && hasOdd20ft;
// Once a parent booking is linked, each booking's cargo is entered under its
// own labelled heading so it is clear which containers belong to whom.
const splitView = Boolean(consolidationActive && partner);
const candidatesQuery = useQuery({
queryKey: ["consolidation-candidates", id, completeBookingId],
queryFn: () =>
contractsService.listConsolidationCandidates(
id ?? "",
completeBookingId ?? "",
),
enabled:
partnerPickerOpen && Boolean(id) && Boolean(completeBookingId),
});
const bulkUom = contract ? bulkUnitOfMeasure(contract) : "PER_TON";
const bulkErrors = useMemo<BulkErrors>(() => {
@@ -886,7 +970,65 @@ export default function GlCreateBookingForm() {
!cargoDescriptionError
: !bulkErrors.quantity && !bulkErrors.hazardous && !bulkErrors.reefer;
const formValid = cargoValid && !hasOdd20ft && !dateError && !routeError;
// Consolidation (sharing the wagon with another customer's odd booking) is
// built but switched off for now, so an odd 20ft total always blocks — the
// shared wagon no longer resolves the unpaired container. Flip this back to
// `hasOdd20ft && !consolidationActive` to re-enable the shared-wagon path.
const oddBlocksSubmit = hasOdd20ft;
// Partner side: a linked partner must be picked, carry an odd 20ft count of
// its own (odd + odd = even fills the wagon) and have complete unit details.
const partnerFt20Total = useMemo(() => {
if (!consolidationActive) return 0;
return partnerLines
.filter((l) => parseInt(l.containerSize, 10) === 20)
.reduce((sum, l) => sum + Number(l.quantity || 0), 0);
}, [consolidationActive, partnerLines]);
const partnerError = useMemo<string | undefined>(() => {
if (!consolidationActive) return undefined;
if (!partner) return "Select the booking that shares this wagon.";
const totalQty = partnerLines.reduce(
(sum, l) => sum + Math.max(0, Number(l.quantity) || 0),
0,
);
if (totalQty < 1) {
return `Enter the containers for ${partner.reference}.`;
}
if (partnerFt20Total % 2 === 0) {
return `${partner.reference} must also carry an odd number of 20ft containers so the two bookings fill whole wagons together (it has ${partnerFt20Total}).`;
}
const incomplete = partnerLines.some((line) => {
const qty = Number(line.quantity || 0);
return qty >= 1 && line.units.length < qty;
});
if (incomplete) {
return `Enter the container details for all of ${partner.reference}'s containers.`;
}
const badUnit = partnerLines.some((line) =>
line.units.some(
(u) =>
!ISO_CONTAINER_NUMBER_REGEX.test(u.containerNumber.trim().toUpperCase()) ||
!(Number(u.vgmTons) > 0),
),
);
if (badUnit) {
return `Every ${partner.reference} container needs a valid container number and a VGM above 0.`;
}
if (!partnerCargoDescription.trim()) {
return `Describe the cargo carried in ${partner.reference}'s containers.`;
}
return undefined;
}, [
consolidationActive,
partner,
partnerLines,
partnerFt20Total,
partnerCargoDescription,
]);
const formValid =
cargoValid && !oddBlocksSubmit && !dateError && !routeError && !partnerError;
/** The create-booking DTO from the current form state — shared by the
* authoritative price preview and the actual submit so what GL confirms is
@@ -953,6 +1095,44 @@ export default function GlCreateBookingForm() {
return payload;
};
/**
* Completion DTO for the partner half of a shared wagon. Route, day and train
* are deliberately copied from THIS booking: the two bookings ride the same
* wagon, so they must ride the same train on the same day. Only the cargo and
* the billing currency belong to the partner.
*/
const buildPartnerPayload = (): Freight.CreateBookingUnderContractDto | null => {
if (!partner || !consolidationActive) return null;
const payload: Freight.CreateBookingUnderContractDto = {
paymentCurrency,
...(scheduledDate
? { scheduledDate: new Date(scheduledDate).toISOString() }
: {}),
...(trainScheduleId ? { trainScheduleId } : {}),
...(partnerCargoDescription.trim()
? { cargoFreeText: partnerCargoDescription.trim() }
: {}),
containers: partnerLines
.filter((l) => Number(l.quantity) >= 1)
.map((l) => ({
containerSize: l.containerSize,
quantity: Number(l.quantity),
hazardousQuantity: Number(l.hazardousQuantity || 0) || undefined,
reeferQuantity: Number(l.reeferQuantity || 0) || undefined,
units: l.units.map((u) => ({
containerNumber: u.containerNumber.trim().toUpperCase(),
...(u.sealNumber ? { sealNumber: u.sealNumber } : {}),
vgmTons: Number(u.vgmTons) || 0,
isHazardous: Boolean(u.isHazardous),
isReefer: Boolean(u.isReefer),
})),
})),
};
return payload;
};
// Authoritative price preview (same pricing pass the booking persists at
// create): rail freight + first/last mile + overweight + every surcharge,
// plus the hard-block checks (20ft pairing, max capacity, container numbers
@@ -964,6 +1144,22 @@ export default function GlCreateBookingForm() {
});
const validation = validateShipmentMutation.data ?? null;
// The partner is priced against ITS OWN contract, so the two totals shown in
// the confirm modal are each customer's real bill — nobody pays for the other.
const validatePartnerMutation = useMutation({
mutationFn: (input: {
contractId: string;
bookingId: string;
dto: Freight.CreateBookingUnderContractDto;
}) =>
contractsService.validateShipment(
input.contractId,
input.dto,
input.bookingId,
),
});
const partnerValidation = validatePartnerMutation.data ?? null;
const serverTotal = useMemo(() => {
const items = validation?.lineItems;
if (!items?.length) return null;
@@ -1010,8 +1206,52 @@ export default function GlCreateBookingForm() {
};
}, [serverTotal, priceTotal, overweightSurchargeAmount]);
const partnerTotal = useMemo(() => {
const items = partnerValidation?.lineItems;
if (!items?.length) return null;
return {
currency: partnerValidation?.currency ?? "ETB",
lines: items.map((li) => ({
label: li.description,
unitPrice: li.unitAmount,
unit: li.unit.toLowerCase(),
quantity: li.quantity,
amount: li.amount,
})),
total:
partnerValidation?.totalAmount ?? items.reduce((s, l) => s + l.amount, 0),
};
}, [partnerValidation]);
// The partner half must clear the same hard blocks as this one — the pair is
// booked all-or-nothing, so a block on either side blocks both.
const partnerBlockers = useMemo(() => {
if (!consolidationActive || !partnerValidation) return [];
return [
...(partnerValidation.pairingErrors ?? []),
...(partnerValidation.capacityErrors ?? []),
...(partnerValidation.containerClashErrors ?? []),
...(partnerValidation.spaceErrors ?? []),
];
}, [consolidationActive, partnerValidation]);
const completePairMutation = useMutation({
mutationFn: (input: {
payload: Freight.CreateBookingUnderContractDto;
partnerPayload: Freight.CreateBookingUnderContractDto;
partnerBookingId: string;
}) =>
contractsService.completeConsolidatedPair(id ?? "", completeBookingId ?? "", {
partnerBookingId: input.partnerBookingId,
booking: input.payload,
partner: input.partnerPayload,
}),
});
const submitPending =
mutations.createBooking.isPending || mutations.completeBooking.isPending;
mutations.createBooking.isPending ||
mutations.completeBooking.isPending ||
completePairMutation.isPending;
// Block confirm until the authoritative server price is in hand — the client
// estimate is display-only; booking on it would confirm an un-validated,
@@ -1023,7 +1263,13 @@ export default function GlCreateBookingForm() {
capacityErrors.length > 0 ||
containerClashErrors.length > 0 ||
spaceErrors.length > 0 ||
!serverTotal;
!serverTotal ||
// Same bar for the shared-wagon partner: its authoritative price must be in
// hand and its own hard blocks clear before either booking is confirmed.
(consolidationActive &&
(validatePartnerMutation.isPending ||
!partnerTotal ||
partnerBlockers.length > 0));
const openPriceModal = () => {
// Surface the per-field errors (portal-parity validation) instead of
@@ -1039,6 +1285,15 @@ export default function GlCreateBookingForm() {
validateShipmentMutation.reset();
validateShipmentMutation.mutate(payload);
}
validatePartnerMutation.reset();
const partnerPayload = buildPartnerPayload();
if (partnerPayload && partner?.contractId) {
validatePartnerMutation.mutate({
contractId: partner.contractId,
bookingId: partner.id,
dto: partnerPayload,
});
}
};
const handleSubmit = () => {
@@ -1054,6 +1309,25 @@ export default function GlCreateBookingForm() {
const payload = buildPayload();
if (!payload) return;
// Shared wagon: both halves complete together, all-or-nothing on the server.
if (consolidationActive && partner && completeBookingId) {
// A hard block on the partner's own price preview blocks the pair.
if (partnerBlockers.length > 0) return;
const partnerPayload = buildPartnerPayload();
if (!partnerPayload) return;
completePairMutation.mutate(
{
payload,
partnerPayload,
partnerBookingId: partner.id,
},
{
onSuccess: () => navigate(`/dashboard/clearance/${completeBookingId}`),
},
);
return;
}
if (completeBookingId) {
// Completion mode: cargo + day land on the already-cleared instance —
// the request was linked and accepted at submission time.
@@ -1347,6 +1621,18 @@ export default function GlCreateBookingForm() {
maxRows={4}
styles={fieldStyles}
/>
{/* With a parent booking linked, each booking's containers are
entered in its own labelled section, one after the other. */}
{splitView ? (
<Group gap={8} align="center">
<Badge color="edr-green" variant="light" radius="sm">
{completeBooking?.reference ?? "This booking"}
</Badge>
<Text fz={13} fw={600} c="#10202F">
{completeBooking?.company?.name ?? "—"}
</Text>
</Group>
) : null}
{containerLines.length === 0 ? (
<Text fz="sm" c="dimmed">
This contract has no container sizes in scope.
@@ -1526,7 +1812,71 @@ export default function GlCreateBookingForm() {
))
)}
{hasOdd20ft ? (
{hasOdd20ft && oddConsolidationAvailable ? (
<Alert
color={consolidateOdd ? "edr-green" : "red"}
variant="light"
radius="md"
icon={<AlertCircle size={16} />}
title={`Odd number of 20ft containers (${ft20Total})`}
>
<Stack gap={10}>
<Text fz={13}>
20ft containers travel two per wagon, so one container here
is unpaired. On a customs booking you can pair it with
another customer's odd booking and complete both onto the
shared wagon — each booking is still priced and invoiced
separately.
</Text>
<Switch
checked={consolidateOdd}
color="edr-green"
label="Share a wagon with another booking"
onChange={(e) => {
consolidateTouchedRef.current = true;
setConsolidateOdd(e.currentTarget.checked);
}}
/>
{consolidateOdd ? (
<Group gap={10} align="center" wrap="wrap">
<Button
size="xs"
radius="md"
variant="light"
color="edr-green"
leftSection={<Link2 size={14} />}
onClick={() => setPartnerPickerOpen(true)}
>
{partner
? `Parent booking: ${partner.reference} — change`
: "Parent booking"}
</Button>
{partner ? (
<Button
size="xs"
radius="md"
variant="subtle"
color="gray"
onClick={() => {
setPartner(null);
setPartnerLines([]);
setPartnerCargoDescription("");
}}
>
Remove
</Button>
) : null}
</Group>
) : (
<Text fz={12.5} c="red.7">
With sharing off, book an even number of 20ft containers
— add one more or remove one (e.g. {ft20Total + 1} or{" "}
{ft20Total - 1} instead of {ft20Total}).
</Text>
)}
</Stack>
</Alert>
) : hasOdd20ft ? (
<Alert
color="red"
variant="light"
@@ -1540,6 +1890,34 @@ export default function GlCreateBookingForm() {
— the booking cannot be created with an unpaired 20ft container.
</Alert>
) : null}
{splitView && partner ? (
<>
<Divider my={4} />
<Group gap={8} align="center">
<Badge color="blue" variant="light" radius="sm">
{partner.reference}
</Badge>
<Text fz={13} fw={600} c="#10202F">
{partner.companyName ?? "—"}
</Text>
</Group>
<Text fz={12.5} c="dimmed">
Parent booking — ships on the same day and train, billed to
its own customer.
</Text>
<ConsolidationPartnerPanel
lines={partnerLines}
onLinesChange={setPartnerLines}
cargoDescription={partnerCargoDescription}
onCargoDescriptionChange={setPartnerCargoDescription}
showHazardous={Boolean(contract.isHazardous)}
showReefer={Boolean(contract.isReefer)}
showErrors={showErrors}
error={partnerError}
/>
</>
) : null}
</Stack>
</StepCard>
) : (
@@ -1796,7 +2174,23 @@ export default function GlCreateBookingForm() {
}}
>
<Box maw={896} mx="auto">
{showErrors && !formValid ? (
{/* The review button is disabled on an odd 20ft total, so the click
that would surface the errors never lands — state the reason here
rather than leaving it in a tooltip nobody hovers. */}
{oddBlocksSubmit ? (
<Alert
color="red"
variant="light"
radius="md"
icon={<AlertCircle size={16} />}
mb="sm"
title={`Odd number of 20ft containers (${ft20Total})`}
>
20ft containers travel two per wagon, so they must be booked in
even numbers. Add one more 20ft container or remove one — book{" "}
{ft20Total + 1} or {ft20Total - 1} instead of {ft20Total}.
</Alert>
) : showErrors && !formValid ? (
<Alert
color="red"
variant="light"
@@ -1806,12 +2200,31 @@ export default function GlCreateBookingForm() {
>
Fix the highlighted fields before reviewing the price.
</Alert>
) : partnerError ? (
// The review button is disabled while the parent booking is
// incomplete, so the click that would reveal the errors never
// lands — say what is outstanding without waiting for it.
<Alert
color="yellow"
variant="light"
radius="md"
icon={<AlertCircle size={16} />}
mb="sm"
>
{partnerError}
</Alert>
) : null}
<Group justify="flex-end">
<Tooltip
label={`Book an even number of 20ft containers — ${ft20Total} is odd and would leave one unpaired.`}
label={
oddBlocksSubmit
? `Book an even number of 20ft containers — ${ft20Total} is odd and would leave one unpaired.`
: (partnerError ?? "")
}
withArrow
disabled={!hasOdd20ft}
// Only explain a block that is actually in force: an odd count
// linked to a parent booking is resolved by the shared wagon.
disabled={!oddBlocksSubmit && !partnerError}
>
{/* Mantine tooltips get no pointer events from a disabled button,
so the wrapper carries the hover target. */}
@@ -1821,9 +2234,11 @@ export default function GlCreateBookingForm() {
radius="md"
leftSection={<Receipt size={16} />}
onClick={openPriceModal}
// Same hard block the customer portal applies at review time —
// an unpaired 20ft can never be planned onto a wagon.
disabled={hasOdd20ft}
// An unpaired 20ft can never be planned onto a wagon — unless
// a parent booking is linked to share it, which is what
// oddBlocksSubmit accounts for. The parent's own cargo must be
// complete too, or there is nothing to price.
disabled={oddBlocksSubmit || Boolean(partnerError)}
>
Review price &amp; book
</Button>
@@ -1833,6 +2248,24 @@ export default function GlCreateBookingForm() {
</Box>
</Box>
<ConsolidationPartnerPicker
opened={partnerPickerOpen}
onClose={() => setPartnerPickerOpen(false)}
candidates={candidatesQuery.data ?? []}
isLoading={candidatesQuery.isLoading}
isError={candidatesQuery.isError}
onSelect={(candidate) => {
setPartner(candidate);
// Seed a 20ft and a 40ft line. The parent booking sits on its OWN
// contract, whose size scope need not match this one's, so the panel
// offers both sizes rather than mirroring this contract's scope; a
// size the parent does not ship is simply left at 0.
setPartnerLines(PARTNER_SIZES.map(emptyPartnerLine));
setPartnerCargoDescription("");
setPartnerPickerOpen(false);
}}
/>
<Modal
opened={priceOpen}
onClose={() => {
@@ -1984,6 +2417,18 @@ export default function GlCreateBookingForm() {
)}
<Paper withBorder radius={16} p="lg" style={{ borderColor: "#E6ECF2" }}>
{/* Whose bill this is. Only worth naming when a second booking is
on screen — on a lone booking there is nothing to confuse it with. */}
{consolidationActive && partner ? (
<Group gap={8} align="center" mb={12} wrap="wrap">
<Badge color="edr-green" variant="light" radius="sm">
{completeBooking?.reference ?? "This booking"}
</Badge>
<Text fz={13} fw={600} c="#10202F">
{completeBooking?.company?.name ?? contract.company?.name ?? "—"}
</Text>
</Group>
) : null}
<Stack gap={10}>
{displayTotal.lines.map((line, i) => (
<Group key={i} justify="space-between" wrap="nowrap" gap="sm">
@@ -2028,6 +2473,123 @@ export default function GlCreateBookingForm() {
</Group>
</Paper>
{consolidationActive && partner ? (
<Paper
withBorder
radius={16}
p="lg"
style={{ borderColor: "#E6ECF2" }}
>
<Group gap={8} align="center" mb={12} wrap="wrap">
<Badge color="blue" variant="light" radius="sm">
{partner.reference}
</Badge>
<Text fz={13} fw={600} c="#10202F">
{partner.companyName ?? "—"}
</Text>
</Group>
{validatePartnerMutation.isPending ? (
<Group gap={8} c="dimmed">
<Loader size="xs" color="edr-green" />
<Text fz="sm" c="dimmed">
Pricing the partner booking
</Text>
</Group>
) : partnerBlockers.length > 0 ? (
<Alert
color="red"
variant="light"
radius="md"
icon={<AlertCircle size={16} />}
title={`Cannot book ${partner.reference}`}
>
<Stack gap={6}>
{partnerBlockers.map((msg, i) => (
<Text key={i} fz="sm" c="red.8">
{msg}
</Text>
))}
<Text fz="xs" c="red.7" mt={2}>
Both bookings are confirmed together, so this must be
fixed before either can be booked.
</Text>
</Stack>
</Alert>
) : partnerTotal ? (
<>
<Stack gap={10}>
{partnerTotal.lines.map((line, i) => (
<Group
key={i}
justify="space-between"
wrap="nowrap"
gap="sm"
>
<Box style={{ minWidth: 0 }}>
<Text fz="sm" fw={500}>
{line.label}
</Text>
<Text fz="xs" c="dimmed">
{line.quantity.toLocaleString()} ×{" "}
{line.unitPrice.toLocaleString()}{" "}
{partnerTotal.currency} ·{" "}
{formatRateUnit(line.unit)}
</Text>
</Box>
<Text
fz="sm"
fw={600}
style={{ whiteSpace: "nowrap" }}
>
{line.amount.toLocaleString()}{" "}
{partnerTotal.currency}
</Text>
</Group>
))}
</Stack>
<Divider my="md" />
<Group justify="space-between" align="flex-end">
<Text
fz="xs"
fw={700}
tt="uppercase"
c="blue"
style={{ letterSpacing: "0.06em" }}
>
Total
</Text>
<Text fw={800} fz={28}>
{partnerTotal.total.toLocaleString()}{" "}
<Text span fz={16} fw={700} c="dimmed">
{partnerTotal.currency}
</Text>
</Text>
</Group>
</>
) : (
<Text fz="sm" c="dimmed">
No price yet for the partner booking.
</Text>
)}
</Paper>
) : null}
{consolidationActive && partner ? (
<Alert
color="blue"
variant="light"
radius="md"
icon={<Link2 size={16} />}
>
<Text fz="sm">
These two bookings share one wagon but stay separate: each is
invoiced to its own customer and paid separately. Confirming
books both together if either fails, neither is booked.
</Text>
</Alert>
) : null}
<Group justify="space-between" mt="xs">
<Button
variant="default"
@@ -2046,7 +2608,11 @@ export default function GlCreateBookingForm() {
disabled={confirmDisabled}
onClick={handleSubmit}
>
{completeBookingId ? "Confirm & complete" : "Confirm & book"}
{consolidationActive && partner
? "Confirm & book both"
: completeBookingId
? "Confirm & complete"
: "Confirm & book"}
</Button>
</Group>
</Stack>

View File

@@ -0,0 +1,255 @@
import { type KeyboardEvent } from "react";
import {
Box,
Checkbox,
Group,
Stack,
Text,
TextInput,
} from "@mantine/core";
/**
* Container editor for the PARTNER half of a shared wagon. Deliberately a
* reduced version of the main form's editor: the partner contributes only cargo
* — route, shipment day and train are inherited from the booking it shares the
* wagon with, and hazardous/reefer/return counts are derived from the per-unit
* ticks rather than typed line totals.
*/
export interface PartnerUnitDraft {
containerNumber: string;
sealNumber: string;
vgmTons: string;
isHazardous: boolean;
isReefer: boolean;
isReturn: boolean;
}
export interface PartnerLineDraft {
containerSize: string;
quantity: string;
hazardousQuantity: string;
reeferQuantity: string;
returnQuantity: string;
units: PartnerUnitDraft[];
}
export function emptyPartnerUnit(): PartnerUnitDraft {
return {
containerNumber: "",
sealNumber: "",
vgmTons: "",
isHazardous: false,
isReefer: false,
isReturn: false,
};
}
export function emptyPartnerLine(size: string): PartnerLineDraft {
return {
containerSize: size,
quantity: "0",
hazardousQuantity: "0",
reeferQuantity: "0",
returnQuantity: "0",
units: [],
};
}
/** Quantities are magnitudes — swallow the minus key before it reaches the field. */
const blockNegative = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === "-") event.preventDefault();
};
/** Grow or shrink a line's unit rows to match its quantity. */
function syncUnits(line: PartnerLineDraft, quantity: number): PartnerLineDraft {
const target = Math.max(0, Math.floor(quantity) || 0);
const units = [...line.units];
while (units.length < target) units.push(emptyPartnerUnit());
units.length = target;
return {
...line,
units,
hazardousQuantity: String(units.filter((u) => u.isHazardous).length),
reeferQuantity: String(units.filter((u) => u.isReefer).length),
};
}
interface Props {
lines: PartnerLineDraft[];
onLinesChange: (lines: PartnerLineDraft[]) => void;
cargoDescription: string;
onCargoDescriptionChange: (value: string) => void;
/** Whether per-container hazardous / refrigerated ticks apply. */
showHazardous: boolean;
showReefer: boolean;
/** Surface field errors only after the operator tried to continue. */
showErrors: boolean;
error?: string;
}
export function ConsolidationPartnerPanel({
lines,
onLinesChange,
cargoDescription,
onCargoDescriptionChange,
showHazardous,
showReefer,
showErrors,
error,
}: Props) {
const patchLine = (index: number, patch: Partial<PartnerLineDraft>) => {
onLinesChange(
lines.map((line, i) => (i === index ? { ...line, ...patch } : line)),
);
};
const patchUnit = (
lineIndex: number,
unitIndex: number,
patch: Partial<PartnerUnitDraft>,
) => {
onLinesChange(
lines.map((line, i) => {
if (i !== lineIndex) return line;
const units = line.units.map((unit, u) =>
u === unitIndex ? { ...unit, ...patch } : unit,
);
return {
...line,
units,
hazardousQuantity: String(units.filter((u) => u.isHazardous).length),
reeferQuantity: String(units.filter((u) => u.isReefer).length),
};
}),
);
};
return (
<Stack gap={14}>
{error && showErrors ? (
<Text fz={12.5} c="red.7">
{error}
</Text>
) : null}
{lines.map((line, lineIdx) => (
<Box
key={`${line.containerSize}-${lineIdx}`}
className="rounded-xl"
style={{ border: "1px solid #E6ECF2", padding: 16 }}
>
<Text fz={14} fw={700} mb={10}>
{line.containerSize} containers
</Text>
<TextInput
type="number"
onKeyDown={blockNegative}
label="Quantity *"
min={0}
value={line.quantity}
onChange={(e) => patchLine(lineIdx, { quantity: e.currentTarget.value })}
// Sync off the typed value, not the captured `line` — that snapshot
// still holds the pre-edit quantity and would write it back.
onBlur={(e) => {
const typed = e.currentTarget.value;
patchLine(lineIdx, {
...syncUnits({ ...line, quantity: typed }, Number(typed || 0)),
quantity: typed,
});
}}
mb={12}
/>
{line.units.map((unit, unitIdx) => (
<Box key={unitIdx} mb={10}>
<Text fz={12} fw={600} c="#5B6B7B" mb={6}>
Container {unitIdx + 1}
</Text>
<Group gap={12} grow align="flex-start">
<TextInput
label="Container number *"
placeholder="e.g. MSCU1234567"
value={unit.containerNumber}
error={
showErrors && !unit.containerNumber.trim()
? "Required."
: undefined
}
onChange={(e) =>
patchUnit(lineIdx, unitIdx, {
containerNumber: e.currentTarget.value.toUpperCase(),
})
}
/>
<TextInput
label="Seal number"
value={unit.sealNumber}
onChange={(e) =>
patchUnit(lineIdx, unitIdx, {
sealNumber: e.currentTarget.value,
})
}
/>
<TextInput
type="number"
onKeyDown={blockNegative}
label="VGM (tons) *"
min={0}
value={unit.vgmTons}
error={
showErrors && !(Number(unit.vgmTons) > 0)
? "Required."
: undefined
}
onChange={(e) =>
patchUnit(lineIdx, unitIdx, { vgmTons: e.currentTarget.value })
}
/>
</Group>
{showHazardous || showReefer ? (
<Group gap={16} mt={8}>
{showHazardous ? (
<Checkbox
size="xs"
label="Hazardous"
checked={unit.isHazardous}
onChange={(e) =>
patchUnit(lineIdx, unitIdx, {
isHazardous: e.currentTarget.checked,
})
}
/>
) : null}
{showReefer ? (
<Checkbox
size="xs"
label="Refrigerated"
checked={unit.isReefer}
onChange={(e) =>
patchUnit(lineIdx, unitIdx, {
isReefer: e.currentTarget.checked,
})
}
/>
) : null}
</Group>
) : null}
</Box>
))}
</Box>
))}
<TextInput
label="Cargo description *"
placeholder="What these containers carry"
value={cargoDescription}
error={
showErrors && !cargoDescription.trim() ? "Required." : undefined
}
onChange={(e) => onCargoDescriptionChange(e.currentTarget.value)}
/>
</Stack>
);
}

View File

@@ -0,0 +1,137 @@
import {
Alert,
Badge,
Box,
Button,
Center,
Group,
Loader,
Modal,
Stack,
Text,
ThemeIcon,
} from "@mantine/core";
import { AlertCircle, Link2 } from "lucide-react";
import type { ConsolidationCandidate } from "@/services/contracts.service";
/**
* Picker for the booking that shares this booking's wagon. The server has
* already narrowed the list to bookings that can legally pair — same route and
* direction, customs clearing, an odd 20ft count of their own and not already
* linked to someone else — so every row here is a valid choice.
*/
interface Props {
opened: boolean;
onClose: () => void;
candidates: ConsolidationCandidate[];
isLoading: boolean;
isError: boolean;
onSelect: (candidate: ConsolidationCandidate) => void;
}
export function ConsolidationPartnerPicker({
opened,
onClose,
candidates,
isLoading,
isError,
onSelect,
}: Props) {
return (
<Modal
opened={opened}
onClose={onClose}
centered
radius="lg"
size="lg"
title={
<Group gap={10}>
<ThemeIcon variant="light" color="blue" radius="md" size={34}>
<Link2 size={18} />
</ThemeIcon>
<Box>
<Text fw={800} fz={16}>
Pick the parent booking
</Text>
<Text fz="xs" c="dimmed">
Customs bookings on the same route that also carry an odd number of
20ft containers.
</Text>
</Box>
</Group>
}
>
{isLoading ? (
<Center py="xl">
<Loader size="sm" color="edr-green" />
</Center>
) : isError ? (
<Alert
color="red"
variant="light"
radius="md"
icon={<AlertCircle size={16} />}
>
Could not load the candidate bookings. Close this and try again.
</Alert>
) : candidates.length === 0 ? (
<Alert
color="yellow"
variant="light"
radius="md"
icon={<AlertCircle size={16} />}
title="No booking available to share this wagon"
>
<Text fz="sm">
No other customs booking on this route currently carries an odd
number of 20ft containers. Either wait for one, or switch the
shared-wagon option off and book an even number of 20ft containers.
</Text>
</Alert>
) : (
<Stack gap={10}>
{candidates.map((candidate) => (
<Box
key={candidate.id}
className="rounded-xl"
style={{ border: "1px solid #E6ECF2", padding: 14 }}
>
<Group justify="space-between" align="center" wrap="wrap" gap={10}>
<Box style={{ minWidth: 0 }}>
<Group gap={8} align="center" wrap="wrap">
<Text fz={14} fw={700} c="#10202F">
{candidate.reference}
</Text>
<Badge size="sm" variant="light" color="gray" radius="sm">
{candidate.status.replaceAll("_", " ")}
</Badge>
</Group>
<Text fz={12.5} c="dimmed" mt={2}>
{candidate.companyName ?? "—"}
{candidate.tradeDirection
? ` · ${candidate.tradeDirection}`
: ""}
{" · "}
{candidate.hasCargo
? `${candidate.ft20Quantity} × 20ft`
: "cargo not entered yet"}
</Text>
</Box>
<Button
size="xs"
radius="md"
variant="light"
color="edr-green"
onClick={() => onSelect(candidate)}
>
Select
</Button>
</Group>
</Box>
))}
</Stack>
)}
</Modal>
);
}

View File

@@ -114,6 +114,36 @@ export function CompanyNationalityBadge({
);
}
/**
* The company's registration was typed, not fetched from eTrade — nothing in it
* has been checked against a licence. Loud on purpose: it is the one thing a
* reviewer must not miss about this customer. Two kinds of company land here
* for different reasons, and the badge names which.
*/
export function ManualRegistrationBadge({
cooperative,
investorLicence,
}: {
cooperative?: boolean | null;
investorLicence?: boolean | null;
}) {
if (!cooperative && !investorLicence) return null;
return (
<Badge
color="orange"
variant="light"
size="sm"
radius="md"
fw={600}
style={badgeStyle}
>
{cooperative
? "Manual entry · co-operative"
: "Manual entry · investment licence"}
</Badge>
);
}
/**
* Profile chips for a company row: one chip per role (Importer / Exporter / …)
* carrying its reference code, colored by the profile's status (green active,
@@ -308,9 +338,11 @@ export function InvoiceStatusBadge({
/**
* Inline approval action buttons for a profile row.
* Transitions: pending → approve / reject-with-note | rejected → approve (override) |
* Transitions: pending → approve / reject-with-note | rejected → undo-rejection (→ pending) |
* active → suspend | suspended → reactivate/blacklist | blacklisted → reinstate.
* Rejecting captures a note the customer sees so they can fix and reapply.
* Rejecting captures a note the customer sees so they can fix and reapply — a
* rejected role is theirs to resubmit, so it cannot be approved from here until
* they do (the API refuses it); undoing the rejection is the only way back.
*
* `locked` (customer hasn't submitted onboarding) withholds the review decision
* only — there's no application to judge yet, and the API rejects the call
@@ -496,18 +528,33 @@ export function ProfileApprovalActions({
}
if (status === "rejected") {
if (!canSet("active")) return null;
// No Approve here: the role is waiting on the customer to fix what was
// flagged and resubmit it, and the API refuses rejected → active outright.
// All that's left is undoing a rejection that shouldn't have happened,
// which puts the role back in the queue rather than into service.
if (!canSet("pending")) return null;
return (
<Button
size="xs"
variant="light"
color="edr-green"
radius="md"
loading={isPending}
onClick={() => act("active")}
>
Approve
</Button>
<Group gap={8} wrap="nowrap">
<Text size="xs" c="dimmed" fs="italic">
Awaiting customer resubmission
</Text>
<Tooltip
multiline
w={260}
label="Puts the role back in the pending queue and clears the rejection note. Use only if the rejection itself was a mistake — it does not approve the role."
>
<Button
size="xs"
variant="subtle"
color="gray"
radius="md"
loading={isPending}
onClick={() => act("pending")}
>
Undo rejection
</Button>
</Tooltip>
</Group>
);
}

View File

@@ -4,6 +4,7 @@ export {
CompanyStatusBadge,
CompanyTypeBadge,
InvoiceStatusBadge,
ManualRegistrationBadge,
PaymentStatusBadge,
ProfileApprovalActions,
ProfileChips,

View File

@@ -71,6 +71,13 @@ const ROUTE_META: Array<{ prefix: string; meta: PageMeta }> = [
subtitle: "Manage your account and signature",
},
},
{
prefix: "/dashboard/chat",
meta: {
title: "Chat",
subtitle: "Internal messaging for EDR staff",
},
},
{
// Invoices, Payments, and USD Payments are tabs on one page now
// (FinanceHubPage); the header title itself is set per-tab there.

View File

@@ -12,6 +12,7 @@ import {
Image as ImageIcon,
LayoutDashboard,
LayoutGrid,
Link2,
MapPin,
Network,
Package,
@@ -32,6 +33,7 @@ import {
Users,
Wallet,
LifeBuoy,
MessageSquare,
TrainFront,
XCircle,
} from "lucide-react";
@@ -95,6 +97,14 @@ export const buildSidebarSections = (
icon: <FileText />,
permission: FREIGHT_PERMS.bookings.view,
},
// Shared-wagon gate: a consolidated pair waits for a human decision
// before either half reaches Operations.
{
label: "Shared wagon approvals",
href: "/dashboard/consolidation-approvals",
icon: <Link2 />,
permission: FREIGHT_PERMS.bookings.approveConsolidation,
},
{
label: "Wagon cancellations",
href: "/dashboard/wagon-cancellations",
@@ -124,6 +134,12 @@ export const buildSidebarSections = (
icon: <LifeBuoy />,
permission: FREIGHT_PERMS.support.agentView,
},
{
label: "Chat",
href: "/dashboard/chat",
icon: <MessageSquare />,
permission: FREIGHT_PERMS.chat.view,
},
...demoItems,
],
},
@@ -560,6 +576,11 @@ export const buildSidebarSections = (
href: "/dashboard/configuration/operations-standards",
permission: FREIGHT_PERMS.settings.operationsStandards.view,
},
{
label: "Manual payments",
href: "/dashboard/configuration/manual-payments",
permission: FREIGHT_PERMS.settings.manualPayment.view,
},
],
},
{

View File

@@ -4,34 +4,40 @@ import {
Button,
Checkbox,
Group,
Pagination,
ScrollArea,
Select,
Stack,
Text,
TextInput,
} from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { useQuery } from "@tanstack/react-query";
import { Plus, Search } from "lucide-react";
import { useMemo, useState } from "react";
import { MapPin, Plus, Search } from "lucide-react";
import { memo, useCallback, useEffect, useMemo, useState } from "react";
const PAGE_SIZE = 20;
import { api } from "@/services/api";
/**
* AVAILABLE wagons standing in the train's own yard — the only ones that can
* be coupled. Pick any number and append them to the consist.
* AVAILABLE, unassigned wagons from every yard — filtered and paged on the API,
* so the picker never page-walks the whole fleet into the browser.
*/
export default function AvailableWagonsPanel({
yardId,
yardLabel,
function AvailableWagonsPanel({
homeYardId,
onAssign,
assigning,
exportTrainNumber,
importTrainNumber,
}: AvailableWagonsPanelProps) {
const [search, setSearch] = useState("");
const [debouncedSearch] = useDebouncedValue(search, 300);
const [typeFilter, setTypeFilter] = useState<string>("ALL");
const [yardFilter, setYardFilter] = useState<string>("ALL");
const [runOnly, setRunOnly] = useState(false);
const [selected, setSelected] = useState<string[]>([]);
const [selected, setSelected] = useState<ReadonlySet<string>>(() => new Set());
const [page, setPage] = useState(1);
// The train's own run, e.g. "8001-8002" — only offered when the train has one.
const runLabel = exportTrainNumber
@@ -39,86 +45,95 @@ export default function AvailableWagonsPanel({
: null;
const wagonsQuery = useQuery(
api.wagons.list.queryOptions({
api.wagons.listPaged.queryOptions({
input: {
filters: {
status: Freight.WagonStatus.Available,
currentYardId: yardId,
// Loose wagons only — one already on another train cannot be coupled.
unassigned: true,
search: debouncedSearch.trim() || undefined,
currentYardId: yardFilter === "ALL" ? undefined : yardFilter,
wagonTypeId: typeFilter === "ALL" ? undefined : typeFilter,
// Rostered to this train's run — the API matches either run column.
trainNumber: runOnly && exportTrainNumber ? exportTrainNumber : undefined,
page,
pageSize: PAGE_SIZE,
},
},
enabled: Boolean(yardId),
// Keep the previous page on screen while the next one loads — otherwise
// paging and typing flash the list to "Loading wagons…" on every stroke.
placeholderData: (prev) => prev,
}),
);
const wagons = useMemo(() => {
const q = search.trim().toLowerCase();
return (wagonsQuery.data ?? []).filter((wagon) => {
if (typeFilter !== "ALL" && wagon.wagonTypeId !== typeFilter) return false;
// Rostered to this train's run — match on the export run, which fixes the
// import run anyway.
if (runOnly && wagon.exportTrainNumber !== exportTrainNumber) return false;
if (q && !wagon.wagonNumber.toLowerCase().includes(q)) return false;
return true;
});
}, [wagonsQuery.data, search, typeFilter, runOnly, exportTrainNumber]);
const wagons = wagonsQuery.data?.items ?? [];
const total = wagonsQuery.data?.meta.total ?? 0;
const totalPages = Math.max(1, wagonsQuery.data?.meta.totalPages ?? 1);
const runMatchCount = useMemo(
() =>
exportTrainNumber
? (wagonsQuery.data ?? []).filter(
(w) => w.exportTrainNumber === exportTrainNumber,
).length
: 0,
[wagonsQuery.data, exportTrainNumber],
// Filters change → back to page 1 (and clamp when the list shrinks).
useEffect(() => {
setPage(1);
}, [debouncedSearch, typeFilter, yardFilter, runOnly]);
useEffect(() => {
if (page > totalPages) setPage(totalPages);
}, [page, totalPages]);
// Dropdowns come from the reference lists, not the current page — a yard or
// type must stay pickable even when this page holds none of it.
const yardsQuery = useQuery(api.routes.yards.queryOptions({ staleTime: 5 * 60_000 }));
const wagonTypesQuery = useQuery(api.wagonTypes.list.queryOptions({ staleTime: 5 * 60_000 }));
const yardOptions = useMemo(() => {
const yards = [...(yardsQuery.data ?? [])].sort((a, b) =>
a.id === homeYardId ? -1 : b.id === homeYardId ? 1 : a.label.localeCompare(b.label),
);
return [
{ value: "ALL", label: "All yards" },
...yards.map((yard) => ({
value: yard.id,
label: `${yard.label}${yard.id === homeYardId ? " · train's yard" : ""}`,
})),
];
}, [yardsQuery.data, homeYardId]);
const typeOptions = useMemo(
() => [
{ value: "ALL", label: "All types" },
// e.g. "Flat wagon (NW5)" — name with its type code.
...(wagonTypesQuery.data ?? []).map((type) => ({
value: type.id,
label: type.code ? `${type.name} (${type.code})` : type.name,
})),
],
[wagonTypesQuery.data],
);
const typeOptions = useMemo(() => {
const byId = new Map<string, string>();
for (const wagon of wagonsQuery.data ?? []) {
if (wagon.wagonType) {
// e.g. "Flat wagon (NW5)" — name with its type code.
byId.set(
wagon.wagonType.id,
wagon.wagonType.code
? `${wagon.wagonType.name} (${wagon.wagonType.code})`
: wagon.wagonType.name,
);
}
}
return [
{ value: "ALL", label: "All types" },
...[...byId.entries()].map(([value, label]) => ({ value, label })),
];
}, [wagonsQuery.data]);
const toggle = useCallback((wagonId: string, checked: boolean) => {
setSelected((prev) => {
const next = new Set(prev);
if (checked) next.add(wagonId);
else next.delete(wagonId);
return next;
});
}, []);
const toggle = (wagonId: string, checked: boolean) => {
setSelected((prev) =>
checked ? [...prev, wagonId] : prev.filter((id) => id !== wagonId),
);
};
const allSelected =
wagons.length > 0 && wagons.every((w) => selected.includes(w.id));
const someSelected = wagons.some((w) => selected.includes(w.id));
// Select-all covers this page only — the rest of the matches are not loaded.
const allSelected = wagons.length > 0 && wagons.every((w) => selected.has(w.id));
const someSelected = wagons.some((w) => selected.has(w.id));
const toggleAll = (checked: boolean) => {
setSelected((prev) => {
if (checked) {
const ids = new Set(prev);
wagons.forEach((w) => ids.add(w.id));
return [...ids];
}
const visible = new Set(wagons.map((w) => w.id));
return prev.filter((id) => !visible.has(id));
const next = new Set(prev);
if (checked) wagons.forEach((w) => next.add(w.id));
else wagons.forEach((w) => next.delete(w.id));
return next;
});
};
const handleAssign = () => {
if (!selected.length) return;
onAssign(selected);
setSelected([]);
if (!selected.size) return;
onAssign([...selected]);
setSelected(new Set());
};
return (
@@ -138,100 +153,102 @@ export default function AvailableWagonsPanel({
onChange={(v) => setTypeFilter(v ?? "ALL")}
/>
</Group>
<Select
size="sm"
leftSection={<MapPin size={14} />}
data={yardOptions}
value={yardFilter}
onChange={(v) => setYardFilter(v ?? "ALL")}
searchable
aria-label="Filter by yard"
/>
{runLabel ? (
<Checkbox
size="sm"
label={`Only wagons on this train's run (${runLabel})${runMatchCount} here`}
label={`Only wagons on this train's run (${runLabel})`}
checked={runOnly}
onChange={(e) => setRunOnly(e.currentTarget.checked)}
/>
) : null}
{wagons.length ? (
<Checkbox
size="sm"
label={`Select all (${wagons.length})`}
checked={allSelected}
indeterminate={!allSelected && someSelected}
onChange={(e) => toggleAll(e.currentTarget.checked)}
/>
<Group justify="space-between" wrap="nowrap">
<Checkbox
size="sm"
label={`Select all on this page (${wagons.length})`}
checked={allSelected}
indeterminate={!allSelected && someSelected}
onChange={(e) => toggleAll(e.currentTarget.checked)}
/>
{selected.size ? (
<Text size="xs" c="dimmed">
{selected.size} selected
</Text>
) : null}
</Group>
) : null}
<ScrollArea.Autosize mah={380} type="auto">
<Stack gap={6}>
{/* Previous results stay put while the next page loads (placeholderData),
so dim them rather than blanking the list. */}
<Stack
gap={6}
style={{
opacity: wagonsQuery.isFetching && !wagonsQuery.isLoading ? 0.55 : 1,
transition: "opacity 120ms ease",
}}
>
{wagonsQuery.isLoading ? (
<Text py="md" ta="center" c="dimmed" size="sm">
Loading wagons
</Text>
) : !wagons.length ? (
<Text py="md" ta="center" c="dimmed" size="sm">
No available wagons in {yardLabel ?? "this yard"}
No available wagons match
</Text>
) : (
wagons.map((wagon) => (
<Group
<WagonOption
key={wagon.id}
gap="sm"
wrap="nowrap"
p="xs"
style={{
border: "1px solid var(--mantine-color-gray-2)",
borderRadius: "var(--mantine-radius-md)",
}}
>
<Checkbox
size="sm"
checked={selected.includes(wagon.id)}
onChange={(e) => toggle(wagon.id, e.currentTarget.checked)}
aria-label={`Select wagon ${wagon.wagonNumber}`}
/>
<Stack gap={0} style={{ flex: 1, minWidth: 0 }}>
<Group gap={6} wrap="nowrap">
<Text size="sm" fw={600} ff="monospace" truncate>
{wagon.wagonNumber}
</Text>
{wagon.exportTrainNumber ? (
<Badge
size="xs"
radius="sm"
variant="light"
color={
wagon.exportTrainNumber === exportTrainNumber ? "edr-green" : "gray"
}
>
{wagon.exportTrainNumber}
{wagon.importTrainNumber ? `-${wagon.importTrainNumber}` : ""}
</Badge>
) : null}
</Group>
<Text size="xs" c="dimmed" truncate>
{wagon.wagonType
? `${wagon.wagonType.name} · ${wagon.wagonType.capacityTons ?? "—"}T cap`
: "Unknown type"}
</Text>
</Stack>
</Group>
wagon={wagon}
selected={selected.has(wagon.id)}
homeYardId={homeYardId}
exportTrainNumber={exportTrainNumber}
onToggle={toggle}
/>
))
)}
</Stack>
</ScrollArea.Autosize>
{totalPages > 1 ? (
<Group justify="space-between" wrap="nowrap">
<Text size="xs" c="dimmed">
{(page - 1) * PAGE_SIZE + 1}{Math.min(page * PAGE_SIZE, total)} of {total}
</Text>
<Pagination size="sm" value={page} onChange={setPage} total={totalPages} />
</Group>
) : null}
<Button
leftSection={<Plus size={16} />}
disabled={!selected.length}
disabled={!selected.size}
loading={assigning}
onClick={handleAssign}
>
Add {selected.length ? `${selected.length} wagon${selected.length > 1 ? "s" : ""}` : "wagons"} to consist
Add {selected.size ? `${selected.size} wagon${selected.size > 1 ? "s" : ""}` : "wagons"} to consist
</Button>
</Stack>
);
}
/** Memoized: the workspace re-renders on every pending mutation. */
export default memo(AvailableWagonsPanel);
export interface AvailableWagonsPanelProps {
yardId: string;
yardLabel?: string | null;
/** The train's own yard — sorted first and highlighted; not a restriction. */
homeYardId: string | null;
onAssign: (wagonIds: string[]) => void;
assigning: boolean;
/** This train's odd EXPORT run — drives the "only this run" filter. */
@@ -239,3 +256,81 @@ export interface AvailableWagonsPanelProps {
/** This train's even IMPORT run — label only; the export run does the matching. */
importTrainNumber?: string | null;
}
/**
* One selectable wagon row. Memoized: the picker re-renders on every keystroke
* and every selection change, but a row only actually changes when its own
* checkbox flips — so a full page of rows stays untouched.
*/
const WagonOption = memo(function WagonOption({
wagon,
selected,
homeYardId,
exportTrainNumber,
onToggle,
}: {
wagon: {
id: string;
wagonNumber: string;
currentYardId?: string | null;
currentYard?: { label?: string | null; code?: string | null } | null;
exportTrainNumber?: string | null;
importTrainNumber?: string | null;
wagonType?: { name?: string | null; capacityTons?: number | null } | null;
};
selected: boolean;
homeYardId: string | null;
exportTrainNumber?: string | null;
onToggle: (wagonId: string, checked: boolean) => void;
}) {
return (
<Group
gap="sm"
wrap="nowrap"
p="xs"
style={{
border: "1px solid var(--mantine-color-gray-2)",
borderRadius: "var(--mantine-radius-md)",
}}
>
<Checkbox
size="sm"
checked={selected}
onChange={(e) => onToggle(wagon.id, e.currentTarget.checked)}
aria-label={`Select wagon ${wagon.wagonNumber}`}
/>
<Stack gap={0} style={{ flex: 1, minWidth: 0 }}>
<Group gap={6} wrap="nowrap">
<Text size="sm" fw={600} ff="monospace" truncate>
{wagon.wagonNumber}
</Text>
<Badge
size="xs"
radius="sm"
variant="outline"
color={wagon.currentYardId === homeYardId ? "edr-green" : "gray"}
leftSection={<MapPin size={10} />}
>
{wagon.currentYard?.label ?? wagon.currentYard?.code ?? "No yard"}
</Badge>
{wagon.exportTrainNumber ? (
<Badge
size="xs"
radius="sm"
variant="light"
color={wagon.exportTrainNumber === exportTrainNumber ? "edr-green" : "gray"}
>
{wagon.exportTrainNumber}
{wagon.importTrainNumber ? `-${wagon.importTrainNumber}` : ""}
</Badge>
) : null}
</Group>
<Text size="xs" c="dimmed" truncate>
{wagon.wagonType
? `${wagon.wagonType.name} · ${wagon.wagonType.capacityTons ?? "—"}T cap`
: "Unknown type"}
</Text>
</Stack>
</Group>
);
});

View File

@@ -6,11 +6,13 @@ import {
type DraggableStateSnapshot,
type DropResult,
} from "@hello-pangea/dnd";
import { ActionIcon, Badge, Box, Group, Stack, Text, Tooltip } from "@mantine/core";
import { GripVertical, Trash2, Wrench } from "lucide-react";
import { type ReactNode } from "react";
import { ActionIcon, Badge, Box, Group, Menu, Stack, Text, Tooltip } from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { GripVertical, MapPin, Trash2, Wrench } from "lucide-react";
import { memo, useCallback, useMemo, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { api } from "@/services/api";
import type { TrainCompositionWagon } from "@/services/trainBuilder.service";
import { wagonTypeColor } from "./trainStatus";
@@ -32,15 +34,16 @@ const PortalAwareRow = ({
* The train's ordered wagon consist. Drag to reorder (persisted on drop),
* trash to detach a wagon back to the yard.
*/
export default function ConsistWagonList({
function ConsistWagonList({
wagons,
editable,
onReorder,
onRemove,
onMaintenance,
onChangeYard,
busy = false,
}: ConsistWagonListProps) {
const onDragEnd = (result: DropResult) => {
const onDragEnd = useCallback((result: DropResult) => {
if (!result.destination) return;
const from = result.source.index;
const to = result.destination.index;
@@ -49,7 +52,18 @@ export default function ConsistWagonList({
const [moved] = next.splice(from, 1);
next.splice(to, 0, moved!);
onReorder(next.map((w) => w.id));
};
}, [wagons, onReorder]);
// Legend of the types actually coupled, in consist order — the colour code is
// only readable if the row tints are keyed somewhere.
const legend = useMemo(
() => [
...new Map(
wagons.filter((w) => w.wagonType).map((w) => [w.wagonType!.code, w.wagonType!]),
).values(),
],
[wagons],
);
if (!wagons.length) {
return (
@@ -59,16 +73,6 @@ export default function ConsistWagonList({
);
}
// Legend of the types actually coupled, in consist order — the colour code is
// only readable if the row tints are keyed somewhere.
const legend = [
...new Map(
wagons
.filter((w) => w.wagonType)
.map((w) => [w.wagonType!.code, w.wagonType!]),
).values(),
];
return (
<DragDropContext onDragEnd={onDragEnd}>
<Droppable droppableId="train-consist-wagons" isDropDisabled={!editable || busy}>
@@ -106,6 +110,7 @@ export default function ConsistWagonList({
busy={busy}
onRemove={onRemove}
onMaintenance={onMaintenance}
onChangeYard={onChangeYard}
/>
)}
</Draggable>
@@ -118,6 +123,9 @@ export default function ConsistWagonList({
);
}
/** Memoized: a 40-wagon consist re-renders every row otherwise. */
export default memo(ConsistWagonList);
export interface ConsistWagonListProps {
wagons: TrainCompositionWagon[];
editable: boolean;
@@ -125,10 +133,69 @@ export interface ConsistWagonListProps {
onRemove: (wagonId: string) => void;
/** Detach the wagon and move it to MAINTENANCE status (page confirms first). */
onMaintenance: (wagon: TrainCompositionWagon) => void;
/** Move one wagon to another yard from its yard badge; absent = read-only badge. */
onChangeYard?: (wagonId: string, currentYardId: string) => void;
busy?: boolean;
}
function WagonRow({
/** Yard badge that opens a yard picker when `onChange` is provided. */
function WagonYardBadge({
wagon,
busy,
onChange,
}: {
wagon: TrainCompositionWagon;
busy: boolean;
onChange?: (wagonId: string, currentYardId: string) => void;
}) {
const label = wagon.currentYard?.label ?? wagon.currentYard?.code ?? "No yard";
const yardsQuery = useQuery(
api.routes.yards.queryOptions({ staleTime: 5 * 60_000, enabled: Boolean(onChange) }),
);
if (!onChange) {
return wagon.currentYard ? (
<Badge variant="outline" color="gray" size="xs" radius="sm" leftSection={<MapPin size={10} />}>
{label}
</Badge>
) : null;
}
return (
<Menu shadow="md" width={240} withinPortal>
<Menu.Target>
<Badge
component="button"
type="button"
variant="outline"
color="blue"
size="xs"
radius="sm"
leftSection={<MapPin size={10} />}
disabled={busy}
style={{ cursor: busy ? "default" : "pointer" }}
// Stop the drag handle from swallowing the click.
onMouseDown={(e) => e.stopPropagation()}
aria-label={`Change yard of wagon ${wagon.wagonNumber}`}
>
{label}
</Badge>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>Move wagon to yard</Menu.Label>
{(yardsQuery.data ?? []).map((y) => (
<Menu.Item
key={y.id}
disabled={y.id === wagon.currentYard?.id}
onClick={() => onChange(wagon.id, y.id)}
>
{y.label ?? y.code}
</Menu.Item>
))}
</Menu.Dropdown>
</Menu>
);
}
const WagonRow = memo(function WagonRow({
wagon,
index,
dragProvided,
@@ -137,6 +204,7 @@ function WagonRow({
busy,
onRemove,
onMaintenance,
onChangeYard,
}: {
wagon: TrainCompositionWagon;
index: number;
@@ -146,6 +214,7 @@ function WagonRow({
busy: boolean;
onRemove: (wagonId: string) => void;
onMaintenance: (wagon: TrainCompositionWagon) => void;
onChangeYard?: (wagonId: string, currentYardId: string) => void;
}) {
const color = wagonTypeColor(wagon.wagonType?.code);
@@ -191,6 +260,7 @@ function WagonRow({
{wagon.wagonType.code}
</Badge>
) : null}
<WagonYardBadge wagon={wagon} busy={busy} onChange={onChangeYard} />
</Group>
<Text size="xs" c="dimmed" truncate>
{wagon.wagonType
@@ -227,4 +297,4 @@ function WagonRow({
</Group>
</PortalAwareRow>
);
}
});

View File

@@ -1,4 +1,4 @@
import { useMemo } from "react";
import { memo, useMemo } from "react";
import { Box, Group, Paper, Progress, Stack, Text, Tooltip } from "@mantine/core";
import { useElementSize } from "@mantine/hooks";
import { Box as BoxIcon, Container as ContainerIcon, Fuel, Gauge, TrainFront } from "lucide-react";
@@ -132,7 +132,7 @@ function Coupler() {
);
}
function LocomotiveCar({
const LocomotiveCar = memo(function LocomotiveCar({
code,
name,
maxPullWeightTons,
@@ -260,7 +260,7 @@ function LocomotiveCar({
</Box>
</Tooltip>
);
}
});
const CONTAINER_GRADIENTS = [
"linear-gradient(180deg, var(--mantine-color-cyan-5), var(--mantine-color-cyan-7))",
@@ -271,7 +271,7 @@ const CONTAINER_BORDERS = [
"var(--mantine-color-blue-8)",
];
function WagonCar({ wagon }: { wagon: NormalizedWagon }) {
const WagonCar = memo(function WagonCar({ wagon }: { wagon: NormalizedWagon }) {
// GROSS on both sides: cargo + tare vs rated payload + tare.
const grossTons = round1(wagon.assignedWeightTons + wagon.tareWeightTons);
const maxGrossTons = round1(wagon.capacityTons + wagon.tareWeightTons);
@@ -468,7 +468,7 @@ function WagonCar({ wagon }: { wagon: NormalizedWagon }) {
</Box>
</Tooltip>
);
}
});
/** Railway track: two rails over evenly-spaced sleepers. */
function TrackBed() {
@@ -520,7 +520,7 @@ function TrackBed() {
);
}
export function TrainCompositionDiagram({
export const TrainCompositionDiagram = memo(function TrainCompositionDiagram({
locomotive,
locomotives,
wagons,
@@ -818,7 +818,7 @@ export function TrainCompositionDiagram({
</Stack>
</Paper>
);
}
});
function LegendDot({ color, label }: { color: string; label: string }) {
return (

View File

@@ -4,7 +4,9 @@ import {
Alert,
Badge,
Button,
Card,
Checkbox,
CopyButton,
Group,
Loader,
Menu,
@@ -12,20 +14,25 @@ import {
NumberInput,
ScrollArea,
Select,
SimpleGrid,
Stack,
Table,
Tabs,
Text,
Textarea,
TextInput,
ThemeIcon,
Tooltip,
} from '@mantine/core';
import {
ArrowRightLeft,
Calendar,
Check,
CheckCheck,
ChevronDown,
ChevronRight,
ClipboardCheck,
Copy,
Eye,
FileText,
History,
@@ -83,6 +90,9 @@ import { StoreInventoryModal } from './StoreInventoryModal';
import { WarehouseInquiryTable } from './WarehouseInquiryTable';
import { extractDownloadErrorMessage, extractErrorMessage, formatDate, formatNumber, inventoryStatusOptions, warehousesAtStation, yardsForBooking } from './options';
import { openPdfBlob } from './pdf';
import ListControls from '@/components/common/ListControls';
import RuleEngineListFooter from '@/components/ruleEngine/RuleEngineListFooter';
import { useListControls } from '@/hooks/useListControls';
import '@/components/overview/overview.css';
type ImportUnloadAssignment = { bookingId: string; warehouseId: string; yardId: string; zoneId: string };
@@ -915,7 +925,10 @@ function EligibleTab({
),
[rows, statusOptions],
);
const selectableRows = statusFilteredRows.filter(canReceiveBooking);
const controls = useListControls(statusFilteredRows, {
searchKeys: ['reference', 'customer', 'origin', 'destination', 'containerNumber', 'cargo', 'cargoDescription'],
});
const selectableRows = controls.filteredRows.filter(canReceiveBooking);
const allSelected = selectableRows.length > 0 && selected.size === selectableRows.length;
const someSelected = selected.size > 0 && !allSelected;
const pendingReceiveRows = useMemo(
@@ -1075,7 +1088,7 @@ function EligibleTab({
<Group justify="space-between">
<Text size="sm" c="dimmed">
Selected: <b>{selected.size}</b> / {statusFilteredRows.length} eligible
Selected: <b>{selected.size}</b> / {controls.filteredRows.length} eligible
</Text>
<Group gap="xs">
<Button
@@ -1118,6 +1131,19 @@ function EligibleTab({
: `No eligible PAID ${direction.toLowerCase()} bookings to receive.`}
</Text>
) : (
<Stack gap="sm">
<ListControls
search={controls.search}
onSearchChange={controls.setSearch}
searchPlaceholder="Booking, customer, route, container, cargo…"
dateFrom={controls.dateFrom}
onDateFromChange={controls.setDateFrom}
dateTo={controls.dateTo}
onDateToChange={controls.setDateTo}
hasFilters={controls.hasFilters}
onReset={controls.reset}
showDateRange={false}
/>
<Table.ScrollContainer minWidth={1350}>
<Table highlightOnHover verticalSpacing="xs" striped>
<Table.Thead>
@@ -1146,7 +1172,7 @@ function EligibleTab({
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{statusFilteredRows.map((r) => {
{controls.pagedRows.map((r) => {
const canReceive = canReceiveBooking(r);
return (
<Table.Tr key={r.id}>
@@ -1242,6 +1268,14 @@ function EligibleTab({
</Table.Tbody>
</Table>
</Table.ScrollContainer>
<RuleEngineListFooter
pagination={controls.pagination}
pageCount={controls.pageCount}
totalCount={controls.totalCount}
itemLabel="bookings"
onPaginationChange={controls.setPagination}
/>
</Stack>
)}
<Modal
@@ -1340,7 +1374,10 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged
const [expandedRow, setExpandedRow] = useState<string | null>(null);
const [inspectId, setInspectId] = useState<string | null>(null);
const pendingRows = rows.filter((r) => r.inspectionStatus !== 'PASSED');
const controls = useListControls(rows, {
searchKeys: ['bookingReference', 'customerName', 'containerNumber', 'cargoType', 'grnNumber', 'origin', 'destination'],
});
const pendingRows = controls.filteredRows.filter((r) => r.inspectionStatus !== 'PASSED');
const allSelected = pendingRows.length > 0 && selected.size === pendingRows.length;
const someSelected = selected.size > 0 && !allSelected;
const toggleAll = () =>
@@ -1404,6 +1441,19 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged
No received export items awaiting inspection.
</Text>
) : (
<Stack gap="sm">
<ListControls
search={controls.search}
onSearchChange={controls.setSearch}
searchPlaceholder="Booking, GRN, customer, container, cargo, route…"
dateFrom={controls.dateFrom}
onDateFromChange={controls.setDateFrom}
dateTo={controls.dateTo}
onDateToChange={controls.setDateTo}
hasFilters={controls.hasFilters}
onReset={controls.reset}
showDateRange={false}
/>
<Table.ScrollContainer minWidth={1350}>
<Table highlightOnHover verticalSpacing="xs" striped>
<Table.Thead>
@@ -1430,7 +1480,7 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{rows.map((r: ReadyToLoadRow) => {
{controls.pagedRows.map((r: ReadyToLoadRow) => {
const selectable = r.inspectionStatus !== 'PASSED';
return (
<Fragment key={r.id}>
@@ -1493,6 +1543,14 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged
</Table.Tbody>
</Table>
</Table.ScrollContainer>
<RuleEngineListFooter
pagination={controls.pagination}
pageCount={controls.pageCount}
totalCount={controls.totalCount}
itemLabel="items"
onPaginationChange={controls.setPagination}
/>
</Stack>
)}
<InspectionReportModal
@@ -1517,9 +1575,12 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?:
const [targetScheduleId, setTargetScheduleId] = useState<string | null>(null);
const [selected, setSelected] = useState<Set<string>>(new Set());
const allSelected = rows.length > 0 && selected.size === rows.length;
const controls = useListControls(rows, {
searchKeys: ['bookingReference', 'customerName', 'containerNumber', 'cargoType', 'grnNumber', 'origin', 'destination'],
});
const allSelected = controls.filteredRows.length > 0 && selected.size === controls.filteredRows.length;
const someSelected = selected.size > 0 && !allSelected;
const toggleAll = () => setSelected(allSelected ? new Set() : new Set(rows.map((r) => r.id)));
const toggleAll = () => setSelected(allSelected ? new Set() : new Set(controls.filteredRows.map((r) => r.id)));
const toggleOne = (id: string) =>
setSelected((prev) => {
const next = new Set(prev);
@@ -1589,9 +1650,9 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?:
<Group justify="space-between">
<Text size="sm" c="dimmed">
{selected.size > 0 ? (
<><b>{selected.size}</b> of {rows.length} selected</>
<><b>{selected.size}</b> of {controls.filteredRows.length} selected</>
) : (
<><b>{rows.length}</b> item{rows.length !== 1 ? 's' : ''} ready to load</>
<><b>{controls.filteredRows.length}</b> item{controls.filteredRows.length !== 1 ? 's' : ''} ready to load</>
)}
</Text>
<Button
@@ -1660,6 +1721,19 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?:
No EXPORT items with inspection PASSED waiting to be loaded.
</Text>
) : (
<Stack gap="sm">
<ListControls
search={controls.search}
onSearchChange={controls.setSearch}
searchPlaceholder="Booking, GRN, customer, container, cargo, route…"
dateFrom={controls.dateFrom}
onDateFromChange={controls.setDateFrom}
dateTo={controls.dateTo}
onDateToChange={controls.setDateTo}
hasFilters={controls.hasFilters}
onReset={controls.reset}
showDateRange={false}
/>
<Table.ScrollContainer minWidth={1200}>
<Table highlightOnHover verticalSpacing="xs" striped>
<Table.Thead>
@@ -1685,7 +1759,7 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?:
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{rows.map((r: ReadyToLoadRow) => (
{controls.pagedRows.map((r: ReadyToLoadRow) => (
<Fragment key={r.id}>
<Table.Tr>
<Table.Td>
@@ -1739,6 +1813,14 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?:
</Table.Tbody>
</Table>
</Table.ScrollContainer>
<RuleEngineListFooter
pagination={controls.pagination}
pageCount={controls.pageCount}
totalCount={controls.totalCount}
itemLabel="items"
onPaginationChange={controls.setPagination}
/>
</Stack>
)}
</Stack>
);
@@ -1768,9 +1850,12 @@ function LoadedExportTab({
);
const [selected, setSelected] = useState<Set<string>>(new Set());
const allSelected = rows.length > 0 && selected.size === rows.length;
const controls = useListControls(rows, {
searchKeys: ['bookingReference', 'customerName', 'containerNumber', 'cargoType', 'grnNumber', 'origin', 'destination'],
});
const allSelected = controls.filteredRows.length > 0 && selected.size === controls.filteredRows.length;
const someSelected = selected.size > 0 && !allSelected;
const toggleAll = () => setSelected(allSelected ? new Set() : new Set(rows.map((r) => r.id)));
const toggleAll = () => setSelected(allSelected ? new Set() : new Set(controls.filteredRows.map((r) => r.id)));
const toggleOne = (id: string) =>
setSelected((prev) => {
const next = new Set(prev);
@@ -1802,11 +1887,11 @@ function LoadedExportTab({
<Text size="sm" c="dimmed">
{dispatchable ? (
<>
Selected: <b>{selected.size}</b> / {rows.length} loaded
Selected: <b>{selected.size}</b> / {controls.filteredRows.length} loaded
</>
) : (
<>
<b>{rows.length}</b> item{rows.length !== 1 ? 's' : ''} loaded
<b>{controls.filteredRows.length}</b> item{controls.filteredRows.length !== 1 ? 's' : ''} loaded
</>
)}
</Text>
@@ -1858,6 +1943,19 @@ function LoadedExportTab({
No LOADED export items {dispatchable ? 'waiting to dispatch' : 'yet'}.
</Text>
) : (
<Stack gap="sm">
<ListControls
search={controls.search}
onSearchChange={controls.setSearch}
searchPlaceholder="Booking, GRN, customer, container, cargo, route…"
dateFrom={controls.dateFrom}
onDateFromChange={controls.setDateFrom}
dateTo={controls.dateTo}
onDateToChange={controls.setDateTo}
hasFilters={controls.hasFilters}
onReset={controls.reset}
showDateRange={false}
/>
<Table.ScrollContainer minWidth={1200}>
<Table highlightOnHover verticalSpacing="xs" striped>
<Table.Thead>
@@ -1884,7 +1982,7 @@ function LoadedExportTab({
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{rows.map((r: ReadyToLoadRow) => (
{controls.pagedRows.map((r: ReadyToLoadRow) => (
<Fragment key={r.id}>
<Table.Tr>
{dispatchable && (
@@ -1935,6 +2033,14 @@ function LoadedExportTab({
</Table.Tbody>
</Table>
</Table.ScrollContainer>
<RuleEngineListFooter
pagination={controls.pagination}
pageCount={controls.pageCount}
totalCount={controls.totalCount}
itemLabel="items"
onPaginationChange={controls.setPagination}
/>
</Stack>
)}
<ConfirmActionModal action={confirmAction} onClose={() => setConfirmAction(null)} />
</Stack>
@@ -2218,6 +2324,11 @@ export function ImportArriveQueueTab({
>({});
const [readyBySchedule, setReadyBySchedule] = useState<Record<string, boolean>>({});
const controls = useListControls(trains, {
searchKeys: ['trainNumber', 'route', 'origin', 'destination', 'status'],
dateKey: 'arrivalTime',
});
const autoUnload = async (train: ImportTrain) => {
const assignments = Object.entries(assignmentsBySchedule[train.scheduleId] ?? {})
.filter((entry): entry is [string, Required<ImportUnloadAssignmentDraft>] =>
@@ -2272,10 +2383,6 @@ export function ImportArriveQueueTab({
return (
<Stack gap="sm" mt="sm">
<Text size="sm" c="dimmed">
<b>{trains.length}</b> arrived import train{trains.length !== 1 ? 's' : ''}
</Text>
{isLoading ? (
<Group justify="center" py="lg">
<Loader size="sm" />
@@ -2285,6 +2392,25 @@ export function ImportArriveQueueTab({
No arrived import trains. Trains appear here once their schedule status is ARRIVED.
</Text>
) : (
<Stack gap="sm">
<Group justify="space-between" align="flex-end" wrap="wrap">
<Text size="sm" c="dimmed">
<b>{controls.totalCount}</b> arrived import train{controls.totalCount !== 1 ? 's' : ''}
</Text>
<ListControls
search={controls.search}
onSearchChange={controls.setSearch}
searchPlaceholder="Train #, route, origin, destination…"
dateFrom={controls.dateFrom}
onDateFromChange={controls.setDateFrom}
dateTo={controls.dateTo}
onDateToChange={controls.setDateTo}
dateLabel="Arrival"
hasFilters={controls.hasFilters}
onReset={controls.reset}
/>
</Group>
<Table.ScrollContainer minWidth={1500}>
<Table highlightOnHover verticalSpacing="xs" striped>
<Table.Thead>
@@ -2303,7 +2429,16 @@ export function ImportArriveQueueTab({
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{trains.map((t: ImportTrain) => {
{controls.pagedRows.length === 0 ? (
<Table.Tr>
<Table.Td colSpan={11}>
<Text c="dimmed" ta="center" py="lg" size="sm">
No trains match the current filters.
</Text>
</Table.Td>
</Table.Tr>
) : (
controls.pagedRows.map((t: ImportTrain) => {
const isOpen = openId === t.scheduleId;
const fullyUnloaded = isFullyUnloaded(t);
const unloadedBookings = t.unloadedBookings ?? t.totalBookings - getPendingUnloadBookings(t);
@@ -2311,7 +2446,18 @@ export function ImportArriveQueueTab({
<Fragment key={t.scheduleId}>
<Table.Tr>
<Table.Td>
<Text size="xs" c="dimmed">{t.scheduleId.slice(0, 8)}</Text>
<Group gap={4} wrap="nowrap">
<Text size="xs" c="dimmed">{t.scheduleId.slice(0, 8)}</Text>
<CopyButton value={t.scheduleId}>
{({ copied, copy }) => (
<Tooltip label={copied ? 'Copied' : 'Copy schedule ID'} withArrow>
<ActionIcon size="xs" variant="subtle" color={copied ? 'teal' : 'gray'} onClick={copy}>
{copied ? <CheckCheck size={12} /> : <Copy size={12} />}
</ActionIcon>
</Tooltip>
)}
</CopyButton>
</Group>
</Table.Td>
<Table.Td>
<Text size="sm" fw={600}>{t.trainNumber ?? '—'}</Text>
@@ -2390,10 +2536,19 @@ export function ImportArriveQueueTab({
)}
</Fragment>
);
})}
}))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
<RuleEngineListFooter
pagination={controls.pagination}
pageCount={controls.pageCount}
totalCount={controls.totalCount}
itemLabel="trains"
onPaginationChange={controls.setPagination}
/>
</Stack>
)}
<ConfirmActionModal action={confirmAction} onClose={() => setConfirmAction(null)} />
</Stack>
@@ -2872,6 +3027,41 @@ interface WarehouseFlowWorkbenchProps {
focusedBookingLabel?: string;
}
function WarehouseStatCard({
icon,
label,
value,
sub,
color,
}: {
icon: React.ReactNode;
label: string;
value: React.ReactNode;
sub: string;
color: string;
}) {
return (
<Card withBorder radius="md" padding="md">
<Group gap="sm" wrap="nowrap">
<ThemeIcon color={color} variant="light" size={40} radius="md">
{icon}
</ThemeIcon>
<Stack gap={0} style={{ minWidth: 0 }}>
<Text fw={800} fz={22} lh={1.1}>
{value}
</Text>
<Text size="sm" fw={600}>
{label}
</Text>
<Text size="xs" c="dimmed">
{sub}
</Text>
</Stack>
</Group>
</Card>
);
}
function WarehouseQueueTabs<TValue extends string>({
value,
onChange,
@@ -2939,6 +3129,7 @@ function LocateBookingTab({ enabled }: { enabled: boolean }) {
applied.status,
);
const { data: results = [], isFetching } = useInventoryInquiry(applied, enabled && hasSearch);
const controls = useListControls(results);
const normalizeDraft = (): InventoryInquiryFilter => ({
bookingReference: draft.bookingReference?.trim() || undefined,
@@ -3022,7 +3213,16 @@ function LocateBookingTab({ enabled }: { enabled: boolean }) {
No inventory found for the current filters.
</Text>
) : (
<WarehouseInquiryTable results={results} onView={setViewResult} />
<Stack gap="sm">
<WarehouseInquiryTable results={controls.pagedRows} onView={setViewResult} />
<RuleEngineListFooter
pagination={controls.pagination}
pageCount={controls.pageCount}
totalCount={controls.totalCount}
itemLabel="results"
onPaginationChange={controls.setPagination}
/>
</Stack>
)}
<InventoryInquiryDetailModal
@@ -3039,6 +3239,7 @@ function ImportWarehouseTabs({ enabled, onChanged }: { enabled: boolean; onChang
const { data: arriveRows = [] } = useQuery(api.warehouses.importArriveQueue.queryOptions({ enabled }));
const { data: unloadedRows = [] } = useQuery(api.warehouses.importUnloadedQueue.queryOptions({ enabled }));
const { data: dispatchRows = [] } = useQuery(api.warehouses.importPickupReadyQueue.queryOptions({ enabled }));
const totalBookings = arriveRows.reduce((sum, t) => sum + t.totalBookings, 0);
const tabs: WarehouseQueueTab<ImportWarehouseTab>[] = [
{
value: 'arrive-queue',
@@ -3067,6 +3268,13 @@ function ImportWarehouseTabs({ enabled, onChanged }: { enabled: boolean; onChang
return (
<Stack gap="md">
<SimpleGrid cols={{ base: 1, xs: 2, md: 4 }} spacing="sm">
<WarehouseStatCard icon={<PackageOpen size={18} />} label="Arrived" value={arriveRows.length} sub="Import trains" color="edr-green" />
<WarehouseStatCard icon={<ClipboardCheck size={18} />} label="Unloaded" value={unloadedRows.length} sub="Import trains" color="blue" />
<WarehouseStatCard icon={<Send size={18} />} label="Dispatch Ready" value={dispatchRows.length} sub="Import trains" color="violet" />
<WarehouseStatCard icon={<Calendar size={18} />} label="Total Bookings" value={totalBookings} sub="Across arrived trains" color="orange" />
</SimpleGrid>
<WarehouseQueueTabs value={activeTab} onChange={setActiveTab} tabs={tabs} />
{activeTab === 'arrive-queue' && (
@@ -3152,6 +3360,14 @@ function ExportWarehouseTabs({
return (
<Stack gap="md">
<SimpleGrid cols={{ base: 1, xs: 2, md: 5 }} spacing="sm">
<WarehouseStatCard icon={<Truck size={18} />} label="Eligible" value={exportEligibleCount} sub="Export bookings" color="edr-green" />
<WarehouseStatCard icon={<ClipboardCheck size={18} />} label="Received" value={receivedRows.length} sub="Export bookings" color="blue" />
<WarehouseStatCard icon={<Train size={18} />} label="Ready To Load" value={readyRows.length} sub="Export bookings" color="teal" />
<WarehouseStatCard icon={<PackageCheck size={18} />} label="Loaded" value={loadedRows.length} sub="Export bookings" color="indigo" />
<WarehouseStatCard icon={<Send size={18} />} label="Dispatch Ready" value={loadedRows.length} sub="Export bookings" color="violet" />
</SimpleGrid>
<WarehouseQueueTabs value={activeTab} onChange={setActiveTab} tabs={tabs} />
{activeTab === 'receive-queue' && (

View File

@@ -61,6 +61,10 @@ export const URL_CONSTANTS = {
BASE: "/operations-standards",
},
MANUAL_PAYMENT_SETTINGS: {
BASE: "/payment-settings/manual",
},
AUDIT_LOGS: {
BASE: "/audit",
},
@@ -197,6 +201,17 @@ export const URL_CONSTANTS = {
BY_ID: (id: string) => `/bookings/${id}`,
QUEUE: (queue: string) => `/bookings/queues/${queue}`,
STAFF_ACCEPT: (id: string) => `/bookings/${id}/staff/accept`,
// Consolidated pair: one staff decision applied to both halves at once.
PAIRED_DECISION: (id: string) => `/bookings/${id}/paired-decision`,
// Shared-wagon approval gate: a consolidated pair waits for a human
// decision before either half reaches Operations.
CONSOLIDATION_APPROVAL_QUEUE: "/bookings/consolidation-approvals/queue",
CONSOLIDATION_APPROVAL_HISTORY: (id: string) =>
`/bookings/${id}/consolidation-approvals`,
CONSOLIDATION_APPROVE: (approvalId: string) =>
`/bookings/consolidation-approvals/${approvalId}/approve`,
CONSOLIDATION_REJECT: (approvalId: string) =>
`/bookings/consolidation-approvals/${approvalId}/reject`,
STAFF_REQUEST_CHANGES: (id: string) =>
`/bookings/${id}/staff/request-changes`,
STAFF_REJECT: (id: string) => `/bookings/${id}/staff/reject`,
@@ -323,6 +338,12 @@ export const URL_CONSTANTS = {
AWAITING_SHIPMENT: "/contracts/awaiting-shipment",
BOOKINGS_COMPLETE: (id: string, bookingId: string) =>
`/contracts/${id}/bookings/${bookingId}/complete`,
// Odd-20ft shared-wagon consolidation (customs/Path B): candidates GL may
// link, and the all-or-nothing completion of both halves together.
CONSOLIDATION_CANDIDATES: (id: string, bookingId: string) =>
`/contracts/${id}/bookings/${bookingId}/consolidation-candidates`,
BOOKINGS_COMPLETE_CONSOLIDATED: (id: string, bookingId: string) =>
`/contracts/${id}/bookings/${bookingId}/complete-consolidated`,
VALIDATE_SHIPMENT: (id: string) => `/contracts/${id}/validate-shipment`,
CAPACITY: (id: string) => `/contracts/${id}/capacity`,
// Shipment requests (GENERAL + customs, Path B): customer → GL queue → booking.

View File

@@ -59,6 +59,9 @@ export type BookingActionContext = Pick<
| "reference"
| "schedulingStatus"
| "customsClearingEnabled"
// Set when this booking shares a wagon: the pairable staff decisions then
// apply to both halves at once rather than to this booking alone.
| "consolidationPartnerId"
>;
const ALLOCATABLE_SCHEDULING_STATUSES = new Set([

View File

@@ -102,6 +102,11 @@ export const BOOKING_STATUS_STYLES: Record<string, StatusStyle> = {
label: "Operation Changes",
color: "bg-orange-50 text-orange-700 border-orange-200",
},
// Shared-wagon gate: held for a human decision before reaching Operations.
CONSOLIDATION_APPROVAL_PENDING: {
label: "Wagon Approval",
color: "bg-amber-50 text-amber-700 border-amber-200",
},
OPERATION_PRICE_PENDING_CONFIRM: {
label: "Price Confirm",
color: "bg-amber-50 text-amber-700 border-amber-200",
@@ -309,6 +314,9 @@ export const BOOKING_LIST_TABS = [
"OPERATION_REQUEST_PENDING",
"OPERATION_CHANGES_REQUESTED",
"OPERATION_PRICE_PENDING_CONFIRM",
// Held at the shared-wagon gate — still an ops-review-stage booking, it
// just needs the pairing signed off before Operations can act on it.
"CONSOLIDATION_APPROVAL_PENDING",
],
},
{

View File

@@ -0,0 +1,13 @@
import { api } from "@/auth/http";
/**
* Internal chat (Matrix/Element) REST calls. Just the one endpoint — Chat
* itself is a separate app (chat.edr.et); this backoffice only ever asks for
* a fresh sign-in link into it.
*/
export const chatApi = {
getSsoUrl: async (): Promise<string> => {
const { data } = await api.get<{ url: string }>("/chat/sso");
return data.url;
},
};

View File

@@ -121,7 +121,38 @@ export function useBookingMutations(bookingId: string) {
onError: (error) => toast.error(parseApiError(error, "Failed to cancel booking")),
});
/**
* One staff decision applied to both halves of a consolidated pair. Both
* bookings are invalidated on success so whichever tab is open reflects the
* new state immediately.
*/
const pairedDecision = useMutation({
mutationFn: (payload: {
decision: "accept" | "cancel" | "operationAccept" | "requestChanges";
reason?: string;
note?: string;
validityDays?: number;
}) => {
const { decision, ...options } = payload;
return bookingsService.pairedDecision(bookingId, decision, options);
},
onSuccess: (data) => {
toast.success("Applied to both bookings on the shared wagon");
void invalidateBookingDetail(qc, data.booking.id);
void invalidateBookingDetail(qc, data.partner.id);
},
onError: (error) => {
toast.error(
parseApiError(error, "Failed to apply the decision to both bookings"),
);
// Nothing should have committed (the server runs both halves in one
// transaction), but refetch so the UI never shows a stale guess.
void invalidateBookingDetail(qc, bookingId);
},
});
const isPending =
pairedDecision.isPending ||
staffAccept.isPending ||
requestChanges.isPending ||
staffReject.isPending ||
@@ -134,6 +165,7 @@ export function useBookingMutations(bookingId: string) {
cancel.isPending;
return {
pairedDecision,
staffAccept,
requestChanges,
staffReject,

View File

@@ -0,0 +1,39 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
manualPaymentSettingsService,
type ManualPaymentSettings,
} from "@/services/manualPaymentSettings.service";
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
export const MANUAL_PAYMENT_SETTINGS_KEY = ["manualPaymentSettings"];
export const useManualPaymentSettingsQuery = () =>
useQuery({
queryKey: MANUAL_PAYMENT_SETTINGS_KEY,
queryFn: () => manualPaymentSettingsService.get(),
staleTime: 60_000,
});
export const useUpdateManualPaymentSettings = () => {
const queryClient = useQueryClient();
const { t } = useTranslation();
const { handleError } = useErrorHandler(t);
return useMutation({
mutationFn: (
patch: Partial<Pick<ManualPaymentSettings, "etbEnabled" | "usdEnabled">>,
) => manualPaymentSettingsService.update(patch),
onSuccess: (data) => {
queryClient.setQueryData(MANUAL_PAYMENT_SETTINGS_KEY, data);
// The Manual Payments worklist only lists enabled currencies.
queryClient.invalidateQueries({ queryKey: ["invoices"] });
toast.success(
t("manualPaymentSettings.updated", "Manual payment settings updated"),
);
},
onError: handleError,
});
};

View File

@@ -33,6 +33,10 @@ export const FREIGHT_PERMS = {
staffUsers: {
view: "edr_freight_app:staff:users:view",
},
chat: {
view: "edr_freight_app:chat:view",
sync: "edr_freight_app:chat:sync",
},
bookings: {
view: "edr_freight_app:bookings:view",
create: "edr_freight_app:bookings:create",
@@ -57,6 +61,7 @@ export const FREIGHT_PERMS = {
wagonCancellationVoid: "edr_freight_app:bookings:wagon_cancellation_void",
wagonCancellationRebook:
"edr_freight_app:bookings:wagon_cancellation_rebook",
approveConsolidation: "edr_freight_app:bookings:approve_consolidation",
},
contracts: {
view: "edr_freight_app:contracts:view",
@@ -213,6 +218,8 @@ export const FREIGHT_PERMS = {
/** Train-builder detail Actions menu — each item its own grant. */
changeLocomotives: "edr_freight_app:trains:change_locomotives",
changeYard: "edr_freight_app:trains:change_yard",
/** Move ONE coupled wagon to another yard from the Wagon order list. */
changeWagonYard: "edr_freight_app:trains:change_wagon_yard",
toggleActive: "edr_freight_app:trains:toggle_active",
disband: "edr_freight_app:trains:disband",
},
@@ -378,6 +385,12 @@ export const FREIGHT_PERMS = {
view: "edr_freight_app:settings:operations_standards:view",
manage: "edr_freight_app:settings:operations_standards:manage",
},
// Whether Finance may settle invoices by hand, per currency. Finance holds
// `view` (the worklist offers only enabled currencies); `manage` is admin.
manualPayment: {
view: "edr_freight_app:settings:manual_payment:view",
manage: "edr_freight_app:settings:manual_payment:manage",
},
contractTemplates: {
view: "edr_freight_app:settings:contract_templates:view",
manage: "edr_freight_app:settings:contract_templates:manage",

View File

@@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import { queryClient } from "./queryClient";
/**
* The MutationCache seeds `meta.updates` entries into the cache and then skips
* exactly those keys when running `meta.invalidates`. Getting that skip wrong
* silently reintroduces the refetch it exists to avoid, so it is worth pinning.
*/
const runOnSuccess = (meta: Record<string, unknown>, data: unknown, variables: unknown) => {
const handler = (queryClient.getMutationCache() as unknown as {
config: {
onSuccess?: (
data: unknown,
variables: unknown,
context: unknown,
mutation: { meta?: Record<string, unknown> },
) => void;
};
}).config.onSuccess;
handler?.(data, variables, undefined, { meta });
};
describe("MutationCache updates/invalidates", () => {
it("writes the mutation response into the seeded key and leaves it fresh", () => {
const seededKey = ["train-builder", "composition", "t1"] as const;
const siblingKey = ["train-builder", "list", {}] as const;
queryClient.setQueryData(seededKey, { code: "STALE" });
queryClient.setQueryData(siblingKey, { items: [] });
const response = { code: "FRESH" };
runOnSuccess(
{
updates: (_v: unknown, d: unknown) => [[seededKey, d]],
invalidates: () => [["train-builder"]],
},
response,
{ id: "t1" },
);
// Seeded key holds the response, and was NOT invalidated back to stale.
expect(queryClient.getQueryData(seededKey)).toStrictEqual(response);
expect(queryClient.getQueryState(seededKey)?.isInvalidated).toBe(false);
// Its siblings under the same root still get invalidated.
expect(queryClient.getQueryState(siblingKey)?.isInvalidated).toBe(true);
});
it("invalidates everything when a mutation declares no updates", () => {
const key = ["train-builder", "composition", "t2"] as const;
queryClient.setQueryData(key, { code: "X" });
runOnSuccess({ invalidates: () => [["train-builder"]] }, undefined, undefined);
expect(queryClient.getQueryState(key)?.isInvalidated).toBe(true);
});
});

View File

@@ -1,6 +1,6 @@
import { MutationCache, QueryClient } from "@tanstack/react-query";
import type { InvalidatesMeta } from "@/utils/endpoint";
import type { InvalidatesMeta, UpdatesMeta } from "@/utils/endpoint";
/**
* Single app-wide React Query client (do not nest additional providers).
@@ -14,13 +14,37 @@ import type { InvalidatesMeta } from "@/utils/endpoint";
export const queryClient = new QueryClient({
mutationCache: new MutationCache({
onSuccess: (data, variables, _context, mutation) => {
// Seed first: endpoints that return the entity they just changed write it
// straight into its cache key, so the screen updates from the response
// instead of round-tripping for data it already holds.
const updates = mutation.meta?.updates as UpdatesMeta | undefined;
const seeded: readonly unknown[][] = [];
if (typeof updates === "function") {
for (const [queryKey, value] of updates(variables, data)) {
queryClient.setQueryData(queryKey, value);
seeded.push(queryKey as unknown[]);
}
}
const invalidates = mutation.meta?.invalidates as
| InvalidatesMeta
| undefined;
if (typeof invalidates !== "function") return;
for (const queryKey of invalidates(variables, data)) {
void queryClient.invalidateQueries({ queryKey });
void queryClient.invalidateQueries({
queryKey,
// A seeded key already holds the authoritative value from this very
// response — invalidating it would refetch it right back.
predicate: seeded.length
? (query) =>
!seeded.some(
(key) =>
key.length === query.queryKey.length &&
key.every((part, i) => Object.is(part, query.queryKey[i])),
)
: undefined,
});
}
},
// Mutation failures are surfaced globally by the axios interceptor in

View File

@@ -9,6 +9,7 @@ import {
FolderOpen,
Layers,
LayoutGrid,
Link2,
Milestone,
MoreHorizontal,
Package,
@@ -20,6 +21,7 @@ import {
} from "lucide-react";
import {
ActionIcon,
Alert,
Badge,
Box,
Button,
@@ -47,6 +49,7 @@ import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge
import { NextStepBanner } from "@/components/bookings/NextStepBanner";
import { SchedulingStatusBadge } from "@/components/trainScheduling/ScheduleStatusBadge";
import { ConsolidationWaitingBanner } from "@/components/bookings/detail/ConsolidationWaitingBanner";
import { ConsolidationApprovalCard } from "@/components/bookings/detail/ConsolidationApprovalCard";
import {
detailStyles,
BookingRouteServiceCard,
@@ -79,14 +82,50 @@ export default function BookingRequestDetailPage() {
const [searchParams, setSearchParams] = useSearchParams();
// Deep-link from a warehouse fee invoice → this booking's warehouse section.
useScrollToHash();
// Consolidated pair: `?booking=<partnerId>` swaps the WHOLE page over to the
// other half of the shared wagon. Everything below — KPIs, stepper, the
// overview/orders/documents/trucks sub-tabs, the action toolbar — then reads
// from the selected booking, so each half gets its own complete detail page
// under a top-level tab. The URL id stays put so Back still works.
const selectedId = searchParams.get("booking") || id;
const {
data: booking,
isLoading,
isError,
refetch,
isFetching,
} = useBookingDetail(id);
const mutations = useBookingMutations(id ?? "");
} = useBookingDetail(selectedId);
const mutations = useBookingMutations(selectedId ?? "");
// The pair is discovered from whichever half is on screen: each booking
// carries a reference to the other.
const routeBookingId = id ?? "";
const partnerId = booking?.consolidationPartnerId ?? null;
const isPaired = Boolean(partnerId);
const viewingPartner = selectedId !== routeBookingId;
// Tab identities: the booking named by the URL is always the first tab, the
// other half the second — regardless of which one is currently displayed.
const firstTabId = routeBookingId;
const secondTabId = viewingPartner ? selectedId : partnerId;
// Only for the tab label (reference + customer) — the displayed half is
// loaded above. Skipped entirely when the booking is not part of a pair.
const { data: otherBooking } = useBookingDetail(
secondTabId && secondTabId !== selectedId ? secondTabId : undefined,
);
const firstTabBooking = viewingPartner ? otherBooking : booking;
const secondTabBooking = viewingPartner ? booking : otherBooking;
const selectBooking = (bookingId: string) => {
const next = new URLSearchParams(searchParams);
if (bookingId === routeBookingId) next.delete("booking");
else next.set("booking", bookingId);
// Switching booking resets the sub-tab: the other half has its own content
// and may not even have the tab that was open (e.g. Orders).
next.delete("tab");
setSearchParams(next, { replace: true });
};
if (isLoading) {
return (
@@ -349,6 +388,48 @@ export default function BookingRequestDetailPage() {
/>
<Stack gap="lg">
{/* Consolidated pair: one tab per booking, switching the ENTIRE page
below. The overview/orders/documents/trucks tabs further down are
sub-tabs of whichever booking is selected here. */}
{isPaired && secondTabId ? (
<Tabs
value={selectedId ?? undefined}
onChange={(value) => value && selectBooking(value)}
variant="pills"
radius="md"
>
<Tabs.List>
<Tabs.Tab value={firstTabId} leftSection={<Link2 size={15} />}>
<Stack gap={0} align="flex-start">
<Text fz={13} fw={700}>
{firstTabBooking?.reference ?? "Booking"}
</Text>
<Text fz={11} c="dimmed">
{firstTabBooking?.company?.name ?? "—"}
</Text>
</Stack>
</Tabs.Tab>
<Tabs.Tab value={secondTabId} leftSection={<Link2 size={15} />}>
<Stack gap={0} align="flex-start">
<Text fz={13} fw={700}>
{secondTabBooking?.reference ?? "Partner booking"}
</Text>
<Text fz={11} c="dimmed">
{secondTabBooking?.company?.name ?? "—"}
</Text>
</Stack>
</Tabs.Tab>
</Tabs.List>
</Tabs>
) : null}
{isPaired ? (
<Text size="xs" c="dimmed">
These two bookings share one wagon. Accepting or cancelling applies
to both; each is invoiced and paid separately.
</Text>
) : null}
<KpiStrip items={kpis} />
{booking.holdExpiresAt && booking.schedulingStatus === "HOLDING" ? (
@@ -381,6 +462,21 @@ export default function BookingRequestDetailPage() {
<ConsolidationWaitingBanner bookingId={booking.id} />
)}
{booking.status === "CONSOLIDATION_APPROVAL_PENDING" && (
<Alert
color="yellow"
radius="md"
icon={<Link2 size={18} />}
title="Waiting for shared-wagon approval"
>
<Text size="sm">
This booking shares a wagon with another customer&apos;s booking.
Both are held here until the pairing is approved neither reaches
Operations before then.
</Text>
</Alert>
)}
<Grid gap="lg">
{/* LEFT — primary content, split into tabs to keep each view focused.
The Documents tab is always present, so the tab bar always renders. */}
@@ -455,6 +551,8 @@ export default function BookingRequestDetailPage() {
that train and its clock must be readable before the approve
button. */}
<BookingSchedulingWindowCard booking={booking} />
{/* Renders itself only when this booking has a shared wagon. */}
<ConsolidationApprovalCard bookingId={booking.id} />
<BookingActionsToolbar
booking={booking}
mutations={mutations}

View File

@@ -15,6 +15,7 @@ import {
CheckCircle2,
Clock,
LayoutList,
Link2,
Package,
Plus,
RefreshCw,
@@ -211,10 +212,28 @@ export default function BookingRequestsPage() {
// Search is applied server-side (via the `search` filter param) — no
// client-side filtering here.
const rows = useMemo(
() => (data?.items ?? []).map(toBookingListRow),
[data?.items],
);
const rows = useMemo(() => {
const mapped = (data?.items ?? []).map(toBookingListRow);
// Consolidated pairs share one wagon and are decided together, so they show
// as ONE row. Keep the half that appears first in the current sort and hang
// the other on it as `pairedWith`; the row renders both bookings' details
// and opens the detail page, where each half gets its own tab.
const byId = new Map(mapped.map((row) => [row.id, row]));
const absorbed = new Set<string>();
const merged: BookingListRow[] = [];
for (const row of mapped) {
if (absorbed.has(row.id)) continue;
const partnerId = row.consolidationPartnerId;
const partner = partnerId ? byId.get(partnerId) : undefined;
if (partner && !absorbed.has(partner.id)) {
absorbed.add(partner.id);
merged.push({ ...row, pairedWith: partner });
continue;
}
merged.push(row);
}
return merged;
}, [data?.items]);
const total = data?.total ?? 0;
const hasSearch = controls.searchText.trim().length > 0;
@@ -332,6 +351,22 @@ export default function BookingRequestsPage() {
</Badge>
) : null}
</p>
{/* Shared wagon: the second booking rides in the same row, so the
operator sees both customers before opening the pair. */}
{b.pairedWith ? (
<div className="mt-1.5 border-l-2 border-muted pl-2">
<div className="flex items-center gap-1.5">
<Link2 className="size-3 shrink-0 opacity-70" />
<p className="truncate text-xs font-medium text-foreground">
{b.pairedWith.reference}
</p>
</div>
<p className="mt-0.5 flex items-center gap-1 truncate text-xs text-muted-foreground">
<User className="size-3 shrink-0 opacity-70" />
{b.pairedWith.customerLabel}
</p>
</div>
) : null}
</div>
</div>
);

View File

@@ -0,0 +1,284 @@
import { useState } from "react";
import { Link } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Alert,
Badge,
Box,
Button,
Center,
Group,
Loader,
Modal,
Paper,
Stack,
Text,
Textarea,
ThemeIcon,
} from "@mantine/core";
import { AlertCircle, Check, Clock, Link2, X } from "lucide-react";
import toast from "react-hot-toast";
import { PageContainer, PageHeader } from "@/components/page";
import {
bookingsService,
type ConsolidationApprovalRow,
} from "@/services/bookings.service";
import { formatDateTime } from "@/lib/format";
import { extractErrorMessage } from "@/utils/errorExtractor";
const QUEUE_KEY = ["consolidation-approvals", "queue"];
/**
* Review queue for shared-wagon pairings.
*
* A booking that fills its own wagons goes straight to Operations. A
* consolidated one waits here: two customers' cargo rides one physical wagon
* under two separate invoices, so a person signs off on the pairing first.
* Approving releases BOTH bookings to Operations; rejecting sends BOTH back to
* GL with the reason.
*/
export default function ConsolidationApprovalsPage() {
const qc = useQueryClient();
const [decision, setDecision] = useState<{
row: ConsolidationApprovalRow;
kind: "approve" | "reject";
} | null>(null);
const [note, setNote] = useState("");
const {
data: rows,
isLoading,
isError,
} = useQuery({
queryKey: QUEUE_KEY,
queryFn: () => bookingsService.consolidationApprovalQueue(),
});
const close = () => {
setDecision(null);
setNote("");
};
const decide = useMutation({
mutationFn: () => {
if (!decision) throw new Error("No pairing selected");
return decision.kind === "approve"
? bookingsService.approveConsolidation(decision.row.id, note.trim() || undefined)
: bookingsService.rejectConsolidation(decision.row.id, note.trim());
},
onSuccess: () => {
toast.success(
decision?.kind === "approve"
? "Shared wagon approved — both bookings sent to Operations"
: "Shared wagon rejected — both bookings returned to GL",
);
void qc.invalidateQueries({ queryKey: QUEUE_KEY });
close();
},
onError: (error) =>
toast.error(extractErrorMessage(error, "Could not record the decision")),
});
// A rejection has to tell GL what to fix, so the reason is mandatory there.
const confirmDisabled =
decide.isPending || (decision?.kind === "reject" && !note.trim());
return (
<PageContainer>
<PageHeader
title="Shared wagon approvals"
subtitle="Two customers' cargo on one wagon — review the pairing before it reaches Operations."
/>
{isLoading ? (
<Center py={80}>
<Loader color="edr-green" />
</Center>
) : isError ? (
<Alert color="red" radius="md" icon={<AlertCircle size={16} />}>
Could not load the approval queue.
</Alert>
) : !rows?.length ? (
<Alert color="gray" radius="md" icon={<Check size={16} />}>
Nothing waiting for approval.
</Alert>
) : (
<Stack gap="md">
{rows.map((row) => (
<Paper
key={row.id}
withBorder
radius="lg"
p="lg"
style={{ borderColor: "#E6ECF2" }}
>
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
<Box style={{ minWidth: 0, flex: 1 }}>
<Group gap={8} align="center" mb={10}>
<ThemeIcon variant="light" color="blue" radius="md" size={30}>
<Link2 size={16} />
</ThemeIcon>
<Text fw={800} fz={15}>
Shared wagon
</Text>
<Badge color="yellow" variant="light" radius="sm">
Awaiting approval
</Badge>
</Group>
<Group gap="xl" wrap="wrap">
<BookingSide
id={row.bookingId}
reference={row.booking?.reference ?? row.bookingReference}
company={row.booking?.company?.name}
/>
<BookingSide
id={row.partnerBookingId}
reference={
row.partnerBooking?.reference ??
row.partnerBookingReference
}
company={row.partnerBooking?.company?.name}
/>
</Group>
<Group gap={6} mt={12} c="dimmed">
<Clock size={13} />
<Text fz={12}>
Requested {formatDateTime(row.requestedAt)}
{row.scheduledDate
? ` · ships ${formatDateTime(row.scheduledDate)}`
: ""}
</Text>
</Group>
</Box>
<Group gap="sm">
<Button
color="edr-green"
radius="md"
leftSection={<Check size={15} />}
onClick={() => {
setDecision({ row, kind: "approve" });
setNote("");
}}
>
Approve
</Button>
<Button
color="red"
variant="light"
radius="md"
leftSection={<X size={15} />}
onClick={() => {
setDecision({ row, kind: "reject" });
setNote("");
}}
>
Reject
</Button>
</Group>
</Group>
</Paper>
))}
</Stack>
)}
<Modal
opened={Boolean(decision)}
onClose={() => {
if (!decide.isPending) close();
}}
centered
radius="lg"
title={
<Text fw={800} fz={16}>
{decision?.kind === "approve"
? "Approve this shared wagon?"
: "Reject this shared wagon?"}
</Text>
}
>
<Stack gap="md">
<Text fz="sm" c="dimmed">
{decision?.kind === "approve"
? "Both bookings leave the gate together and continue to Operations. Each is still invoiced and paid separately."
: "Both bookings go back to GL as “changes requested” with your reason. Neither reaches Operations."}
</Text>
<Textarea
label={
decision?.kind === "approve"
? "Note (optional)"
: "Reason (required)"
}
description={
decision?.kind === "approve"
? "Recorded with the approval for the audit trail."
: "GL sees this on both bookings — say what has to change."
}
placeholder={
decision?.kind === "approve"
? "Anything worth recording…"
: "e.g. the partner's cargo weights are unbalanced for one wagon"
}
value={note}
onChange={(e) => setNote(e.currentTarget.value)}
autosize
minRows={3}
/>
<Group justify="flex-end" gap="sm">
<Button
variant="default"
radius="md"
onClick={close}
disabled={decide.isPending}
>
Cancel
</Button>
<Button
color={decision?.kind === "approve" ? "edr-green" : "red"}
radius="md"
loading={decide.isPending}
disabled={confirmDisabled}
onClick={() => decide.mutate()}
>
{decision?.kind === "approve" ? "Approve both" : "Reject both"}
</Button>
</Group>
</Stack>
</Modal>
</PageContainer>
);
}
/** One half of the wagon: its reference (linked) and whose cargo it is. */
function BookingSide({
id,
reference,
company,
}: {
id: string;
reference?: string | null;
company?: string | null;
}) {
return (
<Box style={{ minWidth: 0 }}>
<Text
component={Link}
to={`/dashboard/booking-requests/${id}`}
fz={14}
fw={700}
c="blue.7"
style={{ textDecoration: "none" }}
>
{reference ?? "—"}
</Text>
<Text fz={12.5} c="dimmed">
{company ?? "—"}
</Text>
</Box>
);
}

View File

@@ -105,8 +105,15 @@ export default function DocumentClearanceDetailPage() {
const queried = docs.filter((d) => d.reviewStatus === "QUERIED").length;
const pending = total - approved - queried;
const pct = total === 0 ? 0 : Math.round((approved / total) * 100);
return { total, approved, queried, pending, pct };
// Documents actually sitting with GL: a file is present but not approved.
// Excludes required slots the customer never filled — those are on the
// customer, not on GL.
const awaitingReview = docs.filter(
(d) => d.file && d.reviewStatus !== "APPROVED",
).length;
return { total, approved, queried, pending, pct, awaitingReview };
}, [clearance]);
const awaitingReview = stats.awaitingReview;
const reference = booking?.reference ?? "Clearance";
// Phased customs clearance runs on every contract booking now — ONE_TIME and
@@ -149,24 +156,12 @@ export default function DocumentClearanceDetailPage() {
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
)[0]?.note ?? null;
const docsPhaseComplete =
clearance?.milestones?.some(
(m) => m.milestoneCode === "DOCUMENTS_APPROVED" && m.status === "COMPLETED",
) ?? false;
// Querying a document is only possible while the booking is actually in
// review — the server enforces exactly that (reviewDocument asserts
// DOCUMENTS_UNDER_REVIEW), so once clearance is finalized the button could
// only ever produce a 400.
//
// `preClearanceFinalized` alone was not enough: it is a phased-customs field,
// so a non-customs booking (self-clearance, and every shipping-line booking)
// never sets it and kept offering Query after Operations had finalized.
const queriesLocked =
Boolean(
(clearance as Freight.ContractClearanceView | undefined)
?.preClearanceFinalized,
) ||
(booking?.status != null && booking.status !== "DOCUMENTS_UNDER_REVIEW");
// Documents stay reviewable for as long as the customer can still submit
// them — until the shipment is paid, not merely until clearance is
// finalized. `documentsOpen` is the server's own predicate (the same one
// both the upload and review endpoints gate on), so the buttons are shown
// exactly when the API would accept them.
const documentsClosed = clearance?.documentsOpen === false;
const workflowFiles =
(clearance as Freight.ContractClearanceView | undefined)?.workflowFiles ?? [];
@@ -231,7 +226,19 @@ export default function DocumentClearanceDetailPage() {
Customs
</Badge>
) : null}
{clearance.allApproved ? (
{/* Waiting on GL: an uploaded document with no decision yet, or
one under query. `allApproved` only covers the REQUIRED set,
so an ad-hoc file added after clearance never moves it. */}
{awaitingReview > 0 ? (
<Badge
variant="filled"
color="orange"
radius="sm"
leftSection={<Clock size={13} />}
>
{awaitingReview} needs approval
</Badge>
) : clearance.allApproved ? (
<Badge
variant="light"
color="edr-green"
@@ -346,8 +353,8 @@ export default function DocumentClearanceDetailPage() {
<ClearanceReviewSection
bookingId={id!}
hideSummary
approvalsLocked={isPhasedGeneral && docsPhaseComplete}
queriesLocked={queriesLocked}
approvalsLocked={documentsClosed}
queriesLocked={documentsClosed}
phasedCustoms={isPhasedGeneral}
onChanged={() => void refetch()}
/>

View File

@@ -0,0 +1,78 @@
import { Alert, Button, Card, Center, Stack, Text } from "@mantine/core";
import { MessageSquare, TriangleAlert } from "lucide-react";
import { useState } from "react";
import { PageContainer, PageHeader } from "@/components/page";
import { chatApi } from "@/features/chat/chatApi";
/**
* Chat itself lives at chat.edr.et (Element), not in this app — this page's
* only job is a one-click sign-in link into it. No iframe: Element's own CSP
* refuses to be framed.
*
* The link is minted per click, never on mount and never cached: Synapse's
* login_token is single-use and expires in 5 minutes, and Element reports a
* spent one as "Incorrect username and/or password". A held-onto url is
* therefore wrong on the second click, on a remount served from cache, and on
* any click more than 5 minutes after the page loaded.
*/
export default function ChatLaunchPage() {
const [state, setState] = useState<"idle" | "loading" | "error">("idle");
const open = async () => {
// Opened before the await so it still counts as the user's click — a
// window.open() after it is treated as a popup and blocked.
//
// No "noopener" in the features: passing it makes window.open return null,
// which would leave this blank tab orphaned and send Element into the
// current tab instead. Clearing .opener on the handle does the same job.
const tab = window.open("", "_blank");
if (tab) tab.opener = null;
setState("loading");
try {
const url = await chatApi.getSsoUrl();
if (tab) tab.location.replace(url);
else window.location.assign(url); // popup blocked — go in this tab
setState("idle");
} catch {
tab?.close();
setState("error");
}
};
return (
<PageContainer>
<PageHeader title="Chat" subtitle="Internal messaging for EDR staff" />
<Card withBorder radius="md" p="xl">
<Center>
<Stack align="center" gap="md" py="xl">
{state === "error" && (
<Alert
icon={<TriangleAlert size={18} />}
color="red"
title="Couldn't get a sign-in link"
variant="light"
>
Something went wrong reaching chat. Try again.
</Alert>
)}
<Stack align="center" gap="sm">
<MessageSquare size={40} strokeWidth={1.5} />
<Text c="dimmed" ta="center" maw={360}>
Opens EDR Chat in a new tab, already signed in as you.
</Text>
<Button
onClick={open}
loading={state === "loading"}
leftSection={<MessageSquare size={16} />}
>
Open EDR Chat
</Button>
</Stack>
</Stack>
</Center>
</Card>
</PageContainer>
);
}

View File

@@ -164,6 +164,8 @@ export default function ContractClearanceListPage() {
tradeDirection: b.tradeDirection ?? "—",
freightType: b.freightType ?? "—",
status: b.status,
allDocsApproved: Boolean(b.allDocsApproved),
hasDocumentsAwaitingReview: Boolean(b.hasDocumentsAwaitingReview),
requested: requestedByBooking.get(b.id) ?? null,
contractId: b.contractId ?? null,
contractReference: b.contractReference ?? null,
@@ -193,8 +195,13 @@ export default function ContractClearanceListPage() {
const counts = useMemo(
() => ({
all: allRows.length,
// Counts anything actually waiting on GL, including a document added
// after clearance was finalized (the status stays CLEARANCE_READY).
review: allRows.filter(
(r) => r.status === "AWAITING_DOCUMENTS" || r.status === "DOCUMENTS_UNDER_REVIEW",
(r) =>
r.status === "AWAITING_DOCUMENTS" ||
r.status === "DOCUMENTS_UNDER_REVIEW" ||
r.hasDocumentsAwaitingReview,
).length,
ready: allRows.filter((r) => r.status === "CLEARANCE_READY" || r.bookingCreated)
.length,
@@ -344,6 +351,10 @@ interface ShipmentBookingRow {
tradeDirection: string;
freightType: string;
status: string;
/** Every required document approved, even before clearance is finalized. */
allDocsApproved: boolean;
/** A customer document is waiting on GL — including one added post-clearance. */
hasDocumentsAwaitingReview: boolean;
/** Requested quantities from the originating shipment request. */
requested: Freight.RequestedShipmentLines | null;
/** Contract this shipment booking was created under. */
@@ -518,13 +529,29 @@ function ShipmentBookingsTable({
header: () => <span className={bookingTable.headerCell}>Status</span>,
cell: ({ row }) => (
<Group gap={6} wrap="nowrap">
<Badge
variant="light"
color={shipmentStatusColor(row.original.status)}
radius="sm"
>
{prettyStatus(row.original.status)}
</Badge>
{/* A document is waiting on GL. This outranks the booking status:
a file added after clearance was finalized leaves the status at
CLEARANCE_READY, and the row must still call for the review. */}
{row.original.hasDocumentsAwaitingReview ? (
<Badge variant="filled" color="orange" radius="sm">
Needs approval
</Badge>
) : /* All docs approved but not yet finalized: the booking status is
still DOCUMENTS_UNDER_REVIEW — show the real review state. */
row.original.status === "DOCUMENTS_UNDER_REVIEW" &&
row.original.allDocsApproved ? (
<Badge variant="light" color="edr-green" radius="sm">
Documents approved
</Badge>
) : (
<Badge
variant="light"
color={shipmentStatusColor(row.original.status)}
radius="sm"
>
{prettyStatus(row.original.status)}
</Badge>
)}
{row.original.bookingCreated ? (
<Tooltip label="Booking created by GL Ethiopia" withArrow>
<Badge

View File

@@ -20,6 +20,8 @@ import {
AlertTriangle,
ClipboardList,
FileText,
History,
Receipt,
Share2,
Upload,
} from "lucide-react";
@@ -36,6 +38,8 @@ import { BookingCompanyCard } from "@/components/bookings/detail/BookingCompanyC
import { ContractClearanceReviewSection } from "@/components/contracts/ContractClearanceReviewSection";
import { ClearanceWorkflowFilesPanel } from "@/components/contracts/ClearanceWorkflowFilesPanel";
import { GlExchangePanel } from "@/components/contracts/GlExchangePanel";
import { ClearanceChargesTab } from "@/components/contracts/ClearanceChargesTab";
import { ClearanceHistoryTab } from "@/components/contracts/ClearanceHistoryTab";
import {
GlClearanceUploadModal,
type GlClearanceUploadKind,
@@ -247,6 +251,16 @@ export default function GlClearanceDetailPage() {
<Tabs.Tab value="exchange" leftSection={<Share2 size={14} />}>
Document exchange
</Tabs.Tab>
{data.kind === "booking" ? (
<Tabs.Tab value="charges" leftSection={<Receipt size={14} />}>
Customer charges
</Tabs.Tab>
) : null}
{data.kind === "booking" ? (
<Tabs.Tab value="history" leftSection={<History size={14} />}>
History
</Tabs.Tab>
) : null}
{incidentBookingId ? (
<Tabs.Tab value="incidents" leftSection={<AlertTriangle size={14} />}>
Incidents
@@ -369,6 +383,18 @@ export default function GlClearanceDetailPage() {
<GlExchangePanel entityId={id!} />
</Tabs.Panel>
{data.kind === "booking" ? (
<Tabs.Panel value="charges">
<ClearanceChargesTab bookingId={id!} roleMode="DJ" onViewFile={view} />
</Tabs.Panel>
) : null}
{data.kind === "booking" ? (
<Tabs.Panel value="history">
<ClearanceHistoryTab bookingId={id!} />
</Tabs.Panel>
) : null}
{incidentBookingId ? (
<Tabs.Panel value="incidents">
<SectionCard icon={AlertTriangle} title="Incident reports" accent="edr-green">

View File

@@ -50,6 +50,7 @@ import {
CompanyTimeline,
CompanyTypeBadge,
InvoiceStatusBadge,
ManualRegistrationBadge,
PaymentStatusBadge,
PersonCard,
ProfileApprovalActions,
@@ -744,6 +745,10 @@ export default function CustomerDetailPage() {
) : (
<CompanyStatusBadge status={company.status} />
)}
<ManualRegistrationBadge
cooperative={company.cooperative}
investorLicence={company.investorLicence}
/>
<ChangeRequestPendingBadge companyId={company.id} />
</Group>
}
@@ -792,6 +797,25 @@ export default function CustomerDetailPage() {
</Alert>
)}
{/* Nothing below came from eTrade for these customers. A
co-operative holds no trade licence at all; a foreign investor's
comes from the Investment Commission, not the trade registry.
Either way every registration field was typed, and the reviewer
is the only check there is. */}
{(company.cooperative || company.investorLicence) && (
<Alert
color="orange"
variant="light"
radius="md"
icon={<AlertTriangle size={18} />}
title="Registration entered by hand — not verified against eTrade"
>
{company.cooperative
? "This company onboarded as a co-operative union or farm, which holds no trade licence, so eTrade had no record to look its TIN up in. The company name, registration and address below are the customer's own statement. Check them against the Co-operative Registration Certificate on the Documents tab before approving."
: "This company onboarded on a foreign investment licence, so we could not look its TIN up on eTrade. The company name, registration and address below are the customer's own statement. Check them against the Investment Licence on the Documents tab before approving."}
</Alert>
)}
<ChangeRequestReview company={company} />
<KpiStrip
@@ -861,7 +885,9 @@ export default function CustomerDetailPage() {
value={
company.cooperative
? "Co-operative union / farm (no trade licence)"
: "eTrade trade licence"
: company.investorLicence
? "Foreign investment licence — typed by the customer, not from eTrade"
: "eTrade trade licence"
}
/>
<InfoField label="Address" value={company.address} />

View File

@@ -28,6 +28,7 @@ import { useNavigate } from "react-router-dom";
import {
CompanyNationalityBadge,
CompanyStatusBadge,
ManualRegistrationBadge,
ProfileChips,
formatDate,
} from "@/components/customers";
@@ -143,6 +144,10 @@ export default function CustomersPage() {
{c.name}
</Text>
<CompanyNationalityBadge nationality={c.nationality} />
<ManualRegistrationBadge
cooperative={c.cooperative}
investorLicence={c.investorLicence}
/>
</Group>
<Text size="xs" c="dimmed">
TIN {c.tin}

View File

@@ -1,8 +1,9 @@
import { Tabs } from "@mantine/core";
import { Landmark, Receipt } from "lucide-react";
import { Banknote, DollarSign, Receipt } from "lucide-react";
import { useSearchParams } from "react-router-dom";
import { useAuth } from "@/auth/useAuth";
import { useManualPaymentSettingsQuery } from "@/hooks/useManualPaymentSettings";
import { PageContainer, PageHeader } from "@/components/page";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
@@ -16,7 +17,9 @@ import UsdPaymentsPanel from "./UsdPaymentsPage";
* before, and just doesn't render if the user lacks it.
*
* The Payments tab was removed; its summary (total collected, ETB/USD) now
* lives as a card at the top of the Invoices tab instead.
* lives as a card at the top of the Invoices tab instead. Manual payments are
* split into one tab per currency — ETB keeps the original `?tab=manual-payments`
* key so existing links and the old redirect still land somewhere valid.
*/
const TABS = [
{
@@ -30,13 +33,25 @@ const TABS = [
},
{
key: "manual-payments",
label: "Manual Payments",
icon: Landmark,
label: "Manual Payments (ETB)",
icon: Banknote,
// Same gate as Invoices, not a dedicated key — mirrors the old route.
permission: FREIGHT_PERMS.invoices.view,
/** Hidden unless manual settlement is switched on for this currency. */
manualCurrency: "ETB",
subtitle:
"Import and export invoices in USD or ETB that Finance settles by hand (bank transfer or counter). Upload the customer's slip and confirm the payment before the pay window closes.",
Panel: UsdPaymentsPanel,
"Import and export invoices in ETB that Finance settles by hand (bank transfer or counter). Upload the customer's slip and confirm the payment before the pay window closes.",
Panel: () => <UsdPaymentsPanel currency="ETB" />,
},
{
key: "manual-payments-usd",
label: "Manual Payments (USD)",
icon: DollarSign,
permission: FREIGHT_PERMS.invoices.view,
manualCurrency: "USD",
subtitle:
"Import and export invoices in USD that Finance settles by hand (bank transfer or counter). Upload the customer's slip and confirm the payment before the pay window closes.",
Panel: () => <UsdPaymentsPanel currency="USD" />,
},
] as const;
@@ -46,7 +61,18 @@ export default function FinanceHubPage() {
const { user } = useAuth();
const [searchParams, setSearchParams] = useSearchParams();
const visibleTabs = TABS.filter((tab) => hasPermission(user, tab.permission));
// A currency whose manual-payment channel is switched off has no tab at all
// — the list would be empty and every confirmation refused.
const { data: manualSettings } = useManualPaymentSettingsQuery();
const manualEnabled = (currency: "ETB" | "USD") =>
!manualSettings ||
(currency === "ETB" ? manualSettings.etbEnabled : manualSettings.usdEnabled);
const visibleTabs = TABS.filter(
(tab) =>
hasPermission(user, tab.permission) &&
(!("manualCurrency" in tab) || manualEnabled(tab.manualCurrency)),
);
const requested = searchParams.get("tab");
const active: TabKey =
visibleTabs.find((tab) => tab.key === requested)?.key ??

View File

@@ -27,6 +27,7 @@ import {
} from "@/components/customers";
import { PhasedFileDropzone } from "@/components/contracts/PhasedFileDropzone";
import { useAuth } from "@/auth/useAuth";
import { useManualPaymentSettingsQuery } from "@/hooks/useManualPaymentSettings";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { api } from "@/services/api";
import type { OfflineUsdInvoice } from "@/types/invoice";
@@ -144,7 +145,11 @@ function ConfirmCell({
* Finance settles by hand; confirming records the payment the same way an
* online payment would, so the booking advances identically.
*/
export default function UsdPaymentsPanel() {
export default function UsdPaymentsPanel({
currency,
}: {
currency: "USD" | "ETB";
}) {
const navigate = useNavigate();
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [query, setQuery] = useState("");
@@ -152,7 +157,6 @@ export default function UsdPaymentsPanel() {
const [statusFilter, setStatusFilter] = useState<"" | Freight.InvoiceStatus>(
"",
);
const [currency, setCurrency] = useState<"" | "USD" | "ETB">("");
const [confirming, setConfirming] = useState<OfflineUsdInvoice | null>(null);
const [slip, setSlip] = useState<File | null>(null);
const [reference, setReference] = useState("");
@@ -163,13 +167,23 @@ export default function UsdPaymentsPanel() {
FREIGHT_PERMS.invoices.confirmOffline,
);
// Manual settlement is switched on per currency in Configuration → Manual
// payments. FinanceHubPage hides the tab for a disabled currency; this is
// the fallback for a direct `?tab=` link, and the API refuses regardless.
const { data: manualSettings } = useManualPaymentSettingsQuery();
const currencyEnabled = manualSettings
? currency === "ETB"
? manualSettings.etbEnabled
: manualSettings.usdEnabled
: true;
const filter = useMemo(
() => ({
page: pagination.pageIndex + 1,
pageSize: pagination.pageSize,
search: debouncedQuery,
status: statusFilter || undefined,
currency: currency || undefined,
currency,
}),
[
pagination.pageIndex,
@@ -180,9 +194,10 @@ export default function UsdPaymentsPanel() {
],
);
const { data, isLoading, isError, refetch, isFetching } = useQuery(
api.invoices.listOfflineUsd.queryOptions({ input: { filter } }),
);
const { data, isLoading, isError, refetch, isFetching } = useQuery({
...api.invoices.listOfflineUsd.queryOptions({ input: { filter } }),
enabled: currencyEnabled,
});
const confirm = useMutation(api.invoices.confirmOffline.mutationOptions());
@@ -289,20 +304,6 @@ export default function UsdPaymentsPanel() {
);
},
},
{
id: "currency",
header: "Currency",
cell: ({ row }) => (
<Badge
size="sm"
variant="light"
radius="sm"
color={row.original.currency?.toUpperCase() === "USD" ? "blue" : "teal"}
>
{row.original.currency}
</Badge>
),
},
{
id: "status",
header: "Status",
@@ -342,11 +343,12 @@ export default function UsdPaymentsPanel() {
meta: { headerClassName: "text-right", cellClassName: "text-right" },
cell: ({ row }) => {
if (row.original.status === "PAID" || !canConfirm) return null;
if (!currencyEnabled) return null;
return <ConfirmCell row={row.original} onConfirm={setConfirming} />;
},
},
],
[canConfirm, navigate],
[canConfirm, currencyEnabled, navigate],
);
return (
@@ -376,20 +378,6 @@ export default function UsdPaymentsPanel() {
style={{ flex: 1, minWidth: "240px" }}
radius="lg"
/>
<SegmentedControl
size="sm"
radius="md"
value={currency || "all"}
onChange={(v) => {
setCurrency(v === "all" ? "" : (v as "USD" | "ETB"));
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
}}
data={[
{ label: "All", value: "all" },
{ label: "ETB", value: "ETB" },
{ label: "USD", value: "USD" },
]}
/>
<SegmentedControl
size="sm"
radius="md"
@@ -427,9 +415,11 @@ export default function UsdPaymentsPanel() {
status={isLoading ? "loading" : isError ? "error" : "success"}
onRowClick={(row) => navigate(`/dashboard/invoices/${row.id}`)}
emptyMessage={
debouncedQuery
? "No invoices match your search."
: "No invoices awaiting manual payment confirmation."
!currencyEnabled
? `Manual payment is switched off for ${currency} invoices. Enable it in Configuration → Manual payments.`
: debouncedQuery
? "No invoices match your search."
: `No ${currency} invoices awaiting manual payment confirmation.`
}
error={
isError

View File

@@ -26,6 +26,7 @@ import { PageContainer, PageHeader } from "@/components/page";
import ManageRuleEngineOrderDialog from "@/components/ruleEngine/ManageRuleEngineOrderDialog";
import PriorityRuleApprovalsSection from "@/pages/ruleEngine/PriorityRuleApprovalsSection";
import RateApprovalsSection from "@/pages/ruleEngine/RateApprovalsSection";
import { YardDesksModal } from "@/pages/ruleEngine/YardDesksModal";
import { nextPriorityRangeStart } from "@/pages/ruleEngine/priorityRuleRange";
import RuleEngineCardGrid from "@/components/ruleEngine/RuleEngineCardGrid";
import RuleEngineFormDialog from "@/components/ruleEngine/RuleEngineFormDialog";
@@ -167,6 +168,10 @@ const RuleEngineResourcePage = () => {
null,
);
const [chainOpen, setChainOpen] = useState(false);
// Yards only: which desks work at this yard (input to yard access scoping).
const [desksYard, setDesksYard] = useState<Record<string, unknown> | null>(
null,
);
const [orderDialogOpen, setOrderDialogOpen] = useState(false);
const { viewMode, setViewMode } = useRuleEngineViewMode(
config?.slug ?? DEFAULT_CONFIGURATION_SLUG,
@@ -582,6 +587,17 @@ const RuleEngineResourcePage = () => {
cell: ({ row }) => (
<div onClick={(e) => e.stopPropagation()} data-stop-row-click>
<Group gap="xs" wrap="nowrap" justify="flex-end">
{config.slug === "yards" ? (
<Tooltip label="Desks that work at this yard">
<Button
size="compact-xs"
variant="light"
onClick={() => setDesksYard(row.original)}
>
Desks
</Button>
</Tooltip>
) : null}
{config.orderConfig && canUpdateControls ? (
<RuleEngineOrderControls
record={row.original}
@@ -984,6 +1000,21 @@ const RuleEngineResourcePage = () => {
</Stack>
</Card>
<YardDesksModal
opened={!!desksYard}
onClose={() => setDesksYard(null)}
readOnly={!canUpdateControls}
yard={
desksYard
? {
id: String(desksYard.id),
code: String(desksYard.code ?? ""),
label: String(desksYard.label ?? ""),
}
: null
}
/>
<RuleEngineFormDialog
open={formOpen}
onOpenChange={setFormOpen}

View File

@@ -0,0 +1,134 @@
import { useEffect, useState } from "react";
import {
Alert,
Button,
Group,
Loader,
Modal,
MultiSelect,
Stack,
Text,
} from "@mantine/core";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import toast from "react-hot-toast";
import { extractErrorMessage } from "@/utils/errorExtractor";
import { yardPositionsService } from "@/services/yardPositions.service";
interface YardDesksModalProps {
opened: boolean;
onClose: () => void;
yard: { id: string; code: string; label: string } | null;
/** Read-only when the caller lacks the yards update permission. */
readOnly?: boolean;
}
const positionLabel = (
name: { am?: string; en?: string } | null,
fallback: string,
) => name?.en?.trim() || name?.am?.trim() || fallback;
/**
* Which desks staff a yard — the input to yard access scoping.
*
* Saving REPLACES the yard's whole set (the API's PUT is a replace), which is
* why the control is a multi-select holding the complete list rather than
* add/remove buttons.
*/
export function YardDesksModal({
opened,
onClose,
yard,
readOnly = false,
}: YardDesksModalProps) {
const queryClient = useQueryClient();
const [selected, setSelected] = useState<string[]>([]);
const positions = useQuery({
queryKey: ["yard-positions", "positions"],
queryFn: yardPositionsService.listPositions,
enabled: opened,
staleTime: 5 * 60 * 1000,
});
const mapping = useQuery({
queryKey: ["yard-positions", "yard", yard?.id],
queryFn: () => yardPositionsService.listByYard(yard!.id),
enabled: opened && !!yard?.id,
});
// Reset to what the server holds whenever the modal opens on a new yard, so a
// cancelled edit never leaks into the next one.
useEffect(() => {
if (mapping.data) setSelected(mapping.data.map((row) => row.positionId));
}, [mapping.data]);
const save = useMutation({
mutationFn: () => yardPositionsService.setForYard(yard!.id, selected),
onSuccess: () => {
toast.success("Yard desks updated");
queryClient.invalidateQueries({ queryKey: ["yard-positions"] });
onClose();
},
onError: (error) =>
toast.error(extractErrorMessage(error, "Failed to update yard desks")),
});
const options = (positions.data ?? []).map((position) => ({
value: position.id,
label: positionLabel(position.name, position.id.slice(0, 8)),
}));
return (
<Modal
opened={opened}
onClose={onClose}
title={yard ? `Desks at ${yard.label} (${yard.code})` : "Desks"}
size="lg"
>
<Stack gap="md">
<Alert color="blue" variant="light">
<Text size="sm">
Positions mapped here are the desks that work at this yard. Yard
access scoping reads this mapping a staff member acting on this
desk is scoped to this yard.
</Text>
</Alert>
{positions.isLoading || mapping.isLoading ? (
<Group justify="center" py="lg">
<Loader size="sm" />
</Group>
) : (
<MultiSelect
data={options}
value={selected}
onChange={setSelected}
disabled={readOnly}
label="Positions"
placeholder={selected.length ? undefined : "Select positions"}
description="Saving replaces the whole set — anything removed here loses this yard."
searchable
clearable
hidePickedOptions
maxDropdownHeight={280}
/>
)}
<Group justify="flex-end">
<Button variant="default" onClick={onClose}>
Cancel
</Button>
<Button
onClick={() => save.mutate()}
loading={save.isPending}
disabled={readOnly || mapping.isLoading}
title={readOnly ? "You cannot edit yards" : undefined}
>
Save
</Button>
</Group>
</Stack>
</Modal>
);
}

View File

@@ -0,0 +1,132 @@
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/shared/common/ui/card";
import { Badge } from "@/shared/common/ui/badge";
import { Switch } from "@/shared/common/ui/switch";
import { Skeleton } from "@/shared/common/ui/skeleton";
import { AlertTriangle, Banknote, Landmark } from "lucide-react";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import {
useManualPaymentSettingsQuery,
useUpdateManualPaymentSettings,
} from "@/hooks/useManualPaymentSettings";
type Currency = "ETB" | "USD";
const CURRENCIES: {
code: Currency;
field: "etbEnabled" | "usdEnabled";
icon: typeof Banknote;
title: string;
description: string;
}[] = [
{
code: "ETB",
field: "etbEnabled",
icon: Banknote,
title: "Birr (ETB) invoices",
description:
"ETB invoices are normally paid online by the customer. Switch this on when Finance also needs to settle them by hand — a bank transfer or a payment at the counter.",
},
{
code: "USD",
field: "usdEnabled",
icon: Landmark,
title: "Dollar (USD) invoices",
description:
"USD invoices are paid by bank transfer and have no online channel. Switching this off leaves USD customers with no way to be marked as paid.",
},
];
/**
* Switches the manual (offline) payment channel on or off per currency.
*
* Off means gone, not greyed out: the Manual Payments worklist lists only
* enabled currencies, and the API refuses a confirmation in a disabled one —
* so a stale tab or a direct call cannot slip a payment through.
*/
export default function ManualPaymentSettingsCard() {
const { user } = useAuth();
const canManage =
hasPermission(user, FREIGHT_PERMS.settings.manualPayment.manage) ||
hasPermission(user, FREIGHT_PERMS.admin);
const { data, isLoading } = useManualPaymentSettingsQuery();
const update = useUpdateManualPaymentSettings();
const noneEnabled = Boolean(data && !data.etbEnabled && !data.usdEnabled);
return (
<Card className="shadow-lg border-gray-200 dark:border-gray-700">
<CardHeader>
<CardTitle>Manual payments</CardTitle>
<CardDescription>
Whether Finance staff may mark invoices as paid by hand, from
Invoices Manual Payments. Each currency is switched separately.
Confirming still requires the payment slip and the booking&apos;s pay
window to be open.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{noneEnabled && (
<div className="flex items-start gap-2 rounded-md border border-amber-200 bg-amber-50 p-3 text-sm text-amber-900 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-100">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<p>
Both currencies are off the Manual Payments list is empty and
Finance cannot settle any invoice by hand.
</p>
</div>
)}
{isLoading || !data
? CURRENCIES.map((c) => (
<Skeleton key={c.code} className="h-[86px] w-full rounded-md" />
))
: CURRENCIES.map(({ code, field, icon: Icon, title, description }) => {
const enabled = data[field];
return (
<div
key={code}
className="flex items-start justify-between gap-4 rounded-md border p-4 dark:border-gray-700"
>
<div className="space-y-1">
<div className="flex items-center gap-2">
<Icon className="h-4 w-4 text-muted-foreground" />
<p className="font-medium">{title}</p>
<Badge variant={enabled ? "default" : "secondary"}>
{enabled ? "Enabled" : "Disabled"}
</Badge>
</div>
<p className="text-sm text-muted-foreground">
{description}
</p>
</div>
<Switch
checked={enabled}
disabled={!canManage || update.isPending}
aria-label={`Allow manual payment for ${code} invoices`}
onCheckedChange={(checked) =>
update.mutate({ [field]: checked })
}
/>
</div>
);
})}
{!canManage && (
<p className="text-sm text-muted-foreground">
You can see these settings but not change them that needs the
manual-payment settings permission.
</p>
)}
</CardContent>
</Card>
);
}

View File

@@ -29,7 +29,7 @@ import {
Weight,
Wrench,
} from "lucide-react";
import { useState } from "react";
import { useCallback, useMemo, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import AvailableWagonsPanel from "@/components/trainBuilder/AvailableWagonsPanel";
@@ -94,14 +94,23 @@ export default function TrainBuilderDetailPage() {
const canAssign = hasPermission(user, FREIGHT_PERMS.trains.assignWagons);
const canChangeLocomotives = hasPermission(user, FREIGHT_PERMS.trains.changeLocomotives);
const canChangeYard = hasPermission(user, FREIGHT_PERMS.trains.changeYard);
const canChangeWagonYard = hasPermission(user, FREIGHT_PERMS.trains.changeWagonYard);
const canToggleActive = hasPermission(user, FREIGHT_PERMS.trains.toggleActive);
const canDisband = hasPermission(user, FREIGHT_PERMS.trains.disband);
const compositionQuery = useQuery(
api.trainBuilder.composition.queryOptions({ input: { id }, enabled: Boolean(id) }),
api.trainBuilder.composition.queryOptions({
input: { id },
enabled: Boolean(id),
// Mutations seed this key from their own response (see `seedComposition`
// in services/api.ts), so the cached consist is authoritative — the
// global staleTime of 0 would otherwise refetch it on every remount.
staleTime: 30_000,
}),
);
const assignWagons = useMutation(api.trainBuilder.assignWagons.mutationOptions());
const removeWagon = useMutation(api.trainBuilder.removeWagon.mutationOptions());
const setWagonYard = useMutation(api.trainBuilder.setWagonYard.mutationOptions());
const maintenanceWagon = useMutation(
api.trainBuilder.sendWagonToMaintenance.mutationOptions(),
);
@@ -111,6 +120,34 @@ export default function TrainBuilderDetailPage() {
const activate = useMutation(api.trainBuilder.activate.mutationOptions());
const composition = compositionQuery.data;
// The diagram memoizes off its `locomotives`/`wagons` props; building those
// arrays inline in JSX would hand it a new identity on every render and
// re-normalize + repaint every car for each keystroke or pending mutation.
const diagramLocomotives = useMemo(
() =>
(composition?.locomotives ?? []).map((loco) => ({
code: loco.code,
name: loco.name,
maxPullWeightTons: loco.maxPullWeightTons,
})),
[composition?.locomotives],
);
const diagramWagons = useMemo(
() =>
(composition?.wagons ?? []).map((wagon, index) => ({
sequenceNo: wagon.sequenceNumber ?? index + 1,
capacityTons: wagon.wagonType?.capacityTons ?? 0,
// No bookings at build time — wagons ride empty until allocation.
assignedWeightTons: 0,
tareWeightTons: wagon.wagonType?.tareWeightTons ?? 0,
wagonTypeCode: wagon.wagonType?.code ?? null,
physicalWagonNumber: wagon.wagonNumber,
allocations: [],
})),
[composition?.wagons],
);
// Staff identify a train by its operational run numbers, not the internal
// code — mirrors formatTrainRunLabel on the API, which writes the history note.
const trainRunLabel =
@@ -127,20 +164,76 @@ export default function TrainBuilderDetailPage() {
const busy =
assignWagons.isPending ||
removeWagon.isPending ||
setWagonYard.isPending ||
maintenanceWagon.isPending ||
reorderWagons.isPending;
const withToast = async (action: () => Promise<unknown>, failTitle: string) => {
try {
await action();
} catch (err) {
toast({
title: failTitle,
description: parseError(err, "Something went wrong"),
variant: "destructive",
});
}
};
const withToast = useCallback(
async (action: () => Promise<unknown>, failTitle: string) => {
try {
await action();
} catch (err) {
toast({
title: failTitle,
description: parseError(err, "Something went wrong"),
variant: "destructive",
});
}
},
[toast],
);
// Stable handlers: the consist list and wagon picker are memoized, so a new
// closure each render would defeat the memo and re-render every wagon row
// (and re-mount the drag context) on unrelated state changes.
// `trainId` is only absent before the composition loads, and these handlers
// are wired to controls that render after that — the guard keeps the promise
// rather than leaning on a non-null assertion.
const trainId = composition?.id;
const handleAssign = useCallback(
(wagonIds: string[]) => {
if (!trainId) return;
void withToast(
() => assignWagons.mutateAsync({ id: trainId, wagonIds }),
"Could not add wagons",
);
},
[withToast, assignWagons.mutateAsync, trainId],
);
const handleReorder = useCallback(
(wagonIds: string[]) => {
if (!trainId) return;
void withToast(
() => reorderWagons.mutateAsync({ id: trainId, wagonIds }),
"Could not reorder wagons",
);
},
[withToast, reorderWagons.mutateAsync, trainId],
);
const handleRemove = useCallback(
(wagonId: string) => {
if (!trainId) return;
void withToast(
() => removeWagon.mutateAsync({ id: trainId, wagonId }),
"Could not detach wagon",
);
},
[withToast, removeWagon.mutateAsync, trainId],
);
const handleChangeWagonYard = useCallback(
(wagonId: string, currentYardId: string) => {
if (!trainId) return;
void withToast(
() => setWagonYard.mutateAsync({ id: trainId, wagonId, currentYardId }),
"Could not change wagon yard",
);
},
[withToast, setWagonYard.mutateAsync, trainId],
);
const handleMaintenance = useCallback(
(wagon: TrainCompositionWagon) => setMaintenanceTarget(wagon),
[],
);
if (compositionQuery.isLoading) {
return (
@@ -278,6 +371,29 @@ export default function TrainBuilderDetailPage() {
]}
/>
{composition.wagonYards.length > 1 ? (
<Alert color="blue" icon={<MapPin size={16} />}>
<Stack gap={4}>
<Text size="sm" fw={600}>
This train's wagons stand in {composition.wagonYards.length} yards
</Text>
<Group gap="xs">
{composition.wagonYards.map((group) => (
<Badge key={group.yardId ?? "none"} variant="light" color="blue">
{group.label ?? group.code ?? "No yard"} · {group.wagonCount} wagon
{group.wagonCount === 1 ? "" : "s"}
</Badge>
))}
</Group>
<Text size="xs" c="dimmed">
The train collects each group when it reaches that yard, so the schedule's route
must pass through every one of them before its destination. Customers boarding at
a yard can only book the wagons standing there.
</Text>
</Stack>
</Alert>
) : null}
{!composition.editable ? (
<Alert color="yellow" icon={<AlertTriangle size={16} />}>
This train is out on a dispatched run its composition is frozen until arrival.
@@ -341,21 +457,8 @@ export default function TrainBuilderDetailPage() {
<Stack gap="sm">
<TrainCompositionDiagram
locomotives={composition.locomotives.map((loco) => ({
code: loco.code,
name: loco.name,
maxPullWeightTons: loco.maxPullWeightTons,
}))}
wagons={composition.wagons.map((wagon, index) => ({
sequenceNo: wagon.sequenceNumber ?? index + 1,
capacityTons: wagon.wagonType?.capacityTons ?? 0,
// No bookings at build time — wagons ride empty until allocation.
assignedWeightTons: 0,
tareWeightTons: wagon.wagonType?.tareWeightTons ?? 0,
wagonTypeCode: wagon.wagonType?.code ?? null,
physicalWagonNumber: wagon.wagonNumber,
allocations: [],
}))}
locomotives={diagramLocomotives}
wagons={diagramWagons}
trainNumber={composition.code}
totalLengthMeters={totals.totalLengthMeters}
/>
@@ -379,22 +482,17 @@ export default function TrainBuilderDetailPage() {
<Grid.Col span={{ base: 12, md: 5 }}>
<Card h="100%">
<Stack gap="sm">
<Text fw={600}>Available wagons — {yard?.label ?? "yard"}</Text>
<Text fw={600}>Available wagons — all yards</Text>
<Text size="xs" c="dimmed">
Only AVAILABLE wagons standing in the train's own yard can be coupled.
AVAILABLE, unassigned wagons from every yard can be coupled. The schedule's
route must pass through each wagon's yard before its destination.
</Text>
<AvailableWagonsPanel
yardId={yard?.id ?? ""}
yardLabel={yard?.label}
homeYardId={yard?.id ?? null}
exportTrainNumber={composition.exportTrainNumber}
importTrainNumber={composition.importTrainNumber}
assigning={assignWagons.isPending}
onAssign={(wagonIds) =>
void withToast(
() => assignWagons.mutateAsync({ id: composition.id, wagonIds }),
"Could not add wagons",
)
}
onAssign={handleAssign}
/>
</Stack>
</Card>
@@ -411,19 +509,12 @@ export default function TrainBuilderDetailPage() {
wagons={composition.wagons}
editable={composition.editable && canAssign}
busy={busy}
onReorder={(wagonIds) =>
void withToast(
() => reorderWagons.mutateAsync({ id: composition.id, wagonIds }),
"Could not reorder wagons",
)
onReorder={handleReorder}
onRemove={handleRemove}
onMaintenance={handleMaintenance}
onChangeYard={
composition.editable && canChangeWagonYard ? handleChangeWagonYard : undefined
}
onRemove={(wagonId) =>
void withToast(
() => removeWagon.mutateAsync({ id: composition.id, wagonId }),
"Could not detach wagon",
)
}
onMaintenance={(wagon) => setMaintenanceTarget(wagon)}
/>
</Stack>
</Card>

View File

@@ -1,18 +1,17 @@
import { useNavigate } from 'react-router-dom';
import { Badge, Card, Center, Divider, Group, Loader, SimpleGrid, Stack, Text, ThemeIcon } from '@mantine/core';
import {
ClipboardCheck,
ClipboardList,
ShieldCheck,
PackageCheck,
PackageOpen,
PackagePlus,
PackageSearch,
CircleCheck,
Send,
Train,
Truck,
Warehouse as WarehouseIcon,
Boxes,
Layers,
} from 'lucide-react';
import { PageContainer, PageHeader } from '@/components/page';
@@ -53,13 +52,13 @@ const METRICS: Metric[] = [
{ key: 'totalInventory', label: 'Total Inventory', icon: <Boxes size={22} />, to: '/dashboard/warehouse-inventory', theme: GREEN },
{ key: 'receivedToday', label: 'Received Today', icon: <PackagePlus size={22} />, to: '/dashboard/warehouse-inventory?status=RECEIVED', theme: ORANGE },
{ key: 'awaitingInspection', label: 'Awaiting Inspection', icon: <ClipboardList size={22} />, to: '/dashboard/warehouse-inventory?status=RECEIVED', theme: GREEN },
{ key: 'inspected', label: 'Inspected', icon: <ShieldCheck size={22} />, to: '/dashboard/warehouse-inventory', theme: ORANGE },
{ key: 'stored', label: 'Stored', icon: <Layers size={22} />, to: '/dashboard/warehouse-inventory?status=STORED', theme: GREEN },
{ key: 'reserved', label: 'Reserved', icon: <ClipboardCheck size={22} />, to: '/dashboard/warehouse-inventory?status=RESERVED', theme: ORANGE },
{ key: 'readyForLoading', label: 'Ready For Loading', icon: <PackageCheck size={22} />, to: '/dashboard/loading-queue', theme: GREEN },
{ key: 'loaded', label: 'Loaded', icon: <Truck size={22} />, to: '/dashboard/loaded-inventory', theme: ORANGE },
{ key: 'dispatched', label: 'Dispatched', icon: <Send size={22} />, to: '/dashboard/dispatch-queue', theme: GREEN },
{ key: 'readyForPickup', label: 'Ready For Pickup', icon: <PackageSearch size={22} />, to: '/dashboard/warehouse-inventory?status=READY_FOR_PICKUP', theme: ORANGE },
{ key: 'emptyContainers', label: 'Empty Containers', icon: <PackageOpen size={22} />, to: '/dashboard/containers', theme: ORANGE },
{ key: 'importTrains', label: 'Import Trains', icon: <Train size={22} />, to: '/dashboard/import-warehouse', theme: GREEN },
{ key: 'exportTrains', label: 'Export Trains', icon: <Train size={22} />, to: '/dashboard/export-warehouse', theme: ORANGE },
{ key: 'loaded', label: 'Loaded', icon: <Truck size={22} />, to: '/dashboard/loaded-inventory', theme: GREEN },
{ key: 'dispatched', label: 'Dispatched', icon: <Send size={22} />, to: '/dashboard/dispatch-queue', theme: ORANGE },
{ key: 'readyForPickup', label: 'Ready For Pickup', icon: <PackageSearch size={22} />, to: '/dashboard/warehouse-inventory?status=READY_FOR_PICKUP', theme: GREEN },
{ key: 'readyForLoading', label: 'Ready For Loading', icon: <PackageCheck size={22} />, to: '/dashboard/loading-queue', theme: ORANGE },
{ key: 'delivered', label: 'Delivered', icon: <CircleCheck size={22} />, to: '/dashboard/warehouse-inventory?status=DELIVERED', theme: GREEN },
];

View File

@@ -290,6 +290,32 @@ const TRAIN_BUILDER_INVALIDATIONS: ReadonlyArray<readonly unknown[]> = [
QUERY_KEYS.FLEET.ROOT,
];
/**
* Coupling/uncoupling wagons moves wagons between the available pool and one
* train — it does not touch locomotives, so those roots stay valid. Trimming
* the set keeps a drag-reorder from refetching the whole fleet.
*/
const TRAIN_BUILDER_WAGON_INVALIDATIONS: ReadonlyArray<readonly unknown[]> = [
QUERY_KEYS.TRAIN_BUILDER.ROOT,
QUERY_KEYS.TRAIN_SCHEDULING.ROOT,
["wagons"],
];
/**
* Every train-builder mutation responds with the train's full, fresh
* composition — write it straight into the detail cache so the workspace
* repaints from the response instead of refetching what it was just handed.
*/
const seedComposition = (
input: { id: string } | string,
data: TrainComposition,
): ReadonlyArray<readonly [readonly unknown[], unknown]> => [
[
QUERY_KEYS.TRAIN_BUILDER.composition(typeof input === "string" ? input : input.id),
data,
],
];
export const api = {
trainScheduling: {
// ── Queries ────────────────────────────────────────────────────────────
@@ -2076,6 +2102,7 @@ export const api = {
trainBuilderService.setLocomotives(id, locomotiveIds).then((r) => r.data),
undefined,
() => TRAIN_BUILDER_INVALIDATIONS,
seedComposition,
),
setYard: endpoint<{ id: string; currentYardId: string }, TrainComposition>(
@@ -2085,6 +2112,7 @@ export const api = {
trainBuilderService.setYard(id, currentYardId).then((r) => r.data),
undefined,
() => TRAIN_BUILDER_INVALIDATIONS,
seedComposition,
),
updateDetails: endpoint<
@@ -2097,6 +2125,7 @@ export const api = {
trainBuilderService.updateDetails(id, payload).then((r) => r.data),
undefined,
() => TRAIN_BUILDER_INVALIDATIONS,
seedComposition,
),
assignWagons: endpoint<{ id: string; wagonIds: string[] }, TrainComposition>(
@@ -2105,7 +2134,21 @@ export const api = {
({ id, wagonIds }) =>
trainBuilderService.assignWagons(id, wagonIds).then((r) => r.data),
undefined,
() => TRAIN_BUILDER_INVALIDATIONS,
() => TRAIN_BUILDER_WAGON_INVALIDATIONS,
seedComposition,
),
setWagonYard: endpoint<
{ id: string; wagonId: string; currentYardId: string },
TrainComposition
>(
"train-builder",
"setWagonYard",
({ id, wagonId, currentYardId }) =>
trainBuilderService.setWagonYard(id, wagonId, currentYardId).then((r) => r.data),
undefined,
() => TRAIN_BUILDER_WAGON_INVALIDATIONS,
seedComposition,
),
removeWagon: endpoint<{ id: string; wagonId: string }, TrainComposition>(
@@ -2114,7 +2157,8 @@ export const api = {
({ id, wagonId }) =>
trainBuilderService.removeWagon(id, wagonId).then((r) => r.data),
undefined,
() => TRAIN_BUILDER_INVALIDATIONS,
() => TRAIN_BUILDER_WAGON_INVALIDATIONS,
seedComposition,
),
sendWagonToMaintenance: endpoint<
@@ -2126,7 +2170,8 @@ export const api = {
({ id, wagonId, note }) =>
trainBuilderService.sendWagonToMaintenance(id, wagonId, note).then((r) => r.data),
undefined,
() => TRAIN_BUILDER_INVALIDATIONS,
() => TRAIN_BUILDER_WAGON_INVALIDATIONS,
seedComposition,
),
reorderWagons: endpoint<{ id: string; wagonIds: string[] }, TrainComposition>(
@@ -2135,7 +2180,8 @@ export const api = {
({ id, wagonIds }) =>
trainBuilderService.reorderWagons(id, wagonIds).then((r) => r.data),
undefined,
() => TRAIN_BUILDER_INVALIDATIONS,
() => TRAIN_BUILDER_WAGON_INVALIDATIONS,
seedComposition,
),
deactivate: endpoint<string, TrainComposition>(
@@ -2144,6 +2190,7 @@ export const api = {
(id) => trainBuilderService.deactivate(id).then((r) => r.data),
undefined,
() => TRAIN_BUILDER_INVALIDATIONS,
seedComposition,
),
activate: endpoint<string, TrainComposition>(
@@ -2152,6 +2199,7 @@ export const api = {
(id) => trainBuilderService.activate(id).then((r) => r.data),
undefined,
() => TRAIN_BUILDER_INVALIDATIONS,
seedComposition,
),
disband: endpoint<string, void>(

View File

@@ -6,6 +6,35 @@ import type { Freight } from "@edr/types";
const B = URL_CONSTANTS.BOOKINGS;
/**
* One shared-wagon approval. Covers BOTH bookings on the wagon — the pair is
* decided as a unit, never one side at a time.
*/
export interface ConsolidationApprovalRow {
id: string;
bookingId: string;
partnerBookingId: string;
status: "PENDING" | "APPROVED" | "REJECTED";
requestedBy?: string | null;
requestedAt: string;
decidedBy?: string | null;
decidedAt?: string | null;
decisionNote?: string | null;
scheduledDate?: string | null;
bookingReference?: string | null;
partnerBookingReference?: string | null;
booking?: {
id: string;
reference?: string;
company?: { name?: string } | null;
} | null;
partnerBooking?: {
id: string;
reference?: string;
company?: { name?: string } | null;
} | null;
}
export interface BookingListFilter {
status?: string;
/** Comma-separated statuses for grouped tabs */
@@ -327,6 +356,60 @@ export const bookingsService = {
cancel: (id: string, reason: string) =>
postBooking<BookingDetail>(B.CANCEL(id), { reason }),
// ── Shared-wagon approval gate ──────────────────────────────────────────
/** Pairings awaiting a decision, oldest first. */
consolidationApprovalQueue: async (): Promise<ConsolidationApprovalRow[]> => {
const response = await client.get(B.CONSOLIDATION_APPROVAL_QUEUE);
return (unwrap(response.data) ?? []) as ConsolidationApprovalRow[];
},
/** Decision history for one booking's shared wagon — who, when, and why. */
consolidationApprovalHistory: async (
bookingId: string,
): Promise<ConsolidationApprovalRow[]> => {
const response = await client.get(
B.CONSOLIDATION_APPROVAL_HISTORY(bookingId),
);
return (unwrap(response.data) ?? []) as ConsolidationApprovalRow[];
},
/** Approve: both bookings leave the gate and continue to Operations. */
approveConsolidation: async (approvalId: string, note?: string) => {
const response = await client.post(B.CONSOLIDATION_APPROVE(approvalId), {
note,
});
return unwrap(response.data);
},
/** Reject: both bookings go back to GL for changes with the reason. */
rejectConsolidation: async (approvalId: string, reason: string) => {
const response = await client.post(B.CONSOLIDATION_REJECT(approvalId), {
reason,
});
return unwrap(response.data);
},
/**
* Apply one staff decision to BOTH halves of a consolidated pair. The two
* bookings share a wagon, so they advance or cancel together — all-or-nothing
* on the server. Each half keeps its own invoice and payment.
*/
pairedDecision: async (
id: string,
decision: "accept" | "cancel" | "operationAccept" | "requestChanges",
options: { reason?: string; note?: string; validityDays?: number } = {},
): Promise<{ booking: BookingDetail; partner: BookingDetail }> => {
const response = await client.post(B.PAIRED_DECISION(id), {
decision,
...options,
});
return unwrap(response.data) as {
booking: BookingDetail;
partner: BookingDetail;
};
},
create: async (payload: Record<string, unknown>): Promise<BookingDetail> => {
const response = await client.post<{ booking: BookingDetail } | BookingDetail>(
B.BASE,
@@ -349,6 +432,77 @@ export const bookingsService = {
return unwrap(response.data) as Freight.ClearanceView;
},
/** Clearance action history — reviews, workflow steps, charges (newest first). */
getClearanceHistory: async (
id: string,
): Promise<Freight.ClearanceHistoryEvent[]> => {
const response = await client.get(`/bookings/${id}/clearance/history`);
return unwrap(response.data) as Freight.ClearanceHistoryEvent[];
},
// ── Clearance charges (post-finalization customer billing) ──
getClearanceCharges: async (id: string): Promise<Freight.ClearanceCharge[]> => {
const response = await client.get(`/bookings/${id}/clearance/charges`);
return unwrap(response.data) as Freight.ClearanceCharge[];
},
/** GL Djibouti uploads (or replaces, until billed) the port-charges document. */
uploadPortChargeDocument: async (
id: string,
file: File,
): Promise<Freight.ClearanceCharge[]> => {
const form = new FormData();
form.append("file", file);
const response = await client.post(
`/bookings/${id}/clearance/charges/port-document`,
form,
{ headers: { "Content-Type": "multipart/form-data" } },
);
return unwrap(response.data) as Freight.ClearanceCharge[];
},
/** GL Ethiopia sets or revises a charge's amount + currency. */
billClearanceCharge: async (
id: string,
chargeId: string,
payload: { amount: number; currency: string },
): Promise<Freight.ClearanceCharge[]> => {
const response = await client.patch(
`/bookings/${id}/clearance/charges/${chargeId}/bill`,
payload,
);
return unwrap(response.data) as Freight.ClearanceCharge[];
},
/** GL Ethiopia issues the charge's payable invoice to the customer. */
sendClearanceCharge: async (
id: string,
chargeId: string,
): Promise<Freight.ClearanceCharge[]> => {
const response = await client.post(
`/bookings/${id}/clearance/charges/${chargeId}/send`,
);
return unwrap(response.data) as Freight.ClearanceCharge[];
},
/** GL Ethiopia creates the miscellaneous charge (document + amount + currency). */
createMiscellaneousCharge: async (
id: string,
file: File,
payload: { amount: number; currency: string },
): Promise<Freight.ClearanceCharge[]> => {
const form = new FormData();
form.append("file", file);
form.append("amount", String(payload.amount));
form.append("currency", payload.currency);
const response = await client.post(
`/bookings/${id}/clearance/charges/miscellaneous`,
form,
{ headers: { "Content-Type": "multipart/form-data" } },
);
return unwrap(response.data) as Freight.ClearanceCharge[];
},
/** GL ET asks Djibouti to name the officer handling the shipment in transit. */
requestTransitAssignee: (id: string, note?: string) =>
postBooking<BookingDetail>(B.CLEARANCE_TRANSIT_ASSIGNEE_REQUEST(id), {

View File

@@ -73,6 +73,32 @@ export interface ShipmentValidation {
totalAmount?: number;
}
/**
* A booking GL may pick as the shared-wagon partner of an odd-20ft customs
* booking. `hasCargo` is false for a bare instance whose containers GL still
* enters on the split completion form.
*/
export interface ConsolidationCandidate {
id: string;
reference: string;
contractId: string | null;
companyName: string | null;
status: string;
tradeDirection: string | null;
originYardId: string | null;
destinationYardId: string | null;
scheduledDate: string | null;
ft20Quantity: number;
hasCargo: boolean;
}
/** Both halves of a shared-wagon completion, each with its own full payload. */
export interface CompleteConsolidatedPairPayload {
partnerBookingId: string;
booking: Freight.CreateBookingUnderContractDto;
partner: Freight.CreateBookingUnderContractDto;
}
export interface ContractListSummaryMetrics {
inQueue: number;
needsAction: number;
@@ -677,6 +703,46 @@ export const contractsService = {
};
},
/**
* Bookings GL may link to an odd-20ft customs booking as its shared-wagon
* partner (same route and direction, customs, odd 20ft, not already paired).
*/
listConsolidationCandidates: async (
id: string,
bookingId: string,
): Promise<ConsolidationCandidate[]> => {
const response = await client.get(
C.CONSOLIDATION_CANDIDATES(id, bookingId),
);
return (unwrap(response.data) ?? []) as ConsolidationCandidate[];
},
/**
* Complete an odd-20ft booking together with the partner booking sharing its
* wagon. All-or-nothing on the server: either both bookings complete and are
* linked, or neither does. Each booking keeps its own price and its own
* invoice — only the wagon is shared.
*/
completeConsolidatedPair: async (
id: string,
bookingId: string,
payload: CompleteConsolidatedPairPayload,
): Promise<{
booking: { id: string; reference: string };
partner: { id: string; reference: string };
warnings?: string[];
}> => {
const response = await client.post(
C.BOOKINGS_COMPLETE_CONSOLIDATED(id, bookingId),
payload,
);
return unwrap(response.data) as {
booking: { id: string; reference: string };
partner: { id: string; reference: string };
warnings?: string[];
};
},
/**
* Pre-create validation + authoritative price preview: the same
* BookingPricingService pass that prices the booking on create (rail +

View File

@@ -0,0 +1,36 @@
import { api as client } from "../auth/http";
import { unwrap } from "@/utils/endpoint";
import { URL_CONSTANTS } from "@/constants/URLS";
import type { ApiResponse } from "@/types/apiResponse";
const BASE = URL_CONSTANTS.MANUAL_PAYMENT_SETTINGS.BASE;
/**
* Whether Finance may settle invoices by hand (bank transfer / counter) rather
* than the customer paying online — switched per currency, because the two
* channels are operationally different.
*/
export interface ManualPaymentSettings {
etbEnabled: boolean;
usdEnabled: boolean;
updatedById: string | null;
updatedAt?: string;
}
export const manualPaymentSettingsService = {
get: async (): Promise<ManualPaymentSettings> => {
const response = await client.get<ApiResponse<ManualPaymentSettings>>(BASE);
return unwrap(response.data);
},
/** Partial: an omitted currency keeps its current setting. */
update: async (
patch: Partial<Pick<ManualPaymentSettings, "etbEnabled" | "usdEnabled">>,
): Promise<ManualPaymentSettings> => {
const response = await client.patch<ApiResponse<ManualPaymentSettings>>(
BASE,
patch,
);
return unwrap(response.data);
},
};

View File

@@ -67,6 +67,8 @@ export interface TrainCompositionWagon {
wagonNumber: string;
sequenceNumber: number | null;
status: string;
currentYardId: string | null;
currentYard: YardRefLite | null;
wagonType: {
id: string;
code: string;
@@ -77,6 +79,14 @@ export interface TrainCompositionWagon {
} | null;
}
/** Where a built train's wagons physically stand, largest group first. */
export interface TrainWagonYardGroup {
yardId: string | null;
code: string | null;
label: string | null;
wagonCount: number;
}
export interface TrainCompositionTotals {
wagonCount: number;
totalTareTons: number;
@@ -104,6 +114,7 @@ export interface TrainComposition {
currentYard: YardRefLite | null;
locomotives: TrainCompositionLocomotive[];
wagons: TrainCompositionWagon[];
wagonYards: TrainWagonYardGroup[];
totals: TrainCompositionTotals;
activeSchedules: ActiveScheduleRef[];
editable: boolean;
@@ -304,6 +315,9 @@ export const trainBuilderService = {
/** Relocate the train — coupled locomotives and wagons move with it. */
setYard: (id: string, currentYardId: string) =>
apiClient.patch<TrainComposition>(`${BASE}/${id}/yard`, { currentYardId }),
/** Move one coupled wagon to another yard; the train stays put. */
setWagonYard: (id: string, wagonId: string, currentYardId: string) =>
apiClient.patch<TrainComposition>(`${BASE}/${id}/wagons/${wagonId}/yard`, { currentYardId }),
assignWagons: (id: string, wagonIds: string[]) =>
apiClient.post<TrainComposition>(`${BASE}/${id}/wagons`, { wagonIds }),
removeWagon: (id: string, wagonId: string) =>

View File

@@ -0,0 +1,64 @@
import { api as apiClient } from "../auth/http";
// NOTE: `auth/http`'s response interceptor already unwraps the API's
// `{ success, data }` envelope, so `response.data` IS the payload here — a
// second `.data` hop reads undefined and silently yields an empty list.
/** A desk mapped to a yard, joined to its IAM position for display. */
export interface YardPositionRow {
id: string;
yardId: string;
yardCode: string;
yardLabel: string;
positionId: string;
positionName: { am?: string; en?: string } | null;
positionTypeKey: string | null;
}
export interface SelectablePosition {
id: string;
name: { am?: string; en?: string } | null;
positionTypeKey: string | null;
unitKey: string | null;
}
export interface MyYardScope {
/** null = unrestricted (super admin or `yards:view_all`). */
yardIds: string[] | null;
unrestricted: boolean;
/** False while the backend is still shadow-logging instead of denying. */
enforced: boolean;
}
export const yardPositionsService = {
listByYard: async (yardId: string): Promise<YardPositionRow[]> => {
const { data } = await apiClient.get(`/yard-positions`, {
params: { yardId },
});
return data ?? [];
},
listPositions: async (): Promise<SelectablePosition[]> => {
const { data } = await apiClient.get(`/yard-positions/positions`);
return data ?? [];
},
myScope: async (): Promise<MyYardScope> => {
const { data } = await apiClient.get(`/yard-positions/my-yards`);
return data;
},
/**
* Replaces the yard's whole desk set — send every position that should remain
* mapped, not just the additions.
*/
setForYard: async (
yardId: string,
positionIds: string[],
): Promise<YardPositionRow[]> => {
const { data } = await apiClient.put(`/yard-positions/yard/${yardId}`, {
positionIds,
});
return data ?? [];
},
};

View File

@@ -234,6 +234,10 @@ export interface BookingDetail {
equipmentReturn?: string;
customsClearingEnabled?: boolean;
customsClearingAgent?: string | null;
/** ET clearance queue: every required document approved (pre-finalize). */
allDocsApproved?: boolean;
/** ET clearance queue: a customer document is PENDING or QUERIED. */
hasDocumentsAwaitingReview?: boolean;
contractKind?: "ONE_TIME" | "GENERAL" | null;
contractId?: string | null;
/** Reference of the contract this booking was created under (list column + search). */
@@ -296,6 +300,12 @@ export interface BookingListRow {
governmentInstitution?: string | null;
consolidationPartnerId?: string | null;
consolidationPartnerReference?: string | null;
/**
* The other half of a consolidated pair, folded into this row for display.
* Set client-side when both halves are present in the same page of results —
* the list shows one row per shared wagon, not one per booking.
*/
pairedWith?: BookingListRow | null;
customsClearingEnabled?: boolean;
/**
* Derived booking kind for the list "Type" column. Mirrors the server's

View File

@@ -234,6 +234,13 @@ export interface Company {
* manager to check the owner against, and it holds no freight-forwarder role.
*/
cooperative?: boolean;
/**
* A foreign investor on an Ethiopian Investment Commission licence: eTrade
* holds no record for its TIN, so every registration field below was typed by
* the customer and verified by nobody. The reviewer is the check — compare
* them against the investment licence on the Documents tab.
*/
investorLicence?: boolean;
address?: string | null;
phone?: string | null;
email?: string | null;

View File

@@ -299,6 +299,9 @@ export interface WarehouseDashboard {
dispatched: number;
readyForPickup: number;
delivered: number;
emptyContainers: number;
importTrains: number;
exportTrains: number;
}
// ── Loading (Batch 3) ────────────────────────────────────────────────────────

View File

@@ -1,250 +1,258 @@
import { Link, useLocation } from "react-router-dom";
import {
Archive,
BarChart,
Building2,
ChartAreaIcon,
ClipboardList,
FileText,
Globe,
Settings,
Users2,
UsersRound,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { useAuth } from "@/shared/context/AuthContext";
import {
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "@/shared/common/ui/sidebar";
export interface MenuItem {
label: string;
href: string;
icon: React.ReactNode;
roles?: string[];
/** Sidebar section this item is bucketed under. */
group: string;
}
// Section render order; groups with no role-visible items are skipped.
const GROUP_ORDER = [
"Overview",
"Organizations",
"Content",
"Records",
"Configuration",
"Archive",
"System",
];
export const AppMenuTabs = () => {
const { user } = useAuth();
const { pathname } = useLocation();
const { setOpenMobile } = useSidebar();
const { t } = useTranslation();
const userRoles = user?.roles.map((role) => role.key) || [];
const menuItems: MenuItem[] = [
{
label: "dashboard",
href: "/user-management/dashboard",
icon: <BarChart className="h-4 w-4" />,
roles: ["super_admin"],
group: "Overview",
},
{
label: "organizations",
href: "/user-management/organizations",
icon: <Building2 className="h-4 w-4" />,
roles: ["super_admin"],
group: "Organizations",
},
{
label: "organizationAdmins",
href: "/user-management/organization_admins",
icon: <Users2 className="h-4 w-4" />,
roles: ["super_admin"],
group: "Organizations",
},
{
label: "externalUsers",
href: "/user-management/external_users",
icon: <Users2 className="h-4 w-4" />,
roles: ["super_admin"],
group: "Organizations",
},
{
label: "dashboard",
href: "/user-management/user_management-dashboard",
icon: <BarChart className="h-4 w-4" />,
roles: ["admin", "organization_admin", "unit_admin"],
group: "Overview",
},
{
label: "userManagement",
href: "/user-management/user_management",
icon: <UsersRound className="h-4 w-4" />,
roles: ["admin", "organization_admin", "unit_admin"],
group: "Overview",
},
{
label: "contentManagement",
href: "/user-management/content-management",
icon: <FileText className="h-4 w-4" />,
roles: ["admin", "organization_admin", "unit_admin"],
group: "Content",
},
{
label: "webManagement",
href: "/user-management/web-management",
icon: <Globe className="h-4 w-4" />,
roles: ["admin", "organization_admin", "unit_admin"],
group: "Content",
},
{
label: "Bulk",
href: "/user-management/bulk-upload",
icon: <FileText className="h-4 w-4" />,
roles: ["admin", "organization_admin", "unit_admin"],
group: "Content",
},
{
label: "Position",
href: "/user-management/position-management",
icon: <FileText className="h-4 w-4" />,
roles: ["admin", "organization_admin", "unit_admin", "super_admin"],
group: "Configuration",
},
{
label: "settings",
href: "/user-management/organization-settings",
icon: <Settings className="h-4 w-4" />,
roles: ["admin", "organization_admin", "unit_admin"],
group: "Configuration",
},
{
label: "Add Site",
href: "/user-management/add-site",
icon: <Globe className="h-4 w-4" />,
roles: ["super_admin"],
group: "Configuration",
},
{
label: "migratedRecords",
href: "/user-management/migrated-records-management",
icon: <FileText className="h-4 w-4" />,
roles: ["super_admin"],
group: "Records",
},
{
label: "Sector Reports",
href: "/user-management/sector-reports",
icon: <ChartAreaIcon className="h-4 w-4" />,
roles: ["unit_admin", "admin", "organization_admin"],
group: "Records",
},
{
label: "Archive Users",
href: "/user-management/archive-users",
icon: <Archive className="h-4 w-4" />,
roles: ["super_admin"],
group: "Archive",
},
{
label: "Archived Organizations",
href: "/user-management/archived-organizations",
icon: <Archive className="h-4 w-4" />,
roles: ["super_admin"],
group: "Archive",
},
{
label: "Archive Users",
href: "/user-management/archives",
icon: <Archive className="h-4 w-4" />,
roles: ["admin", "organization_admin", "unit_admin"],
group: "Archive",
},
{
label: "Archived Units & Positions",
href: "/user-management/archived",
icon: <Archive className="h-4 w-4" />,
roles: ["admin", "organization_admin", "unit_admin"],
group: "Archive",
},
{
label: "activityLog",
href: "/user-management/activity_log",
icon: <ClipboardList className="h-4 w-4" />,
roles: ["super_admin"],
group: "System",
},
{
label: "setting",
href: "/user-management/settings",
icon: <Settings className="h-4 w-4" />,
roles: ["super_admin"],
group: "System",
},
{
label: "Letter Template",
href: "/user-management/templates",
icon: <FileText className="h-4 w-4" />,
roles: ["super_admin"],
group: "System",
},
];
const filteredMenu = menuItems.filter((item) =>
item.roles?.some((r) => userRoles.includes(r)),
);
const isActive = (href: string) =>
pathname === href || pathname.startsWith(`${href}/`);
return (
<>
{GROUP_ORDER.map((group) => {
const items = filteredMenu.filter((item) => item.group === group);
if (items.length === 0) return null;
return (
<SidebarGroup key={group} className="pb-0">
<SidebarGroupLabel>{group}</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{items.map((item) => {
const label = t(`organization.${item.label}`, item.label);
return (
<SidebarMenuItem key={item.href}>
<SidebarMenuButton
asChild
isActive={isActive(item.href)}
tooltip={label}
>
<Link
to={item.href}
onClick={() => setOpenMobile(false)}
>
{item.icon}
<span>{label}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
);
})}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
);
})}
</>
);
};
import { Link, useLocation } from "react-router-dom";
import {
Archive,
BarChart,
Building2,
ChartAreaIcon,
ClipboardList,
FileText,
Globe,
MapPin,
Settings,
Users2,
UsersRound,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { useAuth } from "@/shared/context/AuthContext";
import {
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "@/shared/common/ui/sidebar";
export interface MenuItem {
label: string;
href: string;
icon: React.ReactNode;
roles?: string[];
/** Sidebar section this item is bucketed under. */
group: string;
}
// Section render order; groups with no role-visible items are skipped.
const GROUP_ORDER = [
"Overview",
"Organizations",
"Content",
"Records",
"Configuration",
"Archive",
"System",
];
export const AppMenuTabs = () => {
const { user } = useAuth();
const { pathname } = useLocation();
const { setOpenMobile } = useSidebar();
const { t } = useTranslation();
const userRoles = user?.roles.map((role) => role.key) || [];
const menuItems: MenuItem[] = [
{
label: "dashboard",
href: "/user-management/dashboard",
icon: <BarChart className="h-4 w-4" />,
roles: ["super_admin"],
group: "Overview",
},
{
label: "organizations",
href: "/user-management/organizations",
icon: <Building2 className="h-4 w-4" />,
roles: ["super_admin"],
group: "Organizations",
},
{
label: "organizationAdmins",
href: "/user-management/organization_admins",
icon: <Users2 className="h-4 w-4" />,
roles: ["super_admin"],
group: "Organizations",
},
{
label: "externalUsers",
href: "/user-management/external_users",
icon: <Users2 className="h-4 w-4" />,
roles: ["super_admin"],
group: "Organizations",
},
{
label: "dashboard",
href: "/user-management/user_management-dashboard",
icon: <BarChart className="h-4 w-4" />,
roles: ["admin", "organization_admin", "unit_admin"],
group: "Overview",
},
{
label: "userManagement",
href: "/user-management/user_management",
icon: <UsersRound className="h-4 w-4" />,
roles: ["admin", "organization_admin", "unit_admin"],
group: "Overview",
},
{
label: "contentManagement",
href: "/user-management/content-management",
icon: <FileText className="h-4 w-4" />,
roles: ["admin", "organization_admin", "unit_admin"],
group: "Content",
},
{
label: "webManagement",
href: "/user-management/web-management",
icon: <Globe className="h-4 w-4" />,
roles: ["admin", "organization_admin", "unit_admin"],
group: "Content",
},
{
label: "Bulk",
href: "/user-management/bulk-upload",
icon: <FileText className="h-4 w-4" />,
roles: ["admin", "organization_admin", "unit_admin"],
group: "Content",
},
{
label: "Position",
href: "/user-management/position-management",
icon: <FileText className="h-4 w-4" />,
roles: ["admin", "organization_admin", "unit_admin", "super_admin"],
group: "Configuration",
},
{
label: "Locations",
href: "/user-management/locations",
icon: <MapPin className="h-4 w-4" />,
roles: ["admin", "organization_admin", "unit_admin", "super_admin"],
group: "Configuration",
},
{
label: "settings",
href: "/user-management/organization-settings",
icon: <Settings className="h-4 w-4" />,
roles: ["admin", "organization_admin", "unit_admin"],
group: "Configuration",
},
{
label: "Add Site",
href: "/user-management/add-site",
icon: <Globe className="h-4 w-4" />,
roles: ["super_admin"],
group: "Configuration",
},
{
label: "migratedRecords",
href: "/user-management/migrated-records-management",
icon: <FileText className="h-4 w-4" />,
roles: ["super_admin"],
group: "Records",
},
{
label: "Sector Reports",
href: "/user-management/sector-reports",
icon: <ChartAreaIcon className="h-4 w-4" />,
roles: ["unit_admin", "admin", "organization_admin"],
group: "Records",
},
{
label: "Archive Users",
href: "/user-management/archive-users",
icon: <Archive className="h-4 w-4" />,
roles: ["super_admin"],
group: "Archive",
},
{
label: "Archived Organizations",
href: "/user-management/archived-organizations",
icon: <Archive className="h-4 w-4" />,
roles: ["super_admin"],
group: "Archive",
},
{
label: "Archive Users",
href: "/user-management/archives",
icon: <Archive className="h-4 w-4" />,
roles: ["admin", "organization_admin", "unit_admin"],
group: "Archive",
},
{
label: "Archived Units & Positions",
href: "/user-management/archived",
icon: <Archive className="h-4 w-4" />,
roles: ["admin", "organization_admin", "unit_admin"],
group: "Archive",
},
{
label: "activityLog",
href: "/user-management/activity_log",
icon: <ClipboardList className="h-4 w-4" />,
roles: ["super_admin"],
group: "System",
},
{
label: "setting",
href: "/user-management/settings",
icon: <Settings className="h-4 w-4" />,
roles: ["super_admin"],
group: "System",
},
{
label: "Letter Template",
href: "/user-management/templates",
icon: <FileText className="h-4 w-4" />,
roles: ["super_admin"],
group: "System",
},
];
const filteredMenu = menuItems.filter((item) =>
item.roles?.some((r) => userRoles.includes(r)),
);
const isActive = (href: string) =>
pathname === href || pathname.startsWith(`${href}/`);
return (
<>
{GROUP_ORDER.map((group) => {
const items = filteredMenu.filter((item) => item.group === group);
if (items.length === 0) return null;
return (
<SidebarGroup key={group} className="pb-0">
<SidebarGroupLabel>{group}</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{items.map((item) => {
const label = t(`organization.${item.label}`, item.label);
return (
<SidebarMenuItem key={item.href}>
<SidebarMenuButton
asChild
isActive={isActive(item.href)}
tooltip={label}
>
<Link
to={item.href}
onClick={() => setOpenMobile(false)}
>
{item.icon}
<span>{label}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
);
})}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
);
})}
</>
);
};

View File

@@ -0,0 +1,365 @@
import { useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import {
APIProvider,
Map as GoogleMap,
Marker,
type MapMouseEvent,
} from "@vis.gl/react-google-maps";
import { Button } from "@/shared/common/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/shared/common/ui/form";
import { Input } from "@/shared/common/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/common/ui/select";
import { Textarea } from "@/shared/common/ui/textarea";
import { useLocalizedName } from "@/shared/common/localizedName";
import type {
Location,
LocationPayload,
LocationType,
} from "@/user-management/dto/locations/location.type";
import { useLocations } from "@/user-management/hooks/useLocations";
const GOOGLE_MAPS_API_KEY = import.meta.env.VITE_GOOGLE_MAPS_API_KEY?.trim();
/** Addis Ababa — where every EDR location is within a map pan. */
const DEFAULT_CENTER = { lat: 9.032, lng: 38.7469 };
const NO_PARENT = "__none__";
const numeric = (label: string) =>
z
.string()
.trim()
.optional()
.refine((v) => !v || !Number.isNaN(Number(v)), `${label} must be a number`);
const locationSchema = z.object({
nameAm: z.string().trim().min(1, "Amharic name is required"),
nameEn: z.string().trim().optional(),
code: z.string().trim().min(1, "Code is required"),
locationTypeId: z.string().uuid("Location type is required"),
parentId: z.string().optional(),
latitude: numeric("Latitude"),
longitude: numeric("Longitude"),
area: numeric("Area"),
boundaryJson: z
.string()
.trim()
.optional()
.refine((v) => {
if (!v) return true;
try {
const parsed = JSON.parse(v);
return typeof parsed === "object" && parsed !== null;
} catch {
return false;
}
}, "Boundary must be a JSON object"),
});
export type LocationFormValues = z.infer<typeof locationSchema>;
interface LocationFormProps {
mode: "create" | "edit";
location?: Location;
locationTypes: LocationType[];
/** Every location, for the parent picker — the API has no filter endpoint. */
allLocations: Location[];
onSuccess?: () => void;
}
export function LocationForm({
mode,
location,
locationTypes,
allLocations,
onSuccess,
}: LocationFormProps) {
const localizedName = useLocalizedName();
const { createLocation, updateLocation, isCreatingLocation, isUpdatingLocation } =
useLocations();
const form = useForm<LocationFormValues>({
resolver: zodResolver(locationSchema),
defaultValues: {
nameAm: location?.names?.am ?? "",
nameEn: location?.names?.en ?? "",
code: location?.code ?? "",
locationTypeId: location?.locationTypeId ?? "",
parentId: location?.parentId ?? NO_PARENT,
latitude: location?.latitude ?? "",
longitude: location?.longitude ?? "",
area: location?.area ?? "",
boundaryJson: location?.boundaryJson
? JSON.stringify(location.boundaryJson, null, 2)
: "",
},
});
const [lat, lng] = [form.watch("latitude"), form.watch("longitude")];
const pin =
lat && lng && !Number.isNaN(Number(lat)) && !Number.isNaN(Number(lng))
? { lat: Number(lat), lng: Number(lng) }
: null;
const dropPin = (event: MapMouseEvent) => {
const point = event.detail.latLng;
if (!point) return;
form.setValue("latitude", point.lat.toFixed(6), { shouldDirty: true });
form.setValue("longitude", point.lng.toFixed(6), { shouldDirty: true });
};
// ponytail: self only, not descendants — the API accepts any parentId, so a
// deep cycle (A → B → A) is still possible. Walk the chain here if it bites.
const parentOptions = allLocations.filter((item) => item.id !== location?.id);
const submit = (values: LocationFormValues) => {
const payload: LocationPayload = {
names: {
am: values.nameAm,
...(values.nameEn ? { en: values.nameEn } : {}),
},
code: values.code,
locationTypeId: values.locationTypeId,
parentId:
values.parentId && values.parentId !== NO_PARENT
? values.parentId
: undefined,
latitude: values.latitude || undefined,
longitude: values.longitude || undefined,
area: values.area || undefined,
boundaryJson: values.boundaryJson
? (JSON.parse(values.boundaryJson) as Record<string, unknown>)
: undefined,
};
if (mode === "create") {
createLocation(payload, {
onSuccess: () => {
form.reset();
onSuccess?.();
},
});
return;
}
if (location) {
updateLocation(
{ id: location.id, payload },
{ onSuccess: () => onSuccess?.() },
);
}
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(submit)} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="nameAm"
render={({ field }) => (
<FormItem>
<FormLabel>Amharic Name *</FormLabel>
<FormControl>
<Input placeholder="አዲስ አበባ" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="nameEn"
render={({ field }) => (
<FormItem>
<FormLabel>English Name</FormLabel>
<FormControl>
<Input placeholder="Addis Ababa" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="code"
render={({ field }) => (
<FormItem>
<FormLabel>Code *</FormLabel>
<FormControl>
<Input placeholder="LOC-001" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="locationTypeId"
render={({ field }) => (
<FormItem>
<FormLabel>Location Type *</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a type" />
</SelectTrigger>
</FormControl>
<SelectContent>
{locationTypes.map((type) => (
<SelectItem key={type.id} value={type.id}>
{localizedName(type.names)} · L{type.level}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="parentId"
render={({ field }) => (
<FormItem className="col-span-2">
<FormLabel>Parent Location</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="No parent (top level)" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value={NO_PARENT}>
No parent (top level)
</SelectItem>
{parentOptions.map((item) => (
<SelectItem key={item.id} value={item.id}>
{localizedName(item.names)} ({item.code})
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="space-y-2">
<FormLabel>Coordinates</FormLabel>
{GOOGLE_MAPS_API_KEY ? (
<div className="h-64 w-full overflow-hidden rounded-md border">
<APIProvider apiKey={GOOGLE_MAPS_API_KEY}>
<GoogleMap
defaultCenter={pin ?? DEFAULT_CENTER}
defaultZoom={pin ? 12 : 6}
gestureHandling="greedy"
disableDefaultUI={false}
onClick={dropPin}
style={{ width: "100%", height: "100%" }}
>
{pin ? <Marker position={pin} /> : null}
</GoogleMap>
</APIProvider>
</div>
) : (
// Name the missing variable rather than rendering a dead grey box.
<p className="rounded-md border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800">
Map picker unavailable <code>VITE_GOOGLE_MAPS_API_KEY</code> is
not set. Type the coordinates below instead.
</p>
)}
{GOOGLE_MAPS_API_KEY ? (
<p className="text-xs text-muted-foreground">
Click the map to drop a pin, or type the values.
</p>
) : null}
</div>
<div className="grid grid-cols-3 gap-4">
<FormField
control={form.control}
name="latitude"
render={({ field }) => (
<FormItem>
<FormLabel>Latitude</FormLabel>
<FormControl>
<Input placeholder="9.032000" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="longitude"
render={({ field }) => (
<FormItem>
<FormLabel>Longitude</FormLabel>
<FormControl>
<Input placeholder="38.746900" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="area"
render={({ field }) => (
<FormItem>
<FormLabel>Area</FormLabel>
<FormControl>
<Input placeholder="1000.25" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="boundaryJson"
render={({ field }) => (
<FormItem>
<FormLabel>Boundary (GeoJSON)</FormLabel>
<FormControl>
<Textarea
rows={4}
placeholder='{"type":"Polygon","coordinates":[]}'
className="font-mono text-xs"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="submit"
className="w-full"
disabled={isCreatingLocation || isUpdatingLocation}
>
{mode === "create" ? "Create Location" : "Save Changes"}
</Button>
</form>
</Form>
);
}

View File

@@ -0,0 +1,173 @@
import { useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { Button } from "@/shared/common/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/shared/common/ui/form";
import { Input } from "@/shared/common/ui/input";
import { Textarea } from "@/shared/common/ui/textarea";
import type {
LocationType,
LocationTypePayload,
} from "@/user-management/dto/locations/location.type";
import { useLocationTypes } from "@/user-management/hooks/useLocationTypes";
const locationTypeSchema = z.object({
nameAm: z.string().trim().min(1, "Amharic name is required"),
nameEn: z.string().trim().optional(),
code: z.string().trim().min(1, "Code is required"),
description: z.string().trim().optional(),
// Level is the hierarchy depth (1 = country, 2 = region, …). Server takes a
// number, so an empty string would post NaN.
level: z.coerce.number().int().min(1, "Level must be 1 or greater"),
});
export type LocationTypeFormValues = z.input<typeof locationTypeSchema>;
interface LocationTypeFormProps {
mode: "create" | "edit";
locationType?: LocationType;
onSuccess?: () => void;
}
export function LocationTypeForm({
mode,
locationType,
onSuccess,
}: LocationTypeFormProps) {
const {
createLocationType,
updateLocationType,
isCreatingLocationType,
isUpdatingLocationType,
} = useLocationTypes();
const form = useForm<LocationTypeFormValues, unknown, z.output<typeof locationTypeSchema>>({
resolver: zodResolver(locationTypeSchema),
defaultValues: {
nameAm: locationType?.names?.am ?? "",
nameEn: locationType?.names?.en ?? "",
code: locationType?.code ?? "",
description: locationType?.description ?? "",
level: locationType?.level ?? 1,
},
});
const submit = (values: z.output<typeof locationTypeSchema>) => {
const payload: LocationTypePayload = {
names: {
am: values.nameAm,
...(values.nameEn ? { en: values.nameEn } : {}),
},
code: values.code,
description: values.description || undefined,
level: values.level,
};
if (mode === "create") {
createLocationType(payload, {
onSuccess: () => {
form.reset();
onSuccess?.();
},
});
return;
}
if (locationType) {
updateLocationType(
{ id: locationType.id, payload },
{ onSuccess: () => onSuccess?.() },
);
}
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(submit)} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="nameAm"
render={({ field }) => (
<FormItem>
<FormLabel>Amharic Name *</FormLabel>
<FormControl>
<Input placeholder="ከተማ" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="nameEn"
render={({ field }) => (
<FormItem>
<FormLabel>English Name</FormLabel>
<FormControl>
<Input placeholder="City" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="code"
render={({ field }) => (
<FormItem>
<FormLabel>Code *</FormLabel>
<FormControl>
<Input placeholder="CITY" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="level"
render={({ field }) => (
<FormItem>
<FormLabel>Level *</FormLabel>
<FormControl>
<Input type="number" min={1} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea rows={3} placeholder="City level location" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="submit"
className="w-full"
disabled={isCreatingLocationType || isUpdatingLocationType}
>
{mode === "create" ? "Create Location Type" : "Save Changes"}
</Button>
</form>
</Form>
);
}

View File

@@ -0,0 +1,204 @@
import { useState } from "react";
import { ColumnDef } from "@tanstack/react-table";
import { Pencil, Trash2 } from "lucide-react";
import { useAuth } from "@/auth/useAuth";
import { isSuperAdmin } from "@/lib/permissions";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/common/ui/alert-dialog";
import { Button } from "@/shared/common/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/shared/common/ui/dialog";
import { AdvancedTable } from "@/shared/common/ui/table/AdvancedTable";
import { useLocalizedName } from "@/shared/common/localizedName";
import { usePermissions } from "@/shared/context/PermissionContext";
import type { LocationType } from "@/user-management/dto/locations/location.type";
import { useLocationTypes } from "@/user-management/hooks/useLocationTypes";
import { LocationTypeForm } from "./LocationTypeForm";
const PAGE_SIZE = 10;
export function LocationTypesTab() {
const [pageIndex, setPageIndex] = useState(0);
const [editing, setEditing] = useState<LocationType | null>(null);
const [creating, setCreating] = useState(false);
const [deleting, setDeleting] = useState<LocationType | null>(null);
const localizedName = useLocalizedName();
const { permissions } = usePermissions();
const { user } = useAuth();
const superAdmin = isSuperAdmin(user);
const can = (key: string) => superAdmin || permissions.includes(key);
const {
locationTypes,
isLoadingLocationTypes,
refetchLocationTypes,
deleteLocationType,
isDeletingLocationType,
} = useLocationTypes({
skip: pageIndex * PAGE_SIZE,
take: PAGE_SIZE,
orderBy: "level:ASC",
});
const columns: ColumnDef<LocationType>[] = [
{
accessorKey: "names",
header: () => "Name",
cell: ({ row }) => <span>{localizedName(row.original.names)}</span>,
},
{
accessorKey: "code",
header: () => "Code",
cell: ({ row }) => <span>{row.original.code}</span>,
},
{
accessorKey: "level",
header: () => "Level",
cell: ({ row }) => <span>{row.original.level}</span>,
},
{
accessorKey: "description",
header: () => "Description",
cell: ({ row }) => <span>{row.original.description || "--"}</span>,
},
{
id: "actions",
header: () => "Actions",
cell: ({ row }) => (
<div className="flex gap-2">
<Button
variant="ghost"
size="icon"
disabled={!can("can:update:location_type")}
title={
can("can:update:location_type")
? "Edit"
: "You cannot edit location types"
}
onClick={() => setEditing(row.original)}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
disabled={!can("can:delete:location_type")}
title={
can("can:delete:location_type")
? "Delete"
: "You cannot delete location types"
}
onClick={() => setDeleting(row.original)}
>
<Trash2 className="h-4 w-4 text-red-600" />
</Button>
</div>
),
},
];
return (
<div className="space-y-4">
<div className="flex justify-end">
<Button
disabled={!can("can:create:location_type")}
title={
can("can:create:location_type")
? undefined
: "You cannot create location types"
}
onClick={() => setCreating(true)}
>
New Location Type
</Button>
</div>
<AdvancedTable
columns={columns}
data={locationTypes?.items ?? []}
tableName="Location Types"
isLoading={isLoadingLocationTypes}
itemCount={locationTypes?.count ?? 0}
pageIndex={pageIndex}
pageSize={PAGE_SIZE}
onPageChange={setPageIndex}
nextFunction={() => setPageIndex((page) => page + 1)}
prevFunction={() => setPageIndex((page) => Math.max(page - 1, 0))}
refresh={refetchLocationTypes}
/>
<Dialog
open={creating || !!editing}
onOpenChange={(open) => {
if (!open) {
setCreating(false);
setEditing(null);
}
}}
>
<DialogContent className="max-w-xl">
<DialogHeader>
<DialogTitle>
{editing ? "Edit Location Type" : "New Location Type"}
</DialogTitle>
</DialogHeader>
<LocationTypeForm
key={editing?.id ?? "create"}
mode={editing ? "edit" : "create"}
locationType={editing ?? undefined}
onSuccess={() => {
setCreating(false);
setEditing(null);
}}
/>
</DialogContent>
</Dialog>
<AlertDialog
open={!!deleting}
onOpenChange={(open) => !open && setDeleting(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Delete {deleting ? localizedName(deleting.names) : ""}?
</AlertDialogTitle>
<AlertDialogDescription>
This is a permanent delete. Locations already using this type will
block it at the foreign key.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
disabled={isDeletingLocationType}
onClick={() => {
if (deleting) {
deleteLocationType(deleting.id, {
onSuccess: () => setDeleting(null),
});
}
}}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View File

@@ -0,0 +1,233 @@
import { useMemo, useState } from "react";
import { ColumnDef } from "@tanstack/react-table";
import { Pencil, Trash2 } from "lucide-react";
import { useAuth } from "@/auth/useAuth";
import { isSuperAdmin } from "@/lib/permissions";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/common/ui/alert-dialog";
import { Button } from "@/shared/common/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/shared/common/ui/dialog";
import { AdvancedTable } from "@/shared/common/ui/table/AdvancedTable";
import { useLocalizedName } from "@/shared/common/localizedName";
import { usePermissions } from "@/shared/context/PermissionContext";
import type { Location } from "@/user-management/dto/locations/location.type";
import { useLocations } from "@/user-management/hooks/useLocations";
import { useLocationTypes } from "@/user-management/hooks/useLocationTypes";
import { LocationForm } from "./LocationForm";
const PAGE_SIZE = 10;
/** The API has no filter endpoint, so the parent picker and the type/parent
* name columns are resolved from one big list. */
const LOOKUP_TAKE = 1000;
export function LocationsTab() {
const [pageIndex, setPageIndex] = useState(0);
const [editing, setEditing] = useState<Location | null>(null);
const [creating, setCreating] = useState(false);
const [deleting, setDeleting] = useState<Location | null>(null);
const localizedName = useLocalizedName();
const { permissions } = usePermissions();
const { user } = useAuth();
const superAdmin = isSuperAdmin(user);
const can = (key: string) => superAdmin || permissions.includes(key);
const { locations, isLoadingLocations, refetchLocations, deleteLocation, isDeletingLocation } =
useLocations({ skip: pageIndex * PAGE_SIZE, take: PAGE_SIZE });
const { locations: allLocations } = useLocations({ take: LOOKUP_TAKE });
const { locationTypes } = useLocationTypes({ take: LOOKUP_TAKE });
const typeName = useMemo(() => {
const byId = new Map(
(locationTypes?.items ?? []).map((type) => [type.id, type]),
);
return (id: string) => {
const type = byId.get(id);
return type ? `${localizedName(type.names)} (L${type.level})` : "--";
};
}, [locationTypes, localizedName]);
const parentName = useMemo(() => {
const byId = new Map(
(allLocations?.items ?? []).map((item) => [item.id, item]),
);
return (id?: string | null) => {
if (!id) return "--";
const parent = byId.get(id);
return parent ? localizedName(parent.names) : id.slice(0, 8);
};
}, [allLocations, localizedName]);
const columns: ColumnDef<Location>[] = [
{
accessorKey: "names",
header: () => "Name",
cell: ({ row }) => <span>{localizedName(row.original.names)}</span>,
},
{
accessorKey: "code",
header: () => "Code",
cell: ({ row }) => <span>{row.original.code}</span>,
},
{
accessorKey: "locationTypeId",
header: () => "Type",
cell: ({ row }) => <span>{typeName(row.original.locationTypeId)}</span>,
},
{
accessorKey: "parentId",
header: () => "Parent",
cell: ({ row }) => <span>{parentName(row.original.parentId)}</span>,
},
{
id: "coordinates",
header: () => "Coordinates",
cell: ({ row }) => {
const { latitude, longitude } = row.original;
return (
<span>{latitude && longitude ? `${latitude}, ${longitude}` : "--"}</span>
);
},
},
{
id: "actions",
header: () => "Actions",
cell: ({ row }) => (
<div className="flex gap-2">
<Button
variant="ghost"
size="icon"
disabled={!can("can:update:location")}
title={
can("can:update:location") ? "Edit" : "You cannot edit locations"
}
onClick={() => setEditing(row.original)}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
disabled={!can("can:delete:location")}
title={
can("can:delete:location")
? "Delete"
: "You cannot delete locations"
}
onClick={() => setDeleting(row.original)}
>
<Trash2 className="h-4 w-4 text-red-600" />
</Button>
</div>
),
},
];
return (
<div className="space-y-4">
<div className="flex justify-end">
<Button
disabled={!can("can:create:location")}
title={
can("can:create:location")
? undefined
: "You cannot create locations"
}
onClick={() => setCreating(true)}
>
New Location
</Button>
</div>
<AdvancedTable
columns={columns}
data={locations?.items ?? []}
tableName="Locations"
isLoading={isLoadingLocations}
itemCount={locations?.count ?? 0}
pageIndex={pageIndex}
pageSize={PAGE_SIZE}
onPageChange={setPageIndex}
nextFunction={() => setPageIndex((page) => page + 1)}
prevFunction={() => setPageIndex((page) => Math.max(page - 1, 0))}
refresh={refetchLocations}
/>
<Dialog
open={creating || !!editing}
onOpenChange={(open) => {
if (!open) {
setCreating(false);
setEditing(null);
}
}}
>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{editing ? "Edit Location" : "New Location"}
</DialogTitle>
</DialogHeader>
<LocationForm
key={editing?.id ?? "create"}
mode={editing ? "edit" : "create"}
location={editing ?? undefined}
locationTypes={locationTypes?.items ?? []}
allLocations={allLocations?.items ?? []}
onSuccess={() => {
setCreating(false);
setEditing(null);
}}
/>
</DialogContent>
</Dialog>
<AlertDialog
open={!!deleting}
onOpenChange={(open) => !open && setDeleting(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Delete {deleting ? localizedName(deleting.names) : ""}?
</AlertDialogTitle>
<AlertDialogDescription>
This is a permanent delete, not an archive. A location that still
has child locations or unit clusters attached will be refused by
the database.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
disabled={isDeletingLocation}
onClick={() => {
if (deleting) {
deleteLocation(deleting.id, {
onSuccess: () => setDeleting(null),
});
}
}}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View File

@@ -0,0 +1,54 @@
/**
* IAM organisation-structure locations. The API is `@tria-plc/iamapi-common`'s
* generic CRUD controller: list returns `{ count, items }` flattened into the
* response envelope (`/api/locations` is in `flatResponseModules`), and it
* joins nothing — `locationType` and `parent` are NOT expanded, so the UI
* resolves both from the type/location lists it already loaded.
*/
export interface LocaleName {
am: string;
en?: string;
}
export interface LocationType {
id: string;
code: string;
names: LocaleName;
description?: string | null;
level: number;
createdAt?: string;
updatedAt?: string;
}
export interface Location {
id: string;
parentId?: string | null;
locationTypeId: string;
names: LocaleName;
code: string;
/** Decimal strings server-side, not numbers. */
latitude?: string | null;
longitude?: string | null;
area?: string | null;
boundaryJson?: Record<string, unknown> | null;
createdAt?: string;
updatedAt?: string;
}
export interface ListResponse<T> {
count: number;
items: T[];
}
export interface ListQuery {
skip?: number;
take?: number;
/** `field:ASC` / `field:DESC`, comma separated. No search or filter exists. */
orderBy?: string;
}
export type LocationPayload = Omit<Location, "id" | "createdAt" | "updatedAt">;
export type LocationTypePayload = Omit<
LocationType,
"id" | "createdAt" | "updatedAt"
>;

View File

@@ -0,0 +1,93 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
import type {
ListQuery,
ListResponse,
LocationType,
LocationTypePayload,
} from "@/user-management/dto/locations/location.type";
import { locationTypeService } from "../services/api/locationService";
export const useLocationTypes = (params: ListQuery = {}) => {
const { t } = useTranslation();
const { handleError } = useErrorHandler(t);
const queryClient = useQueryClient();
const invalidate = () =>
queryClient.invalidateQueries({ queryKey: ["location-types"] });
const {
data: locationTypes,
isLoading: isLoadingLocationTypes,
isError: isErrorLocationTypes,
refetch: refetchLocationTypes,
} = useQuery<ListResponse<LocationType>>({
queryKey: ["location-types", params],
queryFn: async () => {
const { data } = await locationTypeService.list(params);
return { count: data?.count ?? 0, items: data?.items ?? [] };
},
staleTime: 5 * 60 * 1000,
});
const { mutate: createLocationType, isPending: isCreatingLocationType } =
useMutation({
mutationFn: async (payload: LocationTypePayload) => {
const { data } = await locationTypeService.create(payload);
return data;
},
onSuccess: () => {
toast.success(t("locationType.created", "Location type created"));
invalidate();
},
onError: handleError,
});
const { mutate: updateLocationType, isPending: isUpdatingLocationType } =
useMutation({
mutationFn: async ({
id,
payload,
}: {
id: string;
payload: LocationTypePayload;
}) => {
const { data } = await locationTypeService.update(id, payload);
return data;
},
onSuccess: () => {
toast.success(t("locationType.updated", "Location type updated"));
invalidate();
},
onError: handleError,
});
const { mutate: deleteLocationType, isPending: isDeletingLocationType } =
useMutation({
mutationFn: async (id: string) => {
const { data } = await locationTypeService.remove(id);
return data;
},
onSuccess: () => {
toast.success(t("locationType.deleted", "Location type deleted"));
invalidate();
},
onError: handleError,
});
return {
locationTypes,
isLoadingLocationTypes,
isErrorLocationTypes,
refetchLocationTypes,
createLocationType,
isCreatingLocationType,
updateLocationType,
isUpdatingLocationType,
deleteLocationType,
isDeletingLocationType,
};
};

View File

@@ -0,0 +1,95 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
import type {
ListQuery,
ListResponse,
Location,
LocationPayload,
} from "@/user-management/dto/locations/location.type";
import { locationService } from "../services/api/locationService";
/**
* `params` is the whole server-side query surface: skip/take/orderBy. There is
* no search or filter endpoint, so a caller that needs every location (parent
* picker, name lookups) asks for a large `take`.
*/
export const useLocations = (params: ListQuery = {}) => {
const { t } = useTranslation();
const { handleError } = useErrorHandler(t);
const queryClient = useQueryClient();
const invalidate = () =>
queryClient.invalidateQueries({ queryKey: ["locations"] });
const {
data: locations,
isLoading: isLoadingLocations,
isError: isErrorLocations,
refetch: refetchLocations,
} = useQuery<ListResponse<Location>>({
queryKey: ["locations", params],
queryFn: async () => {
const { data } = await locationService.list(params);
return { count: data?.count ?? 0, items: data?.items ?? [] };
},
staleTime: 5 * 60 * 1000,
});
const { mutate: createLocation, isPending: isCreatingLocation } = useMutation({
mutationFn: async (payload: LocationPayload) => {
const { data } = await locationService.create(payload);
return data;
},
onSuccess: () => {
toast.success(t("location.created", "Location created"));
invalidate();
},
onError: handleError,
});
const { mutate: updateLocation, isPending: isUpdatingLocation } = useMutation({
mutationFn: async ({
id,
payload,
}: {
id: string;
payload: LocationPayload;
}) => {
const { data } = await locationService.update(id, payload);
return data;
},
onSuccess: () => {
toast.success(t("location.updated", "Location updated"));
invalidate();
},
onError: handleError,
});
const { mutate: deleteLocation, isPending: isDeletingLocation } = useMutation({
mutationFn: async (id: string) => {
const { data } = await locationService.remove(id);
return data;
},
onSuccess: () => {
toast.success(t("location.deleted", "Location deleted"));
invalidate();
},
onError: handleError,
});
return {
locations,
isLoadingLocations,
isErrorLocations,
refetchLocations,
createLocation,
isCreatingLocation,
updateLocation,
isUpdatingLocation,
deleteLocation,
isDeletingLocation,
};
};

View File

@@ -0,0 +1,35 @@
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/shared/common/ui/tabs";
import { LocationsTab } from "@/user-management/components/location-management/LocationsTab";
import { LocationTypesTab } from "@/user-management/components/location-management/LocationTypesTab";
export default function LocationManagementPage() {
return (
<div className="w-full space-y-6 p-6">
<div>
<h1 className="text-2xl font-semibold">Location Management</h1>
<p className="text-sm text-muted-foreground">
Locations and their hierarchy levels, shared across the IAM
organisation structure.
</p>
</div>
<Tabs defaultValue="locations">
<TabsList>
<TabsTrigger value="locations">Locations</TabsTrigger>
<TabsTrigger value="types">Location Types</TabsTrigger>
</TabsList>
<TabsContent value="locations" className="pt-4">
<LocationsTab />
</TabsContent>
<TabsContent value="types" className="pt-4">
<LocationTypesTab />
</TabsContent>
</Tabs>
</div>
);
}

View File

@@ -10,6 +10,7 @@ import TemplatePage from "@/super-admin/components/templates/components/template
import CreatePositionPage from "./pages/position-management/create";
import EditPositionPage from "./pages/position-management/edit";
import PositionManagementPage from "./pages/position-management";
import LocationManagementPage from "./pages/location-management";
import MigratedDataManagementPage from "./pages/position-management/MigratedDataManagementPage";
import UserPositionApprovalPage from "./pages/UserPositionApprovalPage";
import ViewMigratedDataPage from "./components/MigratedRecords/ViewMigratedDataPage";
@@ -140,6 +141,10 @@ export function UserManagementRoutes(): ReactElement {
path="user-management/position-management"
element={<PositionManagementPage />}
/>
<Route
path="user-management/locations"
element={<LocationManagementPage />}
/>
{/*
The per-officer teeter (ማህተም) + signature upload. This
is NOT the company stamp: it is the individual approval

View File

@@ -0,0 +1,55 @@
import { withHeaders } from "@/record-management/services/api/withHeaders";
import axiosInstance from "@/shared/services/axiosInstance";
import type {
ListQuery,
ListResponse,
Location,
LocationPayload,
LocationType,
LocationTypePayload,
} from "@/user-management/dto/locations/location.type";
import { AxiosResponse } from "axios";
export const locationService = {
list: (
params: ListQuery = {},
): Promise<AxiosResponse<ListResponse<Location>>> =>
axiosInstance.get(`/locations`, { params, headers: withHeaders() }),
create: (payload: LocationPayload): Promise<AxiosResponse<Location>> =>
axiosInstance.post(`/locations`, payload, { headers: withHeaders() }),
update: (
id: string,
payload: LocationPayload,
): Promise<AxiosResponse<Location>> =>
axiosInstance.put(`/locations/${id}`, payload, { headers: withHeaders() }),
// Hard delete server-side — a location with children or unit clusters fails
// on the foreign key rather than returning a tidy 409.
remove: (id: string): Promise<AxiosResponse<void>> =>
axiosInstance.delete(`/locations/${id}`, { headers: withHeaders() }),
};
export const locationTypeService = {
list: (
params: ListQuery = {},
): Promise<AxiosResponse<ListResponse<LocationType>>> =>
axiosInstance.get(`/location-types`, { params, headers: withHeaders() }),
create: (
payload: LocationTypePayload,
): Promise<AxiosResponse<LocationType>> =>
axiosInstance.post(`/location-types`, payload, { headers: withHeaders() }),
update: (
id: string,
payload: LocationTypePayload,
): Promise<AxiosResponse<LocationType>> =>
axiosInstance.put(`/location-types/${id}`, payload, {
headers: withHeaders(),
}),
remove: (id: string): Promise<AxiosResponse<void>> =>
axiosInstance.delete(`/location-types/${id}`, { headers: withHeaders() }),
};

View File

@@ -33,6 +33,27 @@ export type InvalidatesMeta = (
data: unknown,
) => ReadonlyArray<readonly unknown[]>;
/**
* Cache entries a mutation can write DIRECTLY from its own response, skipping
* a refetch. Many endpoints already return the fresh entity they just changed
* (e.g. every train-builder mutation returns the whole `TrainComposition`), so
* re-fetching that same key is a wasted round-trip and a visible flicker.
*
* Returned pairs are written with `setQueryData` by the app-wide MutationCache
* BEFORE the `invalidates` keys are invalidated, and any key seeded here is
* skipped by that invalidation pass — the value just written IS the fresh one.
*/
export type UpdatesFn<TInput, TResponse> = (
input: TInput,
data: TResponse,
) => ReadonlyArray<readonly [readonly unknown[], unknown]>;
/** Shape stored in `mutation.meta.updates` and consumed by the MutationCache. */
export type UpdatesMeta = (
variables: unknown,
data: unknown,
) => ReadonlyArray<readonly [readonly unknown[], unknown]>;
// ---------------------------------------------------------------------------
// Endpoint interfaces
// ---------------------------------------------------------------------------
@@ -67,6 +88,7 @@ export function endpoint<TInput, TResponse>(
execute: (input: TInput) => Promise<TResponse>,
queryKeyBuilder?: (input: TInput) => readonly unknown[],
invalidates?: InvalidatesFn<TInput, TResponse>,
updates?: UpdatesFn<TInput, TResponse>,
) {
const buildKey = (input?: TInput): readonly unknown[] => {
if (queryKeyBuilder && input !== undefined) {
@@ -101,16 +123,30 @@ export function endpoint<TInput, TResponse>(
const mutationOptions = (
config?: Omit<UseMutationOptions<TResponse, Error, TInput>, "mutationFn">,
): UseMutationOptions<TResponse, Error, TInput> => {
const meta = invalidates
? {
...config?.meta,
invalidates: ((variables, data) =>
invalidates(
variables as TInput,
data as TResponse,
)) satisfies InvalidatesMeta,
}
: config?.meta;
const meta =
invalidates || updates
? {
...config?.meta,
...(invalidates
? {
invalidates: ((variables, data) =>
invalidates(
variables as TInput,
data as TResponse,
)) satisfies InvalidatesMeta,
}
: {}),
...(updates
? {
updates: ((variables, data) =>
updates(
variables as TInput,
data as TResponse,
)) satisfies UpdatesMeta,
}
: {}),
}
: config?.meta;
return {
...config,