feat(invoices): show customer name/phone, align meta column, totals left, JPEG

- Invoice PDF now shows the customer's full name and a phone row (sourced from
  the auth store for the user page, invoice.user for admin) instead of just email.
- Left-align all meta values into a single clean column (unicode-bidi:plaintext
  so Persian names stay RTL while phone/dates read LTR).
- Move the totals box to the left side of the page.
- Rasterize as JPEG (q0.9) instead of PNG to keep the file small (~130KB).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
keyhan
2026-06-20 00:41:17 +03:30
parent bc224a2291
commit 7438b44120
4 changed files with 41 additions and 16 deletions
+29 -15
View File
@@ -21,9 +21,14 @@ const STATUS_KIND: Record<InvoiceStatus, InvoicePdfData['statusKind']> = {
/** Assemble the PDF view-model from an invoice + dictionary, translating to Persian when locale is fa. */
export function buildInvoicePdfData(
invoice: Invoice,
opts: { inv: InvoiceDict; appName: string; locale: string },
opts: {
inv: InvoiceDict;
appName: string;
locale: string;
customer?: { firstName?: string | null; lastName?: string | null; phone?: string | null; email?: string | null };
},
): InvoicePdfData {
const { inv, appName, locale } = opts;
const { inv, appName, locale, customer } = opts;
const isFa = locale.startsWith('fa');
const pdf = inv.pdf;
@@ -34,12 +39,19 @@ export function buildInvoicePdfData(
const trDesc = (desc?: string) => (isFa ? translateInvoiceDescription(desc) : desc ?? '');
const trReason = (reason?: string) => (isFa ? translateInvoiceReason(reason) : reason ?? '-');
const cust = customer ?? invoice.user ?? undefined;
const fullName = [cust?.firstName, cust?.lastName].filter(Boolean).join(' ').trim();
const phone = cust?.phone || undefined;
const meta: InvoicePdfMeta[] = [
{ label: pdf.customer, value: invoice.user?.email || invoice.userId },
{ label: pdf.customer, value: fullName || cust?.email || invoice.userId },
];
if (phone) meta.push({ label: pdf.phone, value: phone });
meta.push(
{ label: pdf.application, value: invoice.application?.name || trReason(invoice.reason) },
{ label: pdf.createdAt, value: formatDate(invoice.createdAt) },
{ label: inv.method, value: invoice.paymentMethod || '-' },
];
);
if (invoice.paidAt) meta.push({ label: pdf.paidAt, value: formatDate(invoice.paidAt) });
if (invoice.gatewayTrackingCode) meta.push({ label: inv.tracking, value: invoice.gatewayTrackingCode });
@@ -127,9 +139,9 @@ export function buildInvoiceHtml(data: InvoicePdfData): string {
const metaRows = data.meta
.map(
(m) => `
<div style="display:flex;justify-content:space-between;gap:12px;padding:10px 0;border-bottom:1px solid #f1f5f9;">
<span style="color:#64748b;font-size:13px;">${esc(m.label)}</span>
<span style="color:#0f172a;font-size:13px;font-weight:600;">${esc(m.value)}</span>
<div style="display:flex;align-items:baseline;gap:12px;padding:10px 0;border-bottom:1px solid #f1f5f9;">
<span style="color:#64748b;font-size:13px;white-space:nowrap;">${esc(m.label)}</span>
<span style="flex:1;text-align:left;color:#0f172a;font-size:13px;font-weight:600;direction:ltr;unicode-bidi:plaintext;">${esc(m.value)}</span>
</div>`,
)
.join('');
@@ -206,8 +218,8 @@ export function buildInvoiceHtml(data: InvoicePdfData): string {
</table>
</div>
<!-- totals -->
<div style="margin-top:24px;display:flex;justify-content:flex-start;">
<!-- totals (left side) -->
<div style="margin-top:24px;display:flex;justify-content:flex-end;">
<div style="width:300px;background:#f8fafc;border:1px solid #f1f5f9;border-radius:16px;padding:8px 20px;">
${totalRows}
</div>
@@ -265,16 +277,17 @@ async function getEmbeddedFontCss(): Promise<string> {
}
/**
* Rasterize the invoice template to a PNG via html-to-image.
* Rasterize the invoice template to a JPEG via html-to-image.
*
* html-to-image renders through an SVG <foreignObject>, i.e. the browser's own
* layout/text engine, so Persian RTL shaping, spacing and bidi (e.g. emails)
* come out correct — unlike a re-implemented canvas text layout, which mangles
* Persian. oklch() colors are handled natively too.
* Persian. oklch() colors are handled natively too. JPEG (vs PNG) keeps the
* resulting PDF small for this mostly-white document.
* Exposed separately so it can be exercised/verified without a download.
*/
export async function renderInvoicePng(data: InvoicePdfData): Promise<InvoicePngResult> {
const [{ toPng }, fontEmbedCSS] = await Promise.all([import('html-to-image'), getEmbeddedFontCss()]);
const [{ toJpeg }, fontEmbedCSS] = await Promise.all([import('html-to-image'), getEmbeddedFontCss()]);
const host = document.createElement('div');
host.style.position = 'fixed';
@@ -294,8 +307,9 @@ export async function renderInvoicePng(data: InvoicePdfData): Promise<InvoicePng
const width = node.scrollWidth;
const height = node.scrollHeight;
const dataUrl = await toPng(node, {
const dataUrl = await toJpeg(node, {
pixelRatio: 2,
quality: 0.9,
backgroundColor: '#ffffff',
width,
height,
@@ -322,13 +336,13 @@ export async function downloadInvoicePdf(data: InvoicePdfData): Promise<void> {
let heightLeft = imgHeight;
let position = 0;
pdf.addImage(dataUrl, 'PNG', 0, position, imgWidth, imgHeight);
pdf.addImage(dataUrl, 'JPEG', 0, position, imgWidth, imgHeight);
heightLeft -= pageHeight;
while (heightLeft > 0) {
position -= pageHeight;
pdf.addPage();
pdf.addImage(dataUrl, 'PNG', 0, position, imgWidth, imgHeight);
pdf.addImage(dataUrl, 'JPEG', 0, position, imgWidth, imgHeight);
heightLeft -= pageHeight;
}