Added ticket and participant creation. Revised Gate kiosk.
This commit is contained in:
parent
d30ee18e77
commit
3906b73c61
5 changed files with 231 additions and 117 deletions
|
|
@ -66,6 +66,7 @@ export const api = {
|
||||||
},
|
},
|
||||||
tickets: {
|
tickets: {
|
||||||
list: () => apiJSON('/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' }),
|
checkIn: (id) => apiJSON(`/api/tickets/${id}/checkin`, { method: 'POST' }),
|
||||||
generateCodes: () => apiJSON('/api/tickets/generate-codes', { method: 'POST' }),
|
generateCodes: () => apiJSON('/api/tickets/generate-codes', { method: 'POST' }),
|
||||||
emailCode: (id) => apiJSON(`/api/tickets/${id}/email-code`, { method: 'POST' }),
|
emailCode: (id) => apiJSON(`/api/tickets/${id}/email-code`, { method: 'POST' }),
|
||||||
|
|
|
||||||
|
|
@ -16,10 +16,6 @@
|
||||||
let detector = $state(null)
|
let detector = $state(null)
|
||||||
let scanInterval = $state(null)
|
let scanInterval = $state(null)
|
||||||
|
|
||||||
const attendees = liveQuery(() =>
|
|
||||||
db.attendees.filter(a => !a.deleted_at).toArray()
|
|
||||||
)
|
|
||||||
|
|
||||||
const tickets = liveQuery(() =>
|
const tickets = liveQuery(() =>
|
||||||
db.tickets.filter(t => !t.deleted_at).toArray()
|
db.tickets.filter(t => !t.deleted_at).toArray()
|
||||||
)
|
)
|
||||||
|
|
@ -29,19 +25,8 @@
|
||||||
)
|
)
|
||||||
|
|
||||||
const recentCheckIns = liveQuery(() =>
|
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
|
db.tickets
|
||||||
.filter(t => t.checked_in_at && !t.deleted_at)
|
.filter(t => !!t.checked_in_at && !t.deleted_at)
|
||||||
.toArray()
|
.toArray()
|
||||||
.then(arr => arr
|
.then(arr => arr
|
||||||
.sort((a, b) => b.checked_in_at.localeCompare(a.checked_in_at))
|
.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 matchedTicket = $derived.by(() => {
|
||||||
const s = search.trim()
|
const s = search.trim()
|
||||||
if (!s || s.length < 2) return null
|
if (!s || s.length < 2) return null
|
||||||
const sl = s.toLowerCase()
|
const sl = s.toLowerCase()
|
||||||
// Exact code match (QR scan)
|
|
||||||
const byCode = ($tickets ?? []).find(t => t.code?.toLowerCase() === sl)
|
const byCode = ($tickets ?? []).find(t => t.code?.toLowerCase() === sl)
|
||||||
if (byCode) return byCode
|
if (byCode) return byCode
|
||||||
// Exact external_id match
|
|
||||||
return ($tickets ?? []).find(t => t.external_id?.toLowerCase() === sl) ?? null
|
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()
|
const s = search.trim().toLowerCase()
|
||||||
if (!s || s.length < 2) return []
|
if (!s || s.length < 2) return []
|
||||||
return ($attendees ?? [])
|
return ($participants ?? [])
|
||||||
.filter(a => a.name.toLowerCase().includes(s) || a.ticket_id?.toLowerCase().includes(s) || a.email?.toLowerCase().includes(s))
|
.filter(p =>
|
||||||
.sort((a, b) => a.name.localeCompare(b.name))
|
p.preferred_name?.toLowerCase().includes(s) ||
|
||||||
|
p.email?.toLowerCase().includes(s)
|
||||||
|
)
|
||||||
|
.sort((a, b) => (a.preferred_name || '').localeCompare(b.preferred_name || ''))
|
||||||
.slice(0, 8)
|
.slice(0, 8)
|
||||||
})
|
})
|
||||||
|
|
||||||
const selected = $derived.by(() => {
|
// Auto-select when exactly one participant matches
|
||||||
if (filtered.length === 1) return filtered[0]
|
const selectedParticipant = $derived.by(() => {
|
||||||
const s = search.trim().toLowerCase()
|
if (filteredParticipants.length === 1) return filteredParticipants[0]
|
||||||
return filtered.find(a => a.ticket_id?.toLowerCase() === s) ?? null
|
return null
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function ticketsFor(participantId) {
|
||||||
|
return ($tickets ?? []).filter(t => t.participant_id === participantId && !t.deleted_at)
|
||||||
|
}
|
||||||
|
|
||||||
function participantFor(ticket) {
|
function participantFor(ticket) {
|
||||||
if (!ticket?.participant_id) return null
|
if (!ticket?.participant_id) return null
|
||||||
return ($participants ?? []).find(p => p.id === ticket.participant_id) ?? null
|
return ($participants ?? []).find(p => p.id === ticket.participant_id) ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function nameFor(ticket) {
|
||||||
|
return ticket.name || participantFor(ticket)?.preferred_name || '(unknown)'
|
||||||
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
qrSupported = 'BarcodeDetector' in window
|
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) {
|
function fmt(ts) {
|
||||||
if (!ts) return ''
|
if (!ts) return ''
|
||||||
return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||||
|
|
@ -220,11 +182,11 @@
|
||||||
<div class="gate-msg gate-msg-error">{error}</div>
|
<div class="gate-msg gate-msg-error">{error}</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Matched ticket card (ticket code scan) -->
|
<!-- Exact code/ID match card -->
|
||||||
{#if matchedTicket && !selected}
|
{#if matchedTicket}
|
||||||
{@const p = participantFor(matchedTicket)}
|
{@const p = participantFor(matchedTicket)}
|
||||||
<div class="gate-match">
|
<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}
|
{#if matchedTicket.ticket_type}
|
||||||
<div class="gate-match-sub">{matchedTicket.ticket_type}</div>
|
<div class="gate-match-sub">{matchedTicket.ticket_type}</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -244,76 +206,71 @@
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Matched attendee card -->
|
<!-- Single participant match — show their tickets -->
|
||||||
{#if selected}
|
{:else if selectedParticipant}
|
||||||
{@const rem = remaining(selected)}
|
{@const pts = ticketsFor(selectedParticipant.id)}
|
||||||
{@const prog = progressLabel(selected)}
|
|
||||||
<div class="gate-match">
|
<div class="gate-match">
|
||||||
<div class="gate-match-name">{selected.name}</div>
|
<div class="gate-match-name">{selectedParticipant.preferred_name || selectedParticipant.email || '(unknown)'}</div>
|
||||||
{#if selected.ticket_type}
|
{#if selectedParticipant.email}
|
||||||
<div class="gate-match-sub">{selected.ticket_type}</div>
|
<div class="gate-match-sub text-muted">{selectedParticipant.email}</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>
|
|
||||||
{/if}
|
{/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}
|
{:else}
|
||||||
<span class="gate-done">All checked in</span>
|
<div style="margin-top:0.75rem;display:flex;flex-direction:column;gap:0.4rem">
|
||||||
{/if}
|
{#each pts as tk (tk.id)}
|
||||||
|
<div class="gate-ticket-row">
|
||||||
{#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}>
|
|
||||||
<span>
|
<span>
|
||||||
<strong>{a.name}</strong>
|
<strong>{tk.name || '(unnamed)'}</strong>
|
||||||
{#if a.ticket_type} · {a.ticket_type}{/if}
|
{#if tk.ticket_type}<span class="text-muted"> · {tk.ticket_type}</span>{/if}
|
||||||
</span>
|
</span>
|
||||||
<span class="badge {a.checked_in ? 'badge-checked' : 'badge-unchecked'}">
|
{#if tk.checked_in_at}
|
||||||
{a.checked_in ? 'In' : 'Pending'}
|
<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>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</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}
|
{/if}
|
||||||
|
|
||||||
<!-- Recent check-ins -->
|
<!-- Recent check-ins -->
|
||||||
<div class="gate-recent">
|
<div class="gate-recent">
|
||||||
<div class="gate-recent-title">Recent Check-ins</div>
|
<div class="gate-recent-title">Recent Check-ins</div>
|
||||||
{#if ($recentCheckIns ?? []).length === 0}
|
{#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}
|
{:else}
|
||||||
{#each $recentCheckIns ?? [] as a}
|
{#each $recentCheckIns ?? [] as tk}
|
||||||
<div class="gate-recent-row">
|
<div class="gate-recent-row">
|
||||||
<span>{a.name}</span>
|
<span>{nameFor(tk)}</span>
|
||||||
<span class="text-muted">{fmt(a.checked_in_at)}</span>
|
<span class="text-muted">{fmt(tk.checked_in_at)}</span>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -458,6 +415,16 @@
|
||||||
align-items: center;
|
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 {
|
.gate-results {
|
||||||
background: var(--c-surface);
|
background: var(--c-surface);
|
||||||
border: 1px solid var(--c-border);
|
border: 1px solid var(--c-border);
|
||||||
|
|
|
||||||
|
|
@ -11,10 +11,25 @@
|
||||||
let generating = $state(false)
|
let generating = $state(false)
|
||||||
let emailing = $state(false)
|
let emailing = $state(false)
|
||||||
let mergeMode = $state(false)
|
let mergeMode = $state(false)
|
||||||
let mergeSource = $state(null) // participant being merged away
|
let mergeSource = $state(null)
|
||||||
let mergeTarget = $state(null) // participant to keep
|
let mergeTarget = $state(null)
|
||||||
let expandedId = $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 role = $derived(session?.user?.role ?? '')
|
||||||
const canManage = $derived(['admin', 'ticketing'].includes(role))
|
const canManage = $derived(['admin', 'ticketing'].includes(role))
|
||||||
|
|
@ -114,6 +129,45 @@
|
||||||
if (!ts) return ''
|
if (!ts) return ''
|
||||||
return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="page">
|
<div class="page">
|
||||||
|
|
@ -121,6 +175,7 @@
|
||||||
<h1 class="page-title">Participants</h1>
|
<h1 class="page-title">Participants</h1>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
{#if canManage}
|
{#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>
|
<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}>
|
<button class="btn btn-ghost btn-sm" onclick={generateCodes} disabled={generating}>
|
||||||
{generating ? '…' : '⚿ Generate Codes'}
|
{generating ? '…' : '⚿ Generate Codes'}
|
||||||
|
|
@ -133,6 +188,41 @@
|
||||||
</div>
|
</div>
|
||||||
</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}
|
{#if mergeMode && mergeSource}
|
||||||
<div class="card" style="margin-bottom:1.5rem;border-color:var(--c-accent)">
|
<div class="card" style="margin-bottom:1.5rem;border-color:var(--c-accent)">
|
||||||
<div style="margin-bottom:0.75rem">
|
<div style="margin-bottom:0.75rem">
|
||||||
|
|
@ -236,7 +326,7 @@
|
||||||
</td>
|
</td>
|
||||||
{/if}
|
{/if}
|
||||||
</tr>
|
</tr>
|
||||||
{#if isExpanded && pts.length > 0}
|
{#if isExpanded}
|
||||||
<tr class="ticket-rows">
|
<tr class="ticket-rows">
|
||||||
<td colspan="5">
|
<td colspan="5">
|
||||||
<div class="ticket-list">
|
<div class="ticket-list">
|
||||||
|
|
@ -270,6 +360,24 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/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>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -294,6 +402,21 @@
|
||||||
background: var(--c-surface);
|
background: var(--c-surface);
|
||||||
font-size: 0.875rem;
|
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 {
|
.badge-partial {
|
||||||
background: rgba(245,158,11,0.15);
|
background: rgba(245,158,11,0.15);
|
||||||
color: var(--c-warn);
|
color: var(--c-warn);
|
||||||
|
|
|
||||||
|
|
@ -131,6 +131,28 @@ func (app *App) handleExportParticipants(w http.ResponseWriter, r *http.Request)
|
||||||
wr.Flush()
|
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) {
|
func (app *App) handleListTickets(w http.ResponseWriter, r *http.Request) {
|
||||||
tickets, err := app.listTickets(nil, "")
|
tickets, err := app.listTickets(nil, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
1
main.go
1
main.go
|
|
@ -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("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("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/{id}/checkin", auth(app.handleCheckInTicket, "admin", "ticketing", "gatekeeper"))
|
||||||
mux.HandleFunc("POST /api/tickets/generate-codes", auth(app.handleGenerateTokens, "admin", "ticketing"))
|
mux.HandleFunc("POST /api/tickets/generate-codes", auth(app.handleGenerateTokens, "admin", "ticketing"))
|
||||||
mux.HandleFunc("GET /api/tickets/export-links", auth(app.handleExportTokenLinks, "admin", "ticketing"))
|
mux.HandleFunc("GET /api/tickets/export-links", auth(app.handleExportTokenLinks, "admin", "ticketing"))
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue