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,351 @@
'use client';
// Finance charts (Recharts). Loaded with next/dynamic from SummaryView so the
// chart library only ships when the Finance tab opens.
//
// Colors: one blue for single-series charts (category/method names sit on the
// axis, so identity never relies on color); on the waterfall blue = money in,
// red = money out and gray = totals, with signed value labels and a legend
// that lists only the kinds on screen; blue = paid / orange = planned with a
// legend. Validated with the dataviz palette checks.
import { useEffect, useState } from 'react';
import {
ResponsiveContainer, BarChart, Bar, XAxis, YAxis, Tooltip, CartesianGrid, Cell, ReferenceLine,
LineChart, Line, Legend, LabelList,
} from 'recharts';
import type { FinanceSummary, ExpenseCategory } from '@/lib/api';
import { formatNumber, formatPyg, formatPygShort } from '@/lib/money';
import { buildWaterfall, detailChartsAvailable, type WaterfallKind } from './derive';
const C = {
blue: '#2a78d6',
orange: '#eb6834',
red: '#e34948',
total: '#52514e',
grid: '#eeeeec',
axis: '#8a8984',
text: '#3f3e3b',
};
const KIND_COLOR: Record<WaterfallKind, string> = { in: C.blue, out: C.red, total: C.total };
const KIND_SYMBOL: Record<WaterfallKind, string> = { in: '+', out: '−', total: '=' };
type T = (key: string, params?: Record<string, string | number>) => string;
const axisProps = { stroke: C.axis, fontSize: 12, tickLine: false, axisLine: false } as const;
/** Bar labels: exact under 10.000 PYG, abbreviated above, always with the currency. */
const labelPyg = (v: number, locale: string) => (Math.abs(v) < 10000 ? formatPyg(v, locale) : `${formatPygShort(v, locale)} PYG`);
export function ChartCard({ title, children, subtitle, legend, className }: {
title: string; subtitle?: string; legend?: React.ReactNode; children: React.ReactNode; className?: string;
}) {
return (
<div className={`bg-white rounded-card shadow-card p-4 min-w-0 ${className || ''}`}>
<div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1">
<h3 className="text-sm font-semibold text-primary-dark">{title}</h3>
{legend}
</div>
{subtitle && <p className="text-xs text-gray-500 mt-0.5">{subtitle}</p>}
<div className="mt-3">{children}</div>
</div>
);
}
function LegendChip({ color, label, symbol }: { color: string; label: string; symbol: string }) {
return (
<span className="inline-flex items-center gap-1.5 text-xs text-gray-600">
<span className="w-3 h-3 rounded-sm inline-flex items-center justify-center text-[9px] text-white font-bold" style={{ background: color }} aria-hidden>{symbol}</span>
{label}
</span>
);
}
function PygTooltip({ active, payload, label, rows, locale }: any) {
if (!active || !payload?.length) return null;
const items: { name: string; value: number; color?: string }[] = rows ? rows(payload[0].payload) : payload.map((p: any) => ({ name: p.name, value: p.value, color: p.color }));
return (
<div className="bg-white border border-gray-200 rounded-lg shadow-sm px-3 py-2 text-xs">
{label !== undefined && <p className="font-semibold text-primary-dark mb-1">{label}</p>}
{items.map((it) => (
<p key={it.name} className="flex items-center gap-2 text-gray-600">
{it.color && <span className="w-2 h-2 rounded-full flex-shrink-0" style={{ background: it.color }} />}
<span>{it.name}</span>
<span className="ml-auto pl-3 font-medium text-primary-dark tabular-nums">{formatPyg(it.value, locale)}</span>
</p>
))}
</div>
);
}
// ==================== Waterfall ====================
/** Phones get a horizontal waterfall: six step names never fit side by side. */
function useNarrow(maxWidth = 640) {
const [narrow, setNarrow] = useState(false);
useEffect(() => {
const mq = window.matchMedia(`(max-width: ${maxWidth - 1}px)`);
const update = () => setNarrow(mq.matches);
update();
mq.addEventListener('change', update);
return () => mq.removeEventListener('change', update);
}, [maxWidth]);
return narrow;
}
/** The waterfall bars only; the card around it (with the breakdown) lives in SummaryView. */
export function WaterfallChart({ summary, locale, t, estimated }: { summary: FinanceSummary; locale: string; t: T; estimated: boolean }) {
const narrow = useNarrow();
const steps = buildWaterfall(summary);
let running = 0;
const rows = steps.map((w) => {
const isTotal = w.kind === 'total';
const start = isTotal ? 0 : running;
const end = isTotal ? w.amount : running + w.amount;
running = end;
const name = w.key === 'profit'
? t(w.amount < 0 ? 'admin.finance.kpi.loss' : 'admin.finance.kpi.profit')
: t(`admin.finance.waterfall.${w.key}`);
return {
key: w.key,
kind: w.kind,
name,
amount: w.amount,
range: [Math.min(start, end), Math.max(start, end)] as [number, number],
color: KIND_COLOR[w.kind],
signed: isTotal ? labelPyg(w.amount, locale) : `${w.amount >= 0 ? '+' : '−'}${labelPyg(Math.abs(w.amount), locale)}`,
};
});
const partnerRows = summary.split.partners.map((p) => ({ name: p.name, value: -p.share, color: C.red }));
const tooltipRows = (r: any) => (r.key === 'partners' ? partnerRows : [{ name: r.name, value: r.amount, color: r.color }]);
const kinds = (['in', 'out', 'total'] as WaterfallKind[]).filter((k) => rows.some((r) => r.kind === k));
// Value label above each bar's top edge. Bars under zero hang from the zero
// line, so their label sits just above it and never runs into the axis labels.
const renderLabel = (props: any): React.ReactElement<SVGElement> => {
const { x, y, width, height, index } = props;
const r = rows[index];
if (!r) return <g />;
const top = Math.min(y, y + height);
return (
<text x={x + width / 2} y={top - 6} textAnchor="middle" fontSize={11} fill={C.text} fontWeight={600}>
{r.signed}
</text>
);
};
const short = (v: number) => formatPygShort(v, locale);
return (
<div>
<div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1">
<h3 className="text-sm font-semibold text-primary-dark">{t('admin.finance.waterfall.title')}</h3>
<span className="flex flex-wrap gap-3">
{kinds.map((k) => (
<LegendChip key={k} color={KIND_COLOR[k]} symbol={KIND_SYMBOL[k]} label={t(`admin.finance.waterfall.legend.${k}`)} />
))}
</span>
</div>
{estimated && <p className="text-xs text-gray-500 mt-0.5">{t('admin.finance.waterfall.estimatedHint')}</p>}
<div className="mt-3">
{narrow ? (
// Step name and signed amount as a two-line axis label; bars run left/right from zero.
<ResponsiveContainer width="100%" height={rows.length * 48 + 24}>
<BarChart data={rows} layout="vertical" margin={{ top: 0, right: 12, left: 0, bottom: 0 }}>
<CartesianGrid horizontal={false} stroke={C.grid} />
<XAxis type="number" {...axisProps} tickFormatter={short} fontSize={11} />
<YAxis
type="category"
dataKey="name"
{...axisProps}
width={136}
interval={0}
tick={(props: any) => {
const r = rows[props.index];
return (
<g transform={`translate(${props.x},${props.y})`}>
<text x={-6} y={-3} textAnchor="end" fontSize={11} fill={C.text}>{r?.name}</text>
<text x={-6} y={11} textAnchor="end" fontSize={11} fontWeight={600} fill={C.text}>{r?.signed}</text>
</g>
);
}}
/>
<ReferenceLine x={0} stroke={C.axis} />
<Tooltip cursor={{ fill: 'rgba(0,0,0,0.04)' }} content={<PygTooltip locale={locale} rows={tooltipRows} />} />
<Bar dataKey="range" radius={4} barSize={20} minPointSize={3} isAnimationActive={false}>
{rows.map((r) => <Cell key={r.key} fill={r.color} />)}
</Bar>
</BarChart>
</ResponsiveContainer>
) : (
<ResponsiveContainer width="100%" height={280}>
<BarChart data={rows} margin={{ top: 24, right: 8, left: 0, bottom: 8 }}>
<CartesianGrid vertical={false} stroke={C.grid} />
<XAxis dataKey="name" {...axisProps} interval={0} tick={{ fontSize: 11, fill: C.text }} height={40} />
<YAxis {...axisProps} tickFormatter={short} width={48} />
<ReferenceLine y={0} stroke={C.axis} />
<Tooltip cursor={{ fill: 'rgba(0,0,0,0.04)' }} content={<PygTooltip locale={locale} rows={tooltipRows} />} />
<Bar dataKey="range" radius={4} maxBarSize={56} minPointSize={3} isAnimationActive={false} label={renderLabel}>
{rows.map((r) => <Cell key={r.key} fill={r.color} />)}
</Bar>
</BarChart>
</ResponsiveContainer>
)}
</div>
</div>
);
}
// ==================== Horizontal single-series bars ====================
function HorizontalBars({ data, locale }: { data: { name: string; value: number; detail?: { name: string; value: number }[] }[]; locale: string }) {
return (
<ResponsiveContainer width="100%" height={Math.max(120, data.length * 40 + 28)}>
<BarChart data={data} layout="vertical" margin={{ top: 0, right: 84, left: 0, bottom: 0 }}>
<CartesianGrid horizontal={false} stroke={C.grid} />
<XAxis type="number" {...axisProps} tickFormatter={(v: number) => formatPygShort(v, locale)} />
<YAxis type="category" dataKey="name" {...axisProps} width={104} tick={{ fontSize: 12, fill: C.text }} />
<Tooltip
cursor={{ fill: 'rgba(0,0,0,0.04)' }}
content={<PygTooltip locale={locale} rows={(r: any) => r.detail || [{ name: r.name, value: r.value, color: C.blue }]} />}
/>
<Bar dataKey="value" fill={C.blue} radius={[0, 4, 4, 0]} barSize={18} minPointSize={2} isAnimationActive={false}>
<LabelList dataKey="value" position="right" formatter={(v: number) => labelPyg(v, locale)} style={{ fontSize: 11, fill: C.text, fontWeight: 600 }} />
</Bar>
</BarChart>
</ResponsiveContainer>
);
}
function categoryName(id: string | null, categories: ExpenseCategory[], locale: string, t: T) {
const c = categories.find((x) => x.id === id);
if (!c) return t('admin.finance.charts.uncategorized');
return locale === 'es' ? c.nameEs : c.nameEn;
}
export function ExpensesByCategoryChart({ summary, categories, locale, t }: { summary: FinanceSummary; categories: ExpenseCategory[]; locale: string; t: T }) {
const data = summary.expenses.byCategory
.filter((c) => c.total > 0)
.map((c) => ({ name: categoryName(c.categoryId, categories, locale, t), value: c.total }));
return (
<ChartCard title={t('admin.finance.charts.byCategory')}>
<HorizontalBars data={data} locale={locale} />
</ChartCard>
);
}
export function RevenueByMethodChart({ summary, locale, t }: { summary: FinanceSummary; locale: string; t: T }) {
const data = summary.revenue.byMethod
.filter((m) => m.gross > 0)
.map((m) => ({
name: t(`admin.finance.methods.${m.method}`),
value: m.gross,
detail: [
{ name: t('admin.finance.waterfall.gross'), value: m.gross },
{ name: t('admin.finance.waterfall.refunds'), value: -m.refunds },
{ name: t('admin.finance.waterfall.fees'), value: -m.fees },
{ name: t('admin.finance.waterfall.net'), value: m.net },
],
}));
return (
<ChartCard title={t('admin.finance.charts.byMethod')}>
<HorizontalBars data={data} locale={locale} />
</ChartCard>
);
}
// ==================== Cumulative sales ====================
export function CumulativeSalesChart({ summary, locale, t }: { summary: FinanceSummary; locale: string; t: T }) {
const fmtDay = (d: string) => new Date(`${d}T12:00:00Z`).toLocaleDateString(locale === 'es' ? 'es-PY' : 'en-US', { day: 'numeric', month: 'short' });
const data = summary.salesTimeline.map((p) => ({ ...p, label: fmtDay(p.date) }));
const breakEven = summary.expenses.total > 0 ? summary.breakEven.tickets : null;
const maxSold = Math.max(1, ...data.map((d) => d.tickets));
// Only draw the break-even line when it fits a readable scale; otherwise a
// far-away target would flatten the sales line to the floor.
const showLine = breakEven !== null && breakEven > 0 && breakEven <= maxSold * 3;
const top = Math.ceil(Math.max(maxSold, showLine ? breakEven! : 0) * 1.15);
return (
<ChartCard
title={t('admin.finance.charts.cumulative')}
subtitle={breakEven !== null && breakEven > 0 && !showLine ? t('admin.finance.charts.breakEvenOffChart', { count: formatNumber(breakEven!, locale) }) : undefined}
>
<ResponsiveContainer width="100%" height={240}>
<LineChart data={data} margin={{ top: 16, right: 16, left: 0, bottom: 0 }}>
<CartesianGrid vertical={false} stroke={C.grid} />
<XAxis dataKey="label" {...axisProps} minTickGap={16} />
<YAxis {...axisProps} allowDecimals={false} domain={[0, top]} width={40} tickFormatter={(v: number) => formatNumber(v, locale)}
label={{ value: t('admin.finance.charts.tickets'), angle: -90, position: 'insideLeft', fontSize: 11, fill: C.axis, dy: 30 }} />
<Tooltip
content={({ active, payload }: any) => {
if (!active || !payload?.length) return null;
const p = payload[0].payload;
return (
<div className="bg-white border border-gray-200 rounded-lg shadow-sm px-3 py-2 text-xs">
<p className="font-semibold text-primary-dark mb-1">{p.label}</p>
<p className="text-gray-600">{t('admin.finance.kpi.ticketsSold', { count: formatNumber(p.tickets, locale) })}</p>
<p className="text-gray-600 tabular-nums">{formatPyg(p.revenue, locale)}</p>
</div>
);
}}
/>
{showLine && (
<ReferenceLine
y={breakEven!}
stroke={C.red}
strokeDasharray="4 4"
label={{ value: `${t('admin.finance.charts.breakEvenLine')}: ${formatNumber(breakEven!, locale)}`, position: 'insideTopRight', fontSize: 11, fill: C.text }}
/>
)}
<Line type="monotone" dataKey="tickets" stroke={C.blue} strokeWidth={2} dot={{ r: 4, fill: C.blue, stroke: '#fff', strokeWidth: 2 }} isAnimationActive={false} />
</LineChart>
</ResponsiveContainer>
</ChartCard>
);
}
// ==================== Planned vs paid ====================
export function PlannedVsPaidChart({ summary, categories, locale, t }: { summary: FinanceSummary; categories: ExpenseCategory[]; locale: string; t: T }) {
const data = summary.expenses.byCategory
.filter((c) => c.total > 0)
.map((c) => ({ name: categoryName(c.categoryId, categories, locale, t), paid: c.paid, planned: c.planned }));
const paidLabel = t('admin.finance.charts.paid');
const plannedLabel = t('admin.finance.charts.planned');
const hasPaid = summary.expenses.paid > 0;
const hasPlanned = summary.expenses.planned > 0;
return (
<ChartCard
title={t('admin.finance.charts.plannedVsActual')}
subtitle={`${paidLabel} ${formatPyg(summary.expenses.paid, locale)} · ${plannedLabel} ${formatPyg(summary.expenses.planned, locale)}`}
>
<ResponsiveContainer width="100%" height={Math.max(140, data.length * 40 + 56)}>
<BarChart data={data} layout="vertical" margin={{ top: 0, right: 16, left: 0, bottom: 0 }}>
<CartesianGrid horizontal={false} stroke={C.grid} />
<XAxis type="number" {...axisProps} tickFormatter={(v: number) => formatPygShort(v, locale)} />
<YAxis type="category" dataKey="name" {...axisProps} width={104} tick={{ fontSize: 12, fill: C.text }} />
<Tooltip cursor={{ fill: 'rgba(0,0,0,0.04)' }} content={<PygTooltip locale={locale} />} />
<Legend iconType="circle" iconSize={8} wrapperStyle={{ fontSize: 12, color: C.text }} />
{hasPaid && <Bar dataKey="paid" name={paidLabel} stackId="s" fill={C.blue} stroke="#fff" strokeWidth={2} barSize={18} isAnimationActive={false} />}
{hasPlanned && <Bar dataKey="planned" name={plannedLabel} stackId="s" fill={C.orange} stroke="#fff" strokeWidth={2} radius={[0, 4, 4, 0]} barSize={18} isAnimationActive={false} />}
</BarChart>
</ResponsiveContainer>
</ChartCard>
);
}
export default function FinanceCharts({ summary, categories, locale, t, part, estimated = false }: {
summary: FinanceSummary; categories: ExpenseCategory[]; locale: string; t: T; part: 'waterfall' | 'details'; estimated?: boolean;
}) {
if (part === 'waterfall') return <WaterfallChart summary={summary} locale={locale} t={t} estimated={estimated} />;
const show = detailChartsAvailable(summary);
if (!show.costs && !show.methods && !show.timeline) return null;
return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{show.costs && <ExpensesByCategoryChart summary={summary} categories={categories} locale={locale} t={t} />}
{show.methods && <RevenueByMethodChart summary={summary} locale={locale} t={t} />}
{show.timeline && <CumulativeSalesChart summary={summary} locale={locale} t={t} />}
{show.costs && <PlannedVsPaidChart summary={summary} categories={categories} locale={locale} t={t} />}
</div>
);
}