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,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