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
+5 -2
View File
@@ -6,7 +6,8 @@
"dev": "dotenv -e .env -- next dev",
"build": "next build",
"start": "dotenv -e .env -- next start",
"lint": "next lint"
"lint": "next lint",
"test": "vitest run"
},
"dependencies": {
"@heroicons/react": "^2.1.4",
@@ -23,6 +24,7 @@
"react-dom": "^18.3.1",
"react-hot-toast": "^2.4.1",
"react-markdown": "^10.1.0",
"recharts": "^2.15.4",
"remark-gfm": "^4.0.1"
},
"devDependencies": {
@@ -33,6 +35,7 @@
"dotenv-cli": "^11.0.0",
"postcss": "^8.4.38",
"tailwindcss": "^3.4.4",
"typescript": "^5.5.2"
"typescript": "^5.5.2",
"vitest": "^4.1.10"
}
}
@@ -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}`)
@@ -0,0 +1,141 @@
import Link from 'next/link';
import { ArrowLeftIcon } from '@heroicons/react/24/outline';
import { Skeleton, SkeletonGroup } from '@/components/ui/Skeleton';
// Loading placeholders for the single event page, one per region, so each part
// of the page can show as soon as its own data is in: the header once the event
// loads, the tab bar once permissions load, stats and tab bodies once tickets load.
/** Title, date line and action buttons; sits beside the real back arrow. */
export function EventHeaderSkeleton() {
return (
<>
<div className="flex-1 min-w-0 pt-1" aria-hidden="true">
<Skeleton className="h-7 w-2/3 md:w-80" />
<Skeleton className="mt-2 h-4 w-40" />
</div>
<div className="hidden md:flex items-center gap-2 flex-shrink-0" aria-hidden="true">
<Skeleton className="h-9 w-28 rounded-btn" />
<Skeleton className="h-9 w-20 rounded-btn" />
</div>
</>
);
}
export function EventMetaChipsSkeleton() {
return (
<div className="hidden md:flex flex-wrap items-center gap-2 mb-4 ml-[52px]" aria-hidden="true">
<Skeleton className="h-6 w-40 rounded-full" />
<Skeleton className="h-6 w-32 rounded-full" />
<Skeleton className="h-6 w-20 rounded-full" />
<Skeleton className="h-6 w-16 rounded-full" />
</div>
);
}
/** Desktop 4-card stats row and the collapsed mobile stats bar. */
export function EventStatsSkeleton() {
return (
<>
<div className="hidden md:grid grid-cols-4 gap-3 mb-4" aria-hidden="true">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="flex items-center gap-2.5 bg-white rounded-card shadow-card px-3 py-2.5">
<Skeleton className="w-8 h-8 rounded-full flex-shrink-0" />
<div className="flex-1">
<Skeleton className="h-5 w-16" />
<Skeleton className="mt-1 h-3 w-20" />
</div>
</div>
))}
</div>
<div className="md:hidden mb-3" aria-hidden="true">
<div className="flex items-center gap-3 px-3 py-2 bg-white rounded-card shadow-card mb-2 min-h-[44px]">
<Skeleton className="h-3 w-10" />
<Skeleton className="h-4 w-14" />
<Skeleton className="h-4 w-12" />
</div>
</div>
</>
);
}
/** Desktop tab strip (top of the content card) and the mobile segmented bar. */
export function EventTabBarSkeleton() {
const widths = ['w-20', 'w-24', 'w-16', 'w-14', 'w-20'];
return (
<>
<div className="hidden md:block bg-white rounded-t-2xl shadow-card border-b border-gray-200 px-4" aria-hidden="true">
<div className="flex gap-4 py-3">
{widths.map((w, i) => <Skeleton key={i} className={`h-5 ${w}`} />)}
</div>
</div>
<div className="md:hidden -mx-4 px-3 py-1.5 bg-gray-100" aria-hidden="true">
<div className="flex gap-1.5 overflow-hidden">
{widths.map((w, i) => <Skeleton key={i} className={`h-[44px] ${w} flex-shrink-0 bg-white/80`} />)}
</div>
</div>
</>
);
}
/** Toolbar plus list rows, sized for the attendee/ticket lists inside a tab. */
export function EventTabBodySkeleton() {
return (
<SkeletonGroup>
<div className="flex items-center gap-2 mb-4" aria-hidden="true">
<Skeleton className="h-10 flex-1 md:max-w-xs rounded-btn" />
<Skeleton className="h-10 w-24 rounded-btn hidden md:block" />
<Skeleton className="h-10 w-28 rounded-btn hidden md:block" />
</div>
<div className="divide-y divide-gray-100" aria-hidden="true">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="flex items-center gap-3 py-3">
<Skeleton className="w-9 h-9 rounded-full flex-shrink-0" />
<div className="flex-1">
<Skeleton className={i % 2 === 0 ? 'h-4 w-1/3' : 'h-4 w-1/4'} />
<Skeleton className="mt-1.5 h-3 w-1/2 md:w-1/3" />
</div>
<Skeleton className="h-5 w-20 rounded-full hidden sm:block" />
<Skeleton className="h-8 w-20 rounded-btn" />
</div>
))}
</div>
</SkeletonGroup>
);
}
/** Wraps a tab body skeleton in the same card the real tab content sits in. */
export function EventTabContentSkeleton() {
return (
<div className="md:bg-white md:rounded-b-2xl md:shadow-card md:p-4 pt-3 md:pt-4">
<EventTabBodySkeleton />
</div>
);
}
/** Back arrow row shared by the loading shell and the real header. */
export function EventBackButton({ href }: { href: string }) {
return (
<Link href={href} className="mt-1 flex-shrink-0">
<button className="p-2 hover:bg-gray-100 rounded-btn min-h-[44px] min-w-[44px] flex items-center justify-center">
<ArrowLeftIcon className="w-5 h-5" />
</button>
</Link>
);
}
/** Whole-page shell before the event itself has loaded. The back arrow is real. */
export function EventDetailSkeleton({ backHref }: { backHref: string }) {
return (
<div className="max-w-full overflow-x-hidden">
<div className="flex items-start gap-3 mb-3">
<EventBackButton href={backHref} />
<EventHeaderSkeleton />
</div>
<EventMetaChipsSkeleton />
<EventStatsSkeleton />
<EventTabBarSkeleton />
<EventTabContentSkeleton />
</div>
);
}
@@ -0,0 +1,881 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import Link from 'next/link';
import { useLanguage } from '@/context/LanguageContext';
import { ticketsApi, emailsApi, adminApi, paymentsApi, siteSettingsApi, financeApi, Ticket, type EventPermission } from '@/lib/api';
import { formatDateLong, formatDateCompact, formatTime } from '@/lib/utils';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import { Dropdown, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents';
import {
CalendarIcon,
MapPinIcon,
CurrencyDollarIcon,
UsersIcon,
TicketIcon,
CheckCircleIcon,
EnvelopeIcon,
PencilIcon,
EyeIcon,
UserGroupIcon,
CreditCardIcon,
ChevronDownIcon,
EllipsisVerticalIcon,
ArrowUturnLeftIcon,
BanknotesIcon,
UserPlusIcon,
} from '@heroicons/react/24/outline';
import toast from 'react-hot-toast';
import clsx from 'clsx';
import { usePrivacy } from '@/context/PrivacyContext';
import type {
TabType,
AttendeeStatusFilter,
TicketStatusFilter,
RecipientFilter,
AddTicketType,
AddTicketFormState,
PrimaryAction,
} from '../_types';
import { formatCurrency, downloadBlob } from '../_utils/format';
import { useEventDetailData } from '../_hooks/useEventDetailData';
import { usePaymentOverrides } from '../_hooks/usePaymentOverrides';
import { OverviewTab } from '../_tabs/OverviewTab';
import { AttendeesTab } from '../_tabs/AttendeesTab';
import { TicketsTab } from '../_tabs/TicketsTab';
import { EmailTab } from '../_tabs/EmailTab';
import { PaymentsTab } from '../_tabs/PaymentsTab';
import { TeamTab } from '../_tabs/TeamTab';
import { FinanceTab } from '../_finance/FinanceTab';
import { EventModals } from '../_modals/EventModals';
import { AddTicketModal } from '../_modals/AddTicketModal';
import EventFormModal from '../../_components/EventFormModal';
import {
EventDetailSkeleton,
EventBackButton,
EventStatsSkeleton,
EventTabBarSkeleton,
EventTabBodySkeleton,
} from './EventDetailSkeleton';
/** Which permission each tab needs (see backend lib/eventPermissions.ts). */
const TAB_PERMISSION: Record<TabType, EventPermission> = {
overview: 'view_overview',
attendees: 'view_attendees_names',
tickets: 'view_attendees_names',
email: 'email_attendees',
payments: 'view_payments',
finance: 'view_finance',
team: 'manage_team',
};
const TAB_ORDER: TabType[] = ['overview', 'attendees', 'tickets', 'email', 'payments', 'finance', 'team'];
/** Tabs rendered from the tickets/templates/door data loaded by useEventDetailData
* (Overview only needs the event, and skeletons its own seat counts). */
const TABS_NEEDING_DETAILS: TabType[] = ['attendees', 'tickets', 'email', 'payments'];
const EMPTY_ADD_TICKET_FORM: AddTicketFormState = {
type: 'paid',
firstName: '',
lastName: '',
email: '',
phone: '',
adminNote: '',
checkinNow: false,
};
/**
* The single event page. Admin and organizer see it at /admin/events/[id];
* team members (event_members) see the same view at /dashboard/events/[id].
* Tabs, header actions and data loads follow the viewer's permissions on this
* event; the server enforces the same permissions on every call.
*/
export function EventDetailView({ eventId, backHref }: { eventId: string; backHref: string }) {
const { locale, t } = useLanguage();
const [permissions, setPermissions] = useState<Set<EventPermission> | null>(null);
useEffect(() => {
financeApi
.myPermissions(eventId)
.then((res) => setPermissions(new Set(res.permissions)))
.catch(() => setPermissions(new Set()));
}, [eventId]);
const can = (p: EventPermission) => !!permissions?.has(p);
const { eventLoading, detailsLoading, event, tickets, templates, doorSummary, loadEventData } = useEventDetailData(eventId, permissions);
const [activeTab, setActiveTabState] = useState<TabType>('overview');
// The open tab lives in the URL (?tab=finance) so reloads, the back button
// and shared links land on it. Switching tabs drops sub-tab params (?fin=).
const setActiveTab = (tab: TabType) => {
setActiveTabState(tab);
const url = new URL(window.location.href);
if (tab === 'overview') url.searchParams.delete('tab'); else url.searchParams.set('tab', tab);
if (tab !== 'finance') url.searchParams.delete('fin');
window.history.replaceState(window.history.state, '', url);
};
// Land on the requested tab if allowed, otherwise the first tab the viewer may open.
useEffect(() => {
if (!permissions) return;
const requested = new URLSearchParams(window.location.search).get('tab') as TabType | null;
if (requested && TAB_ORDER.includes(requested) && permissions.has(TAB_PERMISSION[requested])) {
setActiveTabState(requested);
} else if (!permissions.has(TAB_PERMISSION[activeTab])) {
const first = TAB_ORDER.find((k) => permissions.has(TAB_PERMISSION[k]));
if (first) setActiveTabState(first);
}
}, [permissions]);
// Keep the active tab visible in the horizontally scrolling mobile strip.
const mobileTabsRef = useRef<HTMLDivElement>(null);
// Scroll only the strip itself: scrollIntoView would also shift the page sideways.
useEffect(() => {
const strip = mobileTabsRef.current;
const el = strip?.querySelector<HTMLElement>(`[data-tab="${activeTab}"]`);
if (strip && el) strip.scrollLeft = el.offsetLeft - (strip.clientWidth - el.clientWidth) / 2;
}, [activeTab, eventLoading, permissions]);
// Email state
const [selectedTemplate, setSelectedTemplate] = useState<string>('');
const [recipientFilter, setRecipientFilter] = useState<RecipientFilter>('confirmed');
const [customMessage, setCustomMessage] = useState('');
const [sending, setSending] = useState(false);
const [previewHtml, setPreviewHtml] = useState<string | null>(null);
// Attendees tab state
const [searchQuery, setSearchQuery] = useState('');
const [statusFilter, setStatusFilter] = useState<AttendeeStatusFilter>('all');
const { privacyMode } = usePrivacy();
const showStats = !privacyMode;
const [showNoteModal, setShowNoteModal] = useState(false);
const [selectedTicket, setSelectedTicket] = useState<Ticket | null>(null);
const [noteText, setNoteText] = useState('');
// Unified Add Ticket modal (paid / door / unpaid / guest via segmented control)
const [showAddTicketModal, setShowAddTicketModal] = useState(false);
const [addTicketForm, setAddTicketForm] = useState<AddTicketFormState>(EMPTY_ADD_TICKET_FORM);
const [submitting, setSubmitting] = useState(false);
const openAddTicket = (type: AddTicketType) => {
setAddTicketForm({ ...EMPTY_ADD_TICKET_FORM, type });
setShowAddTicketModal(true);
};
// Export state — separate desktop (Dropdown portal) vs mobile (BottomSheet)
const [showExportDropdown, setShowExportDropdown] = useState(false); // desktop dropdown
const [showExportSheet, setShowExportSheet] = useState(false); // mobile bottom sheet
const [showTicketExportDropdown, setShowTicketExportDropdown] = useState(false); // desktop
const [showTicketExportSheet, setShowTicketExportSheet] = useState(false); // mobile
const [exporting, setExporting] = useState(false);
// Add Ticket — separate desktop dropdown vs mobile bottom sheet
const [showAddTicketDropdown, setShowAddTicketDropdown] = useState(false); // desktop
const [showAddTicketSheet, setShowAddTicketSheet] = useState(false); // mobile FAB
// Tickets tab state
const [ticketSearchQuery, setTicketSearchQuery] = useState('');
const [ticketStatusFilter, setTicketStatusFilter] = useState<TicketStatusFilter>('all');
// Payment options state + handlers
const payments = usePaymentOverrides(eventId, locale);
// Edit event modal (opens in place instead of redirecting to the list page)
const [showEditForm, setShowEditForm] = useState(false);
const [featuredEventId, setFeaturedEventId] = useState<string | null>(null);
useEffect(() => {
siteSettingsApi
.get()
.then(({ settings }) => setFeaturedEventId(settings.featuredEventId || null))
.catch(() => {});
}, []);
// Mobile-specific state
const [mobileHeaderMenuOpen, setMobileHeaderMenuOpen] = useState(false);
const [mobileFilterOpen, setMobileFilterOpen] = useState(false);
const [mobileStatsExpanded, setMobileStatsExpanded] = useState(false);
// Tab bar ref for sticky
const tabBarRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (activeTab === 'payments') {
payments.loadPaymentOptions();
}
}, [activeTab]);
const formatDate = (dateStr: string) => formatDateLong(dateStr, locale as 'en' | 'es');
const formatDateShort = (dateStr: string) => formatDateCompact(dateStr, locale as 'en' | 'es');
const fmtTime = (dateStr: string) => formatTime(dateStr, locale as 'en' | 'es');
const getTicketsByStatus = (status: string) => {
return tickets.filter(t => t.status === status);
};
const getFilteredRecipientCount = () => {
if (recipientFilter === 'all') return tickets.length;
return getTicketsByStatus(recipientFilter).length;
};
const handleMarkPaid = async (ticketId: string) => {
try {
await ticketsApi.markPaid(ticketId);
toast.success('Payment marked as received');
loadEventData();
} catch (error: any) {
toast.error(error.message || 'Failed to mark payment');
}
};
const handleCheckin = async (ticketId: string) => {
try {
await ticketsApi.checkin(ticketId);
toast.success('Attendee checked in');
loadEventData();
} catch (error: any) {
toast.error(error.message || 'Failed to check in');
}
};
const handleReactivate = async (ticket: Ticket) => {
if (!ticket.payment?.id) return;
try {
await paymentsApi.reactivate(ticket.payment.id);
toast.success('Booking reactivated');
loadEventData();
} catch (error: any) {
toast.error(error.message || 'Failed to reactivate booking');
}
};
const handleRemoveCheckin = async (ticketId: string) => {
if (!confirm('Are you sure you want to remove the check-in for this attendee?')) return;
try {
await ticketsApi.removeCheckin(ticketId);
toast.success('Check-in removed');
loadEventData();
} catch (error: any) {
toast.error(error.message || 'Failed to remove check-in');
}
};
const handleOpenNoteModal = (ticket: Ticket) => {
setSelectedTicket(ticket);
setNoteText(ticket.adminNote || '');
setShowNoteModal(true);
};
const handleSaveNote = async () => {
if (!selectedTicket) return;
setSubmitting(true);
try {
await ticketsApi.updateNote(selectedTicket.id, noteText);
toast.success('Note saved');
setShowNoteModal(false);
setSelectedTicket(null);
setNoteText('');
loadEventData();
} catch (error: any) {
toast.error(error.message || 'Failed to save note');
} finally {
setSubmitting(false);
}
};
const handleAddTicket = async (e: React.FormEvent) => {
e.preventDefault();
if (!event) return;
setSubmitting(true);
try {
const res = await ticketsApi.adminAdd({
eventId: event.id,
type: addTicketForm.type,
firstName: addTicketForm.firstName || undefined,
lastName: addTicketForm.lastName || undefined,
email: addTicketForm.email || undefined,
phone: addTicketForm.phone || undefined,
checkinNow: addTicketForm.checkinNow,
adminNote: addTicketForm.adminNote || undefined,
});
toast.success(res.message || 'Ticket created');
setShowAddTicketModal(false);
setAddTicketForm(EMPTY_ADD_TICKET_FORM);
loadEventData();
} catch (error: any) {
toast.error(error.message || 'Failed to add ticket');
} finally {
setSubmitting(false);
}
};
const handleExportAttendees = async (status: 'confirmed' | 'checked_in' | 'confirmed_pending' | 'all') => {
if (!event) return;
setExporting(true);
setShowExportDropdown(false);
try {
const { blob, filename } = await adminApi.exportAttendees(event.id, { status, format: 'csv', q: searchQuery || undefined });
downloadBlob(blob, filename);
toast.success('Export downloaded');
} catch (error: any) {
toast.error(error.message || 'Failed to export attendees');
} finally {
setExporting(false);
}
};
const handleExportTickets = async (status: 'confirmed' | 'checked_in' | 'all') => {
if (!event) return;
setExporting(true);
setShowTicketExportDropdown(false);
try {
const { blob, filename } = await adminApi.exportTicketsCSV(event.id, { status, q: ticketSearchQuery || undefined });
downloadBlob(blob, filename);
toast.success('Export downloaded');
} catch (error: any) {
toast.error(error.message || 'Failed to export tickets');
} finally {
setExporting(false);
}
};
// Filtered tickets for attendees tab
const filteredTickets = tickets.filter((ticket) => {
if (statusFilter !== 'all' && ticket.status !== statusFilter) return false;
if (searchQuery) {
const query = searchQuery.toLowerCase();
const fullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim().toLowerCase();
return (
fullName.includes(query) ||
(ticket.attendeeEmail?.toLowerCase().includes(query) || false) ||
(ticket.attendeePhone?.toLowerCase().includes(query) || false) ||
ticket.id.toLowerCase().includes(query)
);
}
return true;
});
// Filtered tickets for the Tickets tab (only confirmed/checked_in)
const confirmedTickets = tickets.filter(t => ['confirmed', 'checked_in'].includes(t.status));
const filteredConfirmedTickets = confirmedTickets.filter((ticket) => {
if (ticketStatusFilter !== 'all' && ticket.status !== ticketStatusFilter) return false;
if (ticketSearchQuery) {
const query = ticketSearchQuery.toLowerCase();
const fullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim().toLowerCase();
return (
fullName.includes(query) ||
ticket.id.toLowerCase().includes(query)
);
}
return true;
});
const handlePreviewEmail = async () => {
if (!selectedTemplate) {
toast.error('Please select a template');
return;
}
try {
const res = await emailsApi.preview({
templateSlug: selectedTemplate,
variables: {
attendeeName: 'John Doe',
attendeeEmail: 'john@example.com',
ticketId: 'TKT-PREVIEW',
eventTitle: event?.title || '',
eventDate: event ? formatDate(event.startDatetime) : '',
eventTime: event ? fmtTime(event.startDatetime) : '',
eventLocation: event?.location || '',
eventLocationUrl: event?.locationUrl || '',
eventPrice: event ? formatCurrency(event.price, event.currency) : '',
customMessage: customMessage || 'Your custom message will appear here.',
},
locale,
}, eventId);
setPreviewHtml(res.bodyHtml);
} catch (error) {
toast.error('Failed to preview email');
}
};
const handleSendEmail = async () => {
if (!selectedTemplate) {
toast.error('Please select a template');
return;
}
const recipientCount = getFilteredRecipientCount();
if (recipientCount === 0) {
toast.error('No recipients match the selected filter');
return;
}
if (!confirm(`Send email to ${recipientCount} ${recipientFilter === 'all' ? 'attendee(s)' : `${recipientFilter} attendee(s)`}?`)) {
return;
}
setSending(true);
try {
const res = await emailsApi.sendToEvent(eventId, {
templateSlug: selectedTemplate,
recipientFilter,
customVariables: customMessage ? { customMessage } : undefined,
});
if (res.success) {
toast.success(`${res.queuedCount} email(s) are being sent in the background.`);
} else {
toast.error(res.error || 'Failed to queue emails');
}
} catch (error: any) {
toast.error(error.message || 'Failed to send emails');
} finally {
setSending(false);
}
};
// The header renders as soon as the event is in; the tab bar waits for
// permissions and the ticket-derived parts (stats, counts, tab bodies) wait
// for the details, each behind its own skeleton.
if (eventLoading) {
return <EventDetailSkeleton backHref={backHref} />;
}
if (!event) {
return (
<div className="text-center py-12">
<p className="text-gray-500">Event not found</p>
<Link href={backHref}>
<Button variant="outline" className="mt-4">
Back to Events
</Button>
</Link>
</div>
);
}
const confirmedCount = getTicketsByStatus('confirmed').length;
const pendingCount = getTicketsByStatus('pending').length;
const checkedInCount = getTicketsByStatus('checked_in').length;
const cancelledCount = getTicketsByStatus('cancelled').length;
const onHoldCount = getTicketsByStatus('on_hold').length;
// Revenue counts only settled tickets: unpaid (balance due) and comp (guest)
// tickets are excluded; legacy rows without paymentStatus fall back to !isGuest
const isRevenueTicket = (t: Ticket) => (t.paymentStatus ? t.paymentStatus === 'paid' : !t.isGuest);
const paidConfirmedCount = getTicketsByStatus('confirmed').filter(isRevenueTicket).length;
const paidCheckedInCount = getTicketsByStatus('checked_in').filter(isRevenueTicket).length;
// Door sales can be taken at a custom amount (someone paying for their whole
// group), so once the door summary is loaded it is the authority on the total:
// pre-sale tickets at face value plus whatever was actually taken on the night.
const presaleRevenue = doorSummary
? doorSummary.presale.total
: (paidConfirmedCount + paidCheckedInCount) * event.price;
const doorRevenue = doorSummary?.door.total ?? 0;
const revenue = presaleRevenue + doorRevenue;
// Header money follows the UI language's thousands separator.
const money = (amount: number) => formatCurrency(amount, event.currency, locale);
// "confirmed" tickets become "checked_in" at the door, so this counts the
// guests who have a ticket and have not arrived yet (not all confirmed ones).
const notCheckedInLabel = t('admin.eventStats.notCheckedIn');
const allTabs: { key: TabType; label: string; icon: typeof CalendarIcon; count?: number }[] = [
{ key: 'overview', label: t('admin.eventTabs.overview'), icon: CalendarIcon },
{ key: 'attendees', label: t('admin.eventTabs.attendees'), icon: UserGroupIcon, count: detailsLoading ? undefined : tickets.length },
{ key: 'tickets', label: t('admin.eventTabs.tickets'), icon: TicketIcon, count: detailsLoading ? undefined : confirmedTickets.length },
{ key: 'email', label: t('admin.eventTabs.email'), icon: EnvelopeIcon },
{ key: 'payments', label: t('admin.eventTabs.payments'), icon: CreditCardIcon },
{ key: 'finance', label: t('admin.eventTabs.finance'), icon: BanknotesIcon },
{ key: 'team', label: t('admin.eventTabs.team'), icon: UserPlusIcon },
];
const tabs = allTabs.filter((tab) => can(TAB_PERMISSION[tab.key]));
// Event-wide money is only shown to viewers who can see payments.
const showRevenue = can('view_payments');
// ========== Primary action for a ticket ==========
const getPrimaryAction = (ticket: Ticket): PrimaryAction | null => {
if (ticket.status === 'pending' || ticket.status === 'on_hold') {
return { label: 'Mark Paid', onClick: () => handleMarkPaid(ticket.id), variant: 'outline' };
}
if (ticket.status === 'confirmed') {
// Unpaid tickets resolve their balance first; check-in stays available via scanner
if (ticket.paymentStatus === 'unpaid') {
return { label: 'Mark Paid', onClick: () => handleMarkPaid(ticket.id), variant: 'outline' };
}
return { label: 'Check In', onClick: () => handleCheckin(ticket.id), variant: 'primary' };
}
if (ticket.status === 'checked_in') {
return { label: 'Undo', onClick: () => handleRemoveCheckin(ticket.id), variant: 'outline', icon: ArrowUturnLeftIcon };
}
return null;
};
return (
<div className="max-w-full overflow-x-hidden">
{/* ============= HEADER ============= */}
<div className="flex items-start gap-3 mb-3">
<EventBackButton href={backHref} />
<div className="flex-1 min-w-0">
<h1 className="text-xl md:text-2xl font-bold text-primary-dark truncate">{event.title}</h1>
<p className="text-sm text-gray-500">{formatDateShort(event.startDatetime)} &middot; {fmtTime(event.startDatetime)}</p>
</div>
{/* Desktop header actions */}
<div className="hidden md:flex items-center gap-2 flex-shrink-0">
<Link href={`/events/${event.slug}`} target="_blank">
<Button variant="outline" size="sm">
<EyeIcon className="w-4 h-4 mr-1.5" />
View Public
</Button>
</Link>
{can('edit_event') && (
<Button variant="outline" size="sm" onClick={() => setShowEditForm(true)}>
<PencilIcon className="w-4 h-4 mr-1.5" />
Edit
</Button>
)}
</div>
{/* Mobile header overflow menu */}
<div className="md:hidden flex-shrink-0">
<Dropdown
open={mobileHeaderMenuOpen}
onOpenChange={setMobileHeaderMenuOpen}
trigger={
<button className="p-2 hover:bg-gray-100 rounded-btn min-h-[44px] min-w-[44px] flex items-center justify-center">
<EllipsisVerticalIcon className="w-5 h-5" />
</button>
}
>
<DropdownItem onClick={() => { window.open(`/events/${event.slug}`, '_blank'); setMobileHeaderMenuOpen(false); }}>
<EyeIcon className="w-4 h-4 mr-2" /> View Public
</DropdownItem>
{can('edit_event') && (
<DropdownItem onClick={() => { setShowEditForm(true); setMobileHeaderMenuOpen(false); }}>
<PencilIcon className="w-4 h-4 mr-2" /> Edit Event
</DropdownItem>
)}
</Dropdown>
</div>
</div>
{/* ============= COMPACT META CHIPS (desktop) ============= */}
<div className="hidden md:flex flex-wrap items-center gap-2 mb-4 ml-[52px]">
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-gray-100 rounded-full text-xs text-gray-700">
<CalendarIcon className="w-3.5 h-3.5" />
{formatDateShort(event.startDatetime)} {fmtTime(event.startDatetime)}{event.endDatetime && ` – ${fmtTime(event.endDatetime)}`}
</span>
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-gray-100 rounded-full text-xs text-gray-700">
<MapPinIcon className="w-3.5 h-3.5" />
{event.location}
</span>
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-gray-100 rounded-full text-xs text-gray-700">
<CurrencyDollarIcon className="w-3.5 h-3.5" />
{event.price === 0 ? t('admin.eventStats.free') : money(event.price)}
</span>
{showStats && !detailsLoading && (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-gray-100 rounded-full text-xs text-gray-700">
<UsersIcon className="w-3.5 h-3.5" />
{confirmedCount + checkedInCount}/{event.capacity}
</span>
)}
</div>
{/* ============= STATS ROW ============= */}
{showStats && detailsLoading && <EventStatsSkeleton />}
{!detailsLoading && (<>
{/* Desktop: always-visible compact 4-card row */}
<div className="hidden md:block mb-4">
{showStats && (
<div className="grid grid-cols-4 gap-3">
{[
{ label: t('admin.eventStats.capacity'), value: `${confirmedCount + checkedInCount}/${event.capacity}`, icon: UsersIcon, color: 'bg-blue-50 text-blue-600' },
{ label: notCheckedInLabel, value: confirmedCount, icon: CheckCircleIcon, color: 'bg-green-50 text-green-600' },
{ label: t('admin.eventStats.checkedIn'), value: checkedInCount, icon: TicketIcon, color: 'bg-purple-50 text-purple-600' },
...(!showRevenue ? [] : [{
label: t('admin.eventStats.revenue'),
value: money(revenue),
icon: CurrencyDollarIcon,
color: 'bg-gray-50 text-gray-600',
detail: doorSummary
? t('admin.eventStats.presaleDoor', { presale: money(presaleRevenue), door: money(doorRevenue) })
: undefined,
}]),
].map((stat) => (
<div key={stat.label} className="flex items-center gap-2.5 bg-white rounded-card shadow-card px-3 py-2.5">
<div className={clsx('w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0', stat.color)}>
<stat.icon className="w-4 h-4" />
</div>
<div className="min-w-0">
<p className="text-lg font-bold leading-tight truncate">{stat.value}</p>
<p className="text-xs text-gray-500 truncate">{('detail' in stat && stat.detail) || stat.label}</p>
</div>
</div>
))}
</div>
)}
</div>
{/* Mobile: collapsible stats */}
<div className="md:hidden mb-3">
{showStats && (
<div>
<button
onClick={() => setMobileStatsExpanded(!mobileStatsExpanded)}
className="w-full flex items-center justify-between px-3 py-2 bg-white rounded-card shadow-card mb-2 min-h-[44px]"
>
<div className="flex items-center gap-3 overflow-x-auto">
<span className="text-xs font-medium text-gray-500 whitespace-nowrap">{t('admin.eventStats.stats')}</span>
<span className="text-sm font-semibold whitespace-nowrap">{confirmedCount + checkedInCount}/{event.capacity}</span>
<span className="text-xs text-gray-400">|</span>
<span className="text-sm whitespace-nowrap">{t('admin.eventStats.inShort', { count: checkedInCount })}</span>
{showRevenue && (
<>
<span className="text-xs text-gray-400">|</span>
<span className="text-sm whitespace-nowrap">{money(revenue)}</span>
</>
)}
</div>
<ChevronDownIcon className={clsx('w-4 h-4 text-gray-400 transition-transform flex-shrink-0 ml-2', mobileStatsExpanded && 'rotate-180')} />
</button>
{mobileStatsExpanded && (
<div className="grid grid-cols-2 gap-2">
{[
{ label: t('admin.eventStats.capacity'), value: `${confirmedCount + checkedInCount}/${event.capacity}`, icon: UsersIcon, color: 'text-blue-600 bg-blue-50' },
{ label: notCheckedInLabel, value: confirmedCount, icon: CheckCircleIcon, color: 'text-green-600 bg-green-50' },
{ label: t('admin.eventStats.checkedIn'), value: checkedInCount, icon: TicketIcon, color: 'text-purple-600 bg-purple-50' },
...(!showRevenue ? [] : [{
label: t('admin.eventStats.revenue'),
value: money(revenue),
icon: CurrencyDollarIcon,
color: 'text-gray-600 bg-gray-50',
detail: doorSummary
? t('admin.eventStats.presaleDoor', { presale: money(presaleRevenue), door: money(doorRevenue) })
: undefined,
}]),
].map((stat) => (
<div key={stat.label} className="flex items-center gap-2 bg-white rounded-card shadow-card px-3 py-2">
<div className={clsx('w-7 h-7 rounded-full flex items-center justify-center flex-shrink-0', stat.color)}>
<stat.icon className="w-3.5 h-3.5" />
</div>
<div className="min-w-0">
<p className="text-base font-bold leading-tight truncate">{stat.value}</p>
<p className="text-[10px] text-gray-500 truncate">{('detail' in stat && stat.detail) || stat.label}</p>
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
</>)}
{/* ============= TAB BAR ============= */}
{!permissions ? <EventTabBarSkeleton /> : (<>
{/* Desktop: tab bar inside a card top-section */}
<div className="hidden md:block bg-white rounded-t-2xl shadow-card border-b border-gray-200">
<div ref={tabBarRef} className="sticky top-0 z-30 bg-white rounded-t-2xl px-4">
<nav className="flex gap-1">
{tabs.map((tab) => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={clsx(
'py-2.5 px-3 font-medium text-sm transition-colors flex items-center gap-1.5 whitespace-nowrap relative',
activeTab === tab.key
? 'text-primary-dark'
: 'text-gray-500 hover:text-gray-700 hover:bg-gray-50 rounded-t-lg'
)}
>
<tab.icon className="w-4 h-4" />
{tab.label}
{tab.count !== undefined && <span className="text-xs text-gray-400">({tab.count})</span>}
{activeTab === tab.key && (
<span className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary-yellow rounded-full" />
)}
</button>
))}
</nav>
</div>
</div>
{/* Mobile: segmented tab bar */}
<div className="md:hidden sticky top-0 z-30 -mx-4 px-3 py-1.5 bg-gray-100">
<div ref={mobileTabsRef} className="overflow-x-auto scrollbar-hide">
<div className="flex gap-0.5 min-w-max">
{tabs.map((tab) => (
<button
key={tab.key}
data-tab={tab.key}
aria-current={activeTab === tab.key ? 'page' : undefined}
onClick={() => setActiveTab(tab.key)}
className={clsx(
'px-3 py-2 rounded-lg text-sm font-medium transition-all whitespace-nowrap min-h-[44px] flex items-center gap-1',
activeTab === tab.key
? 'bg-white shadow-sm text-primary-dark'
: 'text-gray-500 hover:text-gray-700'
)}
>
<tab.icon className="w-3.5 h-3.5" />
{tab.label}
{tab.count !== undefined && <span className="text-[10px] opacity-70">({tab.count})</span>}
</button>
))}
</div>
</div>
</div>
</>)}
{/* ============= TAB CONTENT ============= */}
<div className="md:bg-white md:rounded-b-2xl md:shadow-card md:p-4 pt-3 md:pt-4">
{/* Overview, Finance and Team render straight away; the rest read
tickets, templates or door takings, so they wait for the details. */}
{detailsLoading && TABS_NEEDING_DETAILS.includes(activeTab) ? (
<EventTabBodySkeleton />
) : (<>
{activeTab === 'overview' && (
<OverviewTab
event={event}
formatDate={formatDate}
fmtTime={fmtTime}
formatCurrency={(amount, currency) => formatCurrency(amount, currency, locale)}
confirmedCount={confirmedCount}
checkedInCount={checkedInCount}
countsLoading={detailsLoading}
/>
)}
{activeTab === 'attendees' && (
<AttendeesTab
locale={locale}
tickets={tickets}
filteredTickets={filteredTickets}
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
statusFilter={statusFilter}
setStatusFilter={setStatusFilter}
pendingCount={pendingCount}
confirmedCount={confirmedCount}
checkedInCount={checkedInCount}
cancelledCount={cancelledCount}
onHoldCount={onHoldCount}
exporting={exporting}
showExportDropdown={showExportDropdown}
setShowExportDropdown={setShowExportDropdown}
showAddTicketDropdown={showAddTicketDropdown}
setShowAddTicketDropdown={setShowAddTicketDropdown}
handleExportAttendees={handleExportAttendees}
openAddTicket={openAddTicket}
setMobileFilterOpen={setMobileFilterOpen}
setShowExportSheet={setShowExportSheet}
setShowAddTicketSheet={setShowAddTicketSheet}
getPrimaryAction={getPrimaryAction}
handleOpenNoteModal={handleOpenNoteModal}
handleReactivate={handleReactivate}
handleMarkPaid={handleMarkPaid}
handleCheckin={handleCheckin}
/>
)}
{activeTab === 'tickets' && (
<TicketsTab
locale={locale}
confirmedTickets={confirmedTickets}
filteredConfirmedTickets={filteredConfirmedTickets}
ticketSearchQuery={ticketSearchQuery}
setTicketSearchQuery={setTicketSearchQuery}
ticketStatusFilter={ticketStatusFilter}
setTicketStatusFilter={setTicketStatusFilter}
confirmedCount={confirmedCount}
checkedInCount={checkedInCount}
exporting={exporting}
showTicketExportDropdown={showTicketExportDropdown}
setShowTicketExportDropdown={setShowTicketExportDropdown}
handleExportTickets={handleExportTickets}
handleCheckin={handleCheckin}
handleRemoveCheckin={handleRemoveCheckin}
setShowTicketExportSheet={setShowTicketExportSheet}
/>
)}
{activeTab === 'email' && (
<EmailTab
templates={templates}
selectedTemplate={selectedTemplate}
setSelectedTemplate={setSelectedTemplate}
recipientFilter={recipientFilter}
setRecipientFilter={setRecipientFilter}
customMessage={customMessage}
setCustomMessage={setCustomMessage}
sending={sending}
handlePreviewEmail={handlePreviewEmail}
handleSendEmail={handleSendEmail}
getFilteredRecipientCount={getFilteredRecipientCount}
ticketsCount={tickets.length}
confirmedCount={confirmedCount}
pendingCount={pendingCount}
checkedInCount={checkedInCount}
cancelledCount={cancelledCount}
/>
)}
{activeTab === 'payments' && (
<PaymentsTab locale={locale} payments={payments} doorSummary={doorSummary} />
)}
{activeTab === 'finance' && <FinanceTab eventId={eventId} />}
{activeTab === 'team' && <TeamTab eventId={eventId} />}
</>)}
</div>
{/* ============= MODALS ============= */}
<EventModals
ticketsCount={tickets.length}
pendingCount={pendingCount}
confirmedCount={confirmedCount}
checkedInCount={checkedInCount}
cancelledCount={cancelledCount}
onHoldCount={onHoldCount}
statusFilter={statusFilter}
setStatusFilter={setStatusFilter}
mobileFilterOpen={mobileFilterOpen}
setMobileFilterOpen={setMobileFilterOpen}
showAddTicketSheet={showAddTicketSheet}
setShowAddTicketSheet={setShowAddTicketSheet}
openAddTicket={openAddTicket}
showExportSheet={showExportSheet}
setShowExportSheet={setShowExportSheet}
handleExportAttendees={handleExportAttendees}
showTicketExportSheet={showTicketExportSheet}
setShowTicketExportSheet={setShowTicketExportSheet}
handleExportTickets={handleExportTickets}
submitting={submitting}
showNoteModal={showNoteModal}
setShowNoteModal={setShowNoteModal}
selectedTicket={selectedTicket}
setSelectedTicket={setSelectedTicket}
noteText={noteText}
setNoteText={setNoteText}
handleSaveNote={handleSaveNote}
previewHtml={previewHtml}
setPreviewHtml={setPreviewHtml}
/>
<AddTicketModal
open={showAddTicketModal}
onClose={() => setShowAddTicketModal(false)}
form={addTicketForm}
setForm={setAddTicketForm}
onSubmit={handleAddTicket}
submitting={submitting}
eventPriceLabel={event.price === 0 ? 'Free' : money(event.price)}
/>
<EventFormModal
open={showEditForm}
event={event}
featuredEventId={featuredEventId}
onFeaturedChange={setFeaturedEventId}
onClose={() => setShowEditForm(false)}
onSaved={() => { setShowEditForm(false); loadEventData(); }}
/>
<AdminMobileStyles />
</div>
);
}
@@ -0,0 +1,829 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import toast from 'react-hot-toast';
import clsx from 'clsx';
import {
PlusIcon, PencilIcon, TrashIcon, LockClosedIcon, LockOpenIcon, RectangleStackIcon,
PaperClipIcon, ArrowUpTrayIcon, CheckCircleIcon, ClockIcon, FunnelIcon, ShoppingBagIcon, BoltIcon, SparklesIcon,
} from '@heroicons/react/24/outline';
import { useLanguage } from '@/context/LanguageContext';
import { useAuth } from '@/context/AuthContext';
import { usePrivacy } from '@/context/PrivacyContext';
import { useMoney } from '@/lib/useMoney';
import {
financeApi, mediaApi, CALC_TYPES,
type CalcType, type EventExpense, type EventFinance, type ExpenseInput, type ExpenseTemplate, type ExpenseTemplatePack, type OtherIncome,
} from '@/lib/api';
import Button from '@/components/ui/Button';
import { Modal, Field, PygInput, DateInput, Money, Pill, Segmented, EmptyState, inputClass, iconButtonClass, todayIso } from './ui';
import { bpToPercent, percentToBp } from './format';
import { previewExpense } from './calc';
import { suggestCategory, tCount } from './derive';
type T = (key: string, params?: Record<string, string | number>) => string;
type Fmt = ReturnType<typeof useMoney>;
const isAuto = (c: CalcType) => c !== 'fixed';
/** One-line explanation of how a row's amount came about ("7 tickets × 5.000 PYG"). */
function formula(e: EventExpense, t: T, m: Fmt): string {
const q = e.liveQuantity ?? e.quantity;
switch (e.calcType) {
case 'fixed': return e.quantity === 1 ? '' : `${m.num(e.quantity)} × ${m.pyg(e.unitAmount)}`;
case 'per_ticket_sold': return t('admin.finance.formula.perTicket', { count: m.num(q), amount: m.pyg(e.unitAmount) });
case 'per_checked_in': return t('admin.finance.formula.perGuest', { count: m.num(q), amount: m.pyg(e.unitAmount) });
case 'percent_of_revenue': return t('admin.finance.formula.percent', { percent: m.pct(e.percentBp) });
case 'minimum_spend': return t('admin.finance.formula.minimum', { minimum: m.pyg(e.minimumAmount), count: m.num(q), amount: m.pyg(e.unitAmount) });
default: return '';
}
}
/** Sticky action bar at the bottom of a modal, so Save stays reachable on phones. */
function ModalActions({ children }: { children: React.ReactNode }) {
return <div className="sticky bottom-0 -mx-4 -mb-4 mt-2 px-4 py-3 bg-white border-t border-gray-100 flex gap-3">{children}</div>;
}
// ==================== Expense form ====================
interface ExpenseFormState {
description: string;
categoryId: string;
calcType: CalcType;
/** Fixed: the whole amount. Per ticket / per guest / minimum spend: the amount per unit. */
unitAmount: number;
percent: string;
minimumAmount: number;
isLocked: boolean;
computedAmount: number;
status: 'planned' | 'paid';
paidByPartnerId: string;
receiptUrl: string;
expenseDate: string;
}
const emptyForm = (): ExpenseFormState => ({
description: '', categoryId: '', calcType: 'fixed', unitAmount: 0, percent: '', minimumAmount: 0,
isLocked: false, computedAmount: 0, status: 'planned', paidByPartnerId: '', receiptUrl: '', expenseDate: '',
});
function fromExpense(e: EventExpense): ExpenseFormState {
return {
description: e.description, categoryId: e.categoryId || '', calcType: e.calcType,
// Older fixed rows may carry a quantity; the form edits their total, which saves as quantity 1.
unitAmount: e.calcType === 'fixed' ? e.unitAmount * e.quantity : e.unitAmount,
percent: e.percentBp ? bpToPercent(e.percentBp) : '', minimumAmount: e.minimumAmount,
isLocked: e.isLocked, computedAmount: e.amount ?? e.computedAmount, status: e.status,
paidByPartnerId: e.paidByPartnerId && e.paidByPartnerId !== 'other' ? e.paidByPartnerId : '',
receiptUrl: e.receiptUrl || '', expenseDate: e.expenseDate ? e.expenseDate.slice(0, 10) : '',
};
}
function ExpenseModal({ open, onClose, eventId, data, expense, onSaved }: {
open: boolean; onClose: () => void; eventId: string; data: EventFinance; expense: EventExpense | null; onSaved: () => void;
}) {
const { t, locale } = useLanguage();
const m = useMoney();
const { user } = useAuth();
const [form, setForm] = useState<ExpenseFormState>(emptyForm());
const [touched, setTouched] = useState(false);
// Category follows the description until someone picks one by hand.
const [categoryAuto, setCategoryAuto] = useState(true);
const [saving, setSaving] = useState(false);
const [uploading, setUploading] = useState(false);
// The media upload is admin/organizer only; others can paste a link.
const canUpload = user?.role === 'admin' || user?.role === 'organizer';
useEffect(() => {
if (open) {
setForm(expense ? fromExpense(expense) : emptyForm());
setTouched(false);
setCategoryAuto(!expense || !expense.categoryId);
}
}, [open, expense]);
const set = <K extends keyof ExpenseFormState>(k: K, v: ExpenseFormState[K]) => setForm((f) => ({ ...f, [k]: v }));
const categories = data.categories.filter((c) => !c.archived || c.id === form.categoryId);
const ct = form.calcType;
const counts = data.summary.counts;
const sales = data.summary.revenue.sales;
const preview = previewExpense(
{ calcType: ct, quantity: 1, unitAmount: form.unitAmount, percentBp: percentToBp(form.percent), minimumAmount: form.minimumAmount },
{ ticketsSold: counts.ticketsSold, checkedIn: counts.checkedIn, sales },
);
const locked = isAuto(ct) && form.isLocked;
const total = locked ? form.computedAmount : preview.amount;
const descriptionError = touched && !form.description.trim() ? t('admin.finance.expenses.descriptionRequired') : undefined;
const setDescription = (v: string) => {
setForm((f) => {
if (!categoryAuto) return { ...f, description: v };
return { ...f, description: v, categoryId: suggestCategory(v, data.categories) || '' };
});
};
const setStatus = (v: 'planned' | 'paid') => {
setForm((f) => ({ ...f, status: v, expenseDate: v === 'paid' && !f.expenseDate ? todayIso() : f.expenseDate }));
};
const upload = async (file: File) => {
setUploading(true);
try {
const res = await mediaApi.upload(file, expense?.id, 'expense');
set('receiptUrl', res.url);
} catch (error: any) {
toast.error(error.message);
} finally {
setUploading(false);
}
};
const submit = async () => {
setTouched(true);
if (!form.description.trim()) return;
setSaving(true);
const payload: ExpenseInput = {
description: form.description.trim(),
categoryId: form.categoryId || null,
calcType: ct,
quantity: ct === 'fixed' ? 1 : undefined,
unitAmount: ct === 'percent_of_revenue' ? 0 : form.unitAmount,
percentBp: ct === 'percent_of_revenue' ? percentToBp(form.percent) : 0,
minimumAmount: ct === 'minimum_spend' ? form.minimumAmount : 0,
isLocked: locked,
computedAmount: locked ? form.computedAmount : undefined,
status: form.status,
paidByPartnerId: form.paidByPartnerId || null,
receiptUrl: form.receiptUrl || null,
expenseDate: form.expenseDate || null,
};
try {
if (expense) await financeApi.updateExpense(eventId, expense.id, payload);
else await financeApi.createExpense(eventId, payload);
toast.success(t(expense ? 'admin.finance.expenses.saved' : 'admin.finance.expenses.created'));
onSaved();
onClose();
} catch (error: any) {
toast.error(error.message);
} finally {
setSaving(false);
}
};
// "5.000 PYG × 4 tickets = 20.000 PYG" with this event's real numbers.
const previewLine = (() => {
if (locked) return t('admin.finance.preview.locked', { total: m.pyg(total) });
const totalText = m.pyg(total);
switch (ct) {
case 'per_ticket_sold': return tCount(t, 'admin.finance.preview.perTicket', preview.count, { count: m.num(preview.count), amount: m.pyg(form.unitAmount), total: totalText });
case 'per_checked_in': return tCount(t, 'admin.finance.preview.perGuest', preview.count, { count: m.num(preview.count), amount: m.pyg(form.unitAmount), total: totalText });
case 'percent_of_revenue': return t('admin.finance.preview.percent', { percent: m.pct(percentToBp(form.percent)), sales: m.pyg(Math.max(0, sales)), total: totalText });
case 'minimum_spend': return t('admin.finance.preview.minimum', { minimum: m.pyg(form.minimumAmount), count: m.num(preview.count), amount: m.pyg(form.unitAmount), total: totalText });
default: return t('admin.finance.preview.fixed', { total: totalText });
}
})();
const amountLabel = {
fixed: 'admin.finance.expenses.amount',
per_ticket_sold: 'admin.finance.expenses.amountPerTicket',
per_checked_in: 'admin.finance.expenses.amountPerGuest',
percent_of_revenue: 'admin.finance.expenses.percentOfRevenue',
minimum_spend: 'admin.finance.expenses.minimumAmount',
}[ct];
return (
<Modal open={open} onClose={onClose} title={t(expense ? 'admin.finance.expenses.edit' : 'admin.finance.expenses.new')} wide>
<Field label={t('admin.finance.expenses.description')} required error={descriptionError}>
<input
className={clsx(inputClass, descriptionError && 'border-red-400')}
value={form.description}
maxLength={300}
placeholder={t('admin.finance.expenses.descriptionPlaceholder')}
onChange={(e) => setDescription(e.target.value)}
onBlur={() => setTouched(true)}
autoFocus
/>
</Field>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<Field label={t(amountLabel)}>
{ct === 'percent_of_revenue' ? (
<div className="relative">
<input className={clsx(inputClass, 'pr-8')} inputMode="decimal" value={form.percent} onChange={(e) => set('percent', e.target.value)} placeholder="10" />
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-sm text-gray-400">%</span>
</div>
) : ct === 'minimum_spend' ? (
<PygInput value={form.minimumAmount} onChange={(n) => set('minimumAmount', n)} />
) : (
<PygInput value={form.unitAmount} onChange={(n) => set('unitAmount', n)} />
)}
</Field>
<Field label={t('admin.finance.expenses.calcType')} hint={t(`admin.finance.calcHints.${ct}`)}>
<select className={inputClass} value={ct} onChange={(e) => set('calcType', e.target.value as CalcType)}>
{CALC_TYPES.map((c) => <option key={c} value={c}>{t(`admin.finance.calcTypes.${c}`)}</option>)}
</select>
</Field>
{ct === 'minimum_spend' && (
<Field label={t('admin.finance.expenses.amountPerGuest')}>
<PygInput value={form.unitAmount} onChange={(n) => set('unitAmount', n)} />
</Field>
)}
</div>
{/* Live result: what this line costs with today's numbers */}
<div className="rounded-btn bg-gray-50 border border-gray-100 px-3 py-2.5">
<p className="text-sm text-gray-700 tabular-nums">{previewLine}</p>
{isAuto(ct) && (
<div className="mt-2 pt-2 border-t border-gray-200 space-y-2">
<label className="flex items-start gap-2 text-sm cursor-pointer">
<input type="checkbox" checked={form.isLocked} onChange={(e) => { set('isLocked', e.target.checked); if (e.target.checked) set('computedAmount', preview.amount); }} className="w-4 h-4 mt-0.5 accent-primary-yellow" />
<span>
<span className="font-medium">{t('admin.finance.expenses.lock')}</span>
<span className="block text-xs text-gray-500">{t('admin.finance.expenses.lockHint')}</span>
</span>
</label>
{form.isLocked && (
<Field label={t('admin.finance.expenses.lockedAmount')}>
<PygInput value={form.computedAmount} onChange={(n) => set('computedAmount', n)} />
</Field>
)}
</div>
)}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<Field
label={t('admin.finance.expenses.category')}
hint={categoryAuto && form.categoryId ? t('admin.finance.expenses.categorySuggested') : undefined}
>
<select className={inputClass} value={form.categoryId} onChange={(e) => { setCategoryAuto(false); set('categoryId', e.target.value); }}>
<option value="">{t('admin.finance.charts.uncategorized')}</option>
{categories.map((c) => <option key={c.id} value={c.id}>{locale === 'es' ? c.nameEs : c.nameEn}</option>)}
</select>
</Field>
<Field label={t('admin.finance.expenses.status')}>
<Segmented<'planned' | 'paid'>
label={t('admin.finance.expenses.status')}
value={form.status}
onChange={setStatus}
options={[
{ key: 'planned', label: t('admin.finance.expenseStatus.planned'), icon: <ClockIcon className="w-4 h-4" aria-hidden /> },
{ key: 'paid', label: t('admin.finance.expenseStatus.paid'), icon: <CheckCircleIcon className="w-4 h-4" aria-hidden /> },
]}
/>
</Field>
{form.status === 'paid' && (
<>
<Field label={t('admin.finance.expenses.date')}>
<DateInput value={form.expenseDate} onChange={(v) => set('expenseDate', v)} />
</Field>
<Field label={t('admin.finance.expenses.paidBy')} hint={data.partners.length > 0 ? t('admin.finance.expenses.paidByHint') : undefined}>
<select className={inputClass} value={form.paidByPartnerId} onChange={(e) => set('paidByPartnerId', e.target.value)}>
<option value="">{t('admin.finance.expenses.organization')}</option>
{data.partners.map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
</Field>
<div className="sm:col-span-2">
<Field label={t('admin.finance.expenses.receipt')} hint={canUpload ? undefined : t('admin.finance.expenses.receiptAdminOnly')}>
<div className="flex gap-2">
<input className={inputClass} type="url" inputMode="url" value={form.receiptUrl} placeholder="https://" onChange={(e) => set('receiptUrl', e.target.value)} />
{canUpload && (
<label className={clsx('flex-shrink-0 inline-flex items-center gap-1.5 px-3 rounded-btn border border-secondary-light-gray cursor-pointer hover:bg-gray-50 min-h-[44px] text-sm font-medium focus-within:ring-2 focus-within:ring-primary-yellow', uploading && 'opacity-50 pointer-events-none')}>
<ArrowUpTrayIcon className="w-4 h-4" aria-hidden />
{uploading ? t('common.loading') : t('admin.finance.expenses.upload')}
<input type="file" accept="image/*" className="sr-only" onChange={(e) => e.target.files?.[0] && upload(e.target.files[0])} />
</label>
)}
</div>
</Field>
</div>
</>
)}
</div>
<ModalActions>
<Button variant="outline" onClick={onClose} className="flex-1 min-h-[44px]">{t('common.cancel')}</Button>
<Button onClick={submit} isLoading={saving} className="flex-1 min-h-[44px]">{t('common.save')}</Button>
</ModalActions>
</Modal>
);
}
// ==================== Income form ====================
function IncomeModal({ open, income, onClose, eventId, onSaved }: {
open: boolean; income: OtherIncome | null; onClose: () => void; eventId: string; onSaved: () => void;
}) {
const { t } = useLanguage();
const [description, setDescription] = useState('');
const [amount, setAmount] = useState(0);
const [saving, setSaving] = useState(false);
useEffect(() => {
if (open) { setDescription(income?.description || ''); setAmount(income?.amount || 0); }
}, [open, income]);
const save = async () => {
setSaving(true);
try {
if (income) await financeApi.updateIncome(eventId, income.id, { description, amount });
else await financeApi.createIncome(eventId, { description, amount });
onSaved();
onClose();
} catch (error: any) {
toast.error(error.message);
} finally {
setSaving(false);
}
};
return (
<Modal
open={open}
onClose={onClose}
title={t(income ? 'admin.finance.otherIncome.edit' : 'admin.finance.otherIncome.add')}
subtitle={t('admin.finance.otherIncome.hint')}
>
<Field label={t('admin.finance.expenses.description')} required>
<input className={inputClass} value={description} maxLength={300} placeholder={t('admin.finance.otherIncome.placeholder')} onChange={(e) => setDescription(e.target.value)} autoFocus />
</Field>
<Field label={t('admin.finance.expenses.amount')} required>
<PygInput value={amount} onChange={setAmount} />
</Field>
<ModalActions>
<Button variant="outline" onClick={onClose} className="flex-1 min-h-[44px]">{t('common.cancel')}</Button>
<Button onClick={save} isLoading={saving} disabled={!description.trim()} className="flex-1 min-h-[44px]">{t('common.save')}</Button>
</ModalActions>
</Modal>
);
}
// ==================== Use a template (packs + single templates) ====================
function TemplateModal({ open, onClose, eventId, onApplied }: { open: boolean; onClose: () => void; eventId: string; onApplied: () => void }) {
const { t } = useLanguage();
const m = useMoney();
const [templates, setTemplates] = useState<ExpenseTemplate[]>([]);
const [packs, setPacks] = useState<ExpenseTemplatePack[]>([]);
const [loading, setLoading] = useState(false);
const [busy, setBusy] = useState<string | null>(null);
useEffect(() => {
if (!open) return;
setLoading(true);
Promise.all([financeApi.getTemplates(eventId), financeApi.getPacks(eventId)])
.then(([tpl, pk]) => { setTemplates(tpl.templates.filter((x) => !x.archived)); setPacks(pk.packs.filter((x) => !x.archived)); })
.catch((error) => toast.error(error.message))
.finally(() => setLoading(false));
}, [open, eventId]);
const apply = async (body: { templateId?: string; packId?: string }, key: string) => {
setBusy(key);
try {
const res = await financeApi.applyTemplate(eventId, body);
toast.success(t('admin.finance.expenses.applied', { count: res.expenses.length }));
onApplied();
onClose();
} catch (error: any) {
toast.error(error.message);
} finally {
setBusy(null);
}
};
const templateName = (id: string) => templates.find((x) => x.id === id)?.name;
const summary = (tpl: ExpenseTemplate) => {
const kind = t(`admin.finance.calcTypes.${tpl.calcType}`);
if (tpl.calcType === 'percent_of_revenue') return `${kind} · ${m.pct(tpl.percentBp)}`;
if (tpl.calcType === 'minimum_spend') return `${kind} · ${m.pyg(tpl.amount)} · min ${m.pyg(tpl.minimumAmount)}`;
return `${kind} · ${m.pyg(tpl.amount)}`;
};
return (
<Modal open={open} onClose={onClose} title={t('admin.finance.expenses.useTemplate')} subtitle={t('admin.finance.expenses.useTemplateHint')}>
{loading ? <p className="text-sm text-gray-500">{t('common.loading')}</p> : templates.length === 0 && packs.length === 0 ? (
<p className="text-sm text-gray-500">{t('admin.finance.expenses.noTemplates')}</p>
) : (
<div className="space-y-4">
{packs.length > 0 && (
<section>
<h3 className="text-xs font-semibold uppercase tracking-wide text-gray-500 mb-1">{t('admin.finance.expenses.packsHeading')}</h3>
<ul className="divide-y divide-gray-100 border border-gray-100 rounded-btn">
{packs.map((p) => (
<li key={p.id} className="px-3 py-2.5 flex items-center gap-3">
<RectangleStackIcon className="w-5 h-5 text-gray-400 flex-shrink-0" aria-hidden />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">{p.name}</p>
<p className="text-xs text-gray-500">{p.templateIds.map(templateName).filter(Boolean).join(' · ')}</p>
</div>
<Button size="sm" isLoading={busy === p.id} onClick={() => apply({ packId: p.id }, p.id)} className="min-h-[40px]">
{t('admin.finance.expenses.addAll', { count: p.templateIds.length })}
</Button>
</li>
))}
</ul>
</section>
)}
{templates.length > 0 && (
<section>
<h3 className="text-xs font-semibold uppercase tracking-wide text-gray-500 mb-1">{t('admin.finance.expenses.templatesHeading')}</h3>
<ul className="divide-y divide-gray-100 border border-gray-100 rounded-btn">
{templates.map((tpl) => (
<li key={tpl.id} className="px-3 py-2.5 flex items-center gap-3">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">{tpl.name}</p>
<p className="text-xs text-gray-500">{summary(tpl)}</p>
</div>
<Button size="sm" variant="outline" isLoading={busy === tpl.id} onClick={() => apply({ templateId: tpl.id }, tpl.id)} className="min-h-[40px]">
<PlusIcon className="w-4 h-4 mr-1" aria-hidden />{t('admin.finance.expenses.addOne')}
</Button>
</li>
))}
</ul>
</section>
)}
</div>
)}
</Modal>
);
}
// ==================== View ====================
type LedgerRow = { kind: 'expense'; e: EventExpense } | { kind: 'income'; i: OtherIncome };
/** Costs and other income in one ledger: income rows read +amount in green. */
export function ExpensesView({ eventId, data, onChange }: { eventId: string; data: EventFinance; onChange: () => void }) {
const { t, locale } = useLanguage();
const m = useMoney();
const { privacyMode } = usePrivacy();
const [category, setCategory] = useState('');
const [editing, setEditing] = useState<EventExpense | null>(null);
const [formOpen, setFormOpen] = useState(false);
const [income, setIncome] = useState<OtherIncome | null>(null);
const [incomeOpen, setIncomeOpen] = useState(false);
const [templatesOpen, setTemplatesOpen] = useState(false);
const [busyId, setBusyId] = useState<string | null>(null);
const open = data.status === 'open';
const { canEditExpenses, canEditOwnExpenses, userId } = data.viewer;
const canAdd = open && (canEditExpenses || canEditOwnExpenses);
const canEditIncome = open && canEditExpenses;
const canEditRow = (e: EventExpense) => open && (canEditExpenses || (canEditOwnExpenses && e.createdBy === userId));
const catById = useMemo(() => new Map(data.categories.map((c) => [c.id, c])), [data.categories]);
const usedCategories = data.categories.filter((c) => data.expenses.some((e) => e.categoryId === c.id));
const partnerName = (id: string | null) => {
if (!id) return t('admin.finance.expenses.organization');
if (id === 'other') return t('admin.finance.expenses.otherPartner');
return data.partners.find((p) => p.id === id)?.name || t('admin.finance.expenses.otherPartner');
};
const expenses = data.expenses.filter((e) => !category || (category === '__none__' ? !e.categoryId : e.categoryId === category));
// A category filter narrows to costs; income has no category.
const rows: LedgerRow[] = [
...expenses.map((e) => ({ kind: 'expense' as const, e })),
...(category ? [] : data.otherIncome.map((i) => ({ kind: 'income' as const, i }))),
];
const amountOf = (e: EventExpense) => e.amount ?? e.computedAmount;
const costTotal = expenses.reduce((s, e) => s + amountOf(e), 0);
const paid = expenses.filter((e) => e.status === 'paid').reduce((s, e) => s + amountOf(e), 0);
const incomeTotal = data.otherIncome.reduce((s, r) => s + r.amount, 0);
const patch = async (e: EventExpense, body: ExpenseInput) => {
setBusyId(e.id);
try {
await financeApi.updateExpense(eventId, e.id, body);
onChange();
} catch (error: any) {
toast.error(error.message);
} finally {
setBusyId(null);
}
};
const remove = async (e: EventExpense) => {
if (!confirm(t('admin.finance.expenses.confirmDeleteNamed', { name: e.description }))) return;
try {
await financeApi.deleteExpense(eventId, e.id);
toast.success(t('admin.finance.expenses.deleted'));
onChange();
} catch (error: any) {
toast.error(error.message);
}
};
const removeIncome = async (row: OtherIncome) => {
if (!confirm(t('admin.finance.otherIncome.confirmDelete', { name: row.description }))) return;
try {
await financeApi.deleteIncome(eventId, row.id);
onChange();
} catch (error: any) {
toast.error(error.message);
}
};
const openNew = () => { setEditing(null); setFormOpen(true); };
const openEdit = (e: EventExpense) => { setEditing(e); setFormOpen(true); };
const openIncome = (row: OtherIncome | null) => { setIncome(row); setIncomeOpen(true); };
const CategoryTag = ({ id }: { id: string | null }) => {
const c = id ? catById.get(id) : null;
return (
<span className="inline-flex items-center gap-1 text-xs text-gray-500">
<span className="w-2 h-2 rounded-full" style={{ background: c?.color || '#D1D5DB' }} aria-hidden />
{c ? (locale === 'es' ? c.nameEs : c.nameEn) : t('admin.finance.charts.uncategorized')}
</span>
);
};
// Status is a labeled toggle with an icon, not a color-only badge.
const StatusToggle = ({ e }: { e: EventExpense }) => {
const isPaid = e.status === 'paid';
const editable = canEditRow(e);
return (
<button
disabled={!editable || busyId === e.id}
onClick={() => patch(e, isPaid ? { status: 'planned' } : { status: 'paid', expenseDate: e.expenseDate || todayIso() })}
title={editable ? t(isPaid ? 'admin.finance.expenses.markPlanned' : 'admin.finance.expenses.markPaid') : undefined}
className={clsx(
'inline-flex items-center gap-1 rounded-full pl-1.5 pr-2.5 min-h-[32px] text-xs font-medium whitespace-nowrap focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-yellow',
isPaid ? 'bg-green-100 text-green-800' : 'bg-yellow-100 text-yellow-800',
editable ? 'cursor-pointer hover:ring-2 hover:ring-offset-1 hover:ring-gray-300' : 'cursor-default',
)}
>
{isPaid ? <CheckCircleIcon className="w-4 h-4" aria-hidden /> : <ClockIcon className="w-4 h-4" aria-hidden />}
{t(`admin.finance.expenseStatus.${e.status}`)}
</button>
);
};
const IncomeTag = () => (
<Pill tone="green"><SparklesIcon className="w-3 h-3 mr-0.5" aria-hidden />{t('admin.finance.ledger.income')}</Pill>
);
const LockButton = ({ e }: { e: EventExpense }) => isAuto(e.calcType) ? (
<button
disabled={!canEditRow(e) || busyId === e.id}
onClick={() => patch(e, { isLocked: !e.isLocked })}
title={t(e.isLocked ? 'admin.finance.expenses.unlockHint' : 'admin.finance.expenses.lockHint')}
className={clsx(iconButtonClass, e.isLocked ? 'text-primary-dark' : 'text-gray-400')}
aria-label={t('admin.finance.expenses.lock')}
aria-pressed={e.isLocked}
>
{e.isLocked ? <LockClosedIcon className="w-4 h-4" /> : <LockOpenIcon className="w-4 h-4" />}
</button>
) : null;
const Meta = ({ e }: { e: EventExpense }) => (
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 mt-1">
<CategoryTag id={e.categoryId} />
{isAuto(e.calcType) && (
<Pill tone={e.isLocked ? 'gray' : 'blue'}>
{e.isLocked ? <LockClosedIcon className="w-3 h-3 mr-0.5" aria-hidden /> : <BoltIcon className="w-3 h-3 mr-0.5" aria-hidden />}
{t(e.isLocked ? 'admin.finance.expenses.locked' : 'admin.finance.expenses.auto')}
</Pill>
)}
{e.receiptUrl && (
<a href={e.receiptUrl} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-0.5 text-xs text-blue-700 hover:underline">
<PaperClipIcon className="w-3.5 h-3.5" aria-hidden />{t('admin.finance.expenses.viewReceipt')}
</a>
)}
</div>
);
const modals = (
<>
<ExpenseModal open={formOpen} onClose={() => setFormOpen(false)} eventId={eventId} data={data} expense={editing} onSaved={onChange} />
<IncomeModal open={incomeOpen} income={income} onClose={() => setIncomeOpen(false)} eventId={eventId} onSaved={onChange} />
<TemplateModal open={templatesOpen} onClose={() => setTemplatesOpen(false)} eventId={eventId} onApplied={onChange} />
</>
);
const addButtons = (
<>
<Button variant="outline" onClick={() => setTemplatesOpen(true)} className="min-h-[44px] whitespace-nowrap !px-3 md:!px-5 !text-sm md:!text-base">
<RectangleStackIcon className="w-4 h-4 mr-1.5" aria-hidden />{t('admin.finance.expenses.useTemplate')}
</Button>
{canEditIncome && (
<Button variant="outline" onClick={() => openIncome(null)} className="min-h-[44px] whitespace-nowrap !px-3 md:!px-5 !text-sm md:!text-base">
<PlusIcon className="w-4 h-4 mr-1.5" aria-hidden />{t('admin.finance.otherIncome.add')}
</Button>
)}
<Button onClick={openNew} className="min-h-[44px] col-span-2 lg:col-span-1">
<PlusIcon className="w-4 h-4 mr-1.5" aria-hidden />{t('admin.finance.expenses.add')}
</Button>
</>
);
if (data.expenses.length === 0 && data.otherIncome.length === 0) {
return (
<div className="space-y-4">
<EmptyState icon={<ShoppingBagIcon className="w-5 h-5" />} title={t('admin.finance.ledger.emptyTitle')} body={t('admin.finance.expenses.empty')}>
{canAdd && addButtons}
</EmptyState>
{modals}
</div>
);
}
const incomeAmount = (i: OtherIncome) => <Money value={i.amount} signed className={clsx('font-semibold whitespace-nowrap', !privacyMode && 'text-green-700')} />;
return (
<div className="space-y-4">
{/* Totals + actions */}
<div className="flex flex-col lg:flex-row lg:items-center gap-3">
<dl className={clsx('grid gap-2 flex-1 lg:max-w-2xl grid-cols-2', incomeTotal > 0 ? 'sm:grid-cols-4' : 'sm:grid-cols-3')}>
{[
{ label: t('admin.finance.ledger.costs'), value: costTotal, strong: true },
{ label: t('admin.finance.expenseStatus.paid'), value: paid },
{ label: t('admin.finance.expenseStatus.planned'), value: costTotal - paid },
...(incomeTotal > 0 ? [{ label: t('admin.finance.otherIncome.title'), value: incomeTotal, income: true }] : []),
].map((s) => (
<div key={s.label} className={clsx('bg-white rounded-card shadow-card px-3 py-2 min-w-0', incomeTotal === 0 && 'strong' in s && 'col-span-2 sm:col-span-1')}>
<dt className="text-xs text-gray-500 truncate">{s.label}</dt>
<dd>
<Money
value={s.value}
signed={'income' in s}
className={clsx('text-sm md:text-base whitespace-nowrap', 'strong' in s && s.strong ? 'font-bold' : 'font-semibold', 'income' in s && !privacyMode && 'text-green-700')}
/>
</dd>
</div>
))}
</dl>
{canAdd && <div className="grid grid-cols-2 lg:flex gap-2 lg:ml-auto">{addButtons}</div>}
</div>
<div className="flex flex-wrap items-center gap-2">
{usedCategories.length > 1 && (
<label className="inline-flex items-center gap-2 text-sm text-gray-600">
<FunnelIcon className="w-4 h-4" aria-hidden />
<span className="sr-only">{t('admin.finance.expenses.category')}</span>
<select
className="px-3 min-h-[40px] text-sm rounded-btn border border-secondary-light-gray bg-white focus:outline-none focus:ring-2 focus:ring-primary-yellow cursor-pointer"
value={category}
onChange={(e) => setCategory(e.target.value)}
>
<option value="">{t('admin.finance.ledger.allRows')}</option>
{usedCategories.map((c) => <option key={c.id} value={c.id}>{locale === 'es' ? c.nameEs : c.nameEn}</option>)}
{data.expenses.some((e) => !e.categoryId) && <option value="__none__">{t('admin.finance.charts.uncategorized')}</option>}
</select>
</label>
)}
{open && !canEditExpenses && canEditOwnExpenses && <p className="text-xs text-gray-500">{t('admin.finance.expenses.ownOnly')}</p>}
{!open && <p className="text-xs text-gray-500 inline-flex items-center gap-1"><LockClosedIcon className="w-3.5 h-3.5" aria-hidden />{t('admin.finance.expenses.frozen')}</p>}
</div>
{/* Desktop table: amount sits on the right, next to the row actions */}
<div className="hidden md:block bg-white rounded-card shadow-card overflow-hidden">
<table className="w-full text-sm table-fixed">
<colgroup>
<col />
<col className="w-36" />
<col className="w-44" />
<col className="w-44" />
<col className="w-32" />
</colgroup>
<thead className="text-xs text-gray-500 bg-gray-50">
<tr>
<th scope="col" className="text-left font-medium px-4 py-2.5">{t('admin.finance.expenses.description')}</th>
<th scope="col" className="text-left font-medium px-3 py-2.5">{t('admin.finance.expenses.status')}</th>
<th scope="col" className="text-left font-medium px-3 py-2.5">{t('admin.finance.expenses.paidBy')}</th>
<th scope="col" className="text-right font-medium px-3 py-2.5">{t('admin.finance.expenses.amount')}</th>
<th scope="col" className="px-3 py-2.5"><span className="sr-only">{t('common.edit')}</span></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{rows.map((row) => row.kind === 'expense' ? (
<tr key={row.e.id} className="align-top hover:bg-gray-50/60">
<td className="px-4 py-3 min-w-0">
{canEditRow(row.e) ? (
<button onClick={() => openEdit(row.e)} className="font-medium text-primary-dark hover:underline text-left cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-yellow rounded">
{row.e.description}
</button>
) : <p className="font-medium text-primary-dark">{row.e.description}</p>}
<Meta e={row.e} />
</td>
<td className="px-3 py-3"><StatusToggle e={row.e} /></td>
<td className="px-3 py-3 text-sm text-gray-600 truncate">{row.e.status === 'paid' ? partnerName(row.e.paidByPartnerId) : ''}</td>
<td className="px-3 py-3 text-right">
<Money value={amountOf(row.e)} className="font-semibold whitespace-nowrap" />
{!privacyMode && formula(row.e, t, m) && <p className="text-xs text-gray-400 mt-0.5">{formula(row.e, t, m)}</p>}
</td>
<td className="px-2 py-2">
<div className="flex justify-end items-center">
<LockButton e={row.e} />
{canEditRow(row.e) && (
<>
<button onClick={() => openEdit(row.e)} className={iconButtonClass} aria-label={t('common.edit')}><PencilIcon className="w-4 h-4" /></button>
<button onClick={() => remove(row.e)} className={clsx(iconButtonClass, 'text-red-600')} aria-label={t('common.delete')}><TrashIcon className="w-4 h-4" /></button>
</>
)}
</div>
</td>
</tr>
) : (
<tr key={row.i.id} className="align-top hover:bg-gray-50/60">
<td className="px-4 py-3 min-w-0">
{canEditIncome ? (
<button onClick={() => openIncome(row.i)} className="font-medium text-primary-dark hover:underline text-left cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-yellow rounded">
{row.i.description}
</button>
) : <p className="font-medium text-primary-dark">{row.i.description}</p>}
</td>
<td className="px-3 py-3"><IncomeTag /></td>
<td className="px-3 py-3" />
<td className="px-3 py-3 text-right">{incomeAmount(row.i)}</td>
<td className="px-2 py-2">
{canEditIncome && (
<div className="flex justify-end items-center">
<button onClick={() => openIncome(row.i)} className={iconButtonClass} aria-label={t('common.edit')}><PencilIcon className="w-4 h-4" /></button>
<button onClick={() => removeIncome(row.i)} className={clsx(iconButtonClass, 'text-red-600')} aria-label={t('common.delete')}><TrashIcon className="w-4 h-4" /></button>
</div>
)}
</td>
</tr>
))}
</tbody>
<tfoot className="bg-gray-50">
<tr>
<td className="px-4 py-2.5 font-semibold" colSpan={3}>
{t('admin.finance.ledger.costs')}
{category && <span className="font-normal text-gray-500"> · {t('admin.finance.expenses.filtered')}</span>}
</td>
<td className="px-3 py-2.5 text-right"><Money value={costTotal} className="font-bold whitespace-nowrap" /></td>
<td />
</tr>
{!category && incomeTotal > 0 && (
<tr>
<td className="px-4 pb-2.5 font-semibold" colSpan={3}>{t('admin.finance.otherIncome.title')}</td>
<td className="px-3 pb-2.5 text-right"><Money value={incomeTotal} signed className={clsx('font-bold whitespace-nowrap', !privacyMode && 'text-green-700')} /></td>
<td />
</tr>
)}
</tfoot>
</table>
</div>
{/* Mobile cards: tap the card to edit, status toggles in place */}
<ul className="md:hidden space-y-2">
{rows.map((row) => row.kind === 'expense' ? (
<li key={row.e.id} className="bg-white rounded-card shadow-card">
<div className="flex items-start gap-3 p-3">
<button
onClick={() => canEditRow(row.e) && openEdit(row.e)}
disabled={!canEditRow(row.e)}
className="flex-1 min-w-0 text-left cursor-pointer disabled:cursor-default focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-yellow rounded"
>
<p className="font-medium text-primary-dark">{row.e.description}</p>
<Meta e={row.e} />
</button>
<div className="text-right">
<Money value={amountOf(row.e)} className="font-semibold whitespace-nowrap" />
{!privacyMode && formula(row.e, t, m) && <p className="text-[11px] text-gray-400">{formula(row.e, t, m)}</p>}
</div>
</div>
<div className="flex items-center gap-2 px-3 pb-2">
<StatusToggle e={row.e} />
{row.e.status === 'paid' && <span className="text-xs text-gray-500 truncate">{partnerName(row.e.paidByPartnerId)}</span>}
<div className="ml-auto flex items-center">
<LockButton e={row.e} />
{canEditRow(row.e) && (
<button onClick={() => remove(row.e)} className={clsx(iconButtonClass, 'text-red-600')} aria-label={t('common.delete')}><TrashIcon className="w-4 h-4" /></button>
)}
</div>
</div>
</li>
) : (
<li key={row.i.id} className="bg-white rounded-card shadow-card">
<div className="flex items-start gap-3 p-3">
<button
onClick={() => canEditIncome && openIncome(row.i)}
disabled={!canEditIncome}
className="flex-1 min-w-0 text-left cursor-pointer disabled:cursor-default focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-yellow rounded"
>
<p className="font-medium text-primary-dark">{row.i.description}</p>
<div className="mt-1"><IncomeTag /></div>
</button>
<div className="flex items-center gap-1">
{incomeAmount(row.i)}
{canEditIncome && (
<button onClick={() => removeIncome(row.i)} className={clsx(iconButtonClass, 'text-red-600')} aria-label={t('common.delete')}><TrashIcon className="w-4 h-4" /></button>
)}
</div>
</div>
</li>
))}
<li className="flex justify-between px-3 py-2 text-sm font-semibold">
<span>{t('admin.finance.ledger.costs')}</span>
<Money value={costTotal} />
</li>
{!category && incomeTotal > 0 && (
<li className="flex justify-between px-3 pb-2 text-sm font-semibold">
<span>{t('admin.finance.otherIncome.title')}</span>
<Money value={incomeTotal} signed className={clsx(!privacyMode && 'text-green-700')} />
</li>
)}
</ul>
{modals}
</div>
);
}
@@ -0,0 +1,71 @@
'use client';
import clsx from 'clsx';
import { CheckCircleIcon, ExclamationTriangleIcon, InformationCircleIcon, LockClosedIcon } from '@heroicons/react/24/outline';
import { useLanguage } from '@/context/LanguageContext';
import type { EventFinance } from '@/lib/api';
import Button from '@/components/ui/Button';
import { Modal, Money } from './ui';
import { tCount, type Lifecycle } from './derive';
/** Last look before freezing the numbers: what is in, what is missing, and the result. */
export function FinalizeDialog({ open, onClose, onConfirm, busy, data, lifecycle }: {
open: boolean; onClose: () => void; onConfirm: () => void; busy: boolean; data: EventFinance; lifecycle: Lifecycle;
}) {
const { t } = useLanguage();
const s = data.summary;
const partnersTotal = s.split.partners.reduce((sum, p) => sum + p.share, 0);
const loss = s.profit < 0;
const Row = ({ tone, children, value }: { tone: 'ok' | 'warn' | 'info'; children: React.ReactNode; value?: React.ReactNode }) => {
const Icon = tone === 'ok' ? CheckCircleIcon : tone === 'warn' ? ExclamationTriangleIcon : InformationCircleIcon;
return (
<li className={clsx('flex items-start gap-2 py-2', tone === 'warn' && 'text-amber-900')}>
<Icon className={clsx('w-5 h-5 flex-shrink-0', tone === 'ok' ? 'text-green-600' : tone === 'warn' ? 'text-amber-600' : 'text-gray-400')} aria-hidden />
<span className="flex-1 min-w-0">{children}</span>
{value && <span className="font-semibold whitespace-nowrap">{value}</span>}
</li>
);
};
return (
<Modal open={open} onClose={onClose} title={t('admin.finance.finalizeDialog.title')} subtitle={t('admin.finance.finalizeDialog.subtitle')}>
<ul className="divide-y divide-gray-100 text-sm">
{!lifecycle.ended && <Row tone="warn">{t('admin.finance.finalizeDialog.notEnded')}</Row>}
{lifecycle.expenseCount === 0
? <Row tone="warn">{t('admin.finance.finalizeDialog.noExpenses')}</Row>
: <Row tone="ok" value={<Money value={s.expenses.total} />}>{tCount(t, 'admin.finance.finalizeDialog.expenses', lifecycle.expenseCount)}</Row>}
{lifecycle.plannedCount > 0
? <Row tone="warn" value={<Money value={s.expenses.planned} />}>{tCount(t, 'admin.finance.finalizeDialog.planned', lifecycle.plannedCount)}</Row>
: lifecycle.expenseCount > 0 && <Row tone="ok">{t('admin.finance.finalizeDialog.allPaid')}</Row>}
{s.revenue.otherIncome > 0 && (
<Row tone="ok" value={<Money value={s.revenue.otherIncome} signed />}>{tCount(t, 'admin.finance.finalizeDialog.income', data.otherIncome.length)}</Row>
)}
{lifecycle.partnerCount > 0
? <Row tone="info" value={<Money value={partnersTotal} />}>{tCount(t, 'admin.finance.finalizeDialog.partners', lifecycle.partnerCount)}</Row>
: <Row tone="info">{t('admin.finance.finalizeDialog.noPartners')}</Row>}
</ul>
<div className="rounded-btn bg-gray-50 border border-gray-100 p-3 space-y-1">
<div className="flex items-baseline justify-between gap-3">
<span className="text-sm text-gray-600">{t(loss ? 'admin.finance.finalizeDialog.finalLoss' : 'admin.finance.finalizeDialog.finalProfit')}</span>
<Money value={s.profit} className={clsx('text-lg font-bold', loss && 'text-red-600')} />
</div>
{lifecycle.partnerCount > 0 && s.split.organization !== null && (
<div className="flex items-baseline justify-between gap-3 text-sm">
<span className="text-gray-600">{t('admin.finance.split.organization')}</span>
<Money value={s.split.organization} className="font-semibold" />
</div>
)}
</div>
<p className="text-xs text-gray-500">{t('admin.finance.finalizeDialog.hint')}</p>
<div className="sticky bottom-0 -mx-4 -mb-4 mt-2 px-4 py-3 bg-white border-t border-gray-100 flex flex-col-reverse sm:flex-row gap-3">
<Button variant="outline" onClick={onClose} className="min-h-[44px] sm:flex-none">{t('common.cancel')}</Button>
<Button onClick={onConfirm} isLoading={busy} className="flex-1 min-h-[44px] whitespace-nowrap">
<LockClosedIcon className="w-4 h-4 mr-1.5" aria-hidden />{t('admin.finance.finalizeDialog.confirm')}
</Button>
</div>
</Modal>
);
}
@@ -0,0 +1,351 @@
'use client';
// Finance charts (Recharts). Loaded with next/dynamic from SummaryView so the
// chart library only ships when the Finance tab opens.
//
// Colors: one blue for single-series charts (category/method names sit on the
// axis, so identity never relies on color); on the waterfall blue = money in,
// red = money out and gray = totals, with signed value labels and a legend
// that lists only the kinds on screen; blue = paid / orange = planned with a
// legend. Validated with the dataviz palette checks.
import { useEffect, useState } from 'react';
import {
ResponsiveContainer, BarChart, Bar, XAxis, YAxis, Tooltip, CartesianGrid, Cell, ReferenceLine,
LineChart, Line, Legend, LabelList,
} from 'recharts';
import type { FinanceSummary, ExpenseCategory } from '@/lib/api';
import { formatNumber, formatPyg, formatPygShort } from '@/lib/money';
import { buildWaterfall, detailChartsAvailable, type WaterfallKind } from './derive';
const C = {
blue: '#2a78d6',
orange: '#eb6834',
red: '#e34948',
total: '#52514e',
grid: '#eeeeec',
axis: '#8a8984',
text: '#3f3e3b',
};
const KIND_COLOR: Record<WaterfallKind, string> = { in: C.blue, out: C.red, total: C.total };
const KIND_SYMBOL: Record<WaterfallKind, string> = { in: '+', out: '−', total: '=' };
type T = (key: string, params?: Record<string, string | number>) => string;
const axisProps = { stroke: C.axis, fontSize: 12, tickLine: false, axisLine: false } as const;
/** Bar labels: exact under 10.000 PYG, abbreviated above, always with the currency. */
const labelPyg = (v: number, locale: string) => (Math.abs(v) < 10000 ? formatPyg(v, locale) : `${formatPygShort(v, locale)} PYG`);
export function ChartCard({ title, children, subtitle, legend, className }: {
title: string; subtitle?: string; legend?: React.ReactNode; children: React.ReactNode; className?: string;
}) {
return (
<div className={`bg-white rounded-card shadow-card p-4 min-w-0 ${className || ''}`}>
<div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1">
<h3 className="text-sm font-semibold text-primary-dark">{title}</h3>
{legend}
</div>
{subtitle && <p className="text-xs text-gray-500 mt-0.5">{subtitle}</p>}
<div className="mt-3">{children}</div>
</div>
);
}
function LegendChip({ color, label, symbol }: { color: string; label: string; symbol: string }) {
return (
<span className="inline-flex items-center gap-1.5 text-xs text-gray-600">
<span className="w-3 h-3 rounded-sm inline-flex items-center justify-center text-[9px] text-white font-bold" style={{ background: color }} aria-hidden>{symbol}</span>
{label}
</span>
);
}
function PygTooltip({ active, payload, label, rows, locale }: any) {
if (!active || !payload?.length) return null;
const items: { name: string; value: number; color?: string }[] = rows ? rows(payload[0].payload) : payload.map((p: any) => ({ name: p.name, value: p.value, color: p.color }));
return (
<div className="bg-white border border-gray-200 rounded-lg shadow-sm px-3 py-2 text-xs">
{label !== undefined && <p className="font-semibold text-primary-dark mb-1">{label}</p>}
{items.map((it) => (
<p key={it.name} className="flex items-center gap-2 text-gray-600">
{it.color && <span className="w-2 h-2 rounded-full flex-shrink-0" style={{ background: it.color }} />}
<span>{it.name}</span>
<span className="ml-auto pl-3 font-medium text-primary-dark tabular-nums">{formatPyg(it.value, locale)}</span>
</p>
))}
</div>
);
}
// ==================== Waterfall ====================
/** Phones get a horizontal waterfall: six step names never fit side by side. */
function useNarrow(maxWidth = 640) {
const [narrow, setNarrow] = useState(false);
useEffect(() => {
const mq = window.matchMedia(`(max-width: ${maxWidth - 1}px)`);
const update = () => setNarrow(mq.matches);
update();
mq.addEventListener('change', update);
return () => mq.removeEventListener('change', update);
}, [maxWidth]);
return narrow;
}
/** The waterfall bars only; the card around it (with the breakdown) lives in SummaryView. */
export function WaterfallChart({ summary, locale, t, estimated }: { summary: FinanceSummary; locale: string; t: T; estimated: boolean }) {
const narrow = useNarrow();
const steps = buildWaterfall(summary);
let running = 0;
const rows = steps.map((w) => {
const isTotal = w.kind === 'total';
const start = isTotal ? 0 : running;
const end = isTotal ? w.amount : running + w.amount;
running = end;
const name = w.key === 'profit'
? t(w.amount < 0 ? 'admin.finance.kpi.loss' : 'admin.finance.kpi.profit')
: t(`admin.finance.waterfall.${w.key}`);
return {
key: w.key,
kind: w.kind,
name,
amount: w.amount,
range: [Math.min(start, end), Math.max(start, end)] as [number, number],
color: KIND_COLOR[w.kind],
signed: isTotal ? labelPyg(w.amount, locale) : `${w.amount >= 0 ? '+' : '−'}${labelPyg(Math.abs(w.amount), locale)}`,
};
});
const partnerRows = summary.split.partners.map((p) => ({ name: p.name, value: -p.share, color: C.red }));
const tooltipRows = (r: any) => (r.key === 'partners' ? partnerRows : [{ name: r.name, value: r.amount, color: r.color }]);
const kinds = (['in', 'out', 'total'] as WaterfallKind[]).filter((k) => rows.some((r) => r.kind === k));
// Value label above each bar's top edge. Bars under zero hang from the zero
// line, so their label sits just above it and never runs into the axis labels.
const renderLabel = (props: any): React.ReactElement<SVGElement> => {
const { x, y, width, height, index } = props;
const r = rows[index];
if (!r) return <g />;
const top = Math.min(y, y + height);
return (
<text x={x + width / 2} y={top - 6} textAnchor="middle" fontSize={11} fill={C.text} fontWeight={600}>
{r.signed}
</text>
);
};
const short = (v: number) => formatPygShort(v, locale);
return (
<div>
<div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1">
<h3 className="text-sm font-semibold text-primary-dark">{t('admin.finance.waterfall.title')}</h3>
<span className="flex flex-wrap gap-3">
{kinds.map((k) => (
<LegendChip key={k} color={KIND_COLOR[k]} symbol={KIND_SYMBOL[k]} label={t(`admin.finance.waterfall.legend.${k}`)} />
))}
</span>
</div>
{estimated && <p className="text-xs text-gray-500 mt-0.5">{t('admin.finance.waterfall.estimatedHint')}</p>}
<div className="mt-3">
{narrow ? (
// Step name and signed amount as a two-line axis label; bars run left/right from zero.
<ResponsiveContainer width="100%" height={rows.length * 48 + 24}>
<BarChart data={rows} layout="vertical" margin={{ top: 0, right: 12, left: 0, bottom: 0 }}>
<CartesianGrid horizontal={false} stroke={C.grid} />
<XAxis type="number" {...axisProps} tickFormatter={short} fontSize={11} />
<YAxis
type="category"
dataKey="name"
{...axisProps}
width={136}
interval={0}
tick={(props: any) => {
const r = rows[props.index];
return (
<g transform={`translate(${props.x},${props.y})`}>
<text x={-6} y={-3} textAnchor="end" fontSize={11} fill={C.text}>{r?.name}</text>
<text x={-6} y={11} textAnchor="end" fontSize={11} fontWeight={600} fill={C.text}>{r?.signed}</text>
</g>
);
}}
/>
<ReferenceLine x={0} stroke={C.axis} />
<Tooltip cursor={{ fill: 'rgba(0,0,0,0.04)' }} content={<PygTooltip locale={locale} rows={tooltipRows} />} />
<Bar dataKey="range" radius={4} barSize={20} minPointSize={3} isAnimationActive={false}>
{rows.map((r) => <Cell key={r.key} fill={r.color} />)}
</Bar>
</BarChart>
</ResponsiveContainer>
) : (
<ResponsiveContainer width="100%" height={280}>
<BarChart data={rows} margin={{ top: 24, right: 8, left: 0, bottom: 8 }}>
<CartesianGrid vertical={false} stroke={C.grid} />
<XAxis dataKey="name" {...axisProps} interval={0} tick={{ fontSize: 11, fill: C.text }} height={40} />
<YAxis {...axisProps} tickFormatter={short} width={48} />
<ReferenceLine y={0} stroke={C.axis} />
<Tooltip cursor={{ fill: 'rgba(0,0,0,0.04)' }} content={<PygTooltip locale={locale} rows={tooltipRows} />} />
<Bar dataKey="range" radius={4} maxBarSize={56} minPointSize={3} isAnimationActive={false} label={renderLabel}>
{rows.map((r) => <Cell key={r.key} fill={r.color} />)}
</Bar>
</BarChart>
</ResponsiveContainer>
)}
</div>
</div>
);
}
// ==================== Horizontal single-series bars ====================
function HorizontalBars({ data, locale }: { data: { name: string; value: number; detail?: { name: string; value: number }[] }[]; locale: string }) {
return (
<ResponsiveContainer width="100%" height={Math.max(120, data.length * 40 + 28)}>
<BarChart data={data} layout="vertical" margin={{ top: 0, right: 84, left: 0, bottom: 0 }}>
<CartesianGrid horizontal={false} stroke={C.grid} />
<XAxis type="number" {...axisProps} tickFormatter={(v: number) => formatPygShort(v, locale)} />
<YAxis type="category" dataKey="name" {...axisProps} width={104} tick={{ fontSize: 12, fill: C.text }} />
<Tooltip
cursor={{ fill: 'rgba(0,0,0,0.04)' }}
content={<PygTooltip locale={locale} rows={(r: any) => r.detail || [{ name: r.name, value: r.value, color: C.blue }]} />}
/>
<Bar dataKey="value" fill={C.blue} radius={[0, 4, 4, 0]} barSize={18} minPointSize={2} isAnimationActive={false}>
<LabelList dataKey="value" position="right" formatter={(v: number) => labelPyg(v, locale)} style={{ fontSize: 11, fill: C.text, fontWeight: 600 }} />
</Bar>
</BarChart>
</ResponsiveContainer>
);
}
function categoryName(id: string | null, categories: ExpenseCategory[], locale: string, t: T) {
const c = categories.find((x) => x.id === id);
if (!c) return t('admin.finance.charts.uncategorized');
return locale === 'es' ? c.nameEs : c.nameEn;
}
export function ExpensesByCategoryChart({ summary, categories, locale, t }: { summary: FinanceSummary; categories: ExpenseCategory[]; locale: string; t: T }) {
const data = summary.expenses.byCategory
.filter((c) => c.total > 0)
.map((c) => ({ name: categoryName(c.categoryId, categories, locale, t), value: c.total }));
return (
<ChartCard title={t('admin.finance.charts.byCategory')}>
<HorizontalBars data={data} locale={locale} />
</ChartCard>
);
}
export function RevenueByMethodChart({ summary, locale, t }: { summary: FinanceSummary; locale: string; t: T }) {
const data = summary.revenue.byMethod
.filter((m) => m.gross > 0)
.map((m) => ({
name: t(`admin.finance.methods.${m.method}`),
value: m.gross,
detail: [
{ name: t('admin.finance.waterfall.gross'), value: m.gross },
{ name: t('admin.finance.waterfall.refunds'), value: -m.refunds },
{ name: t('admin.finance.waterfall.fees'), value: -m.fees },
{ name: t('admin.finance.waterfall.net'), value: m.net },
],
}));
return (
<ChartCard title={t('admin.finance.charts.byMethod')}>
<HorizontalBars data={data} locale={locale} />
</ChartCard>
);
}
// ==================== Cumulative sales ====================
export function CumulativeSalesChart({ summary, locale, t }: { summary: FinanceSummary; locale: string; t: T }) {
const fmtDay = (d: string) => new Date(`${d}T12:00:00Z`).toLocaleDateString(locale === 'es' ? 'es-PY' : 'en-US', { day: 'numeric', month: 'short' });
const data = summary.salesTimeline.map((p) => ({ ...p, label: fmtDay(p.date) }));
const breakEven = summary.expenses.total > 0 ? summary.breakEven.tickets : null;
const maxSold = Math.max(1, ...data.map((d) => d.tickets));
// Only draw the break-even line when it fits a readable scale; otherwise a
// far-away target would flatten the sales line to the floor.
const showLine = breakEven !== null && breakEven > 0 && breakEven <= maxSold * 3;
const top = Math.ceil(Math.max(maxSold, showLine ? breakEven! : 0) * 1.15);
return (
<ChartCard
title={t('admin.finance.charts.cumulative')}
subtitle={breakEven !== null && breakEven > 0 && !showLine ? t('admin.finance.charts.breakEvenOffChart', { count: formatNumber(breakEven!, locale) }) : undefined}
>
<ResponsiveContainer width="100%" height={240}>
<LineChart data={data} margin={{ top: 16, right: 16, left: 0, bottom: 0 }}>
<CartesianGrid vertical={false} stroke={C.grid} />
<XAxis dataKey="label" {...axisProps} minTickGap={16} />
<YAxis {...axisProps} allowDecimals={false} domain={[0, top]} width={40} tickFormatter={(v: number) => formatNumber(v, locale)}
label={{ value: t('admin.finance.charts.tickets'), angle: -90, position: 'insideLeft', fontSize: 11, fill: C.axis, dy: 30 }} />
<Tooltip
content={({ active, payload }: any) => {
if (!active || !payload?.length) return null;
const p = payload[0].payload;
return (
<div className="bg-white border border-gray-200 rounded-lg shadow-sm px-3 py-2 text-xs">
<p className="font-semibold text-primary-dark mb-1">{p.label}</p>
<p className="text-gray-600">{t('admin.finance.kpi.ticketsSold', { count: formatNumber(p.tickets, locale) })}</p>
<p className="text-gray-600 tabular-nums">{formatPyg(p.revenue, locale)}</p>
</div>
);
}}
/>
{showLine && (
<ReferenceLine
y={breakEven!}
stroke={C.red}
strokeDasharray="4 4"
label={{ value: `${t('admin.finance.charts.breakEvenLine')}: ${formatNumber(breakEven!, locale)}`, position: 'insideTopRight', fontSize: 11, fill: C.text }}
/>
)}
<Line type="monotone" dataKey="tickets" stroke={C.blue} strokeWidth={2} dot={{ r: 4, fill: C.blue, stroke: '#fff', strokeWidth: 2 }} isAnimationActive={false} />
</LineChart>
</ResponsiveContainer>
</ChartCard>
);
}
// ==================== Planned vs paid ====================
export function PlannedVsPaidChart({ summary, categories, locale, t }: { summary: FinanceSummary; categories: ExpenseCategory[]; locale: string; t: T }) {
const data = summary.expenses.byCategory
.filter((c) => c.total > 0)
.map((c) => ({ name: categoryName(c.categoryId, categories, locale, t), paid: c.paid, planned: c.planned }));
const paidLabel = t('admin.finance.charts.paid');
const plannedLabel = t('admin.finance.charts.planned');
const hasPaid = summary.expenses.paid > 0;
const hasPlanned = summary.expenses.planned > 0;
return (
<ChartCard
title={t('admin.finance.charts.plannedVsActual')}
subtitle={`${paidLabel} ${formatPyg(summary.expenses.paid, locale)} · ${plannedLabel} ${formatPyg(summary.expenses.planned, locale)}`}
>
<ResponsiveContainer width="100%" height={Math.max(140, data.length * 40 + 56)}>
<BarChart data={data} layout="vertical" margin={{ top: 0, right: 16, left: 0, bottom: 0 }}>
<CartesianGrid horizontal={false} stroke={C.grid} />
<XAxis type="number" {...axisProps} tickFormatter={(v: number) => formatPygShort(v, locale)} />
<YAxis type="category" dataKey="name" {...axisProps} width={104} tick={{ fontSize: 12, fill: C.text }} />
<Tooltip cursor={{ fill: 'rgba(0,0,0,0.04)' }} content={<PygTooltip locale={locale} />} />
<Legend iconType="circle" iconSize={8} wrapperStyle={{ fontSize: 12, color: C.text }} />
{hasPaid && <Bar dataKey="paid" name={paidLabel} stackId="s" fill={C.blue} stroke="#fff" strokeWidth={2} barSize={18} isAnimationActive={false} />}
{hasPlanned && <Bar dataKey="planned" name={plannedLabel} stackId="s" fill={C.orange} stroke="#fff" strokeWidth={2} radius={[0, 4, 4, 0]} barSize={18} isAnimationActive={false} />}
</BarChart>
</ResponsiveContainer>
</ChartCard>
);
}
export default function FinanceCharts({ summary, categories, locale, t, part, estimated = false }: {
summary: FinanceSummary; categories: ExpenseCategory[]; locale: string; t: T; part: 'waterfall' | 'details'; estimated?: boolean;
}) {
if (part === 'waterfall') return <WaterfallChart summary={summary} locale={locale} t={t} estimated={estimated} />;
const show = detailChartsAvailable(summary);
if (!show.costs && !show.methods && !show.timeline) return null;
return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{show.costs && <ExpensesByCategoryChart summary={summary} categories={categories} locale={locale} t={t} />}
{show.methods && <RevenueByMethodChart summary={summary} locale={locale} t={t} />}
{show.timeline && <CumulativeSalesChart summary={summary} locale={locale} t={t} />}
{show.costs && <PlannedVsPaidChart summary={summary} categories={categories} locale={locale} t={t} />}
</div>
);
}
@@ -0,0 +1,91 @@
'use client';
import { useCallback, useEffect, useMemo, useState } from 'react';
import toast from 'react-hot-toast';
import { useLanguage } from '@/context/LanguageContext';
import { financeApi, type EventFinance } from '@/lib/api';
import { Skeleton } from '@/components/ui/Skeleton';
import { SubNav } from './ui';
import { SummaryView } from './SummaryView';
import { ExpensesView } from './ExpensesView';
import { SplitView } from './SplitView';
import { LifecycleStepper } from './LifecycleStepper';
import { deriveLifecycle } from './derive';
export type FinanceSection = 'summary' | 'expenses' | 'split';
const SECTIONS: FinanceSection[] = ['summary', 'expenses', 'split'];
/** Keep the open section in the URL (?fin=expenses) so reloads and shared links land on it. */
function readSection(): FinanceSection {
if (typeof window === 'undefined') return 'summary';
const v = new URLSearchParams(window.location.search).get('fin');
return SECTIONS.includes(v as FinanceSection) ? (v as FinanceSection) : 'summary';
}
function writeSection(section: FinanceSection) {
const url = new URL(window.location.href);
if (section === 'summary') url.searchParams.delete('fin'); else url.searchParams.set('fin', section);
window.history.replaceState(window.history.state, '', url);
}
/**
* Finance tab: P&L summary with charts, expenses and other income, and the
* partner split with payouts. The server already scopes what comes back (e.g.
* collaborators only get their own share), so this renders what it receives.
*/
export function FinanceTab({ eventId }: { eventId: string }) {
const { t } = useLanguage();
const [data, setData] = useState<EventFinance | null>(null);
const [loading, setLoading] = useState(true);
const [section, setSectionState] = useState<FinanceSection>('summary');
useEffect(() => { setSectionState(readSection()); }, []);
const setSection = useCallback((s: FinanceSection) => { setSectionState(s); writeSection(s); }, []);
const load = useCallback(async () => {
try {
setData(await financeApi.get(eventId));
} catch (error: any) {
toast.error(error.message || t('admin.finance.loadError'));
} finally {
setLoading(false);
}
}, [eventId, t]);
useEffect(() => { load(); }, [load]);
// Recomputed with every reload, so "ended X days ago" stays current.
const lifecycle = useMemo(() => (data ? deriveLifecycle(data) : null), [data]);
if (loading) {
return (
<div className="space-y-3" aria-busy="true">
<Skeleton className="h-24" />
<Skeleton className="h-11 w-full md:w-96" />
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
{Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-24" />)}
</div>
<Skeleton className="h-64" />
</div>
);
}
if (!data || !lifecycle) return <p className="text-sm text-gray-500 py-8 text-center">{t('admin.finance.loadError')}</p>;
return (
<div className="space-y-4">
<LifecycleStepper data={data} lifecycle={lifecycle} goTo={setSection} />
<SubNav<FinanceSection>
items={[
{ key: 'summary', label: t('admin.finance.subnav.summary') },
{ key: 'expenses', label: t('admin.finance.subnav.expenses'), count: data.expenses.length + data.otherIncome.length },
{ key: 'split', label: t('admin.finance.subnav.split'), count: data.partners.length },
]}
active={section}
onChange={setSection}
/>
{section === 'summary' && <SummaryView data={data} goTo={setSection} />}
{section === 'expenses' && <ExpensesView eventId={eventId} data={data} onChange={load} />}
{section === 'split' && <SplitView eventId={eventId} data={data} lifecycle={lifecycle} onChange={load} />}
</div>
);
}
@@ -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>
);
}
@@ -0,0 +1,530 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import toast from 'react-hot-toast';
import clsx from 'clsx';
import {
PlusIcon, PencilIcon, TrashIcon, DocumentArrowDownIcon, CheckCircleIcon, LockClosedIcon, LockOpenIcon, ClockIcon,
XMarkIcon, ChevronDownIcon, UserGroupIcon,
} from '@heroicons/react/24/outline';
import { useLanguage } from '@/context/LanguageContext';
import { usePrivacy } from '@/context/PrivacyContext';
import { useMoney } from '@/lib/useMoney';
import {
financeApi, SHARE_TYPES, LOSS_RULES,
type EventFinance, type EventPartner, type FinanceAuditEntry, type LossRule, type PartnerInput, type ShareType,
} from '@/lib/api';
import Button from '@/components/ui/Button';
import { downloadBlob } from '../_utils/format';
import { Modal, Field, PygInput, DateInput, Money, ChoiceCards, EmptyState, Pill, inputClass, iconButtonClass, todayIso } from './ui';
import { UserSearch } from './UserSearch';
import { bpToPercent, percentToBp } from './format';
import { FinalizeDialog } from './FinalizeDialog';
import type { Lifecycle } from './derive';
type T = (key: string, params?: Record<string, string | number>) => string;
type Fmt = ReturnType<typeof useMoney>;
function dealText(p: EventPartner, t: T, m: Fmt): string {
switch (p.shareType) {
case 'percent_profit': return t('admin.finance.deal.percentProfit', { percent: m.pct(p.percentBp) });
case 'percent_revenue': return t('admin.finance.deal.percentRevenue', { percent: m.pct(p.percentBp) });
case 'fixed': return t('admin.finance.deal.fixed', { amount: m.pyg(p.fixedAmount) });
case 'fixed_plus_percent_above_threshold':
return t('admin.finance.deal.fixedPlus', { amount: m.pyg(p.fixedAmount), percent: m.pct(p.percentBp), threshold: m.pyg(p.thresholdAmount) });
}
}
function ModalActions({ children }: { children: React.ReactNode }) {
return <div className="sticky bottom-0 -mx-4 -mb-4 mt-2 px-4 py-3 bg-white border-t border-gray-100 flex gap-3">{children}</div>;
}
// ==================== Partner form ====================
interface PartnerForm {
userId: string | null;
userLabel: string;
externalName: string;
roleLabel: string;
shareType: ShareType;
percent: string;
fixedAmount: number;
thresholdAmount: number;
lossRule: LossRule;
lossCapAmount: number;
}
const emptyPartner = (): PartnerForm => ({
userId: null, userLabel: '', externalName: '', roleLabel: '', shareType: 'percent_profit', percent: '',
fixedAmount: 0, thresholdAmount: 0, lossRule: 'none', lossCapAmount: 0,
});
function PartnerModal({ open, onClose, eventId, partner, onSaved }: {
open: boolean; onClose: () => void; eventId: string; partner: EventPartner | null; onSaved: () => void;
}) {
const { t } = useLanguage();
const [form, setForm] = useState<PartnerForm>(emptyPartner());
const [touched, setTouched] = useState(false);
const [saving, setSaving] = useState(false);
const set = <K extends keyof PartnerForm>(k: K, v: PartnerForm[K]) => setForm((f) => ({ ...f, [k]: v }));
const search = useCallback((q: string) => financeApi.searchPartnerCandidates(eventId, q), [eventId]);
useEffect(() => {
if (!open) return;
setTouched(false);
setForm(partner ? {
userId: partner.userId, userLabel: partner.userId ? partner.name : '', externalName: partner.externalName || '',
roleLabel: partner.roleLabel || '', shareType: partner.shareType, percent: partner.percentBp ? bpToPercent(partner.percentBp) : '',
fixedAmount: partner.fixedAmount, thresholdAmount: partner.thresholdAmount, lossRule: partner.lossRule, lossCapAmount: partner.lossCapAmount,
} : emptyPartner());
}, [open, partner]);
const st = form.shareType;
const hasPercent = st !== 'fixed';
const hasFixed = st === 'fixed' || st === 'fixed_plus_percent_above_threshold';
const whoError = touched && !form.userId && !form.externalName.trim() ? t('admin.finance.split.needsNameOrUser') : undefined;
const submit = async () => {
setTouched(true);
if (!form.userId && !form.externalName.trim()) return;
setSaving(true);
const payload: PartnerInput = {
userId: form.userId,
externalName: form.externalName.trim() || null,
roleLabel: form.roleLabel.trim() || null,
shareType: st,
percentBp: hasPercent ? percentToBp(form.percent) : 0,
fixedAmount: hasFixed ? form.fixedAmount : 0,
thresholdAmount: st === 'fixed_plus_percent_above_threshold' ? form.thresholdAmount : 0,
lossRule: st === 'percent_profit' ? form.lossRule : 'none',
lossCapAmount: st === 'percent_profit' && form.lossRule === 'capped' ? form.lossCapAmount : 0,
};
try {
if (partner) await financeApi.updatePartner(eventId, partner.id, payload);
else await financeApi.createPartner(eventId, payload);
toast.success(t('admin.finance.split.partnerSaved'));
onSaved();
onClose();
} catch (error: any) {
toast.error(error.message);
} finally {
setSaving(false);
}
};
return (
<Modal open={open} onClose={onClose} title={t(partner ? 'admin.finance.split.editPartner' : 'admin.finance.split.addPartner')} wide>
<Field label={t('admin.finance.split.who')} required error={whoError} hint={t('admin.finance.split.whoHint')}>
<div className="space-y-2">
{form.userId ? (
<div className="flex items-center gap-2 pl-3 rounded-btn bg-gray-50 text-sm min-h-[44px]">
<span className="flex-1 truncate">{form.userLabel}</span>
<button type="button" onClick={() => setForm((f) => ({ ...f, userId: null, userLabel: '' }))} className={iconButtonClass} aria-label={t('admin.team.remove')}>
<XMarkIcon className="w-4 h-4" />
</button>
</div>
) : (
<UserSearch
search={search}
onPick={(u) => setForm((f) => ({ ...f, userId: u.id, userLabel: `${u.name} (${u.email})` }))}
placeholder={t('admin.finance.split.searchUser')}
noResults={t('admin.team.noResults')}
/>
)}
{!form.userId && (
<input className={inputClass} value={form.externalName} maxLength={200} placeholder={t('admin.finance.split.externalName')} onChange={(e) => set('externalName', e.target.value)} />
)}
</div>
</Field>
<Field label={t('admin.finance.split.roleLabel')}>
<input className={inputClass} value={form.roleLabel} maxLength={100} placeholder={t('admin.finance.split.rolePlaceholder')} onChange={(e) => set('roleLabel', e.target.value)} />
</Field>
<Field label={t('admin.finance.split.shareType')}>
<ChoiceCards<ShareType>
name="shareType"
value={st}
onChange={(v) => set('shareType', v)}
options={SHARE_TYPES.map((s) => ({ key: s, title: t(`admin.finance.shareTypes.${s}`), description: t(`admin.finance.shareHints.${s}`) }))}
/>
</Field>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{hasFixed && (
<Field label={t('admin.finance.split.fixedAmount')}>
<PygInput value={form.fixedAmount} onChange={(n) => set('fixedAmount', n)} />
</Field>
)}
{hasPercent && (
<Field label={t('admin.finance.split.percent')}>
<div className="relative">
<input className={clsx(inputClass, 'pr-8')} inputMode="decimal" value={form.percent} placeholder="30" onChange={(e) => set('percent', e.target.value)} />
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-sm text-gray-400">%</span>
</div>
</Field>
)}
{st === 'fixed_plus_percent_above_threshold' && (
<Field label={t('admin.finance.split.threshold')}>
<PygInput value={form.thresholdAmount} onChange={(n) => set('thresholdAmount', n)} />
</Field>
)}
</div>
{st === 'percent_profit' && (
<Field label={t('admin.finance.split.lossRule')}>
<ChoiceCards<LossRule>
name="lossRule"
columns={3}
value={form.lossRule}
onChange={(v) => set('lossRule', v)}
options={LOSS_RULES.map((r) => ({ key: r, title: t(`admin.finance.lossRuleTitles.${r}`), description: t(`admin.finance.lossRules.${r}`) }))}
/>
{form.lossRule === 'capped' && (
<div className="mt-2">
<Field label={t('admin.finance.split.lossCap')}>
<PygInput value={form.lossCapAmount} onChange={(n) => set('lossCapAmount', n)} />
</Field>
</div>
)}
</Field>
)}
<ModalActions>
<Button variant="outline" onClick={onClose} className="flex-1 min-h-[44px]">{t('common.cancel')}</Button>
<Button onClick={submit} isLoading={saving} className="flex-1 min-h-[44px]">{t('common.save')}</Button>
</ModalActions>
</Modal>
);
}
// ==================== Payout ====================
function PayoutModal({ partner, amount, onClose, eventId, onSaved }: { partner: EventPartner | null; amount: number; onClose: () => void; eventId: string; onSaved: () => void }) {
const { t } = useLanguage();
const [method, setMethod] = useState('');
const [date, setDate] = useState('');
const [note, setNote] = useState('');
const [saving, setSaving] = useState(false);
useEffect(() => {
if (partner) { setMethod(''); setDate(todayIso()); setNote(''); }
}, [partner]);
const submit = async () => {
if (!partner) return;
setSaving(true);
try {
await financeApi.markPartnerPaid(eventId, partner.id, { paid: true, payoutMethod: method || null, payoutDate: date || null, payoutNote: note || null });
onSaved();
onClose();
} catch (error: any) {
toast.error(error.message);
} finally {
setSaving(false);
}
};
return (
<Modal open={!!partner} onClose={onClose} title={t('admin.finance.split.markPaid')} subtitle={partner?.name}>
<div className="rounded-btn bg-gray-50 p-3 flex justify-between items-baseline">
<span className="text-sm text-gray-600">{t(amount < 0 ? 'admin.finance.split.owes' : 'admin.finance.split.payout')}</span>
<Money value={Math.abs(amount)} className="text-lg font-bold" />
</div>
<Field label={t('admin.finance.split.payoutMethod')}>
<input className={inputClass} value={method} maxLength={50} onChange={(e) => setMethod(e.target.value)} placeholder={t('admin.finance.split.payoutMethodPlaceholder')} />
</Field>
<Field label={t('admin.finance.expenses.date')}>
<DateInput value={date} onChange={setDate} />
</Field>
<Field label={t('admin.finance.split.payoutNote')}>
<textarea className={inputClass} rows={2} value={note} maxLength={1000} onChange={(e) => setNote(e.target.value)} />
</Field>
<ModalActions>
<Button variant="outline" onClick={onClose} className="flex-1 min-h-[44px]">{t('common.cancel')}</Button>
<Button onClick={submit} isLoading={saving} className="flex-1 min-h-[44px]">{t('admin.finance.split.markPaid')}</Button>
</ModalActions>
</Modal>
);
}
// ==================== Audit log ====================
function AuditLog({ eventId }: { eventId: string }) {
const { t, locale } = useLanguage();
const [entries, setEntries] = useState<FinanceAuditEntry[] | null>(null);
const [open, setOpen] = useState(false);
useEffect(() => {
if (open && entries === null) financeApi.auditLog(eventId).then((r) => setEntries(r.entries)).catch(() => setEntries([]));
}, [open, entries, eventId]);
const describe = (e: FinanceAuditEntry) => {
const name = e.after?.description || e.before?.description || e.after?.name || e.before?.name || '';
const action = t(`admin.finance.audit.${e.action}`);
const entity = t(`admin.finance.audit.entity.${e.entityType}`);
return `${entity} · ${action.startsWith('admin.') ? e.action.replace(/_/g, ' ') : action}${name ? ` · ${name}` : ''}`;
};
return (
<div className="bg-white rounded-card shadow-card">
<button
onClick={() => setOpen(!open)}
aria-expanded={open}
className="w-full flex items-center gap-2 px-4 min-h-[48px] text-sm font-semibold cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-yellow rounded-card"
>
<ClockIcon className="w-4 h-4 text-gray-500" aria-hidden />
{t('admin.finance.split.auditLog')}
<ChevronDownIcon className={clsx('w-4 h-4 ml-auto text-gray-400 transition-transform', open && 'rotate-180')} aria-hidden />
</button>
{open && (
<div className="px-4 pb-4">
{entries === null ? <p className="text-sm text-gray-400">{t('common.loading')}</p> : entries.length === 0 ? (
<p className="text-sm text-gray-400">{t('admin.finance.split.auditEmpty')}</p>
) : (
<ul className="divide-y divide-gray-100 text-sm">
{entries.map((e) => (
<li key={e.id} className="py-2 flex flex-col sm:flex-row sm:gap-3">
<span className="text-xs text-gray-400 tabular-nums sm:w-36 flex-shrink-0">
{e.createdAt ? new Date(e.createdAt).toLocaleString(locale === 'es' ? 'es-PY' : 'en-US', { dateStyle: 'short', timeStyle: 'short' }) : ''}
</span>
<span><span className="font-medium">{e.actorName || '-'}</span> <span className="text-gray-600">{describe(e)}</span></span>
</li>
))}
</ul>
)}
</div>
)}
</div>
);
}
// ==================== View ====================
export function SplitView({ eventId, data, lifecycle, onChange }: { eventId: string; data: EventFinance; lifecycle: Lifecycle; onChange: () => void }) {
const { t, locale } = useLanguage();
const m = useMoney();
const { privacyMode } = usePrivacy();
const [editing, setEditing] = useState<EventPartner | null>(null);
const [formOpen, setFormOpen] = useState(false);
const [paying, setPaying] = useState<EventPartner | null>(null);
const [finalizing, setFinalizing] = useState(false);
const [busy, setBusy] = useState<string | null>(null);
const { viewer, summary } = data;
const open = data.status === 'open';
const manage = viewer.canManageSplit;
const lines = new Map(summary.split.partners.map((p) => [p.partnerId, p]));
const hasPartners = data.partners.length > 0;
const paidCount = data.partners.filter((p) => p.payoutStatus === 'paid').length;
const run = async (key: string, fn: () => Promise<unknown>, success?: string) => {
setBusy(key);
try {
await fn();
if (success) toast.success(success);
onChange();
return true;
} catch (error: any) {
toast.error(error.message);
return false;
} finally {
setBusy(null);
}
};
const finalize = async () => {
if (await run('finalize', () => financeApi.finalize(eventId), t('admin.finance.split.finalized'))) setFinalizing(false);
};
const unfinalize = () => {
if (!confirm(t('admin.finance.split.unfinalizeConfirm'))) return;
run('unfinalize', () => financeApi.unfinalize(eventId), t('admin.finance.split.unfinalized'));
};
const remove = (p: EventPartner) => {
if (!confirm(t('admin.finance.split.confirmRemoveNamed', { name: p.name }))) return;
run(`rm-${p.id}`, () => financeApi.deletePartner(eventId, p.id), t('admin.finance.split.partnerRemoved'));
};
const statement = async (p: EventPartner) => {
setBusy(`pdf-${p.id}`);
try {
const { blob, filename } = await financeApi.downloadStatement(eventId, p.id, locale);
downloadBlob(blob, filename);
} catch (error: any) {
toast.error(error.message);
} finally {
setBusy(null);
}
};
const fmtDate = (iso: string | null) => (iso ? new Date(iso).toLocaleDateString(locale === 'es' ? 'es-PY' : 'en-US', { dateStyle: 'medium' }) : '');
const openNew = () => { setEditing(null); setFormOpen(true); };
const loss = summary.profit < 0;
const distributable = summary.split.distributable;
// Percent-of-profit deals apply to what is left after fixed and revenue deals; say so when that differs from the profit.
const profitBase = distributable !== null && distributable !== summary.profit ? distributable : null;
const resultTone = (v: number | null) => (!open && !privacyMode && v !== null ? (v < 0 ? 'text-red-600' : 'text-green-700') : undefined);
const BreakdownRow = ({ label, detail, value, sign, strong, className }: {
label: React.ReactNode; detail?: string; value: number | null; sign?: '+' | '−'; strong?: boolean; className?: string;
}) => (
<div className={clsx('flex items-baseline justify-between gap-3 py-1', strong ? 'font-semibold' : 'text-gray-600')}>
<span className="min-w-0">
{sign && <span className="inline-block w-4 text-gray-400" aria-hidden>{sign}</span>}
{label}
{detail && <span className="block text-xs text-gray-400 pl-4">{detail}</span>}
</span>
<Money value={value} className={clsx('whitespace-nowrap', className)} />
</div>
);
return (
<div className="space-y-4">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
{/* Result, as a short calculation ending in what the organization keeps */}
<div className="bg-white rounded-card shadow-card p-4 lg:col-span-2 text-sm">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-primary-dark">{t('admin.finance.split.calculation')}</h3>
{open && <Pill>{t('admin.finance.kpi.estimated')}</Pill>}
</div>
<div className="mt-2">
<BreakdownRow label={t(loss ? 'admin.finance.kpi.loss' : 'admin.finance.kpi.profit')} value={summary.profit} strong className={resultTone(summary.profit)} />
{summary.split.partners.map((line) => {
const p = data.partners.find((x) => x.id === line.partnerId);
const rule = p ? dealText(p, t, m) : '';
const detail = p?.shareType === 'percent_profit' && profitBase !== null && !privacyMode
? t('admin.finance.split.ofDistributable', { rule, base: m.pyg(profitBase) })
: rule;
return (
<BreakdownRow
key={line.partnerId}
sign={line.share < 0 ? '+' : '−'}
label={line.name}
detail={detail}
value={Math.abs(line.share)}
/>
);
})}
{viewer.fullSplit && (
<>
<div className="border-t border-gray-200 mt-1" />
<BreakdownRow label={t('admin.finance.split.organization')} value={summary.split.organization} strong className={clsx('text-base', resultTone(summary.split.organization))} />
{!hasPartners && <p className="text-xs text-gray-400">{t('admin.finance.split.empty')}</p>}
</>
)}
</div>
{!viewer.fullSplit && <p className="text-xs text-gray-500 mt-2">{t('admin.finance.split.ownShareOnly')}</p>}
</div>
{/* Close the books: the one place to finalize */}
{(manage || !open) && (
<div className={clsx('rounded-card shadow-card p-4 flex flex-col gap-3', open ? 'bg-white' : 'bg-blue-50')}>
<div>
<h3 className="font-semibold text-primary-dark flex items-center gap-2">
{open ? <LockOpenIcon className="w-5 h-5" aria-hidden /> : <LockClosedIcon className="w-5 h-5" aria-hidden />}
{t(open ? 'admin.finance.split.closeBooks' : 'admin.finance.split.booksClosed')}
</h3>
<p className="text-sm text-gray-600 mt-1">
{open
? t('admin.finance.split.closeBooksHint')
: hasPartners
? t('admin.finance.split.payoutsProgress', { paid: paidCount, total: data.partners.length })
: t('admin.finance.split.frozenNoPartners')}
</p>
</div>
<div className="mt-auto">
{open && manage && (
<Button onClick={() => setFinalizing(true)} className="w-full min-h-[44px]">
<LockClosedIcon className="w-4 h-4 mr-1.5" aria-hidden />{t('admin.finance.split.closeBooks')}
</Button>
)}
{!open && viewer.canUnfinalize && (
<Button variant="outline" onClick={unfinalize} isLoading={busy === 'unfinalize'} className="w-full min-h-[44px]">
<LockOpenIcon className="w-4 h-4 mr-1.5" aria-hidden />{t('admin.finance.split.unfinalize')}
</Button>
)}
</div>
</div>
)}
</div>
{/* Partners */}
{!hasPartners ? (
viewer.fullSplit && (
<EmptyState icon={<UserGroupIcon className="w-5 h-5" />} title={t('admin.finance.split.noPartnersTitle')} body={t('admin.finance.split.noPartnersBody')}>
{manage && open && <Button onClick={openNew} className="min-h-[44px]"><PlusIcon className="w-4 h-4 mr-1" aria-hidden />{t('admin.finance.split.addPartner')}</Button>}
</EmptyState>
)
) : (
<div className="bg-white rounded-card shadow-card">
<div className="flex items-center justify-between gap-2 px-4 py-3 border-b border-gray-100">
<h3 className="text-sm font-semibold">{t('admin.finance.split.partners')}</h3>
{manage && open && (
<Button size="sm" onClick={openNew} className="min-h-[44px] md:min-h-0">
<PlusIcon className="w-4 h-4 mr-1" aria-hidden />{t('admin.finance.split.addPartner')}
</Button>
)}
</div>
<ul className="divide-y divide-gray-100">
{data.partners.map((p) => {
const line = lines.get(p.id);
const payout = line?.payout ?? 0;
const owes = payout < 0;
const isPaid = p.payoutStatus === 'paid';
const reimbursement = line?.reimbursement ?? 0;
return (
<li key={p.id} className="px-4 py-3 grid grid-cols-[1fr_auto] md:grid-cols-[minmax(0,1fr)_10rem_9rem_auto] gap-x-4 gap-y-2 items-center">
{/* Who and the deal */}
<div className="min-w-0">
<p className="font-medium text-primary-dark truncate">{p.name}</p>
<p className="text-xs text-gray-500">{[p.roleLabel, dealText(p, t, m)].filter(Boolean).join(' · ')}</p>
{p.shareType === 'percent_profit' && loss && (
<p className="text-xs text-gray-400">{t(`admin.finance.lossRules.${p.lossRule}`)}{p.lossRule === 'capped' ? ` (${m.pyg(p.lossCapAmount)})` : ''}</p>
)}
</div>
{/* Amount */}
<div className="text-right">
<Money value={Math.abs(payout)} className={clsx('font-semibold whitespace-nowrap', owes && !privacyMode && 'text-red-600')} />
<p className="text-xs text-gray-400">
{owes ? t('admin.finance.split.owes') : reimbursement !== 0 && !privacyMode
? t('admin.finance.split.includesReimbursement', { amount: m.pyg(reimbursement) })
: t('admin.finance.split.payout')}
</p>
</div>
{/* Status */}
<div className="col-span-2 md:col-span-1 flex items-center gap-2">
<span className={clsx('inline-flex items-center gap-1 rounded-full pl-1.5 pr-2.5 py-0.5 text-xs font-medium whitespace-nowrap', isPaid ? 'bg-green-100 text-green-800' : 'bg-yellow-100 text-yellow-800')}>
{isPaid ? <CheckCircleIcon className="w-4 h-4" aria-hidden /> : <ClockIcon className="w-4 h-4" aria-hidden />}
{isPaid ? t('admin.finance.split.paidOn', { date: fmtDate(p.payoutDate) }) : t('admin.finance.split.unpaid')}
</span>
{isPaid && (p.payoutMethod || p.payoutNote) && (
<span className="text-xs text-gray-500 truncate md:hidden">{[p.payoutMethod, p.payoutNote].filter(Boolean).join(' · ')}</span>
)}
</div>
{/* Actions */}
<div className="col-span-2 md:col-span-1 flex flex-wrap items-center gap-1 md:justify-end">
{manage && !open && !isPaid && (
<Button size="sm" onClick={() => setPaying(p)} className="min-h-[44px] md:min-h-[36px]">
<CheckCircleIcon className="w-4 h-4 mr-1" aria-hidden />{t('admin.finance.split.markPaid')}
</Button>
)}
{manage && !open && isPaid && (
<Button size="sm" variant="ghost" isLoading={busy === `unpay-${p.id}`} className="min-h-[44px] md:min-h-[36px]"
onClick={() => run(`unpay-${p.id}`, () => financeApi.markPartnerPaid(eventId, p.id, { paid: false }))}>
{t('admin.finance.split.markUnpaid')}
</Button>
)}
<button onClick={() => statement(p)} disabled={busy === `pdf-${p.id}`} className={iconButtonClass} aria-label={`${t('admin.finance.split.statement')} ${p.name}`} title={t('admin.finance.split.statement')}>
<DocumentArrowDownIcon className="w-4 h-4" />
</button>
{manage && open && (
<>
<button onClick={() => { setEditing(p); setFormOpen(true); }} className={iconButtonClass} aria-label={`${t('common.edit')} ${p.name}`}><PencilIcon className="w-4 h-4" /></button>
<button onClick={() => remove(p)} className={clsx(iconButtonClass, 'text-red-600')} aria-label={`${t('common.delete')} ${p.name}`}><TrashIcon className="w-4 h-4" /></button>
</>
)}
</div>
</li>
);
})}
</ul>
{manage && open && <p className="px-4 py-2.5 border-t border-gray-100 text-xs text-gray-500">{t('admin.finance.split.finalizeFirst')}</p>}
</div>
)}
{viewer.fullSplit && <AuditLog key={`${data.status}-${data.computedAt}`} eventId={eventId} />}
<PartnerModal open={formOpen} onClose={() => setFormOpen(false)} eventId={eventId} partner={editing} onSaved={onChange} />
<PayoutModal partner={paying} amount={paying ? lines.get(paying.id)?.payout ?? 0 : 0} onClose={() => setPaying(null)} eventId={eventId} onSaved={onChange} />
<FinalizeDialog open={finalizing} onClose={() => setFinalizing(false)} onConfirm={finalize} busy={busy === 'finalize'} data={data} lifecycle={lifecycle} />
</div>
);
}
@@ -0,0 +1,237 @@
'use client';
import dynamic from 'next/dynamic';
import clsx from 'clsx';
import {
BanknotesIcon, ShoppingBagIcon, ArrowTrendingUpIcon, ArrowTrendingDownIcon, ScaleIcon, EyeSlashIcon,
ExclamationTriangleIcon, CheckCircleIcon, ChevronDownIcon, PlusIcon,
} from '@heroicons/react/24/outline';
import { useLanguage } from '@/context/LanguageContext';
import { usePrivacy } from '@/context/PrivacyContext';
import { useMoney } from '@/lib/useMoney';
import type { EventFinance } from '@/lib/api';
import Button from '@/components/ui/Button';
import { Skeleton } from '@/components/ui/Skeleton';
import { Money, EmptyState, Pill } from './ui';
import { breakEvenState, detailChartsAvailable } from './derive';
import type { FinanceSection } from './FinanceTab';
// Recharts only loads when someone opens the Finance tab.
const FinanceCharts = dynamic(() => import('./FinanceCharts'), {
ssr: false,
loading: () => <Skeleton className="h-64" />,
});
function Kpi({ label, tag, value, detail, icon: Icon, tone, children }: {
label: string; tag?: React.ReactNode; value: React.ReactNode; detail?: React.ReactNode; icon: typeof BanknotesIcon;
tone: 'blue' | 'orange' | 'green' | 'red' | 'purple' | 'gray'; children?: React.ReactNode;
}) {
const tones = {
blue: 'bg-blue-50 text-blue-600', orange: 'bg-orange-50 text-orange-600', green: 'bg-green-50 text-green-600',
red: 'bg-red-50 text-red-600', purple: 'bg-purple-50 text-purple-600', gray: 'bg-gray-100 text-gray-600',
};
return (
<div className="bg-white rounded-card shadow-card p-3 md:p-4 min-w-0 flex flex-col">
<div className="flex items-center gap-2 min-w-0">
<span className={clsx('w-7 h-7 rounded-full flex items-center justify-center flex-shrink-0', tones[tone])}><Icon className="w-4 h-4" aria-hidden /></span>
<p className="text-xs md:text-sm text-gray-500 truncate">{label}</p>
{tag}
</div>
<div className="mt-2 text-lg md:text-2xl font-bold leading-tight break-words">{value}</div>
{detail && <div className="mt-1 text-xs text-gray-500 space-y-0.5">{detail}</div>}
{children}
</div>
);
}
/** The P&L as a compact statement under the waterfall, in the same order as its bars. */
function Breakdown({ data, finalized }: { data: EventFinance; finalized: boolean }) {
const { t, locale } = useLanguage();
const { num } = useMoney();
const s = data.summary;
const catName = (id: string | null) => {
const c = data.categories.find((x) => x.id === id);
return c ? (locale === 'es' ? c.nameEs : c.nameEn) : t('admin.finance.charts.uncategorized');
};
const resultTone = (v: number | null) => (finalized && v !== null ? (v < 0 ? 'text-red-600' : 'text-green-700') : undefined);
const Row = ({ label, value, sign, strong, muted, className }: {
label: React.ReactNode; value: number | null; sign?: '+' | '−'; strong?: boolean; muted?: boolean; className?: string;
}) => (
<div className={clsx('flex items-baseline justify-between gap-3 py-1', strong && 'font-semibold', muted && 'text-gray-500')}>
<span className="min-w-0">{sign && <span className="inline-block w-4 text-gray-400" aria-hidden>{sign}</span>}{label}</span>
<Money value={value} className={clsx('whitespace-nowrap', className)} />
</div>
);
const partners = s.split.partners;
const showSplit = partners.length > 0 && s.split.organization !== null;
return (
<div className="text-sm">
<h3 className="text-sm font-semibold text-primary-dark mb-1">{t('admin.finance.statement.title')}</h3>
<Row label={t('admin.finance.statement.ticketSales', { count: num(s.counts.ticketsSold) })} value={s.revenue.gross} />
{s.revenue.refunds > 0 && <Row sign="−" label={t('admin.finance.waterfall.refunds')} value={s.revenue.refunds} muted />}
{s.revenue.fees > 0 && <Row sign="−" label={t('admin.finance.waterfall.fees')} value={s.revenue.fees} muted />}
{s.expenses.total > 0 && (
<details className="group">
<summary className="list-none cursor-pointer flex items-baseline justify-between gap-3 py-1 text-gray-500 [&::-webkit-details-marker]:hidden">
<span className="inline-flex items-center gap-1">
<span className="inline-block w-4 text-gray-400" aria-hidden>−</span>
{t('admin.finance.waterfall.expenses')}
<ChevronDownIcon className="w-3.5 h-3.5 transition-transform group-open:rotate-180" aria-hidden />
</span>
<Money value={s.expenses.total} className="whitespace-nowrap" />
</summary>
<div className="pl-5 pb-1">
{s.expenses.byCategory.map((c) => (
<div key={c.categoryId || 'none'} className="flex justify-between gap-3 py-0.5 text-xs text-gray-500">
<span className="truncate">{catName(c.categoryId)}</span>
<Money value={c.total} className="whitespace-nowrap" />
</div>
))}
</div>
</details>
)}
{s.revenue.otherIncome > 0 && <Row sign="+" label={t('admin.finance.waterfall.otherIncome')} value={s.revenue.otherIncome} muted />}
<div className="border-t border-gray-200 mt-1" />
<Row label={t(s.profit < 0 ? 'admin.finance.kpi.loss' : 'admin.finance.kpi.profit')} value={s.profit} strong className={resultTone(s.profit)} />
{showSplit && (
<>
{partners.map((p) => (
<Row
key={p.partnerId}
sign={p.share < 0 ? '+' : '−'}
label={<>{p.name}{p.share < 0 && <span className="block text-xs text-gray-400 pl-4">{t('admin.finance.statement.carriesLoss')}</span>}</>}
value={Math.abs(p.share)}
muted
/>
))}
<div className="border-t border-gray-200 mt-1" />
<Row label={t('admin.finance.split.organization')} value={s.split.organization} strong className={resultTone(s.split.organization)} />
</>
)}
</div>
);
}
export function SummaryView({ data, goTo }: { data: EventFinance; goTo: (s: FinanceSection) => void }) {
const { t, locale } = useLanguage();
const { privacyMode } = usePrivacy();
const { num } = useMoney();
const s = data.summary;
const finalized = data.status !== 'open';
const loss = s.profit < 0;
const capacity = data.event.capacity;
const canAdd = data.status === 'open' && (data.viewer.canEditExpenses || data.viewer.canEditOwnExpenses);
const nothingYet = s.revenue.gross === 0 && s.expenses.total === 0 && s.revenue.otherIncome === 0;
if (nothingYet) {
return (
<EmptyState icon={<BanknotesIcon className="w-5 h-5" />} title={t('admin.finance.empty.title')} body={t('admin.finance.empty.body')}>
{canAdd && <Button onClick={() => goTo('expenses')}><PlusIcon className="w-4 h-4 mr-1" />{t('admin.finance.expenses.add')}</Button>}
</EmptyState>
);
}
const sold = s.counts.ticketsSold;
const be = breakEvenState(s);
let breakEvenValue: React.ReactNode;
let breakEvenDetail: React.ReactNode;
let breakEvenBar: number | null = null;
if (be.kind === 'noCosts') {
breakEvenValue = <span className="text-sm md:text-base font-semibold leading-snug text-gray-500">{t('admin.finance.breakEven.noCosts')}</span>;
breakEvenDetail = t('admin.finance.breakEven.noCostsHint');
} else if (be.kind === 'unreachable') {
breakEvenValue = <span className="text-sm md:text-base font-semibold leading-snug">{t('admin.finance.kpi.breakEvenNever')}</span>;
} else if (privacyMode) {
breakEvenValue = '••••';
} else if (be.kind === 'reached') {
breakEvenValue = (
<span className="text-sm md:text-base font-semibold leading-snug inline-flex items-start gap-1.5 text-green-700">
<CheckCircleIcon className="w-5 h-5 flex-shrink-0 mt-0.5" aria-hidden />
{be.at === 0
? t('admin.finance.breakEven.coveredWithoutTickets')
: t('admin.finance.breakEven.reached', { at: num(be.at), sold: num(be.sold) })}
</span>
);
} else {
breakEvenValue = <span className="text-sm md:text-base font-semibold leading-snug">{t('admin.finance.breakEven.needed', { count: num(be.remaining) })}</span>;
breakEvenBar = be.pct;
breakEvenDetail = be.needed > capacity
? <span className="inline-flex items-start gap-1 text-amber-700"><ExclamationTriangleIcon className="w-4 h-4 flex-shrink-0" aria-hidden />{t('admin.finance.kpi.beyondCapacity', { capacity: num(capacity) })}</span>
: t('admin.finance.kpi.breakEvenProgress', { sold: num(be.sold), total: num(be.needed) });
}
const show = detailChartsAvailable(s);
return (
<div className="space-y-4">
<div className="grid grid-cols-1 min-[420px]:grid-cols-2 lg:grid-cols-4 gap-3">
<Kpi
label={t('admin.finance.kpi.ticketRevenue')}
value={<Money value={s.revenue.sales} />}
detail={privacyMode ? undefined : (
<>
<p>{t('admin.finance.kpi.revenueDetail', { count: num(sold) })}</p>
{s.revenue.gross > 0 && (
<p>{t('admin.finance.kpi.presale')} <Money value={s.revenue.presale} /> · {t('admin.finance.kpi.door')} <Money value={s.revenue.door} /></p>
)}
</>
)}
icon={BanknotesIcon}
tone="blue"
/>
<Kpi
label={t('admin.finance.kpi.expenses')}
value={<Money value={s.expenses.total} />}
detail={privacyMode || s.expenses.total === 0 ? undefined : (
<span>{t('admin.finance.charts.paid')} <Money value={s.expenses.paid} /> · {t('admin.finance.charts.planned')} <Money value={s.expenses.planned} /></span>
)}
icon={ShoppingBagIcon}
tone="orange"
/>
<Kpi
label={t(loss ? 'admin.finance.kpi.loss' : 'admin.finance.kpi.profit')}
tag={!finalized && <Pill>{t('admin.finance.kpi.estimated')}</Pill>}
value={<Money value={s.profit} className={clsx(finalized && !privacyMode && (loss ? 'text-red-600' : 'text-green-700'))} />}
detail={t(finalized ? 'admin.finance.kpi.afterFees' : 'admin.finance.kpi.estimatedHint')}
icon={loss ? ArrowTrendingDownIcon : ArrowTrendingUpIcon}
tone={finalized ? (loss ? 'red' : 'green') : 'gray'}
/>
<Kpi label={t('admin.finance.kpi.breakEven')} value={breakEvenValue} detail={breakEvenDetail} icon={ScaleIcon} tone="purple">
{breakEvenBar !== null && (
<div className="mt-2 h-1.5 rounded-full bg-gray-100 overflow-hidden" role="progressbar" aria-valuenow={breakEvenBar} aria-valuemin={0} aria-valuemax={100}>
<div className="h-full rounded-full bg-purple-500" style={{ width: `${Math.max(breakEvenBar, 2)}%` }} />
</div>
)}
</Kpi>
</div>
{privacyMode ? (
<div className="bg-white rounded-card shadow-card p-8 text-center text-sm text-gray-500 flex flex-col items-center gap-2">
<EyeSlashIcon className="w-6 h-6 text-gray-400" aria-hidden />
{t('admin.finance.hiddenStats')}
</div>
) : (
<>
<div className="bg-white rounded-card shadow-card p-4 grid grid-cols-1 lg:grid-cols-5 gap-4 lg:gap-6">
<div className="lg:col-span-3 min-w-0">
<FinanceCharts part="waterfall" summary={s} categories={data.categories} locale={locale} t={t} estimated={!finalized} />
</div>
<div className="lg:col-span-2 min-w-0 border-t lg:border-t-0 lg:border-l border-gray-100 pt-4 lg:pt-0 lg:pl-6">
<Breakdown data={data} finalized={finalized} />
</div>
</div>
<FinanceCharts part="details" summary={s} categories={data.categories} locale={locale} t={t} />
{!show.costs && (
<p className="text-sm text-gray-400 text-center py-2">
{canAdd ? (
<button onClick={() => goTo('expenses')} className="hover:underline cursor-pointer">{t('admin.finance.charts.addExpensesHint')}</button>
) : t('admin.finance.charts.addExpensesHint')}
</p>
)}
</>
)}
</div>
);
}
@@ -0,0 +1,58 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import { inputClass } from './ui';
export interface UserHit { id: string; name: string; email: string; role: string }
/** Debounced search box with a result list; `search` decides who is eligible. */
export function UserSearch({ search, onPick, placeholder, noResults }: {
search: (q: string) => Promise<{ users: UserHit[] }>;
onPick: (u: UserHit) => void;
placeholder: string;
noResults: string;
}) {
const [q, setQ] = useState('');
const [hits, setHits] = useState<UserHit[]>([]);
const [searched, setSearched] = useState(false);
const seq = useRef(0);
useEffect(() => {
const term = q.trim();
if (term.length < 2) { setHits([]); setSearched(false); return; }
const mine = ++seq.current;
const timer = setTimeout(() => {
search(term)
.then((res) => { if (mine === seq.current) { setHits(res.users); setSearched(true); } })
.catch(() => { if (mine === seq.current) { setHits([]); setSearched(true); } });
}, 250);
return () => clearTimeout(timer);
}, [q, search]);
return (
<div>
<div className="relative">
<MagnifyingGlassIcon className="w-4 h-4 text-gray-400 absolute left-3 top-1/2 -translate-y-1/2" />
<input className={`${inputClass} pl-9`} value={q} onChange={(e) => setQ(e.target.value)} placeholder={placeholder} />
</div>
{hits.length > 0 && (
<ul className="mt-1 border border-gray-100 rounded-btn divide-y divide-gray-100 max-h-56 overflow-y-auto">
{hits.map((u) => (
<li key={u.id}>
<button
type="button"
onClick={() => { onPick(u); setQ(''); setHits([]); setSearched(false); }}
className="w-full text-left px-3 py-2 hover:bg-gray-50 min-h-[44px]"
>
<p className="text-sm font-medium truncate">{u.name}</p>
<p className="text-xs text-gray-500 truncate">{u.email} · {u.role}</p>
</button>
</li>
))}
</ul>
)}
{searched && hits.length === 0 && <p className="text-xs text-gray-400 mt-1">{noResults}</p>}
</div>
);
}
@@ -0,0 +1,20 @@
// Client-side preview of an expense amount, mirroring
// backend/src/lib/finance/calculate.ts expenseAmount(). Only used to show
// "this will be X PYG right now" while typing; the server stays authoritative.
import type { CalcType } from '@/lib/api';
export interface PreviewContext { ticketsSold: number; checkedIn: number; sales: number }
export function previewExpense(
e: { calcType: CalcType; quantity: number; unitAmount: number; percentBp: number; minimumAmount: number },
ctx: PreviewContext,
): { amount: number; count: number } {
switch (e.calcType) {
case 'per_ticket_sold': return { amount: e.unitAmount * ctx.ticketsSold, count: ctx.ticketsSold };
case 'per_checked_in': return { amount: e.unitAmount * ctx.checkedIn, count: ctx.checkedIn };
case 'percent_of_revenue': return { amount: Math.round((Math.max(0, ctx.sales) * e.percentBp) / 10000), count: 0 };
case 'minimum_spend': return { amount: Math.max(e.minimumAmount, e.unitAmount * ctx.checkedIn), count: ctx.checkedIn };
default: return { amount: e.unitAmount * e.quantity, count: e.quantity };
}
}
@@ -0,0 +1,246 @@
import { describe, it, expect } from 'vitest';
import {
calculateEventFinance, expenseAmount, CALC_TYPES,
type FinanceExpense, type FinanceInput, type FinancePartner, type FinancePayment,
} from '../../../../../../../backend/src/lib/finance/calculate';
import type { EventExpense, EventFinance, EventPartner, FinanceSummary } from '@/lib/api';
import { breakEvenState, buildWaterfall, deriveLifecycle, detailChartsAvailable, suggestCategory, tCount } from './derive';
import { previewExpense } from './calc';
// The frontend derivations run on the backend's real calculation, so these
// tests also check the UI reads the same numbers the server computes.
let seq = 0;
const pay = (amount: number, over: Partial<FinancePayment> = {}): FinancePayment => ({
id: `p${++seq}`, amount, provider: 'tpago', source: 'presale', status: 'paid', paidAt: '2026-09-01T12:00:00Z', ...over,
});
const expense = (over: Partial<FinanceExpense> = {}): FinanceExpense => ({
id: `e${++seq}`, description: 'x', categoryId: null, calcType: 'fixed', quantity: 1, unitAmount: 0, percentBp: 0,
minimumAmount: 0, computedAmount: 0, isLocked: false, status: 'planned', paidByPartnerId: null, ...over,
});
const partner = (over: Partial<FinancePartner> = {}): FinancePartner => ({
id: `pt${++seq}`, name: 'Partner', shareType: 'percent_profit', percentBp: 5000, fixedAmount: 0, thresholdAmount: 0,
lossRule: 'none', lossCapAmount: 0, ...over,
});
const input = (over: Partial<FinanceInput> = {}): FinanceInput => ({
ticketPrice: 100000, ticketsSold: 0, checkedIn: 0, payments: [], expenses: [], otherIncome: [], fees: [], partners: [], ...over,
});
const summaryOf = (i: FinanceInput) => calculateEventFinance(i) as unknown as FinanceSummary;
// ---- Scenarios (same as the backend's pinned ones)
const noExpenses = summaryOf(input({
ticketPrice: 50000, ticketsSold: 4, checkedIn: 4,
payments: [pay(50000), pay(50000), pay(50000), pay(50000)],
fees: [{ method: 'tpago', percentBp: 290, fixedAmount: 0 }],
}));
const loss = summaryOf(input({
ticketPrice: 50000, ticketsSold: 4, checkedIn: 4,
payments: [pay(50000), pay(50000), pay(50000), pay(50000, { provider: 'cash', source: 'door' })],
fees: [{ method: 'tpago', percentBp: 290, fixedAmount: 0 }],
expenses: [
expense({ unitAmount: 300000, status: 'paid' }),
expense({ calcType: 'per_checked_in', unitAmount: 10000, status: 'planned' }),
],
}));
const ana = partner({ name: 'Ana', shareType: 'percent_profit', percentBp: 3000 });
const withPartners = summaryOf(input({
ticketPrice: 100000, ticketsSold: 10, checkedIn: 9,
payments: Array.from({ length: 10 }, () => pay(100000, { provider: 'bank_transfer' })),
otherIncome: [{ id: 'i1', description: 'Sponsor', amount: 50000 }],
expenses: [
expense({ unitAmount: 200000, status: 'paid' }),
expense({ unitAmount: 150000, status: 'paid', paidByPartnerId: ana.id }),
],
partners: [ana, partner({ name: 'Venue Co', shareType: 'fixed', fixedAmount: 100000 })],
}));
describe('buildWaterfall', () => {
const sumTo = (steps: ReturnType<typeof buildWaterfall>) =>
steps.slice(1, -1).reduce((s, w) => s + w.amount, steps[0].amount);
it('no expenses: gross, fees, profit; nothing else shown', () => {
const steps = buildWaterfall(noExpenses);
expect(steps.map((s) => [s.key, s.amount])).toEqual([['gross', 200000], ['fees', -5800], ['profit', 194200]]);
expect(sumTo(steps)).toBe(steps[steps.length - 1].amount);
});
it('a loss: ends at the (negative) profit', () => {
const steps = buildWaterfall(loss);
expect(steps.map((s) => s.key)).toEqual(['gross', 'fees', 'expenses', 'profit']);
expect(steps[steps.length - 1].amount).toBe(-144350);
expect(sumTo(steps)).toBe(-144350);
});
it('partners: other income in, partner payouts out, ends at what the organization keeps', () => {
const steps = buildWaterfall(withPartners);
expect(steps.map((s) => [s.key, s.kind, s.amount])).toEqual([
['gross', 'total', 1000000],
['expenses', 'out', -350000],
['otherIncome', 'in', 50000],
['partners', 'out', -280000],
['organization', 'total', 420000],
]);
expect(sumTo(steps)).toBe(420000);
});
it('viewers without the full split end at the profit', () => {
const scoped = { ...withPartners, split: { ...withPartners.split, organization: null, distributable: null } };
const steps = buildWaterfall(scoped);
expect(steps[steps.length - 1]).toEqual({ key: 'profit', kind: 'total', amount: 700000 });
expect(steps.some((s) => s.key === 'partners')).toBe(false);
});
});
describe('breakEvenState', () => {
it('no costs: neutral, no progress', () => {
expect(breakEvenState(noExpenses)).toEqual({ kind: 'noCosts' });
});
it('not covered: tickets still needed and progress sold / needed', () => {
expect(breakEvenState(loss)).toEqual({ kind: 'needed', remaining: 4, sold: 4, needed: 8, pct: 50 });
});
it('covered: reached at N of M sold', () => {
expect(breakEvenState(withPartners)).toEqual({ kind: 'reached', at: 3, sold: 10 });
});
it('unreachable at this price', () => {
const s = summaryOf(input({ ticketPrice: 0, expenses: [expense({ unitAmount: 1000 })] }));
expect(breakEvenState(s)).toEqual({ kind: 'unreachable' });
});
});
describe('detailChartsAvailable', () => {
it('hides cost charts without expenses and the timeline with fewer than 2 days', () => {
expect(detailChartsAvailable(noExpenses)).toEqual({ costs: false, methods: true, timeline: false });
});
it('shows the timeline once there are two sales days', () => {
const s = summaryOf(input({ payments: [pay(1, { paidAt: '2026-09-01T10:00:00Z' }), pay(1, { paidAt: '2026-09-02T10:00:00Z' })] }));
expect(detailChartsAvailable(s).timeline).toBe(true);
});
});
// ---- Lifecycle
const NOW = Date.parse('2026-09-28T12:00:00Z');
const exp = (status: 'planned' | 'paid') => ({ status } as EventExpense);
const prt = (payoutStatus: 'pending' | 'paid') => ({ payoutStatus } as EventPartner);
const fin = (over: Partial<Pick<EventFinance, 'status' | 'expenses' | 'partners'>> & { start?: string; end?: string | null } = {}) => ({
status: over.status ?? 'open',
expenses: over.expenses ?? [],
partners: over.partners ?? [],
event: { startDatetime: over.start ?? '2026-10-10T20:00:00Z', endDatetime: over.end ?? null },
} as Parameters<typeof deriveLifecycle>[0]);
describe('deriveLifecycle', () => {
it('upcoming with no costs: selling', () => {
const l = deriveLifecycle(fin(), NOW);
expect(l.step).toBe('selling');
expect(l.ended).toBe(false);
expect(l.todo.map((t) => t.key)).toEqual(['noCosts', 'noPartners', 'notEnded']);
});
it('upcoming with costs: adding costs', () => {
expect(deriveLifecycle(fin({ expenses: [exp('paid')] }), NOW).step).toBe('costs');
});
it('ended with planned costs: adding costs, warns about them, counts days since the end', () => {
const l = deriveLifecycle(fin({ start: '2026-09-20T20:00:00Z', end: '2026-09-20T23:00:00Z', expenses: [exp('paid'), exp('planned'), exp('planned')] }), NOW);
expect(l).toMatchObject({ step: 'costs', ended: true, daysSinceEnd: 7, plannedCount: 2 });
expect(l.todo).toContainEqual({ key: 'planned', count: 2, tone: 'warn' });
});
it('ended with no costs: still adding costs, and that is a warning', () => {
const l = deriveLifecycle(fin({ start: '2026-09-27T20:00:00Z' }), NOW);
expect(l.step).toBe('costs');
expect(l.todo).toContainEqual({ key: 'noCosts', tone: 'warn' });
});
it('ended with every cost paid: ready to close', () => {
const l = deriveLifecycle(fin({ start: '2026-09-27T20:00:00Z', expenses: [exp('paid')], partners: [prt('pending')] }), NOW);
expect(l.step).toBe('ready');
expect(l.todo).toEqual([]);
});
it('uses the end time, not the start, to decide the event is over', () => {
const l = deriveLifecycle(fin({ start: '2026-09-28T10:00:00Z', end: '2026-09-28T14:00:00Z' }), NOW);
expect(l.ended).toBe(false);
});
it('finalized: lists partner payouts still to record', () => {
const l = deriveLifecycle(fin({ status: 'finalized', start: '2026-09-01T20:00:00Z', expenses: [exp('planned')], partners: [prt('paid'), prt('pending')] }), NOW);
expect(l.step).toBe('finalized');
expect(l.todo).toEqual([{ key: 'payoutsPending', count: 1, tone: 'info' }]);
});
it('does not nag collaborators about partners they cannot manage', () => {
const l = deriveLifecycle({ ...fin(), viewer: { fullSplit: false } } as any, NOW);
expect(l.todo.map((t) => t.key)).not.toContain('noPartners');
});
});
// ---- Category suggestion
const categories = [
{ id: 'venue', nameEn: 'Venue', nameEs: 'Lugar', archived: false },
{ id: 'host', nameEn: 'Instructor / host', nameEs: 'Instructor / anfitrión', archived: false },
{ id: 'food', nameEn: 'Food & drinks', nameEs: 'Comida y bebidas', archived: false },
{ id: 'staff', nameEn: 'Staff', nameEs: 'Personal', archived: false },
{ id: 'mkt', nameEn: 'Marketing', nameEs: 'Marketing', archived: false },
{ id: 'sup', nameEn: 'Supplies', nameEs: 'Materiales', archived: false },
];
describe('suggestCategory', () => {
it.each([
['Studio rental', 'venue'],
['Alquiler del salón', 'venue'],
['Teacher fee', 'host'],
['Pago a la profe', 'host'],
['Snacks and drinks', 'food'],
['Cerveza', 'food'],
['Security', 'staff'],
['Instagram ads', 'mkt'],
['Name badges', 'sup'],
])('%s -> %s', (description, id) => {
expect(suggestCategory(description, categories)).toBe(id);
});
it('returns null when nothing matches', () => {
expect(suggestCategory('Miscellaneous', categories)).toBeNull();
expect(suggestCategory('', categories)).toBeNull();
});
it('skips archived categories', () => {
expect(suggestCategory('venue', [{ ...categories[0], archived: true }])).toBeNull();
});
it('matches whole words only', () => {
// "ad" is a marketing keyword, but "headphones" should not match it.
expect(suggestCategory('headphones', categories)).toBeNull();
});
});
describe('tCount', () => {
const t = (key: string, p?: Record<string, string | number>) => `${key}:${p?.count}`;
it('picks _one for 1 and _other otherwise', () => {
expect(tCount(t, 'k', 1)).toBe('k_one:1');
expect(tCount(t, 'k', 0)).toBe('k_other:0');
expect(tCount(t, 'k', 3)).toBe('k_other:3');
});
});
// ---- Modal preview mirrors the server
describe('previewExpense matches the backend expenseAmount', () => {
const ctx = { ticketsSold: 4, checkedIn: 3, sales: 200000 };
it.each(CALC_TYPES)('%s', (calcType) => {
const e = { calcType, quantity: 1, unitAmount: 50000, percentBp: 1250, minimumAmount: 300000 };
const server = expenseAmount({ ...e, computedAmount: 0, isLocked: false }, ctx);
expect(previewExpense(e, ctx).amount).toBe(server.amount);
});
it('minimum spend switches to per-guest once it passes the minimum', () => {
const e = { calcType: 'minimum_spend' as const, quantity: 1, unitAmount: 50000, percentBp: 0, minimumAmount: 300000 };
const big = { ticketsSold: 10, checkedIn: 8, sales: 0 };
expect(previewExpense(e, big).amount).toBe(expenseAmount({ ...e, computedAmount: 0, isLocked: false }, big).amount);
expect(previewExpense(e, big).amount).toBe(400000);
});
});
@@ -0,0 +1,189 @@
// Pure derivations for the Finance tab: where the event is in its money
// lifecycle, what is left to do, how break-even reads, and a category guess
// for a new expense. No React here so it can be unit tested.
import type { EventFinance, ExpenseCategory, FinanceSummary } from '@/lib/api';
export type LifecycleStep = 'selling' | 'costs' | 'ready' | 'finalized';
export const LIFECYCLE_STEPS: LifecycleStep[] = ['selling', 'costs', 'ready', 'finalized'];
export interface TodoItem {
/** i18n key under admin.finance.lifecycle.todo (pluralized with _one / _other when count is set). */
key: 'noCosts' | 'planned' | 'noPartners' | 'payoutsPending' | 'notEnded';
count?: number;
tone: 'warn' | 'info';
}
export interface Lifecycle {
step: LifecycleStep;
ended: boolean;
/** Whole days since the event ended; null while it has not ended. */
daysSinceEnd: number | null;
expenseCount: number;
plannedCount: number;
partnerCount: number;
todo: TodoItem[];
}
type LifecycleInput = Pick<EventFinance, 'status' | 'expenses' | 'partners'> & {
event: { startDatetime: string; endDatetime?: string | null };
viewer?: { fullSplit: boolean };
};
const DAY = 86_400_000;
/** The moment an event counts as over: its end time, or its start when no end is set. */
export function eventEndTime(event: { startDatetime: string; endDatetime?: string | null }): number {
return new Date(event.endDatetime || event.startDatetime).getTime();
}
export function deriveLifecycle(data: LifecycleInput, now: number = Date.now()): Lifecycle {
const end = eventEndTime(data.event);
const ended = Number.isFinite(end) && now >= end;
const daysSinceEnd = ended ? Math.floor((now - end) / DAY) : null;
const expenseCount = data.expenses.length;
const plannedCount = data.expenses.filter((e) => e.status === 'planned').length;
const partnerCount = data.partners.length;
const fullSplit = data.viewer?.fullSplit ?? true;
const todo: TodoItem[] = [];
let step: LifecycleStep;
if (data.status !== 'open') {
step = 'finalized';
const unpaid = data.partners.filter((p) => p.payoutStatus !== 'paid').length;
if (unpaid > 0) todo.push({ key: 'payoutsPending', count: unpaid, tone: 'info' });
} else {
if (!ended) step = expenseCount === 0 ? 'selling' : 'costs';
else step = expenseCount > 0 && plannedCount === 0 ? 'ready' : 'costs';
if (expenseCount === 0) todo.push({ key: 'noCosts', tone: ended ? 'warn' : 'info' });
if (plannedCount > 0) todo.push({ key: 'planned', count: plannedCount, tone: 'warn' });
if (partnerCount === 0 && fullSplit) todo.push({ key: 'noPartners', tone: 'info' });
if (!ended) todo.push({ key: 'notEnded', tone: 'info' });
}
return { step, ended, daysSinceEnd, expenseCount, plannedCount, partnerCount, todo };
}
// ==================== Break-even ====================
export type BreakEvenState =
| { kind: 'noCosts' }
| { kind: 'unreachable' }
| { kind: 'reached'; at: number; sold: number }
| { kind: 'needed'; remaining: number; sold: number; needed: number; pct: number };
export function breakEvenState(summary: Pick<FinanceSummary, 'breakEven' | 'expenses' | 'counts'>): BreakEvenState {
const be = summary.breakEven;
const sold = summary.counts.ticketsSold;
if (summary.expenses.total === 0) return { kind: 'noCosts' };
if (be.tickets === null) return { kind: 'unreachable' };
if (sold >= be.tickets) return { kind: 'reached', at: be.tickets, sold };
const needed = be.tickets;
return {
kind: 'needed',
remaining: be.remaining ?? needed - sold,
sold,
needed,
pct: needed > 0 ? Math.min(100, Math.round((sold / needed) * 100)) : 100,
};
}
// ==================== Category suggestion ====================
/** Words in an expense description, and the words that identify the matching category by name. */
const CATEGORY_HINTS: { words: string[]; category: string[] }[] = [
{
words: ['venue', 'rent', 'rental', 'room', 'hall', 'space', 'studio', 'alquiler', 'local', 'sala', 'lugar', 'espacio', 'salon', 'estudio'],
category: ['venue', 'lugar'],
},
{
words: ['instructor', 'teacher', 'host', 'tutor', 'coach', 'facilitator', 'profesor', 'profesora', 'profe', 'anfitrion', 'anfitriona', 'moderador', 'moderadora'],
category: ['instructor', 'host', 'anfitrion'],
},
{
words: ['snack', 'snacks', 'drink', 'drinks', 'food', 'beer', 'coffee', 'water', 'pizza', 'catering', 'wine', 'soda',
'comida', 'bebida', 'bebidas', 'cerveza', 'cafe', 'agua', 'merienda', 'bocaditos', 'vino', 'gaseosa'],
category: ['food', 'drink', 'comida', 'bebida'],
},
{
words: ['staff', 'helper', 'volunteer', 'security', 'cleaning', 'waiter', 'personal', 'limpieza', 'seguridad', 'ayudante', 'mozo', 'voluntario'],
category: ['staff', 'personal'],
},
{
words: ['ads', 'ad', 'marketing', 'instagram', 'facebook', 'flyer', 'flyers', 'promo', 'poster', 'publicidad', 'anuncio', 'anuncios', 'volante', 'volantes'],
category: ['marketing'],
},
{
words: ['supplies', 'material', 'materials', 'print', 'printing', 'paper', 'markers', 'badges', 'stickers',
'materiales', 'impresion', 'impresiones', 'papel', 'marcadores', 'etiquetas'],
category: ['suppl', 'material'],
},
];
const normalize = (s: string) => s.toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '');
/** Best-guess category id for a description, or null when nothing matches. */
export function suggestCategory(description: string, categories: Pick<ExpenseCategory, 'id' | 'nameEn' | 'nameEs' | 'archived'>[]): string | null {
const tokens = new Set(normalize(description).split(/[^a-z0-9]+/).filter(Boolean));
if (tokens.size === 0) return null;
for (const hint of CATEGORY_HINTS) {
if (!hint.words.some((w) => tokens.has(w))) continue;
const match = categories.find((c) => !c.archived && hint.category.some((k) => normalize(`${c.nameEn} ${c.nameEs}`).includes(k)));
if (match) return match.id;
}
return null;
}
// ==================== Copy helpers ====================
type T = (key: string, params?: Record<string, string | number>) => string;
/** Picks `${key}_one` or `${key}_other` so counts read naturally in both languages. */
export function tCount(t: T, key: string, count: number, params: Record<string, string | number> = {}): string {
return t(`${key}_${count === 1 ? 'one' : 'other'}`, { count, ...params });
}
// ==================== Waterfall ====================
export type WaterfallKind = 'total' | 'in' | 'out';
export interface WaterfallStep {
key: 'gross' | 'refunds' | 'fees' | 'expenses' | 'otherIncome' | 'partners' | 'profit' | 'organization';
kind: WaterfallKind;
/** Signed: positive adds to the running total, negative takes from it. Totals are the level itself. */
amount: number;
}
/**
* Gross revenue -> fees -> expenses -> other income -> partner payouts ->
* what the organization keeps. Zero steps are left out; the steps always add
* up to the final bar. Viewers without the full split end at the profit.
*/
export function buildWaterfall(summary: Pick<FinanceSummary, 'revenue' | 'expenses' | 'profit' | 'split'>): WaterfallStep[] {
const r = summary.revenue;
const steps: WaterfallStep[] = [{ key: 'gross', kind: 'total', amount: r.gross }];
const add = (key: WaterfallStep['key'], amount: number) => {
if (amount !== 0) steps.push({ key, kind: amount > 0 ? 'in' : 'out', amount });
};
add('refunds', -r.refunds);
add('fees', -r.fees);
add('expenses', -summary.expenses.total);
add('otherIncome', r.otherIncome);
const withSplit = summary.split.organization !== null && summary.split.partners.length > 0;
if (withSplit) {
add('partners', -summary.split.partners.reduce((sum, p) => sum + p.share, 0));
steps.push({ key: 'organization', kind: 'total', amount: summary.split.organization! });
} else {
steps.push({ key: 'profit', kind: 'total', amount: summary.profit });
}
return steps;
}
/** Which detail charts have something to show (cumulative sales needs at least two days). */
export function detailChartsAvailable(summary: Pick<FinanceSummary, 'expenses' | 'revenue' | 'salesTimeline'>) {
return {
costs: summary.expenses.byCategory.some((c) => c.total > 0),
methods: summary.revenue.byMethod.some((m) => m.gross > 0),
timeline: summary.salesTimeline.length >= 2,
};
}
@@ -0,0 +1,20 @@
// Input parsing for finance forms. Display formatting lives in @/lib/money.
export { formatNumber, formatPyg, formatPygShort, formatPercent } from '@/lib/money';
/** Basis points to a percent string for inputs (290 -> "2.9"). */
export function bpToPercent(bp: number): string {
return String(Math.round(bp) / 100);
}
/** Percent input ("2,9" or "2.9") to basis points. */
export function percentToBp(value: string): number {
const n = parseFloat(value.replace(',', '.'));
return Number.isFinite(n) ? Math.max(0, Math.min(10000, Math.round(n * 100))) : 0;
}
/** Whole-PYG input ("1.500.000", "1,500,000" or "1500000") to an integer. */
export function parsePygInput(value: string): number {
const digits = value.replace(/[^\d]/g, '');
return digits ? Math.min(2_000_000_000, parseInt(digits, 10)) : 0;
}
@@ -0,0 +1,275 @@
'use client';
// Small shared pieces for the Finance / Team tabs, following the event page's
// existing modal (bottom sheet on mobile, centered card on desktop), compact
// input and badge styles.
import { ReactNode, useEffect, useRef, useState } from 'react';
import clsx from 'clsx';
import { XMarkIcon, CalendarDaysIcon } from '@heroicons/react/24/outline';
import Card from '@/components/ui/Card';
import { usePrivacy } from '@/context/PrivacyContext';
import { useLanguage } from '@/context/LanguageContext';
import { PRIVACY_MASK } from '@/components/admin/SensitiveValue';
import type { FinanceStatus } from '@/lib/api';
import { formatNumber } from '@/lib/money';
import { useMoney } from '@/lib/useMoney';
import { parsePygInput } from './format';
export const inputClass =
'w-full px-3 py-2.5 min-h-[44px] text-base md:text-sm rounded-btn border border-secondary-light-gray bg-white focus:outline-none focus:ring-2 focus:ring-primary-yellow disabled:bg-gray-50 disabled:text-gray-400';
/** Icon-only buttons: 44px touch target on mobile, visible keyboard focus. */
export const iconButtonClass =
'inline-flex items-center justify-center min-h-[44px] min-w-[44px] md:min-h-[36px] md:min-w-[36px] rounded-btn cursor-pointer hover:bg-gray-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-yellow disabled:opacity-40 disabled:cursor-default';
export function Modal({ open, title, subtitle, onClose, children, wide }: {
open: boolean; title: string; subtitle?: string; onClose: () => void; children: ReactNode; wide?: boolean;
}) {
const { t } = useLanguage();
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [open, onClose]);
if (!open) return null;
return (
<div className="fixed inset-0 bg-black/50 z-50 flex items-end md:items-center justify-center p-0 md:p-4" onClick={onClose}>
<Card
className={clsx('w-full rounded-t-2xl md:rounded-card max-h-[92vh] overflow-y-auto', wide ? 'md:max-w-2xl' : 'md:max-w-md')}
onClick={(e: React.MouseEvent) => e.stopPropagation()}
>
<div className="flex items-center justify-between p-4 border-b border-secondary-light-gray sticky top-0 bg-white z-10">
<div className="min-w-0">
<h2 className="text-base font-bold truncate">{title}</h2>
{subtitle && <p className="text-xs text-gray-500 truncate">{subtitle}</p>}
</div>
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-btn min-h-[44px] min-w-[44px] flex items-center justify-center" aria-label={t('admin.finance.close')}>
<XMarkIcon className="w-5 h-5" />
</button>
</div>
<div className="p-4 space-y-3">{children}</div>
</Card>
</div>
);
}
export function Field({ label, hint, required, error, children }: { label: string; hint?: string; required?: boolean; error?: string; children: ReactNode }) {
return (
<div>
<label className="block text-sm font-medium mb-1 text-primary-dark">
{label}{required && <span className="text-red-600 ml-0.5" aria-hidden>*</span>}
</label>
{children}
{error ? <p className="text-xs text-red-600 mt-1">{error}</p> : hint && <p className="text-xs text-gray-500 mt-1">{hint}</p>}
</div>
);
}
/** Radio group rendered as selectable cards: each option explains itself. */
export function ChoiceCards<K extends string>({ options, value, onChange, columns = 2, name }: {
options: { key: K; title: string; description?: string }[];
value: K;
onChange: (k: K) => void;
columns?: 2 | 3;
name: string;
}) {
return (
<div role="radiogroup" className={clsx('grid grid-cols-1 gap-2', columns === 3 ? 'sm:grid-cols-3' : 'sm:grid-cols-2')}>
{options.map((o) => {
const active = o.key === value;
return (
<button
key={o.key}
type="button"
role="radio"
aria-checked={active}
name={name}
onClick={() => onChange(o.key)}
className={clsx(
'text-left rounded-btn border px-3 py-2.5 min-h-[44px] cursor-pointer transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-yellow',
active ? 'border-primary-yellow bg-yellow-50' : 'border-secondary-light-gray hover:border-gray-300 bg-white',
)}
>
<span className="flex items-center gap-2 text-sm font-medium text-primary-dark">
<span className={clsx('w-4 h-4 rounded-full border-2 flex-shrink-0', active ? 'border-primary-yellow bg-primary-yellow shadow-[inset_0_0_0_2px_white]' : 'border-gray-300')} />
{o.title}
</span>
{o.description && <span className="block text-xs text-gray-500 mt-0.5 pl-6">{o.description}</span>}
</button>
);
})}
</div>
);
}
/** Two or three mutually exclusive options as one control (e.g. Planned / Paid). */
export function Segmented<K extends string>({ options, value, onChange, label }: {
options: { key: K; label: string; icon?: ReactNode }[];
value: K;
onChange: (k: K) => void;
label: string;
}) {
return (
<div role="radiogroup" aria-label={label} className="grid grid-flow-col auto-cols-fr gap-1 p-1 bg-gray-100 rounded-btn">
{options.map((o) => (
<button
key={o.key}
type="button"
role="radio"
aria-checked={o.key === value}
onClick={() => onChange(o.key)}
className={clsx(
'inline-flex items-center justify-center gap-1.5 rounded-md text-sm font-medium min-h-[40px] cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-yellow',
o.key === value ? 'bg-white shadow-sm text-primary-dark' : 'text-gray-500 hover:text-gray-700',
)}
>
{o.icon}{o.label}
</button>
))}
</div>
);
}
/** Explains why a list is empty and offers the next step. */
export function EmptyState({ icon, title, body, children }: { icon?: ReactNode; title: string; body?: string; children?: ReactNode }) {
return (
<div className="bg-white rounded-card shadow-card px-6 py-10 text-center flex flex-col items-center gap-2">
{icon && <div className="w-11 h-11 rounded-full bg-yellow-50 text-yellow-700 flex items-center justify-center">{icon}</div>}
<p className="font-semibold text-primary-dark">{title}</p>
{body && <p className="text-sm text-gray-500 max-w-md">{body}</p>}
{children && <div className="flex flex-wrap justify-center gap-2 mt-2">{children}</div>}
</div>
);
}
/** Whole-PYG input that shows the UI language's thousands separator while typing. */
export function PygInput({ value, onChange, disabled, id }: { value: number; onChange: (n: number) => void; disabled?: boolean; id?: string }) {
const { locale } = useLanguage();
const [text, setText] = useState(value ? formatNumber(value, locale) : '');
useEffect(() => { setText(value ? formatNumber(value, locale) : ''); }, [value, locale]);
return (
<div className="relative">
<input
id={id}
inputMode="numeric"
className={clsx(inputClass, 'pr-12 tabular-nums')}
value={text}
disabled={disabled}
placeholder="0"
onChange={(e) => {
const n = parsePygInput(e.target.value);
setText(n ? formatNumber(n, locale) : '');
onChange(n);
}}
/>
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-gray-400">PYG</span>
</div>
);
}
/** Today as YYYY-MM-DD in the viewer's time zone. */
export function todayIso(): string {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
/**
* Date field that reads in the active UI language. A native <input type="date">
* formats itself with the browser's locale (e.g. "dd-mm-jjjj" on a Dutch
* system), so the visible text is ours and the native input only supplies the
* picker, laid transparently on top.
*/
export function DateInput({ value, onChange, clearable, id, className }: {
value: string; onChange: (v: string) => void; clearable?: boolean; id?: string; className?: string;
}) {
const { t, locale } = useLanguage();
const ref = useRef<HTMLInputElement>(null);
const label = value
? new Date(`${value}T12:00:00`).toLocaleDateString(locale === 'es' ? 'es-PY' : 'en-US', { day: 'numeric', month: 'short', year: 'numeric' })
: '';
return (
<div className={clsx('relative', className)}>
<div className={clsx(inputClass, 'flex items-center gap-2 pr-2 focus-within:ring-2 focus-within:ring-primary-yellow')} aria-hidden>
<CalendarDaysIcon className="w-4 h-4 text-gray-400 flex-shrink-0" />
<span className={clsx('flex-1 truncate', !label && 'text-gray-400')}>{label || t('admin.finance.pickDate')}</span>
</div>
<input
ref={ref}
id={id}
type="date"
lang={locale === 'es' ? 'es-PY' : 'en-US'}
value={value}
aria-label={label || t('admin.finance.pickDate')}
onChange={(e) => onChange(e.target.value)}
onClick={(e) => { try { e.currentTarget.showPicker?.(); } catch { /* picker opens natively */ } }}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
/>
{clearable && value && (
<button
type="button"
onClick={() => onChange('')}
className="absolute right-1 top-1/2 -translate-y-1/2 z-10 p-1.5 rounded-btn text-gray-400 hover:text-gray-700 hover:bg-gray-100 cursor-pointer"
aria-label={t('admin.finance.clearDate')}
>
<XMarkIcon className="w-4 h-4" />
</button>
)}
</div>
);
}
/** A PYG amount that respects Hide Stats. */
export function Money({ value, className, signed }: { value: number | null | undefined; className?: string; signed?: boolean }) {
const { privacyMode } = usePrivacy();
const { pyg } = useMoney();
if (value === null || value === undefined) return <span className={className}>-</span>;
const text = signed && value > 0 ? `+${pyg(value)}` : pyg(value);
return <span className={clsx('tabular-nums', className)}>{privacyMode ? PRIVACY_MASK : text}</span>;
}
const STATUS_STYLES: Record<FinanceStatus, string> = {
open: 'bg-gray-100 text-gray-700',
finalized: 'bg-blue-100 text-blue-800',
paid_out: 'bg-green-100 text-green-800',
};
export function FinanceStatusBadge({ status, label }: { status: FinanceStatus; label: string }) {
return <span className={clsx('inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium', STATUS_STYLES[status])}>{label}</span>;
}
export function Pill({ children, tone = 'gray' }: { children: ReactNode; tone?: 'gray' | 'green' | 'yellow' | 'blue' | 'red' }) {
const tones = {
gray: 'bg-gray-100 text-gray-700',
green: 'bg-green-100 text-green-800',
yellow: 'bg-yellow-100 text-yellow-800',
blue: 'bg-blue-100 text-blue-800',
red: 'bg-red-100 text-red-800',
};
return <span className={clsx('inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium whitespace-nowrap', tones[tone])}>{children}</span>;
}
/** Segmented sub-navigation matching the event page's mobile tab pills. */
export function SubNav<K extends string>({ items, active, onChange }: { items: { key: K; label: string; count?: number }[]; active: K; onChange: (k: K) => void }) {
return (
<div role="tablist" className="grid grid-flow-col auto-cols-fr md:inline-grid md:auto-cols-auto gap-1 p-1 bg-gray-100 rounded-lg w-full md:w-auto">
{items.map((it) => (
<button
key={it.key}
role="tab"
aria-selected={active === it.key}
onClick={() => onChange(it.key)}
className={clsx(
// Phones: long labels wrap to two lines instead of running into the next tab.
'px-2 md:px-3 py-1.5 md:py-2 rounded-md text-sm font-medium leading-tight md:whitespace-nowrap min-h-[40px] cursor-pointer transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-yellow',
active === it.key ? 'bg-white shadow-sm text-primary-dark' : 'text-gray-500 hover:text-gray-700',
)}
>
{it.label}
{it.count !== undefined && it.count > 0 && <span className="ml-1.5 text-xs text-gray-400">{it.count}</span>}
</button>
))}
</div>
);
}
@@ -1,43 +1,81 @@
import { useState, useEffect } from 'react';
import toast from 'react-hot-toast';
import { eventsApi, ticketsApi, emailsApi, doorApi, Event, Ticket, EmailTemplate, DoorSummary } from '@/lib/api';
import { eventsApi, ticketsApi, emailsApi, doorApi, Event, Ticket, EmailTemplate, DoorSummary, type EventPermission } from '@/lib/api';
/** A 403 means "not for this viewer": render without that data instead of failing the page. */
const forbidden = (error: any) => error?.status === 403;
/**
* Loads the core data for the admin event detail page (event, tickets, active
* email templates, door takings) and exposes a reload function used after
* mutations.
* Loads the core data for the event detail page and exposes a reload function
* used after mutations. The event itself is fetched straight away so the
* header can render while the rest loads; tickets, active email templates and
* door takings wait for the viewer's permissions on the event and only request
* what they may see, so team members get a working page with fewer tabs.
*
* `eventLoading` and `detailsLoading` cover the first load only: reloads keep
* the current data on screen until the new data replaces it.
*/
export function useEventDetailData(eventId: string) {
const [loading, setLoading] = useState(true);
export function useEventDetailData(eventId: string, permissions: Set<EventPermission> | null) {
const [eventLoading, setEventLoading] = useState(true);
const [detailsLoading, setDetailsLoading] = useState(true);
const [event, setEvent] = useState<Event | null>(null);
const [tickets, setTickets] = useState<Ticket[]>([]);
const [templates, setTemplates] = useState<EmailTemplate[]>([]);
const [doorSummary, setDoorSummary] = useState<DoorSummary | null>(null);
const loadEventData = async () => {
const loadEvent = async () => {
try {
const [eventRes, ticketsRes, templatesRes, doorRes] = await Promise.all([
eventsApi.getById(eventId),
ticketsApi.getAll({ eventId }),
emailsApi.getTemplates(),
const { event } = await eventsApi.getById(eventId);
setEvent(event);
} catch (error) {
toast.error('Failed to load event data');
} finally {
setEventLoading(false);
}
};
const loadDetails = async () => {
if (!permissions) return;
const can = (p: EventPermission) => permissions.has(p);
try {
const [ticketsRes, templatesRes, doorRes] = await Promise.all([
can('view_attendees_names')
? ticketsApi.getAll({ eventId }).catch((error) => {
if (forbidden(error)) return { tickets: [] as Ticket[] };
throw error;
})
: { tickets: [] as Ticket[] },
can('email_attendees')
? emailsApi.getTemplates(eventId).catch((error) => {
if (forbidden(error)) return { templates: [] as EmailTemplate[] };
throw error;
})
: { templates: [] as EmailTemplate[] },
// Door takings split pre-sale from cash/bitcoin/transfer taken on the
// night. It is supporting detail, so a failure here must not blank the page.
doorApi.summary(eventId).catch(() => null),
can('view_payments') ? doorApi.summary(eventId).catch(() => null) : null,
]);
setEvent(eventRes.event);
setTickets(ticketsRes.tickets);
setTemplates(templatesRes.templates.filter(t => t.isActive));
setDoorSummary(doorRes);
} catch (error) {
toast.error('Failed to load event data');
} finally {
setLoading(false);
setDetailsLoading(false);
}
};
const loadEventData = async () => {
await Promise.all([loadEvent(), loadDetails()]);
};
useEffect(() => {
loadEventData();
loadEvent();
}, [eventId]);
return { loading, event, tickets, templates, doorSummary, loadEventData };
useEffect(() => {
loadDetails();
}, [eventId, permissions]);
return { eventLoading, detailsLoading, event, tickets, templates, doorSummary, loadEventData };
}
@@ -1,6 +1,7 @@
import { Event } from '@/lib/api';
import Card from '@/components/ui/Card';
import SensitiveValue from '@/components/admin/SensitiveValue';
import { Skeleton } from '@/components/ui/Skeleton';
import { CalendarIcon, MapPinIcon, CurrencyDollarIcon, UsersIcon } from '@heroicons/react/24/outline';
interface OverviewTabProps {
@@ -10,9 +11,11 @@ interface OverviewTabProps {
formatCurrency: (amount: number, currency: string) => string;
confirmedCount: number;
checkedInCount: number;
/** Tickets are still loading, so the seat counts are not known yet. */
countsLoading?: boolean;
}
export function OverviewTab({ event, formatDate, fmtTime, formatCurrency, confirmedCount, checkedInCount }: OverviewTabProps) {
export function OverviewTab({ event, formatDate, fmtTime, formatCurrency, confirmedCount, checkedInCount, countsLoading = false }: OverviewTabProps) {
return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<Card className="p-5">
@@ -49,8 +52,17 @@ export function OverviewTab({ event, formatDate, fmtTime, formatCurrency, confir
<UsersIcon className="w-5 h-5 text-gray-400 mt-0.5 flex-shrink-0" />
<div>
<p className="font-medium text-sm">Capacity</p>
<p className="text-sm text-gray-600"><SensitiveValue>{confirmedCount + checkedInCount} / {event.capacity}</SensitiveValue> spots filled</p>
<p className="text-xs text-gray-500"><SensitiveValue>{Math.max(0, event.capacity - confirmedCount - checkedInCount)}</SensitiveValue> spots remaining</p>
{countsLoading ? (
<div aria-hidden="true">
<Skeleton className="mt-1 h-4 w-28" />
<Skeleton className="mt-1.5 h-3 w-24" />
</div>
) : (
<>
<p className="text-sm text-gray-600"><SensitiveValue>{confirmedCount + checkedInCount} / {event.capacity}</SensitiveValue> spots filled</p>
<p className="text-xs text-gray-500"><SensitiveValue>{Math.max(0, event.capacity - confirmedCount - checkedInCount)}</SensitiveValue> spots remaining</p>
</>
)}
</div>
</div>
</div>
@@ -51,7 +51,7 @@ function DoorTakings({ locale, summary }: { locale: string; summary: DoorSummary
</p>
</div>
</div>
<p className="font-bold text-lg">{formatCurrency(summary.door.total, summary.currency)}</p>
<p className="font-bold text-lg">{formatCurrency(summary.door.total, summary.currency, locale)}</p>
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 pt-3 border-t">
@@ -65,7 +65,7 @@ function DoorTakings({ locale, summary }: { locale: string; summary: DoorSummary
<p className="font-bold text-sm leading-tight">
{method === 'guest'
? `${entry.count}`
: formatCurrency(entry.total, summary.currency)}
: formatCurrency(entry.total, summary.currency, locale)}
</p>
{method !== 'guest' && (
<p className="text-[10px] text-gray-500">
@@ -79,11 +79,11 @@ function DoorTakings({ locale, summary }: { locale: string; summary: DoorSummary
<div className="flex items-center justify-between text-xs text-gray-600 pt-3 mt-3 border-t">
<span>
{es ? 'Preventa' : 'Pre-sale'}: <strong>{formatCurrency(summary.presale.total, summary.currency)}</strong>
{es ? 'Preventa' : 'Pre-sale'}: <strong>{formatCurrency(summary.presale.total, summary.currency, locale)}</strong>
{' '}({summary.presale.count})
</span>
<span>
{es ? 'Total' : 'Total'}: <strong>{formatCurrency(summary.total, summary.currency)}</strong>
{es ? 'Total' : 'Total'}: <strong>{formatCurrency(summary.total, summary.currency, locale)}</strong>
</span>
</div>
</div>
@@ -0,0 +1,259 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import toast from 'react-hot-toast';
import clsx from 'clsx';
import { TrashIcon, ArrowPathIcon, ChevronDownIcon } from '@heroicons/react/24/outline';
import { useLanguage } from '@/context/LanguageContext';
import {
financeApi, EVENT_PERMISSIONS, ROLE_PRESETS, PRESET_PERMISSIONS,
type EventMember, type EventPermission, type RolePreset,
} from '@/lib/api';
import Button from '@/components/ui/Button';
import { TableSkeleton } from '@/components/ui/Skeleton';
import { Modal, Field, inputClass } from '../_finance/ui';
import { UserSearch, type UserHit } from '../_finance/UserSearch';
/** Overrides after flipping one permission: drop the key when it matches the preset again. */
function toggleOverride(member: EventMember, key: EventPermission): Partial<Record<EventPermission, boolean>> {
const next = { ...member.permissions };
const want = !member.effective.includes(key);
const presetHas = PRESET_PERMISSIONS[member.rolePreset].includes(key);
if (want === presetHas) delete next[key]; else next[key] = want;
return next;
}
function Toggle({ on, overridden, disabled, onClick, label }: { on: boolean; overridden: boolean; disabled?: boolean; onClick: () => void; label: string }) {
return (
<button
role="switch"
aria-checked={on}
aria-label={label}
title={overridden ? label : undefined}
disabled={disabled}
onClick={onClick}
className={clsx(
'relative inline-flex h-6 w-11 md:h-5 md:w-9 flex-shrink-0 rounded-full transition-colors cursor-pointer disabled:opacity-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-yellow',
on ? 'bg-primary-yellow' : 'bg-gray-200',
overridden && 'ring-2 ring-offset-1 ring-blue-400',
)}
>
<span className={clsx('inline-block h-5 w-5 md:h-4 md:w-4 mt-0.5 rounded-full bg-white shadow transition-transform', on ? 'translate-x-[22px] md:translate-x-[18px]' : 'translate-x-0.5')} />
</button>
);
}
export function TeamTab({ eventId }: { eventId: string }) {
const { t } = useLanguage();
const [members, setMembers] = useState<EventMember[] | null>(null);
const [adding, setAdding] = useState(false);
const [picked, setPicked] = useState<UserHit | null>(null);
const [preset, setPreset] = useState<RolePreset>('collaborator');
const [busy, setBusy] = useState<string | null>(null);
const load = useCallback(async () => {
try {
setMembers((await financeApi.getMembers(eventId)).members);
} catch (error: any) {
toast.error(error.message);
setMembers([]);
}
}, [eventId]);
useEffect(() => { load(); }, [load]);
const search = useCallback((q: string) => financeApi.searchMemberCandidates(eventId, q), [eventId]);
const update = async (m: EventMember, body: { rolePreset?: RolePreset; permissions?: Partial<Record<EventPermission, boolean>> }) => {
setBusy(m.id);
try {
const { member } = await financeApi.updateMember(eventId, m.id, body);
setMembers((list) => list?.map((x) => (x.id === m.id ? member : x)) ?? null);
toast.success(t('admin.team.saved'));
} catch (error: any) {
toast.error(error.message);
} finally {
setBusy(null);
}
};
const remove = async (m: EventMember) => {
if (!confirm(t('admin.team.confirmRemove', { name: m.name || m.email || '' }))) return;
setBusy(m.id);
try {
await financeApi.removeMember(eventId, m.id);
setMembers((list) => list?.filter((x) => x.id !== m.id) ?? null);
toast.success(t('admin.team.removed'));
} catch (error: any) {
toast.error(error.message);
} finally {
setBusy(null);
}
};
const add = async () => {
if (!picked) return;
setBusy('add');
try {
await financeApi.addMember(eventId, { userId: picked.id, rolePreset: preset });
toast.success(t('admin.team.added'));
setAdding(false);
setPicked(null);
load();
} catch (error: any) {
toast.error(error.message);
} finally {
setBusy(null);
}
};
if (members === null) return <TableSkeleton rows={3} cols={4} />;
const PresetSelect = ({ m }: { m: EventMember }) => (
<select
className={clsx(inputClass, 'py-1.5 w-auto')}
value={m.rolePreset}
disabled={busy === m.id}
onChange={(e) => update(m, { rolePreset: e.target.value as RolePreset, permissions: m.permissions })}
>
{ROLE_PRESETS.map((p) => <option key={p} value={p}>{t(`admin.rolePresets.${p}`)}</option>)}
</select>
);
const hasOverrides = (m: EventMember) => Object.keys(m.permissions).length > 0;
return (
<div className="space-y-4">
<div className="flex flex-col md:flex-row md:items-center gap-2 justify-between">
<p className="text-xs text-gray-500 max-w-2xl">{t('admin.team.hint')}</p>
<Button size="sm" onClick={() => { setPicked(null); setPreset('collaborator'); setAdding(true); }} className="self-start md:self-auto">
{t('admin.team.add')}
</Button>
</div>
{members.length === 0 ? (
<p className="text-sm text-gray-400 py-8 text-center bg-white rounded-card shadow-card">{t('admin.team.empty')}</p>
) : (
<>
{/* Desktop: permission matrix */}
<div className="hidden lg:block bg-white rounded-card shadow-card overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-100">
<th className="text-left font-medium text-xs text-gray-500 px-4 py-2.5 sticky left-0 bg-white">{t('admin.finance.split.linkedUser')}</th>
<th className="text-left font-medium text-xs text-gray-500 px-2 py-2.5">{t('admin.team.role')}</th>
{EVENT_PERMISSIONS.map((p) => (
<th key={p} className="px-1 py-2.5 align-bottom">
<span className="block text-[10px] font-medium text-gray-500 leading-tight w-16 mx-auto text-center">{t(`admin.permissions.${p}`)}</span>
</th>
))}
<th />
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{members.map((m) => (
<tr key={m.id}>
<td className="px-4 py-3 sticky left-0 bg-white">
<p className="font-medium truncate max-w-[180px]">{m.name}</p>
<p className="text-xs text-gray-500 truncate max-w-[180px]">{m.email}</p>
</td>
<td className="px-2 py-3"><PresetSelect m={m} /></td>
{EVENT_PERMISSIONS.map((p) => (
<td key={p} className="px-1 py-3 text-center">
<Toggle
on={m.effective.includes(p)}
overridden={p in m.permissions}
disabled={busy === m.id}
label={`${t(`admin.permissions.${p}`)}${p in m.permissions ? ` · ${t('admin.team.override')}` : ''}`}
onClick={() => update(m, { permissions: toggleOverride(m, p) })}
/>
</td>
))}
<td className="px-3 py-3 whitespace-nowrap text-right">
{hasOverrides(m) && (
<button onClick={() => update(m, { permissions: {} })} className="p-1.5 rounded-btn hover:bg-gray-100 text-gray-500" title={t('admin.team.reset')} aria-label={t('admin.team.reset')}>
<ArrowPathIcon className="w-4 h-4" />
</button>
)}
<button onClick={() => remove(m)} disabled={busy === m.id} className="p-1.5 rounded-btn hover:bg-gray-100 text-red-600" aria-label={t('admin.team.remove')}>
<TrashIcon className="w-4 h-4" />
</button>
</td>
</tr>
))}
</tbody>
</table>
<p className="text-[11px] text-gray-500 px-4 py-2 flex items-center gap-2 border-t border-gray-100">
<span className="inline-block w-3 h-3 rounded-full ring-2 ring-blue-400" /> {t('admin.team.override')}
</p>
</div>
{/* Mobile / tablet: one card per member */}
<div className="lg:hidden space-y-3">
{members.map((m) => (
<div key={m.id} className="bg-white rounded-card shadow-card p-4 space-y-3">
<div className="flex items-start gap-2">
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{m.name}</p>
<p className="text-xs text-gray-500 truncate">{m.email}</p>
</div>
<button onClick={() => remove(m)} disabled={busy === m.id} className="p-2 rounded-btn hover:bg-gray-100 text-red-600" aria-label={t('admin.team.remove')}>
<TrashIcon className="w-4 h-4" />
</button>
</div>
<div className="flex items-center gap-2">
<PresetSelect m={m} />
{hasOverrides(m) && (
<button onClick={() => update(m, { permissions: {} })} className="text-xs text-gray-500 underline">{t('admin.team.reset')}</button>
)}
</div>
<details className="group rounded-btn border border-gray-100">
<summary className="list-none cursor-pointer flex items-center justify-between px-3 min-h-[44px] text-sm [&::-webkit-details-marker]:hidden">
<span className="font-medium">{t('admin.team.permissions')}</span>
<span className="flex items-center gap-1 text-gray-500">
{t('admin.team.permissionsSummary', { on: m.effective.length, total: EVENT_PERMISSIONS.length })}
<ChevronDownIcon className="w-4 h-4 transition-transform group-open:rotate-180" aria-hidden />
</span>
</summary>
<ul className="divide-y divide-gray-100 px-3">
{EVENT_PERMISSIONS.map((p) => (
<li key={p} className="flex items-center justify-between gap-3 text-sm min-h-[48px]">
<span className={clsx(p in m.permissions && 'text-blue-700')}>{t(`admin.permissions.${p}`)}</span>
<Toggle
on={m.effective.includes(p)}
overridden={p in m.permissions}
disabled={busy === m.id}
label={t(`admin.permissions.${p}`)}
onClick={() => update(m, { permissions: toggleOverride(m, p) })}
/>
</li>
))}
</ul>
</details>
</div>
))}
</div>
</>
)}
<Modal open={adding} onClose={() => setAdding(false)} title={t('admin.team.add')}>
{picked ? (
<div className="flex items-center gap-2 px-3 py-2 rounded-btn bg-gray-50 text-sm">
<span className="flex-1 truncate">{picked.name} ({picked.email})</span>
<button onClick={() => setPicked(null)} className="text-xs underline text-gray-500">{t('common.edit')}</button>
</div>
) : (
<UserSearch search={search} onPick={setPicked} placeholder={t('admin.team.search')} noResults={t('admin.team.noResults')} />
)}
<Field label={t('admin.team.role')}>
<select className={inputClass} value={preset} onChange={(e) => setPreset(e.target.value as RolePreset)}>
{ROLE_PRESETS.map((p) => <option key={p} value={p}>{t(`admin.rolePresets.${p}`)}</option>)}
</select>
</Field>
<p className="text-xs text-gray-500">{PRESET_PERMISSIONS[preset].map((p) => t(`admin.permissions.${p}`)).join(' · ')}</p>
<div className="flex gap-3 pt-2">
<Button variant="outline" onClick={() => setAdding(false)} className="flex-1 min-h-[44px]">{t('common.cancel')}</Button>
<Button onClick={add} disabled={!picked} isLoading={busy === 'add'} className="flex-1 min-h-[44px]">{t('admin.team.add')}</Button>
</div>
</Modal>
</div>
);
}
+1 -1
View File
@@ -1,6 +1,6 @@
import type { ComponentType } from 'react';
export type TabType = 'overview' | 'attendees' | 'tickets' | 'email' | 'payments';
export type TabType = 'overview' | 'attendees' | 'tickets' | 'email' | 'payments' | 'finance' | 'team';
export type AttendeeStatusFilter = 'all' | 'pending' | 'confirmed' | 'checked_in' | 'cancelled' | 'on_hold';
export type TicketStatusFilter = 'all' | 'confirmed' | 'checked_in';
@@ -1,9 +1,10 @@
// Pure formatting/IO helpers for the admin event detail page.
export function formatCurrency(amount: number, currency: string): string {
if (currency === 'PYG') {
return `${amount.toLocaleString('es-PY')} PYG`;
}
import { formatPyg } from '@/lib/money';
/** PYG goes through the shared money helper; `locale` is the UI language (emails keep the default). */
export function formatCurrency(amount: number, currency: string, locale: string = 'es'): string {
if (currency === 'PYG') return formatPyg(amount, locale);
return `$${amount.toFixed(2)} ${currency}`;
}
@@ -0,0 +1,7 @@
import { EventDetailSkeleton } from './_components/EventDetailSkeleton';
// Route-level shell shown the moment an admin opens an event, while the page
// chunk loads. Mirrors the page's own first-load state.
export default function AdminEventDetailLoading() {
return <EventDetailSkeleton backHref="/admin/events" />;
}
+2 -783
View File
@@ -1,790 +1,9 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import { useParams } from 'next/navigation';
import Link from 'next/link';
import { useLanguage } from '@/context/LanguageContext';
import { ticketsApi, emailsApi, adminApi, paymentsApi, siteSettingsApi, Ticket } from '@/lib/api';
import { formatDateLong, formatDateCompact, formatTime } from '@/lib/utils';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import { Skeleton, TableSkeleton } from '@/components/ui/Skeleton';
import { Dropdown, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents';
import {
ArrowLeftIcon,
CalendarIcon,
MapPinIcon,
CurrencyDollarIcon,
UsersIcon,
TicketIcon,
CheckCircleIcon,
EnvelopeIcon,
PencilIcon,
EyeIcon,
UserGroupIcon,
CreditCardIcon,
ChevronDownIcon,
EllipsisVerticalIcon,
ArrowUturnLeftIcon,
} from '@heroicons/react/24/outline';
import toast from 'react-hot-toast';
import clsx from 'clsx';
import { usePrivacy } from '@/context/PrivacyContext';
import type {
TabType,
AttendeeStatusFilter,
TicketStatusFilter,
RecipientFilter,
AddTicketType,
AddTicketFormState,
PrimaryAction,
} from './_types';
import { formatCurrency, downloadBlob } from './_utils/format';
import { useEventDetailData } from './_hooks/useEventDetailData';
import { usePaymentOverrides } from './_hooks/usePaymentOverrides';
import { OverviewTab } from './_tabs/OverviewTab';
import { AttendeesTab } from './_tabs/AttendeesTab';
import { TicketsTab } from './_tabs/TicketsTab';
import { EmailTab } from './_tabs/EmailTab';
import { PaymentsTab } from './_tabs/PaymentsTab';
import { EventModals } from './_modals/EventModals';
import { AddTicketModal } from './_modals/AddTicketModal';
import EventFormModal from '../_components/EventFormModal';
const EMPTY_ADD_TICKET_FORM: AddTicketFormState = {
type: 'paid',
firstName: '',
lastName: '',
email: '',
phone: '',
adminNote: '',
checkinNow: false,
};
import { EventDetailView } from './_components/EventDetailView';
export default function AdminEventDetailPage() {
const params = useParams();
const eventId = params.id as string;
const { locale } = useLanguage();
const { loading, event, tickets, templates, doorSummary, loadEventData } = useEventDetailData(eventId);
const [activeTab, setActiveTab] = useState<TabType>('overview');
// Email state
const [selectedTemplate, setSelectedTemplate] = useState<string>('');
const [recipientFilter, setRecipientFilter] = useState<RecipientFilter>('confirmed');
const [customMessage, setCustomMessage] = useState('');
const [sending, setSending] = useState(false);
const [previewHtml, setPreviewHtml] = useState<string | null>(null);
// Attendees tab state
const [searchQuery, setSearchQuery] = useState('');
const [statusFilter, setStatusFilter] = useState<AttendeeStatusFilter>('all');
const { privacyMode } = usePrivacy();
const showStats = !privacyMode;
const [showNoteModal, setShowNoteModal] = useState(false);
const [selectedTicket, setSelectedTicket] = useState<Ticket | null>(null);
const [noteText, setNoteText] = useState('');
// Unified Add Ticket modal (paid / door / unpaid / guest via segmented control)
const [showAddTicketModal, setShowAddTicketModal] = useState(false);
const [addTicketForm, setAddTicketForm] = useState<AddTicketFormState>(EMPTY_ADD_TICKET_FORM);
const [submitting, setSubmitting] = useState(false);
const openAddTicket = (type: AddTicketType) => {
setAddTicketForm({ ...EMPTY_ADD_TICKET_FORM, type });
setShowAddTicketModal(true);
};
// Export state — separate desktop (Dropdown portal) vs mobile (BottomSheet)
const [showExportDropdown, setShowExportDropdown] = useState(false); // desktop dropdown
const [showExportSheet, setShowExportSheet] = useState(false); // mobile bottom sheet
const [showTicketExportDropdown, setShowTicketExportDropdown] = useState(false); // desktop
const [showTicketExportSheet, setShowTicketExportSheet] = useState(false); // mobile
const [exporting, setExporting] = useState(false);
// Add Ticket — separate desktop dropdown vs mobile bottom sheet
const [showAddTicketDropdown, setShowAddTicketDropdown] = useState(false); // desktop
const [showAddTicketSheet, setShowAddTicketSheet] = useState(false); // mobile FAB
// Tickets tab state
const [ticketSearchQuery, setTicketSearchQuery] = useState('');
const [ticketStatusFilter, setTicketStatusFilter] = useState<TicketStatusFilter>('all');
// Payment options state + handlers
const payments = usePaymentOverrides(eventId, locale);
// Edit event modal (opens in place instead of redirecting to the list page)
const [showEditForm, setShowEditForm] = useState(false);
const [featuredEventId, setFeaturedEventId] = useState<string | null>(null);
useEffect(() => {
siteSettingsApi
.get()
.then(({ settings }) => setFeaturedEventId(settings.featuredEventId || null))
.catch(() => {});
}, []);
// Mobile-specific state
const [mobileHeaderMenuOpen, setMobileHeaderMenuOpen] = useState(false);
const [mobileFilterOpen, setMobileFilterOpen] = useState(false);
const [mobileStatsExpanded, setMobileStatsExpanded] = useState(false);
// Tab bar ref for sticky
const tabBarRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (activeTab === 'payments') {
payments.loadPaymentOptions();
}
}, [activeTab]);
const formatDate = (dateStr: string) => formatDateLong(dateStr, locale as 'en' | 'es');
const formatDateShort = (dateStr: string) => formatDateCompact(dateStr, locale as 'en' | 'es');
const fmtTime = (dateStr: string) => formatTime(dateStr, locale as 'en' | 'es');
const getTicketsByStatus = (status: string) => {
return tickets.filter(t => t.status === status);
};
const getFilteredRecipientCount = () => {
if (recipientFilter === 'all') return tickets.length;
return getTicketsByStatus(recipientFilter).length;
};
const handleMarkPaid = async (ticketId: string) => {
try {
await ticketsApi.markPaid(ticketId);
toast.success('Payment marked as received');
loadEventData();
} catch (error: any) {
toast.error(error.message || 'Failed to mark payment');
}
};
const handleCheckin = async (ticketId: string) => {
try {
await ticketsApi.checkin(ticketId);
toast.success('Attendee checked in');
loadEventData();
} catch (error: any) {
toast.error(error.message || 'Failed to check in');
}
};
const handleReactivate = async (ticket: Ticket) => {
if (!ticket.payment?.id) return;
try {
await paymentsApi.reactivate(ticket.payment.id);
toast.success('Booking reactivated');
loadEventData();
} catch (error: any) {
toast.error(error.message || 'Failed to reactivate booking');
}
};
const handleRemoveCheckin = async (ticketId: string) => {
if (!confirm('Are you sure you want to remove the check-in for this attendee?')) return;
try {
await ticketsApi.removeCheckin(ticketId);
toast.success('Check-in removed');
loadEventData();
} catch (error: any) {
toast.error(error.message || 'Failed to remove check-in');
}
};
const handleOpenNoteModal = (ticket: Ticket) => {
setSelectedTicket(ticket);
setNoteText(ticket.adminNote || '');
setShowNoteModal(true);
};
const handleSaveNote = async () => {
if (!selectedTicket) return;
setSubmitting(true);
try {
await ticketsApi.updateNote(selectedTicket.id, noteText);
toast.success('Note saved');
setShowNoteModal(false);
setSelectedTicket(null);
setNoteText('');
loadEventData();
} catch (error: any) {
toast.error(error.message || 'Failed to save note');
} finally {
setSubmitting(false);
}
};
const handleAddTicket = async (e: React.FormEvent) => {
e.preventDefault();
if (!event) return;
setSubmitting(true);
try {
const res = await ticketsApi.adminAdd({
eventId: event.id,
type: addTicketForm.type,
firstName: addTicketForm.firstName || undefined,
lastName: addTicketForm.lastName || undefined,
email: addTicketForm.email || undefined,
phone: addTicketForm.phone || undefined,
checkinNow: addTicketForm.checkinNow,
adminNote: addTicketForm.adminNote || undefined,
});
toast.success(res.message || 'Ticket created');
setShowAddTicketModal(false);
setAddTicketForm(EMPTY_ADD_TICKET_FORM);
loadEventData();
} catch (error: any) {
toast.error(error.message || 'Failed to add ticket');
} finally {
setSubmitting(false);
}
};
const handleExportAttendees = async (status: 'confirmed' | 'checked_in' | 'confirmed_pending' | 'all') => {
if (!event) return;
setExporting(true);
setShowExportDropdown(false);
try {
const { blob, filename } = await adminApi.exportAttendees(event.id, { status, format: 'csv', q: searchQuery || undefined });
downloadBlob(blob, filename);
toast.success('Export downloaded');
} catch (error: any) {
toast.error(error.message || 'Failed to export attendees');
} finally {
setExporting(false);
}
};
const handleExportTickets = async (status: 'confirmed' | 'checked_in' | 'all') => {
if (!event) return;
setExporting(true);
setShowTicketExportDropdown(false);
try {
const { blob, filename } = await adminApi.exportTicketsCSV(event.id, { status, q: ticketSearchQuery || undefined });
downloadBlob(blob, filename);
toast.success('Export downloaded');
} catch (error: any) {
toast.error(error.message || 'Failed to export tickets');
} finally {
setExporting(false);
}
};
// Filtered tickets for attendees tab
const filteredTickets = tickets.filter((ticket) => {
if (statusFilter !== 'all' && ticket.status !== statusFilter) return false;
if (searchQuery) {
const query = searchQuery.toLowerCase();
const fullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim().toLowerCase();
return (
fullName.includes(query) ||
(ticket.attendeeEmail?.toLowerCase().includes(query) || false) ||
(ticket.attendeePhone?.toLowerCase().includes(query) || false) ||
ticket.id.toLowerCase().includes(query)
);
}
return true;
});
// Filtered tickets for the Tickets tab (only confirmed/checked_in)
const confirmedTickets = tickets.filter(t => ['confirmed', 'checked_in'].includes(t.status));
const filteredConfirmedTickets = confirmedTickets.filter((ticket) => {
if (ticketStatusFilter !== 'all' && ticket.status !== ticketStatusFilter) return false;
if (ticketSearchQuery) {
const query = ticketSearchQuery.toLowerCase();
const fullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim().toLowerCase();
return (
fullName.includes(query) ||
ticket.id.toLowerCase().includes(query)
);
}
return true;
});
const handlePreviewEmail = async () => {
if (!selectedTemplate) {
toast.error('Please select a template');
return;
}
try {
const res = await emailsApi.preview({
templateSlug: selectedTemplate,
variables: {
attendeeName: 'John Doe',
attendeeEmail: 'john@example.com',
ticketId: 'TKT-PREVIEW',
eventTitle: event?.title || '',
eventDate: event ? formatDate(event.startDatetime) : '',
eventTime: event ? fmtTime(event.startDatetime) : '',
eventLocation: event?.location || '',
eventLocationUrl: event?.locationUrl || '',
eventPrice: event ? formatCurrency(event.price, event.currency) : '',
customMessage: customMessage || 'Your custom message will appear here.',
},
locale,
});
setPreviewHtml(res.bodyHtml);
} catch (error) {
toast.error('Failed to preview email');
}
};
const handleSendEmail = async () => {
if (!selectedTemplate) {
toast.error('Please select a template');
return;
}
const recipientCount = getFilteredRecipientCount();
if (recipientCount === 0) {
toast.error('No recipients match the selected filter');
return;
}
if (!confirm(`Send email to ${recipientCount} ${recipientFilter === 'all' ? 'attendee(s)' : `${recipientFilter} attendee(s)`}?`)) {
return;
}
setSending(true);
try {
const res = await emailsApi.sendToEvent(eventId, {
templateSlug: selectedTemplate,
recipientFilter,
customVariables: customMessage ? { customMessage } : undefined,
});
if (res.success) {
toast.success(`${res.queuedCount} email(s) are being sent in the background.`);
} else {
toast.error(res.error || 'Failed to queue emails');
}
} catch (error: any) {
toast.error(error.message || 'Failed to send emails');
} finally {
setSending(false);
}
};
if (loading) {
return (
<div>
<div className="flex items-center justify-between mb-6">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-10 w-36 rounded-btn hidden md:block" />
</div>
<TableSkeleton rows={5} cols={4} />
</div>
);
}
if (!event) {
return (
<div className="text-center py-12">
<p className="text-gray-500">Event not found</p>
<Link href="/admin/events">
<Button variant="outline" className="mt-4">
Back to Events
</Button>
</Link>
</div>
);
}
const confirmedCount = getTicketsByStatus('confirmed').length;
const pendingCount = getTicketsByStatus('pending').length;
const checkedInCount = getTicketsByStatus('checked_in').length;
const cancelledCount = getTicketsByStatus('cancelled').length;
const onHoldCount = getTicketsByStatus('on_hold').length;
// Revenue counts only settled tickets: unpaid (balance due) and comp (guest)
// tickets are excluded; legacy rows without paymentStatus fall back to !isGuest
const isRevenueTicket = (t: Ticket) => (t.paymentStatus ? t.paymentStatus === 'paid' : !t.isGuest);
const paidConfirmedCount = getTicketsByStatus('confirmed').filter(isRevenueTicket).length;
const paidCheckedInCount = getTicketsByStatus('checked_in').filter(isRevenueTicket).length;
// Door sales can be taken at a custom amount (someone paying for their whole
// group), so once the door summary is loaded it is the authority on the total:
// pre-sale tickets at face value plus whatever was actually taken on the night.
const presaleRevenue = doorSummary
? doorSummary.presale.total
: (paidConfirmedCount + paidCheckedInCount) * event.price;
const doorRevenue = doorSummary?.door.total ?? 0;
const revenue = presaleRevenue + doorRevenue;
const tabs: { key: TabType; label: string; icon: typeof CalendarIcon; count?: number }[] = [
{ key: 'overview', label: 'Overview', icon: CalendarIcon },
{ key: 'attendees', label: 'Attendees', icon: UserGroupIcon, count: tickets.length },
{ key: 'tickets', label: 'Tickets', icon: TicketIcon, count: confirmedTickets.length },
{ key: 'email', label: 'Email', icon: EnvelopeIcon },
{ key: 'payments', label: locale === 'es' ? 'Pagos' : 'Payments', icon: CreditCardIcon },
];
// ========== Primary action for a ticket ==========
const getPrimaryAction = (ticket: Ticket): PrimaryAction | null => {
if (ticket.status === 'pending' || ticket.status === 'on_hold') {
return { label: 'Mark Paid', onClick: () => handleMarkPaid(ticket.id), variant: 'outline' };
}
if (ticket.status === 'confirmed') {
// Unpaid tickets resolve their balance first; check-in stays available via scanner
if (ticket.paymentStatus === 'unpaid') {
return { label: 'Mark Paid', onClick: () => handleMarkPaid(ticket.id), variant: 'outline' };
}
return { label: 'Check In', onClick: () => handleCheckin(ticket.id), variant: 'primary' };
}
if (ticket.status === 'checked_in') {
return { label: 'Undo', onClick: () => handleRemoveCheckin(ticket.id), variant: 'outline', icon: ArrowUturnLeftIcon };
}
return null;
};
return (
<div className="max-w-full overflow-x-hidden">
{/* ============= HEADER ============= */}
<div className="flex items-start gap-3 mb-3">
<Link href="/admin/events" className="mt-1 flex-shrink-0">
<button className="p-2 hover:bg-gray-100 rounded-btn min-h-[44px] min-w-[44px] flex items-center justify-center">
<ArrowLeftIcon className="w-5 h-5" />
</button>
</Link>
<div className="flex-1 min-w-0">
<h1 className="text-xl md:text-2xl font-bold text-primary-dark truncate">{event.title}</h1>
<p className="text-sm text-gray-500">{formatDateShort(event.startDatetime)} &middot; {fmtTime(event.startDatetime)}</p>
</div>
{/* Desktop header actions */}
<div className="hidden md:flex items-center gap-2 flex-shrink-0">
<Link href={`/events/${event.slug}`} target="_blank">
<Button variant="outline" size="sm">
<EyeIcon className="w-4 h-4 mr-1.5" />
View Public
</Button>
</Link>
<Button variant="outline" size="sm" onClick={() => setShowEditForm(true)}>
<PencilIcon className="w-4 h-4 mr-1.5" />
Edit
</Button>
</div>
{/* Mobile header overflow menu */}
<div className="md:hidden flex-shrink-0">
<Dropdown
open={mobileHeaderMenuOpen}
onOpenChange={setMobileHeaderMenuOpen}
trigger={
<button className="p-2 hover:bg-gray-100 rounded-btn min-h-[44px] min-w-[44px] flex items-center justify-center">
<EllipsisVerticalIcon className="w-5 h-5" />
</button>
}
>
<DropdownItem onClick={() => { window.open(`/events/${event.slug}`, '_blank'); setMobileHeaderMenuOpen(false); }}>
<EyeIcon className="w-4 h-4 mr-2" /> View Public
</DropdownItem>
<DropdownItem onClick={() => { setShowEditForm(true); setMobileHeaderMenuOpen(false); }}>
<PencilIcon className="w-4 h-4 mr-2" /> Edit Event
</DropdownItem>
</Dropdown>
</div>
</div>
{/* ============= COMPACT META CHIPS (desktop) ============= */}
<div className="hidden md:flex flex-wrap items-center gap-2 mb-4 ml-[52px]">
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-gray-100 rounded-full text-xs text-gray-700">
<CalendarIcon className="w-3.5 h-3.5" />
{formatDateShort(event.startDatetime)} {fmtTime(event.startDatetime)}{event.endDatetime && ` – ${fmtTime(event.endDatetime)}`}
</span>
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-gray-100 rounded-full text-xs text-gray-700">
<MapPinIcon className="w-3.5 h-3.5" />
{event.location}
</span>
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-gray-100 rounded-full text-xs text-gray-700">
<CurrencyDollarIcon className="w-3.5 h-3.5" />
{event.price === 0 ? 'Free' : formatCurrency(event.price, event.currency)}
</span>
{showStats && (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-gray-100 rounded-full text-xs text-gray-700">
<UsersIcon className="w-3.5 h-3.5" />
{confirmedCount + checkedInCount}/{event.capacity}
</span>
)}
</div>
{/* ============= STATS ROW ============= */}
{/* Desktop: always-visible compact 4-card row */}
<div className="hidden md:block mb-4">
{showStats && (
<div className="grid grid-cols-4 gap-3">
{[
{ label: 'Capacity', value: `${confirmedCount + checkedInCount}/${event.capacity}`, icon: UsersIcon, color: 'bg-blue-50 text-blue-600' },
{ label: 'Confirmed', value: confirmedCount, icon: CheckCircleIcon, color: 'bg-green-50 text-green-600' },
{ label: 'Checked In', value: checkedInCount, icon: TicketIcon, color: 'bg-purple-50 text-purple-600' },
{
label: 'Revenue',
value: formatCurrency(revenue, event.currency),
icon: CurrencyDollarIcon,
color: 'bg-gray-50 text-gray-600',
detail: doorSummary
? `Pre-sale ${formatCurrency(presaleRevenue, event.currency)} · Door ${formatCurrency(doorRevenue, event.currency)}`
: undefined,
},
].map((stat) => (
<div key={stat.label} className="flex items-center gap-2.5 bg-white rounded-card shadow-card px-3 py-2.5">
<div className={clsx('w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0', stat.color)}>
<stat.icon className="w-4 h-4" />
</div>
<div className="min-w-0">
<p className="text-lg font-bold leading-tight truncate">{stat.value}</p>
<p className="text-xs text-gray-500 truncate">{('detail' in stat && stat.detail) || stat.label}</p>
</div>
</div>
))}
</div>
)}
</div>
{/* Mobile: collapsible stats */}
<div className="md:hidden mb-3">
{showStats && (
<div>
<button
onClick={() => setMobileStatsExpanded(!mobileStatsExpanded)}
className="w-full flex items-center justify-between px-3 py-2 bg-white rounded-card shadow-card mb-2 min-h-[44px]"
>
<div className="flex items-center gap-3 overflow-x-auto">
<span className="text-xs font-medium text-gray-500 whitespace-nowrap">Stats</span>
<span className="text-sm font-semibold whitespace-nowrap">{confirmedCount + checkedInCount}/{event.capacity}</span>
<span className="text-xs text-gray-400">|</span>
<span className="text-sm whitespace-nowrap">{checkedInCount} in</span>
<span className="text-xs text-gray-400">|</span>
<span className="text-sm whitespace-nowrap">{formatCurrency(revenue, event.currency)}</span>
</div>
<ChevronDownIcon className={clsx('w-4 h-4 text-gray-400 transition-transform flex-shrink-0 ml-2', mobileStatsExpanded && 'rotate-180')} />
</button>
{mobileStatsExpanded && (
<div className="grid grid-cols-2 gap-2">
{[
{ label: 'Capacity', value: `${confirmedCount + checkedInCount}/${event.capacity}`, icon: UsersIcon, color: 'text-blue-600 bg-blue-50' },
{ label: 'Confirmed', value: confirmedCount, icon: CheckCircleIcon, color: 'text-green-600 bg-green-50' },
{ label: 'Checked In', value: checkedInCount, icon: TicketIcon, color: 'text-purple-600 bg-purple-50' },
{
label: 'Revenue',
value: formatCurrency(revenue, event.currency),
icon: CurrencyDollarIcon,
color: 'text-gray-600 bg-gray-50',
detail: doorSummary
? `Pre-sale ${formatCurrency(presaleRevenue, event.currency)} · Door ${formatCurrency(doorRevenue, event.currency)}`
: undefined,
},
].map((stat) => (
<div key={stat.label} className="flex items-center gap-2 bg-white rounded-card shadow-card px-3 py-2">
<div className={clsx('w-7 h-7 rounded-full flex items-center justify-center flex-shrink-0', stat.color)}>
<stat.icon className="w-3.5 h-3.5" />
</div>
<div className="min-w-0">
<p className="text-base font-bold leading-tight truncate">{stat.value}</p>
<p className="text-[10px] text-gray-500 truncate">{('detail' in stat && stat.detail) || stat.label}</p>
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
{/* ============= TAB BAR ============= */}
{/* Desktop: tab bar inside a card top-section */}
<div className="hidden md:block bg-white rounded-t-2xl shadow-card border-b border-gray-200">
<div ref={tabBarRef} className="sticky top-0 z-30 bg-white rounded-t-2xl px-4">
<nav className="flex gap-1">
{tabs.map((tab) => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={clsx(
'py-2.5 px-3 font-medium text-sm transition-colors flex items-center gap-1.5 whitespace-nowrap relative',
activeTab === tab.key
? 'text-primary-dark'
: 'text-gray-500 hover:text-gray-700 hover:bg-gray-50 rounded-t-lg'
)}
>
<tab.icon className="w-4 h-4" />
{tab.label}
{tab.count !== undefined && <span className="text-xs text-gray-400">({tab.count})</span>}
{activeTab === tab.key && (
<span className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary-yellow rounded-full" />
)}
</button>
))}
</nav>
</div>
</div>
{/* Mobile: segmented tab bar */}
<div className="md:hidden sticky top-0 z-30 -mx-4 px-3 py-1.5 bg-gray-100">
<div className="overflow-x-auto scrollbar-hide">
<div className="flex gap-0.5 min-w-max">
{tabs.map((tab) => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={clsx(
'px-3 py-2 rounded-lg text-xs font-medium transition-all whitespace-nowrap min-h-[36px] flex items-center gap-1',
activeTab === tab.key
? 'bg-white shadow-sm text-primary-dark'
: 'text-gray-500 hover:text-gray-700'
)}
>
<tab.icon className="w-3.5 h-3.5" />
{tab.label}
{tab.count !== undefined && <span className="text-[10px] opacity-70">({tab.count})</span>}
</button>
))}
</div>
</div>
</div>
{/* ============= TAB CONTENT ============= */}
<div className="md:bg-white md:rounded-b-2xl md:shadow-card md:p-4 pt-3 md:pt-4">
{activeTab === 'overview' && (
<OverviewTab
event={event}
formatDate={formatDate}
fmtTime={fmtTime}
formatCurrency={formatCurrency}
confirmedCount={confirmedCount}
checkedInCount={checkedInCount}
/>
)}
{activeTab === 'attendees' && (
<AttendeesTab
locale={locale}
tickets={tickets}
filteredTickets={filteredTickets}
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
statusFilter={statusFilter}
setStatusFilter={setStatusFilter}
pendingCount={pendingCount}
confirmedCount={confirmedCount}
checkedInCount={checkedInCount}
cancelledCount={cancelledCount}
onHoldCount={onHoldCount}
exporting={exporting}
showExportDropdown={showExportDropdown}
setShowExportDropdown={setShowExportDropdown}
showAddTicketDropdown={showAddTicketDropdown}
setShowAddTicketDropdown={setShowAddTicketDropdown}
handleExportAttendees={handleExportAttendees}
openAddTicket={openAddTicket}
setMobileFilterOpen={setMobileFilterOpen}
setShowExportSheet={setShowExportSheet}
setShowAddTicketSheet={setShowAddTicketSheet}
getPrimaryAction={getPrimaryAction}
handleOpenNoteModal={handleOpenNoteModal}
handleReactivate={handleReactivate}
handleMarkPaid={handleMarkPaid}
handleCheckin={handleCheckin}
/>
)}
{activeTab === 'tickets' && (
<TicketsTab
locale={locale}
confirmedTickets={confirmedTickets}
filteredConfirmedTickets={filteredConfirmedTickets}
ticketSearchQuery={ticketSearchQuery}
setTicketSearchQuery={setTicketSearchQuery}
ticketStatusFilter={ticketStatusFilter}
setTicketStatusFilter={setTicketStatusFilter}
confirmedCount={confirmedCount}
checkedInCount={checkedInCount}
exporting={exporting}
showTicketExportDropdown={showTicketExportDropdown}
setShowTicketExportDropdown={setShowTicketExportDropdown}
handleExportTickets={handleExportTickets}
handleCheckin={handleCheckin}
handleRemoveCheckin={handleRemoveCheckin}
setShowTicketExportSheet={setShowTicketExportSheet}
/>
)}
{activeTab === 'email' && (
<EmailTab
templates={templates}
selectedTemplate={selectedTemplate}
setSelectedTemplate={setSelectedTemplate}
recipientFilter={recipientFilter}
setRecipientFilter={setRecipientFilter}
customMessage={customMessage}
setCustomMessage={setCustomMessage}
sending={sending}
handlePreviewEmail={handlePreviewEmail}
handleSendEmail={handleSendEmail}
getFilteredRecipientCount={getFilteredRecipientCount}
ticketsCount={tickets.length}
confirmedCount={confirmedCount}
pendingCount={pendingCount}
checkedInCount={checkedInCount}
cancelledCount={cancelledCount}
/>
)}
{activeTab === 'payments' && (
<PaymentsTab locale={locale} payments={payments} doorSummary={doorSummary} />
)}
</div>
{/* ============= MODALS ============= */}
<EventModals
ticketsCount={tickets.length}
pendingCount={pendingCount}
confirmedCount={confirmedCount}
checkedInCount={checkedInCount}
cancelledCount={cancelledCount}
onHoldCount={onHoldCount}
statusFilter={statusFilter}
setStatusFilter={setStatusFilter}
mobileFilterOpen={mobileFilterOpen}
setMobileFilterOpen={setMobileFilterOpen}
showAddTicketSheet={showAddTicketSheet}
setShowAddTicketSheet={setShowAddTicketSheet}
openAddTicket={openAddTicket}
showExportSheet={showExportSheet}
setShowExportSheet={setShowExportSheet}
handleExportAttendees={handleExportAttendees}
showTicketExportSheet={showTicketExportSheet}
setShowTicketExportSheet={setShowTicketExportSheet}
handleExportTickets={handleExportTickets}
submitting={submitting}
showNoteModal={showNoteModal}
setShowNoteModal={setShowNoteModal}
selectedTicket={selectedTicket}
setSelectedTicket={setSelectedTicket}
noteText={noteText}
setNoteText={setNoteText}
handleSaveNote={handleSaveNote}
previewHtml={previewHtml}
setPreviewHtml={setPreviewHtml}
/>
<AddTicketModal
open={showAddTicketModal}
onClose={() => setShowAddTicketModal(false)}
form={addTicketForm}
setForm={setAddTicketForm}
onSubmit={handleAddTicket}
submitting={submitting}
eventPriceLabel={event.price === 0 ? 'Free' : formatCurrency(event.price, event.currency)}
/>
<EventFormModal
open={showEditForm}
event={event}
featuredEventId={featuredEventId}
onFeaturedChange={setFeaturedEventId}
onClose={() => setShowEditForm(false)}
onSaved={() => { setShowEditForm(false); loadEventData(); }}
/>
<AdminMobileStyles />
</div>
);
return <EventDetailView eventId={params.id as string} backHref="/admin/events" />;
}
@@ -24,6 +24,7 @@ interface EventFormData {
endDatetime: string;
location: string;
locationUrl: string;
series: string;
price: number;
// Kept as the raw input so "empty" (not set -> falls back to price) stays
// distinguishable from 0 (a free walk-in).
@@ -49,7 +50,7 @@ const FALLBACK_PRESALE_DEFAULTS: PresaleDefaults = { enabled: true, minutesBefor
const EMPTY_FORM: EventFormData = {
title: '', titleEs: '', slug: '', description: '', descriptionEs: '',
shortDescription: '', shortDescriptionEs: '',
startDatetime: '', endDatetime: '', location: '', locationUrl: '',
startDatetime: '', endDatetime: '', location: '', locationUrl: '', series: '',
price: 0, walkInPrice: '', currency: 'PYG', capacity: 50, status: 'draft',
bannerUrl: '', externalBookingEnabled: false, externalBookingUrl: '',
presaleClosureEnabled: FALLBACK_PRESALE_DEFAULTS.enabled,
@@ -147,7 +148,7 @@ export default function EventFormModal({
shortDescription: event.shortDescription || '', shortDescriptionEs: event.shortDescriptionEs || '',
startDatetime: isoToLocalDatetime(event.startDatetime),
endDatetime: event.endDatetime ? isoToLocalDatetime(event.endDatetime) : '',
location: event.location, locationUrl: event.locationUrl || '',
location: event.location, locationUrl: event.locationUrl || '', series: event.series || '',
price: event.price, currency: event.currency, capacity: event.capacity,
walkInPrice: event.walkInPrice == null ? '' : String(event.walkInPrice),
status: event.status, bannerUrl: event.bannerUrl || '',
@@ -232,6 +233,7 @@ export default function EventFormModal({
startDatetime: formData.startDatetime,
endDatetime: formData.endDatetime || undefined,
location: formData.location, locationUrl: formData.locationUrl || undefined,
series: formData.series.trim() || null,
price: formData.price, currency: formData.currency, capacity: formData.capacity,
// Empty means "not set" and must be saved as null, never 0
walkInPrice: formData.walkInPrice.trim() === '' ? null : Number(formData.walkInPrice),
@@ -360,6 +362,11 @@ export default function EventFormModal({
onChange={(e) => setFormData({ ...formData, location: e.target.value })} required />
<Input label="Location URL (Google Maps)" type="url" value={formData.locationUrl}
onChange={(e) => setFormData({ ...formData, locationUrl: e.target.value })} />
<div>
<Input label={t('admin.eventForm.series')} value={formData.series} maxLength={100} placeholder="Morning Club"
onChange={(e) => setFormData({ ...formData, series: e.target.value })} />
<p className="text-xs text-gray-500 mt-1">{t('admin.eventForm.seriesHint')}</p>
</div>
<div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
@@ -0,0 +1,83 @@
import Card from '@/components/ui/Card';
import { Skeleton, SkeletonGroup } from '@/components/ui/Skeleton';
// Loading placeholders for the admin events list. The page keeps its title,
// Create button and table header on screen and swaps only the rows for these,
// so nothing jumps when the data lands. loading.tsx composes the same pieces
// for the instant route-transition shell.
const TH = 'px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider';
export function EventsTableHead() {
return (
<thead className="bg-secondary-gray">
<tr>
<th className={`text-left ${TH}`}>Event</th>
<th className={`text-left ${TH}`}>Date</th>
<th className={`text-left ${TH}`}>Capacity</th>
<th className={`text-left ${TH}`}>Status</th>
<th className={`text-right ${TH}`}>Actions</th>
</tr>
</thead>
);
}
/** Desktop table body rows: thumbnail + title/location, date, capacity, badge, actions. */
export function EventRowsSkeleton({ rows = 8 }: { rows?: number }) {
return (
<tbody className="divide-y divide-secondary-light-gray" aria-hidden="true">
{Array.from({ length: rows }).map((_, i) => (
<tr key={i}>
<td className="px-4 py-3">
<div className="flex items-center gap-3">
<Skeleton className="w-10 h-10 flex-shrink-0" />
<div className="flex-1">
<Skeleton className={i % 2 === 0 ? 'h-4 w-48' : 'h-4 w-36'} />
<Skeleton className="mt-1.5 h-3 w-28" />
</div>
</div>
</td>
<td className="px-4 py-3"><Skeleton className="h-4 w-24" /></td>
<td className="px-4 py-3"><Skeleton className="h-4 w-14" /></td>
<td className="px-4 py-3"><Skeleton className="h-5 w-20 rounded-full" /></td>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-1">
<Skeleton className="w-8 h-8 rounded-btn" />
<Skeleton className="w-8 h-8 rounded-btn" />
<Skeleton className="w-8 h-8 rounded-btn" />
</div>
</td>
</tr>
))}
</tbody>
);
}
/** Mobile card list: thumbnail, three text lines, status badge, footer row. */
export function EventCardsSkeleton({ count = 4 }: { count?: number }) {
return (
<SkeletonGroup className="space-y-2">
{Array.from({ length: count }).map((_, i) => (
<Card key={i} className="p-3" aria-hidden="true">
<div className="flex items-start gap-3">
<Skeleton className="w-14 h-14 flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<div className="flex-1">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="mt-1.5 h-3 w-1/3" />
<Skeleton className="mt-1.5 h-3 w-1/2" />
</div>
<Skeleton className="h-5 w-16 rounded-full flex-shrink-0" />
</div>
<div className="flex items-center justify-between mt-2 pt-2 border-t border-gray-100">
<Skeleton className="h-3 w-20" />
<Skeleton className="w-9 h-9 rounded-btn" />
</div>
</div>
</div>
</Card>
))}
</SkeletonGroup>
);
}
+29
View File
@@ -0,0 +1,29 @@
'use client';
import { useLanguage } from '@/context/LanguageContext';
import Card from '@/components/ui/Card';
import { Skeleton } from '@/components/ui/Skeleton';
import { EventsTableHead, EventRowsSkeleton, EventCardsSkeleton } from './_components/EventsListSkeleton';
// Route-level shell shown the moment an admin navigates here, while the page
// chunk loads. The title and table header are real; only the rows are placeholders.
export default function AdminEventsLoading() {
const { t } = useLanguage();
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>
<Skeleton className="h-10 w-36 rounded-btn hidden md:block" />
</div>
<Card className="overflow-hidden hidden md:block">
<table className="w-full" aria-busy="true">
<EventsTableHead />
<EventRowsSkeleton />
</table>
</Card>
<div className="md:hidden">
<EventCardsSkeleton />
</div>
</div>
);
}
+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>
@@ -0,0 +1,86 @@
'use client';
// Profit per event. Diverging by sign: blue = profit, red = loss, one axis,
// event names on the category axis so color is never the only cue. A bar far
// larger than the rest is clipped at the axis limit, marked with a break and
// labelled with its real value, so the other events stay readable.
import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, Tooltip, CartesianGrid, Cell, ReferenceLine, LabelList } from 'recharts';
import { formatPyg, formatPygShort } from '@/lib/money';
import { clampRows, clampedAxis } from './clamp';
export default function ProfitByEventChart({ rows, locale }: { rows: { id: string; name: string; profit: number }[]; locale: string }) {
const { rows: clamped, limit } = clampRows(rows);
const axis = limit !== null ? clampedAxis(clamped.map((c) => c.shown)) : null;
// Clipped bars run to the (rounded) end of the axis so the cut is obvious.
const data = clamped.map((c) => ({
...c.row,
clipped: c.clipped,
shown: c.clipped && axis ? (c.row.profit > 0 ? axis.domain[1] : axis.domain[0]) : c.shown,
}));
// Break mark plus the real value inside the clipped end of the bar.
const renderClipLabel = (props: any) => {
const { x, y, width, height, index } = props;
const d = data[index];
if (!d?.clipped) return null;
const left = Math.min(x, x + width);
const right = Math.max(x, x + width);
const positive = d.profit > 0;
const edge = positive ? right : left;
const breakX = positive ? edge - 10 : edge + 10;
const text = `${formatPygShort(d.profit, locale)} PYG`;
return (
<g pointerEvents="none">
<path d={`M${breakX - 3},${y + height + 2} L${breakX + 1},${y - 2} M${breakX + 2},${y + height + 2} L${breakX + 6},${y - 2}`} stroke="#fff" strokeWidth={2} />
<text
x={positive ? breakX - 6 : breakX + 10}
y={y + height / 2}
dy={4}
textAnchor={positive ? 'end' : 'start'}
fontSize={11}
fontWeight={600}
fill="#fff"
>
{text}
</text>
</g>
);
};
return (
<div>
<ResponsiveContainer width="100%" height={Math.max(160, rows.length * 30 + 32)}>
<BarChart data={data} layout="vertical" margin={{ top: 0, right: 16, left: 0, bottom: 0 }}>
<CartesianGrid horizontal={false} stroke="#eeeeec" />
<XAxis
type="number"
domain={axis ? axis.domain : ['auto', 'auto']}
ticks={axis?.ticks}
allowDataOverflow={axis !== null}
stroke="#8a8984"
fontSize={11}
tickLine={false}
axisLine={false}
tickFormatter={(v: number) => formatPygShort(v, locale)}
/>
<YAxis type="category" dataKey="name" stroke="#8a8984" fontSize={11} tickLine={false} axisLine={false} width={150} tick={{ fill: '#52514e', fontSize: 11 }} />
<ReferenceLine x={0} stroke="#8a8984" />
<Tooltip
cursor={{ fill: 'rgba(0,0,0,0.04)' }}
content={({ active, payload }: any) => active && payload?.length ? (
<div className="bg-white border border-gray-200 rounded-lg shadow-sm px-3 py-2 text-xs">
<p className="font-semibold text-primary-dark">{payload[0].payload.name}</p>
<p className="tabular-nums text-gray-600">{formatPyg(payload[0].payload.profit, locale)}</p>
</div>
) : null}
/>
<Bar dataKey="shown" barSize={14} radius={4} isAnimationActive={false}>
{data.map((r) => <Cell key={r.id} fill={r.profit >= 0 ? '#2a78d6' : '#e34948'} />)}
{limit !== null && <LabelList dataKey="shown" content={renderClipLabel} />}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
);
}
@@ -0,0 +1,60 @@
import { describe, it, expect } from 'vitest';
import { clampLimit, clampRows, clampedAxis, niceCeil } from './clamp';
describe('clampLimit', () => {
it('does nothing when values are comparable', () => {
expect(clampLimit([100000, 250000, -80000, 190000])).toBeNull();
});
it('clips a single large outlier just above the next bar', () => {
expect(clampLimit([12000000, 300000, 250000, -100000])).toBe(375000);
});
it('treats a large loss as an outlier too', () => {
expect(clampLimit([-9000000, 200000, 150000])).toBe(250000);
});
it('clips up to three outliers above the first big gap', () => {
expect(clampLimit([50e6, 40e6, 30e6, 200000, 150000])).toBe(250000);
expect(clampLimit([8e6, 3e6, 1.1e6, 400000, 150000])).toBe(3750000);
});
it('leaves a steadily spread-out series alone', () => {
expect(clampLimit([8e6, 4e6, 2e6, 1e6, 500000])).toBeNull();
});
it('does not clip when the gap is below the fourth bar', () => {
expect(clampLimit([4e6, 3e6, 2e6, 1.5e6, 100000])).toBeNull();
});
it('needs at least three non-zero bars', () => {
expect(clampLimit([12000000, 1000])).toBeNull();
expect(clampLimit([12000000, 0, 0, 1000])).toBeNull();
});
});
describe('clampRows', () => {
it('keeps the real value and marks clipped rows', () => {
const { rows, limit } = clampRows([
{ id: 'a', profit: 12000000 }, { id: 'b', profit: 300000 }, { id: 'c', profit: -250000 },
]);
expect(limit).toBe(375000);
expect(rows[0]).toMatchObject({ shown: 375000, clipped: true, row: { profit: 12000000 } });
expect(rows[1]).toMatchObject({ shown: 300000, clipped: false });
expect(rows[2]).toMatchObject({ shown: -250000, clipped: false });
});
});
describe('clampedAxis', () => {
it('rounds the ends up to round numbers', () => {
expect(niceCeil(375000)).toBe(500000);
expect(niceCeil(250000)).toBe(250000);
expect(niceCeil(105)).toBe(200);
expect(niceCeil(0)).toBe(0);
});
it('puts ticks at the ends, halves and zero', () => {
expect(clampedAxis([-375000, 120000, 375000])).toEqual({ domain: [-500000, 500000], ticks: [-500000, -250000, 0, 250000, 500000] });
expect(clampedAxis([10, 375])).toEqual({ domain: [0, 500], ticks: [0, 250, 500] });
});
});
@@ -0,0 +1,57 @@
// Axis clamping for the profit-per-event chart: one huge event would otherwise
// flatten every other bar. Pure so it can be unit tested.
/** How much bigger than the next bar a bar must be to count as an outlier. */
const OUTLIER_RATIO = 2.5;
/** Never clip more than this many bars; past that the data is just spread out. */
const MAX_CLIPPED = 3;
/**
* The axis limit (absolute PYG) to clip at, or null when nothing stands out.
* Walks the largest magnitudes from the top and clips above the first big gap.
*/
export function clampLimit(values: number[]): number | null {
const mags = values.map((v) => Math.abs(v)).filter((v) => v > 0).sort((a, b) => b - a);
if (mags.length < 3) return null;
for (let k = 1; k <= Math.min(MAX_CLIPPED, mags.length - 2); k++) {
if (mags[k - 1] > OUTLIER_RATIO * mags[k]) return Math.round(mags[k] * 1.25);
}
return null;
}
export interface ClampedRow<R> {
row: R;
/** Value drawn on the chart (clipped to the limit). */
shown: number;
/** True when the bar is cut off; its real value is labelled on it. */
clipped: boolean;
}
export function clampRows<R extends { profit: number }>(rows: R[]): { rows: ClampedRow<R>[]; limit: number | null } {
const limit = clampLimit(rows.map((r) => r.profit));
return {
limit,
rows: rows.map((row) => {
const clipped = limit !== null && Math.abs(row.profit) > limit;
return { row, shown: clipped ? Math.sign(row.profit) * limit! : row.profit, clipped };
}),
};
}
/** Smallest "round" number (1, 2, 2.5 or 5 times a power of ten) at or above n, for axis ends. */
export function niceCeil(n: number): number {
if (n <= 0) return 0;
const pow = 10 ** Math.floor(Math.log10(n));
const step = [1, 2, 2.5, 5, 10].find((s) => s * pow >= n)!;
return step * pow;
}
/** Axis domain and ticks for a clipped chart: round ends, ticks at halves. */
export function clampedAxis(shown: number[]): { domain: [number, number]; ticks: number[] } {
const max = Math.max(0, ...shown);
const min = Math.min(0, ...shown);
const hi = niceCeil(max);
const lo = min < 0 ? -niceCeil(-min) : 0;
const ticks = [lo, lo / 2, 0, hi / 2, hi].filter((v, i, a) => a.indexOf(v) === i);
return { domain: [lo, hi], ticks };
}
+294
View File
@@ -0,0 +1,294 @@
'use client';
import { useEffect, useState } from 'react';
import dynamic from 'next/dynamic';
import Link from 'next/link';
import toast from 'react-hot-toast';
import clsx from 'clsx';
import { ChartBarIcon, EyeSlashIcon, FunnelIcon, ChevronDownIcon, ClockIcon, XMarkIcon } from '@heroicons/react/24/outline';
import { useLanguage } from '@/context/LanguageContext';
import { usePrivacy } from '@/context/PrivacyContext';
import { financeApi, type FinanceOverview, type FinanceTotals } from '@/lib/api';
import Card from '@/components/ui/Card';
import { Skeleton } from '@/components/ui/Skeleton';
import { Money, FinanceStatusBadge, DateInput, inputClass } from '../events/[id]/_finance/ui';
import { tCount } from '../events/[id]/_finance/derive';
import { clampLimit } from './_components/clamp';
const ProfitByEventChart = dynamic(() => import('./_components/ProfitByEventChart'), { ssr: false, loading: () => <Skeleton className="h-40" /> });
type Filters = { from: string; to: string; series: string; venue: string; partner: string };
function TotalsTable({ rows, label, t }: { rows: (FinanceTotals & { key: string; name: string })[]; label: string; t: (k: string) => string }) {
return (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="text-xs text-gray-500 border-b border-gray-100">
<tr>
<th className="text-left font-medium py-2 pr-3">{label}</th>
<th className="text-right font-medium py-2 px-3">{t('admin.financeOverview.events')}</th>
{/* Phones: gross and expenses fold away so the table fits without scrolling */}
<th className="text-right font-medium py-2 px-3 hidden md:table-cell">{t('admin.finance.kpi.gross')}</th>
<th className="text-right font-medium py-2 px-3 hidden md:table-cell">{t('admin.finance.kpi.expenses')}</th>
<th className="text-right font-medium py-2 pl-3">{t('admin.finance.kpi.profit')}</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{rows.map((r) => (
<tr key={r.key}>
<td className="py-2 pr-3 truncate max-w-[220px]">{r.name}</td>
<td className="py-2 px-3 text-right tabular-nums">{r.events}</td>
<td className="py-2 px-3 text-right whitespace-nowrap hidden md:table-cell"><Money value={r.gross} /></td>
<td className="py-2 px-3 text-right whitespace-nowrap hidden md:table-cell"><Money value={r.expenses} /></td>
<td className="py-2 pl-3 text-right whitespace-nowrap"><Money value={r.profit} className={clsx('font-semibold', r.profit < 0 && 'text-red-600')} /></td>
</tr>
))}
</tbody>
</table>
</div>
);
}
export default function AdminFinancePage() {
const { t, locale } = useLanguage();
const { privacyMode } = usePrivacy();
const [filters, setFilters] = useState<Filters>({ from: '', to: '', series: '', venue: '', partner: '' });
const [data, setData] = useState<FinanceOverview | null>(null);
const [loading, setLoading] = useState(true);
const [filtersOpen, setFiltersOpen] = useState(false);
const [readyOnly, setReadyOnly] = useState(false);
const activeFilters = Object.values(filters).filter(Boolean).length;
useEffect(() => {
setLoading(true);
financeApi.overview(filters)
.then(setData)
.catch((error) => toast.error(error.message))
.finally(() => setLoading(false));
}, [filters]);
const set = (k: keyof Filters, v: string) => setFilters((f) => ({ ...f, [k]: v }));
const title = (e: { title: string; titleEs: string | null }) => (locale === 'es' && e.titleEs) || e.title;
// Past events whose books are still open: the server lists them, including
// ones with no money recorded yet (which the main list leaves out).
const readyIds = new Set(data?.readyToClose.map((e) => e.id) ?? []);
const isReady = (e: { id: string }) => readyIds.has(e.id);
const readyCount = readyIds.size;
const showReady = readyOnly && readyCount > 0;
const tableEvents = data ? (showReady ? data.readyToClose : data.events) : [];
const chartRows = data ? data.events.slice(0, 30).map((e) => ({ id: e.id, name: title(e), profit: e.profit })) : [];
const clipped = clampLimit(chartRows.map((r) => r.profit)) !== null;
const fmtDate = (iso: string | null) => (iso ? new Date(iso).toLocaleDateString(locale === 'es' ? 'es-PY' : 'en-US', { dateStyle: 'medium' }) : '');
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-primary-dark flex items-center gap-3">
<ChartBarIcon className="w-7 h-7" />
{t('admin.financeOverview.title')}
</h1>
<p className="text-gray-500 mt-1 text-sm">{t('admin.financeOverview.subtitle')}</p>
</div>
{/* Filters: one row above everything they affect */}
<Card className="p-4">
{/* Phones: filters fold away behind one button */}
<button
onClick={() => setFiltersOpen(!filtersOpen)}
aria-expanded={filtersOpen}
className="md:hidden w-full flex items-center gap-2 min-h-[44px] text-sm font-medium cursor-pointer"
>
<FunnelIcon className="w-4 h-4" aria-hidden />
{t('admin.financeOverview.filters')}
{activeFilters > 0 && <span className="rounded-full bg-primary-yellow px-2 text-xs">{activeFilters}</span>}
<ChevronDownIcon className={clsx('w-4 h-4 ml-auto transition-transform', filtersOpen && 'rotate-180')} aria-hidden />
</button>
<div className={clsx('grid-cols-2 md:grid-cols-5 gap-3 md:grid', filtersOpen ? 'grid mt-3 md:mt-0' : 'hidden')}>
<label className="text-xs font-medium">
{t('admin.financeOverview.from')}
<DateInput className="mt-1" clearable value={filters.from} onChange={(v) => set('from', v)} />
</label>
<label className="text-xs font-medium">
{t('admin.financeOverview.to')}
<DateInput className="mt-1" clearable value={filters.to} onChange={(v) => set('to', v)} />
</label>
<label className="text-xs font-medium">
{t('admin.financeOverview.series')}
<select className={clsx(inputClass, 'mt-1')} value={filters.series} onChange={(e) => set('series', e.target.value)}>
<option value="">{t('admin.financeOverview.all')}</option>
{data?.filters.series.map((s) => <option key={s} value={s}>{s}</option>)}
<option value="__none__">{t('admin.financeOverview.noSeries')}</option>
</select>
</label>
<label className="text-xs font-medium">
{t('admin.financeOverview.venue')}
<select className={clsx(inputClass, 'mt-1')} value={filters.venue} onChange={(e) => set('venue', e.target.value)}>
<option value="">{t('admin.financeOverview.all')}</option>
{data?.filters.venues.map((v) => <option key={v} value={v}>{v}</option>)}
</select>
</label>
<label className="text-xs font-medium col-span-2 md:col-span-1">
{t('admin.financeOverview.partner')}
<select className={clsx(inputClass, 'mt-1')} value={filters.partner} onChange={(e) => set('partner', e.target.value)}>
<option value="">{t('admin.financeOverview.all')}</option>
{data?.filters.partners.map((p) => <option key={p.key} value={p.key}>{p.name}</option>)}
</select>
</label>
</div>
</Card>
{loading && !data ? <Skeleton className="h-64" /> : !data || (data.events.length === 0 && data.readyToClose.length === 0) ? (
<Card className="p-8 text-center text-sm text-gray-500">{t('admin.financeOverview.empty')}</Card>
) : (
<div className={clsx('space-y-6', loading && 'opacity-60')}>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{[
{ label: t('admin.financeOverview.events'), value: <span>{data.totals.events}</span> },
{ label: t('admin.finance.kpi.gross'), value: <Money value={data.totals.gross} /> },
{ label: t('admin.finance.kpi.expenses'), value: <Money value={data.totals.expenses} /> },
{ label: t(data.totals.profit < 0 ? 'admin.finance.kpi.loss' : 'admin.finance.kpi.profit'), value: <Money value={data.totals.profit} className={clsx(data.totals.profit < 0 && !privacyMode && 'text-red-600')} /> },
].map((k) => (
<div key={k.label} className="bg-white rounded-card shadow-card px-4 py-3">
<p className="text-lg font-bold">{k.value}</p>
<p className="text-xs text-gray-500">{k.label}</p>
</div>
))}
</div>
{readyCount > 0 && (
<div className="flex flex-col sm:flex-row sm:items-center gap-2 rounded-card border border-amber-200 bg-amber-50 text-amber-900 px-4 py-3">
<p className="flex items-center gap-2 text-sm flex-1">
<ClockIcon className="w-5 h-5 flex-shrink-0" aria-hidden />
<span><span className="font-semibold">{tCount(t, 'admin.financeOverview.readyCount', readyCount)}</span> {t('admin.financeOverview.readyHint')}</span>
</p>
<button
onClick={() => setReadyOnly(!readyOnly)}
aria-pressed={readyOnly}
className={clsx(
'self-start sm:self-auto inline-flex items-center gap-1.5 rounded-full px-3 min-h-[36px] text-sm font-medium cursor-pointer border',
readyOnly ? 'bg-amber-900 text-white border-amber-900' : 'bg-white border-amber-300 hover:bg-amber-100',
)}
>
{readyOnly && <XMarkIcon className="w-4 h-4" aria-hidden />}
{t(readyOnly ? 'admin.financeOverview.showAll' : 'admin.financeOverview.showReady')}
</button>
</div>
)}
<Card className="p-4">
<h2 className="text-sm font-semibold mb-3">{t(showReady ? 'admin.financeOverview.readyTitle' : 'admin.financeOverview.perEvent')}</h2>
{showReady || chartRows.length === 0 ? null : privacyMode ? (
<p className="text-sm text-gray-500 flex items-center gap-2 py-6 justify-center"><EyeSlashIcon className="w-5 h-5" />{t('admin.finance.hiddenStats')}</p>
) : (
<>
<ProfitByEventChart rows={chartRows} locale={locale} />
{clipped && <p className="text-xs text-gray-500 mt-1">{t('admin.financeOverview.clippedHint')}</p>}
</>
)}
{/* Phones: one card per event, no sideways scrolling */}
<ul className="md:hidden mt-4 divide-y divide-gray-100">
{tableEvents.map((e) => (
<li key={e.id}>
<Link href={`/admin/events/${e.id}?tab=finance`} className="block py-3 active:bg-gray-50">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="font-medium truncate">{title(e)}</p>
<p className="text-xs text-gray-500 truncate">{fmtDate(e.startDatetime)} · {e.location}</p>
</div>
<Money value={e.profit} className={clsx('font-semibold whitespace-nowrap', e.profit < 0 && 'text-red-600')} />
</div>
<div className="mt-1.5 flex flex-wrap items-center justify-between gap-x-3 gap-y-1 text-xs text-gray-500">
<span>
{t('admin.finance.kpi.gross')} <Money value={e.gross} /> · {t('admin.finance.kpi.expenses')} <Money value={e.expenses} />
</span>
{isReady(e)
? <span className="inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium bg-amber-100 text-amber-900 whitespace-nowrap">{t('admin.financeOverview.readyBadge')}</span>
: <FinanceStatusBadge status={e.financeStatus} label={t(`admin.finance.status.${e.financeStatus}`)} />}
</div>
</Link>
</li>
))}
</ul>
<div className="hidden md:block mt-4">
<table className="w-full text-sm">
<thead className="text-xs text-gray-500 border-b border-gray-100">
<tr>
<th className="text-left font-medium py-2 pr-3">{t('admin.financeOverview.event')}</th>
<th className="text-left font-medium py-2 px-3">{t('admin.financeOverview.series')}</th>
<th className="text-right font-medium py-2 px-3">{t('admin.finance.kpi.gross')}</th>
<th className="text-right font-medium py-2 px-3">{t('admin.finance.kpi.expenses')}</th>
<th className="text-right font-medium py-2 px-3">{t('admin.finance.kpi.profit')}</th>
<th className="text-left font-medium py-2 pl-3" />
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{tableEvents.map((e) => (
<tr key={e.id}>
<td className="py-2 pr-3">
<Link href={`/admin/events/${e.id}?tab=finance`} className="font-medium hover:underline">{title(e)}</Link>
<p className="text-xs text-gray-500">{fmtDate(e.startDatetime)} · {e.location}</p>
</td>
<td className="py-2 px-3 text-xs text-gray-600">{e.series || '-'}</td>
<td className="py-2 px-3 text-right whitespace-nowrap"><Money value={e.gross} /></td>
<td className="py-2 px-3 text-right whitespace-nowrap"><Money value={e.expenses} /></td>
<td className="py-2 px-3 text-right whitespace-nowrap"><Money value={e.profit} className={clsx('font-semibold', e.profit < 0 && 'text-red-600')} /></td>
<td className="py-2 pl-3">
{isReady(e)
? <span className="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium bg-amber-100 text-amber-900 whitespace-nowrap">{t('admin.financeOverview.readyBadge')}</span>
: <FinanceStatusBadge status={e.financeStatus} label={t(`admin.finance.status.${e.financeStatus}`)} />}
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card className="p-4">
<h2 className="text-sm font-semibold mb-2">{t('admin.financeOverview.perSeries')}</h2>
<TotalsTable t={t} label={t('admin.financeOverview.series')}
rows={data.bySeries.map((s) => ({ ...s, key: s.series || '__none__', name: s.series || t('admin.financeOverview.noSeries') }))} />
</Card>
<Card className="p-4">
<h2 className="text-sm font-semibold mb-2">{t('admin.financeOverview.perVenue')}</h2>
<TotalsTable t={t} label={t('admin.financeOverview.venue')} rows={data.byVenue.map((v) => ({ ...v, key: v.venue, name: v.venue || '-' }))} />
</Card>
</div>
<Card className="p-4">
<h2 className="text-sm font-semibold mb-2">{t('admin.financeOverview.perPartner')}</h2>
{data.byPartner.length === 0 ? <p className="text-sm text-gray-400">{t('admin.finance.split.empty')}</p> : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="text-xs text-gray-500 border-b border-gray-100">
<tr>
<th className="text-left font-medium py-2 pr-3">{t('admin.financeOverview.partner')}</th>
<th className="text-right font-medium py-2 px-3">{t('admin.financeOverview.events')}</th>
<th className="text-right font-medium py-2 px-3 hidden md:table-cell">{t('admin.finance.split.share')}</th>
<th className="text-right font-medium py-2 px-3 hidden md:table-cell">{t('admin.finance.split.reimbursement')}</th>
<th className="text-right font-medium py-2 px-3">{t('admin.financeOverview.paid')}</th>
<th className="text-right font-medium py-2 pl-3">{t('admin.financeOverview.pending')}</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{data.byPartner.map((p) => (
<tr key={p.key}>
<td className="py-2 pr-3">{p.name}</td>
<td className="py-2 px-3 text-right tabular-nums">{p.events}</td>
<td className="py-2 px-3 text-right whitespace-nowrap hidden md:table-cell"><Money value={p.share} /></td>
<td className="py-2 px-3 text-right whitespace-nowrap hidden md:table-cell"><Money value={p.reimbursement} /></td>
<td className="py-2 px-3 text-right whitespace-nowrap"><Money value={p.paid} /></td>
<td className="py-2 pl-3 text-right whitespace-nowrap"><Money value={p.pending} className="font-semibold" /></td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Card>
</div>
)}
</div>
);
}
+2
View File
@@ -29,6 +29,7 @@ import {
QuestionMarkCircleIcon,
EyeIcon,
EyeSlashIcon,
ChartBarIcon,
} from '@heroicons/react/24/outline';
import clsx from 'clsx';
import { useState } from 'react';
@@ -67,6 +68,7 @@ function AdminLayoutInner({
{ name: locale === 'es' ? 'Escáner' : 'Scanner', href: '/admin/scanner', icon: QrCodeIcon, allowedRoles: ['admin', 'organizer', 'staff'] },
{ name: t('admin.nav.users'), href: '/admin/users', icon: UsersIcon, allowedRoles: ['admin'] },
{ name: t('admin.nav.payments'), href: '/admin/payments', icon: CreditCardIcon, allowedRoles: ['admin', 'organizer'] },
{ name: t('admin.nav.finance'), href: '/admin/finance', icon: ChartBarIcon, allowedRoles: ['admin'] },
{ name: locale === 'es' ? 'Opciones de Pago' : 'Payment Options', href: '/admin/payment-options', icon: BanknotesIcon, allowedRoles: ['admin', 'organizer'] },
{ name: t('admin.nav.contacts'), href: '/admin/contacts', icon: EnvelopeIcon, allowedRoles: ['admin', 'organizer', 'marketing'] },
{ name: t('admin.nav.emails'), href: '/admin/emails', icon: InboxIcon, allowedRoles: ['admin', 'organizer'] },
@@ -0,0 +1,396 @@
'use client';
// Site Settings → Expense Templates: expense categories, reusable expense
// templates (calc-type aware), template packs applied to events in one click,
// and the per-method payment fees used for net revenue. Each row saves on its
// own; everything written here is recorded in the finance audit log.
import { useCallback, useEffect, useState } from 'react';
import toast from 'react-hot-toast';
import clsx from 'clsx';
import { PlusIcon, PencilIcon, TrashIcon, ArchiveBoxIcon, ArrowUturnLeftIcon } from '@heroicons/react/24/outline';
import { useLanguage } from '@/context/LanguageContext';
import {
financeApi, CALC_TYPES,
type CalcType, type ExpenseCategory, type ExpenseTemplate, type ExpenseTemplatePack, type PaymentMethodFee,
} from '@/lib/api';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import { Modal, Field, PygInput, Pill, inputClass } from '../../events/[id]/_finance/ui';
import { bpToPercent, formatPercent, formatPyg, percentToBp } from '../../events/[id]/_finance/format';
import { suggestCategory } from '../../events/[id]/_finance/derive';
type T = (key: string, params?: Record<string, string | number>) => string;
function Section({ title, hint, action, children }: { title: string; hint?: string; action?: React.ReactNode; children: React.ReactNode }) {
return (
<Card className="p-4 md:p-6">
<div className="flex items-start justify-between gap-3 mb-4">
<div>
<h2 className="text-lg font-semibold">{title}</h2>
{hint && <p className="text-sm text-gray-500">{hint}</p>}
</div>
{action}
</div>
{children}
</Card>
);
}
const saveError = (t: T) => (error: any) =>
toast.error(error?.code === 'IN_USE' ? t('admin.expenseSettings.inUse') : error.message);
// ==================== Categories ====================
function Categories({ categories, reload, t }: { categories: ExpenseCategory[]; reload: () => void; t: T }) {
const [editing, setEditing] = useState<Partial<ExpenseCategory> | null>(null);
const [saving, setSaving] = useState(false);
const [showArchived, setShowArchived] = useState(false);
const save = async () => {
if (!editing?.nameEn?.trim() || !editing?.nameEs?.trim()) return;
setSaving(true);
const body = { nameEn: editing.nameEn.trim(), nameEs: editing.nameEs.trim(), color: editing.color || '#6B7280', sortOrder: editing.sortOrder ?? categories.length };
try {
if (editing.id) await financeApi.updateCategory(editing.id, body); else await financeApi.createCategory(body);
toast.success(t('admin.expenseSettings.saved'));
setEditing(null);
reload();
} catch (error: any) { saveError(t)(error); } finally { setSaving(false); }
};
const archive = (c: ExpenseCategory) => financeApi.updateCategory(c.id, { archived: !c.archived }).then(reload).catch(saveError(t));
const remove = (c: ExpenseCategory) => {
if (!confirm(t('admin.expenseSettings.confirmDelete', { name: c.nameEn }))) return;
financeApi.deleteCategory(c.id).then(() => { toast.success(t('admin.expenseSettings.deleted')); reload(); }).catch(saveError(t));
};
const visible = categories.filter((c) => showArchived || !c.archived);
return (
<Section
title={t('admin.expenseSettings.categories')}
action={<Button size="sm" onClick={() => setEditing({ color: '#2563EB' })}><PlusIcon className="w-4 h-4 mr-1" />{t('admin.expenseSettings.addCategory')}</Button>}
>
<label className="flex items-center gap-2 text-xs text-gray-500 mb-2">
<input type="checkbox" checked={showArchived} onChange={(e) => setShowArchived(e.target.checked)} className="accent-primary-yellow" />
{t('admin.expenseSettings.showArchived')}
</label>
<ul className="divide-y divide-gray-100">
{visible.map((c) => (
<li key={c.id} className={clsx('flex items-center gap-3 py-2', c.archived && 'opacity-60')}>
<span className="w-3 h-3 rounded-full flex-shrink-0" style={{ background: c.color }} />
<span className="flex-1 min-w-0 text-sm truncate">{c.nameEn} <span className="text-gray-400">/ {c.nameEs}</span></span>
{c.archived && <Pill>{t('admin.expenseSettings.archived')}</Pill>}
<button onClick={() => setEditing(c)} className="p-2 rounded-btn hover:bg-gray-100" aria-label={t('common.edit')}><PencilIcon className="w-4 h-4" /></button>
<button onClick={() => archive(c)} className="p-2 rounded-btn hover:bg-gray-100" title={t(c.archived ? 'admin.expenseSettings.unarchive' : 'admin.expenseSettings.archive')}>
{c.archived ? <ArrowUturnLeftIcon className="w-4 h-4" /> : <ArchiveBoxIcon className="w-4 h-4" />}
</button>
<button onClick={() => remove(c)} className="p-2 rounded-btn hover:bg-gray-100 text-red-600" aria-label={t('common.delete')}><TrashIcon className="w-4 h-4" /></button>
</li>
))}
</ul>
<Modal open={!!editing} onClose={() => setEditing(null)} title={t('admin.expenseSettings.categories')}>
<Field label={t('admin.expenseSettings.nameEn')}>
<input className={inputClass} value={editing?.nameEn || ''} maxLength={100} onChange={(e) => setEditing((x) => ({ ...x, nameEn: e.target.value }))} />
</Field>
<Field label={t('admin.expenseSettings.nameEs')}>
<input className={inputClass} value={editing?.nameEs || ''} maxLength={100} onChange={(e) => setEditing((x) => ({ ...x, nameEs: e.target.value }))} />
</Field>
<Field label={t('admin.expenseSettings.color')}>
<input type="color" className="h-10 w-16 rounded-btn border border-secondary-light-gray" value={editing?.color || '#6B7280'} onChange={(e) => setEditing((x) => ({ ...x, color: e.target.value }))} />
</Field>
<div className="flex gap-3 pt-2">
<Button variant="outline" onClick={() => setEditing(null)} className="flex-1 min-h-[44px]">{t('common.cancel')}</Button>
<Button onClick={save} isLoading={saving} disabled={!editing?.nameEn?.trim() || !editing?.nameEs?.trim()} className="flex-1 min-h-[44px]">{t('common.save')}</Button>
</div>
</Modal>
</Section>
);
}
// ==================== Templates ====================
interface TemplateForm { id?: string; name: string; categoryId: string; description: string; calcType: CalcType; amount: number; percent: string; minimumAmount: number }
function templateSummary(tpl: ExpenseTemplate, t: T, locale: string) {
const kind = t(`admin.finance.calcTypes.${tpl.calcType}`);
if (tpl.calcType === 'percent_of_revenue') return `${kind} · ${formatPercent(tpl.percentBp, locale)}`;
if (tpl.calcType === 'minimum_spend') return `${kind} · ${formatPyg(tpl.amount, locale)} / min ${formatPyg(tpl.minimumAmount, locale)}`;
return `${kind} · ${formatPyg(tpl.amount, locale)}`;
}
/** Label for the main amount field, matching the event expense modal. */
const AMOUNT_LABEL: Record<CalcType, string> = {
fixed: 'admin.finance.expenses.amount',
per_ticket_sold: 'admin.finance.expenses.amountPerTicket',
per_checked_in: 'admin.finance.expenses.amountPerGuest',
percent_of_revenue: 'admin.finance.expenses.percentOfRevenue',
minimum_spend: 'admin.finance.expenses.minimumAmount',
};
function Templates({ templates, categories, reload, t, locale }: { templates: ExpenseTemplate[]; categories: ExpenseCategory[]; reload: () => void; t: T; locale: string }) {
const [form, setForm] = useState<TemplateForm | null>(null);
const [saving, setSaving] = useState(false);
const [showArchived, setShowArchived] = useState(false);
// Category follows the name until someone picks one by hand (same as the event expense modal).
const [categoryAuto, setCategoryAuto] = useState(true);
const set = <K extends keyof TemplateForm>(k: K, v: TemplateForm[K]) => setForm((f) => (f ? { ...f, [k]: v } : f));
const setName = (name: string) => setForm((f) => (f
? { ...f, name, categoryId: categoryAuto ? suggestCategory(name, categories) || '' : f.categoryId }
: f));
const open = (tpl?: ExpenseTemplate) => {
setCategoryAuto(!tpl?.categoryId);
openForm(tpl);
};
const openForm = (tpl?: ExpenseTemplate) => setForm(tpl
? { id: tpl.id, name: tpl.name, categoryId: tpl.categoryId || '', description: tpl.description || '', calcType: tpl.calcType, amount: tpl.amount, percent: tpl.percentBp ? bpToPercent(tpl.percentBp) : '', minimumAmount: tpl.minimumAmount }
: { name: '', categoryId: '', description: '', calcType: 'fixed', amount: 0, percent: '', minimumAmount: 0 });
const save = async () => {
if (!form?.name.trim()) return;
setSaving(true);
const body: Partial<ExpenseTemplate> = {
name: form.name.trim(),
categoryId: form.categoryId || null,
description: form.description.trim() || null,
calcType: form.calcType,
amount: form.calcType === 'percent_of_revenue' ? 0 : form.amount,
percentBp: form.calcType === 'percent_of_revenue' ? percentToBp(form.percent) : 0,
minimumAmount: form.calcType === 'minimum_spend' ? form.minimumAmount : 0,
};
try {
if (form.id) await financeApi.updateTemplate(form.id, body); else await financeApi.createTemplate(body);
toast.success(t('admin.expenseSettings.saved'));
setForm(null);
reload();
} catch (error: any) { saveError(t)(error); } finally { setSaving(false); }
};
const remove = (tpl: ExpenseTemplate) => {
if (!confirm(t('admin.expenseSettings.confirmDelete', { name: tpl.name }))) return;
financeApi.deleteTemplate(tpl.id).then(() => { toast.success(t('admin.expenseSettings.deleted')); reload(); }).catch(saveError(t));
};
const archive = (tpl: ExpenseTemplate) => financeApi.updateTemplate(tpl.id, { archived: !tpl.archived }).then(reload).catch(saveError(t));
const catName = (id: string | null) => {
const c = categories.find((x) => x.id === id);
return c ? (locale === 'es' ? c.nameEs : c.nameEn) : t('admin.finance.charts.uncategorized');
};
const visible = templates.filter((x) => showArchived || !x.archived);
return (
<Section
title={t('admin.expenseSettings.templates')}
action={<Button size="sm" onClick={() => open()}><PlusIcon className="w-4 h-4 mr-1" />{t('admin.expenseSettings.addTemplate')}</Button>}
>
<label className="flex items-center gap-2 text-xs text-gray-500 mb-2">
<input type="checkbox" checked={showArchived} onChange={(e) => setShowArchived(e.target.checked)} className="accent-primary-yellow" />
{t('admin.expenseSettings.showArchived')}
</label>
{visible.length === 0 ? <p className="text-sm text-gray-400">{t('admin.expenseSettings.empty')}</p> : (
<ul className="divide-y divide-gray-100">
{visible.map((tpl) => (
<li key={tpl.id} className={clsx('flex items-center gap-3 py-2', tpl.archived && 'opacity-60')}>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{tpl.name}</p>
<p className="text-xs text-gray-500 truncate">{catName(tpl.categoryId)} · {templateSummary(tpl, t, locale)}</p>
</div>
{tpl.archived && <Pill>{t('admin.expenseSettings.archived')}</Pill>}
<button onClick={() => open(tpl)} className="p-2 rounded-btn hover:bg-gray-100" aria-label={t('common.edit')}><PencilIcon className="w-4 h-4" /></button>
<button onClick={() => archive(tpl)} className="p-2 rounded-btn hover:bg-gray-100" title={t(tpl.archived ? 'admin.expenseSettings.unarchive' : 'admin.expenseSettings.archive')}>
{tpl.archived ? <ArrowUturnLeftIcon className="w-4 h-4" /> : <ArchiveBoxIcon className="w-4 h-4" />}
</button>
<button onClick={() => remove(tpl)} className="p-2 rounded-btn hover:bg-gray-100 text-red-600" aria-label={t('common.delete')}><TrashIcon className="w-4 h-4" /></button>
</li>
))}
</ul>
)}
<Modal open={!!form} onClose={() => setForm(null)} title={t('admin.expenseSettings.templates')} wide>
{form && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="md:col-span-2">
<Field label={t('admin.expenseSettings.name')}>
<input className={inputClass} value={form.name} maxLength={200} onChange={(e) => setName(e.target.value)} />
</Field>
</div>
<Field label={t(AMOUNT_LABEL[form.calcType])}>
{form.calcType === 'percent_of_revenue' ? (
<div className="relative">
<input className={clsx(inputClass, 'pr-8')} inputMode="decimal" value={form.percent} placeholder="10" onChange={(e) => set('percent', e.target.value)} />
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-sm text-gray-400">%</span>
</div>
) : form.calcType === 'minimum_spend' ? (
<PygInput value={form.minimumAmount} onChange={(n) => set('minimumAmount', n)} />
) : (
<PygInput value={form.amount} onChange={(n) => set('amount', n)} />
)}
</Field>
<Field label={t('admin.finance.expenses.calcType')} hint={t(`admin.finance.calcHints.${form.calcType}`)}>
<select className={inputClass} value={form.calcType} onChange={(e) => set('calcType', e.target.value as CalcType)}>
{CALC_TYPES.map((c) => <option key={c} value={c}>{t(`admin.finance.calcTypes.${c}`)}</option>)}
</select>
</Field>
{form.calcType === 'minimum_spend' && (
<Field label={t('admin.finance.expenses.amountPerGuest')}>
<PygInput value={form.amount} onChange={(n) => set('amount', n)} />
</Field>
)}
<Field
label={t('admin.finance.expenses.category')}
hint={categoryAuto && form.categoryId ? t('admin.finance.expenses.categorySuggested') : undefined}
>
<select className={inputClass} value={form.categoryId} onChange={(e) => { setCategoryAuto(false); set('categoryId', e.target.value); }}>
<option value="">{t('admin.finance.charts.uncategorized')}</option>
{categories.filter((c) => !c.archived || c.id === form.categoryId).map((c) => <option key={c.id} value={c.id}>{locale === 'es' ? c.nameEs : c.nameEn}</option>)}
</select>
</Field>
<div className="md:col-span-2">
<Field label={t('admin.expenseSettings.description')}>
<textarea className={inputClass} rows={2} value={form.description} maxLength={1000} onChange={(e) => set('description', e.target.value)} />
</Field>
</div>
</div>
)}
<div className="flex gap-3 pt-2">
<Button variant="outline" onClick={() => setForm(null)} className="flex-1 min-h-[44px]">{t('common.cancel')}</Button>
<Button onClick={save} isLoading={saving} disabled={!form?.name.trim()} className="flex-1 min-h-[44px]">{t('common.save')}</Button>
</div>
</Modal>
</Section>
);
}
// ==================== Packs ====================
function Packs({ packs, templates, reload, t, locale }: { packs: ExpenseTemplatePack[]; templates: ExpenseTemplate[]; reload: () => void; t: T; locale: string }) {
const [form, setForm] = useState<{ id?: string; name: string; description: string; templateIds: string[] } | null>(null);
const [saving, setSaving] = useState(false);
const name = (id: string) => templates.find((x) => x.id === id)?.name || '-';
const save = async () => {
if (!form?.name.trim()) return;
setSaving(true);
const body = { name: form.name.trim(), description: form.description.trim() || null, templateIds: form.templateIds };
try {
if (form.id) await financeApi.updatePack(form.id, body); else await financeApi.createPack(body);
toast.success(t('admin.expenseSettings.saved'));
setForm(null);
reload();
} catch (error: any) { saveError(t)(error); } finally { setSaving(false); }
};
const remove = (p: ExpenseTemplatePack) => {
if (!confirm(t('admin.expenseSettings.confirmDelete', { name: p.name }))) return;
financeApi.deletePack(p.id).then(() => { toast.success(t('admin.expenseSettings.deleted')); reload(); }).catch(saveError(t));
};
const toggle = (id: string) => setForm((f) => f && ({ ...f, templateIds: f.templateIds.includes(id) ? f.templateIds.filter((x) => x !== id) : [...f.templateIds, id] }));
return (
<Section
title={t('admin.expenseSettings.packs')}
action={<Button size="sm" onClick={() => setForm({ name: '', description: '', templateIds: [] })}><PlusIcon className="w-4 h-4 mr-1" />{t('admin.expenseSettings.addPack')}</Button>}
>
{packs.length === 0 ? <p className="text-sm text-gray-400">{t('admin.expenseSettings.empty')}</p> : (
<ul className="divide-y divide-gray-100">
{packs.map((p) => (
<li key={p.id} className="flex items-center gap-3 py-2">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{p.name}</p>
<p className="text-xs text-gray-500 truncate">{p.templateIds.map(name).join(' · ')}</p>
</div>
<button onClick={() => setForm({ id: p.id, name: p.name, description: p.description || '', templateIds: p.templateIds })} className="p-2 rounded-btn hover:bg-gray-100" aria-label={t('common.edit')}><PencilIcon className="w-4 h-4" /></button>
<button onClick={() => remove(p)} className="p-2 rounded-btn hover:bg-gray-100 text-red-600" aria-label={t('common.delete')}><TrashIcon className="w-4 h-4" /></button>
</li>
))}
</ul>
)}
<Modal open={!!form} onClose={() => setForm(null)} title={t('admin.expenseSettings.packs')}>
{form && (
<>
<Field label={t('admin.expenseSettings.name')}>
<input className={inputClass} value={form.name} maxLength={200} placeholder="Lingo Night pack" onChange={(e) => setForm({ ...form, name: e.target.value })} />
</Field>
<Field label={t('admin.expenseSettings.description')}>
<input className={inputClass} value={form.description} maxLength={1000} onChange={(e) => setForm({ ...form, description: e.target.value })} />
</Field>
<Field label={t('admin.expenseSettings.packTemplates')}>
<ul className="border border-gray-100 rounded-btn divide-y divide-gray-100 max-h-64 overflow-y-auto">
{templates.filter((x) => !x.archived || form.templateIds.includes(x.id)).map((tpl) => (
<li key={tpl.id}>
<label className="flex items-center gap-3 px-3 py-2 text-sm cursor-pointer min-h-[44px]">
<input type="checkbox" checked={form.templateIds.includes(tpl.id)} onChange={() => toggle(tpl.id)} className="w-4 h-4 accent-primary-yellow" />
<span className="flex-1 min-w-0">
<span className="block truncate">{tpl.name}</span>
<span className="block text-xs text-gray-500 truncate">{templateSummary(tpl, t, locale)}</span>
</span>
</label>
</li>
))}
</ul>
</Field>
</>
)}
<div className="flex gap-3 pt-2">
<Button variant="outline" onClick={() => setForm(null)} className="flex-1 min-h-[44px]">{t('common.cancel')}</Button>
<Button onClick={save} isLoading={saving} disabled={!form?.name.trim()} className="flex-1 min-h-[44px]">{t('common.save')}</Button>
</div>
</Modal>
</Section>
);
}
// ==================== Fees ====================
function FeeRow({ fee, t, onSaved }: { fee: PaymentMethodFee; t: T; onSaved: () => void }) {
const [percent, setPercent] = useState(fee.percentBp ? bpToPercent(fee.percentBp) : '');
const [fixed, setFixed] = useState(fee.fixedAmount);
const [saving, setSaving] = useState(false);
const dirty = percentToBp(percent) !== fee.percentBp || fixed !== fee.fixedAmount;
const save = async () => {
setSaving(true);
try {
await financeApi.updateFee(fee.method, { percentBp: percentToBp(percent), fixedAmount: fixed });
toast.success(t('admin.expenseSettings.saved'));
onSaved();
} catch (error: any) { toast.error(error.message); } finally { setSaving(false); }
};
return (
<li className="grid grid-cols-2 md:grid-cols-[1fr_120px_180px_auto] items-end gap-2 py-3">
<p className="col-span-2 md:col-span-1 text-sm font-medium self-center">{t(`admin.finance.methods.${fee.method}`)}</p>
<Field label={t('admin.expenseSettings.percent')}>
<input className={inputClass} inputMode="decimal" value={percent} placeholder="0" onChange={(e) => setPercent(e.target.value)} />
</Field>
<Field label={t('admin.expenseSettings.fixed')}>
<PygInput value={fixed} onChange={setFixed} />
</Field>
<Button size="sm" onClick={save} disabled={!dirty} isLoading={saving} className="col-span-2 md:col-span-1 min-h-[42px]">{t('common.save')}</Button>
</li>
);
}
// ==================== Page section ====================
export default function ExpenseTemplatesSettings() {
const { t, locale } = useLanguage();
const [categories, setCategories] = useState<ExpenseCategory[]>([]);
const [templates, setTemplates] = useState<ExpenseTemplate[]>([]);
const [packs, setPacks] = useState<ExpenseTemplatePack[]>([]);
const [fees, setFees] = useState<PaymentMethodFee[]>([]);
const reload = useCallback(() => {
Promise.all([financeApi.getCategories(), financeApi.getTemplates(), financeApi.getPacks(), financeApi.getFees()])
.then(([c, tpl, p, f]) => { setCategories(c.categories); setTemplates(tpl.templates); setPacks(p.packs); setFees(f.fees); })
.catch((error) => toast.error(error.message));
}, []);
useEffect(() => { reload(); }, [reload]);
return (
<div className="space-y-6">
<Templates templates={templates} categories={categories} reload={reload} t={t} locale={locale} />
<Packs packs={packs} templates={templates} reload={reload} t={t} locale={locale} />
<Categories categories={categories} reload={reload} t={t} />
<Section title={t('admin.expenseSettings.fees')} hint={t('admin.expenseSettings.feesHint')}>
<ul className="divide-y divide-gray-100">
{fees.map((f) => <FeeRow key={`${f.method}-${f.percentBp}-${f.fixedAmount}`} fee={f} t={t} onSaved={reload} />)}
</ul>
</Section>
</div>
);
}
+14 -3
View File
@@ -19,10 +19,12 @@ import {
CheckCircleIcon,
StarIcon,
ScaleIcon,
BanknotesIcon,
} from '@heroicons/react/24/outline';
import toast from 'react-hot-toast';
import ExpenseTemplatesSettings from './_components/ExpenseTemplatesSettings';
type SettingsTab = 'general' | 'legal';
type SettingsTab = 'general' | 'legal' | 'expenses';
export default function AdminSettingsPage() {
const { t, locale } = useLanguage();
@@ -195,6 +197,12 @@ export default function AdminSettingsPage() {
labelEs: 'Configuración Legal',
icon: <ScaleIcon className="w-4 h-4" />,
},
{
id: 'expenses',
label: t('admin.expenseSettings.tab'),
labelEs: t('admin.expenseSettings.tab'),
icon: <BanknotesIcon className="w-4 h-4" />,
},
];
return (
@@ -226,8 +234,8 @@ export default function AdminSettingsPage() {
</div>
{/* Tabs */}
<div className="border-b border-secondary-light-gray mb-6">
<nav className="flex space-x-0" aria-label="Tabs">
<div className="border-b border-secondary-light-gray mb-6 overflow-x-auto scrollbar-hide">
<nav className="flex space-x-0 min-w-max" aria-label="Tabs">
{tabs.map((tab) => (
<button
key={tab.id}
@@ -669,6 +677,9 @@ export default function AdminSettingsPage() {
</div>
)}
{/* Expense Templates Tab (rows save individually, no page-level Save) */}
{activeTab === 'expenses' && <ExpenseTemplatesSettings />}
{/* Legal Settings Tab */}
{activeTab === 'legal' && (
<div className="space-y-6">
+505 -3
View File
@@ -91,12 +91,31 @@
"eventEnded": "Event Ended",
"registrationClosed": "Registration Closed",
"presaleClosesBefore": "Pre-sale registration closes {duration} before the event begins.",
"presaleClosesAtStart": "Pre-sale registration closes when the event begins."
"presaleClosesAtStart": "Pre-sale registration closes when the event begins.",
"eventHasEnded": "This event has ended"
},
"booking": {
"join": "Join Event",
"book": "Book Now",
"register": "Register"
},
"door": {
"onlineClosed": "Online sales closed",
"headline": "Tickets available at the door",
"perPerson": "per person, pay at the door",
"onlinePriceWas": "Online price was {price}",
"spotsLeft": "{n} spots left",
"spotsLeftOne": "1 spot left",
"spotsLeftAtDoor": "{n} spots left (at the door)",
"accepts": "Accepted at the door: {methods}",
"doorsOpen": "Doors open at {time}",
"badge": "Tickets at the door",
"methods": {
"cash": "cash",
"pos": "card",
"bitcoin": "Lightning",
"transfer": "bank transfer"
}
}
},
"booking": {
@@ -174,6 +193,13 @@
"receivedConfirmShortly": "Payment received. We will confirm it shortly.",
"receivedConfirmSoon": "Payment received. We will confirm it shortly.",
"confirmForEvent": "We will confirm your {amount} payment for {event} shortly. Nothing else needed."
},
"myEvents": {
"tab": "My Events",
"empty": "You are not on any event team yet.",
"open": "Open",
"back": "Back to my events",
"noAccess": "You do not have access to this event."
}
},
"community": {
@@ -286,7 +312,8 @@
"emails": "Emails",
"gallery": "Gallery",
"photos": "Photos",
"settings": "Settings"
"settings": "Settings",
"finance": "Finance"
},
"events": {
"title": "Manage Events",
@@ -334,6 +361,481 @@
"refunded": "Refunded",
"failed": "Failed"
}
},
"eventTabs": {
"overview": "Overview",
"attendees": "Attendees",
"tickets": "Tickets",
"email": "Email",
"payments": "Payments",
"finance": "Finance",
"team": "Team"
},
"finance": {
"subnav": {
"summary": "Summary",
"expenses": "Costs & income",
"split": "Split & Payouts"
},
"status": {
"open": "Open",
"finalized": "Finalized",
"paid_out": "Paid out"
},
"frozenNote": "Numbers frozen at finalize on {date}. Unfinalize to make changes.",
"loadError": "Could not load finance data",
"hiddenStats": "Finance numbers are hidden. Use Show Stats to reveal them.",
"kpi": {
"gross": "Gross revenue",
"fees": "Payment fees",
"expenses": "Expenses",
"profit": "Profit",
"loss": "Loss",
"breakEven": "Break-even",
"breakEvenNever": "Not reachable at this price",
"presaleDoor": "Pre-sale {presale} · Door {door}",
"ticketsSold": "{count} tickets sold",
"ticketRevenue": "Ticket revenue",
"revenueDetail": "{count} paid tickets, after refunds",
"afterFees": "After fees and all expenses",
"breakEvenProgress": "{sold} of {total}",
"beyondCapacity": "Needs more tickets than the capacity ({capacity}). Raise the price or cut costs.",
"estimated": "Estimated",
"estimatedHint": "After fees and all costs so far",
"presale": "Pre-sale",
"door": "Door"
},
"waterfall": {
"title": "From revenue to result",
"gross": "Gross revenue",
"refunds": "Refunds",
"fees": "Payment fees",
"otherIncome": "Other income",
"net": "Net revenue",
"expenses": "Expenses",
"profit": "Profit",
"organization": "Organization keeps",
"partners": "Partner payouts",
"legend": {
"in": "Money in",
"out": "Money out",
"total": "Total"
},
"estimatedHint": "Estimated: numbers follow sales and costs until the books are closed."
},
"charts": {
"byCategory": "Expenses by category",
"byMethod": "Revenue by payment method",
"cumulative": "Cumulative sales",
"breakEvenLine": "Break-even",
"revenue": "Revenue",
"plannedVsActual": "Planned vs paid expenses",
"planned": "Planned",
"paid": "Paid",
"empty": "No data yet",
"uncategorized": "Uncategorized",
"breakEvenOffChart": "Break-even is {count} tickets, far above sales so far.",
"tickets": "Tickets",
"addExpensesHint": "Add expenses to see the cost breakdown."
},
"methods": {
"tpago": "TPago",
"bank_transfer": "Bank transfer",
"lightning": "Lightning",
"cash": "Cash",
"pos": "POS",
"bancard": "Bancard"
},
"otherIncome": {
"title": "Other income",
"add": "Add income",
"hint": "Sponsors, venue kickbacks and other money in that is not a ticket.",
"empty": "No other income",
"confirmDelete": "Delete \"{name}\"?",
"placeholder": "e.g. Sponsor: Café Uno",
"edit": "Edit income"
},
"expenses": {
"add": "Add expense",
"applyTemplate": "Apply template",
"applyPack": "Apply pack",
"allCategories": "All categories",
"empty": "Add costs like the venue, instructor or snacks, or add your usual costs from a template.",
"description": "Description",
"category": "Category",
"calcType": "Calculated as",
"amount": "Amount",
"lockedAmount": "Locked amount (PYG)",
"status": "Status",
"paidBy": "Paid by",
"organization": "Organization",
"otherPartner": "Another partner",
"receipt": "Receipt",
"uploadReceipt": "Upload receipt",
"viewReceipt": "View receipt",
"receiptAdminOnly": "Receipt uploads are available to admins and organizers.",
"date": "Date",
"lock": "Lock amount",
"locked": "Locked",
"auto": "Auto",
"lockHint": "Keep this amount fixed instead of following ticket sales.",
"markPaid": "Mark as paid",
"markPlanned": "Mark as not paid yet",
"confirmDelete": "Delete this expense?",
"created": "Expense added",
"saved": "Expense saved",
"deleted": "Expense deleted",
"applied": "{count} expense(s) added",
"edit": "Edit expense",
"new": "New expense",
"pickTemplate": "Choose a template",
"pickPack": "Choose a pack",
"noTemplates": "No templates yet. Create them in Settings → Expense Templates.",
"ownOnly": "You can edit the expenses you added.",
"total": "Total",
"descriptionRequired": "Add a short description",
"descriptionPlaceholder": "e.g. Studio rental",
"paidByHint": "Pick a partner if they paid it themselves; they get it back in their payout.",
"upload": "Upload",
"useTemplate": "Use a template",
"useTemplateHint": "Add your usual costs in one tap",
"packsHeading": "Packs (several costs at once)",
"templatesHeading": "Single costs",
"addAll": "Add {count}",
"addOne": "Add",
"confirmDeleteNamed": "Delete \"{name}\"?",
"emptyTitle": "No expenses yet",
"unlockHint": "Locked. Unlock to follow ticket sales again.",
"frozen": "Finalized: expenses can't be changed.",
"filtered": "filtered",
"amountPerTicket": "Amount per ticket",
"amountPerGuest": "Amount per guest",
"percentOfRevenue": "Percentage of ticket revenue",
"minimumAmount": "Minimum amount",
"categorySuggested": "Suggested from the description"
},
"calcTypes": {
"fixed": "Fixed",
"per_ticket_sold": "Per ticket sold",
"per_checked_in": "Per checked-in guest",
"percent_of_revenue": "Percent of revenue",
"minimum_spend": "Minimum spend"
},
"calcHints": {
"fixed": "One set amount, e.g. instructor fee.",
"per_ticket_sold": "Grows with every paid ticket, e.g. a drink voucher.",
"per_checked_in": "Grows with every guest who shows up, e.g. mat rental.",
"percent_of_revenue": "A share of ticket sales, e.g. platform commission.",
"minimum_spend": "Venue minimum: the higher of a minimum or per-guest spend."
},
"expenseStatus": {
"planned": "Planned",
"paid": "Paid"
},
"split": {
"partners": "Partners",
"addPartner": "Add partner",
"editPartner": "Edit partner",
"empty": "No partners: the organization keeps the whole result.",
"linkedUser": "Linked user",
"searchUser": "Search users by name or email",
"externalName": "Or type a name",
"roleLabel": "Role",
"shareType": "Deal",
"percent": "Percent (%)",
"fixedAmount": "Fixed amount (PYG)",
"threshold": "Profit threshold (PYG)",
"lossRule": "If there is a loss",
"lossCap": "Loss cap (PYG)",
"share": "Share",
"reimbursement": "Reimbursements",
"payout": "Payout",
"owes": "Owes",
"organization": "Organization keeps",
"distributable": "Left after fixed & revenue deals",
"markPaid": "Mark as paid",
"markUnpaid": "Undo payout",
"payoutMethod": "Payout method",
"payoutNote": "Note",
"paidOn": "Paid {date}",
"pending": "Pending",
"statement": "Statement PDF",
"finalize": "Finalize",
"unfinalize": "Unfinalize",
"unfinalizeConfirm": "Unfinalize? Numbers will follow live sales again. This is logged.",
"finalized": "Event finalized",
"unfinalized": "Event reopened",
"finalizeFirst": "Payouts can be recorded after you close the books.",
"ownShareOnly": "You see your own share. The full split is visible to managers.",
"partnerSaved": "Partner saved",
"partnerRemoved": "Partner removed",
"confirmRemove": "Remove this partner?",
"needsNameOrUser": "Link a user or type a name",
"auditLog": "Activity log",
"auditEmpty": "No activity yet",
"who": "Who",
"whoHint": "Link a user so they can log in and see their statement, or just type a name.",
"rolePlaceholder": "e.g. Venue, Co-host, Instructor",
"payoutMethodPlaceholder": "e.g. Bank transfer",
"confirmRemoveNamed": "Remove {name} from the split?",
"distributableHint": "Profit shares are taken from this",
"closeBooks": "Close the books",
"booksClosed": "Books closed",
"closeBooksHint": "Once all costs are in, finalize to freeze the numbers and pay partners. You can reopen later.",
"payoutsProgress": "{paid} of {total} partner payouts recorded.",
"noPartnersTitle": "No partners on this event",
"noPartnersBody": "Add a venue, co-host or instructor who shares in the result. Their payout is calculated for you.",
"calculation": "How the result is split",
"ofDistributable": "{rule}, taken from {base} left after fixed and revenue deals",
"unpaid": "Unpaid",
"includesReimbursement": "Incl. {amount} reimbursed",
"frozenNoPartners": "Numbers are frozen. There are no partners to pay."
},
"shareTypes": {
"percent_profit": "Percent of profit",
"percent_revenue": "Percent of revenue",
"fixed": "Fixed amount",
"fixed_plus_percent_above_threshold": "Fixed + percent above threshold"
},
"lossRules": {
"proportional": "Shares the loss proportionally",
"none": "Does not share losses",
"capped": "Shares the loss up to a cap"
},
"empty": {
"title": "No money in or out yet",
"body": "Ticket sales appear here automatically. Add the event's costs to see profit and break-even."
},
"statement": {
"title": "Profit & loss",
"ticketSales": "Ticket sales ({count})",
"carriesLoss": "Carries part of the loss"
},
"formula": {
"perTicket": "{count} tickets × {amount}",
"perGuest": "{count} guests × {amount}",
"percent": "{percent} of ticket revenue",
"minimum": "min {minimum} or {count} × {amount}"
},
"shareHints": {
"percent_profit": "A percentage of what's left after costs.",
"percent_revenue": "A percentage of ticket sales, paid even if the event loses money.",
"fixed": "A set amount, whatever the result.",
"fixed_plus_percent_above_threshold": "A set amount plus a bonus percentage of profit above a target."
},
"lossRuleTitles": {
"none": "Org covers losses",
"proportional": "Shares losses",
"capped": "Shares up to a cap"
},
"deal": {
"percentProfit": "{percent} of profit",
"percentRevenue": "{percent} of ticket sales",
"fixed": "Fixed {amount}",
"fixedPlus": "{amount} + {percent} of profit above {threshold}"
},
"audit": {
"create": "added",
"update": "changed",
"delete": "deleted",
"apply_template": "added from template",
"apply_pack": "added from pack",
"mark_paid": "payout recorded",
"mark_unpaid": "payout undone",
"finalize": "books closed",
"unfinalize": "books reopened",
"paid_out": "all partners paid",
"finalized": "back to finalized",
"entity": {
"expense": "Expense",
"other_income": "Other income",
"partner": "Partner",
"finance_state": "Finance",
"member": "Team"
}
},
"close": "Close",
"pickDate": "Pick a date",
"clearDate": "Clear date",
"lifecycle": {
"label": "Books status",
"steps": {
"selling": "Selling",
"costs": "Adding costs",
"ready": "Ready to close",
"finalized": "Finalized"
},
"todo": {
"noCosts": "No costs added yet",
"planned_one": "1 planned expense not marked paid",
"planned_other": "{count} planned expenses not marked paid",
"noPartners": "No partners added",
"payoutsPending_one": "1 partner payout to record",
"payoutsPending_other": "{count} partner payouts to record",
"notEnded": "The event has not ended yet"
},
"nudge_one": "This event ended 1 day ago. Add any remaining costs and close the books.",
"nudge_other": "This event ended {count} days ago. Add any remaining costs and close the books.",
"nudgeToday": "This event ended today. Add any remaining costs and close the books.",
"reviewCosts": "Review costs",
"frozen": "Numbers frozen on {date}.",
"paidOut": "Numbers frozen on {date}. Every partner has been paid.",
"nudgeReady_one": "This event ended 1 day ago and every cost is marked paid. You can close the books in Split & Payouts.",
"nudgeReady_other": "This event ended {count} days ago and every cost is marked paid. You can close the books in Split & Payouts.",
"nudgeReadyToday": "This event ended today and every cost is marked paid. You can close the books in Split & Payouts."
},
"finalizeDialog": {
"title": "Close the books",
"subtitle": "Check everything is in before freezing the numbers.",
"notEnded": "The event has not ended yet",
"noExpenses": "No expenses recorded",
"expenses_one": "1 expense recorded",
"expenses_other": "{count} expenses recorded",
"planned_one": "1 planned expense not marked paid",
"planned_other": "{count} planned expenses not marked paid",
"allPaid": "All expenses marked paid",
"income_one": "1 other income entry",
"income_other": "{count} other income entries",
"partners_one": "Payout to 1 partner",
"partners_other": "Payouts to {count} partners",
"noPartners": "No partners: the organization keeps the result",
"finalProfit": "Final profit",
"finalLoss": "Final loss",
"hint": "Numbers stop following sales and costs. You can record partner payouts afterwards, and an admin can reopen the books if needed.",
"confirm": "Finalize and freeze numbers"
},
"breakEven": {
"noCosts": "No costs yet",
"noCostsHint": "Add costs to see how many tickets cover them.",
"reached": "Break-even reached at {at} of {sold} tickets sold",
"needed": "{count} more tickets to break even",
"coveredWithoutTickets": "Costs already covered by other income"
},
"preview": {
"fixed": "Adds {total} to this event's costs",
"perTicket_one": "{amount} × {count} ticket = {total}",
"perTicket_other": "{amount} × {count} tickets = {total}",
"perGuest_one": "{amount} × {count} checked-in guest = {total}",
"perGuest_other": "{amount} × {count} checked-in guests = {total}",
"percent": "{percent} of {sales} ticket revenue = {total}",
"minimum": "Higher of {minimum} or {count} × {amount} = {total}",
"locked": "Locked at {total}"
},
"ledger": {
"income": "Income",
"costs": "Costs",
"allRows": "All costs and income",
"emptyTitle": "No costs or income yet"
}
},
"team": {
"title": "Team",
"hint": "People added here get access to this event only, with the permissions below. Admins always have full access.",
"add": "Add member",
"search": "Search users by name or email",
"noResults": "No users found",
"empty": "No team members yet",
"role": "Role",
"remove": "Remove",
"confirmRemove": "Remove {name} from this event?",
"added": "Member added",
"saved": "Permissions saved",
"removed": "Member removed",
"override": "Changed from the role default",
"reset": "Reset to role defaults",
"permissions": "Permissions",
"permissionsSummary": "{on} of {total} on"
},
"rolePresets": {
"staff": "Staff",
"collaborator": "Collaborator",
"co_manager": "Co-manager"
},
"permissions": {
"view_overview": "View overview",
"check_in": "Check in guests",
"view_attendees_names": "See attendee names",
"view_attendees_pii": "See attendee contact details",
"email_attendees": "Email attendees",
"view_payments": "View payments",
"view_finance": "View finance",
"edit_expenses": "Edit expenses",
"edit_own_expenses_only": "Add and edit own expenses",
"view_full_split": "See the full split",
"edit_event": "Edit event",
"manage_team": "Manage team"
},
"expenseSettings": {
"tab": "Expense Templates",
"categories": "Categories",
"templates": "Templates",
"packs": "Template packs",
"fees": "Payment method fees",
"feesHint": "Charged per paid payment when computing an event's net revenue.",
"addCategory": "Add category",
"addTemplate": "Add template",
"addPack": "Add pack",
"nameEn": "Name (English)",
"nameEs": "Name (Spanish)",
"color": "Color",
"name": "Name",
"description": "Description",
"archived": "Archived",
"archive": "Archive",
"unarchive": "Restore",
"showArchived": "Show archived",
"packTemplates": "Templates in this pack",
"percent": "Percent (%)",
"fixed": "Fixed (PYG)",
"saved": "Saved",
"deleted": "Deleted",
"inUse": "In use. Archive it instead.",
"confirmDelete": "Delete {name}?",
"empty": "Nothing here yet"
},
"financeOverview": {
"title": "Finance",
"subtitle": "Profit across events. Finalized events use their frozen numbers.",
"from": "From",
"to": "To",
"series": "Series",
"venue": "Venue",
"partner": "Partner",
"all": "All",
"noSeries": "No series",
"events": "Events",
"event": "Event",
"date": "Date",
"perEvent": "Profit per event",
"perSeries": "By series",
"perVenue": "By venue",
"perPartner": "Partner totals",
"paid": "Paid",
"pending": "Pending",
"empty": "No events with finance data in this range",
"filters": "Filters",
"readyCount_one": "1 past event",
"readyCount_other": "{count} past events",
"readyHint": "still have open books. Add any last costs and close them.",
"showReady": "Show ready to close",
"showAll": "Show all events",
"readyTitle": "Ready to close",
"readyBadge": "Ready to close",
"clippedHint": "Bars with a break mark are cut off to keep the others readable. The label shows the real value."
},
"eventForm": {
"series": "Series",
"seriesHint": "Groups recurring events (e.g. Morning Club) in the Finance overview."
},
"eventStats": {
"capacity": "Capacity",
"notCheckedIn": "Not checked in yet",
"checkedIn": "Checked in",
"revenue": "Revenue",
"presaleDoor": "Pre-sale {presale} · Door {door}",
"stats": "Stats",
"inShort": "{count} in",
"free": "Free"
}
},
"footer": {
@@ -392,4 +894,4 @@
"downloading": "Downloading…"
}
}
}
}
+505 -3
View File
@@ -91,12 +91,31 @@
"eventEnded": "Evento Finalizado",
"registrationClosed": "Inscripciones Cerradas",
"presaleClosesBefore": "La preventa cierra {duration} antes de que comience el evento.",
"presaleClosesAtStart": "La preventa cierra cuando comienza el evento."
"presaleClosesAtStart": "La preventa cierra cuando comienza el evento.",
"eventHasEnded": "Este evento ya terminó"
},
"booking": {
"join": "Unirse al Evento",
"book": "Reservar Ahora",
"register": "Registrarse"
},
"door": {
"onlineClosed": "Venta online cerrada",
"headline": "Entradas disponibles en la puerta",
"perPerson": "por persona, pago en la puerta",
"onlinePriceWas": "El precio online era {price}",
"spotsLeft": "{n} lugares disponibles",
"spotsLeftOne": "1 lugar disponible",
"spotsLeftAtDoor": "{n} lugares disponibles (en la puerta)",
"accepts": "Aceptamos en la puerta: {methods}",
"doorsOpen": "Las puertas abren a las {time}",
"badge": "Entradas en la puerta",
"methods": {
"cash": "efectivo",
"pos": "tarjeta",
"bitcoin": "Lightning",
"transfer": "transferencia"
}
}
},
"booking": {
@@ -174,6 +193,13 @@
"receivedConfirmShortly": "Pago recibido. Lo confirmaremos en breve.",
"receivedConfirmSoon": "Pago recibido. Lo confirmaremos pronto.",
"confirmForEvent": "Confirmaremos tu pago de {amount} para {event} en breve. No necesitas hacer nada más."
},
"myEvents": {
"tab": "Mis eventos",
"empty": "Todavía no formás parte del equipo de ningún evento.",
"open": "Abrir",
"back": "Volver a mis eventos",
"noAccess": "No tenés acceso a este evento."
}
},
"community": {
@@ -286,7 +312,8 @@
"emails": "Emails",
"gallery": "Galería",
"photos": "Fotos",
"settings": "Configuración"
"settings": "Configuración",
"finance": "Finanzas"
},
"events": {
"title": "Gestionar Eventos",
@@ -334,6 +361,481 @@
"refunded": "Reembolsado",
"failed": "Fallido"
}
},
"eventTabs": {
"overview": "Resumen",
"attendees": "Asistentes",
"tickets": "Entradas",
"email": "Email",
"payments": "Pagos",
"finance": "Finanzas",
"team": "Equipo"
},
"finance": {
"subnav": {
"summary": "Resumen",
"expenses": "Gastos e ingresos",
"split": "Reparto y pagos"
},
"status": {
"open": "Abierto",
"finalized": "Cerrado",
"paid_out": "Pagado"
},
"frozenNote": "Números congelados al cerrar el {date}. Reabrí para hacer cambios.",
"loadError": "No se pudieron cargar las finanzas",
"hiddenStats": "Los números están ocultos. Usá Mostrar estadísticas para verlos.",
"kpi": {
"gross": "Ingresos brutos",
"fees": "Comisiones",
"expenses": "Gastos",
"profit": "Ganancia",
"loss": "Pérdida",
"breakEven": "Punto de equilibrio",
"breakEvenNever": "No alcanzable a este precio",
"presaleDoor": "Preventa {presale} · Puerta {door}",
"ticketsSold": "{count} entradas vendidas",
"ticketRevenue": "Ingresos por entradas",
"revenueDetail": "{count} entradas pagas, sin reembolsos",
"afterFees": "Después de comisiones y gastos",
"breakEvenProgress": "{sold} de {total}",
"beyondCapacity": "Necesita más entradas que la capacidad ({capacity}). Subí el precio o bajá gastos.",
"estimated": "Estimado",
"estimatedHint": "Después de comisiones y los gastos cargados",
"presale": "Preventa",
"door": "Puerta"
},
"waterfall": {
"title": "De los ingresos al resultado",
"gross": "Ingresos brutos",
"refunds": "Reembolsos",
"fees": "Comisiones de pago",
"otherIncome": "Otros ingresos",
"net": "Ingreso neto",
"expenses": "Gastos",
"profit": "Ganancia",
"organization": "Queda para la organización",
"partners": "Pagos a socios",
"legend": {
"in": "Entra",
"out": "Sale",
"total": "Total"
},
"estimatedHint": "Estimado: los números siguen las ventas y los gastos hasta cerrar las cuentas."
},
"charts": {
"byCategory": "Gastos por categoría",
"byMethod": "Ingresos por método de pago",
"cumulative": "Ventas acumuladas",
"breakEvenLine": "Equilibrio",
"revenue": "Ingresos",
"plannedVsActual": "Gastos previstos vs pagados",
"planned": "Previsto",
"paid": "Pagado",
"empty": "Sin datos todavía",
"uncategorized": "Sin categoría",
"breakEvenOffChart": "El equilibrio es {count} entradas, muy por encima de lo vendido.",
"tickets": "Entradas",
"addExpensesHint": "Agregá gastos para ver el desglose de costos."
},
"methods": {
"tpago": "TPago",
"bank_transfer": "Transferencia",
"lightning": "Lightning",
"cash": "Efectivo",
"pos": "POS",
"bancard": "Bancard"
},
"otherIncome": {
"title": "Otros ingresos",
"add": "Agregar ingreso",
"hint": "Sponsors, comisiones del local y otros ingresos que no son entradas.",
"empty": "Sin otros ingresos",
"confirmDelete": "¿Eliminar \"{name}\"?",
"placeholder": "ej. Sponsor: Café Uno",
"edit": "Editar ingreso"
},
"expenses": {
"add": "Agregar gasto",
"applyTemplate": "Aplicar plantilla",
"applyPack": "Aplicar paquete",
"allCategories": "Todas las categorías",
"empty": "Agregá gastos como el lugar, el instructor o snacks, o tus gastos habituales desde una plantilla.",
"description": "Descripción",
"category": "Categoría",
"calcType": "Se calcula",
"amount": "Monto",
"lockedAmount": "Monto fijado (PYG)",
"status": "Estado",
"paidBy": "Pagado por",
"organization": "Organización",
"otherPartner": "Otro socio",
"receipt": "Comprobante",
"uploadReceipt": "Subir comprobante",
"viewReceipt": "Ver comprobante",
"receiptAdminOnly": "La carga de comprobantes está disponible para admins y organizadores.",
"date": "Fecha",
"lock": "Fijar monto",
"locked": "Fijado",
"auto": "Auto",
"lockHint": "Mantené este monto fijo en vez de seguir las ventas.",
"markPaid": "Marcar como pagado",
"markPlanned": "Marcar como no pagado",
"confirmDelete": "¿Eliminar este gasto?",
"created": "Gasto agregado",
"saved": "Gasto guardado",
"deleted": "Gasto eliminado",
"applied": "{count} gasto(s) agregado(s)",
"edit": "Editar gasto",
"new": "Nuevo gasto",
"pickTemplate": "Elegí una plantilla",
"pickPack": "Elegí un paquete",
"noTemplates": "Todavía no hay plantillas. Crealas en Configuración → Plantillas de gastos.",
"ownOnly": "Podés editar los gastos que agregaste.",
"total": "Total",
"descriptionRequired": "Agregá una descripción",
"descriptionPlaceholder": "ej. Alquiler del estudio",
"paidByHint": "Elegí un socio si lo pagó de su bolsillo; se le devuelve en su pago.",
"upload": "Subir",
"useTemplate": "Usar plantilla",
"useTemplateHint": "Agregá tus gastos habituales de una vez",
"packsHeading": "Paquetes (varios gastos juntos)",
"templatesHeading": "Gastos individuales",
"addAll": "Agregar {count}",
"addOne": "Agregar",
"confirmDeleteNamed": "¿Eliminar \"{name}\"?",
"emptyTitle": "Todavía no hay gastos",
"unlockHint": "Fijado. Desbloqueá para volver a seguir las ventas.",
"frozen": "Cerrado: no se pueden cambiar los gastos.",
"filtered": "filtrado",
"amountPerTicket": "Monto por entrada",
"amountPerGuest": "Monto por asistente",
"percentOfRevenue": "Porcentaje de la venta de entradas",
"minimumAmount": "Monto mínimo",
"categorySuggested": "Sugerida según la descripción"
},
"calcTypes": {
"fixed": "Fijo",
"per_ticket_sold": "Por entrada vendida",
"per_checked_in": "Por asistente ingresado",
"percent_of_revenue": "Porcentaje de ingresos",
"minimum_spend": "Consumo mínimo"
},
"calcHints": {
"fixed": "Un monto fijo, ej. honorario del instructor.",
"per_ticket_sold": "Crece con cada entrada pagada, ej. un vale de bebida.",
"per_checked_in": "Crece con cada asistente que llega, ej. alquiler de mat.",
"percent_of_revenue": "Un porcentaje de las ventas, ej. comisión.",
"minimum_spend": "Consumo mínimo del local: el mayor entre el mínimo y el consumo por persona."
},
"expenseStatus": {
"planned": "Previsto",
"paid": "Pagado"
},
"split": {
"partners": "Socios",
"addPartner": "Agregar socio",
"editPartner": "Editar socio",
"empty": "Sin socios: la organización se queda con todo.",
"linkedUser": "Usuario vinculado",
"searchUser": "Buscar usuarios por nombre o email",
"externalName": "O escribí un nombre",
"roleLabel": "Rol",
"shareType": "Acuerdo",
"percent": "Porcentaje (%)",
"fixedAmount": "Monto fijo (PYG)",
"threshold": "Umbral de ganancia (PYG)",
"lossRule": "Si hay pérdida",
"lossCap": "Tope de pérdida (PYG)",
"share": "Participación",
"reimbursement": "Reembolsos",
"payout": "A pagar",
"owes": "Debe",
"organization": "Queda para la organización",
"distributable": "Resto tras acuerdos fijos y de ingresos",
"markPaid": "Marcar como pagado",
"markUnpaid": "Deshacer pago",
"payoutMethod": "Método de pago",
"payoutNote": "Nota",
"paidOn": "Pagado el {date}",
"pending": "Pendiente",
"statement": "Liquidación PDF",
"finalize": "Cerrar",
"unfinalize": "Reabrir",
"unfinalizeConfirm": "¿Reabrir? Los números vuelven a seguir las ventas. Queda registrado.",
"finalized": "Evento cerrado",
"unfinalized": "Evento reabierto",
"finalizeFirst": "Los pagos se registran después de cerrar las cuentas.",
"ownShareOnly": "Ves tu propia participación. El reparto completo lo ven los responsables.",
"partnerSaved": "Socio guardado",
"partnerRemoved": "Socio eliminado",
"confirmRemove": "¿Quitar este socio?",
"needsNameOrUser": "Vinculá un usuario o escribí un nombre",
"auditLog": "Registro de actividad",
"auditEmpty": "Sin actividad todavía",
"who": "Quién",
"whoHint": "Vinculá un usuario para que vea su liquidación, o escribí un nombre.",
"rolePlaceholder": "ej. Local, Co-anfitrión, Instructor",
"payoutMethodPlaceholder": "ej. Transferencia",
"confirmRemoveNamed": "¿Quitar a {name} del reparto?",
"distributableHint": "Las participaciones en la ganancia salen de acá",
"closeBooks": "Cerrar cuentas",
"booksClosed": "Cuentas cerradas",
"closeBooksHint": "Cuando estén todos los gastos, cerrá para congelar los números y pagar a los socios. Se puede reabrir.",
"payoutsProgress": "{paid} de {total} pagos a socios registrados.",
"noPartnersTitle": "No hay socios en este evento",
"noPartnersBody": "Agregá un local, co-anfitrión o instructor que participe del resultado. Su pago se calcula solo.",
"calculation": "Cómo se reparte el resultado",
"ofDistributable": "{rule}, sobre {base} que quedan tras los acuerdos fijos y de ingresos",
"unpaid": "Sin pagar",
"includesReimbursement": "Incluye {amount} de reembolso",
"frozenNoPartners": "Los números están congelados. No hay socios a quienes pagar."
},
"shareTypes": {
"percent_profit": "Porcentaje de la ganancia",
"percent_revenue": "Porcentaje de ingresos",
"fixed": "Monto fijo",
"fixed_plus_percent_above_threshold": "Fijo + porcentaje sobre umbral"
},
"lossRules": {
"proportional": "Comparte la pérdida proporcionalmente",
"none": "No comparte pérdidas",
"capped": "Comparte la pérdida hasta un tope"
},
"empty": {
"title": "Todavía no hay ingresos ni gastos",
"body": "Las ventas aparecen solas. Agregá los gastos del evento para ver la ganancia y el punto de equilibrio."
},
"statement": {
"title": "Resultado",
"ticketSales": "Venta de entradas ({count})",
"carriesLoss": "Asume parte de la pérdida"
},
"formula": {
"perTicket": "{count} entradas × {amount}",
"perGuest": "{count} asistentes × {amount}",
"percent": "{percent} de los ingresos",
"minimum": "mín {minimum} o {count} × {amount}"
},
"shareHints": {
"percent_profit": "Un porcentaje de lo que queda después de los gastos.",
"percent_revenue": "Un porcentaje de las ventas, se paga aunque haya pérdida.",
"fixed": "Un monto fijo, sea cual sea el resultado.",
"fixed_plus_percent_above_threshold": "Un monto fijo más un porcentaje de la ganancia sobre una meta."
},
"lossRuleTitles": {
"none": "La org cubre",
"proportional": "Comparte pérdidas",
"capped": "Comparte hasta un tope"
},
"deal": {
"percentProfit": "{percent} de la ganancia",
"percentRevenue": "{percent} de las ventas",
"fixed": "Fijo {amount}",
"fixedPlus": "{amount} + {percent} de la ganancia sobre {threshold}"
},
"audit": {
"create": "agregado",
"update": "modificado",
"delete": "eliminado",
"apply_template": "agregado desde plantilla",
"apply_pack": "agregado desde paquete",
"mark_paid": "pago registrado",
"mark_unpaid": "pago deshecho",
"finalize": "cuentas cerradas",
"unfinalize": "cuentas reabiertas",
"paid_out": "todos los socios cobraron",
"finalized": "vuelve a cerrado",
"entity": {
"expense": "Gasto",
"other_income": "Otro ingreso",
"partner": "Socio",
"finance_state": "Finanzas",
"member": "Equipo"
}
},
"close": "Cerrar",
"pickDate": "Elegí una fecha",
"clearDate": "Borrar fecha",
"lifecycle": {
"label": "Estado de las cuentas",
"steps": {
"selling": "Vendiendo",
"costs": "Cargando gastos",
"ready": "Listo para cerrar",
"finalized": "Cerrado"
},
"todo": {
"noCosts": "Todavía no hay gastos",
"planned_one": "1 gasto planificado sin marcar como pagado",
"planned_other": "{count} gastos planificados sin marcar como pagados",
"noPartners": "No hay socios",
"payoutsPending_one": "Falta registrar 1 pago a socio",
"payoutsPending_other": "Faltan registrar {count} pagos a socios",
"notEnded": "El evento todavía no terminó"
},
"nudge_one": "Este evento terminó hace 1 día. Agregá los gastos que falten y cerrá las cuentas.",
"nudge_other": "Este evento terminó hace {count} días. Agregá los gastos que falten y cerrá las cuentas.",
"nudgeToday": "Este evento terminó hoy. Agregá los gastos que falten y cerrá las cuentas.",
"reviewCosts": "Revisar gastos",
"frozen": "Números congelados el {date}.",
"paidOut": "Números congelados el {date}. Todos los socios cobraron.",
"nudgeReady_one": "Este evento terminó hace 1 día y todos los gastos están pagados. Podés cerrar las cuentas en Reparto y pagos.",
"nudgeReady_other": "Este evento terminó hace {count} días y todos los gastos están pagados. Podés cerrar las cuentas en Reparto y pagos.",
"nudgeReadyToday": "Este evento terminó hoy y todos los gastos están pagados. Podés cerrar las cuentas en Reparto y pagos."
},
"finalizeDialog": {
"title": "Cerrar cuentas",
"subtitle": "Revisá que esté todo antes de congelar los números.",
"notEnded": "El evento todavía no terminó",
"noExpenses": "No hay gastos cargados",
"expenses_one": "1 gasto cargado",
"expenses_other": "{count} gastos cargados",
"planned_one": "1 gasto planificado sin marcar como pagado",
"planned_other": "{count} gastos planificados sin marcar como pagados",
"allPaid": "Todos los gastos están pagados",
"income_one": "1 ingreso adicional",
"income_other": "{count} ingresos adicionales",
"partners_one": "Pago a 1 socio",
"partners_other": "Pagos a {count} socios",
"noPartners": "Sin socios: la organización se queda con el resultado",
"finalProfit": "Ganancia final",
"finalLoss": "Pérdida final",
"hint": "Los números dejan de seguir las ventas y los gastos. Después podés registrar los pagos a socios, y un admin puede reabrir las cuentas si hace falta.",
"confirm": "Cerrar y congelar números"
},
"breakEven": {
"noCosts": "Todavía sin gastos",
"noCostsHint": "Agregá gastos para ver cuántas entradas los cubren.",
"reached": "Equilibrio alcanzado con {at} de {sold} entradas vendidas",
"needed": "Faltan {count} entradas para cubrir gastos",
"coveredWithoutTickets": "Los gastos ya están cubiertos por otros ingresos"
},
"preview": {
"fixed": "Suma {total} a los gastos del evento",
"perTicket_one": "{amount} × {count} entrada = {total}",
"perTicket_other": "{amount} × {count} entradas = {total}",
"perGuest_one": "{amount} × {count} asistente ingresado = {total}",
"perGuest_other": "{amount} × {count} asistentes ingresados = {total}",
"percent": "{percent} de {sales} en entradas = {total}",
"minimum": "El mayor entre {minimum} o {count} × {amount} = {total}",
"locked": "Fijado en {total}"
},
"ledger": {
"income": "Ingreso",
"costs": "Gastos",
"allRows": "Todos los gastos e ingresos",
"emptyTitle": "Todavía no hay gastos ni ingresos"
}
},
"team": {
"title": "Equipo",
"hint": "Las personas que agregues acá acceden solo a este evento, con los permisos de abajo. Los admins siempre tienen acceso completo.",
"add": "Agregar miembro",
"search": "Buscar usuarios por nombre o email",
"noResults": "No se encontraron usuarios",
"empty": "Todavía no hay miembros",
"role": "Rol",
"remove": "Quitar",
"confirmRemove": "¿Quitar a {name} de este evento?",
"added": "Miembro agregado",
"saved": "Permisos guardados",
"removed": "Miembro quitado",
"override": "Cambiado respecto al rol",
"reset": "Volver a los permisos del rol",
"permissions": "Permisos",
"permissionsSummary": "{on} de {total} activos"
},
"rolePresets": {
"staff": "Staff",
"collaborator": "Colaborador",
"co_manager": "Co-responsable"
},
"permissions": {
"view_overview": "Ver resumen",
"check_in": "Registrar ingresos",
"view_attendees_names": "Ver nombres de asistentes",
"view_attendees_pii": "Ver datos de contacto",
"email_attendees": "Enviar emails a asistentes",
"view_payments": "Ver pagos",
"view_finance": "Ver finanzas",
"edit_expenses": "Editar gastos",
"edit_own_expenses_only": "Agregar y editar sus gastos",
"view_full_split": "Ver el reparto completo",
"edit_event": "Editar evento",
"manage_team": "Gestionar equipo"
},
"expenseSettings": {
"tab": "Plantillas de gastos",
"categories": "Categorías",
"templates": "Plantillas",
"packs": "Paquetes de plantillas",
"fees": "Comisiones por método de pago",
"feesHint": "Se descuentan por cada pago al calcular el ingreso neto de un evento.",
"addCategory": "Agregar categoría",
"addTemplate": "Agregar plantilla",
"addPack": "Agregar paquete",
"nameEn": "Nombre (inglés)",
"nameEs": "Nombre (español)",
"color": "Color",
"name": "Nombre",
"description": "Descripción",
"archived": "Archivado",
"archive": "Archivar",
"unarchive": "Restaurar",
"showArchived": "Mostrar archivados",
"packTemplates": "Plantillas del paquete",
"percent": "Porcentaje (%)",
"fixed": "Fijo (PYG)",
"saved": "Guardado",
"deleted": "Eliminado",
"inUse": "Está en uso. Archivalo.",
"confirmDelete": "¿Eliminar {name}?",
"empty": "Todavía no hay nada"
},
"financeOverview": {
"title": "Finanzas",
"subtitle": "Resultados de todos los eventos. Los eventos cerrados usan sus números congelados.",
"from": "Desde",
"to": "Hasta",
"series": "Serie",
"venue": "Lugar",
"partner": "Socio",
"all": "Todos",
"noSeries": "Sin serie",
"events": "Eventos",
"event": "Evento",
"date": "Fecha",
"perEvent": "Ganancia por evento",
"perSeries": "Por serie",
"perVenue": "Por lugar",
"perPartner": "Totales por socio",
"paid": "Pagado",
"pending": "Pendiente",
"empty": "No hay eventos con datos financieros en este rango",
"filters": "Filtros",
"readyCount_one": "1 evento pasado",
"readyCount_other": "{count} eventos pasados",
"readyHint": "todavía con cuentas abiertas. Agregá los últimos gastos y cerralas.",
"showReady": "Ver listos para cerrar",
"showAll": "Ver todos los eventos",
"readyTitle": "Listos para cerrar",
"readyBadge": "Listo para cerrar",
"clippedHint": "Las barras con una marca de corte están recortadas para que se lean las demás. La etiqueta muestra el valor real."
},
"eventForm": {
"series": "Serie",
"seriesHint": "Agrupa eventos recurrentes (ej. Morning Club) en el resumen de Finanzas."
},
"eventStats": {
"capacity": "Capacidad",
"notCheckedIn": "Sin ingresar todavía",
"checkedIn": "Ingresados",
"revenue": "Ingresos",
"presaleDoor": "Preventa {presale} · Puerta {door}",
"stats": "Datos",
"inShort": "{count} adentro",
"free": "Gratis"
}
},
"footer": {
@@ -392,4 +894,4 @@
"downloading": "Descargando…"
}
}
}
}
+5 -3
View File
@@ -3,7 +3,9 @@ import type { EmailTemplate, EmailVariable, EmailLog, EmailStats, Pagination } f
export const emailsApi = {
// Templates
getTemplates: () => fetchApi<{ templates: EmailTemplate[] }>('/api/emails/templates'),
// Team members (non-admin) pass the event they email for, so the server can check their permission there.
getTemplates: (eventId?: string) =>
fetchApi<{ templates: EmailTemplate[] }>(`/api/emails/templates${eventId ? `?eventId=${encodeURIComponent(eventId)}` : ''}`),
getTemplate: (id: string) => fetchApi<{ template: EmailTemplate }>(`/api/emails/templates/${id}`),
@@ -56,8 +58,8 @@ export const emailsApi = {
templateSlug: string;
variables?: Record<string, any>;
locale?: string;
}) =>
fetchApi<{ subject: string; bodyHtml: string }>('/api/emails/preview', {
}, eventId?: string) =>
fetchApi<{ subject: string; bodyHtml: string }>(`/api/emails/preview${eventId ? `?eventId=${encodeURIComponent(eventId)}` : ''}`, {
method: 'POST',
body: JSON.stringify(data),
}),
+2 -1
View File
@@ -13,7 +13,8 @@ export const eventsApi = {
return fetchApi<{ events: Event[]; total?: number }>(`/api/events?${query}`);
},
getById: (id: string) => fetchApi<{ event: Event }>(`/api/events/${id}`),
// serverTime (ISO) lets the event page time its refresh at the sales-state flip.
getById: (id: string) => fetchApi<{ event: Event; serverTime?: string }>(`/api/events/${id}`),
getNext: () => fetchApi<{ event: Event | null }>('/api/events/next'),
+310
View File
@@ -0,0 +1,310 @@
import { fetchApi, fetchBlob } from './client';
// ==================== Types ====================
export type EventPermission =
| 'view_overview' | 'check_in' | 'view_attendees_names' | 'view_attendees_pii' | 'email_attendees'
| 'view_payments' | 'view_finance' | 'edit_expenses' | 'edit_own_expenses_only' | 'view_full_split'
| 'edit_event' | 'manage_team';
export const EVENT_PERMISSIONS: EventPermission[] = [
'view_overview', 'check_in', 'view_attendees_names', 'view_attendees_pii', 'email_attendees', 'view_payments',
'view_finance', 'edit_expenses', 'edit_own_expenses_only', 'view_full_split', 'edit_event', 'manage_team',
];
export type RolePreset = 'staff' | 'collaborator' | 'co_manager';
export const ROLE_PRESETS: RolePreset[] = ['staff', 'collaborator', 'co_manager'];
/** Mirrors backend/src/lib/eventPermissions.ts PRESET_PERMISSIONS. */
export const PRESET_PERMISSIONS: Record<RolePreset, EventPermission[]> = {
staff: ['view_overview', 'check_in', 'view_attendees_names'],
collaborator: ['view_overview', 'view_finance'],
co_manager: EVENT_PERMISSIONS.filter((p) => p !== 'manage_team'),
};
export type CalcType = 'fixed' | 'per_ticket_sold' | 'per_checked_in' | 'percent_of_revenue' | 'minimum_spend';
export const CALC_TYPES: CalcType[] = ['fixed', 'per_ticket_sold', 'per_checked_in', 'percent_of_revenue', 'minimum_spend'];
export type ShareType = 'percent_profit' | 'percent_revenue' | 'fixed' | 'fixed_plus_percent_above_threshold';
export const SHARE_TYPES: ShareType[] = ['percent_profit', 'percent_revenue', 'fixed', 'fixed_plus_percent_above_threshold'];
export type LossRule = 'proportional' | 'none' | 'capped';
export const LOSS_RULES: LossRule[] = ['proportional', 'none', 'capped'];
export type FinanceStatus = 'open' | 'finalized' | 'paid_out';
export interface MyEventPermissions {
eventId: string;
global: boolean;
role: string;
rolePreset: RolePreset | null;
permissions: EventPermission[];
canUnfinalize: boolean;
}
export interface ExpenseCategory {
id: string;
nameEn: string;
nameEs: string;
color: string;
sortOrder: number;
archived: boolean;
}
export interface EventExpense {
id: string;
eventId: string;
categoryId: string | null;
templateId: string | null;
description: string;
calcType: CalcType;
quantity: number;
unitAmount: number;
percentBp: number;
minimumAmount: number;
computedAmount: number;
isLocked: boolean;
status: 'planned' | 'paid';
/** 'other' = fronted by a partner the viewer may not see. */
paidByPartnerId: string | null;
receiptUrl: string | null;
expenseDate: string | null;
createdBy: string | null;
createdAt: string | null;
/** Live values from the calculation (present on GET /finance). */
amount?: number;
liveQuantity?: number;
auto?: boolean;
}
export interface OtherIncome {
id: string;
description: string;
amount: number;
createdAt: string | null;
}
export interface EventPartner {
id: string;
userId: string | null;
externalName: string | null;
name: string;
roleLabel: string | null;
shareType: ShareType;
percentBp: number;
fixedAmount: number;
thresholdAmount: number;
lossRule: LossRule;
lossCapAmount: number;
payoutStatus: 'pending' | 'paid';
payoutDate: string | null;
payoutMethod: string | null;
payoutNote: string | null;
}
export interface PartnerLine {
partnerId: string;
name: string;
shareType: ShareType;
basis: number;
share: number;
reimbursement: number;
payout: number;
}
export interface FinanceSummary {
counts: { ticketsSold: number; checkedIn: number; payments: number };
revenue: {
gross: number; presale: number; door: number; refunds: number; fees: number; otherIncome: number; sales: number; net: number;
byMethod: { method: string; count: number; gross: number; refunds: number; fees: number; net: number }[];
};
expenses: {
lines: { id: string; quantity: number; amount: number; auto: boolean }[];
total: number; planned: number; paid: number;
byCategory: { categoryId: string | null; planned: number; paid: number; total: number }[];
};
profit: number;
breakEven: { tickets: number | null; ticketPrice: number; remaining: number | null };
/** distributable / organization are null for viewers without view_full_split. */
split: { distributable: number | null; partners: PartnerLine[]; organization: number | null };
waterfall: { key: string; label?: string; amount: number }[];
salesTimeline: { date: string; tickets: number; revenue: number }[];
}
export interface EventFinance {
event: { id: string; title: string; titleEs?: string; startDatetime: string; endDatetime: string | null; location: string; series: string | null; price: number; currency: string; capacity: number };
status: FinanceStatus;
finalizedAt: string | null;
live: boolean;
computedAt: string;
summary: FinanceSummary;
expenses: EventExpense[];
otherIncome: OtherIncome[];
partners: EventPartner[];
categories: ExpenseCategory[];
viewer: {
fullSplit: boolean;
canEditExpenses: boolean;
canEditOwnExpenses: boolean;
canManageSplit: boolean;
canUnfinalize: boolean;
userId: string;
};
}
export interface ExpenseTemplate {
id: string;
name: string;
categoryId: string | null;
description: string | null;
calcType: CalcType;
amount: number;
percentBp: number;
minimumAmount: number;
archived: boolean;
}
export interface ExpenseTemplatePack {
id: string;
name: string;
description: string | null;
archived: boolean;
templateIds: string[];
}
export interface PaymentMethodFee {
method: string;
percentBp: number;
fixedAmount: number;
}
export interface EventMember {
id: string;
userId: string;
name: string | null;
email: string | null;
globalRole: string | null;
rolePreset: RolePreset;
permissions: Partial<Record<EventPermission, boolean>>;
effective: EventPermission[];
createdAt: string | null;
}
export interface FinanceAuditEntry {
id: string;
actorUserId: string | null;
actorName: string | null;
entityType: string;
entityId: string | null;
action: string;
before: any;
after: any;
createdAt: string | null;
}
export interface FinanceTotals { events: number; gross: number; fees: number; net: number; expenses: number; profit: number }
export type FinanceOverviewEvent = Omit<FinanceTotals, 'events'> & {
id: string; title: string; titleEs: string | null; startDatetime: string | null; endDatetime: string | null; series: string | null; location: string;
status: string; financeStatus: FinanceStatus; ticketsSold: number; organization: number | null;
};
export interface FinanceOverview {
totals: FinanceTotals;
events: FinanceOverviewEvent[];
/** Past events with open books, including ones with no money recorded yet; longest-waiting first. */
readyToClose: FinanceOverviewEvent[];
bySeries: (FinanceTotals & { series: string | null })[];
byVenue: (FinanceTotals & { venue: string })[];
byPartner: { key: string; name: string; userId: string | null; events: number; share: number; reimbursement: number; payout: number; paid: number; pending: number }[];
filters: { series: string[]; venues: string[]; partners: { key: string; name: string }[] };
}
export interface MyEvent {
event: { id: string; slug: string; title: string; titleEs?: string | null; startDatetime: string; location: string; status: string; bannerUrl?: string | null };
rolePreset: RolePreset;
permissions: EventPermission[];
}
export type ExpenseInput = Partial<Pick<EventExpense,
'description' | 'categoryId' | 'calcType' | 'quantity' | 'unitAmount' | 'percentBp' | 'minimumAmount' | 'computedAmount' |
'isLocked' | 'status' | 'paidByPartnerId' | 'receiptUrl' | 'expenseDate'>>;
export type PartnerInput = Partial<Pick<EventPartner,
'userId' | 'externalName' | 'roleLabel' | 'shareType' | 'percentBp' | 'fixedAmount' | 'thresholdAmount' | 'lossRule' | 'lossCapAmount'>>;
// ==================== Client ====================
const json = (method: string, body?: unknown): RequestInit => ({ method, body: body === undefined ? undefined : JSON.stringify(body) });
const e = (id: string) => `/api/events/${encodeURIComponent(id)}`;
/** Team members pass the event they are working on so the server can check their permission there. */
const scoped = (eventId?: string) => (eventId ? `?eventId=${encodeURIComponent(eventId)}` : '');
export const financeApi = {
myPermissions: (eventId: string) => fetchApi<MyEventPermissions>(`${e(eventId)}/my-permissions`),
get: (eventId: string) => fetchApi<EventFinance>(`${e(eventId)}/finance`),
finalize: (eventId: string) => fetchApi<{ status: FinanceStatus }>(`${e(eventId)}/finance/finalize`, json('POST', {})),
unfinalize: (eventId: string) => fetchApi<{ status: FinanceStatus }>(`${e(eventId)}/finance/unfinalize`, json('POST', {})),
createExpense: (eventId: string, data: ExpenseInput) =>
fetchApi<{ expense: EventExpense }>(`${e(eventId)}/expenses`, json('POST', data)),
updateExpense: (eventId: string, id: string, data: ExpenseInput) =>
fetchApi<{ expense: EventExpense }>(`${e(eventId)}/expenses/${id}`, json('PUT', data)),
deleteExpense: (eventId: string, id: string) => fetchApi<{ message: string }>(`${e(eventId)}/expenses/${id}`, json('DELETE')),
applyTemplate: (eventId: string, data: { templateId?: string; packId?: string }) =>
fetchApi<{ expenses: EventExpense[] }>(`${e(eventId)}/expenses/apply-template`, json('POST', data)),
createIncome: (eventId: string, data: { description: string; amount: number }) =>
fetchApi<{ income: OtherIncome }>(`${e(eventId)}/other-income`, json('POST', data)),
updateIncome: (eventId: string, id: string, data: { description?: string; amount?: number }) =>
fetchApi<{ income: OtherIncome }>(`${e(eventId)}/other-income/${id}`, json('PUT', data)),
deleteIncome: (eventId: string, id: string) => fetchApi<{ message: string }>(`${e(eventId)}/other-income/${id}`, json('DELETE')),
searchPartnerCandidates: (eventId: string, q: string) =>
fetchApi<{ users: { id: string; name: string; email: string; role: string }[] }>(`${e(eventId)}/partners/candidates?q=${encodeURIComponent(q)}`),
createPartner: (eventId: string, data: PartnerInput) =>
fetchApi<{ partner: EventPartner }>(`${e(eventId)}/partners`, json('POST', data)),
updatePartner: (eventId: string, id: string, data: PartnerInput) =>
fetchApi<{ partner: EventPartner }>(`${e(eventId)}/partners/${id}`, json('PUT', data)),
deletePartner: (eventId: string, id: string) => fetchApi<{ message: string }>(`${e(eventId)}/partners/${id}`, json('DELETE')),
markPartnerPaid: (eventId: string, id: string, data: { paid: boolean; payoutDate?: string | null; payoutMethod?: string | null; payoutNote?: string | null }) =>
fetchApi<{ partner: EventPartner; status: FinanceStatus }>(`${e(eventId)}/partners/${id}/mark-paid`, json('POST', data)),
downloadStatement: (eventId: string, partnerId: string, locale: string) =>
fetchBlob(`${e(eventId)}/partners/${partnerId}/statement?locale=${locale}`, 'statement.pdf'),
auditLog: (eventId: string) => fetchApi<{ entries: FinanceAuditEntry[] }>(`${e(eventId)}/audit-log`),
getMembers: (eventId: string) => fetchApi<{ members: EventMember[] }>(`${e(eventId)}/members`),
searchMemberCandidates: (eventId: string, q: string) =>
fetchApi<{ users: { id: string; name: string; email: string; role: string }[] }>(`${e(eventId)}/members/candidates?q=${encodeURIComponent(q)}`),
addMember: (eventId: string, data: { userId: string; rolePreset: RolePreset; permissions?: Partial<Record<EventPermission, boolean>> }) =>
fetchApi<{ member: EventMember }>(`${e(eventId)}/members`, json('POST', data)),
updateMember: (eventId: string, id: string, data: { rolePreset?: RolePreset; permissions?: Partial<Record<EventPermission, boolean>> }) =>
fetchApi<{ member: EventMember }>(`${e(eventId)}/members/${id}`, json('PUT', data)),
removeMember: (eventId: string, id: string) => fetchApi<{ message: string }>(`${e(eventId)}/members/${id}`, json('DELETE')),
// Settings (writes are admin only; reads also work for members via eventId)
getCategories: (eventId?: string) => fetchApi<{ categories: ExpenseCategory[] }>(`/api/finance/settings/expense-categories${scoped(eventId)}`),
createCategory: (data: Partial<ExpenseCategory>) => fetchApi<{ category: ExpenseCategory }>('/api/finance/settings/expense-categories', json('POST', data)),
updateCategory: (id: string, data: Partial<ExpenseCategory>) => fetchApi<{ category: ExpenseCategory }>(`/api/finance/settings/expense-categories/${id}`, json('PUT', data)),
deleteCategory: (id: string) => fetchApi<{ message: string }>(`/api/finance/settings/expense-categories/${id}`, json('DELETE')),
getTemplates: (eventId?: string) => fetchApi<{ templates: ExpenseTemplate[] }>(`/api/finance/settings/expense-templates${scoped(eventId)}`),
createTemplate: (data: Partial<ExpenseTemplate>) => fetchApi<{ template: ExpenseTemplate }>('/api/finance/settings/expense-templates', json('POST', data)),
updateTemplate: (id: string, data: Partial<ExpenseTemplate>) => fetchApi<{ template: ExpenseTemplate }>(`/api/finance/settings/expense-templates/${id}`, json('PUT', data)),
deleteTemplate: (id: string) => fetchApi<{ message: string }>(`/api/finance/settings/expense-templates/${id}`, json('DELETE')),
getPacks: (eventId?: string) => fetchApi<{ packs: ExpenseTemplatePack[] }>(`/api/finance/settings/expense-template-packs${scoped(eventId)}`),
createPack: (data: Partial<ExpenseTemplatePack>) => fetchApi<{ pack: ExpenseTemplatePack }>('/api/finance/settings/expense-template-packs', json('POST', data)),
updatePack: (id: string, data: Partial<ExpenseTemplatePack>) => fetchApi<{ pack: ExpenseTemplatePack }>(`/api/finance/settings/expense-template-packs/${id}`, json('PUT', data)),
deletePack: (id: string) => fetchApi<{ message: string }>(`/api/finance/settings/expense-template-packs/${id}`, json('DELETE')),
getFees: () => fetchApi<{ fees: PaymentMethodFee[] }>('/api/finance/settings/payment-fees'),
updateFee: (method: string, data: { percentBp: number; fixedAmount: number }) =>
fetchApi<{ fee: PaymentMethodFee }>(`/api/finance/settings/payment-fees/${method}`, json('PUT', data)),
overview: (params: { from?: string; to?: string; series?: string; venue?: string; partner?: string } = {}) => {
const q = new URLSearchParams();
Object.entries(params).forEach(([k, v]) => { if (v) q.set(k, v); });
const qs = q.toString();
return fetchApi<FinanceOverview>(`/api/finance/overview${qs ? `?${qs}` : ''}`);
},
myEvents: () => fetchApi<{ events: MyEvent[] }>('/api/dashboard/my-events'),
};
+9
View File
@@ -29,6 +29,15 @@ export { legalSettingsApi } from './legalSettings';
export { legalPagesApi } from './legalPages';
export { faqApi } from './faq';
export { photosApi } from './photos';
export {
financeApi, EVENT_PERMISSIONS, ROLE_PRESETS, PRESET_PERMISSIONS, CALC_TYPES, SHARE_TYPES, LOSS_RULES,
} from './finance';
export type {
EventPermission, RolePreset, CalcType, ShareType, LossRule, FinanceStatus, MyEventPermissions, ExpenseCategory,
EventExpense, OtherIncome, EventPartner, PartnerLine, FinanceSummary, EventFinance, ExpenseTemplate,
ExpenseTemplatePack, PaymentMethodFee, EventMember, FinanceAuditEntry, FinanceOverview, FinanceTotals, MyEvent,
ExpenseInput, PartnerInput,
} from './finance';
export type {
Photo,
PhotoGallery,
+10
View File
@@ -1,3 +1,6 @@
// online: book online · door: online closed, pay at the door until the event ends
export type EventSalesState = 'online' | 'door' | 'sold_out' | 'ended' | 'external' | 'cancelled';
export interface Event {
id: string;
slug: string;
@@ -24,9 +27,16 @@ export interface Event {
presaleClosureEnabled?: boolean | null; // null = inherit the site default
presaleCloseMinutesBefore?: number | null; // null = inherit the site default
presaleClosesAt?: string | null; // server-computed cutoff (ISO); null = never closes
series?: string | null; // groups recurring events in the finance overview
bookedCount?: number; // paid seats (confirmed + checked_in)
claimedCount?: number; // "I've paid" claims awaiting admin verification (hold seats)
availableSeats?: number; // capacity - booked - claimed; the server-authoritative number
// Public sales state, computed by the server (backend lib/salesState.ts).
salesState?: EventSalesState;
// Resolved door price (walk-in price, else ticket price). Only sent in the `door` state.
doorPrice?: number;
// Door tenders accepted, sent with the single event in the `door` state.
doorPaymentMethods?: Array<'cash' | 'bitcoin' | 'transfer' | 'pos'>;
isFeatured?: boolean;
createdAt: string;
updatedAt: string;
+35
View File
@@ -0,0 +1,35 @@
import { describe, it, expect } from 'vitest';
import { formatNumber, formatPercent, formatPyg, formatPygShort } from './money';
describe('money formatting', () => {
it('uses the UI language thousands separator and never decimals', () => {
expect(formatPyg(2470000, 'es')).toBe('2.470.000 PYG');
expect(formatPyg(2470000, 'en')).toBe('2,470,000 PYG');
expect(formatPyg(1234.6, 'en')).toBe('1,235 PYG');
expect(formatPyg(0, 'es')).toBe('0 PYG');
});
it('keeps the sign on losses', () => {
expect(formatPyg(-144350, 'es')).toBe('-144.350 PYG');
expect(formatNumber(-1000, 'en')).toBe('-1,000');
});
it('abbreviates axis labels with the locale decimal mark', () => {
expect(formatPygShort(2470000, 'es')).toBe('2,5M');
expect(formatPygShort(2470000, 'en')).toBe('2.5M');
expect(formatPygShort(12000000, 'en')).toBe('12M');
expect(formatPygShort(850000, 'es')).toBe('850k');
expect(formatPygShort(-850000, 'en')).toBe('-850k');
expect(formatPygShort(500, 'en')).toBe('500');
});
it('formats basis points as a percent', () => {
expect(formatPercent(290, 'es')).toBe('2,9%');
expect(formatPercent(290, 'en')).toBe('2.9%');
expect(formatPercent(3000, 'en')).toBe('30%');
});
it('defaults to Spanish separators', () => {
expect(formatPyg(1500000)).toBe('1.500.000 PYG');
});
});
+40
View File
@@ -0,0 +1,40 @@
// The one place PYG amounts are formatted for admin screens (cards, tables,
// charts, modal previews). PYG has no decimals. Separators follow the UI
// language: English 2,470,000 PYG, Spanish 2.470.000 PYG. Grouping is done by
// hand so it does not depend on the browser's locale data. Components use the
// useMoney() hook (lib/useMoney.ts) to bind these to the active language.
export type MoneyLocale = 'en' | 'es';
const SEP: Record<MoneyLocale, { group: string; decimal: string }> = {
en: { group: ',', decimal: '.' },
es: { group: '.', decimal: ',' },
};
const sepFor = (locale: string) => SEP[locale === 'en' ? 'en' : 'es'];
export function formatNumber(n: number, locale: string = 'es'): string {
const sign = n < 0 ? '-' : '';
return sign + Math.abs(Math.round(n)).toString().replace(/\B(?=(\d{3})+(?!\d))/g, sepFor(locale).group);
}
export function formatPyg(n: number, locale: string = 'es'): string {
return `${formatNumber(n, locale)} PYG`;
}
/** Axis labels: 2.470.000 -> 2,5M (es) / 2.5M (en); 850.000 -> 850k. */
export function formatPygShort(n: number, locale: string = 'es'): string {
const abs = Math.abs(n);
const sign = n < 0 ? '-' : '';
if (abs >= 1_000_000) {
return `${sign}${(abs / 1_000_000).toFixed(abs >= 10_000_000 ? 0 : 1).replace('.', sepFor(locale).decimal)}M`;
}
if (abs >= 1_000) return `${sign}${Math.round(abs / 1_000)}k`;
return `${sign}${abs}`;
}
/** Basis points as a percent string: 290 -> "2.9%" (en) / "2,9%" (es). */
export function formatPercent(bp: number, locale: string = 'es'): string {
const v = Math.round(bp) / 100;
return `${String(v).replace('.', sepFor(locale).decimal)}%`;
}
+26
View File
@@ -0,0 +1,26 @@
import { describe, it, expect } from 'vitest';
import { nextSalesStateChangeMs } from './utils';
const START = '2030-01-01T23:00:00.000Z';
const END = '2030-01-02T02:00:00.000Z';
const CLOSES = '2030-01-01T21:00:00.000Z';
describe('nextSalesStateChangeMs', () => {
it('online flips at the pre-sale cutoff, or the start when closure is off', () => {
expect(nextSalesStateChangeMs({ salesState: 'online', presaleClosesAt: CLOSES, startDatetime: START }))
.toBe(Date.parse(CLOSES));
expect(nextSalesStateChangeMs({ salesState: 'online', presaleClosesAt: null, startDatetime: START }))
.toBe(Date.parse(START));
});
it('door and sold_out flip at the end time, or the start when there is no end', () => {
expect(nextSalesStateChangeMs({ salesState: 'door', startDatetime: START, endDatetime: END })).toBe(Date.parse(END));
expect(nextSalesStateChangeMs({ salesState: 'sold_out', startDatetime: START })).toBe(Date.parse(START));
});
it('other states have no timed change', () => {
for (const salesState of ['ended', 'external', 'cancelled', undefined]) {
expect(nextSalesStateChangeMs({ salesState, startDatetime: START, endDatetime: END })).toBeNull();
}
});
});
+15
View File
@@ -0,0 +1,15 @@
'use client';
import { useCallback, useMemo } from 'react';
import { useLanguage } from '@/context/LanguageContext';
import { formatNumber, formatPercent, formatPyg, formatPygShort } from './money';
/** Formatters bound to the active UI language. */
export function useMoney() {
const { locale } = useLanguage();
const pyg = useCallback((n: number) => formatPyg(n, locale), [locale]);
const num = useCallback((n: number) => formatNumber(n, locale), [locale]);
const short = useCallback((n: number) => formatPygShort(n, locale), [locale]);
const pct = useCallback((bp: number) => formatPercent(bp, locale), [locale]);
return useMemo(() => ({ locale, pyg, num, short, pct }), [locale, pyg, num, short, pct]);
}
+24
View File
@@ -249,6 +249,30 @@ export function isPresaleClosed(
return parseDate(event.presaleClosesAt).getTime() <= now.getTime();
}
/**
* When the server-computed `salesState` is next due to change on the clock
* (epoch ms), or null when it only changes through an edit or a booking:
* online → door/ended when online sales close (pre-sale cutoff, at the latest
* the start); door/sold_out → ended at the end time (or start when none).
* Mirrors backend lib/salesState.ts.
*/
export function nextSalesStateChangeMs(event: {
salesState?: string;
presaleClosesAt?: string | null;
startDatetime: string;
endDatetime?: string | null;
}): number | null {
const startMs = parseDate(event.startDatetime).getTime();
if (event.salesState === 'online') {
const closesMs = event.presaleClosesAt ? parseDate(event.presaleClosesAt).getTime() : startMs;
return Math.min(closesMs, startMs);
}
if (event.salesState === 'door' || event.salesState === 'sold_out') {
return event.endDatetime ? parseDate(event.endDatetime).getTime() : startMs;
}
return null;
}
export type DurationUnit = 'minutes' | 'hours' | 'days';
const MINUTES_PER_UNIT: Record<DurationUnit, number> = {
+3 -1
View File
@@ -35,6 +35,8 @@
".next-build/types/**/*.ts"
],
"exclude": [
"node_modules"
"node_modules",
"**/*.test.ts",
"vitest.config.ts"
]
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
import path from 'path';
// Unit tests for pure frontend logic (finance derivations, formatting).
export default defineConfig({
resolve: { alias: { '@': path.resolve(__dirname, 'src') } },
test: { include: ['src/**/*.test.ts'], environment: 'node' },
});