Compare commits
2 commits
2b409c65c1
...
07f7d3d245
| Author | SHA1 | Date | |
|---|---|---|---|
| 07f7d3d245 | |||
| 87da9cf97f |
6 changed files with 85 additions and 22 deletions
|
|
@ -9,8 +9,8 @@ Turnpike handles gate check-in, volunteer scheduling, and department coordinatio
|
||||||
- **Participant & ticket management** — CSV import (CrowdWork/Zeffy auto-detected), search, check-in
|
- **Participant & ticket management** — CSV import (CrowdWork/Zeffy auto-detected), search, check-in
|
||||||
- **Volunteer scheduling** — departments, shifts with capacity, conflict detection, reordering
|
- **Volunteer scheduling** — departments, shifts with capacity, conflict detection, reordering
|
||||||
- **Public volunteer signup** — self-registration form with email confirmation, auto-participant linking
|
- **Public volunteer signup** — self-registration form with email confirmation, auto-participant linking
|
||||||
- **Volunteer kiosk** — code-authenticated self-service shift signup, no login required
|
- **Volunteer kiosk** — public volunteer flow: signup, email confirmation, code-authenticated shift self-scheduling
|
||||||
- **Gate check-in** — full-screen UI with QR scanner, volunteer dual check-in
|
- **Gate kiosk** — full-screen check-in UI with QR scanner for gatekeepers
|
||||||
- **Schedule** — create shifts, assign volunteers, manage assignments with conflict awareness
|
- **Schedule** — create shifts, assign volunteers, manage assignments with conflict awareness
|
||||||
- **Role-based access** — admin, ticketing, staffing, colead (department-scoped), gatekeeper
|
- **Role-based access** — admin, ticketing, staffing, colead (department-scoped), gatekeeper
|
||||||
- **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
|
||||||
|
|
@ -64,7 +64,7 @@ See [docs/INSTALLATION.md](docs/INSTALLATION.md) for systemd, Docker, NixOS, and
|
||||||
| `ticketing` | Participants, tickets, import. No user management |
|
| `ticketing` | Participants, tickets, import. No user management |
|
||||||
| `staffing` | All departments: volunteers, shifts, schedule. No user management or settings |
|
| `staffing` | All departments: volunteers, shifts, schedule. No user management or settings |
|
||||||
| `colead` | Own department only: volunteers and shifts scoped to assigned department(s) |
|
| `colead` | Own department only: volunteers and shifts scoped to assigned department(s) |
|
||||||
| `gatekeeper` | Full-screen check-in UI with QR scanner. No access to other pages |
|
| `gatekeeper` | Full-screen Gate Kiosk with QR scanner. No access to other pages |
|
||||||
|
|
||||||
See [docs/USAGE.md](docs/USAGE.md) for detailed workflow documentation.
|
See [docs/USAGE.md](docs/USAGE.md) for detailed workflow documentation.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ After logging in, create accounts for your team under **Users**. Each user gets
|
||||||
| **ticketing** | Participants, Tickets, Import | Manage participants and tickets, run CSV imports |
|
| **ticketing** | Participants, Tickets, Import | Manage participants and tickets, run CSV imports |
|
||||||
| **staffing** | Dashboard, Schedule, Volunteers, Departments | Manage volunteers, departments, and shifts across all departments. No user management or settings |
|
| **staffing** | Dashboard, Schedule, Volunteers, Departments | Manage volunteers, departments, and shifts across all departments. No user management or settings |
|
||||||
| **colead** | Dashboard, Schedule, Volunteers | Manage volunteers and shifts within their assigned department(s) only |
|
| **colead** | Dashboard, Schedule, Volunteers | Manage volunteers and shifts within their assigned department(s) only |
|
||||||
| **gatekeeper** | Full-screen Gate UI | Check in ticket holders (search + QR scan). No access to other pages |
|
| **gatekeeper** | Full-screen Gate Kiosk | Check in ticket holders (search + QR scan). No access to other pages |
|
||||||
|
|
||||||
Coleads are scoped to one or more departments. When creating a colead user, assign their department(s).
|
Coleads are scoped to one or more departments. When creating a colead user, assign their department(s).
|
||||||
|
|
||||||
|
|
@ -95,7 +95,15 @@ Under **Volunteers**, you can:
|
||||||
- Create volunteers manually (name, email, department)
|
- Create volunteers manually (name, email, department)
|
||||||
- Assign volunteers to departments
|
- Assign volunteers to departments
|
||||||
- Mark volunteers as co-leads
|
- Mark volunteers as co-leads
|
||||||
- Check in volunteers
|
- Mark volunteers as ready (briefed at the volunteer station)
|
||||||
|
|
||||||
|
### Volunteer statuses
|
||||||
|
|
||||||
|
| Status | Meaning |
|
||||||
|
|--------|---------|
|
||||||
|
| **Unconfirmed** | Signed up but hasn't confirmed their email |
|
||||||
|
| **Confirmed** | Email confirmed, not yet briefed |
|
||||||
|
| **Ready** | Briefed at the volunteer station, has what they need to report for shifts |
|
||||||
|
|
||||||
Volunteers are separate from participants. A person can be both a ticket holder and a volunteer. When a volunteer signs up via the public form, they are automatically linked to their participant record by email.
|
Volunteers are separate from participants. A person can be both a ticket holder and a volunteer. When a volunteer signs up via the public form, they are automatically linked to their participant record by email.
|
||||||
|
|
||||||
|
|
@ -117,7 +125,7 @@ Shifts can be reordered within a department to reflect priority or sequence usin
|
||||||
|
|
||||||
## Volunteer Kiosk
|
## Volunteer Kiosk
|
||||||
|
|
||||||
The kiosk lets volunteers self-select shifts without logging in.
|
The Volunteer Kiosk is the public-facing flow for volunteers: signup, email confirmation, and shift self-scheduling. The shift scheduling page lets volunteers self-select shifts without logging in.
|
||||||
|
|
||||||
### Setup
|
### Setup
|
||||||
|
|
||||||
|
|
@ -145,15 +153,16 @@ No login is required. The kiosk code authenticates the request.
|
||||||
|
|
||||||
Kiosk codes use the character set `A-Z, 2-9` (excluding 0/O, 1/I/L to avoid ambiguity when reading aloud or on printed badges).
|
Kiosk codes use the character set `A-Z, 2-9` (excluding 0/O, 1/I/L to avoid ambiguity when reading aloud or on printed badges).
|
||||||
|
|
||||||
## Gate Check-In
|
## Gate Kiosk
|
||||||
|
|
||||||
Users with the **gatekeeper** role see a dedicated full-screen UI:
|
Users with the **gatekeeper** role see a dedicated full-screen Gate Kiosk:
|
||||||
|
|
||||||
- **QR scanner** — uses the device camera via the BarcodeDetector API. Scanned codes populate the search field.
|
- **QR scanner** — uses the device camera via the BarcodeDetector API. Scanned codes populate the search field.
|
||||||
- **Search** — type a name to filter tickets in real-time (searches local IndexedDB, works offline).
|
- **Search** — type a name to filter tickets in real-time (searches local IndexedDB, works offline).
|
||||||
- **Volunteer dual check-in** — if a ticket holder is also a volunteer, the gate UI shows their volunteer status and offers to check in both simultaneously.
|
|
||||||
- **Recent check-ins** — the last 10 check-ins are shown for quick reference.
|
- **Recent check-ins** — the last 10 check-ins are shown for quick reference.
|
||||||
|
|
||||||
|
Admins and ticketing leads can also check in tickets directly from the **Participants** page by expanding a participant's tickets.
|
||||||
|
|
||||||
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
|
## Schedule
|
||||||
|
|
|
||||||
|
|
@ -207,4 +207,32 @@ tr:hover td { background: rgba(255,255,255,0.02); }
|
||||||
}
|
}
|
||||||
.page { padding: 1rem; }
|
.page { padding: 1rem; }
|
||||||
.stats { grid-template-columns: repeat(2, 1fr); }
|
.stats { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
|
||||||
|
/* Touch targets */
|
||||||
|
.btn { min-height: 44px; padding: 0.6rem 1rem; }
|
||||||
|
.btn-sm { min-height: 44px; padding: 0.5rem 0.75rem; font-size: 0.85rem; }
|
||||||
|
|
||||||
|
/* Page header & actions */
|
||||||
|
.page-header { flex-wrap: wrap; gap: 0.75rem; }
|
||||||
|
.page-title { width: 100%; }
|
||||||
|
.actions { flex-wrap: wrap; }
|
||||||
|
|
||||||
|
/* Search bar */
|
||||||
|
.search-bar { flex-wrap: wrap; }
|
||||||
|
.search-bar input { max-width: none; flex: 1 1 100%; }
|
||||||
|
|
||||||
|
/* Table → card layout */
|
||||||
|
.table-wrap { overflow-x: visible; }
|
||||||
|
table { display: block; }
|
||||||
|
thead { display: none; }
|
||||||
|
tbody { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||||
|
tr { display: flex; flex-wrap: wrap; gap: 0.25rem 0.75rem; align-items: center;
|
||||||
|
padding: 0.75rem; border: 1px solid var(--c-border); border-radius: var(--radius-lg);
|
||||||
|
background: var(--c-surface); }
|
||||||
|
tr:hover td { background: transparent; }
|
||||||
|
td { display: inline; padding: 0; border: none; }
|
||||||
|
td:empty { display: none; }
|
||||||
|
|
||||||
|
/* Forms */
|
||||||
|
.form-grid { grid-template-columns: 1fr !important; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -243,7 +243,7 @@
|
||||||
{#if showAdd && canManage}
|
{#if showAdd && canManage}
|
||||||
<div class="card" style="margin-bottom:1.5rem">
|
<div class="card" style="margin-bottom:1.5rem">
|
||||||
<form onsubmit={addParticipant}>
|
<form onsubmit={addParticipant}>
|
||||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem">
|
<div class="form-grid" style="display:grid;grid-template-columns:1fr 1fr;gap:1rem">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="p-name">Name</label>
|
<label for="p-name">Name</label>
|
||||||
<input id="p-name" bind:value={newName} placeholder="Preferred name" />
|
<input id="p-name" bind:value={newName} placeholder="Preferred name" />
|
||||||
|
|
@ -362,7 +362,7 @@
|
||||||
onclick={mergeMode && mergeSource?.id !== p.id ? () => { mergeTarget = p } : null}
|
onclick={mergeMode && mergeSource?.id !== p.id ? () => { mergeTarget = p } : null}
|
||||||
style={mergeMode && mergeSource?.id !== p.id ? 'cursor:pointer' : ''}
|
style={mergeMode && mergeSource?.id !== p.id ? 'cursor:pointer' : ''}
|
||||||
>
|
>
|
||||||
<td>
|
<td class="td-name">
|
||||||
<strong>{p.preferred_name || '—'}</strong>
|
<strong>{p.preferred_name || '—'}</strong>
|
||||||
{#if p.pronouns}
|
{#if p.pronouns}
|
||||||
<span class="text-muted" style="font-size:0.78rem"> · {p.pronouns}</span>
|
<span class="text-muted" style="font-size:0.78rem"> · {p.pronouns}</span>
|
||||||
|
|
@ -397,7 +397,7 @@
|
||||||
{/if}
|
{/if}
|
||||||
</td>
|
</td>
|
||||||
{#if canManage}
|
{#if canManage}
|
||||||
<td>
|
<td class="td-actions">
|
||||||
{#if !mergeMode}
|
{#if !mergeMode}
|
||||||
<button class="btn btn-ghost btn-sm" onclick={(e) => { e.stopPropagation(); startEdit(p) }}
|
<button class="btn btn-ghost btn-sm" onclick={(e) => { e.stopPropagation(); startEdit(p) }}
|
||||||
title="Edit participant">✎</button>
|
title="Edit participant">✎</button>
|
||||||
|
|
@ -504,4 +504,14 @@
|
||||||
.edit-fields { display: flex; gap: 0.4rem; flex-wrap: wrap; }
|
.edit-fields { display: flex; gap: 0.4rem; flex-wrap: wrap; }
|
||||||
.edit-fields input { flex: 1; min-width: 120px; font-size: 0.825rem; padding: 0.3rem 0.5rem; width: auto; }
|
.edit-fields input { flex: 1; min-width: 120px; font-size: 0.825rem; padding: 0.3rem 0.5rem; width: auto; }
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.td-name { width: 100%; }
|
||||||
|
.td-actions { width: 100%; }
|
||||||
|
.ticket-rows { padding: 0; border: none; border-radius: 0; margin-top: -0.5rem; }
|
||||||
|
.ticket-rows td { width: 100%; }
|
||||||
|
.ticket-row { flex-direction: column; gap: 0.35rem; }
|
||||||
|
.ticket-row div:last-child { text-align: left; }
|
||||||
|
.edit-row { padding: 0.75rem; }
|
||||||
|
.edit-row td { width: 100%; }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -148,7 +148,7 @@
|
||||||
{#if showAdd}
|
{#if showAdd}
|
||||||
<div class="card" style="margin-bottom:1.5rem">
|
<div class="card" style="margin-bottom:1.5rem">
|
||||||
<form onsubmit={addUser}>
|
<form onsubmit={addUser}>
|
||||||
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:1rem">
|
<div class="form-grid" style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:1rem">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="u-username">Username *</label>
|
<label for="u-username">Username *</label>
|
||||||
<input id="u-username" bind:value={newUsername} required placeholder="username" autocomplete="off" />
|
<input id="u-username" bind:value={newUsername} required placeholder="username" autocomplete="off" />
|
||||||
|
|
@ -213,8 +213,8 @@
|
||||||
<tbody>
|
<tbody>
|
||||||
{#each users as u (u.id)}
|
{#each users as u (u.id)}
|
||||||
{#if editID === u.id}
|
{#if editID === u.id}
|
||||||
<tr>
|
<tr class="edit-row">
|
||||||
<td><strong>{u.username}</strong> {#if u.id === me}<span class="badge badge-role">you</span>{/if}</td>
|
<td class="td-name"><strong>{u.username}</strong> {#if u.id === me}<span class="badge badge-role">you</span>{/if}</td>
|
||||||
<td>
|
<td>
|
||||||
<select bind:value={editRole} style="width:auto;margin:0">
|
<select bind:value={editRole} style="width:auto;margin:0">
|
||||||
{#each roles as r}
|
{#each roles as r}
|
||||||
|
|
@ -239,7 +239,7 @@
|
||||||
placeholder="New password (leave blank to keep)"
|
placeholder="New password (leave blank to keep)"
|
||||||
style="margin-top:0.5rem" autocomplete="new-password" />
|
style="margin-top:0.5rem" autocomplete="new-password" />
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td class="td-actions">
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button class="btn btn-primary btn-sm" onclick={() => saveUser(u)} disabled={saving}>
|
<button class="btn btn-primary btn-sm" onclick={() => saveUser(u)} disabled={saving}>
|
||||||
{saving ? '…' : 'Save'}
|
{saving ? '…' : 'Save'}
|
||||||
|
|
@ -250,7 +250,7 @@
|
||||||
</tr>
|
</tr>
|
||||||
{:else}
|
{:else}
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td class="td-name">
|
||||||
<strong>{u.username}</strong>
|
<strong>{u.username}</strong>
|
||||||
{#if u.id === me}
|
{#if u.id === me}
|
||||||
<span class="badge badge-role" style="margin-left:0.4rem">you</span>
|
<span class="badge badge-role" style="margin-left:0.4rem">you</span>
|
||||||
|
|
@ -258,7 +258,7 @@
|
||||||
</td>
|
</td>
|
||||||
<td><span class="badge badge-role">{roleLabel(u.role)}</span></td>
|
<td><span class="badge badge-role">{roleLabel(u.role)}</span></td>
|
||||||
<td class="text-muted">{deptNamesFor(u.department_ids || [])}</td>
|
<td class="text-muted">{deptNamesFor(u.department_ids || [])}</td>
|
||||||
<td>
|
<td class="td-actions">
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button class="btn btn-ghost btn-sm" onclick={() => startEdit(u)}>Edit</button>
|
<button class="btn btn-ghost btn-sm" onclick={() => startEdit(u)}>Edit</button>
|
||||||
{#if u.id !== me}
|
{#if u.id !== me}
|
||||||
|
|
@ -274,3 +274,11 @@
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.td-name { width: 100%; }
|
||||||
|
.td-actions { width: 100%; }
|
||||||
|
.edit-row td { width: 100%; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -132,7 +132,7 @@
|
||||||
{#if showAdd && canManage}
|
{#if showAdd && canManage}
|
||||||
<div class="card" style="margin-bottom:1.5rem">
|
<div class="card" style="margin-bottom:1.5rem">
|
||||||
<form onsubmit={addVolunteer}>
|
<form onsubmit={addVolunteer}>
|
||||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem">
|
<div class="form-grid" style="display:grid;grid-template-columns:1fr 1fr;gap:1rem">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="v-name">Name *</label>
|
<label for="v-name">Name *</label>
|
||||||
<input id="v-name" bind:value={newName} required placeholder="Full name" />
|
<input id="v-name" bind:value={newName} required placeholder="Full name" />
|
||||||
|
|
@ -213,7 +213,7 @@
|
||||||
{@const dept = deptFor(v.department_id)}
|
{@const dept = deptFor(v.department_id)}
|
||||||
{@const participant = participantFor(v.participant_id)}
|
{@const participant = participantFor(v.participant_id)}
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td class="td-name">
|
||||||
<strong>{v.name}</strong>
|
<strong>{v.name}</strong>
|
||||||
{#if v.is_lead}
|
{#if v.is_lead}
|
||||||
<span class="badge badge-lead" style="margin-left:0.4rem">Co-Lead</span>
|
<span class="badge badge-lead" style="margin-left:0.4rem">Co-Lead</span>
|
||||||
|
|
@ -245,13 +245,13 @@
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td class="td-ready">
|
||||||
{#if !v.checked_in}
|
{#if !v.checked_in}
|
||||||
<CheckInButton onclick={() => checkIn(v)} />
|
<CheckInButton onclick={() => checkIn(v)} />
|
||||||
{/if}
|
{/if}
|
||||||
</td>
|
</td>
|
||||||
{#if canManage}
|
{#if canManage}
|
||||||
<td>
|
<td class="td-actions">
|
||||||
<button class="btn btn-ghost btn-sm" onclick={() => toggleLead(v)}
|
<button class="btn btn-ghost btn-sm" onclick={() => toggleLead(v)}
|
||||||
title={v.is_lead ? 'Remove co-lead' : 'Mark as co-lead'}>
|
title={v.is_lead ? 'Remove co-lead' : 'Mark as co-lead'}>
|
||||||
{v.is_lead ? '− Co-Lead' : '+ Co-Lead'}
|
{v.is_lead ? '− Co-Lead' : '+ Co-Lead'}
|
||||||
|
|
@ -266,3 +266,11 @@
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.td-name { width: 100%; }
|
||||||
|
.td-ready { width: 100%; }
|
||||||
|
.td-actions { width: 100%; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue