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

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