Files
Spanglish/frontend/src/app/admin/events/[id]/_finance/SummaryView.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

238 lines
12 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';
import dynamic from 'next/dynamic';
import clsx from 'clsx';
import {
BanknotesIcon, ShoppingBagIcon, ArrowTrendingUpIcon, ArrowTrendingDownIcon, ScaleIcon, EyeSlashIcon,
ExclamationTriangleIcon, CheckCircleIcon, ChevronDownIcon, PlusIcon,
} from '@heroicons/react/24/outline';
import { useLanguage } from '@/context/LanguageContext';
import { usePrivacy } from '@/context/PrivacyContext';
import { useMoney } from '@/lib/useMoney';
import type { EventFinance } from '@/lib/api';
import Button from '@/components/ui/Button';
import { Skeleton } from '@/components/ui/Skeleton';
import { Money, EmptyState, Pill } from './ui';
import { breakEvenState, detailChartsAvailable } from './derive';
import type { FinanceSection } from './FinanceTab';
// Recharts only loads when someone opens the Finance tab.
const FinanceCharts = dynamic(() => import('./FinanceCharts'), {
ssr: false,
loading: () => <Skeleton className="h-64" />,
});
function Kpi({ label, tag, value, detail, icon: Icon, tone, children }: {
label: string; tag?: React.ReactNode; value: React.ReactNode; detail?: React.ReactNode; icon: typeof BanknotesIcon;
tone: 'blue' | 'orange' | 'green' | 'red' | 'purple' | 'gray'; children?: React.ReactNode;
}) {
const tones = {
blue: 'bg-blue-50 text-blue-600', orange: 'bg-orange-50 text-orange-600', green: 'bg-green-50 text-green-600',
red: 'bg-red-50 text-red-600', purple: 'bg-purple-50 text-purple-600', gray: 'bg-gray-100 text-gray-600',
};
return (
<div className="bg-white rounded-card shadow-card p-3 md:p-4 min-w-0 flex flex-col">
<div className="flex items-center gap-2 min-w-0">
<span className={clsx('w-7 h-7 rounded-full flex items-center justify-center flex-shrink-0', tones[tone])}><Icon className="w-4 h-4" aria-hidden /></span>
<p className="text-xs md:text-sm text-gray-500 truncate">{label}</p>
{tag}
</div>
<div className="mt-2 text-lg md:text-2xl font-bold leading-tight break-words">{value}</div>
{detail && <div className="mt-1 text-xs text-gray-500 space-y-0.5">{detail}</div>}
{children}
</div>
);
}
/** The P&L as a compact statement under the waterfall, in the same order as its bars. */
function Breakdown({ data, finalized }: { data: EventFinance; finalized: boolean }) {
const { t, locale } = useLanguage();
const { num } = useMoney();
const s = data.summary;
const catName = (id: string | null) => {
const c = data.categories.find((x) => x.id === id);
return c ? (locale === 'es' ? c.nameEs : c.nameEn) : t('admin.finance.charts.uncategorized');
};
const resultTone = (v: number | null) => (finalized && v !== null ? (v < 0 ? 'text-red-600' : 'text-green-700') : undefined);
const Row = ({ label, value, sign, strong, muted, className }: {
label: React.ReactNode; value: number | null; sign?: '+' | '−'; strong?: boolean; muted?: boolean; className?: string;
}) => (
<div className={clsx('flex items-baseline justify-between gap-3 py-1', strong && 'font-semibold', muted && 'text-gray-500')}>
<span className="min-w-0">{sign && <span className="inline-block w-4 text-gray-400" aria-hidden>{sign}</span>}{label}</span>
<Money value={value} className={clsx('whitespace-nowrap', className)} />
</div>
);
const partners = s.split.partners;
const showSplit = partners.length > 0 && s.split.organization !== null;
return (
<div className="text-sm">
<h3 className="text-sm font-semibold text-primary-dark mb-1">{t('admin.finance.statement.title')}</h3>
<Row label={t('admin.finance.statement.ticketSales', { count: num(s.counts.ticketsSold) })} value={s.revenue.gross} />
{s.revenue.refunds > 0 && <Row sign="−" label={t('admin.finance.waterfall.refunds')} value={s.revenue.refunds} muted />}
{s.revenue.fees > 0 && <Row sign="−" label={t('admin.finance.waterfall.fees')} value={s.revenue.fees} muted />}
{s.expenses.total > 0 && (
<details className="group">
<summary className="list-none cursor-pointer flex items-baseline justify-between gap-3 py-1 text-gray-500 [&::-webkit-details-marker]:hidden">
<span className="inline-flex items-center gap-1">
<span className="inline-block w-4 text-gray-400" aria-hidden>−</span>
{t('admin.finance.waterfall.expenses')}
<ChevronDownIcon className="w-3.5 h-3.5 transition-transform group-open:rotate-180" aria-hidden />
</span>
<Money value={s.expenses.total} className="whitespace-nowrap" />
</summary>
<div className="pl-5 pb-1">
{s.expenses.byCategory.map((c) => (
<div key={c.categoryId || 'none'} className="flex justify-between gap-3 py-0.5 text-xs text-gray-500">
<span className="truncate">{catName(c.categoryId)}</span>
<Money value={c.total} className="whitespace-nowrap" />
</div>
))}
</div>
</details>
)}
{s.revenue.otherIncome > 0 && <Row sign="+" label={t('admin.finance.waterfall.otherIncome')} value={s.revenue.otherIncome} muted />}
<div className="border-t border-gray-200 mt-1" />
<Row label={t(s.profit < 0 ? 'admin.finance.kpi.loss' : 'admin.finance.kpi.profit')} value={s.profit} strong className={resultTone(s.profit)} />
{showSplit && (
<>
{partners.map((p) => (
<Row
key={p.partnerId}
sign={p.share < 0 ? '+' : '−'}
label={<>{p.name}{p.share < 0 && <span className="block text-xs text-gray-400 pl-4">{t('admin.finance.statement.carriesLoss')}</span>}</>}
value={Math.abs(p.share)}
muted
/>
))}
<div className="border-t border-gray-200 mt-1" />
<Row label={t('admin.finance.split.organization')} value={s.split.organization} strong className={resultTone(s.split.organization)} />
</>
)}
</div>
);
}
export function SummaryView({ data, goTo }: { data: EventFinance; goTo: (s: FinanceSection) => void }) {
const { t, locale } = useLanguage();
const { privacyMode } = usePrivacy();
const { num } = useMoney();
const s = data.summary;
const finalized = data.status !== 'open';
const loss = s.profit < 0;
const capacity = data.event.capacity;
const canAdd = data.status === 'open' && (data.viewer.canEditExpenses || data.viewer.canEditOwnExpenses);
const nothingYet = s.revenue.gross === 0 && s.expenses.total === 0 && s.revenue.otherIncome === 0;
if (nothingYet) {
return (
<EmptyState icon={<BanknotesIcon className="w-5 h-5" />} title={t('admin.finance.empty.title')} body={t('admin.finance.empty.body')}>
{canAdd && <Button onClick={() => goTo('expenses')}><PlusIcon className="w-4 h-4 mr-1" />{t('admin.finance.expenses.add')}</Button>}
</EmptyState>
);
}
const sold = s.counts.ticketsSold;
const be = breakEvenState(s);
let breakEvenValue: React.ReactNode;
let breakEvenDetail: React.ReactNode;
let breakEvenBar: number | null = null;
if (be.kind === 'noCosts') {
breakEvenValue = <span className="text-sm md:text-base font-semibold leading-snug text-gray-500">{t('admin.finance.breakEven.noCosts')}</span>;
breakEvenDetail = t('admin.finance.breakEven.noCostsHint');
} else if (be.kind === 'unreachable') {
breakEvenValue = <span className="text-sm md:text-base font-semibold leading-snug">{t('admin.finance.kpi.breakEvenNever')}</span>;
} else if (privacyMode) {
breakEvenValue = '••••';
} else if (be.kind === 'reached') {
breakEvenValue = (
<span className="text-sm md:text-base font-semibold leading-snug inline-flex items-start gap-1.5 text-green-700">
<CheckCircleIcon className="w-5 h-5 flex-shrink-0 mt-0.5" aria-hidden />
{be.at === 0
? t('admin.finance.breakEven.coveredWithoutTickets')
: t('admin.finance.breakEven.reached', { at: num(be.at), sold: num(be.sold) })}
</span>
);
} else {
breakEvenValue = <span className="text-sm md:text-base font-semibold leading-snug">{t('admin.finance.breakEven.needed', { count: num(be.remaining) })}</span>;
breakEvenBar = be.pct;
breakEvenDetail = be.needed > capacity
? <span className="inline-flex items-start gap-1 text-amber-700"><ExclamationTriangleIcon className="w-4 h-4 flex-shrink-0" aria-hidden />{t('admin.finance.kpi.beyondCapacity', { capacity: num(capacity) })}</span>
: t('admin.finance.kpi.breakEvenProgress', { sold: num(be.sold), total: num(be.needed) });
}
const show = detailChartsAvailable(s);
return (
<div className="space-y-4">
<div className="grid grid-cols-1 min-[420px]:grid-cols-2 lg:grid-cols-4 gap-3">
<Kpi
label={t('admin.finance.kpi.ticketRevenue')}
value={<Money value={s.revenue.sales} />}
detail={privacyMode ? undefined : (
<>
<p>{t('admin.finance.kpi.revenueDetail', { count: num(sold) })}</p>
{s.revenue.gross > 0 && (
<p>{t('admin.finance.kpi.presale')} <Money value={s.revenue.presale} /> · {t('admin.finance.kpi.door')} <Money value={s.revenue.door} /></p>
)}
</>
)}
icon={BanknotesIcon}
tone="blue"
/>
<Kpi
label={t('admin.finance.kpi.expenses')}
value={<Money value={s.expenses.total} />}
detail={privacyMode || s.expenses.total === 0 ? undefined : (
<span>{t('admin.finance.charts.paid')} <Money value={s.expenses.paid} /> · {t('admin.finance.charts.planned')} <Money value={s.expenses.planned} /></span>
)}
icon={ShoppingBagIcon}
tone="orange"
/>
<Kpi
label={t(loss ? 'admin.finance.kpi.loss' : 'admin.finance.kpi.profit')}
tag={!finalized && <Pill>{t('admin.finance.kpi.estimated')}</Pill>}
value={<Money value={s.profit} className={clsx(finalized && !privacyMode && (loss ? 'text-red-600' : 'text-green-700'))} />}
detail={t(finalized ? 'admin.finance.kpi.afterFees' : 'admin.finance.kpi.estimatedHint')}
icon={loss ? ArrowTrendingDownIcon : ArrowTrendingUpIcon}
tone={finalized ? (loss ? 'red' : 'green') : 'gray'}
/>
<Kpi label={t('admin.finance.kpi.breakEven')} value={breakEvenValue} detail={breakEvenDetail} icon={ScaleIcon} tone="purple">
{breakEvenBar !== null && (
<div className="mt-2 h-1.5 rounded-full bg-gray-100 overflow-hidden" role="progressbar" aria-valuenow={breakEvenBar} aria-valuemin={0} aria-valuemax={100}>
<div className="h-full rounded-full bg-purple-500" style={{ width: `${Math.max(breakEvenBar, 2)}%` }} />
</div>
)}
</Kpi>
</div>
{privacyMode ? (
<div className="bg-white rounded-card shadow-card p-8 text-center text-sm text-gray-500 flex flex-col items-center gap-2">
<EyeSlashIcon className="w-6 h-6 text-gray-400" aria-hidden />
{t('admin.finance.hiddenStats')}
</div>
) : (
<>
<div className="bg-white rounded-card shadow-card p-4 grid grid-cols-1 lg:grid-cols-5 gap-4 lg:gap-6">
<div className="lg:col-span-3 min-w-0">
<FinanceCharts part="waterfall" summary={s} categories={data.categories} locale={locale} t={t} estimated={!finalized} />
</div>
<div className="lg:col-span-2 min-w-0 border-t lg:border-t-0 lg:border-l border-gray-100 pt-4 lg:pt-0 lg:pl-6">
<Breakdown data={data} finalized={finalized} />
</div>
</div>
<FinanceCharts part="details" summary={s} categories={data.categories} locale={locale} t={t} />
{!show.costs && (
<p className="text-sm text-gray-400 text-center py-2">
{canAdd ? (
<button onClick={() => goTo('expenses')} className="hover:underline cursor-pointer">{t('admin.finance.charts.addExpensesHint')}</button>
) : t('admin.finance.charts.addExpensesHint')}
</p>
)}
</>
)}
</div>
);
}