diff --git a/frontend/src/app/[lang]/dashboard/invoices/page.tsx b/frontend/src/app/[lang]/dashboard/invoices/page.tsx index 4b97473..c6d2ea1 100644 --- a/frontend/src/app/[lang]/dashboard/invoices/page.tsx +++ b/frontend/src/app/[lang]/dashboard/invoices/page.tsx @@ -9,6 +9,7 @@ import api from '@/lib/api'; import { useT, useLocale } from '@/i18n/I18nProvider'; import { translateInvoiceLabel, translateInvoiceDescription, translateInvoiceReason } from '@/lib/invoice-labels'; import { downloadInvoicePdf, buildInvoicePdfData } from '@/lib/invoice-pdf'; +import { useAuthStore } from '@/lib/store'; import type { Invoice, InvoiceStatus } from '@/types'; const statusClasses: Record = { @@ -29,6 +30,7 @@ export default function InvoicesPage() { const lineLabel = (label?: string) => (isFa ? translateInvoiceLabel(label) : (label ?? '-')); const lineDesc = (desc?: string) => (isFa ? translateInvoiceDescription(desc) : (desc ?? '')); const reasonLabel = (reason?: string) => (isFa ? translateInvoiceReason(reason) : (reason ?? '-')); + const authUser = useAuthStore((s) => s.user); const queryClient = useQueryClient(); const searchParams = useSearchParams(); const [statusFilter, setStatusFilter] = useState<'all' | 'unpaid' | InvoiceStatus>('all'); @@ -116,7 +118,14 @@ export default function InvoicesPage() { const downloadPdfMutation = useMutation({ mutationFn: (invoice: Invoice) => - downloadInvoicePdf(buildInvoicePdfData(invoice, { inv, appName: t.common.appName, locale })), + downloadInvoicePdf( + buildInvoicePdfData(invoice, { + inv, + appName: t.common.appName, + locale, + customer: invoice.user ?? authUser ?? undefined, + }), + ), onError: () => notify.error(inv.downloadFailed), }); const dueAmount = Number(selectedInvoice?.dueAmount || 0); diff --git a/frontend/src/i18n/dictionaries/en.ts b/frontend/src/i18n/dictionaries/en.ts index 6b463eb..f7151ba 100644 --- a/frontend/src/i18n/dictionaries/en.ts +++ b/frontend/src/i18n/dictionaries/en.ts @@ -713,6 +713,7 @@ const en: Dictionary = { colDesc: 'Description', colAmount: 'Amount', customer: 'Customer', + phone: 'Phone', application: 'Application', createdAt: 'Issued on', paidAt: 'Paid on', diff --git a/frontend/src/i18n/dictionaries/fa.ts b/frontend/src/i18n/dictionaries/fa.ts index b341d86..0370661 100644 --- a/frontend/src/i18n/dictionaries/fa.ts +++ b/frontend/src/i18n/dictionaries/fa.ts @@ -712,6 +712,7 @@ const fa = { colDesc: 'شرح اقلام', colAmount: 'مبلغ', customer: 'مشتری', + phone: 'تلفن تماس', application: 'اپلیکیشن', createdAt: 'تاریخ صدور', paidAt: 'تاریخ پرداخت', diff --git a/frontend/src/lib/invoice-pdf.ts b/frontend/src/lib/invoice-pdf.ts index c8c3349..622caf2 100644 --- a/frontend/src/lib/invoice-pdf.ts +++ b/frontend/src/lib/invoice-pdf.ts @@ -21,9 +21,14 @@ const STATUS_KIND: Record = { /** 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) => ` -
- ${esc(m.label)} - ${esc(m.value)} +
+ ${esc(m.label)} + ${esc(m.value)}
`, ) .join(''); @@ -206,8 +218,8 @@ export function buildInvoiceHtml(data: InvoicePdfData): string {
- -
+ +
${totalRows}
@@ -265,16 +277,17 @@ async function getEmbeddedFontCss(): Promise { } /** - * 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 , 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 { - 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 { 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; }