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>
352 lines
17 KiB
TypeScript
352 lines
17 KiB
TypeScript
'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>
|
||
);
|
||
}
|