mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
feat(clearance): preview charge documents before and after upload
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Alert,
|
||||
@@ -59,11 +59,65 @@ const isLocked = (s: Freight.ClearanceChargeStatus) =>
|
||||
|
||||
type BillInput = { amount: number; currency: string; description: string };
|
||||
|
||||
/**
|
||||
* A blob URL for a not-yet-uploaded File, so the staff member can open it in
|
||||
* the shared viewer before committing the upload. Revoked whenever the pick
|
||||
* changes or the form unmounts — a leaked object URL pins the whole file in
|
||||
* memory for the life of the tab.
|
||||
*/
|
||||
function useLocalPreview(file: File | null) {
|
||||
const [url, setUrl] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (!file) {
|
||||
setUrl(null);
|
||||
return;
|
||||
}
|
||||
const next = URL.createObjectURL(file);
|
||||
setUrl(next);
|
||||
return () => URL.revokeObjectURL(next);
|
||||
}, [file]);
|
||||
return file && url ? { name: file.name, url, mimeType: file.type } : null;
|
||||
}
|
||||
|
||||
/** "Preview" for a staged file — same viewer the uploaded documents open in. */
|
||||
function StagedFilePreview({
|
||||
file,
|
||||
onViewFile,
|
||||
}: {
|
||||
file: File | null;
|
||||
onViewFile: (file: { name: string; url: string; mimeType?: string | null }) => void;
|
||||
}) {
|
||||
const preview = useLocalPreview(file);
|
||||
if (!file) return null;
|
||||
return (
|
||||
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<FileText size={15} color="var(--mantine-color-edr-green-6)" />
|
||||
<Text fz="12.5px" c="edr-text" truncate style={{ minWidth: 0, maxWidth: 220 }}>
|
||||
{file.name}
|
||||
</Text>
|
||||
{preview && isViewable({ name: file.name, url: "" }) ? (
|
||||
<Tooltip label="Preview before uploading">
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<Eye size={13} />}
|
||||
onClick={() => onViewFile(preview)}
|
||||
>
|
||||
Preview
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
export interface ClearanceChargesTabProps {
|
||||
bookingId: string;
|
||||
/** DJ uploads the port document; ET bills, sends and creates miscellaneous. */
|
||||
roleMode: "ET" | "DJ";
|
||||
onViewFile: (file: { name: string; url: string }) => void;
|
||||
onViewFile: (file: { name: string; url: string; mimeType?: string | null }) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -171,24 +225,13 @@ export function ClearanceChargesTab({
|
||||
onSend={() => port && send.mutate(port.id)}
|
||||
djUpload={
|
||||
roleMode === "DJ" && (!port || port.status === "DOC_UPLOADED") ? (
|
||||
<FileButton
|
||||
onChange={(f) => f && uploadPort.mutate(f)}
|
||||
accept="application/pdf,image/*"
|
||||
disabled={busy}
|
||||
>
|
||||
{(props) => (
|
||||
<Button
|
||||
{...props}
|
||||
size="compact-sm"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<Upload size={14} />}
|
||||
loading={uploadPort.isPending}
|
||||
>
|
||||
{port ? "Replace document" : "Upload document"}
|
||||
</Button>
|
||||
)}
|
||||
</FileButton>
|
||||
<PortDocumentUpload
|
||||
replacing={Boolean(port)}
|
||||
busy={busy}
|
||||
uploading={uploadPort.isPending}
|
||||
onViewFile={onViewFile}
|
||||
onUpload={(f) => uploadPort.mutate(f)}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
@@ -227,6 +270,7 @@ export function ClearanceChargesTab({
|
||||
<MiscCreateForm
|
||||
key={miscCreated}
|
||||
busy={createMisc.isPending}
|
||||
onViewFile={onViewFile}
|
||||
onCreate={(file, input) => createMisc.mutate({ file, ...input })}
|
||||
/>
|
||||
</Paper>
|
||||
@@ -255,6 +299,66 @@ export function ClearanceChargesTab({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Port-charges document: pick, preview, then upload. The pick is staged rather
|
||||
* than sent straight away so the wrong scan can be caught before it lands on
|
||||
* the customer's charge.
|
||||
*/
|
||||
function PortDocumentUpload({
|
||||
replacing,
|
||||
busy,
|
||||
uploading,
|
||||
onViewFile,
|
||||
onUpload,
|
||||
}: {
|
||||
replacing: boolean;
|
||||
busy: boolean;
|
||||
uploading: boolean;
|
||||
onViewFile: (file: { name: string; url: string; mimeType?: string | null }) => void;
|
||||
onUpload: (file: File) => void;
|
||||
}) {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
|
||||
return (
|
||||
<Group gap={8} align="center" wrap="wrap">
|
||||
<FileButton
|
||||
onChange={setFile}
|
||||
accept="application/pdf,image/*"
|
||||
disabled={busy}
|
||||
>
|
||||
{(props) => (
|
||||
<Button
|
||||
{...props}
|
||||
size="compact-sm"
|
||||
variant={file ? "light" : "filled"}
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<Upload size={14} />}
|
||||
>
|
||||
{file ? "Choose another" : replacing ? "Replace document" : "Choose document"}
|
||||
</Button>
|
||||
)}
|
||||
</FileButton>
|
||||
<StagedFilePreview file={file} onViewFile={onViewFile} />
|
||||
{file ? (
|
||||
<Button
|
||||
size="compact-sm"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
loading={uploading}
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
onUpload(file);
|
||||
setFile(null);
|
||||
}}
|
||||
>
|
||||
{replacing ? "Upload replacement" : "Upload document"}
|
||||
</Button>
|
||||
) : null}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
function ChargeCard({
|
||||
title,
|
||||
charge,
|
||||
@@ -272,7 +376,7 @@ function ChargeCard({
|
||||
roleMode: "ET" | "DJ";
|
||||
busy: boolean;
|
||||
emptyHint: string;
|
||||
onViewFile: (file: { name: string; url: string }) => void;
|
||||
onViewFile: (file: { name: string; url: string; mimeType?: string | null }) => void;
|
||||
onBill: (input: BillInput) => void;
|
||||
onSend: () => void;
|
||||
djUpload?: React.ReactNode;
|
||||
@@ -554,9 +658,11 @@ function ChargeCard({
|
||||
function MiscCreateForm({
|
||||
busy,
|
||||
onCreate,
|
||||
onViewFile,
|
||||
}: {
|
||||
busy: boolean;
|
||||
onCreate: (file: File, input: BillInput) => void;
|
||||
onViewFile: (file: { name: string; url: string; mimeType?: string | null }) => void;
|
||||
}) {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [amount, setAmount] = useState<number | string>("");
|
||||
@@ -585,10 +691,11 @@ function MiscCreateForm({
|
||||
radius="md"
|
||||
leftSection={<Upload size={14} />}
|
||||
>
|
||||
{file ? file.name : "Choose document"}
|
||||
{file ? "Choose another" : "Choose document"}
|
||||
</Button>
|
||||
)}
|
||||
</FileButton>
|
||||
<StagedFilePreview file={file} onViewFile={onViewFile} />
|
||||
<NumberInput
|
||||
label="Amount"
|
||||
size="xs"
|
||||
|
||||
@@ -29,7 +29,7 @@ export interface ClearanceOpsTabsProps {
|
||||
*/
|
||||
exchangeEntityId?: string;
|
||||
tradeDirection?: string;
|
||||
onViewFile?: (file: { name: string; url: string }) => void;
|
||||
onViewFile?: (file: { name: string; url: string; mimeType?: string | null }) => void;
|
||||
onDownloadFile?: (file: { id: string; name: string }) => void;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
FileInput,
|
||||
Group,
|
||||
Modal,
|
||||
NumberInput,
|
||||
Paper,
|
||||
Select,
|
||||
Stack,
|
||||
Stepper,
|
||||
Text,
|
||||
Textarea,
|
||||
} from "@mantine/core";
|
||||
import { DateInput } from "@mantine/dates";
|
||||
import {
|
||||
DateInput,
|
||||
} from "@mantine/dates";
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
@@ -30,9 +31,12 @@ import {
|
||||
import type { Freight } from "@edr/types";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||
import { PhasedFileDropzone } from "@/components/contracts/PhasedFileDropzone";
|
||||
import { TransitAssigneePanel } from "@/components/contracts/TransitAssigneePanel";
|
||||
import {
|
||||
SectionCard,
|
||||
} from "@/components/bookings/detail/SectionCard";
|
||||
import {
|
||||
TransitAssigneePanel,
|
||||
} from "@/components/contracts/TransitAssigneePanel";
|
||||
import {
|
||||
TransitPermitMultiUpload,
|
||||
type TransitPermitUploadedRow,
|
||||
@@ -51,8 +55,12 @@ import {
|
||||
type ClearanceViewLike,
|
||||
type MilestoneRow,
|
||||
} from "@/components/contracts/PhasedClearanceActionPanel";
|
||||
import { contractsService } from "@/services/contracts.service";
|
||||
import { bookingsService } from "@/services/bookings.service";
|
||||
import {
|
||||
contractsService,
|
||||
} from "@/services/contracts.service";
|
||||
import {
|
||||
bookingsService,
|
||||
} from "@/services/bookings.service";
|
||||
|
||||
/** Today as `yyyy-MM-dd` in the browser's local zone (a DateInput `minDate`). */
|
||||
function todayISODate(): string {
|
||||
@@ -66,8 +74,11 @@ function todayISODate(): string {
|
||||
* customer docs → transit assignee (DJ names officer) → declaration (ET,
|
||||
* releases the export) → RO (DJ, auto-releases) → create booking (ET)
|
||||
* → payment + wagons → transport document / T1 (ET) → train to Djibouti
|
||||
* → accept T1 (DJ, one button after arrival) → gate pass (DJ)
|
||||
* → final invoice (DJ) + customer slip + GL confirm.
|
||||
* → accept T1 (DJ, one button after arrival) → gate pass (DJ) → offload.
|
||||
*
|
||||
* The post-offload GL Djibouti final invoice was removed from this flow: it
|
||||
* never gated anything downstream, so the export now completes at the offload.
|
||||
* Its API endpoints and any already-issued invoices are untouched.
|
||||
*/
|
||||
export function computeExportActiveStep(
|
||||
clearance: ClearanceViewLike,
|
||||
@@ -93,11 +104,10 @@ export function computeExportActiveStep(
|
||||
if (!clearance.train?.arrivedAt) return 7;
|
||||
if (!clearance.t1Closed) return 8;
|
||||
if (!clearance.gatepassGranted) return 9;
|
||||
// Step 10 is the read-only Offload step. It never gates the flow: the final
|
||||
// invoice may be raised on a secured gate pass alone, so parking the stepper
|
||||
// there would hide the invoice actions whenever operations lag on the offload.
|
||||
if (clearance.finalInvoice?.status !== "PAID") return 11;
|
||||
return 12;
|
||||
// Step 10 is the read-only Offload step, recorded by operations. It never
|
||||
// gated the flow and nothing follows it, so the stepper completes here rather
|
||||
// than waiting on an offload stamp this desk does not control.
|
||||
return 10;
|
||||
}
|
||||
|
||||
export function exportTransitFilesFromWorkflow(
|
||||
@@ -491,27 +501,6 @@ export function ExportClearanceStepper({
|
||||
<OffloadStep clearance={clearance} />
|
||||
</Stepper.Step>
|
||||
|
||||
<Stepper.Step
|
||||
label="Final invoice & payment"
|
||||
description="GL Djibouti invoices after offload; customer pays"
|
||||
icon={
|
||||
clearance.finalInvoice?.status === "PAID" ? (
|
||||
<CheckCircle2 size={14} />
|
||||
) : (
|
||||
<Receipt size={14} />
|
||||
)
|
||||
}
|
||||
>
|
||||
<FinalInvoiceStep
|
||||
bookingId={actionBookingId}
|
||||
clearance={clearance}
|
||||
canDjAct={showDj && canDj}
|
||||
canConfirm={(showDj && canDj) || (showEt && canEt)}
|
||||
onChanged={onChanged}
|
||||
onViewFile={onViewFile}
|
||||
onDownloadFile={onDownloadFile}
|
||||
/>
|
||||
</Stepper.Step>
|
||||
</Stepper>
|
||||
</Paper>
|
||||
</Stack>
|
||||
@@ -688,230 +677,6 @@ function AcceptT1Step({
|
||||
);
|
||||
}
|
||||
|
||||
function FinalInvoiceStep({
|
||||
bookingId,
|
||||
clearance,
|
||||
canDjAct,
|
||||
canConfirm,
|
||||
onChanged,
|
||||
onViewFile,
|
||||
onDownloadFile,
|
||||
}: {
|
||||
bookingId: string | null;
|
||||
clearance: ClearanceViewLike;
|
||||
canDjAct: boolean;
|
||||
canConfirm: boolean;
|
||||
onChanged?: () => void;
|
||||
onViewFile?: (file: { name: string; url: string }) => void;
|
||||
onDownloadFile?: (file: { id: string; name: string }) => void;
|
||||
}) {
|
||||
const [opened, setOpened] = useState(false);
|
||||
const [amount, setAmount] = useState<number | string>("");
|
||||
const [currency, setCurrency] = useState("ETB");
|
||||
const [description, setDescription] = useState("");
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
|
||||
const invoice = clearance.finalInvoice ?? null;
|
||||
const paid = invoice?.status === "PAID";
|
||||
// Raised as a draft — the customer approves it before paying.
|
||||
const approved = Boolean(invoice?.approvedAt);
|
||||
|
||||
// Export: OFFLOADED is a DJ doc milestone that may never be recorded, so the
|
||||
// secured gate pass is enough to open invoicing. Sending an invoice is optional.
|
||||
if (!clearance.offloaded && !clearance.gatepassGranted && !invoice) {
|
||||
return (
|
||||
<StepStatus
|
||||
done={false}
|
||||
pendingLabel="Waiting for cargo offload (handled in operations)."
|
||||
doneLabel=""
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
{invoice ? (
|
||||
<Paper withBorder radius="md" p="sm">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<div>
|
||||
<Text fw={700} size="sm">
|
||||
{invoice.invoiceNumber}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{invoice.totalAmount.toLocaleString()} {invoice.currency}
|
||||
{invoice.description ? ` — ${invoice.description}` : ""}
|
||||
</Text>
|
||||
</div>
|
||||
<Badge color={paid ? "edr-green" : "yellow"} variant="light">
|
||||
{approved ? invoice.status : "AWAITING CUSTOMER APPROVAL"}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Paper>
|
||||
) : null}
|
||||
|
||||
{invoice?.invoiceFile ? (
|
||||
<PhasedUploadedFileRow
|
||||
label="Final Invoice"
|
||||
file={invoice.invoiceFile}
|
||||
onView={onViewFile}
|
||||
onDownload={onDownloadFile}
|
||||
compact
|
||||
/>
|
||||
) : null}
|
||||
{invoice?.slipFile ? (
|
||||
<PhasedUploadedFileRow
|
||||
label="Customer payment slip"
|
||||
file={invoice.slipFile}
|
||||
onView={onViewFile}
|
||||
onDownload={onDownloadFile}
|
||||
compact
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{paid ? (
|
||||
<StepStatus
|
||||
done
|
||||
pendingLabel=""
|
||||
doneLabel={`Payment confirmed${
|
||||
invoice?.confirmedAt ? ` · ${new Date(invoice.confirmedAt).toLocaleString()}` : ""
|
||||
}`}
|
||||
/>
|
||||
) : invoice ? (
|
||||
<>
|
||||
<StepStatus
|
||||
done={false}
|
||||
pendingLabel={
|
||||
!approved
|
||||
? "Waiting for the customer to review and approve the invoice."
|
||||
: invoice.slipFile
|
||||
? "Payment slip attached — confirm to settle the invoice."
|
||||
: "Waiting for the customer to pay and attach the payment slip."
|
||||
}
|
||||
doneLabel=""
|
||||
/>
|
||||
{canConfirm && bookingId && invoice.slipFile ? (
|
||||
<Button
|
||||
color="edr-green"
|
||||
loading={confirming}
|
||||
leftSection={<CheckCircle2 size={16} />}
|
||||
onClick={async () => {
|
||||
setConfirming(true);
|
||||
try {
|
||||
await contractsService.confirmFinalInvoicePaid(bookingId);
|
||||
toast.success("Payment confirmed — invoice settled");
|
||||
onChanged?.();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed");
|
||||
} finally {
|
||||
setConfirming(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Confirm payment received
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
) : canDjAct && bookingId ? (
|
||||
<>
|
||||
<Text size="sm" c="dimmed">
|
||||
Send the final invoice to the customer if post-arrival charges apply (optional).
|
||||
The customer approves it before paying.
|
||||
</Text>
|
||||
<Button
|
||||
color="edr-green"
|
||||
leftSection={<Receipt size={16} />}
|
||||
onClick={() => setOpened(true)}
|
||||
>
|
||||
Send invoice
|
||||
</Button>
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={() => setOpened(false)}
|
||||
title={<Text fw={700}>Send final invoice</Text>}
|
||||
radius="md"
|
||||
size="md"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Group grow align="flex-start">
|
||||
<NumberInput
|
||||
label="Amount"
|
||||
placeholder="0.00"
|
||||
value={amount}
|
||||
onChange={setAmount}
|
||||
min={0}
|
||||
thousandSeparator=","
|
||||
required
|
||||
/>
|
||||
<Select
|
||||
label="Currency"
|
||||
data={["ETB", "USD"]}
|
||||
value={currency}
|
||||
onChange={(v) => setCurrency(v ?? "ETB")}
|
||||
/>
|
||||
</Group>
|
||||
<Textarea
|
||||
label="Description"
|
||||
placeholder="What the invoice bills for"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.currentTarget.value)}
|
||||
minRows={2}
|
||||
/>
|
||||
<PhasedFileDropzone
|
||||
label="Invoice document"
|
||||
description="Any file type."
|
||||
accept="*/*"
|
||||
value={file}
|
||||
onChange={setFile}
|
||||
onPreview={onViewFile}
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={() => setOpened(false)} disabled={sending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="edr-green"
|
||||
loading={sending}
|
||||
disabled={amount === "" || Number(amount) <= 0 || !file}
|
||||
leftSection={<Upload size={16} />}
|
||||
onClick={async () => {
|
||||
if (!file) return;
|
||||
setSending(true);
|
||||
try {
|
||||
await contractsService.sendFinalInvoice(bookingId, {
|
||||
amount: Number(amount),
|
||||
currency,
|
||||
description: description.trim() || undefined,
|
||||
file,
|
||||
});
|
||||
toast.success("Final invoice sent to the customer");
|
||||
setOpened(false);
|
||||
onChanged?.();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed");
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Send invoice
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</>
|
||||
) : (
|
||||
<StepStatus
|
||||
done={false}
|
||||
pendingLabel="Waiting for GL Djibouti to send the final invoice."
|
||||
doneLabel=""
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReleaseOrderActions({
|
||||
entityId,
|
||||
isBooking,
|
||||
|
||||
@@ -6,10 +6,32 @@ import {
|
||||
type DraggableStateSnapshot,
|
||||
type DropResult,
|
||||
} from "@hello-pangea/dnd";
|
||||
import { ActionIcon, Badge, Box, Group, Menu, Stack, Text, Tooltip } from "@mantine/core";
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
Group,
|
||||
Menu,
|
||||
Paper,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { GripVertical, MapPin, Trash2, Wrench } from "lucide-react";
|
||||
import { memo, useCallback, useMemo, type ReactNode } from "react";
|
||||
import { GripVertical, MapPin, Search, Trash2, Wrench, X } from "lucide-react";
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
@@ -41,25 +63,86 @@ function ConsistWagonList({
|
||||
onRemove,
|
||||
onMaintenance,
|
||||
onChangeYard,
|
||||
onChangeYardBulk,
|
||||
busy = false,
|
||||
}: ConsistWagonListProps) {
|
||||
const onDragEnd = useCallback((result: DropResult) => {
|
||||
if (!result.destination) return;
|
||||
const from = result.source.index;
|
||||
const to = result.destination.index;
|
||||
if (from === to) return;
|
||||
const next = [...wagons];
|
||||
const [moved] = next.splice(from, 1);
|
||||
next.splice(to, 0, moved!);
|
||||
onReorder(next.map((w) => w.id));
|
||||
}, [wagons, onReorder]);
|
||||
// Multi-select for the bulk yard move. Only offered when the page passes a
|
||||
// bulk handler — otherwise the checkbox column would lead nowhere.
|
||||
const canSelect = Boolean(onChangeYardBulk) && editable;
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [bulkYardId, setBulkYardId] = useState<string | null>(null);
|
||||
const bulkYardsQuery = useQuery(
|
||||
api.routes.yards.queryOptions({
|
||||
staleTime: 5 * 60_000,
|
||||
enabled: canSelect,
|
||||
}),
|
||||
);
|
||||
// A wagon detached elsewhere must not linger in the selection.
|
||||
useEffect(() => {
|
||||
setSelected((prev) => {
|
||||
if (!prev.size) return prev;
|
||||
const live = new Set(wagons.map((w) => w.id));
|
||||
const next = new Set([...prev].filter((id) => live.has(id)));
|
||||
return next.size === prev.size ? prev : next;
|
||||
});
|
||||
}, [wagons]);
|
||||
const toggleSelected = useCallback((wagonId: string) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(wagonId)) next.delete(wagonId);
|
||||
else next.add(wagonId);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
const clearSelection = useCallback(() => setSelected(new Set()), []);
|
||||
const allSelected = canSelect && selected.size === wagons.length && wagons.length > 0;
|
||||
const applyBulkYard = () => {
|
||||
if (!onChangeYardBulk || !bulkYardId || !selected.size) return;
|
||||
onChangeYardBulk([...selected], bulkYardId, () => {
|
||||
clearSelection();
|
||||
setBulkYardId(null);
|
||||
});
|
||||
};
|
||||
// Search never filters: a consist is a physical order, hiding rows would
|
||||
// make position numbers lie. It scrolls the first match into view instead.
|
||||
const [search, setSearch] = useState("");
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const matchId = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
if (!q) return null;
|
||||
return (
|
||||
wagons.find((w) => w.wagonNumber.toLowerCase().includes(q))?.id ?? null
|
||||
);
|
||||
}, [search, wagons]);
|
||||
useEffect(() => {
|
||||
if (!matchId) return;
|
||||
listRef.current
|
||||
?.querySelector(`[data-wagon-id="${matchId}"]`)
|
||||
?.scrollIntoView({ block: "center", behavior: "smooth" });
|
||||
}, [matchId]);
|
||||
|
||||
const onDragEnd = useCallback(
|
||||
(result: DropResult) => {
|
||||
if (!result.destination) return;
|
||||
const from = result.source.index;
|
||||
const to = result.destination.index;
|
||||
if (from === to) return;
|
||||
const next = [...wagons];
|
||||
const [moved] = next.splice(from, 1);
|
||||
next.splice(to, 0, moved!);
|
||||
onReorder(next.map((w) => w.id));
|
||||
},
|
||||
[wagons, onReorder],
|
||||
);
|
||||
|
||||
// Legend of the types actually coupled, in consist order — the colour code is
|
||||
// only readable if the row tints are keyed somewhere.
|
||||
const legend = useMemo(
|
||||
() => [
|
||||
...new Map(
|
||||
wagons.filter((w) => w.wagonType).map((w) => [w.wagonType!.code, w.wagonType!]),
|
||||
wagons
|
||||
.filter((w) => w.wagonType)
|
||||
.map((w) => [w.wagonType!.code, w.wagonType!]),
|
||||
).values(),
|
||||
],
|
||||
[wagons],
|
||||
@@ -74,52 +157,149 @@ function ConsistWagonList({
|
||||
}
|
||||
|
||||
return (
|
||||
<DragDropContext onDragEnd={onDragEnd}>
|
||||
<Droppable droppableId="train-consist-wagons" isDropDisabled={!editable || busy}>
|
||||
{(dropProvided) => (
|
||||
<Stack gap="xs" ref={dropProvided.innerRef} {...dropProvided.droppableProps}>
|
||||
{legend.length > 1 ? (
|
||||
<Group gap={6} wrap="wrap">
|
||||
{legend.map((type) => (
|
||||
<Badge
|
||||
key={type.code}
|
||||
size="sm"
|
||||
radius="sm"
|
||||
variant="light"
|
||||
color={wagonTypeColor(type.code)}
|
||||
>
|
||||
{type.code} · {type.name}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
) : null}
|
||||
{wagons.map((wagon, index) => (
|
||||
<Draggable
|
||||
key={wagon.id}
|
||||
draggableId={wagon.id}
|
||||
index={index}
|
||||
isDragDisabled={!editable || busy}
|
||||
<Stack gap="xs">
|
||||
<TextInput
|
||||
size="xs"
|
||||
placeholder="Find wagon number…"
|
||||
leftSection={<Search size={14} />}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
error={
|
||||
search.trim() && !matchId
|
||||
? "No wagon in this consist matches"
|
||||
: undefined
|
||||
}
|
||||
aria-label="Find wagon in consist"
|
||||
/>
|
||||
{canSelect ? (
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Checkbox
|
||||
size="xs"
|
||||
label={
|
||||
selected.size
|
||||
? `${selected.size} selected`
|
||||
: "Select wagons to move together"
|
||||
}
|
||||
checked={allSelected}
|
||||
indeterminate={selected.size > 0 && !allSelected}
|
||||
disabled={busy}
|
||||
onChange={() =>
|
||||
setSelected(allSelected ? new Set() : new Set(wagons.map((w) => w.id)))
|
||||
}
|
||||
/>
|
||||
{selected.size ? (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
leftSection={<X size={13} />}
|
||||
onClick={clearSelection}
|
||||
disabled={busy}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
) : null}
|
||||
</Group>
|
||||
) : null}
|
||||
{/* Bulk bar appears only with a selection, so it never competes with the
|
||||
per-wagon yard badge for attention. */}
|
||||
{canSelect && selected.size ? (
|
||||
<Paper withBorder p="xs" radius="md" bg="var(--mantine-color-blue-0)">
|
||||
<Group gap="xs" wrap="nowrap" align="flex-end">
|
||||
<Select
|
||||
size="xs"
|
||||
style={{ flex: 1 }}
|
||||
label={`Move ${selected.size} wagon${selected.size === 1 ? "" : "s"} to yard`}
|
||||
placeholder="Select yard"
|
||||
data={(bulkYardsQuery.data ?? []).map((y) => ({
|
||||
value: y.id,
|
||||
label: y.label ?? y.code,
|
||||
}))}
|
||||
value={bulkYardId}
|
||||
onChange={setBulkYardId}
|
||||
searchable
|
||||
disabled={busy}
|
||||
/>
|
||||
<Button
|
||||
size="xs"
|
||||
leftSection={<MapPin size={14} />}
|
||||
disabled={busy || !bulkYardId}
|
||||
onClick={applyBulkYard}
|
||||
>
|
||||
Move
|
||||
</Button>
|
||||
</Group>
|
||||
</Paper>
|
||||
) : null}
|
||||
{legend.length > 1 ? (
|
||||
<Group gap={6} wrap="wrap">
|
||||
{legend.map((type) => (
|
||||
<Badge
|
||||
key={type.code}
|
||||
size="sm"
|
||||
radius="sm"
|
||||
variant="light"
|
||||
color={wagonTypeColor(type.code)}
|
||||
>
|
||||
{type.code} · {type.name}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
) : null}
|
||||
<DragDropContext onDragEnd={onDragEnd}>
|
||||
<Droppable
|
||||
droppableId="train-consist-wagons"
|
||||
isDropDisabled={!editable || busy}
|
||||
>
|
||||
{(dropProvided) => (
|
||||
<Box
|
||||
ref={listRef}
|
||||
p="xs"
|
||||
style={{
|
||||
maxHeight: 520,
|
||||
overflowY: "auto",
|
||||
border: "1px solid var(--mantine-color-gray-3)",
|
||||
borderRadius: "var(--mantine-radius-md)",
|
||||
}}
|
||||
>
|
||||
<Stack
|
||||
gap="xs"
|
||||
ref={dropProvided.innerRef}
|
||||
{...dropProvided.droppableProps}
|
||||
>
|
||||
{(dragProvided, snapshot) => (
|
||||
<WagonRow
|
||||
wagon={wagon}
|
||||
{wagons.map((wagon, index) => (
|
||||
<Draggable
|
||||
key={wagon.id}
|
||||
draggableId={wagon.id}
|
||||
index={index}
|
||||
dragProvided={dragProvided}
|
||||
snapshot={snapshot}
|
||||
editable={editable}
|
||||
busy={busy}
|
||||
onRemove={onRemove}
|
||||
onMaintenance={onMaintenance}
|
||||
onChangeYard={onChangeYard}
|
||||
/>
|
||||
)}
|
||||
</Draggable>
|
||||
))}
|
||||
{dropProvided.placeholder}
|
||||
</Stack>
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
isDragDisabled={!editable || busy}
|
||||
>
|
||||
{(dragProvided, snapshot) => (
|
||||
<WagonRow
|
||||
wagon={wagon}
|
||||
index={index}
|
||||
dragProvided={dragProvided}
|
||||
snapshot={snapshot}
|
||||
editable={editable}
|
||||
busy={busy}
|
||||
highlighted={wagon.id === matchId}
|
||||
selectable={canSelect}
|
||||
selected={selected.has(wagon.id)}
|
||||
onToggleSelected={toggleSelected}
|
||||
onRemove={onRemove}
|
||||
onMaintenance={onMaintenance}
|
||||
onChangeYard={onChangeYard}
|
||||
/>
|
||||
)}
|
||||
</Draggable>
|
||||
))}
|
||||
{dropProvided.placeholder}
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -135,6 +315,15 @@ export interface ConsistWagonListProps {
|
||||
onMaintenance: (wagon: TrainCompositionWagon) => void;
|
||||
/** Move one wagon to another yard from its yard badge; absent = read-only badge. */
|
||||
onChangeYard?: (wagonId: string, currentYardId: string) => void;
|
||||
/**
|
||||
* Move every selected wagon to one yard in a single request. Absent hides the
|
||||
* selection column entirely.
|
||||
*/
|
||||
onChangeYardBulk?: (
|
||||
wagonIds: string[],
|
||||
currentYardId: string,
|
||||
onDone: () => void,
|
||||
) => void;
|
||||
busy?: boolean;
|
||||
}
|
||||
|
||||
@@ -148,13 +337,23 @@ function WagonYardBadge({
|
||||
busy: boolean;
|
||||
onChange?: (wagonId: string, currentYardId: string) => void;
|
||||
}) {
|
||||
const label = wagon.currentYard?.label ?? wagon.currentYard?.code ?? "No yard";
|
||||
const label =
|
||||
wagon.currentYard?.label ?? wagon.currentYard?.code ?? "No yard";
|
||||
const yardsQuery = useQuery(
|
||||
api.routes.yards.queryOptions({ staleTime: 5 * 60_000, enabled: Boolean(onChange) }),
|
||||
api.routes.yards.queryOptions({
|
||||
staleTime: 5 * 60_000,
|
||||
enabled: Boolean(onChange),
|
||||
}),
|
||||
);
|
||||
if (!onChange) {
|
||||
return wagon.currentYard ? (
|
||||
<Badge variant="outline" color="gray" size="xs" radius="sm" leftSection={<MapPin size={10} />}>
|
||||
<Badge
|
||||
variant="outline"
|
||||
color="gray"
|
||||
size="xs"
|
||||
radius="sm"
|
||||
leftSection={<MapPin size={10} />}
|
||||
>
|
||||
{label}
|
||||
</Badge>
|
||||
) : null;
|
||||
@@ -202,6 +401,10 @@ const WagonRow = memo(function WagonRow({
|
||||
snapshot,
|
||||
editable,
|
||||
busy,
|
||||
highlighted,
|
||||
selectable,
|
||||
selected,
|
||||
onToggleSelected,
|
||||
onRemove,
|
||||
onMaintenance,
|
||||
onChangeYard,
|
||||
@@ -212,6 +415,10 @@ const WagonRow = memo(function WagonRow({
|
||||
snapshot: DraggableStateSnapshot;
|
||||
editable: boolean;
|
||||
busy: boolean;
|
||||
highlighted: boolean;
|
||||
selectable: boolean;
|
||||
selected: boolean;
|
||||
onToggleSelected: (wagonId: string) => void;
|
||||
onRemove: (wagonId: string) => void;
|
||||
onMaintenance: (wagon: TrainCompositionWagon) => void;
|
||||
onChangeYard?: (wagonId: string, currentYardId: string) => void;
|
||||
@@ -224,6 +431,7 @@ const WagonRow = memo(function WagonRow({
|
||||
ref={dragProvided.innerRef}
|
||||
{...dragProvided.draggableProps}
|
||||
{...dragProvided.dragHandleProps}
|
||||
data-wagon-id={wagon.id}
|
||||
gap="sm"
|
||||
wrap="nowrap"
|
||||
p="sm"
|
||||
@@ -237,11 +445,33 @@ const WagonRow = memo(function WagonRow({
|
||||
background: snapshot.isDragging
|
||||
? "white"
|
||||
: `var(--mantine-color-${color}-0)`,
|
||||
boxShadow: snapshot.isDragging ? "0 8px 24px rgba(0, 0, 0, 0.12)" : undefined,
|
||||
cursor: editable ? (snapshot.isDragging ? "grabbing" : "grab") : "default",
|
||||
boxShadow: snapshot.isDragging
|
||||
? "0 8px 24px rgba(0, 0, 0, 0.12)"
|
||||
: highlighted
|
||||
? "0 0 0 3px var(--mantine-color-yellow-4)"
|
||||
: selected
|
||||
? "0 0 0 2px var(--mantine-color-blue-5)"
|
||||
: undefined,
|
||||
cursor: editable
|
||||
? snapshot.isDragging
|
||||
? "grabbing"
|
||||
: "grab"
|
||||
: "default",
|
||||
userSelect: "none",
|
||||
}}
|
||||
>
|
||||
{selectable ? (
|
||||
<Checkbox
|
||||
size="sm"
|
||||
checked={selected}
|
||||
disabled={busy}
|
||||
aria-label={`Select wagon ${wagon.wagonNumber}`}
|
||||
// The row is a drag handle — keep the click on the checkbox.
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onChange={() => onToggleSelected(wagon.id)}
|
||||
/>
|
||||
) : null}
|
||||
{editable ? (
|
||||
<Box c="dimmed" style={{ display: "flex", alignItems: "center" }}>
|
||||
<GripVertical size={18} />
|
||||
|
||||
@@ -111,6 +111,7 @@ export default function TrainBuilderDetailPage() {
|
||||
const assignWagons = useMutation(api.trainBuilder.assignWagons.mutationOptions());
|
||||
const removeWagon = useMutation(api.trainBuilder.removeWagon.mutationOptions());
|
||||
const setWagonYard = useMutation(api.trainBuilder.setWagonYard.mutationOptions());
|
||||
const setWagonsYard = useMutation(api.trainBuilder.setWagonsYard.mutationOptions());
|
||||
const maintenanceWagon = useMutation(
|
||||
api.trainBuilder.sendWagonToMaintenance.mutationOptions(),
|
||||
);
|
||||
@@ -165,6 +166,7 @@ export default function TrainBuilderDetailPage() {
|
||||
assignWagons.isPending ||
|
||||
removeWagon.isPending ||
|
||||
setWagonYard.isPending ||
|
||||
setWagonsYard.isPending ||
|
||||
maintenanceWagon.isPending ||
|
||||
reorderWagons.isPending;
|
||||
|
||||
@@ -230,6 +232,16 @@ export default function TrainBuilderDetailPage() {
|
||||
},
|
||||
[withToast, setWagonYard.mutateAsync, trainId],
|
||||
);
|
||||
const handleChangeWagonsYard = useCallback(
|
||||
(wagonIds: string[], currentYardId: string, onDone: () => void) => {
|
||||
if (!trainId) return;
|
||||
void withToast(async () => {
|
||||
await setWagonsYard.mutateAsync({ id: trainId, wagonIds, currentYardId });
|
||||
onDone();
|
||||
}, "Could not move the selected wagons");
|
||||
},
|
||||
[withToast, setWagonsYard.mutateAsync, trainId],
|
||||
);
|
||||
const handleMaintenance = useCallback(
|
||||
(wagon: TrainCompositionWagon) => setMaintenanceTarget(wagon),
|
||||
[],
|
||||
@@ -515,6 +527,9 @@ export default function TrainBuilderDetailPage() {
|
||||
onChangeYard={
|
||||
composition.editable && canChangeWagonYard ? handleChangeWagonYard : undefined
|
||||
}
|
||||
onChangeYardBulk={
|
||||
composition.editable && canChangeWagonYard ? handleChangeWagonsYard : undefined
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
@@ -2178,6 +2178,19 @@ export const api = {
|
||||
seedComposition,
|
||||
),
|
||||
|
||||
setWagonsYard: endpoint<
|
||||
{ id: string; wagonIds: string[]; currentYardId: string },
|
||||
TrainComposition
|
||||
>(
|
||||
"train-builder",
|
||||
"setWagonsYard",
|
||||
({ id, wagonIds, currentYardId }) =>
|
||||
trainBuilderService.setWagonsYard(id, wagonIds, currentYardId).then((r) => r.data),
|
||||
undefined,
|
||||
() => TRAIN_BUILDER_WAGON_INVALIDATIONS,
|
||||
seedComposition,
|
||||
),
|
||||
|
||||
removeWagon: endpoint<{ id: string; wagonId: string }, TrainComposition>(
|
||||
"train-builder",
|
||||
"removeWagon",
|
||||
|
||||
@@ -357,6 +357,12 @@ export const trainBuilderService = {
|
||||
/** Move one coupled wagon to another yard; the train stays put. */
|
||||
setWagonYard: (id: string, wagonId: string, currentYardId: string) =>
|
||||
apiClient.patch<TrainComposition>(`${BASE}/${id}/wagons/${wagonId}/yard`, { currentYardId }),
|
||||
/** Move several coupled wagons to another yard in one transaction (all-or-nothing). */
|
||||
setWagonsYard: (id: string, wagonIds: string[], currentYardId: string) =>
|
||||
apiClient.patch<TrainComposition>(`${BASE}/${id}/wagons/yard`, {
|
||||
wagonIds,
|
||||
currentYardId,
|
||||
}),
|
||||
assignWagons: (id: string, wagonIds: string[]) =>
|
||||
apiClient.post<TrainComposition>(`${BASE}/${id}/wagons`, { wagonIds }),
|
||||
removeWagon: (id: string, wagonId: string) =>
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import { useState } from "react";
|
||||
import { Alert, Button, Group, Text } from "@mantine/core";
|
||||
import { Alert, Button, Group, Stack, Text } from "@mantine/core";
|
||||
import {
|
||||
CheckCircle2,
|
||||
ClipboardList,
|
||||
Clock,
|
||||
FilePlus2,
|
||||
PackagePlus,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { bookingsService } from "@/services/bookings.service";
|
||||
import { BookingActionModal } from "@/pages/bookings/clearance/BookingActionModal";
|
||||
import {
|
||||
bookingDocNoun,
|
||||
@@ -29,7 +32,26 @@ import { CardTitle, SectionCard } from "./layout";
|
||||
*/
|
||||
export function ClearanceCard({ booking }: { booking: Freight.IBooking }) {
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
// Opening straight onto a blank "Additional documents" row, so answering a
|
||||
// GL request is one click rather than a hunt down the document grid.
|
||||
const [openWithAdHoc, setOpenWithAdHoc] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
// What Global Logistics asked this customer for, if anything. Shares the
|
||||
// clearance query key with the modal, so this costs no extra request.
|
||||
const { data: clearance } = useQuery({
|
||||
queryKey: ["booking-clearance", booking.id],
|
||||
queryFn: () => bookingsService.getClearance(booking.id),
|
||||
});
|
||||
const docRequests = clearance?.docRequests ?? [];
|
||||
const latestRequest = docRequests[0] ?? null;
|
||||
const openAdHoc = () => {
|
||||
setOpenWithAdHoc(true);
|
||||
setModalOpen(true);
|
||||
};
|
||||
const closeModal = () => {
|
||||
setModalOpen(false);
|
||||
setOpenWithAdHoc(false);
|
||||
};
|
||||
const status = booking.status as string;
|
||||
const action = getBookingNextAction(booking);
|
||||
// Self-clearance services collect the customer's own import/export paperwork,
|
||||
@@ -105,6 +127,43 @@ export function ClearanceCard({ booking }: { booking: Freight.IBooking }) {
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{/* GL asked for something — red, above the fold, with the ask in their
|
||||
own words and a one-click way to answer it. */}
|
||||
{latestRequest && (
|
||||
<Alert
|
||||
color="red"
|
||||
variant="light"
|
||||
radius="md"
|
||||
mb="sm"
|
||||
icon={<FilePlus2 size={18} />}
|
||||
title="Global Logistics needs a document from you"
|
||||
>
|
||||
<Stack gap={8}>
|
||||
<Text fz="13px" c="#10202F" style={{ whiteSpace: "pre-wrap" }}>
|
||||
{latestRequest.note}
|
||||
</Text>
|
||||
<Text fz="11.5px" c="dimmed">
|
||||
{latestRequest.byName ?? "Global Logistics"} ·{" "}
|
||||
{new Date(latestRequest.at).toLocaleString()}
|
||||
{docRequests.length > 1
|
||||
? ` · ${docRequests.length} requests in total`
|
||||
: ""}
|
||||
</Text>
|
||||
<Group>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
color="red"
|
||||
radius="md"
|
||||
leftSection={<FilePlus2 size={14} />}
|
||||
onClick={openAdHoc}
|
||||
>
|
||||
Add document
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{summary}
|
||||
|
||||
<Text fz="12.5px" c="dimmed" mt="sm">
|
||||
@@ -119,7 +178,8 @@ export function ClearanceCard({ booking }: { booking: Freight.IBooking }) {
|
||||
<BookingActionModal
|
||||
booking={booking}
|
||||
opened={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
startWithAdHocRow={openWithAdHoc}
|
||||
onClose={closeModal}
|
||||
/>
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
@@ -89,8 +89,7 @@ export function CustomsPaymentsCard({ booking }: { booking: Freight.IBooking })
|
||||
const showSecond = Boolean(
|
||||
clearance.secondDuty?.advised || clearance.secondDuty?.paid,
|
||||
);
|
||||
const showFinal = Boolean(clearance.finalInvoice);
|
||||
if (!showDuty && !showSecond && !showFinal) return null;
|
||||
if (!showDuty && !showSecond) return null;
|
||||
|
||||
const onChanged = () => void refetch();
|
||||
|
||||
@@ -126,14 +125,6 @@ export function CustomsPaymentsCard({ booking }: { booking: Freight.IBooking })
|
||||
onChanged={onChanged}
|
||||
/>
|
||||
)}
|
||||
{showFinal && clearance.finalInvoice && (
|
||||
<FinalInvoiceDueCard
|
||||
invoice={clearance.finalInvoice}
|
||||
bookingId={booking.id}
|
||||
onView={view}
|
||||
onChanged={onChanged}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
{viewer}
|
||||
</SectionCard>
|
||||
@@ -258,188 +249,6 @@ function GroupLabel({ icon: Icon, text }: { icon: typeof Receipt; text: string }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Post-offload final invoice from GL Djibouti (export): shows the due amount +
|
||||
* invoice document; the customer pays offline and attaches the payment slip
|
||||
* here, then GL confirms and the badge flips to PAID.
|
||||
*/
|
||||
function FinalInvoiceDueCard({
|
||||
invoice,
|
||||
bookingId,
|
||||
onView,
|
||||
onChanged,
|
||||
}: {
|
||||
invoice: NonNullable<Freight.ClearanceView["finalInvoice"]>;
|
||||
bookingId: string;
|
||||
onView: (file: { name: string; url: string }) => void;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const [slip, setSlip] = useState<File | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [approving, setApproving] = useState(false);
|
||||
const paid = invoice.status === "PAID";
|
||||
// GL Djibouti raises it as a draft: nothing is payable until the customer
|
||||
// reviews the attached invoice and approves it.
|
||||
const approved = Boolean(invoice.approvedAt);
|
||||
|
||||
return (
|
||||
<Paper
|
||||
withBorder
|
||||
radius="lg"
|
||||
p="lg"
|
||||
style={{
|
||||
borderColor: paid ? "#CDEBDD" : "#F2D9A6",
|
||||
background: paid ? "#F6FBF8" : "#FFFBF2",
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: 3,
|
||||
background: paid ? GREEN : "#E3A93C",
|
||||
}}
|
||||
/>
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
|
||||
<div>
|
||||
<Group gap={8} align="center">
|
||||
<FileBadge size={16} color={paid ? GREEN : "#B07C1F"} />
|
||||
<Text fw={700} fz={15} c={INK}>
|
||||
{paid
|
||||
? "Final invoice paid"
|
||||
: approved
|
||||
? "Final invoice due"
|
||||
: "Final invoice — your approval needed"}{" "}
|
||||
— {invoice.invoiceNumber}
|
||||
</Text>
|
||||
<Badge color={paid ? "edr-green" : "yellow"} variant="light" radius="sm">
|
||||
{approved
|
||||
? invoiceStatusLabel(invoice.status)
|
||||
: "Awaiting your approval"}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Text fz={20} fw={800} mt={6} c={INK}>
|
||||
{invoice.totalAmount.toLocaleString()} {invoice.currency}
|
||||
</Text>
|
||||
{invoice.description ? (
|
||||
<Text fz={13} c="dimmed" mt={2}>
|
||||
{invoice.description}
|
||||
</Text>
|
||||
) : null}
|
||||
{!paid ? (
|
||||
<Text fz={13} c="#9A6B1F" mt={6}>
|
||||
{approved
|
||||
? "Pay the amount above and attach your payment slip — Global Logistics will confirm the payment."
|
||||
: "Review the invoice document from Global Logistics Djibouti and approve it to proceed with payment."}
|
||||
</Text>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<Stack gap="xs" miw={260}>
|
||||
{invoice.invoiceFile ? (
|
||||
<Button
|
||||
variant="default"
|
||||
radius="md"
|
||||
size="sm"
|
||||
leftSection={<Eye size={15} />}
|
||||
onClick={() =>
|
||||
onView({
|
||||
name: invoice.invoiceFile!.name,
|
||||
url: invoice.invoiceFile!.url,
|
||||
})
|
||||
}
|
||||
>
|
||||
View invoice
|
||||
</Button>
|
||||
) : null}
|
||||
{invoice.slipFile ? (
|
||||
<Button
|
||||
variant="default"
|
||||
radius="md"
|
||||
size="sm"
|
||||
leftSection={<Eye size={15} />}
|
||||
onClick={() =>
|
||||
onView({
|
||||
name: invoice.slipFile!.name,
|
||||
url: invoice.slipFile!.url,
|
||||
})
|
||||
}
|
||||
>
|
||||
View payment slip
|
||||
</Button>
|
||||
) : null}
|
||||
{!paid && !approved ? (
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
size="sm"
|
||||
loading={approving}
|
||||
leftSection={<Check size={15} />}
|
||||
onClick={async () => {
|
||||
setApproving(true);
|
||||
try {
|
||||
await contractsService.approveFinalInvoice(bookingId);
|
||||
toast.success("Invoice approved — you can now pay");
|
||||
onChanged();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Approval failed");
|
||||
} finally {
|
||||
setApproving(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Approve invoice
|
||||
</Button>
|
||||
) : null}
|
||||
{!paid && approved ? (
|
||||
<>
|
||||
<FileInput
|
||||
placeholder={
|
||||
invoice.slipFile ? "Replace payment slip" : "Attach payment slip"
|
||||
}
|
||||
value={slip}
|
||||
onChange={setSlip}
|
||||
size="sm"
|
||||
radius="md"
|
||||
/>
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
size="sm"
|
||||
loading={uploading}
|
||||
disabled={!slip}
|
||||
leftSection={<Upload size={15} />}
|
||||
onClick={async () => {
|
||||
if (!slip) return;
|
||||
setUploading(true);
|
||||
try {
|
||||
await contractsService.uploadFinalInvoiceSlip(bookingId, slip);
|
||||
setSlip(null);
|
||||
toast.success("Payment slip attached");
|
||||
onChanged();
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : "Upload failed",
|
||||
);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{invoice.slipFile ? "Replace slip" : "Submit payment slip"}
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Post-arrival additional duty/tax round (import): GL advises an extra amount
|
||||
* with a notice; the customer pays offline and attaches another slip here.
|
||||
|
||||
@@ -14,6 +14,8 @@ import { useClearanceFlow } from "./useClearanceFlow";
|
||||
interface BookingActionModalProps {
|
||||
booking: Freight.IBooking;
|
||||
opened: boolean;
|
||||
/** Open with one blank "Additional documents" row ready (answering a GL request). */
|
||||
startWithAdHocRow?: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
@@ -29,21 +31,30 @@ interface BookingActionModalProps {
|
||||
export function BookingActionModal({
|
||||
booking,
|
||||
opened,
|
||||
startWithAdHocRow,
|
||||
onClose,
|
||||
}: BookingActionModalProps) {
|
||||
if (!opened) return null;
|
||||
return <BookingActionModalBody booking={booking} onClose={onClose} />;
|
||||
return (
|
||||
<BookingActionModalBody
|
||||
booking={booking}
|
||||
startWithAdHocRow={startWithAdHocRow}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BookingActionModalBody({
|
||||
booking,
|
||||
startWithAdHocRow,
|
||||
onClose,
|
||||
}: {
|
||||
booking: Freight.IBooking;
|
||||
startWithAdHocRow?: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const action = getBookingNextAction(booking);
|
||||
const flow = useClearanceFlow(booking);
|
||||
const flow = useClearanceFlow(booking, { startWithAdHocRow });
|
||||
const navigate = useNavigate();
|
||||
const reference = booking.reference;
|
||||
|
||||
|
||||
@@ -266,14 +266,14 @@ export function ClearanceFlow({ booking, flow, footer }: ClearanceFlowProps) {
|
||||
one place. Newest first. */}
|
||||
{(clearance.docRequests?.length ?? 0) > 0 && (
|
||||
<Box mt="lg">
|
||||
<Text fz="12.5px" fw={700} c="#10202F" mb={8}>
|
||||
Requested by Global Logistics
|
||||
<Text fz="12.5px" fw={700} c="#C0392B" mb={8}>
|
||||
Requested by Global Logistics — please add these documents
|
||||
</Text>
|
||||
<Stack gap={8}>
|
||||
{clearance.docRequests!.map((r) => (
|
||||
<Alert
|
||||
key={r.id}
|
||||
color="blue"
|
||||
color="red"
|
||||
variant="light"
|
||||
radius="md"
|
||||
icon={<MessageSquare size={16} />}
|
||||
|
||||
@@ -6,6 +6,20 @@ import type { Freight } from "@edr/types";
|
||||
|
||||
export type AdHocDoc = { name: string; file: File | null };
|
||||
|
||||
/**
|
||||
* Make the customer's document name safe for a multipart field code (the API's
|
||||
* `adHocLabel` turns it back into a label). Empty when unnamed, which keeps the
|
||||
* old `custom_<n>` shape and lets the API fall back to the filename.
|
||||
*/
|
||||
function adHocSlug(name: string): string {
|
||||
return name
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 60);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encapsulates everything the customer-facing clearance/operation flow needs:
|
||||
* the clearance grid query, the staged uploads (keyed pending + ad-hoc docs),
|
||||
@@ -14,7 +28,10 @@ export type AdHocDoc = { name: string; file: File | null };
|
||||
* Both the booking detail clearance card and the home-page action modal drive
|
||||
* their UI off this single hook so the behaviour stays in lock-step.
|
||||
*/
|
||||
export function useClearanceFlow(booking: Freight.IBooking) {
|
||||
export function useClearanceFlow(
|
||||
booking: Freight.IBooking,
|
||||
opts: { startWithAdHocRow?: boolean } = {},
|
||||
) {
|
||||
const queryClient = useQueryClient();
|
||||
const status = booking.status as string;
|
||||
|
||||
@@ -25,7 +42,11 @@ export function useClearanceFlow(booking: Freight.IBooking) {
|
||||
|
||||
// Pending uploads keyed by fileKey, plus ad-hoc rows (label + file).
|
||||
const [pending, setPending] = useState<Record<string, File>>({});
|
||||
const [adHoc, setAdHoc] = useState<AdHocDoc[]>([]);
|
||||
// Seeded with one blank row when the customer came here to answer a GL
|
||||
// request, so the name + file inputs are already on screen.
|
||||
const [adHoc, setAdHoc] = useState<AdHocDoc[]>(
|
||||
opts.startWithAdHocRow ? [{ name: "", file: null }] : [],
|
||||
);
|
||||
// Binding shipment day chosen for the operation request (yyyy-MM-dd).
|
||||
const [scheduledDate, setScheduledDateState] = useState<string>("");
|
||||
// Export rail only: the specific train picked for that day.
|
||||
@@ -192,7 +213,10 @@ export function useClearanceFlow(booking: Freight.IBooking) {
|
||||
const submitDocuments = (opts?: { onSuccess?: () => void }) => {
|
||||
const files: Record<string, File | null> = { ...pending };
|
||||
adHoc.forEach((row, i) => {
|
||||
if (row.file) files[`custom_${Date.now()}_${i}`] = row.file;
|
||||
// The document name the customer typed travels in the field code — it is
|
||||
// the only channel a multipart part has — so GL sees "Special permit"
|
||||
// rather than "scan_003.pdf". `adHocLabel` on the API decodes it back.
|
||||
if (row.file) files[`custom_${adHocSlug(row.name)}_${Date.now()}${i}`] = row.file;
|
||||
});
|
||||
if (Object.keys(files).length === 0) return;
|
||||
uploadMutation.mutate({ id: booking.id, files }, { onSuccess: opts?.onSuccess });
|
||||
|
||||
@@ -81,31 +81,10 @@ export function useBookingPayables(booking: Freight.IBooking) {
|
||||
|
||||
for (const inv of invoicesQ.data ?? []) {
|
||||
const balance = Number(inv.balanceAmount ?? 0);
|
||||
if (inv.type === Freight.GL_FINAL_INVOICE_TYPE) {
|
||||
// Raised as DRAFT; issuing IS the customer's approval, then slip-paid.
|
||||
if (inv.status === Freight.InvoiceStatus.Draft) {
|
||||
out.push({
|
||||
id: inv.id,
|
||||
label: "Final invoice",
|
||||
detail: `${inv.invoiceNumber} · approve to proceed`,
|
||||
amount: Number(inv.totalAmount),
|
||||
currency: inv.currency,
|
||||
action: "APPROVE",
|
||||
anchor: PAYABLE_ANCHORS.customs,
|
||||
});
|
||||
} else if (isPayable(inv.status) && balance > 0) {
|
||||
out.push({
|
||||
id: inv.id,
|
||||
label: "Final invoice",
|
||||
detail: inv.invoiceNumber,
|
||||
amount: balance,
|
||||
currency: inv.currency,
|
||||
action: "UPLOAD_SLIP",
|
||||
anchor: PAYABLE_ANCHORS.customs,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// The GL Djibouti post-offload final invoice was removed from the
|
||||
// clearance flow. Existing ones stay payable from the billing pages; they
|
||||
// are no longer raised here or chased as an outstanding clearance item.
|
||||
if (inv.type === Freight.GL_FINAL_INVOICE_TYPE) continue;
|
||||
if (!isPayable(inv.status) || balance <= 0) continue;
|
||||
if (inv.type === WAGON_CANCEL_FEE_INVOICE_TYPE) {
|
||||
out.push({
|
||||
|
||||
Reference in New Issue
Block a user