Add per-event finance, event team permissions and public door sales state.

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>
This commit is contained in:
Michilis
2026-10-03 03:11:46 +00:00
co-authored by Claude Opus 5.5
parent b51c1360e2
commit e203fb6c74
80 changed files with 11326 additions and 971 deletions
+40
View File
@@ -0,0 +1,40 @@
// 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)}%`;
}