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:
Michilis
2026-10-03 03:11:46 +00:00
co-authored by Claude Opus 5.5
parent b51c1360e2
commit e203fb6c74
80 changed files with 11326 additions and 971 deletions
@@ -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>
);
}