Security recovery: hold sweep, dashboard updates, and admin fixes.

This commit is contained in:
Michilis
2026-07-01 05:51:38 +00:00
parent 38526f17b5
commit cacc52ec24
45 changed files with 1452 additions and 474 deletions
@@ -0,0 +1,5 @@
import { notFound } from 'next/navigation';
export default function AdminCatchAll() {
notFound();
}
+53 -3
View File
@@ -2,7 +2,7 @@
import { useState, useEffect } from 'react';
import { useLanguage } from '@/context/LanguageContext';
import { ticketsApi, eventsApi, Ticket, Event } from '@/lib/api';
import { ticketsApi, eventsApi, paymentsApi, Ticket, Event } from '@/lib/api';
import { parseDate } from '@/lib/utils';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
@@ -17,6 +17,7 @@ import {
PhoneIcon,
FunnelIcon,
MagnifyingGlassIcon,
ArrowPathIcon,
} from '@heroicons/react/24/outline';
import toast from 'react-hot-toast';
import clsx from 'clsx';
@@ -101,6 +102,20 @@ export default function AdminBookingsPage() {
}
};
const handleReactivate = async (ticket: TicketWithDetails) => {
if (!ticket.payment?.id) return;
setProcessing(ticket.id);
try {
await paymentsApi.reactivate(ticket.payment.id);
toast.success('Booking reactivated');
loadData();
} catch (error: any) {
toast.error(error.message || 'Failed to reactivate booking');
} finally {
setProcessing(null);
}
};
const handleCancel = async (ticketId: string) => {
if (!confirm('Are you sure you want to cancel this booking?')) return;
@@ -133,6 +148,7 @@ export default function AdminBookingsPage() {
case 'pending': return 'bg-yellow-100 text-yellow-800';
case 'cancelled': return 'bg-red-100 text-red-800';
case 'checked_in': return 'bg-blue-100 text-blue-800';
case 'on_hold': return 'bg-slate-100 text-slate-600';
default: return 'bg-gray-100 text-gray-800';
}
};
@@ -144,6 +160,7 @@ export default function AdminBookingsPage() {
case 'failed':
case 'cancelled': return 'bg-red-100 text-red-800';
case 'refunded': return 'bg-purple-100 text-purple-800';
case 'on_hold': return 'bg-slate-100 text-slate-600';
default: return 'bg-gray-100 text-gray-800';
}
};
@@ -191,6 +208,7 @@ export default function AdminBookingsPage() {
confirmed: tickets.filter(t => t.status === 'confirmed').length,
checkedIn: tickets.filter(t => t.status === 'checked_in').length,
cancelled: tickets.filter(t => t.status === 'cancelled').length,
onHold: tickets.filter(t => t.status === 'on_hold').length,
pendingPayment: tickets.filter(t => t.payment?.status === 'pending').length,
};
@@ -218,6 +236,9 @@ export default function AdminBookingsPage() {
if (ticket.status === 'pending' && ticket.payment?.status === 'pending') {
return { label: 'Mark Paid', onClick: () => handleMarkPaid(ticket.id), color: 'text-green-600' };
}
if (ticket.status === 'on_hold') {
return { label: 'Mark Paid', onClick: () => handleMarkPaid(ticket.id), color: 'text-green-600' };
}
if (ticket.status === 'confirmed') {
return { label: 'Check In', onClick: () => handleCheckin(ticket.id), color: 'text-blue-600' };
}
@@ -239,7 +260,7 @@ export default function AdminBookingsPage() {
</div>
{/* Stats Cards */}
<div className="grid grid-cols-3 md:grid-cols-3 lg:grid-cols-6 gap-2 md:gap-4 mb-6">
<div className="grid grid-cols-3 md:grid-cols-4 lg:grid-cols-7 gap-2 md:gap-4 mb-6">
<Card className="p-3 md:p-4 text-center">
<p className="text-xl md:text-2xl font-bold text-primary-dark">{stats.total}</p>
<p className="text-xs md:text-sm text-gray-500">Total</p>
@@ -260,6 +281,10 @@ export default function AdminBookingsPage() {
<p className="text-xl md:text-2xl font-bold text-red-600">{stats.cancelled}</p>
<p className="text-xs md:text-sm text-gray-500">Cancelled</p>
</Card>
<Card className="p-3 md:p-4 text-center border-l-4 border-slate-400">
<p className="text-xl md:text-2xl font-bold text-slate-600">{stats.onHold}</p>
<p className="text-xs md:text-sm text-gray-500">On Hold</p>
</Card>
<Card className="p-3 md:p-4 text-center border-l-4 border-orange-400">
<p className="text-xl md:text-2xl font-bold text-orange-600">{stats.pendingPayment}</p>
<p className="text-xs md:text-sm text-gray-500">Pending Pay</p>
@@ -305,6 +330,7 @@ export default function AdminBookingsPage() {
<option value="confirmed">Confirmed</option>
<option value="checked_in">Checked In</option>
<option value="cancelled">Cancelled</option>
<option value="on_hold">On Hold</option>
</select>
</div>
<div>
@@ -316,6 +342,7 @@ export default function AdminBookingsPage() {
<option value="paid">Paid</option>
<option value="refunded">Refunded</option>
<option value="failed">Failed</option>
<option value="on_hold">On Hold</option>
</select>
</div>
</div>
@@ -368,6 +395,7 @@ export default function AdminBookingsPage() {
<thead className="bg-secondary-gray">
<tr>
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Attendee</th>
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">RUC</th>
<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">Payment</th>
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
@@ -378,7 +406,7 @@ export default function AdminBookingsPage() {
<tbody className="divide-y divide-secondary-light-gray">
{sortedTickets.length === 0 ? (
<tr>
<td colSpan={6} className="px-4 py-12 text-center text-gray-500 text-sm">
<td colSpan={7} className="px-4 py-12 text-center text-gray-500 text-sm">
No bookings found.
</td>
</tr>
@@ -392,6 +420,7 @@ export default function AdminBookingsPage() {
<p className="text-xs text-gray-500 truncate max-w-[200px]">{ticket.attendeeEmail || 'N/A'}</p>
{ticket.attendeePhone && <p className="text-xs text-gray-400">{ticket.attendeePhone}</p>}
</td>
<td className="px-4 py-3 text-sm text-gray-600">{ticket.attendeeRuc || '-'}</td>
<td className="px-4 py-3">
<span className="text-sm truncate max-w-[150px] block">
{ticket.event?.title || events.find(e => e.id === ticket.eventId)?.title || 'Unknown'}
@@ -431,6 +460,18 @@ export default function AdminBookingsPage() {
Check In
</Button>
)}
{ticket.status === 'on_hold' && (
<>
<Button size="sm" variant="outline" onClick={() => handleMarkPaid(ticket.id)}
isLoading={processing === ticket.id} className="text-xs px-2 py-1">
Mark Paid
</Button>
<Button size="sm" variant="outline" onClick={() => handleReactivate(ticket)}
isLoading={processing === ticket.id} className="text-xs px-2 py-1">
Reactivate
</Button>
</>
)}
{(ticket.status === 'pending' || ticket.status === 'confirmed') && (
<MoreMenu>
<DropdownItem onClick={() => handleCancel(ticket.id)} className="text-red-600">
@@ -519,6 +560,13 @@ export default function AdminBookingsPage() {
</DropdownItem>
</MoreMenu>
)}
{ticket.status === 'on_hold' && (
<MoreMenu>
<DropdownItem onClick={() => handleReactivate(ticket)}>
<ArrowPathIcon className="w-4 h-4 mr-2" /> Reactivate
</DropdownItem>
</MoreMenu>
)}
{ticket.status === 'checked_in' && (
<span className="text-[10px] text-green-600 flex items-center gap-1">
<CheckCircleIcon className="w-3.5 h-3.5" /> Attended
@@ -557,6 +605,7 @@ export default function AdminBookingsPage() {
{ value: 'confirmed', label: `Confirmed (${stats.confirmed})` },
{ value: 'checked_in', label: `Checked In (${stats.checkedIn})` },
{ value: 'cancelled', label: `Cancelled (${stats.cancelled})` },
{ value: 'on_hold', label: `On Hold (${stats.onHold})` },
].map((opt) => (
<button
key={opt.value}
@@ -581,6 +630,7 @@ export default function AdminBookingsPage() {
{ value: 'paid', label: 'Paid' },
{ value: 'refunded', label: 'Refunded' },
{ value: 'failed', label: 'Failed' },
{ value: 'on_hold', label: 'On Hold' },
].map((opt) => (
<button
key={opt.value}
@@ -6,6 +6,7 @@ export function StatusBadge({ status, compact = false }: { status: string; compa
confirmed: 'bg-green-100 text-green-800',
cancelled: 'bg-red-100 text-red-800',
checked_in: 'bg-blue-100 text-blue-800',
on_hold: 'bg-slate-100 text-slate-600',
};
return (
<span className={clsx(
@@ -20,6 +20,7 @@ interface EventModalsProps {
confirmedCount: number;
checkedInCount: number;
cancelledCount: number;
onHoldCount: number;
statusFilter: AttendeeStatusFilter;
setStatusFilter: (value: AttendeeStatusFilter) => void;
// mobile filter sheet
@@ -75,6 +76,7 @@ export function EventModals(props: EventModalsProps) {
confirmedCount,
checkedInCount,
cancelledCount,
onHoldCount,
statusFilter,
setStatusFilter,
mobileFilterOpen,
@@ -129,6 +131,7 @@ export function EventModals(props: EventModalsProps) {
{ value: 'confirmed', label: `Confirmed (${confirmedCount})` },
{ value: 'checked_in', label: `Checked In (${checkedInCount})` },
{ value: 'cancelled', label: `Cancelled (${cancelledCount})` },
{ value: 'on_hold', label: `On Hold (${onHoldCount})` },
].map((option) => (
<button
key={option.value}
@@ -13,6 +13,7 @@ import {
StarIcon,
FunnelIcon,
ChatBubbleLeftIcon,
ArrowPathIcon,
} from '@heroicons/react/24/outline';
import { StatusBadge } from '../_components/StatusBadge';
import type { AttendeeStatusFilter, PrimaryAction } from '../_types';
@@ -29,6 +30,7 @@ interface AttendeesTabProps {
confirmedCount: number;
checkedInCount: number;
cancelledCount: number;
onHoldCount: number;
exporting: boolean;
showExportDropdown: boolean;
setShowExportDropdown: (value: boolean) => void;
@@ -43,6 +45,7 @@ interface AttendeesTabProps {
setShowAddTicketSheet: (value: boolean) => void;
getPrimaryAction: (ticket: Ticket) => PrimaryAction | null;
handleOpenNoteModal: (ticket: Ticket) => void;
handleReactivate: (ticket: Ticket) => void;
}
export function AttendeesTab({
@@ -57,6 +60,7 @@ export function AttendeesTab({
confirmedCount,
checkedInCount,
cancelledCount,
onHoldCount,
exporting,
showExportDropdown,
setShowExportDropdown,
@@ -71,6 +75,7 @@ export function AttendeesTab({
setShowAddTicketSheet,
getPrimaryAction,
handleOpenNoteModal,
handleReactivate,
}: AttendeesTabProps) {
return (
<div className="space-y-3">
@@ -98,6 +103,7 @@ export function AttendeesTab({
<option value="confirmed">Confirmed ({confirmedCount})</option>
<option value="checked_in">Checked In ({checkedInCount})</option>
<option value="cancelled">Cancelled ({cancelledCount})</option>
<option value="on_hold">On Hold ({onHoldCount})</option>
</select>
<div className="flex-1" />
@@ -241,6 +247,7 @@ export function AttendeesTab({
<td className="px-4 py-2.5">
<p className="text-sm text-gray-600 truncate max-w-[200px]">{ticket.attendeeEmail}</p>
{ticket.attendeePhone && <p className="text-xs text-gray-400">{ticket.attendeePhone}</p>}
{ticket.attendeeRuc && <p className="text-xs text-gray-400">RUC: {ticket.attendeeRuc}</p>}
</td>
<td className="px-4 py-2.5">
<div className="flex items-center gap-1 flex-wrap">
@@ -267,6 +274,11 @@ export function AttendeesTab({
</Button>
)}
<MoreMenu>
{ticket.status === 'on_hold' && (
<DropdownItem onClick={() => handleReactivate(ticket)}>
<ArrowPathIcon className="w-4 h-4 mr-2" /> Reactivate
</DropdownItem>
)}
<DropdownItem onClick={() => handleOpenNoteModal(ticket)}>
<ChatBubbleLeftIcon className="w-4 h-4 mr-2" />
{ticket.adminNote ? 'Edit Note' : 'Add Note'}
@@ -307,6 +319,7 @@ export function AttendeesTab({
<p className="font-medium text-sm truncate">{ticket.attendeeFirstName} {ticket.attendeeLastName || ''}</p>
<p className="text-xs text-gray-500 truncate">{ticket.attendeeEmail}</p>
{ticket.attendeePhone && <p className="text-[10px] text-gray-400">{ticket.attendeePhone}</p>}
{ticket.attendeeRuc && <p className="text-[10px] text-gray-400">RUC: {ticket.attendeeRuc}</p>}
</div>
<div className="flex items-center gap-1.5 flex-shrink-0 flex-wrap justify-end">
<StatusBadge status={ticket.status} compact />
@@ -328,6 +341,11 @@ export function AttendeesTab({
</Button>
)}
<MoreMenu>
{ticket.status === 'on_hold' && (
<DropdownItem onClick={() => handleReactivate(ticket)}>
<ArrowPathIcon className="w-4 h-4 mr-2" /> Reactivate
</DropdownItem>
)}
<DropdownItem onClick={() => handleOpenNoteModal(ticket)}>
<ChatBubbleLeftIcon className="w-4 h-4 mr-2" />
{ticket.adminNote ? 'Edit Note' : 'Add Note'}
+1 -1
View File
@@ -2,7 +2,7 @@ import type { ComponentType } from 'react';
export type TabType = 'overview' | 'attendees' | 'tickets' | 'email' | 'payments';
export type AttendeeStatusFilter = 'all' | 'pending' | 'confirmed' | 'checked_in' | 'cancelled';
export type AttendeeStatusFilter = 'all' | 'pending' | 'confirmed' | 'checked_in' | 'cancelled' | 'on_hold';
export type TicketStatusFilter = 'all' | 'confirmed' | 'checked_in';
export type RecipientFilter = 'all' | 'confirmed' | 'pending' | 'checked_in';
+17 -2
View File
@@ -4,7 +4,7 @@ import { useState, useEffect, useRef } from 'react';
import { useParams, useRouter } from 'next/navigation';
import Link from 'next/link';
import { useLanguage } from '@/context/LanguageContext';
import { ticketsApi, emailsApi, adminApi, Ticket } from '@/lib/api';
import { ticketsApi, emailsApi, adminApi, paymentsApi, Ticket } from '@/lib/api';
import { formatDateLong, formatDateCompact, formatTime } from '@/lib/utils';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
@@ -163,6 +163,17 @@ export default function AdminEventDetailPage() {
}
};
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 {
@@ -421,6 +432,7 @@ export default function AdminEventDetailPage() {
const pendingCount = getTicketsByStatus('pending').length;
const checkedInCount = getTicketsByStatus('checked_in').length;
const cancelledCount = getTicketsByStatus('cancelled').length;
const onHoldCount = getTicketsByStatus('on_hold').length;
const paidConfirmedCount = getTicketsByStatus('confirmed').filter(t => !t.isGuest).length;
const paidCheckedInCount = getTicketsByStatus('checked_in').filter(t => !t.isGuest).length;
const revenue = (paidConfirmedCount + paidCheckedInCount) * event.price;
@@ -435,7 +447,7 @@ export default function AdminEventDetailPage() {
// ========== Primary action for a ticket ==========
const getPrimaryAction = (ticket: Ticket): PrimaryAction | null => {
if (ticket.status === 'pending') {
if (ticket.status === 'pending' || ticket.status === 'on_hold') {
return { label: 'Mark Paid', onClick: () => handleMarkPaid(ticket.id), variant: 'outline' };
}
if (ticket.status === 'confirmed') {
@@ -671,6 +683,7 @@ export default function AdminEventDetailPage() {
confirmedCount={confirmedCount}
checkedInCount={checkedInCount}
cancelledCount={cancelledCount}
onHoldCount={onHoldCount}
exporting={exporting}
showExportDropdown={showExportDropdown}
setShowExportDropdown={setShowExportDropdown}
@@ -685,6 +698,7 @@ export default function AdminEventDetailPage() {
setShowAddTicketSheet={setShowAddTicketSheet}
getPrimaryAction={getPrimaryAction}
handleOpenNoteModal={handleOpenNoteModal}
handleReactivate={handleReactivate}
/>
)}
@@ -742,6 +756,7 @@ export default function AdminEventDetailPage() {
confirmedCount={confirmedCount}
checkedInCount={checkedInCount}
cancelledCount={cancelledCount}
onHoldCount={onHoldCount}
statusFilter={statusFilter}
setStatusFilter={setStatusFilter}
mobileFilterOpen={mobileFilterOpen}
+11 -4
View File
@@ -62,6 +62,10 @@ export default function AdminLayout({
const allowedPathsForRole = new Set(
navigationWithRoles.filter((item) => item.allowedRoles.includes(userRole)).map((item) => item.href)
);
// All known admin routes regardless of role, used only to tell "not allowed
// for this role" apart from "doesn't exist" - the latter should render the
// 404 page instead of bouncing to the default route.
const allAdminHrefs = new Set(navigationWithRoles.map((item) => item.href));
const defaultAdminRoute =
userRole === 'staff' ? '/admin/scanner' : userRole === 'marketing' ? '/admin/contacts' : '/admin';
@@ -79,11 +83,14 @@ export default function AdminLayout({
router.replace(defaultAdminRoute);
return;
}
const isPathAllowed = (path: string) => {
if (allowedPathsForRole.has(path)) return true;
return Array.from(allowedPathsForRole).some((allowed) => path.startsWith(allowed + '/'));
const matchesHrefSet = (path: string, hrefs: Set<string>) => {
if (hrefs.has(path)) return true;
return Array.from(hrefs).some((href) => path.startsWith(href + '/'));
};
if (!isPathAllowed(pathname)) {
// Unknown route entirely (e.g. a typo'd URL) - let it fall through to the
// admin 404 page instead of silently redirecting away.
if (!matchesHrefSet(pathname, allAdminHrefs)) return;
if (!matchesHrefSet(pathname, allowedPathsForRole)) {
router.replace(defaultAdminRoute);
}
}, [pathname, userRole, defaultAdminRoute, router, user, hasAdminAccess]);
+9
View File
@@ -0,0 +1,9 @@
import { NotFoundMessage } from '@/components/NotFoundMessage';
export default function AdminNotFound() {
return (
<div className="flex-1 flex items-center justify-center">
<NotFoundMessage />
</div>
);
}
+73 -12
View File
@@ -147,6 +147,20 @@ export default function AdminPaymentsPage() {
}
};
const handleReactivate = async (payment: PaymentWithDetails) => {
setProcessing(true);
try {
await paymentsApi.reactivate(payment.id);
toast.success(locale === 'es' ? 'Reserva reactivada' : 'Booking reactivated');
setSelectedPayment(null);
loadData();
} catch (error: any) {
toast.error(error.message || 'Failed to reactivate booking');
} finally {
setProcessing(false);
}
};
const handleRefund = async (id: string) => {
if (!confirm('Are you sure you want to process this refund?')) return;
@@ -178,7 +192,7 @@ export default function AdminPaymentsPage() {
const downloadCSV = () => {
if (!exportData) return;
const headers = ['Payment ID', 'Amount', 'Currency', 'Provider', 'Status', 'Reference', 'Paid At', 'Created At', 'Attendee Name', 'Attendee Email', 'Event Title', 'Event Date'];
const headers = ['Payment ID', 'Amount', 'Currency', 'Provider', 'Status', 'Reference', 'Paid At', 'Created At', 'Attendee Name', 'Attendee Email', 'RUC', 'Event Title', 'Event Date'];
const rows = exportData.payments.map(p => [
p.paymentId,
p.amount,
@@ -190,6 +204,7 @@ export default function AdminPaymentsPage() {
p.createdAt,
`${p.attendeeFirstName} ${p.attendeeLastName || ''}`.trim(),
p.attendeeEmail || '',
p.attendeeRuc || '',
p.eventTitle,
p.eventDate,
]);
@@ -225,6 +240,7 @@ export default function AdminPaymentsPage() {
refunded: 'bg-blue-100 text-blue-700',
failed: 'bg-red-100 text-red-700',
cancelled: 'bg-gray-100 text-gray-700',
on_hold: 'bg-slate-100 text-slate-600',
};
const labels: Record<string, string> = {
pending: locale === 'es' ? 'Pendiente' : 'Pending',
@@ -233,6 +249,7 @@ export default function AdminPaymentsPage() {
refunded: locale === 'es' ? 'Reembolsado' : 'Refunded',
failed: locale === 'es' ? 'Fallido' : 'Failed',
cancelled: locale === 'es' ? 'Cancelado' : 'Cancelled',
on_hold: locale === 'es' ? 'En Espera' : 'On Hold',
};
return (
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${styles[status] || 'bg-gray-100 text-gray-700'}`}>
@@ -343,6 +360,8 @@ export default function AdminPaymentsPage() {
payments.filter(p => p.status === 'paid')
);
const pendingApprovalBookingsCount = getUniqueBookingsCount(visiblePendingApprovalPayments);
const onHoldPayments = payments.filter(p => p.status === 'on_hold');
const onHoldBookingsCount = getUniqueBookingsCount(onHoldPayments);
if (loading) {
return (
@@ -414,6 +433,9 @@ export default function AdminPaymentsPage() {
{selectedPayment.ticket.attendeePhone && (
<p className="text-sm text-gray-600">{selectedPayment.ticket.attendeePhone}</p>
)}
{selectedPayment.ticket.attendeeRuc && (
<p className="text-sm text-gray-600">RUC: {selectedPayment.ticket.attendeeRuc}</p>
)}
</div>
)}
@@ -475,6 +497,15 @@ export default function AdminPaymentsPage() {
</div>
</div>
{selectedPayment.status === 'on_hold' && (
<div className="mb-3">
<Button variant="outline" onClick={() => handleReactivate(selectedPayment)} isLoading={processing} className="w-full min-h-[44px]">
<ArrowPathIcon className="w-5 h-5 mr-2" />
{locale === 'es' ? 'Reactivar (volver a pendiente)' : 'Reactivate (back to pending)'}
</Button>
</div>
)}
<div className="flex gap-3">
<Button onClick={() => handleApprove(selectedPayment)} isLoading={processing} className="flex-1 min-h-[44px]">
<CheckCircleIcon className="w-5 h-5 mr-2" />
@@ -486,13 +517,15 @@ export default function AdminPaymentsPage() {
{locale === 'es' ? 'Rechazar' : 'Reject'}
</Button>
</div>
<div className="pt-2 border-t">
<Button variant="outline" onClick={() => handleSendReminder(selectedPayment)} isLoading={sendingReminder} className="w-full min-h-[44px]">
<EnvelopeIcon className="w-5 h-5 mr-2" />
{locale === 'es' ? 'Enviar recordatorio' : 'Send reminder'}
</Button>
</div>
{selectedPayment.status !== 'on_hold' && (
<div className="pt-2 border-t">
<Button variant="outline" onClick={() => handleSendReminder(selectedPayment)} isLoading={sendingReminder} className="w-full min-h-[44px]">
<EnvelopeIcon className="w-5 h-5 mr-2" />
{locale === 'es' ? 'Enviar recordatorio' : 'Send reminder'}
</Button>
</div>
)}
</div>
</Card>
</div>
@@ -627,7 +660,7 @@ export default function AdminPaymentsPage() {
)}
{/* Summary Cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4 mb-6">
<Card className="p-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-yellow-100 rounded-full flex items-center justify-center">
@@ -642,6 +675,20 @@ export default function AdminPaymentsPage() {
</div>
</div>
</Card>
<Card className="p-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-slate-100 rounded-full flex items-center justify-center">
<ClockIcon className="w-5 h-5 text-slate-600" />
</div>
<div>
<p className="text-sm text-gray-500">{locale === 'es' ? 'En Espera' : 'On Hold'}</p>
<p className="text-xl font-bold text-slate-600">{onHoldBookingsCount}</p>
{onHoldPayments.length !== onHoldBookingsCount && (
<p className="text-xs text-gray-400">({onHoldPayments.length} tickets)</p>
)}
</div>
</div>
</Card>
<Card className="p-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-gray-100 rounded-full flex items-center justify-center">
@@ -816,6 +863,7 @@ export default function AdminPaymentsPage() {
<option value="paid">{locale === 'es' ? 'Pagado' : 'Paid'}</option>
<option value="refunded">{locale === 'es' ? 'Reembolsado' : 'Refunded'}</option>
<option value="failed">{locale === 'es' ? 'Fallido' : 'Failed'}</option>
<option value="on_hold">{locale === 'es' ? 'En Espera' : 'On Hold'}</option>
</select>
</div>
<div>
@@ -897,6 +945,7 @@ export default function AdminPaymentsPage() {
<thead className="bg-secondary-gray">
<tr>
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">{locale === 'es' ? 'Asistente' : 'Attendee'}</th>
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">RUC</th>
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">{locale === 'es' ? 'Evento' : 'Event'}</th>
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">{locale === 'es' ? 'Monto' : 'Amount'}</th>
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">{locale === 'es' ? 'Método' : 'Method'}</th>
@@ -906,7 +955,7 @@ export default function AdminPaymentsPage() {
</thead>
<tbody className="divide-y divide-secondary-light-gray">
{filteredPayments.length === 0 ? (
<tr><td colSpan={6} className="px-4 py-12 text-center text-gray-500 text-sm">{locale === 'es' ? 'No se encontraron pagos' : 'No payments found'}</td></tr>
<tr><td colSpan={7} className="px-4 py-12 text-center text-gray-500 text-sm">{locale === 'es' ? 'No se encontraron pagos' : 'No payments found'}</td></tr>
) : (
filteredPayments.map((payment) => {
const bookingInfo = getBookingInfo(payment);
@@ -920,6 +969,7 @@ export default function AdminPaymentsPage() {
</div>
) : <span className="text-gray-400 text-sm">-</span>}
</td>
<td className="px-4 py-3 text-sm text-gray-600">{payment.ticket?.attendeeRuc || '-'}</td>
<td className="px-4 py-3 text-sm truncate max-w-[150px]">{payment.event?.title || '-'}</td>
<td className="px-4 py-3">
<p className="font-medium text-sm">{formatCurrency(bookingInfo.bookingTotal, payment.currency)}</p>
@@ -933,11 +983,16 @@ export default function AdminPaymentsPage() {
<td className="px-4 py-3">{getStatusBadge(payment.status)}</td>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-1">
{(payment.status === 'pending' || payment.status === 'pending_approval') && (
{(payment.status === 'pending' || payment.status === 'pending_approval' || payment.status === 'on_hold') && (
<Button size="sm" onClick={() => setSelectedPayment(payment)} className="text-xs px-2 py-1">
{locale === 'es' ? 'Revisar' : 'Review'}
</Button>
)}
{payment.status === 'on_hold' && (
<Button size="sm" variant="outline" onClick={() => handleReactivate(payment)} className="text-xs px-2 py-1">
{locale === 'es' ? 'Reactivar' : 'Reactivate'}
</Button>
)}
{payment.status === 'paid' && (
<Button size="sm" variant="outline" onClick={() => handleRefund(payment.id)} className="text-xs px-2 py-1">
{t('admin.payments.refund')}
@@ -990,11 +1045,16 @@ export default function AdminPaymentsPage() {
<div className="flex items-center justify-between mt-2 pt-2 border-t border-gray-100">
<p className="text-[10px] text-gray-400">{formatDate(payment.createdAt)}</p>
<div className="flex items-center gap-1">
{(payment.status === 'pending' || payment.status === 'pending_approval') && (
{(payment.status === 'pending' || payment.status === 'pending_approval' || payment.status === 'on_hold') && (
<Button size="sm" onClick={() => setSelectedPayment(payment)} className="text-xs px-2.5 py-1.5 min-h-[36px]">
{locale === 'es' ? 'Revisar' : 'Review'}
</Button>
)}
{payment.status === 'on_hold' && (
<Button size="sm" variant="outline" onClick={() => handleReactivate(payment)} className="text-xs px-2.5 py-1.5 min-h-[36px]">
{locale === 'es' ? 'Reactivar' : 'Reactivate'}
</Button>
)}
{payment.status === 'paid' && (
<Button size="sm" variant="outline" onClick={() => handleRefund(payment.id)} className="text-xs px-2.5 py-1.5 min-h-[36px]">
{t('admin.payments.refund')}
@@ -1040,6 +1100,7 @@ export default function AdminPaymentsPage() {
<option value="paid">{locale === 'es' ? 'Pagado' : 'Paid'}</option>
<option value="refunded">{locale === 'es' ? 'Reembolsado' : 'Refunded'}</option>
<option value="failed">{locale === 'es' ? 'Fallido' : 'Failed'}</option>
<option value="on_hold">{locale === 'es' ? 'En Espera' : 'On Hold'}</option>
</select>
</div>
<div>
+204 -43
View File
@@ -2,22 +2,38 @@
import { useState, useEffect } from 'react';
import { useLanguage } from '@/context/LanguageContext';
import { usersApi, User } from '@/lib/api';
import { usersApi, eventsApi, User, Event } from '@/lib/api';
import { parseDate } from '@/lib/utils';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input';
import { MoreMenu, DropdownItem, BottomSheet, AdminMobileStyles } from '@/components/admin/MobileComponents';
import { TrashIcon, PencilSquareIcon, FunnelIcon, XMarkIcon } from '@heroicons/react/24/outline';
import { CheckCircleIcon } from '@heroicons/react/24/outline';
import { TrashIcon, PencilSquareIcon, FunnelIcon, XMarkIcon, MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import toast from 'react-hot-toast';
import clsx from 'clsx';
type RegisteredRange = '' | '7d' | '30d' | '90d';
function registeredAfterFromRange(range: RegisteredRange): string | undefined {
if (!range) return undefined;
const days = range === '7d' ? 7 : range === '30d' ? 30 : 90;
const date = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
return date.toISOString();
}
export default function AdminUsersPage() {
const { t, locale } = useLanguage();
const [users, setUsers] = useState<User[]>([]);
const [total, setTotal] = useState(0);
const [events, setEvents] = useState<Event[]>([]);
const [loading, setLoading] = useState(true);
const [roleFilter, setRoleFilter] = useState<string>('');
const [statusFilter, setStatusFilter] = useState<string>('');
const [hasBookingsFilter, setHasBookingsFilter] = useState<'' | 'yes' | 'no'>('');
const [registeredRange, setRegisteredRange] = useState<RegisteredRange>('');
const [eventFilter, setEventFilter] = useState<string>('');
const [searchQuery, setSearchQuery] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const [editingUser, setEditingUser] = useState<User | null>(null);
const [editForm, setEditForm] = useState({
name: '',
@@ -30,14 +46,45 @@ export default function AdminUsersPage() {
const [saving, setSaving] = useState(false);
const [mobileFilterOpen, setMobileFilterOpen] = useState(false);
// Debounce the search box like the Emails page does (300ms).
useEffect(() => {
const handle = setTimeout(() => setDebouncedSearch(searchQuery), 300);
return () => clearTimeout(handle);
}, [searchQuery]);
useEffect(() => {
eventsApi.getAll().then((res) => setEvents(res.events)).catch(() => {});
}, []);
useEffect(() => {
loadUsers();
}, [roleFilter]);
}, [roleFilter, statusFilter, hasBookingsFilter, registeredRange, eventFilter, debouncedSearch]);
const hasActiveFilters =
roleFilter || statusFilter || hasBookingsFilter || registeredRange || eventFilter || searchQuery;
const clearFilters = () => {
setRoleFilter('');
setStatusFilter('');
setHasBookingsFilter('');
setRegisteredRange('');
setEventFilter('');
setSearchQuery('');
};
const loadUsers = async () => {
try {
const { users } = await usersApi.getAll(roleFilter || undefined);
const { users, total } = await usersApi.getAll({
role: roleFilter || undefined,
accountStatus: statusFilter || undefined,
hasBookings: hasBookingsFilter || undefined,
registeredAfter: registeredAfterFromRange(registeredRange),
eventId: eventFilter || undefined,
search: debouncedSearch.trim() || undefined,
pageSize: 200,
});
setUsers(users);
setTotal(total);
} catch (error) {
toast.error('Failed to load users');
} finally {
@@ -129,16 +176,29 @@ export default function AdminUsersPage() {
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.users.title')}</h1>
<h1 className="text-xl md:text-2xl font-bold text-primary-dark">{t('admin.users.title')} ({total})</h1>
</div>
{/* Desktop Filters */}
<Card className="p-4 mb-6 hidden md:block">
<div className="flex flex-wrap gap-4">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div>
<label className="block text-sm font-medium mb-1">Search</label>
<div className="relative">
<MagnifyingGlassIcon className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
<input
type="text"
placeholder="Name, email, phone..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-9 pr-3 py-2 rounded-btn border border-secondary-light-gray text-sm focus:outline-none focus:ring-2 focus:ring-primary-yellow"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium mb-1">{t('admin.users.role')}</label>
<select value={roleFilter} onChange={(e) => setRoleFilter(e.target.value)}
className="px-4 py-2 rounded-btn border border-secondary-light-gray min-w-[150px] text-sm">
className="w-full px-4 py-2 rounded-btn border border-secondary-light-gray text-sm">
<option value="">All Roles</option>
<option value="admin">{t('admin.users.roles.admin')}</option>
<option value="organizer">{t('admin.users.roles.organizer')}</option>
@@ -147,25 +207,82 @@ export default function AdminUsersPage() {
<option value="user">{t('admin.users.roles.user')}</option>
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1">Status</label>
<select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}
className="w-full px-4 py-2 rounded-btn border border-secondary-light-gray text-sm">
<option value="">All Statuses</option>
<option value="active">Active</option>
<option value="unclaimed">Unclaimed</option>
<option value="suspended">Suspended</option>
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1">Has Bookings</label>
<select value={hasBookingsFilter} onChange={(e) => setHasBookingsFilter(e.target.value as '' | 'yes' | 'no')}
className="w-full px-4 py-2 rounded-btn border border-secondary-light-gray text-sm">
<option value="">All</option>
<option value="yes">Yes</option>
<option value="no">No</option>
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1">Registered</label>
<select value={registeredRange} onChange={(e) => setRegisteredRange(e.target.value as RegisteredRange)}
className="w-full px-4 py-2 rounded-btn border border-secondary-light-gray text-sm">
<option value="">All Time</option>
<option value="7d">Last 7 days</option>
<option value="30d">Last 30 days</option>
<option value="90d">Last 90 days</option>
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1">Event</label>
<select value={eventFilter} onChange={(e) => setEventFilter(e.target.value)}
className="w-full px-4 py-2 rounded-btn border border-secondary-light-gray text-sm">
<option value="">All Events</option>
{events.map((event) => (
<option key={event.id} value={event.id}>{event.title}</option>
))}
</select>
</div>
</div>
{hasActiveFilters && (
<div className="mt-3 text-xs text-gray-500 flex items-center gap-2">
<span>Showing {users.length} of {total}</span>
<button onClick={clearFilters} className="text-primary-yellow hover:underline">Clear</button>
</div>
)}
</Card>
{/* Mobile Toolbar */}
<div className="md:hidden mb-4 flex items-center gap-2">
<button onClick={() => setMobileFilterOpen(true)}
className={clsx(
'flex items-center gap-1.5 px-3 py-2 rounded-btn border text-sm min-h-[44px]',
roleFilter ? 'border-primary-yellow bg-yellow-50 text-primary-dark' : 'border-secondary-light-gray text-gray-600'
)}>
<FunnelIcon className="w-4 h-4" />
{roleFilter ? t(`admin.users.roles.${roleFilter}`) : 'Filter by Role'}
</button>
{roleFilter && (
<button onClick={() => setRoleFilter('')} className="text-xs text-primary-yellow min-h-[44px] flex items-center">
Clear
<div className="md:hidden mb-4 space-y-2">
<div className="relative">
<MagnifyingGlassIcon className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
<input
type="text"
placeholder="Name, email, phone..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-9 pr-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
/>
</div>
<div className="flex items-center gap-2">
<button onClick={() => setMobileFilterOpen(true)}
className={clsx(
'flex items-center gap-1.5 px-3 py-2 rounded-btn border text-sm min-h-[44px]',
hasActiveFilters ? 'border-primary-yellow bg-yellow-50 text-primary-dark' : 'border-secondary-light-gray text-gray-600'
)}>
<FunnelIcon className="w-4 h-4" />
Filters
</button>
)}
<span className="text-xs text-gray-500 ml-auto">{users.length} users</span>
{hasActiveFilters && (
<button onClick={clearFilters} className="text-xs text-primary-yellow min-h-[44px] flex items-center">
Clear
</button>
)}
<span className="text-xs text-gray-500 ml-auto">{total} users</span>
</div>
</div>
{/* Desktop: Table */}
@@ -176,6 +293,7 @@ export default function AdminUsersPage() {
<tr>
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">User</th>
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Contact</th>
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">RUC</th>
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Role</th>
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Joined</th>
<th className="text-right px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
@@ -183,7 +301,7 @@ export default function AdminUsersPage() {
</thead>
<tbody className="divide-y divide-secondary-light-gray">
{users.length === 0 ? (
<tr><td colSpan={5} className="px-4 py-12 text-center text-gray-500 text-sm">No users found</td></tr>
<tr><td colSpan={6} className="px-4 py-12 text-center text-gray-500 text-sm">No users found</td></tr>
) : (
users.map((user) => (
<tr key={user.id} className="hover:bg-gray-50">
@@ -199,6 +317,7 @@ export default function AdminUsersPage() {
</div>
</td>
<td className="px-4 py-3 text-sm text-gray-600">{user.phone || '-'}</td>
<td className="px-4 py-3 text-sm text-gray-600">{user.rucNumber || '-'}</td>
<td className="px-4 py-3">
<select value={user.role} onChange={(e) => handleRoleChange(user.id, e.target.value)}
className="px-2 py-1 rounded border border-secondary-light-gray text-sm">
@@ -247,6 +366,7 @@ export default function AdminUsersPage() {
<p className="font-medium text-sm truncate">{user.name}</p>
<p className="text-xs text-gray-500 truncate">{user.email}</p>
{user.phone && <p className="text-[10px] text-gray-400">{user.phone}</p>}
{user.rucNumber && <p className="text-[10px] text-gray-400">RUC: {user.rucNumber}</p>}
</div>
{getRoleBadge(user.role)}
</div>
@@ -269,26 +389,67 @@ export default function AdminUsersPage() {
</div>
{/* Mobile Filter BottomSheet */}
<BottomSheet open={mobileFilterOpen} onClose={() => setMobileFilterOpen(false)} title="Filter by Role">
<div className="space-y-1">
{[
{ value: '', label: 'All Roles' },
{ value: 'admin', label: t('admin.users.roles.admin') },
{ value: 'organizer', label: t('admin.users.roles.organizer') },
{ value: 'staff', label: t('admin.users.roles.staff') },
{ value: 'marketing', label: t('admin.users.roles.marketing') },
{ value: 'user', label: t('admin.users.roles.user') },
].map((opt) => (
<button key={opt.value}
onClick={() => { setRoleFilter(opt.value); setMobileFilterOpen(false); }}
className={clsx(
'w-full text-left px-4 py-3 rounded-btn text-sm min-h-[44px] flex items-center justify-between',
roleFilter === opt.value ? 'bg-yellow-50 text-primary-dark font-medium' : 'hover:bg-gray-50'
)}>
{opt.label}
{roleFilter === opt.value && <CheckCircleIcon className="w-4 h-4 text-primary-yellow" />}
</button>
))}
<BottomSheet open={mobileFilterOpen} onClose={() => setMobileFilterOpen(false)} title="Filters">
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">{t('admin.users.role')}</label>
<select value={roleFilter} onChange={(e) => setRoleFilter(e.target.value)}
className="w-full px-3 py-2.5 rounded-btn border border-secondary-light-gray text-sm min-h-[44px]">
<option value="">All Roles</option>
<option value="admin">{t('admin.users.roles.admin')}</option>
<option value="organizer">{t('admin.users.roles.organizer')}</option>
<option value="staff">{t('admin.users.roles.staff')}</option>
<option value="marketing">{t('admin.users.roles.marketing')}</option>
<option value="user">{t('admin.users.roles.user')}</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Status</label>
<select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}
className="w-full px-3 py-2.5 rounded-btn border border-secondary-light-gray text-sm min-h-[44px]">
<option value="">All Statuses</option>
<option value="active">Active</option>
<option value="unclaimed">Unclaimed</option>
<option value="suspended">Suspended</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Has Bookings</label>
<select value={hasBookingsFilter} onChange={(e) => setHasBookingsFilter(e.target.value as '' | 'yes' | 'no')}
className="w-full px-3 py-2.5 rounded-btn border border-secondary-light-gray text-sm min-h-[44px]">
<option value="">All</option>
<option value="yes">Yes</option>
<option value="no">No</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Registered</label>
<select value={registeredRange} onChange={(e) => setRegisteredRange(e.target.value as RegisteredRange)}
className="w-full px-3 py-2.5 rounded-btn border border-secondary-light-gray text-sm min-h-[44px]">
<option value="">All Time</option>
<option value="7d">Last 7 days</option>
<option value="30d">Last 30 days</option>
<option value="90d">Last 90 days</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Event</label>
<select value={eventFilter} onChange={(e) => setEventFilter(e.target.value)}
className="w-full px-3 py-2.5 rounded-btn border border-secondary-light-gray text-sm min-h-[44px]">
<option value="">All Events</option>
{events.map((event) => (
<option key={event.id} value={event.id}>{event.title}</option>
))}
</select>
</div>
<div className="flex gap-3 pt-2">
<Button variant="outline" onClick={() => { clearFilters(); setMobileFilterOpen(false); }} className="flex-1 min-h-[44px]">
Clear All
</Button>
<Button onClick={() => setMobileFilterOpen(false)} className="flex-1 min-h-[44px]">
Apply
</Button>
</div>
</div>
</BottomSheet>