mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 05:55:02 +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,
|
||||
|
||||
Reference in New Issue
Block a user