mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge branch 'dev' into freight/nati-2
This commit is contained in:
@@ -316,7 +316,7 @@ const App = () => {
|
||||
<RequirePermission permission={FREIGHT_PERMS.customers.view}>
|
||||
<CustomersPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
}//
|
||||
/>
|
||||
<Route
|
||||
path="customers/:id"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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={() => {
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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} />
|
||||
))}
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 1–10 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;
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)),
|
||||
|
||||
|
||||
@@ -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',
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -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}`,
|
||||
),
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'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 & issue fee
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
{viewer}
|
||||
</PageShell>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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'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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
@@ -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's odd booking to share a
|
||||
wagon, or held until one is available.
|
||||
</Text>
|
||||
</Alert>
|
||||
);
|
||||
|
||||
@@ -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'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>
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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`,
|
||||
|
||||
Reference in New Issue
Block a user