Merge branch 'dev' into freight/nati-2

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -301,8 +301,9 @@ export default function GlCreateBookingForm() {
const [trainScheduleId, setTrainScheduleId] = useState("");
const [contractRouteId, setContractRouteId] = useState<string | null>(null);
const [notes, setNotes] = useState("");
// ponytail: ETB-only for now — widen back to "USD" | "ETB" when multi-currency billing returns.
const [paymentCurrency, setPaymentCurrency] = useState<"USD" | "ETB">("ETB");
// IMPORT bookings pick ETB or USD — starts empty so the choice is
// deliberate (required before pricing). Everything else is forced to ETB.
const [paymentCurrency, setPaymentCurrency] = useState<"USD" | "ETB" | "">("");
// What the containers carry — captured per booking (moved off the contract).
const [cargoDescription, setCargoDescription] = useState("");
const [containerLines, setContainerLines] = useState<ContainerLineDraft[]>([]);
@@ -873,15 +874,10 @@ export default function GlCreateBookingForm() {
// Only a customs (Path B) instance being COMPLETED by GL can use the shared
// wagon: it is GL, not the customer, who links the two bookings. Anything else
// keeps the historical hard block on odd 20ft.
//
// Switched OFF for now: consolidation is built end to end (toggle, parent
// picker, split entry, paired pricing, approval gate) but not in use, so an
// odd 20ft total is rejected outright instead of offering the shared wagon.
// Drop the `false &&` to bring the whole flow back.
const oddConsolidationAvailable =
false &&
Boolean(completeBookingId && isContainer && contract?.customsClearingEnabled);
// falls through to the server's automatic consolidation gate.
const oddConsolidationAvailable = Boolean(
completeBookingId && isContainer && contract?.customsClearingEnabled,
);
// Auto-on: entering an odd 20ft total opens the consolidation panel by itself,
// once. GL can still switch it off — then odd is blocked exactly as before.
@@ -970,11 +966,11 @@ export default function GlCreateBookingForm() {
!cargoDescriptionError
: !bulkErrors.quantity && !bulkErrors.hazardous && !bulkErrors.reefer;
// Consolidation (sharing the wagon with another customer's odd booking) is
// built but switched off for now, so an odd 20ft total always blocks — the
// shared wagon no longer resolves the unpaired container. Flip this back to
// `hasOdd20ft && !consolidationActive` to re-enable the shared-wagon path.
const oddBlocksSubmit = hasOdd20ft;
// COMPLETION never blocks on an odd 20ft total: a customs instance can share
// the wagon via the manual pair (consolidationActive), and anything else is
// auto-paired or parked as PENDING_CONSOLIDATION by the server's
// consolidation gate. Creating a booking from scratch keeps the block.
const oddBlocksSubmit = hasOdd20ft && !completeBookingId;
// Partner side: a linked partner must be picked, carry an odd 20ft count of
// its own (odd + odd = even fills the wagon) and have complete unit details.
@@ -1027,8 +1023,21 @@ export default function GlCreateBookingForm() {
partnerCargoDescription,
]);
// Only IMPORT actually chooses — the rest bill ETB regardless of the state.
const effectiveCurrency: "USD" | "ETB" =
isImport && paymentCurrency ? paymentCurrency : "ETB";
const currencyError =
isImport && !paymentCurrency
? "Select the billing currency for this booking."
: undefined;
const formValid =
cargoValid && !oddBlocksSubmit && !dateError && !routeError && !partnerError;
cargoValid &&
!oddBlocksSubmit &&
!dateError &&
!routeError &&
!partnerError &&
!currencyError;
/** The create-booking DTO from the current form state — shared by the
* authoritative price preview and the actual submit so what GL confirms is
@@ -1038,7 +1047,7 @@ export default function GlCreateBookingForm() {
const payload: Freight.CreateBookingUnderContractDto = {
...(contractRouteId ? { contractRouteId } : {}),
paymentCurrency,
paymentCurrency: effectiveCurrency,
// Intercity bookings carry no date — staff assign a passing train later.
...(scheduledDate
? { scheduledDate: new Date(scheduledDate).toISOString() }
@@ -1105,7 +1114,7 @@ export default function GlCreateBookingForm() {
if (!partner || !consolidationActive) return null;
const payload: Freight.CreateBookingUnderContractDto = {
paymentCurrency,
paymentCurrency: effectiveCurrency,
...(scheduledDate
? { scheduledDate: new Date(scheduledDate).toISOString() }
: {}),
@@ -2143,10 +2152,11 @@ export default function GlCreateBookingForm() {
: "Shipments are invoiced in ETB."}
</Text>
<CurrencySelector
value={isIntercity ? "ETB" : paymentCurrency}
value={isImport ? paymentCurrency : "ETB"}
onChange={setPaymentCurrency}
disabled={isIntercity}
disabled={!isImport}
allowUsd={isImport}
error={currencyError}
/>
</Box>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -128,6 +128,8 @@ export const URL_CONSTANTS = {
CONTRACT_DOCUMENT: (id: string) => `/api/bookings/${id}/contract/document`,
CONTRACT_SIGN: (id: string) => `/api/bookings/${id}/contract/sign`,
CONTRACT_DOWNLOAD: (id: string) => `/api/bookings/${id}/contract`,
CARRIAGE_ACCEPTANCE_SHEET: (id: string) =>
`/api/bookings/${id}/carriage-acceptance-sheet`,
CANCEL: (id: string | number) => `/api/bookings/${id}/cancel`,
CONFIRM: (id: string | number) => `/api/bookings/${id}/confirm`,
CUSTOMER_TRUCKS: (id: string) => `/api/bookings/${id}/customer-trucks`,

View File

@@ -2,8 +2,8 @@ import { Box, Group, Stack, Text } from "@mantine/core";
import { memo } from "react";
import { ACTION_PROPS, STATUS_CONFIG, cv } from "../constants";
import { Stepper } from "./Stepper";
import { PayNowButton } from "@/pages/bookings/payments/PayNowButton";
import { payWindowState } from "@/pages/bookings/payments/payment-drain";
import { PayButton } from "@/pages/bookings/payments/PayButton";
import { useMyPayables } from "@/pages/bookings/payments/useMyPayables";
import { BookingActionButton } from "@/pages/bookings/clearance/BookingActionButton";
import { bookingHasInlineAction } from "@/pages/bookings/clearance/bookingNextAction";
import {
@@ -28,21 +28,9 @@ export const BookingRow = memo(function BookingRow({
const Icon = cfg.icon;
const AIcon = cfg.action.icon;
const ap = ACTION_PROPS[cfg.action.kind];
// Payable bookings get an inline "Pay now" that opens the payment modal
// instead of navigating to the detail page. A general contract is payable as
// soon as it's FULLY_EXECUTED (signed); a one-time booking only after it's
// SELECTED_FOR_BATCH — same rule as the bookings list's PrimaryAction.
const payableStatus =
booking.bookingType === "GENERAL_CONTRACT"
? "FULLY_EXECUTED"
: "SELECTED_FOR_BATCH";
// A fully-closed pay window (deadline + drain both elapsed) has nothing to pay
// against, so the row falls back to its normal action instead of an empty slot.
// The drain itself still routes here — PayNowButton renders the wait notice.
const canPay =
booking.status === payableStatus &&
booking.paymentStatus !== "PAID" &&
payWindowState(booking).phase !== "closed";
// Anything outstanding (freight, clearance charge, duty slip, cancellation
// fee) → "Pay" jumps to the booking's Payments tab. One shared query.
const payable = useMyPayables().get(booking.id);
// Clearance/operation steps + changes-requested resubmit can be done in place
// via a modal on the row.
const hasInlineAction = bookingHasInlineAction(booking);
@@ -113,8 +101,8 @@ export const BookingRow = memo(function BookingRow({
{cfg.badgeLabel}
</Text>
</Group>
{canPay ? (
<PayNowButton booking={booking} size="sm" />
{payable ? (
<PayButton bookingId={booking.id} summary={payable} size="sm" />
) : canSign ? (
<ContractSignButton booking={booking} size="sm" />
) : canApproveDelivery ? (

View File

@@ -1,11 +1,11 @@
import { useState } from "react";
import {
useState,
} from "react";
import {
Alert,
Anchor,
Badge,
Box,
Button,
FileInput,
Group,
Paper,
Stack,
@@ -15,50 +15,28 @@ import {
import {
AlertTriangle,
Check,
Download,
Eye,
FileBadge,
MessageSquareWarning,
Receipt,
Upload,
} from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import {
useQuery,
} from "@tanstack/react-query";
import toast from "react-hot-toast";
import type { Freight } from "@edr/types";
import { bookingsService } from "@/services/bookings.service";
import { contractsService } from "@/services/contracts.service";
import {
bookingsService,
} from "@/services/bookings.service";
import { downloadStoredFile } from "@/services/files.service";
import { ClearancePhaseStepper } from "../contracts/ClearancePhaseStepper";
import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/ClearanceUploadedDocumentsPanel";
import { useFileViewer } from "@/hooks/useFileViewer";
import { GREEN, INK } from "../contracts/contract-ui";
import { INK } from "../contracts/contract-ui";
const BORDER = "#E6ECF2";
// Customer-facing labels for an invoice status (Freight.InvoiceStatus).
const INVOICE_STATUS_LABELS: Record<string, string> = {
DRAFT: "Draft",
ISSUED: "Issued",
PENDING: "Due",
PAYMENT_PROCESSING: "Payment processing",
PARTIALLY_PAID: "Partially paid",
PAID: "Paid",
OVERDUE: "Overdue",
CANCELLED: "Cancelled",
REFUNDED: "Refunded",
EXPIRED: "Expired",
};
function invoiceStatusLabel(status: string): string {
return (
INVOICE_STATUS_LABELS[status] ??
status
.replace(/_/g, " ")
.toLowerCase()
.replace(/\b\w/g, (m) => m.toUpperCase())
);
}
@@ -81,13 +59,9 @@ export function BookingClearanceWorkflowBanner({
if (!isPhased || !clearance) return null;
const dutyPaid = clearance.milestones?.some(
(m) => m.milestoneCode === "DUTY_TAX_PAID" && m.status === "COMPLETED",
);
const dutyPending =
clearance.dutyRequired &&
clearance.dutyAdvice &&
!dutyPaid;
// Duty / tax, additional duty and the final invoice are paid from the
// booking's Payments tab (CustomsPaymentsCard); this banner keeps the
// progress, the draft declaration and the documents.
// A change request clears the draft while it's open — show the "waiting on
// GL" state instead of the review panel until GL sends a corrected draft.
const draftDeclarationChangeRequestPending = Boolean(
@@ -131,14 +105,6 @@ export function BookingClearanceWorkflowBanner({
/>
) : null}
{dutyPending && clearance.dutyAdvice ? (
<DutyAdvicePanel
dutyAdvice={clearance.dutyAdvice}
bookingId={booking.id}
onChanged={() => void refetch()}
/>
) : null}
{clearance.riskLevel ? (
<Group gap={10} align="center">
<Text fw={700} fz={14} c={INK}>
@@ -160,24 +126,6 @@ export function BookingClearanceWorkflowBanner({
</Group>
) : null}
{clearance.secondDuty?.advised ? (
<SecondDutyDueCard
duty={clearance.secondDuty}
bookingId={booking.id}
onView={(f) => view(f)}
onChanged={() => void refetch()}
/>
) : null}
{clearance.finalInvoice ? (
<FinalInvoiceDueCard
invoice={clearance.finalInvoice}
bookingId={booking.id}
onView={(f) => view(f)}
onChanged={() => void refetch()}
/>
) : null}
{clearance.operationReady ? (
<Alert color="green" variant="light">
Clearance is complete. You may proceed to request your operation date.
@@ -197,76 +145,6 @@ export function BookingClearanceWorkflowBanner({
);
}
function DutyAdvicePanel({
dutyAdvice,
bookingId,
onChanged,
}: {
dutyAdvice: NonNullable<Freight.ClearanceView["dutyAdvice"]>;
bookingId: string;
onChanged: () => void;
}) {
const [file, setFile] = useState<File | null>(null);
const [loading, setLoading] = useState(false);
const noticeFile = dutyAdvice.noticeFile;
return (
<Paper withBorder radius="md" p="md" bg="#FFFBF0">
<Stack gap="sm">
<GroupLabel icon={Receipt} text="Duty / tax payment" />
<Text size="sm">
Amount due:{" "}
<strong>
{dutyAdvice.amount.toLocaleString()} {dutyAdvice.currency}
</strong>
{dutyAdvice.declarationSerial
? ` · Payment code: ${dutyAdvice.declarationSerial}`
: null}
</Text>
{noticeFile ? (
<Anchor
component="button"
type="button"
onClick={() => void downloadStoredFile(noticeFile.id, noticeFile.name)}
size="sm"
>
<Group gap={6} wrap="nowrap">
<Download size={14} />
Download duty notice ({noticeFile.name})
</Group>
</Anchor>
) : null}
<Text size="sm" c="dimmed">
Pay the amount above, then upload your payment slip so clearance can continue.
</Text>
<FileInput label="Payment slip" value={file} onChange={setFile} size="sm" />
<Button
color="orange"
loading={loading}
disabled={!file}
leftSection={<Upload size={16} />}
onClick={async () => {
if (!file) return;
setLoading(true);
try {
await bookingsService.uploadBookingClearanceDutySlip(bookingId, file);
toast.success("Payment slip uploaded");
onChanged();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Upload failed");
} finally {
setLoading(false);
}
}}
>
Submit payment slip
</Button>
</Stack>
</Paper>
);
}
/**
* GL Ethiopia sent a draft customs declaration — an estimated price + files
* the customer must accept before the real declaration is filed, or send back
@@ -446,328 +324,8 @@ function GroupLabel({ icon: Icon, text }: { icon: typeof Receipt; text: string }
);
}
/**
* Post-offload final invoice from GL Djibouti (export): shows the due amount +
* invoice document; the customer pays offline and attaches the payment slip
* here, then GL confirms and the badge flips to PAID.
*/
function FinalInvoiceDueCard({
invoice,
bookingId,
onView,
onChanged,
}: {
invoice: NonNullable<Freight.ClearanceView["finalInvoice"]>;
bookingId: string;
onView: (file: { name: string; url: string }) => void;
onChanged: () => void;
}) {
const [slip, setSlip] = useState<File | null>(null);
const [uploading, setUploading] = useState(false);
const [approving, setApproving] = useState(false);
const paid = invoice.status === "PAID";
// GL Djibouti raises it as a draft: nothing is payable until the customer
// reviews the attached invoice and approves it.
const approved = Boolean(invoice.approvedAt);
return (
<Paper
withBorder
radius="lg"
p="lg"
style={{
borderColor: paid ? "#CDEBDD" : "#F2D9A6",
background: paid ? "#F6FBF8" : "#FFFBF2",
position: "relative",
overflow: "hidden",
}}
>
<Box
style={{
position: "absolute",
left: 0,
top: 0,
bottom: 0,
width: 3,
background: paid ? GREEN : "#E3A93C",
}}
/>
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
<div>
<Group gap={8} align="center">
<FileBadge size={16} color={paid ? GREEN : "#B07C1F"} />
<Text fw={700} fz={15} c={INK}>
{paid
? "Final invoice paid"
: approved
? "Final invoice due"
: "Final invoice — your approval needed"}{" "}
{invoice.invoiceNumber}
</Text>
<Badge color={paid ? "edr-green" : "yellow"} variant="light" radius="sm">
{approved
? invoiceStatusLabel(invoice.status)
: "Awaiting your approval"}
</Badge>
</Group>
<Text fz={20} fw={800} mt={6} c={INK}>
{invoice.totalAmount.toLocaleString()} {invoice.currency}
</Text>
{invoice.description ? (
<Text fz={13} c="dimmed" mt={2}>
{invoice.description}
</Text>
) : null}
{!paid ? (
<Text fz={13} c="#9A6B1F" mt={6}>
{approved
? "Pay the amount above and attach your payment slip — Global Logistics will confirm the payment."
: "Review the invoice document from Global Logistics Djibouti and approve it to proceed with payment."}
</Text>
) : null}
</div>
<Stack gap="xs" miw={260}>
{invoice.invoiceFile ? (
<Button
variant="default"
radius="md"
size="sm"
leftSection={<Eye size={15} />}
onClick={() =>
onView({
name: invoice.invoiceFile!.name,
url: invoice.invoiceFile!.url,
})
}
>
View invoice
</Button>
) : null}
{invoice.slipFile ? (
<Button
variant="default"
radius="md"
size="sm"
leftSection={<Eye size={15} />}
onClick={() =>
onView({
name: invoice.slipFile!.name,
url: invoice.slipFile!.url,
})
}
>
View payment slip
</Button>
) : null}
{!paid && !approved ? (
<Button
color="edr-green"
radius="md"
size="sm"
loading={approving}
leftSection={<Check size={15} />}
onClick={async () => {
setApproving(true);
try {
await contractsService.approveFinalInvoice(bookingId);
toast.success("Invoice approved — you can now pay");
onChanged();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Approval failed");
} finally {
setApproving(false);
}
}}
>
Approve invoice
</Button>
) : null}
{!paid && approved ? (
<>
<FileInput
placeholder={
invoice.slipFile ? "Replace payment slip" : "Attach payment slip"
}
value={slip}
onChange={setSlip}
size="sm"
radius="md"
/>
<Button
color="edr-green"
radius="md"
size="sm"
loading={uploading}
disabled={!slip}
leftSection={<Upload size={15} />}
onClick={async () => {
if (!slip) return;
setUploading(true);
try {
await contractsService.uploadFinalInvoiceSlip(bookingId, slip);
setSlip(null);
toast.success("Payment slip attached");
onChanged();
} catch (e) {
toast.error(
e instanceof Error ? e.message : "Upload failed",
);
} finally {
setUploading(false);
}
}}
>
{invoice.slipFile ? "Replace slip" : "Submit payment slip"}
</Button>
</>
) : null}
</Stack>
</Group>
</Paper>
);
}
const CUSTOMS_RISK_COLOR: Record<string, string> = {
GREEN: "green",
YELLOW: "yellow",
RED: "red",
};
/**
* Post-arrival additional duty/tax round (import): GL advises an extra amount
* with a notice; the customer pays offline and attaches another slip here.
*/
function SecondDutyDueCard({
duty,
bookingId,
onView,
onChanged,
}: {
duty: NonNullable<Freight.ClearanceView["secondDuty"]>;
bookingId: string;
onView: (file: { name: string; url: string }) => void;
onChanged: () => void;
}) {
const [slip, setSlip] = useState<File | null>(null);
const [uploading, setUploading] = useState(false);
const paid = duty.paid;
return (
<Paper
withBorder
radius="lg"
p="lg"
style={{
borderColor: paid ? "#CDEBDD" : "#F2D9A6",
background: paid ? "#F6FBF8" : "#FFFBF2",
position: "relative",
overflow: "hidden",
}}
>
<Box
style={{
position: "absolute",
left: 0,
top: 0,
bottom: 0,
width: 3,
background: paid ? GREEN : "#E3A93C",
}}
/>
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
<div>
<Group gap={8} align="center">
<FileBadge size={16} color={paid ? GREEN : "#B07C1F"} />
<Text fw={700} fz={15} c={INK}>
{paid ? "Additional duty & tax paid" : "Additional duty & tax due"}
</Text>
<Badge color={paid ? "edr-green" : "yellow"} variant="light" radius="sm">
{paid ? "PAID" : "DUE"}
</Badge>
</Group>
<Text fz={20} fw={800} mt={6} c={INK}>
{(duty.amount ?? 0).toLocaleString()} {duty.currency ?? ""}
</Text>
{duty.declarationSerial ? (
<Text fz={13} c="dimmed" mt={2}>
Payment code: {duty.declarationSerial}
</Text>
) : null}
{!paid ? (
<Text fz={13} c="#9A6B1F" mt={6}>
Customs advised additional duty/tax after arrival. Pay the amount
above and attach your payment slip.
</Text>
) : null}
</div>
<Stack gap="xs" miw={260}>
{duty.noticeFile ? (
<Button
variant="default"
radius="md"
size="sm"
leftSection={<Eye size={15} />}
onClick={() =>
onView({ name: duty.noticeFile!.name, url: duty.noticeFile!.url })
}
>
View duty notice
</Button>
) : null}
{duty.slipFile ? (
<Button
variant="default"
radius="md"
size="sm"
leftSection={<Eye size={15} />}
onClick={() =>
onView({ name: duty.slipFile!.name, url: duty.slipFile!.url })
}
>
View payment slip
</Button>
) : null}
{!paid ? (
<>
<FileInput
placeholder={duty.slipFile ? "Replace payment slip" : "Attach payment slip"}
value={slip}
onChange={setSlip}
size="sm"
radius="md"
/>
<Button
color="edr-green"
radius="md"
size="sm"
loading={uploading}
disabled={!slip}
leftSection={<Upload size={15} />}
onClick={async () => {
if (!slip) return;
setUploading(true);
try {
await contractsService.uploadSecondDutySlip(bookingId, slip);
setSlip(null);
toast.success("Payment slip attached");
onChanged();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Upload failed");
} finally {
setUploading(false);
}
}}
>
{duty.slipFile ? "Replace slip" : "Submit payment slip"}
</Button>
</>
) : null}
</Stack>
</Group>
</Paper>
);
}

View File

@@ -1,5 +1,5 @@
import { Button, Group, Modal, Stack, Tabs, Text } from "@mantine/core";
import { useMutation } from "@tanstack/react-query";
import { Button, Group, Modal, Skeleton, Stack, Tabs, Text } from "@mantine/core";
import { useMutation, useQuery } from "@tanstack/react-query";
import {
Clock,
CreditCard,
@@ -8,11 +8,12 @@ import {
Package,
TrainFront,
Truck,
Wallet,
XCircle,
} from "lucide-react";
import { useState } from "react";
import toast from "react-hot-toast";
import { useNavigate } from "react-router-dom";
import { useNavigate, useSearchParams } from "react-router-dom";
import { useFileViewer } from "@/hooks/useFileViewer";
import { bookingsService } from "@/services/bookings.service";
@@ -39,21 +40,22 @@ import {
ConsolidationWaitingBanner,
} from "./components/Notices";
import { BookingPaymentPanel } from "./components/BookingPaymentPanel";
import { AdditionalChargesPanel } from "./components/AdditionalChargesPanel";
import { HeaderButton, PageHeader } from "./components/PageHeader";
import { PaymentMethodModal } from "./components/PaymentMethodModal";
import { ScheduleCard } from "./components/ScheduleCard";
import { WarehousePaymentsSection } from "./components/WarehousePaymentsSection";
import { PaymentsDueStrip, PaymentsTab } from "./components/PaymentsTab";
import { WarehouseLocationCard } from "./components/WarehouseLocationCard";
import { ShipmentDetailsCard } from "./components/ShipmentDetailsCard";
import { ShipmentTrackingCard } from "./components/ShipmentTrackingCard";
import { StatusHero } from "./components/StatusHero";
import { SupportCard } from "./components/SupportCard";
import { WagonCancellationCard } from "./components/WagonCancellationCard";
import { WagonsTab } from "./components/WagonsTab";
import { fmtDate, isNegative, priceTotal } from "./utils";
import { useScrollToHash } from "@/hooks/useScrollToHash";
import { useBookingPayment } from "@/pages/bookings/payments/useBookingPayment";
import { isUsdOfflineBooking } from "@/pages/bookings/payments/offline-payment";
import { useBookingPayables } from "@/pages/bookings/payments/useBookingPayables";
// Pre-payment statuses the customer may self-cancel from this view (free of
// charge). DRAFT / CHANGES_REQUESTED render their own views and drafts can
@@ -65,6 +67,8 @@ const CUSTOMER_CANCELLABLE_STATUSES = [
"CONTRACT_READY",
"OPERATION_REQUEST_PENDING",
"SELECTED_FOR_BATCH",
// Parked waiting for a consolidation partner — nothing reserved yet.
"PENDING_CONSOLIDATION",
];
const cancelErrorMessage = (error: unknown) => {
@@ -86,6 +90,22 @@ export function ReadonlyBookingView({
const navigate = useNavigate();
// Deep-link support: e.g. /bookings/:id#warehouse-payments from an invoice.
useScrollToHash();
// Active tab lives in the URL (?tab=payments) so list/home "Pay" buttons and
// notifications can land straight on the Payments tab.
const [searchParams, setSearchParams] = useSearchParams();
const tab = searchParams.get("tab") ?? "overview";
const setTab = (next: string | null) =>
setSearchParams(
(prev) => {
if (!next || next === "overview") prev.delete("tab");
else prev.set("tab", next);
return prev;
},
{ replace: true },
);
// Everything the customer owes or must decide on — drives the header "Pay"
// button, the tab badge and the overview strip.
const payables = useBookingPayables(booking);
const status = booking.status as string;
const { viewer } = useFileViewer();
@@ -118,6 +138,32 @@ export function ReadonlyBookingView({
const canCancel =
booking.paymentStatus !== "PAID" &&
CUSTOMER_CANCELLABLE_STATUSES.includes(status);
// PAID booking (allocated or not): the same button cancels the WHOLE booking
// through wagon cancellation — a per-wagon fee is invoiced and the paid
// freight becomes a rebooking credit. Blocked once loading starts (server
// enforces; loading flips status past PAID/TRUCK_ASSIGNED).
const canCancelPaid =
booking.paymentStatus === "PAID" &&
["PAID", "TRUCK_ASSIGNED"].includes(status) &&
Boolean(booking.contractId);
const [paidCancelOpen, setPaidCancelOpen] = useState(false);
const paidPreview = useQuery({
queryKey: ["whole-cancel-preview", booking.id],
queryFn: () => bookingsService.previewWagonCancellation(booking.id, {}),
enabled: paidCancelOpen,
});
const paidCancelMutation = useMutation({
mutationFn: () => bookingsService.requestWagonCancellation(booking.id, {}),
onSuccess: () => {
setPaidCancelOpen(false);
toast.success(
"Cancellation requested — pay the cancellation fee to settle it. Your paid freight is kept as credit for rebooking.",
{ duration: 8000 },
);
onBookingUpdated?.();
},
onError: (e) => toast.error(cancelErrorMessage(e)),
});
const pricing = booking.pricingBreakdown;
// A general contract is paid once it's FULLY_EXECUTED (signed) — it never
@@ -186,17 +232,20 @@ export function ReadonlyBookingView({
<PageHeader
booking={booking}
actions={
(canApproveDelivery || (canPay && !showCountdown) || canCancel) && (
(canApproveDelivery ||
(payables.items.length > 0 && tab !== "payments") ||
canCancel ||
canCancelPaid) && (
<Group gap={8} wrap="nowrap">
{canApproveDelivery && (
<ApproveDeliveryButton bookingId={booking.id} />
)}
{canPay && !showCountdown && !isUsdOfflineBooking(booking) && (
{payables.items.length > 0 && tab !== "payments" && (
<HeaderButton
green
icon={<CreditCard size={16} />}
label="Pay now"
onClick={pay.open}
label="Pay"
onClick={() => setTab("payments")}
/>
)}
{canCancel && (
@@ -207,6 +256,14 @@ export function ReadonlyBookingView({
onClick={() => setCancelOpen(true)}
/>
)}
{canCancelPaid && (
<HeaderButton
red
icon={<XCircle size={16} />}
label="Cancel booking"
onClick={() => setPaidCancelOpen(true)}
/>
)}
</Group>
)
}
@@ -259,7 +316,8 @@ export function ReadonlyBookingView({
<KeyFactsStrip booking={booking} />
<Tabs
defaultValue="overview"
value={tab}
onChange={setTab}
keepMounted={false}
color="edr-green"
styles={{
@@ -276,6 +334,32 @@ export function ReadonlyBookingView({
<Tabs.Tab value="overview" leftSection={<LayoutGrid size={15} />}>
Overview
</Tabs.Tab>
<Tabs.Tab
value="payments"
leftSection={<Wallet size={15} />}
rightSection={
payables.items.length > 0 ? (
<Text
component="span"
fz={11}
fw={800}
c="white"
px={6}
style={{
borderRadius: 999,
background: "#B45309",
lineHeight: "18px",
minWidth: 18,
textAlign: "center",
}}
>
{payables.items.length}
</Text>
) : undefined
}
>
Payments
</Tabs.Tab>
<Tabs.Tab value="cargo" leftSection={<Package size={15} />}>
Cargo
</Tabs.Tab>
@@ -297,12 +381,16 @@ export function ReadonlyBookingView({
<Tabs.Panel value="overview">
<div className="flex flex-col gap-6">
{/* The only payment surface on Overview — everything payable lives
on the Payments tab. Hidden when nothing is outstanding. */}
<PaymentsDueStrip booking={booking} onOpen={() => setTab("payments")} />
<ContractCard booking={booking} />
{isClearance && <ClearanceCard booking={booking} />}
{/* Customs (Path B) shipments: GL's phased progress, the duty /
additional-duty payments and the final invoice — all per booking. */}
{/* Customs (Path B) shipments: GL's phased progress, draft
declaration and documents. Its payments moved to the Payments tab. */}
<BookingClearanceWorkflowBanner booking={booking} />
<BodyGrid
@@ -326,16 +414,12 @@ export function ReadonlyBookingView({
paying={pay.processing}
showCountdown={showCountdown}
/>
<AdditionalChargesPanel bookingId={booking.id} />
<ScheduleCard
booking={booking}
title="Consignment & Schedule"
consignment
/>
{/* Renders only on PAID + paid + contract-backed bookings. */}
<WagonCancellationCard
booking={booking}
onBookingUpdated={onBookingUpdated}
/>
<CompanyInfoCard booking={booking} />
<SupportCard />
</>
@@ -344,6 +428,15 @@ export function ReadonlyBookingView({
</div>
</Tabs.Panel>
<Tabs.Panel value="payments">
<PaymentsTab
booking={booking}
pay={pay}
showCountdown={showCountdown}
onBookingUpdated={onBookingUpdated}
/>
</Tabs.Panel>
<Tabs.Panel value="cargo">
<CargoTab booking={booking} />
</Tabs.Panel>
@@ -358,6 +451,7 @@ export function ReadonlyBookingView({
booking.paymentStatus === "PAID" &&
Boolean(booking.contractId)
}
consolidated={Boolean(booking.consolidationPartnerId)}
onCancellationRequested={onBookingUpdated}
/>
</Tabs.Panel>
@@ -453,6 +547,90 @@ export function ReadonlyBookingView({
</Group>
</Stack>
</Modal>
<Modal
opened={paidCancelOpen}
onClose={() => setPaidCancelOpen(false)}
title={
<Text fw={800} fz={18} c="#10202F">
Cancel this booking?
</Text>
}
centered
radius={16}
>
<Stack gap="md">
<Text size="sm" c="#475569">
You&apos;re about to cancel the whole booking{" "}
<Text span fw={700} c="#10202F">
{booking.reference}
</Text>
. A cancellation fee applies per wagon; your paid freight is kept as
a credit you can rebook with once the fee is settled.
{booking.consolidationPartnerId
? " This booking shares a wagon with another customer — both bookings will be cancelled, and the shared wagon's fee is charged to you, not to them."
: ""}{" "}
<Text span fw={700} c="#B3362C">
This cannot be undone from the portal only EDR staff can revert
a cancellation request.
</Text>
</Text>
{paidPreview.isLoading && <Skeleton height={64} radius={10} />}
{paidPreview.data && (
<Stack
gap={4}
p={12}
style={{
borderRadius: 10,
backgroundColor: "#FFFBEB",
border: "1px solid #FDE68A",
}}
>
<Text fz={13} c="#92400E">
Wagons cancelled: <b>{paidPreview.data.wagons}</b>
</Text>
<Text fz={13} c="#92400E">
Cancellation fee:{" "}
<b>
{Number(paidPreview.data.feeAmount).toLocaleString()}{" "}
{paidPreview.data.feeCurrency}
</b>{" "}
({Number(paidPreview.data.feePerWagon).toLocaleString()} per
wagon)
</Text>
<Text fz={13} c="#92400E">
Rebooking credit:{" "}
<b>
{Number(paidPreview.data.creditAmount).toLocaleString()}{" "}
{booking.paymentCurrency}
</b>
</Text>
</Stack>
)}
{paidPreview.isError && (
<Text fz={13} c="#B3362C">
{cancelErrorMessage(paidPreview.error)}
</Text>
)}
<Group justify="flex-end" gap={8}>
<Button
variant="default"
radius={10}
onClick={() => setPaidCancelOpen(false)}
>
Keep booking
</Button>
<Button
color="red"
radius={10}
disabled={!paidPreview.data}
loading={paidCancelMutation.isPending}
onClick={() => paidCancelMutation.mutate()}
>
Cancel booking &amp; issue fee
</Button>
</Group>
</Stack>
</Modal>
{viewer}
</PageShell>
);

View File

@@ -0,0 +1,137 @@
import { useState } from "react";
import { Badge, Box, Button, Group, Stack, Text } from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { CreditCard } from "lucide-react";
import type { Freight } from "@edr/types";
import { bookingsService } from "@/services/bookings.service";
import { useInvoicePayment } from "@/hooks/useInvoicePayment";
import { ModalSafeWrapper } from "@/components/customer-actions/ModalSafeWrapper";
import { PaymentMethodModal } from "./PaymentMethodModal";
import { CardTitle, SectionCard } from "./layout";
const STATUS_META: Record<Freight.AdditionalChargeStatus, { label: string; color: string }> = {
DRAFT: { label: "Draft", color: "gray" },
SENT: { label: "Awaiting payment", color: "#B07D14" },
PAID: { label: "Paid", color: "#0A6F4D" },
CANCELLED: { label: "Cancelled", color: "red" },
};
/**
* Ad-hoc extra charges EDR has raised against this booking — separate from the
* freight invoice on `BookingPaymentPanel`. Only ever shows charges already
* SENT (or settled) — a DRAFT charge isn't visible to the customer yet.
*/
export function AdditionalChargesPanel({ bookingId }: { bookingId: string }) {
const { data: charges = [] } = useQuery({
queryKey: ["additional-charges", bookingId],
queryFn: () => bookingsService.getAdditionalCharges(bookingId),
});
const visible = charges.filter((c) => c.status !== "DRAFT");
if (visible.length === 0) return null;
return (
<SectionCard p={22}>
<CardTitle>Additional charges</CardTitle>
<Stack gap={12} mt={12}>
{visible.map((charge) => (
<ChargeRow key={charge.id} charge={charge} />
))}
</Stack>
</SectionCard>
);
}
function ChargeRow({ charge }: { charge: Freight.AdditionalCharge }) {
const meta = STATUS_META[charge.status];
return (
<Box
p={14}
style={{ borderRadius: 10, border: "1px solid #EEF2F6" }}
>
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Box style={{ minWidth: 0 }}>
<Text fz="13.5px" fw={700} c="#10202F">
{charge.reason}
</Text>
<Text fz="12px" c="#9AA8B5" mt={2}>
{charge.amount.toLocaleString(undefined, { minimumFractionDigits: 2 })}{" "}
{charge.currency}
{charge.convertedAmount != null
? ` (≈ ${charge.convertedAmount.toLocaleString(undefined, { minimumFractionDigits: 2 })} ${charge.convertedCurrency})`
: ""}
{charge.paymentReference ? ` · ref ${charge.paymentReference}` : ""}
</Text>
{charge.dueAt && charge.status === "SENT" && (
<Text fz="12px" c="#9AA8B5">
Due {new Date(charge.dueAt).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" })}
</Text>
)}
</Box>
<Badge
radius="sm"
variant="light"
styles={{ root: { backgroundColor: `${meta.color}22`, color: meta.color } }}
>
{meta.label}
</Badge>
</Group>
{charge.status === "SENT" && charge.invoiceId && (
<ChargePayButton invoiceId={charge.invoiceId} amount={charge.amount} currency={charge.currency} />
)}
</Box>
);
}
function ChargePayButton({
invoiceId,
amount,
currency,
}: {
invoiceId: string;
amount: number;
currency: string;
}) {
const [modalOpen, setModalOpen] = useState(false);
const flow = useInvoicePayment();
const close = () => {
if (!flow.processing) {
setModalOpen(false);
flow.reset();
}
};
return (
<ModalSafeWrapper>
<Button
mt={10}
size="xs"
radius="md"
fw={700}
color="edr-green"
leftSection={<CreditCard size={14} />}
onClick={(e) => {
e.stopPropagation();
setModalOpen(true);
}}
>
Pay now
</Button>
<PaymentMethodModal
opened={modalOpen}
onClose={close}
amountLabel={`${amount.toLocaleString(undefined, { minimumFractionDigits: 2 })} ${currency}`}
currency={currency}
processing={flow.processing}
error={flow.error}
otp={flow.otp}
bill={flow.bill}
onConfirm={(method, payerAccount) => flow.pay(invoiceId, method, payerAccount)}
/>
</ModalSafeWrapper>
);
}

View File

@@ -252,9 +252,9 @@ export function BookingPaymentPanel({
};
return (
<SectionCard p={22}>
<SectionCard id="freight-payment" p={22}>
<Group justify="space-between" align="center">
<CardTitle>Payment</CardTitle>
<CardTitle>Freight payment</CardTitle>
<Group
component="span"
gap={6}

View File

@@ -1,16 +1,19 @@
import { useState } from "react";
import { Alert, Button, Group, Text } from "@mantine/core";
import { Alert, Button, Group, Stack, Text } from "@mantine/core";
import {
CheckCircle2,
ClipboardList,
Clock,
FilePlus2,
PackagePlus,
Upload,
} from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import type { Freight } from "@edr/types";
import { bookingsService } from "@/services/bookings.service";
import { BookingActionModal } from "@/pages/bookings/clearance/BookingActionModal";
import {
bookingDocNoun,
@@ -29,7 +32,26 @@ import { CardTitle, SectionCard } from "./layout";
*/
export function ClearanceCard({ booking }: { booking: Freight.IBooking }) {
const [modalOpen, setModalOpen] = useState(false);
// Opening straight onto a blank "Additional documents" row, so answering a
// GL request is one click rather than a hunt down the document grid.
const [openWithAdHoc, setOpenWithAdHoc] = useState(false);
const navigate = useNavigate();
// What Global Logistics asked this customer for, if anything. Shares the
// clearance query key with the modal, so this costs no extra request.
const { data: clearance } = useQuery({
queryKey: ["booking-clearance", booking.id],
queryFn: () => bookingsService.getClearance(booking.id),
});
const docRequests = clearance?.docRequests ?? [];
const latestRequest = docRequests[0] ?? null;
const openAdHoc = () => {
setOpenWithAdHoc(true);
setModalOpen(true);
};
const closeModal = () => {
setModalOpen(false);
setOpenWithAdHoc(false);
};
const status = booking.status as string;
const action = getBookingNextAction(booking);
// Self-clearance services collect the customer's own import/export paperwork,
@@ -105,6 +127,43 @@ export function ClearanceCard({ booking }: { booking: Freight.IBooking }) {
)}
</Group>
{/* GL asked for something — red, above the fold, with the ask in their
own words and a one-click way to answer it. */}
{latestRequest && (
<Alert
color="red"
variant="light"
radius="md"
mb="sm"
icon={<FilePlus2 size={18} />}
title="Global Logistics needs a document from you"
>
<Stack gap={8}>
<Text fz="13px" c="#10202F" style={{ whiteSpace: "pre-wrap" }}>
{latestRequest.note}
</Text>
<Text fz="11.5px" c="dimmed">
{latestRequest.byName ?? "Global Logistics"} ·{" "}
{new Date(latestRequest.at).toLocaleString()}
{docRequests.length > 1
? ` · ${docRequests.length} requests in total`
: ""}
</Text>
<Group>
<Button
size="compact-sm"
color="red"
radius="md"
leftSection={<FilePlus2 size={14} />}
onClick={openAdHoc}
>
Add document
</Button>
</Group>
</Stack>
</Alert>
)}
{summary}
<Text fz="12.5px" c="dimmed" mt="sm">
@@ -119,7 +178,8 @@ export function ClearanceCard({ booking }: { booking: Freight.IBooking }) {
<BookingActionModal
booking={booking}
opened={modalOpen}
onClose={() => setModalOpen(false)}
startWithAdHocRow={openWithAdHoc}
onClose={closeModal}
/>
)}
</SectionCard>

View File

@@ -0,0 +1,289 @@
import { useState } from "react";
import { Alert, Box, Button, Group, Stack, Text, Textarea } from "@mantine/core";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, CreditCard, Download, Eye, FileText, Receipt, X } from "lucide-react";
import { Link } from "react-router-dom";
import toast from "react-hot-toast";
import type { Freight } from "@edr/types";
import { isViewable } from "@edr/ui-common";
import { useFileViewer } from "@/hooks/useFileViewer";
import { useInvoicePayment } from "@/hooks/useInvoicePayment";
import { bookingsService } from "@/services/bookings.service";
import { downloadStoredFile, fetchViewableFile } from "@/services/files.service";
import { formatAmount } from "../utils";
import { IconSquare } from "./Documents";
import { PaymentMethodModal } from "./PaymentMethodModal";
import { CardTitle, SectionCard } from "./layout";
const LABEL: Record<Freight.ClearanceChargeType, string> = {
PORT_CHARGES: "Port charges",
MISCELLANEOUS: "Miscellaneous charge",
};
const STATUS: Record<
Freight.ClearanceChargeStatus,
{ label: string; bg: string; fg: string }
> = {
DOC_UPLOADED: { label: "DRAFT", bg: "#EEF2F6", fg: "#64748B" },
BILLED: { label: "DRAFT", bg: "#EEF2F6", fg: "#64748B" },
SENT: { label: "NEEDS YOUR APPROVAL", bg: "#FEF3E2", fg: "#B45309" },
REJECTED: { label: "REJECTED", bg: "#FEE2E2", fg: "#B91C1C" },
ACCEPTED: { label: "ACCEPTED — UNPAID", bg: "#E0F2FE", fg: "#0369A1" },
PAID: { label: "PAID", bg: "#E6F7EF", fg: "#0A6F4D" },
};
const money = (c: Freight.ClearanceCharge) =>
`${formatAmount(c.amount)} ${c.currency ?? ""}`;
/**
* Clearance charges Global Logistics proposed for this shipment. The customer
* accepts a price (its invoice is then issued and payable here) or rejects it
* with a note so GL can revise. Renders nothing until GL sends a charge.
*/
export function ClearanceChargesSection({ bookingId }: { bookingId: string }) {
const qc = useQueryClient();
const key = ["booking-clearance-charges", bookingId];
const { data: charges = [] } = useQuery({
queryKey: key,
queryFn: () => bookingsService.getClearanceCharges(bookingId),
});
const [rejecting, setRejecting] = useState<string | null>(null);
const [note, setNote] = useState("");
const [payCharge, setPayCharge] = useState<Freight.ClearanceCharge | null>(null);
const pay = useInvoicePayment();
const { view, viewer } = useFileViewer();
const onError = (e: unknown) =>
toast.error(e instanceof Error ? e.message : "Could not update the charge");
const accept = useMutation({
mutationFn: (chargeId: string) =>
bookingsService.acceptClearanceCharge(bookingId, chargeId),
onSuccess: (next) => {
qc.setQueryData(key, next);
toast.success("Accepted — your invoice is ready to pay");
},
onError,
});
const reject = useMutation({
mutationFn: (p: { chargeId: string; note: string }) =>
bookingsService.rejectClearanceCharge(bookingId, p.chargeId, p.note),
onSuccess: (next) => {
qc.setQueryData(key, next);
setRejecting(null);
setNote("");
toast.success("Sent back to Global Logistics");
},
onError,
});
const busy = accept.isPending || reject.isPending;
if (charges.length === 0) return null;
return (
<SectionCard id="clearance-charges">
<Group justify="space-between" align="center" mb="md">
<CardTitle>Clearance charges</CardTitle>
<Text fz="12.5px" fw={600} c="#9AA8B5">
{charges.length} {charges.length === 1 ? "charge" : "charges"}
</Text>
</Group>
<Stack gap={12}>
{charges.map((c) => {
const st = STATUS[c.status];
return (
<Box
key={c.id}
style={{ border: "1px solid #EEF2F6", borderRadius: 12, padding: "12px 14px" }}
>
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Box style={{ minWidth: 0 }}>
<Group gap={8} wrap="nowrap">
<Text fz="13.5px" fw={700} c="#10202F">
{LABEL[c.type]}
</Text>
<Box
style={{
padding: "3px 9px",
borderRadius: 999,
background: st.bg,
color: st.fg,
fontSize: 11,
fontWeight: 700,
whiteSpace: "nowrap",
}}
>
{st.label}
</Box>
</Group>
{c.description && (
<Text fz="12.5px" c="#6B7C8E" mt={4}>
{c.description}
</Text>
)}
{c.invoiceNumber && c.invoiceId && (
<Text fz="12px" c="#9AA8B5" mt={4}>
Invoice{" "}
<Link to={`/billing/${c.invoiceId}`} style={{ color: "#2E5B96" }}>
{c.invoiceNumber}
</Link>
</Text>
)}
</Box>
<Text fz="14px" fw={800} c="#10202F" style={{ whiteSpace: "nowrap" }}>
{money(c)}
</Text>
</Group>
{/* GL's supporting document (port bill, receipt…) — what the
price is based on, so the customer can check before deciding. */}
{c.file && (
<Group
justify="space-between"
wrap="nowrap"
mt="sm"
style={{ border: "1px solid #EEF2F6", borderRadius: 10, padding: "8px 10px" }}
>
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
<FileText size={16} color="#2E5B96" />
<Text fz="12.5px" c="#10202F" truncate>
{c.file.name}
</Text>
</Group>
<Group gap={6} wrap="nowrap">
{isViewable({ name: c.file.name, url: "" }) && (
<IconSquare
icon={<Eye size={15} />}
onClick={() =>
void fetchViewableFile(c.file!.id, c.file!.name).then(view)
}
/>
)}
<IconSquare
icon={<Download size={15} />}
onClick={() => void downloadStoredFile(c.file!.id, c.file!.name)}
/>
</Group>
</Group>
)}
{c.status === "REJECTED" && c.customerNote && (
<Alert color="red" variant="light" radius="md" p="xs" mt="sm">
<Text fz="12.5px">
You rejected this price: {c.customerNote}. Global Logistics
will revise it and send it again.
</Text>
</Alert>
)}
{c.status === "SENT" &&
(rejecting === c.id ? (
<Stack gap={6} mt="sm">
<Textarea
label="Why are you rejecting this charge?"
placeholder="Tell Global Logistics what is wrong with the price…"
minRows={2}
autosize
maxLength={1000}
value={note}
onChange={(e) => setNote(e.currentTarget.value)}
/>
<Group gap="xs" justify="flex-end">
<Button
variant="default"
size="xs"
disabled={busy}
onClick={() => {
setRejecting(null);
setNote("");
}}
>
Cancel
</Button>
<Button
color="red"
size="xs"
loading={reject.isPending}
disabled={!note.trim()}
onClick={() => reject.mutate({ chargeId: c.id, note: note.trim() })}
>
Submit rejection
</Button>
</Group>
</Stack>
) : (
<Group gap="xs" justify="flex-end" mt="sm">
<Button
variant="default"
size="xs"
radius={10}
leftSection={<X size={14} />}
disabled={busy}
onClick={() => setRejecting(c.id)}
>
Reject
</Button>
<Button
color="edr-green"
size="xs"
radius={10}
leftSection={<Check size={14} />}
loading={accept.isPending}
disabled={busy}
onClick={() => accept.mutate(c.id)}
>
Accept price
</Button>
</Group>
))}
{c.status === "ACCEPTED" && c.invoiceId && (
<Group justify="flex-end" mt="sm">
<Button
size="xs"
radius={10}
color="edr-green"
leftSection={<CreditCard size={14} />}
onClick={() => setPayCharge(c)}
>
Pay
</Button>
</Group>
)}
{c.status === "PAID" && (
<Group gap={6} justify="flex-end" mt="sm">
<Receipt size={14} color="#0A6F4D" />
<Text fz="12px" c="#0A6F4D" fw={600}>
Paid{c.paidAt ? ` · ${new Date(c.paidAt).toLocaleString()}` : ""}
</Text>
</Group>
)}
</Box>
);
})}
</Stack>
<PaymentMethodModal
opened={payCharge !== null}
onClose={() => {
if (!pay.processing) {
setPayCharge(null);
pay.reset();
}
}}
amountLabel={payCharge ? money(payCharge) : undefined}
currency={payCharge?.currency}
onConfirm={(method, payerAccount) =>
payCharge?.invoiceId && pay.pay(payCharge.invoiceId, method, payerAccount)
}
processing={pay.processing}
error={pay.error}
otp={pay.otp}
bill={pay.bill}
/>
{viewer}
</SectionCard>
);
}

View File

@@ -0,0 +1,386 @@
import {
useQuery,
} from "@tanstack/react-query";
import {
Anchor,
Badge,
Box,
Button,
FileInput,
Group,
Paper,
Stack,
Text,
} from "@mantine/core";
import {
// Check,
CheckCircle2,
Download,
Eye,
FileBadge,
Receipt,
Upload,
} from "lucide-react";
import {
useState,
} from "react";
import toast from "react-hot-toast";
import type { Freight } from "@edr/types";
import {
useFileViewer,
} from "@/hooks/useFileViewer";
import {
GREEN,
INK,
} from "@/pages/contracts/contract-ui";
import { bookingsService } from "@/services/bookings.service";
import { contractsService } from "@/services/contracts.service";
import { downloadStoredFile } from "@/services/files.service";
import { CardTitle, SectionCard } from "./layout";
// Customer-facing labels for an invoice status (Freight.InvoiceStatus).
// const INVOICE_STATUS_LABELS: Record<string, string> = {
// DRAFT: "Draft",
// ISSUED: "Issued",
// PENDING: "Due",
// PAYMENT_PROCESSING: "Payment processing",
// PARTIALLY_PAID: "Partially paid",
// PAID: "Paid",
// OVERDUE: "Overdue",
// CANCELLED: "Cancelled",
// REFUNDED: "Refunded",
// EXPIRED: "Expired",
// };
// function invoiceStatusLabel(status: string): string {
// return (
// INVOICE_STATUS_LABELS[status] ??
// status
// .replace(/_/g, " ")
// .toLowerCase()
// .replace(/\b\w/g, (m) => m.toUpperCase())
// );
// }
/**
* Customs payments on a phased (customs) booking — duty / tax, the post-arrival
* additional duty and GL Djibouti's final invoice. All are paid by bank
* transfer; the customer attaches the slip here and Global Logistics confirms.
* Renders nothing until customs has advised something.
*/
export function CustomsPaymentsCard({ booking }: { booking: Freight.IBooking }) {
const isPhased = Boolean(booking.customsClearingEnabled && booking.contractId);
const { view, viewer } = useFileViewer();
const { data: clearance, refetch } = useQuery({
queryKey: ["booking-clearance", booking.id],
queryFn: () => bookingsService.getClearance(booking.id),
enabled: isPhased,
});
if (!isPhased || !clearance) return null;
const dutyPaid = Boolean(
clearance.milestones?.some(
(m) => m.milestoneCode === "DUTY_TAX_PAID" && m.status === "COMPLETED",
),
);
const showDuty = Boolean(clearance.dutyRequired && clearance.dutyAdvice);
const showSecond = Boolean(
clearance.secondDuty?.advised || clearance.secondDuty?.paid,
);
if (!showDuty && !showSecond) return null;
const onChanged = () => void refetch();
return (
<SectionCard id="customs-payments">
<Group justify="space-between" align="center" mb="md">
<CardTitle>Customs payments</CardTitle>
<Text fz="12px" c="#9AA8B5">
Bank transfer · upload the slip here
</Text>
</Group>
<Stack gap="md">
{showDuty && clearance.dutyAdvice && (
dutyPaid ? (
<PaidRow
label="Customs duty & tax"
amount={clearance.dutyAdvice.amount}
currency={clearance.dutyAdvice.currency}
/>
) : (
<DutyAdvicePanel
dutyAdvice={clearance.dutyAdvice}
bookingId={booking.id}
onChanged={onChanged}
/>
)
)}
{showSecond && clearance.secondDuty && (
<SecondDutyDueCard
duty={clearance.secondDuty}
bookingId={booking.id}
onView={view}
onChanged={onChanged}
/>
)}
</Stack>
{viewer}
</SectionCard>
);
}
/** A settled customs payment — slip uploaded, nothing left to do. */
function PaidRow({
label,
amount,
currency,
}: {
label: string;
amount: number;
currency: string;
}) {
return (
<Group
justify="space-between"
wrap="nowrap"
style={{
border: "1px solid #CDEBDD",
background: "#F6FBF8",
borderRadius: 12,
padding: "12px 14px",
}}
>
<Group gap={8} wrap="nowrap">
<CheckCircle2 size={16} color={GREEN} />
<Text fz="13.5px" fw={700} c={INK}>
{label}
</Text>
<Badge color="edr-green" variant="light" radius="sm">
Slip uploaded
</Badge>
</Group>
<Text fz="14px" fw={800} c={INK} style={{ whiteSpace: "nowrap" }}>
{amount.toLocaleString()} {currency}
</Text>
</Group>
);
}
function DutyAdvicePanel({
dutyAdvice,
bookingId,
onChanged,
}: {
dutyAdvice: NonNullable<Freight.ClearanceView["dutyAdvice"]>;
bookingId: string;
onChanged: () => void;
}) {
const [file, setFile] = useState<File | null>(null);
const [loading, setLoading] = useState(false);
const noticeFile = dutyAdvice.noticeFile;
return (
<Paper withBorder radius="md" p="md" bg="#FFFBF0">
<Stack gap="sm">
<GroupLabel icon={Receipt} text="Duty / tax payment" />
<Text size="sm">
Amount due:{" "}
<strong>
{dutyAdvice.amount.toLocaleString()} {dutyAdvice.currency}
</strong>
{dutyAdvice.declarationSerial
? ` · Payment code: ${dutyAdvice.declarationSerial}`
: null}
</Text>
{noticeFile ? (
<Anchor
component="button"
type="button"
onClick={() => void downloadStoredFile(noticeFile.id, noticeFile.name)}
size="sm"
>
<Group gap={6} wrap="nowrap">
<Download size={14} />
Download duty notice ({noticeFile.name})
</Group>
</Anchor>
) : null}
<Text size="sm" c="dimmed">
Pay the amount above, then upload your payment slip so clearance can continue.
</Text>
<FileInput label="Payment slip" value={file} onChange={setFile} size="sm" />
<Button
color="orange"
loading={loading}
disabled={!file}
leftSection={<Upload size={16} />}
onClick={async () => {
if (!file) return;
setLoading(true);
try {
await bookingsService.uploadBookingClearanceDutySlip(bookingId, file);
toast.success("Payment slip uploaded");
onChanged();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Upload failed");
} finally {
setLoading(false);
}
}}
>
Submit payment slip
</Button>
</Stack>
</Paper>
);
}
function GroupLabel({ icon: Icon, text }: { icon: typeof Receipt; text: string }) {
return (
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<Icon size={16} />
<Text fw={600} size="sm">
{text}
</Text>
</div>
);
}
/**
* Post-arrival additional duty/tax round (import): GL advises an extra amount
* with a notice; the customer pays offline and attaches another slip here.
*/
function SecondDutyDueCard({
duty,
bookingId,
onView,
onChanged,
}: {
duty: NonNullable<Freight.ClearanceView["secondDuty"]>;
bookingId: string;
onView: (file: { name: string; url: string }) => void;
onChanged: () => void;
}) {
const [slip, setSlip] = useState<File | null>(null);
const [uploading, setUploading] = useState(false);
const paid = duty.paid;
return (
<Paper
withBorder
radius="lg"
p="lg"
style={{
borderColor: paid ? "#CDEBDD" : "#F2D9A6",
background: paid ? "#F6FBF8" : "#FFFBF2",
position: "relative",
overflow: "hidden",
}}
>
<Box
style={{
position: "absolute",
left: 0,
top: 0,
bottom: 0,
width: 3,
background: paid ? GREEN : "#E3A93C",
}}
/>
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
<div>
<Group gap={8} align="center">
<FileBadge size={16} color={paid ? GREEN : "#B07C1F"} />
<Text fw={700} fz={15} c={INK}>
{paid ? "Additional duty & tax paid" : "Additional duty & tax due"}
</Text>
<Badge color={paid ? "edr-green" : "yellow"} variant="light" radius="sm">
{paid ? "PAID" : "DUE"}
</Badge>
</Group>
<Text fz={20} fw={800} mt={6} c={INK}>
{(duty.amount ?? 0).toLocaleString()} {duty.currency ?? ""}
</Text>
{duty.declarationSerial ? (
<Text fz={13} c="dimmed" mt={2}>
Payment code: {duty.declarationSerial}
</Text>
) : null}
{!paid ? (
<Text fz={13} c="#9A6B1F" mt={6}>
Customs advised additional duty/tax after arrival. Pay the amount
above and attach your payment slip.
</Text>
) : null}
</div>
<Stack gap="xs" miw={260}>
{duty.noticeFile ? (
<Button
variant="default"
radius="md"
size="sm"
leftSection={<Eye size={15} />}
onClick={() =>
onView({ name: duty.noticeFile!.name, url: duty.noticeFile!.url })
}
>
View duty notice
</Button>
) : null}
{duty.slipFile ? (
<Button
variant="default"
radius="md"
size="sm"
leftSection={<Eye size={15} />}
onClick={() =>
onView({ name: duty.slipFile!.name, url: duty.slipFile!.url })
}
>
View payment slip
</Button>
) : null}
{!paid ? (
<>
<FileInput
placeholder={duty.slipFile ? "Replace payment slip" : "Attach payment slip"}
value={slip}
onChange={setSlip}
size="sm"
radius="md"
/>
<Button
color="edr-green"
radius="md"
size="sm"
loading={uploading}
disabled={!slip}
leftSection={<Upload size={15} />}
onClick={async () => {
if (!slip) return;
setUploading(true);
try {
await contractsService.uploadSecondDutySlip(bookingId, slip);
setSlip(null);
toast.success("Payment slip attached");
onChanged();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Upload failed");
} finally {
setUploading(false);
}
}}
>
{duty.slipFile ? "Replace slip" : "Submit payment slip"}
</Button>
</>
) : null}
</Stack>
</Group>
</Paper>
);
}

View File

@@ -28,6 +28,7 @@ import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/Clearanc
import toast from "react-hot-toast";
import { bookingsService } from "@/services/bookings.service";
import type { EmptyContainerReturn } from "@/services/bookings.service";
import { saveBlob } from "@/utils/download";
import { IconSquare } from "./Documents";
import { CardTitle, SectionCard } from "./layout";
@@ -251,6 +252,24 @@ export function DocumentsTab({ booking }: { booking: Freight.IBooking }) {
queryFn: () => warehouseService.bookingHandovers(booking.id),
});
const { data: emptyReturns = [] } = useQuery({
queryKey: ["emptyContainerReturns", booking.id],
queryFn: () =>
bookingsService.listEmptyContainerReturns(booking.id).catch(() => []),
});
const [downloadingReturnId, setDownloadingReturnId] = useState<string | null>(null);
const downloadEir = async (ret: EmptyContainerReturn) => {
setDownloadingReturnId(ret.id);
try {
const blob = await bookingsService.downloadEquipmentInterchangeDocument(ret.id);
saveBlob(blob, `equipment-interchange-${ret.containerNumber}.pdf`);
} catch {
toast.error("Could not download the interchange receipt.");
} finally {
setDownloadingReturnId(null);
}
};
const customerDocs = useMemo(
() => (clearance?.documents ?? []).filter((d) => d.uploadedBy === "customer"),
[clearance],
@@ -303,6 +322,14 @@ export function DocumentsTab({ booking }: { booking: Freight.IBooking }) {
fn: () => bookingsService.downloadBookingHandoverDocument(booking.id),
},
];
// Carriage acceptance sheet only exists for export bookings — 404s
// (skipped below) for import/domestic, so this is safe unconditionally.
if (booking.tradeDirection === "EXPORT") {
jobs.push({
name: `carriage-acceptance-${ref}.pdf`,
fn: () => bookingsService.downloadCarriageAcceptanceSheet(booking.id),
});
}
let saved = 0;
for (const job of jobs) {
try {
@@ -584,12 +611,56 @@ export function DocumentsTab({ booking }: { booking: Freight.IBooking }) {
</SectionCard>
)}
{/* ── 4b. Equipment interchange receipts (empty container returns) ─── */}
{emptyReturns.length > 0 && (
<SectionCard>
<CardTitle>Equipment interchange receipts</CardTitle>
<Text fz="12.5px" c="dimmed" mt={4} mb="sm">
Container number, size, return time, depot, and condition for each empty
container returned on this booking.
</Text>
<Stack gap={0}>
{emptyReturns.map((ret, i) => (
<Box
key={ret.id}
py={12}
style={{
borderBottom:
i === emptyReturns.length - 1 ? undefined : "1px solid #F2F5F8",
}}
>
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Box miw={0} flex={1}>
<Text fz="13.5px" fw={700} c="#10202F">
{ret.containerNumber}
{ret.containerSize ? ` · ${ret.containerSize}ft` : ""}
</Text>
<Text fz="12px" c="#9AA8B5">
{ret.returnDate ? new Date(ret.returnDate).toLocaleString() : "—"}
{ret.facility ? ` · ${ret.facility}` : ""}
{ret.condition ? ` · ${ret.condition}` : ""}
</Text>
</Box>
<IconSquare
icon={<Download size={15} />}
onClick={
downloadingReturnId === ret.id ? undefined : () => void downloadEir(ret)
}
/>
</Group>
</Box>
))}
</Stack>
</SectionCard>
)}
{/* ── Warehouse documents (one-click bundle) ──────────────────────── */}
<SectionCard>
<CardTitle>Warehouse documents</CardTitle>
<Text fz="12.5px" c="dimmed" mt={4} mb="sm">
Goods Received Note, gate clearance / release order and handover download all
available documents for this booking in one click.
Goods Received Note, gate clearance / release order, handover, and for export
bookings the carriage acceptance sheet: download all available documents for
this booking in one click.
</Text>
<Button
leftSection={<Download size={16} />}

View File

@@ -0,0 +1,269 @@
import { Box, Button, Group, Stack, Text, UnstyledButton } from "@mantine/core";
import {
ArrowRight,
CheckCircle2,
CreditCard,
FileCheck2,
Landmark,
Scale,
Upload,
type LucideIcon,
} from "lucide-react";
import type { Freight } from "@edr/types";
import {
isReviewAction,
useBookingPayables,
type PayableAction,
type PayableItem,
} from "@/pages/bookings/payments/useBookingPayables";
import type { useBookingPayment } from "@/pages/bookings/payments/useBookingPayment";
import { formatAmount } from "../utils";
import { BookingPaymentPanel } from "./BookingPaymentPanel";
import { ClearanceChargesSection } from "./ClearanceChargesSection";
import { CustomsPaymentsCard } from "./CustomsPaymentsCard";
import { BodyGrid, CardTitle, SectionCard } from "./layout";
import { WagonCancellationCard } from "./WagonCancellationCard";
const ACTION_META: Record<
PayableAction,
{ verb: string; icon: LucideIcon; color: string }
> = {
PAY: { verb: "Pay now", icon: CreditCard, color: "#0A6F4D" },
BANK_TRANSFER: { verb: "Bank transfer", icon: Landmark, color: "#B07D14" },
UPLOAD_SLIP: { verb: "Upload slip", icon: Upload, color: "#B07D14" },
APPROVE: { verb: "Approve", icon: FileCheck2, color: "#2E5B96" },
DECIDE: { verb: "Accept or reject", icon: Scale, color: "#2E5B96" },
};
const money = (amount: number, currency: string) =>
`${formatAmount(amount)} ${currency}`.trim();
const scrollTo = (id: string) =>
document.getElementById(id)?.scrollIntoView({ behavior: "smooth", block: "start" });
/**
* The booking's Payments tab — every amount the customer owes or must decide
* on, in one place: freight (with its pay window), clearance charges to
* accept and pay, customs duty / final invoice slips, wagon-cancellation fees.
* The summary strip at the top lists what is outstanding and jumps to the card
* that settles it.
*/
export function PaymentsTab({
booking,
pay,
showCountdown,
onBookingUpdated,
}: {
booking: Freight.IBooking;
pay: ReturnType<typeof useBookingPayment>;
showCountdown: boolean;
onBookingUpdated?: () => void;
}) {
const { items, dueTotals, loading } = useBookingPayables(booking);
return (
<Stack gap="lg">
<PaymentsSummary items={items} dueTotals={dueTotals} loading={loading} />
<BodyGrid
left={
<>
<ClearanceChargesSection bookingId={booking.id} />
<CustomsPaymentsCard booking={booking} />
<WagonCancellationCard
booking={booking}
onBookingUpdated={onBookingUpdated}
/>
</>
}
right={
<BookingPaymentPanel
booking={booking}
pricing={booking.pricingBreakdown}
onPay={pay.open}
paying={pay.processing}
showCountdown={showCountdown}
/>
}
/>
</Stack>
);
}
function PaymentsSummary({
items,
dueTotals,
loading,
}: {
items: PayableItem[];
dueTotals: Array<{ currency: string; amount: number }>;
loading: boolean;
}) {
const reviews = items.filter((i) => isReviewAction(i.action)).length;
const settled = !loading && items.length === 0;
return (
<SectionCard
p={22}
style={{
background: settled ? "#F6FBF8" : "#FFFDF7",
borderColor: settled ? "#CDEBDD" : "#F3E2B8",
}}
>
<Group justify="space-between" align="flex-start" wrap="wrap" gap="lg">
<Box style={{ minWidth: 220 }}>
<CardTitle>{settled ? "All settled" : "Amount due"}</CardTitle>
{loading ? (
<Text fz="14px" c="#9AA8B5" mt={8}>
Checking your payments
</Text>
) : settled ? (
<Group gap={8} mt={8} wrap="nowrap">
<CheckCircle2 size={20} color="#0A6F4D" />
<Text fz="18px" fw={800} c="#10202F">
Nothing to pay right now
</Text>
</Group>
) : (
<>
<Group gap={18} mt={6} align="baseline">
{dueTotals.length > 0 ? (
dueTotals.map((t) => (
<Text key={t.currency} fz="28px" fw={800} c="#10202F" lh={1.1}>
{money(t.amount, t.currency)}
</Text>
))
) : (
<Text fz="20px" fw={800} c="#10202F">
Your review is needed
</Text>
)}
</Group>
<Text fz="12.5px" c="#6B7C8E" mt={6}>
{items.length} {items.length === 1 ? "item needs" : "items need"} your
attention
{reviews > 0 ? ` · ${reviews} awaiting your review` : ""}
</Text>
</>
)}
</Box>
{!settled && !loading && (
<Stack gap={6} style={{ flex: 1, minWidth: 280, maxWidth: 480 }}>
{items.map((it) => {
const m = ACTION_META[it.action];
const Icon = m.icon;
return (
<UnstyledButton
key={`${it.anchor}-${it.id}`}
onClick={() => scrollTo(it.anchor)}
style={{
border: "1px solid #EEF2F6",
borderRadius: 10,
padding: "8px 12px",
background: "white",
}}
>
<Group justify="space-between" wrap="nowrap" gap={10}>
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
<Icon size={14} color={m.color} />
<Box style={{ minWidth: 0 }}>
<Text fz="13px" fw={700} c="#10202F" truncate>
{it.label}
</Text>
{it.detail && (
<Text fz="11.5px" c="#9AA8B5" truncate>
{it.detail}
</Text>
)}
</Box>
</Group>
<Group gap={8} wrap="nowrap">
<Text
fz="13px"
fw={800}
c="#10202F"
style={{ whiteSpace: "nowrap" }}
>
{money(it.amount, it.currency)}
</Text>
<Text
fz="11.5px"
fw={700}
c={m.color}
style={{ whiteSpace: "nowrap" }}
>
{m.verb}
</Text>
<ArrowRight size={13} color="#9AA8B5" />
</Group>
</Group>
</UnstyledButton>
);
})}
</Stack>
)}
</Group>
</SectionCard>
);
}
/**
* Compact "amount due" strip on the Overview tab — the only payment surface
* left there. Renders nothing when the booking has nothing outstanding.
*/
export function PaymentsDueStrip({
booking,
onOpen,
}: {
booking: Freight.IBooking;
onOpen: () => void;
}) {
const { items, dueTotals } = useBookingPayables(booking);
if (items.length === 0) return null;
const labels = [...new Set(items.map((i) => i.label))].join(", ");
return (
<SectionCard
p="md"
style={{ background: "#FFFDF7", borderColor: "#F3E2B8" }}
>
<Group justify="space-between" wrap="wrap" gap="md">
<Group gap={12} wrap="nowrap" style={{ minWidth: 0 }}>
<Box
style={{
width: 40,
height: 40,
borderRadius: 12,
flexShrink: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "#FEF3E2",
color: "#B45309",
}}
>
<CreditCard size={18} />
</Box>
<Box style={{ minWidth: 0 }}>
<Text fz="14px" fw={800} c="#10202F">
{dueTotals.length > 0
? `${dueTotals.map((t) => money(t.amount, t.currency)).join(" + ")} due`
: "A payment needs your review"}
</Text>
<Text fz="12.5px" c="#6B7C8E" truncate>
{items.length} {items.length === 1 ? "item" : "items"}: {labels}
</Text>
</Box>
</Group>
<Button
color="edr-green"
radius={10}
rightSection={<ArrowRight size={15} />}
onClick={onOpen}
>
Go to payments
</Button>
</Group>
</SectionCard>
);
}

View File

@@ -26,6 +26,12 @@ import {
type WagonCancellationPreview,
} from "@/services/bookings.service";
import { OperationDatePicker } from "@/pages/bookings/clearance";
import {
RebookUnitsEditor,
containersFromDrafts,
draftsFromSnapshot,
type RebookUnitDraft,
} from "@/pages/bookings/RebookUnitsEditor";
import { useFeeInvoicePayment } from "@/pages/bookings/payments/useBookingPayment";
import type { BookingDetail } from "../booking-detail-types";
@@ -199,24 +205,19 @@ export function WagonCancellationCard({
),
});
const withdrawMutation = useMutation({
mutationFn: () => bookingsService.withdrawWagonCancellation(openRow!.id),
onSuccess: () => {
toast.success("Cancellation withdrawn — the fee invoice was voided.");
void refetch();
onBookingUpdated?.();
},
onError: (e) =>
toast.error(
apiErrorMessage(e, "Could not withdraw the cancellation. Please try again."),
),
});
const [rebookDate, setRebookDate] = useState("");
// Non-customs: container number / seal / VGM may change at rebook. Customs
// (Path B) credits are rebooked by GL from the backoffice instead.
const isCustoms = Boolean(booking.customsClearingEnabled);
const [rebookDrafts, setRebookDrafts] = useState<RebookUnitDraft[] | null>(null);
const snapshotUnits = creditRow?.cancelledQuantities?.units ?? [];
const drafts = rebookDrafts ?? draftsFromSnapshot(snapshotUnits);
const showUnitEditor = !isCustoms && drafts.length > 0;
const rebookMutation = useMutation({
mutationFn: () =>
bookingsService.rebookWagonCancellation(creditRow!.id, {
scheduledDate: rebookDate,
...(showUnitEditor ? { containers: containersFromDrafts(drafts) } : {}),
}),
onSuccess: ({ bookingId }) => {
toast.success("Wagons rebooked — taking you to the new booking.", {
@@ -232,7 +233,7 @@ export function WagonCancellationCard({
if (!canRequest && !ownRows.length) return null;
return (
<SectionCard>
<SectionCard id="wagon-cancellation">
<Group justify="space-between" align="center" mb="sm">
<CardTitle>Wagon Cancellation</CardTitle>
{/* {canRequest && !openRow && !creditRow && (
@@ -256,9 +257,8 @@ export function WagonCancellationCard({
{fmtMoney(openRow.feeAmount, openRow.feeCurrency)}
</Text>
. The cancelled wagons have left the train. Pay the fee to unlock
the rebooking credit, or withdraw the request to get the wagons
back withdrawing works only while the train still has free space
for them.
the rebooking credit. The request cannot be withdrawn from here
if it was a mistake, contact EDR staff.
</Alert>
<Group gap={8}>
<Button
@@ -269,14 +269,6 @@ export function WagonCancellationCard({
>
Pay cancellation fee
</Button>
<Button
variant="default"
radius="md"
loading={withdrawMutation.isPending}
onClick={() => withdrawMutation.mutate()}
>
Withdraw request
</Button>
</Group>
</Stack>
) : creditRow ? (
@@ -290,22 +282,34 @@ export function WagonCancellationCard({
is available. Pick a shipment day to rebook them as a new paid
booking (no further payment needed).
</Alert>
<OperationDatePicker
bookingId={booking.id}
value={rebookDate}
onChange={setRebookDate}
/>
<Group justify="flex-end">
<Button
color="edr-green"
radius="md"
disabled={!rebookDate}
loading={rebookMutation.isPending}
onClick={() => rebookMutation.mutate()}
>
Rebook wagons
</Button>
</Group>
{isCustoms ? (
<Text fz={13} c="#475569">
This is a customs-cleared booking Global Logistics will rebook
the credit for you.
</Text>
) : (
<>
<OperationDatePicker
bookingId={booking.id}
value={rebookDate}
onChange={setRebookDate}
/>
{showUnitEditor && (
<RebookUnitsEditor drafts={drafts} onChange={setRebookDrafts} />
)}
<Group justify="flex-end">
<Button
color="edr-green"
radius="md"
disabled={!rebookDate}
loading={rebookMutation.isPending}
onClick={() => rebookMutation.mutate()}
>
Rebook wagons
</Button>
</Group>
</>
)}
</Stack>
) : (
<Text fz={13} c="#475569">

View File

@@ -15,6 +15,7 @@ import {
} from "@mantine/core";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
AlertCircle,
Container,
Gauge,
MapPin,
@@ -303,11 +304,14 @@ function WagonCard({
wagon,
selectable,
selected,
shared,
onToggle,
}: {
wagon: BookingWagonAllocation;
selectable?: boolean;
selected?: boolean;
/** Shared consolidation wagon — not selectable for cancellation. */
shared?: boolean;
onToggle?: () => void;
}) {
const allocated = Number(wagon.allocatedWeightTons || 0);
@@ -368,6 +372,14 @@ function WagonCard({
<StatusPill status={wagon.status} />
</Group>
{shared && (
<Text fz={11.5} c="#B45309" mb={6}>
Shared wagon the other half belongs to another customer&apos;s
booking, so it cannot be cancelled on its own. Cancel the whole
booking to release it.
</Text>
)}
<LoadBar allocated={allocated} capacity={capacity} />
<Group gap={16} mt="sm" mb={containers.length || wagon.loadType === "BULK" ? "sm" : 0}>
@@ -464,6 +476,7 @@ export function WagonsTab({
bookingId,
currency,
cancellable,
consolidated,
onCancellationRequested,
}: {
bookingId: string;
@@ -471,6 +484,8 @@ export function WagonsTab({
currency?: string;
/** PAID contract booking — specific wagons may be selected for cancellation. */
cancellable?: boolean;
/** Consolidated booking — its shared wagon (a lone 20ft) cannot be cancelled alone. */
consolidated?: boolean;
onCancellationRequested?: () => void;
}) {
const queryClient = useQueryClient();
@@ -703,27 +718,40 @@ export function WagonsTab({
)}
{cancellable && hasOpenCancellation && (
<Alert color="yellow" variant="light">
A wagon cancellation is already awaiting its fee pay or withdraw it
in the wagon cancellation card before requesting another.
A wagon cancellation is already awaiting its fee pay it in the
wagon cancellation card before requesting another. Withdrawing a
request is only possible through EDR staff.
</Alert>
)}
<CancelledWagonsSection rows={ownCancellations} />
<SimpleGrid cols={{ base: 1, md: 2 }} spacing={24}>
{wagons.map((w) => (
<WagonCard
key={w.allocationId ?? w.sequenceNo}
wagon={w}
selectable={
canSelect &&
!!w.allocationId &&
(w.status === "PLANNED" || w.status === "RESERVED")
}
selected={!!w.allocationId && selected.has(w.allocationId)}
onToggle={() => w.allocationId && toggle(w.allocationId)}
/>
))}
{wagons.map((w) => {
// The shared consolidation wagon carries this booking's lone 20ft —
// its other half belongs to the partner booking, so it can never be
// cancelled on its own (the server rejects it too).
const isSharedWagon =
!!consolidated &&
w.loadType === "CONTAINER" &&
(w.containers ?? []).length === 1 &&
Number(w.containers?.[0]?.sizeFt) === 20;
return (
<WagonCard
key={w.allocationId ?? w.sequenceNo}
wagon={w}
shared={isSharedWagon}
selectable={
canSelect &&
!isSharedWagon &&
!!w.allocationId &&
(w.status === "PLANNED" || w.status === "RESERVED")
}
selected={!!w.allocationId && selected.has(w.allocationId)}
onToggle={() => w.allocationId && toggle(w.allocationId)}
/>
);
})}
</SimpleGrid>
<Modal
@@ -739,6 +767,13 @@ export function WagonsTab({
paid freight for them becomes a credit you can rebook on another
day while your contract is valid.
</Text>
<Alert color="red" variant="light" radius="md" icon={<AlertCircle size={16} />}>
<Text fz={13} fw={600}>
This cannot be undone from the portal. Once requested, the wagons
leave the train and only EDR staff can revert the cancellation
make sure before you confirm.
</Text>
</Alert>
{previewMutation.isPending && <Skeleton height={64} radius={10} />}
{preview && (
<Box

View File

@@ -34,8 +34,8 @@ import {
} from "lucide-react";
import { ShipmentTrackingModal } from "./tracking/ShipmentTrackingModal";
import { PayNowButton } from "./payments/PayNowButton";
import { payWindowState } from "./payments/payment-drain";
import { PayButton } from "./payments/PayButton";
import { useMyPayables } from "./payments/useMyPayables";
import { BookingActionButton } from "./clearance/BookingActionButton";
import { bookingHasInlineAction } from "./clearance/bookingNextAction";
import {
@@ -181,27 +181,29 @@ const STAT_CARDS: Array<{
function PrimaryAction({
booking,
credit,
payable,
onNavigate,
}: {
booking: Freight.IBooking;
/** CREDIT_AVAILABLE wagon cancellation opened by this booking, if any. */
credit?: WagonCancellation;
/** Outstanding payments on this booking (from `my-payables`), if any. */
payable?: Freight.BookingPayableSummary;
onNavigate: (path: string) => void;
}) {
const { status, id } = booking;
const go = () => onNavigate(`/bookings/${id}`);
// Cancelled wagons with a paid credit (partial or whole cancel) → rebook.
if (credit) {
// Customs (Path B) credits are rebooked by GL from the backoffice, not here.
if (credit && !booking.customsClearingEnabled) {
return (
<RebookWagonsButton
cancellation={credit}
currency={booking.paymentCurrency}
editableUnits
/>
);
}
// A general contract is payable as soon as it's FULLY_EXECUTED (signed); a
// one-time booking only after it's SELECTED_FOR_BATCH.
const isGeneralContract = booking.bookingType === "GENERAL_CONTRACT";
if (status === "DRAFT") {
return (
<Button
@@ -220,24 +222,16 @@ function PrimaryAction({
</Button>
);
}
// Anything outstanding (freight, clearance charge, duty slip, cancellation
// fee) → "Pay" jumps to the booking's Payments tab.
if (payable) {
return <PayButton bookingId={id} summary={payable} />;
}
// CHANGES_REQUESTED + clearance/operation steps are handled in place by a
// modal (update & resubmit, upload clearance docs, schedule & proceed).
if (bookingHasInlineAction(booking)) {
return <BookingActionButton booking={booking} size="xs" />;
}
const payableStatus = isGeneralContract
? "FULLY_EXECUTED"
: "SELECTED_FOR_BATCH";
// A fully-closed pay window (deadline + drain both elapsed) falls through to
// the default action. The drain itself still routes here — PayNowButton
// renders the "payment processing" wait notice instead of a pay action.
if (
status === payableStatus &&
booking.paymentStatus !== "PAID" &&
payWindowState(booking).phase !== "closed"
) {
return <PayNowButton booking={booking} />;
}
// Contract ready for the customer's signature → full-page contract viewer.
if (bookingIsSignable(booking)) {
return <ContractSignButton booking={booking} size="xs" />;
@@ -460,6 +454,8 @@ export default function BookingsListPage() {
input: { pageSize: 100 },
}),
);
// Outstanding payments per booking → row "Pay" button (one shared query).
const payables = useMyPayables();
const creditByBooking = useMemo(() => {
const m = new Map<string, WagonCancellation>();
for (const r of myCancellations?.items ?? []) {
@@ -702,6 +698,7 @@ export default function BookingsListPage() {
<PrimaryAction
booking={booking}
credit={creditByBooking.get(booking.id)}
payable={payables.get(booking.id)}
onNavigate={navigate}
/>
<Menu position="bottom-end" withinPortal shadow="md" radius="md">

View File

@@ -0,0 +1,99 @@
import { Box, Group, NumberInput, Text, TextInput } from "@mantine/core";
/** One editable rebook unit — prefilled from the cancelled snapshot. */
export interface RebookUnitDraft {
containerSize: string;
containerNumber: string;
sealNumber: string;
vgmTons: number | "";
}
/** Snapshot units → editable drafts (the initial editor state). */
export function draftsFromSnapshot(
units: Array<{
containerSize: string;
containerNumber: string;
sealNumber?: string | null;
vgmTons: number;
}>,
): RebookUnitDraft[] {
return units.map((u) => ({
containerSize: u.containerSize,
containerNumber: u.containerNumber,
sealNumber: u.sealNumber ?? "",
vgmTons: Number(u.vgmTons) || "",
}));
}
/** Drafts → the rebook payload's containers field (grouped by size). */
export function containersFromDrafts(drafts: RebookUnitDraft[]) {
const bySize = new Map<string, RebookUnitDraft[]>();
for (const d of drafts) {
bySize.set(d.containerSize, [...(bySize.get(d.containerSize) ?? []), d]);
}
return [...bySize.entries()].map(([containerSize, units]) => ({
containerSize,
units: units.map((u) => ({
containerNumber: u.containerNumber.trim(),
...(u.sealNumber.trim() ? { sealNumber: u.sealNumber.trim() } : {}),
...(u.vgmTons !== "" ? { vgmTons: Number(u.vgmTons) } : {}),
})),
}));
}
/**
* Per-unit editor for a rebook: container number, seal and VGM may change;
* sizes and quantities are fixed by the credit, so rows can't be added or
* removed.
*/
export function RebookUnitsEditor({
drafts,
onChange,
}: {
drafts: RebookUnitDraft[];
onChange: (next: RebookUnitDraft[]) => void;
}) {
const set = (i: number, patch: Partial<RebookUnitDraft>) =>
onChange(drafts.map((d, idx) => (idx === i ? { ...d, ...patch } : d)));
return (
<Box>
<Text fz={12.5} c="#475569" mb={6}>
Update the container details if they changed the sizes and quantities
stay as cancelled.
</Text>
{drafts.map((d, i) => (
<Group key={i} gap={8} wrap="nowrap" mb={8} align="flex-end">
<TextInput
label={i === 0 || drafts[i - 1].containerSize !== d.containerSize ? `${d.containerSize} container` : " "}
placeholder="Container no."
value={d.containerNumber}
onChange={(e) => set(i, { containerNumber: e.currentTarget.value })}
radius={8}
size="xs"
style={{ flex: 1.4 }}
/>
<TextInput
label={i === 0 ? "Seal no." : " "}
placeholder="Seal no."
value={d.sealNumber}
onChange={(e) => set(i, { sealNumber: e.currentTarget.value })}
radius={8}
size="xs"
style={{ flex: 1 }}
/>
<NumberInput
label={i === 0 ? "VGM (t)" : " "}
placeholder="VGM"
value={d.vgmTons}
onChange={(v) => set(i, { vgmTons: typeof v === "number" ? v : "" })}
min={0}
radius={8}
size="xs"
style={{ width: 90 }}
/>
</Group>
))}
</Box>
);
}

View File

@@ -9,6 +9,12 @@ import { api } from "@/services/api";
import { bookingsService, type WagonCancellation } from "@/services/bookings.service";
import { OperationDatePicker } from "./clearance";
import { formatAmount } from "./BookingDetailPage/utils";
import {
RebookUnitsEditor,
containersFromDrafts,
draftsFromSnapshot,
type RebookUnitDraft,
} from "./RebookUnitsEditor";
const apiErrorMessage = (error: unknown, fallback: string) => {
const data = (error as { response?: { data?: { message?: string | string[] } } })
@@ -26,19 +32,30 @@ export function RebookWagonsButton({
cancellation,
currency,
size = "xs",
editableUnits,
}: {
cancellation: WagonCancellation;
currency?: string;
size?: "xs" | "sm";
/** Non-customs contracts: container number / seal / VGM may be edited at rebook. */
editableUnits?: boolean;
}) {
const navigate = useNavigate();
const qc = useQueryClient();
const [open, setOpen] = useState(false);
const [date, setDate] = useState("");
const snapshotUnits = cancellation.cancelledQuantities?.units ?? [];
const [drafts, setDrafts] = useState<RebookUnitDraft[]>(() =>
draftsFromSnapshot(snapshotUnits),
);
const showEditor = Boolean(editableUnits) && drafts.length > 0;
const rebook = useMutation({
mutationFn: () =>
bookingsService.rebookWagonCancellation(cancellation.id, { scheduledDate: date }),
bookingsService.rebookWagonCancellation(cancellation.id, {
scheduledDate: date,
...(showEditor ? { containers: containersFromDrafts(drafts) } : {}),
}),
onSuccess: ({ bookingId }) => {
qc.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
qc.invalidateQueries({ queryKey: api.bookings.listMyWagonCancellations.queryKey() });
@@ -87,6 +104,9 @@ export function RebookWagonsButton({
value={date}
onChange={setDate}
/>
{showEditor && (
<RebookUnitsEditor drafts={drafts} onChange={setDrafts} />
)}
<Group justify="flex-end">
<Button variant="default" radius="md" onClick={() => setOpen(false)}>
Close

View File

@@ -39,7 +39,7 @@ interface BookingActionButtonProps {
* when the booking has no customer-actionable clearance/operation step;
* otherwise shows a button that opens the in-place {@link BookingActionModal}.
*
* Drop it into a list row exactly like {@link PayNowButton} — it stops click
* Drop it into a list row exactly like {@link PayButton} — it stops click
* propagation so it never triggers the row's navigation handler.
*/
export function BookingActionButton({

View File

@@ -14,6 +14,8 @@ import { useClearanceFlow } from "./useClearanceFlow";
interface BookingActionModalProps {
booking: Freight.IBooking;
opened: boolean;
/** Open with one blank "Additional documents" row ready (answering a GL request). */
startWithAdHocRow?: boolean;
onClose: () => void;
}
@@ -29,21 +31,30 @@ interface BookingActionModalProps {
export function BookingActionModal({
booking,
opened,
startWithAdHocRow,
onClose,
}: BookingActionModalProps) {
if (!opened) return null;
return <BookingActionModalBody booking={booking} onClose={onClose} />;
return (
<BookingActionModalBody
booking={booking}
startWithAdHocRow={startWithAdHocRow}
onClose={onClose}
/>
);
}
function BookingActionModalBody({
booking,
startWithAdHocRow,
onClose,
}: {
booking: Freight.IBooking;
startWithAdHocRow?: boolean;
onClose: () => void;
}) {
const action = getBookingNextAction(booking);
const flow = useClearanceFlow(booking);
const flow = useClearanceFlow(booking, { startWithAdHocRow });
const navigate = useNavigate();
const reference = booking.reference;

View File

@@ -12,6 +12,7 @@ import {
Download,
Eye,
FileText,
MessageSquare,
} from "lucide-react";
import type { Freight } from "@edr/types";
@@ -55,6 +56,7 @@ export function ClearanceFlow({ booking, flow, footer }: ClearanceFlowProps) {
clearance,
customerDocs,
glDocs,
workflowFiles,
isReady,
needsCompletion,
awaitingGlCompletion,
@@ -152,6 +154,56 @@ export function ClearanceFlow({ booking, flow, footer }: ClearanceFlowProps) {
))}
</Stack>
{/* Customs paperwork GL produced for this shipment — declaration, T1
transit permit, Djibouti documents. These live in `workflowFiles`
rather than the seeded output set, so they need their own section:
without it the customer never sees their own declaration or T1. */}
{workflowFiles.length > 0 && (
<>
<Text fz="12.5px" fw={700} c="#10202F" mt="lg" mb={8}>
Clearance documents from Global Logistics
</Text>
<Stack gap={8}>
{workflowFiles.map((doc) => (
<Group
key={doc.code}
justify="space-between"
wrap="nowrap"
className="rounded-xl"
style={{ border: `1px solid ${BORDER}`, padding: 10 }}
>
<Group gap={10} wrap="nowrap" style={{ minWidth: 0 }}>
<Box c="#2E5B96">
<FileText size={18} />
</Box>
<Text fz="13px" c="#10202F" truncate>
{doc.label}
</Text>
</Group>
<Group gap={8} wrap="nowrap">
{isViewable({ name: doc.file.name, url: "" }) && (
<IconSquare
icon={<Eye size={15} />}
onClick={() =>
void fetchViewableFile(doc.file.id, doc.file.name).then(
view,
)
}
/>
)}
<IconSquare
icon={<Download size={15} />}
onClick={() =>
void downloadStoredFile(doc.file.id, doc.file.name)
}
/>
</Group>
</Group>
))}
</Stack>
</>
)}
{/* GL output documents (read-only to the customer). */}
{glDocs.length > 0 && (
<>
@@ -209,6 +261,37 @@ export function ClearanceFlow({ booking, flow, footer }: ClearanceFlowProps) {
</>
)}
{/* What Global Logistics asked for, in their own words — placed directly
above the upload box so the customer reads the ask and answers it in
one place. Newest first. */}
{(clearance.docRequests?.length ?? 0) > 0 && (
<Box mt="lg">
<Text fz="12.5px" fw={700} c="#C0392B" mb={8}>
Requested by Global Logistics please add these documents
</Text>
<Stack gap={8}>
{clearance.docRequests!.map((r) => (
<Alert
key={r.id}
color="red"
variant="light"
radius="md"
icon={<MessageSquare size={16} />}
p="xs"
>
<Text fz="12.5px" c="#10202F">
{r.note}
</Text>
<Text fz="11px" c="dimmed" mt={4}>
{r.byName ?? "Global Logistics"} ·{" "}
{new Date(r.at).toLocaleString()}
</Text>
</Alert>
))}
</Stack>
</Box>
)}
{canUpload ? (
<ClearanceAdHocUploadSection
rows={adHoc}

View File

@@ -6,6 +6,20 @@ import type { Freight } from "@edr/types";
export type AdHocDoc = { name: string; file: File | null };
/**
* Make the customer's document name safe for a multipart field code (the API's
* `adHocLabel` turns it back into a label). Empty when unnamed, which keeps the
* old `custom_<n>` shape and lets the API fall back to the filename.
*/
function adHocSlug(name: string): string {
return name
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 60);
}
/**
* Encapsulates everything the customer-facing clearance/operation flow needs:
* the clearance grid query, the staged uploads (keyed pending + ad-hoc docs),
@@ -14,7 +28,10 @@ export type AdHocDoc = { name: string; file: File | null };
* Both the booking detail clearance card and the home-page action modal drive
* their UI off this single hook so the behaviour stays in lock-step.
*/
export function useClearanceFlow(booking: Freight.IBooking) {
export function useClearanceFlow(
booking: Freight.IBooking,
opts: { startWithAdHocRow?: boolean } = {},
) {
const queryClient = useQueryClient();
const status = booking.status as string;
@@ -25,7 +42,11 @@ export function useClearanceFlow(booking: Freight.IBooking) {
// Pending uploads keyed by fileKey, plus ad-hoc rows (label + file).
const [pending, setPending] = useState<Record<string, File>>({});
const [adHoc, setAdHoc] = useState<AdHocDoc[]>([]);
// Seeded with one blank row when the customer came here to answer a GL
// request, so the name + file inputs are already on screen.
const [adHoc, setAdHoc] = useState<AdHocDoc[]>(
opts.startWithAdHocRow ? [{ name: "", file: null }] : [],
);
// Binding shipment day chosen for the operation request (yyyy-MM-dd).
const [scheduledDate, setScheduledDateState] = useState<string>("");
// Export rail only: the specific train picked for that day.
@@ -97,6 +118,18 @@ export function useClearanceFlow(booking: Freight.IBooking) {
() => (clearance?.documents ?? []).filter((d) => d.uploadedBy === "gl"),
[clearance],
);
// Phased customs paperwork (declaration, T1 transit permit, Djibouti docs).
// Separate from `glDocs`, which only covers the seeded output-document set —
// these carry the shipment's own declaration and permit.
const workflowFiles = useMemo(
() =>
(clearance?.workflowFiles ?? []).filter(
(f): f is Freight.ClearanceWorkflowFile & {
file: NonNullable<Freight.ClearanceWorkflowFile["file"]>;
} => Boolean(f.file),
),
[clearance],
);
// OPERATION_CHANGES_REQUESTED re-opens the same pick-a-day flow: the
// customer resubmits via the same clearance/proceed endpoint.
@@ -180,7 +213,10 @@ export function useClearanceFlow(booking: Freight.IBooking) {
const submitDocuments = (opts?: { onSuccess?: () => void }) => {
const files: Record<string, File | null> = { ...pending };
adHoc.forEach((row, i) => {
if (row.file) files[`custom_${Date.now()}_${i}`] = row.file;
// The document name the customer typed travels in the field code — it is
// the only channel a multipart part has — so GL sees "Special permit"
// rather than "scan_003.pdf". `adHocLabel` on the API decodes it back.
if (row.file) files[`custom_${adHocSlug(row.name)}_${Date.now()}${i}`] = row.file;
});
if (Object.keys(files).length === 0) return;
uploadMutation.mutate({ id: booking.id, files }, { onSuccess: opts?.onSuccess });
@@ -205,6 +241,7 @@ export function useClearanceFlow(booking: Freight.IBooking) {
isLoading: clearanceQuery.isLoading,
customerDocs,
glDocs,
workflowFiles,
isReady,
needsCompletion,
completeTo,

View File

@@ -28,7 +28,7 @@ interface ContractSignButtonProps {
* that navigates to the full-page contract viewer ({@link BookingContractPage})
* where the signature flow lives.
*
* Drop it into a list row exactly like {@link PayNowButton} — it stops click
* Drop it into a list row exactly like {@link PayButton} — it stops click
* propagation so it never triggers the row's navigation handler.
*/
export function ContractSignButton({

View File

@@ -0,0 +1,55 @@
import { Button, type ButtonProps } from "@mantine/core";
import { CreditCard } from "lucide-react";
import { useNavigate } from "react-router-dom";
import type { Freight } from "@edr/types";
import { formatAmount } from "../BookingDetailPage/utils";
/** The booking detail page opened on its Payments tab. */
export const paymentsTabPath = (bookingId: string) =>
`/bookings/${bookingId}?tab=payments`;
/**
* "Pay" on a list / home row. Every payable item (freight, clearance charges,
* customs duty, cancellation fees) lives on the booking's Payments tab, so the
* row only needs to get the customer there — no per-row payment modal.
*/
export function PayButton({
bookingId,
summary,
size = "xs",
fullWidth,
}: {
bookingId: string;
summary?: Freight.BookingPayableSummary;
size?: ButtonProps["size"];
fullWidth?: boolean;
}) {
const navigate = useNavigate();
const single = summary?.totals.length === 1 ? summary.totals[0] : null;
// Only items awaiting the customer's review (a proposed price, a draft
// final invoice): nothing to pay yet, but still theirs to act on.
const reviewOnly = summary != null && summary.totals.length === 0;
return (
<Button
size={size}
radius="md"
fw={700}
fz={13}
color="edr-green"
fullWidth={fullWidth}
leftSection={<CreditCard size={14} />}
onClick={(e) => {
// Don't let a surrounding row-click handler fire.
e.stopPropagation();
navigate(paymentsTabPath(bookingId));
}}
>
{reviewOnly
? "Review payment"
: single
? `Pay ${formatAmount(single.amount)} ${single.currency}`
: "Pay"}
</Button>
);
}

View File

@@ -1,102 +0,0 @@
import { Badge, Button, type ButtonProps } from "@mantine/core";
import { CreditCard, Landmark } from "lucide-react";
import { Freight } from "@edr/types";
import { ModalSafeWrapper } from "@/components/customer-actions/ModalSafeWrapper";
import { PaymentMethodModal } from "../BookingDetailPage/components/PaymentMethodModal";
import { priceTotal } from "../BookingDetailPage/utils";
import { isUsdOfflineBooking } from "./offline-payment";
import { payWindowState } from "./payment-drain";
import { PaymentProcessingNotice } from "./PaymentProcessingNotice";
import { useBookingPayment } from "./useBookingPayment";
interface PayNowButtonProps {
booking: Freight.IBooking;
label?: string;
size?: ButtonProps["size"];
fullWidth?: boolean;
}
/**
* Self-contained "Pay now" action: shows the payment-method modal in place
* instead of navigating to the booking detail page. Drop it into list rows,
* cards, or anywhere a payable booking surfaces.
*/
export function PayNowButton({
booking,
label = "Pay now",
size = "xs",
fullWidth,
}: PayNowButtonProps) {
const pay = useBookingPayment(booking.id);
const pricing = booking.pricingBreakdown;
const payWindow = payWindowState(booking);
// Pay deadline passed but in-flight payments are still settling: show the
// drain countdown instead of any pay action, so nobody pays a second time.
// Checked before the USD branch — a bank transfer is just as double-payable.
if (payWindow.phase === "draining" && payWindow.drainEndsAt) {
return (
<PaymentProcessingNotice
drainEndsAt={payWindow.drainEndsAt}
variant="inline"
/>
);
}
// Window fully over (drain included) — nothing to pay against anymore.
if (payWindow.phase === "closed") {
return null;
}
// USD is paid by bank transfer and confirmed by Finance — no online payment.
if (isUsdOfflineBooking(booking)) {
return (
<Badge
size={size === "xs" ? "md" : "lg"}
radius="md"
variant="light"
color="yellow"
fullWidth={fullWidth}
leftSection={<Landmark size={12} />}
styles={{ label: { textTransform: "none", fontWeight: 700 } }}
>
Pay by bank transfer
</Badge>
);
}
return (
<ModalSafeWrapper>
<Button
size={size}
radius="md"
fw={700}
fz={13}
color="edr-green"
fullWidth={fullWidth}
leftSection={<CreditCard size={14} />}
onClick={(e) => {
// Don't let a surrounding row-click handler fire.
e.stopPropagation();
pay.open();
}}
>
{label}
</Button>
<PaymentMethodModal
opened={pay.modalOpen}
onClose={pay.close}
amountLabel={pricing ? priceTotal(pricing) : undefined}
currency={pricing?.currency ?? booking.paymentCurrency}
processing={pay.processing}
error={pay.error}
otp={pay.otp}
bill={pay.bill}
onConfirm={pay.confirm}
/>
</ModalSafeWrapper>
);
}

View File

@@ -0,0 +1,184 @@
import { useQuery } from "@tanstack/react-query";
import { useMemo } from "react";
import { Freight } from "@edr/types";
import { isPayable } from "@/pages/billing/invoice-ui";
import { bookingsService } from "@/services/bookings.service";
import { invoicesService } from "@/services/invoices.service";
import { isUsdOfflineBooking } from "./offline-payment";
import { WAGON_CANCEL_FEE_INVOICE_TYPE } from "./useBookingPayment";
export type PayableAction =
| "PAY"
| "BANK_TRANSFER"
| "UPLOAD_SLIP"
| "APPROVE"
| "DECIDE";
export interface PayableItem {
id: string;
label: string;
detail?: string | null;
amount: number;
currency: string;
/** What the customer must do with it. */
action: PayableAction;
/** DOM id of the Payments-tab card that handles it. */
anchor: string;
}
/** Card ids on the Payments tab — the summary strip scrolls to these. */
export const PAYABLE_ANCHORS = {
freight: "freight-payment",
charges: "clearance-charges",
customs: "customs-payments",
wagons: "wagon-cancellation",
} as const;
/** Booking statuses at which the freight invoice is actually due (mirrors the API). */
const FREIGHT_PAYABLE_STATUSES = new Set([
"FULLY_EXECUTED",
"SELECTED_FOR_BATCH",
"AWAITING_PAYMENT",
]);
const CHARGE_LABEL: Record<Freight.ClearanceChargeType, string> = {
PORT_CHARGES: "Port charges",
MISCELLANEOUS: "Miscellaneous charge",
};
/** Items the customer still has to review before anything is payable. */
export const isReviewAction = (a: PayableAction) =>
a === "DECIDE" || a === "APPROVE";
/**
* Everything the customer still owes or must decide on for one booking,
* assembled from the same queries the Payments-tab cards use (shared keys, so
* no extra requests): freight + wagon-fee + final invoices, clearance charges,
* customs duty advices. Mirrors the server's `my-payables` rule set.
*/
export function useBookingPayables(booking: Freight.IBooking) {
const isPhased = Boolean(booking.customsClearingEnabled && booking.contractId);
const invoicesQ = useQuery({
queryKey: ["booking-invoices", booking.id],
queryFn: () => invoicesService.listForSource("booking", booking.id),
});
const chargesQ = useQuery({
queryKey: ["booking-clearance-charges", booking.id],
queryFn: () => bookingsService.getClearanceCharges(booking.id),
});
const clearanceQ = useQuery({
queryKey: ["booking-clearance", booking.id],
queryFn: () => bookingsService.getClearance(booking.id),
enabled: isPhased,
});
const items = useMemo(() => {
const out: PayableItem[] = [];
const offline = isUsdOfflineBooking(booking);
for (const inv of invoicesQ.data ?? []) {
const balance = Number(inv.balanceAmount ?? 0);
// The GL Djibouti post-offload final invoice was removed from the
// clearance flow. Existing ones stay payable from the billing pages; they
// are no longer raised here or chased as an outstanding clearance item.
if (inv.type === Freight.GL_FINAL_INVOICE_TYPE) continue;
if (!isPayable(inv.status) || balance <= 0) continue;
if (inv.type === WAGON_CANCEL_FEE_INVOICE_TYPE) {
out.push({
id: inv.id,
label: "Wagon cancellation fee",
detail: inv.invoiceNumber,
amount: balance,
currency: inv.currency,
action: "PAY",
anchor: PAYABLE_ANCHORS.wagons,
});
continue;
}
if (
booking.paymentStatus !== "PAID" &&
FREIGHT_PAYABLE_STATUSES.has(booking.status as string)
) {
out.push({
id: inv.id,
label: "Freight",
detail: inv.invoiceNumber,
amount: balance,
currency: inv.currency,
action: offline ? "BANK_TRANSFER" : "PAY",
anchor: PAYABLE_ANCHORS.freight,
});
}
}
for (const c of chargesQ.data ?? []) {
if (c.status !== "SENT" && c.status !== "ACCEPTED") continue;
out.push({
id: c.id,
label: CHARGE_LABEL[c.type],
detail: c.status === "SENT" ? c.description : c.invoiceNumber,
amount: c.amount ?? 0,
currency: c.currency ?? "",
action: c.status === "SENT" ? "DECIDE" : "PAY",
anchor: PAYABLE_ANCHORS.charges,
});
}
const cl = clearanceQ.data;
if (cl) {
const dutyPaid = cl.milestones?.some(
(m) => m.milestoneCode === "DUTY_TAX_PAID" && m.status === "COMPLETED",
);
if (cl.dutyRequired && cl.dutyAdvice && !dutyPaid) {
out.push({
id: "duty",
label: "Customs duty & tax",
detail: cl.dutyAdvice.declarationSerial
? `Payment code ${cl.dutyAdvice.declarationSerial}`
: null,
amount: cl.dutyAdvice.amount,
currency: cl.dutyAdvice.currency,
action: "UPLOAD_SLIP",
anchor: PAYABLE_ANCHORS.customs,
});
}
if (cl.secondDuty?.advised && !cl.secondDuty.paid) {
out.push({
id: "second-duty",
label: "Additional duty & tax",
detail: cl.secondDuty.declarationSerial
? `Payment code ${cl.secondDuty.declarationSerial}`
: null,
amount: cl.secondDuty.amount ?? 0,
currency: cl.secondDuty.currency ?? "",
action: "UPLOAD_SLIP",
anchor: PAYABLE_ANCHORS.customs,
});
}
}
return out;
}, [booking, invoicesQ.data, chargesQ.data, clearanceQ.data]);
// Payable now, per currency. Items still under review are not "due" yet.
const dueTotals = useMemo(() => {
const m = new Map<string, number>();
for (const it of items) {
if (isReviewAction(it.action) || !it.currency) continue;
m.set(it.currency, (m.get(it.currency) ?? 0) + it.amount);
}
return [...m.entries()].map(([currency, amount]) => ({ currency, amount }));
}, [items]);
return {
items,
dueTotals,
reviewCount: items.filter((i) => isReviewAction(i.action)).length,
loading:
invoicesQ.isPending ||
chargesQ.isPending ||
(isPhased && clearanceQ.isPending),
};
}

View File

@@ -0,0 +1,26 @@
import { useQuery } from "@tanstack/react-query";
import { useMemo } from "react";
import type { Freight } from "@edr/types";
import { bookingsService } from "@/services/bookings.service";
export const MY_PAYABLES_KEY = ["my-payables"] as const;
/**
* Outstanding payments for every booking of the signed-in company, keyed by
* booking id. One request shared by every row on the home page and the
* bookings list (react-query dedupes by key), so rows can show "Pay" without
* each resolving their own invoices.
*/
export function useMyPayables(): Map<string, Freight.BookingPayableSummary> {
const { data } = useQuery({
queryKey: MY_PAYABLES_KEY,
queryFn: bookingsService.getMyPayables,
staleTime: 30_000,
refetchOnWindowFocus: true,
});
return useMemo(
() => new Map((data ?? []).map((p) => [p.bookingId, p] as const)),
[data],
);
}

View File

@@ -29,7 +29,6 @@ import {
Textarea,
ThemeIcon,
Title,
Tooltip,
} from "@mantine/core";
import {
AlertCircle,
@@ -281,7 +280,12 @@ function mapBookingToShipmentValues(
}>;
};
const values: Partial<ShipmentFormInputValues> = {
paymentCurrency: "ETB",
// Resubmit keeps the currency the customer already chose on this booking;
// a missing value falls back to empty so the choice is made deliberately.
paymentCurrency:
booking.paymentCurrency === "USD" || booking.paymentCurrency === "ETB"
? booking.paymentCurrency
: "",
withReturn: booking.equipmentReturn === "WITH_RETURN",
cargoDescription: b.cargoFreeText ?? "",
...(b.contractRouteId ? { contractRouteId: b.contractRouteId } : {}),
@@ -390,8 +394,9 @@ function NewShipmentBookingForm({
// Seed the equipment-return toggle from the contract; the customer can
// still flip it per shipment.
withReturn: contract.equipmentReturn === "WITH_RETURN",
// ponytail: ETB-only for now — preset since there is no other choice.
paymentCurrency: "ETB",
// Starts empty so the choice is deliberate (schema requires it).
// Intercity hides the field entirely, so it keeps the forced ETB.
paymentCurrency: contract.tradeDirection === "DOMESTIC" ? "ETB" : "",
},
resolver: zodResolver(
createShipmentFormSchema({
@@ -413,10 +418,9 @@ function NewShipmentBookingForm({
mode: "onChange",
});
// 20ft containers ride two per wagon, so an odd total leaves one unpaired and
// the booking can never be planned. The server's shipment validation reports
// it too, but only once the price modal opens — block it inline instead, the
// same way the direct-booking wizard does (new-booking-form `calcWagons`).
// 20ft containers ride two per wagon. On the COMPLETION page an odd total
// hard-blocks submit — odd (consolidated) bookings are GL's job in the
// backoffice. The direct-booking route keeps the consolidation notice.
const watchedContainers = form.watch("containers");
const ft20Total =
contract.freightType === "CONTAINER"
@@ -425,6 +429,7 @@ function NewShipmentBookingForm({
.reduce((sum, l) => sum + Number(l.quantity || 0), 0)
: 0;
const hasOdd20ft = ft20Total % 2 === 1;
const blockOdd20ft = hasOdd20ft && Boolean(completeBookingId);
// COMPLETION mode: fetch the booking — a changes-requested resubmit prefills
// the form from it and shows the operations note + uploaded documents.
@@ -581,8 +586,9 @@ function NewShipmentBookingForm({
// run it for every freight type; container contracts additionally get
// overweight warnings + 20ft pairing hard-blocks surfaced in the modal.
const handleReview = form.handleSubmit((values) => {
// An unpaired 20ft can never be planned onto a wagon — don't even price it.
if (hasOdd20ft) return;
// Completion: odd 20ft counts never reach review — the red alert next to
// the button explains; odd (consolidated) bookings are GL's backoffice job.
if (blockOdd20ft) return;
setPendingValues(values);
validateMutation.reset();
validateMutation.mutate(buildDto(values));
@@ -747,27 +753,37 @@ function NewShipmentBookingForm({
Fix the highlighted fields before reviewing the price.
</Alert>
) : null}
<Group justify="flex-end">
<Tooltip
label={`Book an even number of 20ft containers — ${ft20Total} is odd and would leave one unpaired.`}
withArrow
disabled={!hasOdd20ft}
{blockOdd20ft ? (
<Alert
color="red"
variant="light"
radius="md"
icon={<AlertCircle size={16} />}
mb="sm"
>
{/* Mantine tooltips get no pointer events from a disabled button,
so the wrapper carries the hover target. */}
<Box>
<Button
type="button"
color="edr-green"
radius="md"
leftSection={<Receipt size={16} />}
onClick={handleReview}
disabled={hasOdd20ft}
>
{isResubmit ? "Change booking" : "Review price & book"}
</Button>
</Box>
</Tooltip>
{`${ft20Total} is an odd number of 20ft containers. 20ft containers travel two per wagon, so they must be booked in even numbers — add one more or remove one (e.g. ${ft20Total + 1} or ${ft20Total - 1}).`}
</Alert>
) : hasOdd20ft ? (
<Alert
color="yellow"
variant="light"
radius="md"
icon={<AlertCircle size={16} />}
mb="sm"
>
{`${ft20Total} is an odd number of 20ft containers — this booking will be paired with another customer's odd booking to share a wagon, or held until one is available.`}
</Alert>
) : null}
<Group justify="flex-end">
<Button
type="button"
color="edr-green"
radius="md"
leftSection={<Receipt size={16} />}
onClick={handleReview}
>
{isResubmit ? "Change booking" : "Review price & book"}
</Button>
</Group>
</Box>
</Box>
@@ -1687,18 +1703,16 @@ function CargoStep({
if (ft20 % 2 !== 1) return null;
return (
<Alert
color="red"
color="yellow"
variant="light"
radius="md"
icon={<AlertCircle size={16} />}
title={`Odd number of 20ft containers (${ft20})`}
>
<Text fz={13}>
20ft containers travel two per wagon, so they must be booked
in even numbers. Please add one more 20ft container or remove
one (e.g. book {ft20 + 1} or {ft20 - 1} instead of {ft20})
the booking cannot be submitted with an unpaired 20ft
container.
20ft containers travel two per wagon. This booking will be
paired with another customer&apos;s odd booking to share a
wagon, or held until one is available.
</Text>
</Alert>
);

View File

@@ -9,12 +9,12 @@ import {
Loader,
NumberInput,
Paper,
SegmentedControl,
Stack,
Text,
Textarea,
Title,
} from "@mantine/core";
import { CurrencySelector } from "@edr/ui-common";
import { AlertCircle, ArrowLeft, CalendarDays, Send } from "lucide-react";
import toast from "react-hot-toast";
import type { Freight } from "@edr/types";
@@ -36,7 +36,10 @@ export default function NewShipmentRequestPage() {
const [bulkAmount, setBulkAmount] = useState<number | string>("");
// GL books this shipment on the customer's behalf, so the currency they want
// to be invoiced in has to be stated here — the contract itself quotes USD.
const [paymentCurrency, setPaymentCurrency] = useState<"USD" | "ETB">("USD");
// Starts empty so the billing-currency choice is deliberate — required at
// submit. Intercity/export are forced to ETB (server-enforced too).
const [paymentCurrency, setPaymentCurrency] = useState<"USD" | "ETB" | "">("");
const [currencyError, setCurrencyError] = useState<string | undefined>();
const [notes, setNotes] = useState("");
const { data: contract, isLoading } = useQuery({
@@ -106,19 +109,23 @@ export default function NewShipmentRequestPage() {
contract.cargoScope?.[0];
const isPerItem = bulkScope?.cargoType?.unitOfMeasure === "PER_ITEM";
// 20ft containers ride two per wagon, so an odd total leaves one unpaired and
// the request cannot be planned. Consolidation (pairing the odd container with
// another customer's odd booking) is built but switched off for now, so an odd
// request is blocked here rather than dead-ending downstream.
// 20ft containers ride two per wagon. An odd total no longer blocks the
// request — the server auto-pairs it with another customer's odd booking, or
// parks it as PENDING_CONSOLIDATION until one shows up (same consolidation
// gate the direct-booking flow already uses).
const ft20Requested = isContainer ? Number(qtyBySize["20ft"]) || 0 : 0;
const hasOdd20ft = ft20Requested % 2 === 1;
const handleSubmit = () => {
if (hasOdd20ft) return;
if (!isIntercity && !isExport && !paymentCurrency) {
setCurrencyError("Select the billing currency for this shipment.");
return;
}
const dto: Freight.CreateBookingRequestDto = {
contractRouteId: route?.id,
scheduledDate: hasCustoms ? undefined : scheduledDate || undefined,
paymentCurrency: isIntercity || isExport ? "ETB" : paymentCurrency,
paymentCurrency:
isIntercity || isExport ? "ETB" : (paymentCurrency as "USD" | "ETB"),
notes: notes.trim() || undefined,
};
@@ -221,17 +228,16 @@ export default function NewShipmentRequestPage() {
{hasOdd20ft ? (
<Alert
color="red"
color="yellow"
variant="light"
radius="md"
icon={<AlertCircle size={16} />}
title={`Odd number of 20ft containers (${ft20Requested})`}
>
<Text fz={13}>
20ft containers travel two per wagon, so they must be requested
in even numbers. Please add one more 20ft container or remove
one (e.g. request {ft20Requested + 1} or {ft20Requested - 1}{" "}
instead of {ft20Requested}).
20ft containers travel two per wagon. This request will be
paired with another customer&apos;s odd booking to share a
wagon, or held until one is available.
</Text>
</Alert>
) : null}
@@ -253,20 +259,15 @@ export default function NewShipmentRequestPage() {
? "Export shipments are invoiced in ETB."
: "Your contract is quoted in USD. Global Logistics will book this shipment and invoice you in the currency you pick here."}
</Text>
<SegmentedControl
<CurrencySelector
value={isIntercity || isExport ? "ETB" : paymentCurrency}
onChange={(v) => setPaymentCurrency(v as "USD" | "ETB")}
onChange={(v) => {
setPaymentCurrency(v);
setCurrencyError(undefined);
}}
disabled={isIntercity || isExport}
data={
isExport
? [{ label: "ETB", value: "ETB" }]
: [
{ label: "USD", value: "USD" },
{ label: "ETB", value: "ETB" },
]
}
color="teal"
radius={10}
allowUsd={!isIntercity && !isExport}
error={currencyError}
/>
</Box>
@@ -282,7 +283,6 @@ export default function NewShipmentRequestPage() {
leftSection={<Send size={16} />}
loading={submit.isPending}
onClick={handleSubmit}
disabled={hasOdd20ft}
>
Submit shipment request
</Button>

View File

@@ -48,7 +48,9 @@ function serviceFeatures(s: ServiceItem) {
{
key: "customs",
icon: ShieldCheck,
label: "Customs clearance",
label: s.includesEthiopianCustomsOnly
? "Ethiopian customs clearance"
: "Customs clearance",
on: s.includesCustoms,
},
];

View File

@@ -7,6 +7,19 @@ import { client } from "../utils/api";
const B = URL_CONSTANTS.BOOKINGS;
export interface EmptyContainerReturn {
id: string;
containerNumber: string;
containerSize: "20" | "40" | null;
returnDate: string;
facility: string | null;
yard: string | null;
zone: string | null;
condition: string | null;
status: string;
returnedBy: "EDR" | "CUSTOMER" | null;
}
export interface MileVehicleSummary {
plate: string | null;
code: string | null;
@@ -204,6 +217,8 @@ export interface BookingWagonContainer {
sealNumber: string | null;
positionOnWagon: number | null;
grossWeightTons: string | null;
/** Container size in feet (20/40) — identifies the shared consolidation wagon. */
sizeFt: number | null;
}
/** One allocated wagon of a booking, as returned by GET /bookings/:id/wagons. */
@@ -324,6 +339,11 @@ export const bookingsService = {
const { data } = await client.get(`/api/bookings/${id}/mile-summary`);
return data.data;
},
/** Ad-hoc extra charges finance has raised against this booking. */
getAdditionalCharges: async (id: string): Promise<Freight.AdditionalCharge[]> => {
const { data } = await client.get(`/api/bookings/${id}/additional-charges`);
return data.data;
},
assignCustomerTruck: async (
id: string,
payload: CustomerTruckAssignmentPayload,
@@ -378,6 +398,19 @@ export const bookingsService = {
);
return data.data ?? data;
},
listEmptyContainerReturns: async (bookingId: string): Promise<EmptyContainerReturn[]> => {
const { data } = await client.get(
`/api/import-operations/bookings/${bookingId}/empty-container-returns`,
);
return data.data ?? data;
},
downloadEquipmentInterchangeDocument: async (returnId: string): Promise<Blob> => {
const { data } = await client.get(
`/api/import-operations/empty-container-returns/${returnId}/document`,
{ responseType: "blob" },
);
return data;
},
downloadBookingGrnDocument: async (bookingId: string): Promise<Blob> => {
const { data } = await client.get(
`/api/warehouse-inventory/bookings/${bookingId}/grn-document`,
@@ -528,6 +561,40 @@ export const bookingsService = {
return data.data ?? data;
},
/** Outstanding payments per booking — drives the "Pay" badge on list/home rows. */
getMyPayables: async (): Promise<Freight.BookingPayableSummary[]> => {
const { data } = await client.get(`/api/bookings/my-payables`);
return data.data ?? data;
},
// ── Clearance charges (port + miscellaneous) the customer approves, then pays ──
getClearanceCharges: async (id: string): Promise<Freight.ClearanceCharge[]> => {
const { data } = await client.get(`/api/bookings/${id}/clearance/charges`);
return data.data ?? data;
},
acceptClearanceCharge: async (
id: string,
chargeId: string,
): Promise<Freight.ClearanceCharge[]> => {
const { data } = await client.post(
`/api/bookings/${id}/clearance/charges/${chargeId}/accept`,
);
return data.data ?? data;
},
rejectClearanceCharge: async (
id: string,
chargeId: string,
note: string,
): Promise<Freight.ClearanceCharge[]> => {
const { data } = await client.post(
`/api/bookings/${id}/clearance/charges/${chargeId}/reject`,
{ note },
);
return data.data ?? data;
},
acceptDraftDeclaration: async (id: string): Promise<Freight.IBooking> => {
const { data } = await client.post(
`/api/bookings/${id}/clearance/draft-declaration/accept`,
@@ -558,6 +625,13 @@ export const bookingsService = {
return data;
},
downloadCarriageAcceptanceSheet: async (id: string): Promise<Blob> => {
const { data } = await client.get(B.CARRIAGE_ACCEPTANCE_SHEET(id), {
responseType: "blob",
});
return data;
},
checkPayment: async (orderId: string): Promise<{ status: string }> => {
const { data } = await client.post(`/api/payments/bookings/check-payment/${orderId}`);
return data.data ?? data;
@@ -728,20 +802,24 @@ export const bookingsService = {
return data.data ?? data;
},
/** Void a FEE_PENDING request — the fee invoice is cancelled, nothing was released. */
withdrawWagonCancellation: async (
cancellationId: string,
): Promise<WagonCancellation> => {
const { data } = await client.post(
`/api/bookings/wagon-cancellations/${cancellationId}/withdraw`,
);
return data.data ?? data;
},
// Withdraw was removed from the portal on purpose: a customer's cancellation
// request is final — only backoffice staff (void permission) can revert it.
/** Rebook a CREDIT_AVAILABLE cancellation onto a shipment day → new PAID booking. */
rebookWagonCancellation: async (
cancellationId: string,
payload: { scheduledDate: string },
payload: {
scheduledDate: string;
/** Optional unit edits — sizes/quantities must match the credit exactly. */
containers?: Array<{
containerSize: string;
units: Array<{
containerNumber: string;
sealNumber?: string;
vgmTons?: number;
}>;
}>;
},
): Promise<{ cancellation: WagonCancellation; bookingId: string }> => {
const { data } = await client.post(
`/api/bookings/wagon-cancellations/${cancellationId}/rebook`,