Turnpike/frontend/src/pages/ScheduleBoard.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

452 lines
14 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 editShiftID = $state(null)
let editShift = $state({})
let saving = $state(false)
// For volunteer assignment dropdown
let assigningShiftID = $state(null)
let assignVolID = $state(0)
let assigning = $state(false)
const role = $derived(session?.user?.role ?? '')
const myDeptIDs = $derived(session?.user?.department_ids ?? [])
const allDepts = liveQuery(() =>
db.departments.filter(d => !d.deleted_at).toArray()
.then(arr => arr.sort((a, b) => a.name.localeCompare(b.name)))
)
const allShifts = liveQuery(() =>
db.shifts.filter(s => !s.deleted_at).toArray()
.then(arr => arr.sort((a, b) =>
(a.day === b.day ? (a.position - b.position || a.start_time.localeCompare(b.start_time))
: a.day.localeCompare(b.day))
))
)
const allVolunteers = liveQuery(() =>
db.volunteers.filter(v => !v.deleted_at).toArray()
.then(arr => arr.sort((a, b) => a.name.localeCompare(b.name)))
)
const allVolunteerShifts = liveQuery(() =>
db.volunteer_shifts.toArray()
)
// Departments visible to this user
const visibleDepts = $derived.by(() => {
const depts = $allDepts ?? []
if (role === 'volunteer_lead') return depts.filter(d => myDeptIDs.includes(d.id))
return depts
})
// Grouped structure: dept → days → shifts
const board = $derived.by(() => {
const shifts = $allShifts ?? []
const vols = $allVolunteers ?? []
const vss = $allVolunteerShifts ?? []
return visibleDepts.map(dept => {
const deptShifts = shifts.filter(s => s.department_id === dept.id)
const days = {}
for (const s of deptShifts) {
if (!days[s.day]) days[s.day] = []
const assigned = vss.filter(vs => vs.shift_id === s.id).map(vs => ({
vs,
volunteer: vols.find(v => v.id === vs.volunteer_id),
})).filter(x => x.volunteer)
const hasConflict = assigned.some(({ volunteer }) =>
checkConflict(volunteer.id, s.id, vss, shifts)
)
days[s.day].push({ shift: s, assigned, hasConflict })
}
return {
dept,
days: Object.entries(days).sort(([a], [b]) => a.localeCompare(b)),
}
})
})
function checkConflict(volunteerID, shiftID, vss, shifts) {
const target = shifts.find(s => s.id === shiftID)
if (!target) return false
return vss
.filter(vs => vs.volunteer_id === volunteerID && vs.shift_id !== shiftID)
.some(vs => {
const s = shifts.find(sh => sh.id === vs.shift_id)
return s && s.day === target.day &&
s.start_time < target.end_time &&
target.start_time < s.end_time
})
}
function startEdit(s) {
editShiftID = s.id
editShift = { ...s }
}
function cancelEdit() {
editShiftID = null
editShift = {}
}
async function saveShift() {
saving = true
error = ''
try {
await api.shifts.update(editShiftID, editShift)
await db.shifts.put({ ...editShift, id: editShiftID })
cancelEdit()
} catch (err) {
error = err.message
} finally {
saving = false
}
}
async function reorder(shiftId, delta, siblings) {
const idx = siblings.findIndex(({ shift }) => shift.id === shiftId)
const newIdx = idx + delta
if (newIdx < 0 || newIdx >= siblings.length) return
// Swap in a copy, then assign sequential positions 0, 1, 2, …
const reordered = [...siblings]
;[reordered[idx], reordered[newIdx]] = [reordered[newIdx], reordered[idx]]
const positions = reordered.map(({ shift }, i) => ({ id: shift.id, position: i }))
try {
const res = await api.shifts.reorder(positions)
if (res && !res.ok) throw new Error()
for (const p of positions) {
const s = await db.shifts.get(p.id)
if (s) await db.shifts.put({ ...s, position: p.position })
}
} catch (err) {
error = err.message
}
}
function startAssign(shiftId) {
assigningShiftID = shiftId
assignVolID = 0
}
async function doAssign(shiftId) {
if (!assignVolID) return
assigning = true
error = ''
try {
const res = await api.shifts.assignVolunteer(shiftId, assignVolID)
if (res.status === 409) {
const body = await res.json().catch(() => ({}))
error = `Conflict: ${body.conflicting_shifts?.map(s => s.name).join(', ') ?? 'scheduling conflict'} — check the volunteer's other shifts.`
return
} else if (!res.ok) {
const body = await res.json().catch(() => ({}))
error = body.error || 'Assignment failed'
return
}
await db.volunteer_shifts.put({ volunteer_id: assignVolID, shift_id: shiftId, confirmed: true, updated_at: new Date().toISOString() })
assigningShiftID = null
assignVolID = 0
} catch (err) {
error = err.message
} finally {
assigning = false
}
}
async function doAssignForce(shiftId) {
if (!assignVolID) return
assigning = true
error = ''
try {
const res = await api.shifts.assignVolunteer(shiftId, assignVolID, true)
if (!res.ok) {
const body = await res.json().catch(() => ({}))
error = body.error || 'Assignment failed'
return
}
await db.volunteer_shifts.put({ volunteer_id: assignVolID, shift_id: shiftId, confirmed: true, updated_at: new Date().toISOString() })
assigningShiftID = null
assignVolID = 0
} catch (err) {
error = err.message
} finally {
assigning = false
}
}
async function unassign(shiftId, volunteerId) {
error = ''
try {
await api.shifts.unassignVolunteer(shiftId, volunteerId)
await db.volunteer_shifts.delete([volunteerId, shiftId])
} catch (err) {
error = err.message
}
}
function fmt(t) {
if (!t) return ''
const [h, m] = t.split(':').map(Number)
const ampm = h < 12 ? 'am' : 'pm'
return `${h % 12 || 12}:${String(m).padStart(2, '0')}${ampm}`
}
</script>
<div class="page">
<div class="page-header">
<h1 class="page-title">Schedule Board</h1>
</div>
{#if error}
<div class="alert alert-error">{error}</div>
{/if}
{#if ($allShifts ?? []).length === 0}
<div class="empty">
<strong>No shifts yet</strong>
<p>Create shifts in the Shifts page first.</p>
</div>
{:else}
{#each board as { dept, days }}
{#if days.length > 0}
<div class="board-dept">
<div class="board-dept-header">
<span class="dept-dot" style="background:{dept.color}"></span>
{dept.name}
</div>
{#each days as [day, rows]}
<div class="board-day-label">{day}</div>
{#each rows as { shift, assigned, hasConflict }, i}
<div class="board-shift {hasConflict ? 'board-shift-conflict' : ''}">
{#if editShiftID === shift.id}
<!-- Inline edit form -->
<div class="board-edit-form">
<div class="board-edit-grid">
<div class="form-group">
<label for="es-name">Name</label>
<input id="es-name" bind:value={editShift.name} />
</div>
<div class="form-group">
<label for="es-day">Day</label>
<input id="es-day" bind:value={editShift.day} placeholder="YYYY-MM-DD" />
</div>
<div class="form-group">
<label for="es-start">Start</label>
<input id="es-start" type="time" bind:value={editShift.start_time} />
</div>
<div class="form-group">
<label for="es-end">End</label>
<input id="es-end" type="time" bind:value={editShift.end_time} />
</div>
<div class="form-group">
<label for="es-cap">Capacity</label>
<input id="es-cap" type="number" min="0" bind:value={editShift.capacity} />
</div>
</div>
<div class="actions">
<button class="btn btn-primary btn-sm" onclick={saveShift} disabled={saving}>
{saving ? '…' : 'Save'}
</button>
<button class="btn btn-ghost btn-sm" onclick={cancelEdit}>Cancel</button>
</div>
</div>
{:else}
<div class="board-shift-main">
<div class="board-shift-meta">
<strong>{shift.name}</strong>
<span class="text-muted">{fmt(shift.start_time)}{fmt(shift.end_time)}</span>
{#if shift.capacity > 0}
<span class="board-cap {assigned.length >= shift.capacity ? 'board-cap-full' : ''}">
{assigned.length}/{shift.capacity}
</span>
{:else if assigned.length > 0}
<span class="board-cap">{assigned.length}</span>
{/if}
{#if hasConflict}
<span class="badge badge-lead" style="margin-left:0.3rem">⚠ conflict</span>
{/if}
</div>
<div class="board-shift-actions">
<button class="btn btn-ghost btn-sm" onclick={() => startEdit(shift)}>Edit</button>
<button class="btn btn-ghost btn-sm" title="Move up"
onclick={() => reorder(shift.id, -1, rows)}>↑</button>
<button class="btn btn-ghost btn-sm" title="Move down"
onclick={() => reorder(shift.id, 1, rows)}>↓</button>
</div>
</div>
<!-- Assigned volunteers -->
{#if assigned.length > 0}
<div class="board-volunteers">
{#each assigned as { vs, volunteer }}
<div class="board-vol-chip">
{volunteer.name}
{#if checkConflict(volunteer.id, shift.id, $allVolunteerShifts ?? [], $allShifts ?? [])}
<span title="Scheduling conflict" style="color:var(--c-warn)"></span>
{/if}
<button class="board-vol-remove" onclick={() => unassign(shift.id, volunteer.id)} title="Remove">×</button>
</div>
{/each}
</div>
{/if}
<!-- Assign volunteer -->
{#if assigningShiftID === shift.id}
<div class="board-assign-row">
<select bind:value={assignVolID} style="width:auto">
<option value={0}> Select volunteer —</option>
{#each $allVolunteers ?? [] as v}
<option value={v.id}>{v.name}</option>
{/each}
</select>
<button class="btn btn-primary btn-sm" onclick={() => doAssign(shift.id)} disabled={!assignVolID || assigning}>
{assigning ? '…' : 'Assign'}
</button>
<button class="btn btn-ghost btn-sm" onclick={() => doAssignForce(shift.id)} disabled={!assignVolID || assigning} title="Assign ignoring conflicts">
Force
</button>
<button class="btn btn-ghost btn-sm" onclick={() => { assigningShiftID = null; assignVolID = 0 }}>Cancel</button>
</div>
{:else}
<button class="board-add-vol" onclick={() => startAssign(shift.id)}>+ Assign volunteer</button>
{/if}
{/if}
</div>
{/each}
{/each}
</div>
{/if}
{/each}
{/if}
</div>
<style>
.board-dept {
margin-bottom: 2rem;
}
.board-dept-header {
display: flex;
align-items: center;
gap: 0.6rem;
font-size: 1rem;
font-weight: 700;
margin-bottom: 0.5rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--c-border);
}
.board-day-label {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--c-muted);
margin: 0.75rem 0 0.35rem;
}
.board-shift {
background: var(--c-surface);
border: 1px solid var(--c-border);
border-radius: 8px;
padding: 0.85rem 1rem;
margin-bottom: 0.5rem;
}
.board-shift-conflict {
border-color: rgba(245,158,11,0.4);
}
.board-shift-main {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.board-shift-meta {
display: flex;
align-items: center;
gap: 0.6rem;
flex-wrap: wrap;
font-size: 0.875rem;
}
.board-shift-actions {
display: flex;
gap: 0.25rem;
flex-shrink: 0;
}
.board-cap {
font-size: 0.75rem;
background: rgba(99,102,241,0.12);
color: var(--c-accent-h);
padding: 0.1rem 0.4rem;
border-radius: 99px;
}
.board-cap-full {
background: rgba(239,68,68,0.12);
color: var(--c-danger);
}
.board-volunteers {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
margin-top: 0.6rem;
}
.board-vol-chip {
display: inline-flex;
align-items: center;
gap: 0.3rem;
background: rgba(99,102,241,0.12);
color: var(--c-accent-h);
padding: 0.2rem 0.5rem;
border-radius: 99px;
font-size: 0.78rem;
font-weight: 500;
}
.board-vol-remove {
background: none;
border: none;
color: var(--c-muted);
cursor: pointer;
font-size: 1rem;
line-height: 1;
padding: 0;
margin-left: 0.15rem;
}
.board-vol-remove:hover { color: var(--c-danger); }
.board-add-vol {
background: none;
border: none;
color: var(--c-muted);
cursor: pointer;
font-size: 0.78rem;
padding: 0.35rem 0;
margin-top: 0.35rem;
font-family: var(--font);
}
.board-add-vol:hover { color: var(--c-text); }
.board-assign-row {
display: flex;
align-items: center;
gap: 0.4rem;
margin-top: 0.5rem;
flex-wrap: wrap;
}
.board-edit-form {
padding: 0.25rem 0;
}
.board-edit-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 0.75rem;
margin-bottom: 0.75rem;
}
</style>