Established Participants and Tickets model. Migrated concepts.

This commit is contained in:
Pen Anderson 2026-03-04 10:53:42 -06:00
parent 0df93e1886
commit cd8e1e3b3b
22 changed files with 1345 additions and 191 deletions

View file

@ -5,6 +5,7 @@
import Login from './pages/Login.svelte'
import Dashboard from './pages/Dashboard.svelte'
import Attendees from './pages/Attendees.svelte'
import Participants from './pages/Participants.svelte'
import Volunteers from './pages/Volunteers.svelte'
import Departments from './pages/Departments.svelte'
import Users from './pages/Users.svelte'
@ -128,6 +129,8 @@
{/if}
{:else if path.startsWith('/attendees')}
<Attendees {session} />
{:else if path.startsWith('/participants')}
<Participants {session} />
{:else if path.startsWith('/volunteers')}
<Volunteers {session} />
{:else if path.startsWith('/departments')}

View file

@ -56,6 +56,21 @@ export const api = {
get: () => apiJSON('/api/event'),
update: (data) => apiJSON('/api/event', { method: 'PUT', body: JSON.stringify(data) }),
},
participants: {
list: (params = {}) => apiJSON('/api/participants?' + new URLSearchParams(params)),
get: (id) => apiJSON(`/api/participants/${id}`),
create: (data) => apiJSON('/api/participants', { method: 'POST', body: JSON.stringify(data) }),
update: (id, data) => apiJSON(`/api/participants/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
delete: (id) => apiFetch(`/api/participants/${id}`, { method: 'DELETE' }),
merge: (id, otherId) => apiJSON(`/api/participants/${id}/merge/${otherId}`, { method: 'POST' }),
},
tickets: {
list: () => apiJSON('/api/tickets'),
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' }),
emailAllCodes: () => apiJSON('/api/tickets/email-codes', { method: 'POST' }),
},
attendees: {
list: (params = {}) => apiJSON('/api/attendees?' + new URLSearchParams(params)),
get: (id) => apiJSON(`/api/attendees/${id}`),
@ -111,6 +126,7 @@ export const api = {
testEmail: (to) => apiJSON('/api/settings/test-email', { method: 'POST', body: JSON.stringify({ to }) }),
toggleShiftSignups: (open) => apiJSON('/api/settings/shift-signups', { method: 'POST', body: JSON.stringify({ open }) }),
resetAttendees: () => apiJSON('/api/settings/reset-attendees', { method: 'POST' }),
resetTickets: () => apiJSON('/api/settings/reset-tickets', { method: 'POST' }),
resetVolunteers: () => apiJSON('/api/settings/reset-volunteers', { method: 'POST' }),
resetShifts: () => apiJSON('/api/settings/reset-shifts', { method: 'POST' }),
resetDepartments: () => apiJSON('/api/settings/reset-departments', { method: 'POST' }),

View file

@ -1,5 +1,5 @@
<script>
import { LayoutDashboard, ClipboardCheck, Heart, Hexagon, CalendarDays, Upload, Users, Settings, LogOut } from 'lucide-svelte'
import { LayoutDashboard, ClipboardCheck, Heart, Hexagon, CalendarDays, Upload, Users, Settings, LogOut, Ticket } from 'lucide-svelte'
let { session, active, onLogout, navigate, open = false } = $props()
@ -9,6 +9,7 @@
const links = $derived.by(() => {
if (role === 'ticketing') return [
{ href: '/participants', label: 'Participants', icon: Ticket },
{ href: '/attendees', label: 'Attendees', icon: ClipboardCheck },
{ href: '/import', label: 'Import', icon: Upload },
]
@ -25,6 +26,7 @@
]
return [
{ href: '/', label: 'Dashboard', icon: LayoutDashboard },
{ href: '/participants', label: 'Participants', icon: Ticket },
{ href: '/attendees', label: 'Attendees', icon: ClipboardCheck },
{ href: '/volunteers', label: 'Volunteers', icon: Heart },
{ href: '/departments', label: 'Departments', icon: Hexagon },

View file

@ -26,6 +26,20 @@ db.version(2).stores({
outbox: '++id, table, op, synced_at',
})
db.version(3).stores({
session: 'id, token, user',
meta: 'key',
event: 'id',
attendees: 'id, name, ticket_type, checked_in, volunteer_token, deleted_at',
participants: 'id, email, preferred_name, updated_at, deleted_at',
tickets: 'id, participant_id, code, source, checked_in_at, updated_at, deleted_at',
departments: 'id, name, deleted_at',
volunteers: 'id, name, department_id, checked_in, attendee_id, participant_id, deleted_at',
shifts: 'id, department_id, day, position, deleted_at',
volunteer_shifts: '[volunteer_id+shift_id], volunteer_id, shift_id',
outbox: '++id, table, op, synced_at',
})
export async function getLastSync() {
const m = await db.meta.get('last_sync')
return m?.value ?? ''

View file

@ -10,7 +10,7 @@ describe('db schema', () => {
const names = db.tables.map(t => t.name).sort()
expect(names).toEqual([
'attendees', 'departments', 'event', 'meta',
'outbox', 'session', 'shifts', 'volunteer_shifts', 'volunteers',
'outbox', 'participants', 'session', 'shifts', 'tickets', 'volunteer_shifts', 'volunteers',
])
})
})

View file

@ -20,6 +20,14 @@
db.attendees.filter(a => !a.deleted_at).toArray()
)
const tickets = liveQuery(() =>
db.tickets.filter(t => !t.deleted_at).toArray()
)
const participants = liveQuery(() =>
db.participants.filter(p => !p.deleted_at).toArray()
)
const recentCheckIns = liveQuery(() =>
db.attendees
.filter(a => a.checked_in && !a.deleted_at)
@ -31,6 +39,28 @@
)
)
const recentTicketCheckIns = liveQuery(() =>
db.tickets
.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))
.slice(0, 10)
)
)
// Ticket matched by code (exact) or attendee matched by search
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(() => {
const s = search.trim().toLowerCase()
if (!s || s.length < 2) return []
@ -46,6 +76,11 @@
return filtered.find(a => a.ticket_id?.toLowerCase() === s) ?? null
})
function participantFor(ticket) {
if (!ticket?.participant_id) return null
return ($participants ?? []).find(p => p.id === ticket.participant_id) ?? null
}
onMount(() => {
qrSupported = 'BarcodeDetector' in window
})
@ -96,6 +131,19 @@
} catch {}
}
async function checkInTicket(ticket) {
error = ''
try {
const result = await api.tickets.checkIn(ticket.id)
if (result.ticket) {
await db.tickets.put(result.ticket)
search = ''
}
} catch (err) {
error = err.message
}
}
async function checkIn(attendee, count = 1) {
error = ''
try {
@ -172,6 +220,32 @@
<div class="gate-msg gate-msg-error">{error}</div>
{/if}
<!-- Matched ticket card (ticket code scan) -->
{#if matchedTicket && !selected}
{@const p = participantFor(matchedTicket)}
<div class="gate-match">
<div class="gate-match-name">{matchedTicket.name || p?.preferred_name || '(unknown)'}</div>
{#if matchedTicket.ticket_type}
<div class="gate-match-sub">{matchedTicket.ticket_type}</div>
{/if}
{#if matchedTicket.external_id}
<div class="gate-match-sub text-muted">#{matchedTicket.external_id}</div>
{/if}
{#if p?.email}
<div class="gate-match-sub text-muted">{p.email}</div>
{/if}
<div class="gate-match-actions">
{#if !matchedTicket.checked_in_at}
<button class="gbtn gbtn-success" onclick={() => checkInTicket(matchedTicket)}>
✓ Check in
</button>
{:else}
<span class="gate-done">✓ Checked in {fmt(matchedTicket.checked_in_at)}</span>
{/if}
</div>
</div>
{/if}
<!-- Matched attendee card -->
{#if selected}
{@const rem = remaining(selected)}
@ -226,8 +300,8 @@
</button>
{/each}
</div>
{:else if search.trim().length >= 2 && filtered.length === 0}
<div class="gate-msg gate-msg-warn">No matching attendees found.</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>
{/if}
<!-- Recent check-ins -->

View file

@ -0,0 +1,305 @@
<script>
import { liveQuery } from 'dexie'
import { db } from '../db.js'
import { api } from '../api.js'
let { session } = $props()
let search = $state('')
let error = $state('')
let success = $state('')
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 expandedId = $state(null)
let expandedTickets = $state([])
const role = $derived(session?.user?.role ?? '')
const canManage = $derived(['admin', 'ticketing'].includes(role))
const allParticipants = liveQuery(() => db.participants.toArray())
const allTickets = liveQuery(() => db.tickets.toArray())
const filtered = $derived.by(() => {
const list = $allParticipants ?? []
const s = search.toLowerCase().trim()
return list
.filter(p => {
if (!s) return true
return p.preferred_name?.toLowerCase().includes(s) ||
p.email?.toLowerCase().includes(s)
})
.sort((a, b) => (a.preferred_name || a.email).localeCompare(b.preferred_name || b.email))
})
function ticketsFor(participantId) {
return ($allTickets ?? []).filter(t => t.participant_id === participantId)
}
function checkedInCount(participantId) {
return ticketsFor(participantId).filter(t => t.checked_in_at).length
}
async function toggleExpand(id) {
if (expandedId === id) {
expandedId = null
expandedTickets = []
return
}
expandedId = id
expandedTickets = ticketsFor(id)
}
async function generateCodes() {
generating = true
error = ''
success = ''
try {
const result = await api.tickets.generateCodes()
success = `Generated ${result.generated} code${result.generated !== 1 ? 's' : ''}.`
} catch (err) {
error = err.message
} finally {
generating = false
}
}
async function emailAll() {
if (!confirm('Send code emails to all participants with a ticket code and email?')) return
emailing = true
error = ''
success = ''
try {
const result = await api.tickets.emailAllCodes()
success = `Sent ${result.sent} email${result.sent !== 1 ? 's' : ''}${result.skipped ? `, skipped ${result.skipped}` : ''}.`
if (result.errors?.length) error = result.errors.join('; ')
} catch (err) {
error = err.message
} finally {
emailing = false
}
}
function startMerge(p) {
mergeMode = true
mergeSource = p
mergeTarget = null
error = ''
}
function cancelMerge() {
mergeMode = false
mergeSource = null
mergeTarget = null
}
async function confirmMerge() {
if (!mergeSource || !mergeTarget) return
error = ''
try {
const result = await api.participants.merge(mergeTarget.id, mergeSource.id)
success = `Merged "${mergeSource.preferred_name || mergeSource.email}" into "${mergeTarget.preferred_name || mergeTarget.email}".`
if (result.participant) await db.participants.put(result.participant)
if (result.tickets?.length) await db.tickets.bulkPut(result.tickets)
await db.participants.delete(mergeSource.id)
cancelMerge()
} catch (err) {
error = err.message
}
}
function fmtTime(ts) {
if (!ts) return ''
return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
}
</script>
<div class="page">
<div class="page-header">
<h1 class="page-title">Participants</h1>
<div class="actions">
{#if canManage}
<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'}
</button>
<a href="/api/tickets/export-links" class="btn btn-ghost btn-sm">Export Links</a>
<button class="btn btn-ghost btn-sm" onclick={emailAll} disabled={emailing}>
{emailing ? '…' : '✉ Email All'}
</button>
{/if}
</div>
</div>
{#if mergeMode && mergeSource}
<div class="card" style="margin-bottom:1.5rem;border-color:var(--c-accent)">
<div style="margin-bottom:0.75rem">
<strong>Merge:</strong> "{mergeSource.preferred_name || mergeSource.email}" will be merged into the participant you select below.
All their tickets and volunteer records will move to the target.
</div>
{#if mergeTarget}
<div style="margin-bottom:0.75rem">
<strong>Target:</strong> {mergeTarget.preferred_name || mergeTarget.email} ({mergeTarget.email})
</div>
<div class="actions">
<button class="btn btn-primary" onclick={confirmMerge}>Confirm merge</button>
<button class="btn btn-ghost" onclick={cancelMerge}>Cancel</button>
</div>
{:else}
<div class="text-muted" style="font-size:0.875rem">Click a participant row below to select as merge target.</div>
<div class="actions" style="margin-top:0.5rem">
<button class="btn btn-ghost" onclick={cancelMerge}>Cancel</button>
</div>
{/if}
</div>
{/if}
{#if error}
<div class="alert alert-error">{error}</div>
{/if}
{#if success}
<div class="alert alert-success">{success}</div>
{/if}
<div class="search-bar">
<input placeholder="Search name or email…" bind:value={search} />
<span class="text-muted" style="font-size:0.85rem;white-space:nowrap">
{filtered.length} shown
</span>
</div>
{#if ($allParticipants ?? []).length === 0}
<div class="empty">
<strong>No participants yet</strong>
<p>Import a CSV or wait for volunteer signups.</p>
</div>
{:else}
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Tickets</th>
<th>Status</th>
{#if canManage}<th></th>{/if}
</tr>
</thead>
<tbody>
{#each filtered as p (p.id)}
{@const pts = ticketsFor(p.id)}
{@const ci = checkedInCount(p.id)}
{@const isExpanded = expandedId === p.id}
{@const isMergeTarget = mergeMode && mergeSource?.id !== p.id}
<tr
class:merge-target={isMergeTarget}
onclick={mergeMode && mergeSource?.id !== p.id ? () => { mergeTarget = p } : null}
style={mergeMode && mergeSource?.id !== p.id ? 'cursor:pointer' : ''}
>
<td>
<strong>{p.preferred_name || '—'}</strong>
{#if p.pronouns}
<span class="text-muted" style="font-size:0.78rem"> · {p.pronouns}</span>
{/if}
{#if p.note}
<div class="text-muted" style="font-size:0.78rem">{p.note}</div>
{/if}
</td>
<td class="text-muted">{p.email || '—'}</td>
<td>
{#if pts.length > 0}
<button class="btn btn-ghost btn-sm" onclick={(e) => { e.stopPropagation(); toggleExpand(p.id) }}>
{pts.length} ticket{pts.length !== 1 ? 's' : ''}
{isExpanded ? '▲' : '▼'}
</button>
{:else}
<span class="text-muted"></span>
{/if}
</td>
<td>
{#if pts.length > 0}
<span class="badge {ci === pts.length ? 'badge-checked' : ci > 0 ? 'badge-partial' : 'badge-unchecked'}">
{ci}/{pts.length} in
</span>
{:else}
<span class="badge badge-unchecked">No ticket</span>
{/if}
</td>
{#if canManage}
<td>
{#if !mergeMode}
<button class="btn btn-ghost btn-sm" onclick={(e) => { e.stopPropagation(); startMerge(p) }}
title="Merge this participant into another">⇄</button>
{/if}
</td>
{/if}
</tr>
{#if isExpanded && pts.length > 0}
<tr class="ticket-rows">
<td colspan="5">
<div class="ticket-list">
{#each pts as tk (tk.id)}
<div class="ticket-row">
<div>
<strong>{tk.name || '(unnamed)'}</strong>
{#if tk.ticket_type}
<span class="text-muted"> · {tk.ticket_type}</span>
{/if}
{#if tk.external_id}
<span class="text-muted" style="font-size:0.78rem"> · #{tk.external_id}</span>
{/if}
{#if tk.code}
<div style="font-size:0.75rem;margin-top:0.15rem">
<code style="color:var(--c-accent-h)">{tk.code}</code>
{#if p.email && canManage}
<button class="btn btn-ghost btn-sm" style="padding:0.1rem 0.4rem;margin-left:0.25rem"
onclick={() => api.tickets.emailCode(tk.id).then(() => success = 'Email sent.').catch(e => error = e.message)}>✉</button>
{/if}
</div>
{/if}
</div>
<div style="text-align:right">
{#if tk.checked_in_at}
<span class="badge badge-checked">In {fmtTime(tk.checked_in_at)}</span>
{:else}
<span class="badge badge-unchecked">Pending</span>
{/if}
<div class="text-muted" style="font-size:0.75rem">{tk.source}</div>
</div>
</div>
{/each}
</div>
</td>
</tr>
{/if}
{/each}
</tbody>
</table>
</div>
{/if}
</div>
<style>
.merge-target:hover { background: rgba(var(--c-accent-rgb, 99,102,241), 0.08); }
.ticket-rows td { padding: 0; background: var(--c-bg); }
.ticket-list { padding: 0.5rem 1rem 0.75rem; display: flex; flex-direction: column; gap: 0.4rem; }
.ticket-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 0.4rem 0.6rem;
border-radius: 6px;
background: var(--c-surface);
font-size: 0.875rem;
}
.badge-partial {
background: rgba(245,158,11,0.15);
color: var(--c-warn);
padding: 0.15rem 0.5rem;
border-radius: 99px;
font-size: 0.75rem;
font-weight: 600;
}
</style>

View file

@ -12,7 +12,7 @@ export async function syncPull() {
const data = await api.sync.pull(since)
await db.transaction('rw',
[db.event, db.attendees, db.departments, db.volunteers, db.shifts, db.volunteer_shifts],
[db.event, db.attendees, db.participants, db.tickets, db.departments, db.volunteers, db.shifts, db.volunteer_shifts],
async () => {
if (data.event) {
await db.event.put(data.event)
@ -23,6 +23,16 @@ export async function syncPull() {
const deleted = data.attendees.filter(a => a.deleted_at).map(a => a.id)
if (deleted.length) await db.attendees.bulkDelete(deleted)
}
if (data.participants?.length) {
await db.participants.bulkPut(data.participants)
const deleted = data.participants.filter(p => p.deleted_at).map(p => p.id)
if (deleted.length) await db.participants.bulkDelete(deleted)
}
if (data.tickets?.length) {
await db.tickets.bulkPut(data.tickets)
const deleted = data.tickets.filter(t => t.deleted_at).map(t => t.id)
if (deleted.length) await db.tickets.bulkDelete(deleted)
}
if (data.departments?.length) {
await db.departments.bulkPut(data.departments)
const deleted = data.departments.filter(d => d.deleted_at).map(d => d.id)
@ -75,6 +85,9 @@ export function startSSE(onEvent) {
if (payload.data?.type === 'attendee' && payload.data?.attendee) {
await db.attendees.put(payload.data.attendee)
}
if (payload.data?.type === 'ticket' && payload.data?.ticket) {
await db.tickets.put(payload.data.ticket)
}
if (payload.data?.type === 'volunteer' && payload.data?.volunteer) {
await db.volunteers.put(payload.data.volunteer)
}