222 lines
7.1 KiB
Svelte
222 lines
7.1 KiB
Svelte
|
|
<script>
|
|||
|
|
import { liveQuery } from 'dexie'
|
|||
|
|
import { db } from '../db.js'
|
|||
|
|
import { api } from '../api.js'
|
|||
|
|
|
|||
|
|
let { session } = $props()
|
|||
|
|
|
|||
|
|
let error = $state('')
|
|||
|
|
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)
|
|||
|
|
|
|||
|
|
const role = $derived(session?.user?.role ?? '')
|
|||
|
|
const canManage = $derived(['admin', 'coordinator', 'volunteer_lead'].includes(role))
|
|||
|
|
|
|||
|
|
const allShifts = liveQuery(() =>
|
|||
|
|
db.shifts.filter(s => !s.deleted_at).toArray()
|
|||
|
|
)
|
|||
|
|
const allDepts = liveQuery(() =>
|
|||
|
|
db.departments.filter(d => !d.deleted_at).toArray()
|
|||
|
|
.then(arr => arr.sort((a, b) => a.name.localeCompare(b.name)))
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// Group shifts by department, then by day
|
|||
|
|
const grouped = $derived.by(() => {
|
|||
|
|
const shifts = $allShifts ?? []
|
|||
|
|
const depts = $allDepts ?? []
|
|||
|
|
|
|||
|
|
const byDept = {}
|
|||
|
|
for (const s of shifts) {
|
|||
|
|
if (!byDept[s.department_id]) byDept[s.department_id] = {}
|
|||
|
|
if (!byDept[s.department_id][s.day]) byDept[s.department_id][s.day] = []
|
|||
|
|
byDept[s.department_id][s.day].push(s)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return depts
|
|||
|
|
.filter(d => byDept[d.id])
|
|||
|
|
.map(d => ({
|
|||
|
|
dept: d,
|
|||
|
|
days: Object.entries(byDept[d.id] || {})
|
|||
|
|
.sort(([a], [b]) => a.localeCompare(b))
|
|||
|
|
.map(([day, dayShifts]) => ({
|
|||
|
|
day,
|
|||
|
|
shifts: [...dayShifts].sort((a, b) => a.start_time.localeCompare(b.start_time)),
|
|||
|
|
})),
|
|||
|
|
}))
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// Shifts not yet in any department group (e.g. orphaned)
|
|||
|
|
const ungrouped = $derived.by(() => {
|
|||
|
|
const shifts = $allShifts ?? []
|
|||
|
|
const deptIDs = new Set(($allDepts ?? []).map(d => d.id))
|
|||
|
|
return shifts.filter(s => !deptIDs.has(s.department_id))
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
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 formatTime(t) {
|
|||
|
|
if (!t) return ''
|
|||
|
|
// t is HH:MM, format nicely
|
|||
|
|
const [h, m] = t.split(':').map(Number)
|
|||
|
|
const ampm = h >= 12 ? 'pm' : 'am'
|
|||
|
|
return `${h % 12 || 12}:${String(m).padStart(2, '0')}${ampm}`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function formatDay(d) {
|
|||
|
|
if (!d) return ''
|
|||
|
|
const dt = new Date(d + 'T00:00:00')
|
|||
|
|
return dt.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' })
|
|||
|
|
}
|
|||
|
|
</script>
|
|||
|
|
|
|||
|
|
<div class="page">
|
|||
|
|
<div class="page-header">
|
|||
|
|
<h1 class="page-title">Shifts</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 $allDepts ?? [] 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}
|
|||
|
|
<div class="empty">
|
|||
|
|
<strong>No shifts yet</strong>
|
|||
|
|
<p>Add shifts to schedule your volunteers.</p>
|
|||
|
|
</div>
|
|||
|
|
{:else}
|
|||
|
|
{#each grouped as { dept, days }}
|
|||
|
|
<div style="margin-bottom:2rem">
|
|||
|
|
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.75rem">
|
|||
|
|
<span class="dept-dot" style="background:{dept.color}"></span>
|
|||
|
|
<strong style="font-size:1rem">{dept.name}</strong>
|
|||
|
|
</div>
|
|||
|
|
{#each days as { day, shifts }}
|
|||
|
|
<div style="margin-bottom:1rem">
|
|||
|
|
<div class="text-muted" style="font-size:0.78rem;text-transform:uppercase;letter-spacing:0.06em;margin-bottom:0.4rem;padding-left:1rem">
|
|||
|
|
{formatDay(day)}
|
|||
|
|
</div>
|
|||
|
|
<div class="table-wrap">
|
|||
|
|
<table>
|
|||
|
|
<tbody>
|
|||
|
|
{#each shifts as s (s.id)}
|
|||
|
|
<tr>
|
|||
|
|
<td><strong>{s.name}</strong></td>
|
|||
|
|
<td class="text-muted">{formatTime(s.start_time)} – {formatTime(s.end_time)}</td>
|
|||
|
|
<td class="text-muted">
|
|||
|
|
{#if s.capacity}
|
|||
|
|
Capacity: {s.capacity}
|
|||
|
|
{:else}
|
|||
|
|
Unlimited
|
|||
|
|
{/if}
|
|||
|
|
</td>
|
|||
|
|
{#if canManage}
|
|||
|
|
<td style="text-align:right">
|
|||
|
|
<button class="btn btn-danger btn-sm" onclick={() => deleteShift(s)}>Delete</button>
|
|||
|
|
</td>
|
|||
|
|
{/if}
|
|||
|
|
</tr>
|
|||
|
|
{/each}
|
|||
|
|
</tbody>
|
|||
|
|
</table>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
{/each}
|
|||
|
|
</div>
|
|||
|
|
{/each}
|
|||
|
|
{#if ungrouped.length > 0}
|
|||
|
|
<div class="text-muted" style="font-size:0.85rem">
|
|||
|
|
{ungrouped.length} shift(s) with unknown departments
|
|||
|
|
</div>
|
|||
|
|
{/if}
|
|||
|
|
{/if}
|
|||
|
|
</div>
|