'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: () => ,
});
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 (
{value}
{detail &&
{detail}
}
{children}
);
}
/** 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;
}) => (
{sign && {sign}}{label}
);
const partners = s.split.partners;
const showSplit = partners.length > 0 && s.split.organization !== null;
return (
{t('admin.finance.statement.title')}
|
{s.revenue.refunds > 0 &&
|
}
{s.revenue.fees > 0 &&
|
}
{s.expenses.total > 0 && (
−
{t('admin.finance.waterfall.expenses')}
{s.expenses.byCategory.map((c) => (
{catName(c.categoryId)}
))}
)}
{s.revenue.otherIncome > 0 &&
|
}
|
{showSplit && (
<>
{partners.map((p) => (
{p.name}{p.share < 0 && {t('admin.finance.statement.carriesLoss')}}>}
value={Math.abs(p.share)}
muted
/>
))}
>
)}
);
}
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 (
} title={t('admin.finance.empty.title')} body={t('admin.finance.empty.body')}>
{canAdd && }
);
}
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 = {t('admin.finance.breakEven.noCosts')};
breakEvenDetail = t('admin.finance.breakEven.noCostsHint');
} else if (be.kind === 'unreachable') {
breakEvenValue = {t('admin.finance.kpi.breakEvenNever')};
} else if (privacyMode) {
breakEvenValue = '••••';
} else if (be.kind === 'reached') {
breakEvenValue = (
{be.at === 0
? t('admin.finance.breakEven.coveredWithoutTickets')
: t('admin.finance.breakEven.reached', { at: num(be.at), sold: num(be.sold) })}
);
} else {
breakEvenValue = {t('admin.finance.breakEven.needed', { count: num(be.remaining) })};
breakEvenBar = be.pct;
breakEvenDetail = be.needed > capacity
? {t('admin.finance.kpi.beyondCapacity', { capacity: num(capacity) })}
: t('admin.finance.kpi.breakEvenProgress', { sold: num(be.sold), total: num(be.needed) });
}
const show = detailChartsAvailable(s);
return (
}
detail={privacyMode ? undefined : (
<>
{t('admin.finance.kpi.revenueDetail', { count: num(sold) })}
{s.revenue.gross > 0 && (
{t('admin.finance.kpi.presale')} · {t('admin.finance.kpi.door')}
)}
>
)}
icon={BanknotesIcon}
tone="blue"
/>
}
detail={privacyMode || s.expenses.total === 0 ? undefined : (
{t('admin.finance.charts.paid')} · {t('admin.finance.charts.planned')}
)}
icon={ShoppingBagIcon}
tone="orange"
/>
{t('admin.finance.kpi.estimated')}}
value={}
detail={t(finalized ? 'admin.finance.kpi.afterFees' : 'admin.finance.kpi.estimatedHint')}
icon={loss ? ArrowTrendingDownIcon : ArrowTrendingUpIcon}
tone={finalized ? (loss ? 'red' : 'green') : 'gray'}
/>
{breakEvenBar !== null && (
)}
{privacyMode ? (
{t('admin.finance.hiddenStats')}
) : (
<>
{!show.costs && (
{canAdd ? (
) : t('admin.finance.charts.addExpensesHint')}
)}
>
)}
);
}