- Replace the three Attendees-tab modals (Manual Ticket / Add at Door / Invite Guest) with a single Add Ticket modal: Paid/Unpaid/Guest segmented control, shared fields, "Check in now" for all types, and a live "what happens" preview, backed by one POST /api/tickets/admin/add. - Add tickets.payment_status (paid | unpaid | comp) with a backfill migration; keep it in sync on every payment-settlement path (mark-paid, admin approval, Lightning, free bookings, hold recovery). - Show Paid/Unpaid/Comp badges in the attendee list, count only paid tickets toward revenue, let unpaid tickets be resolved via Mark Paid, and flag unpaid tickets with their balance due in the door scanner. - Replace the per-page useStatsPrivacy hook with an admin-wide PrivacyContext + SensitiveValue mask, toggled from the admin layout. - Add server-side pagination with page-size options to the users page. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
277 lines
11 KiB
TypeScript
277 lines
11 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect } from 'react';
|
|
import Link from 'next/link';
|
|
import { useLanguage } from '@/context/LanguageContext';
|
|
import { useAuth } from '@/context/AuthContext';
|
|
import { usePrivacy } from '@/context/PrivacyContext';
|
|
import { adminApi, DashboardData } from '@/lib/api';
|
|
import Card from '@/components/ui/Card';
|
|
import SensitiveValue from '@/components/admin/SensitiveValue';
|
|
import {
|
|
UsersIcon,
|
|
CalendarIcon,
|
|
TicketIcon,
|
|
CurrencyDollarIcon,
|
|
EnvelopeIcon,
|
|
UserGroupIcon,
|
|
ExclamationTriangleIcon,
|
|
} from '@heroicons/react/24/outline';
|
|
import { parseDate, eventSpotsLeft, isEventSoldOut } from '@/lib/utils';
|
|
|
|
export default function AdminDashboardPage() {
|
|
const { t, locale } = useLanguage();
|
|
const { user } = useAuth();
|
|
const { privacyMode } = usePrivacy();
|
|
const [data, setData] = useState<DashboardData | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
useEffect(() => {
|
|
adminApi.getDashboard()
|
|
.then(({ dashboard }) => setData(dashboard))
|
|
.catch(console.error)
|
|
.finally(() => setLoading(false));
|
|
}, []);
|
|
|
|
const formatDate = (dateStr: string) => {
|
|
return parseDate(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
timeZone: 'America/Asuncion',
|
|
});
|
|
};
|
|
|
|
const statCards = data ? [
|
|
{
|
|
label: t('admin.dashboard.stats.users'),
|
|
value: data.stats.totalUsers,
|
|
icon: UsersIcon,
|
|
color: 'bg-blue-100 text-blue-600',
|
|
href: '/admin/users',
|
|
},
|
|
{
|
|
label: t('admin.dashboard.stats.events'),
|
|
value: data.stats.totalEvents,
|
|
icon: CalendarIcon,
|
|
color: 'bg-purple-100 text-purple-600',
|
|
href: '/admin/events',
|
|
},
|
|
{
|
|
label: t('admin.dashboard.stats.tickets'),
|
|
value: data.stats.confirmedTickets,
|
|
icon: TicketIcon,
|
|
color: 'bg-green-100 text-green-600',
|
|
href: '/admin/tickets',
|
|
},
|
|
{
|
|
label: t('admin.dashboard.stats.revenue'),
|
|
value: `${data.stats.totalRevenue.toLocaleString()} PYG`,
|
|
icon: CurrencyDollarIcon,
|
|
color: 'bg-yellow-100 text-yellow-600',
|
|
href: '/admin/payments',
|
|
},
|
|
] : [];
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="flex items-center justify-center py-12">
|
|
<div className="animate-spin w-8 h-8 border-4 border-primary-yellow border-t-transparent rounded-full" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
<div className="mb-8">
|
|
<h1 className="text-2xl font-bold text-primary-dark">{t('admin.dashboard.title')}</h1>
|
|
<p className="text-gray-600">
|
|
{t('admin.dashboard.welcome')}, {user?.name}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Stats Grid */}
|
|
{!privacyMode && (
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
|
{statCards.map((stat) => (
|
|
<Link key={stat.label} href={stat.href}>
|
|
<Card className="p-6 hover:shadow-card-hover transition-shadow">
|
|
<div className="flex items-start justify-between">
|
|
<div>
|
|
<p className="text-sm text-gray-500">{stat.label}</p>
|
|
<p className="mt-2 text-2xl font-bold text-primary-dark">{stat.value}</p>
|
|
</div>
|
|
<div className={`w-12 h-12 rounded-btn flex items-center justify-center ${stat.color}`}>
|
|
<stat.icon className="w-6 h-6" />
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
</Link>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
{/* Alerts */}
|
|
<Card className="p-6">
|
|
<h2 className="font-semibold text-lg mb-4">Alerts</h2>
|
|
<div className="space-y-3">
|
|
{/* Low capacity warnings (availableSeats accounts for paid + claimed seats) */}
|
|
{data?.upcomingEvents
|
|
.filter(event => {
|
|
const spotsLeft = eventSpotsLeft(event);
|
|
return event.capacity > 0 && spotsLeft > 0 && spotsLeft / event.capacity <= 0.2;
|
|
})
|
|
.map(event => {
|
|
const spotsLeft = eventSpotsLeft(event);
|
|
const percentFull = Math.round(((event.capacity - spotsLeft) / event.capacity) * 100);
|
|
return (
|
|
<Link
|
|
key={event.id}
|
|
href="/admin/events"
|
|
className="flex items-center justify-between p-3 bg-orange-50 rounded-btn hover:bg-orange-100 transition-colors"
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
<ExclamationTriangleIcon className="w-5 h-5 text-orange-600" />
|
|
<div>
|
|
<span className="text-sm font-medium">{event.title}</span>
|
|
<p className="text-xs text-gray-500"><SensitiveValue>Only {spotsLeft} spots left ({percentFull}% full)</SensitiveValue></p>
|
|
</div>
|
|
</div>
|
|
<span className="badge badge-warning">Low capacity</span>
|
|
</Link>
|
|
);
|
|
})}
|
|
|
|
{/* Sold out events */}
|
|
{data?.upcomingEvents
|
|
.filter(event => isEventSoldOut(event))
|
|
.map(event => (
|
|
<Link
|
|
key={event.id}
|
|
href="/admin/events"
|
|
className="flex items-center justify-between p-3 bg-red-50 rounded-btn hover:bg-red-100 transition-colors"
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
<ExclamationTriangleIcon className="w-5 h-5 text-red-600" />
|
|
<div>
|
|
<span className="text-sm font-medium">{event.title}</span>
|
|
<p className="text-xs text-gray-500">Event is sold out!</p>
|
|
</div>
|
|
</div>
|
|
<span className="badge badge-danger">Sold out</span>
|
|
</Link>
|
|
))}
|
|
|
|
{/* Actionable: customer says they paid, needs verification */}
|
|
{data && (data.stats.awaitingApprovalPayments ?? 0) > 0 && (
|
|
<Link
|
|
href="/admin/payments"
|
|
className="flex items-center justify-between p-3 bg-yellow-50 rounded-btn hover:bg-yellow-100 transition-colors"
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
<CurrencyDollarIcon className="w-5 h-5 text-yellow-600" />
|
|
<span className="text-sm">Payments awaiting verification</span>
|
|
</div>
|
|
<span className="badge badge-warning"><SensitiveValue>{data.stats.awaitingApprovalPayments}</SensitiveValue></span>
|
|
</Link>
|
|
)}
|
|
{/* Informational: opened checkouts that never paid — hold no seats */}
|
|
{data && data.stats.pendingPayments > 0 && (
|
|
<Link
|
|
href="/admin/payments"
|
|
className="flex items-center justify-between p-3 bg-gray-50 rounded-btn hover:bg-gray-100 transition-colors"
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
<CurrencyDollarIcon className="w-5 h-5 text-gray-400" />
|
|
<span className="text-sm text-gray-500">Unpaid started bookings</span>
|
|
</div>
|
|
<span className="badge badge-info"><SensitiveValue>{data.stats.pendingPayments}</SensitiveValue></span>
|
|
</Link>
|
|
)}
|
|
{data && data.stats.newContacts > 0 && (
|
|
<Link
|
|
href="/admin/contacts"
|
|
className="flex items-center justify-between p-3 bg-blue-50 rounded-btn hover:bg-blue-100 transition-colors"
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
<EnvelopeIcon className="w-5 h-5 text-blue-600" />
|
|
<span className="text-sm">New messages</span>
|
|
</div>
|
|
<span className="badge badge-info">{data.stats.newContacts}</span>
|
|
</Link>
|
|
)}
|
|
|
|
{/* No alerts */}
|
|
{data &&
|
|
data.stats.pendingPayments === 0 &&
|
|
(data.stats.awaitingApprovalPayments ?? 0) === 0 &&
|
|
data.stats.newContacts === 0 &&
|
|
!data.upcomingEvents.some(e => e.capacity > 0 && eventSpotsLeft(e) / e.capacity <= 0.2) && (
|
|
<p className="text-gray-500 text-sm text-center py-2">No alerts at this time</p>
|
|
)}
|
|
</div>
|
|
</Card>
|
|
|
|
{/* Upcoming Events */}
|
|
<Card className="p-6">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h2 className="font-semibold text-lg">Upcoming Events</h2>
|
|
<Link href="/admin/events" className="text-sm text-secondary-blue hover:underline">
|
|
{t('common.viewAll')}
|
|
</Link>
|
|
</div>
|
|
{data?.upcomingEvents.length === 0 ? (
|
|
<p className="text-gray-500 text-sm">No upcoming events</p>
|
|
) : (
|
|
<div className="space-y-3">
|
|
{data?.upcomingEvents.slice(0, 5).map((event) => (
|
|
<Link
|
|
key={event.id}
|
|
href={`/admin/events`}
|
|
className="flex items-center justify-between p-3 bg-secondary-gray rounded-btn hover:bg-gray-200 transition-colors"
|
|
>
|
|
<div>
|
|
<p className="font-medium text-sm">{event.title}</p>
|
|
<p className="text-xs text-gray-500">{formatDate(event.startDatetime)}</p>
|
|
</div>
|
|
{!privacyMode && (
|
|
<span className="text-sm text-gray-600">
|
|
{(event.bookedCount || 0) + (event.claimedCount || 0)}/{event.capacity}
|
|
{(event.claimedCount || 0) > 0 && (
|
|
<span className="text-xs text-yellow-600 block text-right">
|
|
{event.claimedCount} pending approval
|
|
</span>
|
|
)}
|
|
</span>
|
|
)}
|
|
</Link>
|
|
))}
|
|
</div>
|
|
)}
|
|
</Card>
|
|
|
|
{/* Quick Stats */}
|
|
{!privacyMode && (
|
|
<Card className="p-6">
|
|
<h2 className="font-semibold text-lg mb-4">Quick Stats</h2>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="text-center p-4 bg-secondary-gray rounded-btn">
|
|
<UserGroupIcon className="w-8 h-8 mx-auto text-gray-400" />
|
|
<p className="mt-2 text-2xl font-bold">{data?.stats.totalSubscribers || 0}</p>
|
|
<p className="text-xs text-gray-500">Subscribers</p>
|
|
</div>
|
|
<div className="text-center p-4 bg-secondary-gray rounded-btn">
|
|
<TicketIcon className="w-8 h-8 mx-auto text-gray-400" />
|
|
<p className="mt-2 text-2xl font-bold">{data?.stats.totalTickets || 0}</p>
|
|
<p className="text-xs text-gray-500">Total Bookings</p>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|