mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
refactor(costs): the summary card moves into a component of its own
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user