mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 14:50:57 +00:00
Merge branch 'dev' into freight/nati-2
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user