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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user