Merge branch 'dev' into freight/nati-2

This commit is contained in:
Nathnael
2026-08-24 08:38:37 +00:00
225 changed files with 19460 additions and 4235 deletions

View File

@@ -316,7 +316,7 @@ const App = () => {
<RequirePermission permission={FREIGHT_PERMS.customers.view}>
<CustomersPage />
</RequirePermission>
}
}//
/>
<Route
path="customers/:id"

View File

@@ -0,0 +1,434 @@
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Badge,
Box,
Button,
FileButton,
Group,
Loader,
Modal,
NumberInput,
Paper,
Select,
Stack,
Text,
Textarea,
Tooltip,
} from "@mantine/core";
import { DateInput } from "@mantine/dates";
import {
Ban,
Download,
Eye,
FileText,
Plus,
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 { formatDate, formatDateTime } from "@/lib/format";
import { extractErrorMessage } from "@/utils/errorExtractor";
const CURRENCIES = ["ETB", "USD"];
const STATUS_META: Record<Freight.AdditionalChargeStatus, { label: string; color: string }> = {
DRAFT: { label: "Draft", color: "gray" },
SENT: { label: "Sent — unpaid", color: "orange" },
PAID: { label: "Paid", color: "edr-green" },
CANCELLED: { label: "Cancelled", color: "red" },
};
export interface AdditionalPaymentsTabProps {
bookingId: string;
onViewFile: (file: { name: string; url: string }) => void;
}
/**
* Ad-hoc extra charges finance raises against a booking — any number, free-text
* reason. Draft until sent; sending issues the payable invoice and notifies the
* customer (in-app + SMS + email). Settles the same way every invoice does.
*/
export function AdditionalPaymentsTab({ bookingId, onViewFile }: AdditionalPaymentsTabProps) {
const qc = useQueryClient();
const [modalOpen, setModalOpen] = useState(false);
const { data: charges, isLoading } = useQuery({
queryKey: ["additional-charges", bookingId],
queryFn: () => bookingsService.getAdditionalCharges(bookingId),
});
const refresh = (next: Freight.AdditionalCharge[]) =>
qc.setQueryData(["additional-charges", bookingId], next);
const onError = (e: unknown) =>
toast.error(extractErrorMessage(e, "Could not update the charge"));
const create = useMutation({
mutationFn: (p: {
reason: string;
amount: number;
currency: string;
action: "draft" | "send";
file?: File | null;
dueDate?: string | null;
}) => bookingsService.createAdditionalCharge(bookingId, p),
onSuccess: (next, p) => {
toast.success(p.action === "send" ? "Charge sent to the customer" : "Draft saved");
refresh(next);
setModalOpen(false);
},
onError,
});
const send = useMutation({
mutationFn: (chargeId: string) => bookingsService.sendAdditionalCharge(bookingId, chargeId),
onSuccess: (next) => {
toast.success("Charge sent to the customer");
refresh(next);
},
onError,
});
const cancel = useMutation({
mutationFn: (chargeId: string) => bookingsService.cancelAdditionalCharge(bookingId, chargeId),
onSuccess: (next) => {
toast.success("Charge cancelled");
refresh(next);
},
onError,
});
if (isLoading) {
return (
<Group justify="center" py="xl" gap={10}>
<Loader size="sm" color="edr-green" />
<Text c="dimmed">Loading additional charges</Text>
</Group>
);
}
const rows = charges ?? [];
const busy = send.isPending || cancel.isPending;
return (
<Stack gap="md" maw={860}>
<Group justify="space-between">
<Text fz="13px" fw={700} c="edr-text">
Additional charges
</Text>
<Button
size="compact-sm"
color="edr-green"
radius="md"
leftSection={<Plus size={14} />}
onClick={() => setModalOpen(true)}
>
Add charge
</Button>
</Group>
{rows.length === 0 && (
<Text fz="12.5px" c="dimmed">
No additional charges raised on this booking yet.
</Text>
)}
{rows.map((charge) => (
<ChargeCard
key={charge.id}
charge={charge}
busy={busy}
onViewFile={onViewFile}
onSend={() => send.mutate(charge.id)}
onCancel={() => cancel.mutate(charge.id)}
/>
))}
<AddChargeModal
opened={modalOpen}
onClose={() => setModalOpen(false)}
busy={create.isPending}
onSubmit={(p) => create.mutate(p)}
/>
</Stack>
);
}
function ChargeCard({
charge,
busy,
onViewFile,
onSend,
onCancel,
}: {
charge: Freight.AdditionalCharge;
busy: boolean;
onViewFile: (file: { name: string; url: string }) => void;
onSend: () => void;
onCancel: () => void;
}) {
const meta = STATUS_META[charge.status];
return (
<Paper withBorder radius="md" p="md">
<Group justify="space-between" wrap="nowrap" align="flex-start">
<Group gap={10} wrap="nowrap" align="flex-start">
<Receipt size={18} color="var(--mantine-color-edr-green-6)" />
<Box>
<Text fz="14px" fw={700} c="edr-text">
{charge.reason}
</Text>
<Text fz="11.5px" c="dimmed">
Raised{charge.createdByName ? ` by ${charge.createdByName}` : ""} ·{" "}
{formatDateTime(charge.createdAt)}
</Text>
{charge.sentAt && (
<Text fz="11.5px" c="dimmed">
Sent{charge.sentByName ? ` by ${charge.sentByName}` : ""} ·{" "}
{formatDateTime(charge.sentAt)}
{charge.paymentReference ? ` · ref ${charge.paymentReference}` : ""}
</Text>
)}
{charge.paidAt && (
<Text fz="11.5px" c="edr-green.8" fw={600}>
Paid · {formatDateTime(charge.paidAt)}
{charge.invoiceNumber ? ` (invoice ${charge.invoiceNumber})` : ""}
</Text>
)}
{charge.cancelledAt && (
<Text fz="11.5px" c="red.7">
Cancelled · {formatDateTime(charge.cancelledAt)}
{charge.cancelReason ? `${charge.cancelReason}` : ""}
</Text>
)}
{charge.dueAt && charge.status !== "PAID" && charge.status !== "CANCELLED" && (
<Text fz="11.5px" c="dimmed">
Due {formatDate(charge.dueAt)}
</Text>
)}
</Box>
</Group>
<Group gap={8} wrap="nowrap" align="flex-end" style={{ flexDirection: "column" }}>
<Group gap={8} wrap="nowrap">
<Text fz="14px" fw={800} c="edr-text">
{charge.amount.toLocaleString(undefined, { minimumFractionDigits: 2 })}{" "}
{charge.currency}
</Text>
<Badge variant="light" color={meta.color} radius="sm">
{meta.label}
</Badge>
</Group>
{charge.convertedAmount != null && (
<Text fz="11.5px" c="dimmed">
{charge.convertedAmount.toLocaleString(undefined, { minimumFractionDigits: 2 })}{" "}
{charge.convertedCurrency}
</Text>
)}
</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.status === "DRAFT" || charge.status === "SENT") && (
<Group mt="sm" gap={8} justify="flex-end">
<Button
size="compact-sm"
variant="light"
color="red"
radius="md"
leftSection={<Ban size={14} />}
disabled={busy}
onClick={onCancel}
>
Cancel
</Button>
{charge.status === "DRAFT" && (
<Button
size="compact-sm"
color="edr-green"
radius="md"
leftSection={<Send size={14} />}
disabled={busy}
onClick={onSend}
>
Send to customer
</Button>
)}
</Group>
)}
</Paper>
);
}
function AddChargeModal({
opened,
onClose,
busy,
onSubmit,
}: {
opened: boolean;
onClose: () => void;
busy: boolean;
onSubmit: (p: {
reason: string;
amount: number;
currency: string;
action: "draft" | "send";
file?: File | null;
dueDate?: string | null;
}) => void;
}) {
const [reason, setReason] = useState("");
const [amount, setAmount] = useState<number | string>("");
const [currency, setCurrency] = useState("ETB");
const [file, setFile] = useState<File | null>(null);
const [dueDate, setDueDate] = useState<Date | null>(null);
const valid = reason.trim().length > 0 && Number(amount) > 0;
const reset = () => {
setReason("");
setAmount("");
setCurrency("ETB");
setFile(null);
setDueDate(null);
};
const submit = (action: "draft" | "send") => {
if (!valid) return;
onSubmit({
reason: reason.trim(),
amount: Number(amount),
currency,
action,
file,
// Local calendar date, not a UTC-shifted ISO timestamp — toISOString() can
// roll the date back a day for evening local time in a positive-offset zone.
dueDate: dueDate
? `${dueDate.getFullYear()}-${String(dueDate.getMonth() + 1).padStart(2, "0")}-${String(dueDate.getDate()).padStart(2, "0")}`
: null,
});
};
return (
<Modal
opened={opened}
onClose={() => {
onClose();
reset();
}}
title="Add additional charge"
radius="md"
centered
>
<Stack gap="sm">
<Textarea
label="Reason for charge"
placeholder="e.g. Re-weighing fee at Mojo dry port"
autosize
minRows={2}
value={reason}
onChange={(e) => setReason(e.currentTarget.value)}
/>
<Group gap={8} align="flex-end">
<NumberInput
label="Amount"
min={0.01}
decimalScale={2}
value={amount}
onChange={setAmount}
style={{ flex: 1 }}
/>
<Select
label="Currency"
data={CURRENCIES}
value={currency}
onChange={(v) => v && setCurrency(v)}
w={100}
/>
</Group>
<DateInput
label="Due date"
placeholder="Defaults to 14 days after sending"
value={dueDate}
onChange={(v) => setDueDate(v ? new Date(v) : null)}
minDate={new Date()}
clearable
/>
<FileButton onChange={setFile} accept="application/pdf,image/*">
{(props) => (
<Button
{...props}
variant="light"
color="edr-green"
radius="md"
leftSection={<Upload size={14} />}
>
{file ? file.name : "Attach a document (optional)"}
</Button>
)}
</FileButton>
<Group justify="flex-end" mt="sm" gap={8}>
<Button
variant="light"
color="gray"
radius="md"
disabled={busy || !valid}
loading={busy}
onClick={() => submit("draft")}
>
Save draft
</Button>
<Button
color="edr-green"
radius="md"
leftSection={<Send size={14} />}
disabled={busy || !valid}
loading={busy}
onClick={() => submit("send")}
>
Send to customer
</Button>
</Group>
</Stack>
</Modal>
);
}

View File

@@ -1,10 +1,11 @@
import { useNavigate } from "react-router-dom";
import { ExternalLink, MoreHorizontal } from "lucide-react";
import { ExternalLink, MoreHorizontal, Receipt } from "lucide-react";
import { Button, Menu, ActionIcon, Group, Text } from "@mantine/core";
import { BookingConfirmDialog } from "./BookingConfirmDialog";
import { useBookingActionDialog } from "./useBookingActionDialog";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission as hasFreightPermission } from "@/lib/permissions";
import {
isAllocateAction,
isClearanceNavAction,
@@ -50,6 +51,14 @@ export function BookingActionsMenu({
const goToClearanceTab = () =>
navigate(`/dashboard/booking-requests/${row.id}?tab=clearance`);
const goToAdditionalCharges = () =>
navigate(`/dashboard/booking-requests/${row.id}?tab=additional-charges`);
const canSeeAdditionalCharges = hasFreightPermission(
user,
FREIGHT_PERMS.additionalCharges.view,
);
const handleAction = (action: (typeof actions)[number]) => {
onSuppressRowClick?.();
if (isContractNavAction(action.id)) {
@@ -144,6 +153,17 @@ export function BookingActionsMenu({
);
})}
{actions.length > 0 && <Menu.Divider />}
{canSeeAdditionalCharges && (
<Menu.Item
leftSection={<Receipt size={15} />}
onClick={() => {
onSuppressRowClick?.();
goToAdditionalCharges();
}}
>
Additional charges
</Menu.Item>
)}
<Menu.Item
leftSection={<ExternalLink size={15} />}
onClick={() => {

View File

@@ -53,7 +53,7 @@ export const bookingInput = {
export const bookingTable = {
headerCell:
"h-11 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground",
"whitespace-nowrap text-[10px] font-semibold uppercase tracking-[0.08em] text-edr-muted",
rowHover:
"transition-colors hover:bg-muted/25 data-[state=selected]:bg-muted/30",
rowIcon: `flex size-10 shrink-0 items-center justify-center rounded-xl ${bookingGlass.iconWellGreen}`,

View File

@@ -0,0 +1,113 @@
import { useState } from "react";
import { useMutation } from "@tanstack/react-query";
import {
Box,
Button,
Group,
Paper,
Stack,
Text,
Textarea,
} from "@mantine/core";
import { MessageSquarePlus, Send } from "lucide-react";
import toast from "react-hot-toast";
import type { Freight } from "@edr/types";
import { SectionCard } from "./SectionCard";
import { bookingsService } from "@/services/bookings.service";
import { formatDateTime } from "@/lib/format";
import { extractErrorMessage } from "@/utils/errorExtractor";
export interface AdditionalDocsRequestCardProps {
bookingId: string;
/** Past requests, newest first. */
requests: Freight.ClearanceDocRequest[];
/** False once the shipment is paid — documents (and requests) are closed. */
canRequest: boolean;
onSent?: () => void;
}
/**
* GL asks the customer for additional clearance document(s) in plain words.
* The message, its author and its time show on the customer's portal beside
* the upload box, so the customer knows exactly what to send and who asked.
*/
export function AdditionalDocsRequestCard({
bookingId,
requests,
canRequest,
onSent,
}: AdditionalDocsRequestCardProps) {
const [note, setNote] = useState("");
const send = useMutation({
mutationFn: () => bookingsService.requestAdditionalDocuments(bookingId, note),
onSuccess: () => {
toast.success("Request sent to the customer");
setNote("");
onSent?.();
},
onError: (e) =>
toast.error(extractErrorMessage(e, "Could not send the request")),
});
return (
<SectionCard
icon={MessageSquarePlus}
title="Ask for a document"
subtitle="The customer sees your message, who wrote it, and uploads the file from their portal."
accent="edr-green"
>
<Stack gap="sm">
{canRequest ? (
<Box>
<Textarea
placeholder="e.g. Please send the amended commercial invoice showing the revised unit price."
value={note}
onChange={(e) => setNote(e.currentTarget.value)}
autosize
minRows={3}
radius="md"
size="sm"
/>
<Group justify="flex-end" mt={8}>
<Button
size="compact-sm"
color="edr-green"
radius="md"
leftSection={<Send size={14} />}
loading={send.isPending}
disabled={!note.trim()}
onClick={() => send.mutate()}
>
Send request
</Button>
</Group>
</Box>
) : (
<Text fz="12.5px" c="dimmed">
This shipment is settled document requests are closed.
</Text>
)}
{requests.length > 0 && (
<Stack gap={8}>
<Text fz="11px" fw={700} tt="uppercase" c="dimmed" lts="0.05em">
Sent requests
</Text>
{requests.map((r) => (
<Paper key={r.id} withBorder radius="md" p="xs">
<Text fz="12.5px" c="edr-text">
{r.note}
</Text>
<Text fz="11px" c="dimmed" mt={4}>
{r.byName ?? "Staff"} · {formatDateTime(r.at)}
</Text>
</Paper>
))}
</Stack>
)}
</Stack>
</SectionCard>
);
}

View File

@@ -1,10 +1,12 @@
import { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
ActionIcon,
Alert,
Badge,
Box,
Button,
Collapse,
FileButton,
Group,
Loader,
@@ -20,6 +22,7 @@ import {
import {
AlertCircle,
CheckCircle2,
ChevronDown,
Download,
Eye,
FileCheck2,
@@ -187,73 +190,102 @@ export function ClearanceReviewSection({
return (
<Stack gap="lg">
<SectionCard
icon={FileText}
title="Customer documents"
subtitle="Approve each document, or open a query to tell the customer what to fix."
extra={
<Text size="xs" c="dimmed" fw={600}>
{stats.approved}/{stats.total} approved
</Text>
}
>
<Stack gap={12}>
{!hideSummary && stats.total > 0 && (
<Box>
<Progress
value={stats.pct}
color="edr-green"
radius="xl"
size="sm"
mb={6}
/>
<Group gap="lg">
<StatPill color="edr-green" label="Approved" value={stats.approved} />
<StatPill color="red" label="Queried" value={stats.queried} />
<StatPill color="gray" label="Pending" value={stats.pending} />
</Group>
<Paper radius={13} withBorder style={{ overflow: "hidden" }} p={0}>
<Group
justify="space-between"
wrap="nowrap"
px={18}
py={15}
style={{ borderBottom: "1px solid #EFF3F7" }}
>
<Group gap={9} wrap="nowrap" style={{ minWidth: 0 }}>
<FileText size={16} color="#0A8A5F" />
<Box style={{ minWidth: 0 }}>
<Text fz={14} fw={700} c="edr-text">
Customer documents
</Text>
<Text fz={11.5} c="#93A4B5" truncate>
{stats.approved} of {stats.total} approved
{stats.queried > 0 ? ` · ${stats.queried} queried` : ""} · required
marked *
</Text>
</Box>
)}
{customerDocs.length === 0 ? (
<Text size="sm" c="dimmed">
No customer documents are required for this booking.
</Group>
<Group
gap={5}
wrap="nowrap"
px={8}
py={3}
style={{
flexShrink: 0,
borderRadius: 6,
background: approvalsLocked ? "#F4F7FA" : "#E7F5EF",
}}
>
<Box
style={{
width: 6,
height: 6,
borderRadius: 999,
background: approvalsLocked ? "#93A4B5" : "#0A8A5F",
}}
/>
<Text
fz={10.5}
fw={700}
style={{ color: approvalsLocked ? "#67788A" : "#0A8A5F" }}
>
{approvalsLocked ? "Uploads closed" : "Uploads open"}
</Text>
) : (
customerDocs.map((doc) => (
<DocReviewCard
key={`${doc.settingCode}:${doc.fileKey}`}
doc={doc}
approvalsLocked={effectiveApprovalsLocked}
queriesLocked={queriesLocked}
readOnly={readOnly}
note={queryNotes[doc.fileKey] ?? ""}
queryOpen={openQuery[doc.fileKey] ?? false}
onToggleQuery={(open) =>
setOpenQuery((o) => ({ ...o, [doc.fileKey]: open }))
}
onNote={(v) =>
setQueryNotes((n) => ({ ...n, [doc.fileKey]: v }))
}
onApprove={() =>
reviewMutation.mutate({
fileKey: doc.fileKey,
status: "APPROVED",
})
}
onQuery={() =>
reviewMutation.mutate({
fileKey: doc.fileKey,
status: "QUERIED",
note: queryNotes[doc.fileKey],
})
}
onView={view}
busy={reviewMutation.isPending}
/>
))
)}
</Stack>
</SectionCard>
</Group>
</Group>
{!hideSummary && stats.total > 0 && (
<Box px={18} py={12} style={{ borderBottom: "1px solid #EFF3F7" }}>
<Progress value={stats.pct} color="edr-green" radius="xl" size="sm" mb={8} />
<Group gap="lg">
<StatPill color="edr-green" label="Approved" value={stats.approved} />
<StatPill color="red" label="Queried" value={stats.queried} />
<StatPill color="gray" label="Pending" value={stats.pending} />
</Group>
</Box>
)}
{customerDocs.length === 0 ? (
<Text size="sm" c="dimmed" px={18} py={20}>
No customer documents are required for this booking.
</Text>
) : (
customerDocs.map((doc, i) => (
<DocReviewCard
key={`${doc.settingCode}:${doc.fileKey}`}
doc={doc}
first={i === 0}
approvalsLocked={effectiveApprovalsLocked}
queriesLocked={queriesLocked}
readOnly={readOnly}
note={queryNotes[doc.fileKey] ?? ""}
queryOpen={openQuery[doc.fileKey] ?? false}
onToggleQuery={(open) =>
setOpenQuery((o) => ({ ...o, [doc.fileKey]: open }))
}
onNote={(v) => setQueryNotes((n) => ({ ...n, [doc.fileKey]: v }))}
onApprove={() =>
reviewMutation.mutate({ fileKey: doc.fileKey, status: "APPROVED" })
}
onQuery={() =>
reviewMutation.mutate({
fileKey: doc.fileKey,
status: "QUERIED",
note: queryNotes[doc.fileKey],
})
}
onView={view}
busy={reviewMutation.isPending}
/>
))
)}
</Paper>
{clearance.outputCode && !phasedCustoms && (
<SectionCard
@@ -559,6 +591,23 @@ function StatPill({
);
}
/** Row tints straight from the design tokens. */
const ROW_TONE: Record<
Freight.DocumentReviewStatus,
{ bg: string; chipBg: string; fg: string }
> = {
APPROVED: { bg: "#FFFFFF", chipBg: "#E7F5EF", fg: "#0A8A5F" },
QUERIED: { bg: "#FBECEA", chipBg: "#FBECEA", fg: "#C0392B" },
PENDING: { bg: "#FFFFFF", chipBg: "#FCF2E2", fg: "#A76F08" },
};
/**
* One document as a compact 60px row that expands in place. Collapsed it shows
* name, file line, status chip and the review actions; expanded it reveals the
* per-document history timeline and the query note. Keeping the actions in the
* collapsed row means approving a stack of documents never needs a single
* expand.
*/
function DocReviewCard({
doc,
approvalsLocked,
@@ -572,6 +621,7 @@ function DocReviewCard({
onQuery,
onView,
busy,
first,
}: {
doc: Freight.ClearanceDocument;
approvalsLocked: boolean;
@@ -585,146 +635,177 @@ function DocReviewCard({
onQuery: () => void;
onView: (file: { name: string; url: string }) => void;
busy: boolean;
first: boolean;
}) {
const status = doc.reviewStatus ?? "PENDING";
const meta = STATUS_META[status];
const tone = ROW_TONE[status];
const hasFile = !!doc.file;
const isApproved = status === "APPROVED";
const history = doc.history ?? [];
// A queried document is the one the reviewer must act on, so it opens itself.
const [open, setOpen] = useState(status === "QUERIED");
const expandable = history.length > 0 || Boolean(doc.note);
// Opening the query form has to reveal the body it lives in.
const bodyOpen = open || queryOpen;
// The file line carries the same at-a-glance summary as the design: file
// name, who decided, when.
const last = history[history.length - 1];
const fileLine = hasFile
? [
doc.file!.name,
status === "APPROVED" && last ? `Approved by ${last.byName ?? "staff"}` : null,
status === "PENDING" ? "awaiting review" : null,
status === "QUERIED" ? doc.note : null,
last ? formatDateTime(last.at) : null,
]
.filter(Boolean)
.join(" · ")
: "Not uploaded by customer";
return (
<Paper
withBorder
radius="md"
p="md"
<Box
style={{
borderColor:
status === "QUERIED"
? "var(--mantine-color-red-2)"
: status === "APPROVED"
? "var(--mantine-color-edr-green-2)"
: "var(--mantine-color-edr-border-6)",
background: tone.bg,
borderTop: first ? undefined : "1px solid #EFF3F7",
}}
>
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Group gap={12} wrap="nowrap" style={{ minWidth: 0 }}>
<ThemeIcon
variant="light"
color={hasFile ? "edr-green" : "gray"}
radius="md"
size={40}
>
<FileText size={19} />
</ThemeIcon>
<Box style={{ minWidth: 0 }}>
<Text fz="14px" fw={700} c="edr-text" truncate>
{doc.label}
{doc.required ? " *" : ""}
</Text>
<Text fz="12px" c="edr-muted" truncate>
{hasFile ? doc.file!.name : "Not uploaded by customer"}
</Text>
</Box>
</Group>
<Group gap={12} wrap="nowrap" align="center" px={18} py={13}>
<Box
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
width: 34,
height: 34,
borderRadius: 9,
background: tone.chipBg,
color: tone.fg,
}}
>
<FileText size={16} />
</Box>
<Group gap={8} wrap="nowrap">
<Badge variant="light" color={meta.color} radius="sm">
{meta.label}
</Badge>
{hasFile &&
isViewable({
name: doc.file!.name,
url: "",
}) && (
<Tooltip label="Preview document">
<Button
size="compact-xs"
variant="default"
radius="md"
leftSection={<Eye size={13} />}
onClick={() =>
void fetchViewableFile(doc.file!.id, doc.file!.name).then(
onView,
)
}
>
View
</Button>
</Tooltip>
)}
<Box style={{ flex: 1, minWidth: 0 }}>
<Text fz={12.5} fw={600} c="edr-text" truncate>
{doc.label}
{doc.required ? " *" : ""}
</Text>
<Text fz={11} c="#93A4B5" truncate>
{fileLine}
</Text>
</Box>
<Badge
variant="light"
radius="xl"
color={meta.color}
styles={{ root: { flexShrink: 0 } }}
>
{meta.label}
</Badge>
<Group gap={6} wrap="nowrap" style={{ flexShrink: 0 }}>
{hasFile && !readOnly && !isApproved && !approvalsLocked && (
<Button
size="compact-sm"
radius={7}
color="edr-green"
leftSection={<CheckCircle2 size={12} />}
disabled={busy}
onClick={onApprove}
>
Approve
</Button>
)}
{hasFile && !readOnly && !queriesLocked && !queryOpen && (
<Button
size="compact-sm"
radius={7}
variant="default"
disabled={busy}
onClick={() => {
onToggleQuery(true);
setOpen(true);
}}
>
Query
</Button>
)}
{hasFile && isViewable({ name: doc.file!.name, url: "" }) && (
<Tooltip label="Preview document">
<ActionIcon
variant="default"
radius={7}
size={29}
onClick={() =>
void fetchViewableFile(doc.file!.id, doc.file!.name).then(onView)
}
>
<Eye size={13} />
</ActionIcon>
</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",
}}
<ActionIcon
variant="default"
radius={7}
size={29}
onClick={() => void downloadBookingFile(doc.file!.id, doc.file!.name)}
>
<Download size={15} />
</Box>
<Download size={13} />
</ActionIcon>
</Tooltip>
)}
{expandable && (
<Tooltip label={bodyOpen ? "Hide history" : "Show history"}>
<ActionIcon
variant="subtle"
color="gray"
radius={7}
size={29}
aria-expanded={bodyOpen}
aria-label={bodyOpen ? "Hide history" : "Show history"}
onClick={() => setOpen((o) => !o)}
>
<ChevronDown
size={14}
style={{
transition: "transform 150ms",
transform: bodyOpen ? "rotate(180deg)" : undefined,
}}
/>
</ActionIcon>
</Tooltip>
)}
</Group>
</Group>
{(doc.history?.length ?? 0) > 0 && (
<DocHistoryTimeline history={doc.history!} />
)}
<Collapse expanded={bodyOpen}>
<Box px={18} pb={14} pl={64}>
{status === "QUERIED" && doc.note ? (
<Alert
color="red"
variant="light"
radius="md"
icon={<MessageSquareWarning size={15} />}
p="xs"
mb="sm"
>
<Text fz={12.5} c="red.9">
{doc.note}
</Text>
</Alert>
) : null}
{status === "QUERIED" && doc.note && (
<Alert
mt="sm"
color="red"
variant="light"
radius="md"
icon={<MessageSquareWarning size={15} />}
p="xs"
>
<Text fz="12.5px" c="red.9">
{doc.note}
</Text>
</Alert>
)}
{history.length > 0 ? <DocHistoryTimeline history={history} /> : null}
{hasFile && !readOnly && (
<Box mt="sm">
{!queryOpen ? (
<Group justify="flex-end" gap={8}>
{!queriesLocked && (
<Button
size="compact-sm"
variant="light"
color="red"
radius="md"
leftSection={<MessageSquareWarning size={14} />}
disabled={busy}
onClick={() => onToggleQuery(true)}
>
Open query
</Button>
)}
{!isApproved && !approvalsLocked && (
<Button
size="compact-sm"
color="edr-green"
radius="md"
leftSection={<CheckCircle2 size={14} />}
disabled={busy}
onClick={onApprove}
>
Approve
</Button>
)}
</Group>
) : (
{queryOpen && !readOnly ? (
<Box
mt="sm"
p="sm"
style={{
borderRadius: 12,
@@ -733,11 +814,8 @@ function DocReviewCard({
}}
>
<Group gap={6} mb={6}>
<MessageSquareWarning
size={14}
color="var(--mantine-color-red-7)"
/>
<Text fz="12.5px" fw={700} c="red.8">
<MessageSquareWarning size={14} color="var(--mantine-color-red-7)" />
<Text fz={12.5} fw={700} c="red.8">
Describe the problem for the customer
</Text>
</Group>
@@ -775,9 +853,9 @@ function DocReviewCard({
</Button>
</Group>
</Box>
)}
) : null}
</Box>
)}
</Paper>
</Collapse>
</Box>
);
}

View File

@@ -1,6 +1,7 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Alert,
Badge,
Box,
Button,
@@ -12,6 +13,7 @@ import {
Select,
Stack,
Text,
TextInput,
Tooltip,
} from "@mantine/core";
import {
@@ -19,9 +21,11 @@ import {
Download,
Eye,
FileText,
Lock,
Receipt,
Send,
Upload,
XCircle,
} from "lucide-react";
import toast from "react-hot-toast";
import type { Freight } from "@edr/types";
@@ -42,24 +46,87 @@ const STATUS_META: Record<
{ label: string; color: string }
> = {
DOC_UPLOADED: { label: "Awaiting billing", color: "yellow" },
BILLED: { label: "Ready to send", color: "blue" },
SENT: { label: "Sent — unpaid", color: "orange" },
BILLED: { label: "Draft — not sent", color: "blue" },
SENT: { label: "Awaiting customer approval", color: "orange" },
REJECTED: { label: "Rejected by customer", color: "red" },
ACCEPTED: { label: "Accepted — invoice unpaid", color: "teal" },
PAID: { label: "Paid", color: "edr-green" },
};
/** Once the customer accepts, the invoice exists and GL can no longer edit. */
const isLocked = (s: Freight.ClearanceChargeStatus) =>
s === "ACCEPTED" || s === "PAID";
type BillInput = { amount: number; currency: string; description: string };
/**
* A blob URL for a not-yet-uploaded File, so the staff member can open it in
* the shared viewer before committing the upload. Revoked whenever the pick
* changes or the form unmounts — a leaked object URL pins the whole file in
* memory for the life of the tab.
*/
function useLocalPreview(file: File | null) {
const [url, setUrl] = useState<string | null>(null);
useEffect(() => {
if (!file) {
setUrl(null);
return;
}
const next = URL.createObjectURL(file);
setUrl(next);
return () => URL.revokeObjectURL(next);
}, [file]);
return file && url ? { name: file.name, url, mimeType: file.type } : null;
}
/** "Preview" for a staged file — same viewer the uploaded documents open in. */
function StagedFilePreview({
file,
onViewFile,
}: {
file: File | null;
onViewFile: (file: { name: string; url: string; mimeType?: string | null }) => void;
}) {
const preview = useLocalPreview(file);
if (!file) return null;
return (
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
<FileText size={15} color="var(--mantine-color-edr-green-6)" />
<Text fz="12.5px" c="edr-text" truncate style={{ minWidth: 0, maxWidth: 220 }}>
{file.name}
</Text>
{preview && isViewable({ name: file.name, url: "" }) ? (
<Tooltip label="Preview before uploading">
<Button
size="compact-xs"
variant="light"
color="edr-green"
radius="md"
leftSection={<Eye size={13} />}
onClick={() => onViewFile(preview)}
>
Preview
</Button>
</Tooltip>
) : null}
</Group>
);
}
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;
onViewFile: (file: { name: string; url: string; mimeType?: string | null }) => 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.
* Post-finalization charges billed to the customer: port charges (document
* from GL Djibouti, priced by GL Ethiopia) and any number of miscellaneous
* charges. GL prices + describes a charge and sends it; the customer accepts
* (invoice issued, charge locked) or rejects with a note (GL revises and
* re-sends). ETB settles through the portal gateway (CBE), other currencies
* through Finance's manual settlement.
*/
export function ClearanceChargesTab({
bookingId,
@@ -67,6 +134,8 @@ export function ClearanceChargesTab({
onViewFile,
}: ClearanceChargesTabProps) {
const qc = useQueryClient();
// Bumped after each create so the form remounts empty for the next charge.
const [miscCreated, setMiscCreated] = useState(0);
const { data: charges, isLoading } = useQuery({
queryKey: ["clearance-charges", bookingId],
queryFn: () => bookingsService.getClearanceCharges(bookingId),
@@ -87,8 +156,9 @@ export function ClearanceChargesTab({
onError,
});
const bill = useMutation({
mutationFn: (p: { chargeId: string; amount: number; currency: string }) =>
bookingsService.billClearanceCharge(bookingId, p.chargeId, p),
// Body must be exactly the DTO — the API rejects unknown keys like chargeId.
mutationFn: ({ chargeId, ...payload }: BillInput & { chargeId: string }) =>
bookingsService.billClearanceCharge(bookingId, chargeId, payload),
onSuccess: (next) => {
toast.success("Charge amount saved");
refresh(next);
@@ -99,16 +169,18 @@ export function ClearanceChargesTab({
mutationFn: (chargeId: string) =>
bookingsService.sendClearanceCharge(bookingId, chargeId),
onSuccess: (next) => {
toast.success("Invoice sent to the customer");
toast.success("Sent to the customer for approval");
refresh(next);
},
onError,
});
const createMisc = useMutation({
mutationFn: (p: { file: File; amount: number; currency: string }) =>
bookingsService.createMiscellaneousCharge(bookingId, p.file, p),
mutationFn: ({ file, ...payload }: BillInput & { file: File }) =>
bookingsService.createMiscellaneousCharge(bookingId, file, payload),
onSuccess: (next) => {
toast.success("Miscellaneous charge created");
// Remount the form so the next charge starts from an empty one.
setMiscCreated((n) => n + 1);
refresh(next);
},
onError,
@@ -124,7 +196,9 @@ export function ClearanceChargesTab({
}
const port = (charges ?? []).find((c) => c.type === "PORT_CHARGES") ?? null;
const misc = (charges ?? []).find((c) => c.type === "MISCELLANEOUS") ?? null;
const miscCharges = (charges ?? []).filter(
(c) => c.type === "MISCELLANEOUS",
);
const busy =
uploadPort.isPending || bill.isPending || send.isPending || createMisc.isPending;
@@ -137,7 +211,7 @@ export function ClearanceChargesTab({
return (
<Stack gap="md" maw={860}>
<ChargeCard
title="1 · Port charges"
title="Port charges"
charge={port}
roleMode={roleMode}
busy={busy}
@@ -147,63 +221,61 @@ export function ClearanceChargesTab({
: "Waiting for GL Djibouti to upload the port-charges document."
}
onViewFile={onViewFile}
onBill={(amount, currency) =>
port && bill.mutate({ chargeId: port.id, amount, currency })
}
onBill={(input) => port && bill.mutate({ chargeId: port.id, ...input })}
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 })
}
<PortDocumentUpload
replacing={Boolean(port)}
busy={busy}
uploading={uploadPort.isPending}
onViewFile={onViewFile}
onUpload={(f) => uploadPort.mutate(f)}
/>
) : null
}
/>
{/* Any number of miscellaneous charges, in any order relative to the
port charge — each is billed and paid on its own. */}
{miscCharges.map((c, i) => (
<ChargeCard
key={c.id}
title={
miscCharges.length > 1
? `Miscellaneous charge ${i + 1}`
: "Miscellaneous charge"
}
charge={c}
roleMode={roleMode}
busy={busy}
emptyHint=""
onViewFile={onViewFile}
onBill={(input) => bill.mutate({ chargeId: c.id, ...input })}
onSend={() => send.mutate(c.id)}
/>
))}
{roleMode === "ET" && (
<Paper withBorder radius="md" p="md">
<Text fz="14px" fw={700} c="edr-text" mb={4}>
{miscCharges.length > 0
? "Add another miscellaneous charge"
: "Add a miscellaneous charge"}
</Text>
<Text fz="12px" c="dimmed" mb="sm">
Upload the supporting document, set the amount and say what it is
for. The customer sees it once you send it for approval.
</Text>
<MiscCreateForm
key={miscCreated}
busy={createMisc.isPending}
onViewFile={onViewFile}
onCreate={(file, input) => createMisc.mutate({ file, ...input })}
/>
</Paper>
)}
{totals.size > 0 && (
<Paper withBorder radius="md" p="md">
<Group justify="space-between">
@@ -227,6 +299,66 @@ export function ClearanceChargesTab({
);
}
/**
* Port-charges document: pick, preview, then upload. The pick is staged rather
* than sent straight away so the wrong scan can be caught before it lands on
* the customer's charge.
*/
function PortDocumentUpload({
replacing,
busy,
uploading,
onViewFile,
onUpload,
}: {
replacing: boolean;
busy: boolean;
uploading: boolean;
onViewFile: (file: { name: string; url: string; mimeType?: string | null }) => void;
onUpload: (file: File) => void;
}) {
const [file, setFile] = useState<File | null>(null);
return (
<Group gap={8} align="center" wrap="wrap">
<FileButton
onChange={setFile}
accept="application/pdf,image/*"
disabled={busy}
>
{(props) => (
<Button
{...props}
size="compact-sm"
variant={file ? "light" : "filled"}
color="edr-green"
radius="md"
leftSection={<Upload size={14} />}
>
{file ? "Choose another" : replacing ? "Replace document" : "Choose document"}
</Button>
)}
</FileButton>
<StagedFilePreview file={file} onViewFile={onViewFile} />
{file ? (
<Button
size="compact-sm"
color="edr-green"
radius="md"
loading={uploading}
disabled={busy}
onClick={() => {
onUpload(file);
setFile(null);
}}
>
{replacing ? "Upload replacement" : "Upload document"}
</Button>
) : null}
</Group>
);
}
function ChargeCard({
title,
charge,
@@ -244,8 +376,8 @@ function ChargeCard({
roleMode: "ET" | "DJ";
busy: boolean;
emptyHint: string;
onViewFile: (file: { name: string; url: string }) => void;
onBill: (amount: number, currency: string) => void;
onViewFile: (file: { name: string; url: string; mimeType?: string | null }) => void;
onBill: (input: BillInput) => void;
onSend: () => void;
djUpload?: React.ReactNode;
etCreate?: React.ReactNode;
@@ -253,13 +385,17 @@ function ChargeCard({
const [editing, setEditing] = useState(false);
const [amount, setAmount] = useState<number | string>(charge?.amount ?? "");
const [currency, setCurrency] = useState<string>(charge?.currency ?? "ETB");
const [description, setDescription] = useState(charge?.description ?? "");
const status = charge?.status ?? null;
const meta = status ? STATUS_META[status] : null;
// ET enters/revises the amount while the charge is unpaid.
const locked = status != null && isLocked(status);
const needsDescription = charge?.type === "MISCELLANEOUS";
// ET enters/revises the price until the customer accepts it.
const showBillForm =
roleMode === "ET" &&
charge != null &&
!locked &&
(charge.status === "DOC_UPLOADED" || editing);
return (
@@ -284,6 +420,12 @@ function ChargeCard({
{formatDateTime(charge.billedAt)}
</Text>
)}
{charge?.status === "ACCEPTED" && charge.customerDecidedAt && (
<Text fz="11.5px" c="teal.8" fw={600}>
Accepted by the customer · {formatDateTime(charge.customerDecidedAt)}
{charge.invoiceNumber ? ` (invoice ${charge.invoiceNumber})` : ""}
</Text>
)}
{charge?.paidAt && (
<Text fz="11.5px" c="edr-green.8" fw={600}>
Paid · {formatDateTime(charge.paidAt)}
@@ -359,6 +501,32 @@ function ChargeCard({
</Group>
)}
{charge?.description && !showBillForm && (
<Text fz="12.5px" c="edr-text" mt="xs">
{charge.description}
</Text>
)}
{charge?.status === "REJECTED" && charge.customerNote && (
<Alert
color="red"
variant="light"
radius="md"
p="xs"
mt="sm"
icon={<XCircle size={16} />}
title="Rejected by the customer"
>
<Text fz="12.5px">{charge.customerNote}</Text>
{charge.customerDecidedAt && (
<Text fz="11px" c="dimmed" mt={4}>
{formatDateTime(charge.customerDecidedAt)} fix the price or
description and send it again.
</Text>
)}
</Alert>
)}
{!charge && (
<Text fz="12.5px" c="dimmed" mt="xs">
{emptyHint}
@@ -369,6 +537,15 @@ function ChargeCard({
{showBillForm && (
<Group mt="sm" gap={8} align="flex-end" wrap="wrap">
<TextInput
label={needsDescription ? "What is this charge for?" : "Description (optional)"}
size="xs"
radius="md"
value={description}
onChange={(e) => setDescription(e.currentTarget.value)}
maxLength={1000}
w={320}
/>
<NumberInput
label="Amount"
size="xs"
@@ -392,13 +569,21 @@ function ChargeCard({
size="compact-sm"
color="edr-green"
radius="md"
disabled={busy || !(Number(amount) > 0)}
disabled={
busy ||
!(Number(amount) > 0) ||
(needsDescription && !description.trim())
}
onClick={() => {
onBill(Number(amount), currency);
onBill({
amount: Number(amount),
currency,
description: description.trim(),
});
setEditing(false);
}}
>
Save amount
Save
</Button>
{editing && (
<Button
@@ -415,7 +600,7 @@ function ChargeCard({
</Group>
)}
{roleMode === "ET" && charge && !showBillForm && charge.status !== "PAID" && (
{roleMode === "ET" && charge && !showBillForm && !locked && (
<Group mt="sm" gap={8} justify="flex-end">
<Button
size="compact-sm"
@@ -426,13 +611,14 @@ function ChargeCard({
onClick={() => {
setAmount(charge.amount ?? "");
setCurrency(charge.currency ?? "ETB");
setDescription(charge.description ?? "");
setEditing(true);
}}
>
{charge.status === "SENT" ? "Revise (cancels invoice)" : "Edit amount"}
{charge.status === "SENT" ? "Revise" : "Edit"}
</Button>
{charge.status === "BILLED" && (
<Tooltip label="ETB is payable online via CBE; other currencies go to Finance's manual settlement.">
{(charge.status === "BILLED" || charge.status === "REJECTED") && (
<Tooltip label="The customer accepts or rejects the price in the portal; the invoice is issued when they accept.">
<Button
size="compact-sm"
color="edr-green"
@@ -441,15 +627,20 @@ function ChargeCard({
disabled={busy}
onClick={onSend}
>
Send invoice to customer
{charge.status === "REJECTED"
? "Send again for approval"
: "Send to customer for approval"}
</Button>
</Tooltip>
)}
{charge.status === "SENT" && charge.invoiceNumber && (
<Badge variant="light" color="orange" radius="sm">
Invoice {charge.invoiceNumber}
</Badge>
)}
</Group>
)}
{charge?.status === "ACCEPTED" && (
<Group mt="sm" gap={6} justify="flex-end">
<Lock size={14} color="var(--mantine-color-teal-7)" />
<Text fz="12px" c="teal.8" fw={600}>
Locked invoice {charge.invoiceNumber ?? ""} awaiting payment
</Text>
</Group>
)}
{charge?.status === "PAID" && (
@@ -467,16 +658,29 @@ function ChargeCard({
function MiscCreateForm({
busy,
onCreate,
onViewFile,
}: {
busy: boolean;
onCreate: (file: File, amount: number, currency: string) => void;
onCreate: (file: File, input: BillInput) => void;
onViewFile: (file: { name: string; url: string; mimeType?: string | null }) => void;
}) {
const [file, setFile] = useState<File | null>(null);
const [amount, setAmount] = useState<number | string>("");
const [currency, setCurrency] = useState("ETB");
const [description, setDescription] = useState("");
return (
<Group gap={8} align="flex-end" wrap="wrap">
<TextInput
label="What is this charge for?"
placeholder="e.g. Container cleaning and weighbridge fee"
size="xs"
radius="md"
value={description}
onChange={(e) => setDescription(e.currentTarget.value)}
maxLength={1000}
w={320}
/>
<FileButton onChange={setFile} accept="application/pdf,image/*" disabled={busy}>
{(props) => (
<Button
@@ -487,10 +691,11 @@ function MiscCreateForm({
radius="md"
leftSection={<Upload size={14} />}
>
{file ? file.name : "Choose document"}
{file ? "Choose another" : "Choose document"}
</Button>
)}
</FileButton>
<StagedFilePreview file={file} onViewFile={onViewFile} />
<NumberInput
label="Amount"
size="xs"
@@ -514,9 +719,16 @@ function MiscCreateForm({
size="compact-sm"
color="edr-green"
radius="md"
disabled={busy || !file || !(Number(amount) > 0)}
disabled={busy || !file || !(Number(amount) > 0) || !description.trim()}
loading={busy}
onClick={() => file && onCreate(file, Number(amount), currency)}
onClick={() =>
file &&
onCreate(file, {
amount: Number(amount),
currency,
description: description.trim(),
})
}
>
Create charge
</Button>

View File

@@ -29,7 +29,7 @@ export interface ClearanceOpsTabsProps {
*/
exchangeEntityId?: string;
tradeDirection?: string;
onViewFile?: (file: { name: string; url: string }) => void;
onViewFile?: (file: { name: string; url: string; mimeType?: string | null }) => void;
onDownloadFile?: (file: { id: string; name: string }) => void;
}

View File

@@ -2,7 +2,11 @@ import { Check } from "lucide-react";
import { Box, Group, Stack, Text } from "@mantine/core";
import type { Freight } from "@edr/types";
const BRAND_GREEN = "var(--freight-brand, #0A6F4D)";
const GREEN = "#0A8A5F";
const BLUE = "#1D6FD1";
const BORDER = "#E4EBF1";
const MUTED = "#93A4B5";
const INK = "#10202F";
const IMPORT_PHASES = [
"CUSTOMER_INTAKE",
@@ -24,6 +28,18 @@ const PHASE_LABELS: Record<string, string> = {
POST_TRANSIT: "Transit",
};
/** Which desk owns each phase — shown under the label, as in the design. */
const PHASE_ACTOR: Record<string, string> = {
CUSTOMER_INTAKE: "CUSTOMER",
GL_ET_REVIEW: "GL ET",
GL_ET_OUTPUT: "GL ET",
CUSTOMER_DUTY: "CUSTOMER",
GL_ET_POST_CLEARANCE: "GL ET",
GL_DJ_COLLECTION: "GL DJ",
GL_DJ_LOADING: "GL DJ",
POST_TRANSIT: "OPS",
};
const EXPORT_PHASES = [
"CUSTOMER_INTAKE",
"GL_ET_REVIEW",
@@ -38,6 +54,20 @@ function phaseIndex(phases: readonly string[], current?: string | null): number
return idx >= 0 ? idx : 0;
}
/** Half-width connector; only the segment behind a completed dot is green. */
function Line({ done, hidden }: { done: boolean; hidden: boolean }) {
return (
<Box
style={{
flex: 1,
height: 2,
borderRadius: 2,
background: hidden ? "transparent" : done ? GREEN : BORDER,
}}
/>
);
}
export function ClearancePhaseStepper({
clearance,
tradeDirection,
@@ -50,61 +80,69 @@ export function ClearancePhaseStepper({
const phases = tradeDirection === "EXPORT" ? EXPORT_PHASES : IMPORT_PHASES;
const current = clearance?.phase ?? phases[0];
const activeIdx = phaseIndex(phases, current);
const dot = compact ? 26 : 28;
return (
<Group gap={0} wrap="nowrap" align="flex-start" style={{ overflowX: "auto" }}>
{phases.map((phase, index) => {
const isComplete = index < activeIdx;
const isActive = index === activeIdx;
const isLast = index === phases.length - 1;
const actor = PHASE_ACTOR[phase];
return (
<Box key={phase} style={{ flex: isLast ? "0 0 auto" : 1, minWidth: compact ? 72 : 88 }}>
<Group gap={0} wrap="nowrap" align="center">
<Stack gap={4} align="center" style={{ flexShrink: 0 }}>
<Box
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: compact ? 28 : 34,
height: compact ? 28 : 34,
borderRadius: "50%",
background: isComplete ? BRAND_GREEN : isActive ? "white" : "var(--mantine-color-gray-1)",
border: isActive
? `2px solid ${BRAND_GREEN}`
: isComplete
? "2px solid transparent"
: "2px solid var(--mantine-color-gray-3)",
color: isComplete ? "white" : isActive ? BRAND_GREEN : "var(--mantine-color-gray-5)",
}}
>
{isComplete ? <Check size={compact ? 14 : 16} strokeWidth={3} /> : null}
</Box>
<Text
size={compact ? "10px" : "xs"}
fw={isActive ? 600 : 500}
c={isActive ? "edr-green.7" : isComplete ? "dark" : "dimmed"}
ta="center"
style={{ whiteSpace: "nowrap" }}
>
{PHASE_LABELS[phase] ?? phase}
</Text>
</Stack>
{!isLast && (
<Box
style={{
flex: 1,
height: 2,
marginInline: 6,
marginBottom: compact ? 16 : 20,
borderRadius: 2,
background: isComplete ? BRAND_GREEN : "var(--mantine-color-gray-2)",
}}
/>
)}
<Stack
key={phase}
gap={7}
align="center"
style={{ flex: 1, minWidth: compact ? 92 : 112 }}
>
{/* Dot sits centred on its own row so the connectors meet it edge-to-edge. */}
<Group gap={0} wrap="nowrap" align="center" style={{ width: "100%" }}>
<Line done={isComplete || isActive} hidden={index === 0} />
<Box
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
width: dot,
height: dot,
borderRadius: 999,
background: isComplete ? GREEN : "#FFFFFF",
border: `2px solid ${
isComplete ? GREEN : isActive ? BLUE : BORDER
}`,
color: isComplete ? "#FFFFFF" : isActive ? BLUE : MUTED,
fontSize: 12,
fontWeight: 700,
lineHeight: 1,
}}
>
{isComplete ? <Check size={14} strokeWidth={3} /> : index + 1}
</Box>
<Line done={isComplete} hidden={index === phases.length - 1} />
</Group>
</Box>
<Text
fz={10.5}
fw={700}
lh={1.3}
ta="center"
style={{ color: isActive || isComplete ? INK : MUTED }}
>
{PHASE_LABELS[phase] ?? phase}
</Text>
{actor ? (
<Text
fz={9}
fw={700}
lts="0.3px"
style={{ color: isActive ? BLUE : MUTED, marginTop: -3 }}
>
{actor}
</Text>
) : null}
</Stack>
);
})}
</Group>

View File

@@ -1,20 +1,21 @@
import { useMemo, useState } from "react";
import {
useMemo,
useState,
} from "react";
import {
Alert,
Badge,
Button,
FileInput,
Group,
Modal,
NumberInput,
Paper,
Select,
Stack,
Stepper,
Text,
Textarea,
} from "@mantine/core";
import { DateInput } from "@mantine/dates";
import {
DateInput,
} from "@mantine/dates";
import {
AlertTriangle,
CheckCircle2,
@@ -30,9 +31,12 @@ import {
import type { Freight } from "@edr/types";
import toast from "react-hot-toast";
import { SectionCard } from "@/components/bookings/detail/SectionCard";
import { PhasedFileDropzone } from "@/components/contracts/PhasedFileDropzone";
import { TransitAssigneePanel } from "@/components/contracts/TransitAssigneePanel";
import {
SectionCard,
} from "@/components/bookings/detail/SectionCard";
import {
TransitAssigneePanel,
} from "@/components/contracts/TransitAssigneePanel";
import {
TransitPermitMultiUpload,
type TransitPermitUploadedRow,
@@ -51,8 +55,12 @@ import {
type ClearanceViewLike,
type MilestoneRow,
} from "@/components/contracts/PhasedClearanceActionPanel";
import { contractsService } from "@/services/contracts.service";
import { bookingsService } from "@/services/bookings.service";
import {
contractsService,
} from "@/services/contracts.service";
import {
bookingsService,
} from "@/services/bookings.service";
/** Today as `yyyy-MM-dd` in the browser's local zone (a DateInput `minDate`). */
function todayISODate(): string {
@@ -66,8 +74,11 @@ function todayISODate(): string {
* customer docs → transit assignee (DJ names officer) → declaration (ET,
* releases the export) → RO (DJ, auto-releases) → create booking (ET)
* → payment + wagons → transport document / T1 (ET) → train to Djibouti
* → accept T1 (DJ, one button after arrival) → gate pass (DJ)
* → final invoice (DJ) + customer slip + GL confirm.
* → accept T1 (DJ, one button after arrival) → gate pass (DJ) → offload.
*
* The post-offload GL Djibouti final invoice was removed from this flow: it
* never gated anything downstream, so the export now completes at the offload.
* Its API endpoints and any already-issued invoices are untouched.
*/
export function computeExportActiveStep(
clearance: ClearanceViewLike,
@@ -93,11 +104,10 @@ export function computeExportActiveStep(
if (!clearance.train?.arrivedAt) return 7;
if (!clearance.t1Closed) return 8;
if (!clearance.gatepassGranted) return 9;
// Step 10 is the read-only Offload step. It never gates the flow: the final
// invoice may be raised on a secured gate pass alone, so parking the stepper
// there would hide the invoice actions whenever operations lag on the offload.
if (clearance.finalInvoice?.status !== "PAID") return 11;
return 12;
// Step 10 is the read-only Offload step, recorded by operations. It never
// gated the flow and nothing follows it, so the stepper completes here rather
// than waiting on an offload stamp this desk does not control.
return 10;
}
export function exportTransitFilesFromWorkflow(
@@ -491,27 +501,6 @@ export function ExportClearanceStepper({
<OffloadStep clearance={clearance} />
</Stepper.Step>
<Stepper.Step
label="Final invoice & payment"
description="GL Djibouti invoices after offload; customer pays"
icon={
clearance.finalInvoice?.status === "PAID" ? (
<CheckCircle2 size={14} />
) : (
<Receipt size={14} />
)
}
>
<FinalInvoiceStep
bookingId={actionBookingId}
clearance={clearance}
canDjAct={showDj && canDj}
canConfirm={(showDj && canDj) || (showEt && canEt)}
onChanged={onChanged}
onViewFile={onViewFile}
onDownloadFile={onDownloadFile}
/>
</Stepper.Step>
</Stepper>
</Paper>
</Stack>
@@ -688,230 +677,6 @@ function AcceptT1Step({
);
}
function FinalInvoiceStep({
bookingId,
clearance,
canDjAct,
canConfirm,
onChanged,
onViewFile,
onDownloadFile,
}: {
bookingId: string | null;
clearance: ClearanceViewLike;
canDjAct: boolean;
canConfirm: boolean;
onChanged?: () => void;
onViewFile?: (file: { name: string; url: string }) => void;
onDownloadFile?: (file: { id: string; name: string }) => void;
}) {
const [opened, setOpened] = useState(false);
const [amount, setAmount] = useState<number | string>("");
const [currency, setCurrency] = useState("ETB");
const [description, setDescription] = useState("");
const [file, setFile] = useState<File | null>(null);
const [sending, setSending] = useState(false);
const [confirming, setConfirming] = useState(false);
const invoice = clearance.finalInvoice ?? null;
const paid = invoice?.status === "PAID";
// Raised as a draft — the customer approves it before paying.
const approved = Boolean(invoice?.approvedAt);
// Export: OFFLOADED is a DJ doc milestone that may never be recorded, so the
// secured gate pass is enough to open invoicing. Sending an invoice is optional.
if (!clearance.offloaded && !clearance.gatepassGranted && !invoice) {
return (
<StepStatus
done={false}
pendingLabel="Waiting for cargo offload (handled in operations)."
doneLabel=""
/>
);
}
return (
<Stack gap="sm">
{invoice ? (
<Paper withBorder radius="md" p="sm">
<Group justify="space-between" wrap="nowrap">
<div>
<Text fw={700} size="sm">
{invoice.invoiceNumber}
</Text>
<Text size="sm" c="dimmed">
{invoice.totalAmount.toLocaleString()} {invoice.currency}
{invoice.description ? `${invoice.description}` : ""}
</Text>
</div>
<Badge color={paid ? "edr-green" : "yellow"} variant="light">
{approved ? invoice.status : "AWAITING CUSTOMER APPROVAL"}
</Badge>
</Group>
</Paper>
) : null}
{invoice?.invoiceFile ? (
<PhasedUploadedFileRow
label="Final Invoice"
file={invoice.invoiceFile}
onView={onViewFile}
onDownload={onDownloadFile}
compact
/>
) : null}
{invoice?.slipFile ? (
<PhasedUploadedFileRow
label="Customer payment slip"
file={invoice.slipFile}
onView={onViewFile}
onDownload={onDownloadFile}
compact
/>
) : null}
{paid ? (
<StepStatus
done
pendingLabel=""
doneLabel={`Payment confirmed${
invoice?.confirmedAt ? ` · ${new Date(invoice.confirmedAt).toLocaleString()}` : ""
}`}
/>
) : invoice ? (
<>
<StepStatus
done={false}
pendingLabel={
!approved
? "Waiting for the customer to review and approve the invoice."
: invoice.slipFile
? "Payment slip attached — confirm to settle the invoice."
: "Waiting for the customer to pay and attach the payment slip."
}
doneLabel=""
/>
{canConfirm && bookingId && invoice.slipFile ? (
<Button
color="edr-green"
loading={confirming}
leftSection={<CheckCircle2 size={16} />}
onClick={async () => {
setConfirming(true);
try {
await contractsService.confirmFinalInvoicePaid(bookingId);
toast.success("Payment confirmed — invoice settled");
onChanged?.();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed");
} finally {
setConfirming(false);
}
}}
>
Confirm payment received
</Button>
) : null}
</>
) : canDjAct && bookingId ? (
<>
<Text size="sm" c="dimmed">
Send the final invoice to the customer if post-arrival charges apply (optional).
The customer approves it before paying.
</Text>
<Button
color="edr-green"
leftSection={<Receipt size={16} />}
onClick={() => setOpened(true)}
>
Send invoice
</Button>
<Modal
opened={opened}
onClose={() => setOpened(false)}
title={<Text fw={700}>Send final invoice</Text>}
radius="md"
size="md"
>
<Stack gap="md">
<Group grow align="flex-start">
<NumberInput
label="Amount"
placeholder="0.00"
value={amount}
onChange={setAmount}
min={0}
thousandSeparator=","
required
/>
<Select
label="Currency"
data={["ETB", "USD"]}
value={currency}
onChange={(v) => setCurrency(v ?? "ETB")}
/>
</Group>
<Textarea
label="Description"
placeholder="What the invoice bills for"
value={description}
onChange={(e) => setDescription(e.currentTarget.value)}
minRows={2}
/>
<PhasedFileDropzone
label="Invoice document"
description="Any file type."
accept="*/*"
value={file}
onChange={setFile}
onPreview={onViewFile}
/>
<Group justify="flex-end">
<Button variant="default" onClick={() => setOpened(false)} disabled={sending}>
Cancel
</Button>
<Button
color="edr-green"
loading={sending}
disabled={amount === "" || Number(amount) <= 0 || !file}
leftSection={<Upload size={16} />}
onClick={async () => {
if (!file) return;
setSending(true);
try {
await contractsService.sendFinalInvoice(bookingId, {
amount: Number(amount),
currency,
description: description.trim() || undefined,
file,
});
toast.success("Final invoice sent to the customer");
setOpened(false);
onChanged?.();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed");
} finally {
setSending(false);
}
}}
>
Send invoice
</Button>
</Group>
</Stack>
</Modal>
</>
) : (
<StepStatus
done={false}
pendingLabel="Waiting for GL Djibouti to send the final invoice."
doneLabel=""
/>
)}
</Stack>
);
}
export function ReleaseOrderActions({
entityId,
isBooking,

View File

@@ -301,8 +301,9 @@ export default function GlCreateBookingForm() {
const [trainScheduleId, setTrainScheduleId] = useState("");
const [contractRouteId, setContractRouteId] = useState<string | null>(null);
const [notes, setNotes] = useState("");
// ponytail: ETB-only for now — widen back to "USD" | "ETB" when multi-currency billing returns.
const [paymentCurrency, setPaymentCurrency] = useState<"USD" | "ETB">("ETB");
// IMPORT bookings pick ETB or USD — starts empty so the choice is
// deliberate (required before pricing). Everything else is forced to ETB.
const [paymentCurrency, setPaymentCurrency] = useState<"USD" | "ETB" | "">("");
// What the containers carry — captured per booking (moved off the contract).
const [cargoDescription, setCargoDescription] = useState("");
const [containerLines, setContainerLines] = useState<ContainerLineDraft[]>([]);
@@ -873,15 +874,10 @@ export default function GlCreateBookingForm() {
// 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);
// falls through to the server's automatic consolidation gate.
const oddConsolidationAvailable = 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.
@@ -970,11 +966,11 @@ export default function GlCreateBookingForm() {
!cargoDescriptionError
: !bulkErrors.quantity && !bulkErrors.hazardous && !bulkErrors.reefer;
// 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;
// COMPLETION never blocks on an odd 20ft total: a customs instance can share
// the wagon via the manual pair (consolidationActive), and anything else is
// auto-paired or parked as PENDING_CONSOLIDATION by the server's
// consolidation gate. Creating a booking from scratch keeps the block.
const oddBlocksSubmit = hasOdd20ft && !completeBookingId;
// 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.
@@ -1027,8 +1023,21 @@ export default function GlCreateBookingForm() {
partnerCargoDescription,
]);
// Only IMPORT actually chooses — the rest bill ETB regardless of the state.
const effectiveCurrency: "USD" | "ETB" =
isImport && paymentCurrency ? paymentCurrency : "ETB";
const currencyError =
isImport && !paymentCurrency
? "Select the billing currency for this booking."
: undefined;
const formValid =
cargoValid && !oddBlocksSubmit && !dateError && !routeError && !partnerError;
cargoValid &&
!oddBlocksSubmit &&
!dateError &&
!routeError &&
!partnerError &&
!currencyError;
/** The create-booking DTO from the current form state — shared by the
* authoritative price preview and the actual submit so what GL confirms is
@@ -1038,7 +1047,7 @@ export default function GlCreateBookingForm() {
const payload: Freight.CreateBookingUnderContractDto = {
...(contractRouteId ? { contractRouteId } : {}),
paymentCurrency,
paymentCurrency: effectiveCurrency,
// Intercity bookings carry no date — staff assign a passing train later.
...(scheduledDate
? { scheduledDate: new Date(scheduledDate).toISOString() }
@@ -1105,7 +1114,7 @@ export default function GlCreateBookingForm() {
if (!partner || !consolidationActive) return null;
const payload: Freight.CreateBookingUnderContractDto = {
paymentCurrency,
paymentCurrency: effectiveCurrency,
...(scheduledDate
? { scheduledDate: new Date(scheduledDate).toISOString() }
: {}),
@@ -2143,10 +2152,11 @@ export default function GlCreateBookingForm() {
: "Shipments are invoiced in ETB."}
</Text>
<CurrencySelector
value={isIntercity ? "ETB" : paymentCurrency}
value={isImport ? paymentCurrency : "ETB"}
onChange={setPaymentCurrency}
disabled={isIntercity}
disabled={!isImport}
allowUsd={isImport}
error={currencyError}
/>
</Box>

View File

@@ -122,7 +122,10 @@ function phaseCountdown(w: WindowRow): {
}
/** Badge label + Mantine color per UI state — same state the countdown uses. */
const KIND_BADGE: Record<BookingWindowUiKind, { label: string; color: string }> = {
const KIND_BADGE: Record<
BookingWindowUiKind,
{ label: string; color: string }
> = {
OPEN: { label: "Open now", color: "edr-green" },
FULL: { label: "Train full", color: "red" },
PRE_WINDOW: { label: "Opens soon", color: "yellow" },
@@ -301,8 +304,12 @@ export function GlUpcomingWindowsSection({
// Order by the train's dispatch (departure) date, nearest first. Open-now
// breaks ties on the same departure.
return rows.sort((a, b) => {
const da = a.departureDate ? new Date(a.departureDate).getTime() : Infinity;
const db = b.departureDate ? new Date(b.departureDate).getTime() : Infinity;
const da = a.departureDate
? new Date(a.departureDate).getTime()
: Infinity;
const db = b.departureDate
? new Date(b.departureDate).getTime()
: Infinity;
if (da !== db) return da - db;
return Number(b.isOpenNow) - Number(a.isOpenNow);
});
@@ -319,14 +326,16 @@ export function GlUpcomingWindowsSection({
return (
<Card withBorder shadow="sm" radius="lg" p="lg">
<Group justify="space-between" align="flex-start" mb="md" wrap="nowrap">
<Group gap={8} wrap="nowrap">
<CalendarClock size={18} />
<Group justify="space-between" align="center" mb="md" wrap="nowrap">
<Group gap={10} wrap="nowrap">
<div className="flex size-8 shrink-0 items-center justify-center rounded-[9px] bg-edr-soft text-edr-primary-dark">
<CalendarClock size={16} />
</div>
<Box>
<Text fw={700} fz={16}>
<Text ff="heading" fw={600} fz={15} lh={1.2}>
Booking windows
</Text>
<Text fz={13} c="dimmed">
<Text fz={12} c="edr-muted">
{contractId
? "Booking windows on this contract's routes (EAT)"
: "Import and export booking windows across all lanes (EAT)"}
@@ -386,7 +395,11 @@ export function GlUpcomingWindowsSection({
))}
</SimpleGrid>
) : (
<SimpleGrid key={safePage} cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<SimpleGrid
key={safePage}
cols={{ base: 1, sm: 2, lg: 3 }}
spacing="md"
>
{visible.map((w) => (
<WindowCard key={`${w.scheduleId}-${w.bookingCycleNo}`} w={w} />
))}

View File

@@ -2,10 +2,12 @@ import { useEffect, useMemo, useState } from "react";
import {
Alert,
Badge,
Box,
Button,
Group,
NumberInput,
Paper,
Progress,
SegmentedControl,
Select,
Stack,
@@ -14,14 +16,9 @@ import {
Text,
TextInput,
} from "@mantine/core";
import { PhasedFileDropzone, PhasedMultiFileDropzone } from "@/components/contracts/PhasedFileDropzone";
import { TransitAssigneePanel } from "@/components/contracts/TransitAssigneePanel";
import {
TransitPermitMultiUpload,
type TransitPermitUploadedRow,
} from "@/components/contracts/TransitPermitMultiUpload";
import {
AlertTriangle,
ArrowRight,
CheckCircle2,
Clock,
FileText,
@@ -30,10 +27,17 @@ import {
PackageOpen,
Receipt,
ShieldAlert,
ShieldCheck,
Ship,
Truck,
Upload,
} from "lucide-react";
import { PhasedFileDropzone, PhasedMultiFileDropzone } from "@/components/contracts/PhasedFileDropzone";
import { TransitAssigneePanel } from "@/components/contracts/TransitAssigneePanel";
import {
TransitPermitMultiUpload,
type TransitPermitUploadedRow,
} from "@/components/contracts/TransitPermitMultiUpload";
import {
deliveryOrderFileLabel,
isDeliveryOrderFileCode,
@@ -113,6 +117,9 @@ export function isBookingMilestoneDone(
return m?.status === "COMPLETED" || m?.status === "SKIPPED";
}
/** Number of steps in the import stepper — drives the header progress bar. */
const IMPORT_STEP_COUNT = 12;
function computeImportActiveStep(
clearance: ClearanceViewLike,
bookingCreated: boolean,
@@ -322,19 +329,64 @@ export function PhasedClearanceActionPanel({
</Alert>
) : null}
{clearance.nextAction ? (
<Alert color="blue" variant="light" title="Next step">
<Text size="sm">
<strong>{clearance.nextAction.actor.replace("_", " ")}</strong> {" "}
{clearance.nextAction.action}
</Text>
</Alert>
) : null}
<Paper withBorder radius={13} p={0} style={{ overflow: "hidden" }}>
{/* Header: what this workflow is, and how far along it is. */}
<Group
justify="space-between"
wrap="nowrap"
px={18}
py={15}
style={{ borderBottom: "1px solid #EFF3F7" }}
>
<Group gap={9} wrap="nowrap" style={{ minWidth: 0 }}>
<ShieldCheck size={16} color="#0A8A5F" />
<Box style={{ minWidth: 0 }}>
<Text fz={14} fw={700} c="edr-text">
Import pre-booking clearance
</Text>
<Text fz={11.5} c="#93A4B5" truncate>
Step {Math.min(activeStep + 1, IMPORT_STEP_COUNT)} of{" "}
{IMPORT_STEP_COUNT}
{clearance.nextAction
? ` · ${clearance.nextAction.action}`
: ""}
</Text>
</Box>
</Group>
<Group gap={8} wrap="nowrap" style={{ flexShrink: 0 }}>
<Progress
value={Math.round((activeStep / IMPORT_STEP_COUNT) * 100)}
color="edr-green"
radius="xl"
size={6}
w={110}
/>
<Text fz={11.5} c="#67788A" fw={600}>
{Math.round((activeStep / IMPORT_STEP_COUNT) * 100)}%
</Text>
</Group>
</Group>
<Paper withBorder radius="md" p="md">
<Text fw={600} size="sm" mb="md">
Import pre-booking clearance
</Text>
{/* Whose desk the flow is sitting on right now. */}
{clearance.nextAction ? (
<Group
gap={10}
wrap="nowrap"
px={18}
py={12}
style={{ background: "#E9F1FC", borderBottom: "1px solid #EFF3F7" }}
>
<ArrowRight size={15} color="#1D6FD1" style={{ flexShrink: 0 }} />
<Text fz={10.5} fw={700} lts="0.4px" c="#1D6FD1" style={{ flexShrink: 0 }}>
{clearance.nextAction.actor.replace("_", " ").toUpperCase()}
</Text>
<Text fz={11.5} fw={600} c="edr-text" style={{ minWidth: 0 }}>
{clearance.nextAction.action}
</Text>
</Group>
) : null}
<Box p="md">
<Stepper
active={activeStep}
orientation="vertical"
@@ -826,7 +878,8 @@ export function PhasedClearanceActionPanel({
onDownloadFile={onDownloadFile}
/>
</Stepper.Step>
</Stepper>
</Stepper>
</Box>
</Paper>
</Stack>
);
@@ -1782,13 +1835,59 @@ function DraftDeclarationStep({
);
const [currency, setCurrency] = useState(clearance.draftDeclaration?.currency ?? "ETB");
const [loading, setLoading] = useState(false);
// OFF = don't send the customer a draft: the step is skipped, staff file the
// real declaration directly, and duty & tax passes by default with it.
const [sendDraft, setSendDraft] = useState(true);
const changeRequest = clearance.draftDeclarationChangeRequest;
const existingFiles = clearance.draftDeclaration?.files ?? [];
const replaceMode = existingFiles.length > 0;
if (!sendDraft && !replaceMode) {
return (
<Stack gap="md">
<Switch
label="Send the customer a draft declaration"
description="Off: skip this step — upload the customs declaration directly. Duty & tax is passed by default."
checked={sendDraft}
onChange={(e) => setSendDraft(e.currentTarget.checked)}
/>
<Button
color="edr-green"
variant="light"
loading={loading}
fullWidth
onClick={async () => {
setLoading(true);
try {
await bookingsService.skipDraftDeclaration(bookingId);
toast.success(
"Draft declaration skipped — upload the customs declaration next. Duty & tax passed.",
);
onChanged?.();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed");
} finally {
setLoading(false);
}
}}
>
Skip draft declaration
</Button>
</Stack>
);
}
return (
<Stack gap="md">
{!replaceMode ? (
<Switch
label="Send the customer a draft declaration"
description="Off: skip this step — upload the customs declaration directly. Duty & tax is passed by default."
checked={sendDraft}
onChange={(e) => setSendDraft(e.currentTarget.checked)}
/>
) : null}
{/* The customer sent this draft back — their words drive the
correction, so they lead the step. */}
{changeRequest ? (

View File

@@ -199,7 +199,13 @@ export function RequestServiceTypeCard({
if (lastMile)
chips.push({ label: "Last-mile delivery", color: "teal", icon: Warehouse });
if (customs)
chips.push({ label: "Customs clearance (GL)", color: "grape", icon: FileCheck });
chips.push({
label: st.includesEthiopianCustomsOnly
? "Ethiopian customs clearance (GL)"
: "Customs clearance (GL)",
color: "grape",
icon: FileCheck,
});
return (
<SectionCard

View File

@@ -1,4 +1,5 @@
import { Card, Skeleton, Text } from "@mantine/core";
import { ArrowUpRight } from "lucide-react";
import type { LucideIcon } from "lucide-react";
import type { ElementType, ReactNode } from "react";
import { Link } from "react-router-dom";
@@ -8,14 +9,14 @@ import { cn } from "@/lib/utils";
export interface KpiItem {
label: string;
value: ReactNode;
/** Optional leading icon rendered in a tinted chip. */
/** Optional leading icon rendered in a tinted chip beside the label. */
icon?: LucideIcon;
/** Secondary line under the label (e.g. a unit or comparison). */
/** Small tinted pill beside the value (e.g. "+6 today"). */
hint?: string;
/**
* Mantine color name for the icon chip (e.g. "edr-green", "red", "yellow").
* Defaults to the brand green so a strip reads as uniform unless a page opts
* into semantic tints.
* Mantine color name for the icon chip and sparkline (e.g. "edr-green",
* "red", "yellow"). Defaults to the brand green so a strip reads as uniform
* unless a page opts into semantic tints.
*/
color?: string;
/**
@@ -28,6 +29,8 @@ export interface KpiItem {
* becomes clickable (pointer, hover tint); when absent it stays static.
*/
href?: string;
/** Tiny bar sparkline, oldest → newest, scaled to its own max. */
spark?: number[];
}
export interface KpiStripProps {
@@ -36,11 +39,52 @@ export interface KpiStripProps {
loading?: boolean;
}
function Pill({
children,
tone,
}: {
children: ReactNode;
tone: "green" | "red";
}) {
const c = tone === "green" ? "edr-green" : "red";
return (
<span
className="inline-flex shrink-0 items-center gap-1 rounded-full px-[7px] py-[2px] text-[10px] font-medium leading-none"
style={{
background: `var(--mantine-color-${c}-0)`,
color: `var(--mantine-color-${c}-7)`,
}}
>
{children}
</span>
);
}
function Spark({ values, color }: { values: number[]; color: string }) {
const max = Math.max(1, ...values);
return (
<div className="flex h-[26px] shrink-0 items-end gap-[3px]" aria-hidden>
{values.map((v, i) => (
<div
key={i}
className="w-1 rounded-sm"
style={{
height: Math.max(3, Math.round((v / max) * 26)),
background: `var(--mantine-color-${color}-7)`,
opacity: i === values.length - 1 ? 0.9 : 0.28,
}}
/>
))}
</div>
);
}
/**
* A single bordered card divided into up to five KPI cells:
* `[ kpi | kpi | kpi ]`. Hairline dividers separate cells (vertical on wide
* screens, horizontal when they wrap). Surface, border and shadow all come from
* the theme — no per-cell backgrounds, gradients or custom shadows.
* `[ kpi | kpi | kpi ]`. Each cell stacks a tinted icon + label over a large
* display-font value, with an optional hint/delta pill and a sparkline on the
* right. Hairline dividers separate cells (vertical on wide screens,
* horizontal when they wrap).
*/
export function KpiStrip({ items, loading = false }: KpiStripProps) {
// The spec caps a strip at five cells; extra items are dropped rather than
@@ -48,7 +92,7 @@ export function KpiStrip({ items, loading = false }: KpiStripProps) {
const cells = items.slice(0, 5);
return (
<Card withBorder shadow="sm" p={0} className="overflow-hidden">
<Card withBorder shadow="sm" radius="lg" p={0} className="overflow-hidden">
<div className="flex flex-col sm:flex-row">
{cells.map((item, index) => {
const Icon = item.icon;
@@ -66,66 +110,63 @@ export function KpiStrip({ items, loading = false }: KpiStripProps) {
className={cn(
// min-w-0 lets a crowded strip (five cells, long labels)
// truncate its labels instead of overflowing the card.
"flex min-w-0 flex-1 items-center gap-3 px-5 py-4",
"flex min-w-0 flex-1 flex-col justify-center gap-2 px-[18px] py-4",
index > 0 &&
"border-t border-edr-border sm:border-l sm:border-t-0",
item.href &&
"cursor-pointer no-underline transition-colors hover:bg-gray-50 focus-visible:bg-gray-50",
)}
>
{Icon ? (
<div
className="flex size-10 shrink-0 items-center justify-center rounded-lg"
style={{
background: `var(--mantine-color-${color}-1)`,
color: `var(--mantine-color-${color}-7)`,
}}
>
<Icon size={20} strokeWidth={2} />
</div>
) : null}
<div className="flex items-center gap-2">
{Icon ? (
<div
className="flex size-7 shrink-0 items-center justify-center rounded-lg"
style={{
background: `var(--mantine-color-${color}-0)`,
color: `var(--mantine-color-${color}-7)`,
}}
>
<Icon size={14} strokeWidth={2} />
</div>
) : null}
<Text fz={12} fw={500} c="edr-muted" truncate>
{item.label}
</Text>
</div>
<div style={{ minWidth: 0 }}>
{loading ? (
<Skeleton height={26} width={72} radius="sm" my={2} />
) : (
<div className="flex items-baseline gap-2">
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2">
{loading ? (
<Skeleton height={28} width={64} radius="sm" />
) : (
<Text
fw={800}
fz={24}
lh={1.05}
ff="heading"
fw={600}
fz={27}
lh={1}
c="edr-text"
style={{ letterSpacing: "-0.02em" }}
style={{ letterSpacing: "-0.03em" }}
truncate
>
{item.value}
</Text>
{item.delta != null && item.delta !== 0 ? (
<Text
component="span"
fz="xs"
fw={700}
c={item.delta > 0 ? "edr-green.7" : "red.7"}
style={{
whiteSpace: "nowrap",
background:
item.delta > 0
? "var(--mantine-color-edr-green-0)"
: "var(--mantine-color-red-0)",
borderRadius: 999,
padding: "1px 7px",
}}
>
{item.delta > 0 ? "▲" : "▼"}
{Math.abs(item.delta)}%
</Text>
) : null}
</div>
)}
<Text size="xs" fw={600} c="edr-muted" truncate>
{item.label}
{item.hint ? ` · ${item.hint}` : ""}
</Text>
)}
{!loading && item.hint ? (
<Pill tone="green">
<ArrowUpRight size={10} />
{item.hint}
</Pill>
) : null}
{!loading && item.delta != null && item.delta !== 0 ? (
<Pill tone={item.delta > 0 ? "green" : "red"}>
{item.delta > 0 ? "▲" : "▼"}
{Math.abs(item.delta)}%
</Pill>
) : null}
</div>
{item.spark?.length ? (
<Spark values={item.spark} color={color} />
) : null}
</div>
</Cell>
);

View File

@@ -0,0 +1,75 @@
import { Group, Pagination, Select, Text } from "@mantine/core";
import type { DataTableFooterProps } from "@edr/ui-common";
export interface TablePagerProps<T> extends DataTableFooterProps<T> {
/** Plural noun for the row count — "Showing 110 of 48 shipments". */
noun?: string;
pageSizes?: number[];
}
/**
* DataTable footer: row range on the left, rows-per-page select + numbered
* pager on the right. Pass via `footer={(p) => <TablePager {...p} noun="…" />}`.
*/
export function TablePager<T>({
table,
pagination,
noun = "rows",
pageSizes = [10, 25, 50],
}: TablePagerProps<T>) {
const pageIndex = pagination.pageIndex ?? 0;
const pageSize = pagination.pageSize ?? 10;
const total = pagination.totalCount ?? 0;
const pageCount = Math.max(
1,
pagination.pageCount ?? Math.ceil(total / pageSize),
);
const start = total === 0 ? 0 : pageIndex * pageSize + 1;
const end = Math.min((pageIndex + 1) * pageSize, total);
return (
<Group
justify="space-between"
gap="sm"
wrap="wrap"
px="md"
py={10}
style={{ borderTop: "1px solid var(--mantine-color-edr-divider-6)" }}
>
<Text fz={12} c="edr-muted">
Showing {start}{end} of {total} {noun}
</Text>
<Group gap="sm" wrap="nowrap">
<Group gap={6} wrap="nowrap">
<Text fz={12} c="edr-muted">
Rows
</Text>
<Select
size="xs"
w={70}
radius="md"
value={String(pageSize)}
data={pageSizes.map(String)}
onChange={(v) => v && table.setPageSize(Number(v))}
allowDeselect={false}
comboboxProps={{ withinPortal: true }}
aria-label="Rows per page"
/>
</Group>
<div className="h-5 w-px bg-edr-border" />
<Pagination
size="sm"
radius="md"
color="edr-ink"
total={pageCount}
value={pageIndex + 1}
onChange={(p) => table.setPageIndex(p - 1)}
siblings={1}
boundaries={1}
/>
</Group>
</Group>
);
}
export default TablePager;

View File

@@ -254,6 +254,14 @@ const RuleEngineFormDialog = ({
next.cargoTypeId = "";
next.rateUnit = "";
}
// Full customs and Ethiopian-only customs are alternatives on a service
// type — switching one on drops the other so the API never sees both.
if (name === "includesCustoms" && value === true) {
next.includesEthiopianCustomsOnly = false;
}
if (name === "includesEthiopianCustomsOnly" && value === true) {
next.includesCustoms = false;
}
// Turning the shipping-line toggle on or off swaps the entire form, so
// nothing answered under the other shape may survive into the payload.
if (name === "isShippingLineRate") {
@@ -388,7 +396,11 @@ const RuleEngineFormDialog = ({
// A toggle that re-targets what an existing record means (e.g. who
// a rate is priced for) is create-only — flipping it on a saved row
// would silently change every booking that prices off it.
disabled={field.disabled || (field.disabledOnEdit && !!initialRecord)}
disabled={
field.disabled ||
(field.disabledOnEdit && !!initialRecord) ||
field.disabledIf?.(values) === true
}
size="md"
color="edr-green"
/>

View File

@@ -6,10 +6,33 @@ import {
type DraggableStateSnapshot,
type DropResult,
} from "@hello-pangea/dnd";
import { ActionIcon, Badge, Box, Group, Menu, Stack, Text, Tooltip } from "@mantine/core";
import {
ActionIcon,
Badge,
Box,
Button,
Checkbox,
Group,
Menu,
Paper,
ScrollArea,
Select,
Stack,
Text,
TextInput,
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 { GripVertical, MapPin, Search, Trash2, Wrench, X } from "lucide-react";
import {
memo,
useCallback,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import { createPortal } from "react-dom";
import { api } from "@/services/api";
@@ -41,25 +64,86 @@ function ConsistWagonList({
onRemove,
onMaintenance,
onChangeYard,
onChangeYardBulk,
busy = false,
}: ConsistWagonListProps) {
const onDragEnd = useCallback((result: DropResult) => {
if (!result.destination) return;
const from = result.source.index;
const to = result.destination.index;
if (from === to) return;
const next = [...wagons];
const [moved] = next.splice(from, 1);
next.splice(to, 0, moved!);
onReorder(next.map((w) => w.id));
}, [wagons, onReorder]);
// Multi-select for the bulk yard move. Only offered when the page passes a
// bulk handler — otherwise the checkbox column would lead nowhere.
const canSelect = Boolean(onChangeYardBulk) && editable;
const [selected, setSelected] = useState<Set<string>>(new Set());
const [bulkYardId, setBulkYardId] = useState<string | null>(null);
const bulkYardsQuery = useQuery(
api.routes.yards.queryOptions({
staleTime: 5 * 60_000,
enabled: canSelect,
}),
);
// A wagon detached elsewhere must not linger in the selection.
useEffect(() => {
setSelected((prev) => {
if (!prev.size) return prev;
const live = new Set(wagons.map((w) => w.id));
const next = new Set([...prev].filter((id) => live.has(id)));
return next.size === prev.size ? prev : next;
});
}, [wagons]);
const toggleSelected = useCallback((wagonId: string) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(wagonId)) next.delete(wagonId);
else next.add(wagonId);
return next;
});
}, []);
const clearSelection = useCallback(() => setSelected(new Set()), []);
const allSelected = canSelect && selected.size === wagons.length && wagons.length > 0;
const applyBulkYard = () => {
if (!onChangeYardBulk || !bulkYardId || !selected.size) return;
onChangeYardBulk([...selected], bulkYardId, () => {
clearSelection();
setBulkYardId(null);
});
};
// Search never filters: a consist is a physical order, hiding rows would
// make position numbers lie. It scrolls the first match into view instead.
const [search, setSearch] = useState("");
const listRef = useRef<HTMLDivElement>(null);
const matchId = useMemo(() => {
const q = search.trim().toLowerCase();
if (!q) return null;
return (
wagons.find((w) => w.wagonNumber.toLowerCase().includes(q))?.id ?? null
);
}, [search, wagons]);
useEffect(() => {
if (!matchId) return;
listRef.current
?.querySelector(`[data-wagon-id="${matchId}"]`)
?.scrollIntoView({ block: "center", behavior: "smooth" });
}, [matchId]);
const onDragEnd = useCallback(
(result: DropResult) => {
if (!result.destination) return;
const from = result.source.index;
const to = result.destination.index;
if (from === to) return;
const next = [...wagons];
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!]),
wagons
.filter((w) => w.wagonType)
.map((w) => [w.wagonType!.code, w.wagonType!]),
).values(),
],
[wagons],
@@ -74,52 +158,149 @@ function ConsistWagonList({
}
return (
<DragDropContext onDragEnd={onDragEnd}>
<Droppable droppableId="train-consist-wagons" isDropDisabled={!editable || busy}>
{(dropProvided) => (
<Stack gap="xs" ref={dropProvided.innerRef} {...dropProvided.droppableProps}>
{legend.length > 1 ? (
<Group gap={6} wrap="wrap">
{legend.map((type) => (
<Badge
key={type.code}
size="sm"
radius="sm"
variant="light"
color={wagonTypeColor(type.code)}
>
{type.code} · {type.name}
</Badge>
))}
</Group>
) : null}
{wagons.map((wagon, index) => (
<Draggable
key={wagon.id}
draggableId={wagon.id}
index={index}
isDragDisabled={!editable || busy}
<Stack gap="xs">
<TextInput
size="xs"
placeholder="Find wagon number…"
leftSection={<Search size={14} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
error={
search.trim() && !matchId
? "No wagon in this consist matches"
: undefined
}
aria-label="Find wagon in consist"
/>
{canSelect ? (
<Group justify="space-between" wrap="nowrap">
<Checkbox
size="xs"
label={
selected.size
? `${selected.size} selected`
: "Select wagons to move together"
}
checked={allSelected}
indeterminate={selected.size > 0 && !allSelected}
disabled={busy}
onChange={() =>
setSelected(allSelected ? new Set() : new Set(wagons.map((w) => w.id)))
}
/>
{selected.size ? (
<Button
size="compact-xs"
variant="subtle"
color="gray"
leftSection={<X size={13} />}
onClick={clearSelection}
disabled={busy}
>
Clear
</Button>
) : null}
</Group>
) : null}
{/* Bulk bar appears only with a selection, so it never competes with the
per-wagon yard badge for attention. */}
{canSelect && selected.size ? (
<Paper withBorder p="xs" radius="md" bg="var(--mantine-color-blue-0)">
<Group gap="xs" wrap="nowrap" align="flex-end">
<Select
size="xs"
style={{ flex: 1 }}
label={`Move ${selected.size} wagon${selected.size === 1 ? "" : "s"} to yard`}
placeholder="Select yard"
data={(bulkYardsQuery.data ?? []).map((y) => ({
value: y.id,
label: y.label ?? y.code,
}))}
value={bulkYardId}
onChange={setBulkYardId}
searchable
disabled={busy}
/>
<Button
size="xs"
leftSection={<MapPin size={14} />}
disabled={busy || !bulkYardId}
onClick={applyBulkYard}
>
Move
</Button>
</Group>
</Paper>
) : null}
{legend.length > 1 ? (
<Group gap={6} wrap="wrap">
{legend.map((type) => (
<Badge
key={type.code}
size="sm"
radius="sm"
variant="light"
color={wagonTypeColor(type.code)}
>
{type.code} · {type.name}
</Badge>
))}
</Group>
) : null}
<DragDropContext onDragEnd={onDragEnd}>
<Droppable
droppableId="train-consist-wagons"
isDropDisabled={!editable || busy}
>
{(dropProvided) => (
<Box
ref={listRef}
p="xs"
style={{
maxHeight: 520,
overflowY: "auto",
border: "1px solid var(--mantine-color-gray-3)",
borderRadius: "var(--mantine-radius-md)",
}}
>
<Stack
gap="xs"
ref={dropProvided.innerRef}
{...dropProvided.droppableProps}
>
{(dragProvided, snapshot) => (
<WagonRow
wagon={wagon}
{wagons.map((wagon, index) => (
<Draggable
key={wagon.id}
draggableId={wagon.id}
index={index}
dragProvided={dragProvided}
snapshot={snapshot}
editable={editable}
busy={busy}
onRemove={onRemove}
onMaintenance={onMaintenance}
onChangeYard={onChangeYard}
/>
)}
</Draggable>
))}
{dropProvided.placeholder}
</Stack>
)}
</Droppable>
</DragDropContext>
isDragDisabled={!editable || busy}
>
{(dragProvided, snapshot) => (
<WagonRow
wagon={wagon}
index={index}
dragProvided={dragProvided}
snapshot={snapshot}
editable={editable}
busy={busy}
highlighted={wagon.id === matchId}
selectable={canSelect}
selected={selected.has(wagon.id)}
onToggleSelected={toggleSelected}
onRemove={onRemove}
onMaintenance={onMaintenance}
onChangeYard={onChangeYard}
/>
)}
</Draggable>
))}
{dropProvided.placeholder}
</Stack>
</Box>
)}
</Droppable>
</DragDropContext>
</Stack>
);
}
@@ -135,6 +316,15 @@ export interface ConsistWagonListProps {
onMaintenance: (wagon: TrainCompositionWagon) => void;
/** Move one wagon to another yard from its yard badge; absent = read-only badge. */
onChangeYard?: (wagonId: string, currentYardId: string) => void;
/**
* Move every selected wagon to one yard in a single request. Absent hides the
* selection column entirely.
*/
onChangeYardBulk?: (
wagonIds: string[],
currentYardId: string,
onDone: () => void,
) => void;
busy?: boolean;
}
@@ -148,19 +338,34 @@ function WagonYardBadge({
busy: boolean;
onChange?: (wagonId: string, currentYardId: string) => void;
}) {
const label = wagon.currentYard?.label ?? wagon.currentYard?.code ?? "No yard";
const label =
wagon.currentYard?.label ?? wagon.currentYard?.code ?? "No yard";
const yardsQuery = useQuery(
api.routes.yards.queryOptions({ staleTime: 5 * 60_000, enabled: Boolean(onChange) }),
api.routes.yards.queryOptions({
staleTime: 5 * 60_000,
enabled: Boolean(onChange),
}),
);
// The yard list is long — filter box + capped scroll keep the dropdown usable.
const [yardFilter, setYardFilter] = useState("");
const filteredYards = (yardsQuery.data ?? []).filter((y) =>
(y.label ?? y.code ?? "").toLowerCase().includes(yardFilter.trim().toLowerCase()),
);
if (!onChange) {
return wagon.currentYard ? (
<Badge variant="outline" color="gray" size="xs" radius="sm" leftSection={<MapPin size={10} />}>
<Badge
variant="outline"
color="gray"
size="xs"
radius="sm"
leftSection={<MapPin size={10} />}
>
{label}
</Badge>
) : null;
}
return (
<Menu shadow="md" width={240} withinPortal>
<Menu shadow="md" width={240} withinPortal onClose={() => setYardFilter("")}>
<Menu.Target>
<Badge
component="button"
@@ -181,15 +386,33 @@ function WagonYardBadge({
</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>
))}
<Box px={8} pb={6}>
<TextInput
size="xs"
placeholder="Filter yards…"
leftSection={<Search size={12} />}
value={yardFilter}
onChange={(e) => setYardFilter(e.currentTarget.value)}
// A keypress inside the menu must type, not jump menu focus.
onKeyDown={(e) => e.stopPropagation()}
/>
</Box>
<ScrollArea.Autosize mah={350} type="auto">
{filteredYards.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>
))}
{filteredYards.length === 0 ? (
<Text size="xs" c="dimmed" px={12} py={6}>
No yard matches
</Text>
) : null}
</ScrollArea.Autosize>
</Menu.Dropdown>
</Menu>
);
@@ -202,6 +425,10 @@ const WagonRow = memo(function WagonRow({
snapshot,
editable,
busy,
highlighted,
selectable,
selected,
onToggleSelected,
onRemove,
onMaintenance,
onChangeYard,
@@ -212,6 +439,10 @@ const WagonRow = memo(function WagonRow({
snapshot: DraggableStateSnapshot;
editable: boolean;
busy: boolean;
highlighted: boolean;
selectable: boolean;
selected: boolean;
onToggleSelected: (wagonId: string) => void;
onRemove: (wagonId: string) => void;
onMaintenance: (wagon: TrainCompositionWagon) => void;
onChangeYard?: (wagonId: string, currentYardId: string) => void;
@@ -224,6 +455,7 @@ const WagonRow = memo(function WagonRow({
ref={dragProvided.innerRef}
{...dragProvided.draggableProps}
{...dragProvided.dragHandleProps}
data-wagon-id={wagon.id}
gap="sm"
wrap="nowrap"
p="sm"
@@ -237,11 +469,33 @@ const WagonRow = memo(function WagonRow({
background: snapshot.isDragging
? "white"
: `var(--mantine-color-${color}-0)`,
boxShadow: snapshot.isDragging ? "0 8px 24px rgba(0, 0, 0, 0.12)" : undefined,
cursor: editable ? (snapshot.isDragging ? "grabbing" : "grab") : "default",
boxShadow: snapshot.isDragging
? "0 8px 24px rgba(0, 0, 0, 0.12)"
: highlighted
? "0 0 0 3px var(--mantine-color-yellow-4)"
: selected
? "0 0 0 2px var(--mantine-color-blue-5)"
: undefined,
cursor: editable
? snapshot.isDragging
? "grabbing"
: "grab"
: "default",
userSelect: "none",
}}
>
{selectable ? (
<Checkbox
size="sm"
checked={selected}
disabled={busy}
aria-label={`Select wagon ${wagon.wagonNumber}`}
// The row is a drag handle — keep the click on the checkbox.
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
onChange={() => onToggleSelected(wagon.id)}
/>
) : null}
{editable ? (
<Box c="dimmed" style={{ display: "flex", alignItems: "center" }}>
<GripVertical size={18} />

View File

@@ -0,0 +1,194 @@
import {
Badge,
Button,
Checkbox,
Group,
Pagination,
Paper,
Stack,
Table,
Text,
ThemeIcon,
Tooltip,
} from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { Link2, MapPin, PackageOpen, User } from "lucide-react";
import { useState } from "react";
import { api } from "@/services/api";
interface Props {
trainId: string;
/** Staff may attach and the train is editable (not out on a run). */
canAttach: boolean;
attachPending: boolean;
onAttach: (wagonIds: string[]) => void;
}
/**
* "Detached wagons" tab: wagons last detached from THIS train that are still
* loose — with when, where and by whom they were detached — so staff can pick
* them straight back onto the consist without hunting through the global pool.
*/
export default function DetachedWagonsPanel({
trainId,
canAttach,
attachPending,
onAttach,
}: Props) {
const [page, setPage] = useState(1);
const query = useQuery(
api.trainBuilder.detachedWagons.queryOptions({
input: { id: trainId, page, pageSize: 20 },
enabled: Boolean(trainId),
// Keep the previous page on screen while the next one loads.
placeholderData: (prev) => prev,
}),
);
const rows = query.data?.items ?? [];
const totalPages = Math.max(1, query.data?.meta.totalPages ?? 1);
// Selection is page-scoped in the header checkbox but survives paging, so
// staff can gather wagons across pages into one attach.
const [selected, setSelected] = useState<ReadonlySet<string>>(new Set());
const allSelected = rows.length > 0 && rows.every((r) => selected.has(r.wagonId));
const toggle = (wagonId: string, checked: boolean) =>
setSelected((prev) => {
const next = new Set(prev);
if (checked) next.add(wagonId);
else next.delete(wagonId);
return next;
});
return (
<Paper radius="xl" p="lg" withBorder>
<Stack gap="lg">
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
<Group gap="md" align="center" wrap="nowrap">
<ThemeIcon size={44} radius="md" variant="light" color="orange">
<PackageOpen size={22} />
</ThemeIcon>
<Stack gap={2}>
<Text fw={700} fz="lg">
Detached wagons
</Text>
<Text size="sm" c="dimmed">
Wagons that left this train and are still loose select and
attach them back in one click.
</Text>
</Stack>
</Group>
{canAttach ? (
<Button
leftSection={<Link2 size={16} />}
disabled={selected.size === 0}
loading={attachPending}
onClick={() => {
onAttach([...selected]);
setSelected(new Set());
}}
>
Attach {selected.size || ""} wagon{selected.size === 1 ? "" : "s"}
</Button>
) : null}
</Group>
{query.isLoading ? (
<Text size="sm" c="dimmed" ta="center" py="md">
Loading detached wagons
</Text>
) : rows.length === 0 ? (
<Text size="sm" c="dimmed" ta="center" py="md">
No loose wagons were detached from this train detach history starts
being recorded from now on.
</Text>
) : (
<Table striped highlightOnHover withTableBorder>
<Table.Thead>
<Table.Tr>
{canAttach ? (
<Table.Th w={36}>
<Checkbox
checked={allSelected}
indeterminate={selected.size > 0 && !allSelected}
onChange={(e) =>
setSelected(
e.currentTarget.checked
? new Set(rows.map((r) => r.wagonId))
: new Set(),
)
}
/>
</Table.Th>
) : null}
<Table.Th>Wagon</Table.Th>
<Table.Th>Type</Table.Th>
<Table.Th>Now standing at</Table.Th>
<Table.Th>Last detached</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{rows.map((r) => (
<Table.Tr key={r.wagonId}>
{canAttach ? (
<Table.Td>
<Checkbox
checked={selected.has(r.wagonId)}
onChange={(e) => toggle(r.wagonId, e.currentTarget.checked)}
/>
</Table.Td>
) : null}
<Table.Td>
<Text fw={600} size="sm" ff="monospace">
{r.wagonNumber}
</Text>
</Table.Td>
<Table.Td>
<Badge size="sm" variant="light" color="gray">
{r.wagonTypeCode ?? "—"}
</Badge>
</Table.Td>
<Table.Td>
<Text size="sm">{r.currentYardLabel ?? "No yard"}</Text>
</Table.Td>
<Table.Td>
<Group gap="md" wrap="wrap">
<Tooltip label={new Date(r.detachedAt).toLocaleString()}>
<Text size="sm">{new Date(r.detachedAt).toLocaleDateString()}</Text>
</Tooltip>
{r.detachedYardLabel ? (
<Group gap={4} wrap="nowrap">
<MapPin size={12} />
<Text size="xs" c="dimmed">
at {r.detachedYardLabel}
</Text>
</Group>
) : null}
{r.detachedBy ? (
<Group gap={4} wrap="nowrap">
<User size={12} />
<Text size="xs" c="dimmed">
by {r.detachedBy}
</Text>
</Group>
) : null}
</Group>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
{totalPages > 1 ? (
<Group justify="space-between">
<Text size="xs" c="dimmed">
{query.data?.meta.total ?? 0} wagon(s) · selection carries across pages
</Text>
<Pagination size="sm" value={page} onChange={setPage} total={totalPages} />
</Group>
) : null}
</Stack>
</Paper>
);
}

View File

@@ -0,0 +1,140 @@
import { Badge, Group, Pagination, Paper, Stack, Text, ThemeIcon, Timeline } from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { ArrowLeftRight, History, MapPin, Minus, Plus, TrainFront, User } from "lucide-react";
import { useState } from "react";
import { api } from "@/services/api";
import type { TrainHistoryEntry } from "@/services/trainBuilder.service";
const PAGE_SIZE = 20;
const ACTION_META: Record<
TrainHistoryEntry["action"],
{ label: string; color: string; icon: typeof Plus }
> = {
ADD: { label: "Wagon attached", color: "edr-green", icon: Plus },
REMOVE: { label: "Wagon detached", color: "red", icon: Minus },
SWITCH: { label: "Wagon switched", color: "blue", icon: ArrowLeftRight },
};
/**
* "History" tab of the train-builder detail page: every wagon ever attached,
* detached or switched on this built train — builder edits and trip events
* (real cuts, mid-route couples, consist adjustments) alike, newest first.
*/
export default function TrainHistoryPanel({ trainId }: { trainId: string }) {
const [page, setPage] = useState(1);
const historyQuery = useQuery(
api.trainBuilder.history.queryOptions({
input: { id: trainId, page, pageSize: PAGE_SIZE },
enabled: Boolean(trainId),
// Keep the previous page on screen while the next one loads.
placeholderData: (prev) => prev,
}),
);
const entries = historyQuery.data?.items ?? [];
const totalPages = Math.max(1, historyQuery.data?.meta.totalPages ?? 1);
const total = historyQuery.data?.meta.total ?? 0;
return (
<Paper radius="xl" p="lg" withBorder>
<Stack gap="lg">
<Group gap="md" align="center" wrap="nowrap">
<ThemeIcon size={44} radius="md" variant="light" color="edr-green">
<History size={22} />
</ThemeIcon>
<Stack gap={2}>
<Text fw={700} fz="lg">
Wagon history
</Text>
<Text size="sm" c="dimmed">
Who attached, detached or switched which wagon on this train from
the builder and from its trips newest first.
</Text>
</Stack>
</Group>
{historyQuery.isLoading ? (
<Text size="sm" c="dimmed" ta="center" py="md">
Loading history
</Text>
) : entries.length === 0 ? (
<Text size="sm" c="dimmed" ta="center" py="md">
No wagon changes recorded yet for this train.
</Text>
) : (
<Timeline bulletSize={26} lineWidth={2} color="edr-green">
{entries.map((entry) => {
const meta = ACTION_META[entry.action] ?? ACTION_META.ADD;
const Icon = meta.icon;
return (
<Timeline.Item
key={entry.id}
bullet={<Icon size={13} />}
color={meta.color}
title={
<Group gap="xs" wrap="nowrap">
<Badge size="sm" variant="light" color={meta.color}>
{meta.label}
</Badge>
{entry.subject ? (
<Text size="sm" fw={600} ff="monospace">
{entry.subject}
</Text>
) : null}
{entry.scheduleReference ? (
<Badge
size="sm"
variant="light"
color="blue"
leftSection={<TrainFront size={10} />}
>
{entry.scheduleReference}
</Badge>
) : (
<Badge size="sm" variant="light" color="gray">
Builder
</Badge>
)}
</Group>
}
>
<Group gap="md" mt={2}>
<Text size="xs" c="dimmed">
{new Date(entry.occurredAt).toLocaleString()}
</Text>
{entry.yardLabel ? (
<Group gap={4} wrap="nowrap">
<MapPin size={12} />
<Text size="xs" c="dimmed">
at {entry.yardLabel}
</Text>
</Group>
) : null}
{entry.actor ? (
<Group gap={4} wrap="nowrap">
<User size={12} />
<Text size="xs" c="dimmed">
{entry.actor}
</Text>
</Group>
) : null}
</Group>
</Timeline.Item>
);
})}
</Timeline>
)}
{totalPages > 1 ? (
<Group justify="space-between">
<Text size="xs" c="dimmed">
{total} change(s)
</Text>
<Pagination size="sm" value={page} onChange={setPage} total={totalPages} />
</Group>
) : null}
</Stack>
</Paper>
);
}

View File

@@ -1,5 +1,6 @@
import { Button, Group, Modal, Stack, Text, Textarea } from "@mantine/core";
import { DateTimePicker } from "@mantine/dates";
import { useMediaQuery } from "@mantine/hooks";
import { useEffect, useState } from "react";
/**
@@ -34,6 +35,7 @@ export function CheckpointTimeModal({
loading: boolean;
onSubmit: (values: { occurredAt: string; note: string }) => void;
}) {
const isSmallScreen = useMediaQuery("(max-width: 48em)");
const [at, setAt] = useState<Date | null>(null);
const [note, setNote] = useState("");
useEffect(() => {
@@ -47,6 +49,7 @@ export function CheckpointTimeModal({
opened={opened}
onClose={onClose}
centered
fullScreen={isSmallScreen}
radius="lg"
title={
<Group gap={8}>
@@ -67,6 +70,8 @@ export function CheckpointTimeModal({
value={at}
onChange={(v) => setAt(v ? new Date(v) : null)}
maxDate={new Date()}
dropdownType={isSmallScreen ? "modal" : "popover"}
popoverProps={{ withinPortal: true }}
valueFormat="DD MMM YYYY HH:mm"
clearable={false}
radius="md"

View File

@@ -15,6 +15,8 @@ import {
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertCircle, ArrowRight, PackageCheck, PackageOpen, TrainFront } from "lucide-react";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission as hasFreightPermission } from "@/lib/permissions";
import { api } from "@/services/api";
import { useToast } from "@/hooks/use-toast";
import type {
@@ -130,6 +132,9 @@ export function IntercityRideAlongPanel({
direction: string | null | undefined;
}) {
const { toast } = useToast();
const { user } = useAuth();
const canLoad = hasFreightPermission(user, FREIGHT_PERMS.trainScheduling.load);
const canUnload = hasFreightPermission(user, FREIGHT_PERMS.trainScheduling.unload);
const queryClient = useQueryClient();
const [selected, setSelected] = useState<string[]>([]);
@@ -378,12 +383,19 @@ export function IntercityRideAlongPanel({
<Table.Td>
<Group gap="xs" justify="flex-end">
{row.status === "PAID" && (
<Tooltip label="Train must be at the booking's origin yard">
<Tooltip
label={
canLoad
? "Train must be at the booking's origin yard"
: "You don't have permission to load cargo"
}
>
<Button
size="compact-xs"
variant="light"
leftSection={<PackageCheck size={13} />}
loading={load.isPending}
disabled={!canLoad}
onClick={() =>
load.mutate({ scheduleId, bookingId: row.id })
}
@@ -393,13 +405,20 @@ export function IntercityRideAlongPanel({
</Tooltip>
)}
{row.status === "IN_TRANSIT" && (
<Tooltip label="Train must be at the booking's destination yard">
<Tooltip
label={
canUnload
? "Train must be at the booking's destination yard"
: "You don't have permission to unload cargo"
}
>
<Button
size="compact-xs"
variant="light"
color="orange"
leftSection={<PackageOpen size={13} />}
loading={unload.isPending}
disabled={!canUnload}
onClick={() =>
unload.mutate({ scheduleId, bookingId: row.id })
}

View File

@@ -22,6 +22,15 @@ type Slot = NonNullable<TrainScheduleDetail["trainSet"]>["wagons"][number];
type Stop = { yardId: string; label: string };
type Span = [number, number];
/** The allocations of one slot that ride the same corridor — one drawn bar. */
type SlotPart = {
slot: Slot;
span: Span;
loaded: boolean;
/** Allocations riding THIS span (all of the slot's when it is not split). */
allocations: NonNullable<Slot["allocations"]>;
};
/** One physical wagon of the consist with every slot (leg load) pinned to it. */
interface WagonRow {
key: string;
@@ -30,12 +39,57 @@ interface WagonRow {
position: number;
typeCode: string | null;
capacityTons: number;
slots: Array<{ slot: Slot; span: Span; loaded: boolean }>;
slots: SlotPart[];
}
const round1 = (n: number) => Math.round(n * 10) / 10;
const overlaps = (a: Span, b: Span) => a[0] < b[1] && b[0] < a[1];
/**
* One drawn bar per corridor a slot actually serves.
*
* A wagon reused across disjoint legs (containers Doraleh→Dire Dawa, bulk
* Dire Dawa→Gelan) is ONE slot whose stored board/alight yards are the UNION
* of its loads. Drawing that union as a single bar claims both loads ride the
* whole way and hides where each one actually sits. Each allocation carries
* its own booking yards, so group by corridor and draw one bar per group —
* the board then reads "containers on leg 1, bulk on leg 2" truthfully.
*
* Falls back to the slot's own span whenever the yards are missing or not on
* the stop list, which is exactly the previous behaviour.
*/
function splitByCorridor(slot: Slot, slotSpan: Span, stops: Stop[]): SlotPart[] {
const allocations = slot.allocations ?? [];
const whole: SlotPart[] = [
{ slot, span: slotSpan, loaded: allocations.length > 0, allocations },
];
if (allocations.length < 2) return whole;
const idx = (yardId?: string | null) =>
yardId ? stops.findIndex((s) => s.yardId === yardId) : -1;
const byCorridor = new Map<string, { span: Span; allocations: typeof allocations }>();
for (const allocation of allocations) {
const from = idx(allocation.originYardId);
const to = idx(allocation.destinationYardId);
// Any allocation without a usable corridor → keep the old single bar.
if (from < 0 || to <= from) return whole;
const key = `${from}-${to}`;
const entry = byCorridor.get(key);
if (entry) entry.allocations.push(allocation);
else byCorridor.set(key, { span: [from, to], allocations: [allocation] });
}
if (byCorridor.size < 2) return whole;
return [...byCorridor.values()]
.sort((a, b) => a.span[0] - b.span[0])
.map((part) => ({
slot,
span: part.span,
loaded: true,
allocations: part.allocations,
}));
}
/**
* Leg board: rows = physical wagons in coupling order, columns = corridor legs
* (A→B, B→C, …). A wagon reused on disjoint legs shows one load per leg on the
@@ -86,11 +140,7 @@ export function LegLoadBoardPanel({
row.position = Math.min(row.position, slot.position ?? slot.sequenceNo);
// Coupled-but-empty consist wagons carry no slot row: they are a target only.
if (!slot.consistOnly) {
row.slots.push({
slot,
span: spanOf(slot),
loaded: (slot.allocations?.length ?? 0) > 0,
});
row.slots.push(...splitByCorridor(slot, spanOf(slot), stops));
}
}
return [...byKey.values()].sort((a, b) => a.position - b.position);
@@ -211,15 +261,16 @@ export function LegLoadBoardPanel({
</Table.Thead>
<Table.Tbody>
{rows.map((row) => {
const cargoTons = row.slots.reduce(
(s, x) =>
s +
((x.slot.allocations ?? []).reduce(
(a, al) => a + (al.allocatedWeightTons ?? 0),
0,
) || x.slot.assignedWeightTons || 0),
0,
);
// Heaviest single leg, not the sum of every bar: one slot may be
// drawn as several corridor bars, and a wagon reused on disjoint
// legs never carries both loads at once. Summing them reported a
// 60T wagon as 120T loaded and painted the capacity red.
const cargoTons = row.slots.reduce((max, part) => {
const tons =
part.allocations.reduce((a, al) => a + (al.allocatedWeightTons ?? 0), 0) ||
(row.slots.length === 1 ? part.slot.assignedWeightTons || 0 : 0);
return Math.max(max, tons);
}, 0);
const isPickedRow = picked?.rowKey === row.key;
// A row can take the picked load when nothing loaded on it rides
// any of the picked load's legs.
@@ -279,12 +330,14 @@ export function LegLoadBoardPanel({
const isPicked = picked?.slotId === s.slot.id;
const swappable =
!!picked && !isPicked && !isPickedRow && s.loaded && canRearrange;
const allocs = s.slot.allocations ?? [];
// The allocations riding THIS bar's corridor — not the whole
// slot's, so a leg-shared wagon labels each leg with its own load.
const allocs = s.allocations;
const bulk = allocs.some((a) => (a.loadType ?? "CONTAINER").toUpperCase() === "BULK");
const containers = allocs.flatMap((a) => a.containerItems ?? []);
cells.push(
<Table.Td
key={s.slot.id}
key={`${s.slot.id}-${s.span[0]}-${s.span[1]}`}
colSpan={Math.max(1, s.span[1] - s.span[0])}
onClick={
!canRearrange

View File

@@ -25,6 +25,8 @@ import { useEffect, useState } from "react";
import { Freight } from "@edr/types";
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission as hasFreightPermission } from "@/lib/permissions";
import { useToast } from "@/hooks/use-toast";
import { api } from "@/services/api";
import type { TrackStation, YardWorkBookingRow } from "@/types/trainScheduling";
@@ -111,6 +113,8 @@ export function LogPassYardWorkModal({
alreadyLogged: boolean;
}) {
const { toast } = useToast();
const { user } = useAuth();
const canLoad = hasFreightPermission(user, FREIGHT_PERMS.trainScheduling.load);
const [justLogged, setJustLogged] = useState(false);
// When the train was here — defaults to now, past allowed (recorded after the fact).
const [passAt, setPassAt] = useState<Date | null>(null);
@@ -353,18 +357,20 @@ export function LogPassYardWorkModal({
{!row.loadedAt ? (
<Tooltip
label={
!logged
? "Log the pass first — the train must be at this yard"
: !row.canLoad
? "Booking is not ready to load (payment pending)"
: "Confirm cargo loaded onto the train"
!canLoad
? "You don't have permission to load cargo"
: !logged
? "Log the pass first — the train must be at this yard"
: !row.canLoad
? "Booking is not ready to load (payment pending)"
: "Confirm cargo loaded onto the train"
}
>
<Button
size="compact-xs"
variant="light"
leftSection={<PackageCheck size={13} />}
disabled={!logged || !row.canLoad}
disabled={!canLoad || !logged || !row.canLoad}
loading={
load.isPending && load.variables?.bookingId === row.id
}

View File

@@ -1,6 +1,7 @@
import {
Badge,
Group,
Pagination,
Paper,
Stack,
Text,
@@ -8,6 +9,7 @@ import {
Timeline,
} from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import {
ArrowLeftRight,
History,
@@ -41,13 +43,18 @@ const ACTION_META: Record<
* bookings removed from the composition — newest first.
*/
export default function ScheduleHistoryPanel({ scheduleId }: { scheduleId: string }) {
const [page, setPage] = useState(1);
const historyQuery = useQuery(
api.trainScheduling.scheduleHistory.queryOptions({
input: { scheduleId },
input: { scheduleId, page, pageSize: 20 },
enabled: Boolean(scheduleId),
// Keep the previous page on screen while the next one loads.
placeholderData: (prev) => prev,
}),
);
const entries = historyQuery.data ?? [];
const entries = historyQuery.data?.items ?? [];
const totalPages = Math.max(1, historyQuery.data?.meta.totalPages ?? 1);
const total = historyQuery.data?.meta.total ?? 0;
return (
<Paper radius="xl" p="lg">
@@ -131,6 +138,15 @@ export default function ScheduleHistoryPanel({ scheduleId }: { scheduleId: strin
})}
</Timeline>
)}
{totalPages > 1 ? (
<Group justify="space-between">
<Text size="xs" c="dimmed">
{total} change(s)
</Text>
<Pagination size="sm" value={page} onChange={setPage} total={totalPages} />
</Group>
) : null}
</Stack>
</Paper>
);

View File

@@ -0,0 +1,844 @@
import {
Alert,
Badge,
Button,
Checkbox,
Group,
Loader,
Modal,
NumberInput,
Pagination,
Paper,
ScrollArea,
Select,
SimpleGrid,
Stack,
Table,
Text,
TextInput,
Tooltip,
} from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { useMutation, useQuery } from "@tanstack/react-query";
import { Freight } from "@edr/types";
import { isAxiosError } from "axios";
import { AlertTriangle, Link2, Lock, MapPin, Plus, Search } from "lucide-react";
import { useMemo, useState } from "react";
import { useToast } from "@/hooks/use-toast";
import { api } from "@/services/api";
import type { ScheduleWagonYardRow } from "@/services/trainBuilder.service";
/**
* Schedule yards tab: where THIS departure plans to board each consist wagon,
* side by side with where the wagon physically stands (the train builder's
* truth). Booking capacity per origin reads the plan; dispatch refuses to
* leave until plan and physical yards agree. Edits are queued locally and
* saved in one PATCH.
*/
const parseError = (error: unknown, fallback: string) => {
if (isAxiosError(error)) {
const message = error.response?.data?.message;
if (Array.isArray(message)) return message.join(", ");
if (typeof message === "string") return message;
}
return fallback;
};
interface Props {
scheduleId: string;
canEdit: boolean;
}
export function ScheduleWagonYardPanel({ scheduleId, canEdit }: Props) {
const { toast } = useToast();
const query = useQuery(
api.trainScheduling.scheduleWagonYards.queryOptions({ input: { scheduleId } }),
);
const save = useMutation(api.trainScheduling.updateScheduleWagonYards.mutationOptions());
const data = query.data;
/** wagonId → yardId queued but not yet saved. */
const [pending, setPending] = useState<Record<string, string>>({});
/** wagonId → cut yard queued but not yet saved; null = queued clear (rides to destination). */
const [pendingCut, setPendingCut] = useState<Record<string, string | null>>({});
/** wagonId → real-cut flag queued but not yet saved. */
const [pendingRealCut, setPendingRealCut] = useState<Record<string, boolean>>({});
/** Loose wagons queued to couple: wagonId → couple stop + display data. */
const [pendingCouples, setPendingCouples] = useState<
Record<string, { yardId: string; wagonNumber: string; typeCode: string }>
>({});
/** Already-planned couples queued for removal. */
const [pendingUncouple, setPendingUncouple] = useState<string[]>([]);
// "Add wagon" modal + its filters.
const [coupleModalOpen, setCoupleModalOpen] = useState(false);
const [coupleYardFilter, setCoupleYardFilter] = useState<string | null>(null);
const [coupleType, setCoupleType] = useState<string | null>(null);
const [coupleSearch, setCoupleSearch] = useState("");
const [couplePage, setCouplePage] = useState(1);
const [debouncedCoupleSearch] = useDebouncedValue(coupleSearch, 300);
const [bulkType, setBulkType] = useState<string | null>(null);
const [bulkFrom, setBulkFrom] = useState<string | null>(null);
const [bulkTo, setBulkTo] = useState<string | null>(null);
const [bulkCount, setBulkCount] = useState<number | string>(1);
const editable = Boolean(canEdit && data?.editable);
const pickupStops = useMemo(() => (data?.stops ?? []).filter((s) => s.pickup), [data]);
/** Mid-route stops only — wagons are coupled between the origin and the destination. */
const intermediateStops = useMemo(() => {
const stops = data?.stops ?? [];
return stops.slice(1, -1).filter((s) => s.pickup);
}, [data]);
// Loose-wagon list for the "Add wagon" modal. A wagon can only be coupled
// where it physically stands, and only at a pickup stop of this route — the
// Add button carries that yard; off-route wagons render disabled.
const coupleListQuery = useQuery(
api.wagons.listPaged.queryOptions({
input: {
filters: {
status: Freight.WagonStatus.Available,
unassigned: true,
currentYardId: coupleYardFilter ?? undefined,
wagonTypeId: coupleType ?? undefined,
search: debouncedCoupleSearch || undefined,
page: couplePage,
pageSize: 8,
},
},
enabled: editable && coupleModalOpen,
placeholderData: (prev) => prev,
}),
);
const coupleCandidates = coupleListQuery.data?.items ?? [];
const coupleTotalPages = Math.max(1, coupleListQuery.data?.meta.totalPages ?? 1);
const yardsQuery = useQuery(
api.routes.yards.queryOptions({ staleTime: 5 * 60_000, enabled: coupleModalOpen }),
);
const wagonTypesQuery = useQuery(
api.wagonTypes.list.queryOptions({ staleTime: 5 * 60_000, enabled: coupleModalOpen }),
);
const yardOptions = pickupStops.map((s) => ({ value: s.yardId, label: s.label }));
const yardLabel = (id: string | null) =>
(data?.stops ?? []).find((s) => s.yardId === id)?.label ??
data?.wagons.find((w) => w.plannedYardId === id)?.plannedYardLabel ??
data?.wagons.find((w) => w.physicalYardId === id)?.physicalYardLabel ??
id ??
"—";
const effectiveYard = (w: ScheduleWagonYardRow) => pending[w.id] ?? w.plannedYardId;
const effectiveCut = (w: ScheduleWagonYardRow) =>
w.id in pendingCut ? pendingCut[w.id] : w.cutYardId;
const effectiveRealCut = (w: ScheduleWagonYardRow) =>
(pendingRealCut[w.id] ?? w.realCut) && effectiveCut(w) != null;
const stopIndexOf = (yardId: string | null) =>
yardId == null ? -1 : (data?.stops ?? []).findIndex((s) => s.yardId === yardId);
/** Drop stops a wagon boarding at `boardYardId` can be cut at — strictly after
* boarding, excluding the destination (that's the cleared/default state). */
const cutOptionsFor = (boardYardId: string | null) => {
const stops = data?.stops ?? [];
const boardIdx = Math.max(0, stopIndexOf(boardYardId));
return stops
.slice(boardIdx + 1, stops.length - 1)
.map((s) => ({ value: s.yardId, label: s.label }));
};
/** Board-yard changes can invalidate a cut (server rejects cut ≤ board) — queue a clear. */
const clearInvalidCut = (
next: Record<string, string | null>,
w: ScheduleWagonYardRow,
boardYardId: string | null,
) => {
const cut = w.id in next ? next[w.id] : w.cutYardId;
if (cut != null && stopIndexOf(cut) <= stopIndexOf(boardYardId)) {
if (w.cutYardId == null) delete next[w.id];
else next[w.id] = null;
}
return next;
};
const perStop = useMemo(
() =>
(data?.stops ?? []).map((s) => ({
...s,
planned: (data?.wagons ?? []).filter((w) => (pending[w.id] ?? w.plannedYardId) === s.yardId)
.length,
cut: (data?.wagons ?? []).filter(
(w) => (w.id in pendingCut ? pendingCut[w.id] : w.cutYardId) === s.yardId,
).length,
coupled:
(data?.wagons ?? []).filter(
(w) => w.coupledYardId === s.yardId && !pendingUncouple.includes(w.id),
).length +
Object.values(pendingCouples).filter((c) => c.yardId === s.yardId).length,
})),
[data, pending, pendingCut, pendingCouples, pendingUncouple],
);
const typeOptions = useMemo(() => {
const seen = new Map<string, string>();
for (const w of data?.wagons ?? []) seen.set(w.wagonType.id, w.wagonType.code);
return [...seen].map(([value, label]) => ({ value, label }));
}, [data]);
const pendingCount =
new Set([
...Object.keys(pending),
...Object.keys(pendingCut),
...Object.keys(pendingRealCut),
]).size +
Object.keys(pendingCouples).length +
pendingUncouple.length;
const queueBulk = () => {
if (!data || !bulkFrom || !bulkTo || bulkFrom === bulkTo) return;
const n = Number(bulkCount) || 0;
const picked = data.wagons
.filter(
(w) =>
!w.locked &&
effectiveYard(w) === bulkFrom &&
(!bulkType || w.wagonType.id === bulkType),
)
.slice(0, n);
if (!picked.length) {
toast({ title: "No free wagons match", variant: "destructive" });
return;
}
setPending((prev) => {
const next = { ...prev };
for (const w of picked) {
if (w.plannedYardId === bulkTo) delete next[w.id];
else next[w.id] = bulkTo;
}
return next;
});
setPendingCut((prev) => {
let next = { ...prev };
for (const w of picked) next = clearInvalidCut(next, w, bulkTo);
return next;
});
};
const handleSave = async () => {
if (!pendingCount) return;
try {
const wagonIds = [
...new Set([
...Object.keys(pending),
...Object.keys(pendingCut),
...Object.keys(pendingRealCut),
]),
];
const result = await save.mutateAsync({
scheduleId,
payload: {
moves: wagonIds.map((wagonId) => ({
wagonId,
...(wagonId in pending ? { yardId: pending[wagonId] } : {}),
...(wagonId in pendingCut ? { cutYardId: pendingCut[wagonId] } : {}),
...(wagonId in pendingRealCut ? { realCut: pendingRealCut[wagonId] } : {}),
})),
...(Object.keys(pendingCouples).length
? {
couple: Object.entries(pendingCouples).map(([wagonId, c]) => ({
wagonId,
yardId: c.yardId,
})),
}
: {}),
...(pendingUncouple.length ? { uncouple: pendingUncouple } : {}),
},
});
setPending({});
setPendingCut({});
setPendingRealCut({});
setPendingCouples({});
setPendingUncouple([]);
toast({
title: `Schedule yards updated — ${pendingCount} wagon(s) re-planned`,
description: result.warnings.length ? result.warnings.join(" ") : undefined,
variant: result.warnings.length ? "destructive" : undefined,
});
} catch (err) {
toast({
title: "Update failed",
description: parseError(err, "Could not update the schedule's wagon yards"),
variant: "destructive",
});
}
};
if (query.isLoading) return <Loader size="sm" />;
if (query.isError || !data) {
return (
<Alert color="red" icon={<AlertTriangle size={16} />}>
{parseError(
query.error,
"This schedule has no wagon yard plan (not created from a built train).",
)}
</Alert>
);
}
return (
<Stack gap="md">
<Alert color="blue" icon={<MapPin size={16} />} variant="light">
<b>Planned</b> = where this departure boards the wagon (what customers can book per
origin). <b>Physical</b> = where the wagon stands now (train builder). <b>Cut at</b> ={" "}
where this departure detaches the wagon and leaves it blank means it rides to the
destination; booking capacity past the cut shrinks accordingly. Tick <b>Real cut</b> to
remove the wagon from the train build permanently at that yard (untick = it sits out this
trip only). <b>Coupled</b> wagons are loose wagons joining the train at a stop they
become part of the build for good. Dispatch is blocked until every wagon stands at its
planned yard.
{data.misaligned > 0 ? (
<Text component="span" c="orange" fw={600}>
{" "}
{data.misaligned} wagon(s) currently misaligned.
</Text>
) : null}
</Alert>
<SimpleGrid cols={{ base: 2, sm: 3, md: Math.min(5, Math.max(2, perStop.length)) }}>
{perStop.map((s) => (
<Paper key={s.yardId} withBorder p="sm" radius="md">
<Group justify="space-between" mb={4}>
<Text fw={600} size="sm">
{s.label}
</Text>
{!s.pickup ? (
<Badge size="xs" color="gray" variant="light">
destination
</Badge>
) : null}
</Group>
<Group gap="xs">
<Badge color="edr-green" variant="filled">
Planned {s.planned}
</Badge>
<Badge color={s.physical === s.planned ? "gray" : "orange"} variant="light">
Physical {s.physical}
</Badge>
{s.cut > 0 ? (
<Badge color="red" variant="light">
Cut {s.cut}
</Badge>
) : null}
{s.coupled > 0 ? (
<Badge color="blue" variant="light">
+{s.coupled} coupled
</Badge>
) : null}
{!s.pickup ? (
<Badge color="blue" variant="light">
Through{" "}
{data.wagons.filter(
(w) => !w.coupledYardId || !pendingUncouple.includes(w.id),
).length +
Object.keys(pendingCouples).length -
perStop.reduce((sum, p) => sum + p.cut, 0)}
</Badge>
) : null}
</Group>
</Paper>
))}
</SimpleGrid>
{editable ? (
<Paper withBorder p="sm" radius="md">
<Group align="end" gap="sm" wrap="wrap">
<NumberInput
label="Move"
min={1}
max={data.wagons.length}
value={bulkCount}
onChange={setBulkCount}
w={90}
/>
<Select
label="Wagon type"
placeholder="Any"
clearable
data={typeOptions}
value={bulkType}
onChange={setBulkType}
w={140}
/>
<Select label="From" data={yardOptions} value={bulkFrom} onChange={setBulkFrom} w={170} />
<Select label="To" data={yardOptions} value={bulkTo} onChange={setBulkTo} w={170} />
<Button
variant="light"
onClick={queueBulk}
disabled={!bulkFrom || !bulkTo || bulkFrom === bulkTo}
>
Queue
</Button>
</Group>
</Paper>
) : null}
{editable ? (
<Group justify="space-between">
<Group gap={6}>
<Link2 size={16} />
<Text fw={600} size="sm">
Consist plan for this trip
</Text>
</Group>
<Button
leftSection={<Plus size={16} />}
variant="light"
onClick={() => setCoupleModalOpen(true)}
>
Add wagon
</Button>
</Group>
) : null}
<Modal
opened={coupleModalOpen}
onClose={() => setCoupleModalOpen(false)}
size="xl"
radius="md"
title={
<Group gap={8}>
<Link2 size={18} />
<Text fw={700}>Add wagons to this trip</Text>
</Group>
}
>
<Stack gap="sm">
<Alert color="blue" variant="light" p="xs">
A wagon is coupled where it physically stands, so it must be waiting at one of this
route&apos;s stops between the origin and the destination. Wagons elsewhere are listed
but cannot be added until they are moved.
</Alert>
<Group align="end" gap="sm" wrap="wrap">
<Select
label="Yard"
placeholder="All yards"
clearable
searchable
data={(yardsQuery.data ?? [])
.filter(
(y) =>
y.id !== data.stops[0]?.yardId &&
y.id !== data.stops[data.stops.length - 1]?.yardId,
)
.slice()
.sort((a, b) => a.label.localeCompare(b.label))
.map((y) => ({
value: y.id,
label: intermediateStops.some((s) => s.yardId === y.id)
? `${y.label} · route stop`
: y.label,
}))}
value={coupleYardFilter}
onChange={(v) => {
setCoupleYardFilter(v);
setCouplePage(1);
}}
w={220}
/>
<Select
label="Wagon type"
placeholder="Any type"
clearable
data={(wagonTypesQuery.data ?? []).map((t) => ({
value: t.id,
label: t.code ? `${t.name} (${t.code})` : t.name,
}))}
value={coupleType}
onChange={(v) => {
setCoupleType(v);
setCouplePage(1);
}}
w={200}
/>
<TextInput
label="Search"
placeholder="Wagon number…"
leftSection={<Search size={14} />}
value={coupleSearch}
onChange={(e) => {
setCoupleSearch(e.currentTarget.value);
setCouplePage(1);
}}
w={200}
/>
</Group>
{coupleListQuery.isLoading ? (
<Group justify="center" p="md">
<Loader size="sm" />
</Group>
) : (
<ScrollArea.Autosize mah={380}>
<Table striped highlightOnHover withTableBorder>
<Table.Thead>
<Table.Tr>
<Table.Th>Wagon</Table.Th>
<Table.Th>Type</Table.Th>
<Table.Th>Standing at</Table.Th>
<Table.Th ta="right">Couple</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{coupleCandidates.map((w) => {
const onTrip = data.wagons.some((row) => row.id === w.id);
const queued = w.id in pendingCouples;
const stop = intermediateStops.find((s) => s.yardId === w.currentYardId);
return (
<Table.Tr key={w.id}>
<Table.Td>
<Text fw={600} size="sm">
{w.wagonNumber}
</Text>
</Table.Td>
<Table.Td>
<Badge size="sm" variant="light" color="gray">
{w.wagonType?.code ?? w.wagonTypeId}
</Badge>
</Table.Td>
<Table.Td>
<Text size="sm">{w.currentYard?.label ?? "No yard"}</Text>
</Table.Td>
<Table.Td ta="right">
{onTrip ? (
<Badge size="sm" variant="light" color="gray">
On this trip
</Badge>
) : queued ? (
<Button
size="compact-xs"
variant="subtle"
color="red"
onClick={() =>
setPendingCouples((prev) => {
const next = { ...prev };
delete next[w.id];
return next;
})
}
>
Queued remove
</Button>
) : stop ? (
<Button
size="compact-xs"
variant="light"
leftSection={<Plus size={12} />}
onClick={() =>
setPendingCouples((prev) => ({
...prev,
[w.id]: {
yardId: stop.yardId,
wagonNumber: w.wagonNumber,
typeCode: w.wagonType?.code ?? w.wagonTypeId,
},
}))
}
>
Couple at {stop.label}
</Button>
) : (
<Tooltip label="Not standing at a mid-route stop of this schedule (origin and destination excluded)">
<Button size="compact-xs" variant="default" disabled>
Off route
</Button>
</Tooltip>
)}
</Table.Td>
</Table.Tr>
);
})}
{coupleCandidates.length === 0 ? (
<Table.Tr>
<Table.Td colSpan={4}>
<Text size="sm" c="dimmed" ta="center" py="sm">
No loose wagons match the filters.
</Text>
</Table.Td>
</Table.Tr>
) : null}
</Table.Tbody>
</Table>
</ScrollArea.Autosize>
)}
<Group justify="space-between">
{coupleTotalPages > 1 ? (
<Pagination
size="sm"
value={couplePage}
onChange={setCouplePage}
total={coupleTotalPages}
/>
) : (
<span />
)}
<Group gap="sm">
<Text size="sm" c="dimmed">
{Object.keys(pendingCouples).length} wagon(s) queued save the plan to apply
</Text>
<Button onClick={() => setCoupleModalOpen(false)}>Done</Button>
</Group>
</Group>
</Stack>
</Modal>
<Table striped highlightOnHover withTableBorder>
<Table.Thead>
<Table.Tr>
<Table.Th>#</Table.Th>
<Table.Th>Wagon</Table.Th>
<Table.Th>Type</Table.Th>
<Table.Th>Physical yard</Table.Th>
<Table.Th>Planned yard (this schedule)</Table.Th>
<Table.Th>Cut at (rides to)</Table.Th>
<Table.Th>Status</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{data.wagons
.filter((w) => !w.coupledYardId)
.map((w) => {
const planned = effectiveYard(w);
const cut = effectiveCut(w);
const changed = w.id in pending || w.id in pendingCut || w.id in pendingRealCut;
return (
<Table.Tr key={w.id} bg={changed ? "var(--mantine-color-yellow-light)" : undefined}>
<Table.Td>{w.sequenceNumber ?? "—"}</Table.Td>
<Table.Td>
<Text fw={600} size="sm">
{w.wagonNumber}
</Text>
</Table.Td>
<Table.Td>{w.wagonType.code}</Table.Td>
<Table.Td>{w.physicalYardLabel ?? "No yard"}</Table.Td>
<Table.Td>
{editable && !w.locked ? (
<Select
size="xs"
data={yardOptions}
value={planned}
onChange={(v) => {
setPending((prev) => {
const next = { ...prev };
if (!v || v === w.plannedYardId) delete next[w.id];
else next[w.id] = v;
return next;
});
setPendingCut((prev) =>
clearInvalidCut({ ...prev }, w, v ?? w.plannedYardId),
);
}}
w={180}
/>
) : (
<Group gap={4}>
<Text size="sm">{yardLabel(planned)}</Text>
{w.locked ? (
<Tooltip label={w.lockReason ?? "Locked"}>
<Lock size={14} />
</Tooltip>
) : null}
</Group>
)}
</Table.Td>
<Table.Td>
{editable ? (
// Locked wagons stay editable here — the server enforces the
// cargo-destination floor and the toast explains a 409.
<Stack gap={4}>
<Select
size="xs"
clearable
placeholder="Destination"
data={cutOptionsFor(planned)}
value={cut}
onChange={(v) => {
setPendingCut((prev) => {
const next = { ...prev };
if ((v ?? null) === w.cutYardId) delete next[w.id];
else next[w.id] = v ?? null;
return next;
});
if (!v) {
// No cut → no real-cut flag to keep.
setPendingRealCut((prev) => {
const next = { ...prev };
if (w.realCut) next[w.id] = false;
else delete next[w.id];
return next;
});
}
}}
w={180}
/>
{cut ? (
<Checkbox
size="xs"
label="Real cut (train loses wagon)"
checked={effectiveRealCut(w)}
onChange={(e) => {
const v = e.currentTarget.checked;
setPendingRealCut((prev) => {
const next = { ...prev };
if (v === w.realCut) delete next[w.id];
else next[w.id] = v;
return next;
});
}}
/>
) : null}
</Stack>
) : (
<Text size="sm">
{cut
? `${yardLabel(cut)}${effectiveRealCut(w) ? " (real cut)" : ""}`
: "Destination"}
</Text>
)}
</Table.Td>
<Table.Td>
{planned === w.physicalYardId ? (
<Badge color="teal" variant="light" size="sm">
Aligned
</Badge>
) : (
<Badge color="orange" variant="light" size="sm">
Needs move
</Badge>
)}
</Table.Td>
</Table.Tr>
);
})}
{data.wagons
.filter((w) => w.coupledYardId)
.map((w) => {
const queuedOff = pendingUncouple.includes(w.id);
return (
<Table.Tr
key={w.id}
bg={queuedOff ? "var(--mantine-color-yellow-light)" : undefined}
opacity={queuedOff ? 0.5 : undefined}
>
<Table.Td></Table.Td>
<Table.Td>
<Text fw={600} size="sm">
{w.wagonNumber}
</Text>
</Table.Td>
<Table.Td>{w.wagonType.code}</Table.Td>
<Table.Td>{w.physicalYardLabel ?? "No yard"}</Table.Td>
<Table.Td>
<Badge size="sm" variant="light" color="blue" leftSection={<Link2 size={12} />}>
Coupled at {w.coupledYardLabel ?? w.coupledYardId}
</Badge>
</Table.Td>
<Table.Td>
<Text size="sm">Destination</Text>
</Table.Td>
<Table.Td>
<Group gap={6}>
{w.aligned ? (
<Badge color="teal" variant="light" size="sm">
At couple yard
</Badge>
) : (
<Badge color="orange" variant="light" size="sm">
Not at couple yard
</Badge>
)}
{editable ? (
<Tooltip
label={w.locked ? w.lockReason ?? "Locked" : "Remove from couple plan"}
>
<Button
size="compact-xs"
variant="subtle"
color="red"
disabled={w.locked}
onClick={() =>
setPendingUncouple((prev) =>
queuedOff ? prev.filter((id) => id !== w.id) : [...prev, w.id],
)
}
>
{queuedOff ? "Keep" : "Uncouple"}
</Button>
</Tooltip>
) : null}
</Group>
</Table.Td>
</Table.Tr>
);
})}
{Object.entries(pendingCouples).map(([wagonId, c]) => (
<Table.Tr key={wagonId} bg="var(--mantine-color-yellow-light)">
<Table.Td></Table.Td>
<Table.Td>
<Text fw={600} size="sm">
{c.wagonNumber}
</Text>
</Table.Td>
<Table.Td>{c.typeCode}</Table.Td>
<Table.Td>{yardLabel(c.yardId)}</Table.Td>
<Table.Td>
<Badge size="sm" variant="light" color="blue" leftSection={<Link2 size={12} />}>
Coupled at {yardLabel(c.yardId)} (pending)
</Badge>
</Table.Td>
<Table.Td>
<Text size="sm">Destination</Text>
</Table.Td>
<Table.Td>
<Button
size="compact-xs"
variant="subtle"
color="red"
onClick={() =>
setPendingCouples((prev) => {
const next = { ...prev };
delete next[wagonId];
return next;
})
}
>
Remove
</Button>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
{editable ? (
<Group justify="flex-end">
<Text size="sm" c="dimmed">
{pendingCount} pending change(s)
</Text>
<Button
variant="default"
onClick={() => {
setPending({});
setPendingCut({});
setPendingRealCut({});
setPendingCouples({});
setPendingUncouple([]);
}}
disabled={!pendingCount}
>
Discard
</Button>
<Button
onClick={() => void handleSave()}
loading={save.isPending}
disabled={!pendingCount}
>
Save plan
</Button>
</Group>
) : null}
</Stack>
);
}

View File

@@ -38,6 +38,8 @@ import { CountdownTimer } from "@edr/ui-common";
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
import { EntityLink } from "@/components/detail";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission as hasFreightPermission } from "@/lib/permissions";
import { api } from "@/services/api";
import { bookingsService } from "@/services/bookings.service";
import { useToast } from "@/hooks/use-toast";
@@ -170,6 +172,9 @@ export function ScheduleWorkspacePanel({
onChanged,
}: ScheduleWorkspacePanelProps) {
const { toast } = useToast();
const { user } = useAuth();
const canLoad = hasFreightPermission(user, FREIGHT_PERMS.trainScheduling.load);
const canUnload = hasFreightPermission(user, FREIGHT_PERMS.trainScheduling.unload);
const freightType: FreightType | undefined =
schedule.freightType === "CONTAINER" || schedule.freightType === "BULK"
@@ -347,6 +352,64 @@ export function ScheduleWorkspacePanel({
const pct = capacity > 0 ? Math.min(100, Math.round((used / capacity) * 100)) : 0;
const over = capacity > 0 && used > capacity;
// One confirmation dialog for every booking action; the action fires only
// after staff confirm, and the existing toasts report the outcome.
const [confirmAction, setConfirmAction] = useState<{
kind: "add" | "load" | "truckToTrain" | "unload" | "remove";
bookingId: string;
ref: string;
weightTons?: number;
} | null>(null);
const confirmMeta: Record<
NonNullable<typeof confirmAction>["kind"],
{ title: string; message: string; color: string; confirmLabel: string }
> = {
add: {
title: "Add booking to this train?",
message:
"The booking is assigned to this departure and wagons are auto-pinned. Adding past the pull-weight limit is allowed but flagged for review.",
color: "edr-green",
confirmLabel: "Add to train",
},
load: {
title: "Load cargo onto the train?",
message:
"Stamps the booking as loaded at this yard. The server checks the train is actually standing here.",
color: "edr-green",
confirmLabel: "Load",
},
truckToTrain: {
title: "Load as direct truck-to-train?",
message:
"Sets direct truck-to-train handover (no warehouse receipt, no GRN — the carriage acceptance sheet becomes the handover document) and loads the cargo.",
color: "blue",
confirmLabel: "Load direct",
},
unload: {
title: "Unload cargo at this yard?",
message: "Stamps the booking's arrival at this yard and frees its wagons for reuse.",
color: "orange",
confirmLabel: "Unload",
},
remove: {
title: "Remove booking from this train?",
message:
"Returns the booking to the unassigned pool, writes a removal log entry, and notifies the customer.",
color: "red",
confirmLabel: "Remove",
},
};
const runConfirmedAction = () => {
if (!confirmAction) return;
const { kind, bookingId, ref, weightTons } = confirmAction;
setConfirmAction(null);
if (kind === "add") forceAdd(bookingId, ref, weightTons ?? 0);
else if (kind === "load") doLoad(bookingId, ref);
else if (kind === "truckToTrain") doTruckToTrain(bookingId, ref);
else if (kind === "unload") doUnload(bookingId, ref);
else removeFromTrain(bookingId, ref);
};
const forceAdd = (bookingId: string, ref: string, weightTons: number) => {
const wouldOverfill = capacity > 0 && used + (weightTons || 0) > capacity;
assign
@@ -649,7 +712,14 @@ export function ScheduleWorkspacePanel({
radius="md"
rightSection={<ArrowRight size={14} />}
loading={assign.isPending}
onClick={() => forceAdd(b.id, b.reference, b.weightTons)}
onClick={() =>
setConfirmAction({
kind: "add",
bookingId: b.id,
ref: b.reference,
weightTons: b.weightTons,
})
}
>
Add
</Button>
@@ -773,13 +843,15 @@ export function ScheduleWorkspacePanel({
{showLoad ? (
<Tooltip
label={
boardHere
? `Load cargo onto the train at ${group.label}`
: passed
? `Train already passed ${group.label} — this cargo missed its stop`
: `Loads at ${group.label} — train is ${
trainAtLabel ? `at ${trainAtLabel}` : "not there yet"
}`
!canLoad
? "You don't have permission to load cargo"
: boardHere
? `Load cargo onto the train at ${group.label}`
: passed
? `Train already passed ${group.label} — this cargo missed its stop`
: `Loads at ${group.label} — train is ${
trainAtLabel ? `at ${trainAtLabel}` : "not there yet"
}`
}
withArrow
>
@@ -788,13 +860,15 @@ export function ScheduleWorkspacePanel({
variant="filled"
color="edr-green"
radius="md"
disabled={!boardHere}
disabled={!boardHere || !canLoad}
leftSection={<PackageCheck size={13} />}
loading={
loadJourney.isPending &&
loadJourney.variables?.bookingId === b.id
}
onClick={() => doLoad(b.id, ref)}
onClick={() =>
setConfirmAction({ kind: "load", bookingId: b.id, ref })
}
>
Load
</Button>
@@ -802,7 +876,11 @@ export function ScheduleWorkspacePanel({
) : null}
{showTruckToTrain ? (
<Tooltip
label="Customer truck loaded straight onto the wagon — no warehouse receipt, no GRN. Sets direct truck-to-train handover and loads."
label={
canLoad
? "Customer truck loaded straight onto the wagon — no warehouse receipt, no GRN. Sets direct truck-to-train handover and loads."
: "You don't have permission to load cargo"
}
withArrow
>
<Button
@@ -810,9 +888,16 @@ export function ScheduleWorkspacePanel({
variant="light"
color="blue"
radius="md"
disabled={!canLoad}
leftSection={<Truck size={13} />}
loading={truckToTrainPending === b.id}
onClick={() => doTruckToTrain(b.id, ref)}
onClick={() =>
setConfirmAction({
kind: "truckToTrain",
bookingId: b.id,
ref,
})
}
>
Truck to Train
</Button>
@@ -821,9 +906,11 @@ export function ScheduleWorkspacePanel({
{showUnload ? (
<Tooltip
label={
alightHere
? "Unload at this yard — stamps the booking's arrival"
: "Unloads when the train reaches its destination yard"
!canUnload
? "You don't have permission to unload cargo"
: alightHere
? "Unload at this yard — stamps the booking's arrival"
: "Unloads when the train reaches its destination yard"
}
withArrow
>
@@ -832,13 +919,15 @@ export function ScheduleWorkspacePanel({
variant="light"
color="orange"
radius="md"
disabled={!alightHere}
disabled={!alightHere || !canUnload}
leftSection={<PackageOpen size={13} />}
loading={
unloadJourney.isPending &&
unloadJourney.variables?.bookingId === b.id
}
onClick={() => doUnload(b.id, ref)}
onClick={() =>
setConfirmAction({ kind: "unload", bookingId: b.id, ref })
}
>
Unload
</Button>
@@ -857,7 +946,9 @@ export function ScheduleWorkspacePanel({
unassign.isPending &&
unassign.variables?.bookingId === b.id
}
onClick={() => removeFromTrain(b.id, ref)}
onClick={() =>
setConfirmAction({ kind: "remove", bookingId: b.id, ref })
}
>
Remove
</Button>
@@ -961,6 +1052,82 @@ export function ScheduleWorkspacePanel({
</Group>
</Stack>
</Modal>
{/* Confirm add / load / unload / remove */}
<Modal
opened={Boolean(confirmAction)}
onClose={() => setConfirmAction(null)}
centered
radius="lg"
size="md"
withCloseButton={false}
title={
confirmAction ? (
<Group gap={10} wrap="nowrap">
<ThemeIcon
size={40}
radius="md"
variant="light"
color={confirmMeta[confirmAction.kind].color}
>
{confirmAction.kind === "remove" ? (
<X size={21} />
) : confirmAction.kind === "unload" ? (
<PackageOpen size={21} />
) : confirmAction.kind === "truckToTrain" ? (
<Truck size={21} />
) : (
<PackageCheck size={21} />
)}
</ThemeIcon>
<div>
<Text fw={800}>{confirmMeta[confirmAction.kind].title}</Text>
<Text size="xs" c="dimmed">
{confirmAction.ref}
</Text>
</div>
</Group>
) : null
}
>
{confirmAction ? (
<Stack gap="md">
<Text size="sm">{confirmMeta[confirmAction.kind].message}</Text>
{confirmAction.kind === "add" &&
capacity > 0 &&
used + (confirmAction.weightTons ?? 0) > capacity ? (
<Group
gap={8}
p="xs"
wrap="nowrap"
style={{
borderRadius: 10,
background: "var(--mantine-color-red-0)",
border: "1px solid var(--mantine-color-red-2)",
}}
>
<AlertTriangle size={16} color="#B42318" />
<Text size="xs" c="red.8" fw={500}>
This add pushes the heaviest leg past the locomotive pull weight (
{(used + (confirmAction.weightTons ?? 0)).toFixed(1)}T / {capacity.toFixed(0)}T).
</Text>
</Group>
) : null}
<Group justify="flex-end" gap="sm">
<Button variant="default" radius="md" onClick={() => setConfirmAction(null)}>
Cancel
</Button>
<Button
color={confirmMeta[confirmAction.kind].color}
radius="md"
onClick={runConfirmedAction}
>
{confirmMeta[confirmAction.kind].confirmLabel}
</Button>
</Group>
</Stack>
) : null}
</Modal>
</Paper>
);
}

View File

@@ -39,11 +39,6 @@ interface InteractiveTrainConsistProps {
onMoveLoad?: (move: WagonLoadMove) => void;
}
const wagonItems = (wagon: Wagon) =>
(wagon.allocations ?? [])
.flatMap((a) => a.containerItems ?? [])
.sort((a, b) => (a.positionOnWagon ?? 99) - (b.positionOnWagon ?? 99));
const CONTAINER_GRADIENTS = [
"linear-gradient(180deg, var(--mantine-color-cyan-5), var(--mantine-color-cyan-7))",
"linear-gradient(180deg, var(--mantine-color-blue-5), var(--mantine-color-blue-7))",
@@ -192,7 +187,31 @@ function WagonCar({
}) {
const [dropHover, setDropHover] = useState(false);
const wagon = slots[0]!;
const loaded = slots.filter((s) => (s.allocations?.length ?? 0) > 0);
const loadedSlots = slots.filter((s) => (s.allocations?.length ?? 0) > 0);
// One drawn row per LOAD, not per slot. A wagon reused across disjoint legs
// (containers to Dire Dawa, bulk onward) is ONE slot holding two allocations
// with different corridors — counting slots drew that as a single row and
// hid the second load entirely. Group the slot's allocations by their own
// booking corridor so each load gets its own row, stacked top/bottom.
const loaded = loadedSlots.flatMap((slot) => {
const allocations = slot.allocations ?? [];
const byCorridor = new Map<string, typeof allocations>();
for (const allocation of allocations) {
const key =
allocation.originYardId && allocation.destinationYardId
? `${allocation.originYardId}->${allocation.destinationYardId}`
: "whole-route";
byCorridor.set(key, [...(byCorridor.get(key) ?? []), allocation]);
}
if (byCorridor.size < 2) {
return [{ slot, allocations, corridorKey: null as string | null }];
}
return [...byCorridor.entries()].map(([key, group]) => ({
slot,
allocations: group,
corridorKey: key as string | null,
}));
});
const shared = loaded.length > 1;
const isEmpty = !loaded.length;
const isBulk = loaded.some((s) =>
@@ -201,12 +220,15 @@ function WagonCar({
// GROSS on both sides: cargo across every slot + tare (counted ONCE — the
// slots share the same physical wagon) vs rated payload + tare.
const tare = wagon.tareWeightTons ?? 0;
// Heaviest single load, not the sum: rows on disjoint legs never ride at the
// same time, so summing them would over-report what the wagon carries.
const cargo = loaded.reduce(
(sum, s) =>
sum +
((s.allocations ?? []).reduce((a, al) => a + (al.allocatedWeightTons ?? 0), 0) ||
s.assignedWeightTons ||
0),
(max, row) =>
Math.max(
max,
row.allocations.reduce((a, al) => a + (al.allocatedWeightTons ?? 0), 0) ||
(loaded.length === 1 ? row.slot.assignedWeightTons || 0 : 0),
),
0,
);
const assigned = cargo + tare;
@@ -249,8 +271,8 @@ function WagonCar({
<HoverCard width={280} shadow="lg" radius="md" position="top" withArrow openDelay={120}>
<HoverCard.Target>
<Box
onClick={() => onSelectSlot(loaded[0] ?? wagon)}
style={{ width: 120, flexShrink: 0, cursor: "pointer" }}
onClick={() => onSelectSlot(loaded[0]?.slot ?? wagon)}
style={{ width: 148, flexShrink: 0, cursor: "pointer" }}
>
<Box
onDragOver={(e) => {
@@ -270,7 +292,9 @@ function WagonCar({
}}
style={{
position: "relative",
height: 70,
// A leg-sharing wagon stacks its loads (bulk and container rows
// top/bottom) — give the stack real height so both stay legible.
height: shared ? 88 : 70,
borderRadius: 11,
background: isEmpty
? "var(--mantine-color-gray-0)"
@@ -387,16 +411,25 @@ function WagonCar({
// two side by side. A shared wagon stacks its slots top/bottom
// (intercity above, export below); each row selects ITS slot.
<Stack gap={3} style={{ width: "100%" }}>
{loaded.map((slot, r) => {
const rowBulk = (slot.allocations ?? []).some((a) =>
{loaded.map((row, r) => {
const slot = row.slot;
const rowBulk = row.allocations.some((a) =>
(a.loadType ?? "").toUpperCase().includes("BULK"),
);
const rowBlocks = wagonItems(slot).slice(0, 2);
// Container blocks of THIS row's allocations only, so a
// leg-shared wagon shows each leg's own boxes.
const rowBlocks = row.allocations
.flatMap((a) => a.containerItems ?? [])
.slice()
.sort(
(a, b) => (a.positionOnWagon ?? 0) - (b.positionOnWagon ?? 0),
)
.slice(0, 2);
const rowSelected = shared && slot.id === selectedWagonId;
const rowHeight = shared ? 13 : 26;
const rowHeight = shared ? 20 : 26;
return (
<Group
key={slot.id}
key={`${slot.id}-${row.corridorKey ?? "all"}`}
gap={3}
justify="center"
wrap="nowrap"
@@ -547,15 +580,18 @@ function WagonCar({
</Text>
) : (
<Stack gap={6}>
{loaded.map((slot) => {
const slotAllocation = slot.allocations?.[0];
{loaded.map((row) => {
const slot = row.slot;
const slotAllocation = row.allocations[0];
const slotCompany = getCompany(slotAllocation?.bookingId);
const slotContainers = wagonItems(slot).map(
(c) => c.containerNumber?.trim() || "—",
);
// This row's own containers, so a leg-shared wagon lists each
// leg's boxes under its own load rather than all of them twice.
const slotContainers = row.allocations
.flatMap((a) => a.containerItems ?? [])
.map((c) => c.containerNumber?.trim() || "—");
return (
<Stack
key={slot.id}
key={`${slot.id}-${row.corridorKey ?? "all"}`}
gap={4}
style={
shared

View File

@@ -18,6 +18,8 @@ interface TrainConsistViewProps {
scheduleDetail: TrainScheduleDetail;
scheduleId: string;
maxWagons: number;
/** Hide the consist-wide Wagons stat tile (dispatch shows leg capacity instead). */
showWagonStat?: boolean;
/** Booking id selected in the side panel — highlights its wagons in the consist. */
highlightBookingId?: string | null;
}
@@ -45,6 +47,7 @@ export const TrainConsistView = ({
scheduleDetail,
scheduleId,
maxWagons,
showWagonStat = true,
highlightBookingId,
}: TrainConsistViewProps) => {
const [selectedWagonId, setSelectedWagonId] = useState<string | null>(null);
@@ -172,6 +175,7 @@ export const TrainConsistView = ({
lengthMax={lengthMax}
wagonCount={wagonsUsed}
wagonMax={maxWagons}
showWagons={showWagonStat}
/>
{/* Consist panel */}

View File

@@ -9,6 +9,12 @@ interface TrainStatsBarProps {
lengthMax: number | null;
wagonCount: number;
wagonMax: number;
/**
* The wagon tile is a consist-wide count, which reads as wrong on a
* multi-leg schedule where per-leg capacity is the real number. Dispatch
* hides it (leg capacity is shown there instead); the batch board keeps it.
*/
showWagons?: boolean;
}
function pctColor(pct: number) {
@@ -83,6 +89,7 @@ export const TrainStatsBar = ({
lengthMax,
wagonCount,
wagonMax,
showWagons = true,
}: TrainStatsBarProps) => {
const weightPct = weightMax ? (weightUsed / weightMax) * 100 : null;
const lengthPct = lengthMax ? (lengthUsed / lengthMax) * 100 : null;
@@ -95,7 +102,7 @@ export const TrainStatsBar = ({
withBorder
style={{ borderColor: "var(--mantine-color-gray-2)", background: "white" }}
>
<SimpleGrid cols={{ base: 1, xs: 3 }} spacing="lg">
<SimpleGrid cols={{ base: 1, xs: showWagons ? 3 : 2 }} spacing="lg">
<StatTile
icon={<Weight size={15} />}
label="Gross weight"
@@ -108,7 +115,7 @@ export const TrainStatsBar = ({
px={{ base: 0, xs: "lg" }}
style={{
borderLeft: "1px solid var(--mantine-color-gray-2)",
borderRight: "1px solid var(--mantine-color-gray-2)",
borderRight: showWagons ? "1px solid var(--mantine-color-gray-2)" : undefined,
}}
>
<StatTile
@@ -120,14 +127,16 @@ export const TrainStatsBar = ({
unit="m"
/>
</Box>
<StatTile
icon={<Train size={15} />}
label="Wagons"
pct={wagonPct}
current={String(wagonCount)}
max={String(wagonMax)}
unit=""
/>
{showWagons ? (
<StatTile
icon={<Train size={15} />}
label="Wagons"
pct={wagonPct}
current={String(wagonCount)}
max={String(wagonMax)}
unit=""
/>
) : null}
</SimpleGrid>
</Paper>
);

View File

@@ -51,6 +51,8 @@ import {
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useAuth } from '@/auth/useAuth';
import { FREIGHT_PERMS, hasPermission as hasFreightPermission } from '@/lib/permissions';
import { api } from '@/services/api';
import { QUERY_KEYS } from '@/constants/QUERY_KEYS';
import { useToast } from '@/hooks/use-toast';
@@ -1566,6 +1568,8 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged
/** Export items that passed inspection and are queued to be loaded onto a train. */
function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?: () => void }) {
const { toast } = useToast();
const { user } = useAuth();
const canLoad = hasFreightPermission(user, FREIGHT_PERMS.warehouseInventory.load);
const { data: rows = [], isLoading } = useQuery(
api.warehouses.readyToLoadExport.queryOptions({ enabled }),
);
@@ -1655,16 +1659,18 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?:
<><b>{controls.filteredRows.length}</b> item{controls.filteredRows.length !== 1 ? 's' : ''} ready to load</>
)}
</Text>
<Button
size="compact-sm"
variant="filled"
color="teal"
leftSection={<Truck size={14} />}
disabled={rows.length === 0}
onClick={() => setTrainPickerOpen(true)}
>
{selected.size > 0 ? `Load Selected (${selected.size})` : 'Auto Load Ready Items'}
</Button>
<Tooltip label={canLoad ? undefined : "You don't have permission to load cargo"} disabled={canLoad} withArrow>
<Button
size="compact-sm"
variant="filled"
color="teal"
leftSection={<Truck size={14} />}
disabled={rows.length === 0 || !canLoad}
onClick={() => setTrainPickerOpen(true)}
>
{selected.size > 0 ? `Load Selected (${selected.size})` : 'Auto Load Ready Items'}
</Button>
</Tooltip>
</Group>
<Modal
@@ -2305,6 +2311,8 @@ export function ImportArriveQueueTab({
onChanged?: () => void;
}) {
const { toast } = useToast();
const { user } = useAuth();
const canUnload = hasFreightPermission(user, FREIGHT_PERMS.warehouseInventory.unload);
const { data: trains = [], isLoading } = useQuery(
api.warehouses.importArriveQueue.queryOptions({ enabled }),
);
@@ -2489,23 +2497,25 @@ export function ImportArriveQueueTab({
>
Open
</Button>
<Button
size="compact-xs"
color={fullyUnloaded ? 'gray' : 'indigo'}
leftSection={<Truck size={14} />}
loading={busyId === t.scheduleId}
disabled={fullyUnloaded || t.totalBookings === 0 || !readyBySchedule[t.scheduleId] || warehousesLoading}
onClick={() =>
setConfirmAction({
title: 'Auto unload train',
message: `Unload all arrived bookings from train ${t.trainNumber ?? t.scheduleId.slice(0, 8)} into their assigned warehouse locations?`,
confirmLabel: 'Unload train',
run: () => autoUnload(t),
})
}
>
{fullyUnloaded ? 'Already Unloaded' : 'Auto Unload Arrived Bookings'}
</Button>
<Tooltip label={canUnload ? undefined : "You don't have permission to unload cargo"} disabled={canUnload} withArrow>
<Button
size="compact-xs"
color={fullyUnloaded ? 'gray' : 'indigo'}
leftSection={<Truck size={14} />}
loading={busyId === t.scheduleId}
disabled={fullyUnloaded || t.totalBookings === 0 || !readyBySchedule[t.scheduleId] || warehousesLoading || !canUnload}
onClick={() =>
setConfirmAction({
title: 'Auto unload train',
message: `Unload all arrived bookings from train ${t.trainNumber ?? t.scheduleId.slice(0, 8)} into their assigned warehouse locations?`,
confirmLabel: 'Unload train',
run: () => autoUnload(t),
})
}
>
{fullyUnloaded ? 'Already Unloaded' : 'Auto Unload Arrived Bookings'}
</Button>
</Tooltip>
</Group>
</Table.Td>
</Table.Tr>

View File

@@ -240,6 +240,8 @@ export const URL_CONSTANTS = {
CLEARANCE_DUTY: (id: string) => `/bookings/${id}/clearance/duty`,
CLEARANCE_DRAFT_DECLARATION: (id: string) =>
`/bookings/${id}/clearance/draft-declaration`,
CLEARANCE_DRAFT_DECLARATION_SKIP: (id: string) =>
`/bookings/${id}/clearance/draft-declaration/skip`,
CLEARANCE_TRANSIT_ASSIGNEE_REQUEST: (id: string) =>
`/bookings/${id}/clearance/transit-assignee/request`,
CLEARANCE_TRANSIT_ASSIGNEE_ASSIGN: (id: string) =>
@@ -778,6 +780,8 @@ export const URL_CONSTANTS = {
`/import-operations/empty-container-returns/${id}/status`,
EMPTY_CONTAINER_RETURNS_LOAD_ON_TRAIN:
"/import-operations/empty-container-returns/load-on-train",
EMPTY_CONTAINER_RETURN_DOCUMENT: (id: string) =>
`/import-operations/empty-container-returns/${id}/document`,
},
VEHICLES: {

View File

@@ -20,6 +20,7 @@ import type {
SaveWarehousePayload,
SaveYardPayload,
SaveZonePayload,
WarehouseDashboardFilter,
WarehouseFilter,
} from '@/types/warehouse';
@@ -461,10 +462,10 @@ export function useInventoryActivity(id?: string) {
});
}
export function useWarehouseDashboard() {
export function useWarehouseDashboard(filter?: WarehouseDashboardFilter) {
return useQuery({
queryKey: ['warehouses', 'dashboard'],
queryFn: () => warehouseService.dashboard().then((r) => r.data),
queryKey: ['warehouses', 'dashboard', filter ?? {}],
queryFn: () => warehouseService.dashboard(filter).then((r) => r.data),
refetchInterval: DASHBOARD_REFETCH_MS,
});
}

View File

@@ -104,6 +104,9 @@ export const FREIGHT_PERMS = {
view: "edr_freight_app:train_scheduling:view",
create: "edr_freight_app:train_scheduling:create",
update: "edr_freight_app:train_scheduling:update",
/** Confirm cargo loaded/unloaded at a yard — import, export, and intercity alike. */
load: "edr_freight_app:train_scheduling:load",
unload: "edr_freight_app:train_scheduling:unload",
cancel: "edr_freight_app:train_scheduling:cancel",
reschedule: "edr_freight_app:train_scheduling:reschedule",
rulesManage: "edr_freight_app:train_scheduling:rules_manage",
@@ -340,6 +343,12 @@ export const FREIGHT_PERMS = {
cancel: "edr_freight_app:warehouse_fee_invoices:cancel",
pay: "edr_freight_app:warehouse_fee_invoices:pay",
},
additionalCharges: {
view: "edr_freight_app:additional_charges:view",
create: "edr_freight_app:additional_charges:create",
send: "edr_freight_app:additional_charges:send",
cancel: "edr_freight_app:additional_charges:cancel",
},
/**
* Audit trail. View-only — the API exposes no write routes for audit rows,
* so there is no manage/delete counterpart to grant.

View File

@@ -13,6 +13,7 @@ import {
Milestone,
MoreHorizontal,
Package,
Receipt,
RefreshCw,
Ship,
Truck,
@@ -64,6 +65,7 @@ import {
ContractOrdersPanel,
} from "@/components/bookings/detail";
import { WarehouseInfoCard } from "@/components/warehouses";
import { AdditionalPaymentsTab } from "@/components/bookings/AdditionalPaymentsTab";
import { getStatusMeta } from "@/features/bookings/booking-status.config";
import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
import { formatDateTime, formatMoney } from "@/lib/format";
@@ -74,7 +76,10 @@ import {
useBookingMutations,
} from "@/hooks/bookings/useBookings";
import { useScrollToHash } from "@/hooks/useScrollToHash";
import { useFileViewer } from "@/hooks/useFileViewer";
import { bookingsService } from "@/services/bookings.service";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission as hasFreightPermission } from "@/lib/permissions";
export default function BookingRequestDetailPage() {
const { id } = useParams<{ id: string }>();
@@ -82,6 +87,12 @@ export default function BookingRequestDetailPage() {
const [searchParams, setSearchParams] = useSearchParams();
// Deep-link from a warehouse fee invoice → this booking's warehouse section.
useScrollToHash();
const { view, viewer } = useFileViewer();
const { user } = useAuth();
const canSeeAdditionalCharges = hasFreightPermission(
user,
FREIGHT_PERMS.additionalCharges.view,
);
// Consolidated pair: `?booking=<partnerId>` swaps the WHOLE page over to the
// other half of the shared wagon. Everything below — KPIs, stepper, the
@@ -206,7 +217,9 @@ export default function BookingRequestDetailPage() {
? "documents"
: requestedTab === "trucks"
? "trucks"
: "overview";
: requestedTab === "additional-charges"
? "additional-charges"
: "overview";
const setActiveTab = (tab: string | null) => {
const next = new URLSearchParams(searchParams);
if (tab && tab !== "overview") next.set("tab", tab);
@@ -509,6 +522,14 @@ export default function BookingRequestDetailPage() {
<Tabs.Tab value="trucks" leftSection={<Truck size={16} />}>
Trucks
</Tabs.Tab>
{canSeeAdditionalCharges && (
<Tabs.Tab
value="additional-charges"
leftSection={<Receipt size={16} />}
>
Additional payments
</Tabs.Tab>
)}
</Tabs.List>
<Tabs.Panel value="overview">
@@ -528,6 +549,11 @@ export default function BookingRequestDetailPage() {
<Tabs.Panel value="trucks">
<BookingTrucksPanel bookingId={booking.id} />
</Tabs.Panel>
{canSeeAdditionalCharges && (
<Tabs.Panel value="additional-charges">
<AdditionalPaymentsTab bookingId={booking.id} onViewFile={view} />
</Tabs.Panel>
)}
</Tabs>
</Grid.Col>
@@ -562,6 +588,7 @@ export default function BookingRequestDetailPage() {
</Grid.Col>
</Grid>
</Stack>
{viewer}
</PageContainer>
);
}

View File

@@ -1,19 +1,12 @@
import { useMyTradeAccess } from "@/hooks/useMyTradeAccess";
import {
Box,
Button,
Card,
Group,
Modal,
Stack,
Text,
} from "@mantine/core";
import { Box, Button, Card, Group, Modal, Stack, Text } from "@mantine/core";
import {
AlertTriangle,
ArrowRight,
Calendar,
CheckCircle2,
Clock,
FileText,
LayoutList,
Link2,
Package,
@@ -23,13 +16,19 @@ import {
User,
} from "lucide-react";
import { useCallback, useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Link, useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { BookingActionsMenu } from "@/components/bookings/BookingActionsMenu";
import { ExportButton } from "@/components/export/ExportButton";
import { formatDate, humanize } from "@/lib/format";
import { FilterBar, dateRangeParams, routeParams, useFilters, type FilterDef } from "@/components/filters";
import {
FilterBar,
dateRangeParams,
routeParams,
useFilters,
type FilterDef,
} from "@/components/filters";
import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
// BookingStatusTabs / Operations* queues removed — replaced by booking-kind tabs.
@@ -150,36 +149,97 @@ export default function BookingRequestsPage() {
// split), so a deep link can never land behind "More filters" unseen.
const bookingFilterDefs: FilterDef[] = useMemo(
() => [
{ key: "customerKind", label: "Booked by", type: "enum", multiple: false, options: CUSTOMER_KIND_OPTIONS },
{ key: "bookingType", label: "Kind", type: "enum", multiple: false, options: BOOKING_KIND_OPTIONS },
{ key: "statuses", label: "Status", type: "enum", options: STATUS_OPTIONS },
{
key: "tradeDirection", label: "Direction", type: "enum", multiple: false,
key: "customerKind",
label: "Booked by",
type: "enum",
multiple: false,
options: CUSTOMER_KIND_OPTIONS,
},
{
key: "bookingType",
label: "Kind",
type: "enum",
multiple: false,
options: BOOKING_KIND_OPTIONS,
},
{
key: "statuses",
label: "Status",
type: "enum",
options: STATUS_OPTIONS,
},
{
key: "tradeDirection",
label: "Direction",
type: "enum",
multiple: false,
options: filterOptions(TRADE_DIRECTION_OPTIONS),
},
{ key: "freightType", label: "Freight", type: "enum", multiple: false, options: FREIGHT_TYPE_OPTIONS },
{ key: "serviceTypeId", label: "Service", type: "enum", multiple: false, options: serviceTypeOptions },
{ key: "paymentStatus", label: "Payment", type: "enum", multiple: false, options: PAYMENT_STATUS_OPTIONS, secondary: true },
{
key: "freightType",
label: "Freight",
type: "enum",
multiple: false,
options: FREIGHT_TYPE_OPTIONS,
},
{
key: "serviceTypeId",
label: "Service",
type: "enum",
multiple: false,
options: serviceTypeOptions,
},
{
key: "paymentStatus",
label: "Payment",
type: "enum",
multiple: false,
options: PAYMENT_STATUS_OPTIONS,
secondary: true,
},
{
// Wins over the `paymentStatus` filter above — the queue is by
// definition PAID — because it's later in this array: toApiParams
// merges defs in order, so a later toParams overwrites an earlier one.
key: "paidUnallocated", label: "Allocation", type: "boolean", secondary: true,
key: "paidUnallocated",
label: "Allocation",
type: "boolean",
secondary: true,
trueLabel: "Paid, not allocated",
toParams: (v) => (v.v[0] === "true" ? { paymentStatus: "PAID", assignedToSchedule: "false" } : {}),
toParams: (v) =>
v.v[0] === "true"
? { paymentStatus: "PAID", assignedToSchedule: "false" }
: {},
},
{ key: "isGovernment", label: "Ownership", type: "enum", multiple: false, options: OWNERSHIP_OPTIONS, secondary: true },
{
key: "route", label: "Route", type: "route", options: yardOptions,
key: "isGovernment",
label: "Ownership",
type: "enum",
multiple: false,
options: OWNERSHIP_OPTIONS,
secondary: true,
},
{
key: "route",
label: "Route",
type: "route",
options: yardOptions,
toParams: routeParams("originYardId", "destinationYardId"),
},
{
key: "created", label: "Created", type: "date", secondary: true,
key: "created",
label: "Created",
type: "date",
secondary: true,
operators: ["between", "before", "after"],
toParams: dateRangeParams("createdFrom", "createdTo"),
},
{
key: "scheduled", label: "Scheduled", type: "date", secondary: true,
key: "scheduled",
label: "Scheduled",
type: "date",
secondary: true,
operators: ["between", "before", "after"],
toParams: dateRangeParams("scheduledFrom", "scheduledTo"),
},
@@ -187,19 +247,24 @@ export default function BookingRequestsPage() {
[filterOptions, yardOptions, serviceTypeOptions],
);
const controls = useFilters(bookingFilterDefs, { defaultSort: "createdAt:DESC", pageSize: 10 });
const controls = useFilters(bookingFilterDefs, {
defaultSort: "createdAt:DESC",
pageSize: 10,
});
const filter: BookingListFilter = useMemo(
() => ({
...(controls.params as unknown as BookingListFilter),
// React Query cache key per kind selection ("ALL" when unfiltered) —
// kept as a param the API ignores, matching the pre-migration cache key.
tab: (controls.values.bookingType?.v[0] as BookingKind | undefined) ?? "ALL",
tab:
(controls.values.bookingType?.v[0] as BookingKind | undefined) ?? "ALL",
}),
[controls.params, controls.values.bookingType],
);
const { data, isLoading, isError, refetch, isFetching } = useBookingList(filter);
const { data, isLoading, isError, refetch, isFetching } =
useBookingList(filter);
const primaryAllocateId = allocateIds[0];
const { data: allocateBooking } = useBookingDetail(
allocateOpen ? primaryAllocateId : undefined,
@@ -262,8 +327,9 @@ export default function BookingRequestsPage() {
async (row: BookingListRow) => {
setAllocatingId(row.id);
try {
const candidates =
await trainSchedulingService.getAllocationCandidates(row.id);
const candidates = await trainSchedulingService.getAllocationCandidates(
row.id,
);
if (candidates.sameDay.length > 0) {
const target = candidates.sameDay[0];
await trainSchedulingService.allocatePaidBooking(row.id, target.id);
@@ -330,7 +396,9 @@ export default function BookingRequestsPage() {
</div>
<div className="min-w-0 max-w-[220px]">
<div className="flex items-center gap-1.5">
<p className="truncate font-medium text-foreground">{b.reference}</p>
<p className="truncate font-medium text-foreground">
{b.reference}
</p>
<Badge
variant={isGeneral ? "secondary" : "outline"}
className="h-5 shrink-0 px-1.5 text-[10px] font-medium"
@@ -338,6 +406,26 @@ export default function BookingRequestsPage() {
{isGeneral ? "General" : "One-time"}
</Badge>
</div>
{b.contractReference ? (
<p className="mt-0.5 flex items-center gap-1 truncate text-xs">
<FileText className="size-3 shrink-0 text-muted-foreground opacity-70" />
{b.contractId ? (
<Link
to={`/dashboard/contract-requests/${b.contractId}/view`}
// The row itself opens the booking — without this the
// contract link would never win the click.
onClick={(e) => e.stopPropagation()}
className="truncate text-blue-600 hover:underline"
>
{b.contractReference}
</Link>
) : (
<span className="truncate text-muted-foreground">
{b.contractReference}
</span>
)}
</p>
) : null}
<p className="mt-0.5 flex items-center gap-1 truncate text-xs text-muted-foreground">
{b.isShippingLine ? (
<Ship className="size-3 shrink-0 opacity-70" />
@@ -346,7 +434,10 @@ export default function BookingRequestsPage() {
)}
{b.customerLabel}
{b.isShippingLine ? (
<Badge variant="secondary" className="h-4 shrink-0 px-1 text-[9px] font-medium">
<Badge
variant="secondary"
className="h-4 shrink-0 px-1 text-[9px] font-medium"
>
Shipping line
</Badge>
) : null}
@@ -382,7 +473,9 @@ export default function BookingRequestsPage() {
<div className="flex items-center gap-1.5 text-sm font-medium text-foreground">
<span className="max-w-[8rem] truncate">{b.originLabel}</span>
<ArrowRight className="size-3.5 shrink-0 text-muted-foreground" />
<span className="max-w-[8rem] truncate">{b.destinationLabel}</span>
<span className="max-w-[8rem] truncate">
{b.destinationLabel}
</span>
</div>
<div className="flex gap-1.5">
<Badge
@@ -443,7 +536,8 @@ export default function BookingRequestsPage() {
size: 140,
cell: ({ row }) => {
const b = row.original;
const needsAllocation = b.paymentStatus === "PAID" && !b.trainScheduleId;
const needsAllocation =
b.paymentStatus === "PAID" && !b.trainScheduleId;
return (
<Group gap="xs" wrap="nowrap">
{needsAllocation ? (
@@ -474,61 +568,61 @@ export default function BookingRequestsPage() {
return (
<PageContainer>
<Stack gap="lg">
<PageHeader
title="Booking requests"
subtitle="Review, approve, and schedule freight booking requests."
action={
<>
<Button
color="edr-green"
leftSection={<Plus size={18} />}
onClick={() => navigate("/dashboard/booking-requests/new")}
>
Create booking
</Button>
<Button
variant="default"
leftSection={<RefreshCw size={16} />}
loading={isFetching}
onClick={handleRefresh}
>
Refresh
</Button>
</>
}
/>
<PageHeader
title="Booking requests"
subtitle="Review, approve, and schedule freight booking requests."
action={
<>
<Button
color="edr-green"
leftSection={<Plus size={18} />}
onClick={() => navigate("/dashboard/booking-requests/new")}
>
Create booking
</Button>
<Button
variant="default"
leftSection={<RefreshCw size={16} />}
loading={isFetching}
onClick={handleRefresh}
>
Refresh
</Button>
</>
}
/>
<KpiStrip
loading={summaryLoading}
items={[
{
label: "In queue",
value: metrics?.inQueue ?? 0,
icon: LayoutList,
color: "edr-green",
},
{
label: "Needs action",
value: metrics?.needsAction ?? 0,
icon: Clock,
color: "yellow",
},
{
label: "Urgent",
value: metrics?.urgent ?? 0,
icon: AlertTriangle,
color: "red",
},
{
label: "Completed",
value: tabCounts?.completed ?? 0,
icon: CheckCircle2,
color: "edr-green",
},
]}
/>
<KpiStrip
loading={summaryLoading}
items={[
{
label: "In queue",
value: metrics?.inQueue ?? 0,
icon: LayoutList,
color: "edr-green",
},
{
label: "Needs action",
value: metrics?.needsAction ?? 0,
icon: Clock,
color: "yellow",
},
{
label: "Urgent",
value: metrics?.urgent ?? 0,
icon: AlertTriangle,
color: "red",
},
{
label: "Completed",
value: tabCounts?.completed ?? 0,
icon: CheckCircle2,
color: "edr-green",
},
]}
/>
{/* Status tabs replaced by booking-kind tabs (one-time / general). The
{/* Status tabs replaced by booking-kind tabs (one-time / general). The
old BookingStatusTabs is commented out — status is now a filter select.
<BookingStatusTabs
active={activeTab}
@@ -540,92 +634,95 @@ export default function BookingRequestsPage() {
/>
*/}
<Card p={0}>
<Stack gap={0}>
<Box px="md" pt="sm" pb="xs" w="100%">
<FilterBar
defs={bookingFilterDefs}
controls={controls}
searchPlaceholder="Search booking, contract, customer or shipping line…"
viewId="booking-requests"
>
<ExportButton datasetKey="bookings" params={controls.params} />
</FilterBar>
<Card p={0}>
<Stack gap={0}>
<Box px="md" pt="sm" pb="xs" w="100%">
<FilterBar
defs={bookingFilterDefs}
controls={controls}
searchPlaceholder="Search booking, contract, customer or shipping line…"
viewId="booking-requests"
>
<ExportButton datasetKey="bookings" params={controls.params} />
</FilterBar>
</Box>
{showEmpty ? (
<Box px="md" pb="md">
<BookingTableEmpty
isError={isError}
hasSearch={hasSearch}
onRetry={handleRefresh}
/>
</Box>
{showEmpty ? (
<Box px="md" pb="md">
<BookingTableEmpty
isError={isError}
hasSearch={hasSearch}
onRetry={handleRefresh}
/>
</Box>
) : (
<Box style={{ overflowX: "auto" }} w="100%">
<DataTable
columns={columns}
data={rows}
status={isLoading ? "loading" : isError ? "error" : "success"}
onRowClick={handleRowClick}
{...controls.tableProps(total)}
containerClassName="border-0 shadow-none bg-transparent"
footer={DataTableFooter}
/>
</Box>
)}
</Stack>
</Card>
</Stack>
<Modal
opened={otherDayModal !== null}
onClose={() => setOtherDayModal(null)}
title="Allocate to another date"
centered
>
<Stack gap="sm">
<Text size="sm" c="dimmed">
No train on {otherDayModal ? formatDate(otherDayModal.booking.scheduledDate) : "the booking's day"}{" "}
fits booking {otherDayModal?.booking.reference}. These trains on
other dates do the customer will be notified of the date change.
</Text>
{otherDayModal?.candidates.map((c) => (
<Group key={c.id} justify="space-between" wrap="nowrap">
<div>
<Text size="sm" fw={500}>
{c.reference ?? "Train"}
</Text>
<Text size="xs" c="dimmed">
Departs {formatDate(c.scheduledDepartureDate)}
{c.direction ? ` · ${c.direction}` : ""}
</Text>
</div>
<Button
size="compact-sm"
color="edr-green"
loading={allocatingId === otherDayModal.booking.id}
onClick={() => void handleAllocateOtherDay(c)}
>
Allocate
</Button>
</Group>
))}
) : (
<Box style={{ overflowX: "auto" }} w="100%">
<DataTable
columns={columns}
data={rows}
status={isLoading ? "loading" : isError ? "error" : "success"}
onRowClick={handleRowClick}
{...controls.tableProps(total)}
containerClassName="border-0 shadow-none bg-transparent"
footer={DataTableFooter}
/>
</Box>
)}
</Stack>
</Modal>
</Card>
</Stack>
{allocateBooking ? (
<AllocateBookingWizard
booking={allocateBooking}
opened={allocateOpen}
onClose={() => {
setAllocateOpen(false);
setAllocateIds([]);
void refetch();
}}
initialBookingIds={allocateIds}
/>
) : null}
<Modal
opened={otherDayModal !== null}
onClose={() => setOtherDayModal(null)}
title="Allocate to another date"
centered
>
<Stack gap="sm">
<Text size="sm" c="dimmed">
No train on{" "}
{otherDayModal
? formatDate(otherDayModal.booking.scheduledDate)
: "the booking's day"}{" "}
fits booking {otherDayModal?.booking.reference}. These trains on
other dates do the customer will be notified of the date change.
</Text>
{otherDayModal?.candidates.map((c) => (
<Group key={c.id} justify="space-between" wrap="nowrap">
<div>
<Text size="sm" fw={500}>
{c.reference ?? "Train"}
</Text>
<Text size="xs" c="dimmed">
Departs {formatDate(c.scheduledDepartureDate)}
{c.direction ? ` · ${c.direction}` : ""}
</Text>
</div>
<Button
size="compact-sm"
color="edr-green"
loading={allocatingId === otherDayModal.booking.id}
onClick={() => void handleAllocateOtherDay(c)}
>
Allocate
</Button>
</Group>
))}
</Stack>
</Modal>
{allocateBooking ? (
<AllocateBookingWizard
booking={allocateBooking}
opened={allocateOpen}
onClose={() => {
setAllocateOpen(false);
setAllocateIds([]);
void refetch();
}}
initialBookingIds={allocateIds}
/>
) : null}
</PageContainer>
);
}

View File

@@ -10,13 +10,23 @@ import {
Group,
Loader,
Modal,
Pagination,
Paper,
Stack,
Tabs,
Text,
Textarea,
ThemeIcon,
} from "@mantine/core";
import { AlertCircle, Check, Clock, Link2, X } from "lucide-react";
import {
AlertCircle,
Check,
Clock,
FileText,
Link2,
User,
X,
} from "lucide-react";
import toast from "react-hot-toast";
import { PageContainer, PageHeader } from "@/components/page";
@@ -28,6 +38,39 @@ import { formatDateTime } from "@/lib/format";
import { extractErrorMessage } from "@/utils/errorExtractor";
const QUEUE_KEY = ["consolidation-approvals", "queue"];
const PAGE_SIZE = 10;
type Status = ConsolidationApprovalRow["status"];
const TABS: { value: Status; label: string }[] = [
{ value: "PENDING", label: "Awaiting approval" },
{ value: "APPROVED", label: "Approved" },
{ value: "REJECTED", label: "Rejected" },
];
const STATUS_COLOR: Record<Status, string> = {
PENDING: "yellow",
APPROVED: "green",
REJECTED: "red",
};
const STATUS_LABEL: Record<Status, string> = {
PENDING: "Awaiting approval",
APPROVED: "Approved",
REJECTED: "Rejected",
};
const STATUS_VERB: Record<Status, string> = {
PENDING: "",
APPROVED: "Approved by",
REJECTED: "Rejected by",
};
const EMPTY_TEXT: Record<Status, string> = {
PENDING: "Nothing waiting for approval.",
APPROVED: "No shared wagon has been approved yet.",
REJECTED: "No shared wagon has been rejected.",
};
/**
* Review queue for shared-wagon pairings.
@@ -37,6 +80,11 @@ const QUEUE_KEY = ["consolidation-approvals", "queue"];
* under two separate invoices, so a person signs off on the pairing first.
* Approving releases BOTH bookings to Operations; rejecting sends BOTH back to
* GL with the reason.
*
* Decided pairings stay on the page rather than vanishing: the decided tabs are
* the record of who signed off on which wagon and why. A rejection is not final
* either — a rejected pairing can still be approved from here once whatever
* blocked it is settled.
*/
export default function ConsolidationApprovalsPage() {
const qc = useQueryClient();
@@ -45,16 +93,31 @@ export default function ConsolidationApprovalsPage() {
kind: "approve" | "reject";
} | null>(null);
const [note, setNote] = useState("");
const [tab, setTab] = useState<Status>("PENDING");
const [page, setPage] = useState(1);
const {
data: rows,
isLoading,
isError,
} = useQuery({
queryKey: QUEUE_KEY,
queryFn: () => bookingsService.consolidationApprovalQueue(),
const { data, isLoading, isError, isFetching } = useQuery({
queryKey: [...QUEUE_KEY, tab, page],
queryFn: () =>
bookingsService.consolidationApprovalQueue({
status: tab,
page,
pageSize: PAGE_SIZE,
}),
// Keeping the last page on screen while the next one loads stops the list
// from collapsing to a spinner on every page or tab click.
placeholderData: (previous) => previous,
});
const shown = data?.items ?? [];
const pageCount = Math.max(1, data?.meta.totalPages ?? 1);
const countOf = (status: Status) => data?.counts?.[status] ?? 0;
const goToTab = (next: Status) => {
setTab(next);
setPage(1);
};
const close = () => {
setDecision(null);
setNote("");
@@ -64,7 +127,10 @@ export default function ConsolidationApprovalsPage() {
mutationFn: () => {
if (!decision) throw new Error("No pairing selected");
return decision.kind === "approve"
? bookingsService.approveConsolidation(decision.row.id, note.trim() || undefined)
? bookingsService.approveConsolidation(
decision.row.id,
note.trim() || undefined,
)
: bookingsService.rejectConsolidation(decision.row.id, note.trim());
},
onSuccess: () => {
@@ -73,6 +139,7 @@ export default function ConsolidationApprovalsPage() {
? "Shared wagon approved — both bookings sent to Operations"
: "Shared wagon rejected — both bookings returned to GL",
);
goToTab(decision?.kind === "approve" ? "APPROVED" : "REJECTED");
void qc.invalidateQueries({ queryKey: QUEUE_KEY });
close();
},
@@ -99,90 +166,194 @@ export default function ConsolidationApprovalsPage() {
<Alert color="red" radius="md" icon={<AlertCircle size={16} />}>
Could not load the approval queue.
</Alert>
) : !rows?.length ? (
<Alert color="gray" radius="md" icon={<Check size={16} />}>
Nothing waiting for approval.
</Alert>
) : (
<Stack gap="md">
{rows.map((row) => (
<Paper
key={row.id}
withBorder
radius="lg"
p="lg"
style={{ borderColor: "#E6ECF2" }}
>
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
<Box style={{ minWidth: 0, flex: 1 }}>
<Group gap={8} align="center" mb={10}>
<ThemeIcon variant="light" color="blue" radius="md" size={30}>
<Link2 size={16} />
</ThemeIcon>
<Text fw={800} fz={15}>
Shared wagon
</Text>
<Badge color="yellow" variant="light" radius="sm">
Awaiting approval
</Badge>
</Group>
<Group gap="xl" wrap="wrap">
<BookingSide
id={row.bookingId}
reference={row.booking?.reference ?? row.bookingReference}
company={row.booking?.company?.name}
/>
<BookingSide
id={row.partnerBookingId}
reference={
row.partnerBooking?.reference ??
row.partnerBookingReference
}
company={row.partnerBooking?.company?.name}
/>
</Group>
<Group gap={6} mt={12} c="dimmed">
<Clock size={13} />
<Text fz={12}>
Requested {formatDateTime(row.requestedAt)}
{row.scheduledDate
? ` · ships ${formatDateTime(row.scheduledDate)}`
: ""}
</Text>
</Group>
</Box>
<Group gap="sm">
<Button
color="edr-green"
radius="md"
leftSection={<Check size={15} />}
onClick={() => {
setDecision({ row, kind: "approve" });
setNote("");
}}
>
Approve
</Button>
<Button
color="red"
<Tabs
value={tab}
onChange={(value) => goToTab((value as Status) ?? "PENDING")}
radius="md"
>
<Tabs.List mb="md">
{TABS.map(({ value, label }) => (
<Tabs.Tab
key={value}
value={value}
rightSection={
<Badge
size="sm"
variant="light"
radius="md"
leftSection={<X size={15} />}
onClick={() => {
setDecision({ row, kind: "reject" });
setNote("");
}}
color={STATUS_COLOR[value]}
radius="sm"
>
Reject
</Button>
{countOf(value)}
</Badge>
}
>
{label}
</Tabs.Tab>
))}
</Tabs.List>
{!shown.length ? (
<Alert color="gray" radius="md" icon={<Check size={16} />}>
{EMPTY_TEXT[tab]}
</Alert>
) : (
<Stack gap="md">
{shown.map((row) => (
<Paper
key={row.id}
withBorder
radius="lg"
p="lg"
style={{ borderColor: "#E6ECF2" }}
>
<Group
justify="space-between"
align="flex-start"
wrap="wrap"
gap="md"
>
<Box style={{ minWidth: 0, flex: 1 }}>
<Group gap={8} align="center" mb={10}>
<ThemeIcon
variant="light"
color="blue"
radius="md"
size={30}
>
<Link2 size={16} />
</ThemeIcon>
<Text fw={800} fz={15}>
Shared wagon
</Text>
<Badge
color={STATUS_COLOR[row.status]}
variant="light"
radius="sm"
>
{STATUS_LABEL[row.status]}
</Badge>
</Group>
<Group gap="xl" wrap="wrap">
<BookingSide
id={row.bookingId}
reference={
row.booking?.reference ?? row.bookingReference
}
company={row.booking?.company?.name}
contractReference={row.contractReference}
contractId={row.booking?.contractId}
/>
<BookingSide
id={row.partnerBookingId}
reference={
row.partnerBooking?.reference ??
row.partnerBookingReference
}
company={row.partnerBooking?.company?.name}
contractReference={row.partnerContractReference}
contractId={row.partnerBooking?.contractId}
/>
</Group>
<Group gap={6} mt={12} c="dimmed">
<Clock size={13} />
<Text fz={12}>
Requested {formatDateTime(row.requestedAt)}
{row.requestedByName
? ` by ${row.requestedByName}`
: ""}
{row.scheduledDate
? ` · ships ${formatDateTime(row.scheduledDate)}`
: ""}
</Text>
</Group>
{row.status !== "PENDING" && (
<Group gap={6} mt={6} c="dimmed" align="flex-start">
<User size={13} style={{ marginTop: 2 }} />
<Box style={{ minWidth: 0 }}>
<Text fz={12}>
{STATUS_VERB[row.status]}{" "}
{row.decidedByName ?? "an unknown user"}
{row.decidedAt
? ` on ${formatDateTime(row.decidedAt)}`
: ""}
</Text>
{row.decisionNote && (
<Text fz={12} fs="italic">
{row.decisionNote}
</Text>
)}
</Box>
</Group>
)}
</Box>
{row.status !== "APPROVED" && (
<Group gap="sm">
<Button
color="edr-green"
radius="md"
leftSection={<Check size={15} />}
onClick={() => {
setDecision({ row, kind: "approve" });
setNote("");
}}
>
{row.status === "REJECTED"
? "Approve anyway"
: "Approve"}
</Button>
{row.status === "PENDING" && (
<Button
color="red"
variant="light"
radius="md"
leftSection={<X size={15} />}
onClick={() => {
setDecision({ row, kind: "reject" });
setNote("");
}}
>
Reject
</Button>
)}
</Group>
)}
</Group>
</Paper>
))}
{pageCount > 1 && (
<Group
justify="space-between"
align="center"
mt={4}
wrap="wrap"
>
<Text fz={12} c="dimmed">
Showing {(page - 1) * PAGE_SIZE + 1}
{Math.min(page * PAGE_SIZE, data?.total ?? 0)} of{" "}
{data?.total ?? 0}
</Text>
<Pagination
size="sm"
radius="md"
color="edr-ink"
total={pageCount}
value={page}
onChange={setPage}
disabled={isFetching}
siblings={1}
boundaries={1}
/>
</Group>
</Group>
</Paper>
))}
</Stack>
)}
</Stack>
)}
</Tabs>
)}
<Modal
@@ -194,17 +365,21 @@ export default function ConsolidationApprovalsPage() {
radius="lg"
title={
<Text fw={800} fz={16}>
{decision?.kind === "approve"
? "Approve this shared wagon?"
: "Reject this shared wagon?"}
{decision?.kind !== "approve"
? "Reject this shared wagon?"
: decision.row.status === "REJECTED"
? "Approve this rejected shared wagon?"
: "Approve this shared wagon?"}
</Text>
}
>
<Stack gap="md">
<Text fz="sm" c="dimmed">
{decision?.kind === "approve"
? "Both bookings leave the gate together and continue to Operations. Each is still invoiced and paid separately."
: "Both bookings go back to GL as “changes requested” with your reason. Neither reaches Operations."}
{decision?.kind !== "approve"
? "Both bookings go back to GL as “changes requested” with your reason. Neither reaches Operations."
: decision.row.status === "REJECTED"
? "This pairing was rejected before. Approving it now overrides that decision — both bookings leave the gate together and continue to Operations."
: "Both bookings leave the gate together and continue to Operations. Each is still invoiced and paid separately."}
</Text>
<Textarea
@@ -254,15 +429,23 @@ export default function ConsolidationApprovalsPage() {
);
}
/** One half of the wagon: its reference (linked) and whose cargo it is. */
/**
* One half of the wagon: its booking reference, the contract it was raised
* under, and whose cargo it is. Both references link out — a reviewer deciding
* a pairing usually wants the contract, not just the shipment.
*/
function BookingSide({
id,
reference,
company,
contractReference,
contractId,
}: {
id: string;
reference?: string | null;
company?: string | null;
contractReference?: string | null;
contractId?: string | null;
}) {
return (
<Box style={{ minWidth: 0 }}>
@@ -276,6 +459,32 @@ function BookingSide({
>
{reference ?? "—"}
</Text>
{contractReference && (
<Group gap={4} wrap="nowrap" mt={2}>
<FileText
size={11}
className="shrink-0"
color="var(--mantine-color-dimmed)"
/>
{contractId ? (
<Text
component={Link}
to={`/dashboard/contract-requests/${contractId}/view`}
fz={12}
c="blue.7"
style={{ textDecoration: "none" }}
>
{contractReference}
</Text>
) : (
<Text fz={12} c="dimmed">
{contractReference}
</Text>
)}
</Group>
)}
<Text fz={12.5} c="dimmed">
{company ?? "—"}
</Text>

View File

@@ -36,6 +36,7 @@ import {
BookingContractCard,
} from "@/components/bookings/detail";
import { ClearanceReviewSection } from "@/components/bookings/detail/ClearanceReviewSection";
import { AdditionalDocsRequestCard } from "@/components/bookings/detail/AdditionalDocsRequestCard";
import { ClearancePhaseStepper } from "@/components/contracts/ClearancePhaseStepper";
import { PhasedClearanceActionPanel } from "@/components/contracts/PhasedClearanceActionPanel";
import { ClearanceMilestoneTimeline } from "@/components/contracts/ClearanceMilestoneTimeline";
@@ -363,6 +364,14 @@ export default function DocumentClearanceDetailPage() {
<Grid.Col span={{ base: 12, lg: isPhasedGeneral ? 5 : 4 }}>
<Box style={{ position: "sticky", top: 24 }}>
<Stack gap="lg">
{/* Documents stay open until payment, so GL can ask for a
missing file at any point in that window. */}
<AdditionalDocsRequestCard
bookingId={id!}
requests={clearance.docRequests ?? []}
canRequest={!documentsClosed}
onSent={() => void refetch()}
/>
{booking ? <BookingCompanyCard booking={booking} /> : null}
{booking ? <BookingContractCard booking={booking} /> : null}
{isPhasedGeneral ? (

View File

@@ -55,9 +55,31 @@ interface WagonCancellation {
reason?: string | null;
rebookedAt?: string | null;
createdAt: string;
booking?: { id: string; reference: string; company?: { name: string } };
booking?: {
id: string;
reference: string;
customsClearingEnabled?: boolean;
company?: { name: string };
};
rebookedBooking?: { id: string; reference: string };
feeInvoice?: { invoiceNumber: string; status: string };
cancelledQuantities?: {
bySize?: Record<string, number>;
units?: Array<{
containerSize: string;
containerNumber: string;
sealNumber?: string | null;
vgmTons: number;
}>;
};
}
/** Editable rebook unit — prefilled from the cancelled snapshot. */
interface RebookUnitDraft {
containerSize: string;
containerNumber: string;
sealNumber: string;
vgmTons: number | "";
}
interface WagonCancellationListResponse {
@@ -116,6 +138,49 @@ export default function WagonCancellationsPage() {
const [from, setFrom] = useState<Date | null>(null);
const [to, setTo] = useState<Date | null>(null);
const [voiding, setVoiding] = useState<WagonCancellation | null>(null);
// GL rebook of a customs (Path B) credit: pick the day; container number /
// seal / VGM may be corrected. Non-customs credits are rebooked by the
// customer from the portal.
const canRebook = hasPermission(
user,
FREIGHT_PERMS.bookings.wagonCancellationRebook,
);
const [rebooking, setRebooking] = useState<WagonCancellation | null>(null);
const [rebookDate, setRebookDate] = useState<Date | null>(null);
const [rebookDrafts, setRebookDrafts] = useState<RebookUnitDraft[]>([]);
const openRebook = (r: WagonCancellation) => {
setRebooking(r);
setRebookDate(null);
setRebookDrafts(
(r.cancelledQuantities?.units ?? []).map((u) => ({
containerSize: u.containerSize,
containerNumber: u.containerNumber,
sealNumber: u.sealNumber ?? "",
vgmTons: Number(u.vgmTons) || "",
})),
);
};
const rebookContainersPayload = () => {
const bySize = new Map<string, RebookUnitDraft[]>();
for (const d of rebookDrafts) {
bySize.set(d.containerSize, [...(bySize.get(d.containerSize) ?? []), d]);
}
return [...bySize.entries()].map(([containerSize, units]) => ({
containerSize,
units: units.map((u) => ({
containerNumber: u.containerNumber.trim(),
...(u.sealNumber.trim() ? { sealNumber: u.sealNumber.trim() } : {}),
...(u.vgmTons !== "" ? { vgmTons: Number(u.vgmTons) } : {}),
})),
}));
};
const rebook = useMutation({
mutationFn: () =>
api.post(`/bookings/wagon-cancellations/${rebooking!.id}/rebook`, {
scheduledDate: toDayString(rebookDate!),
...(rebookDrafts.length ? { containers: rebookContainersPayload() } : {}),
}),
});
const resetPage = () =>
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
@@ -234,18 +299,39 @@ export default function WagonCancellationsPage() {
header: () => <span />,
cell: ({ row }) => {
const r = row.original;
if (r.status !== "FEE_PENDING" || !canVoid) return null;
const showVoid = r.status === "FEE_PENDING" && canVoid;
// Customs credits are GL's to rebook; non-customs ones the customer
// rebooks from the portal.
const showRebook =
r.status === "CREDIT_AVAILABLE" &&
canRebook &&
Boolean(r.booking?.customsClearingEnabled) &&
Number(r.creditAmount) > 0;
if (!showVoid && !showRebook) return null;
return (
<Group justify="flex-end" wrap="nowrap">
<Button
size="xs"
radius="md"
variant="subtle"
color="red"
onClick={() => setVoiding(r)}
>
Void
</Button>
{showRebook && (
<Button
size="xs"
radius="md"
variant="light"
color="green"
onClick={() => openRebook(r)}
>
Rebook
</Button>
)}
{showVoid && (
<Button
size="xs"
radius="md"
variant="subtle"
color="red"
onClick={() => setVoiding(r)}
>
Void
</Button>
)}
</Group>
);
},
@@ -391,6 +477,117 @@ export default function WagonCancellationsPage() {
</Stack>
)}
</Modal>
<Modal
opened={!!rebooking}
onClose={() => setRebooking(null)}
title="Rebook cancelled wagons"
centered
radius="md"
>
{rebooking && (
<Stack gap="sm">
<Text size="sm">
{rebooking.booking?.reference ?? rebooking.bookingId} ·{" "}
{rebooking.wagonsCancelled} wagon(s) · credit{" "}
{formatMoney(rebooking.creditAmount, rebooking.feeCurrency, 2)}
</Text>
<DatePickerInput
label="Shipment day"
placeholder="Pick the day"
value={rebookDate}
onChange={(v) => setRebookDate(v ? new Date(v) : null)}
radius="md"
/>
{rebookDrafts.length > 0 && (
<Stack gap={6}>
<Text size="xs" c="dimmed">
Correct the container details if they changed sizes and
quantities stay as cancelled.
</Text>
{rebookDrafts.map((d, i) => (
<Group key={i} gap={8} wrap="nowrap" align="flex-end">
<TextInput
label={`${d.containerSize} container`}
value={d.containerNumber}
onChange={(e) => {
const v = e.currentTarget.value;
setRebookDrafts((prev) =>
prev.map((x, idx) =>
idx === i ? { ...x, containerNumber: v } : x,
),
);
}}
size="xs"
radius="md"
style={{ flex: 1.4 }}
/>
<TextInput
label="Seal no."
value={d.sealNumber}
onChange={(e) => {
const v = e.currentTarget.value;
setRebookDrafts((prev) =>
prev.map((x, idx) =>
idx === i ? { ...x, sealNumber: v } : x,
),
);
}}
size="xs"
radius="md"
style={{ flex: 1 }}
/>
<TextInput
label="VGM (t)"
type="number"
value={d.vgmTons === "" ? "" : String(d.vgmTons)}
onChange={(e) => {
const raw = e.currentTarget.value;
setRebookDrafts((prev) =>
prev.map((x, idx) =>
idx === i
? { ...x, vgmTons: raw === "" ? "" : Number(raw) }
: x,
),
);
}}
size="xs"
radius="md"
style={{ width: 90 }}
/>
</Group>
))}
</Stack>
)}
<Group justify="flex-end" gap="sm">
<Button
variant="default"
radius="md"
onClick={() => setRebooking(null)}
>
Close
</Button>
<Button
color="green"
radius="md"
disabled={!rebookDate}
loading={rebook.isPending}
onClick={async () => {
try {
await rebook.mutateAsync();
toast.success("Credit rebooked as a new paid booking");
setRebooking(null);
void refetch();
} catch {
// interceptor surfaces the reason
}
}}
>
Rebook
</Button>
</Group>
</Stack>
)}
</Modal>
</PageContainer>
);
}

View File

@@ -8,15 +8,20 @@ import {
Card,
Group,
Menu,
Select,
Stack,
Text,
TextInput,
ThemeIcon,
Tooltip,
UnstyledButton,
} from "@mantine/core";
import { useInterval } from "@mantine/hooks";
import type { LucideIcon } from "lucide-react";
import {
ArrowRight,
Calendar,
Building2,
CalendarClock,
ExternalLink,
Eye,
FileText,
@@ -27,26 +32,27 @@ import {
RefreshCw,
Search,
ShieldCheck,
User,
ShipWheel,
TriangleAlert,
Truck,
X,
} from "lucide-react";
import {
DataTable,
usePagination,
type ColumnDef,
} from "@edr/ui-common";
import { DataTable, usePagination, type ColumnDef } from "@edr/ui-common";
import type { Freight } from "@edr/types";
import { useQuery } from "@tanstack/react-query";
import { PageContainer } from "@/components/page/PageContainer";
import { PageHeader } from "@/components/page/PageHeader";
import { KpiStrip } from "@/components/page/KpiStrip";
import { TablePager } from "@/components/page/TablePager";
import { bookingTable } from "@/components/bookings/booking-ui.styles";
import { useAuth } from "@/auth/useAuth";
import { useBookingEtClearanceQueue } from "@/hooks/bookings/useBookings";
import type { BookingDetail } from "@/types/booking";
import { FREIGHT_PERMS, hasPermission, isDjiboutiGl } from "@/lib/permissions";
import { formatDate } from "@/lib/format";
import { GlUpcomingWindowsSection } from "@/components/contracts/GlUpcomingWindowsSection";
import { CLEARANCE_TABS } from "@/features/clearance/clearance-tabs.config";
import {
RequestedCargoChips,
summarizeRequestedCargo,
@@ -62,53 +68,134 @@ function yardLabel(
return yard.label ?? yard.name ?? yard.code ?? "—";
}
/**
* "Origin → Destination", wrapping past 120px as "Addis Ababa" /
* "→ Djibouti": the arrow is glued to the destination with an nbsp, and
* text wraps normally (the table's cells are otherwise nowrap) so a long
* lane never spills into the next column.
*/
function RouteLabel({
origin,
destination,
}: {
origin: string;
destination: string;
}) {
const prettyStatus = (s: string) =>
s
.toLowerCase()
.replace(/_/g, " ")
.replace(/^\w/, (c) => c.toUpperCase());
const shipmentStatusColor = (s: string) => {
if (s === "AWAITING_DOCUMENTS") return "yellow";
if (s === "DOCUMENTS_UNDER_REVIEW") return "blue";
if (s === "CLEARANCE_READY") return "edr-green";
if (
[
"SELECTED_FOR_BATCH",
"PNR_GENERATED",
"AWAITING_PAYMENT",
"PAYMENT_VERIFICATION_IN_PROGRESS",
].includes(s)
)
return "violet";
if (s === "EXPIRED") return "orange";
if (s === "CANCELLED" || s === "REJECTED") return "red";
return "gray";
};
/** Rows created per day over the last `days` days, oldest → newest. */
function perDay(rows: { createdAt: string | null }[], days = 8): number[] {
const today = new Date().setHours(0, 0, 0, 0);
const out = new Array<number>(days).fill(0);
for (const r of rows) {
if (!r.createdAt) continue;
const age = Math.floor(
(today - new Date(r.createdAt).setHours(0, 0, 0, 0)) / 86_400_000,
);
if (age >= 0 && age < days) out[days - 1 - age] += 1;
}
return out;
}
// ── Tabs ─────────────────────────────────────────────────────────────────────
type TabKey = "all" | "import" | "export" | "review";
const TABS: { key: TabKey; label: string; icon: LucideIcon }[] = [
...CLEARANCE_TABS,
{ key: "review", label: "Needs approval", icon: TriangleAlert },
];
// ── Small pieces ─────────────────────────────────────────────────────────────
function LivePill({ updatedAt }: { updatedAt: number }) {
// Re-render every 30s so "Xm ago" keeps ticking between refetches.
const [, setTick] = useState(0);
useInterval(() => setTick((t) => t + 1), 30_000, { autoInvoke: true });
const mins = Math.max(0, Math.round((Date.now() - updatedAt) / 60_000));
const label = !updatedAt
? "Connecting…"
: mins < 1
? "Live · updated just now"
: `Live · updated ${mins}m ago`;
return (
<Text
size="sm"
maw={120}
lh={1.35}
style={{ whiteSpace: "normal", overflowWrap: "anywhere" }}
>
{origin}{" "}
<ArrowRight
size={13}
className="text-muted-foreground"
style={{ display: "inline-block", verticalAlign: "-2px" }}
/>
{"\u00A0"}
{destination}
</Text>
<span className="inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap rounded-full bg-edr-soft px-2.5 py-1 text-[11px] font-medium text-edr-primary-dark">
<span className="size-1.5 rounded-full bg-edr-primary-dark" />
{label}
</span>
);
}
function CustomsBadge({ customs }: { customs: boolean }) {
return customs ? (
<Badge
size="xs"
variant="light"
color="edr-green"
radius="sm"
leftSection={<ShieldCheck size={11} />}
function DirectionPill({ direction }: { direction: string }) {
const isImport = direction === "IMPORT";
const Icon = isImport ? Truck : ShipWheel;
const color = isImport ? "blue" : "teal";
return (
<span
className="inline-flex items-center gap-1 rounded-[5px] px-1.5 py-[2px] text-[10px] font-medium leading-none"
style={{
background: `var(--mantine-color-${color}-0)`,
color: `var(--mantine-color-${color}-7)`,
}}
>
Customs
</Badge>
) : (
<Badge size="xs" variant="light" color="gray" radius="sm">
No customs
</Badge>
<Icon size={10} />
{prettyStatus(direction)}
</span>
);
}
function OutlinePill({ children }: { children: React.ReactNode }) {
return (
<span className="inline-flex items-center rounded-[5px] border border-edr-border px-1.5 py-[2px] text-[10px] leading-none text-edr-muted">
{children}
</span>
);
}
function RouteCell({
origin,
destination,
direction,
freightType,
customs,
}: {
origin: string;
destination: string;
direction: string;
freightType: string;
customs: boolean;
}) {
return (
<Stack gap={5} py={2}>
<Group gap={6} wrap="nowrap">
<Text fz={12.5} fw={500} c="edr-text">
{origin}
</Text>
<ArrowRight size={12} className="shrink-0 text-edr-muted" />
<Text fz={12.5} fw={500} c="edr-text">
{destination}
</Text>
</Group>
<Group gap={6} wrap="nowrap">
<DirectionPill direction={direction} />
<OutlinePill>{prettyStatus(freightType)}</OutlinePill>
{customs ? (
<span className="inline-flex items-center gap-1 rounded-[5px] bg-edr-soft px-1.5 py-[2px] text-[10px] font-medium leading-none text-edr-primary-dark">
<ShieldCheck size={10} />
Customs
</span>
) : null}
</Group>
</Stack>
);
}
@@ -129,13 +216,21 @@ export default function ContractClearanceListPage() {
!isDjiboutiGl(user);
const [query, setQuery] = useState("");
const [tab, setTab] = useState<TabKey>("all");
const [freight, setFreight] = useState<string | null>(null);
const [status, setStatus] = useState<string | null>(null);
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const resetPage = useCallback(
() => setPagination({ pageIndex: 0, pageSize: pagination.pageSize }),
[setPagination, pagination.pageSize],
);
const {
data: bookingQueue,
isLoading,
isError,
isFetching,
dataUpdatedAt,
refetch,
} = useBookingEtClearanceQueue(true);
@@ -149,7 +244,8 @@ export default function ContractClearanceListPage() {
const requestedByBooking = useMemo(() => {
const map = new Map<string, Freight.RequestedShipmentLines>();
for (const req of requestQueue ?? []) {
if (req.createdBookingId) map.set(req.createdBookingId, req.requestedLines);
if (req.createdBookingId)
map.set(req.createdBookingId, req.requestedLines);
}
return map;
}, [requestQueue]);
@@ -170,7 +266,8 @@ export default function ContractClearanceListPage() {
contractId: b.contractId ?? null,
contractReference: b.contractReference ?? null,
contractKind: b.contractKind ?? null,
customs: b.serviceType?.includesCustoms ?? Boolean(b.customsClearingEnabled),
customs:
b.serviceType?.includesCustoms ?? Boolean(b.customsClearingEnabled),
createdAt: b.createdAt ?? null,
// A bare initiated instance has no cargo/price yet — GL still has to
// create (complete) the booking.
@@ -178,23 +275,9 @@ export default function ContractClearanceListPage() {
})) as ShipmentBookingRow[];
}, [bookingQueue, requestedByBooking]);
const rows = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return allRows;
return allRows.filter(
(r) =>
r.reference.toLowerCase().includes(q) ||
r.customerLabel.toLowerCase().includes(q) ||
(r.contractReference ?? "").toLowerCase().includes(q) ||
r.originLabel.toLowerCase().includes(q) ||
r.destinationLabel.toLowerCase().includes(q) ||
summarizeRequestedCargo(r.requested).toLowerCase().includes(q),
);
}, [allRows, query]);
const counts = useMemo(
// KPI groups span the whole queue, regardless of tab/filters.
const groups = useMemo(
() => ({
all: allRows.length,
// Counts anything actually waiting on GL, including a document added
// after clearance was finalized (the status stays CLEARANCE_READY).
review: allRows.filter(
@@ -202,13 +285,72 @@ export default function ContractClearanceListPage() {
r.status === "AWAITING_DOCUMENTS" ||
r.status === "DOCUMENTS_UNDER_REVIEW" ||
r.hasDocumentsAwaitingReview,
).length,
ready: allRows.filter((r) => r.status === "CLEARANCE_READY" || r.bookingCreated)
.length,
),
approval: allRows.filter((r) => r.hasDocumentsAwaitingReview),
ready: allRows.filter(
(r) => r.status === "CLEARANCE_READY" || r.bookingCreated,
),
}),
[allRows],
);
const newToday = perDay(allRows, 1)[0];
const tabCounts = useMemo<Record<TabKey, number>>(
() => ({
all: allRows.length,
import: allRows.filter((r) => r.tradeDirection === "IMPORT").length,
export: allRows.filter((r) => r.tradeDirection === "EXPORT").length,
review: groups.approval.length,
}),
[allRows, groups.approval.length],
);
const statusOptions = useMemo(
() =>
[...new Set(allRows.map((r) => r.status))].sort().map((s) => ({
value: s,
label: prettyStatus(s),
})),
[allRows],
);
const rows = useMemo(() => {
const q = query.trim().toLowerCase();
return allRows.filter((r) => {
if (tab === "review" && !r.hasDocumentsAwaitingReview) return false;
if (
(tab === "import" || tab === "export") &&
r.tradeDirection !== tab.toUpperCase()
)
return false;
if (freight && r.freightType !== freight) return false;
if (status && r.status !== status) return false;
if (!q) return true;
return [
r.reference,
r.customerLabel,
r.contractReference ?? "",
r.originLabel,
r.destinationLabel,
summarizeRequestedCargo(r.requested),
].some((v) => v.toLowerCase().includes(q));
});
}, [allRows, tab, freight, status, query]);
const total = rows.length;
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
const pagedRows = useMemo(() => {
const start = pagination.pageIndex * pagination.pageSize;
return rows.slice(start, start + pagination.pageSize);
}, [rows, pagination.pageIndex, pagination.pageSize]);
const hasFilters = Boolean(query || freight || status);
const clearFilters = useCallback(() => {
setQuery("");
setFreight(null);
setStatus(null);
resetPage();
}, [resetPage]);
const openBooking = useCallback(
// `from` so the detail page's Back returns to this hub.
@@ -223,31 +365,20 @@ export default function ContractClearanceListPage() {
<PageContainer>
<Stack gap="lg">
<PageHeader
title="Document Clearance"
title="Clearance queue"
subtitle="Every customs shipment in phased clearance — the documents live on the shipment, not on the contract."
meta={
<Badge
variant="light"
color="edr-green"
radius="sm"
leftSection={<ShieldCheck size={13} />}
>
{counts.all} in clearance
</Badge>
}
meta={<LivePill updatedAt={dataUpdatedAt} />}
action={
<Group gap="sm" wrap="nowrap">
<ActionIcon
variant="default"
size="lg"
radius="md"
onClick={() => void refetch()}
loading={isFetching}
aria-label="Refresh"
>
<RefreshCw size={16} />
</ActionIcon>
</Group>
<Button
variant="default"
radius="md"
size="sm"
leftSection={<RefreshCw size={14} />}
loading={isFetching}
onClick={() => void refetch()}
>
Refresh
</Button>
}
/>
@@ -256,65 +387,220 @@ export default function ContractClearanceListPage() {
items={[
{
label: "In clearance",
value: counts.all,
value: allRows.length,
icon: Inbox,
color: "edr-green",
color: "blue",
hint: newToday ? `+${newToday} today` : undefined,
spark: perDay(allRows),
},
{
label: "Awaiting review",
value: counts.review,
value: groups.review.length,
icon: ShieldCheck,
color: "yellow",
spark: perDay(groups.review),
},
{
label: "Needs approval",
value: groups.approval.length,
icon: TriangleAlert,
color: "red",
spark: perDay(groups.approval),
},
{
label: "Ready / booked",
value: counts.ready,
value: groups.ready.length,
icon: PackageCheck,
color: "edr-green",
spark: perDay(groups.ready),
},
]}
/>
<GlUpcomingWindowsSection />
<Card p={0} withBorder shadow="sm" radius="lg">
<Card
p={0}
withBorder
shadow="sm"
radius="lg"
style={{ overflow: "hidden" }}
>
<Stack gap={0}>
<Box px="md" pt="md" pb="sm">
<Group justify="space-between" gap="md" wrap="wrap">
<TextInput
placeholder="Search shipment, contract, customer or route…"
leftSection={<Search size={18} />}
value={query}
onChange={(e) => {
setQuery(e.currentTarget.value);
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
}}
rightSection={
query ? (
<ActionIcon
size="sm"
color="gray"
radius="md"
variant="transparent"
onClick={() => setQuery("")}
{/* ── Tabs ─────────────────────────────────────────────── */}
<Group
justify="space-between"
align="stretch"
px="md"
h={46}
wrap="nowrap"
style={{
borderBottom: "1px solid var(--mantine-color-edr-border-6)",
}}
>
<Group gap={2} wrap="nowrap" align="stretch">
{TABS.map((t) => {
const active = tab === t.key;
const Icon = t.icon;
return (
<UnstyledButton
key={t.key}
onClick={() => {
setTab(t.key);
resetPage();
}}
px={13}
className="flex items-center gap-2 transition-colors"
style={{
borderBottom: `2px solid ${
active
? "var(--mantine-color-edr-green-6)"
: "transparent"
}`,
marginBottom: -1,
}}
aria-pressed={active}
>
<Icon
size={14}
style={{
color: active
? "var(--mantine-color-edr-green-6)"
: "var(--mantine-color-gray-5)",
}}
/>
<Text
fz={13}
fw={active ? 600 : 500}
c={active ? "edr-text" : "edr-muted"}
>
<X size={16} />
</ActionIcon>
) : null
}
radius="lg"
style={{ flex: 1, minWidth: 220 }}
/>
<Text size="sm" c="dimmed">
{rows.length} record{rows.length !== 1 ? "s" : ""}
</Text>
{t.label}
</Text>
<span
className="rounded-full px-1.5 py-px text-[10.5px] font-semibold leading-[1.4]"
style={{
background: active
? "var(--mantine-color-edr-green-0)"
: "var(--mantine-color-gray-1)",
color: active
? "var(--mantine-color-edr-green-7)"
: "var(--mantine-color-edr-muted-6)",
}}
>
{tabCounts[t.key]}
</span>
</UnstyledButton>
);
})}
</Group>
</Box>
<Text
fz={12}
c="edr-muted"
className="self-center whitespace-nowrap"
>
{total} record{total !== 1 ? "s" : ""}
</Text>
</Group>
{/* ── Filter bar ───────────────────────────────────────── */}
<Group
gap={9}
px="md"
py={12}
wrap="wrap"
style={{
borderBottom: "1px solid var(--mantine-color-edr-divider-6)",
}}
>
<TextInput
placeholder="Search reference, customer, contract, or route…"
leftSection={<Search size={15} />}
value={query}
onChange={(e) => {
setQuery(e.currentTarget.value);
resetPage();
}}
rightSection={
query ? (
<ActionIcon
size="sm"
color="gray"
radius="md"
variant="transparent"
onClick={() => {
setQuery("");
resetPage();
}}
aria-label="Clear search"
>
<X size={14} />
</ActionIcon>
) : null
}
radius="md"
size="sm"
styles={{
input: { background: "var(--mantine-color-gray-0)" },
}}
style={{ flex: 1, minWidth: 220 }}
/>
<Select
placeholder="Freight"
data={[
{ value: "CONTAINER", label: "Container" },
{ value: "BULK", label: "Bulk" },
]}
value={freight}
onChange={(v) => {
setFreight(v);
resetPage();
}}
clearable
radius="md"
size="sm"
w={124}
comboboxProps={{ withinPortal: true }}
aria-label="Filter by freight type"
/>
<Select
placeholder="Status"
data={statusOptions}
value={status}
onChange={(v) => {
setStatus(v);
resetPage();
}}
clearable
radius="md"
size="sm"
w={180}
comboboxProps={{ withinPortal: true }}
aria-label="Filter by status"
/>
{hasFilters ? (
<Button
variant="subtle"
color="gray"
size="compact-sm"
radius="md"
leftSection={<X size={14} />}
onClick={clearFilters}
>
Clear
</Button>
) : null}
</Group>
<ShipmentBookingsTable
rows={rows}
rows={pagedRows}
total={total}
pageCount={pageCount}
pagination={pagination}
setPagination={setPagination}
loading={isLoading}
error={isError}
hasFilters={hasFilters}
onClearFilters={clearFilters}
canCreateBooking={canCreateBooking}
onOpen={openBooking}
onCreateBooking={(row) =>
@@ -337,7 +623,6 @@ export default function ContractClearanceListPage() {
</Stack>
</Card>
</Stack>
</PageContainer>
);
}
@@ -367,43 +652,19 @@ interface ShipmentBookingRow {
bookingCreated: boolean;
}
const formatDate = (iso: string | null) => {
if (!iso) return "—";
const d = new Date(iso);
return Number.isNaN(d.getTime())
? "—"
: d.toLocaleDateString(undefined, { day: "2-digit", month: "short", year: "numeric" });
};
const prettyStatus = (s: string) =>
s
.toLowerCase()
.replace(/_/g, " ")
.replace(/^\w/, (c) => c.toUpperCase());
const shipmentStatusColor = (s: string) => {
if (s === "AWAITING_DOCUMENTS") return "yellow";
if (s === "DOCUMENTS_UNDER_REVIEW") return "blue";
if (s === "CLEARANCE_READY") return "edr-green";
if (
[
"SELECTED_FOR_BATCH",
"PNR_GENERATED",
"AWAITING_PAYMENT",
"PAYMENT_VERIFICATION_IN_PROGRESS",
].includes(s)
)
return "violet";
if (s === "EXPIRED") return "orange";
if (s === "CANCELLED" || s === "REJECTED") return "red";
return "gray";
};
type PaginationState = ReturnType<typeof usePagination>["pagination"];
/** GENERAL-contract shipment bookings currently in per-booking clearance. */
function ShipmentBookingsTable({
rows,
total,
pageCount,
pagination,
setPagination,
loading,
error,
hasFilters,
onClearFilters,
canCreateBooking,
onOpen,
onCreateBooking,
@@ -411,8 +672,14 @@ function ShipmentBookingsTable({
onViewContract,
}: {
rows: ShipmentBookingRow[];
total: number;
pageCount: number;
pagination: PaginationState;
setPagination: ReturnType<typeof usePagination>["setPagination"];
loading: boolean;
error: boolean;
hasFilters: boolean;
onClearFilters: () => void;
canCreateBooking: boolean;
onOpen: (id: string) => void;
onCreateBooking: (row: ShipmentBookingRow) => void;
@@ -440,18 +707,23 @@ function ShipmentBookingsTable({
id: "booking",
header: () => <span className={bookingTable.headerCell}>Booking</span>,
cell: ({ row }) => (
<div className="flex items-center gap-3 py-1.5">
<div className={bookingTable.rowIcon}>
<PackageCheck className="size-4" strokeWidth={1.75} />
<div className="flex items-center gap-2.5 py-1">
<div className="flex size-[30px] shrink-0 items-center justify-center rounded-[9px] bg-edr-divider text-edr-muted">
<PackageCheck size={15} strokeWidth={1.75} />
</div>
<div className="min-w-0">
<p className="font-medium text-foreground">
<Text fz={13} fw={600} c="edr-text">
{row.original.reference}
</p>
<p className="mt-0.5 flex items-center gap-1 text-xs text-muted-foreground">
<User className="size-3 shrink-0 opacity-70" />
{row.original.customerLabel}
</p>
</Text>
<Group gap={4} wrap="nowrap" align="flex-start">
<Building2
size={10}
className="mt-[3px] shrink-0 text-edr-muted opacity-70"
/>
<Text fz={11} c="edr-muted" className="cell-wrap">
{row.original.customerLabel}
</Text>
</Group>
</div>
</div>
),
@@ -462,17 +734,19 @@ function ShipmentBookingsTable({
cell: ({ row }) => {
const r = row.original;
return (
<Stack gap={4} py={2}>
<Group gap={6} wrap="nowrap">
<FileText size={13} className="shrink-0 text-muted-foreground" />
<Text size="sm" fw={500}>
<Stack gap={3} py={2}>
<Group gap={5} wrap="nowrap">
<FileText size={12} className="shrink-0 text-edr-muted" />
<Text fz={12.5} c="edr-text">
{r.contractReference ?? "—"}
</Text>
</Group>
{r.contractKind ? (
<Badge size="xs" variant="default" radius="sm" tt="uppercase">
{r.contractKind === "GENERAL" ? "General" : "One-time"}
</Badge>
<Text fz={10.5} c="edr-muted">
{r.contractKind === "GENERAL"
? "General contract"
: "One-time"}
</Text>
) : null}
</Stack>
);
@@ -481,44 +755,33 @@ function ShipmentBookingsTable({
{
id: "route",
header: () => <span className={bookingTable.headerCell}>Route</span>,
cell: ({ row }) => (
<RouteLabel
origin={row.original.originLabel}
destination={row.original.destinationLabel}
/>
),
},
{
id: "kind",
header: () => <span className={bookingTable.headerCell}>Type</span>,
cell: ({ row }) => (
<Group gap={6} wrap="nowrap">
<Badge variant="light" color="gray" radius="sm">
{prettyStatus(row.original.tradeDirection)}
</Badge>
<Badge variant="outline" color="gray" radius="sm">
{prettyStatus(row.original.freightType)}
</Badge>
<CustomsBadge customs={row.original.customs} />
</Group>
),
cell: ({ row }) => {
const r = row.original;
return (
<RouteCell
origin={r.originLabel}
destination={r.destinationLabel}
direction={r.tradeDirection}
freightType={r.freightType}
customs={r.customs}
/>
);
},
},
{
id: "requested",
header: () => (
<span className={bookingTable.headerCell}>Requested cargo</span>
),
header: () => <span className={bookingTable.headerCell}>Cargo</span>,
cell: ({ row }) => (
<RequestedCargoChips lines={row.original.requested} size="sm" />
<RequestedCargoChips lines={row.original.requested} size="xs" />
),
},
{
id: "created",
header: () => <span className={bookingTable.headerCell}>Created</span>,
cell: ({ row }) => (
<Group gap={6} wrap="nowrap">
<Calendar size={13} className="shrink-0 text-muted-foreground" />
<Text size="sm" c="dimmed">
<Group gap={5} wrap="nowrap">
<CalendarClock size={12} className="shrink-0 text-edr-muted" />
<Text fz={11.5} c="edr-muted">
{formatDate(row.original.createdAt)}
</Text>
</Group>
@@ -533,14 +796,14 @@ function ShipmentBookingsTable({
a file added after clearance was finalized leaves the status at
CLEARANCE_READY, and the row must still call for the review. */}
{row.original.hasDocumentsAwaitingReview ? (
<Badge variant="filled" color="orange" radius="sm">
<Badge variant="filled" color="orange" radius="sm" size="sm">
Needs approval
</Badge>
) : /* All docs approved but not yet finalized: the booking status is
still DOCUMENTS_UNDER_REVIEW — show the real review state. */
row.original.status === "DOCUMENTS_UNDER_REVIEW" &&
row.original.allDocsApproved ? (
<Badge variant="light" color="edr-green" radius="sm">
<Badge variant="light" color="edr-green" radius="sm" size="sm">
Documents approved
</Badge>
) : (
@@ -548,6 +811,7 @@ function ShipmentBookingsTable({
variant="light"
color={shipmentStatusColor(row.original.status)}
radius="sm"
size="sm"
>
{prettyStatus(row.original.status)}
</Badge>
@@ -558,6 +822,7 @@ function ShipmentBookingsTable({
variant="light"
color="blue"
radius="sm"
size="sm"
leftSection={<PackagePlus size={11} />}
>
Booked
@@ -617,7 +882,10 @@ function ShipmentBookingsTable({
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item leftSection={<Eye size={14} />} onClick={() => onOpen(r.id)}>
<Menu.Item
leftSection={<Eye size={14} />}
onClick={() => onOpen(r.id)}
>
Open booking
</Menu.Item>
{bookable ? (
@@ -655,25 +923,53 @@ function ShipmentBookingsTable({
[canCreateBooking, onOpen, onCreateBooking, onViewContract],
);
if (!loading && !error && rows.length === 0) {
if (!loading && !error && total === 0) {
return (
<Stack align="center" gap={8} py={48}>
<ThemeIcon variant="light" color="gray" radius="xl" size={48}>
<Inbox size={22} />
</ThemeIcon>
<Text c="dimmed">No shipment bookings in clearance.</Text>
<Text c="dimmed">
{hasFilters
? "No shipments match these filters."
: "No shipment bookings in clearance."}
</Text>
{hasFilters ? (
<Button
variant="light"
color="gray"
size="compact-sm"
radius="md"
onClick={onClearFilters}
>
Clear filters
</Button>
) : null}
</Stack>
);
}
return (
<Box w="100%" miw={0} style={{ overflowX: "auto" }}>
<Box w="100%" miw={0}>
<DataTable<ShipmentBookingRow, unknown>
columns={columns}
data={rows}
status={loading ? "loading" : error ? "error" : "success"}
onRowClick={(row) => onOpen(row.id)}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: total,
}}
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
manualPagination: true,
pageCount,
}}
containerClassName="edr-clearance-table border-0 shadow-none rounded-none bg-transparent"
footer={(p) => <TablePager {...p} noun="shipments" />}
/>
</Box>
);

View File

@@ -15,33 +15,33 @@ import {
TextInput,
ThemeIcon,
Tooltip,
UnstyledButton,
} from "@mantine/core";
import { useInterval } from "@mantine/hooks";
import type { LucideIcon } from "lucide-react";
import {
AlertTriangle,
ArrowRight,
Building2,
CalendarClock,
ChevronRight,
FileText,
Inbox,
Layers,
PackageCheck,
RefreshCw,
Search,
ShipWheel,
Truck,
User,
Weight,
X,
} from "lucide-react";
import {
DataTable,
DataTableFooter,
usePagination,
type ColumnDef,
} from "@edr/ui-common";
import { DataTable, usePagination, type ColumnDef } from "@edr/ui-common";
import { PageContainer } from "@/components/page/PageContainer";
import { PageHeader } from "@/components/page/PageHeader";
import { KpiStrip } from "@/components/page/KpiStrip";
import { TablePager } from "@/components/page/TablePager";
import { bookingTable } from "@/components/bookings/booking-ui.styles";
import { useBookingDjClearanceQueue } from "@/hooks/bookings/useBookings";
import type { BookingDetail } from "@/types/booking";
@@ -106,6 +106,20 @@ function statusColor(status: string): string {
}
}
/** Rows created/scheduled per day over the last `days` days, oldest → newest. */
function perDay(rows: { scheduledDate: string | null }[], days = 8): number[] {
const today = new Date().setHours(0, 0, 0, 0);
const out = new Array<number>(days).fill(0);
for (const r of rows) {
if (!r.scheduledDate) continue;
const age = Math.floor(
(today - new Date(r.scheduledDate).setHours(0, 0, 0, 0)) / 86_400_000,
);
if (age >= 0 && age < days) out[days - 1 - age] += 1;
}
return out;
}
// ── DJ next action (shipments) ───────────────────────────────────────────────
type DjActionKey = "RO_HOLD" | "COLLECT_DO" | "ISSUE_RO" | "LOADING" | "REVIEW";
@@ -180,28 +194,65 @@ function toShipmentRow(b: BookingDetail): ShipmentRow {
};
}
// ── Tabs ─────────────────────────────────────────────────────────────────────
type TabKey = "all" | "import" | "export" | "hold";
const TABS: { key: TabKey; label: string; icon: LucideIcon }[] = [
{ key: "all", label: "All", icon: Layers },
{ key: "import", label: "Import", icon: Truck },
{ key: "export", label: "Export", icon: ShipWheel },
{ key: "hold", label: "On hold", icon: AlertTriangle },
];
// ── Shared cell pieces ───────────────────────────────────────────────────────
function DirectionIcon({ direction }: { direction: string }) {
function LivePill({ updatedAt }: { updatedAt: number }) {
// Re-render every 30s so "Xm ago" keeps ticking between refetches.
const [, setTick] = useState(0);
useInterval(() => setTick((t) => t + 1), 30_000, { autoInvoke: true });
const mins = Math.max(0, Math.round((Date.now() - updatedAt) / 60_000));
const label = !updatedAt
? "Connecting…"
: mins < 1
? "Live · updated just now"
: `Live · updated ${mins}m ago`;
return (
<span className="inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap rounded-full bg-edr-soft px-2.5 py-1 text-[11px] font-medium text-edr-primary-dark">
<span className="size-1.5 rounded-full bg-edr-primary-dark" />
{label}
</span>
);
}
function DirectionPill({ direction }: { direction: string }) {
const isImport = direction === "IMPORT";
const Icon = isImport ? Truck : ShipWheel;
const label = directionLabel(direction);
const color = isImport ? "blue" : "teal";
return (
<Tooltip label={label} withArrow>
<ThemeIcon
variant="light"
color={isImport ? "edr-green" : "gray"}
radius="md"
size={26}
aria-label={label}
<Tooltip label={directionLabel(direction)} withArrow>
<span
className="inline-flex items-center gap-1 rounded-[5px] px-1.5 py-[2px] text-[10px] font-medium leading-none"
style={{
background: `var(--mantine-color-${color}-0)`,
color: `var(--mantine-color-${color}-7)`,
}}
>
<Icon size={14} strokeWidth={1.9} />
</ThemeIcon>
<Icon size={10} />
{prettyStatus(direction)}
</span>
</Tooltip>
);
}
function OutlinePill({ children }: { children: React.ReactNode }) {
return (
<span className="inline-flex items-center rounded-[5px] border border-edr-border px-1.5 py-[2px] text-[10px] leading-none text-edr-muted">
{children}
</span>
);
}
function RouteCell({
origin,
destination,
@@ -214,30 +265,19 @@ function RouteCell({
freightType: string;
}) {
return (
<Stack gap={4} py={2}>
{/* Wraps past 120px as "Addis Ababa" / "→ Djibouti"; text wraps
normally (cells are otherwise nowrap) so it never spills over. */}
<Text
size="sm"
fw={500}
maw={120}
lh={1.35}
style={{ whiteSpace: "normal", overflowWrap: "anywhere" }}
>
{origin}{" "}
<ArrowRight
size={13}
className="text-muted-foreground"
style={{ display: "inline-block", verticalAlign: "-2px" }}
/>
{"\u00A0"}
{destination}
</Text>
<Group gap={8} align="center">
<DirectionIcon direction={direction} />
<Badge size="xs" variant="default" radius="sm">
{freightType}
</Badge>
<Stack gap={5} py={2}>
<Group gap={6} wrap="nowrap">
<Text fz={12.5} fw={500} c="edr-text">
{origin}
</Text>
<ArrowRight size={12} className="shrink-0 text-edr-muted" />
<Text fz={12.5} fw={500} c="edr-text">
{destination}
</Text>
</Group>
<Group gap={6} wrap="nowrap">
<DirectionPill direction={direction} />
<OutlinePill>{prettyStatus(freightType)}</OutlinePill>
</Group>
</Stack>
);
@@ -254,7 +294,7 @@ function RouteCell({
export default function GlDjiboutiClearanceListPage() {
const navigate = useNavigate();
const [query, setQuery] = useState("");
const [direction, setDirection] = useState<string | null>(null);
const [tab, setTab] = useState<TabKey>("all");
const [freight, setFreight] = useState<string | null>(null);
const [status, setStatus] = useState<string | null>(null);
const [action, setAction] = useState<string | null>(null);
@@ -265,6 +305,7 @@ export default function GlDjiboutiClearanceListPage() {
isLoading: bookingsLoading,
isError: bookingsError,
isFetching: bookingsFetching,
dataUpdatedAt,
refetch: refetchBookings,
} = useBookingDjClearanceQueue();
@@ -277,18 +318,30 @@ export default function GlDjiboutiClearanceListPage() {
() => (bookingQueue ?? []).map(toShipmentRow),
[bookingQueue],
);
// KPI metrics span the whole queue, regardless of filters.
const metrics = useMemo(
() => ({
shipments: allShipmentRows.length,
collectDo: allShipmentRows.filter((r) => r.action.key === "COLLECT_DO")
.length,
issueRo: allShipmentRows.filter((r) => r.action.key === "ISSUE_RO").length,
roHolds: allShipmentRows.filter((r) => r.action.key === "RO_HOLD").length,
shipments: allShipmentRows,
collectDo: allShipmentRows.filter((r) => r.action.key === "COLLECT_DO"),
issueRo: allShipmentRows.filter((r) => r.action.key === "ISSUE_RO"),
roHolds: allShipmentRows.filter((r) => r.action.key === "RO_HOLD"),
}),
[allShipmentRows],
);
const tabCounts = useMemo<Record<TabKey, number>>(
() => ({
all: allShipmentRows.length,
import: allShipmentRows.filter((r) => r.tradeDirection === "IMPORT")
.length,
export: allShipmentRows.filter((r) => r.tradeDirection === "EXPORT")
.length,
hold: metrics.roHolds.length,
}),
[allShipmentRows, metrics.roHolds.length],
);
const statusOptions = useMemo(
() =>
[...new Set(allShipmentRows.map((r) => r.status))].sort().map((s) => ({
@@ -298,64 +351,45 @@ export default function GlDjiboutiClearanceListPage() {
[allShipmentRows],
);
const matchesShared = useCallback(
(
r: {
reference: string;
customerLabel: string;
originLabel: string;
destinationLabel: string;
tradeDirection: string;
freightType: string;
status: string;
},
extraSearchFields: string[] = [],
) => {
if (direction && r.tradeDirection !== direction) return false;
const shipmentRows = useMemo(() => {
const q = query.trim().toLowerCase();
return allShipmentRows.filter((r) => {
if (tab === "hold" && r.action.key !== "RO_HOLD") return false;
if (
(tab === "import" || tab === "export") &&
r.tradeDirection !== tab.toUpperCase()
)
return false;
if (freight && r.freightType !== freight) return false;
if (status && r.status !== status) return false;
const q = query.trim().toLowerCase();
if (action && r.action.key !== action) return false;
if (!q) return true;
return [
r.reference,
r.customerLabel,
r.contractReference,
r.originLabel,
r.destinationLabel,
prettyStatus(r.status),
...extraSearchFields,
].some((v) => v.toLowerCase().includes(q));
},
[direction, freight, status, query],
);
const shipmentRows = useMemo(
() =>
allShipmentRows.filter(
(r) =>
(!action || r.action.key === action) &&
// Shipments also match the parent contract reference in search.
matchesShared(r, [r.contractReference]),
),
[allShipmentRows, action, matchesShared],
);
});
}, [allShipmentRows, tab, freight, status, action, query]);
const isLoading = bookingsLoading;
const isError = bookingsError;
const isFetching = bookingsFetching;
const total = shipmentRows.length;
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
const showEmpty = !isLoading && !isError && total === 0;
const pagedShipmentRows = useMemo(() => {
const start = pagination.pageIndex * pagination.pageSize;
return shipmentRows.slice(start, start + pagination.pageSize);
}, [shipmentRows, pagination.pageIndex, pagination.pageSize]);
const hasFilters = Boolean(query || direction || freight || status || action);
const hasFilters = Boolean(query || freight || status || action);
const clearFilters = useCallback(() => {
setQuery("");
setDirection(null);
setFreight(null);
setStatus(null);
setAction(null);
@@ -379,18 +413,23 @@ export default function GlDjiboutiClearanceListPage() {
cell: ({ row }) => {
const r = row.original;
return (
<div className="flex items-center gap-3 py-1.5">
<div className={bookingTable.rowIcon}>
<PackageCheck className="size-4" strokeWidth={1.75} />
<div className="flex items-center gap-2.5 py-1">
<div className="flex size-[30px] shrink-0 items-center justify-center rounded-[9px] bg-edr-divider text-edr-muted">
<PackageCheck size={15} strokeWidth={1.75} />
</div>
<div className="min-w-0">
<p className="font-medium text-foreground">
<Text fz={13} fw={600} c="edr-text">
{r.reference}
</p>
<p className="mt-0.5 flex items-center gap-1 text-xs text-muted-foreground">
<User className="size-3 shrink-0 opacity-70" />
{r.customerLabel}
</p>
</Text>
<Group gap={4} wrap="nowrap" align="flex-start">
<Building2
size={10}
className="mt-[3px] shrink-0 text-edr-muted opacity-70"
/>
<Text fz={11} c="edr-muted" className="cell-wrap">
{r.customerLabel}
</Text>
</Group>
</div>
</div>
);
@@ -400,9 +439,11 @@ export default function GlDjiboutiClearanceListPage() {
id: "contract",
header: () => <span className={bookingTable.headerCell}>Contract</span>,
cell: ({ row }) => (
<Group gap={6} wrap="nowrap">
<FileText size={13} className="shrink-0 text-muted-foreground" />
<Text size="sm">{row.original.contractReference}</Text>
<Group gap={5} wrap="nowrap">
<FileText size={12} className="shrink-0 text-edr-muted" />
<Text fz={12.5} c="edr-text">
{row.original.contractReference}
</Text>
</Group>
),
},
@@ -428,9 +469,11 @@ export default function GlDjiboutiClearanceListPage() {
const r = row.original;
return (
<Stack gap={4} py={2}>
<Group gap={6} wrap="nowrap">
<Weight size={13} className="shrink-0 text-muted-foreground" />
<Text size="sm">{r.weightTons} t</Text>
<Group gap={5} wrap="nowrap">
<Weight size={12} className="shrink-0 text-edr-muted" />
<Text fz={12} fw={500} c="edr-text">
{r.weightTons} t
</Text>
</Group>
{r.isHazardous ? (
<Badge
@@ -449,7 +492,9 @@ export default function GlDjiboutiClearanceListPage() {
},
{
id: "action",
header: () => <span className={bookingTable.headerCell}>DJ action</span>,
header: () => (
<span className={bookingTable.headerCell}>DJ action</span>
),
cell: ({ row }) => {
const r = row.original;
const badge = (
@@ -466,7 +511,7 @@ export default function GlDjiboutiClearanceListPage() {
) : (
badge
)}
<Text size="xs" c="dimmed">
<Text fz={10.5} c="edr-muted">
{phaseLabel(r.phase)}
</Text>
</Stack>
@@ -489,11 +534,13 @@ export default function GlDjiboutiClearanceListPage() {
},
{
id: "scheduled",
header: () => <span className={bookingTable.headerCell}>Scheduled</span>,
header: () => (
<span className={bookingTable.headerCell}>Scheduled</span>
),
cell: ({ row }) => (
<Group gap={6} wrap="nowrap">
<CalendarClock size={13} className="shrink-0 text-muted-foreground" />
<Text size="sm" c="dimmed">
<Group gap={5} wrap="nowrap">
<CalendarClock size={12} className="shrink-0 text-edr-muted" />
<Text fz={11.5} c="edr-muted">
{formatDate(row.original.scheduledDate)}
</Text>
</Group>
@@ -505,7 +552,7 @@ export default function GlDjiboutiClearanceListPage() {
header: "",
cell: () => (
<Group justify="flex-end" pr="xs">
<ChevronRight size={16} className="text-muted-foreground" />
<ChevronRight size={16} className="text-edr-muted" />
</Group>
),
},
@@ -519,17 +566,18 @@ export default function GlDjiboutiClearanceListPage() {
<PageHeader
title="GL Djibouti — Clearance"
subtitle="Customs shipments handed off to Djibouti GL — every clearance step lives on the shipment."
meta={<LivePill updatedAt={dataUpdatedAt} />}
action={
<ActionIcon
<Button
variant="default"
size="lg"
radius="md"
size="sm"
leftSection={<RefreshCw size={14} />}
loading={isFetching}
onClick={handleRefresh}
aria-label="Refresh"
>
<RefreshCw size={16} />
</ActionIcon>
Refresh
</Button>
}
/>
@@ -538,139 +586,230 @@ export default function GlDjiboutiClearanceListPage() {
items={[
{
label: "Shipments in queue",
value: metrics.shipments,
value: metrics.shipments.length,
icon: PackageCheck,
color: "blue",
spark: perDay(metrics.shipments),
},
{
label: "Imports — collect DO",
value: metrics.collectDo,
value: metrics.collectDo.length,
icon: Truck,
color: "yellow",
spark: perDay(metrics.collectDo),
},
{
label: "Exports — issue RO",
value: metrics.issueRo,
value: metrics.issueRo.length,
icon: ShipWheel,
color: "blue",
spark: perDay(metrics.issueRo),
},
{
label: "RO amendment holds",
value: metrics.roHolds,
value: metrics.roHolds.length,
icon: AlertTriangle,
color: "red",
spark: perDay(metrics.roHolds),
},
]}
/>
<Card p={0} withBorder shadow="sm" radius="lg">
<Card
p={0}
withBorder
shadow="sm"
radius="lg"
style={{ overflow: "hidden" }}
>
<Stack gap={0}>
<Box px="md" pt="md" pb="sm">
<Group gap="sm" wrap="wrap">
<TextInput
placeholder="Search reference, customer, route, or status…"
leftSection={<Search size={18} />}
value={query}
onChange={(e) => {
setQuery(e.currentTarget.value);
resetPage();
}}
rightSection={
query ? (
<ActionIcon
size="sm"
color="gray"
radius="md"
variant="transparent"
onClick={() => {
setQuery("");
resetPage();
{/* ── Tabs ─────────────────────────────────────────────── */}
<Group
justify="space-between"
align="stretch"
px="md"
h={46}
wrap="nowrap"
style={{
borderBottom: "1px solid var(--mantine-color-edr-border-6)",
}}
>
<Group gap={2} wrap="nowrap" align="stretch">
{TABS.map((t) => {
const active = tab === t.key;
const Icon = t.icon;
return (
<UnstyledButton
key={t.key}
onClick={() => {
setTab(t.key);
resetPage();
}}
px={13}
className="flex items-center gap-2 transition-colors"
style={{
borderBottom: `2px solid ${
active
? "var(--mantine-color-edr-green-6)"
: "transparent"
}`,
marginBottom: -1,
}}
aria-pressed={active}
>
<Icon
size={14}
style={{
color: active
? "var(--mantine-color-edr-green-6)"
: "var(--mantine-color-gray-5)",
}}
/>
<Text
fz={13}
fw={active ? 600 : 500}
c={active ? "edr-text" : "edr-muted"}
>
{t.label}
</Text>
<span
className="rounded-full px-1.5 py-px text-[10.5px] font-semibold leading-[1.4]"
style={{
background: active
? "var(--mantine-color-edr-green-0)"
: "var(--mantine-color-gray-1)",
color: active
? "var(--mantine-color-edr-green-7)"
: "var(--mantine-color-edr-muted-6)",
}}
>
<X size={16} />
</ActionIcon>
) : null
}
radius="lg"
style={{ flex: 1, minWidth: 220 }}
/>
<Select
placeholder="Direction"
data={[
{ value: "IMPORT", label: "Import" },
{ value: "EXPORT", label: "Export" },
]}
value={direction}
onChange={(v) => {
setDirection(v);
resetPage();
}}
clearable
radius="lg"
w={130}
/>
<Select
placeholder="Freight"
data={[
{ value: "CONTAINER", label: "Container" },
{ value: "BULK", label: "Bulk" },
]}
value={freight}
onChange={(v) => {
setFreight(v);
resetPage();
}}
clearable
radius="lg"
w={130}
/>
<Select
placeholder="Status"
data={statusOptions}
value={status}
onChange={(v) => {
setStatus(v);
resetPage();
}}
clearable
radius="lg"
w={190}
/>
<Select
placeholder="DJ action"
data={DJ_ACTION_OPTIONS}
value={action}
onChange={(v) => {
setAction(v);
resetPage();
}}
clearable
radius="lg"
w={180}
/>
{hasFilters ? (
<Button
variant="subtle"
color="gray"
size="compact-sm"
radius="md"
leftSection={<X size={14} />}
onClick={clearFilters}
>
Clear
</Button>
) : null}
{tabCounts[t.key]}
</span>
</UnstyledButton>
);
})}
</Group>
</Box>
<Text
fz={12}
c="edr-muted"
className="self-center whitespace-nowrap"
>
{total} record{total !== 1 ? "s" : ""}
</Text>
</Group>
{showEmpty ? (
{/* ── Filter bar ───────────────────────────────────────── */}
<Group
gap={9}
px="md"
py={12}
wrap="wrap"
style={{
borderBottom: "1px solid var(--mantine-color-edr-divider-6)",
}}
>
<TextInput
placeholder="Search reference, customer, route, or status…"
leftSection={<Search size={15} />}
value={query}
onChange={(e) => {
setQuery(e.currentTarget.value);
resetPage();
}}
rightSection={
query ? (
<ActionIcon
size="sm"
color="gray"
radius="md"
variant="transparent"
onClick={() => {
setQuery("");
resetPage();
}}
aria-label="Clear search"
>
<X size={14} />
</ActionIcon>
) : null
}
radius="md"
size="sm"
styles={{
input: { background: "var(--mantine-color-gray-0)" },
}}
style={{ flex: 1, minWidth: 220 }}
/>
<Select
placeholder="Freight"
data={[
{ value: "CONTAINER", label: "Container" },
{ value: "BULK", label: "Bulk" },
]}
value={freight}
onChange={(v) => {
setFreight(v);
resetPage();
}}
clearable
radius="md"
size="sm"
w={124}
comboboxProps={{ withinPortal: true }}
aria-label="Filter by freight type"
/>
<Select
placeholder="Status"
data={statusOptions}
value={status}
onChange={(v) => {
setStatus(v);
resetPage();
}}
clearable
radius="md"
size="sm"
w={180}
comboboxProps={{ withinPortal: true }}
aria-label="Filter by status"
/>
<Select
placeholder="DJ action"
data={DJ_ACTION_OPTIONS}
value={action}
onChange={(v) => {
setAction(v);
resetPage();
}}
clearable
radius="md"
size="sm"
w={170}
comboboxProps={{ withinPortal: true }}
aria-label="Filter by DJ action"
/>
{hasFilters ? (
<Button
variant="subtle"
color="gray"
size="compact-sm"
radius="md"
leftSection={<X size={14} />}
onClick={clearFilters}
>
Clear
</Button>
) : null}
</Group>
{!isLoading && !isError && total === 0 ? (
<Stack align="center" gap={8} py={48}>
<ThemeIcon variant="light" color="gray" radius="xl" size={48}>
<Inbox size={22} />
</ThemeIcon>
<Text c="dimmed">
{hasFilters
? "No records match these filters."
? "No shipments match these filters."
: "No shipments awaiting a Djibouti action."}
</Text>
{hasFilters ? (
@@ -686,7 +825,7 @@ export default function GlDjiboutiClearanceListPage() {
) : null}
</Stack>
) : (
<Box w="100%" miw={0} style={{ overflowX: "auto" }}>
<Box w="100%" miw={0}>
<DataTable<ShipmentRow, unknown>
columns={shipmentColumns}
data={pagedShipmentRows}
@@ -705,7 +844,7 @@ export default function GlDjiboutiClearanceListPage() {
pageCount,
}}
containerClassName="edr-clearance-table border-0 shadow-none rounded-none bg-transparent"
footer={DataTableFooter}
footer={(p) => <TablePager {...p} noun="shipments" />}
/>
</Box>
)}

View File

@@ -32,6 +32,22 @@
white-space: nowrap;
}
/*
* Booking (col 1) and Contract (col 2) carry free-text company/contract names.
* Cap those two columns and let their content wrap onto 2+ lines so a very long
* name (e.g. "SHAFICI PHARMACEUTICAL MEDICAL SUPPLIES WHOLESALER PARTINERSHIP")
* stacks inside its own cell instead of shoving the next column off-screen.
* Everything below the header row so the header labels still sit on one line.
*/
.edr-clearance-table tbody td:not([colspan]):nth-child(1) {
max-width: 240px;
white-space: normal;
}
.edr-clearance-table tbody td:not([colspan]):nth-child(2) {
max-width: 200px;
white-space: normal;
}
/*
* Mantine Badge caps itself at max-width: 100%; inside an auto-layout table
* cell that resolves against min-content and clips the label. Let badges size
@@ -41,6 +57,22 @@
max-width: none;
}
/*
* Opt-out for long free text (company/customer names). The blanket nowrap rule
* above keeps every cell on one line so columns size to content; a very long
* name would otherwise force the column absurdly wide. Mark such text with
* `cell-wrap` to cap it and wrap onto 2+ lines instead of pushing the layout.
*/
.edr-clearance-table .cell-wrap,
.edr-clearance-table .mantine-Group-root > .cell-wrap {
white-space: normal;
overflow-wrap: anywhere;
word-break: break-word;
min-width: 0;
max-width: 100%;
line-height: 1.3;
}
/*
* Mantine Group's preventGrowOverflow caps every child at 100%/N of the cell.
* In an auto-width table cell that resolves against min-content and collapses
@@ -70,7 +102,7 @@
min-width: 0;
position: sticky;
right: 0;
box-shadow: -12px 0 16px -6px rgba(16, 32, 47, 0.3);
box-shadow: -10px 0 14px -8px rgba(16, 32, 47, 0.12);
}
/*
@@ -78,17 +110,41 @@
* background or the columns underneath show through.
*/
.edr-clearance-table td:last-child:not([colspan]) {
background: #f5f8fb;
background: var(--mantine-color-body);
z-index: 2;
}
/* Row hover uses the tailwind `hover:bg-accent` class on the <tr>. */
.edr-clearance-table tbody tr:hover td:last-child:not([colspan]) {
background: var(--accent, #f4fbf8);
background: #f7fbf9;
}
/* Header cell is sticky on both axes — it must outrank the body's sticky column. */
.edr-clearance-table th:last-child {
background: #f4f7fa;
background: var(--mantine-color-gray-0);
z-index: 3;
}
/* ── Design pass: flat head band, 64px rows, hairline dividers ─────────── */
.edr-clearance-table thead th {
height: 38px;
padding-top: 0;
padding-bottom: 0;
background: var(--mantine-color-gray-0);
border-bottom: 1px solid var(--mantine-color-edr-divider-6);
}
.edr-clearance-table tbody td:not([colspan]) {
height: 64px;
padding-top: 8px;
padding-bottom: 8px;
border-bottom: 1px solid var(--mantine-color-edr-divider-6);
}
.edr-clearance-table tbody tr:last-child td:not([colspan]) {
border-bottom: 0;
}
.edr-clearance-table tbody tr:hover td {
background: #f7fbf9;
}

View File

@@ -95,12 +95,17 @@ function PayWindowCell({ deadline }: { deadline: string | null }) {
);
}
/** `invoices.type` of a wagon-cancellation fee — mirrors the API constant. */
const WAGON_CANCEL_FEE_INVOICE_TYPE = "WAGON_CANCEL_FEE";
/**
* "Confirm paid" for one row. Booking invoices are only confirmable while the
* booking's pay window is open (the API refuses otherwise): no window yet →
* no button; window closed → button disabled with the reason, and it flips
* live the second the countdown hits zero. Non-booking invoices (warehouse,
* clearance…) have no window and stay confirmable.
* clearance…) have no window and stay confirmable — and so do
* wagon-cancellation fees, which ride source=booking but are raised on an
* already-paid booking whose window has closed.
*/
function ConfirmCell({
row,
@@ -109,10 +114,11 @@ function ConfirmCell({
row: OfflineUsdInvoice;
onConfirm: (row: OfflineUsdInvoice) => void;
}) {
const deadline = row.booking?.paymentDeadline ?? null;
const feeInvoice = row.type === WAGON_CANCEL_FEE_INVOICE_TYPE;
const deadline = feeInvoice ? null : (row.booking?.paymentDeadline ?? null);
const now = useNow(deadline);
if (row.booking && !deadline) return null;
if (row.booking && !deadline && !feeInvoice) return null;
const closed = Boolean(deadline && new Date(deadline).getTime() <= now);
return (

View File

@@ -59,6 +59,7 @@ import {
import {
DEFAULT_CONFIGURATION_SLUG,
DEFAULT_RULES_SLUG,
ROUTE_SCOPED_TRIGGERS,
RULE_ENGINE_CATEGORY_BASE_PATH,
RULE_ENGINE_SELECT_NONE,
getRuleEngineResource,
@@ -120,9 +121,7 @@ const yardOptionsForLegEnd = (
// direction + route, so their yard dropdowns narrow exactly like base
// freight.
(appliesTo === "OTHER" &&
["CUSTOMS_CLEARANCE", "WITH_RETURN", "FUEL"].includes(
String(values.trigger ?? ""),
))
ROUTE_SCOPED_TRIGGERS.includes(String(values.trigger ?? "")))
) {
const direction = String(values.tradeDirection ?? "");
// Direction is what decides the countries, so offer nothing until it is set

View File

@@ -41,6 +41,8 @@ export interface FormFieldDef {
disabled?: boolean;
/** Editable on create, locked when editing an existing record. */
disabledOnEdit?: boolean;
/** Lock the field while the predicate accepts the live form values. */
disabledIf?: (values: Record<string, unknown>) => boolean;
/** Trailing unit label shown inside the input (e.g. "USD" on a rate value). */
suffix?: string;
/** Hide this field when another field currently equals one of these values. */
@@ -227,6 +229,10 @@ const RATE_TRIGGERS = [
{ label: "Cancellation (per wagon, per direction + cargo type)", value: "CANCELLATION" },
{ label: "Shipping line extra fee (PIL)", value: "PIL_EXTRA_FEE" },
{ label: "Customs clearance service fee (billed with the booking)", value: "CUSTOMS_CLEARANCE" },
{
label: "Ethiopian customs clearance service fee (Ethiopian-side-only services)",
value: "ETHIOPIAN_CUSTOMS_CLEARANCE",
},
{ label: "Fuel (per lane + cargo type)", value: "FUEL" },
];
@@ -285,8 +291,16 @@ const SHIPPING_LINE_CARGO_KINDS = [
const isBaseFreightRate = (values: Record<string, unknown>) =>
["BULK", "CONTAINER", "INTERCITY"].includes(String(values.appliesTo ?? ""));
/** Surcharges sold per origin → destination leg (mirrors RatesService.isRouteScoped). */
export const ROUTE_SCOPED_TRIGGERS = [
"CUSTOMS_CLEARANCE",
"ETHIOPIAN_CUSTOMS_CLEARANCE",
"WITH_RETURN",
"FUEL",
];
/**
* Rates priced per leg: base rail freight, plus the customs clearance fee and
* Rates priced per leg: base rail freight, plus the customs clearance fees and
* the empty-container return surcharge (sold per route + container type).
*/
const isRouteScopedRate = (values: Record<string, unknown>) =>
@@ -295,19 +309,19 @@ const isRouteScopedRate = (values: Record<string, unknown>) =>
(isShippingLineRate(values)
? hasShippingLine(values) &&
(values.shippingLineRateKind === "BASE" ||
["CUSTOMS_CLEARANCE", "WITH_RETURN", "FUEL"].includes(
String(values.trigger ?? ""),
))
ROUTE_SCOPED_TRIGGERS.includes(String(values.trigger ?? "")))
: isBaseFreightRate(values)) ||
(String(values.appliesTo ?? "") === "OTHER" &&
["CUSTOMS_CLEARANCE", "WITH_RETURN", "FUEL"].includes(String(values.trigger ?? "")));
ROUTE_SCOPED_TRIGGERS.includes(String(values.trigger ?? "")));
/**
* Surcharges sold per cargo kind: the admin says container or bulk, then names
* the container type or bulk commodity the fee covers.
*/
const isCargoKindTrigger = (values: Record<string, unknown>) =>
["CUSTOMS_CLEARANCE", "CANCELLATION"].includes(String(values.trigger ?? ""));
["CUSTOMS_CLEARANCE", "ETHIOPIAN_CUSTOMS_CLEARANCE", "CANCELLATION"].includes(
String(values.trigger ?? ""),
);
const unitOption = (value: string) => ({ label: value.replace(/_/g, " "), value });
@@ -351,6 +365,7 @@ const unitsForShape = (
// Wagon cancellation fee — scales with the cancelled wagons only.
return ["PER_WAGON"];
case "CUSTOMS_CLEARANCE":
case "ETHIOPIAN_CUSTOMS_CLEARANCE":
// Per cargo kind: container fees per box/wagon, bulk per ton/wagon.
return cargoKind === "BULK"
? ["PER_TON", "PER_WAGON"]
@@ -888,7 +903,28 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
{ name: "canBeBookedAlone", label: "Can be booked alone", type: "boolean" },
{ name: "includesFirstMile", label: "Includes first mile", type: "boolean" },
{ name: "includesLastMile", label: "Includes last mile", type: "boolean" },
{ name: "includesCustoms", label: "Includes customs", type: "boolean" },
// Full customs and Ethiopian-only customs are alternatives — turning one
// on clears and locks the other (see RuleEngineFormDialog.setField). The
// API stores includesCustoms = true for both; the toggle shown here is
// "full customs", so an Ethiopian-only record reads it back as off.
{
name: "includesCustoms",
label: "Includes customs",
type: "boolean",
description:
"Full customs clearance bundled with the service. Cannot be combined with Ethiopian customs only.",
getInitialValue: (record) =>
record.includesCustoms === true && record.includesEthiopianCustomsOnly !== true,
disabledIf: (v) => v.includesEthiopianCustomsOnly === true,
},
{
name: "includesEthiopianCustomsOnly",
label: "Ethiopian customs only",
type: "boolean",
description:
"EDR clears customs on the Ethiopian side only. Same clearance flow; contracts and bookings price off the Ethiopian customs clearance rate. Cannot be combined with Includes customs.",
disabledIf: (v) => v.includesCustoms === true,
},
{ name: "isActive", label: "Active", type: "boolean" },
],
},
@@ -1094,6 +1130,11 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
label: "Customs clearance",
filters: { trigger: "CUSTOMS_CLEARANCE", isShippingLineRate: "false" },
},
{
key: "ethiopian-customs",
label: "Ethiopian customs",
filters: { trigger: "ETHIOPIAN_CUSTOMS_CLEARANCE", isShippingLineRate: "false" },
},
{
key: "return",
label: "Container return",
@@ -1248,6 +1289,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
(String(v.appliesTo ?? "") === "OTHER" &&
[
"CUSTOMS_CLEARANCE",
"ETHIOPIAN_CUSTOMS_CLEARANCE",
"CANCELLATION",
"WITH_RETURN",
"LASHING",

View File

@@ -9,6 +9,7 @@ import {
Modal,
Progress,
Stack,
Tabs,
Text,
Textarea,
} from "@mantine/core";
@@ -17,8 +18,10 @@ import { isAxiosError } from "axios";
import {
AlertTriangle,
CalendarClock,
History,
MapPin,
MoreHorizontal,
PackageOpen,
Power,
PowerOff,
Replace,
@@ -36,6 +39,8 @@ import AvailableWagonsPanel from "@/components/trainBuilder/AvailableWagonsPanel
import ChangeLocomotivesModal from "@/components/trainBuilder/ChangeLocomotivesModal";
import ChangeYardModal from "@/components/trainBuilder/ChangeYardModal";
import ConsistWagonList from "@/components/trainBuilder/ConsistWagonList";
import DetachedWagonsPanel from "@/components/trainBuilder/DetachedWagonsPanel";
import TrainHistoryPanel from "@/components/trainBuilder/TrainHistoryPanel";
import {
directionColor,
locomotiveStatusColor,
@@ -111,6 +116,7 @@ export default function TrainBuilderDetailPage() {
const assignWagons = useMutation(api.trainBuilder.assignWagons.mutationOptions());
const removeWagon = useMutation(api.trainBuilder.removeWagon.mutationOptions());
const setWagonYard = useMutation(api.trainBuilder.setWagonYard.mutationOptions());
const setWagonsYard = useMutation(api.trainBuilder.setWagonsYard.mutationOptions());
const maintenanceWagon = useMutation(
api.trainBuilder.sendWagonToMaintenance.mutationOptions(),
);
@@ -165,6 +171,7 @@ export default function TrainBuilderDetailPage() {
assignWagons.isPending ||
removeWagon.isPending ||
setWagonYard.isPending ||
setWagonsYard.isPending ||
maintenanceWagon.isPending ||
reorderWagons.isPending;
@@ -230,6 +237,16 @@ export default function TrainBuilderDetailPage() {
},
[withToast, setWagonYard.mutateAsync, trainId],
);
const handleChangeWagonsYard = useCallback(
(wagonIds: string[], currentYardId: string, onDone: () => void) => {
if (!trainId) return;
void withToast(async () => {
await setWagonsYard.mutateAsync({ id: trainId, wagonIds, currentYardId });
onDone();
}, "Could not move the selected wagons");
},
[withToast, setWagonsYard.mutateAsync, trainId],
);
const handleMaintenance = useCallback(
(wagon: TrainCompositionWagon) => setMaintenanceTarget(wagon),
[],
@@ -371,7 +388,22 @@ export default function TrainBuilderDetailPage() {
]}
/>
{composition.wagonYards.length > 1 ? (
<Tabs defaultValue="build" keepMounted={false}>
<Tabs.List>
<Tabs.Tab value="build" leftSection={<TrainIcon size={14} />}>
Build
</Tabs.Tab>
<Tabs.Tab value="detached" leftSection={<PackageOpen size={14} />}>
Detached wagons
</Tabs.Tab>
<Tabs.Tab value="history" leftSection={<History size={14} />}>
History
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="build" pt="md">
<Stack gap="lg">
{composition.wagonYards.length > 1 ? (
<Alert color="blue" icon={<MapPin size={16} />}>
<Stack gap={4}>
<Text size="sm" fw={600}>
@@ -515,6 +547,9 @@ export default function TrainBuilderDetailPage() {
onChangeYard={
composition.editable && canChangeWagonYard ? handleChangeWagonYard : undefined
}
onChangeYardBulk={
composition.editable && canChangeWagonYard ? handleChangeWagonsYard : undefined
}
/>
</Stack>
</Card>
@@ -560,6 +595,22 @@ export default function TrainBuilderDetailPage() {
</Stack>
</Card>
) : null}
</Stack>
</Tabs.Panel>
<Tabs.Panel value="detached" pt="md">
<DetachedWagonsPanel
trainId={composition.id}
canAttach={composition.editable && canAssign}
attachPending={assignWagons.isPending}
onAttach={handleAssign}
/>
</Tabs.Panel>
<Tabs.Panel value="history" pt="md">
<TrainHistoryPanel trainId={composition.id} />
</Tabs.Panel>
</Tabs>
<ChangeLocomotivesModal
composition={composition}

View File

@@ -41,6 +41,7 @@ import {
Train,
Weight,
Workflow as WorkflowIcon,
Warehouse,
} from "lucide-react";
import { DateTimePicker } from "@mantine/dates";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
@@ -59,6 +60,7 @@ import { ContainerPlacementGrid } from "@/components/trainScheduling/ContainerPl
import { FleetAvailabilitySummary } from "@/components/trainScheduling/FleetAvailabilitySummary";
import { IntercityRideAlongPanel } from "@/components/trainScheduling/IntercityRideAlongPanel";
import { LegCapacityPanel } from "@/components/trainScheduling/LegCapacityPanel";
import { ScheduleWagonYardPanel } from "@/components/trainScheduling/ScheduleWagonYardPanel";
import { LegLoadBoardPanel } from "@/components/trainScheduling/LegLoadBoardPanel";
import { LoadEmptyContainersModal } from "@/components/trainScheduling/LoadEmptyContainersModal";
import MergeScheduleTrainModal from "@/components/trainScheduling/MergeScheduleTrainModal";
@@ -145,13 +147,36 @@ export default function TrainScheduleV2DetailPage() {
input: { id: scheduleId ?? "" },
enabled: Boolean(scheduleId),
// Live phase updates come from the booking-window socket (PHASE pushes
// invalidate this query); 60s is the self-heal net for a missed emit so
// the workspace countdown never freezes on an expired phase.
// invalidate this query). The fast self-heal net is the one-row phase
// heartbeat below — this long interval is only the last-resort refresh
// for changes the schedule row itself never sees.
refetchInterval: 300_000,
}),
);
// One-row 60s heartbeat: refetch the (expensive) full detail only when the
// schedule row actually changed — same freshness as polling the detail
// itself, at a fraction of the server cost.
const phaseQuery = useQuery(
api.trainScheduling.schedulePhase.queryOptions({
input: { id: scheduleId ?? "" },
enabled: Boolean(scheduleId),
refetchInterval: 60_000,
}),
);
const lastPhaseSig = useRef<string | null>(null);
useEffect(() => {
if (!phaseQuery.data) return;
const sig = JSON.stringify(phaseQuery.data);
if (lastPhaseSig.current !== null && lastPhaseSig.current !== sig) {
void detailQuery.refetch();
}
lastPhaseSig.current = sig;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [phaseQuery.data]);
useBookingWindowSocket(Boolean(scheduleId));
const schedule = detailQuery.data;
// Controlled so tab-scoped queries (eligible pool) pause on other tabs.
const [activeTab, setActiveTab] = useState<string | null>("workflow");
const freightType: FreightType | undefined = schedule?.freightType as FreightType | undefined;
const isDjiboutiPort = (value?: string | null) =>
["DJIBOUTI", "DORALEH", "DMP", "DCT", "NAGAD"].some((token) =>
@@ -219,7 +244,9 @@ export default function TrainScheduleV2DetailPage() {
const eligibleQuery = useQuery(
api.trainScheduling.eligibleBookings.queryOptions({
input: { filters: eligibleFilters, freightType: eligibleFreightType },
enabled: Boolean(schedule),
// The eligible pool feeds the Workflow tab's bookings step only — don't
// fetch (or refetch on invalidation) while another tab is open.
enabled: Boolean(schedule) && activeTab === "workflow",
}),
);
const preview = useMutation(api.trainScheduling.preview.mutationOptions());
@@ -846,6 +873,7 @@ export default function TrainScheduleV2DetailPage() {
scheduleDetail={schedule}
scheduleId={scheduleId ?? ""}
maxWagons={schedule.maxWagons ?? 53}
showWagonStat={false}
/>
) : (
<TrainCompositionDiagram
@@ -1273,7 +1301,13 @@ export default function TrainScheduleV2DetailPage() {
) : null}
*/}
<Tabs defaultValue="workflow" radius="md" color="edr-green" keepMounted={false}>
<Tabs
value={activeTab}
onChange={setActiveTab}
radius="md"
color="edr-green"
keepMounted={false}
>
<Tabs.List mb="md">
<Tabs.Tab value="workflow" leftSection={<WorkflowIcon size={16} />}>
Workflow
@@ -1287,6 +1321,9 @@ export default function TrainScheduleV2DetailPage() {
<Tabs.Tab value="leg-board" leftSection={<Grid3x3 size={16} />}>
Leg board
</Tabs.Tab>
<Tabs.Tab value="wagon-yards" leftSection={<Warehouse size={16} />}>
Schedule yards
</Tabs.Tab>
<Tabs.Tab value="history" leftSection={<HistoryIcon size={16} />}>
History
</Tabs.Tab>
@@ -1382,6 +1419,15 @@ export default function TrainScheduleV2DetailPage() {
/>
</Tabs.Panel>
<Tabs.Panel value="wagon-yards">
{scheduleId ? (
<ScheduleWagonYardPanel
scheduleId={scheduleId}
canEdit={hasPermission(authUser, FREIGHT_PERMS.trainScheduling.update)}
/>
) : null}
</Tabs.Panel>
<Tabs.Panel value="history">
{scheduleId ? <ScheduleHistoryPanel scheduleId={scheduleId} /> : null}
</Tabs.Panel>

View File

@@ -330,17 +330,6 @@ export default function TrainScheduleV2ListPage() {
meta: { headerClassName, cellClassName },
cell: ({ row }) => (
<Stack gap={6}>
<Group gap={6} wrap="nowrap">
<Text size="sm" fw={600} lh={1.2}>
{row.original.routeName ?? "—"}
</Text>
{row.original.direction ? (
<Badge size="xs" variant="light" color={directionColor(row.original.direction)}>
{row.original.direction}
</Badge>
) : null}
<ShippingLineBadge schedule={row.original} />
</Group>
<Box maw={260}>
<RouteCorridor
origin={row.original.origin}
@@ -349,6 +338,14 @@ export default function TrainScheduleV2ListPage() {
orientation="vertical"
/>
</Box>
<Group gap={6} wrap="nowrap">
{row.original.direction ? (
<Badge size="xs" variant="light" color={directionColor(row.original.direction)}>
{row.original.direction}
</Badge>
) : null}
<ShippingLineBadge schedule={row.original} />
</Group>
</Stack>
),
},
@@ -362,13 +359,7 @@ export default function TrainScheduleV2ListPage() {
id: "metrics",
header: "Load",
meta: { headerClassName, cellClassName },
cell: ({ row }) => (
<Group gap={6} wrap="nowrap">
<MetricChip value={row.original.bookingsCount} label="bkg" />
<WagonChips schedule={row.original} />
<MetricChip value={`${row.original.totalWeightTons}T`} label="" subtle />
</Group>
),
cell: ({ row }) => <MetricChip value={row.original.bookingsCount} label="bkg" />,
},
{
id: "actions",
@@ -887,14 +878,6 @@ export default function TrainScheduleV2ListPage() {
);
}
/**
* The row's wagon chips, matching the detail page's wagon plan: used is slots
* carrying a booking allocation, the denominator is the schedule's capacity
* (API-computed: the larger of coupled consist and planned `maxWagons`, since
* wagons are coupled on demand), and remaining excludes wagons reserved by
* bookings that have not paid yet — that space is claimed, so it is not
* bookable.
*/
/** Green tint for departures dedicated to a shipping line (overrides direction tint). */
const SHIPPING_LINE_ROW_STYLE = {
backgroundColor: "var(--mantine-color-edr-green-0)",
@@ -959,30 +942,6 @@ function ShippingLineBadge({ schedule }: { schedule: TrainScheduleListItem }) {
);
}
function WagonChips({ schedule }: { schedule: TrainScheduleListItem }) {
// Pre-deploy API rows carry only wagonCount; fall back so the chip still
// renders rather than reading 0 used on every train.
const total = schedule.wagonsTotal ?? schedule.wagonCount;
const used = schedule.wagonsUsed;
const reserved = schedule.wagonsReserved ?? 0;
const remaining = schedule.wagonsRemaining;
if (used == null) {
return <MetricChip value={total} label="wgn" subtle />;
}
return (
<>
<MetricChip
value={`${used}/${total}`}
label={schedule.wagonCount === 0 ? "wgn planned" : "wgn used"}
/>
{reserved > used ? <MetricChip value={reserved} label="reserved" subtle /> : null}
{remaining != null ? <MetricChip value={remaining} label="bookable" subtle /> : null}
</>
);
}
function MetricChip({
value,
label,
@@ -1049,9 +1008,6 @@ function ScheduleCard({
{schedule.reference}
</Text>
) : null}
<Text fw={600} size="sm" lineClamp={1}>
{schedule.routeName ?? "Train schedule"}
</Text>
</Group>
<Text size="xs" c="dimmed">
{day} · {time}
@@ -1082,11 +1038,7 @@ function ScheduleCard({
) : null}
<ShippingLineBadge schedule={schedule} />
</Group>
<Group gap={6} wrap="nowrap">
<MetricChip value={schedule.bookingsCount} label="bkg" />
<WagonChips schedule={schedule} />
<MetricChip value={`${schedule.totalWeightTons}T`} label="" subtle />
</Group>
<MetricChip value={schedule.bookingsCount} label="bkg" />
</Group>
<Group gap="xs" wrap="nowrap">

View File

@@ -19,12 +19,14 @@ import {
Select,
Checkbox,
} from "@mantine/core";
import { ChevronDown, ChevronRight, History } from "lucide-react";
import { ChevronDown, ChevronRight, FileText, History } from "lucide-react";
import { DataTable, type ColumnDef } from "@edr/ui-common";
import { PageContainer, PageHeader } from "@/components/page";
import ListControls from "@/components/common/ListControls";
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
import { extractDownloadErrorMessage } from "@/components/warehouses/options";
import { openPdfBlob } from "@/components/warehouses/pdf";
import { OverviewHorizontalBarChart } from "@/components/overview/OverviewHorizontalBarChart";
import { OverviewStackedBarChart } from "@/components/overview/OverviewStackedBarChart";
import { useListControls, toDayString } from "@/hooks/useListControls";
@@ -103,6 +105,25 @@ export default function ContainerReturnsPage() {
const [activeKey, setActiveKey] = useState<string | null>(null);
const [historyRow, setHistoryRow] = useState<any | null>(null);
const [allocateRow, setAllocateRow] = useState<EmptyContainerReturn | null>(null);
const [documentBusyId, setDocumentBusyId] = useState<string | null>(null);
const viewInterchangeDocument = async (ret: EmptyContainerReturn) => {
setDocumentBusyId(ret.id);
const pdfWindow = window.open("", "_blank");
try {
const response = await importOperationsService.downloadEquipmentInterchangeDocument(ret.id);
openPdfBlob(response.data, `equipment-interchange-${ret.containerNumber}.pdf`, pdfWindow);
} catch (error) {
pdfWindow?.close();
toast({
variant: "destructive",
title: "Could not open interchange receipt",
description: await extractDownloadErrorMessage(error),
});
} finally {
setDocumentBusyId(null);
}
};
const { data: unloadedQueue = [], isLoading: queueLoading } = useQuery({
queryKey: ["import-unloaded-queue"],
@@ -416,6 +437,15 @@ export default function ContainerReturnsPage() {
const nextStatus = RETURN_STATUS_ORDER[RETURN_STATUS_ORDER.indexOf(ret.status) + 1];
return (
<Group gap="xs" justify="flex-end" wrap="nowrap">
<ActionIcon
variant="subtle"
color="gray"
onClick={() => void viewInterchangeDocument(ret)}
loading={documentBusyId === ret.id}
title="View equipment interchange receipt"
>
<FileText size={14} />
</ActionIcon>
<ActionIcon
variant="subtle"
color="gray"

View File

@@ -17,6 +17,8 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, PackageCheck, PackageOpen, TrainFront, Warehouse } from "lucide-react";
import { PageContainer, PageHeader } from "@/components/page";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission as hasFreightPermission } from "@/lib/permissions";
import ListControls from "@/components/common/ListControls";
// Generic list footer — already shared by the fleet and train-scheduling lists
// despite the ruleEngine path.
@@ -93,6 +95,9 @@ const apiErrorMessage = (error: unknown) => {
function Rows({ rows }: { rows: IntercityRideAlongRow[] }) {
const { toast } = useToast();
const { user } = useAuth();
const canLoad = hasFreightPermission(user, FREIGHT_PERMS.trainScheduling.load);
const canUnload = hasFreightPermission(user, FREIGHT_PERMS.trainScheduling.unload);
const queryClient = useQueryClient();
const refresh = () =>
queryClient.invalidateQueries({
@@ -201,31 +206,37 @@ function Rows({ rows }: { rows: IntercityRideAlongRow[] }) {
<Group gap="xs" justify="flex-end" wrap="nowrap">
{/* Work the cargo right here while the train is at the yard. */}
{r.trainScheduleId && atOrigin(r) && isWaiting(r) && r.status === "PAID" && (
<Button
size="compact-xs"
variant="light"
leftSection={<PackageCheck size={13} />}
loading={load.isPending}
onClick={() =>
load.mutate({ scheduleId: r.trainScheduleId as string, bookingId: r.bookingId })
}
>
Load
</Button>
<Tooltip label={canLoad ? undefined : "You don't have permission to load cargo"} disabled={canLoad}>
<Button
size="compact-xs"
variant="light"
leftSection={<PackageCheck size={13} />}
loading={load.isPending}
disabled={!canLoad}
onClick={() =>
load.mutate({ scheduleId: r.trainScheduleId as string, bookingId: r.bookingId })
}
>
Load
</Button>
</Tooltip>
)}
{r.trainScheduleId && atDestination(r) && isRiding(r) && (
<Button
size="compact-xs"
variant="light"
color="orange"
leftSection={<PackageOpen size={13} />}
loading={unload.isPending}
onClick={() =>
unload.mutate({ scheduleId: r.trainScheduleId as string, bookingId: r.bookingId })
}
>
Unload
</Button>
<Tooltip label={canUnload ? undefined : "You don't have permission to unload cargo"} disabled={canUnload}>
<Button
size="compact-xs"
variant="light"
color="orange"
leftSection={<PackageOpen size={13} />}
loading={unload.isPending}
disabled={!canUnload}
onClick={() =>
unload.mutate({ scheduleId: r.trainScheduleId as string, bookingId: r.bookingId })
}
>
Unload
</Button>
</Tooltip>
)}
</Group>
</Table.Td>

View File

@@ -1,7 +1,10 @@
import { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Badge, Card, Center, Divider, Group, Loader, SimpleGrid, Stack, Text, ThemeIcon } from '@mantine/core';
import { Button, Card, Center, Group, Loader, Popover, Select, SimpleGrid, Stack, Text, ThemeIcon } from '@mantine/core';
import { DatePickerInput } from '@mantine/dates';
import {
ClipboardList,
Filter,
PackageCheck,
PackageOpen,
PackagePlus,
@@ -24,7 +27,7 @@ import {
WarehouseOpsKpiStrip,
ZoneOccupancyHeatmap,
} from '@/components/warehouses';
import { useWarehouseDashboard } from '@/hooks/useWarehouses';
import { useWarehouseDashboard, useWarehouses } from '@/hooks/useWarehouses';
import type { WarehouseDashboard } from '@/types/warehouse';
function SectionTitle({ children }: { children: React.ReactNode }) {
@@ -41,58 +44,110 @@ interface Metric {
icon: React.ReactNode;
/** Route to navigate to when the card is clicked. */
to: string;
theme: string;
}
const ORANGE = 'rgb(241, 147, 23)';
const GREEN = '#084b21';
const METRICS: Metric[] = [
{ key: 'totalWarehouses', label: 'Total Warehouses', icon: <WarehouseIcon size={22} />, to: '/dashboard/warehouses', theme: ORANGE },
{ key: 'totalInventory', label: 'Total Inventory', icon: <Boxes size={22} />, to: '/dashboard/warehouse-inventory', theme: GREEN },
{ key: 'receivedToday', label: 'Received Today', icon: <PackagePlus size={22} />, to: '/dashboard/warehouse-inventory?status=RECEIVED', theme: ORANGE },
{ key: 'awaitingInspection', label: 'Awaiting Inspection', icon: <ClipboardList size={22} />, to: '/dashboard/warehouse-inventory?status=RECEIVED', theme: GREEN },
{ key: 'emptyContainers', label: 'Empty Containers', icon: <PackageOpen size={22} />, to: '/dashboard/containers', theme: ORANGE },
{ key: 'importTrains', label: 'Import Trains', icon: <Train size={22} />, to: '/dashboard/import-warehouse', theme: GREEN },
{ key: 'exportTrains', label: 'Export Trains', icon: <Train size={22} />, to: '/dashboard/export-warehouse', theme: ORANGE },
{ key: 'loaded', label: 'Loaded', icon: <Truck size={22} />, to: '/dashboard/loaded-inventory', theme: GREEN },
{ key: 'dispatched', label: 'Dispatched', icon: <Send size={22} />, to: '/dashboard/dispatch-queue', theme: ORANGE },
{ key: 'readyForPickup', label: 'Ready For Pickup', icon: <PackageSearch size={22} />, to: '/dashboard/warehouse-inventory?status=READY_FOR_PICKUP', theme: GREEN },
{ key: 'readyForLoading', label: 'Ready For Loading', icon: <PackageCheck size={22} />, to: '/dashboard/loading-queue', theme: ORANGE },
{ key: 'delivered', label: 'Delivered', icon: <CircleCheck size={22} />, to: '/dashboard/warehouse-inventory?status=DELIVERED', theme: GREEN },
{ key: 'totalWarehouses', label: 'Total Warehouses', icon: <WarehouseIcon size={18} />, to: '/dashboard/warehouses' },
{ key: 'totalInventory', label: 'Total Inventory', icon: <Boxes size={18} />, to: '/dashboard/warehouse-inventory' },
{ key: 'received', label: 'Received Today', icon: <PackagePlus size={18} />, to: '/dashboard/warehouse-inventory?status=RECEIVED' },
{ key: 'awaitingInspection', label: 'Awaiting Inspection', icon: <ClipboardList size={18} />, to: '/dashboard/warehouse-inventory?status=RECEIVED' },
{ key: 'emptyContainers', label: 'Empty Containers', icon: <PackageOpen size={18} />, to: '/dashboard/containers' },
{ key: 'importTrains', label: 'Import Trains', icon: <Train size={18} />, to: '/dashboard/import-warehouse' },
{ key: 'exportTrains', label: 'Export Trains', icon: <Train size={18} />, to: '/dashboard/export-warehouse' },
{ key: 'loaded', label: 'Loaded', icon: <Truck size={18} />, to: '/dashboard/loaded-inventory' },
{ key: 'dispatched', label: 'Dispatched', icon: <Send size={18} />, to: '/dashboard/dispatch-queue' },
{ key: 'readyForPickup', label: 'Ready For Pickup', icon: <PackageSearch size={18} />, to: '/dashboard/warehouse-inventory?status=READY_FOR_PICKUP' },
{ key: 'readyForLoading', label: 'Ready For Loading', icon: <PackageCheck size={18} />, to: '/dashboard/loading-queue' },
{ key: 'delivered', label: 'Delivered', icon: <CircleCheck size={18} />, to: '/dashboard/warehouse-inventory?status=DELIVERED' },
];
export default function WarehouseDashboardPage() {
const navigate = useNavigate();
const { data, isError, isLoading } = useWarehouseDashboard();
// null → the API defaults `received` to "today", matching the page's original behaviour.
const [receivedDate, setReceivedDate] = useState<string | null>(null);
const [warehouseId, setWarehouseId] = useState<string | null>(null);
const [filtersOpen, setFiltersOpen] = useState(false);
const hasCustomDate = Boolean(receivedDate);
const warehousesQuery = useWarehouses();
const warehouseOptions = useMemo(
() => (warehousesQuery.data ?? []).map((w) => ({ value: w.id, label: `${w.name} (${w.code})` })),
[warehousesQuery.data],
);
const activeFilterCount = (warehouseId ? 1 : 0) + (hasCustomDate ? 1 : 0);
const { data, isError, isLoading } = useWarehouseDashboard({
// Same date both ends → the one day the picker selected, inclusive.
dateFrom: receivedDate ?? undefined,
dateTo: receivedDate ?? undefined,
warehouseId: warehouseId ?? undefined,
});
return (
<PageContainer>
<PageHeader
title="Warehouse Dashboard"
subtitle="Live overview of warehouse capacity and inventory lifecycle."
subtitle="Freight import/export logistics operations overview"
action={
<Badge
color="edr-green"
variant="light"
size="lg"
leftSection={
<span
style={{
display: 'inline-block',
width: 8,
height: 8,
borderRadius: '50%',
background: 'var(--mantine-color-edr-green-6)',
}}
/>
}
>
Live · updates every 60s
</Badge>
<Group gap="sm" wrap="wrap" justify="flex-end">
<DatePickerInput
placeholder="Received: today"
value={receivedDate}
onChange={setReceivedDate}
clearable
w={180}
/>
<Popover opened={filtersOpen} onChange={setFiltersOpen} position="bottom-end" withArrow shadow="md">
<Popover.Target>
<Button
variant="default"
leftSection={<Filter size={16} />}
rightSection={activeFilterCount > 0 ? <Text size="xs" fw={700} c="edr-green">{activeFilterCount}</Text> : null}
onClick={() => setFiltersOpen((o) => !o)}
>
Filters
</Button>
</Popover.Target>
<Popover.Dropdown>
<Stack gap="sm" w={240}>
<Select
label="Warehouse"
placeholder="All warehouses"
clearable
searchable
data={warehouseOptions}
value={warehouseId}
onChange={setWarehouseId}
/>
{activeFilterCount > 0 && (
<Button
variant="subtle"
color="gray"
size="xs"
onClick={() => {
setWarehouseId(null);
setReceivedDate(null);
}}
>
Clear filters
</Button>
)}
</Stack>
</Popover.Dropdown>
</Popover>
</Group>
}
/>
{(warehouseId || hasCustomDate) && (
<Text size="xs" c="dimmed" mt={-8}>
Scoped to{' '}
{warehouseId ? warehouseOptions.find((o) => o.value === warehouseId)?.label ?? 'selected warehouse' : 'all warehouses'}
{hasCustomDate ? ` · Received counts for ${receivedDate}` : ' · Received counts: today'}
. Status-backlog and fleet counters are always current regardless of the date filter.
</Text>
)}
{isLoading ? (
<Center py="xl">
<Loader />
@@ -102,41 +157,33 @@ export default function WarehouseDashboardPage() {
<Text c="red">Failed to load warehouse dashboard.</Text>
</Center>
) : (
<Stack gap="xl">
<Stack gap="lg">
{/* Needs attention — live ops counters (received today, pending
inspection, trucks on-site, items aging > 7 days). */}
<Stack gap="sm">
<SectionTitle>Needs attention</SectionTitle>
<WarehouseOpsKpiStrip />
</Stack>
<Divider />
<WarehouseOpsKpiStrip />
<SimpleGrid cols={{ base: 1, xs: 2, md: 4 }} spacing="md">
{METRICS.map((metric) => (
<Card
key={metric.key}
padding="lg"
padding="md"
withBorder
radius="md"
onClick={() => navigate(metric.to)}
className="cursor-pointer transition-[transform,border-color] duration-150 hover:-translate-y-0.5 hover:border-edr-primary!"
>
<Group justify="space-between" align="flex-start" wrap="nowrap">
<div>
<Text size="xs" c="edr-muted" tt="uppercase" fw={700} style={{ letterSpacing: 0.4 }}>
{metric.label}
</Text>
<Text fw={800} fz={32} mt={8} c="edr-text" lh={1.1}>
{data ? data[metric.key] : 0}
</Text>
</div>
<ThemeIcon
variant="light"
size={46}
radius="md"
style={{ backgroundColor: `${metric.theme}1a`, color: metric.theme }}
>
<Group gap="sm" wrap="nowrap">
<ThemeIcon color="edr-green" variant="light" size={40} radius="md">
{metric.icon}
</ThemeIcon>
<Stack gap={0} style={{ minWidth: 0 }}>
<Text size="xs" c="edr-muted" fw={600}>
{metric.key === 'received' && hasCustomDate ? 'Received' : metric.label}
</Text>
<Text fw={700} fz={20} c="edr-text" lh={1.2}>
{data ? data[metric.key] : 0}
</Text>
</Stack>
</Group>
</Card>
))}

View File

@@ -231,13 +231,21 @@ import {
type BuildTrainPayload,
type BuiltTrainListFilters,
type BuiltTrainListResponse,
type DetachedWagonRow,
type TrainHistoryEntry,
type ScheduleConsist,
type ScheduleWagonYards,
type UpdateScheduleWagonYardsPayload,
type UpdateScheduleWagonYardsResult,
type ScheduleHistoryEntry,
type TrainComposition,
type UpdateTrainDetailsPayload,
type UsedTrainNumbers,
} from "./trainBuilder.service";
import { trainSchedulingService } from "./trainScheduling.service";
import {
trainSchedulingService,
type SchedulePhaseSnapshot,
} from "./trainScheduling.service";
import { truckTypesService, type TruckType } from "./truck-types.service";
import { wagonTypesService, type WagonType } from "./wagon-types.service";
import {
@@ -377,6 +385,14 @@ export const api = {
({ id }) => QUERY_KEYS.TRAIN_SCHEDULING.scheduleById(id),
),
// One-row heartbeat behind the detail page's 60s poll — the giant detail
// payload refetches only when this snapshot changes.
schedulePhase: endpoint<{ id: string }, SchedulePhaseSnapshot>(
"train-scheduling",
"schedule-phase",
({ id }) => trainSchedulingService.getSchedulePhase(id),
),
eligibleBookings: endpoint<
{ filters?: TrainScheduleFilters; freightType?: FreightType },
EligibleContainerBookingsResponse
@@ -416,6 +432,30 @@ export const api = {
],
),
scheduleWagonYards: endpoint<{ scheduleId: string }, ScheduleWagonYards>(
"train-scheduling",
"schedule-wagon-yards",
({ scheduleId }) =>
trainBuilderService.scheduleWagonYards(scheduleId).then((r) => r.data),
({ scheduleId }) => [
...QUERY_KEYS.TRAIN_SCHEDULING.ROOT,
"wagon-yards",
scheduleId,
],
),
updateScheduleWagonYards: endpoint<
{ scheduleId: string; payload: UpdateScheduleWagonYardsPayload },
UpdateScheduleWagonYardsResult
>(
"train-scheduling",
"update-schedule-wagon-yards",
({ scheduleId, payload }) =>
trainBuilderService.updateScheduleWagonYards(scheduleId, payload).then((r) => r.data),
undefined,
() => TRAIN_SCHEDULING_INVALIDATIONS,
),
adjustConsist: endpoint<
{ scheduleId: string; payload: AdjustConsistPayload },
AdjustConsistResult
@@ -428,15 +468,20 @@ export const api = {
() => TRAIN_BUILDER_INVALIDATIONS,
),
scheduleHistory: endpoint<{ scheduleId: string }, ScheduleHistoryEntry[]>(
scheduleHistory: endpoint<
{ scheduleId: string; page: number; pageSize: number },
PaginatedResponse<ScheduleHistoryEntry>
>(
"train-scheduling",
"schedule-history",
({ scheduleId }) =>
trainBuilderService.scheduleHistory(scheduleId).then((r) => r.data),
({ scheduleId }) => [
({ scheduleId, page, pageSize }) =>
trainBuilderService.scheduleHistory(scheduleId, page, pageSize).then((r) => r.data),
({ scheduleId, page, pageSize }) => [
...QUERY_KEYS.TRAIN_SCHEDULING.ROOT,
"history",
scheduleId,
page,
pageSize,
],
),
@@ -2076,6 +2121,22 @@ export const api = {
({ id }) => QUERY_KEYS.TRAIN_BUILDER.composition(id),
),
// Keys derive to ["train-builder", "history"|"detachedWagons", input] — the
// shared TRAIN_BUILDER.ROOT invalidation refreshes both after every edit.
history: endpoint<
{ id: string; page: number; pageSize: number },
PaginatedResponse<TrainHistoryEntry>
>("train-builder", "history", ({ id, page, pageSize }) =>
trainBuilderService.getHistory(id, page, pageSize).then((r) => r.data),
),
detachedWagons: endpoint<
{ id: string; page: number; pageSize: number },
PaginatedResponse<DetachedWagonRow>
>("train-builder", "detachedWagons", ({ id, page, pageSize }) =>
trainBuilderService.getDetachedWagons(id, page, pageSize).then((r) => r.data),
),
// Key derives to ["train-builder", "usedTrainNumbers"], so the shared
// TRAIN_BUILDER.ROOT invalidation refreshes it after every build/edit.
usedTrainNumbers: endpoint<void, UsedTrainNumbers>(
@@ -2151,6 +2212,19 @@ export const api = {
seedComposition,
),
setWagonsYard: endpoint<
{ id: string; wagonIds: string[]; currentYardId: string },
TrainComposition
>(
"train-builder",
"setWagonsYard",
({ id, wagonIds, currentYardId }) =>
trainBuilderService.setWagonsYard(id, wagonIds, currentYardId).then((r) => r.data),
undefined,
() => TRAIN_BUILDER_WAGON_INVALIDATIONS,
seedComposition,
),
removeWagon: endpoint<{ id: string; wagonId: string }, TrainComposition>(
"train-builder",
"removeWagon",

View File

@@ -18,23 +18,46 @@ export interface ConsolidationApprovalRow {
requestedBy?: string | null;
requestedAt: string;
decidedBy?: string | null;
/** Display name of the approver/rejecter — the id alone means nothing. */
decidedByName?: string | null;
requestedByName?: string | null;
decidedAt?: string | null;
decisionNote?: string | null;
scheduledDate?: string | null;
bookingReference?: string | null;
partnerBookingReference?: string | null;
/** Contract each half was created under — reviewers work by contract. */
contractReference?: string | null;
partnerContractReference?: string | null;
booking?: {
id: string;
reference?: string;
contractId?: string | null;
company?: { name?: string } | null;
} | null;
partnerBooking?: {
id: string;
reference?: string;
contractId?: string | null;
company?: { name?: string } | null;
} | null;
}
/** One page of approval rows plus the whole-queue counts behind the tabs. */
export interface ConsolidationApprovalPage {
items: ConsolidationApprovalRow[];
total: number;
counts: Record<ConsolidationApprovalRow["status"], number>;
meta: {
page: number;
pageSize: number;
total: number;
totalPages: number;
hasNextPage: boolean;
hasPreviousPage: boolean;
};
}
export interface BookingListFilter {
status?: string;
/** Comma-separated statuses for grouped tabs */
@@ -164,7 +187,9 @@ async function postBooking<T>(url: string, body?: unknown): Promise<T> {
}
export const bookingsService = {
getListSummary: async (filter?: BookingListFilter): Promise<BookingListSummary> => {
getListSummary: async (
filter?: BookingListFilter,
): Promise<BookingListSummary> => {
const params: Record<string, string | number | boolean | undefined> = {};
if (filter) {
if (filter.statuses) params.statuses = filter.statuses;
@@ -176,14 +201,16 @@ export const bookingsService = {
if (filter.serviceTypeId) params.serviceTypeId = filter.serviceTypeId;
if (filter.bookingType) params.bookingType = filter.bookingType;
if (filter.tradeDirection) params.tradeDirection = filter.tradeDirection;
if (filter.paymentCurrency) params.paymentCurrency = filter.paymentCurrency;
if (filter.paymentCurrency)
params.paymentCurrency = filter.paymentCurrency;
if (filter.paymentStatus) params.paymentStatus = filter.paymentStatus;
if (filter.createdFrom) params.createdFrom = filter.createdFrom;
if (filter.createdTo) params.createdTo = filter.createdTo;
if (filter.scheduledFrom) params.scheduledFrom = filter.scheduledFrom;
if (filter.scheduledTo) params.scheduledTo = filter.scheduledTo;
if (filter.originYardId) params.originYardId = filter.originYardId;
if (filter.destinationYardId) params.destinationYardId = filter.destinationYardId;
if (filter.destinationYardId)
params.destinationYardId = filter.destinationYardId;
if (filter.isGovernment) params.isGovernment = filter.isGovernment;
if (filter.customerKind) params.customerKind = filter.customerKind;
}
@@ -203,22 +230,26 @@ export const bookingsService = {
if (filter.pageSize != null) params.pageSize = filter.pageSize;
if (filter.sortBy) params.sortBy = filter.sortBy;
if (filter.sortOrder) params.sortOrder = filter.sortOrder;
if (filter.schedulingStatuses) params.schedulingStatuses = filter.schedulingStatuses;
if (filter.assignedToSchedule) params.assignedToSchedule = filter.assignedToSchedule;
if (filter.schedulingStatuses)
params.schedulingStatuses = filter.schedulingStatuses;
if (filter.assignedToSchedule)
params.assignedToSchedule = filter.assignedToSchedule;
if (filter.companyId) params.companyId = filter.companyId;
if (filter.contractId) params.contractId = filter.contractId;
if (filter.freightType) params.freightType = filter.freightType;
if (filter.serviceTypeId) params.serviceTypeId = filter.serviceTypeId;
if (filter.bookingType) params.bookingType = filter.bookingType;
if (filter.tradeDirection) params.tradeDirection = filter.tradeDirection;
if (filter.paymentCurrency) params.paymentCurrency = filter.paymentCurrency;
if (filter.paymentCurrency)
params.paymentCurrency = filter.paymentCurrency;
if (filter.paymentStatus) params.paymentStatus = filter.paymentStatus;
if (filter.createdFrom) params.createdFrom = filter.createdFrom;
if (filter.createdTo) params.createdTo = filter.createdTo;
if (filter.scheduledFrom) params.scheduledFrom = filter.scheduledFrom;
if (filter.scheduledTo) params.scheduledTo = filter.scheduledTo;
if (filter.originYardId) params.originYardId = filter.originYardId;
if (filter.destinationYardId) params.destinationYardId = filter.destinationYardId;
if (filter.destinationYardId)
params.destinationYardId = filter.destinationYardId;
if (filter.isGovernment) params.isGovernment = filter.isGovernment;
if (filter.customerKind) params.customerKind = filter.customerKind;
if (filter.customsClearingEnabled)
@@ -330,7 +361,9 @@ export const bookingsService = {
getConsolidationDetails: async (
id: string,
): Promise<ConsolidationDetails> => {
const response = await client.get<ConsolidationDetails>(B.CONSOLIDATION(id));
const response = await client.get<ConsolidationDetails>(
B.CONSOLIDATION(id),
);
return unwrap(response.data) as ConsolidationDetails;
},
@@ -348,8 +381,7 @@ export const bookingsService = {
payBooking: (id: string) => postBooking<BookingDetail>(B.PAYMENT_PAY(id)),
startTransit: (id: string) =>
postBooking<BookingDetail>(B.START_TRANSIT(id)),
startTransit: (id: string) => postBooking<BookingDetail>(B.START_TRANSIT(id)),
complete: (id: string) => postBooking<BookingDetail>(B.COMPLETE(id)),
@@ -358,10 +390,36 @@ export const bookingsService = {
// ── Shared-wagon approval gate ──────────────────────────────────────────
/** Pairings awaiting a decision, oldest first. */
consolidationApprovalQueue: async (): Promise<ConsolidationApprovalRow[]> => {
const response = await client.get(B.CONSOLIDATION_APPROVAL_QUEUE);
return (unwrap(response.data) ?? []) as ConsolidationApprovalRow[];
/**
* One page of the gate. `status` picks the tab; the counts come back for all
* three tabs regardless, so the badges show the whole queue and not the page.
*/
consolidationApprovalQueue: async (
params: {
status?: ConsolidationApprovalRow["status"];
page?: number;
pageSize?: number;
} = {},
): Promise<ConsolidationApprovalPage> => {
const response = await client.get(B.CONSOLIDATION_APPROVAL_QUEUE, {
params,
});
const data = unwrap(response.data) as ConsolidationApprovalPage | null;
return (
data ?? {
items: [],
total: 0,
counts: { PENDING: 0, APPROVED: 0, REJECTED: 0 },
meta: {
page: 1,
pageSize: params.pageSize ?? 10,
total: 0,
totalPages: 0,
hasNextPage: false,
hasPreviousPage: false,
},
}
);
},
/** Decision history for one booking's shared wagon — who, when, and why. */
@@ -411,10 +469,9 @@ export const bookingsService = {
},
create: async (payload: Record<string, unknown>): Promise<BookingDetail> => {
const response = await client.post<{ booking: BookingDetail } | BookingDetail>(
B.BASE,
payload,
);
const response = await client.post<
{ booking: BookingDetail } | BookingDetail
>(B.BASE, payload);
const data = unwrap(response.data) as { booking?: BookingDetail };
return (data.booking ?? data) as BookingDetail;
},
@@ -432,6 +489,14 @@ export const bookingsService = {
return unwrap(response.data) as Freight.ClearanceView;
},
/** GL asks the customer for additional clearance document(s). */
requestAdditionalDocuments: async (
id: string,
note: string,
): Promise<void> => {
await client.post(`/bookings/${id}/clearance/doc-requests`, { note });
},
/** Clearance action history — reviews, workflow steps, charges (newest first). */
getClearanceHistory: async (
id: string,
@@ -441,7 +506,9 @@ export const bookingsService = {
},
// ── Clearance charges (post-finalization customer billing) ──
getClearanceCharges: async (id: string): Promise<Freight.ClearanceCharge[]> => {
getClearanceCharges: async (
id: string,
): Promise<Freight.ClearanceCharge[]> => {
const response = await client.get(`/bookings/${id}/clearance/charges`);
return unwrap(response.data) as Freight.ClearanceCharge[];
},
@@ -461,11 +528,11 @@ export const bookingsService = {
return unwrap(response.data) as Freight.ClearanceCharge[];
},
/** GL Ethiopia sets or revises a charge's amount + currency. */
/** GL Ethiopia sets or revises a charge's amount, currency and description. */
billClearanceCharge: async (
id: string,
chargeId: string,
payload: { amount: number; currency: string },
payload: { amount: number; currency: string; description?: string },
): Promise<Freight.ClearanceCharge[]> => {
const response = await client.patch(
`/bookings/${id}/clearance/charges/${chargeId}/bill`,
@@ -474,7 +541,7 @@ export const bookingsService = {
return unwrap(response.data) as Freight.ClearanceCharge[];
},
/** GL Ethiopia issues the charge's payable invoice to the customer. */
/** GL Ethiopia sends the priced charge to the customer for approval. */
sendClearanceCharge: async (
id: string,
chargeId: string,
@@ -485,16 +552,17 @@ export const bookingsService = {
return unwrap(response.data) as Freight.ClearanceCharge[];
},
/** GL Ethiopia creates the miscellaneous charge (document + amount + currency). */
/** GL Ethiopia creates a miscellaneous charge (document + amount + currency + description). */
createMiscellaneousCharge: async (
id: string,
file: File,
payload: { amount: number; currency: string },
payload: { amount: number; currency: string; description: string },
): Promise<Freight.ClearanceCharge[]> => {
const form = new FormData();
form.append("file", file);
form.append("amount", String(payload.amount));
form.append("currency", payload.currency);
form.append("description", payload.description);
const response = await client.post(
`/bookings/${id}/clearance/charges/miscellaneous`,
form,
@@ -503,6 +571,68 @@ export const bookingsService = {
return unwrap(response.data) as Freight.ClearanceCharge[];
},
// ── Additional charges (ad-hoc finance billing) ──
getAdditionalCharges: async (
id: string,
): Promise<Freight.AdditionalCharge[]> => {
const response = await client.get(`/bookings/${id}/additional-charges`);
return unwrap(response.data) as Freight.AdditionalCharge[];
},
/** Finance raises a new charge — 'draft' just saves it, 'send' also issues the invoice and notifies the customer. */
createAdditionalCharge: async (
id: string,
payload: {
reason: string;
amount: number;
currency: string;
action: "draft" | "send";
file?: File | null;
/** ISO date (YYYY-MM-DD); omit to fall back to the invoice's default 14-day term. */
dueDate?: string | null;
},
): Promise<Freight.AdditionalCharge[]> => {
const form = new FormData();
form.append("reason", payload.reason);
form.append("amount", String(payload.amount));
form.append("currency", payload.currency);
form.append("action", payload.action);
if (payload.dueDate) form.append("dueDate", payload.dueDate);
if (payload.file) form.append("file", payload.file);
const response = await client.post(
`/bookings/${id}/additional-charges`,
form,
{
headers: { "Content-Type": "multipart/form-data" },
},
);
return unwrap(response.data) as Freight.AdditionalCharge[];
},
/** Issues the draft charge's payable invoice and notifies the customer. */
sendAdditionalCharge: async (
id: string,
chargeId: string,
): Promise<Freight.AdditionalCharge[]> => {
const response = await client.post(
`/bookings/${id}/additional-charges/${chargeId}/send`,
);
return unwrap(response.data) as Freight.AdditionalCharge[];
},
/** Withdraws a draft or unpaid additional charge. */
cancelAdditionalCharge: async (
id: string,
chargeId: string,
reason?: string,
): Promise<Freight.AdditionalCharge[]> => {
const response = await client.post(
`/bookings/${id}/additional-charges/${chargeId}/cancel`,
{ reason },
);
return unwrap(response.data) as Freight.AdditionalCharge[];
},
/** GL ET asks Djibouti to name the officer handling the shipment in transit. */
requestTransitAssignee: (id: string, note?: string) =>
postBooking<BookingDetail>(B.CLEARANCE_TRANSIT_ASSIGNEE_REQUEST(id), {
@@ -560,15 +690,25 @@ export const bookingsService = {
currency: string,
): Promise<BookingDetail> => {
const form = new FormData();
files.forEach((file, index) => form.append(`draft_declaration_${index}`, file));
files.forEach((file, index) =>
form.append(`draft_declaration_${index}`, file),
);
form.append("price", String(price));
form.append("currency", currency);
const response = await client.post(B.CLEARANCE_DRAFT_DECLARATION(id), form, {
headers: { "Content-Type": "multipart/form-data" },
});
const response = await client.post(
B.CLEARANCE_DRAFT_DECLARATION(id),
form,
{
headers: { "Content-Type": "multipart/form-data" },
},
);
return unwrap(response.data) as BookingDetail;
},
/** Skip the draft-declaration round — file the real declaration directly; duty & tax passes by default. */
skipDraftDeclaration: (id: string) =>
postBooking<BookingDetail>(B.CLEARANCE_DRAFT_DECLARATION_SKIP(id)),
finalizePreClearance: (id: string) =>
postBooking<BookingDetail>(B.CLEARANCE_FINALIZE_PRE(id)),

View File

@@ -144,4 +144,10 @@ export const importOperationsService = {
);
return unwrap(response.data);
},
/** Equipment interchange receipt — the doc handed to the customer at handover. */
downloadEquipmentInterchangeDocument: (id: string) =>
client.get<Blob>(URL_CONSTANTS.IMPORT_OPERATIONS.EMPTY_CONTAINER_RETURN_DOCUMENT(id), {
responseType: 'blob',
}),
};

View File

@@ -1,3 +1,5 @@
import type { PaginatedResponse } from "@edr/types";
import { api as apiClient } from "../auth/http";
// ---------------------------------------------------------------------------
@@ -300,10 +302,102 @@ export interface ScheduleHistoryEntry {
/** Adjust response: fresh consist + schedule-impact warnings to surface. */
export type AdjustConsistResult = ScheduleConsist & { warnings: string[] };
/** One wagon adjustment on a built train (History tab): builder edits and trip events alike. */
export interface TrainHistoryEntry {
id: string;
action: "ADD" | "REMOVE" | "SWITCH";
subject: string | null;
yardLabel: string | null;
actor: string | null;
/** Set when the change came from a trip (schedule); null = train-builder edit. */
scheduleReference: string | null;
occurredAt: string;
}
/** Wagon last detached from this train and still loose — the re-attach shortlist. */
export interface DetachedWagonRow {
wagonId: string;
wagonNumber: string;
wagonTypeCode: string | null;
currentYardLabel: string | null;
detachedAt: string;
detachedYardLabel: string | null;
detachedBy: string | null;
}
/** One consist wagon in the schedule-yards tab: where this departure plans it vs where it stands. */
export interface ScheduleWagonYardRow {
id: string;
wagonNumber: string;
sequenceNumber: number | null;
wagonType: { id: string; code: string; name: string };
physicalYardId: string | null;
physicalYardLabel: string | null;
plannedYardId: string | null;
plannedYardLabel: string | null;
/** Drop stop this departure cuts the wagon at; null = rides to the destination. */
cutYardId: string | null;
cutYardLabel: string | null;
/** true = REAL cut: the built train permanently loses the wagon at the cut yard. */
realCut: boolean;
/** Set on planned-couple rows: the pickup stop this loose wagon joins the train at. */
coupledYardId: string | null;
coupledYardLabel: string | null;
aligned: boolean;
locked: boolean;
lockReason: string | null;
}
export interface ScheduleWagonYardStop {
yardId: string;
label: string;
/** Origin or intermediate stop — wagons can board here. The destination cannot. */
pickup: boolean;
planned: number;
physical: number;
/** Wagons this departure cuts (detaches and leaves) at this stop. */
cut: number;
/** Loose wagons this departure couples onto the train at this stop. */
coupled: number;
}
export interface ScheduleWagonYards {
scheduleId: string;
train: { id: string; code: string };
editable: boolean;
stops: ScheduleWagonYardStop[];
wagons: ScheduleWagonYardRow[];
misaligned: number;
}
export interface UpdateScheduleWagonYardsPayload {
/** Omit a field to leave it unchanged; cutYardId null clears the cut (rides to destination). */
moves?: Array<{
wagonId: string;
yardId?: string;
cutYardId?: string | null;
realCut?: boolean;
}>;
/** Loose wagons to plan-couple at a pickup stop (they must stand at that yard). */
couple?: Array<{ wagonId: string; yardId: string }>;
/** Wagon ids to drop from the couple plan. */
uncouple?: string[];
}
export type UpdateScheduleWagonYardsResult = ScheduleWagonYards & { warnings: string[] };
export const trainBuilderService = {
list: (filters: BuiltTrainListFilters = {}) =>
apiClient.get<BuiltTrainListResponse>(`${BASE}${toQuery(filters)}`),
getComposition: (id: string) => apiClient.get<TrainComposition>(`${BASE}/${id}`),
getHistory: (id: string, page: number, pageSize: number) =>
apiClient.get<PaginatedResponse<TrainHistoryEntry>>(
`${BASE}/${id}/history?page=${page}&pageSize=${pageSize}`,
),
getDetachedWagons: (id: string, page: number, pageSize: number) =>
apiClient.get<PaginatedResponse<DetachedWagonRow>>(
`${BASE}/${id}/detached-wagons?page=${page}&pageSize=${pageSize}`,
),
/** Import/export run numbers already claimed by existing trains. */
usedTrainNumbers: () => apiClient.get<UsedTrainNumbers>(`${BASE}/used-train-numbers`),
build: (payload: BuildTrainPayload) => apiClient.post<TrainComposition>(BASE, payload),
@@ -318,6 +412,12 @@ export const trainBuilderService = {
/** Move one coupled wagon to another yard; the train stays put. */
setWagonYard: (id: string, wagonId: string, currentYardId: string) =>
apiClient.patch<TrainComposition>(`${BASE}/${id}/wagons/${wagonId}/yard`, { currentYardId }),
/** Move several coupled wagons to another yard in one transaction (all-or-nothing). */
setWagonsYard: (id: string, wagonIds: string[], currentYardId: string) =>
apiClient.patch<TrainComposition>(`${BASE}/${id}/wagons/yard`, {
wagonIds,
currentYardId,
}),
assignWagons: (id: string, wagonIds: string[]) =>
apiClient.post<TrainComposition>(`${BASE}/${id}/wagons`, { wagonIds }),
removeWagon: (id: string, wagonId: string) =>
@@ -350,9 +450,18 @@ export const trainBuilderService = {
`/train-scheduling/schedules/${scheduleId}/adjust-consist`,
payload,
),
/** Schedule-only wagon yard plan (where THIS departure boards each wagon). */
scheduleWagonYards: (scheduleId: string) =>
apiClient.get<ScheduleWagonYards>(`/train-scheduling/schedules/${scheduleId}/wagon-yards`),
/** Re-plan boarding yards for this schedule; physical wagon yards untouched. */
updateScheduleWagonYards: (scheduleId: string, payload: UpdateScheduleWagonYardsPayload) =>
apiClient.patch<UpdateScheduleWagonYardsResult>(
`/train-scheduling/schedules/${scheduleId}/wagon-yards`,
payload,
),
/** Unified wagon/booking change history for the schedule's History tab. */
scheduleHistory: (scheduleId: string) =>
apiClient.get<ScheduleHistoryEntry[]>(
`/train-scheduling/schedules/${scheduleId}/history`,
scheduleHistory: (scheduleId: string, page: number, pageSize: number) =>
apiClient.get<PaginatedResponse<ScheduleHistoryEntry>>(
`/train-scheduling/schedules/${scheduleId}/history?page=${page}&pageSize=${pageSize}`,
),
};

View File

@@ -51,12 +51,30 @@ interface BookingReferenceDataResponse {
yard?: Array<YardOption & { label?: string }>;
}
/** Lightweight polling snapshot — refetch the full detail only when this changes. */
export interface SchedulePhaseSnapshot {
status: string;
bookingWindowStatus: string | null;
windowPhase: string | null;
windowOpensAt: string | null;
windowClosesAt: string | null;
docReviewEndsAt: string | null;
paymentPhaseEndsAt: string | null;
updatedAt: string;
}
const pathsFor = (freightType?: FreightType) =>
freightType === "BULK"
? URL_CONSTANTS.TRAIN_SCHEDULING.BULK
: URL_CONSTANTS.TRAIN_SCHEDULING.CONTAINER;
export const trainSchedulingService = {
getSchedulePhase: async (id: string): Promise<SchedulePhaseSnapshot> => {
const response = await client.get<SchedulePhaseSnapshot>(
`/train-scheduling/schedules/${id}/phase`,
);
return unwrap(response.data);
},
getEligibleBookings: async (
filters?: TrainScheduleFilters,
freightType?: FreightType,

View File

@@ -64,6 +64,7 @@ import type {
Warehouse,
WarehouseActivityLog,
WarehouseDashboard,
WarehouseDashboardFilter,
WarehouseFacility,
WarehouseFilter,
WarehouseInventoryItem,
@@ -245,7 +246,10 @@ export const warehouseService = {
apiClient.get<Warehouse[]>(URL_CONSTANTS.WAREHOUSES.BASE, {
params: cleanParams(filter ?? {}),
}),
dashboard: () => apiClient.get<WarehouseDashboard>(URL_CONSTANTS.WAREHOUSES.DASHBOARD),
dashboard: (filter?: WarehouseDashboardFilter) =>
apiClient.get<WarehouseDashboard>(URL_CONSTANTS.WAREHOUSES.DASHBOARD, {
params: cleanParams(filter ?? {}),
}),
getDashboardSummary: (_filter?: InventoryFilter) =>
apiClient.get<WarehouseDashboard>(URL_CONSTANTS.WAREHOUSES.DASHBOARD),
getById: (id: string) => apiClient.get<Warehouse>(URL_CONSTANTS.WAREHOUSES.BY_ID(id)),

View File

@@ -556,6 +556,13 @@ export interface TrainScheduleWagonAllocation {
allocatedWeightTons: number;
loadType?: string | null;
status?: string;
/**
* This load's OWN corridor. A wagon reused across disjoint legs carries two
* loads with different yards, so the wagon's boardYardId/alightYardId (their
* union) cannot say which load rides which leg — these can.
*/
originYardId?: string | null;
destinationYardId?: string | null;
containerItems?: Array<{
id: string;
containerNumber: string | null;

View File

@@ -286,10 +286,18 @@ export interface WarehouseActivityLog {
createdAt: string;
}
/** Both dates omitted → `received` defaults to "today" (the original behaviour). */
export interface WarehouseDashboardFilter {
dateFrom?: string;
dateTo?: string;
warehouseId?: string;
}
export interface WarehouseDashboard {
totalWarehouses: number;
totalInventory: number;
receivedToday: number;
/** Items received in the requested range — "today" when no range is set. */
received: number;
awaitingInspection: number;
inspected: number;
stored: number;