diff --git a/client/src/components/Budget/CostsPanel.tsx b/client/src/components/Budget/CostsPanel.tsx index 349eb3a39..857201a3f 100644 --- a/client/src/components/Budget/CostsPanel.tsx +++ b/client/src/components/Budget/CostsPanel.tsx @@ -38,6 +38,7 @@ import { AddRowButton, EditorField, GRID_2, INPUT, PillSelect, Segmented, TEXTAR import { CountPill, EYEBROW } from '../Planner/bookings/bookingParts' import CostsToolbar, { type CostsView } from './CostsToolbar' import CostsTable, { CostsTableSummary } from './CostsTable' +import SummaryCard from './CostsSummaryCard' /** Split chips a row shows before the rest fold into "+N" (#1763). */ const SPLIT_CHIP_MAX = 6 @@ -1025,45 +1026,6 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps } // ── pure subcomponents ───────────────────────────────────────────────────── -/** A tone's colour, from the status tokens the booking cards use. */ -const TONE_COLOR = { owe: 'var(--danger)', owed: 'var(--success)', unfinished: 'var(--warning)', total: 'var(--accent)' } as const - -/** - * One of the four figures on top, drawn like a booking card: a head band tinted - * by what the figure means, with its icon, name and a line on what it counts, - * then the amount and a foot with who or what is behind it. - */ -function SummaryCard({ label, sub, amount, currency, locale, icon, foot, tone }: { label: string; sub: string; amount: number; currency: string; locale: string; icon: React.ReactNode; foot: React.ReactNode; tone: 'owe' | 'owed' | 'total' | 'unfinished' }) { - const color = TONE_COLOR[tone] - // formatToParts keeps the design's "big integer + muted symbol/decimals" styling - // while letting Intl place the symbol and pick separators per locale + currency. - let parts: Intl.NumberFormatPart[] | null = null - try { - const d = currencyDecimals(currency) - parts = new Intl.NumberFormat(currencyLocale(currency), { style: 'currency', currency: (currency || 'EUR').toUpperCase(), minimumFractionDigits: d, maximumFractionDigits: d }).formatToParts(amount || 0) - } catch { parts = null } - const big = (p: Intl.NumberFormatPart) => p.type === 'integer' || p.type === 'group' || p.type === 'minusSign' - return ( -
-
- {icon} -
-
{label}
-
{sub}
-
-
-
-
- {parts - ? parts.map((p, i) => {p.value}) - : {formatMoney(amount, currency, locale)}} -
-
{foot}
-
-
- ) -} - function FlowPills({ ids, lead, Avatar, name }: { ids: number[]; lead: string; Avatar: (p: { id: number; size?: number }) => React.JSX.Element; name: (id: number) => string }) { const uniq = Array.from(new Set(ids)) return ( diff --git a/client/src/components/Budget/CostsSummaryCard.test.tsx b/client/src/components/Budget/CostsSummaryCard.test.tsx new file mode 100644 index 000000000..b72ff4f12 --- /dev/null +++ b/client/src/components/Budget/CostsSummaryCard.test.tsx @@ -0,0 +1,32 @@ +// FE-COSTS-CARD-001 to -002: the figure cards on top of the Costs panel +import { render, screen, within } from '../../../tests/helpers/render'; +import SummaryCard from './CostsSummaryCard'; + +const card = (currency: string) => ( + Two expenses} + tone="total" + /> +); + +describe('CostsSummaryCard', () => { + it('FE-COSTS-CARD-001: mutes the symbol and the decimals beside the whole amount', () => { + render(card('USD')); + const region = screen.getByRole('region', { name: 'Total spend' }); + expect(region).toHaveTextContent('$1,234.50'); + expect(within(region).getByText('$')).toHaveClass('font-medium'); + expect(within(region).getByText('234')).not.toHaveClass('font-medium'); + expect(within(region).getByText('Two expenses')).toBeInTheDocument(); + }); + + it('FE-COSTS-CARD-002: falls back to the plain amount for a code Intl does not know', () => { + render(card('EURO')); + expect(screen.getByRole('region', { name: 'Total spend' })).toHaveTextContent('1,234.50 EURO'); + }); +}); diff --git a/client/src/components/Budget/CostsSummaryCard.tsx b/client/src/components/Budget/CostsSummaryCard.tsx new file mode 100644 index 000000000..114fb94f9 --- /dev/null +++ b/client/src/components/Budget/CostsSummaryCard.tsx @@ -0,0 +1,104 @@ +import { currencyDecimals, currencyLocale, formatMoney } from '../../utils/formatters'; +import { fs } from '../shared/DialogShell'; + +/** A tone's colour, from the status tokens the booking cards use. */ +const TONE_COLOR = { + owe: 'var(--danger)', + owed: 'var(--success)', + unfinished: 'var(--warning)', + total: 'var(--accent)', +} as const; + +/** + * One of the four figures on top, drawn like a booking card: a head band tinted + * by what the figure means, with its icon, name and a line on what it counts, + * then the amount and a foot with who or what is behind it. + */ +export default function SummaryCard({ + label, + sub, + amount, + currency, + locale, + icon, + foot, + tone, +}: { + label: string; + sub: string; + amount: number; + currency: string; + locale: string; + icon: React.ReactNode; + foot: React.ReactNode; + tone: 'owe' | 'owed' | 'total' | 'unfinished'; +}) { + const color = TONE_COLOR[tone]; + // formatToParts keeps the design's "big integer + muted symbol/decimals" styling + // while letting Intl place the symbol and pick separators per locale + currency. + let parts: Intl.NumberFormatPart[] | null = null; + try { + const d = currencyDecimals(currency); + parts = new Intl.NumberFormat(currencyLocale(currency), { + style: 'currency', + currency: (currency || 'EUR').toUpperCase(), + minimumFractionDigits: d, + maximumFractionDigits: d, + }).formatToParts(amount || 0); + } catch { + parts = null; + } + const big = (p: Intl.NumberFormatPart) => p.type === 'integer' || p.type === 'group' || p.type === 'minusSign'; + return ( +
+
+ + {icon} + +
+
+ {label} +
+
+ {sub} +
+
+
+
+
+ {parts ? ( + parts.map((p, i) => ( + + {p.value} + + )) + ) : ( + {formatMoney(amount, currency, locale)} + )} +
+
+ {foot} +
+
+
+ ); +}