Established Participants and Tickets model. Migrated concepts.
This commit is contained in:
parent
0df93e1886
commit
cd8e1e3b3b
22 changed files with 1345 additions and 191 deletions
|
|
@ -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 -->
|
||||
|
|
|
|||
305
frontend/src/pages/Participants.svelte
Normal file
305
frontend/src/pages/Participants.svelte
Normal 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue