Turnpike/frontend/src/pages/Shifts.svelte
Pen Anderson 1033cdb29b Created Turnpike, event attendee and volunteer management
Built after prototype, Traverse, an attendee and volunteer list
maintainer.
2026-03-03 18:07:38 -06:00

221 lines
7.1 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'
let { session } = $props()
let error = $state('')
let showAdd = $state(false)
let adding = $state(false)
let newDeptID = $state('')
let newName = $state('')
let newDay = $state('')
let newStart = $state('')
let newEnd = $state('')
let newCapacity = $state(0)
const role = $derived(session?.user?.role ?? '')
const canManage = $derived(['admin', 'coordinator', 'volunteer_lead'].includes(role))
const allShifts = liveQuery(() =>
db.shifts.filter(s => !s.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)))
)
// Group shifts by department, then by day
const grouped = $derived.by(() => {
const shifts = $allShifts ?? []
const depts = $allDepts ?? []
const byDept = {}
for (const s of shifts) {
if (!byDept[s.department_id]) byDept[s.department_id] = {}
if (!byDept[s.department_id][s.day]) byDept[s.department_id][s.day] = []
byDept[s.department_id][s.day].push(s)
}
return depts
.filter(d => byDept[d.id])
.map(d => ({
dept: d,
days: Object.entries(byDept[d.id] || {})
.sort(([a], [b]) => a.localeCompare(b))
.map(([day, dayShifts]) => ({
day,
shifts: [...dayShifts].sort((a, b) => a.start_time.localeCompare(b.start_time)),
})),
}))
})
// Shifts not yet in any department group (e.g. orphaned)
const ungrouped = $derived.by(() => {
const shifts = $allShifts ?? []
const deptIDs = new Set(($allDepts ?? []).map(d => d.id))
return shifts.filter(s => !deptIDs.has(s.department_id))
})
async function addShift(e) {
e.preventDefault()
if (!newDeptID) return
adding = true
error = ''
try {
const s = await api.shifts.create({
department_id: parseInt(newDeptID),
name: newName,
day: newDay,
start_time: newStart,
end_time: newEnd,
capacity: parseInt(newCapacity) || 0,
})
await db.shifts.put(s)
showAdd = false
newName = newDay = newStart = newEnd = ''
newDeptID = ''
newCapacity = 0
} catch (err) {
error = err.message
} finally {
adding = false
}
}
async function deleteShift(s) {
if (!confirm(`Delete shift "${s.name}"?`)) return
try {
await api.shifts.delete(s.id)
await db.shifts.delete(s.id)
} catch (err) {
error = err.message
}
}
function formatTime(t) {
if (!t) return ''
// t is HH:MM, format nicely
const [h, m] = t.split(':').map(Number)
const ampm = h >= 12 ? 'pm' : 'am'
return `${h % 12 || 12}:${String(m).padStart(2, '0')}${ampm}`
}
function formatDay(d) {
if (!d) return ''
const dt = new Date(d + 'T00:00:00')
return dt.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' })
}
</script>
<div class="page">
<div class="page-header">
<h1 class="page-title">Shifts</h1>
{#if canManage}
<div class="actions">
<button class="btn btn-primary btn-sm" onclick={() => showAdd = !showAdd}>+ Add shift</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={addShift}>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem">
<div class="form-group">
<label for="s-dept">Department *</label>
<select id="s-dept" bind:value={newDeptID} required>
<option value="">Select department…</option>
{#each $allDepts ?? [] as d}
<option value={String(d.id)}>{d.name}</option>
{/each}
</select>
</div>
<div class="form-group">
<label for="s-name">Shift name *</label>
<input id="s-name" bind:value={newName} required placeholder="e.g. Gate Morning" />
</div>
<div class="form-group">
<label for="s-day">Day *</label>
<input id="s-day" type="date" bind:value={newDay} required />
</div>
<div class="form-group">
<label for="s-cap">Capacity <span class="text-muted">(0 = unlimited)</span></label>
<input id="s-cap" type="number" min="0" bind:value={newCapacity} />
</div>
<div class="form-group">
<label for="s-start">Start time *</label>
<input id="s-start" type="time" bind:value={newStart} required />
</div>
<div class="form-group">
<label for="s-end">End time *</label>
<input id="s-end" type="time" bind:value={newEnd} required />
</div>
</div>
<div class="actions">
<button type="submit" class="btn btn-primary" disabled={adding}>
{adding ? 'Adding…' : 'Add shift'}
</button>
<button type="button" class="btn btn-ghost" onclick={() => showAdd = false}>Cancel</button>
</div>
</form>
</div>
{/if}
{#if ($allShifts ?? []).length === 0}
<div class="empty">
<strong>No shifts yet</strong>
<p>Add shifts to schedule your volunteers.</p>
</div>
{:else}
{#each grouped as { dept, days }}
<div style="margin-bottom:2rem">
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.75rem">
<span class="dept-dot" style="background:{dept.color}"></span>
<strong style="font-size:1rem">{dept.name}</strong>
</div>
{#each days as { day, shifts }}
<div style="margin-bottom:1rem">
<div class="text-muted" style="font-size:0.78rem;text-transform:uppercase;letter-spacing:0.06em;margin-bottom:0.4rem;padding-left:1rem">
{formatDay(day)}
</div>
<div class="table-wrap">
<table>
<tbody>
{#each shifts as s (s.id)}
<tr>
<td><strong>{s.name}</strong></td>
<td class="text-muted">{formatTime(s.start_time)} {formatTime(s.end_time)}</td>
<td class="text-muted">
{#if s.capacity}
Capacity: {s.capacity}
{:else}
Unlimited
{/if}
</td>
{#if canManage}
<td style="text-align:right">
<button class="btn btn-danger btn-sm" onclick={() => deleteShift(s)}>Delete</button>
</td>
{/if}
</tr>
{/each}
</tbody>
</table>
</div>
</div>
{/each}
</div>
{/each}
{#if ungrouped.length > 0}
<div class="text-muted" style="font-size:0.85rem">
{ungrouped.length} shift(s) with unknown departments
</div>
{/if}
{/if}
</div>