feat(currency): sweep remaining concat call sites to formatCurrency

Builds on the centralised formatter shipped in ee2da8f. Replaces
\`\${currency} \${amount}\` style concatenations across the dashboard
revenue tooltip, command-search invoice/expense fallback labels,
expense-duplicate banner, and the invoice + expense PDF templates.
Drops the duplicate \`currencySymbol\` helper inside expense-pdf.service
in favour of the shared util; the two PDF helpers (renderReceiptHeader,
addReceiptErrorPage) now take a currency code instead of a pre-rendered
symbol so the formatter is the single source for spacing + thousands
separators. Also re-runs Prettier on the files where the prior commit
shipped without it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-09 18:35:34 +02:00
parent 7804e9bb17
commit 43191659e6
10 changed files with 53 additions and 71 deletions

View File

@@ -27,9 +27,7 @@ export default async function PortalInvoicesPage() {
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
<h1 className="text-2xl font-semibold text-gray-900">Invoices</h1> <h1 className="text-2xl font-semibold text-gray-900">Invoices</h1>
<p className="text-sm text-gray-500 mt-1"> <p className="text-sm text-gray-500 mt-1">Your billing statements and payment history</p>
Your billing statements and payment history
</p>
</div> </div>
{invoices.length === 0 ? ( {invoices.length === 0 ? (
@@ -43,10 +41,7 @@ export default async function PortalInvoicesPage() {
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
{invoices.map((invoice) => ( {invoices.map((invoice) => (
<div <div key={invoice.id} className="bg-white rounded-lg border p-5">
key={invoice.id}
className="bg-white rounded-lg border p-5"
>
<div className="flex items-start justify-between gap-4 flex-wrap"> <div className="flex items-start justify-between gap-4 flex-wrap">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">

View File

@@ -56,9 +56,7 @@ export function BerthCard({ berth }: BerthCardProps) {
const metaParts: string[] = []; const metaParts: string[] = [];
if (dimText) metaParts.push(dimText); if (dimText) metaParts.push(dimText);
if (berth.price) if (berth.price)
metaParts.push( metaParts.push(formatCurrency(berth.price, berth.priceCurrency, { maxFractionDigits: 0 }));
formatCurrency(berth.price, berth.priceCurrency, { maxFractionDigits: 0 }),
);
const tags = berth.tags ?? []; const tags = berth.tags ?? [];

View File

@@ -405,18 +405,14 @@ export function BerthForm({ berth, open, onOpenChange }: BerthFormProps) {
<CurrencyInput <CurrencyInput
value={watch('price') ?? ''} value={watch('price') ?? ''}
currency={watch('priceCurrency') ?? 'USD'} currency={watch('priceCurrency') ?? 'USD'}
onChange={(v) => onChange={(v) => setValue('price', v ?? undefined, { shouldDirty: true })}
setValue('price', v ?? undefined, { shouldDirty: true })
}
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Currency</Label> <Label>Currency</Label>
<CurrencySelect <CurrencySelect
value={watch('priceCurrency') ?? 'USD'} value={watch('priceCurrency') ?? 'USD'}
onValueChange={(v) => onValueChange={(v) => setValue('priceCurrency', v, { shouldDirty: true })}
setValue('priceCurrency', v, { shouldDirty: true })
}
/> />
</div> </div>
</div> </div>

View File

@@ -7,6 +7,7 @@ import { EmptyState } from '@/components/shared/empty-state';
import { ChartCard } from './chart-card'; import { ChartCard } from './chart-card';
import { useRevenue } from './use-analytics'; import { useRevenue } from './use-analytics';
import type { DateRange } from '@/lib/services/analytics.service'; import type { DateRange } from '@/lib/services/analytics.service';
import { formatCurrency } from '@/lib/utils/currency';
interface Props { interface Props {
range: DateRange; range: DateRange;
@@ -71,9 +72,9 @@ export function RevenueBreakdownChart({ range }: Props) {
fontSize: 12, fontSize: 12,
}} }}
formatter={(value, _name, item) => { 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); const num = typeof value === 'number' ? value : Number(value);
return [`${num.toLocaleString()} ${c}`, 'Amount']; return [formatCurrency(num, c), 'Amount'];
}} }}
/> />
<Bar dataKey="amount" fill="hsl(var(--chart-3))" radius={[4, 4, 0, 0]} /> <Bar dataKey="amount" fill="hsl(var(--chart-3))" radius={[4, 4, 0, 0]} />

View File

@@ -10,6 +10,7 @@ import { format } from 'date-fns';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { formatCurrency } from '@/lib/utils/currency';
import type { ExpenseRow } from './expense-columns'; import type { ExpenseRow } from './expense-columns';
interface Props { interface Props {
@@ -59,9 +60,10 @@ export function ExpenseDuplicateBanner({ expense }: Props) {
if (!expense.duplicateOf) return null; if (!expense.duplicateOf) return null;
const candidateLabel = candidate const candidateLabel = candidate
? `${candidate.establishmentName ?? 'Unnamed expense'} · ${ ? `${candidate.establishmentName ?? 'Unnamed expense'} · ${formatCurrency(
candidate.amount candidate.amount,
} ${candidate.currency} · ${format(new Date(candidate.expenseDate), 'd MMM yyyy')}` candidate.currency,
)} · ${format(new Date(candidate.expenseDate), 'd MMM yyyy')}`
: 'a previously recorded expense'; : 'a previously recorded expense';
return ( return (

View File

@@ -112,9 +112,7 @@ export function InvoiceLineItems({ name = 'lineItems', currency = 'USD' }: Invoi
type="button" type="button"
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => onClick={() => append({ description: '', quantity: 1, unitPrice: 0 })}
append({ description: '', quantity: 1, unitPrice: 0 })
}
> >
<Plus className="mr-1.5 h-3.5 w-3.5" /> <Plus className="mr-1.5 h-3.5 w-3.5" />
Add Line Item Add Line Item

View File

@@ -37,6 +37,7 @@ import {
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { formatCurrency } from '@/lib/utils/currency';
import { import {
useSearch, useSearch,
type BucketType, 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.paymentStatus === 'paid') badges.push({ label: 'Paid', tone: 'success' });
else if (inv.status === 'sent') badges.push({ label: 'Sent', tone: 'neutral' }); else if (inv.status === 'sent') badges.push({ label: 'Sent', tone: 'neutral' });
const sub = inv.totalAmount const sub = inv.totalAmount
? `${inv.clientName} · ${inv.totalAmount} ${inv.currency}` ? `${inv.clientName} · ${formatCurrency(inv.totalAmount, inv.currency)}`
: inv.clientName; : inv.clientName;
rows.push({ rows.push({
kind: 'result', kind: 'result',
@@ -975,7 +976,7 @@ function buildFlatRows(args: BuildFlatRowsArgs): FlatRow[] {
key: `expenses:${e.id}`, key: `expenses:${e.id}`,
bucket: 'expenses', bucket: 'expenses',
icon: Receipt, icon: Receipt,
label: e.description ?? e.vendor ?? `${e.amount} ${e.currency}`, label: e.description ?? e.vendor ?? formatCurrency(e.amount, e.currency),
sub, sub,
href: `/${portSlug}/expenses/${e.id}`, href: `/${portSlug}/expenses/${e.id}`,
badges: badges.length > 0 ? badges : undefined, badges: badges.length > 0 ? badges : undefined,

View File

@@ -6,8 +6,10 @@ import { Input } from '@/components/ui/input';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { currencySymbol } from '@/lib/utils/currency'; import { currencySymbol } from '@/lib/utils/currency';
interface CurrencyInputProps interface CurrencyInputProps extends Omit<
extends Omit<React.ComponentProps<'input'>, 'value' | 'onChange' | 'type'> { React.ComponentProps<'input'>,
'value' | 'onChange' | 'type'
> {
/** Controlled raw numeric value. `null` / `undefined` render empty. */ /** Controlled raw numeric value. `null` / `undefined` render empty. */
value: number | string | null | undefined; value: number | string | null | undefined;
/** Fires with a raw number (or `null` if cleared). */ /** Fires with a raw number (or `null` if cleared). */
@@ -29,8 +31,7 @@ export const CurrencyInput = React.forwardRef<HTMLInputElement, CurrencyInputPro
({ value, onChange, currency = 'USD', className, ...props }, ref) => { ({ value, onChange, currency = 'USD', className, ...props }, ref) => {
const symbol = currencySymbol(currency); const symbol = currencySymbol(currency);
const display = const display = value === null || value === undefined || value === '' ? '' : String(value);
value === null || value === undefined || value === '' ? '' : String(value);
return ( return (
<div className="relative"> <div className="relative">

View File

@@ -1,5 +1,7 @@
import type { Template } from '@pdfme/common'; import type { Template } from '@pdfme/common';
import { formatCurrency } from '@/lib/utils/currency';
export const invoiceTemplate: Template = { export const invoiceTemplate: Template = {
basePdf: 'BLANK_PDF' as unknown as string, basePdf: 'BLANK_PDF' as unknown as string,
schemas: [ schemas: [
@@ -90,21 +92,22 @@ export function buildInvoiceInputs(
lineItems: Record<string, unknown>[], lineItems: Record<string, unknown>[],
port: Record<string, unknown>, port: Record<string, unknown>,
): Record<string, string> { ): Record<string, string> {
const currency = (invoice.currency as string) ?? 'USD';
const itemLines = lineItems const itemLines = lineItems
.map( .map(
(li, i) => (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'); .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) { 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) { 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 { return {
portName: (port?.name as string) ?? 'Port Nimara', portName: (port?.name as string) ?? 'Port Nimara',
@@ -112,7 +115,8 @@ export function buildInvoiceInputs(
invoiceNumber: invoice.invoiceNumber as string, invoiceNumber: invoice.invoiceNumber as string,
invoiceDate: `Date: ${new Date(invoice.createdAt as string | Date).toLocaleDateString('en-GB')}`, invoiceDate: `Date: ${new Date(invoice.createdAt as string | Date).toLocaleDateString('en-GB')}`,
dueDate: `Due: ${invoice.dueDate}`, 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', lineItems: itemLines || 'No line items',
totals: totalsText, totals: totalsText,
notes: invoice.notes ? `Notes: ${invoice.notes}` : '', notes: invoice.notes ? `Notes: ${invoice.notes}` : '',

View File

@@ -42,6 +42,7 @@ import { db } from '@/lib/db';
import { expenses } from '@/lib/db/schema/financial'; import { expenses } from '@/lib/db/schema/financial';
import { files } from '@/lib/db/schema/documents'; import { files } from '@/lib/db/schema/documents';
import { getRate } from '@/lib/services/currency'; import { getRate } from '@/lib/services/currency';
import { formatCurrency } from '@/lib/utils/currency';
import { getStorageBackend } from '@/lib/storage'; import { getStorageBackend } from '@/lib/storage';
import { logger } from '@/lib/logger'; 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 ─────────────────────────────────────────────────────────────── // ─── Grouping ───────────────────────────────────────────────────────────────
function groupKey(row: ProcessedExpense, by: GroupBy): string { function groupKey(row: ProcessedExpense, by: GroupBy): string {
@@ -574,8 +560,7 @@ function addSummaryBox(
totals: Totals, totals: Totals,
opts: { includeProcessingFee: boolean; groupBy: GroupBy }, opts: { includeProcessingFee: boolean; groupBy: GroupBy },
) { ) {
const sym = currencySymbol(totals.targetCurrency); const otherCurrency = totals.targetCurrency === 'USD' ? 'EUR' : 'USD';
const otherSym = totals.targetCurrency === 'USD' ? '€' : '$';
const otherTotal = totals.targetCurrency === 'USD' ? totals.eurTotal : totals.usdTotal; const otherTotal = totals.targetCurrency === 'USD' ? totals.eurTotal : totals.usdTotal;
doc.fontSize(14).font('Helvetica-Bold').text('Summary'); doc.fontSize(14).font('Helvetica-Bold').text('Summary');
@@ -584,12 +569,14 @@ function addSummaryBox(
const lineY = doc.y; const lineY = doc.y;
const lines = [ const lines = [
`Total expenses: ${totals.count}`, `Total expenses: ${totals.count}`,
`Subtotal (${totals.targetCurrency}): ${sym}${totals.targetTotal.toFixed(2)}`, `Subtotal (${totals.targetCurrency}): ${formatCurrency(totals.targetTotal, totals.targetCurrency)}`,
`${totals.targetCurrency === 'USD' ? 'EUR' : 'USD'} equivalent: ${otherSym}${otherTotal.toFixed(2)}`, `${otherCurrency} equivalent: ${formatCurrency(otherTotal, otherCurrency)}`,
]; ];
if (opts.includeProcessingFee) { if (opts.includeProcessingFee) {
lines.push(`Processing fee (5%): ${sym}${totals.processingFee.toFixed(2)}`); lines.push(
lines.push(`Final total: ${sym}${totals.finalTotal.toFixed(2)}`); `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}`); if (opts.groupBy !== 'none') lines.push(`Grouping: by ${opts.groupBy}`);
@@ -600,7 +587,7 @@ function addSummaryBox(
const warningLines = showNoReceiptWarning const warningLines = showNoReceiptWarning
? [ ? [
`WARNING: ${totals.noReceiptCount} expense${totals.noReceiptCount === 1 ? '' : 's'} on this report ${totals.noReceiptCount === 1 ? 'has' : 'have'} no receipt attached`, `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.fontSize(14).font('Helvetica-Bold').text('Expense details');
doc.moveDown(0.4); doc.moveDown(0.4);
const sym = currencySymbol(opts.targetCurrency);
const baseColumns: Column[] = [ const baseColumns: Column[] = [
{ header: 'Date', width: 60, x: 60 }, { header: 'Date', width: 60, x: 60 },
{ header: 'Establishment', width: 110, x: 120 }, { header: 'Establishment', width: 110, x: 120 },
@@ -707,7 +693,7 @@ function addExpenseTable(
} }
doc.fillColor('#000000').fontSize(8).font('Helvetica'); doc.fillColor('#000000').fontSize(8).font('Helvetica');
const date = row.expenseDate.toISOString().slice(0, 10); 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 // Annotate the establishment cell with a red "(no receipt)" marker
// when the rep created the expense without proof. This keeps the // when the rep created the expense without proof. This keeps the
// warning glanceable per row without adding a new column. // warning glanceable per row without adding a new column.
@@ -760,7 +746,7 @@ function addExpenseTable(
.fontSize(9) .fontSize(9)
.font('Helvetica-Bold') .font('Helvetica-Bold')
.text( .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, 65,
doc.y + 5, doc.y + 5,
{ width: doc.page.width - 130 }, { width: doc.page.width - 130 },
@@ -791,7 +777,6 @@ async function addReceiptPages(
); );
const backend = await getStorageBackend(); const backend = await getStorageBackend();
const sym = currencySymbol(opts.targetCurrency);
let receiptCounter = 0; let receiptCounter = 0;
let resizedCount = 0; let resizedCount = 0;
@@ -818,7 +803,7 @@ async function addReceiptPages(
expense, expense,
receiptCounter, receiptCounter,
totalReceipts, totalReceipts,
sym, opts.targetCurrency,
'Receipt file metadata missing', 'Receipt file metadata missing',
); );
continue; continue;
@@ -837,7 +822,7 @@ async function addReceiptPages(
// Page header // Page header
doc.addPage(); 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. // Embed the image full-bleed in the remaining vertical space.
const margin = 60; const margin = 60;
@@ -881,7 +866,7 @@ async function addReceiptPages(
expense, expense,
receiptCounter, receiptCounter,
totalReceipts, totalReceipts,
sym, opts.targetCurrency,
(err as Error).message ?? 'Receipt could not be loaded from storage', (err as Error).message ?? 'Receipt could not be loaded from storage',
); );
} finally { } finally {
@@ -909,7 +894,7 @@ function renderReceiptHeader(
file: ResolvedFile, file: ResolvedFile,
index: number, index: number,
total: number, total: number,
sym: string, currency: string,
) { ) {
const margin = 60; const margin = 60;
const headerH = 90; const headerH = 90;
@@ -936,7 +921,7 @@ function renderReceiptHeader(
.fontSize(11) .fontSize(11)
.font('Helvetica-Bold') .font('Helvetica-Bold')
.text( .text(
`${expense.establishmentName ?? '—'} ${sym}${expense.amountTarget.toFixed(2)}`, `${expense.establishmentName ?? '—'} ${formatCurrency(expense.amountTarget, currency)}`,
margin + 10, margin + 10,
baseY + 36, baseY + 36,
); );
@@ -962,7 +947,7 @@ function addReceiptErrorPage(
expense: ProcessedExpense, expense: ProcessedExpense,
index: number, index: number,
total: number, total: number,
sym: string, currency: string,
message: string, message: string,
) { ) {
doc.addPage(); doc.addPage();
@@ -970,9 +955,10 @@ function addReceiptErrorPage(
doc doc
.fontSize(11) .fontSize(11)
.font('Helvetica') .font('Helvetica')
.text(`${expense.establishmentName ?? '—'} ${sym}${expense.amountTarget.toFixed(2)}`, { .text(
align: 'center', `${expense.establishmentName ?? '—'} ${formatCurrency(expense.amountTarget, currency)}`,
}); { align: 'center' },
);
doc.moveDown(2); doc.moveDown(2);
doc.fontSize(11).fillColor('#dc3545').text(message, { align: 'center' }); doc.fontSize(11).fillColor('#dc3545').text(message, { align: 'center' });
doc.fillColor('#000000'); doc.fillColor('#000000');