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
+25 -17
View File
@@ -7,7 +7,6 @@ import { useLanguage } from '@/context/LanguageContext';
import { eventsApi, siteSettingsApi, Event } from '@/lib/api';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import { AdminPageSkeleton } from '@/components/ui/Skeleton';
import { MoreMenu, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents';
import { PlusIcon, PencilIcon, TrashIcon, EyeIcon, PhotoIcon, DocumentDuplicateIcon, ArchiveBoxIcon, StarIcon, LinkIcon } from '@heroicons/react/24/outline';
import { StarIcon as StarIconSolid } from '@heroicons/react/24/solid';
@@ -15,6 +14,7 @@ import toast from 'react-hot-toast';
import clsx from 'clsx';
import { parseDate } from '@/lib/utils';
import EventFormModal from './_components/EventFormModal';
import { EventsTableHead, EventRowsSkeleton, EventCardsSkeleton } from './_components/EventsListSkeleton';
import Pagination from '@/components/admin/Pagination';
export default function AdminEventsPage() {
@@ -26,6 +26,11 @@ export default function AdminEventsPage() {
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(25);
const [loading, setLoading] = useState(true);
// True while the rows on screen are not the rows for the current page (first
// load, page or page-size change). Reloads after a mutation keep the old rows
// up instead, so the table does not flash after every action.
const [fetching, setFetching] = useState(true);
const requestId = useRef(0);
const [showForm, setShowForm] = useState(false);
const [editingEvent, setEditingEvent] = useState<Event | null>(null);
const [featuredEventId, setFeaturedEventId] = useState<string | null>(null);
@@ -36,6 +41,7 @@ export default function AdminEventsPage() {
}, []);
useEffect(() => {
setFetching(true);
loadEvents();
}, [page, pageSize]);
@@ -59,8 +65,12 @@ export default function AdminEventsPage() {
}, [searchParams, events, loading]);
const loadEvents = async () => {
// Drop responses that arrive after a newer request (e.g. a slow page 1
// landing after the user has already moved to page 2).
const id = ++requestId.current;
try {
const { events, total } = await eventsApi.getAll({ page, pageSize });
if (id !== requestId.current) return;
setEvents(events);
setTotal(total ?? events.length);
// If the current page emptied out (e.g. after deleting its last event),
@@ -69,9 +79,13 @@ export default function AdminEventsPage() {
setPage(Math.max(1, Math.ceil((total ?? 0) / pageSize)));
}
} catch (error) {
if (id !== requestId.current) return;
toast.error('Failed to load events');
} finally {
setLoading(false);
if (id === requestId.current) {
setLoading(false);
setFetching(false);
}
}
};
@@ -172,10 +186,6 @@ export default function AdminEventsPage() {
}
};
if (loading) {
return <AdminPageSkeleton cols={5} />;
}
return (
<div>
<div className="flex items-center justify-between mb-6">
@@ -198,16 +208,11 @@ export default function AdminEventsPage() {
{/* Desktop: Table */}
<Card className="overflow-hidden hidden md:block">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-secondary-gray">
<tr>
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Event</th>
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Date</th>
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Capacity</th>
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
<th className="text-right px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<table className="w-full" aria-busy={fetching}>
<EventsTableHead />
{fetching ? (
<EventRowsSkeleton rows={Math.min(pageSize, 8)} />
) : (
<tbody className="divide-y divide-secondary-light-gray">
{events.length === 0 ? (
<tr>
@@ -319,13 +324,16 @@ export default function AdminEventsPage() {
))
)}
</tbody>
)}
</table>
</div>
</Card>
{/* Mobile: Card List */}
<div className="md:hidden space-y-2">
{events.length === 0 ? (
{fetching ? (
<EventCardsSkeleton />
) : events.length === 0 ? (
<div className="text-center py-10 text-gray-500 text-sm">
No events found. Create your first event!
</div>