Compare commits
2 Commits
backup6
...
a5d97d65e1
| Author | SHA1 | Date | |
|---|---|---|---|
| a5d97d65e1 | |||
|
|
e09ff4ed60 |
@@ -2,7 +2,7 @@ import { Hono } from 'hono';
|
|||||||
import { zValidator } from '@hono/zod-validator';
|
import { zValidator } from '@hono/zod-validator';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { db, dbGet, dbAll, tickets, events, users, payments, paymentOptions, siteSettings } from '../db/index.js';
|
import { db, dbGet, dbAll, tickets, events, users, payments, paymentOptions, siteSettings } from '../db/index.js';
|
||||||
import { eq, and, or, sql } from 'drizzle-orm';
|
import { eq, and, or, sql, inArray } from 'drizzle-orm';
|
||||||
import { requireAuth, getAuthUser } from '../lib/auth.js';
|
import { requireAuth, getAuthUser } from '../lib/auth.js';
|
||||||
import { generateId, generateTicketCode, getNow, calculateAvailableSeats, isEventSoldOut } from '../lib/utils.js';
|
import { generateId, generateTicketCode, getNow, calculateAvailableSeats, isEventSoldOut } from '../lib/utils.js';
|
||||||
import { createInvoice, isLNbitsConfigured } from '../lib/lnbits.js';
|
import { createInvoice, isLNbitsConfigured } from '../lib/lnbits.js';
|
||||||
@@ -1394,7 +1394,7 @@ ticketsRouter.post('/admin/manual', requireAuth(['admin', 'organizer', 'staff'])
|
|||||||
}, 201);
|
}, 201);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get all tickets (admin)
|
// Get all tickets (admin) - includes payment for each ticket
|
||||||
ticketsRouter.get('/', requireAuth(['admin', 'organizer']), async (c) => {
|
ticketsRouter.get('/', requireAuth(['admin', 'organizer']), async (c) => {
|
||||||
const eventId = c.req.query('eventId');
|
const eventId = c.req.query('eventId');
|
||||||
const status = c.req.query('status');
|
const status = c.req.query('status');
|
||||||
@@ -1413,9 +1413,25 @@ ticketsRouter.get('/', requireAuth(['admin', 'organizer']), async (c) => {
|
|||||||
query = query.where(and(...conditions));
|
query = query.where(and(...conditions));
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await dbAll(query);
|
const ticketsList = await dbAll(query);
|
||||||
|
const ticketIds = ticketsList.map((t: any) => t.id);
|
||||||
|
|
||||||
return c.json({ tickets: result });
|
let paymentByTicketId: Record<string, any> = {};
|
||||||
|
if (ticketIds.length > 0) {
|
||||||
|
const paymentsList = await dbAll(
|
||||||
|
(db as any).select().from(payments).where(inArray((payments as any).ticketId, ticketIds))
|
||||||
|
);
|
||||||
|
for (const p of paymentsList as any[]) {
|
||||||
|
paymentByTicketId[p.ticketId] = p;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ticketsWithPayment = ticketsList.map((t: any) => ({
|
||||||
|
...t,
|
||||||
|
payment: paymentByTicketId[t.id] || null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return c.json({ tickets: ticketsWithPayment });
|
||||||
});
|
});
|
||||||
|
|
||||||
export default ticketsRouter;
|
export default ticketsRouter;
|
||||||
|
|||||||
@@ -60,18 +60,12 @@ export default function AdminBookingsPage() {
|
|||||||
eventsApi.getAll(),
|
eventsApi.getAll(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const ticketsWithDetails = await Promise.all(
|
const ticketsWithEvent = ticketsRes.tickets.map((ticket) => ({
|
||||||
ticketsRes.tickets.map(async (ticket) => {
|
...ticket,
|
||||||
try {
|
event: eventsRes.events.find((e) => e.id === ticket.eventId),
|
||||||
const { ticket: fullTicket } = await ticketsApi.getById(ticket.id);
|
}));
|
||||||
return fullTicket;
|
|
||||||
} catch {
|
|
||||||
return ticket;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
setTickets(ticketsWithDetails);
|
setTickets(ticketsWithEvent);
|
||||||
setEvents(eventsRes.events);
|
setEvents(eventsRes.events);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error('Failed to load bookings');
|
toast.error('Failed to load bookings');
|
||||||
@@ -153,7 +147,8 @@ export default function AdminBookingsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getPaymentMethodLabel = (provider: string) => {
|
const getPaymentMethodLabel = (provider: string | null) => {
|
||||||
|
if (provider == null) return '—';
|
||||||
const labels: Record<string, string> = {
|
const labels: Record<string, string> = {
|
||||||
cash: locale === 'es' ? 'Efectivo en el Evento' : 'Cash at Event',
|
cash: locale === 'es' ? 'Efectivo en el Evento' : 'Cash at Event',
|
||||||
bank_transfer: locale === 'es' ? 'Transferencia Bancaria' : 'Bank Transfer',
|
bank_transfer: locale === 'es' ? 'Transferencia Bancaria' : 'Bank Transfer',
|
||||||
@@ -164,13 +159,13 @@ export default function AdminBookingsPage() {
|
|||||||
return labels[provider] || provider;
|
return labels[provider] || provider;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getDisplayProvider = (ticket: TicketWithDetails) => {
|
const getDisplayProvider = (ticket: TicketWithDetails): string | null => {
|
||||||
if (ticket.payment?.provider) return ticket.payment.provider;
|
if (ticket.payment?.provider) return ticket.payment.provider;
|
||||||
if (ticket.bookingId) {
|
if (ticket.bookingId) {
|
||||||
const sibling = tickets.find(t => t.bookingId === ticket.bookingId && t.payment?.provider);
|
const sibling = tickets.find((t) => t.bookingId === ticket.bookingId && t.payment?.provider);
|
||||||
return sibling?.payment?.provider ?? 'cash';
|
return sibling?.payment?.provider ?? null;
|
||||||
}
|
}
|
||||||
return 'cash';
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const filteredTickets = tickets.filter((ticket) => {
|
const filteredTickets = tickets.filter((ticket) => {
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import {
|
|||||||
} from '@heroicons/react/24/outline';
|
} from '@heroicons/react/24/outline';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
|
import { useStatsPrivacy } from '@/hooks/useStatsPrivacy';
|
||||||
|
|
||||||
type TabType = 'overview' | 'attendees' | 'tickets' | 'email' | 'payments';
|
type TabType = 'overview' | 'attendees' | 'tickets' | 'email' | 'payments';
|
||||||
|
|
||||||
@@ -68,7 +69,7 @@ export default function AdminEventDetailPage() {
|
|||||||
const [statusFilter, setStatusFilter] = useState<'all' | 'pending' | 'confirmed' | 'checked_in' | 'cancelled'>('all');
|
const [statusFilter, setStatusFilter] = useState<'all' | 'pending' | 'confirmed' | 'checked_in' | 'cancelled'>('all');
|
||||||
const [showAddAtDoorModal, setShowAddAtDoorModal] = useState(false);
|
const [showAddAtDoorModal, setShowAddAtDoorModal] = useState(false);
|
||||||
const [showManualTicketModal, setShowManualTicketModal] = useState(false);
|
const [showManualTicketModal, setShowManualTicketModal] = useState(false);
|
||||||
const [showStats, setShowStats] = useState(true);
|
const [showStats, setShowStats, toggleStats] = useStatsPrivacy();
|
||||||
const [showNoteModal, setShowNoteModal] = useState(false);
|
const [showNoteModal, setShowNoteModal] = useState(false);
|
||||||
const [selectedTicket, setSelectedTicket] = useState<Ticket | null>(null);
|
const [selectedTicket, setSelectedTicket] = useState<Ticket | null>(null);
|
||||||
const [noteText, setNoteText] = useState('');
|
const [noteText, setNoteText] = useState('');
|
||||||
@@ -576,6 +577,10 @@ export default function AdminEventDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
{/* Desktop header actions */}
|
{/* Desktop header actions */}
|
||||||
<div className="hidden md:flex items-center gap-2 flex-shrink-0">
|
<div className="hidden md:flex items-center gap-2 flex-shrink-0">
|
||||||
|
<Button variant="outline" size="sm" onClick={toggleStats} title={showStats ? 'Hide stats' : 'Show stats'}>
|
||||||
|
{showStats ? <EyeSlashIcon className="w-4 h-4 mr-1.5" /> : <EyeIcon className="w-4 h-4 mr-1.5" />}
|
||||||
|
{showStats ? 'Hide Stats' : 'Show Stats'}
|
||||||
|
</Button>
|
||||||
<Link href={`/events/${event.id}`} target="_blank">
|
<Link href={`/events/${event.id}`} target="_blank">
|
||||||
<Button variant="outline" size="sm">
|
<Button variant="outline" size="sm">
|
||||||
<EyeIcon className="w-4 h-4 mr-1.5" />
|
<EyeIcon className="w-4 h-4 mr-1.5" />
|
||||||
@@ -606,7 +611,7 @@ export default function AdminEventDetailPage() {
|
|||||||
<DropdownItem onClick={() => { router.push(`/admin/events?edit=${event.id}`); setMobileHeaderMenuOpen(false); }}>
|
<DropdownItem onClick={() => { router.push(`/admin/events?edit=${event.id}`); setMobileHeaderMenuOpen(false); }}>
|
||||||
<PencilIcon className="w-4 h-4 mr-2" /> Edit Event
|
<PencilIcon className="w-4 h-4 mr-2" /> Edit Event
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
<DropdownItem onClick={() => { setShowStats(v => !v); setMobileHeaderMenuOpen(false); }}>
|
<DropdownItem onClick={() => { toggleStats(); setMobileHeaderMenuOpen(false); }}>
|
||||||
{showStats ? <EyeSlashIcon className="w-4 h-4 mr-2" /> : <EyeIcon className="w-4 h-4 mr-2" />}
|
{showStats ? <EyeSlashIcon className="w-4 h-4 mr-2" /> : <EyeIcon className="w-4 h-4 mr-2" />}
|
||||||
{showStats ? 'Hide Stats' : 'Show Stats'}
|
{showStats ? 'Hide Stats' : 'Show Stats'}
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
@@ -628,10 +633,12 @@ export default function AdminEventDetailPage() {
|
|||||||
<CurrencyDollarIcon className="w-3.5 h-3.5" />
|
<CurrencyDollarIcon className="w-3.5 h-3.5" />
|
||||||
{event.price === 0 ? 'Free' : formatCurrency(event.price, event.currency)}
|
{event.price === 0 ? 'Free' : formatCurrency(event.price, event.currency)}
|
||||||
</span>
|
</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">
|
{showStats && (
|
||||||
<UsersIcon className="w-3.5 h-3.5" />
|
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-gray-100 rounded-full text-xs text-gray-700">
|
||||||
{confirmedCount + checkedInCount}/{event.capacity}
|
<UsersIcon className="w-3.5 h-3.5" />
|
||||||
</span>
|
{confirmedCount + checkedInCount}/{event.capacity}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ============= STATS ROW ============= */}
|
{/* ============= STATS ROW ============= */}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { useLanguage } from '@/context/LanguageContext';
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
import { eventsApi, siteSettingsApi, Event } from '@/lib/api';
|
import { eventsApi, siteSettingsApi, Event } from '@/lib/api';
|
||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
@@ -16,6 +16,7 @@ import toast from 'react-hot-toast';
|
|||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
|
|
||||||
export default function AdminEventsPage() {
|
export default function AdminEventsPage() {
|
||||||
|
const router = useRouter();
|
||||||
const { t, locale } = useLanguage();
|
const { t, locale } = useLanguage();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const [events, setEvents] = useState<Event[]>([]);
|
const [events, setEvents] = useState<Event[]>([]);
|
||||||
@@ -458,7 +459,11 @@ export default function AdminEventsPage() {
|
|||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
events.map((event) => (
|
events.map((event) => (
|
||||||
<tr key={event.id} className={clsx("hover:bg-gray-50", featuredEventId === event.id && "bg-amber-50")}>
|
<tr
|
||||||
|
key={event.id}
|
||||||
|
onClick={() => router.push(`/admin/events/${event.id}`)}
|
||||||
|
className={clsx("hover:bg-gray-50 cursor-pointer", featuredEventId === event.id && "bg-amber-50")}
|
||||||
|
>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{event.bannerUrl ? (
|
{event.bannerUrl ? (
|
||||||
@@ -492,7 +497,7 @@ export default function AdminEventsPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3" onClick={(e) => e.stopPropagation()}>
|
||||||
<div className="flex items-center justify-end gap-1">
|
<div className="flex items-center justify-end gap-1">
|
||||||
{event.status === 'draft' && (
|
{event.status === 'draft' && (
|
||||||
<Button size="sm" variant="ghost" onClick={() => handleStatusChange(event, 'published')}>
|
<Button size="sm" variant="ghost" onClick={() => handleStatusChange(event, 'published')}>
|
||||||
@@ -561,7 +566,11 @@ export default function AdminEventsPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
events.map((event) => (
|
events.map((event) => (
|
||||||
<Card key={event.id} className={clsx("p-3", featuredEventId === event.id && "ring-2 ring-amber-300")}>
|
<Card
|
||||||
|
key={event.id}
|
||||||
|
className={clsx("p-3 cursor-pointer", featuredEventId === event.id && "ring-2 ring-amber-300")}
|
||||||
|
onClick={() => router.push(`/admin/events/${event.id}`)}
|
||||||
|
>
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
{event.bannerUrl ? (
|
{event.bannerUrl ? (
|
||||||
<img src={event.bannerUrl} alt={event.title}
|
<img src={event.bannerUrl} alt={event.title}
|
||||||
@@ -590,7 +599,7 @@ export default function AdminEventsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between mt-2 pt-2 border-t border-gray-100">
|
<div className="flex items-center justify-between mt-2 pt-2 border-t border-gray-100">
|
||||||
<p className="text-xs text-gray-500">{event.bookedCount || 0} / {event.capacity} spots</p>
|
<p className="text-xs text-gray-500">{event.bookedCount || 0} / {event.capacity} spots</p>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
|
||||||
<Link href={`/admin/events/${event.id}`}
|
<Link href={`/admin/events/${event.id}`}
|
||||||
className="p-2 hover:bg-primary-yellow/20 text-primary-dark rounded-btn min-h-[36px] min-w-[36px] flex items-center justify-center">
|
className="p-2 hover:bg-primary-yellow/20 text-primary-dark rounded-btn min-h-[36px] min-w-[36px] flex items-center justify-center">
|
||||||
<EyeIcon className="w-4 h-4" />
|
<EyeIcon className="w-4 h-4" />
|
||||||
|
|||||||
41
frontend/src/hooks/useStatsPrivacy.ts
Normal file
41
frontend/src/hooks/useStatsPrivacy.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'spanglish-admin-stats-hidden';
|
||||||
|
|
||||||
|
export function useStatsPrivacy() {
|
||||||
|
const [showStats, setShowStatsState] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (stored !== null) {
|
||||||
|
setShowStatsState(stored !== 'true');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setShowStats = useCallback((value: boolean | ((prev: boolean) => boolean)) => {
|
||||||
|
setShowStatsState((prev) => {
|
||||||
|
const next = typeof value === 'function' ? value(prev) : value;
|
||||||
|
try {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
localStorage.setItem(STORAGE_KEY, String(!next));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleStats = useCallback(() => {
|
||||||
|
setShowStats((prev) => !prev);
|
||||||
|
}, [setShowStats]);
|
||||||
|
|
||||||
|
return [showStats, setShowStats, toggleStats] as const;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user