mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 04:08:11 +00:00
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:
@@ -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
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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 & 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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ export {
|
||||
CompanyStatusBadge,
|
||||
CompanyTypeBadge,
|
||||
InvoiceStatusBadge,
|
||||
ManualRegistrationBadge,
|
||||
PaymentStatusBadge,
|
||||
ProfileApprovalActions,
|
||||
ProfileChips,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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' && (
|
||||
|
||||
Reference in New Issue
Block a user