Files
Spanglish/frontend/src/app/admin/events/[id]/_modals/AddTicketModal.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

210 lines
8.8 KiB
TypeScript

import type { Dispatch, FormEvent, SetStateAction } from 'react';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import clsx from 'clsx';
import {
BanknotesIcon,
CheckCircleIcon,
EnvelopeIcon,
LinkIcon,
StarIcon,
XMarkIcon,
} from '@heroicons/react/24/outline';
import type { AddTicketType, AddTicketFormState } from '../_types';
interface AddTicketModalProps {
open: boolean;
onClose: () => void;
form: AddTicketFormState;
setForm: Dispatch<SetStateAction<AddTicketFormState>>;
onSubmit: (e: FormEvent) => void;
submitting: boolean;
eventPriceLabel: string;
}
const TYPE_OPTIONS: { value: AddTicketType; label: string }[] = [
{ value: 'paid', label: 'Paid' },
{ value: 'unpaid', label: 'Unpaid' },
{ value: 'guest', label: 'Guest' },
];
const SUBMIT_LABELS: Record<AddTicketType, string> = {
paid: 'Create & send ticket',
unpaid: 'Create & send pay link',
guest: 'Invite guest',
};
const SUBMIT_ICONS: Record<AddTicketType, typeof EnvelopeIcon> = {
paid: EnvelopeIcon,
unpaid: LinkIcon,
guest: StarIcon,
};
// Live "what happens" preview lines for the selected type / email / check-in combo
function previewLines(form: AddTicketFormState, eventPriceLabel: string): string[] {
const hasEmail = !!form.email.trim();
const lines: string[] = [];
if (form.type === 'paid') {
lines.push(`Payment of ${eventPriceLabel} recorded as paid — counts toward revenue`);
lines.push('Confirmation email with QR ticket sent');
} else if (form.type === 'unpaid') {
lines.push(`Ticket marked unpaid — balance of ${eventPriceLabel} to collect at the door`);
lines.push('QR code issued, flagged "unpaid" for door staff');
lines.push(hasEmail
? 'Bancard (TPago) payment link emailed to the attendee'
: 'No email — no pay link sent, payment collected at the door');
} else {
lines.push('Free guest ticket (comp) — not counted in revenue');
lines.push('Auto-confirmed with QR code');
lines.push(hasEmail ? 'Confirmation email sent' : 'No email — nothing is sent');
}
if (form.checkinNow) {
lines.push('Checked in immediately');
}
return lines;
}
const PREVIEW_STYLES: Record<AddTicketType, { box: string; icon: string; text: string }> = {
paid: { box: 'bg-blue-50 border-blue-200', icon: 'text-blue-500', text: 'text-blue-800' },
unpaid: { box: 'bg-orange-50 border-orange-200', icon: 'text-orange-500', text: 'text-orange-800' },
guest: { box: 'bg-amber-50 border-amber-200', icon: 'text-amber-500', text: 'text-amber-800' },
};
const PREVIEW_ICONS: Record<AddTicketType, typeof EnvelopeIcon> = {
paid: CheckCircleIcon,
unpaid: BanknotesIcon,
guest: StarIcon,
};
export function AddTicketModal({
open,
onClose,
form,
setForm,
onSubmit,
submitting,
eventPriceLabel,
}: AddTicketModalProps) {
if (!open) return null;
const emailRequired = form.type === 'paid';
const style = PREVIEW_STYLES[form.type];
const PreviewIcon = PREVIEW_ICONS[form.type];
const SubmitIcon = SUBMIT_ICONS[form.type];
return (
<div
className="fixed inset-0 bg-black/50 z-50 flex items-end md:items-center justify-center p-0 md:p-4"
onClick={onClose}
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 Ticket</h2>
<button
onClick={onClose}
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={onSubmit} className="p-4 space-y-3 overflow-y-auto flex-1 min-h-0">
{/* Segmented ticket-type control */}
<div className="flex rounded-btn bg-gray-100 p-1">
{TYPE_OPTIONS.map((option) => (
<button
key={option.value}
type="button"
onClick={() => setForm((f) => ({ ...f, type: option.value }))}
className={clsx(
'flex-1 px-3 py-2 text-sm font-medium rounded-btn min-h-[36px] transition-colors',
form.type === option.value
? 'bg-white shadow-sm text-primary-dark'
: 'text-gray-500 hover:text-gray-700'
)}
>
{option.label}
</button>
))}
</div>
<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={form.firstName}
onChange={(e) => setForm((f) => ({ ...f, 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={form.lastName}
onChange={(e) => setForm((f) => ({ ...f, 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 {emailRequired && '*'}</label>
<input type="email" required={emailRequired} value={form.email}
onChange={(e) => setForm((f) => ({ ...f, 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={emailRequired ? 'email@example.com' : 'email@example.com (optional)'} />
<p className="text-[10px] text-gray-500 mt-1">
{form.type === 'paid' && 'Ticket will be sent to this email'}
{form.type === 'unpaid' && 'If provided, the payment link is sent here'}
{form.type === 'guest' && '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={form.phone}
onChange={(e) => setForm((f) => ({ ...f, 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={form.adminNote}
onChange={(e) => setForm((f) => ({ ...f, 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="checkinNow" checked={form.checkinNow}
onChange={(e) => setForm((f) => ({ ...f, checkinNow: e.target.checked }))}
className="w-4 h-4 rounded border-secondary-light-gray text-primary-yellow focus:ring-primary-yellow" />
<label htmlFor="checkinNow" className="text-sm font-medium">Check in now</label>
</div>
{/* Live preview of what this submission does */}
<div className={clsx('border rounded-lg p-3', style.box)}>
<div className="flex items-start gap-2">
<PreviewIcon className={clsx('w-4 h-4 mt-0.5 flex-shrink-0', style.icon)} />
<div className={clsx('text-xs', style.text)}>
<p className="font-medium">What happens:</p>
<ul className="list-disc ml-4 mt-0.5 space-y-0.5">
{previewLines(form, eventPriceLabel).map((line) => (
<li key={line}>{line}</li>
))}
</ul>
</div>
</div>
</div>
<div className="flex gap-3 pt-2">
<Button type="button" variant="outline" onClick={onClose} className="flex-1 min-h-[44px]">Cancel</Button>
<Button type="submit" isLoading={submitting} className="flex-1 min-h-[44px]">
<SubmitIcon className="w-4 h-4 mr-1.5" />
{SUBMIT_LABELS[form.type]}
</Button>
</div>
</form>
</Card>
</div>
);
}