Turnpike/frontend/src/pages/Volunteers.svelte

268 lines
8.6 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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 filterChecked = $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('')
const role = $derived(session?.user?.role ?? '')
const canManage = $derived(['admin', 'ticketing', '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 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 (filterChecked === 'true' && !v.checked_in) return false
if (filterChecked === 'false' && 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 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 toggleLead(v) {
try {
const updated = await api.volunteers.update(v.id, { ...v, is_lead: !v.is_lead })
await db.volunteers.put(updated)
} catch (err) {
error = err.message
}
}
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 deptFor(id) {
return ($allDepts ?? []).find(d => d.id === id)
}
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 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={filterChecked} style="width:auto">
<option value="">All</option>
<option value="false">Not checked in</option>
<option value="true">Checked in</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)}
{@const participant = participantFor(v.participant_id)}
<tr>
<td>
<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 record">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="text-muted">
{#if dept}
<span class="dept-dot" style="background:{dept.color};margin-right:0.4rem"></span>{dept.name}
{:else}
{/if}
</td>
<td>
<span class="badge {v.checked_in ? 'badge-checked' : 'badge-unchecked'}">
{v.checked_in ? 'Checked in' : 'Pending'}
</span>
{#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>
{#if !v.checked_in}
<CheckInButton onclick={() => checkIn(v)} />
{/if}
</td>
{#if canManage}
<td>
<button class="btn btn-ghost btn-sm" onclick={() => toggleLead(v)}
title={v.is_lead ? 'Remove co-lead' : 'Mark as co-lead'}>
{v.is_lead ? ' Co-Lead' : '+ Co-Lead'}
</button>
<button class="btn btn-danger btn-sm" onclick={() => deleteVolunteer(v)}>Delete</button>
</td>
{/if}
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>