feat(invoices): Persian line items + elegant client-side PDF download
- Translate persisted English invoice line labels/descriptions/reasons to Persian at display time (new lib/invoice-labels.ts), covering both new and historical invoices without a data migration. - Generate a styled, RTL Persian invoice PDF on the client (lib/invoice-pdf.ts) with the Abrban logo, line-item table and totals, via html2canvas + jsPDF. - Wire both the user and admin invoice pages to the translator and new download. - Add invoices.pdf dictionary keys (fa/en). - Remove the now-dead ASCII-only backend PDF endpoints and generateInvoicePdf. - Add frontend/.npmrc (npmmirror registry + high timeouts) for Iran-network installs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,8 @@ import { FileText, Search, User, Wallet, CreditCard, XCircle, Download } from 'l
|
||||
import { notify } from '@/lib/notify';
|
||||
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 type { Invoice, InvoiceStatus, PaymentMethod } from '@/types';
|
||||
import { Select } from '@/components/ui/select';
|
||||
|
||||
@@ -59,18 +61,14 @@ export default function AdminInvoicesPage() {
|
||||
onError: (err: any) => notify.error(err, inv.statusUpdateFailed),
|
||||
});
|
||||
|
||||
const isFa = locale.startsWith('fa');
|
||||
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 downloadPdfMutation = useMutation({
|
||||
mutationFn: async (invoice: Invoice) => {
|
||||
const { data } = await api.get(`/billing/admin/invoices/${invoice.id}/pdf`, { responseType: 'blob' });
|
||||
const url = window.URL.createObjectURL(new Blob([data], { type: 'application/pdf' }));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `${invoice.invoiceNumber}.pdf`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
},
|
||||
mutationFn: (invoice: Invoice) =>
|
||||
downloadInvoicePdf(buildInvoicePdfData(invoice, { inv, appName: t.common.appName, locale })),
|
||||
onError: () => notify.error(inv.downloadFailed),
|
||||
});
|
||||
|
||||
@@ -162,7 +160,7 @@ export default function AdminInvoicesPage() {
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<p className="text-sm font-semibold text-gray-900">{invoice.invoiceNumber}</p>
|
||||
<p className="text-xs text-gray-500">{invoice.application?.name || invoice.reason} · {formatDate(invoice.createdAt)}</p>
|
||||
<p className="text-xs text-gray-500">{invoice.application?.name || reasonLabel(invoice.reason)} · {formatDate(invoice.createdAt)}</p>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<p className="text-sm text-gray-900">{invoice.user?.email || invoice.userId}</p>
|
||||
@@ -193,7 +191,7 @@ export default function AdminInvoicesPage() {
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-gray-900">{selectedInvoice.invoiceNumber}</h2>
|
||||
<p className="text-sm text-gray-500">{selectedInvoice.application?.name || selectedInvoice.reason}</p>
|
||||
<p className="text-sm text-gray-500">{selectedInvoice.application?.name || reasonLabel(selectedInvoice.reason)}</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<span className={statusClasses[selectedInvoice.status]}>{statusLabel(selectedInvoice.status)}</span>
|
||||
@@ -241,8 +239,8 @@ export default function AdminInvoicesPage() {
|
||||
{(selectedInvoice.lines || []).map((line) => (
|
||||
<div key={line.id} className="border border-gray-100 rounded-xl p-3 flex justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">{line.label}</p>
|
||||
{line.description && <p className="text-xs text-gray-500">{line.description}</p>}
|
||||
<p className="text-sm font-medium text-gray-900">{lineLabel(line.label)}</p>
|
||||
{line.description && <p className="text-xs text-gray-500">{lineDesc(line.description)}</p>}
|
||||
</div>
|
||||
<span className="text-sm font-bold">{formatPrice(line.amount)} {t.common.currencyShort}</span>
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,8 @@ import { CreditCard, FileText, Wallet, XCircle, CheckCircle, Clock, Download } f
|
||||
import { notify } from '@/lib/notify';
|
||||
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 type { Invoice, InvoiceStatus } from '@/types';
|
||||
|
||||
const statusClasses: Record<InvoiceStatus, string> = {
|
||||
@@ -22,7 +24,11 @@ export default function InvoicesPage() {
|
||||
const t = useT();
|
||||
const inv = t.dashboard.invoices;
|
||||
const locale = useLocale();
|
||||
const isFa = locale.startsWith('fa');
|
||||
const statusLabel = (s: InvoiceStatus) => (inv.status as Record<string, string>)[s] ?? s;
|
||||
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 queryClient = useQueryClient();
|
||||
const searchParams = useSearchParams();
|
||||
const [statusFilter, setStatusFilter] = useState<'all' | 'unpaid' | InvoiceStatus>('all');
|
||||
@@ -105,23 +111,14 @@ export default function InvoicesPage() {
|
||||
onError: (err: any) => notify.error(err, inv.paymentFailed),
|
||||
});
|
||||
|
||||
const downloadPdfMutation = useMutation({
|
||||
mutationFn: async (invoice: Invoice) => {
|
||||
const { data } = await api.get(`/billing/invoices/${invoice.id}/pdf`, { responseType: 'blob' });
|
||||
const url = window.URL.createObjectURL(new Blob([data], { type: 'application/pdf' }));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `${invoice.invoiceNumber}.pdf`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
},
|
||||
onError: () => notify.error(inv.downloadFailed),
|
||||
});
|
||||
|
||||
const formatPrice = (amount: number) => Number(amount || 0).toLocaleString('en-US');
|
||||
const formatDate = (value?: string) => value ? new Date(value).toLocaleString(locale) : '-';
|
||||
|
||||
const downloadPdfMutation = useMutation({
|
||||
mutationFn: (invoice: Invoice) =>
|
||||
downloadInvoicePdf(buildInvoicePdfData(invoice, { inv, appName: t.common.appName, locale })),
|
||||
onError: () => notify.error(inv.downloadFailed),
|
||||
});
|
||||
const dueAmount = Number(selectedInvoice?.dueAmount || 0);
|
||||
const walletBalance = Number(walletData?.balance || 0);
|
||||
const isPayable = selectedInvoice?.status === 'issued' || selectedInvoice?.status === 'partially_paid';
|
||||
@@ -182,7 +179,7 @@ export default function InvoicesPage() {
|
||||
<div>
|
||||
<p className="font-semibold text-gray-900">{invoice.invoiceNumber}</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{invoice.application?.name || invoice.reason} · {formatDate(invoice.createdAt)}
|
||||
{invoice.application?.name || reasonLabel(invoice.reason)} · {formatDate(invoice.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
<span className={statusClasses[invoice.status]}>{statusLabel(invoice.status)}</span>
|
||||
@@ -208,7 +205,7 @@ export default function InvoicesPage() {
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-gray-900">{selectedInvoice.invoiceNumber}</h2>
|
||||
<p className="text-sm text-gray-500">{selectedInvoice.application?.name || selectedInvoice.reason}</p>
|
||||
<p className="text-sm text-gray-500">{selectedInvoice.application?.name || reasonLabel(selectedInvoice.reason)}</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<span className={statusClasses[selectedInvoice.status]}>{statusLabel(selectedInvoice.status)}</span>
|
||||
@@ -238,8 +235,8 @@ export default function InvoicesPage() {
|
||||
<div key={line.id} className="border border-gray-100 rounded-xl p-3">
|
||||
<div className="flex justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">{line.label}</p>
|
||||
{line.description && <p className="text-xs text-gray-500">{line.description}</p>}
|
||||
<p className="text-sm font-medium text-gray-900">{lineLabel(line.label)}</p>
|
||||
{line.description && <p className="text-xs text-gray-500">{lineDesc(line.description)}</p>}
|
||||
</div>
|
||||
<span className="text-sm font-bold text-gray-900">{formatPrice(line.amount)} {t.common.currencyShort}</span>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user