Added ticket and participant creation. Revised Gate kiosk.

This commit is contained in:
Pen Anderson 2026-03-04 14:19:51 -06:00
parent d30ee18e77
commit 3906b73c61
5 changed files with 231 additions and 117 deletions

View file

@ -66,6 +66,7 @@ export const api = {
},
tickets: {
list: () => apiJSON('/api/tickets'),
create: (data) => apiJSON('/api/tickets', { method: 'POST', body: JSON.stringify(data) }),
checkIn: (id) => apiJSON(`/api/tickets/${id}/checkin`, { method: 'POST' }),
generateCodes: () => apiJSON('/api/tickets/generate-codes', { method: 'POST' }),
emailCode: (id) => apiJSON(`/api/tickets/${id}/email-code`, { method: 'POST' }),

View file

@ -16,10 +16,6 @@
let detector = $state(null)
let scanInterval = $state(null)
const attendees = liveQuery(() =>
db.attendees.filter(a => !a.deleted_at).toArray()
)
const tickets = liveQuery(() =>
db.tickets.filter(t => !t.deleted_at).toArray()
)
@ -29,19 +25,8 @@
)
const recentCheckIns = liveQuery(() =>
db.attendees
.filter(a => a.checked_in && !a.deleted_at)
.toArray()
.then(arr => arr
.filter(a => a.checked_in_at)
.sort((a, b) => b.checked_in_at.localeCompare(a.checked_in_at))
.slice(0, 10)
)
)
const recentTicketCheckIns = liveQuery(() =>
db.tickets
.filter(t => t.checked_in_at && !t.deleted_at)
.filter(t => !!t.checked_in_at && !t.deleted_at)
.toArray()
.then(arr => arr
.sort((a, b) => b.checked_in_at.localeCompare(a.checked_in_at))
@ -49,38 +34,49 @@
)
)
// Ticket matched by code (exact) or attendee matched by search
// Exact code/external_id match (QR scan or typed code)
const matchedTicket = $derived.by(() => {
const s = search.trim()
if (!s || s.length < 2) return null
const sl = s.toLowerCase()
// Exact code match (QR scan)
const byCode = ($tickets ?? []).find(t => t.code?.toLowerCase() === sl)
if (byCode) return byCode
// Exact external_id match
return ($tickets ?? []).find(t => t.external_id?.toLowerCase() === sl) ?? null
})
const filtered = $derived.by(() => {
// Name/email search across participants
const filteredParticipants = $derived.by(() => {
if (matchedTicket) return []
const s = search.trim().toLowerCase()
if (!s || s.length < 2) return []
return ($attendees ?? [])
.filter(a => a.name.toLowerCase().includes(s) || a.ticket_id?.toLowerCase().includes(s) || a.email?.toLowerCase().includes(s))
.sort((a, b) => a.name.localeCompare(b.name))
return ($participants ?? [])
.filter(p =>
p.preferred_name?.toLowerCase().includes(s) ||
p.email?.toLowerCase().includes(s)
)
.sort((a, b) => (a.preferred_name || '').localeCompare(b.preferred_name || ''))
.slice(0, 8)
})
const selected = $derived.by(() => {
if (filtered.length === 1) return filtered[0]
const s = search.trim().toLowerCase()
return filtered.find(a => a.ticket_id?.toLowerCase() === s) ?? null
// Auto-select when exactly one participant matches
const selectedParticipant = $derived.by(() => {
if (filteredParticipants.length === 1) return filteredParticipants[0]
return null
})
function ticketsFor(participantId) {
return ($tickets ?? []).filter(t => t.participant_id === participantId && !t.deleted_at)
}
function participantFor(ticket) {
if (!ticket?.participant_id) return null
return ($participants ?? []).find(p => p.id === ticket.participant_id) ?? null
}
function nameFor(ticket) {
return ticket.name || participantFor(ticket)?.preferred_name || '(unknown)'
}
onMount(() => {
qrSupported = 'BarcodeDetector' in window
})
@ -144,40 +140,6 @@
}
}
async function checkIn(attendee, count = 1) {
error = ''
try {
const result = await api.attendees.checkIn(attendee.id, { count })
if (result.attendee) {
await db.attendees.put(result.attendee)
}
} catch (err) {
error = err.message
}
}
async function checkInWithVolunteer(attendee) {
error = ''
try {
const result = await api.attendees.checkIn(attendee.id, { count: 1, also_volunteer: true })
if (result.attendee) await db.attendees.put(result.attendee)
if (result.volunteer) await db.volunteers.put(result.volunteer)
} catch (err) {
error = err.message
}
}
function remaining(a) {
return (a.party_size ?? 1) - (a.checked_in_count ?? 0)
}
function progressLabel(a) {
const ps = a.party_size ?? 1
const ci = a.checked_in_count ?? 0
if (ps <= 1) return null
return `${ci}/${ps} checked in`
}
function fmt(ts) {
if (!ts) return ''
return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
@ -220,11 +182,11 @@
<div class="gate-msg gate-msg-error">{error}</div>
{/if}
<!-- Matched ticket card (ticket code scan) -->
{#if matchedTicket && !selected}
<!-- Exact code/ID match card -->
{#if matchedTicket}
{@const p = participantFor(matchedTicket)}
<div class="gate-match">
<div class="gate-match-name">{matchedTicket.name || p?.preferred_name || '(unknown)'}</div>
<div class="gate-match-name">{nameFor(matchedTicket)}</div>
{#if matchedTicket.ticket_type}
<div class="gate-match-sub">{matchedTicket.ticket_type}</div>
{/if}
@ -244,76 +206,71 @@
{/if}
</div>
</div>
{/if}
<!-- Matched attendee card -->
{#if selected}
{@const rem = remaining(selected)}
{@const prog = progressLabel(selected)}
<!-- Single participant match — show their tickets -->
{:else if selectedParticipant}
{@const pts = ticketsFor(selectedParticipant.id)}
<div class="gate-match">
<div class="gate-match-name">{selected.name}</div>
{#if selected.ticket_type}
<div class="gate-match-sub">{selected.ticket_type}</div>
{/if}
{#if selected.ticket_id}
<div class="gate-match-sub text-muted">#{selected.ticket_id}</div>
{/if}
{#if prog}
<div class="gate-party">
<span class="gate-party-label">{prog}</span>
</div>
{/if}
<div class="gate-match-actions">
{#if rem > 0}
<button class="gbtn gbtn-success" onclick={() => checkIn(selected, 1)}>
✓ Check in 1
</button>
{#if rem > 1}
<button class="gbtn gbtn-ghost" onclick={() => checkIn(selected, rem)}>
Check in all {rem}
</button>
<div class="gate-match-name">{selectedParticipant.preferred_name || selectedParticipant.email || '(unknown)'}</div>
{#if selectedParticipant.email}
<div class="gate-match-sub text-muted">{selectedParticipant.email}</div>
{/if}
{#if pts.length === 0}
<div class="gate-match-sub" style="margin-top:0.5rem;color:var(--c-warn)">No tickets on file</div>
{:else}
<span class="gate-done">All checked in</span>
{/if}
{#if selected.volunteer_token && !selected.checked_in}
<button class="gbtn gbtn-ghost" onclick={() => checkInWithVolunteer(selected)}>
+ Volunteer
</button>
{/if}
</div>
</div>
{:else if search.trim().length >= 2 && filtered.length > 1}
<!-- Multiple results list -->
<div class="gate-results">
{#each filtered as a}
<button class="gate-result-row" onclick={() => search = a.ticket_id || a.name}>
<div style="margin-top:0.75rem;display:flex;flex-direction:column;gap:0.4rem">
{#each pts as tk (tk.id)}
<div class="gate-ticket-row">
<span>
<strong>{a.name}</strong>
{#if a.ticket_type} · {a.ticket_type}{/if}
<strong>{tk.name || '(unnamed)'}</strong>
{#if tk.ticket_type}<span class="text-muted"> · {tk.ticket_type}</span>{/if}
</span>
<span class="badge {a.checked_in ? 'badge-checked' : 'badge-unchecked'}">
{a.checked_in ? 'In' : 'Pending'}
{#if tk.checked_in_at}
<span class="gate-done" style="font-size:0.8rem">{fmt(tk.checked_in_at)}</span>
{:else}
<button class="gbtn gbtn-success" style="padding:0.3rem 0.75rem;font-size:0.8rem"
onclick={() => checkInTicket(tk)}>✓ Check in</button>
{/if}
</div>
{/each}
</div>
{/if}
</div>
<!-- Multiple participant matches -->
{:else if search.trim().length >= 2 && filteredParticipants.length > 1}
<div class="gate-results">
{#each filteredParticipants as p}
{@const pts = ticketsFor(p.id)}
{@const ci = pts.filter(t => t.checked_in_at).length}
<button class="gate-result-row" onclick={() => search = p.preferred_name || p.email || ''}>
<span>
<strong>{p.preferred_name || p.email || '(unknown)'}</strong>
{#if p.email && p.preferred_name}
<span class="text-muted" style="font-size:0.8rem"> · {p.email}</span>
{/if}
</span>
<span class="badge {ci === pts.length && pts.length > 0 ? 'badge-checked' : ci > 0 ? 'badge-partial' : 'badge-unchecked'}">
{pts.length > 0 ? `${ci}/${pts.length}` : 'No ticket'}
</span>
</button>
{/each}
</div>
{:else if search.trim().length >= 2 && filtered.length === 0 && !matchedTicket}
<div class="gate-msg gate-msg-warn">No matching attendees or tickets found.</div>
{:else if search.trim().length >= 2}
<div class="gate-msg gate-msg-warn">No matching participants or tickets found.</div>
{/if}
<!-- Recent check-ins -->
<div class="gate-recent">
<div class="gate-recent-title">Recent Check-ins</div>
{#if ($recentCheckIns ?? []).length === 0}
<div class="gate-recent-empty">No check-ins yet today.</div>
<div class="gate-recent-empty">No check-ins yet.</div>
{:else}
{#each $recentCheckIns ?? [] as a}
{#each $recentCheckIns ?? [] as tk}
<div class="gate-recent-row">
<span>{a.name}</span>
<span class="text-muted">{fmt(a.checked_in_at)}</span>
<span>{nameFor(tk)}</span>
<span class="text-muted">{fmt(tk.checked_in_at)}</span>
</div>
{/each}
{/if}
@ -458,6 +415,16 @@
align-items: center;
}
.gate-ticket-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.4rem 0.6rem;
background: var(--c-bg);
border-radius: 6px;
font-size: 0.875rem;
}
.gate-results {
background: var(--c-surface);
border: 1px solid var(--c-border);

View file

@ -11,10 +11,25 @@
let generating = $state(false)
let emailing = $state(false)
let mergeMode = $state(false)
let mergeSource = $state(null) // participant being merged away
let mergeTarget = $state(null) // participant to keep
let mergeSource = $state(null)
let mergeTarget = $state(null)
let expandedId = $state(null)
let expandedTickets = $state([])
// Add participant form
let showAdd = $state(false)
let adding = $state(false)
let newName = $state('')
let newEmail = $state('')
let newPhone = $state('')
let newPronouns = $state('')
let newNote = $state('')
// Add ticket form (per participant)
let addTicketFor = $state(null) // participant id
let addingTicket = $state(false)
let newTicketName = $state('')
let newTicketType = $state('')
let newTicketExtId = $state('')
const role = $derived(session?.user?.role ?? '')
const canManage = $derived(['admin', 'ticketing'].includes(role))
@ -114,6 +129,45 @@
if (!ts) return ''
return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
}
async function addParticipant(e) {
e.preventDefault()
adding = true; error = ''
try {
const p = await api.participants.create({
preferred_name: newName, email: newEmail, phone: newPhone,
pronouns: newPronouns, note: newNote,
})
await db.participants.put(p)
showAdd = false
newName = newEmail = newPhone = newPronouns = newNote = ''
} catch (err) {
error = err.message
} finally {
adding = false
}
}
async function addTicket(e, participantId) {
e.preventDefault()
addingTicket = true; error = ''
try {
const tk = await api.tickets.create({
participant_id: participantId,
name: newTicketName,
ticket_type: newTicketType,
external_id: newTicketExtId,
source: 'manual',
})
await db.tickets.put(tk)
addTicketFor = null
newTicketName = newTicketType = newTicketExtId = ''
} catch (err) {
error = err.message
} finally {
addingTicket = false
}
}
</script>
<div class="page">
@ -121,6 +175,7 @@
<h1 class="page-title">Participants</h1>
<div class="actions">
{#if canManage}
<button class="btn btn-primary btn-sm" onclick={() => showAdd = !showAdd}>+ Add</button>
<a href="/api/participants/export" class="btn btn-ghost btn-sm">Export CSV</a>
<button class="btn btn-ghost btn-sm" onclick={generateCodes} disabled={generating}>
{generating ? '…' : '⚿ Generate Codes'}
@ -133,6 +188,41 @@
</div>
</div>
{#if showAdd && canManage}
<div class="card" style="margin-bottom:1.5rem">
<form onsubmit={addParticipant}>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem">
<div class="form-group">
<label for="p-name">Name</label>
<input id="p-name" bind:value={newName} placeholder="Preferred name" />
</div>
<div class="form-group">
<label for="p-email">Email</label>
<input id="p-email" type="email" bind:value={newEmail} placeholder="email@example.com" />
</div>
<div class="form-group">
<label for="p-phone">Phone</label>
<input id="p-phone" bind:value={newPhone} placeholder="Optional" />
</div>
<div class="form-group">
<label for="p-pronouns">Pronouns</label>
<input id="p-pronouns" bind:value={newPronouns} placeholder="Optional" />
</div>
</div>
<div class="form-group">
<label for="p-note">Note</label>
<input id="p-note" bind:value={newNote} placeholder="Optional note" />
</div>
<div class="actions">
<button type="submit" class="btn btn-primary" disabled={adding || (!newName && !newEmail)}>
{adding ? 'Adding…' : 'Add participant'}
</button>
<button type="button" class="btn btn-ghost" onclick={() => showAdd = false}>Cancel</button>
</div>
</form>
</div>
{/if}
{#if mergeMode && mergeSource}
<div class="card" style="margin-bottom:1.5rem;border-color:var(--c-accent)">
<div style="margin-bottom:0.75rem">
@ -236,7 +326,7 @@
</td>
{/if}
</tr>
{#if isExpanded && pts.length > 0}
{#if isExpanded}
<tr class="ticket-rows">
<td colspan="5">
<div class="ticket-list">
@ -270,6 +360,24 @@
</div>
</div>
{/each}
{#if canManage}
{#if addTicketFor === p.id}
<form class="ticket-add-form" onsubmit={(e) => addTicket(e, p.id)}>
<input bind:value={newTicketName} placeholder="Name on ticket (optional)" style="flex:2" />
<input bind:value={newTicketType} placeholder="Type (optional)" style="flex:1" />
<input bind:value={newTicketExtId} placeholder="External ID (optional)" style="flex:1" />
<button type="submit" class="btn btn-primary btn-sm" disabled={addingTicket}>
{addingTicket ? '…' : 'Add'}
</button>
<button type="button" class="btn btn-ghost btn-sm" onclick={() => addTicketFor = null}>Cancel</button>
</form>
{:else}
<button class="btn btn-ghost btn-sm" style="align-self:flex-start;margin-top:0.25rem"
onclick={() => { addTicketFor = p.id; newTicketName = newTicketType = newTicketExtId = '' }}>
+ Add ticket
</button>
{/if}
{/if}
</div>
</td>
</tr>
@ -294,6 +402,21 @@
background: var(--c-surface);
font-size: 0.875rem;
}
.ticket-add-form {
display: flex;
gap: 0.4rem;
align-items: center;
flex-wrap: wrap;
padding: 0.4rem 0.6rem;
background: var(--c-bg);
border-radius: 6px;
border: 1px dashed var(--c-border);
}
.ticket-add-form input {
min-width: 0;
font-size: 0.825rem;
padding: 0.3rem 0.5rem;
}
.badge-partial {
background: rgba(245,158,11,0.15);
color: var(--c-warn);

View file

@ -131,6 +131,28 @@ func (app *App) handleExportParticipants(w http.ResponseWriter, r *http.Request)
wr.Flush()
}
func (app *App) handleCreateTicket(w http.ResponseWriter, r *http.Request) {
var t Ticket
if err := json.NewDecoder(r.Body).Decode(&t); err != nil {
writeError(w, "invalid request", http.StatusBadRequest)
return
}
if t.ParticipantID == nil {
writeError(w, "participant_id is required", http.StatusBadRequest)
return
}
if t.Source == "" {
t.Source = "manual"
}
created, err := app.createTicket(t)
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusCreated)
writeJSON(w, created)
}
func (app *App) handleListTickets(w http.ResponseWriter, r *http.Request) {
tickets, err := app.listTickets(nil, "")
if err != nil {

View file

@ -120,6 +120,7 @@ func (app *App) registerRoutes(mux *http.ServeMux) {
mux.HandleFunc("POST /api/participants/{id}/merge/{other_id}", auth(app.handleMergeParticipants, "admin", "ticketing"))
mux.HandleFunc("GET /api/tickets", auth(app.handleListTickets, "admin", "ticketing", "gatekeeper"))
mux.HandleFunc("POST /api/tickets", auth(app.handleCreateTicket, "admin", "ticketing"))
mux.HandleFunc("POST /api/tickets/{id}/checkin", auth(app.handleCheckInTicket, "admin", "ticketing", "gatekeeper"))
mux.HandleFunc("POST /api/tickets/generate-codes", auth(app.handleGenerateTokens, "admin", "ticketing"))
mux.HandleFunc("GET /api/tickets/export-links", auth(app.handleExportTokenLinks, "admin", "ticketing"))