453 lines
14 KiB
Svelte
453 lines
14 KiB
Svelte
|
|
<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>
|