Turnpike/frontend/src/pages/Volunteers.svelte

269 lines
8.6 KiB
Svelte
Raw Normal View History

<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
}
}
2026-03-04 22:25:31 -06:00
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}
2026-03-04 22:32:10 -06:00
<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>
2026-03-04 22:25:31 -06:00
<button class="btn btn-ghost btn-sm" onclick={() => toggleLead(v)}
2026-03-04 22:32:10 -06:00
title={v.is_lead ? 'Remove co-lead' : 'Mark as co-lead'}>
{v.is_lead ? ' Co-Lead' : '+ Co-Lead'}
2026-03-04 22:25:31 -06:00
</button>
<button class="btn btn-danger btn-sm" onclick={() => deleteVolunteer(v)}>Delete</button>
</td>
{/if}
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>