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() {
Currency
- 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) {
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
Amount *
-
+ setValue('amount', v ?? Number.NaN, { shouldDirty: true, shouldValidate: true })
+ }
/>
{errors.amount &&
{errors.amount.message}
}
Currency
-
+
+ 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)}
{fields.length > 0 && (
- Subtotal:{' '}
-
- {subtotal.toLocaleString('en-US', {
- minimumFractionDigits: 2,
- maximumFractionDigits: 2,
- })}
-
+ Subtotal: {formatCurrency(subtotal, 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 (
+
+
+
+
+
+ {SUPPORTED_CURRENCIES.map((c) => (
+
+ {c.symbol}
+ {c.code}
+ {c.label}
+
+ ))}
+
+
+ );
+}
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();
+}