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
@@ -0,0 +1,20 @@
// Client-side preview of an expense amount, mirroring
// backend/src/lib/finance/calculate.ts expenseAmount(). Only used to show
// "this will be X PYG right now" while typing; the server stays authoritative.
import type { CalcType } from '@/lib/api';
export interface PreviewContext { ticketsSold: number; checkedIn: number; sales: number }
export function previewExpense(
e: { calcType: CalcType; quantity: number; unitAmount: number; percentBp: number; minimumAmount: number },
ctx: PreviewContext,
): { amount: number; count: number } {
switch (e.calcType) {
case 'per_ticket_sold': return { amount: e.unitAmount * ctx.ticketsSold, count: ctx.ticketsSold };
case 'per_checked_in': return { amount: e.unitAmount * ctx.checkedIn, count: ctx.checkedIn };
case 'percent_of_revenue': return { amount: Math.round((Math.max(0, ctx.sales) * e.percentBp) / 10000), count: 0 };
case 'minimum_spend': return { amount: Math.max(e.minimumAmount, e.unitAmount * ctx.checkedIn), count: ctx.checkedIn };
default: return { amount: e.unitAmount * e.quantity, count: e.quantity };
}
}