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:
co-authored by
Claude Opus 5.5
parent
b51c1360e2
commit
e203fb6c74
@@ -0,0 +1,829 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import clsx from 'clsx';
|
||||
import {
|
||||
PlusIcon, PencilIcon, TrashIcon, LockClosedIcon, LockOpenIcon, RectangleStackIcon,
|
||||
PaperClipIcon, ArrowUpTrayIcon, CheckCircleIcon, ClockIcon, FunnelIcon, ShoppingBagIcon, BoltIcon, SparklesIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { usePrivacy } from '@/context/PrivacyContext';
|
||||
import { useMoney } from '@/lib/useMoney';
|
||||
import {
|
||||
financeApi, mediaApi, CALC_TYPES,
|
||||
type CalcType, type EventExpense, type EventFinance, type ExpenseInput, type ExpenseTemplate, type ExpenseTemplatePack, type OtherIncome,
|
||||
} from '@/lib/api';
|
||||
import Button from '@/components/ui/Button';
|
||||
import { Modal, Field, PygInput, DateInput, Money, Pill, Segmented, EmptyState, inputClass, iconButtonClass, todayIso } from './ui';
|
||||
import { bpToPercent, percentToBp } from './format';
|
||||
import { previewExpense } from './calc';
|
||||
import { suggestCategory, tCount } from './derive';
|
||||
|
||||
type T = (key: string, params?: Record<string, string | number>) => string;
|
||||
type Fmt = ReturnType<typeof useMoney>;
|
||||
|
||||
const isAuto = (c: CalcType) => c !== 'fixed';
|
||||
|
||||
/** One-line explanation of how a row's amount came about ("7 tickets × 5.000 PYG"). */
|
||||
function formula(e: EventExpense, t: T, m: Fmt): string {
|
||||
const q = e.liveQuantity ?? e.quantity;
|
||||
switch (e.calcType) {
|
||||
case 'fixed': return e.quantity === 1 ? '' : `${m.num(e.quantity)} × ${m.pyg(e.unitAmount)}`;
|
||||
case 'per_ticket_sold': return t('admin.finance.formula.perTicket', { count: m.num(q), amount: m.pyg(e.unitAmount) });
|
||||
case 'per_checked_in': return t('admin.finance.formula.perGuest', { count: m.num(q), amount: m.pyg(e.unitAmount) });
|
||||
case 'percent_of_revenue': return t('admin.finance.formula.percent', { percent: m.pct(e.percentBp) });
|
||||
case 'minimum_spend': return t('admin.finance.formula.minimum', { minimum: m.pyg(e.minimumAmount), count: m.num(q), amount: m.pyg(e.unitAmount) });
|
||||
default: return '';
|
||||
}
|
||||
}
|
||||
|
||||
/** Sticky action bar at the bottom of a modal, so Save stays reachable on phones. */
|
||||
function ModalActions({ children }: { children: React.ReactNode }) {
|
||||
return <div className="sticky bottom-0 -mx-4 -mb-4 mt-2 px-4 py-3 bg-white border-t border-gray-100 flex gap-3">{children}</div>;
|
||||
}
|
||||
|
||||
// ==================== Expense form ====================
|
||||
|
||||
interface ExpenseFormState {
|
||||
description: string;
|
||||
categoryId: string;
|
||||
calcType: CalcType;
|
||||
/** Fixed: the whole amount. Per ticket / per guest / minimum spend: the amount per unit. */
|
||||
unitAmount: number;
|
||||
percent: string;
|
||||
minimumAmount: number;
|
||||
isLocked: boolean;
|
||||
computedAmount: number;
|
||||
status: 'planned' | 'paid';
|
||||
paidByPartnerId: string;
|
||||
receiptUrl: string;
|
||||
expenseDate: string;
|
||||
}
|
||||
|
||||
const emptyForm = (): ExpenseFormState => ({
|
||||
description: '', categoryId: '', calcType: 'fixed', unitAmount: 0, percent: '', minimumAmount: 0,
|
||||
isLocked: false, computedAmount: 0, status: 'planned', paidByPartnerId: '', receiptUrl: '', expenseDate: '',
|
||||
});
|
||||
|
||||
function fromExpense(e: EventExpense): ExpenseFormState {
|
||||
return {
|
||||
description: e.description, categoryId: e.categoryId || '', calcType: e.calcType,
|
||||
// Older fixed rows may carry a quantity; the form edits their total, which saves as quantity 1.
|
||||
unitAmount: e.calcType === 'fixed' ? e.unitAmount * e.quantity : e.unitAmount,
|
||||
percent: e.percentBp ? bpToPercent(e.percentBp) : '', minimumAmount: e.minimumAmount,
|
||||
isLocked: e.isLocked, computedAmount: e.amount ?? e.computedAmount, status: e.status,
|
||||
paidByPartnerId: e.paidByPartnerId && e.paidByPartnerId !== 'other' ? e.paidByPartnerId : '',
|
||||
receiptUrl: e.receiptUrl || '', expenseDate: e.expenseDate ? e.expenseDate.slice(0, 10) : '',
|
||||
};
|
||||
}
|
||||
|
||||
function ExpenseModal({ open, onClose, eventId, data, expense, onSaved }: {
|
||||
open: boolean; onClose: () => void; eventId: string; data: EventFinance; expense: EventExpense | null; onSaved: () => void;
|
||||
}) {
|
||||
const { t, locale } = useLanguage();
|
||||
const m = useMoney();
|
||||
const { user } = useAuth();
|
||||
const [form, setForm] = useState<ExpenseFormState>(emptyForm());
|
||||
const [touched, setTouched] = useState(false);
|
||||
// Category follows the description until someone picks one by hand.
|
||||
const [categoryAuto, setCategoryAuto] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
// The media upload is admin/organizer only; others can paste a link.
|
||||
const canUpload = user?.role === 'admin' || user?.role === 'organizer';
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setForm(expense ? fromExpense(expense) : emptyForm());
|
||||
setTouched(false);
|
||||
setCategoryAuto(!expense || !expense.categoryId);
|
||||
}
|
||||
}, [open, expense]);
|
||||
|
||||
const set = <K extends keyof ExpenseFormState>(k: K, v: ExpenseFormState[K]) => setForm((f) => ({ ...f, [k]: v }));
|
||||
const categories = data.categories.filter((c) => !c.archived || c.id === form.categoryId);
|
||||
const ct = form.calcType;
|
||||
const counts = data.summary.counts;
|
||||
const sales = data.summary.revenue.sales;
|
||||
const preview = previewExpense(
|
||||
{ calcType: ct, quantity: 1, unitAmount: form.unitAmount, percentBp: percentToBp(form.percent), minimumAmount: form.minimumAmount },
|
||||
{ ticketsSold: counts.ticketsSold, checkedIn: counts.checkedIn, sales },
|
||||
);
|
||||
const locked = isAuto(ct) && form.isLocked;
|
||||
const total = locked ? form.computedAmount : preview.amount;
|
||||
const descriptionError = touched && !form.description.trim() ? t('admin.finance.expenses.descriptionRequired') : undefined;
|
||||
|
||||
const setDescription = (v: string) => {
|
||||
setForm((f) => {
|
||||
if (!categoryAuto) return { ...f, description: v };
|
||||
return { ...f, description: v, categoryId: suggestCategory(v, data.categories) || '' };
|
||||
});
|
||||
};
|
||||
const setStatus = (v: 'planned' | 'paid') => {
|
||||
setForm((f) => ({ ...f, status: v, expenseDate: v === 'paid' && !f.expenseDate ? todayIso() : f.expenseDate }));
|
||||
};
|
||||
|
||||
const upload = async (file: File) => {
|
||||
setUploading(true);
|
||||
try {
|
||||
const res = await mediaApi.upload(file, expense?.id, 'expense');
|
||||
set('receiptUrl', res.url);
|
||||
} catch (error: any) {
|
||||
toast.error(error.message);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
setTouched(true);
|
||||
if (!form.description.trim()) return;
|
||||
setSaving(true);
|
||||
const payload: ExpenseInput = {
|
||||
description: form.description.trim(),
|
||||
categoryId: form.categoryId || null,
|
||||
calcType: ct,
|
||||
quantity: ct === 'fixed' ? 1 : undefined,
|
||||
unitAmount: ct === 'percent_of_revenue' ? 0 : form.unitAmount,
|
||||
percentBp: ct === 'percent_of_revenue' ? percentToBp(form.percent) : 0,
|
||||
minimumAmount: ct === 'minimum_spend' ? form.minimumAmount : 0,
|
||||
isLocked: locked,
|
||||
computedAmount: locked ? form.computedAmount : undefined,
|
||||
status: form.status,
|
||||
paidByPartnerId: form.paidByPartnerId || null,
|
||||
receiptUrl: form.receiptUrl || null,
|
||||
expenseDate: form.expenseDate || null,
|
||||
};
|
||||
try {
|
||||
if (expense) await financeApi.updateExpense(eventId, expense.id, payload);
|
||||
else await financeApi.createExpense(eventId, payload);
|
||||
toast.success(t(expense ? 'admin.finance.expenses.saved' : 'admin.finance.expenses.created'));
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch (error: any) {
|
||||
toast.error(error.message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// "5.000 PYG × 4 tickets = 20.000 PYG" with this event's real numbers.
|
||||
const previewLine = (() => {
|
||||
if (locked) return t('admin.finance.preview.locked', { total: m.pyg(total) });
|
||||
const totalText = m.pyg(total);
|
||||
switch (ct) {
|
||||
case 'per_ticket_sold': return tCount(t, 'admin.finance.preview.perTicket', preview.count, { count: m.num(preview.count), amount: m.pyg(form.unitAmount), total: totalText });
|
||||
case 'per_checked_in': return tCount(t, 'admin.finance.preview.perGuest', preview.count, { count: m.num(preview.count), amount: m.pyg(form.unitAmount), total: totalText });
|
||||
case 'percent_of_revenue': return t('admin.finance.preview.percent', { percent: m.pct(percentToBp(form.percent)), sales: m.pyg(Math.max(0, sales)), total: totalText });
|
||||
case 'minimum_spend': return t('admin.finance.preview.minimum', { minimum: m.pyg(form.minimumAmount), count: m.num(preview.count), amount: m.pyg(form.unitAmount), total: totalText });
|
||||
default: return t('admin.finance.preview.fixed', { total: totalText });
|
||||
}
|
||||
})();
|
||||
|
||||
const amountLabel = {
|
||||
fixed: 'admin.finance.expenses.amount',
|
||||
per_ticket_sold: 'admin.finance.expenses.amountPerTicket',
|
||||
per_checked_in: 'admin.finance.expenses.amountPerGuest',
|
||||
percent_of_revenue: 'admin.finance.expenses.percentOfRevenue',
|
||||
minimum_spend: 'admin.finance.expenses.minimumAmount',
|
||||
}[ct];
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} title={t(expense ? 'admin.finance.expenses.edit' : 'admin.finance.expenses.new')} wide>
|
||||
<Field label={t('admin.finance.expenses.description')} required error={descriptionError}>
|
||||
<input
|
||||
className={clsx(inputClass, descriptionError && 'border-red-400')}
|
||||
value={form.description}
|
||||
maxLength={300}
|
||||
placeholder={t('admin.finance.expenses.descriptionPlaceholder')}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
onBlur={() => setTouched(true)}
|
||||
autoFocus
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<Field label={t(amountLabel)}>
|
||||
{ct === 'percent_of_revenue' ? (
|
||||
<div className="relative">
|
||||
<input className={clsx(inputClass, 'pr-8')} inputMode="decimal" value={form.percent} onChange={(e) => set('percent', e.target.value)} placeholder="10" />
|
||||
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-sm text-gray-400">%</span>
|
||||
</div>
|
||||
) : ct === 'minimum_spend' ? (
|
||||
<PygInput value={form.minimumAmount} onChange={(n) => set('minimumAmount', n)} />
|
||||
) : (
|
||||
<PygInput value={form.unitAmount} onChange={(n) => set('unitAmount', n)} />
|
||||
)}
|
||||
</Field>
|
||||
<Field label={t('admin.finance.expenses.calcType')} hint={t(`admin.finance.calcHints.${ct}`)}>
|
||||
<select className={inputClass} value={ct} onChange={(e) => set('calcType', e.target.value as CalcType)}>
|
||||
{CALC_TYPES.map((c) => <option key={c} value={c}>{t(`admin.finance.calcTypes.${c}`)}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
{ct === 'minimum_spend' && (
|
||||
<Field label={t('admin.finance.expenses.amountPerGuest')}>
|
||||
<PygInput value={form.unitAmount} onChange={(n) => set('unitAmount', n)} />
|
||||
</Field>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Live result: what this line costs with today's numbers */}
|
||||
<div className="rounded-btn bg-gray-50 border border-gray-100 px-3 py-2.5">
|
||||
<p className="text-sm text-gray-700 tabular-nums">{previewLine}</p>
|
||||
{isAuto(ct) && (
|
||||
<div className="mt-2 pt-2 border-t border-gray-200 space-y-2">
|
||||
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||||
<input type="checkbox" checked={form.isLocked} onChange={(e) => { set('isLocked', e.target.checked); if (e.target.checked) set('computedAmount', preview.amount); }} className="w-4 h-4 mt-0.5 accent-primary-yellow" />
|
||||
<span>
|
||||
<span className="font-medium">{t('admin.finance.expenses.lock')}</span>
|
||||
<span className="block text-xs text-gray-500">{t('admin.finance.expenses.lockHint')}</span>
|
||||
</span>
|
||||
</label>
|
||||
{form.isLocked && (
|
||||
<Field label={t('admin.finance.expenses.lockedAmount')}>
|
||||
<PygInput value={form.computedAmount} onChange={(n) => set('computedAmount', n)} />
|
||||
</Field>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<Field
|
||||
label={t('admin.finance.expenses.category')}
|
||||
hint={categoryAuto && form.categoryId ? t('admin.finance.expenses.categorySuggested') : undefined}
|
||||
>
|
||||
<select className={inputClass} value={form.categoryId} onChange={(e) => { setCategoryAuto(false); set('categoryId', e.target.value); }}>
|
||||
<option value="">{t('admin.finance.charts.uncategorized')}</option>
|
||||
{categories.map((c) => <option key={c.id} value={c.id}>{locale === 'es' ? c.nameEs : c.nameEn}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label={t('admin.finance.expenses.status')}>
|
||||
<Segmented<'planned' | 'paid'>
|
||||
label={t('admin.finance.expenses.status')}
|
||||
value={form.status}
|
||||
onChange={setStatus}
|
||||
options={[
|
||||
{ key: 'planned', label: t('admin.finance.expenseStatus.planned'), icon: <ClockIcon className="w-4 h-4" aria-hidden /> },
|
||||
{ key: 'paid', label: t('admin.finance.expenseStatus.paid'), icon: <CheckCircleIcon className="w-4 h-4" aria-hidden /> },
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{form.status === 'paid' && (
|
||||
<>
|
||||
<Field label={t('admin.finance.expenses.date')}>
|
||||
<DateInput value={form.expenseDate} onChange={(v) => set('expenseDate', v)} />
|
||||
</Field>
|
||||
<Field label={t('admin.finance.expenses.paidBy')} hint={data.partners.length > 0 ? t('admin.finance.expenses.paidByHint') : undefined}>
|
||||
<select className={inputClass} value={form.paidByPartnerId} onChange={(e) => set('paidByPartnerId', e.target.value)}>
|
||||
<option value="">{t('admin.finance.expenses.organization')}</option>
|
||||
{data.partners.map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<div className="sm:col-span-2">
|
||||
<Field label={t('admin.finance.expenses.receipt')} hint={canUpload ? undefined : t('admin.finance.expenses.receiptAdminOnly')}>
|
||||
<div className="flex gap-2">
|
||||
<input className={inputClass} type="url" inputMode="url" value={form.receiptUrl} placeholder="https://" onChange={(e) => set('receiptUrl', e.target.value)} />
|
||||
{canUpload && (
|
||||
<label className={clsx('flex-shrink-0 inline-flex items-center gap-1.5 px-3 rounded-btn border border-secondary-light-gray cursor-pointer hover:bg-gray-50 min-h-[44px] text-sm font-medium focus-within:ring-2 focus-within:ring-primary-yellow', uploading && 'opacity-50 pointer-events-none')}>
|
||||
<ArrowUpTrayIcon className="w-4 h-4" aria-hidden />
|
||||
{uploading ? t('common.loading') : t('admin.finance.expenses.upload')}
|
||||
<input type="file" accept="image/*" className="sr-only" onChange={(e) => e.target.files?.[0] && upload(e.target.files[0])} />
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
</Field>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ModalActions>
|
||||
<Button variant="outline" onClick={onClose} className="flex-1 min-h-[44px]">{t('common.cancel')}</Button>
|
||||
<Button onClick={submit} isLoading={saving} className="flex-1 min-h-[44px]">{t('common.save')}</Button>
|
||||
</ModalActions>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== Income form ====================
|
||||
|
||||
function IncomeModal({ open, income, onClose, eventId, onSaved }: {
|
||||
open: boolean; income: OtherIncome | null; onClose: () => void; eventId: string; onSaved: () => void;
|
||||
}) {
|
||||
const { t } = useLanguage();
|
||||
const [description, setDescription] = useState('');
|
||||
const [amount, setAmount] = useState(0);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) { setDescription(income?.description || ''); setAmount(income?.amount || 0); }
|
||||
}, [open, income]);
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
if (income) await financeApi.updateIncome(eventId, income.id, { description, amount });
|
||||
else await financeApi.createIncome(eventId, { description, amount });
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch (error: any) {
|
||||
toast.error(error.message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={t(income ? 'admin.finance.otherIncome.edit' : 'admin.finance.otherIncome.add')}
|
||||
subtitle={t('admin.finance.otherIncome.hint')}
|
||||
>
|
||||
<Field label={t('admin.finance.expenses.description')} required>
|
||||
<input className={inputClass} value={description} maxLength={300} placeholder={t('admin.finance.otherIncome.placeholder')} onChange={(e) => setDescription(e.target.value)} autoFocus />
|
||||
</Field>
|
||||
<Field label={t('admin.finance.expenses.amount')} required>
|
||||
<PygInput value={amount} onChange={setAmount} />
|
||||
</Field>
|
||||
<ModalActions>
|
||||
<Button variant="outline" onClick={onClose} className="flex-1 min-h-[44px]">{t('common.cancel')}</Button>
|
||||
<Button onClick={save} isLoading={saving} disabled={!description.trim()} className="flex-1 min-h-[44px]">{t('common.save')}</Button>
|
||||
</ModalActions>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== Use a template (packs + single templates) ====================
|
||||
|
||||
function TemplateModal({ open, onClose, eventId, onApplied }: { open: boolean; onClose: () => void; eventId: string; onApplied: () => void }) {
|
||||
const { t } = useLanguage();
|
||||
const m = useMoney();
|
||||
const [templates, setTemplates] = useState<ExpenseTemplate[]>([]);
|
||||
const [packs, setPacks] = useState<ExpenseTemplatePack[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setLoading(true);
|
||||
Promise.all([financeApi.getTemplates(eventId), financeApi.getPacks(eventId)])
|
||||
.then(([tpl, pk]) => { setTemplates(tpl.templates.filter((x) => !x.archived)); setPacks(pk.packs.filter((x) => !x.archived)); })
|
||||
.catch((error) => toast.error(error.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, [open, eventId]);
|
||||
|
||||
const apply = async (body: { templateId?: string; packId?: string }, key: string) => {
|
||||
setBusy(key);
|
||||
try {
|
||||
const res = await financeApi.applyTemplate(eventId, body);
|
||||
toast.success(t('admin.finance.expenses.applied', { count: res.expenses.length }));
|
||||
onApplied();
|
||||
onClose();
|
||||
} catch (error: any) {
|
||||
toast.error(error.message);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const templateName = (id: string) => templates.find((x) => x.id === id)?.name;
|
||||
const summary = (tpl: ExpenseTemplate) => {
|
||||
const kind = t(`admin.finance.calcTypes.${tpl.calcType}`);
|
||||
if (tpl.calcType === 'percent_of_revenue') return `${kind} · ${m.pct(tpl.percentBp)}`;
|
||||
if (tpl.calcType === 'minimum_spend') return `${kind} · ${m.pyg(tpl.amount)} · min ${m.pyg(tpl.minimumAmount)}`;
|
||||
return `${kind} · ${m.pyg(tpl.amount)}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} title={t('admin.finance.expenses.useTemplate')} subtitle={t('admin.finance.expenses.useTemplateHint')}>
|
||||
{loading ? <p className="text-sm text-gray-500">{t('common.loading')}</p> : templates.length === 0 && packs.length === 0 ? (
|
||||
<p className="text-sm text-gray-500">{t('admin.finance.expenses.noTemplates')}</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{packs.length > 0 && (
|
||||
<section>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-gray-500 mb-1">{t('admin.finance.expenses.packsHeading')}</h3>
|
||||
<ul className="divide-y divide-gray-100 border border-gray-100 rounded-btn">
|
||||
{packs.map((p) => (
|
||||
<li key={p.id} className="px-3 py-2.5 flex items-center gap-3">
|
||||
<RectangleStackIcon className="w-5 h-5 text-gray-400 flex-shrink-0" aria-hidden />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium">{p.name}</p>
|
||||
<p className="text-xs text-gray-500">{p.templateIds.map(templateName).filter(Boolean).join(' · ')}</p>
|
||||
</div>
|
||||
<Button size="sm" isLoading={busy === p.id} onClick={() => apply({ packId: p.id }, p.id)} className="min-h-[40px]">
|
||||
{t('admin.finance.expenses.addAll', { count: p.templateIds.length })}
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
{templates.length > 0 && (
|
||||
<section>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-gray-500 mb-1">{t('admin.finance.expenses.templatesHeading')}</h3>
|
||||
<ul className="divide-y divide-gray-100 border border-gray-100 rounded-btn">
|
||||
{templates.map((tpl) => (
|
||||
<li key={tpl.id} className="px-3 py-2.5 flex items-center gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium">{tpl.name}</p>
|
||||
<p className="text-xs text-gray-500">{summary(tpl)}</p>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" isLoading={busy === tpl.id} onClick={() => apply({ templateId: tpl.id }, tpl.id)} className="min-h-[40px]">
|
||||
<PlusIcon className="w-4 h-4 mr-1" aria-hidden />{t('admin.finance.expenses.addOne')}
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== View ====================
|
||||
|
||||
type LedgerRow = { kind: 'expense'; e: EventExpense } | { kind: 'income'; i: OtherIncome };
|
||||
|
||||
/** Costs and other income in one ledger: income rows read +amount in green. */
|
||||
export function ExpensesView({ eventId, data, onChange }: { eventId: string; data: EventFinance; onChange: () => void }) {
|
||||
const { t, locale } = useLanguage();
|
||||
const m = useMoney();
|
||||
const { privacyMode } = usePrivacy();
|
||||
const [category, setCategory] = useState('');
|
||||
const [editing, setEditing] = useState<EventExpense | null>(null);
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [income, setIncome] = useState<OtherIncome | null>(null);
|
||||
const [incomeOpen, setIncomeOpen] = useState(false);
|
||||
const [templatesOpen, setTemplatesOpen] = useState(false);
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
|
||||
const open = data.status === 'open';
|
||||
const { canEditExpenses, canEditOwnExpenses, userId } = data.viewer;
|
||||
const canAdd = open && (canEditExpenses || canEditOwnExpenses);
|
||||
const canEditIncome = open && canEditExpenses;
|
||||
const canEditRow = (e: EventExpense) => open && (canEditExpenses || (canEditOwnExpenses && e.createdBy === userId));
|
||||
|
||||
const catById = useMemo(() => new Map(data.categories.map((c) => [c.id, c])), [data.categories]);
|
||||
const usedCategories = data.categories.filter((c) => data.expenses.some((e) => e.categoryId === c.id));
|
||||
const partnerName = (id: string | null) => {
|
||||
if (!id) return t('admin.finance.expenses.organization');
|
||||
if (id === 'other') return t('admin.finance.expenses.otherPartner');
|
||||
return data.partners.find((p) => p.id === id)?.name || t('admin.finance.expenses.otherPartner');
|
||||
};
|
||||
const expenses = data.expenses.filter((e) => !category || (category === '__none__' ? !e.categoryId : e.categoryId === category));
|
||||
// A category filter narrows to costs; income has no category.
|
||||
const rows: LedgerRow[] = [
|
||||
...expenses.map((e) => ({ kind: 'expense' as const, e })),
|
||||
...(category ? [] : data.otherIncome.map((i) => ({ kind: 'income' as const, i }))),
|
||||
];
|
||||
const amountOf = (e: EventExpense) => e.amount ?? e.computedAmount;
|
||||
const costTotal = expenses.reduce((s, e) => s + amountOf(e), 0);
|
||||
const paid = expenses.filter((e) => e.status === 'paid').reduce((s, e) => s + amountOf(e), 0);
|
||||
const incomeTotal = data.otherIncome.reduce((s, r) => s + r.amount, 0);
|
||||
|
||||
const patch = async (e: EventExpense, body: ExpenseInput) => {
|
||||
setBusyId(e.id);
|
||||
try {
|
||||
await financeApi.updateExpense(eventId, e.id, body);
|
||||
onChange();
|
||||
} catch (error: any) {
|
||||
toast.error(error.message);
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
};
|
||||
const remove = async (e: EventExpense) => {
|
||||
if (!confirm(t('admin.finance.expenses.confirmDeleteNamed', { name: e.description }))) return;
|
||||
try {
|
||||
await financeApi.deleteExpense(eventId, e.id);
|
||||
toast.success(t('admin.finance.expenses.deleted'));
|
||||
onChange();
|
||||
} catch (error: any) {
|
||||
toast.error(error.message);
|
||||
}
|
||||
};
|
||||
const removeIncome = async (row: OtherIncome) => {
|
||||
if (!confirm(t('admin.finance.otherIncome.confirmDelete', { name: row.description }))) return;
|
||||
try {
|
||||
await financeApi.deleteIncome(eventId, row.id);
|
||||
onChange();
|
||||
} catch (error: any) {
|
||||
toast.error(error.message);
|
||||
}
|
||||
};
|
||||
const openNew = () => { setEditing(null); setFormOpen(true); };
|
||||
const openEdit = (e: EventExpense) => { setEditing(e); setFormOpen(true); };
|
||||
const openIncome = (row: OtherIncome | null) => { setIncome(row); setIncomeOpen(true); };
|
||||
|
||||
const CategoryTag = ({ id }: { id: string | null }) => {
|
||||
const c = id ? catById.get(id) : null;
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-gray-500">
|
||||
<span className="w-2 h-2 rounded-full" style={{ background: c?.color || '#D1D5DB' }} aria-hidden />
|
||||
{c ? (locale === 'es' ? c.nameEs : c.nameEn) : t('admin.finance.charts.uncategorized')}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
// Status is a labeled toggle with an icon, not a color-only badge.
|
||||
const StatusToggle = ({ e }: { e: EventExpense }) => {
|
||||
const isPaid = e.status === 'paid';
|
||||
const editable = canEditRow(e);
|
||||
return (
|
||||
<button
|
||||
disabled={!editable || busyId === e.id}
|
||||
onClick={() => patch(e, isPaid ? { status: 'planned' } : { status: 'paid', expenseDate: e.expenseDate || todayIso() })}
|
||||
title={editable ? t(isPaid ? 'admin.finance.expenses.markPlanned' : 'admin.finance.expenses.markPaid') : undefined}
|
||||
className={clsx(
|
||||
'inline-flex items-center gap-1 rounded-full pl-1.5 pr-2.5 min-h-[32px] text-xs font-medium whitespace-nowrap focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-yellow',
|
||||
isPaid ? 'bg-green-100 text-green-800' : 'bg-yellow-100 text-yellow-800',
|
||||
editable ? 'cursor-pointer hover:ring-2 hover:ring-offset-1 hover:ring-gray-300' : 'cursor-default',
|
||||
)}
|
||||
>
|
||||
{isPaid ? <CheckCircleIcon className="w-4 h-4" aria-hidden /> : <ClockIcon className="w-4 h-4" aria-hidden />}
|
||||
{t(`admin.finance.expenseStatus.${e.status}`)}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const IncomeTag = () => (
|
||||
<Pill tone="green"><SparklesIcon className="w-3 h-3 mr-0.5" aria-hidden />{t('admin.finance.ledger.income')}</Pill>
|
||||
);
|
||||
|
||||
const LockButton = ({ e }: { e: EventExpense }) => isAuto(e.calcType) ? (
|
||||
<button
|
||||
disabled={!canEditRow(e) || busyId === e.id}
|
||||
onClick={() => patch(e, { isLocked: !e.isLocked })}
|
||||
title={t(e.isLocked ? 'admin.finance.expenses.unlockHint' : 'admin.finance.expenses.lockHint')}
|
||||
className={clsx(iconButtonClass, e.isLocked ? 'text-primary-dark' : 'text-gray-400')}
|
||||
aria-label={t('admin.finance.expenses.lock')}
|
||||
aria-pressed={e.isLocked}
|
||||
>
|
||||
{e.isLocked ? <LockClosedIcon className="w-4 h-4" /> : <LockOpenIcon className="w-4 h-4" />}
|
||||
</button>
|
||||
) : null;
|
||||
|
||||
const Meta = ({ e }: { e: EventExpense }) => (
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 mt-1">
|
||||
<CategoryTag id={e.categoryId} />
|
||||
{isAuto(e.calcType) && (
|
||||
<Pill tone={e.isLocked ? 'gray' : 'blue'}>
|
||||
{e.isLocked ? <LockClosedIcon className="w-3 h-3 mr-0.5" aria-hidden /> : <BoltIcon className="w-3 h-3 mr-0.5" aria-hidden />}
|
||||
{t(e.isLocked ? 'admin.finance.expenses.locked' : 'admin.finance.expenses.auto')}
|
||||
</Pill>
|
||||
)}
|
||||
{e.receiptUrl && (
|
||||
<a href={e.receiptUrl} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-0.5 text-xs text-blue-700 hover:underline">
|
||||
<PaperClipIcon className="w-3.5 h-3.5" aria-hidden />{t('admin.finance.expenses.viewReceipt')}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const modals = (
|
||||
<>
|
||||
<ExpenseModal open={formOpen} onClose={() => setFormOpen(false)} eventId={eventId} data={data} expense={editing} onSaved={onChange} />
|
||||
<IncomeModal open={incomeOpen} income={income} onClose={() => setIncomeOpen(false)} eventId={eventId} onSaved={onChange} />
|
||||
<TemplateModal open={templatesOpen} onClose={() => setTemplatesOpen(false)} eventId={eventId} onApplied={onChange} />
|
||||
</>
|
||||
);
|
||||
|
||||
const addButtons = (
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setTemplatesOpen(true)} className="min-h-[44px] whitespace-nowrap !px-3 md:!px-5 !text-sm md:!text-base">
|
||||
<RectangleStackIcon className="w-4 h-4 mr-1.5" aria-hidden />{t('admin.finance.expenses.useTemplate')}
|
||||
</Button>
|
||||
{canEditIncome && (
|
||||
<Button variant="outline" onClick={() => openIncome(null)} className="min-h-[44px] whitespace-nowrap !px-3 md:!px-5 !text-sm md:!text-base">
|
||||
<PlusIcon className="w-4 h-4 mr-1.5" aria-hidden />{t('admin.finance.otherIncome.add')}
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={openNew} className="min-h-[44px] col-span-2 lg:col-span-1">
|
||||
<PlusIcon className="w-4 h-4 mr-1.5" aria-hidden />{t('admin.finance.expenses.add')}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
if (data.expenses.length === 0 && data.otherIncome.length === 0) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<EmptyState icon={<ShoppingBagIcon className="w-5 h-5" />} title={t('admin.finance.ledger.emptyTitle')} body={t('admin.finance.expenses.empty')}>
|
||||
{canAdd && addButtons}
|
||||
</EmptyState>
|
||||
{modals}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const incomeAmount = (i: OtherIncome) => <Money value={i.amount} signed className={clsx('font-semibold whitespace-nowrap', !privacyMode && 'text-green-700')} />;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Totals + actions */}
|
||||
<div className="flex flex-col lg:flex-row lg:items-center gap-3">
|
||||
<dl className={clsx('grid gap-2 flex-1 lg:max-w-2xl grid-cols-2', incomeTotal > 0 ? 'sm:grid-cols-4' : 'sm:grid-cols-3')}>
|
||||
{[
|
||||
{ label: t('admin.finance.ledger.costs'), value: costTotal, strong: true },
|
||||
{ label: t('admin.finance.expenseStatus.paid'), value: paid },
|
||||
{ label: t('admin.finance.expenseStatus.planned'), value: costTotal - paid },
|
||||
...(incomeTotal > 0 ? [{ label: t('admin.finance.otherIncome.title'), value: incomeTotal, income: true }] : []),
|
||||
].map((s) => (
|
||||
<div key={s.label} className={clsx('bg-white rounded-card shadow-card px-3 py-2 min-w-0', incomeTotal === 0 && 'strong' in s && 'col-span-2 sm:col-span-1')}>
|
||||
<dt className="text-xs text-gray-500 truncate">{s.label}</dt>
|
||||
<dd>
|
||||
<Money
|
||||
value={s.value}
|
||||
signed={'income' in s}
|
||||
className={clsx('text-sm md:text-base whitespace-nowrap', 'strong' in s && s.strong ? 'font-bold' : 'font-semibold', 'income' in s && !privacyMode && 'text-green-700')}
|
||||
/>
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
{canAdd && <div className="grid grid-cols-2 lg:flex gap-2 lg:ml-auto">{addButtons}</div>}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{usedCategories.length > 1 && (
|
||||
<label className="inline-flex items-center gap-2 text-sm text-gray-600">
|
||||
<FunnelIcon className="w-4 h-4" aria-hidden />
|
||||
<span className="sr-only">{t('admin.finance.expenses.category')}</span>
|
||||
<select
|
||||
className="px-3 min-h-[40px] text-sm rounded-btn border border-secondary-light-gray bg-white focus:outline-none focus:ring-2 focus:ring-primary-yellow cursor-pointer"
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value)}
|
||||
>
|
||||
<option value="">{t('admin.finance.ledger.allRows')}</option>
|
||||
{usedCategories.map((c) => <option key={c.id} value={c.id}>{locale === 'es' ? c.nameEs : c.nameEn}</option>)}
|
||||
{data.expenses.some((e) => !e.categoryId) && <option value="__none__">{t('admin.finance.charts.uncategorized')}</option>}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
{open && !canEditExpenses && canEditOwnExpenses && <p className="text-xs text-gray-500">{t('admin.finance.expenses.ownOnly')}</p>}
|
||||
{!open && <p className="text-xs text-gray-500 inline-flex items-center gap-1"><LockClosedIcon className="w-3.5 h-3.5" aria-hidden />{t('admin.finance.expenses.frozen')}</p>}
|
||||
</div>
|
||||
|
||||
{/* Desktop table: amount sits on the right, next to the row actions */}
|
||||
<div className="hidden md:block bg-white rounded-card shadow-card overflow-hidden">
|
||||
<table className="w-full text-sm table-fixed">
|
||||
<colgroup>
|
||||
<col />
|
||||
<col className="w-36" />
|
||||
<col className="w-44" />
|
||||
<col className="w-44" />
|
||||
<col className="w-32" />
|
||||
</colgroup>
|
||||
<thead className="text-xs text-gray-500 bg-gray-50">
|
||||
<tr>
|
||||
<th scope="col" className="text-left font-medium px-4 py-2.5">{t('admin.finance.expenses.description')}</th>
|
||||
<th scope="col" className="text-left font-medium px-3 py-2.5">{t('admin.finance.expenses.status')}</th>
|
||||
<th scope="col" className="text-left font-medium px-3 py-2.5">{t('admin.finance.expenses.paidBy')}</th>
|
||||
<th scope="col" className="text-right font-medium px-3 py-2.5">{t('admin.finance.expenses.amount')}</th>
|
||||
<th scope="col" className="px-3 py-2.5"><span className="sr-only">{t('common.edit')}</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{rows.map((row) => row.kind === 'expense' ? (
|
||||
<tr key={row.e.id} className="align-top hover:bg-gray-50/60">
|
||||
<td className="px-4 py-3 min-w-0">
|
||||
{canEditRow(row.e) ? (
|
||||
<button onClick={() => openEdit(row.e)} className="font-medium text-primary-dark hover:underline text-left cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-yellow rounded">
|
||||
{row.e.description}
|
||||
</button>
|
||||
) : <p className="font-medium text-primary-dark">{row.e.description}</p>}
|
||||
<Meta e={row.e} />
|
||||
</td>
|
||||
<td className="px-3 py-3"><StatusToggle e={row.e} /></td>
|
||||
<td className="px-3 py-3 text-sm text-gray-600 truncate">{row.e.status === 'paid' ? partnerName(row.e.paidByPartnerId) : ''}</td>
|
||||
<td className="px-3 py-3 text-right">
|
||||
<Money value={amountOf(row.e)} className="font-semibold whitespace-nowrap" />
|
||||
{!privacyMode && formula(row.e, t, m) && <p className="text-xs text-gray-400 mt-0.5">{formula(row.e, t, m)}</p>}
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<div className="flex justify-end items-center">
|
||||
<LockButton e={row.e} />
|
||||
{canEditRow(row.e) && (
|
||||
<>
|
||||
<button onClick={() => openEdit(row.e)} className={iconButtonClass} aria-label={t('common.edit')}><PencilIcon className="w-4 h-4" /></button>
|
||||
<button onClick={() => remove(row.e)} className={clsx(iconButtonClass, 'text-red-600')} aria-label={t('common.delete')}><TrashIcon className="w-4 h-4" /></button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
<tr key={row.i.id} className="align-top hover:bg-gray-50/60">
|
||||
<td className="px-4 py-3 min-w-0">
|
||||
{canEditIncome ? (
|
||||
<button onClick={() => openIncome(row.i)} className="font-medium text-primary-dark hover:underline text-left cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-yellow rounded">
|
||||
{row.i.description}
|
||||
</button>
|
||||
) : <p className="font-medium text-primary-dark">{row.i.description}</p>}
|
||||
</td>
|
||||
<td className="px-3 py-3"><IncomeTag /></td>
|
||||
<td className="px-3 py-3" />
|
||||
<td className="px-3 py-3 text-right">{incomeAmount(row.i)}</td>
|
||||
<td className="px-2 py-2">
|
||||
{canEditIncome && (
|
||||
<div className="flex justify-end items-center">
|
||||
<button onClick={() => openIncome(row.i)} className={iconButtonClass} aria-label={t('common.edit')}><PencilIcon className="w-4 h-4" /></button>
|
||||
<button onClick={() => removeIncome(row.i)} className={clsx(iconButtonClass, 'text-red-600')} aria-label={t('common.delete')}><TrashIcon className="w-4 h-4" /></button>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot className="bg-gray-50">
|
||||
<tr>
|
||||
<td className="px-4 py-2.5 font-semibold" colSpan={3}>
|
||||
{t('admin.finance.ledger.costs')}
|
||||
{category && <span className="font-normal text-gray-500"> · {t('admin.finance.expenses.filtered')}</span>}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-right"><Money value={costTotal} className="font-bold whitespace-nowrap" /></td>
|
||||
<td />
|
||||
</tr>
|
||||
{!category && incomeTotal > 0 && (
|
||||
<tr>
|
||||
<td className="px-4 pb-2.5 font-semibold" colSpan={3}>{t('admin.finance.otherIncome.title')}</td>
|
||||
<td className="px-3 pb-2.5 text-right"><Money value={incomeTotal} signed className={clsx('font-bold whitespace-nowrap', !privacyMode && 'text-green-700')} /></td>
|
||||
<td />
|
||||
</tr>
|
||||
)}
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Mobile cards: tap the card to edit, status toggles in place */}
|
||||
<ul className="md:hidden space-y-2">
|
||||
{rows.map((row) => row.kind === 'expense' ? (
|
||||
<li key={row.e.id} className="bg-white rounded-card shadow-card">
|
||||
<div className="flex items-start gap-3 p-3">
|
||||
<button
|
||||
onClick={() => canEditRow(row.e) && openEdit(row.e)}
|
||||
disabled={!canEditRow(row.e)}
|
||||
className="flex-1 min-w-0 text-left cursor-pointer disabled:cursor-default focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-yellow rounded"
|
||||
>
|
||||
<p className="font-medium text-primary-dark">{row.e.description}</p>
|
||||
<Meta e={row.e} />
|
||||
</button>
|
||||
<div className="text-right">
|
||||
<Money value={amountOf(row.e)} className="font-semibold whitespace-nowrap" />
|
||||
{!privacyMode && formula(row.e, t, m) && <p className="text-[11px] text-gray-400">{formula(row.e, t, m)}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-3 pb-2">
|
||||
<StatusToggle e={row.e} />
|
||||
{row.e.status === 'paid' && <span className="text-xs text-gray-500 truncate">{partnerName(row.e.paidByPartnerId)}</span>}
|
||||
<div className="ml-auto flex items-center">
|
||||
<LockButton e={row.e} />
|
||||
{canEditRow(row.e) && (
|
||||
<button onClick={() => remove(row.e)} className={clsx(iconButtonClass, 'text-red-600')} aria-label={t('common.delete')}><TrashIcon className="w-4 h-4" /></button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
) : (
|
||||
<li key={row.i.id} className="bg-white rounded-card shadow-card">
|
||||
<div className="flex items-start gap-3 p-3">
|
||||
<button
|
||||
onClick={() => canEditIncome && openIncome(row.i)}
|
||||
disabled={!canEditIncome}
|
||||
className="flex-1 min-w-0 text-left cursor-pointer disabled:cursor-default focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-yellow rounded"
|
||||
>
|
||||
<p className="font-medium text-primary-dark">{row.i.description}</p>
|
||||
<div className="mt-1"><IncomeTag /></div>
|
||||
</button>
|
||||
<div className="flex items-center gap-1">
|
||||
{incomeAmount(row.i)}
|
||||
{canEditIncome && (
|
||||
<button onClick={() => removeIncome(row.i)} className={clsx(iconButtonClass, 'text-red-600')} aria-label={t('common.delete')}><TrashIcon className="w-4 h-4" /></button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
<li className="flex justify-between px-3 py-2 text-sm font-semibold">
|
||||
<span>{t('admin.finance.ledger.costs')}</span>
|
||||
<Money value={costTotal} />
|
||||
</li>
|
||||
{!category && incomeTotal > 0 && (
|
||||
<li className="flex justify-between px-3 pb-2 text-sm font-semibold">
|
||||
<span>{t('admin.finance.otherIncome.title')}</span>
|
||||
<Money value={incomeTotal} signed className={clsx(!privacyMode && 'text-green-700')} />
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
|
||||
{modals}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
'use client';
|
||||
|
||||
import clsx from 'clsx';
|
||||
import { CheckCircleIcon, ExclamationTriangleIcon, InformationCircleIcon, LockClosedIcon } from '@heroicons/react/24/outline';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import type { EventFinance } from '@/lib/api';
|
||||
import Button from '@/components/ui/Button';
|
||||
import { Modal, Money } from './ui';
|
||||
import { tCount, type Lifecycle } from './derive';
|
||||
|
||||
/** Last look before freezing the numbers: what is in, what is missing, and the result. */
|
||||
export function FinalizeDialog({ open, onClose, onConfirm, busy, data, lifecycle }: {
|
||||
open: boolean; onClose: () => void; onConfirm: () => void; busy: boolean; data: EventFinance; lifecycle: Lifecycle;
|
||||
}) {
|
||||
const { t } = useLanguage();
|
||||
const s = data.summary;
|
||||
const partnersTotal = s.split.partners.reduce((sum, p) => sum + p.share, 0);
|
||||
const loss = s.profit < 0;
|
||||
|
||||
const Row = ({ tone, children, value }: { tone: 'ok' | 'warn' | 'info'; children: React.ReactNode; value?: React.ReactNode }) => {
|
||||
const Icon = tone === 'ok' ? CheckCircleIcon : tone === 'warn' ? ExclamationTriangleIcon : InformationCircleIcon;
|
||||
return (
|
||||
<li className={clsx('flex items-start gap-2 py-2', tone === 'warn' && 'text-amber-900')}>
|
||||
<Icon className={clsx('w-5 h-5 flex-shrink-0', tone === 'ok' ? 'text-green-600' : tone === 'warn' ? 'text-amber-600' : 'text-gray-400')} aria-hidden />
|
||||
<span className="flex-1 min-w-0">{children}</span>
|
||||
{value && <span className="font-semibold whitespace-nowrap">{value}</span>}
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} title={t('admin.finance.finalizeDialog.title')} subtitle={t('admin.finance.finalizeDialog.subtitle')}>
|
||||
<ul className="divide-y divide-gray-100 text-sm">
|
||||
{!lifecycle.ended && <Row tone="warn">{t('admin.finance.finalizeDialog.notEnded')}</Row>}
|
||||
{lifecycle.expenseCount === 0
|
||||
? <Row tone="warn">{t('admin.finance.finalizeDialog.noExpenses')}</Row>
|
||||
: <Row tone="ok" value={<Money value={s.expenses.total} />}>{tCount(t, 'admin.finance.finalizeDialog.expenses', lifecycle.expenseCount)}</Row>}
|
||||
{lifecycle.plannedCount > 0
|
||||
? <Row tone="warn" value={<Money value={s.expenses.planned} />}>{tCount(t, 'admin.finance.finalizeDialog.planned', lifecycle.plannedCount)}</Row>
|
||||
: lifecycle.expenseCount > 0 && <Row tone="ok">{t('admin.finance.finalizeDialog.allPaid')}</Row>}
|
||||
{s.revenue.otherIncome > 0 && (
|
||||
<Row tone="ok" value={<Money value={s.revenue.otherIncome} signed />}>{tCount(t, 'admin.finance.finalizeDialog.income', data.otherIncome.length)}</Row>
|
||||
)}
|
||||
{lifecycle.partnerCount > 0
|
||||
? <Row tone="info" value={<Money value={partnersTotal} />}>{tCount(t, 'admin.finance.finalizeDialog.partners', lifecycle.partnerCount)}</Row>
|
||||
: <Row tone="info">{t('admin.finance.finalizeDialog.noPartners')}</Row>}
|
||||
</ul>
|
||||
|
||||
<div className="rounded-btn bg-gray-50 border border-gray-100 p-3 space-y-1">
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<span className="text-sm text-gray-600">{t(loss ? 'admin.finance.finalizeDialog.finalLoss' : 'admin.finance.finalizeDialog.finalProfit')}</span>
|
||||
<Money value={s.profit} className={clsx('text-lg font-bold', loss && 'text-red-600')} />
|
||||
</div>
|
||||
{lifecycle.partnerCount > 0 && s.split.organization !== null && (
|
||||
<div className="flex items-baseline justify-between gap-3 text-sm">
|
||||
<span className="text-gray-600">{t('admin.finance.split.organization')}</span>
|
||||
<Money value={s.split.organization} className="font-semibold" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">{t('admin.finance.finalizeDialog.hint')}</p>
|
||||
|
||||
<div className="sticky bottom-0 -mx-4 -mb-4 mt-2 px-4 py-3 bg-white border-t border-gray-100 flex flex-col-reverse sm:flex-row gap-3">
|
||||
<Button variant="outline" onClick={onClose} className="min-h-[44px] sm:flex-none">{t('common.cancel')}</Button>
|
||||
<Button onClick={onConfirm} isLoading={busy} className="flex-1 min-h-[44px] whitespace-nowrap">
|
||||
<LockClosedIcon className="w-4 h-4 mr-1.5" aria-hidden />{t('admin.finance.finalizeDialog.confirm')}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { financeApi, type EventFinance } from '@/lib/api';
|
||||
import { Skeleton } from '@/components/ui/Skeleton';
|
||||
import { SubNav } from './ui';
|
||||
import { SummaryView } from './SummaryView';
|
||||
import { ExpensesView } from './ExpensesView';
|
||||
import { SplitView } from './SplitView';
|
||||
import { LifecycleStepper } from './LifecycleStepper';
|
||||
import { deriveLifecycle } from './derive';
|
||||
|
||||
export type FinanceSection = 'summary' | 'expenses' | 'split';
|
||||
const SECTIONS: FinanceSection[] = ['summary', 'expenses', 'split'];
|
||||
|
||||
/** Keep the open section in the URL (?fin=expenses) so reloads and shared links land on it. */
|
||||
function readSection(): FinanceSection {
|
||||
if (typeof window === 'undefined') return 'summary';
|
||||
const v = new URLSearchParams(window.location.search).get('fin');
|
||||
return SECTIONS.includes(v as FinanceSection) ? (v as FinanceSection) : 'summary';
|
||||
}
|
||||
function writeSection(section: FinanceSection) {
|
||||
const url = new URL(window.location.href);
|
||||
if (section === 'summary') url.searchParams.delete('fin'); else url.searchParams.set('fin', section);
|
||||
window.history.replaceState(window.history.state, '', url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finance tab: P&L summary with charts, expenses and other income, and the
|
||||
* partner split with payouts. The server already scopes what comes back (e.g.
|
||||
* collaborators only get their own share), so this renders what it receives.
|
||||
*/
|
||||
export function FinanceTab({ eventId }: { eventId: string }) {
|
||||
const { t } = useLanguage();
|
||||
const [data, setData] = useState<EventFinance | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [section, setSectionState] = useState<FinanceSection>('summary');
|
||||
|
||||
useEffect(() => { setSectionState(readSection()); }, []);
|
||||
const setSection = useCallback((s: FinanceSection) => { setSectionState(s); writeSection(s); }, []);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setData(await financeApi.get(eventId));
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || t('admin.finance.loadError'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [eventId, t]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
// Recomputed with every reload, so "ended X days ago" stays current.
|
||||
const lifecycle = useMemo(() => (data ? deriveLifecycle(data) : null), [data]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-3" aria-busy="true">
|
||||
<Skeleton className="h-24" />
|
||||
<Skeleton className="h-11 w-full md:w-96" />
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
{Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-24" />)}
|
||||
</div>
|
||||
<Skeleton className="h-64" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!data || !lifecycle) return <p className="text-sm text-gray-500 py-8 text-center">{t('admin.finance.loadError')}</p>;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<LifecycleStepper data={data} lifecycle={lifecycle} goTo={setSection} />
|
||||
|
||||
<SubNav<FinanceSection>
|
||||
items={[
|
||||
{ key: 'summary', label: t('admin.finance.subnav.summary') },
|
||||
{ key: 'expenses', label: t('admin.finance.subnav.expenses'), count: data.expenses.length + data.otherIncome.length },
|
||||
{ key: 'split', label: t('admin.finance.subnav.split'), count: data.partners.length },
|
||||
]}
|
||||
active={section}
|
||||
onChange={setSection}
|
||||
/>
|
||||
|
||||
{section === 'summary' && <SummaryView data={data} goTo={setSection} />}
|
||||
{section === 'expenses' && <ExpensesView eventId={eventId} data={data} onChange={load} />}
|
||||
{section === 'split' && <SplitView eventId={eventId} data={data} lifecycle={lifecycle} onChange={load} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
'use client';
|
||||
|
||||
import clsx from 'clsx';
|
||||
import { CheckIcon, ExclamationTriangleIcon, InformationCircleIcon, ClockIcon } from '@heroicons/react/24/outline';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import type { EventFinance } from '@/lib/api';
|
||||
import { LIFECYCLE_STEPS, tCount, type Lifecycle } from './derive';
|
||||
import type { FinanceSection } from './FinanceTab';
|
||||
|
||||
/**
|
||||
* Where the event's books stand: Selling -> Adding costs -> Ready to close ->
|
||||
* Finalized, with what is still missing underneath. Closing the books itself
|
||||
* happens in Split & Payouts; this only points the way.
|
||||
*/
|
||||
export function LifecycleStepper({ data, lifecycle, goTo }: { data: EventFinance; lifecycle: Lifecycle; goTo: (s: FinanceSection) => void }) {
|
||||
const { t, locale } = useLanguage();
|
||||
const current = LIFECYCLE_STEPS.indexOf(lifecycle.step);
|
||||
const open = data.status === 'open';
|
||||
const dateFmt = (iso: string | null) =>
|
||||
iso ? new Date(iso).toLocaleDateString(locale === 'es' ? 'es-PY' : 'en-US', { dateStyle: 'medium' }) : '';
|
||||
|
||||
// Once every cost is in, the nudge says so instead of asking for more.
|
||||
const nudgeKey = lifecycle.step === 'ready' ? 'admin.finance.lifecycle.nudgeReady' : 'admin.finance.lifecycle.nudge';
|
||||
const nudge = open && lifecycle.ended && lifecycle.daysSinceEnd !== null
|
||||
? lifecycle.daysSinceEnd === 0
|
||||
? t(`${nudgeKey}Today`)
|
||||
: tCount(t, nudgeKey, lifecycle.daysSinceEnd)
|
||||
: null;
|
||||
const ready = lifecycle.step === 'ready';
|
||||
|
||||
return (
|
||||
<section className="bg-white rounded-card shadow-card p-3 md:p-4" aria-label={t('admin.finance.lifecycle.label')}>
|
||||
<ol className="grid grid-cols-4 gap-1">
|
||||
{LIFECYCLE_STEPS.map((step, i) => {
|
||||
const done = i < current || (step === 'finalized' && !open);
|
||||
const active = i === current && open;
|
||||
return (
|
||||
<li key={step} className="flex flex-col items-center text-center min-w-0 relative" aria-current={i === current ? 'step' : undefined}>
|
||||
{i > 0 && (
|
||||
<span
|
||||
className={clsx('absolute top-3 h-0.5', i <= current ? 'bg-primary-yellow' : 'bg-gray-200')}
|
||||
// From the previous circle's edge to this one's (circles are 1.5rem, columns 0.25rem apart).
|
||||
style={{ right: 'calc(50% + 0.875rem)', width: 'calc(100% - 1.5rem)' }}
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
className={clsx(
|
||||
'relative z-10 w-6 h-6 rounded-full flex items-center justify-center text-xs font-semibold border-2',
|
||||
done && 'bg-primary-yellow border-primary-yellow text-primary-dark',
|
||||
active && 'bg-white border-primary-yellow text-primary-dark',
|
||||
!done && !active && 'bg-white border-gray-200 text-gray-400',
|
||||
)}
|
||||
aria-hidden
|
||||
>
|
||||
{done ? <CheckIcon className="w-3.5 h-3.5" strokeWidth={3} /> : i + 1}
|
||||
</span>
|
||||
<span className={clsx('mt-1 text-[11px] sm:text-xs leading-tight', i === current ? 'font-semibold text-primary-dark' : 'text-gray-500')}>
|
||||
{t(`admin.finance.lifecycle.steps.${step}`)}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
|
||||
{(nudge || lifecycle.todo.length > 0 || !open) && (
|
||||
<div className="mt-3 pt-3 border-t border-gray-100 space-y-2 text-sm">
|
||||
{nudge && (
|
||||
<div className={clsx(
|
||||
'flex flex-col sm:flex-row sm:items-center gap-2 rounded-btn border px-3 py-2',
|
||||
ready ? 'bg-green-50 border-green-200 text-green-900' : 'bg-amber-50 border-amber-200 text-amber-900',
|
||||
)}>
|
||||
<p className="flex items-start gap-2 flex-1">
|
||||
{ready ? <CheckIcon className="w-4 h-4 flex-shrink-0 mt-0.5" aria-hidden /> : <ClockIcon className="w-4 h-4 flex-shrink-0 mt-0.5" aria-hidden />}
|
||||
{nudge}
|
||||
</p>
|
||||
{data.viewer.canEditExpenses && !ready && (
|
||||
<button onClick={() => goTo('expenses')} className="self-start sm:self-auto text-sm font-medium underline whitespace-nowrap cursor-pointer min-h-[32px]">
|
||||
{t('admin.finance.lifecycle.reviewCosts')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!open && (
|
||||
<p className="text-gray-600">
|
||||
{t(data.status === 'paid_out' ? 'admin.finance.lifecycle.paidOut' : 'admin.finance.lifecycle.frozen', { date: dateFmt(data.finalizedAt) })}
|
||||
</p>
|
||||
)}
|
||||
{lifecycle.todo.length > 0 && (
|
||||
<ul className="flex flex-wrap gap-x-4 gap-y-1">
|
||||
{lifecycle.todo.map((item) => (
|
||||
<li key={item.key} className={clsx('inline-flex items-center gap-1.5 text-xs sm:text-sm', item.tone === 'warn' ? 'text-amber-800' : 'text-gray-500')}>
|
||||
{item.tone === 'warn'
|
||||
? <ExclamationTriangleIcon className="w-4 h-4 flex-shrink-0" aria-hidden />
|
||||
: <InformationCircleIcon className="w-4 h-4 flex-shrink-0" aria-hidden />}
|
||||
{item.count !== undefined
|
||||
? tCount(t, `admin.finance.lifecycle.todo.${item.key}`, item.count)
|
||||
: t(`admin.finance.lifecycle.todo.${item.key}`)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,530 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import clsx from 'clsx';
|
||||
import {
|
||||
PlusIcon, PencilIcon, TrashIcon, DocumentArrowDownIcon, CheckCircleIcon, LockClosedIcon, LockOpenIcon, ClockIcon,
|
||||
XMarkIcon, ChevronDownIcon, UserGroupIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { usePrivacy } from '@/context/PrivacyContext';
|
||||
import { useMoney } from '@/lib/useMoney';
|
||||
import {
|
||||
financeApi, SHARE_TYPES, LOSS_RULES,
|
||||
type EventFinance, type EventPartner, type FinanceAuditEntry, type LossRule, type PartnerInput, type ShareType,
|
||||
} from '@/lib/api';
|
||||
import Button from '@/components/ui/Button';
|
||||
import { downloadBlob } from '../_utils/format';
|
||||
import { Modal, Field, PygInput, DateInput, Money, ChoiceCards, EmptyState, Pill, inputClass, iconButtonClass, todayIso } from './ui';
|
||||
import { UserSearch } from './UserSearch';
|
||||
import { bpToPercent, percentToBp } from './format';
|
||||
import { FinalizeDialog } from './FinalizeDialog';
|
||||
import type { Lifecycle } from './derive';
|
||||
|
||||
type T = (key: string, params?: Record<string, string | number>) => string;
|
||||
type Fmt = ReturnType<typeof useMoney>;
|
||||
|
||||
function dealText(p: EventPartner, t: T, m: Fmt): string {
|
||||
switch (p.shareType) {
|
||||
case 'percent_profit': return t('admin.finance.deal.percentProfit', { percent: m.pct(p.percentBp) });
|
||||
case 'percent_revenue': return t('admin.finance.deal.percentRevenue', { percent: m.pct(p.percentBp) });
|
||||
case 'fixed': return t('admin.finance.deal.fixed', { amount: m.pyg(p.fixedAmount) });
|
||||
case 'fixed_plus_percent_above_threshold':
|
||||
return t('admin.finance.deal.fixedPlus', { amount: m.pyg(p.fixedAmount), percent: m.pct(p.percentBp), threshold: m.pyg(p.thresholdAmount) });
|
||||
}
|
||||
}
|
||||
|
||||
function ModalActions({ children }: { children: React.ReactNode }) {
|
||||
return <div className="sticky bottom-0 -mx-4 -mb-4 mt-2 px-4 py-3 bg-white border-t border-gray-100 flex gap-3">{children}</div>;
|
||||
}
|
||||
|
||||
// ==================== Partner form ====================
|
||||
|
||||
interface PartnerForm {
|
||||
userId: string | null;
|
||||
userLabel: string;
|
||||
externalName: string;
|
||||
roleLabel: string;
|
||||
shareType: ShareType;
|
||||
percent: string;
|
||||
fixedAmount: number;
|
||||
thresholdAmount: number;
|
||||
lossRule: LossRule;
|
||||
lossCapAmount: number;
|
||||
}
|
||||
|
||||
const emptyPartner = (): PartnerForm => ({
|
||||
userId: null, userLabel: '', externalName: '', roleLabel: '', shareType: 'percent_profit', percent: '',
|
||||
fixedAmount: 0, thresholdAmount: 0, lossRule: 'none', lossCapAmount: 0,
|
||||
});
|
||||
|
||||
function PartnerModal({ open, onClose, eventId, partner, onSaved }: {
|
||||
open: boolean; onClose: () => void; eventId: string; partner: EventPartner | null; onSaved: () => void;
|
||||
}) {
|
||||
const { t } = useLanguage();
|
||||
const [form, setForm] = useState<PartnerForm>(emptyPartner());
|
||||
const [touched, setTouched] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const set = <K extends keyof PartnerForm>(k: K, v: PartnerForm[K]) => setForm((f) => ({ ...f, [k]: v }));
|
||||
const search = useCallback((q: string) => financeApi.searchPartnerCandidates(eventId, q), [eventId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setTouched(false);
|
||||
setForm(partner ? {
|
||||
userId: partner.userId, userLabel: partner.userId ? partner.name : '', externalName: partner.externalName || '',
|
||||
roleLabel: partner.roleLabel || '', shareType: partner.shareType, percent: partner.percentBp ? bpToPercent(partner.percentBp) : '',
|
||||
fixedAmount: partner.fixedAmount, thresholdAmount: partner.thresholdAmount, lossRule: partner.lossRule, lossCapAmount: partner.lossCapAmount,
|
||||
} : emptyPartner());
|
||||
}, [open, partner]);
|
||||
|
||||
const st = form.shareType;
|
||||
const hasPercent = st !== 'fixed';
|
||||
const hasFixed = st === 'fixed' || st === 'fixed_plus_percent_above_threshold';
|
||||
const whoError = touched && !form.userId && !form.externalName.trim() ? t('admin.finance.split.needsNameOrUser') : undefined;
|
||||
|
||||
const submit = async () => {
|
||||
setTouched(true);
|
||||
if (!form.userId && !form.externalName.trim()) return;
|
||||
setSaving(true);
|
||||
const payload: PartnerInput = {
|
||||
userId: form.userId,
|
||||
externalName: form.externalName.trim() || null,
|
||||
roleLabel: form.roleLabel.trim() || null,
|
||||
shareType: st,
|
||||
percentBp: hasPercent ? percentToBp(form.percent) : 0,
|
||||
fixedAmount: hasFixed ? form.fixedAmount : 0,
|
||||
thresholdAmount: st === 'fixed_plus_percent_above_threshold' ? form.thresholdAmount : 0,
|
||||
lossRule: st === 'percent_profit' ? form.lossRule : 'none',
|
||||
lossCapAmount: st === 'percent_profit' && form.lossRule === 'capped' ? form.lossCapAmount : 0,
|
||||
};
|
||||
try {
|
||||
if (partner) await financeApi.updatePartner(eventId, partner.id, payload);
|
||||
else await financeApi.createPartner(eventId, payload);
|
||||
toast.success(t('admin.finance.split.partnerSaved'));
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch (error: any) {
|
||||
toast.error(error.message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} title={t(partner ? 'admin.finance.split.editPartner' : 'admin.finance.split.addPartner')} wide>
|
||||
<Field label={t('admin.finance.split.who')} required error={whoError} hint={t('admin.finance.split.whoHint')}>
|
||||
<div className="space-y-2">
|
||||
{form.userId ? (
|
||||
<div className="flex items-center gap-2 pl-3 rounded-btn bg-gray-50 text-sm min-h-[44px]">
|
||||
<span className="flex-1 truncate">{form.userLabel}</span>
|
||||
<button type="button" onClick={() => setForm((f) => ({ ...f, userId: null, userLabel: '' }))} className={iconButtonClass} aria-label={t('admin.team.remove')}>
|
||||
<XMarkIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<UserSearch
|
||||
search={search}
|
||||
onPick={(u) => setForm((f) => ({ ...f, userId: u.id, userLabel: `${u.name} (${u.email})` }))}
|
||||
placeholder={t('admin.finance.split.searchUser')}
|
||||
noResults={t('admin.team.noResults')}
|
||||
/>
|
||||
)}
|
||||
{!form.userId && (
|
||||
<input className={inputClass} value={form.externalName} maxLength={200} placeholder={t('admin.finance.split.externalName')} onChange={(e) => set('externalName', e.target.value)} />
|
||||
)}
|
||||
</div>
|
||||
</Field>
|
||||
<Field label={t('admin.finance.split.roleLabel')}>
|
||||
<input className={inputClass} value={form.roleLabel} maxLength={100} placeholder={t('admin.finance.split.rolePlaceholder')} onChange={(e) => set('roleLabel', e.target.value)} />
|
||||
</Field>
|
||||
<Field label={t('admin.finance.split.shareType')}>
|
||||
<ChoiceCards<ShareType>
|
||||
name="shareType"
|
||||
value={st}
|
||||
onChange={(v) => set('shareType', v)}
|
||||
options={SHARE_TYPES.map((s) => ({ key: s, title: t(`admin.finance.shareTypes.${s}`), description: t(`admin.finance.shareHints.${s}`) }))}
|
||||
/>
|
||||
</Field>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{hasFixed && (
|
||||
<Field label={t('admin.finance.split.fixedAmount')}>
|
||||
<PygInput value={form.fixedAmount} onChange={(n) => set('fixedAmount', n)} />
|
||||
</Field>
|
||||
)}
|
||||
{hasPercent && (
|
||||
<Field label={t('admin.finance.split.percent')}>
|
||||
<div className="relative">
|
||||
<input className={clsx(inputClass, 'pr-8')} inputMode="decimal" value={form.percent} placeholder="30" onChange={(e) => set('percent', e.target.value)} />
|
||||
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-sm text-gray-400">%</span>
|
||||
</div>
|
||||
</Field>
|
||||
)}
|
||||
{st === 'fixed_plus_percent_above_threshold' && (
|
||||
<Field label={t('admin.finance.split.threshold')}>
|
||||
<PygInput value={form.thresholdAmount} onChange={(n) => set('thresholdAmount', n)} />
|
||||
</Field>
|
||||
)}
|
||||
</div>
|
||||
{st === 'percent_profit' && (
|
||||
<Field label={t('admin.finance.split.lossRule')}>
|
||||
<ChoiceCards<LossRule>
|
||||
name="lossRule"
|
||||
columns={3}
|
||||
value={form.lossRule}
|
||||
onChange={(v) => set('lossRule', v)}
|
||||
options={LOSS_RULES.map((r) => ({ key: r, title: t(`admin.finance.lossRuleTitles.${r}`), description: t(`admin.finance.lossRules.${r}`) }))}
|
||||
/>
|
||||
{form.lossRule === 'capped' && (
|
||||
<div className="mt-2">
|
||||
<Field label={t('admin.finance.split.lossCap')}>
|
||||
<PygInput value={form.lossCapAmount} onChange={(n) => set('lossCapAmount', n)} />
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
</Field>
|
||||
)}
|
||||
<ModalActions>
|
||||
<Button variant="outline" onClick={onClose} className="flex-1 min-h-[44px]">{t('common.cancel')}</Button>
|
||||
<Button onClick={submit} isLoading={saving} className="flex-1 min-h-[44px]">{t('common.save')}</Button>
|
||||
</ModalActions>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== Payout ====================
|
||||
|
||||
function PayoutModal({ partner, amount, onClose, eventId, onSaved }: { partner: EventPartner | null; amount: number; onClose: () => void; eventId: string; onSaved: () => void }) {
|
||||
const { t } = useLanguage();
|
||||
const [method, setMethod] = useState('');
|
||||
const [date, setDate] = useState('');
|
||||
const [note, setNote] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
useEffect(() => {
|
||||
if (partner) { setMethod(''); setDate(todayIso()); setNote(''); }
|
||||
}, [partner]);
|
||||
const submit = async () => {
|
||||
if (!partner) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await financeApi.markPartnerPaid(eventId, partner.id, { paid: true, payoutMethod: method || null, payoutDate: date || null, payoutNote: note || null });
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch (error: any) {
|
||||
toast.error(error.message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Modal open={!!partner} onClose={onClose} title={t('admin.finance.split.markPaid')} subtitle={partner?.name}>
|
||||
<div className="rounded-btn bg-gray-50 p-3 flex justify-between items-baseline">
|
||||
<span className="text-sm text-gray-600">{t(amount < 0 ? 'admin.finance.split.owes' : 'admin.finance.split.payout')}</span>
|
||||
<Money value={Math.abs(amount)} className="text-lg font-bold" />
|
||||
</div>
|
||||
<Field label={t('admin.finance.split.payoutMethod')}>
|
||||
<input className={inputClass} value={method} maxLength={50} onChange={(e) => setMethod(e.target.value)} placeholder={t('admin.finance.split.payoutMethodPlaceholder')} />
|
||||
</Field>
|
||||
<Field label={t('admin.finance.expenses.date')}>
|
||||
<DateInput value={date} onChange={setDate} />
|
||||
</Field>
|
||||
<Field label={t('admin.finance.split.payoutNote')}>
|
||||
<textarea className={inputClass} rows={2} value={note} maxLength={1000} onChange={(e) => setNote(e.target.value)} />
|
||||
</Field>
|
||||
<ModalActions>
|
||||
<Button variant="outline" onClick={onClose} className="flex-1 min-h-[44px]">{t('common.cancel')}</Button>
|
||||
<Button onClick={submit} isLoading={saving} className="flex-1 min-h-[44px]">{t('admin.finance.split.markPaid')}</Button>
|
||||
</ModalActions>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== Audit log ====================
|
||||
|
||||
function AuditLog({ eventId }: { eventId: string }) {
|
||||
const { t, locale } = useLanguage();
|
||||
const [entries, setEntries] = useState<FinanceAuditEntry[] | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
useEffect(() => {
|
||||
if (open && entries === null) financeApi.auditLog(eventId).then((r) => setEntries(r.entries)).catch(() => setEntries([]));
|
||||
}, [open, entries, eventId]);
|
||||
const describe = (e: FinanceAuditEntry) => {
|
||||
const name = e.after?.description || e.before?.description || e.after?.name || e.before?.name || '';
|
||||
const action = t(`admin.finance.audit.${e.action}`);
|
||||
const entity = t(`admin.finance.audit.entity.${e.entityType}`);
|
||||
return `${entity} · ${action.startsWith('admin.') ? e.action.replace(/_/g, ' ') : action}${name ? ` · ${name}` : ''}`;
|
||||
};
|
||||
return (
|
||||
<div className="bg-white rounded-card shadow-card">
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
aria-expanded={open}
|
||||
className="w-full flex items-center gap-2 px-4 min-h-[48px] text-sm font-semibold cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-yellow rounded-card"
|
||||
>
|
||||
<ClockIcon className="w-4 h-4 text-gray-500" aria-hidden />
|
||||
{t('admin.finance.split.auditLog')}
|
||||
<ChevronDownIcon className={clsx('w-4 h-4 ml-auto text-gray-400 transition-transform', open && 'rotate-180')} aria-hidden />
|
||||
</button>
|
||||
{open && (
|
||||
<div className="px-4 pb-4">
|
||||
{entries === null ? <p className="text-sm text-gray-400">{t('common.loading')}</p> : entries.length === 0 ? (
|
||||
<p className="text-sm text-gray-400">{t('admin.finance.split.auditEmpty')}</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-gray-100 text-sm">
|
||||
{entries.map((e) => (
|
||||
<li key={e.id} className="py-2 flex flex-col sm:flex-row sm:gap-3">
|
||||
<span className="text-xs text-gray-400 tabular-nums sm:w-36 flex-shrink-0">
|
||||
{e.createdAt ? new Date(e.createdAt).toLocaleString(locale === 'es' ? 'es-PY' : 'en-US', { dateStyle: 'short', timeStyle: 'short' }) : ''}
|
||||
</span>
|
||||
<span><span className="font-medium">{e.actorName || '-'}</span> <span className="text-gray-600">{describe(e)}</span></span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== View ====================
|
||||
|
||||
export function SplitView({ eventId, data, lifecycle, onChange }: { eventId: string; data: EventFinance; lifecycle: Lifecycle; onChange: () => void }) {
|
||||
const { t, locale } = useLanguage();
|
||||
const m = useMoney();
|
||||
const { privacyMode } = usePrivacy();
|
||||
const [editing, setEditing] = useState<EventPartner | null>(null);
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [paying, setPaying] = useState<EventPartner | null>(null);
|
||||
const [finalizing, setFinalizing] = useState(false);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
|
||||
const { viewer, summary } = data;
|
||||
const open = data.status === 'open';
|
||||
const manage = viewer.canManageSplit;
|
||||
const lines = new Map(summary.split.partners.map((p) => [p.partnerId, p]));
|
||||
const hasPartners = data.partners.length > 0;
|
||||
const paidCount = data.partners.filter((p) => p.payoutStatus === 'paid').length;
|
||||
|
||||
const run = async (key: string, fn: () => Promise<unknown>, success?: string) => {
|
||||
setBusy(key);
|
||||
try {
|
||||
await fn();
|
||||
if (success) toast.success(success);
|
||||
onChange();
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
toast.error(error.message);
|
||||
return false;
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const finalize = async () => {
|
||||
if (await run('finalize', () => financeApi.finalize(eventId), t('admin.finance.split.finalized'))) setFinalizing(false);
|
||||
};
|
||||
const unfinalize = () => {
|
||||
if (!confirm(t('admin.finance.split.unfinalizeConfirm'))) return;
|
||||
run('unfinalize', () => financeApi.unfinalize(eventId), t('admin.finance.split.unfinalized'));
|
||||
};
|
||||
const remove = (p: EventPartner) => {
|
||||
if (!confirm(t('admin.finance.split.confirmRemoveNamed', { name: p.name }))) return;
|
||||
run(`rm-${p.id}`, () => financeApi.deletePartner(eventId, p.id), t('admin.finance.split.partnerRemoved'));
|
||||
};
|
||||
const statement = async (p: EventPartner) => {
|
||||
setBusy(`pdf-${p.id}`);
|
||||
try {
|
||||
const { blob, filename } = await financeApi.downloadStatement(eventId, p.id, locale);
|
||||
downloadBlob(blob, filename);
|
||||
} catch (error: any) {
|
||||
toast.error(error.message);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
const fmtDate = (iso: string | null) => (iso ? new Date(iso).toLocaleDateString(locale === 'es' ? 'es-PY' : 'en-US', { dateStyle: 'medium' }) : '');
|
||||
const openNew = () => { setEditing(null); setFormOpen(true); };
|
||||
|
||||
const loss = summary.profit < 0;
|
||||
const distributable = summary.split.distributable;
|
||||
// Percent-of-profit deals apply to what is left after fixed and revenue deals; say so when that differs from the profit.
|
||||
const profitBase = distributable !== null && distributable !== summary.profit ? distributable : null;
|
||||
const resultTone = (v: number | null) => (!open && !privacyMode && v !== null ? (v < 0 ? 'text-red-600' : 'text-green-700') : undefined);
|
||||
|
||||
const BreakdownRow = ({ label, detail, value, sign, strong, className }: {
|
||||
label: React.ReactNode; detail?: string; value: number | null; sign?: '+' | '−'; strong?: boolean; className?: string;
|
||||
}) => (
|
||||
<div className={clsx('flex items-baseline justify-between gap-3 py-1', strong ? 'font-semibold' : 'text-gray-600')}>
|
||||
<span className="min-w-0">
|
||||
{sign && <span className="inline-block w-4 text-gray-400" aria-hidden>{sign}</span>}
|
||||
{label}
|
||||
{detail && <span className="block text-xs text-gray-400 pl-4">{detail}</span>}
|
||||
</span>
|
||||
<Money value={value} className={clsx('whitespace-nowrap', className)} />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
{/* Result, as a short calculation ending in what the organization keeps */}
|
||||
<div className="bg-white rounded-card shadow-card p-4 lg:col-span-2 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold text-primary-dark">{t('admin.finance.split.calculation')}</h3>
|
||||
{open && <Pill>{t('admin.finance.kpi.estimated')}</Pill>}
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<BreakdownRow label={t(loss ? 'admin.finance.kpi.loss' : 'admin.finance.kpi.profit')} value={summary.profit} strong className={resultTone(summary.profit)} />
|
||||
{summary.split.partners.map((line) => {
|
||||
const p = data.partners.find((x) => x.id === line.partnerId);
|
||||
const rule = p ? dealText(p, t, m) : '';
|
||||
const detail = p?.shareType === 'percent_profit' && profitBase !== null && !privacyMode
|
||||
? t('admin.finance.split.ofDistributable', { rule, base: m.pyg(profitBase) })
|
||||
: rule;
|
||||
return (
|
||||
<BreakdownRow
|
||||
key={line.partnerId}
|
||||
sign={line.share < 0 ? '+' : '−'}
|
||||
label={line.name}
|
||||
detail={detail}
|
||||
value={Math.abs(line.share)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{viewer.fullSplit && (
|
||||
<>
|
||||
<div className="border-t border-gray-200 mt-1" />
|
||||
<BreakdownRow label={t('admin.finance.split.organization')} value={summary.split.organization} strong className={clsx('text-base', resultTone(summary.split.organization))} />
|
||||
{!hasPartners && <p className="text-xs text-gray-400">{t('admin.finance.split.empty')}</p>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{!viewer.fullSplit && <p className="text-xs text-gray-500 mt-2">{t('admin.finance.split.ownShareOnly')}</p>}
|
||||
</div>
|
||||
|
||||
{/* Close the books: the one place to finalize */}
|
||||
{(manage || !open) && (
|
||||
<div className={clsx('rounded-card shadow-card p-4 flex flex-col gap-3', open ? 'bg-white' : 'bg-blue-50')}>
|
||||
<div>
|
||||
<h3 className="font-semibold text-primary-dark flex items-center gap-2">
|
||||
{open ? <LockOpenIcon className="w-5 h-5" aria-hidden /> : <LockClosedIcon className="w-5 h-5" aria-hidden />}
|
||||
{t(open ? 'admin.finance.split.closeBooks' : 'admin.finance.split.booksClosed')}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
{open
|
||||
? t('admin.finance.split.closeBooksHint')
|
||||
: hasPartners
|
||||
? t('admin.finance.split.payoutsProgress', { paid: paidCount, total: data.partners.length })
|
||||
: t('admin.finance.split.frozenNoPartners')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-auto">
|
||||
{open && manage && (
|
||||
<Button onClick={() => setFinalizing(true)} className="w-full min-h-[44px]">
|
||||
<LockClosedIcon className="w-4 h-4 mr-1.5" aria-hidden />{t('admin.finance.split.closeBooks')}
|
||||
</Button>
|
||||
)}
|
||||
{!open && viewer.canUnfinalize && (
|
||||
<Button variant="outline" onClick={unfinalize} isLoading={busy === 'unfinalize'} className="w-full min-h-[44px]">
|
||||
<LockOpenIcon className="w-4 h-4 mr-1.5" aria-hidden />{t('admin.finance.split.unfinalize')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Partners */}
|
||||
{!hasPartners ? (
|
||||
viewer.fullSplit && (
|
||||
<EmptyState icon={<UserGroupIcon className="w-5 h-5" />} title={t('admin.finance.split.noPartnersTitle')} body={t('admin.finance.split.noPartnersBody')}>
|
||||
{manage && open && <Button onClick={openNew} className="min-h-[44px]"><PlusIcon className="w-4 h-4 mr-1" aria-hidden />{t('admin.finance.split.addPartner')}</Button>}
|
||||
</EmptyState>
|
||||
)
|
||||
) : (
|
||||
<div className="bg-white rounded-card shadow-card">
|
||||
<div className="flex items-center justify-between gap-2 px-4 py-3 border-b border-gray-100">
|
||||
<h3 className="text-sm font-semibold">{t('admin.finance.split.partners')}</h3>
|
||||
{manage && open && (
|
||||
<Button size="sm" onClick={openNew} className="min-h-[44px] md:min-h-0">
|
||||
<PlusIcon className="w-4 h-4 mr-1" aria-hidden />{t('admin.finance.split.addPartner')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<ul className="divide-y divide-gray-100">
|
||||
{data.partners.map((p) => {
|
||||
const line = lines.get(p.id);
|
||||
const payout = line?.payout ?? 0;
|
||||
const owes = payout < 0;
|
||||
const isPaid = p.payoutStatus === 'paid';
|
||||
const reimbursement = line?.reimbursement ?? 0;
|
||||
return (
|
||||
<li key={p.id} className="px-4 py-3 grid grid-cols-[1fr_auto] md:grid-cols-[minmax(0,1fr)_10rem_9rem_auto] gap-x-4 gap-y-2 items-center">
|
||||
{/* Who and the deal */}
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-primary-dark truncate">{p.name}</p>
|
||||
<p className="text-xs text-gray-500">{[p.roleLabel, dealText(p, t, m)].filter(Boolean).join(' · ')}</p>
|
||||
{p.shareType === 'percent_profit' && loss && (
|
||||
<p className="text-xs text-gray-400">{t(`admin.finance.lossRules.${p.lossRule}`)}{p.lossRule === 'capped' ? ` (${m.pyg(p.lossCapAmount)})` : ''}</p>
|
||||
)}
|
||||
</div>
|
||||
{/* Amount */}
|
||||
<div className="text-right">
|
||||
<Money value={Math.abs(payout)} className={clsx('font-semibold whitespace-nowrap', owes && !privacyMode && 'text-red-600')} />
|
||||
<p className="text-xs text-gray-400">
|
||||
{owes ? t('admin.finance.split.owes') : reimbursement !== 0 && !privacyMode
|
||||
? t('admin.finance.split.includesReimbursement', { amount: m.pyg(reimbursement) })
|
||||
: t('admin.finance.split.payout')}
|
||||
</p>
|
||||
</div>
|
||||
{/* Status */}
|
||||
<div className="col-span-2 md:col-span-1 flex items-center gap-2">
|
||||
<span className={clsx('inline-flex items-center gap-1 rounded-full pl-1.5 pr-2.5 py-0.5 text-xs font-medium whitespace-nowrap', isPaid ? 'bg-green-100 text-green-800' : 'bg-yellow-100 text-yellow-800')}>
|
||||
{isPaid ? <CheckCircleIcon className="w-4 h-4" aria-hidden /> : <ClockIcon className="w-4 h-4" aria-hidden />}
|
||||
{isPaid ? t('admin.finance.split.paidOn', { date: fmtDate(p.payoutDate) }) : t('admin.finance.split.unpaid')}
|
||||
</span>
|
||||
{isPaid && (p.payoutMethod || p.payoutNote) && (
|
||||
<span className="text-xs text-gray-500 truncate md:hidden">{[p.payoutMethod, p.payoutNote].filter(Boolean).join(' · ')}</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Actions */}
|
||||
<div className="col-span-2 md:col-span-1 flex flex-wrap items-center gap-1 md:justify-end">
|
||||
{manage && !open && !isPaid && (
|
||||
<Button size="sm" onClick={() => setPaying(p)} className="min-h-[44px] md:min-h-[36px]">
|
||||
<CheckCircleIcon className="w-4 h-4 mr-1" aria-hidden />{t('admin.finance.split.markPaid')}
|
||||
</Button>
|
||||
)}
|
||||
{manage && !open && isPaid && (
|
||||
<Button size="sm" variant="ghost" isLoading={busy === `unpay-${p.id}`} className="min-h-[44px] md:min-h-[36px]"
|
||||
onClick={() => run(`unpay-${p.id}`, () => financeApi.markPartnerPaid(eventId, p.id, { paid: false }))}>
|
||||
{t('admin.finance.split.markUnpaid')}
|
||||
</Button>
|
||||
)}
|
||||
<button onClick={() => statement(p)} disabled={busy === `pdf-${p.id}`} className={iconButtonClass} aria-label={`${t('admin.finance.split.statement')} ${p.name}`} title={t('admin.finance.split.statement')}>
|
||||
<DocumentArrowDownIcon className="w-4 h-4" />
|
||||
</button>
|
||||
{manage && open && (
|
||||
<>
|
||||
<button onClick={() => { setEditing(p); setFormOpen(true); }} className={iconButtonClass} aria-label={`${t('common.edit')} ${p.name}`}><PencilIcon className="w-4 h-4" /></button>
|
||||
<button onClick={() => remove(p)} className={clsx(iconButtonClass, 'text-red-600')} aria-label={`${t('common.delete')} ${p.name}`}><TrashIcon className="w-4 h-4" /></button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{manage && open && <p className="px-4 py-2.5 border-t border-gray-100 text-xs text-gray-500">{t('admin.finance.split.finalizeFirst')}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{viewer.fullSplit && <AuditLog key={`${data.status}-${data.computedAt}`} eventId={eventId} />}
|
||||
|
||||
<PartnerModal open={formOpen} onClose={() => setFormOpen(false)} eventId={eventId} partner={editing} onSaved={onChange} />
|
||||
<PayoutModal partner={paying} amount={paying ? lines.get(paying.id)?.payout ?? 0 : 0} onClose={() => setPaying(null)} eventId={eventId} onSaved={onChange} />
|
||||
<FinalizeDialog open={finalizing} onClose={() => setFinalizing(false)} onConfirm={finalize} busy={busy === 'finalize'} data={data} lifecycle={lifecycle} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
|
||||
import { inputClass } from './ui';
|
||||
|
||||
export interface UserHit { id: string; name: string; email: string; role: string }
|
||||
|
||||
/** Debounced search box with a result list; `search` decides who is eligible. */
|
||||
export function UserSearch({ search, onPick, placeholder, noResults }: {
|
||||
search: (q: string) => Promise<{ users: UserHit[] }>;
|
||||
onPick: (u: UserHit) => void;
|
||||
placeholder: string;
|
||||
noResults: string;
|
||||
}) {
|
||||
const [q, setQ] = useState('');
|
||||
const [hits, setHits] = useState<UserHit[]>([]);
|
||||
const [searched, setSearched] = useState(false);
|
||||
const seq = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const term = q.trim();
|
||||
if (term.length < 2) { setHits([]); setSearched(false); return; }
|
||||
const mine = ++seq.current;
|
||||
const timer = setTimeout(() => {
|
||||
search(term)
|
||||
.then((res) => { if (mine === seq.current) { setHits(res.users); setSearched(true); } })
|
||||
.catch(() => { if (mine === seq.current) { setHits([]); setSearched(true); } });
|
||||
}, 250);
|
||||
return () => clearTimeout(timer);
|
||||
}, [q, search]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="relative">
|
||||
<MagnifyingGlassIcon className="w-4 h-4 text-gray-400 absolute left-3 top-1/2 -translate-y-1/2" />
|
||||
<input className={`${inputClass} pl-9`} value={q} onChange={(e) => setQ(e.target.value)} placeholder={placeholder} />
|
||||
</div>
|
||||
{hits.length > 0 && (
|
||||
<ul className="mt-1 border border-gray-100 rounded-btn divide-y divide-gray-100 max-h-56 overflow-y-auto">
|
||||
{hits.map((u) => (
|
||||
<li key={u.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { onPick(u); setQ(''); setHits([]); setSearched(false); }}
|
||||
className="w-full text-left px-3 py-2 hover:bg-gray-50 min-h-[44px]"
|
||||
>
|
||||
<p className="text-sm font-medium truncate">{u.name}</p>
|
||||
<p className="text-xs text-gray-500 truncate">{u.email} · {u.role}</p>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{searched && hits.length === 0 && <p className="text-xs text-gray-400 mt-1">{noResults}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// Client-side preview of an expense amount, mirroring
|
||||
// backend/src/lib/finance/calculate.ts expenseAmount(). Only used to show
|
||||
// "this will be X PYG right now" while typing; the server stays authoritative.
|
||||
|
||||
import type { CalcType } from '@/lib/api';
|
||||
|
||||
export interface PreviewContext { ticketsSold: number; checkedIn: number; sales: number }
|
||||
|
||||
export function previewExpense(
|
||||
e: { calcType: CalcType; quantity: number; unitAmount: number; percentBp: number; minimumAmount: number },
|
||||
ctx: PreviewContext,
|
||||
): { amount: number; count: number } {
|
||||
switch (e.calcType) {
|
||||
case 'per_ticket_sold': return { amount: e.unitAmount * ctx.ticketsSold, count: ctx.ticketsSold };
|
||||
case 'per_checked_in': return { amount: e.unitAmount * ctx.checkedIn, count: ctx.checkedIn };
|
||||
case 'percent_of_revenue': return { amount: Math.round((Math.max(0, ctx.sales) * e.percentBp) / 10000), count: 0 };
|
||||
case 'minimum_spend': return { amount: Math.max(e.minimumAmount, e.unitAmount * ctx.checkedIn), count: ctx.checkedIn };
|
||||
default: return { amount: e.unitAmount * e.quantity, count: e.quantity };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
calculateEventFinance, expenseAmount, CALC_TYPES,
|
||||
type FinanceExpense, type FinanceInput, type FinancePartner, type FinancePayment,
|
||||
} from '../../../../../../../backend/src/lib/finance/calculate';
|
||||
import type { EventExpense, EventFinance, EventPartner, FinanceSummary } from '@/lib/api';
|
||||
import { breakEvenState, buildWaterfall, deriveLifecycle, detailChartsAvailable, suggestCategory, tCount } from './derive';
|
||||
import { previewExpense } from './calc';
|
||||
|
||||
// The frontend derivations run on the backend's real calculation, so these
|
||||
// tests also check the UI reads the same numbers the server computes.
|
||||
|
||||
let seq = 0;
|
||||
const pay = (amount: number, over: Partial<FinancePayment> = {}): FinancePayment => ({
|
||||
id: `p${++seq}`, amount, provider: 'tpago', source: 'presale', status: 'paid', paidAt: '2026-09-01T12:00:00Z', ...over,
|
||||
});
|
||||
const expense = (over: Partial<FinanceExpense> = {}): FinanceExpense => ({
|
||||
id: `e${++seq}`, description: 'x', categoryId: null, calcType: 'fixed', quantity: 1, unitAmount: 0, percentBp: 0,
|
||||
minimumAmount: 0, computedAmount: 0, isLocked: false, status: 'planned', paidByPartnerId: null, ...over,
|
||||
});
|
||||
const partner = (over: Partial<FinancePartner> = {}): FinancePartner => ({
|
||||
id: `pt${++seq}`, name: 'Partner', shareType: 'percent_profit', percentBp: 5000, fixedAmount: 0, thresholdAmount: 0,
|
||||
lossRule: 'none', lossCapAmount: 0, ...over,
|
||||
});
|
||||
const input = (over: Partial<FinanceInput> = {}): FinanceInput => ({
|
||||
ticketPrice: 100000, ticketsSold: 0, checkedIn: 0, payments: [], expenses: [], otherIncome: [], fees: [], partners: [], ...over,
|
||||
});
|
||||
const summaryOf = (i: FinanceInput) => calculateEventFinance(i) as unknown as FinanceSummary;
|
||||
|
||||
// ---- Scenarios (same as the backend's pinned ones)
|
||||
|
||||
const noExpenses = summaryOf(input({
|
||||
ticketPrice: 50000, ticketsSold: 4, checkedIn: 4,
|
||||
payments: [pay(50000), pay(50000), pay(50000), pay(50000)],
|
||||
fees: [{ method: 'tpago', percentBp: 290, fixedAmount: 0 }],
|
||||
}));
|
||||
const loss = summaryOf(input({
|
||||
ticketPrice: 50000, ticketsSold: 4, checkedIn: 4,
|
||||
payments: [pay(50000), pay(50000), pay(50000), pay(50000, { provider: 'cash', source: 'door' })],
|
||||
fees: [{ method: 'tpago', percentBp: 290, fixedAmount: 0 }],
|
||||
expenses: [
|
||||
expense({ unitAmount: 300000, status: 'paid' }),
|
||||
expense({ calcType: 'per_checked_in', unitAmount: 10000, status: 'planned' }),
|
||||
],
|
||||
}));
|
||||
const ana = partner({ name: 'Ana', shareType: 'percent_profit', percentBp: 3000 });
|
||||
const withPartners = summaryOf(input({
|
||||
ticketPrice: 100000, ticketsSold: 10, checkedIn: 9,
|
||||
payments: Array.from({ length: 10 }, () => pay(100000, { provider: 'bank_transfer' })),
|
||||
otherIncome: [{ id: 'i1', description: 'Sponsor', amount: 50000 }],
|
||||
expenses: [
|
||||
expense({ unitAmount: 200000, status: 'paid' }),
|
||||
expense({ unitAmount: 150000, status: 'paid', paidByPartnerId: ana.id }),
|
||||
],
|
||||
partners: [ana, partner({ name: 'Venue Co', shareType: 'fixed', fixedAmount: 100000 })],
|
||||
}));
|
||||
|
||||
describe('buildWaterfall', () => {
|
||||
const sumTo = (steps: ReturnType<typeof buildWaterfall>) =>
|
||||
steps.slice(1, -1).reduce((s, w) => s + w.amount, steps[0].amount);
|
||||
|
||||
it('no expenses: gross, fees, profit; nothing else shown', () => {
|
||||
const steps = buildWaterfall(noExpenses);
|
||||
expect(steps.map((s) => [s.key, s.amount])).toEqual([['gross', 200000], ['fees', -5800], ['profit', 194200]]);
|
||||
expect(sumTo(steps)).toBe(steps[steps.length - 1].amount);
|
||||
});
|
||||
|
||||
it('a loss: ends at the (negative) profit', () => {
|
||||
const steps = buildWaterfall(loss);
|
||||
expect(steps.map((s) => s.key)).toEqual(['gross', 'fees', 'expenses', 'profit']);
|
||||
expect(steps[steps.length - 1].amount).toBe(-144350);
|
||||
expect(sumTo(steps)).toBe(-144350);
|
||||
});
|
||||
|
||||
it('partners: other income in, partner payouts out, ends at what the organization keeps', () => {
|
||||
const steps = buildWaterfall(withPartners);
|
||||
expect(steps.map((s) => [s.key, s.kind, s.amount])).toEqual([
|
||||
['gross', 'total', 1000000],
|
||||
['expenses', 'out', -350000],
|
||||
['otherIncome', 'in', 50000],
|
||||
['partners', 'out', -280000],
|
||||
['organization', 'total', 420000],
|
||||
]);
|
||||
expect(sumTo(steps)).toBe(420000);
|
||||
});
|
||||
|
||||
it('viewers without the full split end at the profit', () => {
|
||||
const scoped = { ...withPartners, split: { ...withPartners.split, organization: null, distributable: null } };
|
||||
const steps = buildWaterfall(scoped);
|
||||
expect(steps[steps.length - 1]).toEqual({ key: 'profit', kind: 'total', amount: 700000 });
|
||||
expect(steps.some((s) => s.key === 'partners')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('breakEvenState', () => {
|
||||
it('no costs: neutral, no progress', () => {
|
||||
expect(breakEvenState(noExpenses)).toEqual({ kind: 'noCosts' });
|
||||
});
|
||||
it('not covered: tickets still needed and progress sold / needed', () => {
|
||||
expect(breakEvenState(loss)).toEqual({ kind: 'needed', remaining: 4, sold: 4, needed: 8, pct: 50 });
|
||||
});
|
||||
it('covered: reached at N of M sold', () => {
|
||||
expect(breakEvenState(withPartners)).toEqual({ kind: 'reached', at: 3, sold: 10 });
|
||||
});
|
||||
it('unreachable at this price', () => {
|
||||
const s = summaryOf(input({ ticketPrice: 0, expenses: [expense({ unitAmount: 1000 })] }));
|
||||
expect(breakEvenState(s)).toEqual({ kind: 'unreachable' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('detailChartsAvailable', () => {
|
||||
it('hides cost charts without expenses and the timeline with fewer than 2 days', () => {
|
||||
expect(detailChartsAvailable(noExpenses)).toEqual({ costs: false, methods: true, timeline: false });
|
||||
});
|
||||
it('shows the timeline once there are two sales days', () => {
|
||||
const s = summaryOf(input({ payments: [pay(1, { paidAt: '2026-09-01T10:00:00Z' }), pay(1, { paidAt: '2026-09-02T10:00:00Z' })] }));
|
||||
expect(detailChartsAvailable(s).timeline).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Lifecycle
|
||||
|
||||
const NOW = Date.parse('2026-09-28T12:00:00Z');
|
||||
const exp = (status: 'planned' | 'paid') => ({ status } as EventExpense);
|
||||
const prt = (payoutStatus: 'pending' | 'paid') => ({ payoutStatus } as EventPartner);
|
||||
const fin = (over: Partial<Pick<EventFinance, 'status' | 'expenses' | 'partners'>> & { start?: string; end?: string | null } = {}) => ({
|
||||
status: over.status ?? 'open',
|
||||
expenses: over.expenses ?? [],
|
||||
partners: over.partners ?? [],
|
||||
event: { startDatetime: over.start ?? '2026-10-10T20:00:00Z', endDatetime: over.end ?? null },
|
||||
} as Parameters<typeof deriveLifecycle>[0]);
|
||||
|
||||
describe('deriveLifecycle', () => {
|
||||
it('upcoming with no costs: selling', () => {
|
||||
const l = deriveLifecycle(fin(), NOW);
|
||||
expect(l.step).toBe('selling');
|
||||
expect(l.ended).toBe(false);
|
||||
expect(l.todo.map((t) => t.key)).toEqual(['noCosts', 'noPartners', 'notEnded']);
|
||||
});
|
||||
|
||||
it('upcoming with costs: adding costs', () => {
|
||||
expect(deriveLifecycle(fin({ expenses: [exp('paid')] }), NOW).step).toBe('costs');
|
||||
});
|
||||
|
||||
it('ended with planned costs: adding costs, warns about them, counts days since the end', () => {
|
||||
const l = deriveLifecycle(fin({ start: '2026-09-20T20:00:00Z', end: '2026-09-20T23:00:00Z', expenses: [exp('paid'), exp('planned'), exp('planned')] }), NOW);
|
||||
expect(l).toMatchObject({ step: 'costs', ended: true, daysSinceEnd: 7, plannedCount: 2 });
|
||||
expect(l.todo).toContainEqual({ key: 'planned', count: 2, tone: 'warn' });
|
||||
});
|
||||
|
||||
it('ended with no costs: still adding costs, and that is a warning', () => {
|
||||
const l = deriveLifecycle(fin({ start: '2026-09-27T20:00:00Z' }), NOW);
|
||||
expect(l.step).toBe('costs');
|
||||
expect(l.todo).toContainEqual({ key: 'noCosts', tone: 'warn' });
|
||||
});
|
||||
|
||||
it('ended with every cost paid: ready to close', () => {
|
||||
const l = deriveLifecycle(fin({ start: '2026-09-27T20:00:00Z', expenses: [exp('paid')], partners: [prt('pending')] }), NOW);
|
||||
expect(l.step).toBe('ready');
|
||||
expect(l.todo).toEqual([]);
|
||||
});
|
||||
|
||||
it('uses the end time, not the start, to decide the event is over', () => {
|
||||
const l = deriveLifecycle(fin({ start: '2026-09-28T10:00:00Z', end: '2026-09-28T14:00:00Z' }), NOW);
|
||||
expect(l.ended).toBe(false);
|
||||
});
|
||||
|
||||
it('finalized: lists partner payouts still to record', () => {
|
||||
const l = deriveLifecycle(fin({ status: 'finalized', start: '2026-09-01T20:00:00Z', expenses: [exp('planned')], partners: [prt('paid'), prt('pending')] }), NOW);
|
||||
expect(l.step).toBe('finalized');
|
||||
expect(l.todo).toEqual([{ key: 'payoutsPending', count: 1, tone: 'info' }]);
|
||||
});
|
||||
|
||||
it('does not nag collaborators about partners they cannot manage', () => {
|
||||
const l = deriveLifecycle({ ...fin(), viewer: { fullSplit: false } } as any, NOW);
|
||||
expect(l.todo.map((t) => t.key)).not.toContain('noPartners');
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Category suggestion
|
||||
|
||||
const categories = [
|
||||
{ id: 'venue', nameEn: 'Venue', nameEs: 'Lugar', archived: false },
|
||||
{ id: 'host', nameEn: 'Instructor / host', nameEs: 'Instructor / anfitrión', archived: false },
|
||||
{ id: 'food', nameEn: 'Food & drinks', nameEs: 'Comida y bebidas', archived: false },
|
||||
{ id: 'staff', nameEn: 'Staff', nameEs: 'Personal', archived: false },
|
||||
{ id: 'mkt', nameEn: 'Marketing', nameEs: 'Marketing', archived: false },
|
||||
{ id: 'sup', nameEn: 'Supplies', nameEs: 'Materiales', archived: false },
|
||||
];
|
||||
|
||||
describe('suggestCategory', () => {
|
||||
it.each([
|
||||
['Studio rental', 'venue'],
|
||||
['Alquiler del salón', 'venue'],
|
||||
['Teacher fee', 'host'],
|
||||
['Pago a la profe', 'host'],
|
||||
['Snacks and drinks', 'food'],
|
||||
['Cerveza', 'food'],
|
||||
['Security', 'staff'],
|
||||
['Instagram ads', 'mkt'],
|
||||
['Name badges', 'sup'],
|
||||
])('%s -> %s', (description, id) => {
|
||||
expect(suggestCategory(description, categories)).toBe(id);
|
||||
});
|
||||
|
||||
it('returns null when nothing matches', () => {
|
||||
expect(suggestCategory('Miscellaneous', categories)).toBeNull();
|
||||
expect(suggestCategory('', categories)).toBeNull();
|
||||
});
|
||||
|
||||
it('skips archived categories', () => {
|
||||
expect(suggestCategory('venue', [{ ...categories[0], archived: true }])).toBeNull();
|
||||
});
|
||||
|
||||
it('matches whole words only', () => {
|
||||
// "ad" is a marketing keyword, but "headphones" should not match it.
|
||||
expect(suggestCategory('headphones', categories)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('tCount', () => {
|
||||
const t = (key: string, p?: Record<string, string | number>) => `${key}:${p?.count}`;
|
||||
it('picks _one for 1 and _other otherwise', () => {
|
||||
expect(tCount(t, 'k', 1)).toBe('k_one:1');
|
||||
expect(tCount(t, 'k', 0)).toBe('k_other:0');
|
||||
expect(tCount(t, 'k', 3)).toBe('k_other:3');
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Modal preview mirrors the server
|
||||
|
||||
describe('previewExpense matches the backend expenseAmount', () => {
|
||||
const ctx = { ticketsSold: 4, checkedIn: 3, sales: 200000 };
|
||||
it.each(CALC_TYPES)('%s', (calcType) => {
|
||||
const e = { calcType, quantity: 1, unitAmount: 50000, percentBp: 1250, minimumAmount: 300000 };
|
||||
const server = expenseAmount({ ...e, computedAmount: 0, isLocked: false }, ctx);
|
||||
expect(previewExpense(e, ctx).amount).toBe(server.amount);
|
||||
});
|
||||
|
||||
it('minimum spend switches to per-guest once it passes the minimum', () => {
|
||||
const e = { calcType: 'minimum_spend' as const, quantity: 1, unitAmount: 50000, percentBp: 0, minimumAmount: 300000 };
|
||||
const big = { ticketsSold: 10, checkedIn: 8, sales: 0 };
|
||||
expect(previewExpense(e, big).amount).toBe(expenseAmount({ ...e, computedAmount: 0, isLocked: false }, big).amount);
|
||||
expect(previewExpense(e, big).amount).toBe(400000);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
// Pure derivations for the Finance tab: where the event is in its money
|
||||
// lifecycle, what is left to do, how break-even reads, and a category guess
|
||||
// for a new expense. No React here so it can be unit tested.
|
||||
|
||||
import type { EventFinance, ExpenseCategory, FinanceSummary } from '@/lib/api';
|
||||
|
||||
export type LifecycleStep = 'selling' | 'costs' | 'ready' | 'finalized';
|
||||
export const LIFECYCLE_STEPS: LifecycleStep[] = ['selling', 'costs', 'ready', 'finalized'];
|
||||
|
||||
export interface TodoItem {
|
||||
/** i18n key under admin.finance.lifecycle.todo (pluralized with _one / _other when count is set). */
|
||||
key: 'noCosts' | 'planned' | 'noPartners' | 'payoutsPending' | 'notEnded';
|
||||
count?: number;
|
||||
tone: 'warn' | 'info';
|
||||
}
|
||||
|
||||
export interface Lifecycle {
|
||||
step: LifecycleStep;
|
||||
ended: boolean;
|
||||
/** Whole days since the event ended; null while it has not ended. */
|
||||
daysSinceEnd: number | null;
|
||||
expenseCount: number;
|
||||
plannedCount: number;
|
||||
partnerCount: number;
|
||||
todo: TodoItem[];
|
||||
}
|
||||
|
||||
type LifecycleInput = Pick<EventFinance, 'status' | 'expenses' | 'partners'> & {
|
||||
event: { startDatetime: string; endDatetime?: string | null };
|
||||
viewer?: { fullSplit: boolean };
|
||||
};
|
||||
|
||||
const DAY = 86_400_000;
|
||||
|
||||
/** The moment an event counts as over: its end time, or its start when no end is set. */
|
||||
export function eventEndTime(event: { startDatetime: string; endDatetime?: string | null }): number {
|
||||
return new Date(event.endDatetime || event.startDatetime).getTime();
|
||||
}
|
||||
|
||||
export function deriveLifecycle(data: LifecycleInput, now: number = Date.now()): Lifecycle {
|
||||
const end = eventEndTime(data.event);
|
||||
const ended = Number.isFinite(end) && now >= end;
|
||||
const daysSinceEnd = ended ? Math.floor((now - end) / DAY) : null;
|
||||
const expenseCount = data.expenses.length;
|
||||
const plannedCount = data.expenses.filter((e) => e.status === 'planned').length;
|
||||
const partnerCount = data.partners.length;
|
||||
const fullSplit = data.viewer?.fullSplit ?? true;
|
||||
|
||||
const todo: TodoItem[] = [];
|
||||
let step: LifecycleStep;
|
||||
if (data.status !== 'open') {
|
||||
step = 'finalized';
|
||||
const unpaid = data.partners.filter((p) => p.payoutStatus !== 'paid').length;
|
||||
if (unpaid > 0) todo.push({ key: 'payoutsPending', count: unpaid, tone: 'info' });
|
||||
} else {
|
||||
if (!ended) step = expenseCount === 0 ? 'selling' : 'costs';
|
||||
else step = expenseCount > 0 && plannedCount === 0 ? 'ready' : 'costs';
|
||||
|
||||
if (expenseCount === 0) todo.push({ key: 'noCosts', tone: ended ? 'warn' : 'info' });
|
||||
if (plannedCount > 0) todo.push({ key: 'planned', count: plannedCount, tone: 'warn' });
|
||||
if (partnerCount === 0 && fullSplit) todo.push({ key: 'noPartners', tone: 'info' });
|
||||
if (!ended) todo.push({ key: 'notEnded', tone: 'info' });
|
||||
}
|
||||
|
||||
return { step, ended, daysSinceEnd, expenseCount, plannedCount, partnerCount, todo };
|
||||
}
|
||||
|
||||
// ==================== Break-even ====================
|
||||
|
||||
export type BreakEvenState =
|
||||
| { kind: 'noCosts' }
|
||||
| { kind: 'unreachable' }
|
||||
| { kind: 'reached'; at: number; sold: number }
|
||||
| { kind: 'needed'; remaining: number; sold: number; needed: number; pct: number };
|
||||
|
||||
export function breakEvenState(summary: Pick<FinanceSummary, 'breakEven' | 'expenses' | 'counts'>): BreakEvenState {
|
||||
const be = summary.breakEven;
|
||||
const sold = summary.counts.ticketsSold;
|
||||
if (summary.expenses.total === 0) return { kind: 'noCosts' };
|
||||
if (be.tickets === null) return { kind: 'unreachable' };
|
||||
if (sold >= be.tickets) return { kind: 'reached', at: be.tickets, sold };
|
||||
const needed = be.tickets;
|
||||
return {
|
||||
kind: 'needed',
|
||||
remaining: be.remaining ?? needed - sold,
|
||||
sold,
|
||||
needed,
|
||||
pct: needed > 0 ? Math.min(100, Math.round((sold / needed) * 100)) : 100,
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== Category suggestion ====================
|
||||
|
||||
/** Words in an expense description, and the words that identify the matching category by name. */
|
||||
const CATEGORY_HINTS: { words: string[]; category: string[] }[] = [
|
||||
{
|
||||
words: ['venue', 'rent', 'rental', 'room', 'hall', 'space', 'studio', 'alquiler', 'local', 'sala', 'lugar', 'espacio', 'salon', 'estudio'],
|
||||
category: ['venue', 'lugar'],
|
||||
},
|
||||
{
|
||||
words: ['instructor', 'teacher', 'host', 'tutor', 'coach', 'facilitator', 'profesor', 'profesora', 'profe', 'anfitrion', 'anfitriona', 'moderador', 'moderadora'],
|
||||
category: ['instructor', 'host', 'anfitrion'],
|
||||
},
|
||||
{
|
||||
words: ['snack', 'snacks', 'drink', 'drinks', 'food', 'beer', 'coffee', 'water', 'pizza', 'catering', 'wine', 'soda',
|
||||
'comida', 'bebida', 'bebidas', 'cerveza', 'cafe', 'agua', 'merienda', 'bocaditos', 'vino', 'gaseosa'],
|
||||
category: ['food', 'drink', 'comida', 'bebida'],
|
||||
},
|
||||
{
|
||||
words: ['staff', 'helper', 'volunteer', 'security', 'cleaning', 'waiter', 'personal', 'limpieza', 'seguridad', 'ayudante', 'mozo', 'voluntario'],
|
||||
category: ['staff', 'personal'],
|
||||
},
|
||||
{
|
||||
words: ['ads', 'ad', 'marketing', 'instagram', 'facebook', 'flyer', 'flyers', 'promo', 'poster', 'publicidad', 'anuncio', 'anuncios', 'volante', 'volantes'],
|
||||
category: ['marketing'],
|
||||
},
|
||||
{
|
||||
words: ['supplies', 'material', 'materials', 'print', 'printing', 'paper', 'markers', 'badges', 'stickers',
|
||||
'materiales', 'impresion', 'impresiones', 'papel', 'marcadores', 'etiquetas'],
|
||||
category: ['suppl', 'material'],
|
||||
},
|
||||
];
|
||||
|
||||
const normalize = (s: string) => s.toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '');
|
||||
|
||||
/** Best-guess category id for a description, or null when nothing matches. */
|
||||
export function suggestCategory(description: string, categories: Pick<ExpenseCategory, 'id' | 'nameEn' | 'nameEs' | 'archived'>[]): string | null {
|
||||
const tokens = new Set(normalize(description).split(/[^a-z0-9]+/).filter(Boolean));
|
||||
if (tokens.size === 0) return null;
|
||||
for (const hint of CATEGORY_HINTS) {
|
||||
if (!hint.words.some((w) => tokens.has(w))) continue;
|
||||
const match = categories.find((c) => !c.archived && hint.category.some((k) => normalize(`${c.nameEn} ${c.nameEs}`).includes(k)));
|
||||
if (match) return match.id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ==================== Copy helpers ====================
|
||||
|
||||
type T = (key: string, params?: Record<string, string | number>) => string;
|
||||
|
||||
/** Picks `${key}_one` or `${key}_other` so counts read naturally in both languages. */
|
||||
export function tCount(t: T, key: string, count: number, params: Record<string, string | number> = {}): string {
|
||||
return t(`${key}_${count === 1 ? 'one' : 'other'}`, { count, ...params });
|
||||
}
|
||||
|
||||
// ==================== Waterfall ====================
|
||||
|
||||
export type WaterfallKind = 'total' | 'in' | 'out';
|
||||
export interface WaterfallStep {
|
||||
key: 'gross' | 'refunds' | 'fees' | 'expenses' | 'otherIncome' | 'partners' | 'profit' | 'organization';
|
||||
kind: WaterfallKind;
|
||||
/** Signed: positive adds to the running total, negative takes from it. Totals are the level itself. */
|
||||
amount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gross revenue -> fees -> expenses -> other income -> partner payouts ->
|
||||
* what the organization keeps. Zero steps are left out; the steps always add
|
||||
* up to the final bar. Viewers without the full split end at the profit.
|
||||
*/
|
||||
export function buildWaterfall(summary: Pick<FinanceSummary, 'revenue' | 'expenses' | 'profit' | 'split'>): WaterfallStep[] {
|
||||
const r = summary.revenue;
|
||||
const steps: WaterfallStep[] = [{ key: 'gross', kind: 'total', amount: r.gross }];
|
||||
const add = (key: WaterfallStep['key'], amount: number) => {
|
||||
if (amount !== 0) steps.push({ key, kind: amount > 0 ? 'in' : 'out', amount });
|
||||
};
|
||||
add('refunds', -r.refunds);
|
||||
add('fees', -r.fees);
|
||||
add('expenses', -summary.expenses.total);
|
||||
add('otherIncome', r.otherIncome);
|
||||
const withSplit = summary.split.organization !== null && summary.split.partners.length > 0;
|
||||
if (withSplit) {
|
||||
add('partners', -summary.split.partners.reduce((sum, p) => sum + p.share, 0));
|
||||
steps.push({ key: 'organization', kind: 'total', amount: summary.split.organization! });
|
||||
} else {
|
||||
steps.push({ key: 'profit', kind: 'total', amount: summary.profit });
|
||||
}
|
||||
return steps;
|
||||
}
|
||||
|
||||
/** Which detail charts have something to show (cumulative sales needs at least two days). */
|
||||
export function detailChartsAvailable(summary: Pick<FinanceSummary, 'expenses' | 'revenue' | 'salesTimeline'>) {
|
||||
return {
|
||||
costs: summary.expenses.byCategory.some((c) => c.total > 0),
|
||||
methods: summary.revenue.byMethod.some((m) => m.gross > 0),
|
||||
timeline: summary.salesTimeline.length >= 2,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// Input parsing for finance forms. Display formatting lives in @/lib/money.
|
||||
|
||||
export { formatNumber, formatPyg, formatPygShort, formatPercent } from '@/lib/money';
|
||||
|
||||
/** Basis points to a percent string for inputs (290 -> "2.9"). */
|
||||
export function bpToPercent(bp: number): string {
|
||||
return String(Math.round(bp) / 100);
|
||||
}
|
||||
|
||||
/** Percent input ("2,9" or "2.9") to basis points. */
|
||||
export function percentToBp(value: string): number {
|
||||
const n = parseFloat(value.replace(',', '.'));
|
||||
return Number.isFinite(n) ? Math.max(0, Math.min(10000, Math.round(n * 100))) : 0;
|
||||
}
|
||||
|
||||
/** Whole-PYG input ("1.500.000", "1,500,000" or "1500000") to an integer. */
|
||||
export function parsePygInput(value: string): number {
|
||||
const digits = value.replace(/[^\d]/g, '');
|
||||
return digits ? Math.min(2_000_000_000, parseInt(digits, 10)) : 0;
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
'use client';
|
||||
|
||||
// Small shared pieces for the Finance / Team tabs, following the event page's
|
||||
// existing modal (bottom sheet on mobile, centered card on desktop), compact
|
||||
// input and badge styles.
|
||||
|
||||
import { ReactNode, useEffect, useRef, useState } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { XMarkIcon, CalendarDaysIcon } from '@heroicons/react/24/outline';
|
||||
import Card from '@/components/ui/Card';
|
||||
import { usePrivacy } from '@/context/PrivacyContext';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { PRIVACY_MASK } from '@/components/admin/SensitiveValue';
|
||||
import type { FinanceStatus } from '@/lib/api';
|
||||
import { formatNumber } from '@/lib/money';
|
||||
import { useMoney } from '@/lib/useMoney';
|
||||
import { parsePygInput } from './format';
|
||||
|
||||
export const inputClass =
|
||||
'w-full px-3 py-2.5 min-h-[44px] text-base md:text-sm rounded-btn border border-secondary-light-gray bg-white focus:outline-none focus:ring-2 focus:ring-primary-yellow disabled:bg-gray-50 disabled:text-gray-400';
|
||||
|
||||
/** Icon-only buttons: 44px touch target on mobile, visible keyboard focus. */
|
||||
export const iconButtonClass =
|
||||
'inline-flex items-center justify-center min-h-[44px] min-w-[44px] md:min-h-[36px] md:min-w-[36px] rounded-btn cursor-pointer hover:bg-gray-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-yellow disabled:opacity-40 disabled:cursor-default';
|
||||
|
||||
export function Modal({ open, title, subtitle, onClose, children, wide }: {
|
||||
open: boolean; title: string; subtitle?: string; onClose: () => void; children: ReactNode; wide?: boolean;
|
||||
}) {
|
||||
const { t } = useLanguage();
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [open, onClose]);
|
||||
if (!open) return null;
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-end md:items-center justify-center p-0 md:p-4" onClick={onClose}>
|
||||
<Card
|
||||
className={clsx('w-full rounded-t-2xl md:rounded-card max-h-[92vh] overflow-y-auto', wide ? 'md:max-w-2xl' : 'md:max-w-md')}
|
||||
onClick={(e: React.MouseEvent) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between p-4 border-b border-secondary-light-gray sticky top-0 bg-white z-10">
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-base font-bold truncate">{title}</h2>
|
||||
{subtitle && <p className="text-xs text-gray-500 truncate">{subtitle}</p>}
|
||||
</div>
|
||||
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-btn min-h-[44px] min-w-[44px] flex items-center justify-center" aria-label={t('admin.finance.close')}>
|
||||
<XMarkIcon className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-4 space-y-3">{children}</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Field({ label, hint, required, error, children }: { label: string; hint?: string; required?: boolean; error?: string; children: ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-primary-dark">
|
||||
{label}{required && <span className="text-red-600 ml-0.5" aria-hidden>*</span>}
|
||||
</label>
|
||||
{children}
|
||||
{error ? <p className="text-xs text-red-600 mt-1">{error}</p> : hint && <p className="text-xs text-gray-500 mt-1">{hint}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Radio group rendered as selectable cards: each option explains itself. */
|
||||
export function ChoiceCards<K extends string>({ options, value, onChange, columns = 2, name }: {
|
||||
options: { key: K; title: string; description?: string }[];
|
||||
value: K;
|
||||
onChange: (k: K) => void;
|
||||
columns?: 2 | 3;
|
||||
name: string;
|
||||
}) {
|
||||
return (
|
||||
<div role="radiogroup" className={clsx('grid grid-cols-1 gap-2', columns === 3 ? 'sm:grid-cols-3' : 'sm:grid-cols-2')}>
|
||||
{options.map((o) => {
|
||||
const active = o.key === value;
|
||||
return (
|
||||
<button
|
||||
key={o.key}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={active}
|
||||
name={name}
|
||||
onClick={() => onChange(o.key)}
|
||||
className={clsx(
|
||||
'text-left rounded-btn border px-3 py-2.5 min-h-[44px] cursor-pointer transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-yellow',
|
||||
active ? 'border-primary-yellow bg-yellow-50' : 'border-secondary-light-gray hover:border-gray-300 bg-white',
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-2 text-sm font-medium text-primary-dark">
|
||||
<span className={clsx('w-4 h-4 rounded-full border-2 flex-shrink-0', active ? 'border-primary-yellow bg-primary-yellow shadow-[inset_0_0_0_2px_white]' : 'border-gray-300')} />
|
||||
{o.title}
|
||||
</span>
|
||||
{o.description && <span className="block text-xs text-gray-500 mt-0.5 pl-6">{o.description}</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Two or three mutually exclusive options as one control (e.g. Planned / Paid). */
|
||||
export function Segmented<K extends string>({ options, value, onChange, label }: {
|
||||
options: { key: K; label: string; icon?: ReactNode }[];
|
||||
value: K;
|
||||
onChange: (k: K) => void;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<div role="radiogroup" aria-label={label} className="grid grid-flow-col auto-cols-fr gap-1 p-1 bg-gray-100 rounded-btn">
|
||||
{options.map((o) => (
|
||||
<button
|
||||
key={o.key}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={o.key === value}
|
||||
onClick={() => onChange(o.key)}
|
||||
className={clsx(
|
||||
'inline-flex items-center justify-center gap-1.5 rounded-md text-sm font-medium min-h-[40px] cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-yellow',
|
||||
o.key === value ? 'bg-white shadow-sm text-primary-dark' : 'text-gray-500 hover:text-gray-700',
|
||||
)}
|
||||
>
|
||||
{o.icon}{o.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Explains why a list is empty and offers the next step. */
|
||||
export function EmptyState({ icon, title, body, children }: { icon?: ReactNode; title: string; body?: string; children?: ReactNode }) {
|
||||
return (
|
||||
<div className="bg-white rounded-card shadow-card px-6 py-10 text-center flex flex-col items-center gap-2">
|
||||
{icon && <div className="w-11 h-11 rounded-full bg-yellow-50 text-yellow-700 flex items-center justify-center">{icon}</div>}
|
||||
<p className="font-semibold text-primary-dark">{title}</p>
|
||||
{body && <p className="text-sm text-gray-500 max-w-md">{body}</p>}
|
||||
{children && <div className="flex flex-wrap justify-center gap-2 mt-2">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Whole-PYG input that shows the UI language's thousands separator while typing. */
|
||||
export function PygInput({ value, onChange, disabled, id }: { value: number; onChange: (n: number) => void; disabled?: boolean; id?: string }) {
|
||||
const { locale } = useLanguage();
|
||||
const [text, setText] = useState(value ? formatNumber(value, locale) : '');
|
||||
useEffect(() => { setText(value ? formatNumber(value, locale) : ''); }, [value, locale]);
|
||||
return (
|
||||
<div className="relative">
|
||||
<input
|
||||
id={id}
|
||||
inputMode="numeric"
|
||||
className={clsx(inputClass, 'pr-12 tabular-nums')}
|
||||
value={text}
|
||||
disabled={disabled}
|
||||
placeholder="0"
|
||||
onChange={(e) => {
|
||||
const n = parsePygInput(e.target.value);
|
||||
setText(n ? formatNumber(n, locale) : '');
|
||||
onChange(n);
|
||||
}}
|
||||
/>
|
||||
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-gray-400">PYG</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Today as YYYY-MM-DD in the viewer's time zone. */
|
||||
export function todayIso(): string {
|
||||
const d = new Date();
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Date field that reads in the active UI language. A native <input type="date">
|
||||
* formats itself with the browser's locale (e.g. "dd-mm-jjjj" on a Dutch
|
||||
* system), so the visible text is ours and the native input only supplies the
|
||||
* picker, laid transparently on top.
|
||||
*/
|
||||
export function DateInput({ value, onChange, clearable, id, className }: {
|
||||
value: string; onChange: (v: string) => void; clearable?: boolean; id?: string; className?: string;
|
||||
}) {
|
||||
const { t, locale } = useLanguage();
|
||||
const ref = useRef<HTMLInputElement>(null);
|
||||
const label = value
|
||||
? new Date(`${value}T12:00:00`).toLocaleDateString(locale === 'es' ? 'es-PY' : 'en-US', { day: 'numeric', month: 'short', year: 'numeric' })
|
||||
: '';
|
||||
return (
|
||||
<div className={clsx('relative', className)}>
|
||||
<div className={clsx(inputClass, 'flex items-center gap-2 pr-2 focus-within:ring-2 focus-within:ring-primary-yellow')} aria-hidden>
|
||||
<CalendarDaysIcon className="w-4 h-4 text-gray-400 flex-shrink-0" />
|
||||
<span className={clsx('flex-1 truncate', !label && 'text-gray-400')}>{label || t('admin.finance.pickDate')}</span>
|
||||
</div>
|
||||
<input
|
||||
ref={ref}
|
||||
id={id}
|
||||
type="date"
|
||||
lang={locale === 'es' ? 'es-PY' : 'en-US'}
|
||||
value={value}
|
||||
aria-label={label || t('admin.finance.pickDate')}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onClick={(e) => { try { e.currentTarget.showPicker?.(); } catch { /* picker opens natively */ } }}
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
||||
/>
|
||||
{clearable && value && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange('')}
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 z-10 p-1.5 rounded-btn text-gray-400 hover:text-gray-700 hover:bg-gray-100 cursor-pointer"
|
||||
aria-label={t('admin.finance.clearDate')}
|
||||
>
|
||||
<XMarkIcon className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A PYG amount that respects Hide Stats. */
|
||||
export function Money({ value, className, signed }: { value: number | null | undefined; className?: string; signed?: boolean }) {
|
||||
const { privacyMode } = usePrivacy();
|
||||
const { pyg } = useMoney();
|
||||
if (value === null || value === undefined) return <span className={className}>-</span>;
|
||||
const text = signed && value > 0 ? `+${pyg(value)}` : pyg(value);
|
||||
return <span className={clsx('tabular-nums', className)}>{privacyMode ? PRIVACY_MASK : text}</span>;
|
||||
}
|
||||
|
||||
const STATUS_STYLES: Record<FinanceStatus, string> = {
|
||||
open: 'bg-gray-100 text-gray-700',
|
||||
finalized: 'bg-blue-100 text-blue-800',
|
||||
paid_out: 'bg-green-100 text-green-800',
|
||||
};
|
||||
|
||||
export function FinanceStatusBadge({ status, label }: { status: FinanceStatus; label: string }) {
|
||||
return <span className={clsx('inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium', STATUS_STYLES[status])}>{label}</span>;
|
||||
}
|
||||
|
||||
export function Pill({ children, tone = 'gray' }: { children: ReactNode; tone?: 'gray' | 'green' | 'yellow' | 'blue' | 'red' }) {
|
||||
const tones = {
|
||||
gray: 'bg-gray-100 text-gray-700',
|
||||
green: 'bg-green-100 text-green-800',
|
||||
yellow: 'bg-yellow-100 text-yellow-800',
|
||||
blue: 'bg-blue-100 text-blue-800',
|
||||
red: 'bg-red-100 text-red-800',
|
||||
};
|
||||
return <span className={clsx('inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium whitespace-nowrap', tones[tone])}>{children}</span>;
|
||||
}
|
||||
|
||||
/** Segmented sub-navigation matching the event page's mobile tab pills. */
|
||||
export function SubNav<K extends string>({ items, active, onChange }: { items: { key: K; label: string; count?: number }[]; active: K; onChange: (k: K) => void }) {
|
||||
return (
|
||||
<div role="tablist" className="grid grid-flow-col auto-cols-fr md:inline-grid md:auto-cols-auto gap-1 p-1 bg-gray-100 rounded-lg w-full md:w-auto">
|
||||
{items.map((it) => (
|
||||
<button
|
||||
key={it.key}
|
||||
role="tab"
|
||||
aria-selected={active === it.key}
|
||||
onClick={() => onChange(it.key)}
|
||||
className={clsx(
|
||||
// Phones: long labels wrap to two lines instead of running into the next tab.
|
||||
'px-2 md:px-3 py-1.5 md:py-2 rounded-md text-sm font-medium leading-tight md:whitespace-nowrap min-h-[40px] cursor-pointer transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-yellow',
|
||||
active === it.key ? 'bg-white shadow-sm text-primary-dark' : 'text-gray-500 hover:text-gray-700',
|
||||
)}
|
||||
>
|
||||
{it.label}
|
||||
{it.count !== undefined && it.count > 0 && <span className="ml-1.5 text-xs text-gray-400">{it.count}</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user