Files
Spanglish/frontend/src/app/admin/events/[id]/_finance/FinanceCharts.tsx
T
MichilisandClaude Opus 5.5 e203fb6c74 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>
2026-10-03 03:11:46 +00:00

352 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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>
);
}