Turnpike/frontend/src/pages/ScheduleBoard.svelte

453 lines
14 KiB
Svelte
Raw Normal View History

<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>