Created Turnpike, event attendee and volunteer management
Built after prototype, Traverse, an attendee and volunteer list maintainer.
This commit is contained in:
commit
1033cdb29b
59 changed files with 8663 additions and 0 deletions
266
frontend/src/pages/Users.svelte
Normal file
266
frontend/src/pages/Users.svelte
Normal 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue