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:
@@ -9,6 +9,7 @@ import api from '@/lib/api';
|
|||||||
import { useT, useLocale } from '@/i18n/I18nProvider';
|
import { useT, useLocale } from '@/i18n/I18nProvider';
|
||||||
import { translateInvoiceLabel, translateInvoiceDescription, translateInvoiceReason } from '@/lib/invoice-labels';
|
import { translateInvoiceLabel, translateInvoiceDescription, translateInvoiceReason } from '@/lib/invoice-labels';
|
||||||
import { downloadInvoicePdf, buildInvoicePdfData } from '@/lib/invoice-pdf';
|
import { downloadInvoicePdf, buildInvoicePdfData } from '@/lib/invoice-pdf';
|
||||||
|
import { useAuthStore } from '@/lib/store';
|
||||||
import type { Invoice, InvoiceStatus } from '@/types';
|
import type { Invoice, InvoiceStatus } from '@/types';
|
||||||
|
|
||||||
const statusClasses: Record<InvoiceStatus, string> = {
|
const statusClasses: Record<InvoiceStatus, string> = {
|
||||||
@@ -29,6 +30,7 @@ export default function InvoicesPage() {
|
|||||||
const lineLabel = (label?: string) => (isFa ? translateInvoiceLabel(label) : (label ?? '-'));
|
const lineLabel = (label?: string) => (isFa ? translateInvoiceLabel(label) : (label ?? '-'));
|
||||||
const lineDesc = (desc?: string) => (isFa ? translateInvoiceDescription(desc) : (desc ?? ''));
|
const lineDesc = (desc?: string) => (isFa ? translateInvoiceDescription(desc) : (desc ?? ''));
|
||||||
const reasonLabel = (reason?: string) => (isFa ? translateInvoiceReason(reason) : (reason ?? '-'));
|
const reasonLabel = (reason?: string) => (isFa ? translateInvoiceReason(reason) : (reason ?? '-'));
|
||||||
|
const authUser = useAuthStore((s) => s.user);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const [statusFilter, setStatusFilter] = useState<'all' | 'unpaid' | InvoiceStatus>('all');
|
const [statusFilter, setStatusFilter] = useState<'all' | 'unpaid' | InvoiceStatus>('all');
|
||||||
@@ -116,7 +118,14 @@ export default function InvoicesPage() {
|
|||||||
|
|
||||||
const downloadPdfMutation = useMutation({
|
const downloadPdfMutation = useMutation({
|
||||||
mutationFn: (invoice: Invoice) =>
|
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),
|
onError: () => notify.error(inv.downloadFailed),
|
||||||
});
|
});
|
||||||
const dueAmount = Number(selectedInvoice?.dueAmount || 0);
|
const dueAmount = Number(selectedInvoice?.dueAmount || 0);
|
||||||
|
|||||||
@@ -713,6 +713,7 @@ const en: Dictionary = {
|
|||||||
colDesc: 'Description',
|
colDesc: 'Description',
|
||||||
colAmount: 'Amount',
|
colAmount: 'Amount',
|
||||||
customer: 'Customer',
|
customer: 'Customer',
|
||||||
|
phone: 'Phone',
|
||||||
application: 'Application',
|
application: 'Application',
|
||||||
createdAt: 'Issued on',
|
createdAt: 'Issued on',
|
||||||
paidAt: 'Paid on',
|
paidAt: 'Paid on',
|
||||||
|
|||||||
@@ -712,6 +712,7 @@ const fa = {
|
|||||||
colDesc: 'شرح اقلام',
|
colDesc: 'شرح اقلام',
|
||||||
colAmount: 'مبلغ',
|
colAmount: 'مبلغ',
|
||||||
customer: 'مشتری',
|
customer: 'مشتری',
|
||||||
|
phone: 'تلفن تماس',
|
||||||
application: 'اپلیکیشن',
|
application: 'اپلیکیشن',
|
||||||
createdAt: 'تاریخ صدور',
|
createdAt: 'تاریخ صدور',
|
||||||
paidAt: 'تاریخ پرداخت',
|
paidAt: 'تاریخ پرداخت',
|
||||||
|
|||||||
@@ -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. */
|
/** Assemble the PDF view-model from an invoice + dictionary, translating to Persian when locale is fa. */
|
||||||
export function buildInvoicePdfData(
|
export function buildInvoicePdfData(
|
||||||
invoice: Invoice,
|
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 {
|
): InvoicePdfData {
|
||||||
const { inv, appName, locale } = opts;
|
const { inv, appName, locale, customer } = opts;
|
||||||
const isFa = locale.startsWith('fa');
|
const isFa = locale.startsWith('fa');
|
||||||
const pdf = inv.pdf;
|
const pdf = inv.pdf;
|
||||||
|
|
||||||
@@ -34,12 +39,19 @@ export function buildInvoicePdfData(
|
|||||||
const trDesc = (desc?: string) => (isFa ? translateInvoiceDescription(desc) : desc ?? '');
|
const trDesc = (desc?: string) => (isFa ? translateInvoiceDescription(desc) : desc ?? '');
|
||||||
const trReason = (reason?: string) => (isFa ? translateInvoiceReason(reason) : reason ?? '-');
|
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[] = [
|
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.application, value: invoice.application?.name || trReason(invoice.reason) },
|
||||||
{ label: pdf.createdAt, value: formatDate(invoice.createdAt) },
|
{ label: pdf.createdAt, value: formatDate(invoice.createdAt) },
|
||||||
{ label: inv.method, value: invoice.paymentMethod || '-' },
|
{ label: inv.method, value: invoice.paymentMethod || '-' },
|
||||||
];
|
);
|
||||||
if (invoice.paidAt) meta.push({ label: pdf.paidAt, value: formatDate(invoice.paidAt) });
|
if (invoice.paidAt) meta.push({ label: pdf.paidAt, value: formatDate(invoice.paidAt) });
|
||||||
if (invoice.gatewayTrackingCode) meta.push({ label: inv.tracking, value: invoice.gatewayTrackingCode });
|
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
|
const metaRows = data.meta
|
||||||
.map(
|
.map(
|
||||||
(m) => `
|
(m) => `
|
||||||
<div style="display:flex;justify-content:space-between;gap:12px;padding:10px 0;border-bottom:1px solid #f1f5f9;">
|
<div style="display:flex;align-items:baseline;gap:12px;padding:10px 0;border-bottom:1px solid #f1f5f9;">
|
||||||
<span style="color:#64748b;font-size:13px;">${esc(m.label)}</span>
|
<span style="color:#64748b;font-size:13px;white-space:nowrap;">${esc(m.label)}</span>
|
||||||
<span style="color:#0f172a;font-size:13px;font-weight:600;">${esc(m.value)}</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>`,
|
</div>`,
|
||||||
)
|
)
|
||||||
.join('');
|
.join('');
|
||||||
@@ -206,8 +218,8 @@ export function buildInvoiceHtml(data: InvoicePdfData): string {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- totals -->
|
<!-- totals (left side) -->
|
||||||
<div style="margin-top:24px;display:flex;justify-content:flex-start;">
|
<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;">
|
<div style="width:300px;background:#f8fafc;border:1px solid #f1f5f9;border-radius:16px;padding:8px 20px;">
|
||||||
${totalRows}
|
${totalRows}
|
||||||
</div>
|
</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
|
* 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)
|
* 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
|
* 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.
|
* Exposed separately so it can be exercised/verified without a download.
|
||||||
*/
|
*/
|
||||||
export async function renderInvoicePng(data: InvoicePdfData): Promise<InvoicePngResult> {
|
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');
|
const host = document.createElement('div');
|
||||||
host.style.position = 'fixed';
|
host.style.position = 'fixed';
|
||||||
@@ -294,8 +307,9 @@ export async function renderInvoicePng(data: InvoicePdfData): Promise<InvoicePng
|
|||||||
|
|
||||||
const width = node.scrollWidth;
|
const width = node.scrollWidth;
|
||||||
const height = node.scrollHeight;
|
const height = node.scrollHeight;
|
||||||
const dataUrl = await toPng(node, {
|
const dataUrl = await toJpeg(node, {
|
||||||
pixelRatio: 2,
|
pixelRatio: 2,
|
||||||
|
quality: 0.9,
|
||||||
backgroundColor: '#ffffff',
|
backgroundColor: '#ffffff',
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
@@ -322,13 +336,13 @@ export async function downloadInvoicePdf(data: InvoicePdfData): Promise<void> {
|
|||||||
let heightLeft = imgHeight;
|
let heightLeft = imgHeight;
|
||||||
let position = 0;
|
let position = 0;
|
||||||
|
|
||||||
pdf.addImage(dataUrl, 'PNG', 0, position, imgWidth, imgHeight);
|
pdf.addImage(dataUrl, 'JPEG', 0, position, imgWidth, imgHeight);
|
||||||
heightLeft -= pageHeight;
|
heightLeft -= pageHeight;
|
||||||
|
|
||||||
while (heightLeft > 0) {
|
while (heightLeft > 0) {
|
||||||
position -= pageHeight;
|
position -= pageHeight;
|
||||||
pdf.addPage();
|
pdf.addPage();
|
||||||
pdf.addImage(dataUrl, 'PNG', 0, position, imgWidth, imgHeight);
|
pdf.addImage(dataUrl, 'JPEG', 0, position, imgWidth, imgHeight);
|
||||||
heightLeft -= pageHeight;
|
heightLeft -= pageHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user