Files
Spanglish/frontend/src/app/admin/page.tsx
T
MichilisandClaude Fable 5 9b2668f498 Unify admin ticket creation into one modal with first-class payment status.
- 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>
2026-07-26 05:25:00 +00:00

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>
);
}