diff --git a/src/app/(portal)/portal/invoices/page.tsx b/src/app/(portal)/portal/invoices/page.tsx index 25e9b727..105f683a 100644 --- a/src/app/(portal)/portal/invoices/page.tsx +++ b/src/app/(portal)/portal/invoices/page.tsx @@ -27,9 +27,7 @@ export default async function PortalInvoicesPage() {

Invoices

-

- Your billing statements and payment history -

+

Your billing statements and payment history

{invoices.length === 0 ? ( @@ -43,10 +41,7 @@ export default async function PortalInvoicesPage() { ) : (
{invoices.map((invoice) => ( -
+
diff --git a/src/components/berths/berth-card.tsx b/src/components/berths/berth-card.tsx index 5e1d6114..997ca6a9 100644 --- a/src/components/berths/berth-card.tsx +++ b/src/components/berths/berth-card.tsx @@ -56,9 +56,7 @@ export function BerthCard({ berth }: BerthCardProps) { const metaParts: string[] = []; if (dimText) metaParts.push(dimText); if (berth.price) - metaParts.push( - formatCurrency(berth.price, berth.priceCurrency, { maxFractionDigits: 0 }), - ); + metaParts.push(formatCurrency(berth.price, berth.priceCurrency, { maxFractionDigits: 0 })); const tags = berth.tags ?? []; diff --git a/src/components/berths/berth-form.tsx b/src/components/berths/berth-form.tsx index f9991033..1fe5944d 100644 --- a/src/components/berths/berth-form.tsx +++ b/src/components/berths/berth-form.tsx @@ -405,18 +405,14 @@ export function BerthForm({ berth, open, onOpenChange }: BerthFormProps) { - setValue('price', v ?? undefined, { shouldDirty: true }) - } + onChange={(v) => setValue('price', v ?? undefined, { shouldDirty: true })} />
- setValue('priceCurrency', v, { shouldDirty: true }) - } + onValueChange={(v) => setValue('priceCurrency', v, { shouldDirty: true })} />
diff --git a/src/components/dashboard/revenue-breakdown-chart.tsx b/src/components/dashboard/revenue-breakdown-chart.tsx index d04aec16..190a4bb8 100644 --- a/src/components/dashboard/revenue-breakdown-chart.tsx +++ b/src/components/dashboard/revenue-breakdown-chart.tsx @@ -7,6 +7,7 @@ import { EmptyState } from '@/components/shared/empty-state'; import { ChartCard } from './chart-card'; import { useRevenue } from './use-analytics'; import type { DateRange } from '@/lib/services/analytics.service'; +import { formatCurrency } from '@/lib/utils/currency'; interface Props { range: DateRange; @@ -71,9 +72,9 @@ export function RevenueBreakdownChart({ range }: Props) { fontSize: 12, }} formatter={(value, _name, item) => { - const c = (item?.payload as { currency?: string } | undefined)?.currency ?? ''; + const c = (item?.payload as { currency?: string } | undefined)?.currency ?? 'USD'; const num = typeof value === 'number' ? value : Number(value); - return [`${num.toLocaleString()} ${c}`, 'Amount']; + return [formatCurrency(num, c), 'Amount']; }} /> diff --git a/src/components/expenses/expense-duplicate-banner.tsx b/src/components/expenses/expense-duplicate-banner.tsx index f5e8b050..f4402145 100644 --- a/src/components/expenses/expense-duplicate-banner.tsx +++ b/src/components/expenses/expense-duplicate-banner.tsx @@ -10,6 +10,7 @@ import { format } from 'date-fns'; import { Button } from '@/components/ui/button'; import { apiFetch } from '@/lib/api/client'; import { cn } from '@/lib/utils'; +import { formatCurrency } from '@/lib/utils/currency'; import type { ExpenseRow } from './expense-columns'; interface Props { @@ -59,9 +60,10 @@ export function ExpenseDuplicateBanner({ expense }: Props) { if (!expense.duplicateOf) return null; const candidateLabel = candidate - ? `${candidate.establishmentName ?? 'Unnamed expense'} · ${ - candidate.amount - } ${candidate.currency} · ${format(new Date(candidate.expenseDate), 'd MMM yyyy')}` + ? `${candidate.establishmentName ?? 'Unnamed expense'} · ${formatCurrency( + candidate.amount, + candidate.currency, + )} · ${format(new Date(candidate.expenseDate), 'd MMM yyyy')}` : 'a previously recorded expense'; return ( diff --git a/src/components/invoices/invoice-line-items.tsx b/src/components/invoices/invoice-line-items.tsx index 47081060..6bab3208 100644 --- a/src/components/invoices/invoice-line-items.tsx +++ b/src/components/invoices/invoice-line-items.tsx @@ -112,9 +112,7 @@ export function InvoiceLineItems({ name = 'lineItems', currency = 'USD' }: Invoi type="button" variant="outline" size="sm" - onClick={() => - append({ description: '', quantity: 1, unitPrice: 0 }) - } + onClick={() => append({ description: '', quantity: 1, unitPrice: 0 })} > Add Line Item diff --git a/src/components/search/command-search.tsx b/src/components/search/command-search.tsx index fda51416..adccefc3 100644 --- a/src/components/search/command-search.tsx +++ b/src/components/search/command-search.tsx @@ -37,6 +37,7 @@ import { import { apiFetch } from '@/lib/api/client'; import { cn } from '@/lib/utils'; +import { formatCurrency } from '@/lib/utils/currency'; import { useSearch, type BucketType, @@ -951,7 +952,7 @@ function buildFlatRows(args: BuildFlatRowsArgs): FlatRow[] { else if (inv.paymentStatus === 'paid') badges.push({ label: 'Paid', tone: 'success' }); else if (inv.status === 'sent') badges.push({ label: 'Sent', tone: 'neutral' }); const sub = inv.totalAmount - ? `${inv.clientName} · ${inv.totalAmount} ${inv.currency}` + ? `${inv.clientName} · ${formatCurrency(inv.totalAmount, inv.currency)}` : inv.clientName; rows.push({ kind: 'result', @@ -975,7 +976,7 @@ function buildFlatRows(args: BuildFlatRowsArgs): FlatRow[] { key: `expenses:${e.id}`, bucket: 'expenses', icon: Receipt, - label: e.description ?? e.vendor ?? `${e.amount} ${e.currency}`, + label: e.description ?? e.vendor ?? formatCurrency(e.amount, e.currency), sub, href: `/${portSlug}/expenses/${e.id}`, badges: badges.length > 0 ? badges : undefined, diff --git a/src/components/shared/currency-input.tsx b/src/components/shared/currency-input.tsx index 8ef4fd2b..6aa1f1ca 100644 --- a/src/components/shared/currency-input.tsx +++ b/src/components/shared/currency-input.tsx @@ -6,8 +6,10 @@ import { Input } from '@/components/ui/input'; import { cn } from '@/lib/utils'; import { currencySymbol } from '@/lib/utils/currency'; -interface CurrencyInputProps - extends Omit, 'value' | 'onChange' | 'type'> { +interface CurrencyInputProps extends Omit< + React.ComponentProps<'input'>, + 'value' | 'onChange' | 'type' +> { /** Controlled raw numeric value. `null` / `undefined` render empty. */ value: number | string | null | undefined; /** Fires with a raw number (or `null` if cleared). */ @@ -29,8 +31,7 @@ export const CurrencyInput = React.forwardRef { const symbol = currencySymbol(currency); - const display = - value === null || value === undefined || value === '' ? '' : String(value); + const display = value === null || value === undefined || value === '' ? '' : String(value); return (
diff --git a/src/lib/pdf/templates/invoice-template.ts b/src/lib/pdf/templates/invoice-template.ts index 57495cfd..06b7394d 100644 --- a/src/lib/pdf/templates/invoice-template.ts +++ b/src/lib/pdf/templates/invoice-template.ts @@ -1,5 +1,7 @@ import type { Template } from '@pdfme/common'; +import { formatCurrency } from '@/lib/utils/currency'; + export const invoiceTemplate: Template = { basePdf: 'BLANK_PDF' as unknown as string, schemas: [ @@ -90,21 +92,22 @@ export function buildInvoiceInputs( lineItems: Record[], port: Record, ): Record { + const currency = (invoice.currency as string) ?? 'USD'; const itemLines = lineItems .map( (li, i) => - `${i + 1}. ${li.description} | Qty: ${li.quantity} | Unit: ${invoice.currency} ${Number(li.unitPrice).toFixed(2)} | Total: ${invoice.currency} ${Number(li.total).toFixed(2)}`, + `${i + 1}. ${li.description} | Qty: ${li.quantity} | Unit: ${formatCurrency(Number(li.unitPrice), currency)} | Total: ${formatCurrency(Number(li.total), currency)}`, ) .join('\n'); - let totalsText = `Subtotal: ${invoice.currency} ${Number(invoice.subtotal).toFixed(2)}`; + let totalsText = `Subtotal: ${formatCurrency(Number(invoice.subtotal), currency)}`; if (Number(invoice.discountAmount) > 0) { - totalsText += `\nDiscount (${invoice.discountPct}%): -${invoice.currency} ${Number(invoice.discountAmount).toFixed(2)}`; + totalsText += `\nDiscount (${invoice.discountPct}%): -${formatCurrency(Number(invoice.discountAmount), currency)}`; } if (Number(invoice.feeAmount) > 0) { - totalsText += `\nFee (${invoice.feePct}%): +${invoice.currency} ${Number(invoice.feeAmount).toFixed(2)}`; + totalsText += `\nFee (${invoice.feePct}%): +${formatCurrency(Number(invoice.feeAmount), currency)}`; } - totalsText += `\n─────────────\nTOTAL: ${invoice.currency} ${Number(invoice.total).toFixed(2)}`; + totalsText += `\n─────────────\nTOTAL: ${formatCurrency(Number(invoice.total), currency)}`; return { portName: (port?.name as string) ?? 'Port Nimara', @@ -112,7 +115,8 @@ export function buildInvoiceInputs( invoiceNumber: invoice.invoiceNumber as string, invoiceDate: `Date: ${new Date(invoice.createdAt as string | Date).toLocaleDateString('en-GB')}`, dueDate: `Due: ${invoice.dueDate}`, - clientInfo: `${invoice.clientName}\n${invoice.billingEmail ?? ''}\n${invoice.billingAddress ?? ''}`.trim(), + clientInfo: + `${invoice.clientName}\n${invoice.billingEmail ?? ''}\n${invoice.billingAddress ?? ''}`.trim(), lineItems: itemLines || 'No line items', totals: totalsText, notes: invoice.notes ? `Notes: ${invoice.notes}` : '', diff --git a/src/lib/services/expense-pdf.service.ts b/src/lib/services/expense-pdf.service.ts index 12747514..a60ab631 100644 --- a/src/lib/services/expense-pdf.service.ts +++ b/src/lib/services/expense-pdf.service.ts @@ -42,6 +42,7 @@ import { db } from '@/lib/db'; import { expenses } from '@/lib/db/schema/financial'; import { files } from '@/lib/db/schema/documents'; import { getRate } from '@/lib/services/currency'; +import { formatCurrency } from '@/lib/utils/currency'; import { getStorageBackend } from '@/lib/storage'; import { logger } from '@/lib/logger'; @@ -308,21 +309,6 @@ function pageDims(format: PageFormat): { width: number; height: number } { } } -// ─── Symbol helper ────────────────────────────────────────────────────────── - -function currencySymbol(c: string): string { - switch (c.toUpperCase()) { - case 'USD': - return '$'; - case 'EUR': - return '€'; - case 'GBP': - return '£'; - default: - return c.toUpperCase() + ' '; - } -} - // ─── Grouping ─────────────────────────────────────────────────────────────── function groupKey(row: ProcessedExpense, by: GroupBy): string { @@ -574,8 +560,7 @@ function addSummaryBox( totals: Totals, opts: { includeProcessingFee: boolean; groupBy: GroupBy }, ) { - const sym = currencySymbol(totals.targetCurrency); - const otherSym = totals.targetCurrency === 'USD' ? '€' : '$'; + const otherCurrency = totals.targetCurrency === 'USD' ? 'EUR' : 'USD'; const otherTotal = totals.targetCurrency === 'USD' ? totals.eurTotal : totals.usdTotal; doc.fontSize(14).font('Helvetica-Bold').text('Summary'); @@ -584,12 +569,14 @@ function addSummaryBox( const lineY = doc.y; const lines = [ `Total expenses: ${totals.count}`, - `Subtotal (${totals.targetCurrency}): ${sym}${totals.targetTotal.toFixed(2)}`, - `${totals.targetCurrency === 'USD' ? 'EUR' : 'USD'} equivalent: ${otherSym}${otherTotal.toFixed(2)}`, + `Subtotal (${totals.targetCurrency}): ${formatCurrency(totals.targetTotal, totals.targetCurrency)}`, + `${otherCurrency} equivalent: ${formatCurrency(otherTotal, otherCurrency)}`, ]; if (opts.includeProcessingFee) { - lines.push(`Processing fee (5%): ${sym}${totals.processingFee.toFixed(2)}`); - lines.push(`Final total: ${sym}${totals.finalTotal.toFixed(2)}`); + lines.push( + `Processing fee (5%): ${formatCurrency(totals.processingFee, totals.targetCurrency)}`, + ); + lines.push(`Final total: ${formatCurrency(totals.finalTotal, totals.targetCurrency)}`); } if (opts.groupBy !== 'none') lines.push(`Grouping: by ${opts.groupBy}`); @@ -600,7 +587,7 @@ function addSummaryBox( const warningLines = showNoReceiptWarning ? [ `WARNING: ${totals.noReceiptCount} expense${totals.noReceiptCount === 1 ? '' : 's'} on this report ${totals.noReceiptCount === 1 ? 'has' : 'have'} no receipt attached`, - `(${sym}${totals.noReceiptAmount.toFixed(2)} at risk of being denied reimbursement).`, + `(${formatCurrency(totals.noReceiptAmount, totals.targetCurrency)} at risk of being denied reimbursement).`, ] : []; @@ -663,7 +650,6 @@ function addExpenseTable( doc.fontSize(14).font('Helvetica-Bold').text('Expense details'); doc.moveDown(0.4); - const sym = currencySymbol(opts.targetCurrency); const baseColumns: Column[] = [ { header: 'Date', width: 60, x: 60 }, { header: 'Establishment', width: 110, x: 120 }, @@ -707,7 +693,7 @@ function addExpenseTable( } doc.fillColor('#000000').fontSize(8).font('Helvetica'); const date = row.expenseDate.toISOString().slice(0, 10); - const amount = `${sym}${row.amountTarget.toFixed(2)}`; + const amount = formatCurrency(row.amountTarget, opts.targetCurrency); // Annotate the establishment cell with a red "(no receipt)" marker // when the rep created the expense without proof. This keeps the // warning glanceable per row without adding a new column. @@ -760,7 +746,7 @@ function addExpenseTable( .fontSize(9) .font('Helvetica-Bold') .text( - `${group.key} (${group.rows.length} expense${group.rows.length === 1 ? '' : 's'} — ${sym}${groupTotal.toFixed(2)})`, + `${group.key} (${group.rows.length} expense${group.rows.length === 1 ? '' : 's'} — ${formatCurrency(groupTotal, opts.targetCurrency)})`, 65, doc.y + 5, { width: doc.page.width - 130 }, @@ -791,7 +777,6 @@ async function addReceiptPages( ); const backend = await getStorageBackend(); - const sym = currencySymbol(opts.targetCurrency); let receiptCounter = 0; let resizedCount = 0; @@ -818,7 +803,7 @@ async function addReceiptPages( expense, receiptCounter, totalReceipts, - sym, + opts.targetCurrency, 'Receipt file metadata missing', ); continue; @@ -837,7 +822,7 @@ async function addReceiptPages( // Page header doc.addPage(); - renderReceiptHeader(doc, expense, file, receiptCounter, totalReceipts, sym); + renderReceiptHeader(doc, expense, file, receiptCounter, totalReceipts, opts.targetCurrency); // Embed the image full-bleed in the remaining vertical space. const margin = 60; @@ -881,7 +866,7 @@ async function addReceiptPages( expense, receiptCounter, totalReceipts, - sym, + opts.targetCurrency, (err as Error).message ?? 'Receipt could not be loaded from storage', ); } finally { @@ -909,7 +894,7 @@ function renderReceiptHeader( file: ResolvedFile, index: number, total: number, - sym: string, + currency: string, ) { const margin = 60; const headerH = 90; @@ -936,7 +921,7 @@ function renderReceiptHeader( .fontSize(11) .font('Helvetica-Bold') .text( - `${expense.establishmentName ?? '—'} ${sym}${expense.amountTarget.toFixed(2)}`, + `${expense.establishmentName ?? '—'} ${formatCurrency(expense.amountTarget, currency)}`, margin + 10, baseY + 36, ); @@ -962,7 +947,7 @@ function addReceiptErrorPage( expense: ProcessedExpense, index: number, total: number, - sym: string, + currency: string, message: string, ) { doc.addPage(); @@ -970,9 +955,10 @@ function addReceiptErrorPage( doc .fontSize(11) .font('Helvetica') - .text(`${expense.establishmentName ?? '—'} ${sym}${expense.amountTarget.toFixed(2)}`, { - align: 'center', - }); + .text( + `${expense.establishmentName ?? '—'} ${formatCurrency(expense.amountTarget, currency)}`, + { align: 'center' }, + ); doc.moveDown(2); doc.fontSize(11).fillColor('#dc3545').text(message, { align: 'center' }); doc.fillColor('#000000');