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>
This commit is contained in:
co-authored by
Claude Fable 5
parent
e38d14970d
commit
9b2668f498
@@ -1,17 +1,16 @@
|
||||
import type { Dispatch, FormEvent, SetStateAction } from 'react';
|
||||
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,
|
||||
PlusIcon,
|
||||
StarIcon,
|
||||
XMarkIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import type { AttendeeStatusFilter, AttendeeFormState, AddAtDoorFormState } from '../_types';
|
||||
import type { AttendeeStatusFilter, AddTicketType } from '../_types';
|
||||
|
||||
interface EventModalsProps {
|
||||
// counts + filter
|
||||
@@ -29,6 +28,7 @@ interface EventModalsProps {
|
||||
// add ticket sheet
|
||||
showAddTicketSheet: boolean;
|
||||
setShowAddTicketSheet: (value: boolean) => void;
|
||||
openAddTicket: (type: AddTicketType) => void;
|
||||
// export sheets
|
||||
showExportSheet: boolean;
|
||||
setShowExportSheet: (value: boolean) => void;
|
||||
@@ -36,24 +36,6 @@ interface EventModalsProps {
|
||||
showTicketExportSheet: boolean;
|
||||
setShowTicketExportSheet: (value: boolean) => void;
|
||||
handleExportTickets: (status: 'confirmed' | 'checked_in' | 'all') => void;
|
||||
// add at door
|
||||
showAddAtDoorModal: boolean;
|
||||
setShowAddAtDoorModal: (value: boolean) => void;
|
||||
addAtDoorForm: AddAtDoorFormState;
|
||||
setAddAtDoorForm: Dispatch<SetStateAction<AddAtDoorFormState>>;
|
||||
handleAddAtDoor: (e: FormEvent) => void;
|
||||
// manual ticket
|
||||
showManualTicketModal: boolean;
|
||||
setShowManualTicketModal: (value: boolean) => void;
|
||||
manualTicketForm: AttendeeFormState;
|
||||
setManualTicketForm: Dispatch<SetStateAction<AttendeeFormState>>;
|
||||
handleManualTicket: (e: FormEvent) => void;
|
||||
// invite guest
|
||||
showInviteGuestModal: boolean;
|
||||
setShowInviteGuestModal: (value: boolean) => void;
|
||||
inviteGuestForm: AttendeeFormState;
|
||||
setInviteGuestForm: Dispatch<SetStateAction<AttendeeFormState>>;
|
||||
handleInviteGuest: (e: FormEvent) => void;
|
||||
// shared submit flag
|
||||
submitting: boolean;
|
||||
// note modal
|
||||
@@ -83,27 +65,13 @@ export function EventModals(props: EventModalsProps) {
|
||||
setMobileFilterOpen,
|
||||
showAddTicketSheet,
|
||||
setShowAddTicketSheet,
|
||||
openAddTicket,
|
||||
showExportSheet,
|
||||
setShowExportSheet,
|
||||
handleExportAttendees,
|
||||
showTicketExportSheet,
|
||||
setShowTicketExportSheet,
|
||||
handleExportTickets,
|
||||
showAddAtDoorModal,
|
||||
setShowAddAtDoorModal,
|
||||
addAtDoorForm,
|
||||
setAddAtDoorForm,
|
||||
handleAddAtDoor,
|
||||
showManualTicketModal,
|
||||
setShowManualTicketModal,
|
||||
manualTicketForm,
|
||||
setManualTicketForm,
|
||||
handleManualTicket,
|
||||
showInviteGuestModal,
|
||||
setShowInviteGuestModal,
|
||||
inviteGuestForm,
|
||||
setInviteGuestForm,
|
||||
handleInviteGuest,
|
||||
submitting,
|
||||
showNoteModal,
|
||||
setShowNoteModal,
|
||||
@@ -156,27 +124,27 @@ export function EventModals(props: EventModalsProps) {
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<button
|
||||
onClick={() => { setShowManualTicketModal(true); setShowAddTicketSheet(false); }}
|
||||
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">Manual Ticket</p>
|
||||
<p className="text-xs text-gray-500">Send confirmation email with ticket</p>
|
||||
<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={() => { setShowAddAtDoorModal(true); setShowAddTicketSheet(false); }}
|
||||
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"
|
||||
>
|
||||
<PlusIcon className="w-5 h-5 text-gray-500" />
|
||||
<BanknotesIcon className="w-5 h-5 text-gray-500" />
|
||||
<div>
|
||||
<p className="font-medium">Add at Door</p>
|
||||
<p className="text-xs text-gray-500">Quick add with optional auto check-in</p>
|
||||
<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={() => { setShowInviteGuestModal(true); setShowAddTicketSheet(false); }}
|
||||
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" />
|
||||
@@ -237,243 +205,6 @@ export function EventModals(props: EventModalsProps) {
|
||||
</div>
|
||||
</BottomSheet>
|
||||
|
||||
{/* Add at Door Modal */}
|
||||
{showAddAtDoorModal && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-50 flex items-end md:items-center justify-center p-0 md:p-4"
|
||||
onClick={() => setShowAddAtDoorModal(false)}
|
||||
role="presentation"
|
||||
>
|
||||
<Card
|
||||
className="w-full md:max-w-md max-h-[90vh] flex flex-col overflow-hidden rounded-t-2xl md:rounded-card"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between p-4 border-b border-secondary-light-gray flex-shrink-0">
|
||||
<h2 className="text-base font-bold">Add Attendee at Door</h2>
|
||||
<button
|
||||
onClick={() => setShowAddAtDoorModal(false)}
|
||||
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>
|
||||
<form onSubmit={handleAddAtDoor} className="p-4 space-y-3 overflow-y-auto flex-1 min-h-0">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1">First Name *</label>
|
||||
<input type="text" required value={addAtDoorForm.firstName}
|
||||
onChange={(e) => setAddAtDoorForm({ ...addAtDoorForm, firstName: 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"
|
||||
placeholder="First name" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1">Last Name</label>
|
||||
<input type="text" value={addAtDoorForm.lastName}
|
||||
onChange={(e) => setAddAtDoorForm({ ...addAtDoorForm, lastName: 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"
|
||||
placeholder="Last name" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1">Email</label>
|
||||
<input type="email" value={addAtDoorForm.email}
|
||||
onChange={(e) => setAddAtDoorForm({ ...addAtDoorForm, email: 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"
|
||||
placeholder="email@example.com" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1">Phone</label>
|
||||
<input type="tel" value={addAtDoorForm.phone}
|
||||
onChange={(e) => setAddAtDoorForm({ ...addAtDoorForm, phone: 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"
|
||||
placeholder="+595 981 123456" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1">Admin Note</label>
|
||||
<textarea value={addAtDoorForm.adminNote}
|
||||
onChange={(e) => setAddAtDoorForm({ ...addAtDoorForm, adminNote: 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={2} placeholder="Internal note..." />
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input type="checkbox" id="autoCheckin" checked={addAtDoorForm.autoCheckin}
|
||||
onChange={(e) => setAddAtDoorForm({ ...addAtDoorForm, autoCheckin: e.target.checked })}
|
||||
className="w-4 h-4 rounded border-secondary-light-gray text-primary-yellow focus:ring-primary-yellow" />
|
||||
<label htmlFor="autoCheckin" className="text-sm font-medium">Auto check-in immediately</label>
|
||||
</div>
|
||||
<div className="flex gap-3 pt-2">
|
||||
<Button type="button" variant="outline" onClick={() => setShowAddAtDoorModal(false)} className="flex-1 min-h-[44px]">Cancel</Button>
|
||||
<Button type="submit" isLoading={submitting} className="flex-1 min-h-[44px]">Add Attendee</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Manual Ticket Modal */}
|
||||
{showManualTicketModal && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-50 flex items-end md:items-center justify-center p-0 md:p-4"
|
||||
onClick={() => setShowManualTicketModal(false)}
|
||||
role="presentation"
|
||||
>
|
||||
<Card
|
||||
className="w-full md:max-w-md max-h-[90vh] flex flex-col overflow-hidden rounded-t-2xl md:rounded-card"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between p-4 border-b border-secondary-light-gray flex-shrink-0">
|
||||
<div>
|
||||
<h2 className="text-base font-bold">Create Manual Ticket</h2>
|
||||
<p className="text-xs text-gray-500">Confirmation email will be sent</p>
|
||||
</div>
|
||||
<button onClick={() => setShowManualTicketModal(false)}
|
||||
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>
|
||||
<form onSubmit={handleManualTicket} className="p-4 space-y-3 overflow-y-auto flex-1 min-h-0">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1">First Name *</label>
|
||||
<input type="text" required value={manualTicketForm.firstName}
|
||||
onChange={(e) => setManualTicketForm({ ...manualTicketForm, firstName: 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"
|
||||
placeholder="First name" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1">Last Name</label>
|
||||
<input type="text" value={manualTicketForm.lastName}
|
||||
onChange={(e) => setManualTicketForm({ ...manualTicketForm, lastName: 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"
|
||||
placeholder="Last name" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1">Email *</label>
|
||||
<input type="email" required value={manualTicketForm.email}
|
||||
onChange={(e) => setManualTicketForm({ ...manualTicketForm, email: 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"
|
||||
placeholder="email@example.com" />
|
||||
<p className="text-[10px] text-gray-500 mt-1">Ticket will be sent to this email</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1">Phone</label>
|
||||
<input type="tel" value={manualTicketForm.phone}
|
||||
onChange={(e) => setManualTicketForm({ ...manualTicketForm, phone: 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"
|
||||
placeholder="+595 981 123456" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1">Admin Note</label>
|
||||
<textarea value={manualTicketForm.adminNote}
|
||||
onChange={(e) => setManualTicketForm({ ...manualTicketForm, adminNote: 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={2} placeholder="Internal note..." />
|
||||
</div>
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<EnvelopeIcon className="w-4 h-4 text-blue-500 mt-0.5 flex-shrink-0" />
|
||||
<div className="text-xs text-blue-800">
|
||||
<p className="font-medium">This will send:</p>
|
||||
<ul className="list-disc ml-4 mt-0.5 space-y-0.5">
|
||||
<li>Booking confirmation email</li>
|
||||
<li>Ticket with QR code</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3 pt-2">
|
||||
<Button type="button" variant="outline" onClick={() => setShowManualTicketModal(false)} className="flex-1 min-h-[44px]">Cancel</Button>
|
||||
<Button type="submit" isLoading={submitting} className="flex-1 min-h-[44px]">
|
||||
<EnvelopeIcon className="w-4 h-4 mr-1.5" />
|
||||
Create & Send
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Invite Guest Modal */}
|
||||
{showInviteGuestModal && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-50 flex items-end md:items-center justify-center p-0 md:p-4"
|
||||
onClick={() => setShowInviteGuestModal(false)}
|
||||
role="presentation"
|
||||
>
|
||||
<Card
|
||||
className="w-full md:max-w-md max-h-[90vh] flex flex-col overflow-hidden rounded-t-2xl md:rounded-card"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between p-4 border-b border-secondary-light-gray flex-shrink-0">
|
||||
<div>
|
||||
<h2 className="text-base font-bold">Invite Guest</h2>
|
||||
<p className="text-xs text-gray-500">Free ticket — not counted in revenue</p>
|
||||
</div>
|
||||
<button onClick={() => setShowInviteGuestModal(false)}
|
||||
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>
|
||||
<form onSubmit={handleInviteGuest} className="p-4 space-y-3 overflow-y-auto flex-1 min-h-0">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1">First Name *</label>
|
||||
<input type="text" required value={inviteGuestForm.firstName}
|
||||
onChange={(e) => setInviteGuestForm({ ...inviteGuestForm, firstName: 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"
|
||||
placeholder="First name" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1">Last Name</label>
|
||||
<input type="text" value={inviteGuestForm.lastName}
|
||||
onChange={(e) => setInviteGuestForm({ ...inviteGuestForm, lastName: 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"
|
||||
placeholder="Last name" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1">Email</label>
|
||||
<input type="email" value={inviteGuestForm.email}
|
||||
onChange={(e) => setInviteGuestForm({ ...inviteGuestForm, email: 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"
|
||||
placeholder="email@example.com (optional)" />
|
||||
<p className="text-[10px] text-gray-500 mt-1">If provided, a confirmation email will be sent</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1">Phone</label>
|
||||
<input type="tel" value={inviteGuestForm.phone}
|
||||
onChange={(e) => setInviteGuestForm({ ...inviteGuestForm, phone: 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"
|
||||
placeholder="+595 981 123456" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1">Admin Note</label>
|
||||
<textarea value={inviteGuestForm.adminNote}
|
||||
onChange={(e) => setInviteGuestForm({ ...inviteGuestForm, adminNote: 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={2} placeholder="Internal note..." />
|
||||
</div>
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<StarIcon className="w-4 h-4 text-amber-500 mt-0.5 flex-shrink-0" />
|
||||
<p className="text-xs text-amber-800">
|
||||
Guest tickets are <strong>free</strong> and are automatically confirmed. They are not counted toward revenue or paid ticket totals.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3 pt-2">
|
||||
<Button type="button" variant="outline" onClick={() => setShowInviteGuestModal(false)} className="flex-1 min-h-[44px]">Cancel</Button>
|
||||
<Button type="submit" isLoading={submitting} className="flex-1 min-h-[44px]">
|
||||
<StarIcon className="w-4 h-4 mr-1.5" />
|
||||
Invite Guest
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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">
|
||||
|
||||
Reference in New Issue
Block a user