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