Combined Schedule and Shifts views.
This commit is contained in:
parent
f30b84aa3a
commit
0df93e1886
6 changed files with 123 additions and 244 deletions
|
|
@ -7,11 +7,11 @@ Turnpike handles gate check-in, volunteer scheduling, and department coordinatio
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Attendee management** — CSV import (CrowdWork/Zeffy auto-detected), party-size tracking, search, check-in
|
- **Attendee management** — CSV import (CrowdWork/Zeffy auto-detected), party-size tracking, search, check-in
|
||||||
- **Volunteer scheduling** — departments, shifts with capacity, conflict detection, drag-and-drop reordering
|
- **Volunteer scheduling** — departments, shifts with capacity, conflict detection, reordering
|
||||||
- **Public volunteer signup** — self-registration form with email confirmation, auto-attendee linking
|
- **Public volunteer signup** — self-registration form with email confirmation, auto-attendee linking
|
||||||
- **Volunteer kiosk** — token-authenticated self-service shift signup, no login required
|
- **Volunteer kiosk** — token-authenticated self-service shift signup, no login required
|
||||||
- **Gate check-in** — full-screen UI with QR scanner, party check-in ("2/3 checked in"), volunteer dual check-in
|
- **Gate check-in** — full-screen UI with QR scanner, party check-in ("2/3 checked in"), volunteer dual check-in
|
||||||
- **Schedule board** — department leads and coordinators manage shift assignments with conflict awareness
|
- **Schedule** — create shifts, assign volunteers, manage assignments with conflict awareness
|
||||||
- **Role-based access** — admin, coordinator, volunteer lead (department-scoped), gate
|
- **Role-based access** — admin, coordinator, volunteer lead (department-scoped), gate
|
||||||
- **Offline-first PWA** — installs on phones/tablets, full offline check-in with background sync
|
- **Offline-first PWA** — installs on phones/tablets, full offline check-in with background sync
|
||||||
- **Real-time** — check-ins and changes broadcast live via SSE
|
- **Real-time** — check-ins and changes broadcast live via SSE
|
||||||
|
|
@ -61,7 +61,7 @@ See [docs/INSTALLATION.md](docs/INSTALLATION.md) for systemd, Docker, NixOS, and
|
||||||
| Role | Access |
|
| Role | Access |
|
||||||
|------|--------|
|
|------|--------|
|
||||||
| `admin` | Full access: attendee import, user management, SMTP settings, all departments and shifts |
|
| `admin` | Full access: attendee import, user management, SMTP settings, all departments and shifts |
|
||||||
| `coordinator` | All departments: volunteers, shifts, schedule board. No user management or settings |
|
| `coordinator` | All departments: volunteers, shifts, schedule. No user management or settings |
|
||||||
| `volunteer_lead` | Own department only: volunteers and shifts scoped to assigned department |
|
| `volunteer_lead` | Own department only: volunteers and shifts scoped to assigned department |
|
||||||
| `gate` | Full-screen check-in UI with QR scanner. No access to other pages |
|
| `gate` | Full-screen check-in UI with QR scanner. No access to other pages |
|
||||||
|
|
||||||
|
|
@ -91,7 +91,7 @@ The Vite dev server runs on `:5173` and proxies `/api` requests to the Go server
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
- [Usage Guide](docs/USAGE.md) — event setup, attendee import, volunteer signup, volunteer kiosk, gate check-in, schedule board
|
- [Usage Guide](docs/USAGE.md) — event setup, attendee import, volunteer signup, volunteer kiosk, gate check-in, schedule
|
||||||
- [Installation Guide](docs/INSTALLATION.md) — building, deploying, systemd, Docker, NixOS, reverse proxy, backup
|
- [Installation Guide](docs/INSTALLATION.md) — building, deploying, systemd, Docker, NixOS, reverse proxy, backup
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,8 @@ After logging in, create accounts for your team under **Users**. Each user gets
|
||||||
| Role | What they see | What they can do |
|
| Role | What they see | What they can do |
|
||||||
|------|--------------|------------------|
|
|------|--------------|------------------|
|
||||||
| **admin** | All pages + Settings | Everything: attendee import, user management, SMTP config, departments, shifts, volunteers |
|
| **admin** | All pages + Settings | Everything: attendee import, user management, SMTP config, departments, shifts, volunteers |
|
||||||
| **coordinator** | Dashboard, Schedule Board, Volunteers, Departments, Shifts | Manage volunteers, departments, and shifts across all departments. Cannot manage users or settings |
|
| **coordinator** | Dashboard, Schedule, Volunteers, Departments | Manage volunteers, departments, and shifts across all departments. Cannot manage users or settings |
|
||||||
| **volunteer_lead** | Schedule Board, Volunteers, Departments | Manage volunteers and shifts within their assigned department only |
|
| **volunteer_lead** | Schedule, Volunteers, Departments | Manage volunteers and shifts within their assigned department only |
|
||||||
| **gate** | Full-screen Gate UI | Check in attendees (search + QR scan). No access to other pages |
|
| **gate** | Full-screen Gate UI | Check in attendees (search + QR scan). No access to other pages |
|
||||||
|
|
||||||
Ticketing and ops staff should use the **admin** role. The `ticketing` role exists in the codebase but is effectively unused — admin covers all ticketing functions.
|
Ticketing and ops staff should use the **admin** role. The `ticketing` role exists in the codebase but is effectively unused — admin covers all ticketing functions.
|
||||||
|
|
@ -26,7 +26,7 @@ Volunteer leads are scoped to a single department. When creating a volunteer_lea
|
||||||
1. **Configure your event** — go to the Dashboard and set the event name and dates.
|
1. **Configure your event** — go to the Dashboard and set the event name and dates.
|
||||||
2. **Create departments** — under Departments, add each department your event needs (e.g., Gate, Greeters, Rangers, Build, LNT).
|
2. **Create departments** — under Departments, add each department your event needs (e.g., Gate, Greeters, Rangers, Build, LNT).
|
||||||
3. **Import attendees** — see next section.
|
3. **Import attendees** — see next section.
|
||||||
4. **Create shifts** — under Shifts, create shifts for each department with day, start/end time, and capacity.
|
4. **Create shifts** — under Schedule, create shifts for each department with day, start/end time, and capacity.
|
||||||
|
|
||||||
## Importing Attendees
|
## Importing Attendees
|
||||||
|
|
||||||
|
|
@ -108,7 +108,7 @@ Volunteers are separate from attendees. A person can be both an attendee (ticket
|
||||||
|
|
||||||
## Shift Scheduling
|
## Shift Scheduling
|
||||||
|
|
||||||
Under **Shifts**, create shifts for each department:
|
Under **Schedule**, create shifts for each department:
|
||||||
|
|
||||||
- **Day** — the date of the shift
|
- **Day** — the date of the shift
|
||||||
- **Start/end time** — HH:MM format
|
- **Start/end time** — HH:MM format
|
||||||
|
|
@ -116,11 +116,11 @@ Under **Shifts**, create shifts for each department:
|
||||||
|
|
||||||
### Assigning volunteers
|
### Assigning volunteers
|
||||||
|
|
||||||
From the Shifts page or the Schedule Board, assign volunteers to shifts. Turnpike checks for conflicts — if a volunteer already has a shift on the same day with overlapping times, you'll see a warning and can choose to force the assignment.
|
From the Schedule page, assign volunteers to shifts. Turnpike checks for conflicts — if a volunteer already has a shift on the same day with overlapping times, you'll see a warning and can choose to force the assignment.
|
||||||
|
|
||||||
### Reordering
|
### Reordering
|
||||||
|
|
||||||
Shifts can be reordered within a department to reflect priority or sequence. The Schedule Board supports drag-and-drop reordering.
|
Shifts can be reordered within a department to reflect priority or sequence using the up/down buttons on each shift card.
|
||||||
|
|
||||||
## Volunteer Kiosk
|
## Volunteer Kiosk
|
||||||
|
|
||||||
|
|
@ -162,9 +162,9 @@ Users with the **gate** role see a dedicated full-screen UI:
|
||||||
|
|
||||||
Gate devices should install Turnpike as a PWA (Add to Home Screen) for the best experience. Check-ins are stored locally and sync when connectivity is available.
|
Gate devices should install Turnpike as a PWA (Add to Home Screen) for the best experience. Check-ins are stored locally and sync when connectivity is available.
|
||||||
|
|
||||||
## Schedule Board
|
## Schedule
|
||||||
|
|
||||||
The Schedule Board is the primary UI for coordinators and volunteer leads. It shows:
|
The Schedule page is the primary UI for managing shifts and volunteer assignments. It shows:
|
||||||
|
|
||||||
- Shifts grouped by department and day
|
- Shifts grouped by department and day
|
||||||
- Each shift card shows: name, time, capacity (used/total), assigned volunteers
|
- Each shift card shows: name, time, capacity (used/total), assigned volunteers
|
||||||
|
|
@ -173,10 +173,12 @@ The Schedule Board is the primary UI for coordinators and volunteer leads. It sh
|
||||||
**Coordinators and admins** see all departments. **Volunteer leads** see only their assigned department.
|
**Coordinators and admins** see all departments. **Volunteer leads** see only their assigned department.
|
||||||
|
|
||||||
Actions available:
|
Actions available:
|
||||||
|
- Create new shifts (+ Add shift button)
|
||||||
|
- Edit shift details inline
|
||||||
|
- Delete shifts
|
||||||
- Assign volunteers to shifts from a dropdown
|
- Assign volunteers to shifts from a dropdown
|
||||||
- Remove volunteer assignments
|
- Remove volunteer assignments
|
||||||
- Reorder shifts within a department
|
- Reorder shifts within a department
|
||||||
- Edit shift details inline
|
|
||||||
|
|
||||||
## SMTP Configuration
|
## SMTP Configuration
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@
|
||||||
import Attendees from './pages/Attendees.svelte'
|
import Attendees from './pages/Attendees.svelte'
|
||||||
import Volunteers from './pages/Volunteers.svelte'
|
import Volunteers from './pages/Volunteers.svelte'
|
||||||
import Departments from './pages/Departments.svelte'
|
import Departments from './pages/Departments.svelte'
|
||||||
import Shifts from './pages/Shifts.svelte'
|
|
||||||
import Users from './pages/Users.svelte'
|
import Users from './pages/Users.svelte'
|
||||||
import Import from './pages/Import.svelte'
|
import Import from './pages/Import.svelte'
|
||||||
import Kiosk from './pages/Kiosk.svelte'
|
import Kiosk from './pages/Kiosk.svelte'
|
||||||
|
|
@ -133,8 +132,6 @@
|
||||||
<Volunteers {session} />
|
<Volunteers {session} />
|
||||||
{:else if path.startsWith('/departments')}
|
{:else if path.startsWith('/departments')}
|
||||||
<Departments {session} />
|
<Departments {session} />
|
||||||
{:else if path.startsWith('/shifts')}
|
|
||||||
<Shifts {session} />
|
|
||||||
{:else if path.startsWith('/schedule')}
|
{:else if path.startsWith('/schedule')}
|
||||||
<ScheduleBoard {session} />
|
<ScheduleBoard {session} />
|
||||||
{:else if path.startsWith('/users')}
|
{:else if path.startsWith('/users')}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<script>
|
<script>
|
||||||
import { LayoutDashboard, ClipboardCheck, Heart, Hexagon, Clock, CalendarDays, Upload, Users, Settings, LogOut } from 'lucide-svelte'
|
import { LayoutDashboard, ClipboardCheck, Heart, Hexagon, CalendarDays, Upload, Users, Settings, LogOut } from 'lucide-svelte'
|
||||||
|
|
||||||
let { session, active, onLogout, navigate, open = false } = $props()
|
let { session, active, onLogout, navigate, open = false } = $props()
|
||||||
|
|
||||||
|
|
@ -22,14 +22,12 @@
|
||||||
{ href: '/schedule', label: 'Schedule', icon: CalendarDays },
|
{ href: '/schedule', label: 'Schedule', icon: CalendarDays },
|
||||||
{ href: '/volunteers', label: 'Volunteers', icon: Heart },
|
{ href: '/volunteers', label: 'Volunteers', icon: Heart },
|
||||||
{ href: '/departments', label: 'Departments', icon: Hexagon },
|
{ href: '/departments', label: 'Departments', icon: Hexagon },
|
||||||
{ href: '/shifts', label: 'Shifts', icon: Clock },
|
|
||||||
]
|
]
|
||||||
return [
|
return [
|
||||||
{ href: '/', label: 'Dashboard', icon: LayoutDashboard },
|
{ href: '/', label: 'Dashboard', icon: LayoutDashboard },
|
||||||
{ href: '/attendees', label: 'Attendees', icon: ClipboardCheck },
|
{ href: '/attendees', label: 'Attendees', icon: ClipboardCheck },
|
||||||
{ href: '/volunteers', label: 'Volunteers', icon: Heart },
|
{ href: '/volunteers', label: 'Volunteers', icon: Heart },
|
||||||
{ href: '/departments', label: 'Departments', icon: Hexagon },
|
{ href: '/departments', label: 'Departments', icon: Hexagon },
|
||||||
{ href: '/shifts', label: 'Shifts', icon: Clock },
|
|
||||||
{ href: '/schedule', label: 'Schedule', icon: CalendarDays },
|
{ href: '/schedule', label: 'Schedule', icon: CalendarDays },
|
||||||
{ href: '/import', label: 'Import', icon: Upload },
|
{ href: '/import', label: 'Import', icon: Upload },
|
||||||
{ href: '/users', label: 'Users', icon: Users },
|
{ href: '/users', label: 'Users', icon: Users },
|
||||||
|
|
|
||||||
|
|
@ -10,12 +10,23 @@
|
||||||
let editShift = $state({})
|
let editShift = $state({})
|
||||||
let saving = $state(false)
|
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
|
// For volunteer assignment dropdown
|
||||||
let assigningShiftID = $state(null)
|
let assigningShiftID = $state(null)
|
||||||
let assignVolID = $state(0)
|
let assignVolID = $state(0)
|
||||||
let assigning = $state(false)
|
let assigning = $state(false)
|
||||||
|
|
||||||
const role = $derived(session?.user?.role ?? '')
|
const role = $derived(session?.user?.role ?? '')
|
||||||
|
const canManage = $derived(['admin', 'coordinator', 'volunteer_lead'].includes(role))
|
||||||
const myDeptIDs = $derived(session?.user?.department_ids ?? [])
|
const myDeptIDs = $derived(session?.user?.department_ids ?? [])
|
||||||
|
|
||||||
const allDepts = liveQuery(() =>
|
const allDepts = liveQuery(() =>
|
||||||
|
|
@ -194,6 +205,48 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
function fmt(t) {
|
||||||
if (!t) return ''
|
if (!t) return ''
|
||||||
const [h, m] = t.split(':').map(Number)
|
const [h, m] = t.split(':').map(Number)
|
||||||
|
|
@ -204,17 +257,66 @@
|
||||||
|
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<h1 class="page-title">Schedule Board</h1>
|
<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>
|
</div>
|
||||||
|
|
||||||
{#if error}
|
{#if error}
|
||||||
<div class="alert alert-error">{error}</div>
|
<div class="alert alert-error">{error}</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if ($allShifts ?? []).length === 0}
|
{#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">
|
<div class="empty">
|
||||||
<strong>No shifts yet</strong>
|
<strong>No shifts yet</strong>
|
||||||
<p>Create shifts in the Shifts page first.</p>
|
<p>Add shifts to schedule your volunteers.</p>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
{#each board as { dept, days }}
|
{#each board as { dept, days }}
|
||||||
|
|
@ -226,7 +328,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#each days as [day, rows]}
|
{#each days as [day, rows]}
|
||||||
<div class="board-day-label">{day}</div>
|
<div class="board-day-label">{formatDay(day)}</div>
|
||||||
|
|
||||||
{#each rows as { shift, assigned, hasConflict }, i}
|
{#each rows as { shift, assigned, hasConflict }, i}
|
||||||
<div class="board-shift {hasConflict ? 'board-shift-conflict' : ''}">
|
<div class="board-shift {hasConflict ? 'board-shift-conflict' : ''}">
|
||||||
|
|
@ -285,6 +387,7 @@
|
||||||
onclick={() => reorder(shift.id, -1, rows)}>↑</button>
|
onclick={() => reorder(shift.id, -1, rows)}>↑</button>
|
||||||
<button class="btn btn-ghost btn-sm" title="Move down"
|
<button class="btn btn-ghost btn-sm" title="Move down"
|
||||||
onclick={() => reorder(shift.id, 1, rows)}>↓</button>
|
onclick={() => reorder(shift.id, 1, rows)}>↓</button>
|
||||||
|
<button class="btn btn-danger btn-sm" onclick={() => deleteShift(shift)}>Delete</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,221 +0,0 @@
|
||||||
<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>
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue