Created Turnpike, event attendee and volunteer management

Built after prototype, Traverse, an attendee and volunteer list
maintainer.
This commit is contained in:
Pen Anderson 2026-03-03 11:27:07 -06:00
commit 1033cdb29b
59 changed files with 8663 additions and 0 deletions

View file

@ -0,0 +1,266 @@
<script>
import { liveQuery } from 'dexie'
import { db } from '../db.js'
import { api } from '../api.js'
let { session } = $props()
let users = $state([])
let loadError = $state('')
let error = $state('')
let loading = $state(true)
let showAdd = $state(false)
let adding = $state(false)
let newUsername = $state('')
let newPassword = $state('')
let newRole = $state('gate')
let newDeptIDs = $state([])
let editID = $state(null)
let editRole = $state('')
let editDeptIDs = $state([])
let editPassword = $state('')
let saving = $state(false)
const allDepts = liveQuery(() =>
db.departments.filter(d => !d.deleted_at).toArray()
.then(arr => arr.sort((a, b) => a.name.localeCompare(b.name)))
)
const roles = ['admin', 'coordinator', 'ticketing', 'gate', 'volunteer_lead']
const me = $derived(session?.user?.id)
async function loadUsers() {
loading = true
try {
users = await api.users.list()
} catch (err) {
loadError = err.message
} finally {
loading = false
}
}
$effect(() => { loadUsers() })
async function addUser(e) {
e.preventDefault()
adding = true
error = ''
try {
const u = await api.users.create({
username: newUsername,
password: newPassword,
role: newRole,
department_ids: newDeptIDs,
})
users = [...users, u]
showAdd = false
newUsername = newPassword = ''
newRole = 'gate'
newDeptIDs = []
} catch (err) {
error = err.message
} finally {
adding = false
}
}
function startEdit(u) {
editID = u.id
editRole = u.role
editDeptIDs = [...(u.department_ids || [])]
editPassword = ''
}
function cancelEdit() {
editID = null
}
async function saveUser(u) {
saving = true
error = ''
try {
const payload = { role: editRole, department_ids: editDeptIDs }
if (editPassword) payload.password = editPassword
const updated = await api.users.update(u.id, payload)
users = users.map(x => x.id === u.id ? updated : x)
editID = null
} catch (err) {
error = err.message
} finally {
saving = false
}
}
async function deleteUser(u) {
if (!confirm(`Delete user "${u.username}"? This cannot be undone.`)) return
try {
await api.users.delete(u.id)
users = users.filter(x => x.id !== u.id)
} catch (err) {
error = err.message
}
}
function toggleDept(id, list) {
const idx = list.indexOf(id)
if (idx === -1) return [...list, id]
return list.filter(x => x !== id)
}
function deptNamesFor(ids) {
const depts = $allDepts ?? []
return ids.map(id => depts.find(d => d.id === id)?.name).filter(Boolean).join(', ') || '—'
}
function roleLabel(r) {
return { admin: 'Admin', coordinator: 'Coordinator', ticketing: 'Ticketing', gate: 'Gate', volunteer_lead: 'Vol. Lead' }[r] || r
}
</script>
<div class="page">
<div class="page-header">
<h1 class="page-title">Users</h1>
<div class="actions">
<button class="btn btn-primary btn-sm" onclick={() => showAdd = !showAdd}>+ Add user</button>
</div>
</div>
{#if loadError}
<div class="alert alert-error">{loadError}</div>
{/if}
{#if error}
<div class="alert alert-error">{error}</div>
{/if}
{#if showAdd}
<div class="card" style="margin-bottom:1.5rem">
<form onsubmit={addUser}>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:1rem">
<div class="form-group">
<label for="u-username">Username *</label>
<input id="u-username" bind:value={newUsername} required placeholder="username" autocomplete="off" />
</div>
<div class="form-group">
<label for="u-password">Password *</label>
<input id="u-password" type="password" bind:value={newPassword} required autocomplete="new-password" />
</div>
<div class="form-group">
<label for="u-role">Role *</label>
<select id="u-role" bind:value={newRole}>
{#each roles as r}
<option value={r}>{roleLabel(r)}</option>
{/each}
</select>
</div>
</div>
{#if ($allDepts ?? []).length > 0}
<div class="form-group">
<span style="font-size:0.82rem;color:var(--c-muted);font-weight:500">Departments</span>
<div style="display:flex;flex-wrap:wrap;gap:0.5rem;margin-top:0.35rem">
{#each $allDepts ?? [] as d}
<label style="display:flex;align-items:center;gap:0.35rem;cursor:pointer;font-size:0.875rem;color:var(--c-text)">
<input type="checkbox" style="width:auto"
checked={newDeptIDs.includes(d.id)}
onchange={() => newDeptIDs = toggleDept(d.id, newDeptIDs)} />
<span class="dept-dot" style="background:{d.color}"></span>
{d.name}
</label>
{/each}
</div>
</div>
{/if}
<div class="actions">
<button type="submit" class="btn btn-primary" disabled={adding}>
{adding ? 'Adding…' : 'Add user'}
</button>
<button type="button" class="btn btn-ghost" onclick={() => showAdd = false}>Cancel</button>
</div>
</form>
</div>
{/if}
{#if loading}
<div class="text-muted" style="padding:2rem 0">Loading…</div>
{:else if users.length === 0}
<div class="empty">
<strong>No users yet</strong>
</div>
{:else}
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Username</th>
<th>Role</th>
<th>Departments</th>
<th></th>
</tr>
</thead>
<tbody>
{#each users as u (u.id)}
{#if editID === u.id}
<tr>
<td><strong>{u.username}</strong> {#if u.id === me}<span class="badge badge-role">you</span>{/if}</td>
<td>
<select bind:value={editRole} style="width:auto;margin:0">
{#each roles as r}
<option value={r}>{roleLabel(r)}</option>
{/each}
</select>
</td>
<td>
{#if ($allDepts ?? []).length > 0}
<div style="display:flex;flex-wrap:wrap;gap:0.4rem">
{#each $allDepts ?? [] as d}
<label style="display:flex;align-items:center;gap:0.3rem;cursor:pointer;font-size:0.82rem;color:var(--c-text)">
<input type="checkbox" style="width:auto"
checked={editDeptIDs.includes(d.id)}
onchange={() => editDeptIDs = toggleDept(d.id, editDeptIDs)} />
{d.name}
</label>
{/each}
</div>
{/if}
<input type="password" bind:value={editPassword}
placeholder="New password (leave blank to keep)"
style="margin-top:0.5rem" autocomplete="new-password" />
</td>
<td>
<div class="actions">
<button class="btn btn-primary btn-sm" onclick={() => saveUser(u)} disabled={saving}>
{saving ? '…' : 'Save'}
</button>
<button class="btn btn-ghost btn-sm" onclick={cancelEdit}>Cancel</button>
</div>
</td>
</tr>
{:else}
<tr>
<td>
<strong>{u.username}</strong>
{#if u.id === me}
<span class="badge badge-role" style="margin-left:0.4rem">you</span>
{/if}
</td>
<td><span class="badge badge-role">{roleLabel(u.role)}</span></td>
<td class="text-muted">{deptNamesFor(u.department_ids || [])}</td>
<td>
<div class="actions">
<button class="btn btn-ghost btn-sm" onclick={() => startEdit(u)}>Edit</button>
{#if u.id !== me}
<button class="btn btn-danger btn-sm" onclick={() => deleteUser(u)}>Delete</button>
{/if}
</div>
</td>
</tr>
{/if}
{/each}
</tbody>
</table>
</div>
{/if}
</div>