2026-03-04 10:53:42 -06:00
|
|
|
<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)
|
2026-03-04 14:19:51 -06:00
|
|
|
let mergeSource = $state(null)
|
|
|
|
|
let mergeTarget = $state(null)
|
2026-03-04 10:53:42 -06:00
|
|
|
let expandedId = $state(null)
|
2026-03-04 14:19:51 -06:00
|
|
|
|
|
|
|
|
// 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('')
|
|
|
|
|
|
2026-03-04 14:31:52 -06:00
|
|
|
// Edit participant
|
|
|
|
|
let editId = $state(null)
|
|
|
|
|
let editName = $state('')
|
|
|
|
|
let editEmail = $state('')
|
|
|
|
|
let editPhone = $state('')
|
|
|
|
|
let editPronouns = $state('')
|
|
|
|
|
let editNote = $state('')
|
|
|
|
|
let saving = $state(false)
|
|
|
|
|
|
2026-03-04 14:19:51 -06:00
|
|
|
// Add ticket form (per participant)
|
|
|
|
|
let addTicketFor = $state(null) // participant id
|
|
|
|
|
let addingTicket = $state(false)
|
|
|
|
|
let newTicketName = $state('')
|
|
|
|
|
let newTicketType = $state('')
|
|
|
|
|
let newTicketExtId = $state('')
|
2026-03-04 10:53:42 -06:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-04 14:31:52 -06:00
|
|
|
function toggleExpand(id) {
|
|
|
|
|
expandedId = expandedId === id ? null : id
|
2026-03-04 10:53:42 -06:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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' })
|
|
|
|
|
}
|
2026-03-04 14:19:51 -06:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-04 14:31:52 -06:00
|
|
|
function startEdit(p) {
|
|
|
|
|
editId = p.id
|
|
|
|
|
editName = p.preferred_name
|
|
|
|
|
editEmail = p.email
|
|
|
|
|
editPhone = p.phone
|
|
|
|
|
editPronouns = p.pronouns
|
|
|
|
|
editNote = p.note
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function saveEdit(e) {
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
saving = true; error = ''
|
|
|
|
|
try {
|
|
|
|
|
const p = await api.participants.update(editId, {
|
|
|
|
|
preferred_name: editName, email: editEmail, phone: editPhone,
|
|
|
|
|
pronouns: editPronouns, note: editNote,
|
|
|
|
|
})
|
|
|
|
|
await db.participants.put(p)
|
|
|
|
|
editId = null
|
|
|
|
|
} catch (err) {
|
|
|
|
|
error = err.message
|
|
|
|
|
} finally {
|
|
|
|
|
saving = false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-04 14:19:51 -06:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-04 10:53:42 -06:00
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
<div class="page">
|
|
|
|
|
<div class="page-header">
|
|
|
|
|
<h1 class="page-title">Participants</h1>
|
|
|
|
|
<div class="actions">
|
|
|
|
|
{#if canManage}
|
2026-03-04 14:19:51 -06:00
|
|
|
<button class="btn btn-primary btn-sm" onclick={() => showAdd = !showAdd}>+ Add</button>
|
2026-03-04 10:53:42 -06:00
|
|
|
<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>
|
|
|
|
|
|
2026-03-04 14:19:51 -06:00
|
|
|
{#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}
|
|
|
|
|
|
2026-03-04 10:53:42 -06:00
|
|
|
{#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}
|
2026-03-04 14:31:52 -06:00
|
|
|
{@const isEditing = editId === p.id}
|
|
|
|
|
{#if isEditing}
|
|
|
|
|
<tr class="edit-row">
|
|
|
|
|
<td colspan={canManage ? 5 : 4}>
|
|
|
|
|
<form class="participant-edit-form" onsubmit={saveEdit}>
|
|
|
|
|
<div class="edit-fields">
|
|
|
|
|
<input bind:value={editName} placeholder="Preferred name" />
|
|
|
|
|
<input type="email" bind:value={editEmail} placeholder="Email" />
|
|
|
|
|
<input bind:value={editPhone} placeholder="Phone" />
|
|
|
|
|
<input bind:value={editPronouns} placeholder="Pronouns" />
|
|
|
|
|
<input bind:value={editNote} placeholder="Note" style="flex:2" />
|
|
|
|
|
</div>
|
|
|
|
|
<div class="actions" style="margin-top:0.5rem">
|
|
|
|
|
<button type="submit" class="btn btn-primary btn-sm" disabled={saving}>{saving ? 'Saving…' : 'Save'}</button>
|
|
|
|
|
<button type="button" class="btn btn-ghost btn-sm" onclick={() => editId = null}>Cancel</button>
|
|
|
|
|
</div>
|
|
|
|
|
</form>
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
{:else}
|
2026-03-04 10:53:42 -06:00
|
|
|
<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>
|
2026-03-04 14:31:52 -06:00
|
|
|
<td class="text-muted">
|
|
|
|
|
{p.email || '—'}
|
|
|
|
|
{#if p.phone}
|
|
|
|
|
<div style="font-size:0.78rem">{p.phone}</div>
|
|
|
|
|
{/if}
|
|
|
|
|
</td>
|
2026-03-04 10:53:42 -06:00
|
|
|
<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}
|
2026-03-04 14:31:52 -06:00
|
|
|
<button class="btn btn-ghost btn-sm" onclick={(e) => { e.stopPropagation(); startEdit(p) }}
|
|
|
|
|
title="Edit participant">✎</button>
|
2026-03-04 10:53:42 -06:00
|
|
|
<button class="btn btn-ghost btn-sm" onclick={(e) => { e.stopPropagation(); startMerge(p) }}
|
|
|
|
|
title="Merge this participant into another">⇄</button>
|
|
|
|
|
{/if}
|
|
|
|
|
</td>
|
|
|
|
|
{/if}
|
|
|
|
|
</tr>
|
2026-03-04 14:31:52 -06:00
|
|
|
{/if}
|
|
|
|
|
{#if isExpanded && !isEditing}
|
2026-03-04 10:53:42 -06:00
|
|
|
<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}
|
2026-03-04 14:19:51 -06:00
|
|
|
{#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}
|
2026-03-04 10:53:42 -06:00
|
|
|
</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;
|
|
|
|
|
}
|
2026-03-04 14:19:51 -06:00
|
|
|
.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;
|
|
|
|
|
}
|
2026-03-04 14:31:52 -06:00
|
|
|
.edit-row td { padding: 0.5rem 1rem; background: var(--c-bg); }
|
|
|
|
|
.participant-edit-form { display: flex; flex-direction: column; gap: 0.25rem; }
|
|
|
|
|
.edit-fields { display: flex; gap: 0.4rem; flex-wrap: wrap; }
|
|
|
|
|
.edit-fields input { flex: 1; min-width: 120px; font-size: 0.825rem; padding: 0.3rem 0.5rem; width: auto; }
|
|
|
|
|
|
2026-03-04 10:53:42 -06:00
|
|
|
</style>
|