mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +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) =>
|
||||
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) => {
|
||||
if (!value) return '-';
|
||||
const date = new Date(value as string | Date);
|
||||
@@ -96,12 +93,6 @@ const textOp = (
|
||||
color = '0 0 0',
|
||||
) => `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 = () => [
|
||||
lineOp(60, 218, 535, 218),
|
||||
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' });
|
||||
}
|
||||
|
||||
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>) => {
|
||||
for (const value of values) {
|
||||
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,
|
||||
useBookingMutations,
|
||||
} from "@/hooks/bookings/useBookings";
|
||||
import { useScrollToHash } from "@/hooks/useScrollToHash";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
// 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 navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
// Deep-link from a warehouse fee invoice → this booking's warehouse section.
|
||||
useScrollToHash();
|
||||
const {
|
||||
data: booking,
|
||||
isLoading,
|
||||
@@ -281,10 +284,12 @@ export default function BookingRequestDetailPage() {
|
||||
<Stack gap="lg">
|
||||
<BookingCompanyCard booking={booking} />
|
||||
<BookingPricingSummary booking={booking} />
|
||||
<WarehouseInfoCard
|
||||
bookingId={booking.id}
|
||||
bookingReference={booking.reference}
|
||||
/>
|
||||
<Box id="warehouse-payments">
|
||||
<WarehouseInfoCard
|
||||
bookingId={booking.id}
|
||||
bookingReference={booking.reference}
|
||||
/>
|
||||
</Box>
|
||||
<BookingActionsToolbar
|
||||
booking={booking}
|
||||
mutations={mutations}
|
||||
|
||||
@@ -15,7 +15,8 @@ import {
|
||||
Text,
|
||||
TextInput,
|
||||
} 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 { PageContainer, PageHeader } from '@/components/page';
|
||||
@@ -31,7 +32,7 @@ import {
|
||||
type WarehouseInvoiceStatus,
|
||||
} from '@/types/warehouse';
|
||||
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';
|
||||
|
||||
const STATUS_COLOR: Record<WarehouseInvoiceStatus, string> = {
|
||||
@@ -155,6 +156,7 @@ export default function WarehouseInvoicesPage() {
|
||||
|
||||
function InvoiceDetailModal({ id, onClose }: { id: string | null; onClose: () => void }) {
|
||||
const { toast } = useToast();
|
||||
const navigate = useNavigate();
|
||||
const { data: inv, isLoading } = useQuery(
|
||||
api.warehouses.invoice.queryOptions({
|
||||
input: { id: id ?? '' },
|
||||
@@ -172,13 +174,33 @@ function InvoiceDetailModal({ id, onClose }: { id: string | null; onClose: () =>
|
||||
const canGateClear = inv?.status === 'PAID' && Boolean(inv.inventoryId);
|
||||
|
||||
const downloadInvoicePdf = async (invoice: WarehouseFeeInvoice) => {
|
||||
const blob = buildWarehouseInvoicePdf(invoice, 'INVOICE');
|
||||
openPdfBlob(blob, `warehouse-invoice-${invoice.invoiceNumber}.pdf`);
|
||||
const pdfWindow = window.open('', '_blank');
|
||||
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 blob = buildWarehouseInvoicePdf(invoice, 'RECEIPT');
|
||||
openPdfBlob(blob, `warehouse-receipt-${invoice.invoiceNumber}.pdf`);
|
||||
const pdfWindow = window.open('', '_blank');
|
||||
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) => {
|
||||
@@ -366,6 +388,20 @@ function InvoiceDetailModal({ id, onClose }: { id: string | null; onClose: () =>
|
||||
)}
|
||||
|
||||
<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
|
||||
variant="light"
|
||||
color="gray"
|
||||
|
||||
@@ -147,6 +147,16 @@ export const URL_CONSTANTS = {
|
||||
BILLING: {
|
||||
MY_INVOICES: "/api/billing/my-invoices",
|
||||
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`,
|
||||
},
|
||||
|
||||
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,
|
||||
Title,
|
||||
} 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 { invoicesService } from "@/services/invoices.service";
|
||||
import { warehouseInvoicesService } from "@/services/warehouse-invoices.service";
|
||||
import { saveBlob } from "@/utils/download";
|
||||
import { formatCurrency } from "@/lib/currency";
|
||||
import { BORDER, INK, MUTED } from "../contracts/contract-ui";
|
||||
import {
|
||||
@@ -95,6 +99,54 @@ export default function InvoiceDetailPage() {
|
||||
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 (
|
||||
<Box style={{ padding: "28px 32px 32px" }}>
|
||||
<Stack gap="lg">
|
||||
@@ -117,19 +169,56 @@ export default function InvoiceDetailPage() {
|
||||
</Title>
|
||||
<InvoiceStatusBadge status={invoice.status} />
|
||||
</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
|
||||
color="edr-green"
|
||||
variant="default"
|
||||
radius="md"
|
||||
size="md"
|
||||
leftSection={<CreditCard size={16} />}
|
||||
loading={payMutation.isPending}
|
||||
onClick={handlePay}
|
||||
styles={{ root: { fontWeight: 600, height: 42, paddingInline: 18 } }}
|
||||
leftSection={<Download size={16} />}
|
||||
onClick={downloadInvoice}
|
||||
styles={{ root: { fontWeight: 600, height: 42, paddingInline: 16 } }}
|
||||
>
|
||||
Pay {formatCurrency(Number(invoice.totalAmount), invoice.currency)}
|
||||
Download invoice
|
||||
</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>
|
||||
|
||||
{payMutation.isError && (
|
||||
|
||||
@@ -22,6 +22,7 @@ const STATUS_STYLE: Record<
|
||||
[Freight.InvoiceStatus.Overdue]: { label: "Overdue", bg: "#FDECEC", fg: "#C0392B" },
|
||||
[Freight.InvoiceStatus.Cancelled]: { label: "Cancelled", bg: "#EEF2F6", fg: "#64748B" },
|
||||
[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 }) {
|
||||
|
||||
@@ -24,19 +24,22 @@ import {
|
||||
ConsolidationPairedNotice,
|
||||
ConsolidationWaitingBanner,
|
||||
} from "./components/Notices";
|
||||
import { BookingPaymentPanel } from "./components/BookingPaymentPanel";
|
||||
import { HeaderButton, PageHeader } from "./components/PageHeader";
|
||||
import { PaymentDeadlineCard } from "./components/PaymentDeadlineCard";
|
||||
import { PaymentMethodModal } from "./components/PaymentMethodModal";
|
||||
import { PaymentCard } from "./components/pricing";
|
||||
import { ScheduleCard } from "./components/ScheduleCard";
|
||||
import { WarehousePaymentsSection } from "./components/WarehousePaymentsSection";
|
||||
import { ShipmentDetailsCard } from "./components/ShipmentDetailsCard";
|
||||
import { ShipmentTrackingCard } from "./components/ShipmentTrackingCard";
|
||||
import { StatusHero } from "./components/StatusHero";
|
||||
import { SupportCard } from "./components/SupportCard";
|
||||
import { fmtDate, isNegative, priceTotal } from "./utils";
|
||||
import { useScrollToHash } from "@/hooks/useScrollToHash";
|
||||
|
||||
export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) {
|
||||
const navigate = useNavigate();
|
||||
// Deep-link support: e.g. /bookings/:id#warehouse-payments from an invoice.
|
||||
useScrollToHash();
|
||||
const status = booking.status as string;
|
||||
const [payModalOpen, setPayModalOpen] = useState(false);
|
||||
const { view, viewer } = useFileViewer();
|
||||
@@ -164,6 +167,8 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
|
||||
|
||||
<ShipmentTrackingCard bookingId={booking.id} />
|
||||
|
||||
<WarehousePaymentsSection bookingId={booking.id} />
|
||||
|
||||
{booking.files && booking.files.length > 0 && (
|
||||
<SectionCard>
|
||||
<Group justify="space-between" align="center" mb="md">
|
||||
@@ -215,14 +220,13 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
|
||||
}
|
||||
right={
|
||||
<>
|
||||
{showCountdown && (
|
||||
<PaymentDeadlineCard
|
||||
paymentDeadline={booking.paymentDeadline!}
|
||||
onPay={() => setPayModalOpen(true)}
|
||||
paying={payMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
<PaymentCard booking={booking} pricing={pricing} />
|
||||
<BookingPaymentPanel
|
||||
booking={booking}
|
||||
pricing={pricing}
|
||||
onPay={() => setPayModalOpen(true)}
|
||||
paying={payMutation.isPending}
|
||||
showCountdown={showCountdown}
|
||||
/>
|
||||
<ScheduleCard
|
||||
booking={booking}
|
||||
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 { CheckCircle2, Clock } from "lucide-react";
|
||||
import { Clock } from "lucide-react";
|
||||
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { fmtDate, priceLineItems, priceTotal, type Pricing } from "../utils";
|
||||
import { priceLineItems, priceTotal, type Pricing } from "../utils";
|
||||
import { CardTitle, SectionCard } from "./layout";
|
||||
|
||||
function LineItems({ pricing }: { pricing: Pricing }) {
|
||||
@@ -107,113 +105,6 @@ export function EstimateCard({
|
||||
);
|
||||
}
|
||||
|
||||
export function PaymentCard({
|
||||
booking,
|
||||
pricing,
|
||||
}: {
|
||||
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>
|
||||
);
|
||||
}
|
||||
// The booking payment card (countdown + breakdown + invoices + download) now
|
||||
// lives in ./BookingPaymentPanel. EstimateCard above stays for the draft and
|
||||
// changes-requested views, which only show an estimate.
|
||||
|
||||
@@ -29,12 +29,39 @@ export const invoicesService = {
|
||||
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. */
|
||||
get: async (id: string): Promise<PortalInvoiceDetail> => {
|
||||
const { data } = await client.get(B.MY_INVOICE_BY_ID(id));
|
||||
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. */
|
||||
pay: async (
|
||||
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