Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Link } from 'react-router-dom';
import { formatMoney } from '../../lib/money';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { ArrowRightLeft, StickyNote, LogIn, LogOut } from 'lucide-react';
Expand Down Expand Up @@ -229,7 +230,7 @@ export default function GuestDetailsModal({
{t('frontDesk.accountSummary')}
</p>
<p className="text-sm text-telivity-navy font-semibold">
{t('frontDesk.balance')}: ${Number(balance).toFixed(2)}
{t('frontDesk.balance')}: {formatMoney(balance)}
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
Expand All @@ -247,7 +248,7 @@ export default function GuestDetailsModal({
<li key={c.id} className="flex justify-between gap-2 text-sm">
<span className="text-telivity-slate truncate">{c.description || '—'}</span>
<span className="font-medium text-telivity-navy shrink-0">
${Number(c.amount).toFixed(2)}
{formatMoney(c.amount)}
</span>
</li>
))}
Expand All @@ -268,7 +269,7 @@ export default function GuestDetailsModal({
<li key={p.id} className="flex justify-between gap-2 text-sm">
<span className="text-telivity-slate truncate">{p.method || '—'}</span>
<span className="font-medium text-telivity-navy shrink-0">
${Number(p.amount).toFixed(2)}
{formatMoney(p.amount)}
</span>
</li>
))}
Expand Down
5 changes: 3 additions & 2 deletions apps/dashboard/src/components/rates/RatePlanCalendar.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useMemo, useState } from 'react';
import { formatMoney } from '../../lib/money';
import { useQuery } from '@tanstack/react-query';
import { addDays, format } from 'date-fns';
import { api } from '../../lib/api';
Expand Down Expand Up @@ -165,8 +166,8 @@ export default function RatePlanCalendar({ ratePlanId, propertyId, baseAmount }:
{days.map((day, i) => (
<tr key={day.date} className={`border-b border-gray-50 ${i % 2 === 1 ? 'bg-gray-50/50' : ''}`}>
<td className="px-3 py-2 text-sm text-telivity-slate">{day.date}</td>
<td className="px-3 py-2 text-sm text-right text-telivity-mid-grey">${day.baseRate.toFixed(2)}</td>
<td className="px-3 py-2 text-sm text-right font-medium text-telivity-navy">${day.effectiveRate.toFixed(2)}</td>
<td className="px-3 py-2 text-sm text-right text-telivity-mid-grey">{formatMoney(day.baseRate)}</td>
<td className="px-3 py-2 text-sm text-right font-medium text-telivity-navy">{formatMoney(day.effectiveRate)}</td>
<td className="px-3 py-2">
<div className="flex flex-wrap gap-1">
{day.badges.length === 0 ? (
Expand Down
17 changes: 17 additions & 0 deletions apps/dashboard/src/context/PropertyContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { createContext, useContext, useState, useEffect, type ReactNode } from '
import { useSearchParams } from 'react-router-dom';
import { api, setPropertyId as setApiPropertyId } from '../lib/api';
import { joinPropertyRoom, leavePropertyRoom } from '../lib/socket';
import { DEFAULT_CURRENCY, setActiveCurrency } from '../lib/money';
import {
PORTFOLIO_MODE_ID,
type PropertySummary,
Expand All @@ -10,6 +11,8 @@ import {

interface PropertyContextValue {
propertyId: string | null;
/** Active property's ISO 4217 code; falls back to USD before properties load. */
currencyCode: string;
setPropertyId: (id: string) => void;
isPortfolioMode: boolean;
properties: PropertySummary[];
Expand All @@ -20,6 +23,7 @@ interface PropertyContextValue {

const PropertyContext = createContext<PropertyContextValue>({
propertyId: null,
currencyCode: DEFAULT_CURRENCY,
setPropertyId: () => {},
isPortfolioMode: false,
properties: [],
Expand All @@ -39,6 +43,12 @@ export function PropertyProvider({ children }: { children: ReactNode }) {
const [propertiesError, setPropertiesError] = useState<string | null>(null);

const isPortfolioMode = propertyId === PORTFOLIO_MODE_ID;
// Portfolio mode spans properties that may not share a currency, so it has no
// single answer — fall back rather than assert one property's code over others.
const currencyCode =
(!isPortfolioMode &&
properties.find((p) => p.id === propertyId)?.currencyCode) ||
DEFAULT_CURRENCY;

function setPropertyId(id: string) {
setPropertyIdState(id);
Expand Down Expand Up @@ -75,6 +85,12 @@ export function PropertyProvider({ children }: { children: ReactNode }) {
// Bootstrap once; propertyId auto-select handled inside the effect.
}, []);

// Keep the money formatter's default in step with the active property, the
// same way setApiPropertyId keeps the API client in step above.
useEffect(() => {
setActiveCurrency(currencyCode);
}, [currencyCode]);

useEffect(() => {
if (isPortfolioMode) {
setApiPropertyId(null);
Expand All @@ -91,6 +107,7 @@ export function PropertyProvider({ children }: { children: ReactNode }) {
<PropertyContext.Provider
value={{
propertyId,
currencyCode,
setPropertyId,
isPortfolioMode,
properties,
Expand Down
91 changes: 91 additions & 0 deletions apps/dashboard/src/lib/money.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
/**
* Currency formatting for the dashboard.
*
* Money is stored and returned with an explicit `currencyCode` on every record,
* but the UI historically rendered `$${amount.toFixed(2)}` — a hardcoded symbol
* and a hardcoded two decimal places. That is wrong twice for a property trading
* in anything else: the symbol is someone else's, and zero-decimal currencies
* (JPY, KRW, VND, CLP…) do not have minor units at all, so ¥511,275 rendered as
* "$511275.00".
*
* Intl.NumberFormat already knows the symbol, the separators and the correct
* number of fraction digits for every ISO 4217 code, so it does the work here.
*/

/** Fallback when a record carries no currency and no property is selected. */
export const DEFAULT_CURRENCY = 'USD';

/**
* The active property's currency, pushed here by PropertyContext.
*
* Same pattern the API client already uses for propertyId (`setPropertyId` in
* lib/api.ts): a module-level value the context keeps current. It means a money
* render does not need the currency threaded into every component that happens
* to display an amount — dozens of call sites across the dashboard, many inside
* helpers that cannot call a hook at all.
*/
let activeCurrency = DEFAULT_CURRENCY;

export function setActiveCurrency(code?: string | null) {
activeCurrency = (code || DEFAULT_CURRENCY).toUpperCase();
}

export function getActiveCurrency() {
return activeCurrency;
}

/**
* Format a money value for display.
*
* @param amount number or numeric string (the API returns money as strings)
* @param currencyCode ISO 4217 code from the record, or the active property's
* @param locale defaults to the browser's, so separators match the viewer
*/
export function formatMoney(
amount: number | string | null | undefined,
currencyCode?: string | null,
locale?: string,
): string {
if (amount === null || amount === undefined || amount === '') return '—';
const value = typeof amount === 'string' ? Number(amount) : amount;
if (!Number.isFinite(value)) return '—';

const code = (currencyCode || activeCurrency).toUpperCase();
try {
return new Intl.NumberFormat(locale, {
style: 'currency',
currency: code,
}).format(value);
} catch {
// Unknown/invalid code: show the number with the code rather than a wrong symbol.
return `${value.toLocaleString(locale)} ${code}`;
}
}

/**
* Format without the symbol — for table columns that carry the currency in the
* header, and for inputs where a symbol would have to be stripped before parsing.
*/
export function formatMoneyPlain(
amount: number | string | null | undefined,
currencyCode?: string | null,
locale?: string,
): string {
if (amount === null || amount === undefined || amount === '') return '—';
const value = typeof amount === 'string' ? Number(amount) : amount;
if (!Number.isFinite(value)) return '—';

const code = (currencyCode || activeCurrency).toUpperCase();
try {
const digits = new Intl.NumberFormat(locale, {
style: 'currency',
currency: code,
}).resolvedOptions().maximumFractionDigits;
return new Intl.NumberFormat(locale, {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
}).format(value);
} catch {
return value.toLocaleString(locale);
}
}
2 changes: 2 additions & 0 deletions apps/dashboard/src/lib/property-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ export interface PropertySummary {
id: string;
name: string;
code: string;
/** ISO 4217 from the property record — drives every money render in the UI. */
currencyCode?: string | null;
organizationId?: string | null;
staffDisplayName?: string | null;
staffLogoMediaId?: string | null;
Expand Down
19 changes: 10 additions & 9 deletions apps/dashboard/src/pages/Accounting.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useState } from 'react';
import { formatMoney } from '../lib/money';
import { Routes, Route, Link } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Calculator, Plus, Download, BarChart3, Pencil, Archive } from 'lucide-react';
Expand Down Expand Up @@ -64,7 +65,7 @@ const AGING_LABELS: Record<keyof AgingBuckets, string> = {

function AccountingHome() {
const { t } = useTranslation();
const { propertyId } = useProperty();
const { propertyId, currencyCode } = useProperty();
const queryClient = useQueryClient();
const today = format(new Date(), 'yyyy-MM-dd');
const [depositOpen, setDepositOpen] = useState(false);
Expand Down Expand Up @@ -149,7 +150,7 @@ function AccountingHome() {
return api.post('/v1/deposits', {
propertyId,
amount: moneyString(depositAmount),
currencyCode: 'USD',
currencyCode,
});
},
onSuccess: () => {
Expand Down Expand Up @@ -215,7 +216,7 @@ function AccountingHome() {
propertyId,
name: ledgerName,
paymentTermsDays: ledgerTerms || undefined,
currencyCode: 'USD',
currencyCode,
});
},
onSuccess: () => {
Expand Down Expand Up @@ -340,12 +341,12 @@ function AccountingHome() {
{(Object.keys(AGING_LABELS) as (keyof AgingBuckets)[]).map((key) => (
<div key={key} className="flex justify-between border-b border-gray-50 py-1">
<span>{AGING_LABELS[key]}</span>
<span className="font-medium">${Number(report.buckets[key] ?? 0).toFixed(2)}</span>
<span className="font-medium">{formatMoney(report.buckets[key] ?? 0)}</span>
</div>
))}
<div className="flex justify-between pt-2 font-semibold">
<span>{t('accounting.total')}</span>
<span>${Number(report.total ?? 0).toFixed(2)}</span>
<span>{formatMoney(report.total ?? 0)}</span>
</div>
</div>
) : (
Expand Down Expand Up @@ -396,7 +397,7 @@ function AccountingHome() {
<ul className="space-y-2 text-sm">
{deposits.slice(0, 8).map((d) => (
<li key={d.id} className="flex justify-between items-center border-b border-gray-50 py-1">
<span>${Number(d.amount).toFixed(2)}</span>
<span>{formatMoney(d.amount)}</span>
<span className="text-telivity-mid-grey">{d.status}</span>
{d.status === 'held' && (
<button
Expand Down Expand Up @@ -492,7 +493,7 @@ function AccountingHome() {
<span className="ml-2 text-xs text-telivity-mid-grey">({t('accounting.closed')})</span>
)}
</span>
<span className="font-medium shrink-0">${Number(l.balance ?? 0).toFixed(2)}</span>
<span className="font-medium shrink-0">{formatMoney(l.balance ?? 0)}</span>
<div className="flex flex-wrap gap-2 justify-end">
<button onClick={() => { setSelectedLedger(l); setArActionOpen('payment'); }} className="text-xs text-telivity-teal hover:underline">{t('accounting.payment')}</button>
<button onClick={async () => { setSelectedLedger(l); setArActionOpen('aging'); await refetchAging(); }} className="text-xs text-telivity-teal hover:underline">{t('accounting.aging')}</button>
Expand Down Expand Up @@ -554,7 +555,7 @@ function AccountingHome() {
</div>
</Modal>

<Modal open={depositActionOpen} onClose={() => setDepositActionOpen(false)} title={t('accounting.depositActions', { amount: Number(selectedDeposit?.amount ?? 0).toFixed(2) })}>
<Modal open={depositActionOpen} onClose={() => setDepositActionOpen(false)} title={t('accounting.depositActions', { amount: formatMoney(selectedDeposit?.amount ?? 0) })}>
<div className="space-y-4">
<input type="text" value={applyFolioId} onChange={(e) => setApplyFolioId(e.target.value)} placeholder={t('accounting.folioIdPlaceholder')} className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm font-mono text-xs" />
<button onClick={() => applyDeposit.mutate()} disabled={applyDeposit.isPending} className="w-full bg-telivity-teal text-white rounded-lg px-4 py-2 text-sm font-semibold disabled:opacity-50">{t('accounting.applyToFolio')}</button>
Expand Down Expand Up @@ -596,7 +597,7 @@ function AccountingHome() {
<option value="">{t('accounting.selectTransaction')}</option>
{reversible.map((tx) => (
<option key={tx.id} value={tx.id}>
${Number(tx.amount).toFixed(2)} · {tx.createdAt?.split('T')[0] ?? tx.id.slice(0, 8)}
{formatMoney(tx.amount)} · {tx.createdAt?.split('T')[0] ?? tx.id.slice(0, 8)}
</option>
))}
</select>
Expand Down
15 changes: 8 additions & 7 deletions apps/dashboard/src/pages/Cashier.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useState, useEffect } from 'react';
import { formatMoney } from '../lib/money';
import { Routes, Route, useNavigate, useParams } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Banknote, Plus, ChevronLeft, FileText } from 'lucide-react';
Expand Down Expand Up @@ -85,7 +86,7 @@ function CashierHome() {
{drawers.map((d, i) => (
<tr key={d.id} className={`border-b border-gray-50 ${i % 2 === 1 ? 'bg-gray-50/50' : ''}`}>
<td className="px-4 py-3 text-sm font-medium text-telivity-navy">{d.name}</td>
<td className="px-4 py-3 text-sm text-right">${Number(d.startingFloat ?? 0).toFixed(2)}</td>
<td className="px-4 py-3 text-sm text-right">{formatMoney(d.startingFloat ?? 0)}</td>
<td className="px-4 py-3 text-right">
<button onClick={() => navigate(`/cashier/sessions/${d.id}`)} className="text-xs font-semibold text-telivity-teal hover:underline">{t('cashier.openSession')}</button>
</td>
Expand Down Expand Up @@ -222,7 +223,7 @@ function CashierSession() {
<div className="bg-white rounded-xl shadow-sm p-6 max-w-md space-y-4">
{drawer?.startingFloat != null && (
<p className="text-sm text-telivity-mid-grey">
{t('cashier.startingFloat')}: ${Number(drawer.startingFloat).toFixed(2)}
{t('cashier.startingFloat')}: {formatMoney(drawer.startingFloat)}
</p>
)}
<div>
Expand Down Expand Up @@ -291,10 +292,10 @@ function SessionReport() {
) : (
<div className="space-y-4">
<div className="bg-white rounded-xl shadow-sm p-5 grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div><p className="text-xs text-telivity-mid-grey">{t('cashier.openingFloat')}</p><p className="font-semibold">${session?.openingFloat ?? '0.00'}</p></div>
<div><p className="text-xs text-telivity-mid-grey">{t('cashier.expected')}</p><p className="font-semibold">${report.expectedBalance ?? '0.00'}</p></div>
<div><p className="text-xs text-telivity-mid-grey">{t('cashier.counted')}</p><p className="font-semibold">${session?.countedBalance ?? '—'}</p></div>
<div><p className="text-xs text-telivity-mid-grey">{t('cashier.variance')}</p><p className="font-semibold">${session?.variance ?? '0.00'}</p></div>
<div><p className="text-xs text-telivity-mid-grey">{t('cashier.openingFloat')}</p><p className="font-semibold">{formatMoney(session?.openingFloat ?? 0)}</p></div>
<div><p className="text-xs text-telivity-mid-grey">{t('cashier.expected')}</p><p className="font-semibold">{formatMoney(report.expectedBalance ?? 0)}</p></div>
<div><p className="text-xs text-telivity-mid-grey">{t('cashier.counted')}</p><p className="font-semibold">{formatMoney(session?.countedBalance)}</p></div>
<div><p className="text-xs text-telivity-mid-grey">{t('cashier.variance')}</p><p className="font-semibold">{formatMoney(session?.variance ?? 0)}</p></div>
</div>

<div className="bg-white rounded-xl shadow-sm overflow-hidden">
Expand All @@ -314,7 +315,7 @@ function SessionReport() {
<tr key={type} className="border-b border-gray-50">
<td className="px-4 py-3 text-sm capitalize">{type.replace('_', ' ')}</td>
<td className="px-4 py-3 text-sm text-right">{row.count}</td>
<td className="px-4 py-3 text-sm text-right">${Number(row.total).toFixed(2)}</td>
<td className="px-4 py-3 text-sm text-right">{formatMoney(row.total)}</td>
</tr>
))}
</tbody>
Expand Down
Loading
Loading