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
@@ -0,0 +1,41 @@
'use client';
import Link from 'next/link';
import { CalendarIcon, MapPinIcon, ChevronRightIcon } from '@heroicons/react/24/outline';
import { useLanguage } from '@/context/LanguageContext';
import type { MyEvent } from '@/lib/api';
/** Events the user was added to as staff, collaborator or co-manager. */
export default function MyEventsTab({ events }: { events: MyEvent[] }) {
const { t, locale } = useLanguage();
if (events.length === 0) {
return <p className="text-sm text-gray-500 py-8 text-center">{t('dashboard.myEvents.empty')}</p>;
}
const fmt = (iso: string) =>
new Date(iso).toLocaleDateString(locale === 'es' ? 'es-PY' : 'en-US', { weekday: 'short', day: 'numeric', month: 'short', year: 'numeric' });
return (
<ul className="space-y-3">
{events.map(({ event, rolePreset }) => (
<li key={event.id}>
<Link
href={`/dashboard/events/${event.id}`}
className="flex items-center gap-4 rounded-card bg-white p-4 shadow-card transition-shadow hover:shadow-card-hover"
>
<div className="min-w-0 flex-1">
<p className="font-semibold text-primary-dark truncate">{(locale === 'es' && event.titleEs) || event.title}</p>
<p className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-gray-500">
<span className="inline-flex items-center gap-1"><CalendarIcon className="h-3.5 w-3.5" />{fmt(event.startDatetime)}</span>
<span className="inline-flex items-center gap-1 min-w-0"><MapPinIcon className="h-3.5 w-3.5 flex-shrink-0" /><span className="truncate">{event.location}</span></span>
</p>
</div>
<span className="rounded-full bg-yellow-100 px-2 py-0.5 text-xs font-medium text-yellow-800 whitespace-nowrap">
{t(`admin.rolePresets.${rolePreset}`)}
</span>
<ChevronRightIcon className="h-5 w-5 flex-shrink-0 text-gray-400" />
</Link>
</li>
))}
</ul>
);
}
@@ -0,0 +1,23 @@
'use client';
import { useParams } from 'next/navigation';
import { PrivacyProvider } from '@/context/PrivacyContext';
import { EventDetailView } from '@/app/admin/events/[id]/_components/EventDetailView';
/**
* The single event page for team members (staff / collaborators / co-managers
* added in the event's Team tab). Same view as the admin page; tabs and data
* follow the member's permissions on this event only.
*/
export default function MemberEventPage() {
const params = useParams();
return (
<PrivacyProvider>
<div className="section-padding min-h-[70vh]">
<div className="container-page">
<EventDetailView eventId={params.id as string} backHref="/dashboard?tab=events" />
</div>
</div>
</PrivacyProvider>
);
}
+19 -3
View File
@@ -6,9 +6,11 @@ import { useLanguage } from '@/context/LanguageContext';
import { useAuth } from '@/context/AuthContext';
import {
dashboardApi,
financeApi,
NextEventInfo,
UserTicket,
UserPayment,
type MyEvent,
} from '@/lib/api';
import toast from 'react-hot-toast';
import { CardListSkeleton } from '@/components/ui/Skeleton';
@@ -17,20 +19,28 @@ import OverviewTab from './components/OverviewTab';
import TicketsTab from './components/TicketsTab';
import PaymentsTab from './components/PaymentsTab';
import AccountTab from './components/AccountTab';
import MyEventsTab from './components/MyEventsTab';
type Tab = 'overview' | 'tickets' | 'payments' | 'account';
type Tab = 'overview' | 'tickets' | 'payments' | 'events' | 'account';
export default function DashboardPage() {
const router = useRouter();
const { locale } = useLanguage();
const { locale, t } = useLanguage();
const { user, isLoading: authLoading } = useAuth();
const [activeTab, setActiveTab] = useState<Tab>('overview');
const [nextEvent, setNextEvent] = useState<NextEventInfo | null>(null);
const [tickets, setTickets] = useState<UserTicket[]>([]);
const [payments, setPayments] = useState<UserPayment[]>([]);
const [myEvents, setMyEvents] = useState<MyEvent[]>([]);
const [loading, setLoading] = useState(true);
// ?tab=events (the back link from a team event page) opens My Events.
useEffect(() => {
const tab = new URLSearchParams(window.location.search).get('tab');
if (tab === 'events') setActiveTab('events');
}, []);
useEffect(() => {
if (!authLoading && !user) {
router.push('/login');
@@ -47,14 +57,17 @@ export default function DashboardPage() {
const loadDashboardData = async () => {
setLoading(true);
try {
const [nextEventRes, ticketsRes, paymentsRes] = await Promise.all([
const [nextEventRes, ticketsRes, paymentsRes, myEventsRes] = await Promise.all([
dashboardApi.getNextEvent(),
dashboardApi.getTickets(),
dashboardApi.getPayments(),
// Team memberships are optional extra; never fail the dashboard over them.
financeApi.myEvents().catch(() => ({ events: [] as MyEvent[] })),
]);
setNextEvent(nextEventRes.nextEvent);
setTickets(ticketsRes.tickets);
setPayments(paymentsRes.payments);
setMyEvents(myEventsRes.events);
} catch (error) {
console.error('Failed to load dashboard:', error);
toast.error(locale === 'es' ? 'Error al cargar el panel' : 'Failed to load dashboard data');
@@ -67,6 +80,8 @@ export default function DashboardPage() {
{ id: 'overview', label: { en: 'Overview', es: 'Resumen' } },
{ id: 'tickets', label: { en: 'Tickets', es: 'Entradas' } },
{ id: 'payments', label: { en: 'Payments', es: 'Pagos' } },
// Only for people on at least one event team
...(myEvents.length > 0 ? [{ id: 'events' as Tab, label: { en: t('dashboard.myEvents.tab'), es: t('dashboard.myEvents.tab') } }] : []),
{ id: 'account', label: { en: 'Account', es: 'Cuenta' } },
];
@@ -125,6 +140,7 @@ export default function DashboardPage() {
{activeTab === 'payments' && (
<PaymentsTab payments={payments} language={locale} onChange={loadDashboardData} />
)}
{activeTab === 'events' && <MyEventsTab events={myEvents} />}
{activeTab === 'account' && <AccountTab onUpdate={loadDashboardData} />}
</>
)}
@@ -18,11 +18,14 @@ export default function EventsClient({ initialEvents }: { initialEvents: Event[]
const [filter, setFilter] = useState<'upcoming' | 'past'>('upcoming');
const now = new Date();
// An event that has started but still sells at the door stays under Upcoming.
const isUpcoming = (e: Event) =>
new Date(e.startDatetime) >= now || e.salesState === 'door';
const upcomingEvents = initialEvents.filter(e =>
e.status === 'published' && new Date(e.startDatetime) >= now
e.status === 'published' && isUpcoming(e)
);
const pastEvents = initialEvents.filter(e =>
e.status === 'completed' || (e.status === 'published' && new Date(e.startDatetime) < now)
e.status === 'completed' || (e.status === 'published' && !isUpcoming(e))
);
const displayedEvents = filter === 'upcoming' ? upcomingEvents : pastEvents;
@@ -34,9 +37,16 @@ export default function EventsClient({ initialEvents }: { initialEvents: Event[]
if (event.status === 'cancelled') {
return <span className="badge badge-danger">{t('events.details.cancelled')}</span>;
}
if (event.availableSeats === 0) {
if (event.salesState === 'sold_out' || event.availableSeats === 0) {
return <span className="badge badge-warning">{t('events.details.soldOut')}</span>;
}
if (event.salesState === 'door') {
return (
<span className="badge bg-primary-yellow/20 text-primary-dark whitespace-nowrap">
{t('events.door.badge')}
</span>
);
}
return null;
};
@@ -1,11 +1,11 @@
'use client';
import { useState, useEffect } from 'react';
import { useState, useEffect, useCallback } from 'react';
import Link from 'next/link';
import Image from 'next/image';
import { useLanguage } from '@/context/LanguageContext';
import { eventsApi, Event } from '@/lib/api';
import { formatPrice, formatDateLong, formatTime, eventSpotsLeft, isEventSoldOut, isPresaleClosed, parseDate, formatDurationWords } from '@/lib/utils';
import { formatPrice, formatDateLong, formatTime, eventSpotsLeft, isEventSoldOut, isPresaleClosed, parseDate, formatDurationWords, nextSalesStateChangeMs } from '@/lib/utils';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import ShareButtons from '@/components/ShareButtons';
@@ -24,6 +24,10 @@ interface EventDetailClientProps {
}
const MAX_TICKETS_PER_PERSON = 5;
// setTimeout overflows past ~24.8 days; farther flips are caught on a later visit.
const MAX_TIMER_MS = 2_147_483_647;
// Display order of door tenders ("card" is the POS terminal; never a provider name).
const DOOR_METHOD_ORDER = ['cash', 'pos', 'bitcoin', 'transfer'] as const;
export default function EventDetailClient({ eventId, initialEvent }: EventDetailClientProps) {
const { t, locale } = useLanguage();
@@ -36,13 +40,50 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail
setMounted(true);
}, []);
// Refresh event data on client for real-time availability
useEffect(() => {
// Server clock minus ours, so the refresh below fires on the server's schedule.
const [clockOffsetMs, setClockOffsetMs] = useState(0);
const refreshEvent = useCallback(() => {
const sentAt = Date.now();
eventsApi.getById(eventId)
.then(({ event }) => setEvent(event))
.then(({ event, serverTime }) => {
setEvent(event);
if (serverTime) {
const receivedAt = Date.now();
setClockOffsetMs(parseDate(serverTime).getTime() - (sentAt + receivedAt) / 2);
}
})
.catch(console.error);
}, [eventId]);
// Refresh event data on client for real-time availability. The server-rendered
// copy can be up to a minute old (fetch revalidate), so this also corrects a
// stale sales state right after load.
useEffect(() => {
refreshEvent();
}, [refreshEvent]);
// salesState flips on the clock (online → door at presaleClosesAt, door →
// ended at the end time) without any edit to the event: refetch at that moment
// so an open page stops offering online booking. Overdue flips retry after 3s.
useEffect(() => {
const changeAt = nextSalesStateChangeMs(event);
if (changeAt === null) return;
const delay = Math.max(changeAt - (Date.now() + clockOffsetMs) + 1000, 3000);
if (delay > MAX_TIMER_MS) return;
const timer = setTimeout(refreshEvent, delay);
return () => clearTimeout(timer);
}, [event, clockOffsetMs, refreshEvent]);
// Phones suspend timers in background tabs; catch up when the page is shown again.
useEffect(() => {
const onVisible = () => {
if (document.visibilityState === 'visible') refreshEvent();
};
document.addEventListener('visibilitychange', onVisible);
return () => document.removeEventListener('visibilitychange', onVisible);
}, [refreshEvent]);
// Server-authoritative availability (paid + claimed seats count; abandoned
// pending bookings don't) — matches the booking API's sold-out check exactly.
const spotsLeft = eventSpotsLeft(event);
@@ -67,18 +108,78 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail
const fmtTime = (dateStr: string) => formatTime(dateStr, locale as 'en' | 'es');
const isCancelled = event.status === 'cancelled';
// Server-computed sales state (backend lib/salesState.ts). External booking
// events, and any cached copy from before salesState existed, keep the
// original client-side rules below.
const salesState = event.salesState;
const legacyRules = !salesState || salesState === 'external';
const isDoorSales = salesState === 'door' && typeof event.doorPrice === 'number';
const hasEnded = salesState === 'ended';
// Only calculate isPastEvent after mount to avoid hydration mismatch
const isPastEvent = mounted ? new Date(event.startDatetime) < new Date() : false;
const isPastEvent = legacyRules ? (mounted ? new Date(event.startDatetime) < new Date() : false) : hasEnded;
// Pre-sale closure (server-computed cutoff); same mount guard as isPastEvent
const presaleClosed = mounted ? isPresaleClosed(event) : false;
const canBook = !isSoldOut && !isCancelled && !isPastEvent && !presaleClosed && (event.status === 'published' || event.status === 'unlisted');
const presaleClosed = legacyRules ? (mounted ? isPresaleClosed(event) : false) : salesState === 'door';
const canBook = legacyRules
? !isSoldOut && !isCancelled && !isPastEvent && !presaleClosed && (event.status === 'published' || event.status === 'unlisted')
: salesState === 'online';
// Effective lead time (event override or site default), derived from the server cutoff
const presaleLeadMinutes = event.presaleClosesAt
? Math.max(0, Math.round((parseDate(event.startDatetime).getTime() - parseDate(event.presaleClosesAt).getTime()) / 60_000))
: null;
const spotsLeftText = spotsLeft === 1
? t('events.door.spotsLeftOne')
: t('events.door.spotsLeft', { n: spotsLeft });
const doorMethodsText = DOOR_METHOD_ORDER
.filter((method) => event.doorPaymentMethods?.includes(method))
.map((method) => t(`events.door.methods.${method}`))
.join(', ');
// Online sales closed but seats left: people can still come and pay at the
// door. Informational only — there is nothing to click.
const DoorSalesBlock = () => {
const doorPrice = event.doorPrice ?? event.price;
return (
<div className="rounded-card bg-primary-yellow/15 border border-primary-yellow/40 px-5 py-6 text-center">
<p className="text-xs font-semibold uppercase tracking-wide text-gray-500">
{t('events.door.onlineClosed')}
</p>
<p className="mt-1 text-lg font-semibold text-primary-dark">
{t('events.door.headline')}
</p>
<p className="mt-4 text-4xl font-bold text-primary-dark">
{doorPrice === 0 ? t('events.details.free') : formatPrice(doorPrice, event.currency)}
</p>
{doorPrice > 0 && (
<p className="text-xs text-gray-500 mt-1">{t('events.door.perPerson')}</p>
)}
{doorPrice > event.price && event.price > 0 && (
<p className="mt-2 text-sm text-gray-500">
{t('events.door.onlinePriceWas', { price: formatPrice(event.price, event.currency) })}
</p>
)}
<p className="mt-4 text-sm font-medium text-primary-dark">{spotsLeftText}</p>
{doorMethodsText && (
<p className="mt-1 text-xs text-gray-500">{t('events.door.accepts', { methods: doorMethodsText })}</p>
)}
<p className="mt-1 text-xs text-gray-500" suppressHydrationWarning>
{t('events.door.doorsOpen', { time: fmtTime(event.startDatetime) })}
</p>
</div>
);
};
// Booking card content - reused for mobile and desktop positions
const BookingCardContent = () => (
const BookingCardContent = () => {
if (isDoorSales) return <DoorSalesBlock />;
if (hasEnded) {
return (
<div className="rounded-card bg-secondary-gray px-5 py-6 text-center">
<p className="text-lg font-semibold text-primary-dark">{t('events.details.eventHasEnded')}</p>
</div>
);
}
return (
<>
<div className="text-center mb-4">
<p className="text-sm text-gray-500">{t('events.details.price')}</p>
@@ -173,7 +274,8 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail
</p>
)}
</>
);
);
};
return (
<div className="section-padding">
@@ -272,7 +374,9 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail
<div>
<p className="font-medium text-sm">{t('events.details.capacity')}</p>
<p className="text-gray-600">
{spotsLeft} / {event.capacity} {t('events.details.spotsLeft')}
{isDoorSales
? t('events.door.spotsLeftAtDoor', { n: spotsLeft })
: `${spotsLeft} / ${event.capacity} ${t('events.details.spotsLeft')}`}
</p>
</div>
</div>
+10 -4
View File
@@ -1,6 +1,7 @@
import type { Metadata } from 'next';
import { notFound, permanentRedirect } from 'next/navigation';
import EventDetailClient from './EventDetailClient';
import type { EventSalesState } from '@/lib/api';
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://spanglish.com.py';
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
@@ -26,6 +27,7 @@ interface Event {
availableSeats?: number;
bookedCount?: number;
presaleClosesAt?: string | null;
salesState?: EventSalesState;
createdAt: string;
updatedAt: string;
}
@@ -114,19 +116,23 @@ function generateEventJsonLd(event: Event) {
name: 'Spanglish',
url: siteUrl,
},
// The offer is always the online price; the door price is never published
// here. In the `door` state seats can still be bought (at the door), so it
// stays InStock and drops the pre-sale validThrough, which is in the past.
offers: {
'@type': 'Offer',
price: event.price,
priceCurrency: event.currency,
availability:
event.salesState === 'sold_out' ||
(typeof event.availableSeats === 'number'
? event.availableSeats
: Math.max(0, (event.capacity ?? 0) - (event.bookedCount ?? 0))) > 0
? 'https://schema.org/InStock'
: 'https://schema.org/SoldOut',
: Math.max(0, (event.capacity ?? 0) - (event.bookedCount ?? 0))) <= 0
? 'https://schema.org/SoldOut'
: 'https://schema.org/InStock',
url: `${siteUrl}/events/${event.slug}`,
validFrom: new Date().toISOString(),
...(event.presaleClosesAt ? { validThrough: event.presaleClosesAt } : {}),
...(event.presaleClosesAt && event.salesState !== 'door' ? { validThrough: event.presaleClosesAt } : {}),
},
image: event.bannerUrl
? (event.bannerUrl.startsWith('http') ? event.bannerUrl : `${siteUrl}${event.bannerUrl}`)