feat(clearance): preview charge documents before and after upload

This commit is contained in:
Marshal
2026-08-21 07:04:22 +00:00
parent ce3fde676e
commit 4c549029fe
32 changed files with 1195 additions and 660 deletions

View File

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

View File

@@ -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.

View File

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

View File

@@ -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} />}

View File

@@ -6,6 +6,20 @@ import type { Freight } from "@edr/types";
export type AdHocDoc = { name: string; file: File | null };
/**
* Make the customer's document name safe for a multipart field code (the API's
* `adHocLabel` turns it back into a label). Empty when unnamed, which keeps the
* old `custom_<n>` shape and lets the API fall back to the filename.
*/
function adHocSlug(name: string): string {
return name
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 60);
}
/**
* Encapsulates everything the customer-facing clearance/operation flow needs:
* the clearance grid query, the staged uploads (keyed pending + ad-hoc docs),
@@ -14,7 +28,10 @@ export type AdHocDoc = { name: string; file: File | null };
* Both the booking detail clearance card and the home-page action modal drive
* their UI off this single hook so the behaviour stays in lock-step.
*/
export function useClearanceFlow(booking: Freight.IBooking) {
export function useClearanceFlow(
booking: Freight.IBooking,
opts: { startWithAdHocRow?: boolean } = {},
) {
const queryClient = useQueryClient();
const status = booking.status as string;
@@ -25,7 +42,11 @@ export function useClearanceFlow(booking: Freight.IBooking) {
// Pending uploads keyed by fileKey, plus ad-hoc rows (label + file).
const [pending, setPending] = useState<Record<string, File>>({});
const [adHoc, setAdHoc] = useState<AdHocDoc[]>([]);
// Seeded with one blank row when the customer came here to answer a GL
// request, so the name + file inputs are already on screen.
const [adHoc, setAdHoc] = useState<AdHocDoc[]>(
opts.startWithAdHocRow ? [{ name: "", file: null }] : [],
);
// Binding shipment day chosen for the operation request (yyyy-MM-dd).
const [scheduledDate, setScheduledDateState] = useState<string>("");
// Export rail only: the specific train picked for that day.
@@ -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 });

View File

@@ -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({