Files
Spanglish/frontend/src/app/admin/events/[id]/_finance/SplitView.tsx
T
MichilisandClaude Opus 5.5 e203fb6c74 Add per-event finance, event team permissions and public door sales state.
Event finance
- Finance tab on the event page: P&L summary with a revenue-to-result
  waterfall, costs and other income in one ledger, and the partner split
  with payouts. Lifecycle stepper (Selling, Adding costs, Ready to close,
  Finalized) with what is left to do; closing the books goes through a
  checklist dialog that freezes the numbers.
- Expense modal shows only the fields each calculation type needs, a live
  preview with the event's real counts, and a category suggested from the
  description. Date inputs follow the UI language.
- Global Finance page: profit per event with outliers clipped and labelled,
  and a "Ready to close" list of past events whose books are still open.
- Calculation service (integer PYG, basis points) with pinned regression
  scenarios; PYG formatting centralised in lib/money with locale-aware
  separators.

Event team permissions
- Per-event members with role presets and requireEventPermission; the
  header stat "Confirmed" is relabelled "Not checked in yet", which is
  what it counts.

Public sales state
- One sales state (online, door, sold out, ended, external, cancelled) for
  the event page, listings and JSON-LD, with the door price and tenders
  shown only while people can still pay at the door.

Frontend unit tests run with vitest (npm test in frontend/).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 03:11:46 +00:00

531 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { 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>
);
}