365 lines
12 KiB
Svelte
365 lines
12 KiB
Svelte
<script>
|
|
import { liveQuery } from 'dexie'
|
|
import { db } from '../db.js'
|
|
import { api } from '../api.js'
|
|
import CheckInButton from '../components/CheckInButton.svelte'
|
|
|
|
let { session } = $props()
|
|
|
|
let search = $state('')
|
|
let filterDept = $state('')
|
|
let filterStatus = $state('')
|
|
let error = $state('')
|
|
let showAdd = $state(false)
|
|
let adding = $state(false)
|
|
let newName = $state('')
|
|
let newEmail = $state('')
|
|
let newDeptID = $state('')
|
|
let newIsLead = $state(false)
|
|
let newNote = $state('')
|
|
|
|
let editID = $state(null)
|
|
let editDeptID = $state('')
|
|
let editIsLead = $state(false)
|
|
let editNote = $state('')
|
|
let saving = $state(false)
|
|
|
|
const role = $derived(session?.user?.role ?? '')
|
|
const canManage = $derived(['admin', 'ticketing', 'staffing', 'colead'].includes(role))
|
|
const canConfirm = $derived(['admin', 'staffing', 'colead'].includes(role))
|
|
const myDeptIDs = $derived(session?.user?.department_ids ?? [])
|
|
|
|
// Auto-filter coleads to their department on mount
|
|
$effect(() => {
|
|
if (role === 'colead' && myDeptIDs.length > 0 && !filterDept) {
|
|
filterDept = String(myDeptIDs[0])
|
|
}
|
|
})
|
|
|
|
const allVolunteers = liveQuery(() =>
|
|
db.volunteers.filter(v => !v.deleted_at).toArray()
|
|
)
|
|
const allParticipants = liveQuery(() => db.participants.toArray())
|
|
const allTickets = liveQuery(() => db.tickets.filter(t => !t.deleted_at).toArray())
|
|
const allDepts = liveQuery(() =>
|
|
db.departments.filter(d => !d.deleted_at).toArray()
|
|
.then(arr => arr.sort((a, b) => a.name.localeCompare(b.name)))
|
|
)
|
|
|
|
const filtered = $derived.by(() => {
|
|
const list = $allVolunteers ?? []
|
|
const s = search.toLowerCase()
|
|
return list
|
|
.filter(v => {
|
|
if (filterDept && v.department_id !== parseInt(filterDept)) return false
|
|
if (filterStatus === 'unconfirmed' && v.email_confirmed) return false
|
|
if (filterStatus === 'registered' && (!v.email_confirmed || v.confirmed)) return false
|
|
if (filterStatus === 'confirmed' && (!v.confirmed || v.checked_in)) return false
|
|
if (filterStatus === 'ready' && !v.checked_in) return false
|
|
if (s && !v.name.toLowerCase().includes(s) &&
|
|
!(v.email || '').toLowerCase().includes(s)) return false
|
|
return true
|
|
})
|
|
.sort((a, b) => a.name.localeCompare(b.name))
|
|
})
|
|
|
|
async function checkIn(v) {
|
|
try {
|
|
const updated = await api.volunteers.checkIn(v.id)
|
|
await db.volunteers.put(updated)
|
|
} catch (err) {
|
|
error = err.message
|
|
}
|
|
}
|
|
|
|
async function confirmVolunteer(v) {
|
|
try {
|
|
const updated = await api.volunteers.confirm(v.id)
|
|
await db.volunteers.put(updated)
|
|
} catch (err) {
|
|
error = err.message
|
|
}
|
|
}
|
|
|
|
async function addVolunteer(e) {
|
|
e.preventDefault()
|
|
adding = true
|
|
error = ''
|
|
try {
|
|
const data = {
|
|
name: newName,
|
|
email: newEmail,
|
|
is_lead: newIsLead,
|
|
note: newNote,
|
|
}
|
|
if (newDeptID) data.department_id = parseInt(newDeptID)
|
|
const v = await api.volunteers.create(data)
|
|
await db.volunteers.put(v)
|
|
showAdd = false
|
|
newName = newEmail = newNote = ''
|
|
newDeptID = ''
|
|
newIsLead = false
|
|
} catch (err) {
|
|
error = err.message
|
|
} finally {
|
|
adding = false
|
|
}
|
|
}
|
|
|
|
async function deleteVolunteer(v) {
|
|
if (!confirm(`Delete volunteer "${v.name}"?`)) return
|
|
try {
|
|
await api.volunteers.delete(v.id)
|
|
await db.volunteers.delete(v.id)
|
|
} catch (err) {
|
|
error = err.message
|
|
}
|
|
}
|
|
|
|
function startEdit(v) {
|
|
editID = v.id
|
|
editDeptID = v.department_id ? String(v.department_id) : ''
|
|
editIsLead = v.is_lead
|
|
editNote = v.note ?? ''
|
|
}
|
|
|
|
function cancelEdit() {
|
|
editID = null
|
|
}
|
|
|
|
async function saveVolunteer(v) {
|
|
saving = true
|
|
error = ''
|
|
try {
|
|
const updated = await api.volunteers.update(v.id, {
|
|
...v,
|
|
department_id: editDeptID ? parseInt(editDeptID) : null,
|
|
is_lead: editIsLead,
|
|
note: editNote,
|
|
})
|
|
await db.volunteers.put(updated)
|
|
editID = null
|
|
} catch (err) {
|
|
error = err.message
|
|
} finally {
|
|
saving = false
|
|
}
|
|
}
|
|
|
|
function deptFor(id) {
|
|
return ($allDepts ?? []).find(d => d.id === id)
|
|
}
|
|
|
|
function participantHasTickets(participantId) {
|
|
if (!participantId) return false
|
|
return ($allTickets ?? []).some(t => t.participant_id === participantId)
|
|
}
|
|
|
|
function participantFor(id) {
|
|
return ($allParticipants ?? []).find(p => p.id === id) ?? null
|
|
}
|
|
</script>
|
|
|
|
<div class="page">
|
|
<div class="page-header">
|
|
<h1 class="page-title">Volunteers</h1>
|
|
{#if canManage}
|
|
<div class="actions">
|
|
<button class="btn btn-primary btn-sm" onclick={() => showAdd = !showAdd}>+ Add</button>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
{#if error}
|
|
<div class="alert alert-error">{error}</div>
|
|
{/if}
|
|
|
|
{#if showAdd && canManage}
|
|
<div class="card" style="margin-bottom:1.5rem">
|
|
<form onsubmit={addVolunteer}>
|
|
<div class="form-grid" style="display:grid;grid-template-columns:1fr 1fr;gap:1rem">
|
|
<div class="form-group">
|
|
<label for="v-name">Name *</label>
|
|
<input id="v-name" bind:value={newName} required placeholder="Full name" />
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="v-email">Email</label>
|
|
<input id="v-email" type="email" bind:value={newEmail} placeholder="email@example.com" />
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="v-dept">Department</label>
|
|
<select id="v-dept" bind:value={newDeptID}>
|
|
<option value="">No department</option>
|
|
{#each $allDepts ?? [] as d}
|
|
<option value={String(d.id)}>{d.name}</option>
|
|
{/each}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="v-note">Note</label>
|
|
<input id="v-note" bind:value={newNote} placeholder="Optional note" />
|
|
</div>
|
|
<div style="margin-bottom:1rem">
|
|
<label style="display:flex;align-items:center;gap:0.5rem;cursor:pointer">
|
|
<input type="checkbox" style="width:auto" bind:checked={newIsLead} />
|
|
Department lead
|
|
</label>
|
|
</div>
|
|
<div class="actions">
|
|
<button type="submit" class="btn btn-primary" disabled={adding}>
|
|
{adding ? 'Adding…' : 'Add volunteer'}
|
|
</button>
|
|
<button type="button" class="btn btn-ghost" onclick={() => showAdd = false}>Cancel</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
{/if}
|
|
|
|
<div class="search-bar">
|
|
<input placeholder="Search name, email…" bind:value={search} />
|
|
{#if ($allDepts ?? []).length > 0}
|
|
<select bind:value={filterDept} style="width:auto">
|
|
<option value="">All departments</option>
|
|
{#each $allDepts ?? [] as d}
|
|
<option value={String(d.id)}>{d.name}</option>
|
|
{/each}
|
|
</select>
|
|
{/if}
|
|
<select bind:value={filterStatus} style="width:auto">
|
|
<option value="">All statuses</option>
|
|
<option value="unconfirmed">Unconfirmed</option>
|
|
<option value="registered">Registered</option>
|
|
<option value="confirmed">Confirmed</option>
|
|
<option value="ready">Ready</option>
|
|
</select>
|
|
<span class="text-muted" style="font-size:0.85rem;white-space:nowrap">
|
|
{filtered.length} shown
|
|
</span>
|
|
</div>
|
|
|
|
{#if ($allVolunteers ?? []).length === 0}
|
|
<div class="empty">
|
|
<strong>No volunteers yet</strong>
|
|
<p>Add volunteers manually above, or enable public signup in Settings.</p>
|
|
</div>
|
|
{:else}
|
|
<div class="table-wrap">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Name</th>
|
|
<th>Department</th>
|
|
<th>Status</th>
|
|
<th></th>
|
|
{#if canManage}<th></th>{/if}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{#each filtered as v (v.id)}
|
|
{@const dept = deptFor(v.department_id)}
|
|
{#if editID === v.id}
|
|
<tr class="edit-row">
|
|
<td class="td-name" style="width:100%">
|
|
<strong>{v.name}</strong>
|
|
{#if v.email}<div class="text-muted" style="font-size:0.78rem">{v.email}</div>{/if}
|
|
</td>
|
|
<td class="td-edit-dept">
|
|
<select bind:value={editDeptID} style="margin:0">
|
|
<option value="">No department</option>
|
|
{#each $allDepts ?? [] as d}
|
|
<option value={String(d.id)}>{d.name}</option>
|
|
{/each}
|
|
</select>
|
|
</td>
|
|
<td class="td-edit-checks">
|
|
<label style="display:flex;align-items:center;gap:0.4rem;cursor:pointer;white-space:nowrap">
|
|
<input type="checkbox" style="width:auto" bind:checked={editIsLead} /> Co-Lead
|
|
</label>
|
|
</td>
|
|
<td class="td-edit-note">
|
|
<input bind:value={editNote} placeholder="Note" style="margin:0" />
|
|
</td>
|
|
<td class="td-actions">
|
|
<button class="btn btn-primary btn-sm" onclick={() => saveVolunteer(v)} disabled={saving}>
|
|
{saving ? '…' : 'Save'}
|
|
</button>
|
|
<button class="btn btn-ghost btn-sm" onclick={cancelEdit}>Cancel</button>
|
|
</td>
|
|
</tr>
|
|
{:else}
|
|
<tr>
|
|
<td class="td-name">
|
|
<strong>{v.name}</strong>
|
|
{#if v.is_lead}
|
|
<span class="badge badge-lead" style="margin-left:0.4rem">Co-Lead</span>
|
|
{/if}
|
|
{#if !v.participant_id}
|
|
<span class="badge badge-unchecked" style="margin-left:0.4rem" title="Not linked to a participant">No ticket</span>
|
|
{:else if !participantHasTickets(v.participant_id)}
|
|
<span class="badge badge-partial" style="margin-left:0.4rem" title="No ticket on file">No ticket</span>
|
|
{/if}
|
|
{#if v.email}
|
|
<div class="text-muted" style="font-size:0.78rem">{v.email}</div>
|
|
{/if}
|
|
{#if v.note}
|
|
<div class="text-muted" style="font-size:0.78rem">{v.note}</div>
|
|
{/if}
|
|
</td>
|
|
<td class="td-dept text-muted">
|
|
{#if dept}
|
|
<span class="dept-dot" style="background:{dept.color};margin-right:0.4rem"></span>{dept.name}
|
|
{:else}
|
|
—
|
|
{/if}
|
|
</td>
|
|
<td class="td-status">
|
|
{#if v.checked_in}
|
|
<span class="badge badge-checked">Ready</span>
|
|
{:else if v.confirmed}
|
|
<span class="badge badge-confirmed">Confirmed</span>
|
|
{:else if v.email_confirmed}
|
|
<span class="badge badge-registered">Registered</span>
|
|
{:else}
|
|
<span class="badge badge-unchecked">Unconfirmed</span>
|
|
{/if}
|
|
{#if v.checked_in_at}
|
|
<div class="text-muted" style="font-size:0.75rem">
|
|
{new Date(v.checked_in_at).toLocaleTimeString()}
|
|
</div>
|
|
{/if}
|
|
</td>
|
|
<td class="td-ready">
|
|
{#if !v.checked_in}
|
|
<CheckInButton onclick={() => checkIn(v)} />
|
|
{/if}
|
|
</td>
|
|
{#if canManage}
|
|
<td class="td-actions">
|
|
{#if canConfirm && v.email_confirmed && !v.confirmed}
|
|
<button class="btn btn-primary btn-sm" onclick={() => confirmVolunteer(v)}>Confirm</button>
|
|
{/if}
|
|
<button class="btn btn-ghost btn-sm" onclick={() => startEdit(v)}>Edit</button>
|
|
<button class="btn btn-danger btn-sm" onclick={() => deleteVolunteer(v)}>Delete</button>
|
|
</td>
|
|
{/if}
|
|
</tr>
|
|
{/if}
|
|
{/each}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<style>
|
|
@media (max-width: 640px) {
|
|
.td-name { flex: 1; min-width: 0; order: 1; }
|
|
.td-ready { flex-shrink: 0; align-self: flex-start; order: 2; }
|
|
.td-dept { width: 100%; order: 3; }
|
|
.td-status { width: 100%; order: 4; }
|
|
.td-actions { width: 100%; order: 5; display: flex; justify-content: flex-end; }
|
|
.edit-row td { width: 100%; }
|
|
.td-edit-dept, .td-edit-checks, .td-edit-note { width: 100%; }
|
|
}
|
|
</style>
|