Files
Spanglish/frontend/src/app/admin/events/page.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

458 lines
20 KiB
TypeScript

'use client';
import { useState, useEffect, useRef } from 'react';
import Link from 'next/link';
import { useRouter, useSearchParams } from 'next/navigation';
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 { 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';
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() {
const router = useRouter();
const { t, locale } = useLanguage();
const searchParams = useSearchParams();
const [events, setEvents] = useState<Event[]>([]);
const [total, setTotal] = useState(0);
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);
const [settingFeatured, setSettingFeatured] = useState<string | null>(null);
useEffect(() => {
loadFeaturedEvent();
}, []);
useEffect(() => {
setFetching(true);
loadEvents();
}, [page, pageSize]);
// The ?edit=<id> deep link may point at an event that is not on the current
// page, so fall back to fetching it directly instead of only scanning the page.
const handledEditId = useRef<string | null>(null);
useEffect(() => {
const editId = searchParams.get('edit');
if (!editId || handledEditId.current === editId) return;
const event = events.find(e => e.id === editId);
if (event) {
handledEditId.current = editId;
handleEdit(event);
return;
}
if (loading) return;
handledEditId.current = editId;
eventsApi.getById(editId)
.then(({ event }) => handleEdit(event))
.catch(() => toast.error('Event not found'));
}, [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),
// fall back to the new last page.
if (events.length === 0 && (total ?? 0) > 0 && page > 1) {
setPage(Math.max(1, Math.ceil((total ?? 0) / pageSize)));
}
} catch (error) {
if (id !== requestId.current) return;
toast.error('Failed to load events');
} finally {
if (id === requestId.current) {
setLoading(false);
setFetching(false);
}
}
};
const loadFeaturedEvent = async () => {
try {
const { settings } = await siteSettingsApi.get();
setFeaturedEventId(settings.featuredEventId || null);
} catch (error) {
// Ignore - settings may not exist yet
}
};
const handleSetFeatured = async (eventId: string | null) => {
setSettingFeatured(eventId || 'clearing');
try {
await siteSettingsApi.setFeaturedEvent(eventId);
setFeaturedEventId(eventId);
toast.success(eventId ? 'Event set as featured' : 'Featured event removed');
} catch (error: any) {
toast.error(error.message || 'Failed to update featured event');
} finally {
setSettingFeatured(null);
}
};
const handleEdit = (event: Event) => {
setEditingEvent(event);
setShowForm(true);
};
const handleCloseForm = () => {
setShowForm(false);
setEditingEvent(null);
};
const handleFormSaved = () => {
handleCloseForm();
loadEvents();
};
const handleDelete = async (id: string) => {
if (!confirm('Are you sure you want to delete this event?')) return;
try {
await eventsApi.delete(id);
toast.success('Event deleted');
loadEvents();
} catch (error) {
toast.error('Failed to delete event');
}
};
const handleStatusChange = async (event: Event, status: Event['status']) => {
try {
await eventsApi.update(event.id, { status });
toast.success('Status updated');
loadEvents();
} catch (error) {
toast.error('Failed to update status');
}
};
const formatDate = (dateStr: string) => {
return parseDate(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
month: 'short', day: 'numeric', year: 'numeric', timeZone: 'America/Asuncion',
});
};
const isEventOver = (event: Event) => {
const refDate = event.endDatetime || event.startDatetime;
return new Date(refDate) < new Date();
};
const getStatusBadge = (status: string) => {
const styles: Record<string, string> = {
draft: 'badge-gray', published: 'badge-success', unlisted: 'badge-warning',
cancelled: 'badge-danger', completed: 'badge-info', archived: 'badge-gray',
};
return <span className={`badge ${styles[status] || 'badge-gray'}`}>{status}</span>;
};
const handleDuplicate = async (event: Event) => {
try {
await eventsApi.duplicate(event.id);
toast.success('Event duplicated successfully');
loadEvents();
} catch (error) {
toast.error('Failed to duplicate event');
}
};
const handleArchive = async (event: Event) => {
try {
await eventsApi.update(event.id, { status: 'archived' });
toast.success('Event archived');
loadEvents();
} catch (error) {
toast.error('Failed to archive event');
}
};
return (
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-xl md:text-2xl font-bold text-primary-dark">{t('admin.events.title')}</h1>
<Button onClick={() => { setEditingEvent(null); setShowForm(true); }} className="hidden md:flex">
<PlusIcon className="w-5 h-5 mr-2" />
{t('admin.events.create')}
</Button>
</div>
<EventFormModal
open={showForm}
event={editingEvent}
featuredEventId={featuredEventId}
onFeaturedChange={setFeaturedEventId}
onClose={handleCloseForm}
onSaved={handleFormSaved}
/>
{/* Desktop: Table */}
<Card className="overflow-hidden hidden md:block">
<div className="overflow-x-auto">
<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>
<td colSpan={5} className="px-6 py-12 text-center text-gray-500">
No events found. Create your first event!
</td>
</tr>
) : (
events.map((event) => (
<tr
key={event.id}
onClick={() => router.push(`/admin/events/${event.id}`)}
className={clsx("hover:bg-gray-50 cursor-pointer", featuredEventId === event.id && "bg-amber-50")}
>
<td className="px-4 py-3">
<div className="flex items-center gap-3">
{event.bannerUrl ? (
<img src={event.bannerUrl} alt={event.title}
className="w-10 h-10 rounded-lg object-cover flex-shrink-0" />
) : (
<div className="w-10 h-10 rounded-lg bg-secondary-gray flex items-center justify-center flex-shrink-0">
<PhotoIcon className="w-5 h-5 text-gray-400" />
</div>
)}
<div>
<div className="flex items-center gap-2">
<p className="font-medium text-sm">{event.title}</p>
{featuredEventId === event.id && (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full text-[10px] font-medium bg-amber-100 text-amber-800">
<StarIconSolid className="w-2.5 h-2.5" /> Featured
</span>
)}
</div>
<p className="text-xs text-gray-500 truncate max-w-xs">{event.location}</p>
</div>
</div>
</td>
<td className="px-4 py-3 text-sm text-gray-600">{formatDate(event.startDatetime)}</td>
<td className="px-4 py-3 text-sm">
{(event.bookedCount || 0) + (event.claimedCount || 0)} / {event.capacity}
{(event.claimedCount || 0) > 0 && (
<span className="block text-[11px] text-yellow-600">
{event.claimedCount} pending approval
</span>
)}
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-1.5">
{getStatusBadge(event.status)}
{isEventOver(event) && event.status !== 'completed' && event.status !== 'cancelled' && event.status !== 'archived' && (
<span className="inline-flex items-center px-1.5 py-0.5 rounded-full text-[10px] font-medium bg-gray-800 text-white">Over</span>
)}
</div>
</td>
<td className="px-4 py-3" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-end gap-1">
{event.status === 'draft' && (
<Button size="sm" variant="ghost" onClick={() => handleStatusChange(event, 'published')}>
Publish
</Button>
)}
{event.status === 'published' && (
<button onClick={() => handleSetFeatured(featuredEventId === event.id ? null : event.id)}
disabled={settingFeatured !== null}
className={clsx("p-2 rounded-btn disabled:opacity-50",
featuredEventId === event.id ? "bg-amber-100 text-amber-600 hover:bg-amber-200" : "hover:bg-amber-100 text-gray-400 hover:text-amber-600")}
title={featuredEventId === event.id ? "Remove from featured" : "Set as featured"}>
{featuredEventId === event.id ? <StarIconSolid className="w-4 h-4" /> : <StarIcon className="w-4 h-4" />}
</button>
)}
<Link href={`/admin/events/${event.id}`}
className="p-2 hover:bg-primary-yellow/20 text-primary-dark rounded-btn" title="Manage">
<EyeIcon className="w-4 h-4" />
</Link>
<button onClick={() => handleEdit(event)} className="p-2 hover:bg-gray-100 rounded-btn" title="Edit">
<PencilIcon className="w-4 h-4" />
</button>
<MoreMenu>
{(event.status === 'draft' || event.status === 'published') && (
<DropdownItem onClick={() => handleStatusChange(event, 'unlisted')}>
<LinkIcon className="w-4 h-4 mr-2" /> Make Unlisted
</DropdownItem>
)}
{event.status === 'unlisted' && (
<DropdownItem onClick={() => handleStatusChange(event, 'published')}>
Make Public
</DropdownItem>
)}
{(event.status === 'published' || event.status === 'unlisted') && (
<DropdownItem onClick={() => handleStatusChange(event, 'draft')}>
Unpublish
</DropdownItem>
)}
<DropdownItem onClick={() => handleDuplicate(event)}>
<DocumentDuplicateIcon className="w-4 h-4 mr-2" /> Duplicate
</DropdownItem>
{event.status !== 'archived' && (
<DropdownItem onClick={() => handleArchive(event)}>
<ArchiveBoxIcon className="w-4 h-4 mr-2" /> Archive
</DropdownItem>
)}
<DropdownItem onClick={() => handleDelete(event.id)} className="text-red-600">
<TrashIcon className="w-4 h-4 mr-2" /> Delete
</DropdownItem>
</MoreMenu>
</div>
</td>
</tr>
))
)}
</tbody>
)}
</table>
</div>
</Card>
{/* Mobile: Card List */}
<div className="md:hidden space-y-2">
{fetching ? (
<EventCardsSkeleton />
) : events.length === 0 ? (
<div className="text-center py-10 text-gray-500 text-sm">
No events found. Create your first event!
</div>
) : (
events.map((event) => (
<Card
key={event.id}
className={clsx("p-3 cursor-pointer", featuredEventId === event.id && "ring-2 ring-amber-300")}
onClick={() => router.push(`/admin/events/${event.id}`)}
>
<div className="flex items-start gap-3">
{event.bannerUrl ? (
<img src={event.bannerUrl} alt={event.title}
className="w-14 h-14 rounded-lg object-cover flex-shrink-0" />
) : (
<div className="w-14 h-14 rounded-lg bg-secondary-gray flex items-center justify-center flex-shrink-0">
<PhotoIcon className="w-6 h-6 text-gray-400" />
</div>
)}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="font-medium text-sm truncate">{event.title}</p>
<p className="text-xs text-gray-500">{formatDate(event.startDatetime)}</p>
<p className="text-xs text-gray-400 truncate">{event.location}</p>
</div>
<div className="flex items-center gap-1 flex-shrink-0 flex-wrap justify-end">
{getStatusBadge(event.status)}
{isEventOver(event) && event.status !== 'completed' && event.status !== 'cancelled' && event.status !== 'archived' && (
<span className="inline-flex items-center px-1.5 py-0.5 rounded-full text-[10px] font-medium bg-gray-800 text-white">Over</span>
)}
{featuredEventId === event.id && (
<StarIconSolid className="w-4 h-4 text-amber-500" />
)}
</div>
</div>
<div className="flex items-center justify-between mt-2 pt-2 border-t border-gray-100">
<p className="text-xs text-gray-500">
{(event.bookedCount || 0) + (event.claimedCount || 0)} / {event.capacity} spots
{(event.claimedCount || 0) > 0 && (
<span className="text-yellow-600"> · {event.claimedCount} pending</span>
)}
</p>
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
<Link href={`/admin/events/${event.id}`}
className="p-2 hover:bg-primary-yellow/20 text-primary-dark rounded-btn min-h-[36px] min-w-[36px] flex items-center justify-center">
<EyeIcon className="w-4 h-4" />
</Link>
<MoreMenu>
<DropdownItem onClick={() => handleEdit(event)}>
<PencilIcon className="w-4 h-4 mr-2" /> Edit
</DropdownItem>
{event.status === 'draft' && (
<DropdownItem onClick={() => handleStatusChange(event, 'published')}>
Publish
</DropdownItem>
)}
{(event.status === 'draft' || event.status === 'published') && (
<DropdownItem onClick={() => handleStatusChange(event, 'unlisted')}>
<LinkIcon className="w-4 h-4 mr-2" /> Make Unlisted
</DropdownItem>
)}
{event.status === 'unlisted' && (
<DropdownItem onClick={() => handleStatusChange(event, 'published')}>
Make Public
</DropdownItem>
)}
{(event.status === 'published' || event.status === 'unlisted') && (
<DropdownItem onClick={() => handleStatusChange(event, 'draft')}>
Unpublish
</DropdownItem>
)}
{event.status === 'published' && (
<DropdownItem onClick={() => handleSetFeatured(featuredEventId === event.id ? null : event.id)}>
<StarIcon className="w-4 h-4 mr-2" />
{featuredEventId === event.id ? 'Unfeature' : 'Set Featured'}
</DropdownItem>
)}
<DropdownItem onClick={() => handleDuplicate(event)}>
<DocumentDuplicateIcon className="w-4 h-4 mr-2" /> Duplicate
</DropdownItem>
{event.status !== 'archived' && (
<DropdownItem onClick={() => handleArchive(event)}>
<ArchiveBoxIcon className="w-4 h-4 mr-2" /> Archive
</DropdownItem>
)}
<DropdownItem onClick={() => handleDelete(event.id)} className="text-red-600">
<TrashIcon className="w-4 h-4 mr-2" /> Delete
</DropdownItem>
</MoreMenu>
</div>
</div>
</div>
</div>
</Card>
))
)}
</div>
<Pagination
id="events"
page={page}
pageSize={pageSize}
total={total}
onPageChange={setPage}
onPageSizeChange={setPageSize}
className="mb-20 md:mb-0"
/>
{/* Mobile FAB */}
<div className="md:hidden fixed bottom-6 right-6 z-40">
<button onClick={() => { setEditingEvent(null); setShowForm(true); }}
className="w-14 h-14 bg-primary-yellow text-primary-dark rounded-full shadow-lg flex items-center justify-center hover:bg-yellow-400 active:scale-95 transition-transform">
<PlusIcon className="w-6 h-6" />
</button>
</div>
<AdminMobileStyles />
</div>
);
}