refactor(costs): the summary card moves into a component of its own

This commit is contained in:
Maurice
2026-10-12 21:14:05 +00:00
parent 191b02b6de
commit b21f9d6739
3 changed files with 137 additions and 39 deletions
+1 -39
View File
@@ -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 (
<section aria-label={label} className="flex flex-col overflow-hidden rounded-2xl border border-edge-faint bg-surface-secondary">
<div className="flex items-center gap-2.5 border-b border-edge-faint px-4 py-3" style={{ background: `color-mix(in srgb, ${color} ${tone === 'total' ? 7 : 11}%, transparent)` }}>
<span className="grid h-8 w-8 flex-none place-items-center rounded-[10px] bg-surface-card shadow-sm" style={{ color: tone === 'total' ? 'var(--text-primary)' : color }}>{icon}</span>
<div className="min-w-0">
<div className="truncate font-bold text-content" style={fs(13.5, 'body')}>{label}</div>
<div className="truncate text-content-faint" style={fs(11.5)}>{sub}</div>
</div>
</div>
<div className="flex flex-1 flex-col gap-3 px-4 pb-4 pt-3.5">
<div className="flex items-baseline font-semibold leading-none tracking-[-0.035em]" style={{ fontSize: 'calc(40px * var(--fs-scale-title, 1))', color: tone === 'total' ? 'var(--text-primary)' : color }}>
{parts
? parts.map((p, i) => <span key={i} className={big(p) ? undefined : 'font-medium text-content-faint'} style={big(p) ? undefined : { fontSize: 'calc(22px * var(--fs-scale-title, 1))' }}>{p.value}</span>)
: <span>{formatMoney(amount, currency, locale)}</span>}
</div>
<div className="mt-auto flex flex-wrap items-center gap-1.5 text-content-muted" style={fs(12.5, 'body')}>{foot}</div>
</div>
</section>
)
}
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 (
@@ -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) => (
<SummaryCard
label="Total spend"
sub="Everything the trip has cost"
amount={1234.5}
currency={currency}
locale="en"
icon={null}
foot={<span>Two expenses</span>}
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');
});
});
@@ -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 (
<section
aria-label={label}
className="flex flex-col overflow-hidden rounded-2xl border border-edge-faint bg-surface-secondary"
>
<div
className="flex items-center gap-2.5 border-b border-edge-faint px-4 py-3"
style={{ background: `color-mix(in srgb, ${color} ${tone === 'total' ? 7 : 11}%, transparent)` }}
>
<span
className="grid h-8 w-8 flex-none place-items-center rounded-[10px] bg-surface-card shadow-sm"
style={{ color: tone === 'total' ? 'var(--text-primary)' : color }}
>
{icon}
</span>
<div className="min-w-0">
<div className="truncate font-bold text-content" style={fs(13.5, 'body')}>
{label}
</div>
<div className="truncate text-content-faint" style={fs(11.5)}>
{sub}
</div>
</div>
</div>
<div className="flex flex-1 flex-col gap-3 px-4 pb-4 pt-3.5">
<div
className="flex items-baseline font-semibold leading-none tracking-[-0.035em]"
style={{
fontSize: 'calc(40px * var(--fs-scale-title, 1))',
color: tone === 'total' ? 'var(--text-primary)' : color,
}}
>
{parts ? (
parts.map((p, i) => (
<span
key={i}
className={big(p) ? undefined : 'font-medium text-content-faint'}
style={big(p) ? undefined : { fontSize: 'calc(22px * var(--fs-scale-title, 1))' }}
>
{p.value}
</span>
))
) : (
<span>{formatMoney(amount, currency, locale)}</span>
)}
</div>
<div className="mt-auto flex flex-wrap items-center gap-1.5 text-content-muted" style={fs(12.5, 'body')}>
{foot}
</div>
</div>
</section>
);
}