mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge branch 'dev' into freight/nati-2
Conflict in ClearanceDocumentsPage: this branch migrated the page to the pill FilterBar, dev added filters to the Select stack it replaced. Kept the FilterBar and carried dev's additions across as a "Booked by" (customerKind) FilterDef plus the shipping-line search placeholder; dev's startOfDayIso/endOfDayIso went away because dateRangeParams already does that. The Ship icon import is needed by dev's shipping-line customer cell, which merged cleanly on its own.
This commit is contained in:
@@ -315,7 +315,7 @@ const App = () => {
|
||||
}
|
||||
/>
|
||||
{/* Merged Invoices / Payments / USD Payments hub — tabs switch via
|
||||
?tab=invoices|payments|usd-payments (default invoices). Access is
|
||||
?tab=invoices|payments|manual-payments (default invoices). Access is
|
||||
OR'd across both keys so a user with just one still gets in; each
|
||||
tab hides itself if the user lacks the permission it used to be
|
||||
routed on. */}
|
||||
@@ -352,7 +352,7 @@ const App = () => {
|
||||
/>
|
||||
<Route
|
||||
path="usd-payments"
|
||||
element={<Navigate to="/dashboard/invoices?tab=usd-payments" replace />}
|
||||
element={<Navigate to="/dashboard/invoices?tab=manual-payments" replace />}
|
||||
/>
|
||||
<Route
|
||||
path="invoices/:id"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Package } from "lucide-react";
|
||||
import { SimpleGrid, Divider, Box, Table, Text, Badge } from "@mantine/core";
|
||||
import { SimpleGrid, Divider, Box, Group, Table, Text, Badge } from "@mantine/core";
|
||||
|
||||
import type { BookingDetail } from "@/types/booking";
|
||||
import { cargoTonsAndItems } from "@/utils/cargoWeight";
|
||||
@@ -29,12 +29,37 @@ export function BookingCargoCard({ booking }: BookingCargoCardProps) {
|
||||
Number(c.hazardousQuantity ?? 0) > 0 || Number(c.reeferQuantity ?? 0) > 0,
|
||||
);
|
||||
|
||||
const isBulk = booking.freightType === "BULK";
|
||||
// Bulk: the commodity itself (Wheat, Steel…) is the headline. Containers:
|
||||
// the freight kind, with the shipper's own description alongside.
|
||||
const cargoHeadline = isBulk
|
||||
? (booking.cargoType?.label ?? booking.cargoType?.name ?? "Bulk cargo")
|
||||
: "Containers";
|
||||
const cargoDescription = booking.cargoFreeText?.trim() || null;
|
||||
|
||||
return (
|
||||
<SectionCard icon={Package} title="Cargo specifications" accent="orange">
|
||||
<Group gap="sm" align="center" mb="md" wrap="wrap">
|
||||
<Text fw={800} fz={22} lh={1.1}>
|
||||
{cargoHeadline}
|
||||
</Text>
|
||||
<Badge variant="light" color={isBulk ? "orange" : "blue"} radius="sm">
|
||||
{isBulk ? "Bulk" : "Container"}
|
||||
</Badge>
|
||||
{cargoDescription ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
— {cargoDescription}
|
||||
</Text>
|
||||
) : null}
|
||||
</Group>
|
||||
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="sm">
|
||||
<MetricTile
|
||||
label="Cargo type"
|
||||
value={booking.cargoType?.label ?? booking.freightType}
|
||||
label={isBulk ? "Commodity" : "Cargo type"}
|
||||
value={
|
||||
isBulk
|
||||
? (booking.cargoType?.label ?? booking.cargoType?.name ?? "—")
|
||||
: (cargoDescription ?? booking.freightType)
|
||||
}
|
||||
/>
|
||||
<MetricTile label="Total VGM" value={`${tons} tons`} />
|
||||
{items != null && <MetricTile label="Items" value={`${items}`} />}
|
||||
|
||||
@@ -1,11 +1,30 @@
|
||||
import { Alert, Badge, Button, Card, Group, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Modal,
|
||||
NumberInput,
|
||||
Radio,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { AlertTriangle, RefreshCw, Send, ShieldCheck } from "lucide-react";
|
||||
import { AlertTriangle, Ban, Download, FileText, RefreshCw, Send, ShieldCheck } from "lucide-react";
|
||||
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
import { api } from "@/services/api";
|
||||
import type { EimsInvoiceStatus } from "@/types/eims";
|
||||
import { eimsService } from "@/services/eims.service";
|
||||
import { openPdfBlob } from "@/components/warehouses/pdf";
|
||||
import { EIMS_MODE_OF_PAYMENT, type EimsInvoiceStatus, type EimsModeOfPayment } from "@/types/eims";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
const STATUS_COLOR: Record<EimsInvoiceStatus, string> = {
|
||||
@@ -14,6 +33,7 @@ const STATUS_COLOR: Record<EimsInvoiceStatus, string> = {
|
||||
REGISTERED: "edr-green",
|
||||
FAILED: "red",
|
||||
UNKNOWN: "orange",
|
||||
CANCELLED: "gray",
|
||||
};
|
||||
|
||||
const STATUS_LABEL: Record<EimsInvoiceStatus, string> = {
|
||||
@@ -22,6 +42,7 @@ const STATUS_LABEL: Record<EimsInvoiceStatus, string> = {
|
||||
REGISTERED: "Filed",
|
||||
FAILED: "Rejected",
|
||||
UNKNOWN: "Unacknowledged",
|
||||
CANCELLED: "Cancelled",
|
||||
};
|
||||
|
||||
function Field({ label, value }: { label: string; value?: string | number | null }) {
|
||||
@@ -37,6 +58,367 @@ function Field({ label, value }: { label: string; value?: string | number | null
|
||||
);
|
||||
}
|
||||
|
||||
/** Reason codes from the collection docs, e.g. "1" (Duplicate), "6" (Calculation Error). */
|
||||
const CANCEL_REASON_CODES = [
|
||||
{ value: "1", label: "1 — Duplicate" },
|
||||
{ value: "2", label: "2 — Buyer request" },
|
||||
{ value: "3", label: "3 — Data entry error" },
|
||||
{ value: "6", label: "6 — Calculation error" },
|
||||
];
|
||||
|
||||
function CancelModal({
|
||||
invoiceId,
|
||||
opened,
|
||||
onClose,
|
||||
}: {
|
||||
invoiceId: string;
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { toast } = useToast();
|
||||
const [reasonCode, setReasonCode] = useState<string | null>(null);
|
||||
const [remark, setRemark] = useState("");
|
||||
|
||||
const cancel = useMutation(
|
||||
api.invoices.eimsCancel.mutationOptions({
|
||||
onSuccess: () => {
|
||||
onClose();
|
||||
toast({ title: "Cancelled with MoR" });
|
||||
},
|
||||
onError: (error) => toast({ title: "Could not cancel", description: error.message, variant: "destructive" }),
|
||||
}),
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title="Cancel EIMS registration" centered>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
Cancels this invoice's registered document at MoR. Irreversible — an already-cancelled
|
||||
invoice refuses a second attempt.
|
||||
</Text>
|
||||
<Select
|
||||
label="Reason code"
|
||||
withAsterisk
|
||||
data={CANCEL_REASON_CODES}
|
||||
value={reasonCode}
|
||||
onChange={setReasonCode}
|
||||
placeholder="Select a reason"
|
||||
/>
|
||||
<Textarea
|
||||
label="Remark"
|
||||
placeholder="Optional note"
|
||||
value={remark}
|
||||
onChange={(e) => setRemark(e.currentTarget.value)}
|
||||
autosize
|
||||
minRows={2}
|
||||
/>
|
||||
<Button
|
||||
color="red"
|
||||
leftSection={<Ban size={16} />}
|
||||
loading={cancel.isPending}
|
||||
disabled={!reasonCode}
|
||||
onClick={() => cancel.mutate({ id: invoiceId, reasonCode: reasonCode!, remark: remark.trim() || undefined })}
|
||||
>
|
||||
Cancel with MoR
|
||||
</Button>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function SalesReceiptModal({
|
||||
invoiceId,
|
||||
opened,
|
||||
onClose,
|
||||
}: {
|
||||
invoiceId: string;
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { toast } = useToast();
|
||||
const [modeOfPayment, setModeOfPayment] = useState<EimsModeOfPayment | null>(null);
|
||||
const [collectedAmount, setCollectedAmount] = useState<number | "">("");
|
||||
const [reason, setReason] = useState("");
|
||||
|
||||
const register = useMutation(
|
||||
api.invoices.eimsRegisterSalesReceipt.mutationOptions({
|
||||
onSuccess: (receipt) => {
|
||||
onClose();
|
||||
toast({ title: "Sales receipt filed", description: `RRN ${receipt.rrn ?? "—"}` });
|
||||
},
|
||||
onError: (error) => toast({ title: "Could not file receipt", description: error.message, variant: "destructive" }),
|
||||
}),
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title="File sales receipt" centered>
|
||||
<Stack gap="md">
|
||||
<Select
|
||||
label="Mode of payment"
|
||||
withAsterisk
|
||||
data={EIMS_MODE_OF_PAYMENT.map((v) => ({ value: v, label: v }))}
|
||||
value={modeOfPayment}
|
||||
onChange={(v) => setModeOfPayment(v as EimsModeOfPayment)}
|
||||
placeholder="Select"
|
||||
/>
|
||||
<NumberInput
|
||||
label="Collected amount"
|
||||
placeholder="Defaults to the invoice's paid amount"
|
||||
min={0}
|
||||
decimalScale={2}
|
||||
value={collectedAmount}
|
||||
onChange={(v) => setCollectedAmount(v === "" ? "" : Number(v))}
|
||||
/>
|
||||
<TextInput
|
||||
label="Reason"
|
||||
placeholder='Defaults to "Payment received"'
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.currentTarget.value)}
|
||||
/>
|
||||
<Button
|
||||
color="edr-green"
|
||||
leftSection={<Send size={16} />}
|
||||
loading={register.isPending}
|
||||
disabled={!modeOfPayment}
|
||||
onClick={() =>
|
||||
register.mutate({
|
||||
id: invoiceId,
|
||||
modeOfPayment: modeOfPayment!,
|
||||
collectedAmount: collectedAmount === "" ? undefined : collectedAmount,
|
||||
reason: reason.trim() || undefined,
|
||||
})
|
||||
}
|
||||
>
|
||||
File with MoR
|
||||
</Button>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function WithholdingReceiptModal({
|
||||
invoiceId,
|
||||
opened,
|
||||
onClose,
|
||||
}: {
|
||||
invoiceId: string;
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { toast } = useToast();
|
||||
const [type, setType] = useState("TWHT");
|
||||
const [preTaxAmount, setPreTaxAmount] = useState<number | "">("");
|
||||
const [withholdingAmount, setWithholdingAmount] = useState<number | "">("");
|
||||
const [reason, setReason] = useState("");
|
||||
|
||||
const register = useMutation(
|
||||
api.invoices.eimsRegisterWithholdingReceipt.mutationOptions({
|
||||
onSuccess: (receipt) => {
|
||||
onClose();
|
||||
toast({ title: "Withholding receipt filed", description: `RRN ${receipt.rrn ?? "—"}` });
|
||||
},
|
||||
onError: (error) => toast({ title: "Could not file receipt", description: error.message, variant: "destructive" }),
|
||||
}),
|
||||
);
|
||||
|
||||
const valid = preTaxAmount !== "" && withholdingAmount !== "";
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title="File withholding receipt" centered>
|
||||
<Stack gap="md">
|
||||
<TextInput label="Type" withAsterisk value={type} onChange={(e) => setType(e.currentTarget.value)} />
|
||||
<NumberInput
|
||||
label="Pre-tax amount"
|
||||
withAsterisk
|
||||
min={0}
|
||||
decimalScale={2}
|
||||
value={preTaxAmount}
|
||||
onChange={(v) => setPreTaxAmount(v === "" ? "" : Number(v))}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Withholding amount"
|
||||
withAsterisk
|
||||
min={0}
|
||||
decimalScale={2}
|
||||
value={withholdingAmount}
|
||||
onChange={(v) => setWithholdingAmount(v === "" ? "" : Number(v))}
|
||||
/>
|
||||
<TextInput
|
||||
label="Reason"
|
||||
placeholder='Defaults to "Withholding"'
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.currentTarget.value)}
|
||||
/>
|
||||
<Button
|
||||
color="edr-green"
|
||||
leftSection={<Send size={16} />}
|
||||
loading={register.isPending}
|
||||
disabled={!valid}
|
||||
onClick={() =>
|
||||
register.mutate({
|
||||
id: invoiceId,
|
||||
type,
|
||||
preTaxAmount: preTaxAmount as number,
|
||||
withholdingAmount: withholdingAmount as number,
|
||||
reason: reason.trim() || undefined,
|
||||
})
|
||||
}
|
||||
>
|
||||
File with MoR
|
||||
</Button>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function MemoModal({
|
||||
invoiceId,
|
||||
opened,
|
||||
onClose,
|
||||
}: {
|
||||
invoiceId: string;
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { toast } = useToast();
|
||||
const [type, setType] = useState<"CRE" | "DEB">("CRE");
|
||||
const [reason, setReason] = useState("");
|
||||
|
||||
const issue = useMutation(
|
||||
api.invoices.issueMemo.mutationOptions({
|
||||
onSuccess: (memo) => {
|
||||
onClose();
|
||||
toast({ title: "Memo issued", description: `${memo.invoiceNumber} — file it with MoR separately` });
|
||||
},
|
||||
onError: (error) => toast({ title: "Could not issue memo", description: error.message, variant: "destructive" }),
|
||||
}),
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title="Issue credit/debit memo" centered>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
Creates a new invoice linked to this one, with every line copied verbatim. Filing it with
|
||||
MoR is a separate step — it does not happen automatically here.
|
||||
</Text>
|
||||
<Radio.Group value={type} onChange={(v) => setType(v as "CRE" | "DEB")} label="Type">
|
||||
<Stack gap="xs" mt="xs">
|
||||
<Radio value="CRE" label="Credit memo" description="Reduces what the buyer owes; created settled." />
|
||||
<Radio value="DEB" label="Debit memo" description="An additional charge; created as a new open invoice." />
|
||||
</Stack>
|
||||
</Radio.Group>
|
||||
<Textarea
|
||||
label="Reason"
|
||||
withAsterisk
|
||||
placeholder="Why this memo is being issued"
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.currentTarget.value)}
|
||||
autosize
|
||||
minRows={2}
|
||||
/>
|
||||
<Button
|
||||
color="edr-green"
|
||||
loading={issue.isPending}
|
||||
disabled={!reason.trim()}
|
||||
onClick={() => issue.mutate({ id: invoiceId, type, reason: reason.trim() })}
|
||||
>
|
||||
Issue memo
|
||||
</Button>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function ReceiptsSection({ invoiceId, canFile }: { invoiceId: string; canFile: boolean }) {
|
||||
const { toast } = useToast();
|
||||
const { data: receipts } = useQuery(api.invoices.eimsReceipts.queryOptions({ input: { id: invoiceId } }));
|
||||
const [salesOpen, setSalesOpen] = useState(false);
|
||||
const [withholdingOpen, setWithholdingOpen] = useState(false);
|
||||
const [downloadingId, setDownloadingId] = useState<string | null>(null);
|
||||
|
||||
const download = async (receiptId: string, receiptNumber: string) => {
|
||||
setDownloadingId(receiptId);
|
||||
try {
|
||||
const { data } = await eimsService.downloadReceiptDocument(invoiceId, receiptId);
|
||||
openPdfBlob(data, `${receiptNumber}.pdf`);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Could not download receipt",
|
||||
description: error instanceof Error ? error.message : undefined,
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setDownloadingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between">
|
||||
<Text fw={600} size="sm" c="edr-text">
|
||||
Receipts
|
||||
</Text>
|
||||
{canFile && (
|
||||
<Group gap="xs">
|
||||
<Button size="xs" variant="light" onClick={() => setSalesOpen(true)}>
|
||||
File sales receipt
|
||||
</Button>
|
||||
<Button size="xs" variant="light" onClick={() => setWithholdingOpen(true)}>
|
||||
File withholding receipt
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{receipts && receipts.length > 0 ? (
|
||||
<Table striped withTableBorder={false} verticalSpacing="xs">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Kind</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th>RRN</Table.Th>
|
||||
<Table.Th />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{receipts.map((r) => (
|
||||
<Table.Tr key={r.id}>
|
||||
<Table.Td>{r.kind}</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={STATUS_COLOR[r.status] ?? "gray"} variant="light" size="sm">
|
||||
{STATUS_LABEL[r.status] ?? r.status}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td style={{ fontFamily: "monospace" }}>{r.rrn ?? "—"}</Table.Td>
|
||||
<Table.Td>
|
||||
{r.status === "REGISTERED" && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
leftSection={<Download size={14} />}
|
||||
loading={downloadingId === r.id}
|
||||
onClick={() => void download(r.id, r.receiptNumber)}
|
||||
>
|
||||
PDF
|
||||
</Button>
|
||||
)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed">
|
||||
No receipts filed yet.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<SalesReceiptModal invoiceId={invoiceId} opened={salesOpen} onClose={() => setSalesOpen(false)} />
|
||||
<WithholdingReceiptModal invoiceId={invoiceId} opened={withholdingOpen} onClose={() => setWithholdingOpen(false)} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* MoR EIMS filing state for one invoice, with the manual actions.
|
||||
*
|
||||
@@ -48,6 +430,12 @@ export function EimsFilingCard({ invoiceId }: { invoiceId: string }) {
|
||||
const { user } = useAuth();
|
||||
const { toast } = useToast();
|
||||
const canFile = hasPermission(user, FREIGHT_PERMS.invoices.eimsRegister);
|
||||
const canCancel = hasPermission(user, FREIGHT_PERMS.invoices.eimsCancel);
|
||||
const canFileReceipt = hasPermission(user, FREIGHT_PERMS.invoices.eimsReceiptRegister);
|
||||
const canIssueMemo = hasPermission(user, FREIGHT_PERMS.invoices.memoIssue);
|
||||
|
||||
const [cancelOpen, setCancelOpen] = useState(false);
|
||||
const [memoOpen, setMemoOpen] = useState(false);
|
||||
|
||||
const { data: eims, isLoading } = useQuery(
|
||||
api.invoices.eimsStatus.queryOptions({ input: { id: invoiceId }, enabled: Boolean(invoiceId) }),
|
||||
@@ -118,39 +506,78 @@ export function EimsFilingCard({ invoiceId }: { invoiceId: string }) {
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{canFile && (
|
||||
<Group gap="sm">
|
||||
{/* UNKNOWN is never re-filed from here: resubmitting risks a duplicate registration. */}
|
||||
{status !== "REGISTERED" && status !== "UNKNOWN" && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
radius="md"
|
||||
loading={register.isPending}
|
||||
disabled={busy}
|
||||
leftSection={status === "FAILED" ? <RefreshCw size={14} /> : <Send size={14} />}
|
||||
onClick={() => register.mutate({ id: invoiceId })}
|
||||
>
|
||||
{status === "FAILED" ? "File again" : "File with MoR"}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{eims.eimsIrn && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
radius="md"
|
||||
loading={verify.isPending}
|
||||
disabled={busy}
|
||||
leftSection={<ShieldCheck size={14} />}
|
||||
onClick={() => verify.mutate({ id: invoiceId })}
|
||||
>
|
||||
Verify with MoR
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
{status === "CANCELLED" && (
|
||||
<Alert color="gray" icon={<Ban size={16} />} title="Cancelled with MoR">
|
||||
{eims.eimsCancellationDate ? `Confirmed ${eims.eimsCancellationDate}. ` : ""}
|
||||
{eims.eimsCancellationRemark}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Group gap="sm">
|
||||
{canFile && (
|
||||
<>
|
||||
{/* UNKNOWN is never re-filed from here: resubmitting risks a duplicate registration. */}
|
||||
{status !== "REGISTERED" && status !== "UNKNOWN" && status !== "CANCELLED" && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
radius="md"
|
||||
loading={register.isPending}
|
||||
disabled={busy}
|
||||
leftSection={status === "FAILED" ? <RefreshCw size={14} /> : <Send size={14} />}
|
||||
onClick={() => register.mutate({ id: invoiceId })}
|
||||
>
|
||||
{status === "FAILED" ? "File again" : "File with MoR"}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{eims.eimsIrn && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
radius="md"
|
||||
loading={verify.isPending}
|
||||
disabled={busy}
|
||||
leftSection={<ShieldCheck size={14} />}
|
||||
onClick={() => verify.mutate({ id: invoiceId })}
|
||||
>
|
||||
Verify with MoR
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{canCancel && eims.eimsIrn && status !== "CANCELLED" && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="red"
|
||||
radius="md"
|
||||
leftSection={<Ban size={14} />}
|
||||
onClick={() => setCancelOpen(true)}
|
||||
>
|
||||
Cancel with MoR
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{canIssueMemo && status === "REGISTERED" && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
radius="md"
|
||||
leftSection={<FileText size={14} />}
|
||||
onClick={() => setMemoOpen(true)}
|
||||
>
|
||||
Issue credit/debit memo
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{eims.eimsIrn && <ReceiptsSection invoiceId={invoiceId} canFile={canFileReceipt} />}
|
||||
</Stack>
|
||||
|
||||
<CancelModal invoiceId={invoiceId} opened={cancelOpen} onClose={() => setCancelOpen(false)} />
|
||||
<MemoModal invoiceId={invoiceId} opened={memoOpen} onClose={() => setMemoOpen(false)} />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -256,7 +256,7 @@ const RuleEngineFormDialog = ({
|
||||
next.containerTypeId = "";
|
||||
next.cargoTypeId = "";
|
||||
}
|
||||
// Cargo kind (customs / lashing) decides both the container-type scope
|
||||
// Cargo kind (customs / cancellation) decides both the container-type scope
|
||||
// and the legal units (container → per box/wagon, bulk → per ton/wagon).
|
||||
if (name === "cargoKind") {
|
||||
next.containerTypeId = "";
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { Button, Group, Modal, Stack, Text, Textarea } from "@mantine/core";
|
||||
import { DateTimePicker } from "@mantine/dates";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
/**
|
||||
* Time + note for one leg of a train's journey — used both to log a pass
|
||||
* (defaults to now) and to correct an already-logged leg (prefilled). Past
|
||||
* times are allowed (staff record after the fact); the future is not, and the
|
||||
* server additionally keeps legs in corridor order.
|
||||
*/
|
||||
export function CheckpointTimeModal({
|
||||
opened,
|
||||
onClose,
|
||||
title,
|
||||
icon,
|
||||
description,
|
||||
initialOccurredAt,
|
||||
initialNote,
|
||||
submitLabel,
|
||||
submitColor = "edr-green",
|
||||
loading,
|
||||
onSubmit,
|
||||
}: {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
icon?: React.ReactNode;
|
||||
description?: string;
|
||||
/** ISO; omit to default to now. */
|
||||
initialOccurredAt?: string | null;
|
||||
initialNote?: string | null;
|
||||
submitLabel: string;
|
||||
submitColor?: string;
|
||||
loading: boolean;
|
||||
onSubmit: (values: { occurredAt: string; note: string }) => void;
|
||||
}) {
|
||||
const [at, setAt] = useState<Date | null>(null);
|
||||
const [note, setNote] = useState("");
|
||||
useEffect(() => {
|
||||
if (!opened) return;
|
||||
setAt(initialOccurredAt ? new Date(initialOccurredAt) : new Date());
|
||||
setNote(initialNote ?? "");
|
||||
}, [opened, initialOccurredAt, initialNote]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
centered
|
||||
radius="lg"
|
||||
title={
|
||||
<Group gap={8}>
|
||||
{icon}
|
||||
<Text fw={700}>{title}</Text>
|
||||
</Group>
|
||||
}
|
||||
>
|
||||
<Stack gap="md">
|
||||
{description ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
{description}
|
||||
</Text>
|
||||
) : null}
|
||||
<DateTimePicker
|
||||
label="Time"
|
||||
description="Defaults to now — pick an earlier time if you are recording after the fact."
|
||||
value={at}
|
||||
onChange={(v) => setAt(v ? new Date(v) : null)}
|
||||
maxDate={new Date()}
|
||||
valueFormat="DD MMM YYYY HH:mm"
|
||||
clearable={false}
|
||||
radius="md"
|
||||
/>
|
||||
<Textarea
|
||||
label="Note"
|
||||
placeholder="Optional"
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.currentTarget.value)}
|
||||
autosize
|
||||
minRows={2}
|
||||
maxRows={4}
|
||||
maxLength={500}
|
||||
radius="md"
|
||||
/>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" onClick={onClose} disabled={loading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color={submitColor}
|
||||
loading={loading}
|
||||
disabled={!at}
|
||||
onClick={() =>
|
||||
at && onSubmit({ occurredAt: at.toISOString(), note: note.trim() })
|
||||
}
|
||||
>
|
||||
{submitLabel}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
import { Fragment, useEffect, useMemo, useState } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { isAxiosError } from "axios";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Group,
|
||||
Paper,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { ArrowLeftRight, Boxes, Info, MoveRight, Wheat, X } from "lucide-react";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import type { TrainScheduleDetail } from "@/types/trainScheduling";
|
||||
|
||||
type Slot = NonNullable<TrainScheduleDetail["trainSet"]>["wagons"][number];
|
||||
type Stop = { yardId: string; label: string };
|
||||
type Span = [number, number];
|
||||
|
||||
/** One physical wagon of the consist with every slot (leg load) pinned to it. */
|
||||
interface WagonRow {
|
||||
key: string;
|
||||
physicalWagonId: string | null;
|
||||
label: string;
|
||||
position: number;
|
||||
typeCode: string | null;
|
||||
capacityTons: number;
|
||||
slots: Array<{ slot: Slot; span: Span; loaded: boolean }>;
|
||||
}
|
||||
|
||||
const round1 = (n: number) => Math.round(n * 10) / 10;
|
||||
const overlaps = (a: Span, b: Span) => a[0] < b[1] && b[0] < a[1];
|
||||
|
||||
/**
|
||||
* 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
|
||||
* same row, so a "53 full on A→B, 53 full on C→D" train reads at a glance.
|
||||
* Loads move by click: pick a load, then click a wagon that is free on that
|
||||
* load's legs (move) or another load (swap). Same API as the consist strip.
|
||||
*/
|
||||
export function LegLoadBoardPanel({
|
||||
schedule,
|
||||
onChanged,
|
||||
}: {
|
||||
schedule: TrainScheduleDetail;
|
||||
onChanged?: () => void;
|
||||
}) {
|
||||
const { toast } = useToast();
|
||||
const stops: Stop[] = schedule.stops ?? [];
|
||||
const legs = useMemo(
|
||||
() => stops.slice(0, -1).map((from, i) => ({ from, to: stops[i + 1], idx: i })),
|
||||
[stops],
|
||||
);
|
||||
const canRearrange = !["DISPATCHED", "ARRIVED", "CANCELLED"].includes(schedule.status);
|
||||
|
||||
const spanOf = (slot: Slot): Span => {
|
||||
const from = slot.boardYardId ? stops.findIndex((s) => s.yardId === slot.boardYardId) : 0;
|
||||
const to = slot.alightYardId
|
||||
? stops.findIndex((s) => s.yardId === slot.alightYardId)
|
||||
: stops.length - 1;
|
||||
return [from < 0 ? 0 : from, to < 0 ? Math.max(1, stops.length - 1) : to];
|
||||
};
|
||||
|
||||
const rows: WagonRow[] = useMemo(() => {
|
||||
const byKey = new Map<string, WagonRow>();
|
||||
for (const slot of schedule.trainSet?.wagons ?? []) {
|
||||
const key = slot.physicalWagonId ?? `slot:${slot.id}`;
|
||||
let row = byKey.get(key);
|
||||
if (!row) {
|
||||
row = {
|
||||
key,
|
||||
physicalWagonId: slot.physicalWagonId ?? null,
|
||||
label: slot.physicalWagonNumber ?? `#${slot.position ?? slot.sequenceNo}`,
|
||||
position: slot.position ?? slot.sequenceNo,
|
||||
typeCode: slot.wagonType?.code ?? null,
|
||||
capacityTons: slot.capacityTons ?? 0,
|
||||
slots: [],
|
||||
};
|
||||
byKey.set(key, row);
|
||||
}
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
return [...byKey.values()].sort((a, b) => a.position - b.position);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [schedule.trainSet?.wagons, stops]);
|
||||
|
||||
const [picked, setPicked] = useState<{ slotId: string; rowKey: string; span: Span } | null>(
|
||||
null,
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!picked) return;
|
||||
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setPicked(null);
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [picked]);
|
||||
|
||||
const moveMutation = useMutation(api.trainScheduling.moveWagonLoad.mutationOptions());
|
||||
const doMove = async (targetWagonId: string, swap: boolean) => {
|
||||
if (!picked || moveMutation.isPending) return;
|
||||
try {
|
||||
await moveMutation.mutateAsync({
|
||||
scheduleId: schedule.id,
|
||||
wagonId: picked.slotId,
|
||||
targetWagonId,
|
||||
});
|
||||
toast({ title: swap ? "Loads swapped" : "Load moved" });
|
||||
setPicked(null);
|
||||
onChanged?.();
|
||||
} catch (error) {
|
||||
const message = isAxiosError(error)
|
||||
? ((error.response?.data as { message?: string | string[] } | undefined)?.message ??
|
||||
null)
|
||||
: null;
|
||||
toast({
|
||||
title: "Could not move the load",
|
||||
description: Array.isArray(message)
|
||||
? message.join(", ")
|
||||
: (message ?? "The move was rejected — check wagon type, payload and leg."),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (stops.length < 2) {
|
||||
return (
|
||||
<Alert color="gray" icon={<Info size={16} />} radius="md">
|
||||
This schedule has no corridor stops yet — the leg board needs a route with at least
|
||||
two stops.
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
if (!rows.length) {
|
||||
return (
|
||||
<Alert color="gray" icon={<Info size={16} />} radius="md">
|
||||
No wagons on this train yet.
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
const sharedRows = rows.filter((r) => r.slots.filter((s) => s.loaded).length > 1).length;
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap">
|
||||
<Stack gap={2}>
|
||||
<Text fw={700} size="sm">
|
||||
Loads per wagon per leg
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
One row per physical wagon, one column per leg. A wagon reused on different legs
|
||||
shows one load per leg.{" "}
|
||||
{canRearrange
|
||||
? "Click a load to pick it up, then click a wagon free on those legs to move it, or another load to swap."
|
||||
: "Read-only — the train has departed."}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Group gap="xs">
|
||||
{sharedRows > 0 ? (
|
||||
<Badge variant="light" color="violet" radius="sm">
|
||||
{sharedRows} wagon{sharedRows === 1 ? "" : "s"} shared across legs
|
||||
</Badge>
|
||||
) : null}
|
||||
{picked ? (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="default"
|
||||
leftSection={<X size={14} />}
|
||||
onClick={() => setPicked(null)}
|
||||
>
|
||||
Cancel move (Esc)
|
||||
</Button>
|
||||
) : null}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Paper withBorder radius="md" style={{ overflowX: "auto" }}>
|
||||
<Table verticalSpacing={6} horizontalSpacing="sm" style={{ minWidth: 640 }}>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th style={{ position: "sticky", left: 0, background: "var(--mantine-color-body)", zIndex: 1, width: 180 }}>
|
||||
Wagon
|
||||
</Table.Th>
|
||||
{legs.map((leg) => (
|
||||
<Table.Th key={leg.idx} style={{ minWidth: 200 }}>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Text size="xs" fw={700} truncate>
|
||||
{leg.from.label}
|
||||
</Text>
|
||||
<MoveRight size={12} />
|
||||
<Text size="xs" fw={700} truncate>
|
||||
{leg.to.label}
|
||||
</Text>
|
||||
</Group>
|
||||
</Table.Th>
|
||||
))}
|
||||
<Table.Th style={{ width: 110 }}>Cargo</Table.Th>
|
||||
</Table.Tr>
|
||||
</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,
|
||||
);
|
||||
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.
|
||||
const rowFreeForPicked =
|
||||
!!picked &&
|
||||
!isPickedRow &&
|
||||
!row.slots.some((s) => s.loaded && overlaps(s.span, picked.span));
|
||||
// Where a "move here" lands: an existing empty slot on those legs,
|
||||
// else the physical wagon itself (the API mints the slot).
|
||||
const emptyTargetSlot = picked
|
||||
? row.slots.find((s) => !s.loaded && overlaps(s.span, picked.span))
|
||||
: undefined;
|
||||
const moveTargetId = emptyTargetSlot?.slot.id ?? row.physicalWagonId ?? null;
|
||||
|
||||
// Lay slots into leg columns; uncovered legs render as empty cells.
|
||||
const cells: React.ReactNode[] = [];
|
||||
let col = 0;
|
||||
const sorted = [...row.slots].sort((a, b) => a.span[0] - b.span[0]);
|
||||
// Empty cell = uncovered leg (target: the physical wagon) or an
|
||||
// empty slot (target: that slot). Both take the picked load when
|
||||
// the row is free on its legs.
|
||||
const emptyCell = (from: number, to: number, targetId = moveTargetId) => {
|
||||
const droppable = rowFreeForPicked && canRearrange && !!targetId &&
|
||||
!!picked && overlaps([from, to], picked.span);
|
||||
return (
|
||||
<Table.Td
|
||||
key={`e-${from}`}
|
||||
colSpan={Math.max(1, to - from)}
|
||||
onClick={droppable ? () => void doMove(targetId!, false) : undefined}
|
||||
style={{
|
||||
cursor: droppable ? "pointer" : "default",
|
||||
background: droppable ? "var(--mantine-color-teal-0)" : undefined,
|
||||
outline: droppable ? "1px dashed var(--mantine-color-teal-5)" : undefined,
|
||||
outlineOffset: -3,
|
||||
borderRadius: 6,
|
||||
}}
|
||||
>
|
||||
{droppable ? (
|
||||
<Text size="xs" c="teal.7" fw={600} ta="center">
|
||||
Move here
|
||||
</Text>
|
||||
) : (
|
||||
<Text size="xs" c="dimmed" ta="center">
|
||||
—
|
||||
</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
);
|
||||
};
|
||||
for (const s of sorted) {
|
||||
if (s.span[0] > col) cells.push(emptyCell(col, s.span[0]));
|
||||
if (!s.loaded) {
|
||||
cells.push(emptyCell(s.span[0], s.span[1], s.slot.id));
|
||||
col = Math.max(col, s.span[1]);
|
||||
continue;
|
||||
}
|
||||
const isPicked = picked?.slotId === s.slot.id;
|
||||
const swappable =
|
||||
!!picked && !isPicked && !isPickedRow && s.loaded && canRearrange;
|
||||
const allocs = s.slot.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}
|
||||
colSpan={Math.max(1, s.span[1] - s.span[0])}
|
||||
onClick={
|
||||
!canRearrange
|
||||
? undefined
|
||||
: s.loaded && !picked
|
||||
? () => setPicked({ slotId: s.slot.id, rowKey: row.key, span: s.span })
|
||||
: swappable
|
||||
? () => void doMove(s.slot.id, true)
|
||||
: isPicked
|
||||
? () => setPicked(null)
|
||||
: undefined
|
||||
}
|
||||
style={{
|
||||
cursor: canRearrange && (s.loaded || swappable) ? "pointer" : "default",
|
||||
padding: 4,
|
||||
}}
|
||||
>
|
||||
{s.loaded ? (
|
||||
<Paper
|
||||
radius="sm"
|
||||
px={8}
|
||||
py={6}
|
||||
style={{
|
||||
background: bulk
|
||||
? "var(--mantine-color-orange-0)"
|
||||
: "var(--mantine-color-cyan-0)",
|
||||
borderLeft: `4px solid ${
|
||||
bulk ? "var(--mantine-color-orange-6)" : "var(--mantine-color-cyan-6)"
|
||||
}`,
|
||||
outline: isPicked
|
||||
? "2px solid var(--mantine-color-edr-green-6)"
|
||||
: swappable
|
||||
? "1px dashed var(--mantine-color-orange-6)"
|
||||
: undefined,
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
>
|
||||
<Group gap={6} wrap="nowrap" justify="space-between">
|
||||
<Group gap={6} wrap="nowrap">
|
||||
{bulk ? <Wheat size={13} /> : <Boxes size={13} />}
|
||||
<Text size="xs" fw={700} truncate>
|
||||
{[...new Set(allocs.map((a) => a.bookingReference ?? "—"))].join(", ")}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed" style={{ whiteSpace: "nowrap" }}>
|
||||
{round1(
|
||||
allocs.reduce((a, al) => a + (al.allocatedWeightTons ?? 0), 0),
|
||||
)}{" "}
|
||||
t
|
||||
</Text>
|
||||
</Group>
|
||||
<Group gap={4} mt={2} wrap="wrap">
|
||||
{bulk
|
||||
? allocs.map((a) =>
|
||||
a.bulkLoad ? (
|
||||
<Badge key={a.id} size="xs" variant="light" color="orange" radius="sm">
|
||||
{a.bulkLoad.cargoDescription ?? "Bulk"} · {round1(a.bulkLoad.weightTons)} t
|
||||
</Badge>
|
||||
) : null,
|
||||
)
|
||||
: containers.map((c) => (
|
||||
<Badge key={c.id} size="xs" variant="light" color="cyan" radius="sm">
|
||||
{c.containerNumber ?? "no number"}
|
||||
</Badge>
|
||||
))}
|
||||
{swappable ? (
|
||||
<Badge size="xs" color="orange" radius="sm" leftSection={<ArrowLeftRight size={10} />}>
|
||||
swap
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
</Paper>
|
||||
) : (
|
||||
<Text size="xs" c="dimmed" ta="center">
|
||||
empty
|
||||
</Text>
|
||||
)}
|
||||
</Table.Td>,
|
||||
);
|
||||
col = Math.max(col, s.span[1]);
|
||||
}
|
||||
if (col < legs.length) cells.push(emptyCell(col, legs.length));
|
||||
|
||||
return (
|
||||
<Table.Tr
|
||||
key={row.key}
|
||||
style={{
|
||||
background: isPickedRow
|
||||
? "var(--mantine-color-green-0)"
|
||||
: rowFreeForPicked
|
||||
? undefined
|
||||
: picked
|
||||
? "var(--mantine-color-gray-0)"
|
||||
: undefined,
|
||||
opacity: picked && !isPickedRow && !rowFreeForPicked ? 0.55 : 1,
|
||||
}}
|
||||
>
|
||||
<Table.Td style={{ position: "sticky", left: 0, background: "var(--mantine-color-body)", zIndex: 1 }}>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Badge variant="outline" color="gray" radius="sm" size="sm">
|
||||
#{row.position}
|
||||
</Badge>
|
||||
<Stack gap={0}>
|
||||
<Text size="sm" fw={700}>
|
||||
{row.label}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{row.typeCode ?? "—"} · {round1(row.capacityTons)} t
|
||||
</Text>
|
||||
</Stack>
|
||||
{row.slots.filter((s) => s.loaded).length > 1 ? (
|
||||
<Tooltip label="This wagon carries different loads on different legs">
|
||||
<Badge size="xs" color="violet" variant="light" radius="sm">
|
||||
shared
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</Group>
|
||||
</Table.Td>
|
||||
{cells.map((c, i) => (
|
||||
<Fragment key={i}>{c}</Fragment>
|
||||
))}
|
||||
<Table.Td>
|
||||
<Text size="xs" fw={600} c={cargoTons > row.capacityTons + 0.001 ? "red.7" : undefined}>
|
||||
{round1(cargoTons)} / {round1(row.capacityTons)} t
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
ThemeIcon,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { DateTimePicker } from "@mantine/dates";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
CheckCircle2,
|
||||
@@ -111,7 +112,12 @@ export function LogPassYardWorkModal({
|
||||
}) {
|
||||
const { toast } = useToast();
|
||||
const [justLogged, setJustLogged] = useState(false);
|
||||
useEffect(() => setJustLogged(false), [station?.sequenceNo, opened]);
|
||||
// When the train was here — defaults to now, past allowed (recorded after the fact).
|
||||
const [passAt, setPassAt] = useState<Date | null>(null);
|
||||
useEffect(() => {
|
||||
setJustLogged(false);
|
||||
setPassAt(new Date());
|
||||
}, [station?.sequenceNo, opened]);
|
||||
const logged = alreadyLogged || justLogged;
|
||||
|
||||
const yardWorkQuery = useQuery(
|
||||
@@ -133,7 +139,13 @@ export function LogPassYardWorkModal({
|
||||
const doLogPass = () => {
|
||||
if (!station) return;
|
||||
recordCheckpoint.mutate(
|
||||
{ id: scheduleId, payload: { sequenceNo: station.sequenceNo } },
|
||||
{
|
||||
id: scheduleId,
|
||||
payload: {
|
||||
sequenceNo: station.sequenceNo,
|
||||
...(passAt ? { occurredAt: passAt.toISOString() } : {}),
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setJustLogged(true);
|
||||
@@ -373,6 +385,20 @@ export function LogPassYardWorkModal({
|
||||
</>
|
||||
)}
|
||||
|
||||
{!logged ? (
|
||||
<DateTimePicker
|
||||
label={isFinal ? "Arrival time" : "Time at station"}
|
||||
description="Defaults to now — pick an earlier time if you are recording after the fact."
|
||||
value={passAt}
|
||||
onChange={(v) => setPassAt(v ? new Date(v) : null)}
|
||||
maxDate={new Date()}
|
||||
valueFormat="DD MMM YYYY HH:mm"
|
||||
clearable={false}
|
||||
radius="md"
|
||||
maw={320}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Group justify="space-between" mt="xs">
|
||||
<Text size="xs" c="dimmed">
|
||||
{logged && pendingBoarders.length > 0
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Fragment } from "react";
|
||||
import { Badge, Box, Button, Group, Stack, Text } from "@mantine/core";
|
||||
import { Check, Flag, MapPin, Train } from "lucide-react";
|
||||
import { Check, Flag, MapPin, Pencil, Train } from "lucide-react";
|
||||
|
||||
import { freightBrand } from "@/theme/freight-brand";
|
||||
import type { TrainCheckpoint, TrackStation } from "@/types/trainScheduling";
|
||||
@@ -14,6 +14,8 @@ export interface RouteCorridorTrackProps {
|
||||
canLog: boolean;
|
||||
loggingSeq?: number | null;
|
||||
onLogCheckpoint?: (sequenceNo: number) => void;
|
||||
/** Present when logged legs may be corrected (dispatched or arrived). */
|
||||
onEditCheckpoint?: (checkpoint: TrainCheckpoint) => void;
|
||||
}
|
||||
|
||||
const COLUMN_WIDTH = 150;
|
||||
@@ -31,6 +33,7 @@ export function RouteCorridorTrack({
|
||||
canLog,
|
||||
loggingSeq,
|
||||
onLogCheckpoint,
|
||||
onEditCheckpoint,
|
||||
}: RouteCorridorTrackProps) {
|
||||
const bySeq = new Map(checkpoints.map((c) => [c.sequenceNo, c]));
|
||||
const lastIndex = stations.length - 1;
|
||||
@@ -160,14 +163,28 @@ export function RouteCorridorTrack({
|
||||
|
||||
{/* checkpoint time or action */}
|
||||
{checkpoint ? (
|
||||
<Text size="10px" c="dimmed" ta="center">
|
||||
{new Date(checkpoint.occurredAt).toLocaleString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</Text>
|
||||
<Stack gap={2} align="center">
|
||||
<Text size="10px" c="dimmed" ta="center">
|
||||
{new Date(checkpoint.occurredAt).toLocaleString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</Text>
|
||||
{onEditCheckpoint ? (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
radius="md"
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
leftSection={<Pencil size={11} />}
|
||||
onClick={() => onEditCheckpoint(checkpoint)}
|
||||
>
|
||||
Edit time
|
||||
</Button>
|
||||
) : null}
|
||||
</Stack>
|
||||
) : isNext ? (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
|
||||
@@ -95,7 +95,10 @@ export const QUERY_KEYS = {
|
||||
byId: (id: string) => ["invoices", "detail", id] as const,
|
||||
offlineUsd: (filter?: InvoiceListFilter) =>
|
||||
["invoices", "offline-usd", filter ?? {}] as const,
|
||||
summary: (filter?: Omit<InvoiceListFilter, "page" | "pageSize">) =>
|
||||
["invoices", "summary", filter ?? {}] as const,
|
||||
eimsStatus: (id: string) => ["invoices", "eims", id] as const,
|
||||
eimsReceipts: (id: string) => ["invoices", "eims", id, "receipts"] as const,
|
||||
},
|
||||
|
||||
BOOKINGS: {
|
||||
|
||||
@@ -134,8 +134,10 @@ export const URL_CONSTANTS = {
|
||||
|
||||
BILLING: {
|
||||
INVOICES: "/billing/invoices",
|
||||
INVOICES_SUMMARY: "/billing/invoices/summary",
|
||||
INVOICE_BY_ID: (id: string) => `/billing/invoices/${id}`,
|
||||
INVOICE_DOCUMENT: (id: string) => `/billing/invoices/${id}/document`,
|
||||
INVOICE_MEMO: (id: string) => `/billing/invoices/${id}/memo`,
|
||||
OFFLINE_USD: "/billing/offline-usd",
|
||||
CONFIRM_OFFLINE: (id: string) => `/billing/invoices/${id}/confirm-offline`,
|
||||
},
|
||||
@@ -146,6 +148,12 @@ export const URL_CONSTANTS = {
|
||||
REGISTER: (id: string) => `/invoices/${id}/eims/register`,
|
||||
VERIFY: (id: string) => `/invoices/${id}/eims/verify`,
|
||||
RESOLVE: (id: string) => `/invoices/${id}/eims/resolve`,
|
||||
CANCEL: (id: string) => `/invoices/${id}/eims/cancel`,
|
||||
RECEIPT_SALES: (id: string) => `/invoices/${id}/eims/receipt/sales`,
|
||||
RECEIPT_WITHHOLDING: (id: string) => `/invoices/${id}/eims/receipt/withholding`,
|
||||
RECEIPTS: (id: string) => `/invoices/${id}/eims/receipts`,
|
||||
RECEIPT_DOCUMENT: (id: string, receiptId: string) =>
|
||||
`/invoices/${id}/eims/receipts/${receiptId}/document`,
|
||||
},
|
||||
|
||||
CUSTOMERS_API: {
|
||||
@@ -482,6 +490,8 @@ export const URL_CONSTANTS = {
|
||||
`/train-scheduling/schedules/${id}/intercity/marshalling/document`,
|
||||
CHECKPOINTS: (id: string) =>
|
||||
`/train-scheduling/schedules/${id}/checkpoints`,
|
||||
CHECKPOINT: (id: string, sequenceNo: number) =>
|
||||
`/train-scheduling/schedules/${id}/checkpoints/${sequenceNo}`,
|
||||
ARRIVE: (id: string) => `/train-scheduling/schedules/${id}/arrive`,
|
||||
RESCHEDULE_PREVIEW: (id: string) =>
|
||||
`/train-scheduling/schedules/${id}/reschedule/preview`,
|
||||
|
||||
@@ -20,9 +20,13 @@ export function toBookingListRow(booking: BookingDetail): BookingListRow {
|
||||
reference: booking.reference,
|
||||
contractReference: booking.contractReference ?? null,
|
||||
contractId: booking.contractId ?? null,
|
||||
customerLabel: booking.isGovernment
|
||||
? (booking.governmentInstitution ?? "Government")
|
||||
: labelFromRef(booking.company, booking.companyId ?? undefined),
|
||||
// Shipping-line bookings have no customer company — the line IS the customer.
|
||||
customerLabel: booking.shippingLineCompany
|
||||
? booking.shippingLineCompany.name
|
||||
: booking.isGovernment
|
||||
? (booking.governmentInstitution ?? "Government")
|
||||
: labelFromRef(booking.company, booking.companyId ?? undefined),
|
||||
isShippingLine: Boolean(booking.shippingLineCompany ?? booking.shippingLineCompanyId),
|
||||
// customerLabel: labelFromRef(booking.customer, booking.customerId),
|
||||
status: booking.status,
|
||||
scheduledDate: booking.scheduledDate,
|
||||
|
||||
@@ -145,10 +145,16 @@ export const FREIGHT_PERMS = {
|
||||
view: "edr_freight_app:invoices:view",
|
||||
export: "edr_freight_app:invoices:export",
|
||||
confirmOffline: "edr_freight_app:invoices:confirm_offline",
|
||||
// Filing with MoR EIMS. Held by named admins rather than a role preset: registration is
|
||||
// irreversible at the tax authority, and resolving clears a system-wide filing block.
|
||||
// Filing with MoR EIMS. Off the general Finance role — automatic filing needs no permission
|
||||
// at all (the cron sweep runs as the system); these are the manual, exceptional-operations
|
||||
// actions, granted to the `chief` position (maker-checker, same as shipping-line credit
|
||||
// mark-paid/cancel approval) rather than every Finance user.
|
||||
eimsRegister: "edr_freight_app:invoices:eims_register",
|
||||
eimsResolve: "edr_freight_app:invoices:eims_resolve",
|
||||
eimsCancel: "edr_freight_app:invoices:eims_cancel",
|
||||
eimsReceiptRegister: "edr_freight_app:invoices:eims_receipt_register",
|
||||
// Issuing a credit/debit memo is filing-equivalent — same restricted grant as the eims_* keys.
|
||||
memoIssue: "edr_freight_app:invoices:memo_issue",
|
||||
},
|
||||
firstMile: {
|
||||
view: "edr_freight_app:first_mile:view",
|
||||
|
||||
@@ -13,12 +13,14 @@ import {
|
||||
MoreHorizontal,
|
||||
Package,
|
||||
RefreshCw,
|
||||
Ship,
|
||||
Truck,
|
||||
Wallet,
|
||||
Weight,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Center,
|
||||
@@ -174,7 +176,14 @@ export default function BookingRequestDetailPage() {
|
||||
};
|
||||
|
||||
const company = booking.company;
|
||||
const shippingLine = booking.shippingLineCompany ?? null;
|
||||
const customerName = toBookingListRow(booking).customerLabel;
|
||||
// What is being shipped, in words: bulk → the commodity (Wheat, Steel…);
|
||||
// containers → the shipper's own description when given.
|
||||
const cargoLabel =
|
||||
booking.freightType === "BULK"
|
||||
? (booking.cargoType?.label ?? booking.cargoType?.name ?? null)
|
||||
: (booking.cargoFreeText?.trim() || null);
|
||||
|
||||
const amount = Number(booking.totalAmount);
|
||||
const containers = booking.bookingContainers ?? [];
|
||||
@@ -237,10 +246,27 @@ export default function BookingRequestDetailPage() {
|
||||
}
|
||||
subtitle={
|
||||
<Group gap={6} wrap="wrap">
|
||||
<EntityLink
|
||||
to={company?.id ? `/dashboard/customers/${company.id}` : null}
|
||||
label={customerName ?? "—"}
|
||||
/>
|
||||
{shippingLine ? (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Ship size={14} />
|
||||
<Text size="sm" fw={600}>
|
||||
{shippingLine.name}
|
||||
</Text>
|
||||
<Badge size="xs" radius="sm" variant="light" color="teal">
|
||||
Shipping line
|
||||
</Badge>
|
||||
</Group>
|
||||
) : (
|
||||
<EntityLink
|
||||
to={company?.id ? `/dashboard/customers/${company.id}` : null}
|
||||
label={customerName ?? "—"}
|
||||
/>
|
||||
)}
|
||||
{cargoLabel ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
· {cargoLabel}
|
||||
</Text>
|
||||
) : null}
|
||||
<Text size="sm" c="dimmed">
|
||||
· Scheduled {booking.scheduledDate}
|
||||
</Text>
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
Package,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Ship,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
@@ -62,6 +63,12 @@ const BOOKING_KIND_OPTIONS: { value: BookingKind; label: string }[] = [
|
||||
{ value: "GENERAL_CONTRACT", label: "General booking" },
|
||||
];
|
||||
|
||||
/** Who booked: shipping lines own bookings via `shippingLineCompanyId`, not a customer company. */
|
||||
const CUSTOMER_KIND_OPTIONS = [
|
||||
{ value: "SHIPPING_LINE", label: "Shipping line" },
|
||||
{ value: "CUSTOMER", label: "Customer" },
|
||||
];
|
||||
|
||||
/** Status options for the filter select — built from the shared status styles. */
|
||||
const STATUS_OPTIONS = Object.entries(BOOKING_STATUS_STYLES).map(
|
||||
([value, { label }]) => ({ value, label }),
|
||||
@@ -132,6 +139,7 @@ export default function BookingRequestsPage() {
|
||||
// split), so a deep link can never land behind "More filters" unseen.
|
||||
const bookingFilterDefs: FilterDef[] = useMemo(
|
||||
() => [
|
||||
{ key: "customerKind", label: "Booked by", type: "enum", multiple: false, options: CUSTOMER_KIND_OPTIONS },
|
||||
{ key: "bookingType", label: "Kind", type: "enum", multiple: false, options: BOOKING_KIND_OPTIONS },
|
||||
{ key: "statuses", label: "Status", type: "enum", options: STATUS_OPTIONS },
|
||||
{
|
||||
@@ -301,8 +309,17 @@ export default function BookingRequestsPage() {
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="mt-0.5 flex items-center gap-1 truncate text-xs text-muted-foreground">
|
||||
<User className="size-3 shrink-0 opacity-70" />
|
||||
{b.isShippingLine ? (
|
||||
<Ship className="size-3 shrink-0 opacity-70" />
|
||||
) : (
|
||||
<User className="size-3 shrink-0 opacity-70" />
|
||||
)}
|
||||
{b.customerLabel}
|
||||
{b.isShippingLine ? (
|
||||
<Badge variant="secondary" className="h-4 shrink-0 px-1 text-[9px] font-medium">
|
||||
Shipping line
|
||||
</Badge>
|
||||
) : null}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -483,7 +500,7 @@ export default function BookingRequestsPage() {
|
||||
<FilterBar
|
||||
defs={bookingFilterDefs}
|
||||
controls={controls}
|
||||
searchPlaceholder="Search booking, contract or customer…"
|
||||
searchPlaceholder="Search booking, contract, customer or shipping line…"
|
||||
viewId="booking-requests"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
ThemeIcon,
|
||||
} from "@mantine/core";
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
import { FileText, Inbox, RefreshCw, User } from "lucide-react";
|
||||
import { FileText, Inbox, RefreshCw, Ship, User } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
@@ -67,6 +67,12 @@ const OWNERSHIP_OPTIONS = [
|
||||
{ value: "false", label: "Private" },
|
||||
];
|
||||
|
||||
/** Who booked: shipping lines own bookings via `shippingLineCompanyId`, not a customer company. */
|
||||
const CUSTOMER_KIND_OPTIONS = [
|
||||
{ value: "SHIPPING_LINE", label: "Shipping line" },
|
||||
{ value: "CUSTOMER", label: "Customer" },
|
||||
];
|
||||
|
||||
export default function ClearanceDocumentsPage() {
|
||||
const navigate = useNavigate();
|
||||
const { filterOptions } = useMyTradeAccess();
|
||||
@@ -91,6 +97,7 @@ export default function ClearanceDocumentsPage() {
|
||||
options: filterOptions(TRADE_DIRECTION_OPTIONS),
|
||||
},
|
||||
{ key: "freightType", label: "Freight", type: "enum", multiple: false, options: FREIGHT_TYPE_OPTIONS },
|
||||
{ key: "customerKind", label: "Booked by", type: "enum", multiple: false, options: CUSTOMER_KIND_OPTIONS },
|
||||
{ key: "isGovernment", label: "Ownership", type: "enum", multiple: false, options: OWNERSHIP_OPTIONS },
|
||||
{
|
||||
key: "created",
|
||||
@@ -131,17 +138,29 @@ export default function ClearanceDocumentsPage() {
|
||||
header: () => <span className={bookingTable.headerCell}>Customer</span>,
|
||||
cell: ({ row }) => {
|
||||
const b = row.original;
|
||||
const customer = b.isGovernment
|
||||
? (b.governmentInstitution ?? "Government")
|
||||
: (b.company?.name ?? "—");
|
||||
const isShippingLine = Boolean(b.shippingLineCompany ?? b.shippingLineCompanyId);
|
||||
const customer = isShippingLine
|
||||
? (b.shippingLineCompany?.name ?? "Shipping line")
|
||||
: b.isGovernment
|
||||
? (b.governmentInstitution ?? "Government")
|
||||
: (b.company?.name ?? "—");
|
||||
return (
|
||||
<div className="flex items-center gap-3 py-1.5">
|
||||
<div className={bookingTable.rowIcon}>
|
||||
<User className="size-4" strokeWidth={1.75} />
|
||||
{isShippingLine ? (
|
||||
<Ship className="size-4" strokeWidth={1.75} />
|
||||
) : (
|
||||
<User className="size-4" strokeWidth={1.75} />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-foreground">
|
||||
<p className="flex items-center gap-1.5 font-medium text-foreground">
|
||||
{customer}
|
||||
{isShippingLine ? (
|
||||
<Badge variant="secondary" className="h-4 shrink-0 px-1 text-[9px] font-medium">
|
||||
Shipping line
|
||||
</Badge>
|
||||
) : null}
|
||||
</p>
|
||||
<p className="mt-0.5 flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<FileText className="size-3 shrink-0 opacity-70" />
|
||||
@@ -245,7 +264,7 @@ export default function ClearanceDocumentsPage() {
|
||||
<FilterBar
|
||||
defs={filterDefs}
|
||||
controls={controls}
|
||||
searchPlaceholder="Search booking, contract or customer…"
|
||||
searchPlaceholder="Search booking, contract, customer or shipping line…"
|
||||
viewId="clearance-documents"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
@@ -52,6 +52,47 @@ import {
|
||||
summarizeRequestedCargo,
|
||||
} from "@/features/clearance/requestedCargo";
|
||||
import { contractsService } from "@/services/contracts.service";
|
||||
import "./contract-clearance-table.css";
|
||||
|
||||
/** Yards carry `label` (API) — older shapes used `name`/`code`. */
|
||||
function yardLabel(
|
||||
yard?: { label?: string; code?: string; name?: string } | null,
|
||||
): string {
|
||||
if (!yard) return "—";
|
||||
return yard.label ?? yard.name ?? yard.code ?? "—";
|
||||
}
|
||||
|
||||
/**
|
||||
* "Origin → Destination", wrapping past 120px as "Addis Ababa" /
|
||||
* "→ Djibouti": the arrow is glued to the destination with an nbsp, and
|
||||
* text wraps normally (the table's cells are otherwise nowrap) so a long
|
||||
* lane never spills into the next column.
|
||||
*/
|
||||
function RouteLabel({
|
||||
origin,
|
||||
destination,
|
||||
}: {
|
||||
origin: string;
|
||||
destination: string;
|
||||
}) {
|
||||
return (
|
||||
<Text
|
||||
size="sm"
|
||||
maw={120}
|
||||
lh={1.35}
|
||||
style={{ whiteSpace: "normal", overflowWrap: "anywhere" }}
|
||||
>
|
||||
{origin}{" "}
|
||||
<ArrowRight
|
||||
size={13}
|
||||
className="text-muted-foreground"
|
||||
style={{ display: "inline-block", verticalAlign: "-2px" }}
|
||||
/>
|
||||
{"\u00A0"}
|
||||
{destination}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
function CustomsBadge({ customs }: { customs: boolean }) {
|
||||
return customs ? (
|
||||
@@ -118,8 +159,8 @@ export default function ContractClearanceListPage() {
|
||||
id: b.id,
|
||||
reference: b.reference,
|
||||
customerLabel: b.company?.name ?? b.governmentInstitution ?? "—",
|
||||
originLabel: b.originYard?.name ?? "—",
|
||||
destinationLabel: b.destinationYard?.name ?? "—",
|
||||
originLabel: yardLabel(b.originYard),
|
||||
destinationLabel: yardLabel(b.destinationYard),
|
||||
tradeDirection: b.tradeDirection ?? "—",
|
||||
freightType: b.freightType ?? "—",
|
||||
status: b.status,
|
||||
@@ -430,11 +471,10 @@ function ShipmentBookingsTable({
|
||||
id: "route",
|
||||
header: () => <span className={bookingTable.headerCell}>Route</span>,
|
||||
cell: ({ row }) => (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Text size="sm">{row.original.originLabel}</Text>
|
||||
<ArrowRight size={13} className="shrink-0 text-muted-foreground" />
|
||||
<Text size="sm">{row.original.destinationLabel}</Text>
|
||||
</Group>
|
||||
<RouteLabel
|
||||
origin={row.original.originLabel}
|
||||
destination={row.original.destinationLabel}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -600,13 +640,13 @@ function ShipmentBookingsTable({
|
||||
}
|
||||
|
||||
return (
|
||||
<Box style={{ overflowX: "auto" }} px="xs" pb="xs">
|
||||
<Box w="100%" miw={0} style={{ overflowX: "auto" }}>
|
||||
<DataTable<ShipmentBookingRow, unknown>
|
||||
columns={columns}
|
||||
data={rows}
|
||||
status={loading ? "loading" : error ? "error" : "success"}
|
||||
onRowClick={(row) => onOpen(row.id)}
|
||||
containerClassName="border-0 shadow-none bg-transparent [&_th]:max-w-[100px] [&_td]:max-w-[100px] [&_td]:break-words"
|
||||
containerClassName="edr-clearance-table border-0 shadow-none rounded-none bg-transparent"
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -45,6 +45,7 @@ import { KpiStrip } from "@/components/page/KpiStrip";
|
||||
import { bookingTable } from "@/components/bookings/booking-ui.styles";
|
||||
import { useBookingDjClearanceQueue } from "@/hooks/bookings/useBookings";
|
||||
import type { BookingDetail } from "@/types/booking";
|
||||
import "./contract-clearance-table.css";
|
||||
|
||||
const prettyStatus = (s?: string | null) =>
|
||||
(s ?? "")
|
||||
@@ -214,15 +215,24 @@ function RouteCell({
|
||||
}) {
|
||||
return (
|
||||
<Stack gap={4} py={2}>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Text size="sm" fw={500}>
|
||||
{origin}
|
||||
</Text>
|
||||
<ArrowRight size={14} className="shrink-0 text-muted-foreground" />
|
||||
<Text size="sm" fw={500}>
|
||||
{destination}
|
||||
</Text>
|
||||
</Group>
|
||||
{/* Wraps past 120px as "Addis Ababa" / "→ Djibouti"; text wraps
|
||||
normally (cells are otherwise nowrap) so it never spills over. */}
|
||||
<Text
|
||||
size="sm"
|
||||
fw={500}
|
||||
maw={120}
|
||||
lh={1.35}
|
||||
style={{ whiteSpace: "normal", overflowWrap: "anywhere" }}
|
||||
>
|
||||
{origin}{" "}
|
||||
<ArrowRight
|
||||
size={13}
|
||||
className="text-muted-foreground"
|
||||
style={{ display: "inline-block", verticalAlign: "-2px" }}
|
||||
/>
|
||||
{"\u00A0"}
|
||||
{destination}
|
||||
</Text>
|
||||
<Group gap={8} align="center">
|
||||
<DirectionIcon direction={direction} />
|
||||
<Badge size="xs" variant="default" radius="sm">
|
||||
@@ -676,7 +686,7 @@ export default function GlDjiboutiClearanceListPage() {
|
||||
) : null}
|
||||
</Stack>
|
||||
) : (
|
||||
<Box style={{ overflowX: "auto" }} px="xs" pb="xs">
|
||||
<Box w="100%" miw={0} style={{ overflowX: "auto" }}>
|
||||
<DataTable<ShipmentRow, unknown>
|
||||
columns={shipmentColumns}
|
||||
data={pagedShipmentRows}
|
||||
@@ -694,7 +704,7 @@ export default function GlDjiboutiClearanceListPage() {
|
||||
manualPagination: true,
|
||||
pageCount,
|
||||
}}
|
||||
containerClassName="border-0 shadow-none bg-transparent [&_th]:max-w-[100px] [&_td]:max-w-[100px] [&_td]:break-words"
|
||||
containerClassName="edr-clearance-table border-0 shadow-none rounded-none bg-transparent"
|
||||
footer={DataTableFooter}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
/*
|
||||
* Scoped to .edr-clearance-table — the DataTable container div on the
|
||||
* Document Clearance hubs (GL Ethiopia + GL Djibouti). Mirrors the portal's /bookings table
|
||||
* (bookings-table.css): content-sized columns with a 100px floor, no
|
||||
* truncation, horizontal scroll when the table outgrows the card, sticky
|
||||
* header row and a sticky shadowed action column.
|
||||
*/
|
||||
.edr-clearance-table {
|
||||
overflow-x: auto;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/*
|
||||
* width: max-content — the table is exactly as wide as its columns' content
|
||||
* needs, never squeezed to fit the viewport; the container scrolls instead.
|
||||
* min-width: 100% keeps it filling the card when content is narrow.
|
||||
*/
|
||||
.edr-clearance-table table {
|
||||
table-layout: auto;
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
/* 100px floor, no ceiling: cells grow to fit their text, nothing is clipped. */
|
||||
.edr-clearance-table th,
|
||||
.edr-clearance-table td:not([colspan]) {
|
||||
min-width: 100px;
|
||||
max-width: none;
|
||||
overflow: visible;
|
||||
text-overflow: clip;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/*
|
||||
* Mantine Badge caps itself at max-width: 100%; inside an auto-layout table
|
||||
* cell that resolves against min-content and clips the label. Let badges size
|
||||
* to their text so the column grows to fit them.
|
||||
*/
|
||||
.edr-clearance-table .mantine-Badge-root {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
/*
|
||||
* Mantine Group's preventGrowOverflow caps every child at 100%/N of the cell.
|
||||
* In an auto-width table cell that resolves against min-content and collapses
|
||||
* the badges/text in the Type, Route and Status columns to nothing. Let group
|
||||
* children size to their content; the column grows and the container scrolls.
|
||||
*/
|
||||
.edr-clearance-table .mantine-Group-root > * {
|
||||
max-width: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Sticky header row. */
|
||||
.edr-clearance-table thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/*
|
||||
* Sticky action column, shrunk to its content. The width overrides the inline
|
||||
* width DataTable stamps from tanstack's column size — hence !important.
|
||||
* `:not([colspan])` keeps the full-width error/empty rows out.
|
||||
*/
|
||||
.edr-clearance-table th:last-child,
|
||||
.edr-clearance-table td:last-child:not([colspan]) {
|
||||
width: 1% !important;
|
||||
min-width: 0;
|
||||
position: sticky;
|
||||
right: 0;
|
||||
box-shadow: -12px 0 16px -6px rgba(16, 32, 47, 0.3);
|
||||
}
|
||||
|
||||
/*
|
||||
* Sticky cells sit above the scrolling ones, so they need their own opaque
|
||||
* background or the columns underneath show through.
|
||||
*/
|
||||
.edr-clearance-table td:last-child:not([colspan]) {
|
||||
background: #f5f8fb;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
/* Row hover uses the tailwind `hover:bg-accent` class on the <tr>. */
|
||||
.edr-clearance-table tbody tr:hover td:last-child:not([colspan]) {
|
||||
background: var(--accent, #f4fbf8);
|
||||
}
|
||||
|
||||
/* Header cell is sticky on both axes — it must outrank the body's sticky column. */
|
||||
.edr-clearance-table th:last-child {
|
||||
background: #f4f7fa;
|
||||
z-index: 3;
|
||||
}
|
||||
@@ -353,6 +353,10 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
|
||||
{ id: "importTrainNumber", header: "Import train no.", accessorKey: "importTrainNumber", format: "code" },
|
||||
{ id: "currentYard", header: "Current Yard", accessorKey: "currentYard", format: "entityLabel" },
|
||||
{ id: "status", header: "Status", accessorKey: "status", format: "statusBadge" },
|
||||
// From the status-flip log: last time the wagon went to maintenance, and
|
||||
// last time it became available again (dash = never logged).
|
||||
{ id: "lastMaintenanceAt", header: "Last to maintenance", accessorKey: "lastMaintenanceAt", format: "date" },
|
||||
{ id: "lastAvailableAt", header: "Available since", accessorKey: "lastAvailableAt", format: "date" },
|
||||
],
|
||||
formFields: [
|
||||
// Run numbers are optional — a wagon sits in the fleet unassigned to any
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Tabs } from "@mantine/core";
|
||||
import { Landmark, Receipt, Wallet } from "lucide-react";
|
||||
import { Landmark, Receipt } from "lucide-react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
@@ -8,13 +8,15 @@ import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
|
||||
import InvoicesPanel from "./InvoicesPage";
|
||||
import UsdPaymentsPanel from "./UsdPaymentsPage";
|
||||
import PaymentsPanel from "../payments/PaymentsPage";
|
||||
|
||||
/**
|
||||
* Invoices, Payments, and USD Payments used to be three separate routes/pages
|
||||
* with near-identical chrome. They're merged here as URL-linkable tabs
|
||||
* (`?tab=`) on one page — each tab keeps the permission it was individually
|
||||
* gated on before, and just doesn't render if the user lacks it.
|
||||
* Invoices and USD Payments used to be separate routes/pages with
|
||||
* near-identical chrome. They're merged here as URL-linkable tabs (`?tab=`)
|
||||
* on one page — each tab keeps the permission it was individually gated on
|
||||
* before, and just doesn't render if the user lacks it.
|
||||
*
|
||||
* The Payments tab was removed; its summary (total collected, ETB/USD) now
|
||||
* lives as a card at the top of the Invoices tab instead.
|
||||
*/
|
||||
const TABS = [
|
||||
{
|
||||
@@ -27,21 +29,13 @@ const TABS = [
|
||||
Panel: InvoicesPanel,
|
||||
},
|
||||
{
|
||||
key: "payments",
|
||||
label: "Payments",
|
||||
icon: Wallet,
|
||||
permission: FREIGHT_PERMS.payments.view,
|
||||
subtitle: "View and reconcile booking payment transactions.",
|
||||
Panel: PaymentsPanel,
|
||||
},
|
||||
{
|
||||
key: "usd-payments",
|
||||
label: "USD Payments",
|
||||
key: "manual-payments",
|
||||
label: "Manual Payments",
|
||||
icon: Landmark,
|
||||
// Same gate as Invoices, not a dedicated key — mirrors the old route.
|
||||
permission: FREIGHT_PERMS.invoices.view,
|
||||
subtitle:
|
||||
"USD invoices are paid by bank transfer. Upload the customer's slip and confirm the payment before the pay window closes.",
|
||||
"Import and export invoices in USD or ETB that Finance settles by hand (bank transfer or counter). Upload the customer's slip and confirm the payment before the pay window closes.",
|
||||
Panel: UsdPaymentsPanel,
|
||||
},
|
||||
] as const;
|
||||
|
||||
@@ -8,16 +8,18 @@ import {
|
||||
Grid,
|
||||
Group,
|
||||
Loader,
|
||||
Menu,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ArrowLeft, Building2, Download, FileText } from "lucide-react";
|
||||
import { ArrowLeft, Building2, Download, FileText, Printer } from "lucide-react";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
import { EimsFilingCard } from "@/components/invoices/EimsFilingCard";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
|
||||
@@ -77,9 +79,31 @@ function InfoField({
|
||||
);
|
||||
}
|
||||
|
||||
/** Billed-to company, with its contact/registration details as quick-info rows. */
|
||||
/**
|
||||
* Billed-to party: a customer company, or — for shipping-line credit invoices
|
||||
* (`companyId` null) — the shipping line itself. The two payers are mutually
|
||||
* exclusive (DB-enforced), so exactly one branch has data.
|
||||
*/
|
||||
function RecipientCard({ invoice }: { invoice: Invoice }) {
|
||||
const company = invoice.company;
|
||||
const shippingLine = invoice.shippingLineCompany;
|
||||
|
||||
if (!company && shippingLine) {
|
||||
const rows: FieldRowProps[] = [
|
||||
{ label: "Phone", value: shippingLine.phoneNumber },
|
||||
{ label: "Email", value: shippingLine.email },
|
||||
];
|
||||
return (
|
||||
<LinkedEntityCard
|
||||
icon={Building2}
|
||||
title="Billed to"
|
||||
name={shippingLine.name}
|
||||
rows={rows}
|
||||
emptyMessage="No additional shipping line details available."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const rows: FieldRowProps[] = [
|
||||
{ label: "Profile", value: invoice.companyProfile?.reference },
|
||||
{ label: "TIN", value: company?.tin },
|
||||
@@ -91,7 +115,7 @@ function RecipientCard({ invoice }: { invoice: Invoice }) {
|
||||
return (
|
||||
<LinkedEntityCard
|
||||
icon={Building2}
|
||||
title="Recipient"
|
||||
title="Billed to"
|
||||
name={company?.name ?? "Unnamed company"}
|
||||
to={invoice.companyId ? `/dashboard/customers/${invoice.companyId}` : null}
|
||||
rows={rows}
|
||||
@@ -145,6 +169,7 @@ export default function InvoiceDetailPage() {
|
||||
const canExport = hasPermission(user, FREIGHT_PERMS.invoices.export);
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
|
||||
const { data: invoice, isLoading } = useQuery(
|
||||
@@ -154,12 +179,27 @@ export default function InvoiceDetailPage() {
|
||||
}),
|
||||
);
|
||||
|
||||
const downloadDocument = async () => {
|
||||
const downloadDocument = async (format?: "a4" | "thermal") => {
|
||||
if (!id) return;
|
||||
setDownloading(true);
|
||||
try {
|
||||
const { data } = await invoicesService.downloadDocument(id);
|
||||
openPdfBlob(data, `${invoice?.invoiceNumber ?? "invoice"}.pdf`);
|
||||
const { data } = await invoicesService.downloadDocument(id, format);
|
||||
const suffix = format === "thermal" ? "-thermal" : "";
|
||||
openPdfBlob(data, `${invoice?.invoiceNumber ?? "invoice"}${suffix}.pdf`);
|
||||
} catch (error) {
|
||||
// Thermal rendering deliberately fails loudly rather than silently returning an A4-shaped,
|
||||
// QR-less document (see PdfRenderService's `noFallback`) — surface that here rather than
|
||||
// let it become a silent unhandled rejection with just a spinner stopping.
|
||||
toast({
|
||||
title: format === "thermal" ? "Could not generate the thermal invoice" : "Could not download the invoice",
|
||||
description:
|
||||
format === "thermal"
|
||||
? "Thermal rendering requires Chromium on the server. The A4 PDF is still available."
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: undefined,
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setDownloading(false);
|
||||
}
|
||||
@@ -202,17 +242,34 @@ export default function InvoiceDetailPage() {
|
||||
subtitle={humanize(invoice.source)}
|
||||
meta={<InvoiceStatusBadge status={invoice.status} />}
|
||||
action={
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size="lg"
|
||||
radius="md"
|
||||
aria-label="Download invoice"
|
||||
disabled={!canExport}
|
||||
loading={downloading}
|
||||
onClick={() => void downloadDocument()}
|
||||
>
|
||||
<Download size={16} />
|
||||
</ActionIcon>
|
||||
<Menu position="bottom-end" withinPortal>
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size="lg"
|
||||
radius="md"
|
||||
aria-label="Download invoice"
|
||||
disabled={!canExport}
|
||||
loading={downloading}
|
||||
>
|
||||
<Download size={16} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={<Download size={14} />}
|
||||
onClick={() => void downloadDocument("a4")}
|
||||
>
|
||||
Download PDF (A4)
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<Printer size={14} />}
|
||||
onClick={() => void downloadDocument("thermal")}
|
||||
>
|
||||
Download thermal invoice (80mm)
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
}
|
||||
/>
|
||||
|
||||
|
||||
@@ -11,7 +11,14 @@ import {
|
||||
} from "@mantine/core";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { RefreshCw, Search, X } from "lucide-react";
|
||||
import {
|
||||
Banknote,
|
||||
CircleDollarSign,
|
||||
Landmark,
|
||||
RefreshCw,
|
||||
Search,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
@@ -21,7 +28,9 @@ import {
|
||||
formatMoney,
|
||||
humanize,
|
||||
} from "@/components/customers";
|
||||
import { KpiStrip } from "@/components/page";
|
||||
import CreditInvoiceActions from "@/components/shipping-lines/CreditInvoiceActions";
|
||||
import { useExchangeSettingsQuery } from "@/hooks/useExchangeSettings";
|
||||
import { api } from "@/services/api";
|
||||
import type { Invoice } from "@/types/invoice";
|
||||
import {
|
||||
@@ -79,6 +88,21 @@ export default function InvoicesPanel() {
|
||||
[pendingActions],
|
||||
);
|
||||
|
||||
// Summary card: total collected (paidAmount) across every invoice matching
|
||||
// the current search/status filters, not just the visible page.
|
||||
const { data: summary, isLoading: summaryLoading } = useQuery(
|
||||
api.invoices.collectedSummary.queryOptions({
|
||||
input: {
|
||||
filter: { search: debouncedQuery, status: statusFilter || undefined },
|
||||
},
|
||||
}),
|
||||
);
|
||||
const { data: exchangeSettings } = useExchangeSettingsQuery();
|
||||
const etbCollected = summary?.ETB ?? 0;
|
||||
const usdCollected = summary?.USD ?? 0;
|
||||
const rate = exchangeSettings?.feed?.rate ?? exchangeSettings?.fallbackRate;
|
||||
const etbFromUsd = rate ? usdCollected * rate : null;
|
||||
|
||||
const columns: ColumnDef<Invoice>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
@@ -95,7 +119,9 @@ export default function InvoicesPanel() {
|
||||
header: "Billed to",
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" c="edr-text" truncate maw={200}>
|
||||
{row.original.company?.name ?? "—"}
|
||||
{row.original.company?.name ??
|
||||
row.original.shippingLineCompany?.name ??
|
||||
"—"}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
@@ -170,7 +196,33 @@ export default function InvoicesPanel() {
|
||||
);
|
||||
|
||||
return (
|
||||
<Card p={0}>
|
||||
<Stack gap="md">
|
||||
<KpiStrip
|
||||
loading={summaryLoading}
|
||||
items={[
|
||||
{
|
||||
label: "Total collected",
|
||||
hint: etbFromUsd !== null ? "ETB + USD" : "ETB only",
|
||||
value: formatMoney(etbCollected + (etbFromUsd ?? 0), "ETB"),
|
||||
icon: CircleDollarSign,
|
||||
color: "edr-green",
|
||||
},
|
||||
{
|
||||
label: "Collected in ETB",
|
||||
value: formatMoney(etbCollected, "ETB"),
|
||||
icon: Banknote,
|
||||
color: "blue",
|
||||
},
|
||||
{
|
||||
label: "Collected in USD",
|
||||
value: formatMoney(usdCollected, "USD"),
|
||||
icon: Landmark,
|
||||
color: "violet",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Card p={0}>
|
||||
<Stack gap={0}>
|
||||
<Box px="md" pt="md" pb="sm" w="100%">
|
||||
<Group justify="space-between" gap="md" wrap="wrap">
|
||||
@@ -264,6 +316,7 @@ export default function InvoicesPanel() {
|
||||
</Box>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Card>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
@@ -55,14 +56,19 @@ function formatRemaining(deadlineMs: number, now: number): string | null {
|
||||
: `${pad(hours)}:${pad(minutes)}:${pad(seconds)}`;
|
||||
}
|
||||
|
||||
function PayWindowCell({ deadline }: { deadline: string | null }) {
|
||||
/** Ticks once a second while a deadline is set, so window state updates live. */
|
||||
function useNow(deadline: string | null): number {
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
|
||||
useEffect(() => {
|
||||
if (!deadline) return;
|
||||
const interval = setInterval(() => setNow(Date.now()), 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [deadline]);
|
||||
return now;
|
||||
}
|
||||
|
||||
function PayWindowCell({ deadline }: { deadline: string | null }) {
|
||||
const now = useNow(deadline);
|
||||
|
||||
if (!deadline) {
|
||||
return (
|
||||
@@ -88,13 +94,56 @@ function PayWindowCell({ deadline }: { deadline: string | null }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** True once the pay window has closed — the API refuses confirmation then. */
|
||||
function windowClosed(row: OfflineUsdInvoice): boolean {
|
||||
const deadline = row.booking?.paymentDeadline;
|
||||
return Boolean(deadline && new Date(deadline).getTime() <= Date.now());
|
||||
/**
|
||||
* "Confirm paid" for one row. Booking invoices are only confirmable while the
|
||||
* booking's pay window is open (the API refuses otherwise): no window yet →
|
||||
* no button; window closed → button disabled with the reason, and it flips
|
||||
* live the second the countdown hits zero. Non-booking invoices (warehouse,
|
||||
* clearance…) have no window and stay confirmable.
|
||||
*/
|
||||
function ConfirmCell({
|
||||
row,
|
||||
onConfirm,
|
||||
}: {
|
||||
row: OfflineUsdInvoice;
|
||||
onConfirm: (row: OfflineUsdInvoice) => void;
|
||||
}) {
|
||||
const deadline = row.booking?.paymentDeadline ?? null;
|
||||
const now = useNow(deadline);
|
||||
|
||||
if (row.booking && !deadline) return null;
|
||||
const closed = Boolean(deadline && new Date(deadline).getTime() <= now);
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
label="Pay window closed — the booking can no longer be confirmed as paid."
|
||||
disabled={!closed}
|
||||
withArrow
|
||||
>
|
||||
<span>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
color="edr-green"
|
||||
leftSection={<CheckCircle2 size={14} />}
|
||||
disabled={closed}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onConfirm(row);
|
||||
}}
|
||||
>
|
||||
Confirm paid
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
/** USD Payments tab body of `FinanceHubPage` — page chrome lives in the parent. */
|
||||
/**
|
||||
* Manual Payments tab body of `FinanceHubPage` — page chrome lives in the
|
||||
* parent. Lists open USD and ETB invoices (import and export alike) that
|
||||
* Finance settles by hand; confirming records the payment the same way an
|
||||
* online payment would, so the booking advances identically.
|
||||
*/
|
||||
export default function UsdPaymentsPanel() {
|
||||
const navigate = useNavigate();
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
@@ -103,6 +152,7 @@ export default function UsdPaymentsPanel() {
|
||||
const [statusFilter, setStatusFilter] = useState<"" | Freight.InvoiceStatus>(
|
||||
"",
|
||||
);
|
||||
const [currency, setCurrency] = useState<"" | "USD" | "ETB">("");
|
||||
const [confirming, setConfirming] = useState<OfflineUsdInvoice | null>(null);
|
||||
const [slip, setSlip] = useState<File | null>(null);
|
||||
const [reference, setReference] = useState("");
|
||||
@@ -119,8 +169,15 @@ export default function UsdPaymentsPanel() {
|
||||
pageSize: pagination.pageSize,
|
||||
search: debouncedQuery,
|
||||
status: statusFilter || undefined,
|
||||
currency: currency || undefined,
|
||||
}),
|
||||
[pagination.pageIndex, pagination.pageSize, debouncedQuery, statusFilter],
|
||||
[
|
||||
pagination.pageIndex,
|
||||
pagination.pageSize,
|
||||
debouncedQuery,
|
||||
statusFilter,
|
||||
currency,
|
||||
],
|
||||
);
|
||||
|
||||
const { data, isLoading, isError, refetch, isFetching } = useQuery(
|
||||
@@ -170,7 +227,9 @@ export default function UsdPaymentsPanel() {
|
||||
header: "Customer",
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" c="edr-text" truncate maw={200}>
|
||||
{row.original.company?.name ?? "—"}
|
||||
{row.original.company?.name ??
|
||||
row.original.shippingLineCompany?.name ??
|
||||
"—"}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
@@ -179,6 +238,28 @@ export default function UsdPaymentsPanel() {
|
||||
header: "Booking",
|
||||
cell: ({ row }) => {
|
||||
const booking = row.original.booking;
|
||||
const bookings = row.original.bookings ?? [];
|
||||
if (!booking && bookings.length) {
|
||||
// Shipping-line credit invoice: one link per billed booking.
|
||||
return (
|
||||
<Group gap={4} wrap="wrap" maw={280}>
|
||||
{bookings.map((b) => (
|
||||
<Button
|
||||
key={b.id}
|
||||
variant="subtle"
|
||||
size="compact-xs"
|
||||
rightSection={<ExternalLink size={11} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate(`/dashboard/booking-requests/${b.id}`);
|
||||
}}
|
||||
>
|
||||
{b.reference}
|
||||
</Button>
|
||||
))}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
if (!booking) {
|
||||
return (
|
||||
<Text size="sm" c="dimmed">
|
||||
@@ -187,20 +268,41 @@ export default function UsdPaymentsPanel() {
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="compact-sm"
|
||||
rightSection={<ExternalLink size={13} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate(`/dashboard/booking-requests/${booking.id}`);
|
||||
}}
|
||||
>
|
||||
{booking.reference}
|
||||
</Button>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="compact-sm"
|
||||
rightSection={<ExternalLink size={13} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate(`/dashboard/booking-requests/${booking.id}`);
|
||||
}}
|
||||
>
|
||||
{booking.reference}
|
||||
</Button>
|
||||
{booking.tradeDirection && (
|
||||
<Badge size="xs" variant="light" radius="sm" color="gray">
|
||||
{humanize(booking.tradeDirection)}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "currency",
|
||||
header: "Currency",
|
||||
cell: ({ row }) => (
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
radius="sm"
|
||||
color={row.original.currency?.toUpperCase() === "USD" ? "blue" : "teal"}
|
||||
>
|
||||
{row.original.currency}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: "Status",
|
||||
@@ -239,22 +341,8 @@ export default function UsdPaymentsPanel() {
|
||||
header: "",
|
||||
meta: { headerClassName: "text-right", cellClassName: "text-right" },
|
||||
cell: ({ row }) => {
|
||||
const paid = row.original.status === "PAID";
|
||||
if (paid || !canConfirm) return null;
|
||||
return (
|
||||
<Button
|
||||
size="compact-sm"
|
||||
color="edr-green"
|
||||
leftSection={<CheckCircle2 size={14} />}
|
||||
disabled={windowClosed(row.original)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setConfirming(row.original);
|
||||
}}
|
||||
>
|
||||
Confirm paid
|
||||
</Button>
|
||||
);
|
||||
if (row.original.status === "PAID" || !canConfirm) return null;
|
||||
return <ConfirmCell row={row.original} onConfirm={setConfirming} />;
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -288,6 +376,20 @@ export default function UsdPaymentsPanel() {
|
||||
style={{ flex: 1, minWidth: "240px" }}
|
||||
radius="lg"
|
||||
/>
|
||||
<SegmentedControl
|
||||
size="sm"
|
||||
radius="md"
|
||||
value={currency || "all"}
|
||||
onChange={(v) => {
|
||||
setCurrency(v === "all" ? "" : (v as "USD" | "ETB"));
|
||||
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
|
||||
}}
|
||||
data={[
|
||||
{ label: "All", value: "all" },
|
||||
{ label: "ETB", value: "ETB" },
|
||||
{ label: "USD", value: "USD" },
|
||||
]}
|
||||
/>
|
||||
<SegmentedControl
|
||||
size="sm"
|
||||
radius="md"
|
||||
@@ -318,7 +420,7 @@ export default function UsdPaymentsPanel() {
|
||||
</Box>
|
||||
|
||||
<Box style={{ overflowX: "auto" }} w="100%">
|
||||
<Box miw={1040}>
|
||||
<Box miw={1160}>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
@@ -326,13 +428,13 @@ export default function UsdPaymentsPanel() {
|
||||
onRowClick={(row) => navigate(`/dashboard/invoices/${row.id}`)}
|
||||
emptyMessage={
|
||||
debouncedQuery
|
||||
? "No USD invoices match your search."
|
||||
: "No USD invoices awaiting confirmation."
|
||||
? "No invoices match your search."
|
||||
: "No invoices awaiting manual payment confirmation."
|
||||
}
|
||||
error={
|
||||
isError
|
||||
? {
|
||||
message: "Failed to load USD invoices.",
|
||||
message: "Failed to load invoices.",
|
||||
onRetry: () => void refetch(),
|
||||
}
|
||||
: undefined
|
||||
@@ -361,7 +463,7 @@ export default function UsdPaymentsPanel() {
|
||||
opened={confirming !== null}
|
||||
onClose={closeConfirm}
|
||||
title={
|
||||
<Text fw={700}>Confirm bank transfer payment</Text>
|
||||
<Text fw={700}>Confirm manual payment</Text>
|
||||
}
|
||||
radius="md"
|
||||
size="md"
|
||||
@@ -371,20 +473,21 @@ export default function UsdPaymentsPanel() {
|
||||
<Text size="sm" c="dimmed">
|
||||
Confirming settles {confirming.invoiceNumber} in full (
|
||||
{formatMoney(confirming.balanceAmount, confirming.currency)}) and
|
||||
marks the booking as paid. Upload the customer's bank slip
|
||||
first — this cannot be undone.
|
||||
marks the booking as paid — exactly as if the customer had paid
|
||||
online. Upload the customer's bank slip or receipt first —
|
||||
this cannot be undone.
|
||||
</Text>
|
||||
|
||||
<PhasedFileDropzone
|
||||
label="Bank payment slip"
|
||||
description="PDF or image of the customer's transfer slip."
|
||||
label="Payment slip / receipt"
|
||||
description="PDF or image of the customer's bank transfer slip or payment receipt."
|
||||
value={slip}
|
||||
onChange={setSlip}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Bank reference"
|
||||
description="Optional — the transfer reference from the slip."
|
||||
label="Payment reference"
|
||||
description="Optional — the transfer or receipt reference from the slip."
|
||||
placeholder="e.g. FT24091234567"
|
||||
value={reference}
|
||||
onChange={(e) => setReference(e.target.value)}
|
||||
|
||||
@@ -580,14 +580,10 @@ const RuleEngineResourcePage = () => {
|
||||
config={config}
|
||||
layout="row"
|
||||
readOnly={!canUpdateControls}
|
||||
onEdit={
|
||||
config.slug === "container-types"
|
||||
? undefined
|
||||
: (record) => {
|
||||
setEditing(record);
|
||||
setFormOpen(true);
|
||||
}
|
||||
}
|
||||
onEdit={(record) => {
|
||||
setEditing(record);
|
||||
setFormOpen(true);
|
||||
}}
|
||||
onDelete={setDeleteTarget}
|
||||
onViewChain={
|
||||
config.slug === "approval-rules"
|
||||
@@ -958,9 +954,7 @@ const RuleEngineResourcePage = () => {
|
||||
totalCount={totalCount}
|
||||
onPaginationChange={setPagination}
|
||||
readOnly={!canUpdate && !canDelete}
|
||||
onEdit={
|
||||
canUpdate && config.slug !== "container-types" ? openEdit : undefined
|
||||
}
|
||||
onEdit={canUpdate ? openEdit : undefined}
|
||||
onDelete={canDelete ? setDeleteTarget : undefined}
|
||||
onViewChain={
|
||||
config.slug === "approval-rules"
|
||||
|
||||
@@ -187,7 +187,7 @@ const RATE_TRIGGERS = [
|
||||
{ label: "Shipping line mapped", value: "SHIPPING_LINE" },
|
||||
{ label: "Penalty", value: "CONSOLIDATION" },
|
||||
{ label: "Lashing (bulk, per cargo type)", value: "LASHING" },
|
||||
{ label: "Cancellation", value: "CANCELLATION" },
|
||||
{ label: "Cancellation (per wagon, per direction + cargo type)", value: "CANCELLATION" },
|
||||
{ label: "Shipping line extra fee (PIL)", value: "PIL_EXTRA_FEE" },
|
||||
{ label: "Customs clearance service fee (billed with the booking)", value: "CUSTOMS_CLEARANCE" },
|
||||
{ label: "Fuel (per lane + cargo type)", value: "FUEL" },
|
||||
@@ -265,6 +265,13 @@ const isRouteScopedRate = (values: Record<string, unknown>) =>
|
||||
(String(values.appliesTo ?? "") === "OTHER" &&
|
||||
["CUSTOMS_CLEARANCE", "WITH_RETURN", "FUEL"].includes(String(values.trigger ?? "")));
|
||||
|
||||
/**
|
||||
* Surcharges sold per cargo kind: the admin says container or bulk, then names
|
||||
* the container type or bulk commodity the fee covers.
|
||||
*/
|
||||
const isCargoKindTrigger = (values: Record<string, unknown>) =>
|
||||
["CUSTOMS_CLEARANCE", "CANCELLATION"].includes(String(values.trigger ?? ""));
|
||||
|
||||
const unitOption = (value: string) => ({ label: value.replace(/_/g, " "), value });
|
||||
|
||||
/**
|
||||
@@ -304,7 +311,8 @@ const unitsForShape = (
|
||||
// Container-only service — per returned container, per wagon, or flat.
|
||||
return ["PER_CONTAINER", "PER_WAGON", "FLAT"];
|
||||
case "CANCELLATION":
|
||||
return ["FLAT", "PER_INVOICE"];
|
||||
// Wagon cancellation fee — scales with the cancelled wagons only.
|
||||
return ["PER_WAGON"];
|
||||
case "CUSTOMS_CLEARANCE":
|
||||
// Per cargo kind: container fees per box/wagon, bulk per ton/wagon.
|
||||
return cargoKind === "BULK"
|
||||
@@ -1054,9 +1062,10 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
showIf: (v) =>
|
||||
hasShippingLine(v) && v.shippingLineRateKind === "SURCHARGE",
|
||||
},
|
||||
// ── Trade direction — Bulk & Container base freight, plus the route-
|
||||
// scoped surcharges (customs clearance; empty-container return, which is
|
||||
// import-only for now so export is not offered) ────────────────────────
|
||||
// ── Trade direction — Bulk & Container base freight, plus the directed
|
||||
// surcharges (customs clearance, cancellation, lashing, fuel; empty-
|
||||
// container return, which is import-only for now so export is not
|
||||
// offered) ─────────────────────────────────────────────────────────────
|
||||
{
|
||||
name: "tradeDirection",
|
||||
label: "Trade direction",
|
||||
@@ -1074,9 +1083,13 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
!isShippingLineRate(v) &&
|
||||
(["BULK", "CONTAINER"].includes(String(v.appliesTo ?? "")) ||
|
||||
(String(v.appliesTo ?? "") === "OTHER" &&
|
||||
["CUSTOMS_CLEARANCE", "WITH_RETURN", "LASHING", "FUEL"].includes(
|
||||
String(v.trigger ?? ""),
|
||||
))),
|
||||
[
|
||||
"CUSTOMS_CLEARANCE",
|
||||
"CANCELLATION",
|
||||
"WITH_RETURN",
|
||||
"LASHING",
|
||||
"FUEL",
|
||||
].includes(String(v.trigger ?? "")))),
|
||||
},
|
||||
// Shipping lines only ever ship import — the export leg is sold through
|
||||
// the customer's contract — so the direction is stated, not asked. Shown
|
||||
@@ -1097,8 +1110,9 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
computeValue: () => "IMPORT",
|
||||
showIf: hasShippingLine,
|
||||
},
|
||||
// ── Cargo kind — customs clearance is priced separately for containers
|
||||
// (one rate per container type) and bulk ───────────────────────────────
|
||||
// ── Cargo kind — customs clearance and the cancellation fee are priced
|
||||
// separately for containers (one rate per container type) and bulk (one
|
||||
// rate per commodity) ──────────────────────────────────────────────────
|
||||
{
|
||||
name: "cargoKind",
|
||||
label: "Cargo kind",
|
||||
@@ -1107,11 +1121,10 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
options: INTERCITY_KINDS,
|
||||
placeholder: "Is this fee for containers or bulk?",
|
||||
description:
|
||||
"Container fees bill per box or wagon (one rate per container type); bulk fees bill per ton or wagon.",
|
||||
showIf: (v) =>
|
||||
v.appliesTo === "OTHER" && v.trigger === "CUSTOMS_CLEARANCE",
|
||||
"Container fees are set per container type; bulk fees per commodity. Customs: container per box or wagon, bulk per ton or wagon. Cancellation: per wagon.",
|
||||
showIf: (v) => v.appliesTo === "OTHER" && isCargoKindTrigger(v),
|
||||
// Not a stored column: a container fee carries its containerTypeId, a
|
||||
// bulk fee carries none.
|
||||
// bulk fee its cargoTypeId.
|
||||
getInitialValue: (record) =>
|
||||
record.containerTypeId ? "CONTAINER" : "BULK",
|
||||
},
|
||||
@@ -1124,10 +1137,10 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
placeholder: "Which container type this fee covers",
|
||||
showIf: (v) =>
|
||||
v.appliesTo === "OTHER" &&
|
||||
v.trigger === "CUSTOMS_CLEARANCE" &&
|
||||
isCargoKindTrigger(v) &&
|
||||
v.cargoKind === "CONTAINER",
|
||||
},
|
||||
// ── Bulk cargo type — the bulk customs fee names its commodity ────────
|
||||
// ── Bulk cargo type — the bulk fee names its commodity ────────────────
|
||||
{
|
||||
name: "cargoTypeId",
|
||||
label: "Bulk cargo type",
|
||||
@@ -1136,7 +1149,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
placeholder: "Which bulk commodity this fee covers",
|
||||
showIf: (v) =>
|
||||
v.appliesTo === "OTHER" &&
|
||||
v.trigger === "CUSTOMS_CLEARANCE" &&
|
||||
isCargoKindTrigger(v) &&
|
||||
v.cargoKind === "BULK",
|
||||
},
|
||||
// ── Cargo type — a fuel rate names the commodity it covers (different
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
MapPin,
|
||||
Navigation,
|
||||
PackageCheck,
|
||||
Pencil,
|
||||
Train,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
@@ -29,9 +30,10 @@ import {
|
||||
} from "@mantine/core";
|
||||
|
||||
import { PageContainer } from "@/components/page";
|
||||
import { CheckpointTimeModal } from "@/components/trainScheduling/CheckpointTimeModal";
|
||||
import { LogPassYardWorkModal } from "@/components/trainScheduling/LogPassYardWorkModal";
|
||||
import { RouteCorridorTrack } from "@/components/trainScheduling/RouteCorridorTrack";
|
||||
import type { TrackStation } from "@/types/trainScheduling";
|
||||
import type { TrackStation, TrainCheckpoint } from "@/types/trainScheduling";
|
||||
import {
|
||||
RouteCorridor,
|
||||
StatusPill,
|
||||
@@ -156,6 +158,16 @@ export default function TrainScheduleTrackPage() {
|
||||
const recordCheckpoint = useMutation(
|
||||
api.trainScheduling.recordCheckpoint.mutationOptions(),
|
||||
);
|
||||
const updateCheckpoint = useMutation(
|
||||
api.trainScheduling.updateCheckpoint.mutationOptions(),
|
||||
);
|
||||
// Time-entry dialogs: logging a pass at a yard with no work (the yard-work
|
||||
// modal carries its own picker), and correcting an already-logged leg.
|
||||
const [logModal, setLogModal] = useState<{
|
||||
station: TrackStation;
|
||||
isFinal: boolean;
|
||||
} | null>(null);
|
||||
const [editModal, setEditModal] = useState<TrainCheckpoint | null>(null);
|
||||
// Yard work drives the log-pass modal: which bookings board/alight per stop.
|
||||
const yardWorkQuery = useQuery(
|
||||
api.trainScheduling.yardWork.queryOptions({
|
||||
@@ -241,15 +253,30 @@ export default function TrainScheduleTrackPage() {
|
||||
|
||||
const handleLog = (sequenceNo: number) => {
|
||||
const station = track.stations.find((s) => s.sequenceNo === sequenceNo);
|
||||
if (!station) return;
|
||||
const isFinal = sequenceNo === track.stations[totalStations - 1]?.sequenceNo;
|
||||
if (station && stationHasWork(station)) {
|
||||
if (stationHasWork(station)) {
|
||||
setYardModal({ station, isFinal, alreadyLogged: false });
|
||||
return;
|
||||
}
|
||||
setLogModal({ station, isFinal });
|
||||
};
|
||||
|
||||
const submitLog = (values: { occurredAt: string; note: string }) => {
|
||||
if (!logModal) return;
|
||||
const { station, isFinal } = logModal;
|
||||
recordCheckpoint.mutate(
|
||||
{ id: scheduleId, payload: { sequenceNo } },
|
||||
{
|
||||
id: scheduleId,
|
||||
payload: {
|
||||
sequenceNo: station.sequenceNo,
|
||||
occurredAt: values.occurredAt,
|
||||
...(values.note ? { note: values.note } : {}),
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setLogModal(null);
|
||||
toast({
|
||||
title: isFinal
|
||||
? "Train arrived — assets freed, moved to destination yard"
|
||||
@@ -266,6 +293,32 @@ export default function TrainScheduleTrackPage() {
|
||||
);
|
||||
};
|
||||
|
||||
const submitEdit = (values: { occurredAt: string; note: string }) => {
|
||||
if (!editModal) return;
|
||||
updateCheckpoint.mutate(
|
||||
{
|
||||
id: scheduleId,
|
||||
sequenceNo: editModal.sequenceNo,
|
||||
payload: { occurredAt: values.occurredAt, note: values.note || null },
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setEditModal(null);
|
||||
toast({ title: "Checkpoint updated" });
|
||||
},
|
||||
onError: (err) =>
|
||||
toast({
|
||||
title: "Could not update checkpoint",
|
||||
description: parseError(err, "Please try again"),
|
||||
variant: "destructive",
|
||||
}),
|
||||
},
|
||||
);
|
||||
};
|
||||
// Legs stay correctable for as long as the journey exists — while rolling
|
||||
// and after arrival.
|
||||
const canEdit = track.status === "DISPATCHED" || track.status === "ARRIVED";
|
||||
|
||||
// "Forgot to load" catch: while the train sits at the current station, any
|
||||
// boarder there that is still unloaded can be loaded until the next pass.
|
||||
const currentStationObj = track.stations.find(
|
||||
@@ -502,6 +555,7 @@ export default function TrainScheduleTrackPage() {
|
||||
: null
|
||||
}
|
||||
onLogCheckpoint={handleLog}
|
||||
onEditCheckpoint={canEdit ? setEditModal : undefined}
|
||||
/>
|
||||
|
||||
{/* Cargo the operator forgot: boarders at the CURRENT station stay
|
||||
@@ -595,24 +649,38 @@ export default function TrainScheduleTrackPage() {
|
||||
)
|
||||
}
|
||||
title={
|
||||
<Group gap="sm">
|
||||
<Text fw={700} size="sm">
|
||||
{cp.label ?? `Station ${cp.sequenceNo}`}
|
||||
</Text>
|
||||
<Badge
|
||||
size="xs"
|
||||
radius="sm"
|
||||
variant="light"
|
||||
color={
|
||||
cp.kind === "ARRIVED"
|
||||
? "teal"
|
||||
: cp.kind === "DEPARTED"
|
||||
? "blue"
|
||||
: "edr-green"
|
||||
}
|
||||
>
|
||||
{cp.kind}
|
||||
</Badge>
|
||||
<Group gap="sm" justify="space-between" wrap="nowrap">
|
||||
<Group gap="sm">
|
||||
<Text fw={700} size="sm">
|
||||
{cp.label ?? `Station ${cp.sequenceNo}`}
|
||||
</Text>
|
||||
<Badge
|
||||
size="xs"
|
||||
radius="sm"
|
||||
variant="light"
|
||||
color={
|
||||
cp.kind === "ARRIVED"
|
||||
? "teal"
|
||||
: cp.kind === "DEPARTED"
|
||||
? "blue"
|
||||
: "edr-green"
|
||||
}
|
||||
>
|
||||
{cp.kind}
|
||||
</Badge>
|
||||
</Group>
|
||||
{canEdit ? (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
radius="md"
|
||||
variant="light"
|
||||
color="gray"
|
||||
leftSection={<Pencil size={12} />}
|
||||
onClick={() => setEditModal(cp)}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
) : null}
|
||||
</Group>
|
||||
}
|
||||
>
|
||||
@@ -630,6 +698,39 @@ export default function TrainScheduleTrackPage() {
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
<CheckpointTimeModal
|
||||
opened={logModal !== null}
|
||||
onClose={() => setLogModal(null)}
|
||||
title={
|
||||
logModal?.isFinal
|
||||
? `Mark arrived at ${logModal.station.label}`
|
||||
: `Log pass at ${logModal?.station.label ?? "station"}`
|
||||
}
|
||||
icon={logModal?.isFinal ? <Flag size={18} /> : <MapPin size={18} />}
|
||||
description={
|
||||
logModal?.isFinal
|
||||
? "Marks the train arrived: remaining bookings arrive, assets are freed."
|
||||
: undefined
|
||||
}
|
||||
submitLabel={logModal?.isFinal ? "Mark arrived" : "Log pass"}
|
||||
submitColor={logModal?.isFinal ? "teal" : "edr-green"}
|
||||
loading={recordCheckpoint.isPending}
|
||||
onSubmit={submitLog}
|
||||
/>
|
||||
|
||||
<CheckpointTimeModal
|
||||
opened={editModal !== null}
|
||||
onClose={() => setEditModal(null)}
|
||||
title={`Edit ${editModal?.label ?? "checkpoint"}`}
|
||||
icon={<Pencil size={18} />}
|
||||
description="Corrects this leg's time and note only — nothing else changes."
|
||||
initialOccurredAt={editModal?.occurredAt}
|
||||
initialNote={editModal?.note}
|
||||
submitLabel="Save"
|
||||
loading={updateCheckpoint.isPending}
|
||||
onSubmit={submitEdit}
|
||||
/>
|
||||
|
||||
<LogPassYardWorkModal
|
||||
opened={yardModal !== null}
|
||||
onClose={() => setYardModal(null)}
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
Navigation,
|
||||
Package,
|
||||
PackageCheck,
|
||||
Grid3x3,
|
||||
Route as RouteIcon,
|
||||
Ruler,
|
||||
Send,
|
||||
@@ -41,6 +42,7 @@ import {
|
||||
Weight,
|
||||
Workflow as WorkflowIcon,
|
||||
} from "lucide-react";
|
||||
import { DateTimePicker } from "@mantine/dates";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
|
||||
@@ -57,6 +59,7 @@ import { ContainerPlacementGrid } from "@/components/trainScheduling/ContainerPl
|
||||
import { FleetAvailabilitySummary } from "@/components/trainScheduling/FleetAvailabilitySummary";
|
||||
import { IntercityRideAlongPanel } from "@/components/trainScheduling/IntercityRideAlongPanel";
|
||||
import { LegCapacityPanel } from "@/components/trainScheduling/LegCapacityPanel";
|
||||
import { LegLoadBoardPanel } from "@/components/trainScheduling/LegLoadBoardPanel";
|
||||
import { LoadEmptyContainersModal } from "@/components/trainScheduling/LoadEmptyContainersModal";
|
||||
import MergeScheduleTrainModal from "@/components/trainScheduling/MergeScheduleTrainModal";
|
||||
import ScheduleHistoryPanel from "@/components/trainScheduling/ScheduleHistoryPanel";
|
||||
@@ -125,6 +128,13 @@ export default function TrainScheduleV2DetailPage() {
|
||||
const [gatepassFileUrl, setGatepassFileUrl] = useState("");
|
||||
const [gatepassNotes, setGatepassNotes] = useState("");
|
||||
const [dispatchConfirmOpen, setDispatchConfirmOpen] = useState(false);
|
||||
// Actual departure — staff often dispatch on paper first and record it later,
|
||||
// so the time is picked (defaults to now when the dialog opens).
|
||||
const [dispatchAt, setDispatchAt] = useState<Date | null>(null);
|
||||
const openDispatchConfirm = () => {
|
||||
setDispatchAt(new Date());
|
||||
setDispatchConfirmOpen(true);
|
||||
};
|
||||
const [switchTarget, setSwitchTarget] = useState<EligibleContainerBooking | null>(null);
|
||||
const [visualization3DOpen, setVisualization3DOpen] = useState(false);
|
||||
const [loadEmptiesOpen, setLoadEmptiesOpen] = useState(false);
|
||||
@@ -477,7 +487,10 @@ export default function TrainScheduleV2DetailPage() {
|
||||
const runDispatch = async () => {
|
||||
setDispatchConfirmOpen(false);
|
||||
try {
|
||||
await dispatch.mutateAsync(scheduleId);
|
||||
await dispatch.mutateAsync({
|
||||
id: scheduleId,
|
||||
payload: dispatchAt ? { actualDepartureAt: dispatchAt.toISOString() } : {},
|
||||
});
|
||||
await openMarshallingDocument({
|
||||
title: "Train dispatched",
|
||||
successDescription: "Marshalling document generated for the dispatched train.",
|
||||
@@ -873,7 +886,7 @@ export default function TrainScheduleV2DetailPage() {
|
||||
radius="md"
|
||||
leftSection={<Send size={18} />}
|
||||
loading={dispatch.isPending}
|
||||
onClick={() => setDispatchConfirmOpen(true)}
|
||||
onClick={openDispatchConfirm}
|
||||
>
|
||||
Dispatch train
|
||||
</Button>
|
||||
@@ -1271,6 +1284,9 @@ export default function TrainScheduleV2DetailPage() {
|
||||
<Tabs.Tab value="legs" leftSection={<RouteIcon size={16} />}>
|
||||
Leg capacity
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="leg-board" leftSection={<Grid3x3 size={16} />}>
|
||||
Leg board
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="history" leftSection={<HistoryIcon size={16} />}>
|
||||
History
|
||||
</Tabs.Tab>
|
||||
@@ -1359,6 +1375,13 @@ export default function TrainScheduleV2DetailPage() {
|
||||
<LegCapacityPanel schedule={schedule} />
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="leg-board">
|
||||
<LegLoadBoardPanel
|
||||
schedule={schedule}
|
||||
onChanged={() => void detailQuery.refetch()}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="history">
|
||||
{scheduleId ? <ScheduleHistoryPanel scheduleId={scheduleId} /> : null}
|
||||
</Tabs.Panel>
|
||||
@@ -1444,6 +1467,17 @@ export default function TrainScheduleV2DetailPage() {
|
||||
undone.
|
||||
</Text>
|
||||
|
||||
<DateTimePicker
|
||||
label="Actual departure"
|
||||
description="When the train left — defaults to now; a past time is fine."
|
||||
value={dispatchAt}
|
||||
onChange={(v) => setDispatchAt(v ? new Date(v) : null)}
|
||||
maxDate={new Date()}
|
||||
valueFormat="DD MMM YYYY HH:mm"
|
||||
clearable={false}
|
||||
radius="md"
|
||||
/>
|
||||
|
||||
{hasDispatchWarnings ? (
|
||||
<Alert
|
||||
color="orange"
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
} from "@mantine/core";
|
||||
import { DateTimePicker } from "@mantine/dates";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { isAxiosError } from "axios";
|
||||
import {
|
||||
@@ -134,6 +135,8 @@ export default function TrainScheduleV2ListPage() {
|
||||
// confirmation.
|
||||
const [dispatchTarget, setDispatchTarget] =
|
||||
useState<TrainScheduleListItem | null>(null);
|
||||
// Actual departure — defaults to now when the dialog opens; past is fine.
|
||||
const [dispatchAt, setDispatchAt] = useState<Date | null>(null);
|
||||
// Cancelling is likewise irreversible — confirmed before the mutation fires.
|
||||
const [cancelTarget, setCancelTarget] =
|
||||
useState<TrainScheduleListItem | null>(null);
|
||||
@@ -362,6 +365,7 @@ export default function TrainScheduleV2ListPage() {
|
||||
{row.original.direction}
|
||||
</Badge>
|
||||
) : null}
|
||||
<ShippingLineBadge schedule={row.original} />
|
||||
</Group>
|
||||
<Box maw={220}>
|
||||
<RouteCorridor
|
||||
@@ -507,7 +511,10 @@ export default function TrainScheduleV2ListPage() {
|
||||
{canDispatch && schedule.status === "SCHEDULED" ? (
|
||||
<Menu.Item
|
||||
leftSection={<Play size={15} />}
|
||||
onClick={() => setDispatchTarget(schedule)}
|
||||
onClick={() => {
|
||||
setDispatchAt(new Date());
|
||||
setDispatchTarget(schedule);
|
||||
}}
|
||||
>
|
||||
Start (dispatch) train
|
||||
</Menu.Item>
|
||||
@@ -742,7 +749,11 @@ export default function TrainScheduleV2ListPage() {
|
||||
onRowClick={(schedule) =>
|
||||
navigate(`/dashboard/operations/train-scheduling-v2/${schedule.id}`)
|
||||
}
|
||||
rowStyle={(schedule) => directionRowStyle(schedule.direction)}
|
||||
rowStyle={(schedule) =>
|
||||
schedule.shippingLineCompanyId
|
||||
? SHIPPING_LINE_ROW_STYLE
|
||||
: directionRowStyle(schedule.direction)
|
||||
}
|
||||
error={
|
||||
schedulesQuery.isError
|
||||
? {
|
||||
@@ -954,6 +965,16 @@ export default function TrainScheduleV2ListPage() {
|
||||
wagons or cargo not yet marked loaded — those warnings are shown
|
||||
there, not here.
|
||||
</Text>
|
||||
<DateTimePicker
|
||||
label="Actual departure"
|
||||
description="When the train left — defaults to now; a past time is fine."
|
||||
value={dispatchAt}
|
||||
onChange={(v) => setDispatchAt(v ? new Date(v) : null)}
|
||||
maxDate={new Date()}
|
||||
valueFormat="DD MMM YYYY HH:mm"
|
||||
clearable={false}
|
||||
radius="md"
|
||||
/>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" onClick={() => setDispatchTarget(null)}>
|
||||
Cancel
|
||||
@@ -965,7 +986,12 @@ export default function TrainScheduleV2ListPage() {
|
||||
onClick={async () => {
|
||||
if (!dispatchTarget) return;
|
||||
try {
|
||||
await dispatchSchedule.mutateAsync(dispatchTarget.id);
|
||||
await dispatchSchedule.mutateAsync({
|
||||
id: dispatchTarget.id,
|
||||
payload: dispatchAt
|
||||
? { actualDepartureAt: dispatchAt.toISOString() }
|
||||
: {},
|
||||
});
|
||||
toast({ title: "Train dispatched" });
|
||||
setDispatchTarget(null);
|
||||
void schedulesQuery.refetch();
|
||||
@@ -1052,6 +1078,20 @@ export default function TrainScheduleV2ListPage() {
|
||||
* bookings that have not paid yet — that space is claimed, so it is not
|
||||
* bookable.
|
||||
*/
|
||||
/** Green tint for departures dedicated to a shipping line (overrides direction tint). */
|
||||
const SHIPPING_LINE_ROW_STYLE = {
|
||||
backgroundColor: "var(--mantine-color-edr-green-0)",
|
||||
} as const;
|
||||
|
||||
function ShippingLineBadge({ schedule }: { schedule: TrainScheduleListItem }) {
|
||||
if (!schedule.shippingLineCompanyId) return null;
|
||||
return (
|
||||
<Badge size="xs" variant="light" color="edr-green">
|
||||
{schedule.shippingLineCompanyName ?? "Shipping line"}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function WagonChips({ schedule }: { schedule: TrainScheduleListItem }) {
|
||||
// Pre-deploy API rows carry only wagonCount; fall back so the chip still
|
||||
// renders rather than reading 0 used on every train.
|
||||
@@ -1133,6 +1173,7 @@ function ScheduleCard({
|
||||
withBorder
|
||||
onClick={onOpen}
|
||||
className="cursor-pointer overflow-hidden transition-[transform,border-color] duration-150 hover:-translate-y-0.5 hover:border-edr-primary!"
|
||||
style={schedule.shippingLineCompanyId ? SHIPPING_LINE_ROW_STYLE : undefined}
|
||||
>
|
||||
<Stack gap="sm" p="md">
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
@@ -1182,6 +1223,7 @@ function ScheduleCard({
|
||||
{schedule.direction}
|
||||
</Badge>
|
||||
) : null}
|
||||
<ShippingLineBadge schedule={schedule} />
|
||||
</Group>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<MetricChip value={schedule.bookingsCount} label="bkg" />
|
||||
|
||||
@@ -961,6 +961,7 @@ interface StandaloneReturnModalProps {
|
||||
|
||||
function StandaloneReturnModal({ opened, onClose, onSubmit, loading }: StandaloneReturnModalProps) {
|
||||
const [containerNumber, setContainerNumber] = useState<string>("");
|
||||
const [containerSize, setContainerSize] = useState<EmptyContainerSize | null>(null);
|
||||
const [returnedBy, setReturnedBy] = useState<"EDR" | "CUSTOMER" | null>(null);
|
||||
const [returnDate, setReturnDate] = useState<string>(new Date().toISOString().split("T")[0]);
|
||||
const [warehouse, setWarehouse] = useState<string | null>(null);
|
||||
@@ -1021,6 +1022,7 @@ function StandaloneReturnModal({ opened, onClose, onSubmit, loading }: Standalon
|
||||
containers: [
|
||||
{
|
||||
containerNumber,
|
||||
containerSize: containerSize ?? undefined,
|
||||
returnDate,
|
||||
warehouse: selectedWarehouse?.name || warehouse,
|
||||
yard: selectedYard?.name,
|
||||
@@ -1034,6 +1036,7 @@ function StandaloneReturnModal({ opened, onClose, onSubmit, loading }: Standalon
|
||||
});
|
||||
|
||||
setContainerNumber("");
|
||||
setContainerSize(null);
|
||||
setReturnedBy(null);
|
||||
setReturnDate(new Date().toISOString().split("T")[0]);
|
||||
setWarehouse(null);
|
||||
@@ -1071,6 +1074,17 @@ function StandaloneReturnModal({ opened, onClose, onSubmit, loading }: Standalon
|
||||
required
|
||||
/>
|
||||
|
||||
<Select
|
||||
label="Container Type"
|
||||
placeholder="Select container size"
|
||||
value={containerSize}
|
||||
onChange={(val) => setContainerSize(val as EmptyContainerSize | null)}
|
||||
data={[
|
||||
{ value: "20", label: "20 ft" },
|
||||
{ value: "40", label: "40 ft" },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Select
|
||||
label="Return Warehouse"
|
||||
placeholder="Select warehouse for container return"
|
||||
|
||||
@@ -39,6 +39,7 @@ import type {
|
||||
} from "@/types/fileUploadSettings";
|
||||
import type {
|
||||
Invoice,
|
||||
InvoiceCollectedSummary,
|
||||
InvoiceListFilter,
|
||||
PaginatedInvoices,
|
||||
PaginatedOfflineUsdInvoices,
|
||||
@@ -80,6 +81,8 @@ import type {
|
||||
LocomotiveRecord,
|
||||
PinWagonsPayload,
|
||||
RecordCheckpointPayload,
|
||||
UpdateCheckpointPayload,
|
||||
DispatchSchedulePayload,
|
||||
StaffBookingWindow,
|
||||
ScheduleMergePreview,
|
||||
TrainScheduleDetail,
|
||||
@@ -173,7 +176,7 @@ import { customersService } from "./customers.service";
|
||||
import { shippingLineCompaniesService } from "./shippingLineCompanies.service";
|
||||
import { shippingLineCreditsService } from "./shippingLineCredits.service";
|
||||
import { eimsService } from "./eims.service";
|
||||
import type { EimsInvoiceStatusView, EimsVerifyResult } from "@/types/eims";
|
||||
import type { EimsInvoiceStatusView, EimsModeOfPayment, EimsReceiptView, EimsVerifyResult } from "@/types/eims";
|
||||
import { invoicesService } from "./invoices.service";
|
||||
import { dropdownSettingsService } from "./dropdownSettings.service";
|
||||
import { fileUploadSettingsService } from "./fileUploadSettings.service";
|
||||
@@ -796,10 +799,13 @@ export const api = {
|
||||
],
|
||||
),
|
||||
|
||||
dispatchSchedule: endpoint<string, TrainScheduleDetail>(
|
||||
dispatchSchedule: endpoint<
|
||||
{ id: string; payload?: DispatchSchedulePayload },
|
||||
TrainScheduleDetail
|
||||
>(
|
||||
"train-scheduling",
|
||||
"dispatch-schedule",
|
||||
(id) => trainSchedulingService.dispatchSchedule(id),
|
||||
({ id, payload }) => trainSchedulingService.dispatchSchedule(id, payload),
|
||||
undefined,
|
||||
() => TRAIN_SCHEDULING_INVALIDATIONS,
|
||||
),
|
||||
@@ -917,6 +923,18 @@ export const api = {
|
||||
() => TRAIN_SCHEDULING_INVALIDATIONS,
|
||||
),
|
||||
|
||||
updateCheckpoint: endpoint<
|
||||
{ id: string; sequenceNo: number; payload: UpdateCheckpointPayload },
|
||||
TrainTrackResponse
|
||||
>(
|
||||
"train-scheduling",
|
||||
"update-checkpoint",
|
||||
({ id, sequenceNo, payload }) =>
|
||||
trainSchedulingService.updateCheckpoint(id, sequenceNo, payload),
|
||||
undefined,
|
||||
() => TRAIN_SCHEDULING_INVALIDATIONS,
|
||||
),
|
||||
|
||||
arriveSchedule: endpoint<string, TrainScheduleDetail>(
|
||||
"train-scheduling",
|
||||
"arrive-schedule",
|
||||
@@ -3137,6 +3155,16 @@ export const api = {
|
||||
({ id }) => QUERY_KEYS.INVOICES.byId(id),
|
||||
),
|
||||
|
||||
collectedSummary: endpoint<
|
||||
{ filter: Omit<InvoiceListFilter, "page" | "pageSize"> },
|
||||
InvoiceCollectedSummary
|
||||
>(
|
||||
"invoices",
|
||||
"collectedSummary",
|
||||
({ filter }) => invoicesService.collectedSummary(filter),
|
||||
({ filter }) => QUERY_KEYS.INVOICES.summary(filter),
|
||||
),
|
||||
|
||||
listOfflineUsd: endpoint<
|
||||
{ filter: InvoiceListFilter },
|
||||
PaginatedOfflineUsdInvoices
|
||||
@@ -3189,6 +3217,51 @@ export const api = {
|
||||
undefined,
|
||||
({ id }) => [QUERY_KEYS.INVOICES.eimsStatus(id), QUERY_KEYS.INVOICES.byId(id)],
|
||||
),
|
||||
|
||||
eimsCancel: endpoint<{ id: string; reasonCode: string; remark?: string }, EimsInvoiceStatusView>(
|
||||
"invoices",
|
||||
"eimsCancel",
|
||||
({ id, reasonCode, remark }) => eimsService.cancel(id, { reasonCode, remark }),
|
||||
undefined,
|
||||
({ id }) => [QUERY_KEYS.INVOICES.eimsStatus(id), QUERY_KEYS.INVOICES.byId(id)],
|
||||
),
|
||||
|
||||
eimsReceipts: endpoint<{ id: string }, EimsReceiptView[]>(
|
||||
"invoices",
|
||||
"eimsReceipts",
|
||||
({ id }) => eimsService.listReceipts(id),
|
||||
({ id }) => QUERY_KEYS.INVOICES.eimsReceipts(id),
|
||||
),
|
||||
|
||||
eimsRegisterSalesReceipt: endpoint<
|
||||
{ id: string; modeOfPayment: EimsModeOfPayment; reason?: string; collectedAmount?: number },
|
||||
EimsReceiptView
|
||||
>(
|
||||
"invoices",
|
||||
"eimsRegisterSalesReceipt",
|
||||
({ id, ...input }) => eimsService.registerSalesReceipt(id, input),
|
||||
undefined,
|
||||
({ id }) => [QUERY_KEYS.INVOICES.eimsReceipts(id)],
|
||||
),
|
||||
|
||||
eimsRegisterWithholdingReceipt: endpoint<
|
||||
{ id: string; type: string; preTaxAmount: number; withholdingAmount: number; reason?: string },
|
||||
EimsReceiptView
|
||||
>(
|
||||
"invoices",
|
||||
"eimsRegisterWithholdingReceipt",
|
||||
({ id, ...input }) => eimsService.registerWithholdingReceipt(id, input),
|
||||
undefined,
|
||||
({ id }) => [QUERY_KEYS.INVOICES.eimsReceipts(id)],
|
||||
),
|
||||
|
||||
issueMemo: endpoint<{ id: string; type: "CRE" | "DEB"; reason: string }, Invoice>(
|
||||
"invoices",
|
||||
"issueMemo",
|
||||
({ id, ...input }) => invoicesService.issueMemo(id, input),
|
||||
undefined,
|
||||
() => [QUERY_KEYS.INVOICES.ROOT],
|
||||
),
|
||||
},
|
||||
|
||||
overview: {
|
||||
|
||||
@@ -36,6 +36,8 @@ export interface BookingListFilter {
|
||||
scheduledTo?: string;
|
||||
originYardId?: string;
|
||||
destinationYardId?: string;
|
||||
/** SHIPPING_LINE = booked by a shipping line; CUSTOMER = ordinary customer company. */
|
||||
customerKind?: "SHIPPING_LINE" | "CUSTOMER";
|
||||
/** "true" = government bookings only, "false" = private only. */
|
||||
isGovernment?: "true" | "false";
|
||||
/** Free-text search: booking reference, customer name, contract reference (server-side). */
|
||||
@@ -151,6 +153,7 @@ export const bookingsService = {
|
||||
if (filter.originYardId) params.originYardId = filter.originYardId;
|
||||
if (filter.destinationYardId) params.destinationYardId = filter.destinationYardId;
|
||||
if (filter.isGovernment) params.isGovernment = filter.isGovernment;
|
||||
if (filter.customerKind) params.customerKind = filter.customerKind;
|
||||
}
|
||||
const response = await client.get<BookingListSummary>(B.LIST_SUMMARY, {
|
||||
params,
|
||||
@@ -184,6 +187,7 @@ export const bookingsService = {
|
||||
if (filter.originYardId) params.originYardId = filter.originYardId;
|
||||
if (filter.destinationYardId) params.destinationYardId = filter.destinationYardId;
|
||||
if (filter.isGovernment) params.isGovernment = filter.isGovernment;
|
||||
if (filter.customerKind) params.customerKind = filter.customerKind;
|
||||
if (filter.customsClearingEnabled)
|
||||
params.customsClearingEnabled = filter.customsClearingEnabled;
|
||||
if (filter.search) params.search = filter.search;
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { api as apiClient } from "@/auth/http";
|
||||
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||
import type { EimsInvoiceStatusView, EimsVerifyResult } from "@/types/eims";
|
||||
import type {
|
||||
EimsInvoiceStatusView,
|
||||
EimsModeOfPayment,
|
||||
EimsReceiptView,
|
||||
EimsVerifyResult,
|
||||
} from "@/types/eims";
|
||||
|
||||
/**
|
||||
* MoR EIMS filing actions on an invoice.
|
||||
@@ -36,4 +41,45 @@ export const eimsService = {
|
||||
.post<EimsInvoiceStatusView>(URL_CONSTANTS.EIMS.RESOLVE(invoiceId), input)
|
||||
.then((r) => r.data);
|
||||
},
|
||||
|
||||
/** Cancel the invoice's registered EIMS document. Refuses (409) an already-cancelled one. */
|
||||
cancel(
|
||||
invoiceId: string,
|
||||
input: { reasonCode: string; remark?: string },
|
||||
): Promise<EimsInvoiceStatusView> {
|
||||
return apiClient
|
||||
.post<EimsInvoiceStatusView>(URL_CONSTANTS.EIMS.CANCEL(invoiceId), input)
|
||||
.then((r) => r.data);
|
||||
},
|
||||
|
||||
registerSalesReceipt(
|
||||
invoiceId: string,
|
||||
input: { modeOfPayment: EimsModeOfPayment; reason?: string; collectedAmount?: number },
|
||||
): Promise<EimsReceiptView> {
|
||||
return apiClient
|
||||
.post<EimsReceiptView>(URL_CONSTANTS.EIMS.RECEIPT_SALES(invoiceId), input)
|
||||
.then((r) => r.data);
|
||||
},
|
||||
|
||||
registerWithholdingReceipt(
|
||||
invoiceId: string,
|
||||
input: { type: string; preTaxAmount: number; withholdingAmount: number; reason?: string },
|
||||
): Promise<EimsReceiptView> {
|
||||
return apiClient
|
||||
.post<EimsReceiptView>(URL_CONSTANTS.EIMS.RECEIPT_WITHHOLDING(invoiceId), input)
|
||||
.then((r) => r.data);
|
||||
},
|
||||
|
||||
listReceipts(invoiceId: string): Promise<EimsReceiptView[]> {
|
||||
return apiClient
|
||||
.get<EimsReceiptView[]>(URL_CONSTANTS.EIMS.RECEIPTS(invoiceId))
|
||||
.then((r) => r.data);
|
||||
},
|
||||
|
||||
/** Blob download — same pattern as `invoicesService.downloadDocument`. */
|
||||
downloadReceiptDocument(invoiceId: string, receiptId: string) {
|
||||
return apiClient.get<Blob>(URL_CONSTANTS.EIMS.RECEIPT_DOCUMENT(invoiceId, receiptId), {
|
||||
responseType: "blob",
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import { api as apiClient } from "@/auth/http";
|
||||
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||
import type {
|
||||
Invoice,
|
||||
InvoiceCollectedSummary,
|
||||
InvoiceListFilter,
|
||||
PaginatedInvoices,
|
||||
PaginatedOfflineUsdInvoices,
|
||||
@@ -23,19 +24,42 @@ export const invoicesService = {
|
||||
.then((r) => r.data);
|
||||
},
|
||||
|
||||
/** Total collected (paidAmount) across every filtered invoice, by currency. */
|
||||
collectedSummary(
|
||||
filter: Omit<InvoiceListFilter, "page" | "pageSize">,
|
||||
): Promise<InvoiceCollectedSummary> {
|
||||
return apiClient
|
||||
.get<InvoiceCollectedSummary>(URL_CONSTANTS.BILLING.INVOICES_SUMMARY, {
|
||||
params: cleanParams(filter),
|
||||
})
|
||||
.then((r) => r.data);
|
||||
},
|
||||
|
||||
getById(id: string): Promise<Invoice> {
|
||||
return apiClient
|
||||
.get<Invoice>(URL_CONSTANTS.BILLING.INVOICE_BY_ID(id))
|
||||
.then((r) => r.data);
|
||||
},
|
||||
|
||||
downloadDocument(id: string) {
|
||||
/** `format` omitted or "a4" → standard A4 PDF; "thermal" → 80mm thermal layout (ADD-P001). */
|
||||
downloadDocument(id: string, format?: "a4" | "thermal") {
|
||||
return apiClient.get<Blob>(URL_CONSTANTS.BILLING.INVOICE_DOCUMENT(id), {
|
||||
responseType: "blob",
|
||||
params: format ? { format } : undefined,
|
||||
});
|
||||
},
|
||||
|
||||
/** Finance worklist: USD invoices awaiting bank-transfer confirmation. */
|
||||
/** Issue a credit/debit memo against a registered invoice (MoR DEB/CRE) — filing-equivalent. */
|
||||
issueMemo(
|
||||
id: string,
|
||||
input: { type: "CRE" | "DEB"; reason: string },
|
||||
): Promise<Invoice> {
|
||||
return apiClient
|
||||
.post<Invoice>(URL_CONSTANTS.BILLING.INVOICE_MEMO(id), input)
|
||||
.then((r) => r.data);
|
||||
},
|
||||
|
||||
/** Finance worklist: USD and ETB invoices awaiting manual payment confirmation. */
|
||||
listOfflineUsd(
|
||||
filter: InvoiceListFilter,
|
||||
): Promise<PaginatedOfflineUsdInvoices> {
|
||||
@@ -46,7 +70,7 @@ export const invoicesService = {
|
||||
.then((r) => r.data);
|
||||
},
|
||||
|
||||
/** Confirm a USD invoice paid by bank transfer — the slip file is required. */
|
||||
/** Confirm an invoice (USD or ETB) paid manually — the slip file is required. */
|
||||
confirmOffline(id: string, file: File, reference?: string): Promise<Invoice> {
|
||||
const body = new FormData();
|
||||
body.append("file", file);
|
||||
|
||||
@@ -28,6 +28,8 @@ import type {
|
||||
LocomotiveRecord,
|
||||
PinWagonsPayload,
|
||||
RecordCheckpointPayload,
|
||||
UpdateCheckpointPayload,
|
||||
DispatchSchedulePayload,
|
||||
StaffBookingWindow,
|
||||
ScheduleMergePreview,
|
||||
TrainScheduleDetail,
|
||||
@@ -524,10 +526,11 @@ export const trainSchedulingService = {
|
||||
|
||||
dispatchSchedule: async (
|
||||
scheduleId: string,
|
||||
payload: DispatchSchedulePayload = {},
|
||||
): Promise<TrainScheduleDetail> => {
|
||||
const response = await client.post<TrainScheduleDetail>(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.DISPATCH(scheduleId),
|
||||
{},
|
||||
payload,
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
@@ -696,6 +699,18 @@ export const trainSchedulingService = {
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
updateCheckpoint: async (
|
||||
scheduleId: string,
|
||||
sequenceNo: number,
|
||||
payload: UpdateCheckpointPayload,
|
||||
): Promise<TrainTrackResponse> => {
|
||||
const response = await client.patch<TrainTrackResponse>(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.CHECKPOINT(scheduleId, sequenceNo),
|
||||
payload,
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
arriveSchedule: async (scheduleId: string): Promise<TrainScheduleDetail> => {
|
||||
const response = await client.post<TrainScheduleDetail>(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.ARRIVE(scheduleId),
|
||||
|
||||
@@ -26,6 +26,9 @@ export interface Wagon {
|
||||
lengthMeters?: number;
|
||||
} | null;
|
||||
status: Freight.WagonStatus;
|
||||
/** Latest status-log flip to MAINTENANCE / to AVAILABLE (list endpoint only). */
|
||||
lastMaintenanceAt?: string | null;
|
||||
lastAvailableAt?: string | null;
|
||||
currentYardId: string | null;
|
||||
currentYard?: { id: string; label?: string; code?: string } | null;
|
||||
/** Odd EXPORT run (Ethiopia → Djibouti); null when the wagon is not on a run. */
|
||||
|
||||
@@ -251,6 +251,10 @@ export interface BookingDetail {
|
||||
updatedAt: string;
|
||||
// customer?: BookingNamedRef & { companyName?: string };
|
||||
company?: BookingNamedRef & Partial<BookingCompany>;
|
||||
/** Set when booked by a shipping line (then `companyId`/`company` are null). */
|
||||
shippingLineCompanyId?: string | null;
|
||||
/** Owner when booked by a shipping line (then `company` is absent). Hydrated server-side. */
|
||||
shippingLineCompany?: { id: string; name: string; email?: string | null; phoneNumber?: string | null };
|
||||
originYard?: BookingNamedRef;
|
||||
destinationYard?: BookingNamedRef;
|
||||
serviceType?: BookingNamedRef & { code?: string; includesCustoms?: boolean; includesFirstMile?: boolean; includesLastMile?: boolean };
|
||||
@@ -273,6 +277,8 @@ export interface BookingListRow {
|
||||
/** Needed to link the reference to the contract's detail page. */
|
||||
contractId?: string | null;
|
||||
customerLabel: string;
|
||||
/** True when the booking is owned by a shipping line rather than a customer company. */
|
||||
isShippingLine?: boolean;
|
||||
status: BookingStatus;
|
||||
scheduledDate: string;
|
||||
totalAmount: number;
|
||||
|
||||
@@ -65,3 +65,16 @@ export interface EimsVerifyResult {
|
||||
[section: string]: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
/** MoR `ModeOfPayment` enum — confirmed by a live schema error, verbatim spelling/casing. */
|
||||
export const EIMS_MODE_OF_PAYMENT = [
|
||||
"CASH",
|
||||
"CHEQUE",
|
||||
"CPO",
|
||||
"Local Bank Transfer",
|
||||
"SWIFT",
|
||||
"Wire Transfer",
|
||||
"Letter of Credit",
|
||||
"Card",
|
||||
] as const;
|
||||
export type EimsModeOfPayment = (typeof EIMS_MODE_OF_PAYMENT)[number];
|
||||
|
||||
@@ -13,6 +13,8 @@ export interface InvoiceListFilter {
|
||||
companyId?: string;
|
||||
status?: Freight.InvoiceStatus;
|
||||
search?: string;
|
||||
/** Manual-payments worklist only. */
|
||||
currency?: "USD" | "ETB";
|
||||
}
|
||||
|
||||
/** Standard paginated list envelope (matches the customers/bookings service shape). */
|
||||
@@ -22,20 +24,27 @@ export interface PaginatedInvoices {
|
||||
}
|
||||
|
||||
/**
|
||||
* A USD invoice on Finance's offline-settlement worklist. Booking-sourced rows
|
||||
* carry the shipment's pay-window deadline so the list can show the same
|
||||
* countdown the customer sees — Finance must confirm before it closes.
|
||||
* A USD or ETB invoice on Finance's manual-settlement worklist. Booking-sourced
|
||||
* rows carry the shipment's trade direction and pay-window deadline so the list
|
||||
* can show the same countdown the customer sees — Finance must confirm before
|
||||
* it closes.
|
||||
*/
|
||||
export interface OfflineUsdInvoice extends Invoice {
|
||||
booking: {
|
||||
id: string;
|
||||
reference: string;
|
||||
tradeDirection: string | null;
|
||||
paymentDeadline: string | null;
|
||||
paymentStatus: string;
|
||||
} | null;
|
||||
/** Shipping-line credit invoices span many bookings — one entry per credit. */
|
||||
bookings: { id: string; reference: string; tradeDirection: string | null }[];
|
||||
}
|
||||
|
||||
export interface PaginatedOfflineUsdInvoices {
|
||||
items: OfflineUsdInvoice[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
/** Total collected (`paidAmount`) across every filtered invoice, keyed by currency. */
|
||||
export type InvoiceCollectedSummary = Record<string, number>;
|
||||
|
||||
@@ -196,6 +196,9 @@ export interface TrainScheduleListItem {
|
||||
origin: string | null;
|
||||
destination: string | null;
|
||||
freightType?: FreightType | null;
|
||||
/** Set when the departure is dedicated to one shipping line (hidden from customers). */
|
||||
shippingLineCompanyId?: string | null;
|
||||
shippingLineCompanyName?: string | null;
|
||||
/** Built train (Train Builder) behind this departure, when scheduled by train. */
|
||||
train?: {
|
||||
id: string;
|
||||
@@ -907,10 +910,22 @@ export interface TrainTrackResponse {
|
||||
export interface RecordCheckpointPayload {
|
||||
sequenceNo: number;
|
||||
kind?: TrainCheckpointKind;
|
||||
/** When the train was at the station; defaults to now. Past OK, future rejected. */
|
||||
occurredAt?: string;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
/** Edit an already-logged leg — pure correction, no side effects. */
|
||||
export interface UpdateCheckpointPayload {
|
||||
occurredAt?: string;
|
||||
note?: string | null;
|
||||
}
|
||||
|
||||
export interface DispatchSchedulePayload {
|
||||
/** Actual departure; defaults to now. Past OK, future rejected. */
|
||||
actualDepartureAt?: string;
|
||||
}
|
||||
|
||||
export interface TrainScheduleFilters {
|
||||
originStationId?: string;
|
||||
destinationStationId?: string;
|
||||
|
||||
@@ -1,18 +1,32 @@
|
||||
/**
|
||||
* Break-bulk (PER_ITEM) bulk bookings overload `cargoTotalWeightVgm` with the
|
||||
* ITEM COUNT; their real tonnage lives in `bulkTotalWeightTons`. Every other
|
||||
* booking stores tons in `cargoTotalWeightVgm` directly. Rendering the raw
|
||||
* VGM column showed a 20-item / 100T booking as "20 tons".
|
||||
* ITEM COUNT; their real tonnage lives in `bulkTotalWeightTons`. Container
|
||||
* bookings never store a total at all — `cargoTotalWeightVgm` stays 0 and the
|
||||
* weight lives per line (`quantity × vgmPerUnitTons`). Every other booking
|
||||
* stores tons in `cargoTotalWeightVgm` directly. Rendering the raw VGM column
|
||||
* showed a 20-item / 100T booking as "20 tons" and every container booking as
|
||||
* "0 tons".
|
||||
*/
|
||||
export function cargoTonsAndItems(booking: {
|
||||
freightType?: string | null;
|
||||
cargoTotalWeightVgm?: number | string | null;
|
||||
bulkTotalWeightTons?: number | string | null;
|
||||
bookingContainers?: Array<{
|
||||
quantity?: number | string | null;
|
||||
vgmPerUnitTons?: number | string | null;
|
||||
}> | null;
|
||||
}): { tons: number; items: number | null } {
|
||||
const bulkTons = Number(booking.bulkTotalWeightTons ?? 0);
|
||||
const vgm = Number(booking.cargoTotalWeightVgm ?? 0);
|
||||
if (booking.freightType === "BULK" && bulkTons > 0) {
|
||||
return { tons: bulkTons, items: vgm > 0 ? vgm : null };
|
||||
}
|
||||
if (vgm <= 0 && booking.bookingContainers?.length) {
|
||||
const lineTons = booking.bookingContainers.reduce(
|
||||
(sum, c) => sum + Number(c.quantity ?? 0) * Number(c.vgmPerUnitTons ?? 0),
|
||||
0,
|
||||
);
|
||||
return { tons: Math.round(lineTons * 1000) / 1000, items: null };
|
||||
}
|
||||
return { tons: vgm, items: null };
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
Menu as MenuIcon,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Search,
|
||||
// Search,
|
||||
Settings,
|
||||
Upload,
|
||||
User,
|
||||
@@ -423,7 +423,7 @@ export function AppLayout({
|
||||
)}
|
||||
|
||||
{/* Search pill */}
|
||||
<Group
|
||||
{/* <Group
|
||||
gap={8}
|
||||
align="center"
|
||||
visibleFrom="sm"
|
||||
@@ -441,7 +441,7 @@ export function AppLayout({
|
||||
<Text size="sm" style={{ color: mutedColor, userSelect: "none" }}>
|
||||
Search shipments, bookings…
|
||||
</Text>
|
||||
</Group>
|
||||
</Group> */}
|
||||
|
||||
{/* Notifications */}
|
||||
<NotificationBellContainer />
|
||||
@@ -549,7 +549,7 @@ export function AppLayout({
|
||||
navigate("/bookings/new", { state: { fresh: true } })
|
||||
}
|
||||
>
|
||||
New Booking
|
||||
New Contract
|
||||
</Menu.Item>
|
||||
<Divider />
|
||||
<Menu.Item
|
||||
|
||||
@@ -158,7 +158,9 @@ export function ReadonlyBookingView({
|
||||
// the customer. Hide the customer's rebook everywhere and tell them GL will
|
||||
// handle it. Non-customs bookings stay self-service.
|
||||
const isCustoms = Boolean(booking.customsClearingEnabled);
|
||||
const canSelfRebook = !isCustoms;
|
||||
// A PAID booking cancelled through wagon cancellation rebooks via its credit
|
||||
// (WagonCancellationCard), not the fresh-booking rebook link.
|
||||
const canSelfRebook = !isCustoms && booking.paymentStatus !== "PAID";
|
||||
const isPendingConsolidation = status === "PENDING_CONSOLIDATION";
|
||||
const isClearance = [
|
||||
"AWAITING_DOCUMENTS",
|
||||
@@ -219,7 +221,9 @@ export function ReadonlyBookingView({
|
||||
subtitle={
|
||||
status === "REJECTED"
|
||||
? "This booking request has been rejected."
|
||||
: "This booking process has been terminated."
|
||||
: booking.paymentStatus === "PAID"
|
||||
? "All wagons were cancelled. Your paid freight is held as a credit — rebook it from the Wagon Cancellation card below."
|
||||
: "This booking process has been terminated."
|
||||
}
|
||||
reason={booking.latestChangeRequestNote}
|
||||
onRebook={canSelfRebook ? onRebook : undefined}
|
||||
@@ -348,6 +352,7 @@ export function ReadonlyBookingView({
|
||||
<Tabs.Panel value="wagons">
|
||||
<WagonsTab
|
||||
bookingId={booking.id}
|
||||
currency={booking.paymentCurrency}
|
||||
cancellable={
|
||||
booking.status === "PAID" &&
|
||||
booking.paymentStatus === "PAID" &&
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
Textarea,
|
||||
} from "@mantine/core";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { CheckCircle2, Clock, CreditCard, TrainTrack } from "lucide-react";
|
||||
import { CheckCircle2, Clock, CreditCard } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
@@ -76,7 +76,7 @@ const apiErrorMessage = (error: unknown, fallback: string) => {
|
||||
const th = { color: "#9AA8B5", fontSize: 11 } as const;
|
||||
|
||||
/**
|
||||
* Partial wagon cancellation on a PAID contract booking: request a cut (fee
|
||||
* Wagon cancellation (partial or whole) on a PAID contract booking: request a cut (fee
|
||||
* previewed first), pay the cancellation fee, then rebook the freed credit
|
||||
* onto another shipment day — plus the booking's cancellation history.
|
||||
* Wagons leave the schedule at request time; the fee settles the credit.
|
||||
@@ -90,10 +90,13 @@ export function WagonCancellationCard({
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const status = booking.status as string;
|
||||
const eligible =
|
||||
status === "PAID" &&
|
||||
(booking.paymentStatus as string) === "PAID" &&
|
||||
!!booking.contractId;
|
||||
const paidContract =
|
||||
(booking.paymentStatus as string) === "PAID" && !!booking.contractId;
|
||||
// New cuts only on a live PAID booking; a booking fully cancelled through
|
||||
// this flow (status CANCELLED) still shows the card so its credit can be
|
||||
// paid for / rebooked.
|
||||
const canRequest = status === "PAID" && paidContract;
|
||||
const eligible = paidContract && (status === "PAID" || status === "CANCELLED");
|
||||
|
||||
const isBulk = booking.freightType === "BULK";
|
||||
const detail = booking as BookingDetail;
|
||||
@@ -226,12 +229,13 @@ export function WagonCancellationCard({
|
||||
});
|
||||
|
||||
if (!eligible) return null;
|
||||
if (!canRequest && !ownRows.length) return null;
|
||||
|
||||
return (
|
||||
<SectionCard>
|
||||
<Group justify="space-between" align="center" mb="sm">
|
||||
<CardTitle>Wagon Cancellation</CardTitle>
|
||||
{!openRow && !creditRow && (
|
||||
{/* {canRequest && !openRow && !creditRow && (
|
||||
<Button
|
||||
variant="default"
|
||||
radius="md"
|
||||
@@ -240,7 +244,7 @@ export function WagonCancellationCard({
|
||||
>
|
||||
Cancel wagons
|
||||
</Button>
|
||||
)}
|
||||
)} */}
|
||||
</Group>
|
||||
|
||||
{openRow ? (
|
||||
@@ -305,9 +309,9 @@ export function WagonCancellationCard({
|
||||
</Stack>
|
||||
) : (
|
||||
<Text fz={13} c="#475569">
|
||||
Need fewer wagons than you paid for? Cancel part of this booking for
|
||||
a per-wagon fee — the freed freight amount becomes a credit you can
|
||||
rebook onto another shipment day.
|
||||
Need fewer wagons than you paid for — or none? Cancel part or all of
|
||||
this booking for a per-wagon fee — the freed freight amount becomes a
|
||||
credit you can rebook onto another shipment day.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
@@ -401,16 +405,16 @@ export function WagonCancellationCard({
|
||||
{booking.reference}
|
||||
</Text>{" "}
|
||||
to cancel. A per-wagon fee applies; once it's paid the wagons
|
||||
are released and the freed amount becomes a rebooking credit. At
|
||||
least one wagon must remain — to cancel everything, cancel the
|
||||
whole booking instead.
|
||||
are released and the freed amount becomes a rebooking credit.
|
||||
Cancelling every wagon cancels the whole booking — the full freight
|
||||
amount becomes your credit.
|
||||
</Text>
|
||||
|
||||
{isBulk ? (
|
||||
<NumberInput
|
||||
label="Wagons to cancel"
|
||||
min={1}
|
||||
max={wagonsRequired > 1 ? wagonsRequired - 1 : undefined}
|
||||
max={wagonsRequired > 0 ? wagonsRequired : undefined}
|
||||
allowDecimal={false}
|
||||
value={wagons}
|
||||
onChange={(v) => {
|
||||
|
||||
@@ -462,10 +462,13 @@ function WagonCard({
|
||||
*/
|
||||
export function WagonsTab({
|
||||
bookingId,
|
||||
currency,
|
||||
cancellable,
|
||||
onCancellationRequested,
|
||||
}: {
|
||||
bookingId: string;
|
||||
/** Booking payment currency — labels the rebooking credit. */
|
||||
currency?: string;
|
||||
/** PAID contract booking — specific wagons may be selected for cancellation. */
|
||||
cancellable?: boolean;
|
||||
onCancellationRequested?: () => void;
|
||||
@@ -684,7 +687,7 @@ export function WagonsTab({
|
||||
</Box>
|
||||
<Button
|
||||
color="orange"
|
||||
disabled={selected.size === 0 || selected.size >= wagons.length}
|
||||
disabled={selected.size === 0}
|
||||
onClick={openConfirm}
|
||||
>
|
||||
Cancel selected ({selected.size})
|
||||
@@ -692,8 +695,8 @@ export function WagonsTab({
|
||||
</Group>
|
||||
{selected.size >= wagons.length && selected.size > 0 && (
|
||||
<Text fz={12} c="#B3362C" mt={6}>
|
||||
You cannot cancel every wagon here — to cancel the whole booking,
|
||||
use the booking cancellation instead.
|
||||
Every wagon is selected — this cancels the whole booking once the
|
||||
fee is paid; the full freight amount becomes your rebooking credit.
|
||||
</Text>
|
||||
)}
|
||||
</SectionCard>
|
||||
@@ -759,7 +762,7 @@ export function WagonsTab({
|
||||
Rebooking credit kept
|
||||
</Text>
|
||||
<Text fz={14} fw={800} c="#0A6F4D">
|
||||
{Number(preview.creditAmount).toLocaleString()}
|
||||
{Number(preview.creditAmount).toLocaleString()} {currency ?? ""}
|
||||
</Text>
|
||||
</Group>
|
||||
</Box>
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
LayoutList,
|
||||
MoreVertical,
|
||||
Package,
|
||||
Plus,
|
||||
|
||||
Search,
|
||||
Train,
|
||||
Wallet,
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
bookingIsSignable,
|
||||
} from "./contract/ContractSignButton";
|
||||
import { ApproveDeliveryButton } from "./delivery/ApproveDeliveryButton";
|
||||
import { RebookWagonsButton } from "./RebookWagonsButton";
|
||||
import {
|
||||
BookingStatusBadge as StatusBadge,
|
||||
BookingTypeBadge,
|
||||
@@ -52,7 +53,10 @@ import {
|
||||
} from "./booking-display";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import type { BookingListFilter } from "@/services/bookings.service";
|
||||
import type {
|
||||
BookingListFilter,
|
||||
WagonCancellation,
|
||||
} from "@/services/bookings.service";
|
||||
import type { Freight } from "@edr/types";
|
||||
import {
|
||||
DataTable,
|
||||
@@ -176,13 +180,25 @@ const STAT_CARDS: Array<{
|
||||
|
||||
function PrimaryAction({
|
||||
booking,
|
||||
credit,
|
||||
onNavigate,
|
||||
}: {
|
||||
booking: Freight.IBooking;
|
||||
/** CREDIT_AVAILABLE wagon cancellation opened by this booking, if any. */
|
||||
credit?: WagonCancellation;
|
||||
onNavigate: (path: string) => void;
|
||||
}) {
|
||||
const { status, id } = booking;
|
||||
const go = () => onNavigate(`/bookings/${id}`);
|
||||
// Cancelled wagons with a paid credit (partial or whole cancel) → rebook.
|
||||
if (credit) {
|
||||
return (
|
||||
<RebookWagonsButton
|
||||
cancellation={credit}
|
||||
currency={booking.paymentCurrency}
|
||||
/>
|
||||
);
|
||||
}
|
||||
// A general contract is payable as soon as it's FULLY_EXECUTED (signed); a
|
||||
// one-time booking only after it's SELECTED_FOR_BATCH.
|
||||
const isGeneralContract = booking.bookingType === "GENERAL_CONTRACT";
|
||||
@@ -423,6 +439,22 @@ export default function BookingsListPage() {
|
||||
api.bookings.list.queryOptions({ input: filter }),
|
||||
);
|
||||
|
||||
// Rebookable credits (fee-paid wagon cancellations) keyed by source booking,
|
||||
// so the row action can offer "Rebook wagons" in place of "View".
|
||||
// ponytail: one page of 100 newest rows; paginate if a customer ever holds more.
|
||||
const { data: myCancellations } = useQuery(
|
||||
api.bookings.listMyWagonCancellations.queryOptions({
|
||||
input: { pageSize: 100 },
|
||||
}),
|
||||
);
|
||||
const creditByBooking = useMemo(() => {
|
||||
const m = new Map<string, WagonCancellation>();
|
||||
for (const r of myCancellations?.items ?? []) {
|
||||
if (r.status === "CREDIT_AVAILABLE" && !m.has(r.bookingId)) m.set(r.bookingId, r);
|
||||
}
|
||||
return m;
|
||||
}, [myCancellations]);
|
||||
|
||||
// Per-card lifecycle counts (one cheap query each, total-only).
|
||||
const allCount = useStatusCount(undefined);
|
||||
const activeCount = useStatusCount(
|
||||
@@ -654,7 +686,11 @@ export default function BookingsListPage() {
|
||||
Track
|
||||
</Button>
|
||||
)}
|
||||
<PrimaryAction booking={booking} onNavigate={navigate} />
|
||||
<PrimaryAction
|
||||
booking={booking}
|
||||
credit={creditByBooking.get(booking.id)}
|
||||
onNavigate={navigate}
|
||||
/>
|
||||
<Menu position="bottom-end" withinPortal shadow="md" radius="md">
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
@@ -706,7 +742,7 @@ export default function BookingsListPage() {
|
||||
Track every cargo booking — from draft to delivery.
|
||||
</Text>
|
||||
</Box>
|
||||
<Button
|
||||
{/* <Button
|
||||
component={Link}
|
||||
to="/contracts"
|
||||
color="edr-green"
|
||||
@@ -714,7 +750,7 @@ export default function BookingsListPage() {
|
||||
leftSection={<Plus size={16} />}
|
||||
>
|
||||
New booking
|
||||
</Button>
|
||||
</Button> */}
|
||||
</Group>
|
||||
|
||||
{/* ── Summary stat cards ──────────────────────────────────────── */}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Alert, Button, Group, Modal, Stack, Text } from "@mantine/core";
|
||||
import { RotateCcw } from "lucide-react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import { bookingsService, type WagonCancellation } from "@/services/bookings.service";
|
||||
import { OperationDatePicker } from "./clearance";
|
||||
import { formatAmount } from "./BookingDetailPage/utils";
|
||||
|
||||
const apiErrorMessage = (error: unknown, fallback: string) => {
|
||||
const data = (error as { response?: { data?: { message?: string | string[] } } })
|
||||
?.response?.data;
|
||||
if (Array.isArray(data?.message)) return data.message.join(", ");
|
||||
if (data?.message) return data.message;
|
||||
return fallback;
|
||||
};
|
||||
|
||||
/**
|
||||
* List-row action for a CREDIT_AVAILABLE wagon cancellation: pick a shipment
|
||||
* day, rebook the credit as a new PAID booking, jump to it.
|
||||
*/
|
||||
export function RebookWagonsButton({
|
||||
cancellation,
|
||||
currency,
|
||||
size = "xs",
|
||||
}: {
|
||||
cancellation: WagonCancellation;
|
||||
currency?: string;
|
||||
size?: "xs" | "sm";
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [date, setDate] = useState("");
|
||||
|
||||
const rebook = useMutation({
|
||||
mutationFn: () =>
|
||||
bookingsService.rebookWagonCancellation(cancellation.id, { scheduledDate: date }),
|
||||
onSuccess: ({ bookingId }) => {
|
||||
qc.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
|
||||
qc.invalidateQueries({ queryKey: api.bookings.listMyWagonCancellations.queryKey() });
|
||||
toast.success("Wagons rebooked — taking you to the new booking.", { duration: 6000 });
|
||||
setOpen(false);
|
||||
navigate(`/bookings/${bookingId}`);
|
||||
},
|
||||
onError: (e) =>
|
||||
toast.error(apiErrorMessage(e, "Could not rebook the wagons. Please try again.")),
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
size={size}
|
||||
radius="md"
|
||||
color="edr-green"
|
||||
fw={700}
|
||||
fz={13}
|
||||
leftSection={<RotateCcw size={14} />}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
Rebook wagons
|
||||
</Button>
|
||||
<Modal
|
||||
opened={open}
|
||||
onClose={() => setOpen(false)}
|
||||
title={
|
||||
<Text fw={800} fz={18} c="#10202F">
|
||||
Rebook cancelled wagons
|
||||
</Text>
|
||||
}
|
||||
centered
|
||||
radius={16}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Alert color="teal" radius="md">
|
||||
{Number(cancellation.wagonsCancelled)} wagon(s) — credit of{" "}
|
||||
<Text span fw={700}>
|
||||
{formatAmount(cancellation.creditAmount)} {currency ?? ""}
|
||||
</Text>
|
||||
. Pick a shipment day; the new booking is created already paid.
|
||||
</Alert>
|
||||
<OperationDatePicker
|
||||
bookingId={cancellation.bookingId}
|
||||
value={date}
|
||||
onChange={setDate}
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" radius="md" onClick={() => setOpen(false)}>
|
||||
Close
|
||||
</Button>
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
disabled={!date}
|
||||
loading={rebook.isPending}
|
||||
onClick={() => rebook.mutate()}
|
||||
>
|
||||
Rebook wagons
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -114,6 +114,17 @@ export default function ShippingLineBookingsPage() {
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "scheduledDate",
|
||||
header: () => <ColHeader label="Shipment date" />,
|
||||
cell: ({ row }) => (
|
||||
<Text fz={13} c={row.original.scheduledDate ? undefined : "edr-muted"}>
|
||||
{row.original.scheduledDate
|
||||
? new Date(row.original.scheduledDate).toLocaleDateString()
|
||||
: "—"}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: () => <ColHeader label="Status" />,
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
Box,
|
||||
Button,
|
||||
Center,
|
||||
Divider,
|
||||
FileButton,
|
||||
Group,
|
||||
List,
|
||||
@@ -26,14 +27,15 @@ import {
|
||||
Select,
|
||||
Stack,
|
||||
Switch,
|
||||
Table,
|
||||
Text,
|
||||
TextInput,
|
||||
Textarea,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
CalendarDays,
|
||||
CheckCircle2,
|
||||
ChevronLeft,
|
||||
@@ -43,6 +45,7 @@ import {
|
||||
MapPin,
|
||||
Package,
|
||||
PackageCheck,
|
||||
Receipt,
|
||||
Snowflake,
|
||||
Train,
|
||||
X,
|
||||
@@ -55,6 +58,7 @@ import {
|
||||
StepLabel,
|
||||
fieldStyles,
|
||||
} from "../contracts/new-contract-form/shared";
|
||||
import { formatRateUnit } from "../contracts/new-contract-form/unit-rates";
|
||||
import {
|
||||
ShipmentFormInputValues,
|
||||
ShipmentFormValues,
|
||||
@@ -65,6 +69,7 @@ import {
|
||||
downloadContainerImportTemplate,
|
||||
parseContainerExcel,
|
||||
} from "../contracts/new-shipment-form/container-excel";
|
||||
import { formatAmount } from "../contracts/new-shipment-form/total";
|
||||
import {
|
||||
shippingLineBookingsService,
|
||||
type CompleteBookingContainerLine,
|
||||
@@ -213,21 +218,21 @@ function CompleteBookingForm({ booking }: { booking: ShippingLineBooking }) {
|
||||
: 0;
|
||||
const hasOdd20ft = ft20Total % 2 === 1;
|
||||
|
||||
// Two-step submit: the payload is priced first (authoritative quote, saved
|
||||
// server-side with fresh rate snapshots on every preview), the shipping line
|
||||
// confirms the figure, and only then does the booking submit.
|
||||
// Two-step submit, same shape as the customer shipment form: the confirm
|
||||
// modal opens at once with the payload pending, the server prices it (and
|
||||
// runs the 20ft pairing check) while the modal shows a loader, the shipping
|
||||
// line confirms the figure, and only then does the booking submit.
|
||||
const [pendingPayload, setPendingPayload] =
|
||||
useState<CompleteShippingLineBookingPayload | null>(null);
|
||||
const [quote, setQuote] = useState<ShippingLinePriceQuote | null>(null);
|
||||
|
||||
const previewMutation = useMutation({
|
||||
mutationFn: (payload: CompleteShippingLineBookingPayload) =>
|
||||
shippingLineBookingsService.pricePreview(booking.id, payload),
|
||||
onSuccess: (result, payload) => {
|
||||
setPendingPayload(payload);
|
||||
setQuote(result);
|
||||
},
|
||||
// A pricing failure (no rate configured) closes the confirm dialog — the
|
||||
// error modal takes over with the server's message.
|
||||
onError: () => setPendingPayload(null),
|
||||
});
|
||||
const quote = previewMutation.data ?? null;
|
||||
|
||||
const submitMutation = useMutation({
|
||||
mutationFn: (payload: CompleteShippingLineBookingPayload) =>
|
||||
@@ -241,11 +246,17 @@ function CompleteBookingForm({ booking }: { booking: ShippingLineBooking }) {
|
||||
// A submit failure (day filled up, window closed meanwhile) must not leave
|
||||
// a stale confirm dialog on screen — the error modal takes over.
|
||||
onError: () => {
|
||||
setQuote(null);
|
||||
previewMutation.reset();
|
||||
setPendingPayload(null);
|
||||
},
|
||||
});
|
||||
|
||||
const closeConfirm = () => {
|
||||
if (submitMutation.isPending) return;
|
||||
previewMutation.reset();
|
||||
setPendingPayload(null);
|
||||
};
|
||||
|
||||
/**
|
||||
* Map a container size to the configured container type: reefer type when
|
||||
* any container on the line is refrigerated, standard type otherwise —
|
||||
@@ -313,11 +324,21 @@ function CompleteBookingForm({ booking }: { booking: ShippingLineBooking }) {
|
||||
return;
|
||||
}
|
||||
setPayloadError(null);
|
||||
// Price first — the confirm dialog opens when the quote arrives; the
|
||||
// booking submits only after the shipping line confirms the figure.
|
||||
// Open the confirm dialog now and price into it; the booking submits only
|
||||
// after the shipping line confirms the figure.
|
||||
setPendingPayload(payload);
|
||||
previewMutation.reset();
|
||||
previewMutation.mutate(payload);
|
||||
});
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (!pendingPayload || !quote) return;
|
||||
// Guard: never let unresolved 20ft pairing errors submit — the server
|
||||
// rejects them anyway; the disabled button just says so first.
|
||||
if (quote.pairingErrors.length > 0) return;
|
||||
submitMutation.mutate(pendingPayload);
|
||||
};
|
||||
|
||||
const showValidationSummary =
|
||||
form.formState.isSubmitted && !form.formState.isValid;
|
||||
|
||||
@@ -446,112 +467,14 @@ function CompleteBookingForm({ booking }: { booking: ShippingLineBooking }) {
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
{/* Price confirmation: the quote just computed (and snapshotted)
|
||||
server-side. Nothing submits until the figure is confirmed. */}
|
||||
<Modal
|
||||
opened={Boolean(quote)}
|
||||
onClose={() => {
|
||||
setQuote(null);
|
||||
setPendingPayload(null);
|
||||
}}
|
||||
centered
|
||||
radius="md"
|
||||
size="lg"
|
||||
title={
|
||||
<Group gap={8}>
|
||||
<PackageCheck size={18} color="var(--mantine-color-teal-6)" />
|
||||
<Text fw={700} fz={16}>
|
||||
Confirm your booking price
|
||||
</Text>
|
||||
</Group>
|
||||
}
|
||||
overlayProps={{ blur: 2, backgroundOpacity: 0.55 }}
|
||||
>
|
||||
{quote && (
|
||||
<Stack gap="md">
|
||||
<Box style={{ overflowX: "auto" }}>
|
||||
<Table verticalSpacing="xs" horizontalSpacing="md">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Charge</Table.Th>
|
||||
<Table.Th ta="right">Qty</Table.Th>
|
||||
<Table.Th ta="right">Amount</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{quote.lineItems.map((item, i) => (
|
||||
<Table.Tr key={i}>
|
||||
<Table.Td>
|
||||
<Text size="sm">{item.description}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
<Text size="sm" c="dimmed">
|
||||
{item.quantity ?? 1}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
<Text size="sm" fw={600}>
|
||||
{Number(item.amount).toLocaleString()}{" "}
|
||||
{item.currency}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
<Group
|
||||
justify="space-between"
|
||||
p="sm"
|
||||
style={{
|
||||
borderRadius: 10,
|
||||
background: "var(--mantine-color-teal-0)",
|
||||
}}
|
||||
>
|
||||
<Text fw={700}>Total</Text>
|
||||
<Text fw={800} fz={18}>
|
||||
{Number(quote.totalAmount).toLocaleString()} {quote.currency}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
The amount is charged to your credit account — no payment is
|
||||
due now. EDR bills your accumulated charges periodically.
|
||||
</Text>
|
||||
{quote.warnings.length > 0 && (
|
||||
<Alert
|
||||
color="yellow"
|
||||
radius="md"
|
||||
icon={<AlertCircle size={16} />}
|
||||
>
|
||||
{quote.warnings.join(" ")}
|
||||
</Alert>
|
||||
)}
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button
|
||||
variant="default"
|
||||
radius="md"
|
||||
onClick={() => {
|
||||
setQuote(null);
|
||||
setPendingPayload(null);
|
||||
}}
|
||||
>
|
||||
Go back
|
||||
</Button>
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<PackageCheck size={16} />}
|
||||
loading={submitMutation.isPending}
|
||||
onClick={() =>
|
||||
pendingPayload && submitMutation.mutate(pendingPayload)
|
||||
}
|
||||
>
|
||||
Confirm & book
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
<PriceConfirmModal
|
||||
opened={Boolean(pendingPayload)}
|
||||
quote={quote}
|
||||
quoteLoading={previewMutation.isPending}
|
||||
loading={submitMutation.isPending}
|
||||
onConfirm={handleConfirm}
|
||||
onReject={closeConfirm}
|
||||
/>
|
||||
|
||||
<Box flex={1} p="24px">
|
||||
<Stack gap="lg" className="mx-auto max-w-4xl">
|
||||
@@ -618,6 +541,225 @@ function CompleteBookingForm({ booking }: { booking: ShippingLineBooking }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Price confirmation — the customer shipment form's modal, one-to-one: opens
|
||||
* the moment the form submits, shows a loader while the server prices and
|
||||
* checks 20ft pairing, then the authoritative breakdown. Pairing violations
|
||||
* hard-block confirm (the server rejects them on /complete too); overweight
|
||||
* containers only warn — the surcharge is already inside the total.
|
||||
*/
|
||||
function PriceConfirmModal({
|
||||
opened,
|
||||
quote,
|
||||
quoteLoading,
|
||||
loading,
|
||||
onConfirm,
|
||||
onReject,
|
||||
}: {
|
||||
opened: boolean;
|
||||
quote: ShippingLinePriceQuote | null;
|
||||
quoteLoading: boolean;
|
||||
loading: boolean;
|
||||
onConfirm: () => void;
|
||||
onReject: () => void;
|
||||
}) {
|
||||
const pairingErrors = quote?.pairingErrors ?? [];
|
||||
const hasPairingBlock = pairingErrors.length > 0;
|
||||
const overweightLines = quote?.overweightLines ?? [];
|
||||
const overweightSurchargeAmount =
|
||||
quote?.lineItems.find((li) => li.code === "OVERWEIGHT_PER_TON")?.amount ??
|
||||
0;
|
||||
|
||||
// Confirm waits for the authoritative price and a clean pairing check.
|
||||
const confirmDisabled =
|
||||
loading || quoteLoading || hasPairingBlock || !quote;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onReject}
|
||||
closeOnClickOutside={!loading}
|
||||
closeOnEscape={!loading}
|
||||
withCloseButton={!loading}
|
||||
centered
|
||||
radius="lg"
|
||||
size="lg"
|
||||
title={
|
||||
<Group gap={10}>
|
||||
<ThemeIcon variant="light" color="edr-green" radius="md" size={34}>
|
||||
<Receipt size={18} />
|
||||
</ThemeIcon>
|
||||
<Box>
|
||||
<Text fw={800} fz={16} c="#10202F">
|
||||
Confirm shipment price
|
||||
</Text>
|
||||
<Text fz="xs" c="dimmed">
|
||||
Review the total before booking this shipment.
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
}
|
||||
>
|
||||
<Stack gap="md">
|
||||
{quoteLoading && (
|
||||
<Group gap={8} c="dimmed">
|
||||
<Loader size="xs" color="edr-green" />
|
||||
<Text fz="sm" c="dimmed">
|
||||
Computing the final price breakdown and checking container
|
||||
weights…
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{hasPairingBlock && (
|
||||
<Alert
|
||||
color="red"
|
||||
variant="light"
|
||||
radius="md"
|
||||
icon={<AlertCircle size={16} />}
|
||||
title="Cannot complete booking — 20ft wagon pairing"
|
||||
>
|
||||
<Stack gap={6}>
|
||||
{pairingErrors.map((msg, i) => (
|
||||
<Text key={i} fz="sm" c="red.8">
|
||||
{msg}
|
||||
</Text>
|
||||
))}
|
||||
<Text fz="xs" c="red.7" mt={2}>
|
||||
Adjust the 20ft container weights or quantities so pairs differ
|
||||
by no more than 10 tons.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{overweightLines.length > 0 && (
|
||||
<Alert
|
||||
color="yellow"
|
||||
variant="light"
|
||||
radius="md"
|
||||
icon={<AlertTriangle size={16} />}
|
||||
title="Overweight containers"
|
||||
>
|
||||
<Stack gap={6}>
|
||||
{overweightLines.map((line, i) => (
|
||||
<Text key={i} fz="sm" c="#9A5B00">
|
||||
{line.containerTypeCode}: {line.totalVgmTons}t exceeds limit{" "}
|
||||
{line.maxAllowedTons}t (+{line.excessTons}t overweight)
|
||||
</Text>
|
||||
))}
|
||||
<Text fz="xs" c="#9A5B00" mt={2}>
|
||||
{overweightSurchargeAmount > 0
|
||||
? `An overweight surcharge of ${formatAmount(overweightSurchargeAmount)} ${
|
||||
quote?.currency ?? ""
|
||||
} applies (included in the total below). You can still submit, or go back and adjust weights.`
|
||||
: "An overweight surcharge applies. You can still submit, or go back and adjust weights."}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{quote && quote.warnings.length > 0 && (
|
||||
<Alert
|
||||
color="yellow"
|
||||
variant="light"
|
||||
radius="md"
|
||||
icon={<AlertTriangle size={16} />}
|
||||
>
|
||||
{quote.warnings.join(" ")}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{quote && (
|
||||
<Paper
|
||||
withBorder
|
||||
radius={16}
|
||||
p="lg"
|
||||
style={{ borderColor: "#E6ECF2" }}
|
||||
>
|
||||
<Stack gap={10}>
|
||||
{quote.lineItems.map((line, i) => (
|
||||
<Group key={i} justify="space-between" wrap="nowrap" gap="sm">
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text fz="sm" c="#10202F" fw={500}>
|
||||
{line.description}
|
||||
</Text>
|
||||
<Text fz="xs" c="dimmed">
|
||||
{(line.quantity ?? 1).toLocaleString()} ×{" "}
|
||||
{formatAmount(line.unitAmount ?? line.amount)}{" "}
|
||||
{quote.currency}
|
||||
{line.unit
|
||||
? ` · ${formatRateUnit(line.unit.toLowerCase())}`
|
||||
: ""}
|
||||
</Text>
|
||||
</Box>
|
||||
<Text
|
||||
fz="sm"
|
||||
fw={600}
|
||||
c="#10202F"
|
||||
style={{ whiteSpace: "nowrap" }}
|
||||
>
|
||||
{formatAmount(line.amount)} {quote.currency}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
{quote.lineItems.length === 0 && (
|
||||
<Text fz="sm" c="dimmed">
|
||||
No priced lines — check the cargo details.
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
<Divider my="md" />
|
||||
<Group justify="space-between" align="flex-end">
|
||||
<Text
|
||||
fz="xs"
|
||||
fw={700}
|
||||
tt="uppercase"
|
||||
c="edr-green"
|
||||
style={{ letterSpacing: "0.06em" }}
|
||||
>
|
||||
Total
|
||||
</Text>
|
||||
<Text fw={800} fz={28} c="#10202F">
|
||||
{formatAmount(quote.totalAmount)}{" "}
|
||||
<Text span fz={16} fw={700} c="edr-muted">
|
||||
{quote.currency}
|
||||
</Text>
|
||||
</Text>
|
||||
</Group>
|
||||
<Text fz="xs" c="dimmed" mt="sm">
|
||||
The amount is charged to your credit account — no payment is due
|
||||
now. EDR bills your accumulated charges periodically.
|
||||
</Text>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
<Group justify="space-between" mt="xs">
|
||||
<Button
|
||||
variant="default"
|
||||
radius="md"
|
||||
leftSection={<X size={16} />}
|
||||
onClick={onReject}
|
||||
disabled={loading}
|
||||
>
|
||||
Reject & edit
|
||||
</Button>
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<CheckCircle2 size={16} />}
|
||||
onClick={onConfirm}
|
||||
loading={loading}
|
||||
disabled={confirmDisabled}
|
||||
>
|
||||
Confirm & book
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/** The booking's lane — fixed at initiate time from the chosen route. */
|
||||
function RouteCard({ booking }: { booking: ShippingLineBooking }) {
|
||||
return (
|
||||
|
||||
@@ -67,6 +67,15 @@ export interface ShippingLinePriceQuote {
|
||||
currency: string;
|
||||
lineItems: Freight.PricingBreakdownLineItem[];
|
||||
warnings: string[];
|
||||
/** Containers over their type's weight limit — a surcharge, not a block. */
|
||||
overweightLines: {
|
||||
containerTypeCode: string;
|
||||
totalVgmTons: number;
|
||||
maxAllowedTons: number;
|
||||
excessTons: number;
|
||||
}[];
|
||||
/** 20ft wagon-pairing violations (pair weight diff over the cap) — hard block. */
|
||||
pairingErrors: string[];
|
||||
}
|
||||
|
||||
/** One wagon the batch engine allocated to the booking. */
|
||||
|
||||
Reference in New Issue
Block a user