From ee2da8f67e1542689ca6fd87a75fc4153eee609f Mon Sep 17 00:00:00 2001 From: Matt Date: Sat, 9 May 2026 04:24:46 +0200 Subject: [PATCH] feat(currency): centralise money formatting + curated currency picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New `src/lib/utils/currency.ts` is the single source of truth for display formatting (`formatCurrency`) and the supported-currency catalog (`SUPPORTED_CURRENCIES`, 10 codes covering the marina market). New shared components: - `` — number input with leading symbol prefix and decimal inputMode, raw number value out via onChange. - `` — Select dropdown over `SUPPORTED_CURRENCIES` with symbol + code + label per row, replaces the free-text 3-letter inputs that let reps type "EURO" or "$$$" into a 3-char ISO column. Threaded through every money input + display: - Forms: berth (price/currency), expense (amount/currency), invoice (currency Select + line-items unit-price + step-3 review totals). - Reads: berth-card / berth-columns / invoice-card / expense-card / dashboard KPIs / dashboard revenue-forecast / portal-invoices page. Each had its own ad-hoc `Intl.NumberFormat` wrapper with slightly different fallbacks; collapsed onto the shared helper. `InvoiceLineItems` gained a `currency` prop so the unit-price input prefix and the subtotal use the parent invoice's currency rather than hard-coded `en-US` formatting. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../[portSlug]/invoices/new/page.tsx | 25 ++++--- src/app/(portal)/portal/invoices/page.tsx | 11 +-- src/components/berths/berth-card.tsx | 18 ++--- src/components/berths/berth-columns.tsx | 7 +- src/components/berths/berth-form.tsx | 17 ++++- src/components/dashboard/kpi-cards.tsx | 11 +-- src/components/dashboard/revenue-forecast.tsx | 13 ++-- src/components/expenses/expense-card.tsx | 9 +-- .../expenses/expense-form-dialog.tsx | 23 ++++-- src/components/invoices/invoice-card.tsx | 9 +-- .../invoices/invoice-line-items.tsx | 35 ++++----- src/components/shared/currency-input.tsx | 66 +++++++++++++++++ src/components/shared/currency-select.tsx | 48 +++++++++++++ src/lib/utils/currency.ts | 71 +++++++++++++++++++ 14 files changed, 264 insertions(+), 99 deletions(-) create mode 100644 src/components/shared/currency-input.tsx create mode 100644 src/components/shared/currency-select.tsx create mode 100644 src/lib/utils/currency.ts diff --git a/src/app/(dashboard)/[portSlug]/invoices/new/page.tsx b/src/app/(dashboard)/[portSlug]/invoices/new/page.tsx index 0e7f9b34..2211d18b 100644 --- a/src/app/(dashboard)/[portSlug]/invoices/new/page.tsx +++ b/src/app/(dashboard)/[portSlug]/invoices/new/page.tsx @@ -22,8 +22,10 @@ import { } from '@/components/ui/select'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { OwnerPicker } from '@/components/shared/owner-picker'; +import { CurrencySelect } from '@/components/shared/currency-select'; import { InvoiceLineItems } from '@/components/invoices/invoice-line-items'; import { apiFetch } from '@/lib/api/client'; +import { formatCurrency } from '@/lib/utils/currency'; import { createInvoiceSchema, type CreateInvoiceInput } from '@/lib/validators/invoices'; const PAYMENT_TERMS = [ @@ -324,11 +326,10 @@ export default function NewInvoicePage() {
- setValue('currency', v, { shouldDirty: true })} + className="w-48" />
@@ -352,7 +353,7 @@ export default function NewInvoicePage() { Line Items - + {errors.lineItems && !Array.isArray(errors.lineItems) && (

{(errors.lineItems as { message?: string }).message} @@ -415,8 +416,10 @@ export default function NewInvoicePage() {

{li.description} - {(Number(li.quantity) * Number(li.unitPrice)).toFixed(2)}{' '} - {watchedValues.currency} + {formatCurrency( + Number(li.quantity) * Number(li.unitPrice), + watchedValues.currency, + )}
))} @@ -427,21 +430,21 @@ export default function NewInvoicePage() {
Subtotal - {subtotal.toFixed(2)} {watchedValues.currency} + {formatCurrency(subtotal, watchedValues.currency)}
{isNet10 && (
Net 10 Discount (~2%) - -{discountAmount.toFixed(2)} {watchedValues.currency} + -{formatCurrency(discountAmount, watchedValues.currency)}
)}
Total - {total.toFixed(2)} {watchedValues.currency} + {formatCurrency(total, watchedValues.currency)}
diff --git a/src/app/(portal)/portal/invoices/page.tsx b/src/app/(portal)/portal/invoices/page.tsx index 4c27828d..25e9b727 100644 --- a/src/app/(portal)/portal/invoices/page.tsx +++ b/src/app/(portal)/portal/invoices/page.tsx @@ -5,6 +5,7 @@ import type { Metadata } from 'next'; import { getPortalSession } from '@/lib/portal/auth'; import { getClientInvoices } from '@/lib/services/portal.service'; import { Badge } from '@/components/ui/badge'; +import { formatCurrency } from '@/lib/utils/currency'; export const metadata: Metadata = { title: 'Invoices' }; @@ -16,16 +17,6 @@ const STATUS_COLORS: Record = { sold: 'Sold', }; -function formatPrice(price: string, currency: string): string { - try { - return new Intl.NumberFormat('en-US', { - style: 'currency', - currency: currency || 'USD', - maximumFractionDigits: 0, - }).format(Number(price)); - } catch { - return `${currency} ${price}`; - } -} - interface BerthCardProps { berth: BerthRow; } @@ -66,7 +55,10 @@ export function BerthCard({ berth }: BerthCardProps) { const metaParts: string[] = []; if (dimText) metaParts.push(dimText); - if (berth.price) metaParts.push(formatPrice(berth.price, berth.priceCurrency)); + if (berth.price) + metaParts.push( + formatCurrency(berth.price, berth.priceCurrency, { maxFractionDigits: 0 }), + ); const tags = berth.tags ?? []; diff --git a/src/components/berths/berth-columns.tsx b/src/components/berths/berth-columns.tsx index 5d7a9bd4..f13c611a 100644 --- a/src/components/berths/berth-columns.tsx +++ b/src/components/berths/berth-columns.tsx @@ -12,6 +12,7 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { TagBadge } from '@/components/shared/tag-badge'; +import { formatCurrency } from '@/lib/utils/currency'; import { mooringLetterDot } from './mooring-letter-tone'; export type BerthRow = { @@ -176,11 +177,7 @@ function joinNonNull(parts: Array, sep = ' · '): str function formatMoney(amount: string | null, currency: string): string | null { if (!amount) return null; - return new Intl.NumberFormat('en-US', { - style: 'currency', - currency: currency || 'USD', - maximumFractionDigits: 0, - }).format(Number(amount)); + return formatCurrency(amount, currency, { maxFractionDigits: 0 }); } export const berthColumns: ColumnDef[] = [ diff --git a/src/components/berths/berth-form.tsx b/src/components/berths/berth-form.tsx index 59857174..f9991033 100644 --- a/src/components/berths/berth-form.tsx +++ b/src/components/berths/berth-form.tsx @@ -20,6 +20,8 @@ import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetFooter } from '@/com import { Separator } from '@/components/ui/separator'; import { Switch } from '@/components/ui/switch'; import { TagPicker } from '@/components/shared/tag-picker'; +import { CurrencyInput } from '@/components/shared/currency-input'; +import { CurrencySelect } from '@/components/shared/currency-select'; import { apiFetch } from '@/lib/api/client'; import { toastError } from '@/lib/api/toast-error'; import { updateBerthSchema, type UpdateBerthInput } from '@/lib/validators/berths'; @@ -400,11 +402,22 @@ export function BerthForm({ berth, open, onOpenChange }: BerthFormProps) {
- + + setValue('price', v ?? undefined, { shouldDirty: true }) + } + />
- + + setValue('priceCurrency', v, { shouldDirty: true }) + } + />
diff --git a/src/components/dashboard/kpi-cards.tsx b/src/components/dashboard/kpi-cards.tsx index a0232c3b..7b6a5f57 100644 --- a/src/components/dashboard/kpi-cards.tsx +++ b/src/components/dashboard/kpi-cards.tsx @@ -6,6 +6,7 @@ import { apiFetch } from '@/lib/api/client'; import { useUIStore } from '@/stores/ui-store'; import { KPITile } from '@/components/ui/kpi-tile'; import { Skeleton } from '@/components/ui/skeleton'; +import { formatCurrency } from '@/lib/utils/currency'; import { WidgetErrorBoundary } from './widget-error-boundary'; interface KpiData { @@ -15,13 +16,7 @@ interface KpiData { occupancyRate: number; } -function formatCurrency(value: number): string { - return new Intl.NumberFormat('en-US', { - style: 'currency', - currency: 'USD', - maximumFractionDigits: 0, - }).format(value); -} +const formatUsd = (value: number) => formatCurrency(value, 'USD', { maxFractionDigits: 0 }); function formatPercent(value: number): string { return `${value.toFixed(1)}%`; @@ -81,7 +76,7 @@ export function KpiCards() { }, { label: 'Pipeline Value', - value: isError ? '-' : formatCurrency(data?.pipelineValueUsd ?? 0), + value: isError ? '-' : formatUsd(data?.pipelineValueUsd ?? 0), accent: 'success', }, { diff --git a/src/components/dashboard/revenue-forecast.tsx b/src/components/dashboard/revenue-forecast.tsx index e98c5ad8..77ea5c0c 100644 --- a/src/components/dashboard/revenue-forecast.tsx +++ b/src/components/dashboard/revenue-forecast.tsx @@ -7,6 +7,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { CardSkeleton } from '@/components/shared/loading-skeleton'; import { stageLabel } from '@/lib/constants'; +import { formatCurrency } from '@/lib/utils/currency'; import { WidgetErrorBoundary } from './widget-error-boundary'; interface StageBreakdownRow { @@ -21,13 +22,7 @@ interface ForecastData { weightsSource: 'db' | 'default'; } -function formatCurrency(value: number): string { - return new Intl.NumberFormat('en-US', { - style: 'currency', - currency: 'USD', - maximumFractionDigits: 0, - }).format(value); -} +const formatUsd = (value: number) => formatCurrency(value, 'USD', { maxFractionDigits: 0 }); function RevenueForecastInner() { const { data, isLoading } = useQuery({ @@ -56,7 +51,7 @@ function RevenueForecastInner() {

Weighted Pipeline Value

-

{formatCurrency(data?.totalWeightedValue ?? 0)}

+

{formatUsd(data?.totalWeightedValue ?? 0)}

{activeStages.length > 0 && ( @@ -67,7 +62,7 @@ function RevenueForecastInner() { {stageLabel(s.stage)} ({s.count}) - {formatCurrency(s.weightedValue)} + {formatUsd(s.weightedValue)} ))} diff --git a/src/components/expenses/expense-card.tsx b/src/components/expenses/expense-card.tsx index 391bc4fc..bb8978eb 100644 --- a/src/components/expenses/expense-card.tsx +++ b/src/components/expenses/expense-card.tsx @@ -13,6 +13,7 @@ import { } from '@/components/ui/dropdown-menu'; import { ListCard, ListCardAvatar, ListCardMeta } from '@/components/shared/list-card'; import { cn } from '@/lib/utils'; +import { formatCurrency } from '@/lib/utils/currency'; import type { ExpenseRow } from './expense-columns'; const PAYMENT_STATUS_COLORS: Record = { @@ -47,13 +48,7 @@ function deriveAccent(status: string | null, duplicateOf: string | null): string } } -function formatAmount(amount: string, currency: string): string { - try { - return new Intl.NumberFormat('en', { style: 'currency', currency }).format(Number(amount)); - } catch { - return `${currency} ${amount}`; - } -} +const formatAmount = (amount: string, currency: string) => formatCurrency(amount, currency); interface ExpenseCardProps { expense: ExpenseRow; diff --git a/src/components/expenses/expense-form-dialog.tsx b/src/components/expenses/expense-form-dialog.tsx index 74b389f8..5ba5c88c 100644 --- a/src/components/expenses/expense-form-dialog.tsx +++ b/src/components/expenses/expense-form-dialog.tsx @@ -19,6 +19,8 @@ import { SelectValue, } from '@/components/ui/select'; import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetFooter } from '@/components/ui/sheet'; +import { CurrencyInput } from '@/components/shared/currency-input'; +import { CurrencySelect } from '@/components/shared/currency-select'; import { apiFetch } from '@/lib/api/client'; import { createExpenseSchema, type CreateExpenseInput } from '@/lib/validators/expenses'; import { EXPENSE_CATEGORIES, PAYMENT_METHODS } from '@/lib/constants'; @@ -50,6 +52,7 @@ export function ExpenseFormDialog({ open, onOpenChange, expense }: ExpenseFormDi handleSubmit, setValue, reset, + watch, formState: { errors, isSubmitting }, } = useForm({ resolver: zodResolver(createExpenseSchema), @@ -215,20 +218,26 @@ export function ExpenseFormDialog({ open, onOpenChange, expense }: ExpenseFormDi
- + setValue('amount', v ?? Number.NaN, { shouldDirty: true, shouldValidate: true }) + } /> {errors.amount &&

{errors.amount.message}

}
- + + setValue('currency', v, { shouldDirty: true }) + } + /> {errors.currency && (

{errors.currency.message}

)} diff --git a/src/components/invoices/invoice-card.tsx b/src/components/invoices/invoice-card.tsx index f1f1282b..998f851a 100644 --- a/src/components/invoices/invoice-card.tsx +++ b/src/components/invoices/invoice-card.tsx @@ -22,6 +22,7 @@ import { } from '@/components/ui/dropdown-menu'; import { ListCard, ListCardAvatar, ListCardMeta } from '@/components/shared/list-card'; import { cn } from '@/lib/utils'; +import { formatCurrency } from '@/lib/utils/currency'; import type { InvoiceRow } from './invoice-columns'; const STATUS_COLORS: Record = { @@ -48,13 +49,7 @@ const STATUS_ACCENT: Record = { cancelled: 'bg-slate-200', }; -function formatAmount(total: string, currency: string): string { - try { - return new Intl.NumberFormat('en', { style: 'currency', currency }).format(Number(total)); - } catch { - return `${currency} ${total}`; - } -} +const formatAmount = (total: string, currency: string) => formatCurrency(total, currency); interface InvoiceCardProps { invoice: InvoiceRow; diff --git a/src/components/invoices/invoice-line-items.tsx b/src/components/invoices/invoice-line-items.tsx index c52529d4..47081060 100644 --- a/src/components/invoices/invoice-line-items.tsx +++ b/src/components/invoices/invoice-line-items.tsx @@ -5,6 +5,8 @@ import { Plus, Trash2 } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; +import { CurrencyInput } from '@/components/shared/currency-input'; +import { formatCurrency } from '@/lib/utils/currency'; interface LineItem { description: string; @@ -14,10 +16,13 @@ interface LineItem { interface InvoiceLineItemsProps { name?: string; + /** Currency code from the parent invoice form (drives both the + * unit-price input symbol and the subtotal formatting). */ + currency?: string; } -export function InvoiceLineItems({ name = 'lineItems' }: InvoiceLineItemsProps) { - const { register, watch } = useFormContext(); +export function InvoiceLineItems({ name = 'lineItems', currency = 'USD' }: InvoiceLineItemsProps) { + const { register, setValue, watch } = useFormContext(); const { fields, append, remove } = useFieldArray({ name }); const lineItems: LineItem[] = watch(name) ?? []; @@ -66,22 +71,18 @@ export function InvoiceLineItems({ name = 'lineItems' }: InvoiceLineItemsProps) />
- + setValue(`${name}.${index}.unitPrice`, v ?? 0, { shouldDirty: true }) + } step="any" - placeholder="0.00" className="h-8 text-sm text-right" />
- - {lineTotal.toLocaleString('en-US', { - minimumFractionDigits: 2, - maximumFractionDigits: 2, - })} - + {formatCurrency(lineTotal, currency)}
diff --git a/src/components/shared/currency-input.tsx b/src/components/shared/currency-input.tsx new file mode 100644 index 00000000..8ef4fd2b --- /dev/null +++ b/src/components/shared/currency-input.tsx @@ -0,0 +1,66 @@ +'use client'; + +import * as React from 'react'; + +import { Input } from '@/components/ui/input'; +import { cn } from '@/lib/utils'; +import { currencySymbol } from '@/lib/utils/currency'; + +interface CurrencyInputProps + extends Omit, '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). */ + onChange: (value: number | null) => void; + /** ISO currency code; renders as a leading symbol prefix. */ + currency?: string; + className?: string; +} + +/** + * Numeric input pre-decorated with a currency symbol. The display + * value is the raw number the user typed (we don't fight the keystroke + * cadence by re-formatting on every key) — formatted display lives in + * read-only contexts via `formatCurrency()`. This keeps form behaviour + * predictable while still scoping the input to a money field via the + * symbol prefix and the `decimal` inputMode. + */ +export const CurrencyInput = React.forwardRef( + ({ value, onChange, currency = 'USD', className, ...props }, ref) => { + const symbol = currencySymbol(currency); + + const display = + value === null || value === undefined || value === '' ? '' : String(value); + + return ( +
+ + {symbol} + + { + const raw = e.target.value; + if (raw === '') { + onChange(null); + return; + } + const n = Number(raw); + onChange(Number.isFinite(n) ? n : null); + }} + className={cn('pl-9 tabular-nums', className)} + {...props} + /> +
+ ); + }, +); +CurrencyInput.displayName = 'CurrencyInput'; diff --git a/src/components/shared/currency-select.tsx b/src/components/shared/currency-select.tsx new file mode 100644 index 00000000..aede6a3e --- /dev/null +++ b/src/components/shared/currency-select.tsx @@ -0,0 +1,48 @@ +'use client'; + +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { SUPPORTED_CURRENCIES } from '@/lib/utils/currency'; + +interface CurrencySelectProps { + value: string | undefined; + onValueChange: (value: string) => void; + disabled?: boolean; + id?: string; + className?: string; +} + +/** + * Select dropdown over the curated `SUPPORTED_CURRENCIES` list. Replaces + * the free-text `` price-currency spots so reps + * can't typo "EURO" or "$$$" into a 3-letter ISO column. + */ +export function CurrencySelect({ + value, + onValueChange, + disabled, + id, + className, +}: CurrencySelectProps) { + return ( + + ); +} diff --git a/src/lib/utils/currency.ts b/src/lib/utils/currency.ts new file mode 100644 index 00000000..e0b22fcb --- /dev/null +++ b/src/lib/utils/currency.ts @@ -0,0 +1,71 @@ +/** + * Single source of truth for money formatting + the supported-currency + * dropdown list. Everywhere in the CRM that renders an amount should + * call `formatCurrency()` rather than spinning up its own + * `Intl.NumberFormat`, and every form that captures an amount should + * use the curated `SUPPORTED_CURRENCIES` list rather than a free-text + * 3-letter input. + */ + +export const SUPPORTED_CURRENCIES = [ + { code: 'USD', symbol: '$', label: 'US Dollar' }, + { code: 'EUR', symbol: '€', label: 'Euro' }, + { code: 'GBP', symbol: '£', label: 'British Pound' }, + { code: 'CAD', symbol: 'CA$', label: 'Canadian Dollar' }, + { code: 'AUD', symbol: 'A$', label: 'Australian Dollar' }, + { code: 'CHF', symbol: 'CHF', label: 'Swiss Franc' }, + { code: 'JPY', symbol: '¥', label: 'Japanese Yen' }, + { code: 'AED', symbol: 'د.إ', label: 'UAE Dirham' }, + { code: 'SGD', symbol: 'S$', label: 'Singapore Dollar' }, + { code: 'HKD', symbol: 'HK$', label: 'Hong Kong Dollar' }, +] as const; + +export type SupportedCurrencyCode = (typeof SUPPORTED_CURRENCIES)[number]['code']; + +const SUPPORTED_SET: ReadonlySet = new Set(SUPPORTED_CURRENCIES.map((c) => c.code)); + +/** + * Format an amount for display. Accepts numbers, numeric strings, and + * null/undefined (returns the empty string for the latter so callers + * can short-circuit display logic). + * + * Defaults to `en-US` locale because the CRM is single-locale today; + * pass `locale` explicitly when rendering for portal users in the + * future. Unknown ISO codes fall through to Intl unchanged so the + * function never throws on legacy data. + */ +export function formatCurrency( + amount: number | string | null | undefined, + currency: string | null | undefined = 'USD', + options: { locale?: string; minFractionDigits?: number; maxFractionDigits?: number } = {}, +): string { + if (amount === null || amount === undefined || amount === '') return ''; + const value = typeof amount === 'number' ? amount : Number(amount); + if (!Number.isFinite(value)) return ''; + const code = (currency ?? 'USD').toUpperCase(); + const { locale = 'en-US', minFractionDigits = 2, maxFractionDigits = 2 } = options; + try { + return new Intl.NumberFormat(locale, { + style: 'currency', + currency: code, + minimumFractionDigits: minFractionDigits, + maximumFractionDigits: maxFractionDigits, + }).format(value); + } catch { + // Unknown currency code — degrade to a bare number with the code + // appended rather than throwing. Keeps display robust against any + // legacy NocoDB rows that smuggled non-ISO strings into the column. + return `${value.toFixed(maxFractionDigits)} ${code}`; + } +} + +/** Whether a 3-letter code is in the curated supported list. */ +export function isSupportedCurrency(code: string): boolean { + return SUPPORTED_SET.has(code.toUpperCase()); +} + +/** Symbol for a given currency code, falling back to the code itself. */ +export function currencySymbol(code: string): string { + const match = SUPPORTED_CURRENCIES.find((c) => c.code === code.toUpperCase()); + return match?.symbol ?? code.toUpperCase(); +}