mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 12:18:11 +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"
|
||||
|
||||
Reference in New Issue
Block a user