2026-03-03 11:27:07 -06:00
|
|
|
|
<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)
|
|
|
|
|
|
|
2026-03-03 20:51:26 -06:00
|
|
|
|
// 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)
|
|
|
|
|
|
|
2026-03-03 11:27:07 -06:00
|
|
|
|
// For volunteer assignment dropdown
|
|
|
|
|
|
let assigningShiftID = $state(null)
|
|
|
|
|
|
let assignVolID = $state(0)
|
|
|
|
|
|
let assigning = $state(false)
|
|
|
|
|
|
|
|
|
|
|
|
const role = $derived(session?.user?.role ?? '')
|
2026-03-03 20:51:26 -06:00
|
|
|
|
const canManage = $derived(['admin', 'coordinator', 'volunteer_lead'].includes(role))
|
2026-03-03 11:27:07 -06:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-03 20:51:26 -06:00
|
|
|
|
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' })
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-03 11:27:07 -06:00
|
|
|
|
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">
|
2026-03-03 20:51:26 -06:00
|
|
|
|
<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}
|
2026-03-03 11:27:07 -06:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{#if error}
|
|
|
|
|
|
<div class="alert alert-error">{error}</div>
|
|
|
|
|
|
{/if}
|
|
|
|
|
|
|
2026-03-03 20:51:26 -06:00
|
|
|
|
{#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}
|
2026-03-03 11:27:07 -06:00
|
|
|
|
<div class="empty">
|
|
|
|
|
|
<strong>No shifts yet</strong>
|
2026-03-03 20:51:26 -06:00
|
|
|
|
<p>Add shifts to schedule your volunteers.</p>
|
2026-03-03 11:27:07 -06:00
|
|
|
|
</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]}
|
2026-03-03 20:51:26 -06:00
|
|
|
|
<div class="board-day-label">{formatDay(day)}</div>
|
2026-03-03 11:27:07 -06:00
|
|
|
|
|
|
|
|
|
|
{#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>
|
2026-03-03 20:51:26 -06:00
|
|
|
|
<button class="btn btn-danger btn-sm" onclick={() => deleteShift(shift)}>Delete</button>
|
2026-03-03 11:27:07 -06:00
|
|
|
|
</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>
|