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,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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user