Event finance - Finance tab on the event page: P&L summary with a revenue-to-result waterfall, costs and other income in one ledger, and the partner split with payouts. Lifecycle stepper (Selling, Adding costs, Ready to close, Finalized) with what is left to do; closing the books goes through a checklist dialog that freezes the numbers. - Expense modal shows only the fields each calculation type needs, a live preview with the event's real counts, and a category suggested from the description. Date inputs follow the UI language. - Global Finance page: profit per event with outliers clipped and labelled, and a "Ready to close" list of past events whose books are still open. - Calculation service (integer PYG, basis points) with pinned regression scenarios; PYG formatting centralised in lib/money with locale-aware separators. Event team permissions - Per-event members with role presets and requireEventPermission; the header stat "Confirmed" is relabelled "Not checked in yet", which is what it counts. Public sales state - One sales state (online, door, sold out, ended, external, cancelled) for the event page, listings and JSON-LD, with the door price and tenders shown only while people can still pay at the door. Frontend unit tests run with vitest (npm test in frontend/). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
41 lines
1.6 KiB
TypeScript
41 lines
1.6 KiB
TypeScript
// The one place PYG amounts are formatted for admin screens (cards, tables,
|
|
// charts, modal previews). PYG has no decimals. Separators follow the UI
|
|
// language: English 2,470,000 PYG, Spanish 2.470.000 PYG. Grouping is done by
|
|
// hand so it does not depend on the browser's locale data. Components use the
|
|
// useMoney() hook (lib/useMoney.ts) to bind these to the active language.
|
|
|
|
export type MoneyLocale = 'en' | 'es';
|
|
|
|
const SEP: Record<MoneyLocale, { group: string; decimal: string }> = {
|
|
en: { group: ',', decimal: '.' },
|
|
es: { group: '.', decimal: ',' },
|
|
};
|
|
|
|
const sepFor = (locale: string) => SEP[locale === 'en' ? 'en' : 'es'];
|
|
|
|
export function formatNumber(n: number, locale: string = 'es'): string {
|
|
const sign = n < 0 ? '-' : '';
|
|
return sign + Math.abs(Math.round(n)).toString().replace(/\B(?=(\d{3})+(?!\d))/g, sepFor(locale).group);
|
|
}
|
|
|
|
export function formatPyg(n: number, locale: string = 'es'): string {
|
|
return `${formatNumber(n, locale)} PYG`;
|
|
}
|
|
|
|
/** Axis labels: 2.470.000 -> 2,5M (es) / 2.5M (en); 850.000 -> 850k. */
|
|
export function formatPygShort(n: number, locale: string = 'es'): string {
|
|
const abs = Math.abs(n);
|
|
const sign = n < 0 ? '-' : '';
|
|
if (abs >= 1_000_000) {
|
|
return `${sign}${(abs / 1_000_000).toFixed(abs >= 10_000_000 ? 0 : 1).replace('.', sepFor(locale).decimal)}M`;
|
|
}
|
|
if (abs >= 1_000) return `${sign}${Math.round(abs / 1_000)}k`;
|
|
return `${sign}${abs}`;
|
|
}
|
|
|
|
/** Basis points as a percent string: 290 -> "2.9%" (en) / "2,9%" (es). */
|
|
export function formatPercent(bp: number, locale: string = 'es'): string {
|
|
const v = Math.round(bp) / 100;
|
|
return `${String(v).replace('.', sepFor(locale).decimal)}%`;
|
|
}
|