feat: ui changes and pdf setup on the clients

This commit is contained in:
Nathnael
2026-07-01 06:56:58 +00:00
parent 94982d3c5a
commit e5aab84f51
16 changed files with 857 additions and 354 deletions

View File

@@ -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);

View 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]);
}

View File

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

View File

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

View File

@@ -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`,
},
};

View 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]);
}

View File

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

View File

@@ -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 }) {

View File

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

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

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

View File

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

View File

@@ -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;
},
};

View 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);
}