Turnpike/frontend/src/pages/ScheduleBoard.svelte

555 lines
18 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)
// Add shift form
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)
// For volunteer assignment dropdown
let assigningShiftID = $state(null)
let assignVolID = $state(0)
let assigning = $state(false)
const role = $derived(session?.user?.role ?? '')
const canManage = $derived(['admin', 'coordinator', 'volunteer_lead'].includes(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
}
}
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 formatDay(d) {
if (!d) return ''
const dt = new Date(d + 'T00:00:00')
return dt.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' })
}
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</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 visibleDepts 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 && !showAdd}
<div class="empty">
<strong>No shifts yet</strong>
<p>Add shifts to schedule your volunteers.</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">{formatDay(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>
<button class="btn btn-danger btn-sm" onclick={() => deleteShift(shift)}>Delete</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>