mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 09:35:44 +00:00
feat: ui changes and pdf setup on the clients
This commit is contained in:
@@ -31,9 +31,6 @@ export interface WarehouseHandoverPdfContext {
|
|||||||
const escapePdfText = (value: string) =>
|
const escapePdfText = (value: string) =>
|
||||||
value.replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)');
|
value.replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)');
|
||||||
|
|
||||||
const money = (amount: unknown, currency = 'USD') =>
|
|
||||||
`${Number(amount ?? 0).toLocaleString()} ${currency === 'ETB' ? 'Birr (ETB)' : currency}`;
|
|
||||||
|
|
||||||
const fmtDate = (value: unknown) => {
|
const fmtDate = (value: unknown) => {
|
||||||
if (!value) return '-';
|
if (!value) return '-';
|
||||||
const date = new Date(value as string | Date);
|
const date = new Date(value as string | Date);
|
||||||
@@ -96,12 +93,6 @@ const textOp = (
|
|||||||
color = '0 0 0',
|
color = '0 0 0',
|
||||||
) => `BT\n${color} rg\n/${bold ? 'F2' : 'F1'} ${size} Tf\n${x} ${y} Td\n(${escapePdfText(text)}) Tj\nET`;
|
) => `BT\n${color} rg\n/${bold ? 'F2' : 'F1'} ${size} Tf\n${x} ${y} Td\n(${escapePdfText(text)}) Tj\nET`;
|
||||||
|
|
||||||
const buildAuthorizationBand = (label: 'PAID' | 'CLEARED') => [
|
|
||||||
lineOp(60, 242, 535, 242),
|
|
||||||
textOp('AUTHORIZED SEAL', 382, 218, 9, true, GREEN),
|
|
||||||
buildCircularSeal(452, 155, label),
|
|
||||||
];
|
|
||||||
|
|
||||||
const buildWarehouseOfficerSealBand = () => [
|
const buildWarehouseOfficerSealBand = () => [
|
||||||
lineOp(60, 218, 535, 218),
|
lineOp(60, 218, 535, 218),
|
||||||
textOp('WAREHOUSE OFFICER SEAL', 92, 194, 9, true, GREEN),
|
textOp('WAREHOUSE OFFICER SEAL', 92, 194, 9, true, GREEN),
|
||||||
@@ -146,57 +137,6 @@ function buildSimplePdf(lines: PdfLine[], rawOps: string[] = []): Blob {
|
|||||||
return new Blob([pdf], { type: 'application/pdf' });
|
return new Blob([pdf], { type: 'application/pdf' });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildWarehouseInvoicePdf(invoice: WarehouseFeeInvoice, kind: 'INVOICE' | 'RECEIPT') {
|
|
||||||
const paid = kind === 'RECEIPT' || invoice.status === 'PAID';
|
|
||||||
const title = `Warehouse Fee ${kind === 'RECEIPT' ? 'Receipt' : 'Invoice'}`;
|
|
||||||
const bookingReference = firstText(invoice.bookingReference);
|
|
||||||
const customerName = firstText(invoice.customerName);
|
|
||||||
const inventoryReference = firstText(invoice.inventoryReference);
|
|
||||||
const inventoryInfo = firstText(invoice.inventoryInfo, invoice.containerNumber, invoice.cargoDescription);
|
|
||||||
const clearanceStatus = firstText(
|
|
||||||
invoice.clearanceStatus,
|
|
||||||
paid ? 'FEE PAID - READY FOR RELEASE' : 'PENDING PAYMENT',
|
|
||||||
);
|
|
||||||
const lines: PdfLine[] = [
|
|
||||||
{ text: 'Ethio-Djibouti Railway S.C.', size: 12, bold: true, yGap: 0, align: 'center' },
|
|
||||||
{ text: title, size: 23, bold: true, yGap: 28, align: 'center' },
|
|
||||||
{ text: `Document No: ${invoice.invoiceNumber}`, size: 12, bold: true, yGap: 32, align: 'center' },
|
|
||||||
{ text: `Status: ${invoice.status.replace(/_/g, ' ')} Type: ${invoice.invoiceType.replace(/_/g, ' ')}`, align: 'center' },
|
|
||||||
{ text: `Booking Reference: ${bookingReference} Customer: ${customerName}`, align: 'center' },
|
|
||||||
{ text: `Inventory Reference: ${inventoryReference} Inventory Info: ${inventoryInfo}`, align: 'center' },
|
|
||||||
{ text: `Clearance: ${clearanceStatus}`, align: 'center' },
|
|
||||||
{ text: `Issued: ${fmtDate(invoice.issuedAt)} Paid At: ${fmtDate(invoice.paidAt)}`, align: 'center' },
|
|
||||||
{ text: 'ITEMS', size: 13, bold: true, yGap: 30, align: 'center' },
|
|
||||||
...(invoice.items ?? []).flatMap((item) => [
|
|
||||||
{ text: item.description, bold: true, align: 'center' as const },
|
|
||||||
{
|
|
||||||
text: `${item.feeType.replace(/_/g, ' ')} | Qty ${Number(item.quantity ?? 0).toLocaleString()} | Rate ${money(item.unitRate, item.currency)} | Amount ${money(item.amount, item.currency)}`,
|
|
||||||
yGap: 13,
|
|
||||||
align: 'center' as const,
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
{ text: 'TOTALS', size: 13, bold: true, yGap: 30, align: 'center' },
|
|
||||||
{ text: `Subtotal: ${money(invoice.subtotalAmount, invoice.currency)}`, align: 'center' },
|
|
||||||
{ text: `Tax: ${money(invoice.taxAmount, invoice.currency)}`, align: 'center' },
|
|
||||||
{ text: `Total: ${money(invoice.totalAmount, invoice.currency)}`, bold: true, align: 'center' },
|
|
||||||
{ text: `Paid: ${money(invoice.paidAmount, invoice.currency)}`, align: 'center' },
|
|
||||||
{ text: `Balance: ${money(invoice.balanceAmount, invoice.currency)}`, bold: true, align: 'center' },
|
|
||||||
];
|
|
||||||
const authorizationOps = [
|
|
||||||
...buildAuthorizationBand('PAID'),
|
|
||||||
textOp('Prepared by EDR warehouse finance', 72, 196, 10),
|
|
||||||
textOp('Finance officer name / signature / date:', 72, 164, 10),
|
|
||||||
lineOp(245, 162, 360, 162, '0 0 0'),
|
|
||||||
];
|
|
||||||
const invoiceOps = [
|
|
||||||
lineOp(60, 242, 535, 242),
|
|
||||||
textOp('Prepared by EDR warehouse finance', 72, 196, 10),
|
|
||||||
textOp('Finance officer name / signature / date:', 72, 164, 10),
|
|
||||||
lineOp(245, 162, 360, 162, '0 0 0'),
|
|
||||||
];
|
|
||||||
return buildSimplePdf(lines, paid ? authorizationOps : invoiceOps);
|
|
||||||
}
|
|
||||||
|
|
||||||
const firstText = (...values: Array<unknown>) => {
|
const firstText = (...values: Array<unknown>) => {
|
||||||
for (const value of values) {
|
for (const value of values) {
|
||||||
if (value !== null && value !== undefined && String(value).trim()) return String(value);
|
if (value !== null && value !== undefined && String(value).trim()) return String(value);
|
||||||
|
|||||||
30
apps/edr-freight-web/backoffice/src/hooks/useScrollToHash.ts
Normal file
30
apps/edr-freight-web/backoffice/src/hooks/useScrollToHash.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { useLocation } from "react-router-dom";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scroll to the element whose `id` matches the URL hash. Retries for a short
|
||||||
|
* window so it still lands on sections that mount after an async fetch (there is
|
||||||
|
* no router-level hash handling). Deep-link targets give a card an `id`.
|
||||||
|
*/
|
||||||
|
export function useScrollToHash(): void {
|
||||||
|
const { hash } = useLocation();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hash) return;
|
||||||
|
const id = decodeURIComponent(hash.slice(1));
|
||||||
|
let tries = 0;
|
||||||
|
let timer: ReturnType<typeof setTimeout>;
|
||||||
|
|
||||||
|
const tick = () => {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el) {
|
||||||
|
el.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (tries++ < 20) timer = setTimeout(tick, 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
timer = setTimeout(tick, 100);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [hash]);
|
||||||
|
}
|
||||||
@@ -50,6 +50,7 @@ import {
|
|||||||
useBookingDetail,
|
useBookingDetail,
|
||||||
useBookingMutations,
|
useBookingMutations,
|
||||||
} from "@/hooks/bookings/useBookings";
|
} from "@/hooks/bookings/useBookings";
|
||||||
|
import { useScrollToHash } from "@/hooks/useScrollToHash";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
|
|
||||||
// Signature / generated-contract files are surfaced on the contract page, not
|
// Signature / generated-contract files are surfaced on the contract page, not
|
||||||
@@ -65,6 +66,8 @@ export default function BookingRequestDetailPage() {
|
|||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
// Deep-link from a warehouse fee invoice → this booking's warehouse section.
|
||||||
|
useScrollToHash();
|
||||||
const {
|
const {
|
||||||
data: booking,
|
data: booking,
|
||||||
isLoading,
|
isLoading,
|
||||||
@@ -281,10 +284,12 @@ export default function BookingRequestDetailPage() {
|
|||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
<BookingCompanyCard booking={booking} />
|
<BookingCompanyCard booking={booking} />
|
||||||
<BookingPricingSummary booking={booking} />
|
<BookingPricingSummary booking={booking} />
|
||||||
<WarehouseInfoCard
|
<Box id="warehouse-payments">
|
||||||
bookingId={booking.id}
|
<WarehouseInfoCard
|
||||||
bookingReference={booking.reference}
|
bookingId={booking.id}
|
||||||
/>
|
bookingReference={booking.reference}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
<BookingActionsToolbar
|
<BookingActionsToolbar
|
||||||
booking={booking}
|
booking={booking}
|
||||||
mutations={mutations}
|
mutations={mutations}
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { Ban, CreditCard, DoorOpen, Download, Eye, Receipt, Search } from 'lucide-react';
|
import { Ban, CreditCard, DoorOpen, Download, ExternalLink, Eye, Receipt, Search } from 'lucide-react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { DataTable, type ColumnDef } from '@edr/ui-common';
|
import { DataTable, type ColumnDef } from '@edr/ui-common';
|
||||||
|
|
||||||
import { PageContainer, PageHeader } from '@/components/page';
|
import { PageContainer, PageHeader } from '@/components/page';
|
||||||
@@ -31,7 +32,7 @@ import {
|
|||||||
type WarehouseInvoiceStatus,
|
type WarehouseInvoiceStatus,
|
||||||
} from '@/types/warehouse';
|
} from '@/types/warehouse';
|
||||||
import { openPdfBlob } from '@/components/warehouses/pdf';
|
import { openPdfBlob } from '@/components/warehouses/pdf';
|
||||||
import { buildWarehouseExitPaperPdf, buildWarehouseInvoicePdf } from '@/components/warehouses/warehousePdf';
|
import { buildWarehouseExitPaperPdf } from '@/components/warehouses/warehousePdf';
|
||||||
import { extractErrorMessage } from '@/components/warehouses/options';
|
import { extractErrorMessage } from '@/components/warehouses/options';
|
||||||
|
|
||||||
const STATUS_COLOR: Record<WarehouseInvoiceStatus, string> = {
|
const STATUS_COLOR: Record<WarehouseInvoiceStatus, string> = {
|
||||||
@@ -155,6 +156,7 @@ export default function WarehouseInvoicesPage() {
|
|||||||
|
|
||||||
function InvoiceDetailModal({ id, onClose }: { id: string | null; onClose: () => void }) {
|
function InvoiceDetailModal({ id, onClose }: { id: string | null; onClose: () => void }) {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const navigate = useNavigate();
|
||||||
const { data: inv, isLoading } = useQuery(
|
const { data: inv, isLoading } = useQuery(
|
||||||
api.warehouses.invoice.queryOptions({
|
api.warehouses.invoice.queryOptions({
|
||||||
input: { id: id ?? '' },
|
input: { id: id ?? '' },
|
||||||
@@ -172,13 +174,33 @@ function InvoiceDetailModal({ id, onClose }: { id: string | null; onClose: () =>
|
|||||||
const canGateClear = inv?.status === 'PAID' && Boolean(inv.inventoryId);
|
const canGateClear = inv?.status === 'PAID' && Boolean(inv.inventoryId);
|
||||||
|
|
||||||
const downloadInvoicePdf = async (invoice: WarehouseFeeInvoice) => {
|
const downloadInvoicePdf = async (invoice: WarehouseFeeInvoice) => {
|
||||||
const blob = buildWarehouseInvoicePdf(invoice, 'INVOICE');
|
const pdfWindow = window.open('', '_blank');
|
||||||
openPdfBlob(blob, `warehouse-invoice-${invoice.invoiceNumber}.pdf`);
|
try {
|
||||||
|
const { data } = await warehouseService.downloadInvoiceDocument(invoice.id);
|
||||||
|
openPdfBlob(data, `warehouse-invoice-${invoice.invoiceNumber}.pdf`, pdfWindow);
|
||||||
|
} catch (error) {
|
||||||
|
pdfWindow?.close();
|
||||||
|
toast({
|
||||||
|
variant: 'destructive',
|
||||||
|
title: 'Download failed',
|
||||||
|
description: extractErrorMessage(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const downloadReceiptPdf = async (invoice: WarehouseFeeInvoice) => {
|
const downloadReceiptPdf = async (invoice: WarehouseFeeInvoice) => {
|
||||||
const blob = buildWarehouseInvoicePdf(invoice, 'RECEIPT');
|
const pdfWindow = window.open('', '_blank');
|
||||||
openPdfBlob(blob, `warehouse-receipt-${invoice.invoiceNumber}.pdf`);
|
try {
|
||||||
|
const { data } = await warehouseService.downloadInvoiceReceipt(invoice.id);
|
||||||
|
openPdfBlob(data, `warehouse-receipt-${invoice.invoiceNumber}.pdf`, pdfWindow);
|
||||||
|
} catch (error) {
|
||||||
|
pdfWindow?.close();
|
||||||
|
toast({
|
||||||
|
variant: 'destructive',
|
||||||
|
title: 'Download failed',
|
||||||
|
description: extractErrorMessage(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getExitPaperContext = async (invoice: WarehouseFeeInvoice) => {
|
const getExitPaperContext = async (invoice: WarehouseFeeInvoice) => {
|
||||||
@@ -366,6 +388,20 @@ function InvoiceDetailModal({ id, onClose }: { id: string | null; onClose: () =>
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<Group justify="flex-end" mt="sm">
|
<Group justify="flex-end" mt="sm">
|
||||||
|
{inv.bookingId && (
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
leftSection={<ExternalLink size={16} />}
|
||||||
|
onClick={() =>
|
||||||
|
navigate(
|
||||||
|
`/dashboard/booking-requests/${inv.bookingId}#warehouse-payments`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
View booking
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="light"
|
variant="light"
|
||||||
color="gray"
|
color="gray"
|
||||||
|
|||||||
@@ -147,6 +147,16 @@ export const URL_CONSTANTS = {
|
|||||||
BILLING: {
|
BILLING: {
|
||||||
MY_INVOICES: "/api/billing/my-invoices",
|
MY_INVOICES: "/api/billing/my-invoices",
|
||||||
MY_INVOICE_BY_ID: (id: string) => `/api/billing/my-invoices/${id}`,
|
MY_INVOICE_BY_ID: (id: string) => `/api/billing/my-invoices/${id}`,
|
||||||
|
MY_INVOICE_DOCUMENT: (id: string) => `/api/billing/my-invoices/${id}/document`,
|
||||||
|
MY_INVOICE_RECEIPT: (id: string) => `/api/billing/my-invoices/${id}/receipt`,
|
||||||
PAY_INVOICE: (id: string) => `/api/billing/my-invoices/${id}/pay`,
|
PAY_INVOICE: (id: string) => `/api/billing/my-invoices/${id}/pay`,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
WAREHOUSE_INVOICES: {
|
||||||
|
FOR_BOOKING: (bookingId: string) =>
|
||||||
|
`/api/bookings/${bookingId}/warehouse-fee-invoices`,
|
||||||
|
BY_ID: (id: string) => `/api/warehouse-fee-invoices/${id}`,
|
||||||
|
DOCUMENT: (id: string) => `/api/warehouse-fee-invoices/${id}/document`,
|
||||||
|
RECEIPT: (id: string) => `/api/warehouse-fee-invoices/${id}/receipt`,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
30
apps/edr-freight-web/portal/src/hooks/useScrollToHash.ts
Normal file
30
apps/edr-freight-web/portal/src/hooks/useScrollToHash.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { useLocation } from "react-router-dom";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scroll to the element whose `id` matches the URL hash. Retries for a short
|
||||||
|
* window so it still lands on sections that mount after an async fetch (the app
|
||||||
|
* has no router-level hash handling). Deep-link targets give a card an `id`.
|
||||||
|
*/
|
||||||
|
export function useScrollToHash(): void {
|
||||||
|
const { hash } = useLocation();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hash) return;
|
||||||
|
const id = decodeURIComponent(hash.slice(1));
|
||||||
|
let tries = 0;
|
||||||
|
let timer: ReturnType<typeof setTimeout>;
|
||||||
|
|
||||||
|
const tick = () => {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el) {
|
||||||
|
el.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (tries++ < 20) timer = setTimeout(tick, 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
timer = setTimeout(tick, 100);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [hash]);
|
||||||
|
}
|
||||||
@@ -15,9 +15,13 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
Title,
|
Title,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { ArrowLeft, CreditCard, Info } from "lucide-react";
|
import { ArrowLeft, CreditCard, Download, ExternalLink, Info, Receipt } from "lucide-react";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
|
import { invoicesService } from "@/services/invoices.service";
|
||||||
|
import { warehouseInvoicesService } from "@/services/warehouse-invoices.service";
|
||||||
|
import { saveBlob } from "@/utils/download";
|
||||||
import { formatCurrency } from "@/lib/currency";
|
import { formatCurrency } from "@/lib/currency";
|
||||||
import { BORDER, INK, MUTED } from "../contracts/contract-ui";
|
import { BORDER, INK, MUTED } from "../contracts/contract-ui";
|
||||||
import {
|
import {
|
||||||
@@ -95,6 +99,54 @@ export default function InvoiceDetailPage() {
|
|||||||
payMutation.mutate({ id, payload: { returnUrl, failureUrl } });
|
payMutation.mutate({ id, payload: { returnUrl, failureUrl } });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const hasReceipt = Number(invoice.paidAmount) > 0;
|
||||||
|
const canViewSource =
|
||||||
|
invoice.source === "booking" || invoice.source === "warehouse";
|
||||||
|
|
||||||
|
const downloadInvoice = async () => {
|
||||||
|
try {
|
||||||
|
saveBlob(
|
||||||
|
await invoicesService.downloadDocument(id),
|
||||||
|
`invoice-${invoice.invoiceNumber}.pdf`,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
toast.error("Invoice PDF isn't ready yet. Contact EDR if this persists.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadReceipt = async () => {
|
||||||
|
try {
|
||||||
|
saveBlob(
|
||||||
|
await invoicesService.downloadReceipt(id),
|
||||||
|
`receipt-${invoice.invoiceNumber}.pdf`,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
toast.error("Receipt isn't available yet.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// The source link: a booking invoice goes straight to the booking; a warehouse
|
||||||
|
// fee invoice resolves its booking (via the warehouse view) and deep-links to
|
||||||
|
// that booking's warehouse-payments section.
|
||||||
|
const viewSource = async () => {
|
||||||
|
if (invoice.source === "booking") {
|
||||||
|
navigate(`/bookings/${invoice.sourceId}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (invoice.source === "warehouse") {
|
||||||
|
try {
|
||||||
|
const wh = await warehouseInvoicesService.get(invoice.id);
|
||||||
|
if (wh?.bookingId) {
|
||||||
|
navigate(`/bookings/${wh.bookingId}#warehouse-payments`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* fall through to the toast below */
|
||||||
|
}
|
||||||
|
toast.error("This invoice's source isn't linked to a booking.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box style={{ padding: "28px 32px 32px" }}>
|
<Box style={{ padding: "28px 32px 32px" }}>
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
@@ -117,19 +169,56 @@ export default function InvoiceDetailPage() {
|
|||||||
</Title>
|
</Title>
|
||||||
<InvoiceStatusBadge status={invoice.status} />
|
<InvoiceStatusBadge status={invoice.status} />
|
||||||
</Group>
|
</Group>
|
||||||
{payable && (
|
<Group gap={8} wrap="wrap">
|
||||||
|
{canViewSource && (
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
radius="md"
|
||||||
|
size="md"
|
||||||
|
leftSection={<ExternalLink size={16} />}
|
||||||
|
onClick={viewSource}
|
||||||
|
styles={{ root: { fontWeight: 600, height: 42, paddingInline: 16 } }}
|
||||||
|
>
|
||||||
|
View source
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
color="edr-green"
|
variant="default"
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
leftSection={<CreditCard size={16} />}
|
leftSection={<Download size={16} />}
|
||||||
loading={payMutation.isPending}
|
onClick={downloadInvoice}
|
||||||
onClick={handlePay}
|
styles={{ root: { fontWeight: 600, height: 42, paddingInline: 16 } }}
|
||||||
styles={{ root: { fontWeight: 600, height: 42, paddingInline: 18 } }}
|
|
||||||
>
|
>
|
||||||
Pay {formatCurrency(Number(invoice.totalAmount), invoice.currency)}
|
Download invoice
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
{hasReceipt && (
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
radius="md"
|
||||||
|
size="md"
|
||||||
|
leftSection={<Receipt size={16} />}
|
||||||
|
onClick={downloadReceipt}
|
||||||
|
styles={{ root: { fontWeight: 600, height: 42, paddingInline: 16 } }}
|
||||||
|
>
|
||||||
|
Receipt
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{payable && (
|
||||||
|
<Button
|
||||||
|
color="edr-green"
|
||||||
|
radius="md"
|
||||||
|
size="md"
|
||||||
|
leftSection={<CreditCard size={16} />}
|
||||||
|
loading={payMutation.isPending}
|
||||||
|
onClick={handlePay}
|
||||||
|
styles={{ root: { fontWeight: 600, height: 42, paddingInline: 18 } }}
|
||||||
|
>
|
||||||
|
Pay {formatCurrency(Number(invoice.totalAmount), invoice.currency)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{payMutation.isError && (
|
{payMutation.isError && (
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ const STATUS_STYLE: Record<
|
|||||||
[Freight.InvoiceStatus.Overdue]: { label: "Overdue", bg: "#FDECEC", fg: "#C0392B" },
|
[Freight.InvoiceStatus.Overdue]: { label: "Overdue", bg: "#FDECEC", fg: "#C0392B" },
|
||||||
[Freight.InvoiceStatus.Cancelled]: { label: "Cancelled", bg: "#EEF2F6", fg: "#64748B" },
|
[Freight.InvoiceStatus.Cancelled]: { label: "Cancelled", bg: "#EEF2F6", fg: "#64748B" },
|
||||||
[Freight.InvoiceStatus.Refunded]: { label: "Refunded", bg: "#EAF1FB", fg: "#2563EB" },
|
[Freight.InvoiceStatus.Refunded]: { label: "Refunded", bg: "#EAF1FB", fg: "#2563EB" },
|
||||||
|
[Freight.InvoiceStatus.Expired]: { label: "Expired", bg: "#FBEAE7", fg: "#C0392B" },
|
||||||
};
|
};
|
||||||
|
|
||||||
export function InvoiceStatusBadge({ status }: { status: Freight.InvoiceStatus }) {
|
export function InvoiceStatusBadge({ status }: { status: Freight.InvoiceStatus }) {
|
||||||
|
|||||||
@@ -24,19 +24,22 @@ import {
|
|||||||
ConsolidationPairedNotice,
|
ConsolidationPairedNotice,
|
||||||
ConsolidationWaitingBanner,
|
ConsolidationWaitingBanner,
|
||||||
} from "./components/Notices";
|
} from "./components/Notices";
|
||||||
|
import { BookingPaymentPanel } from "./components/BookingPaymentPanel";
|
||||||
import { HeaderButton, PageHeader } from "./components/PageHeader";
|
import { HeaderButton, PageHeader } from "./components/PageHeader";
|
||||||
import { PaymentDeadlineCard } from "./components/PaymentDeadlineCard";
|
|
||||||
import { PaymentMethodModal } from "./components/PaymentMethodModal";
|
import { PaymentMethodModal } from "./components/PaymentMethodModal";
|
||||||
import { PaymentCard } from "./components/pricing";
|
|
||||||
import { ScheduleCard } from "./components/ScheduleCard";
|
import { ScheduleCard } from "./components/ScheduleCard";
|
||||||
|
import { WarehousePaymentsSection } from "./components/WarehousePaymentsSection";
|
||||||
import { ShipmentDetailsCard } from "./components/ShipmentDetailsCard";
|
import { ShipmentDetailsCard } from "./components/ShipmentDetailsCard";
|
||||||
import { ShipmentTrackingCard } from "./components/ShipmentTrackingCard";
|
import { ShipmentTrackingCard } from "./components/ShipmentTrackingCard";
|
||||||
import { StatusHero } from "./components/StatusHero";
|
import { StatusHero } from "./components/StatusHero";
|
||||||
import { SupportCard } from "./components/SupportCard";
|
import { SupportCard } from "./components/SupportCard";
|
||||||
import { fmtDate, isNegative, priceTotal } from "./utils";
|
import { fmtDate, isNegative, priceTotal } from "./utils";
|
||||||
|
import { useScrollToHash } from "@/hooks/useScrollToHash";
|
||||||
|
|
||||||
export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) {
|
export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
// Deep-link support: e.g. /bookings/:id#warehouse-payments from an invoice.
|
||||||
|
useScrollToHash();
|
||||||
const status = booking.status as string;
|
const status = booking.status as string;
|
||||||
const [payModalOpen, setPayModalOpen] = useState(false);
|
const [payModalOpen, setPayModalOpen] = useState(false);
|
||||||
const { view, viewer } = useFileViewer();
|
const { view, viewer } = useFileViewer();
|
||||||
@@ -164,6 +167,8 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
|
|||||||
|
|
||||||
<ShipmentTrackingCard bookingId={booking.id} />
|
<ShipmentTrackingCard bookingId={booking.id} />
|
||||||
|
|
||||||
|
<WarehousePaymentsSection bookingId={booking.id} />
|
||||||
|
|
||||||
{booking.files && booking.files.length > 0 && (
|
{booking.files && booking.files.length > 0 && (
|
||||||
<SectionCard>
|
<SectionCard>
|
||||||
<Group justify="space-between" align="center" mb="md">
|
<Group justify="space-between" align="center" mb="md">
|
||||||
@@ -215,14 +220,13 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
|
|||||||
}
|
}
|
||||||
right={
|
right={
|
||||||
<>
|
<>
|
||||||
{showCountdown && (
|
<BookingPaymentPanel
|
||||||
<PaymentDeadlineCard
|
booking={booking}
|
||||||
paymentDeadline={booking.paymentDeadline!}
|
pricing={pricing}
|
||||||
onPay={() => setPayModalOpen(true)}
|
onPay={() => setPayModalOpen(true)}
|
||||||
paying={payMutation.isPending}
|
paying={payMutation.isPending}
|
||||||
/>
|
showCountdown={showCountdown}
|
||||||
)}
|
/>
|
||||||
<PaymentCard booking={booking} pricing={pricing} />
|
|
||||||
<ScheduleCard
|
<ScheduleCard
|
||||||
booking={booking}
|
booking={booking}
|
||||||
title="Consignment & Schedule"
|
title="Consignment & Schedule"
|
||||||
|
|||||||
@@ -0,0 +1,370 @@
|
|||||||
|
import { ActionIcon, Box, Button, Group, Stack, Text } from "@mantine/core";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
CheckCircle2,
|
||||||
|
CreditCard,
|
||||||
|
Download,
|
||||||
|
FileText,
|
||||||
|
Receipt,
|
||||||
|
Timer,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
|
||||||
|
import type { Freight } from "@edr/types";
|
||||||
|
|
||||||
|
import { invoicesService, type PortalInvoice } from "@/services/invoices.service";
|
||||||
|
import { InvoiceStatusBadge, titleCase } from "@/pages/billing/invoice-ui";
|
||||||
|
import { saveBlob } from "@/utils/download";
|
||||||
|
|
||||||
|
import { fmtDate, priceLineItems, priceTotal, type Pricing } from "../utils";
|
||||||
|
import { CardTitle, SectionCard } from "./layout";
|
||||||
|
|
||||||
|
const Divider = () => <Box my={16} h={1} w="100%" bg="#EEF2F6" />;
|
||||||
|
|
||||||
|
// ── Pay-window countdown ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface Remaining {
|
||||||
|
days: number;
|
||||||
|
hours: number;
|
||||||
|
minutes: number;
|
||||||
|
seconds: number;
|
||||||
|
expired: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRemaining(deadlineMs: number): Remaining {
|
||||||
|
const diff = deadlineMs - Date.now();
|
||||||
|
if (diff <= 0) return { days: 0, hours: 0, minutes: 0, seconds: 0, expired: true };
|
||||||
|
const total = Math.floor(diff / 1000);
|
||||||
|
return {
|
||||||
|
days: Math.floor(total / 86400),
|
||||||
|
hours: Math.floor((total % 86400) / 3600),
|
||||||
|
minutes: Math.floor((total % 3600) / 60),
|
||||||
|
seconds: total % 60,
|
||||||
|
expired: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function Segment({ value, label }: { value: number; label: string }) {
|
||||||
|
return (
|
||||||
|
<Stack gap={2} align="center" style={{ minWidth: 52 }}>
|
||||||
|
<Text fz="26px" fw={800} c="#10202F" lh={1} style={{ fontVariantNumeric: "tabular-nums" }}>
|
||||||
|
{String(value).padStart(2, "0")}
|
||||||
|
</Text>
|
||||||
|
<Text fz="10.5px" fw={700} c="#9AA8B5" tt="uppercase" style={{ letterSpacing: "0.6px" }}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Countdown({
|
||||||
|
deadline,
|
||||||
|
onPay,
|
||||||
|
paying,
|
||||||
|
}: {
|
||||||
|
deadline: string;
|
||||||
|
onPay?: () => void;
|
||||||
|
paying?: boolean;
|
||||||
|
}) {
|
||||||
|
const deadlineMs = new Date(deadline).getTime();
|
||||||
|
const [remaining, setRemaining] = useState<Remaining>(() => getRemaining(deadlineMs));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setRemaining(getRemaining(deadlineMs));
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
const next = getRemaining(deadlineMs);
|
||||||
|
setRemaining(next);
|
||||||
|
if (next.expired) clearInterval(interval);
|
||||||
|
}, 1000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [deadlineMs]);
|
||||||
|
|
||||||
|
if (remaining.expired) {
|
||||||
|
return (
|
||||||
|
<Text fz="13.5px" c="#6B7C8E">
|
||||||
|
The payment window has closed. Move this booking to another schedule or
|
||||||
|
contact support.
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Group justify="space-between" wrap="nowrap" px={4}>
|
||||||
|
<Segment value={remaining.days} label="Days" />
|
||||||
|
<Segment value={remaining.hours} label="Hrs" />
|
||||||
|
<Segment value={remaining.minutes} label="Min" />
|
||||||
|
<Segment value={remaining.seconds} label="Sec" />
|
||||||
|
</Group>
|
||||||
|
<Text mt={12} fz="12px" c="#9AA8B5">
|
||||||
|
Deadline:{" "}
|
||||||
|
{new Date(deadline).toLocaleString(undefined, {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
{onPay && (
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
mt={14}
|
||||||
|
radius={10}
|
||||||
|
color="edr-green"
|
||||||
|
leftSection={<CreditCard size={17} />}
|
||||||
|
onClick={onPay}
|
||||||
|
loading={paying}
|
||||||
|
styles={{ root: { height: 46 }, label: { fontSize: 13.5, fontWeight: 700 } }}
|
||||||
|
>
|
||||||
|
Pay now
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Merged payment panel ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One card covering the whole payment story for a booking: the live pay-window
|
||||||
|
* countdown (when open), the price breakdown, and the invoice(s) — each with a
|
||||||
|
* link to its detail page and a download. Replaces the separate deadline +
|
||||||
|
* breakdown cards.
|
||||||
|
*/
|
||||||
|
export function BookingPaymentPanel({
|
||||||
|
booking,
|
||||||
|
pricing,
|
||||||
|
onPay,
|
||||||
|
paying,
|
||||||
|
showCountdown,
|
||||||
|
}: {
|
||||||
|
booking: Freight.IBooking;
|
||||||
|
pricing: Pricing;
|
||||||
|
onPay?: () => void;
|
||||||
|
paying?: boolean;
|
||||||
|
showCountdown?: boolean;
|
||||||
|
}) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const paid = booking.paymentStatus === "PAID";
|
||||||
|
const isAdjusted =
|
||||||
|
booking.adjustedTotalAmount !== null &&
|
||||||
|
booking.adjustedTotalAmount !== undefined;
|
||||||
|
const currency = pricing?.currency ?? booking.paymentCurrency;
|
||||||
|
const total = isAdjusted
|
||||||
|
? `${Number(booking.adjustedTotalAmount).toLocaleString()} ${currency}`
|
||||||
|
: priceTotal(pricing);
|
||||||
|
const items = priceLineItems(pricing);
|
||||||
|
|
||||||
|
const { data: invoices = [] } = useQuery({
|
||||||
|
queryKey: ["booking-invoices", booking.id],
|
||||||
|
queryFn: () => invoicesService.listForSource("booking", booking.id),
|
||||||
|
});
|
||||||
|
// The invoice worth a prominent "Download" — the first issued one, else any.
|
||||||
|
const primary =
|
||||||
|
invoices.find((inv) => inv.status !== "DRAFT") ?? invoices[0];
|
||||||
|
const primaryPaid = primary ? Number(primary.paidAmount) > 0 : false;
|
||||||
|
|
||||||
|
const downloadInvoice = async (inv: PortalInvoice) => {
|
||||||
|
try {
|
||||||
|
saveBlob(
|
||||||
|
await invoicesService.downloadDocument(inv.id),
|
||||||
|
`invoice-${inv.invoiceNumber}.pdf`,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
toast.error("Invoice PDF isn't ready yet. Contact EDR if this persists.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadReceipt = async (inv: PortalInvoice) => {
|
||||||
|
try {
|
||||||
|
saveBlob(
|
||||||
|
await invoicesService.downloadReceipt(inv.id),
|
||||||
|
`receipt-${inv.invoiceNumber}.pdf`,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
toast.error("Receipt isn't available yet.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionCard p={22}>
|
||||||
|
<Group justify="space-between" align="center">
|
||||||
|
<CardTitle>Payment</CardTitle>
|
||||||
|
<Group
|
||||||
|
component="span"
|
||||||
|
gap={6}
|
||||||
|
align="center"
|
||||||
|
wrap="nowrap"
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
borderRadius: 999,
|
||||||
|
padding: "5px 11px",
|
||||||
|
fontSize: 11.5,
|
||||||
|
fontWeight: 700,
|
||||||
|
backgroundColor: paid ? "#ECF6F1" : showCountdown ? "#FEF6E6" : "#FDF3E0",
|
||||||
|
color: paid ? "#0A6F4D" : showCountdown ? "#B07D14" : "#9A5B00",
|
||||||
|
border: paid ? "1px solid #CDEBDD" : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{paid ? <CheckCircle2 size={13} /> : showCountdown ? <Timer size={13} /> : null}
|
||||||
|
{paid
|
||||||
|
? "Paid"
|
||||||
|
: showCountdown
|
||||||
|
? "Pay window open"
|
||||||
|
: (booking.paymentStatus?.replace(/_/g, " ") ?? "Pending")}
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{showCountdown && booking.paymentDeadline && (
|
||||||
|
<Box mt={16}>
|
||||||
|
<Countdown
|
||||||
|
deadline={booking.paymentDeadline}
|
||||||
|
onPay={onPay}
|
||||||
|
paying={paying}
|
||||||
|
/>
|
||||||
|
<Divider />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box mt={showCountdown ? 0 : 12}>
|
||||||
|
<Text fz="26px" fw={800} c="#10202F">
|
||||||
|
{total}
|
||||||
|
</Text>
|
||||||
|
{isAdjusted && (
|
||||||
|
<Box
|
||||||
|
component="span"
|
||||||
|
mt={6}
|
||||||
|
style={{
|
||||||
|
display: "inline-block",
|
||||||
|
borderRadius: 6,
|
||||||
|
backgroundColor: "#EAF1FB",
|
||||||
|
padding: "3px 8px",
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "#2E5B96",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Adjusted by EDR
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{isAdjusted && booking.adjustmentReason && (
|
||||||
|
<Text mt={6} fz="12.5px" c="#6B7C8E">
|
||||||
|
{booking.adjustmentReason}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{paid && (
|
||||||
|
<Text mt={4} fz="12.5px" c="#9AA8B5">
|
||||||
|
Paid · {fmtDate(booking.updatedAt)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{items.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Divider />
|
||||||
|
<Stack gap={11}>
|
||||||
|
{items.map((it) => (
|
||||||
|
<Group key={it.label} justify="space-between" wrap="nowrap">
|
||||||
|
<Text fz="13px" c="#6B7C8E">
|
||||||
|
{it.label}
|
||||||
|
</Text>
|
||||||
|
<Text fz="13px" fw={600} c="#10202F">
|
||||||
|
{it.value}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
<Group
|
||||||
|
justify="space-between"
|
||||||
|
mt={12}
|
||||||
|
pt={14}
|
||||||
|
style={{ borderTop: "1px solid #EEF2F6" }}
|
||||||
|
>
|
||||||
|
<Text fz="14px" fw={800} c="#10202F">
|
||||||
|
{isAdjusted ? "Adjusted total" : "Total"}
|
||||||
|
</Text>
|
||||||
|
<Text fz="15px" fw={800} c="#10202F">
|
||||||
|
{total}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{invoices.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Divider />
|
||||||
|
<Group justify="space-between" align="center" mb={10}>
|
||||||
|
<CardTitle>Invoices</CardTitle>
|
||||||
|
<Text fz="12px" c="#9AA8B5">
|
||||||
|
{invoices.length}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Stack gap={10}>
|
||||||
|
{invoices.map((inv) => (
|
||||||
|
<Group key={inv.id} justify="space-between" wrap="nowrap">
|
||||||
|
<Box style={{ minWidth: 0 }}>
|
||||||
|
<Text
|
||||||
|
fz="13px"
|
||||||
|
fw={700}
|
||||||
|
c="#10202F"
|
||||||
|
style={{ cursor: "pointer" }}
|
||||||
|
onClick={() => navigate(`/billing/${inv.id}`)}
|
||||||
|
>
|
||||||
|
{inv.invoiceNumber}
|
||||||
|
</Text>
|
||||||
|
<Text fz="12px" c="#9AA8B5">
|
||||||
|
{titleCase(inv.type)}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Group gap={8} wrap="nowrap">
|
||||||
|
<InvoiceStatusBadge status={inv.status} />
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
aria-label="Download invoice"
|
||||||
|
onClick={() => downloadInvoice(inv)}
|
||||||
|
>
|
||||||
|
<Download size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{primary && (
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
mt={16}
|
||||||
|
variant="default"
|
||||||
|
radius={10}
|
||||||
|
leftSection={<FileText size={17} color="#475569" />}
|
||||||
|
onClick={() => downloadInvoice(primary)}
|
||||||
|
styles={{
|
||||||
|
root: { height: 46 },
|
||||||
|
label: { fontSize: 13.5, fontWeight: 700, color: "#10202F" },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Download invoice
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{primary && primaryPaid && (
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
mt={8}
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
radius={10}
|
||||||
|
leftSection={<Receipt size={17} />}
|
||||||
|
onClick={() => downloadReceipt(primary)}
|
||||||
|
styles={{ root: { height: 42 }, label: { fontSize: 13, fontWeight: 700 } }}
|
||||||
|
>
|
||||||
|
Download receipt
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</SectionCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,151 +0,0 @@
|
|||||||
import { Box, Button, Group, Stack, Text } from "@mantine/core";
|
|
||||||
import { CreditCard, Timer } from "lucide-react";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
|
|
||||||
import { CardTitle, SectionCard } from "./layout";
|
|
||||||
|
|
||||||
interface Remaining {
|
|
||||||
days: number;
|
|
||||||
hours: number;
|
|
||||||
minutes: number;
|
|
||||||
seconds: number;
|
|
||||||
expired: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getRemaining(deadlineMs: number): Remaining {
|
|
||||||
const diff = deadlineMs - Date.now();
|
|
||||||
if (diff <= 0) {
|
|
||||||
return { days: 0, hours: 0, minutes: 0, seconds: 0, expired: true };
|
|
||||||
}
|
|
||||||
const totalSeconds = Math.floor(diff / 1000);
|
|
||||||
return {
|
|
||||||
days: Math.floor(totalSeconds / 86400),
|
|
||||||
hours: Math.floor((totalSeconds % 86400) / 3600),
|
|
||||||
minutes: Math.floor((totalSeconds % 3600) / 60),
|
|
||||||
seconds: totalSeconds % 60,
|
|
||||||
expired: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function Segment({ value, label }: { value: number; label: string }) {
|
|
||||||
return (
|
|
||||||
<Stack gap={2} align="center" style={{ minWidth: 52 }}>
|
|
||||||
<Text
|
|
||||||
fz="28px"
|
|
||||||
fw={800}
|
|
||||||
c="#10202F"
|
|
||||||
lh={1}
|
|
||||||
style={{ fontVariantNumeric: "tabular-nums" }}
|
|
||||||
>
|
|
||||||
{String(value).padStart(2, "0")}
|
|
||||||
</Text>
|
|
||||||
<Text fz="10.5px" fw={700} c="#9AA8B5" tt="uppercase" className="tracking-[0.6px]">
|
|
||||||
{label}
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PaymentDeadlineCard({
|
|
||||||
paymentDeadline,
|
|
||||||
onPay,
|
|
||||||
paying,
|
|
||||||
}: {
|
|
||||||
/** ISO timestamp marking the end of the pay window. */
|
|
||||||
paymentDeadline: string;
|
|
||||||
onPay?: () => void;
|
|
||||||
paying?: boolean;
|
|
||||||
}) {
|
|
||||||
const deadlineMs = new Date(paymentDeadline).getTime();
|
|
||||||
const [remaining, setRemaining] = useState<Remaining>(() => getRemaining(deadlineMs));
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setRemaining(getRemaining(deadlineMs));
|
|
||||||
const interval = setInterval(() => {
|
|
||||||
const next = getRemaining(deadlineMs);
|
|
||||||
setRemaining(next);
|
|
||||||
if (next.expired) clearInterval(interval);
|
|
||||||
}, 1000);
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, [deadlineMs]);
|
|
||||||
|
|
||||||
const accentBg = remaining.expired ? "#FBEAE7" : "#FEF6E6";
|
|
||||||
const accentFg = remaining.expired ? "#C0392B" : "#B07D14";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SectionCard
|
|
||||||
p={22}
|
|
||||||
style={
|
|
||||||
remaining.expired
|
|
||||||
? undefined
|
|
||||||
: { borderColor: "#F2E4C4", boxShadow: "0 0 0 1px #FBEAC2" }
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Group justify="space-between" align="center">
|
|
||||||
<CardTitle>Payment deadline</CardTitle>
|
|
||||||
<Group
|
|
||||||
component="span"
|
|
||||||
gap={6}
|
|
||||||
align="center"
|
|
||||||
wrap="nowrap"
|
|
||||||
style={{
|
|
||||||
display: "inline-flex",
|
|
||||||
borderRadius: 999,
|
|
||||||
padding: "5px 11px",
|
|
||||||
fontSize: 11.5,
|
|
||||||
fontWeight: 700,
|
|
||||||
backgroundColor: accentBg,
|
|
||||||
color: accentFg,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Timer size={13} />
|
|
||||||
{remaining.expired ? "Expired" : "Pay window open"}
|
|
||||||
</Group>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
{remaining.expired ? (
|
|
||||||
<Text mt={14} fz="13.5px" c="#6B7C8E">
|
|
||||||
The payment window has closed. Move this booking to another schedule or
|
|
||||||
contact support.
|
|
||||||
</Text>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Group justify="space-between" mt={16} wrap="nowrap" px={4}>
|
|
||||||
<Segment value={remaining.days} label="Days" />
|
|
||||||
<Segment value={remaining.hours} label="Hrs" />
|
|
||||||
<Segment value={remaining.minutes} label="Min" />
|
|
||||||
<Segment value={remaining.seconds} label="Sec" />
|
|
||||||
</Group>
|
|
||||||
<Text mt={14} fz="12.5px" c="#9AA8B5" ta="center">
|
|
||||||
Complete payment before the window closes to secure your slot.
|
|
||||||
</Text>
|
|
||||||
{onPay && (
|
|
||||||
<Button
|
|
||||||
fullWidth
|
|
||||||
mt={16}
|
|
||||||
radius={10}
|
|
||||||
color="edr-green"
|
|
||||||
leftSection={<CreditCard size={17} />}
|
|
||||||
onClick={onPay}
|
|
||||||
loading={paying}
|
|
||||||
styles={{ root: { height: 46 }, label: { fontSize: 13.5, fontWeight: 700 } }}
|
|
||||||
>
|
|
||||||
Pay now
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Box mt={16} h={1} w="100%" bg="#EEF2F6" />
|
|
||||||
<Text mt={12} fz="12px" c="#9AA8B5">
|
|
||||||
Deadline:{" "}
|
|
||||||
{new Date(paymentDeadline).toLocaleString(undefined, {
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
})}
|
|
||||||
</Text>
|
|
||||||
</SectionCard>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import { ActionIcon, Box, Group, Stack, Text } from "@mantine/core";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Download, Receipt } from "lucide-react";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
|
||||||
|
import {
|
||||||
|
warehouseInvoicesService,
|
||||||
|
type PortalWarehouseInvoice,
|
||||||
|
} from "@/services/warehouse-invoices.service";
|
||||||
|
import { saveBlob } from "@/utils/download";
|
||||||
|
|
||||||
|
import { CardTitle, SectionCard } from "./layout";
|
||||||
|
|
||||||
|
const money = (amount: number | string | null | undefined, currency: string) =>
|
||||||
|
`${Number(amount ?? 0).toLocaleString()} ${currency}`;
|
||||||
|
|
||||||
|
const STATUS_STYLE: Record<string, { bg: string; fg: string }> = {
|
||||||
|
DRAFT: { bg: "#EEF2F6", fg: "#64748B" },
|
||||||
|
ISSUED: { bg: "#FEF3E2", fg: "#B45309" },
|
||||||
|
PARTIALLY_PAID: { bg: "#FEF9E7", fg: "#A16207" },
|
||||||
|
PAID: { bg: "#E6F7EF", fg: "#0A6F4D" },
|
||||||
|
CANCELLED: { bg: "#EEF2F6", fg: "#64748B" },
|
||||||
|
};
|
||||||
|
|
||||||
|
function StatusPill({ status }: { status: string }) {
|
||||||
|
const s = STATUS_STYLE[status] ?? { bg: "#EEF2F6", fg: "#64748B" };
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
padding: "3px 9px",
|
||||||
|
borderRadius: 999,
|
||||||
|
background: s.bg,
|
||||||
|
color: s.fg,
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 700,
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{status.replace(/_/g, " ")}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Warehouse fee invoices linked to this booking — display + PDF download only.
|
||||||
|
* Paying them online is tracked separately (in-system demurrage/storage
|
||||||
|
* payment). Renders nothing when the booking has no warehouse fees. Carries
|
||||||
|
* `id="warehouse-payments"` so the invoice detail page can deep-link here.
|
||||||
|
*/
|
||||||
|
export function WarehousePaymentsSection({ bookingId }: { bookingId: string }) {
|
||||||
|
const { data: invoices = [] } = useQuery({
|
||||||
|
queryKey: ["booking-warehouse-invoices", bookingId],
|
||||||
|
queryFn: () => warehouseInvoicesService.listForBooking(bookingId),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (invoices.length === 0) return null;
|
||||||
|
|
||||||
|
const download = async (inv: PortalWarehouseInvoice) => {
|
||||||
|
try {
|
||||||
|
saveBlob(
|
||||||
|
await warehouseInvoicesService.downloadDocument(inv.id),
|
||||||
|
`warehouse-invoice-${inv.invoiceNumber}.pdf`,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
toast.error("Warehouse invoice PDF isn't ready yet.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadReceipt = async (inv: PortalWarehouseInvoice) => {
|
||||||
|
try {
|
||||||
|
saveBlob(
|
||||||
|
await warehouseInvoicesService.downloadReceipt(inv.id),
|
||||||
|
`warehouse-receipt-${inv.invoiceNumber}.pdf`,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
toast.error("Receipt isn't available yet.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionCard id="warehouse-payments">
|
||||||
|
<Group justify="space-between" align="center" mb="md">
|
||||||
|
<CardTitle>Warehouse payments</CardTitle>
|
||||||
|
<Text fz="12.5px" fw={600} c="#9AA8B5">
|
||||||
|
{invoices.length} {invoices.length === 1 ? "invoice" : "invoices"}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Stack gap={12}>
|
||||||
|
{invoices.map((inv) => {
|
||||||
|
const detail = [
|
||||||
|
inv.invoiceType?.replace(/_/g, " "),
|
||||||
|
inv.cargoDescription ??
|
||||||
|
inv.containerNumber ??
|
||||||
|
inv.inventoryReference ??
|
||||||
|
undefined,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" · ");
|
||||||
|
return (
|
||||||
|
<Group
|
||||||
|
key={inv.id}
|
||||||
|
justify="space-between"
|
||||||
|
align="flex-start"
|
||||||
|
wrap="nowrap"
|
||||||
|
style={{
|
||||||
|
border: "1px solid #EEF2F6",
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: "12px 14px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box style={{ minWidth: 0 }}>
|
||||||
|
<Group gap={8} wrap="nowrap">
|
||||||
|
<Text fz="13.5px" fw={700} c="#10202F">
|
||||||
|
{inv.invoiceNumber}
|
||||||
|
</Text>
|
||||||
|
<StatusPill status={inv.status} />
|
||||||
|
</Group>
|
||||||
|
{detail && (
|
||||||
|
<Text fz="12px" c="#9AA8B5" mt={2}>
|
||||||
|
{detail}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Text fz="12.5px" c="#6B7C8E" mt={4}>
|
||||||
|
Total {money(inv.totalAmount, inv.currency)} · Balance{" "}
|
||||||
|
{money(inv.balanceAmount, inv.currency)}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Group gap={6} wrap="nowrap">
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
aria-label="Download invoice"
|
||||||
|
onClick={() => download(inv)}
|
||||||
|
>
|
||||||
|
<Download size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
{Number(inv.paidAmount) > 0 && (
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
aria-label="Download receipt"
|
||||||
|
onClick={() => downloadReceipt(inv)}
|
||||||
|
>
|
||||||
|
<Receipt size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
</SectionCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,7 @@
|
|||||||
import { Box, Group, Stack, Text } from "@mantine/core";
|
import { Box, Group, Stack, Text } from "@mantine/core";
|
||||||
import { CheckCircle2, Clock } from "lucide-react";
|
import { Clock } from "lucide-react";
|
||||||
|
|
||||||
import type { Freight } from "@edr/types";
|
import { priceLineItems, priceTotal, type Pricing } from "../utils";
|
||||||
|
|
||||||
import { fmtDate, priceLineItems, priceTotal, type Pricing } from "../utils";
|
|
||||||
import { CardTitle, SectionCard } from "./layout";
|
import { CardTitle, SectionCard } from "./layout";
|
||||||
|
|
||||||
function LineItems({ pricing }: { pricing: Pricing }) {
|
function LineItems({ pricing }: { pricing: Pricing }) {
|
||||||
@@ -107,113 +105,6 @@ export function EstimateCard({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PaymentCard({
|
// The booking payment card (countdown + breakdown + invoices + download) now
|
||||||
booking,
|
// lives in ./BookingPaymentPanel. EstimateCard above stays for the draft and
|
||||||
pricing,
|
// changes-requested views, which only show an estimate.
|
||||||
}: {
|
|
||||||
booking: Freight.IBooking;
|
|
||||||
pricing: Pricing;
|
|
||||||
}) {
|
|
||||||
const paid = booking.paymentStatus === "PAID";
|
|
||||||
// Customer sees the grand total plus the price breakdown that makes it up.
|
|
||||||
// A staff adjustment, when present, overrides the computed total and is
|
|
||||||
// flagged with an "Adjusted by EDR" badge.
|
|
||||||
const isAdjusted =
|
|
||||||
booking.adjustedTotalAmount !== null &&
|
|
||||||
booking.adjustedTotalAmount !== undefined;
|
|
||||||
const currency = pricing?.currency ?? booking.paymentCurrency;
|
|
||||||
const total = isAdjusted
|
|
||||||
? `${Number(booking.adjustedTotalAmount).toLocaleString()} ${currency}`
|
|
||||||
: priceTotal(pricing);
|
|
||||||
const hasItems = priceLineItems(pricing).length > 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SectionCard p={22}>
|
|
||||||
<Group justify="space-between" align="center">
|
|
||||||
<CardTitle>Payment</CardTitle>
|
|
||||||
<Group
|
|
||||||
component="span"
|
|
||||||
gap={6}
|
|
||||||
align="center"
|
|
||||||
wrap="nowrap"
|
|
||||||
style={{
|
|
||||||
display: "inline-flex",
|
|
||||||
borderRadius: 999,
|
|
||||||
padding: "5px 11px",
|
|
||||||
fontSize: 11.5,
|
|
||||||
fontWeight: 700,
|
|
||||||
backgroundColor: paid ? "#ECF6F1" : "#FDF3E0",
|
|
||||||
color: paid ? "#0A6F4D" : "#9A5B00",
|
|
||||||
border: paid ? "1px solid #CDEBDD" : undefined,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{paid && <CheckCircle2 size={13} />}
|
|
||||||
{paid
|
|
||||||
? "Paid"
|
|
||||||
: (booking.paymentStatus?.replace(/_/g, " ") ?? "Pending")}
|
|
||||||
</Group>
|
|
||||||
</Group>
|
|
||||||
<Box mt={12}>
|
|
||||||
<Text fz="26px" fw={800} c="#10202F">
|
|
||||||
{total}
|
|
||||||
</Text>
|
|
||||||
{isAdjusted && (
|
|
||||||
<Box
|
|
||||||
component="span"
|
|
||||||
mt={6}
|
|
||||||
style={{
|
|
||||||
display: "inline-block",
|
|
||||||
borderRadius: 6,
|
|
||||||
backgroundColor: "#EAF1FB",
|
|
||||||
padding: "3px 8px",
|
|
||||||
fontSize: 11,
|
|
||||||
fontWeight: 700,
|
|
||||||
color: "#2E5B96",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Adjusted by EDR
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
{isAdjusted && booking.adjustmentReason && (
|
|
||||||
<Text mt={6} fz="12.5px" c="#6B7C8E">
|
|
||||||
{booking.adjustmentReason}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
{paid && (
|
|
||||||
<Text mt={4} fz="12.5px" c="#9AA8B5">
|
|
||||||
Paid · {fmtDate(booking.updatedAt)}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
{hasItems && (
|
|
||||||
<>
|
|
||||||
<Divider />
|
|
||||||
<LineItems pricing={pricing} />
|
|
||||||
<Group
|
|
||||||
justify="space-between"
|
|
||||||
mt={12}
|
|
||||||
pt={14}
|
|
||||||
style={{ borderTop: "1px solid #EEF2F6" }}
|
|
||||||
>
|
|
||||||
<Text fz="14px" fw={800} c="#10202F">
|
|
||||||
{isAdjusted ? "Adjusted total" : "Total"}
|
|
||||||
</Text>
|
|
||||||
<Text fz="15px" fw={800} c="#10202F">
|
|
||||||
{total}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{/* <Button */}
|
|
||||||
{/* fullWidth */}
|
|
||||||
{/* mt={16} */}
|
|
||||||
{/* variant="default" */}
|
|
||||||
{/* radius={10} */}
|
|
||||||
{/* leftSection={<FileText size={17} color="#475569" />} */}
|
|
||||||
{/* styles={{ root: { height: 46 }, label: { fontSize: 13.5, fontWeight: 700, color: "#10202F" } }} */}
|
|
||||||
{/* > */}
|
|
||||||
{/* Download invoice */}
|
|
||||||
{/* </Button> */}
|
|
||||||
</SectionCard>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -29,12 +29,39 @@ export const invoicesService = {
|
|||||||
return data.data ?? data;
|
return data.data ?? data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** The customer's invoices for one source record (e.g. a booking). */
|
||||||
|
listForSource: async (
|
||||||
|
source: string,
|
||||||
|
sourceId: string,
|
||||||
|
): Promise<PortalInvoice[]> => {
|
||||||
|
const { data } = await client.get(B.MY_INVOICES, {
|
||||||
|
params: { source, sourceId },
|
||||||
|
});
|
||||||
|
return data.data ?? data;
|
||||||
|
},
|
||||||
|
|
||||||
/** One of the customer's invoices, with its line items. */
|
/** One of the customer's invoices, with its line items. */
|
||||||
get: async (id: string): Promise<PortalInvoiceDetail> => {
|
get: async (id: string): Promise<PortalInvoiceDetail> => {
|
||||||
const { data } = await client.get(B.MY_INVOICE_BY_ID(id));
|
const { data } = await client.get(B.MY_INVOICE_BY_ID(id));
|
||||||
return data.data ?? data;
|
return data.data ?? data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** The sealed invoice PDF for one of the customer's invoices. */
|
||||||
|
downloadDocument: async (id: string): Promise<Blob> => {
|
||||||
|
const { data } = await client.get(B.MY_INVOICE_DOCUMENT(id), {
|
||||||
|
responseType: "blob",
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** The sealed payment-receipt PDF (available once paid). */
|
||||||
|
downloadReceipt: async (id: string): Promise<Blob> => {
|
||||||
|
const { data } = await client.get(B.MY_INVOICE_RECEIPT(id), {
|
||||||
|
responseType: "blob",
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
/** Initiate gateway payment for an open invoice; returns the client action. */
|
/** Initiate gateway payment for an open invoice; returns the client action. */
|
||||||
pay: async (
|
pay: async (
|
||||||
id: string,
|
id: string,
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||||
|
import { client } from "../utils/api";
|
||||||
|
|
||||||
|
const W = URL_CONSTANTS.WAREHOUSE_INVOICES;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A warehouse fee invoice as the freight API projects it for the customer
|
||||||
|
* (the historical `WarehouseFeeInvoice` view shape — a subset is used here).
|
||||||
|
*/
|
||||||
|
export interface PortalWarehouseInvoice {
|
||||||
|
id: string;
|
||||||
|
invoiceNumber: string;
|
||||||
|
invoiceType: string;
|
||||||
|
status: string;
|
||||||
|
currency: string;
|
||||||
|
totalAmount: number | string;
|
||||||
|
paidAmount: number | string;
|
||||||
|
balanceAmount: number | string;
|
||||||
|
issuedAt?: string | null;
|
||||||
|
dueDate?: string | null;
|
||||||
|
paidAt?: string | null;
|
||||||
|
bookingId?: string | null;
|
||||||
|
inventoryId?: string | null;
|
||||||
|
bookingReference?: string | null;
|
||||||
|
inventoryReference?: string | null;
|
||||||
|
cargoDescription?: string | null;
|
||||||
|
containerNumber?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const warehouseInvoicesService = {
|
||||||
|
/** Warehouse fee invoices linked to a booking (via its inventory items). */
|
||||||
|
listForBooking: async (bookingId: string): Promise<PortalWarehouseInvoice[]> => {
|
||||||
|
const { data } = await client.get(W.FOR_BOOKING(bookingId));
|
||||||
|
return data.data ?? data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** A single warehouse fee invoice (carries `bookingId` for source linking). */
|
||||||
|
get: async (id: string): Promise<PortalWarehouseInvoice> => {
|
||||||
|
const { data } = await client.get(W.BY_ID(id));
|
||||||
|
return data.data ?? data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** The sealed warehouse fee invoice PDF. */
|
||||||
|
downloadDocument: async (id: string): Promise<Blob> => {
|
||||||
|
const { data } = await client.get(W.DOCUMENT(id), { responseType: "blob" });
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** The sealed warehouse fee payment receipt PDF (available once paid). */
|
||||||
|
downloadReceipt: async (id: string): Promise<Blob> => {
|
||||||
|
const { data } = await client.get(W.RECEIPT(id), { responseType: "blob" });
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
};
|
||||||
11
apps/edr-freight-web/portal/src/utils/download.ts
Normal file
11
apps/edr-freight-web/portal/src/utils/download.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
/** Trigger a browser download of a Blob under `filename`. */
|
||||||
|
export function saveBlob(blob: Blob, filename: string): void {
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user