Files
Spanglish/frontend/src/app/admin/events/[id]/_modals/EventModals.tsx
T
MichilisandClaude Opus 5 a0161a67d2 Add "paid at the door" ticket type to the Add Ticket modal.
Door walk-ins were only expressible as an unpaid ticket, which left the
cash out of revenue. The new type records the cash payment as paid and
makes every field optional, since a walk-in often gives no details:
a blank name is logged as "Walk-in", and a confirmation email only goes
out when an email is entered.

Door tickets reuse paymentStatus 'paid' (the column enum is capped at
paid/unpaid/comp) so badges and revenue totals pick them up with no
migration; the cash payment row is referenced "Paid at door" to keep
them distinguishable from emailed manual tickets.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 04:39:54 +00:00

266 lines
10 KiB
TypeScript

import { Ticket } from '@/lib/api';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import { BottomSheet } from '@/components/admin/MobileComponents';
import clsx from 'clsx';
import {
BanknotesIcon,
CheckCircleIcon,
EnvelopeIcon,
StarIcon,
XMarkIcon,
} from '@heroicons/react/24/outline';
import type { AttendeeStatusFilter, AddTicketType } from '../_types';
interface EventModalsProps {
// counts + filter
ticketsCount: number;
pendingCount: number;
confirmedCount: number;
checkedInCount: number;
cancelledCount: number;
onHoldCount: number;
statusFilter: AttendeeStatusFilter;
setStatusFilter: (value: AttendeeStatusFilter) => void;
// mobile filter sheet
mobileFilterOpen: boolean;
setMobileFilterOpen: (value: boolean) => void;
// add ticket sheet
showAddTicketSheet: boolean;
setShowAddTicketSheet: (value: boolean) => void;
openAddTicket: (type: AddTicketType) => void;
// export sheets
showExportSheet: boolean;
setShowExportSheet: (value: boolean) => void;
handleExportAttendees: (status: 'confirmed' | 'checked_in' | 'confirmed_pending' | 'all') => void;
showTicketExportSheet: boolean;
setShowTicketExportSheet: (value: boolean) => void;
handleExportTickets: (status: 'confirmed' | 'checked_in' | 'all') => void;
// shared submit flag
submitting: boolean;
// note modal
showNoteModal: boolean;
setShowNoteModal: (value: boolean) => void;
selectedTicket: Ticket | null;
setSelectedTicket: (value: Ticket | null) => void;
noteText: string;
setNoteText: (value: string) => void;
handleSaveNote: () => void;
// preview modal
previewHtml: string | null;
setPreviewHtml: (value: string | null) => void;
}
export function EventModals(props: EventModalsProps) {
const {
ticketsCount,
pendingCount,
confirmedCount,
checkedInCount,
cancelledCount,
onHoldCount,
statusFilter,
setStatusFilter,
mobileFilterOpen,
setMobileFilterOpen,
showAddTicketSheet,
setShowAddTicketSheet,
openAddTicket,
showExportSheet,
setShowExportSheet,
handleExportAttendees,
showTicketExportSheet,
setShowTicketExportSheet,
handleExportTickets,
submitting,
showNoteModal,
setShowNoteModal,
selectedTicket,
setSelectedTicket,
noteText,
setNoteText,
handleSaveNote,
previewHtml,
setPreviewHtml,
} = props;
return (
<>
{/* Mobile filter bottom sheet */}
<BottomSheet
open={mobileFilterOpen}
onClose={() => setMobileFilterOpen(false)}
title="Filter by Status"
>
<div className="space-y-1">
{[
{ value: 'all', label: `All (${ticketsCount})` },
{ value: 'pending', label: `Pending (${pendingCount})` },
{ 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}
onClick={() => { setStatusFilter(option.value as AttendeeStatusFilter); setMobileFilterOpen(false); }}
className={clsx(
'w-full text-left px-4 py-3 rounded-btn text-sm min-h-[44px] flex items-center justify-between',
statusFilter === option.value ? 'bg-yellow-50 text-primary-dark font-medium' : 'hover:bg-gray-50'
)}
>
{option.label}
{statusFilter === option.value && <CheckCircleIcon className="w-4 h-4 text-primary-yellow" />}
</button>
))}
</div>
</BottomSheet>
{/* Mobile FAB bottom sheet */}
<BottomSheet
open={showAddTicketSheet}
onClose={() => setShowAddTicketSheet(false)}
title="Add Ticket"
>
<div className="space-y-1">
<button
onClick={() => { openAddTicket('paid'); setShowAddTicketSheet(false); }}
className="w-full text-left px-4 py-3 rounded-btn text-sm hover:bg-gray-50 min-h-[44px] flex items-center gap-3"
>
<EnvelopeIcon className="w-5 h-5 text-gray-500" />
<div>
<p className="font-medium">Paid Ticket</p>
<p className="text-xs text-gray-500">Send confirmation email with QR ticket</p>
</div>
</button>
<button
onClick={() => { openAddTicket('door'); setShowAddTicketSheet(false); }}
className="w-full text-left px-4 py-3 rounded-btn text-sm hover:bg-gray-50 min-h-[44px] flex items-center gap-3"
>
<BanknotesIcon className="w-5 h-5 text-gray-500" />
<div>
<p className="font-medium">Paid at Door</p>
<p className="text-xs text-gray-500">Cash taken at the door, all fields optional</p>
</div>
</button>
<button
onClick={() => { openAddTicket('unpaid'); setShowAddTicketSheet(false); }}
className="w-full text-left px-4 py-3 rounded-btn text-sm hover:bg-gray-50 min-h-[44px] flex items-center gap-3"
>
<BanknotesIcon className="w-5 h-5 text-gray-500" />
<div>
<p className="font-medium">Unpaid Ticket</p>
<p className="text-xs text-gray-500">Pay link now or collect at the door</p>
</div>
</button>
<button
onClick={() => { openAddTicket('guest'); setShowAddTicketSheet(false); }}
className="w-full text-left px-4 py-3 rounded-btn text-sm hover:bg-gray-50 min-h-[44px] flex items-center gap-3"
>
<StarIcon className="w-5 h-5 text-gray-500" />
<div>
<p className="font-medium">Invite Guest</p>
<p className="text-xs text-gray-500">Free ticket, not counted in revenue</p>
</div>
</button>
</div>
</BottomSheet>
{/* Mobile export bottom sheet (attendees) */}
<BottomSheet
open={showExportSheet}
onClose={() => setShowExportSheet(false)}
title="Export Attendees"
>
<div className="space-y-1">
{[
{ status: 'all' as const, label: 'Export All' },
{ status: 'confirmed' as const, label: 'Export Confirmed' },
{ status: 'checked_in' as const, label: 'Export Checked-in' },
{ status: 'confirmed_pending' as const, label: 'Confirmed & Pending' },
].map((opt) => (
<button
key={opt.status}
onClick={() => { handleExportAttendees(opt.status); setShowExportSheet(false); }}
className="w-full text-left px-4 py-3 rounded-btn text-sm hover:bg-gray-50 min-h-[44px]"
>
{opt.label}
</button>
))}
<p className="text-[10px] text-gray-400 px-4 pt-2">Format: CSV</p>
</div>
</BottomSheet>
{/* Mobile export bottom sheet (tickets) */}
<BottomSheet
open={showTicketExportSheet}
onClose={() => setShowTicketExportSheet(false)}
title="Export Tickets"
>
<div className="space-y-1">
{[
{ status: 'all' as const, label: 'Export All' },
{ status: 'confirmed' as const, label: 'Export Valid' },
{ status: 'checked_in' as const, label: 'Export Checked-in' },
].map((opt) => (
<button
key={opt.status}
onClick={() => { handleExportTickets(opt.status); setShowTicketExportSheet(false); }}
className="w-full text-left px-4 py-3 rounded-btn text-sm hover:bg-gray-50 min-h-[44px]"
>
{opt.label}
</button>
))}
<p className="text-[10px] text-gray-400 px-4 pt-2">Format: CSV</p>
</div>
</BottomSheet>
{/* Note Modal */}
{showNoteModal && selectedTicket && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-end md:items-center justify-center p-0 md:p-4">
<Card className="w-full md:max-w-md rounded-t-2xl md:rounded-card">
<div className="flex items-center justify-between p-4 border-b border-secondary-light-gray">
<div>
<h2 className="text-base font-bold">Admin Note</h2>
<p className="text-xs text-gray-500">{selectedTicket.attendeeFirstName} {selectedTicket.attendeeLastName || ''}</p>
</div>
<button onClick={() => { setShowNoteModal(false); setSelectedTicket(null); }}
className="p-2 hover:bg-gray-100 rounded-btn min-h-[44px] min-w-[44px] flex items-center justify-center">
<XMarkIcon className="w-5 h-5" />
</button>
</div>
<div className="p-4 space-y-3">
<div>
<label className="block text-xs font-medium mb-1">Note</label>
<textarea value={noteText} onChange={(e) => setNoteText(e.target.value)}
className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
rows={4} placeholder="Add a private note..." maxLength={1000} />
<p className="text-[10px] text-gray-400 mt-1 text-right">{noteText.length}/1000</p>
</div>
<div className="flex gap-3">
<Button variant="outline" onClick={() => { setShowNoteModal(false); setSelectedTicket(null); }} className="flex-1 min-h-[44px]">Cancel</Button>
<Button onClick={handleSaveNote} isLoading={submitting} className="flex-1 min-h-[44px]">Save Note</Button>
</div>
</div>
</Card>
</div>
)}
{/* Preview Modal */}
{previewHtml && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<Card className="w-full max-w-3xl max-h-[90vh] overflow-hidden flex flex-col">
<div className="flex items-center justify-between p-4 border-b border-secondary-light-gray">
<h2 className="text-base font-bold">Email Preview</h2>
<Button variant="outline" size="sm" onClick={() => setPreviewHtml(null)} className="min-h-[44px] md:min-h-0">Close</Button>
</div>
<div className="flex-1 overflow-auto">
<iframe srcDoc={previewHtml} sandbox="" className="w-full h-full min-h-[500px]" title="Email Preview" />
</div>
</Card>
</div>
)}
</>
);
}