Created Turnpike, event attendee and volunteer management

Built after prototype, Traverse, an attendee and volunteer list
maintainer.
This commit is contained in:
Pen Anderson 2026-03-03 11:27:07 -06:00
commit 5d56ba8112
59 changed files with 8663 additions and 0 deletions

24
frontend/.gitignore vendored Normal file
View file

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

3
frontend/.vscode/extensions.json vendored Normal file
View file

@ -0,0 +1,3 @@
{
"recommendations": ["svelte.svelte-vscode"]
}

43
frontend/README.md Normal file
View file

@ -0,0 +1,43 @@
# Svelte + Vite
This template should help get you started developing with Svelte in Vite.
## Recommended IDE Setup
[VS Code](https://code.visualstudio.com/) + [Svelte](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode).
## Need an official Svelte framework?
Check out [SvelteKit](https://github.com/sveltejs/kit#readme), which is also powered by Vite. Deploy anywhere with its serverless-first approach and adapt to various platforms, with out of the box support for TypeScript, SCSS, and Less, and easily-added support for mdsvex, GraphQL, PostCSS, Tailwind CSS, and more.
## Technical considerations
**Why use this over SvelteKit?**
- It brings its own routing solution which might not be preferable for some users.
- It is first and foremost a framework that just happens to use Vite under the hood, not a Vite app.
This template contains as little as possible to get started with Vite + Svelte, while taking into account the developer experience with regards to HMR and intellisense. It demonstrates capabilities on par with the other `create-vite` templates and is a good starting point for beginners dipping their toes into a Vite + Svelte project.
Should you later need the extended capabilities and extensibility provided by SvelteKit, the template has been structured similarly to SvelteKit so that it is easy to migrate.
**Why include `.vscode/extensions.json`?**
Other templates indirectly recommend extensions via the README, but this file allows VS Code to prompt the user to install the recommended extension upon opening the project.
**Why enable `checkJs` in the JS template?**
It is likely that most cases of changing variable types in runtime are likely to be accidental, rather than deliberate. This provides advanced typechecking out of the box. Should you like to take advantage of the dynamically-typed nature of JavaScript, it is trivial to change the configuration.
**Why is HMR not preserving my local component state?**
HMR state preservation comes with a number of gotchas! It has been disabled by default in both `svelte-hmr` and `@sveltejs/vite-plugin-svelte` due to its often surprising behavior. You can read the details [here](https://github.com/sveltejs/svelte-hmr/tree/master/packages/svelte-hmr#preservation-of-local-state).
If you have state that's important to retain within a component, consider creating an external store which would not be replaced by HMR.
```js
// store.js
// An extremely simple external store
import { writable } from 'svelte/store'
export default writable(0)
```

14
frontend/index.html Normal file
View file

@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#6366f1" />
<link rel="manifest" href="/manifest.json" />
<title>Turnpike</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

33
frontend/jsconfig.json Normal file
View file

@ -0,0 +1,33 @@
{
"compilerOptions": {
"moduleResolution": "bundler",
"target": "ESNext",
"module": "ESNext",
/**
* svelte-preprocess cannot figure out whether you have
* a value or a type, so tell TypeScript to enforce using
* `import type` instead of `import` for Types.
*/
"verbatimModuleSyntax": true,
"isolatedModules": true,
"resolveJsonModule": true,
/**
* To have warnings / errors of the Svelte compiler at the
* correct position, enable source maps by default.
*/
"sourceMap": true,
"esModuleInterop": true,
"types": ["vite/client"],
"skipLibCheck": true,
/**
* Typecheck JS in `.svelte` and `.js` files by default.
* Disable this if you'd like to use dynamic types.
*/
"checkJs": true
},
/**
* Use global.d.ts instead of compilerOptions.types
* to avoid limiting type declarations.
*/
"include": ["src/**/*.d.ts", "src/**/*.js", "src/**/*.svelte"]
}

1391
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

19
frontend/package.json Normal file
View file

@ -0,0 +1,19 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"svelte": "^5.45.2",
"vite": "^7.3.1"
},
"dependencies": {
"dexie": "^4.3.0"
}
}

View file

@ -0,0 +1,13 @@
{
"name": "Turnpike",
"short_name": "Turnpike",
"description": "Event attendee & volunteer management",
"start_url": "/",
"display": "standalone",
"background_color": "#0f1117",
"theme_color": "#6366f1",
"icons": [
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" }
]
}

102
frontend/src/App.svelte Normal file
View file

@ -0,0 +1,102 @@
<script>
import { onMount } from 'svelte'
import { getSession, clearSession } from './db.js'
import { syncPull, startSSE, startSyncLoop } from './sync.js'
import Login from './pages/Login.svelte'
import Dashboard from './pages/Dashboard.svelte'
import Attendees from './pages/Attendees.svelte'
import Volunteers from './pages/Volunteers.svelte'
import Departments from './pages/Departments.svelte'
import Shifts from './pages/Shifts.svelte'
import Users from './pages/Users.svelte'
import Import from './pages/Import.svelte'
import Kiosk from './pages/Kiosk.svelte'
import GateUI from './pages/GateUI.svelte'
import ScheduleBoard from './pages/ScheduleBoard.svelte'
import Settings from './pages/Settings.svelte'
import Nav from './components/Nav.svelte'
import SyncStatus from './components/SyncStatus.svelte'
let session = $state(null)
let loading = $state(true)
let route = $state(window.location.hash || '#/')
// Check if this is a kiosk token URL before doing anything else
const kioskToken = $derived(window.location.hash.match(/^#\/v\/([A-Z0-9]+)/i)?.[1] ?? '')
onMount(async () => {
// Kiosk pages don't need auth
if (kioskToken) {
loading = false
return
}
session = await getSession()
loading = false
if (session) {
await syncPull()
startSSE()
startSyncLoop()
}
window.addEventListener('hashchange', () => {
route = window.location.hash || '#/'
})
})
function onLogin(s) {
session = s
window.location.hash = '#/'
syncPull().then(() => { startSSE(); startSyncLoop() })
}
async function onLogout() {
await clearSession()
session = null
window.location.hash = '#/login'
}
const path = $derived(route.replace(/^#/, '') || '/')
const role = $derived(session?.user?.role ?? '')
</script>
{#if loading}
<!-- checking session -->
{:else if kioskToken}
<Kiosk />
{:else if !session}
<Login onlogin={onLogin} />
{:else if role === 'gate'}
<!-- Gate users get the full-screen GateUI instead of the standard layout -->
<GateUI {session} {onLogout} />
{:else}
<div class="layout">
<Nav {session} {onLogout} active={path} />
<div class="main">
{#if path === '/' || path === ''}
{#if role === 'volunteer_lead'}
<ScheduleBoard {session} />
{:else}
<Dashboard {session} />
{/if}
{:else if path.startsWith('/attendees')}
<Attendees {session} />
{:else if path.startsWith('/volunteers')}
<Volunteers {session} />
{:else if path.startsWith('/departments')}
<Departments {session} />
{:else if path.startsWith('/shifts')}
<Shifts {session} />
{:else if path.startsWith('/schedule')}
<ScheduleBoard {session} />
{:else if path.startsWith('/users')}
<Users {session} />
{:else if path.startsWith('/import')}
<Import {session} />
{:else if path.startsWith('/settings')}
<Settings {session} />
{:else}
<div class="page"><p class="text-muted">Page not found.</p></div>
{/if}
<SyncStatus />
</div>
</div>
{/if}

140
frontend/src/api.js Normal file
View file

@ -0,0 +1,140 @@
import { db } from './db.js'
async function getToken() {
const session = await db.session.get(1)
return session?.token ?? null
}
export async function apiFetch(path, options = {}) {
const token = await getToken()
const headers = {}
// Don't set Content-Type for FormData — browser sets it with correct boundary
if (!(options.body instanceof FormData)) {
headers['Content-Type'] = 'application/json'
}
Object.assign(headers, options.headers)
if (token) headers['Authorization'] = `Bearer ${token}`
const res = await fetch(path, { ...options, headers })
if (res.status === 401) {
await db.session.clear()
window.location.hash = '#/login'
throw new Error('unauthorized')
}
return res
}
export async function apiJSON(path, options = {}) {
const res = await apiFetch(path, options)
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }))
throw new Error(err.error || res.statusText)
}
return res.json()
}
// Unauthenticated fetch for the kiosk (no JWT, no redirect on 401)
async function kioskFetch(path, options = {}) {
const headers = { 'Content-Type': 'application/json', ...options.headers }
const res = await fetch(path, { ...options, headers })
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }))
const e = new Error(err.error || res.statusText)
e.status = res.status
e.body = err
throw e
}
return res.json()
}
export const api = {
login: (username, password) =>
apiJSON('/api/login', { method: 'POST', body: JSON.stringify({ username, password }) }),
logout: () => apiFetch('/api/logout', { method: 'POST' }),
me: () => apiJSON('/api/me'),
event: {
get: () => apiJSON('/api/event'),
update: (data) => apiJSON('/api/event', { method: 'PUT', body: JSON.stringify(data) }),
},
attendees: {
list: (params = {}) => apiJSON('/api/attendees?' + new URLSearchParams(params)),
get: (id) => apiJSON(`/api/attendees/${id}`),
create: (data) => apiJSON('/api/attendees', { method: 'POST', body: JSON.stringify(data) }),
update: (id, data) => apiJSON(`/api/attendees/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
delete: (id) => apiFetch(`/api/attendees/${id}`, { method: 'DELETE' }),
checkIn: (id, opts = {}) =>
apiJSON(`/api/attendees/${id}/checkin`, { method: 'POST', body: JSON.stringify(opts) }),
generateTokens: () =>
apiJSON('/api/attendees/generate-tokens', { method: 'POST' }),
emailToken: (id) =>
apiJSON(`/api/attendees/${id}/email-token`, { method: 'POST' }),
emailAllTokens: () =>
apiJSON('/api/attendees/email-tokens', { method: 'POST' }),
},
volunteers: {
list: (params = {}) => apiJSON('/api/volunteers?' + new URLSearchParams(params)),
get: (id) => apiJSON(`/api/volunteers/${id}`),
create: (data) => apiJSON('/api/volunteers', { method: 'POST', body: JSON.stringify(data) }),
update: (id, data) => apiJSON(`/api/volunteers/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
delete: (id) => apiFetch(`/api/volunteers/${id}`, { method: 'DELETE' }),
checkIn: (id) => apiJSON(`/api/volunteers/${id}/checkin`, { method: 'POST' }),
assignShift: (id, shiftId) => apiFetch(`/api/volunteers/${id}/shifts`, { method: 'POST', body: JSON.stringify({ shift_id: shiftId }) }),
unassignShift: (id, shiftId) => apiFetch(`/api/volunteers/${id}/shifts/${shiftId}`, { method: 'DELETE' }),
},
departments: {
list: () => apiJSON('/api/departments'),
create: (data) => apiJSON('/api/departments', { method: 'POST', body: JSON.stringify(data) }),
update: (id, data) => apiJSON(`/api/departments/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
delete: (id) => apiFetch(`/api/departments/${id}`, { method: 'DELETE' }),
},
shifts: {
list: (params = {}) => apiJSON('/api/shifts?' + new URLSearchParams(params)),
create: (data) => apiJSON('/api/shifts', { method: 'POST', body: JSON.stringify(data) }),
update: (id, data) => apiJSON(`/api/shifts/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
delete: (id) => apiFetch(`/api/shifts/${id}`, { method: 'DELETE' }),
assignVolunteer: (shiftId, volunteerId, force = false) =>
apiFetch(`/api/shifts/${shiftId}/volunteers`, { method: 'POST', body: JSON.stringify({ volunteer_id: volunteerId, force }) }),
unassignVolunteer: (shiftId, volunteerId) =>
apiFetch(`/api/shifts/${shiftId}/volunteers/${volunteerId}`, { method: 'DELETE' }),
reorder: (positions) =>
apiFetch('/api/shifts/reorder', { method: 'POST', body: JSON.stringify(positions) }),
},
users: {
list: () => apiJSON('/api/users'),
create: (data) => apiJSON('/api/users', { method: 'POST', body: JSON.stringify(data) }),
update: (id, data) => apiJSON(`/api/users/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
delete: (id) => apiFetch(`/api/users/${id}`, { method: 'DELETE' }),
},
settings: {
get: () => apiJSON('/api/settings'),
update: (data) => apiJSON('/api/settings', { method: 'PUT', body: JSON.stringify(data) }),
testEmail: (to) => apiJSON('/api/settings/test-email', { method: 'POST', body: JSON.stringify({ to }) }),
},
import: async (formData) => {
const res = await apiFetch('/api/import', { method: 'POST', body: formData })
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }))
throw new Error(err.error || res.statusText)
}
return res.json()
},
sync: {
pull: (since) => apiJSON('/api/sync/pull' + (since ? `?since=${encodeURIComponent(since)}` : '')),
},
kiosk: {
get: (token) => kioskFetch(`/api/v/${token}`),
// claim returns {conflict: true, conflicting_shifts: [...]} on 409, or the updated kiosk state on success.
claim: async (token, shiftId, force = false) => {
const res = await fetch(`/api/v/${token}/shifts/${shiftId}${force ? '?force=true' : ''}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
})
const body = await res.json().catch(() => ({}))
if (res.status === 409) return { conflict: true, ...body }
if (!res.ok) throw new Error(body.error || res.statusText)
return body
},
unclaim: (token, shiftId) =>
kioskFetch(`/api/v/${token}/shifts/${shiftId}`, { method: 'DELETE' }),
},
}

177
frontend/src/app.css Normal file
View file

@ -0,0 +1,177 @@
:root {
--c-bg: #0f1117;
--c-surface: #1a1d27;
--c-border: #2a2d3a;
--c-text: #e2e4ed;
--c-muted: #7a7f96;
--c-accent: #6366f1;
--c-accent-h: #818cf8;
--c-success: #22c55e;
--c-warn: #f59e0b;
--c-danger: #ef4444;
--radius: 6px;
--radius-lg: 10px;
--font: system-ui, -apple-system, sans-serif;
--transition: 150ms ease;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
height: 100%;
background: var(--c-bg);
color: var(--c-text);
font-family: var(--font);
font-size: 15px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
a { color: var(--c-accent); text-decoration: none; }
a:hover { color: var(--c-accent-h); }
/* Layout */
.layout { display: flex; height: 100vh; overflow: hidden; }
.sidebar {
width: 220px; flex-shrink: 0;
background: var(--c-surface);
border-right: 1px solid var(--c-border);
display: flex; flex-direction: column;
padding: 1.5rem 0;
}
.sidebar-brand {
font-size: 1.1rem; font-weight: 700; letter-spacing: -0.02em;
padding: 0 1.25rem 1.25rem;
border-bottom: 1px solid var(--c-border);
margin-bottom: 0.5rem;
color: var(--c-text);
}
.sidebar-brand span { color: var(--c-accent); }
.nav-link {
display: flex; align-items: center; gap: 0.6rem;
padding: 0.5rem 1.25rem;
color: var(--c-muted); font-size: 0.9rem;
transition: color var(--transition), background var(--transition);
}
.nav-link:hover { color: var(--c-text); background: rgba(255,255,255,0.04); }
.nav-link.active { color: var(--c-text); background: rgba(99,102,241,0.12); }
.main { flex: 1; overflow-y: auto; display: flex; flex-direction: column; }
.page { padding: 2rem; flex: 1; }
.page-header {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 1.5rem;
}
.page-title { font-size: 1.4rem; font-weight: 700; letter-spacing: -0.02em; }
/* Cards */
.card { background: var(--c-surface); border: 1px solid var(--c-border); border-radius: var(--radius-lg); padding: 1.25rem; }
/* Stats */
.stats { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 1rem; margin-bottom: 1.5rem; }
.stat { background: var(--c-surface); border: 1px solid var(--c-border); border-radius: var(--radius-lg); padding: 1.1rem 1.25rem; }
.stat-label { font-size: 0.78rem; color: var(--c-muted); text-transform: uppercase; letter-spacing: 0.06em; }
.stat-value { font-size: 2rem; font-weight: 700; margin-top: 0.2rem; letter-spacing: -0.03em; }
/* Buttons */
.btn {
display: inline-flex; align-items: center; gap: 0.4rem;
padding: 0.45rem 1rem; border-radius: var(--radius);
border: 1px solid transparent;
font-size: 0.875rem; font-weight: 500; font-family: var(--font);
cursor: pointer; white-space: nowrap;
transition: background var(--transition), border-color var(--transition), opacity var(--transition);
}
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-primary { background: var(--c-accent); color: #fff; }
.btn-primary:hover:not(:disabled) { background: var(--c-accent-h); }
.btn-ghost { background: transparent; color: var(--c-muted); border-color: var(--c-border); }
.btn-ghost:hover:not(:disabled) { color: var(--c-text); border-color: var(--c-text); }
.btn-danger { background: transparent; color: var(--c-danger); border-color: var(--c-danger); }
.btn-danger:hover:not(:disabled) { background: var(--c-danger); color: #fff; }
.btn-success { background: var(--c-success); color: #000; font-weight: 600; }
.btn-success:hover:not(:disabled) { filter: brightness(1.1); }
.btn-sm { padding: 0.3rem 0.65rem; font-size: 0.8rem; }
/* Forms */
.form-group { display: flex; flex-direction: column; gap: 0.35rem; margin-bottom: 1rem; }
label { font-size: 0.82rem; color: var(--c-muted); font-weight: 500; }
input, select, textarea {
background: var(--c-bg); border: 1px solid var(--c-border);
border-radius: var(--radius); color: var(--c-text);
font-size: 0.9rem; padding: 0.5rem 0.75rem;
width: 100%; font-family: var(--font);
transition: border-color var(--transition);
}
input:focus, select:focus, textarea:focus { outline: none; border-color: var(--c-accent); }
input::placeholder { color: var(--c-muted); }
/* Search */
.search-bar { display: flex; gap: 0.5rem; align-items: center; margin-bottom: 1rem; }
.search-bar input { max-width: 320px; }
/* Table */
.table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; font-size: 0.875rem; }
th {
text-align: left; font-size: 0.75rem; font-weight: 600;
color: var(--c-muted); text-transform: uppercase; letter-spacing: 0.06em;
padding: 0.6rem 1rem; border-bottom: 1px solid var(--c-border);
}
td { padding: 0.75rem 1rem; border-bottom: 1px solid var(--c-border); vertical-align: middle; }
tr:last-child td { border-bottom: none; }
tr:hover td { background: rgba(255,255,255,0.02); }
/* Badges */
.badge {
display: inline-flex; align-items: center;
padding: 0.18rem 0.55rem; border-radius: 99px;
font-size: 0.72rem; font-weight: 600;
text-transform: uppercase; letter-spacing: 0.04em;
}
.badge-checked { background: rgba(34,197,94,0.15); color: var(--c-success); }
.badge-unchecked { background: rgba(122,127,150,0.15); color: var(--c-muted); }
.badge-role { background: rgba(99,102,241,0.15); color: var(--c-accent-h); }
.badge-lead { background: rgba(245,158,11,0.15); color: var(--c-warn); }
/* Alerts */
.alert { padding: 0.75rem 1rem; border-radius: var(--radius); font-size: 0.875rem; margin-bottom: 1rem; }
.alert-error { background: rgba(239,68,68,0.12); border: 1px solid rgba(239,68,68,0.3); color: #fca5a5; }
.alert-success { background: rgba(34,197,94,0.12); border: 1px solid rgba(34,197,94,0.3); color: #86efac; }
/* Sync indicator */
.sync-bar {
display: flex; align-items: center; gap: 0.5rem;
padding: 0.5rem 1.25rem; margin-top: auto;
font-size: 0.78rem; color: var(--c-muted);
border-top: 1px solid var(--c-border);
}
.sync-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; background: var(--c-muted); }
.sync-dot.online { background: var(--c-success); }
.sync-dot.syncing { background: var(--c-warn); animation: pulse 1s infinite; }
.sync-dot.offline { background: var(--c-danger); }
/* Login */
.login-wrap { min-height: 100vh; display: flex; align-items: center; justify-content: center; }
.login-box { width: 100%; max-width: 380px; }
.login-title { font-size: 1.5rem; font-weight: 700; margin-bottom: 0.25rem; }
.login-sub { color: var(--c-muted); font-size: 0.875rem; margin-bottom: 2rem; }
/* Misc */
.dept-dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
.empty { text-align: center; padding: 3rem 1rem; color: var(--c-muted); }
.empty p { margin-top: 0.5rem; font-size: 0.875rem; }
.text-muted { color: var(--c-muted); }
.text-success { color: var(--c-success); }
.text-danger { color: var(--c-danger); }
.flex { display: flex; align-items: center; }
.spacer { flex: 1; }
.actions { display: flex; gap: 0.4rem; align-items: center; }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
@media (max-width: 640px) {
.sidebar { display: none; }
.page { padding: 1rem; }
.stats { grid-template-columns: repeat(2, 1fr); }
}

View file

@ -0,0 +1,13 @@
<script>
let { onclick } = $props()
let loading = $state(false)
async function handle() {
loading = true
try { await onclick() } finally { loading = false }
}
</script>
<button class="btn btn-success btn-sm" onclick={handle} disabled={loading}>
{loading ? '…' : '✓ Check in'}
</button>

View file

@ -0,0 +1,57 @@
<script>
let { session, active, onLogout } = $props()
const role = $derived(session?.user?.role ?? '')
// Role-specific nav sets
const links = $derived.by(() => {
if (role === 'ticketing') return [
{ href: '#/attendees', label: 'Attendees', icon: '✓' },
{ href: '#/import', label: 'Import', icon: '↑' },
]
if (role === 'volunteer_lead') return [
{ href: '#/', label: 'Schedule', icon: '◷' },
{ href: '#/volunteers', label: 'Volunteers', icon: '◎' },
{ href: '#/departments', label: 'Departments', icon: '⬡' },
]
if (role === 'coordinator') return [
{ href: '#/', label: 'Dashboard', icon: '⊞' },
{ href: '#/schedule', label: 'Schedule', icon: '◷' },
{ href: '#/volunteers', label: 'Volunteers', icon: '◎' },
{ href: '#/departments', label: 'Departments', icon: '⬡' },
{ href: '#/shifts', label: 'Shifts', icon: '◑' },
]
// admin — all links
return [
{ href: '#/', label: 'Dashboard', icon: '⊞' },
{ href: '#/attendees', label: 'Attendees', icon: '✓' },
{ href: '#/volunteers', label: 'Volunteers', icon: '◎' },
{ href: '#/departments', label: 'Departments', icon: '⬡' },
{ href: '#/shifts', label: 'Shifts', icon: '◑' },
{ href: '#/schedule', label: 'Schedule', icon: '◷' },
{ href: '#/import', label: 'Import', icon: '↑' },
{ href: '#/users', label: 'Users', icon: '⊕' },
{ href: '#/settings', label: 'Settings', icon: '⚙' },
]
})
function isActive(href) {
const p = href.replace(/^#/, '')
if (p === '/') return active === '/' || active === ''
return active.startsWith(p)
}
</script>
<nav class="sidebar">
<div class="sidebar-brand">Turn<span>pike</span></div>
{#each links as link}
<a href={link.href} class="nav-link" class:active={isActive(link.href)}>
<span class="icon">{link.icon}</span>
{link.label}
</a>
{/each}
<div style="flex:1"></div>
<button class="nav-link btn-ghost" style="border:none;cursor:pointer;width:100%;text-align:left" onclick={onLogout}>
<span class="icon"></span> Sign out
</button>
</nav>

View file

@ -0,0 +1,46 @@
<script>
import { onMount, onDestroy } from 'svelte'
import { getLastSync } from '../db.js'
import { syncPull } from '../sync.js'
let online = $state(navigator.onLine)
let syncing = $state(false)
let lastSync = $state('')
async function refresh() {
lastSync = await getLastSync()
}
async function manualSync() {
syncing = true
await syncPull()
await refresh()
syncing = false
}
function onOnline() { online = true; manualSync() }
function onOffline() { online = false }
onMount(() => {
refresh()
window.addEventListener('online', onOnline)
window.addEventListener('offline', onOffline)
})
onDestroy(() => {
window.removeEventListener('online', onOnline)
window.removeEventListener('offline', onOffline)
})
const dotClass = $derived(syncing ? 'syncing' : online ? 'online' : 'offline')
const label = $derived(syncing ? 'Syncing…' : online ? 'Online' : 'Offline')
const lastSyncLabel = $derived(lastSync ? new Date(lastSync).toLocaleTimeString() : 'Never')
</script>
<div class="sync-bar">
<div class="sync-dot {dotClass}"></div>
<span>{label}</span>
<span class="text-muted" style="margin-left:auto;font-size:0.72rem">Last sync: {lastSyncLabel}</span>
{#if online && !syncing}
<button class="btn btn-ghost btn-sm" onclick={manualSync}>↻</button>
{/if}
</div>

49
frontend/src/db.js Normal file
View file

@ -0,0 +1,49 @@
import Dexie from 'dexie'
export const db = new Dexie('turnpike')
db.version(1).stores({
session: 'id, token, user',
meta: 'key',
event: 'id',
attendees: 'id, name, ticket_type, checked_in, deleted_at',
departments: 'id, name, deleted_at',
volunteers: 'id, name, department_id, checked_in, attendee_id, deleted_at',
shifts: 'id, department_id, day, deleted_at',
volunteer_shifts: '[volunteer_id+shift_id], volunteer_id, shift_id',
outbox: '++id, table, op, synced_at',
})
db.version(2).stores({
session: 'id, token, user',
meta: 'key',
event: 'id',
attendees: 'id, name, ticket_type, checked_in, volunteer_token, deleted_at',
departments: 'id, name, deleted_at',
volunteers: 'id, name, department_id, checked_in, attendee_id, deleted_at',
shifts: 'id, department_id, day, position, deleted_at',
volunteer_shifts: '[volunteer_id+shift_id], volunteer_id, shift_id',
outbox: '++id, table, op, synced_at',
})
export async function getLastSync() {
const m = await db.meta.get('last_sync')
return m?.value ?? ''
}
export async function setLastSync(ts) {
await db.meta.put({ key: 'last_sync', value: ts })
}
export async function getSession() {
return db.session.get(1)
}
export async function saveSession(token, user) {
await db.session.put({ id: 1, token, user })
}
export async function clearSession() {
await db.session.clear()
await db.meta.clear()
}

9
frontend/src/main.js Normal file
View file

@ -0,0 +1,9 @@
import { mount } from 'svelte'
import './app.css'
import App from './App.svelte'
const app = mount(App, {
target: document.getElementById('app'),
})
export default app

View file

@ -0,0 +1,274 @@
<script>
import { liveQuery } from 'dexie'
import { db } from '../db.js'
import { api } from '../api.js'
import CheckInButton from '../components/CheckInButton.svelte'
let { session } = $props()
let search = $state('')
let filterType = $state('')
let filterChecked = $state('')
let error = $state('')
let success = $state('')
let showAdd = $state(false)
let newName = $state('')
let newEmail = $state('')
let newPhone = $state('')
let newTicketID = $state('')
let newTicketType = $state('')
let newNote = $state('')
let adding = $state(false)
let generating = $state(false)
let emailing = $state(false)
const role = $derived(session?.user?.role ?? '')
const canManage = $derived(['admin', 'ticketing'].includes(role))
const canCheckIn = $derived(['admin', 'ticketing', 'gate'].includes(role))
const allAttendees = liveQuery(() => db.attendees.toArray())
const ticketTypes = liveQuery(() =>
db.attendees.orderBy('ticket_type').uniqueKeys()
)
const filtered = $derived.by(() => {
const list = $allAttendees ?? []
const s = search.toLowerCase()
return list
.filter(a => {
if (filterType && a.ticket_type !== filterType) return false
if (filterChecked === 'true' && !a.checked_in) return false
if (filterChecked === 'false' && a.checked_in) return false
if (s && !a.name.toLowerCase().includes(s) &&
!a.email.toLowerCase().includes(s) &&
!a.ticket_id.toLowerCase().includes(s)) return false
return true
})
.sort((a, b) => a.name.localeCompare(b.name))
})
async function checkIn(attendee) {
try {
const result = await api.attendees.checkIn(attendee.id)
if (result.attendee) await db.attendees.put(result.attendee)
} catch (err) {
error = err.message
}
}
async function addAttendee(e) {
e.preventDefault()
adding = true
error = ''
try {
const a = await api.attendees.create({
name: newName, email: newEmail, phone: newPhone,
ticket_id: newTicketID, ticket_type: newTicketType, note: newNote,
})
await db.attendees.put(a)
showAdd = false
newName = newEmail = newPhone = newTicketID = newTicketType = newNote = ''
} catch (err) {
error = err.message
} finally {
adding = false
}
}
async function generateTokens() {
generating = true
error = ''
success = ''
try {
const result = await api.attendees.generateTokens()
success = `Generated ${result.generated} token${result.generated !== 1 ? 's' : ''}.`
} catch (err) {
error = err.message
} finally {
generating = false
}
}
async function emailAll() {
if (!confirm('Send token emails to all attendees with a token and email address?')) return
emailing = true
error = ''
success = ''
try {
const result = await api.attendees.emailAllTokens()
success = `Sent ${result.sent} email${result.sent !== 1 ? 's' : ''}${result.skipped ? `, skipped ${result.skipped}` : ''}.`
if (result.errors?.length) error = result.errors.join('; ')
} catch (err) {
error = err.message
} finally {
emailing = false
}
}
async function emailToken(attendee) {
error = ''
success = ''
try {
await api.attendees.emailToken(attendee.id)
success = `Token email sent to ${attendee.name}.`
} catch (err) {
error = err.message
}
}
</script>
<div class="page">
<div class="page-header">
<h1 class="page-title">Attendees</h1>
<div class="actions">
{#if canManage}
<button class="btn btn-primary btn-sm" onclick={() => showAdd = !showAdd}>+ Add</button>
<a href="/api/attendees/export" class="btn btn-ghost btn-sm">Export CSV</a>
<button class="btn btn-ghost btn-sm" onclick={generateTokens} disabled={generating}>
{generating ? '…' : '⚿ Tokens'}
</button>
<a href="/api/attendees/export-tokens" class="btn btn-ghost btn-sm">Export Links</a>
<button class="btn btn-ghost btn-sm" onclick={emailAll} disabled={emailing}>
{emailing ? '…' : '✉ Email All'}
</button>
{/if}
</div>
</div>
{#if error}
<div class="alert alert-error">{error}</div>
{/if}
{#if success}
<div class="alert alert-success">{success}</div>
{/if}
{#if showAdd && canManage}
<div class="card" style="margin-bottom:1.5rem">
<form onsubmit={addAttendee}>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem">
<div class="form-group">
<label for="new-name">Name *</label>
<input id="new-name" bind:value={newName} required placeholder="Full name" />
</div>
<div class="form-group">
<label for="new-email">Email</label>
<input id="new-email" type="email" bind:value={newEmail} placeholder="email@example.com" />
</div>
<div class="form-group">
<label for="new-ticket-id">Ticket ID</label>
<input id="new-ticket-id" bind:value={newTicketID} placeholder="From ticketing system" />
</div>
<div class="form-group">
<label for="new-ticket-type">Ticket type</label>
<input id="new-ticket-type" bind:value={newTicketType} placeholder="e.g. General, VIP" />
</div>
</div>
<div class="form-group">
<label for="new-note">Note</label>
<input id="new-note" bind:value={newNote} placeholder="Optional note" />
</div>
<div class="actions">
<button type="submit" class="btn btn-primary" disabled={adding}>
{adding ? 'Adding…' : 'Add attendee'}
</button>
<button type="button" class="btn btn-ghost" onclick={() => showAdd = false}>Cancel</button>
</div>
</form>
</div>
{/if}
<div class="search-bar">
<input placeholder="Search name, email, ticket ID…" bind:value={search} />
{#if ($ticketTypes ?? []).length > 0}
<select bind:value={filterType} style="width:auto">
<option value="">All types</option>
{#each $ticketTypes ?? [] as t}
<option value={t}>{t}</option>
{/each}
</select>
{/if}
<select bind:value={filterChecked} style="width:auto">
<option value="">All</option>
<option value="false">Not checked in</option>
<option value="true">Checked in</option>
</select>
<span class="text-muted" style="font-size:0.85rem;white-space:nowrap">
{filtered.length} shown
</span>
</div>
{#if ($allAttendees ?? []).length === 0}
<div class="empty">
<strong>No attendees yet</strong>
<p>Import a CSV or add attendees manually.</p>
</div>
{:else}
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Name</th>
<th>Ticket type</th>
<th>Email</th>
<th>Status</th>
{#if canCheckIn}<th></th>{/if}
</tr>
</thead>
<tbody>
{#each filtered as a (a.id)}
<tr>
<td>
<strong>{a.name}</strong>
{#if a.ticket_id}
<span class="text-muted" style="font-size:0.8rem"> · {a.ticket_id}</span>
{/if}
{#if (a.party_size ?? 1) > 1}
<span class="badge badge-lead" style="margin-left:0.3rem">×{a.party_size}</span>
{/if}
{#if a.note}
<div class="text-muted" style="font-size:0.78rem">{a.note}</div>
{/if}
</td>
<td class="text-muted">{a.ticket_type || '—'}</td>
<td>
<div>{a.email || '—'}</div>
{#if a.volunteer_token && canManage}
<div style="font-size:0.75rem;margin-top:0.15rem">
<code style="color:var(--c-accent-h)">{a.volunteer_token}</code>
{#if a.email}
<button class="btn btn-ghost btn-sm" style="padding:0.1rem 0.4rem;margin-left:0.25rem"
onclick={() => emailToken(a)}>✉</button>
{/if}
</div>
{/if}
</td>
<td>
{#if (a.party_size ?? 1) > 1}
<span class="badge {a.checked_in ? 'badge-checked' : 'badge-unchecked'}">
{a.checked_in_count ?? 0}/{a.party_size} in
</span>
{:else}
<span class="badge {a.checked_in ? 'badge-checked' : 'badge-unchecked'}">
{a.checked_in ? 'Checked in' : 'Pending'}
</span>
{/if}
{#if a.checked_in_at}
<div class="text-muted" style="font-size:0.75rem">
{new Date(a.checked_in_at).toLocaleTimeString()}
</div>
{/if}
</td>
{#if canCheckIn}
<td>
{#if (a.checked_in_count ?? 0) < (a.party_size ?? 1)}
<CheckInButton onclick={() => checkIn(a)} />
{/if}
</td>
{/if}
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>

View file

@ -0,0 +1,62 @@
<script>
import { liveQuery } from 'dexie'
import { db } from '../db.js'
let { session } = $props()
const attendees = liveQuery(() => db.attendees.toArray())
const event = liveQuery(() => db.event.get(1))
const total = $derived(($attendees ?? []).length)
const checkedIn = $derived(($attendees ?? []).filter(a => a.checked_in).length)
const remaining = $derived(total - checkedIn)
const pct = $derived(total > 0 ? Math.round((checkedIn / total) * 100) : 0)
</script>
<div class="page">
<div class="page-header">
<h1 class="page-title">{$event?.name ?? 'Event'}</h1>
{#if $event?.venue}
<span class="text-muted">{$event.venue}</span>
{/if}
</div>
{#if $event?.start_date}
<p class="text-muted" style="margin-bottom:1.5rem">
{$event.start_date}{$event.end_date !== $event.start_date ? ` ${$event.end_date}` : ''}
{#if $event.timezone} · {$event.timezone}{/if}
</p>
{/if}
<div class="stats">
<div class="stat">
<div class="stat-label">Total</div>
<div class="stat-value">{total}</div>
</div>
<div class="stat">
<div class="stat-label">Checked in</div>
<div class="stat-value" style="color:var(--c-success)">{checkedIn}</div>
</div>
<div class="stat">
<div class="stat-label">Remaining</div>
<div class="stat-value">{remaining}</div>
</div>
<div class="stat">
<div class="stat-label">Progress</div>
<div class="stat-value">{pct}%</div>
</div>
</div>
{#if total > 0}
<div class="card" style="margin-bottom:1rem">
<div style="height:8px;background:var(--c-border);border-radius:99px;overflow:hidden">
<div style="height:100%;width:{pct}%;background:var(--c-success);border-radius:99px;transition:width 0.4s ease"></div>
</div>
</div>
{/if}
<p class="text-muted" style="font-size:0.85rem">
Welcome, <strong style="color:var(--c-text)">{session?.user?.username}</strong>
· <span class="badge badge-role">{session?.user?.role}</span>
</p>
</div>

View file

@ -0,0 +1,190 @@
<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 newName = $state('')
let newColor = $state('#6366f1')
let newDesc = $state('')
let editID = $state(null)
let editName = $state('')
let editColor = $state('#6366f1')
let editDesc = $state('')
let saving = $state(false)
const role = $derived(session?.user?.role ?? '')
const canCreate = $derived(['admin', 'coordinator'].includes(role))
const canDelete = $derived(role === 'admin')
const allDepts = liveQuery(() =>
db.departments.filter(d => !d.deleted_at).toArray()
.then(arr => arr.sort((a, b) => a.name.localeCompare(b.name)))
)
async function addDept(e) {
e.preventDefault()
adding = true
error = ''
try {
const d = await api.departments.create({ name: newName, color: newColor, description: newDesc })
await db.departments.put(d)
showAdd = false
newName = newDesc = ''
newColor = '#6366f1'
} catch (err) {
error = err.message
} finally {
adding = false
}
}
function startEdit(d) {
editID = d.id
editName = d.name
editColor = d.color || '#6366f1'
editDesc = d.description || ''
}
function cancelEdit() {
editID = null
}
async function saveDept(d) {
if (!editName.trim()) return
saving = true
error = ''
try {
const updated = await api.departments.update(d.id, {
name: editName, color: editColor, description: editDesc,
})
await db.departments.put(updated)
editID = null
} catch (err) {
error = err.message
} finally {
saving = false
}
}
async function deleteDept(d) {
if (!confirm(`Delete department "${d.name}"? Volunteers in this department will be unassigned.`)) return
try {
await api.departments.delete(d.id)
await db.departments.delete(d.id)
} catch (err) {
error = err.message
}
}
</script>
<div class="page">
<div class="page-header">
<h1 class="page-title">Departments</h1>
{#if canCreate}
<div class="actions">
<button class="btn btn-primary btn-sm" onclick={() => showAdd = !showAdd}>+ Add</button>
</div>
{/if}
</div>
{#if error}
<div class="alert alert-error">{error}</div>
{/if}
{#if showAdd && canCreate}
<div class="card" style="margin-bottom:1.5rem">
<form onsubmit={addDept}>
<div style="display:grid;grid-template-columns:1fr 1fr auto;gap:1rem;align-items:end">
<div class="form-group" style="margin-bottom:0">
<label for="d-name">Name *</label>
<input id="d-name" bind:value={newName} required placeholder="e.g. Security" />
</div>
<div class="form-group" style="margin-bottom:0">
<label for="d-desc">Description</label>
<input id="d-desc" bind:value={newDesc} placeholder="Optional" />
</div>
<div class="form-group" style="margin-bottom:0">
<label for="d-color">Color</label>
<input id="d-color" type="color" bind:value={newColor} style="width:60px;padding:0.2rem;height:2.3rem;cursor:pointer" />
</div>
</div>
<div class="actions" style="margin-top:1rem">
<button type="submit" class="btn btn-primary" disabled={adding}>
{adding ? 'Adding…' : 'Add department'}
</button>
<button type="button" class="btn btn-ghost" onclick={() => showAdd = false}>Cancel</button>
</div>
</form>
</div>
{/if}
{#if ($allDepts ?? []).length === 0}
<div class="empty">
<strong>No departments yet</strong>
<p>Add departments to organize your volunteer teams.</p>
</div>
{:else}
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Department</th>
<th>Description</th>
{#if canCreate}<th></th>{/if}
</tr>
</thead>
<tbody>
{#each $allDepts ?? [] as d (d.id)}
{#if editID === d.id}
<tr>
<td>
<div style="display:flex;align-items:center;gap:0.5rem">
<input type="color" bind:value={editColor} style="width:36px;height:36px;padding:0.1rem;border-radius:4px;cursor:pointer;flex-shrink:0" />
<input bind:value={editName} required placeholder="Name" style="margin:0" />
</div>
</td>
<td>
<input bind:value={editDesc} placeholder="Description" style="margin:0" />
</td>
{#if canCreate}
<td>
<div class="actions">
<button class="btn btn-primary btn-sm" onclick={() => saveDept(d)} disabled={saving}>
{saving ? '…' : 'Save'}
</button>
<button class="btn btn-ghost btn-sm" onclick={cancelEdit}>Cancel</button>
</div>
</td>
{/if}
</tr>
{:else}
<tr>
<td>
<span class="dept-dot" style="background:{d.color};margin-right:0.5rem"></span>
<strong>{d.name}</strong>
</td>
<td class="text-muted">{d.description || '—'}</td>
{#if canCreate}
<td>
<div class="actions">
<button class="btn btn-ghost btn-sm" onclick={() => startEdit(d)}>Edit</button>
{#if canDelete}
<button class="btn btn-danger btn-sm" onclick={() => deleteDept(d)}>Delete</button>
{/if}
</div>
</td>
{/if}
</tr>
{/if}
{/each}
</tbody>
</table>
</div>
{/if}
</div>

View file

@ -0,0 +1,451 @@
<script>
import { onMount, onDestroy } from 'svelte'
import { liveQuery } from 'dexie'
import { db } from '../db.js'
import { api } from '../api.js'
let { session, onLogout } = $props()
let search = $state('')
let error = $state('')
let scannerMsg = $state('')
let qrSupported = $state(false)
let scanning = $state(false)
let videoRef = $state(null)
let stream = $state(null)
let detector = $state(null)
let scanInterval = $state(null)
const attendees = liveQuery(() =>
db.attendees.filter(a => !a.deleted_at).toArray()
)
const recentCheckIns = liveQuery(() =>
db.attendees
.filter(a => a.checked_in && !a.deleted_at)
.toArray()
.then(arr => arr
.filter(a => a.checked_in_at)
.sort((a, b) => b.checked_in_at.localeCompare(a.checked_in_at))
.slice(0, 10)
)
)
const filtered = $derived.by(() => {
const s = search.trim().toLowerCase()
if (!s || s.length < 2) return []
return ($attendees ?? [])
.filter(a => a.name.toLowerCase().includes(s) || a.ticket_id?.toLowerCase().includes(s) || a.email?.toLowerCase().includes(s))
.sort((a, b) => a.name.localeCompare(b.name))
.slice(0, 8)
})
const selected = $derived.by(() => {
if (filtered.length === 1) return filtered[0]
const s = search.trim().toLowerCase()
return filtered.find(a => a.ticket_id?.toLowerCase() === s) ?? null
})
onMount(() => {
qrSupported = 'BarcodeDetector' in window
})
onDestroy(() => {
stopScanner()
})
async function toggleScanner() {
if (scanning) {
stopScanner()
} else {
await startScanner()
}
}
async function startScanner() {
scannerMsg = ''
try {
stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' } })
if (videoRef) videoRef.srcObject = stream
detector = new BarcodeDetector({ formats: ['qr_code'] })
scanning = true
scanInterval = setInterval(doScan, 150)
} catch (err) {
scannerMsg = 'Camera access denied or unavailable.'
}
}
function stopScanner() {
clearInterval(scanInterval)
scanInterval = null
stream?.getTracks().forEach(t => t.stop())
stream = null
scanning = false
if (videoRef) videoRef.srcObject = null
}
async function doScan() {
if (!detector || !videoRef || videoRef.readyState < 2) return
try {
const codes = await detector.detect(videoRef)
if (codes.length > 0) {
const val = codes[0].rawValue
search = val
stopScanner()
}
} catch {}
}
async function checkIn(attendee, count = 1) {
error = ''
try {
const result = await api.attendees.checkIn(attendee.id, { count })
if (result.attendee) {
await db.attendees.put(result.attendee)
}
} catch (err) {
error = err.message
}
}
async function checkInWithVolunteer(attendee) {
error = ''
try {
const result = await api.attendees.checkIn(attendee.id, { count: 1, also_volunteer: true })
if (result.attendee) await db.attendees.put(result.attendee)
if (result.volunteer) await db.volunteers.put(result.volunteer)
} catch (err) {
error = err.message
}
}
function remaining(a) {
return (a.party_size ?? 1) - (a.checked_in_count ?? 0)
}
function progressLabel(a) {
const ps = a.party_size ?? 1
const ci = a.checked_in_count ?? 0
if (ps <= 1) return null
return `${ci}/${ps} checked in`
}
function fmt(ts) {
if (!ts) return ''
return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
}
</script>
<div class="gate">
<div class="gate-header">
<div class="gate-brand">Turn<span>pike</span> &nbsp;<span class="gate-role">Gate Check-in</span></div>
<button class="gate-logout" onclick={onLogout}>Sign out</button>
</div>
<div class="gate-body">
<!-- Search + QR -->
<div class="gate-search-row">
<input
class="gate-search"
placeholder="Search name, ticket ID, or scan QR…"
bind:value={search}
/>
{#if qrSupported}
<button class="gbtn {scanning ? 'gbtn-danger' : 'gbtn-ghost'}" onclick={toggleScanner}>
{scanning ? '■ Stop' : '⊡ Scan QR'}
</button>
{/if}
</div>
{#if scanning}
<div class="gate-scanner">
<video bind:this={videoRef} autoplay playsinline muted class="gate-video"></video>
<div class="gate-scanner-hint">Point camera at QR code on ticket</div>
</div>
{/if}
{#if scannerMsg}
<div class="gate-msg gate-msg-warn">{scannerMsg}</div>
{/if}
{#if error}
<div class="gate-msg gate-msg-error">{error}</div>
{/if}
<!-- Matched attendee card -->
{#if selected}
{@const rem = remaining(selected)}
{@const prog = progressLabel(selected)}
<div class="gate-match">
<div class="gate-match-name">{selected.name}</div>
{#if selected.ticket_type}
<div class="gate-match-sub">{selected.ticket_type}</div>
{/if}
{#if selected.ticket_id}
<div class="gate-match-sub text-muted">#{selected.ticket_id}</div>
{/if}
{#if prog}
<div class="gate-party">
<span class="gate-party-label">{prog}</span>
</div>
{/if}
<div class="gate-match-actions">
{#if rem > 0}
<button class="gbtn gbtn-success" onclick={() => checkIn(selected, 1)}>
✓ Check in 1
</button>
{#if rem > 1}
<button class="gbtn gbtn-ghost" onclick={() => checkIn(selected, rem)}>
Check in all {rem}
</button>
{/if}
{:else}
<span class="gate-done">All checked in</span>
{/if}
{#if selected.volunteer_token && !selected.checked_in}
<button class="gbtn gbtn-ghost" onclick={() => checkInWithVolunteer(selected)}>
+ Volunteer
</button>
{/if}
</div>
</div>
{:else if search.trim().length >= 2 && filtered.length > 1}
<!-- Multiple results list -->
<div class="gate-results">
{#each filtered as a}
<button class="gate-result-row" onclick={() => search = a.ticket_id || a.name}>
<span>
<strong>{a.name}</strong>
{#if a.ticket_type} · {a.ticket_type}{/if}
</span>
<span class="badge {a.checked_in ? 'badge-checked' : 'badge-unchecked'}">
{a.checked_in ? 'In' : 'Pending'}
</span>
</button>
{/each}
</div>
{:else if search.trim().length >= 2 && filtered.length === 0}
<div class="gate-msg gate-msg-warn">No matching attendees found.</div>
{/if}
<!-- Recent check-ins -->
<div class="gate-recent">
<div class="gate-recent-title">Recent Check-ins</div>
{#if ($recentCheckIns ?? []).length === 0}
<div class="gate-recent-empty">No check-ins yet today.</div>
{:else}
{#each $recentCheckIns ?? [] as a}
<div class="gate-recent-row">
<span>{a.name}</span>
<span class="text-muted">{fmt(a.checked_in_at)}</span>
</div>
{/each}
{/if}
</div>
</div>
</div>
<style>
.gate {
min-height: 100vh;
display: flex;
flex-direction: column;
background: var(--c-bg);
color: var(--c-text);
font-family: var(--font);
}
.gate-header {
background: var(--c-surface);
border-bottom: 1px solid var(--c-border);
padding: 0.85rem 1.5rem;
display: flex;
align-items: center;
justify-content: space-between;
}
.gate-brand {
font-size: 1.1rem;
font-weight: 700;
letter-spacing: -0.02em;
}
.gate-brand span:first-of-type { color: var(--c-accent); }
.gate-role {
font-size: 0.8rem;
font-weight: 400;
color: var(--c-muted);
letter-spacing: 0;
}
.gate-logout {
background: none;
border: none;
color: var(--c-muted);
cursor: pointer;
font-size: 0.8rem;
font-family: var(--font);
padding: 0.3rem 0.6rem;
border-radius: 4px;
}
.gate-logout:hover { color: var(--c-text); background: rgba(255,255,255,0.05); }
.gate-body {
max-width: 680px;
margin: 0 auto;
padding: 1.5rem 1rem;
width: 100%;
}
.gate-search-row {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
}
.gate-search {
flex: 1;
font-size: 1.1rem;
padding: 0.7rem 1rem;
background: var(--c-surface);
border: 1px solid var(--c-border);
border-radius: 8px;
color: var(--c-text);
font-family: var(--font);
}
.gate-search:focus { outline: none; border-color: var(--c-accent); }
.gate-scanner {
margin-bottom: 1rem;
border-radius: 8px;
overflow: hidden;
border: 1px solid var(--c-border);
position: relative;
}
.gate-video {
width: 100%;
display: block;
max-height: 280px;
object-fit: cover;
background: #000;
}
.gate-scanner-hint {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: rgba(0,0,0,0.6);
color: #fff;
text-align: center;
padding: 0.4rem;
font-size: 0.78rem;
}
.gate-msg {
padding: 0.7rem 1rem;
border-radius: 6px;
font-size: 0.875rem;
margin-bottom: 1rem;
}
.gate-msg-error { background: rgba(239,68,68,0.12); border: 1px solid rgba(239,68,68,0.3); color: #fca5a5; }
.gate-msg-warn { background: rgba(245,158,11,0.1); border: 1px solid rgba(245,158,11,0.3); color: var(--c-warn); }
.gate-match {
background: var(--c-surface);
border: 1px solid var(--c-accent);
border-radius: 10px;
padding: 1.25rem;
margin-bottom: 1rem;
}
.gate-match-name { font-size: 1.4rem; font-weight: 700; margin-bottom: 0.2rem; }
.gate-match-sub { color: var(--c-muted); font-size: 0.875rem; }
.gate-party {
margin: 0.5rem 0;
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.gate-party-label {
font-size: 0.85rem;
font-weight: 600;
color: var(--c-warn);
background: rgba(245,158,11,0.15);
padding: 0.2rem 0.6rem;
border-radius: 99px;
}
.gate-match-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
margin-top: 1rem;
}
.gate-done {
color: var(--c-success);
font-size: 0.875rem;
font-weight: 600;
display: flex;
align-items: center;
}
.gate-results {
background: var(--c-surface);
border: 1px solid var(--c-border);
border-radius: 8px;
overflow: hidden;
margin-bottom: 1rem;
}
.gate-result-row {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
background: none;
border: none;
border-bottom: 1px solid var(--c-border);
color: var(--c-text);
font-family: var(--font);
font-size: 0.875rem;
cursor: pointer;
text-align: left;
}
.gate-result-row:last-child { border-bottom: none; }
.gate-result-row:hover { background: rgba(255,255,255,0.04); }
.gate-recent { margin-top: 2rem; }
.gate-recent-title {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--c-muted);
margin-bottom: 0.5rem;
}
.gate-recent-empty { color: var(--c-muted); font-size: 0.875rem; }
.gate-recent-row {
display: flex;
justify-content: space-between;
padding: 0.4rem 0;
font-size: 0.875rem;
border-bottom: 1px solid var(--c-border);
}
.gate-recent-row:last-child { border-bottom: none; }
.gbtn {
display: inline-flex; align-items: center; gap: 0.4rem;
padding: 0.5rem 1rem; border-radius: 6px;
border: 1px solid transparent;
font-size: 0.875rem; font-weight: 500; cursor: pointer;
font-family: var(--font); white-space: nowrap;
transition: background 150ms, border-color 150ms;
}
.gbtn:disabled { opacity: 0.5; cursor: not-allowed; }
.gbtn-success { background: var(--c-success); color: #000; font-weight: 600; }
.gbtn-success:hover:not(:disabled) { filter: brightness(1.1); }
.gbtn-danger { background: var(--c-danger); color: #fff; }
.gbtn-danger:hover:not(:disabled) { filter: brightness(1.1); }
.gbtn-ghost { background: transparent; color: var(--c-muted); border-color: var(--c-border); }
.gbtn-ghost:hover:not(:disabled) { color: var(--c-text); border-color: var(--c-text); }
@media (max-width: 480px) {
.gate-match-name { font-size: 1.2rem; }
.gate-search { font-size: 1rem; }
}
</style>

View file

@ -0,0 +1,96 @@
<script>
import { api } from '../api.js'
import { syncPull } from '../sync.js'
let { session } = $props()
let file = $state(null)
let importing = $state(false)
let result = $state(null)
let error = $state('')
async function doImport(e) {
e.preventDefault()
if (!file) return
importing = true
error = ''
result = null
try {
const fd = new FormData()
fd.append('csv', file)
result = await api.import(fd)
// Pull to sync new attendees into Dexie
await syncPull()
} catch (err) {
error = err.message
} finally {
importing = false
}
}
function onFileChange(e) {
file = e.target.files[0] ?? null
result = null
error = ''
}
</script>
<div class="page">
<div class="page-header">
<h1 class="page-title">Import</h1>
</div>
<div class="card" style="max-width:540px;margin-bottom:1.5rem">
<form onsubmit={doImport}>
<div class="form-group">
<label for="csv-file">CSV file</label>
<input id="csv-file" type="file" accept=".csv,text/csv"
onchange={onFileChange}
style="padding:0.4rem 0;border:none;background:transparent;color:var(--c-text)" />
</div>
<div style="font-size:0.82rem;color:var(--c-muted);margin-bottom:1rem;line-height:1.6">
<strong style="color:var(--c-text)">Supported formats:</strong><br>
<strong>CrowdWork / ticketing platform:</strong> columns <code>Patron Name</code>, <code>Patron Email</code>, <code>Tier Name</code>, <code>Order Number</code><br>
<strong>Generic:</strong> columns <code>name</code>, <code>email</code>, <code>ticket_id</code>, <code>ticket_type</code>, <code>note</code><br>
Duplicate names are skipped.
</div>
<button type="submit" class="btn btn-primary" disabled={!file || importing}>
{importing ? 'Importing…' : 'Import'}
</button>
</form>
</div>
{#if error}
<div class="alert alert-error">{error}</div>
{/if}
{#if result}
<div class="card" style="max-width:540px">
<div style="display:flex;gap:2rem;margin-bottom:{result.errors?.length ? '1rem' : '0'}">
<div>
<div class="text-muted" style="font-size:0.78rem;text-transform:uppercase;letter-spacing:0.06em">Imported</div>
<div style="font-size:2rem;font-weight:700;color:var(--c-success)">{result.inserted}</div>
</div>
<div>
<div class="text-muted" style="font-size:0.78rem;text-transform:uppercase;letter-spacing:0.06em">Skipped</div>
<div style="font-size:2rem;font-weight:700;color:var(--c-muted)">{result.skipped}</div>
</div>
{#if result.errors?.length}
<div>
<div class="text-muted" style="font-size:0.78rem;text-transform:uppercase;letter-spacing:0.06em">Errors</div>
<div style="font-size:2rem;font-weight:700;color:var(--c-danger)">{result.errors.length}</div>
</div>
{/if}
</div>
{#if result.errors?.length}
<div style="font-size:0.82rem;color:var(--c-muted)">
{#each result.errors as err}
<div style="padding:0.2rem 0">{err}</div>
{/each}
</div>
{/if}
</div>
{/if}
</div>

View file

@ -0,0 +1,356 @@
<script>
import { onMount } from 'svelte'
import { api } from '../api.js'
// Token comes from the URL hash: /#/v/TOKEN
const token = $derived(window.location.hash.match(/^#\/v\/([A-Z0-9]+)/i)?.[1] ?? '')
let state = $state(null) // { volunteer, shifts, available }
let loading = $state(true)
let error = $state('')
// Conflict dialog state
let conflictShift = $state(null)
let conflictingShifts = $state([])
let claiming = $state(false)
$effect(() => {
if (token) loadState()
})
async function loadState() {
loading = true
error = ''
try {
state = await api.kiosk.get(token)
} catch (err) {
error = err.message
} finally {
loading = false
}
}
async function claim(shift) {
claiming = true
error = ''
try {
const result = await api.kiosk.claim(token, shift.id)
if (result.conflict) {
conflictShift = shift
conflictingShifts = result.conflicting_shifts ?? []
claiming = false
return
}
state = result
} catch (err) {
error = err.message
} finally {
claiming = false
}
}
async function claimForce() {
if (!conflictShift) return
claiming = true
error = ''
try {
const result = await api.kiosk.claim(token, conflictShift.id, true)
if (!result.conflict) {
state = result
conflictShift = null
conflictingShifts = []
}
} catch (err) {
error = err.message
} finally {
claiming = false
}
}
async function unclaim(shiftId) {
error = ''
try {
state = await api.kiosk.unclaim(token, 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}`
}
function groupByDay(shifts) {
const days = {}
for (const s of shifts) {
if (!days[s.day]) days[s.day] = []
days[s.day].push(s)
}
return Object.entries(days).sort(([a], [b]) => a.localeCompare(b))
}
const isAssigned = $derived((shiftId) =>
state?.shifts?.some(s => s.id === shiftId) ?? false
)
</script>
<div class="kiosk">
<div class="kiosk-header">
<div class="kiosk-brand">Turn<span>pike</span> &nbsp;<span class="kiosk-role">Volunteer Portal</span></div>
</div>
{#if !token}
<div class="kiosk-body">
<div class="kiosk-error">No volunteer token found in URL.<br>Check the link you were sent.</div>
</div>
{:else if loading}
<div class="kiosk-body kiosk-center">Loading…</div>
{:else if error && !state}
<div class="kiosk-body">
<div class="kiosk-error">{error}</div>
</div>
{:else if state}
<div class="kiosk-body">
{#if error}
<div class="kiosk-alert">{error}</div>
{/if}
<!-- Conflict dialog -->
{#if conflictShift}
<div class="kiosk-overlay">
<div class="kiosk-dialog">
<h3>Scheduling Conflict</h3>
<p>
<strong>{conflictShift.name}</strong> ({fmt(conflictShift.start_time)}{fmt(conflictShift.end_time)})
overlaps with:
</p>
<ul>
{#each conflictingShifts as s}
<li>{s.name}{fmt(s.start_time)}{fmt(s.end_time)}</li>
{/each}
</ul>
<p class="kiosk-muted">You can still sign up — just confirm you're aware of the overlap.</p>
<div class="kiosk-actions">
<button class="kbtn kbtn-primary" onclick={claimForce} disabled={claiming}>
{claiming ? '…' : 'Sign up anyway'}
</button>
<button class="kbtn kbtn-ghost" onclick={() => { conflictShift = null; conflictingShifts = [] }}>
Cancel
</button>
</div>
</div>
</div>
{/if}
<!-- Volunteer header -->
<div class="kiosk-card">
<div class="kiosk-vol-name">{state.volunteer.name}</div>
<div class="kiosk-vol-meta">
{state.volunteer.email || ''}
{state.volunteer.is_lead ? ' · Department Lead' : ''}
</div>
<div class="kiosk-token">Token: <code>{token}</code></div>
</div>
<!-- Assigned shifts -->
{#if state.shifts.length > 0}
<section>
<h2 class="kiosk-section-title">My Shifts</h2>
{#each groupByDay(state.shifts) as [day, shifts]}
<div class="kiosk-day-label">{day}</div>
{#each shifts as s}
<div class="kiosk-shift-card kiosk-shift-assigned">
<div class="kiosk-shift-info">
<strong>{s.name}</strong>
<span class="kiosk-time">{fmt(s.start_time)} {fmt(s.end_time)}</span>
</div>
<button class="kbtn kbtn-ghost kbtn-sm" onclick={() => unclaim(s.id)}>Remove</button>
</div>
{/each}
{/each}
</section>
{:else}
<div class="kiosk-empty">You haven't signed up for any shifts yet.</div>
{/if}
<!-- Available shifts -->
{#if state.available.length > 0}
<section>
<h2 class="kiosk-section-title">Available Shifts</h2>
{#each groupByDay(state.available) as [day, shifts]}
<div class="kiosk-day-label">{day}</div>
{#each shifts as s}
{@const assigned = isAssigned(s.id)}
{#if !assigned}
<div class="kiosk-shift-card">
<div class="kiosk-shift-info">
<strong>{s.name}</strong>
<span class="kiosk-time">{fmt(s.start_time)} {fmt(s.end_time)}</span>
{#if s.capacity > 0}
<span class="kiosk-cap">
{s.capacity} spots
</span>
{/if}
</div>
<button class="kbtn kbtn-primary kbtn-sm" onclick={() => claim(s)} disabled={claiming}>
Sign up
</button>
</div>
{/if}
{/each}
{/each}
</section>
{:else if state.shifts.length === 0}
<div class="kiosk-empty">No shifts are currently available in your department.</div>
{/if}
</div>
{/if}
</div>
<style>
.kiosk {
min-height: 100vh;
background: var(--c-bg);
color: var(--c-text);
font-family: var(--font);
display: flex;
flex-direction: column;
}
.kiosk-header {
background: var(--c-surface);
border-bottom: 1px solid var(--c-border);
padding: 1rem 1.5rem;
display: flex;
align-items: center;
}
.kiosk-brand {
font-size: 1.1rem;
font-weight: 700;
letter-spacing: -0.02em;
color: var(--c-text);
}
.kiosk-brand span:first-of-type { color: var(--c-accent); }
.kiosk-role {
font-size: 0.8rem;
font-weight: 400;
color: var(--c-muted);
letter-spacing: 0;
}
.kiosk-body {
max-width: 640px;
margin: 0 auto;
padding: 1.5rem 1rem;
width: 100%;
}
.kiosk-center { display: flex; align-items: center; justify-content: center; }
.kiosk-error {
background: rgba(239,68,68,0.12);
border: 1px solid rgba(239,68,68,0.3);
color: #fca5a5;
padding: 1rem;
border-radius: 8px;
margin: 2rem 0;
text-align: center;
line-height: 1.7;
}
.kiosk-alert {
background: rgba(239,68,68,0.1);
border: 1px solid rgba(239,68,68,0.25);
color: #fca5a5;
padding: 0.75rem 1rem;
border-radius: 6px;
margin-bottom: 1rem;
font-size: 0.875rem;
}
.kiosk-card {
background: var(--c-surface);
border: 1px solid var(--c-border);
border-radius: 10px;
padding: 1.25rem;
margin-bottom: 1.5rem;
}
.kiosk-vol-name { font-size: 1.3rem; font-weight: 700; margin-bottom: 0.2rem; }
.kiosk-vol-meta { color: var(--c-muted); font-size: 0.875rem; margin-bottom: 0.5rem; }
.kiosk-token { font-size: 0.78rem; color: var(--c-muted); }
.kiosk-token code {
background: rgba(99,102,241,0.15);
color: var(--c-accent-h);
padding: 0.1rem 0.4rem;
border-radius: 4px;
font-size: 0.85em;
letter-spacing: 0.08em;
}
.kiosk-section-title {
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--c-muted);
margin: 1.25rem 0 0.5rem;
}
.kiosk-day-label {
font-size: 0.78rem;
color: var(--c-muted);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
margin: 0.75rem 0 0.35rem;
}
.kiosk-shift-card {
background: var(--c-surface);
border: 1px solid var(--c-border);
border-radius: 8px;
padding: 0.9rem 1rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 0.5rem;
}
.kiosk-shift-assigned { border-color: rgba(99,102,241,0.35); }
.kiosk-shift-info { display: flex; flex-direction: column; gap: 0.2rem; }
.kiosk-shift-info strong { font-size: 0.9rem; }
.kiosk-time { font-size: 0.8rem; color: var(--c-muted); }
.kiosk-cap { font-size: 0.75rem; color: var(--c-muted); }
.kiosk-empty { color: var(--c-muted); font-size: 0.875rem; padding: 1rem 0; }
.kbtn {
display: inline-flex; align-items: center;
padding: 0.45rem 1rem; border-radius: 6px;
border: 1px solid transparent;
font-size: 0.875rem; font-weight: 500; cursor: pointer;
white-space: nowrap;
font-family: var(--font);
transition: background 150ms, border-color 150ms;
}
.kbtn:disabled { opacity: 0.5; cursor: not-allowed; }
.kbtn-sm { padding: 0.3rem 0.65rem; font-size: 0.8rem; }
.kbtn-primary { background: var(--c-accent); color: #fff; }
.kbtn-primary:hover:not(:disabled) { background: var(--c-accent-h); }
.kbtn-ghost { background: transparent; color: var(--c-muted); border-color: var(--c-border); }
.kbtn-ghost:hover:not(:disabled) { color: var(--c-text); border-color: var(--c-text); }
.kiosk-overlay {
position: fixed; inset: 0;
background: rgba(0,0,0,0.6);
display: flex; align-items: center; justify-content: center;
z-index: 100;
padding: 1rem;
}
.kiosk-dialog {
background: var(--c-surface);
border: 1px solid var(--c-border);
border-radius: 12px;
padding: 1.5rem;
max-width: 440px;
width: 100%;
}
.kiosk-dialog h3 { font-size: 1.1rem; font-weight: 700; margin-bottom: 0.75rem; }
.kiosk-dialog p { font-size: 0.875rem; margin-bottom: 0.75rem; line-height: 1.6; }
.kiosk-dialog ul { margin: 0.5rem 0 0.75rem 1.25rem; font-size: 0.875rem; line-height: 1.7; color: var(--c-muted); }
.kiosk-muted { color: var(--c-muted) !important; }
.kiosk-actions { display: flex; gap: 0.5rem; margin-top: 1rem; }
</style>

View file

@ -0,0 +1,49 @@
<script>
import { api } from '../api.js'
import { saveSession } from '../db.js'
let { onlogin } = $props()
let username = $state('')
let password = $state('')
let error = $state('')
let loading = $state(false)
async function submit(e) {
e.preventDefault()
error = ''
loading = true
try {
const { token, user } = await api.login(username, password)
await saveSession(token, user)
onlogin({ token, user })
} catch (err) {
error = err.message || 'Login failed'
} finally {
loading = false
}
}
</script>
<div class="login-wrap">
<div class="login-box">
<h1 class="login-title">Turnpike</h1>
<p class="login-sub">Event management</p>
{#if error}
<div class="alert alert-error">{error}</div>
{/if}
<form onsubmit={submit}>
<div class="form-group">
<label for="username">Username</label>
<input id="username" bind:value={username} autocomplete="username" required />
</div>
<div class="form-group">
<label for="password">Password</label>
<input id="password" type="password" bind:value={password} autocomplete="current-password" required />
</div>
<button type="submit" class="btn btn-primary" style="width:100%" disabled={loading}>
{loading ? 'Signing in…' : 'Sign in'}
</button>
</form>
</div>
</div>

View file

@ -0,0 +1,452 @@
<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>

View file

@ -0,0 +1,151 @@
<script>
import { onMount } from 'svelte'
import { api } from '../api.js'
let loading = $state(true)
let saving = $state(false)
let testing = $state(false)
let error = $state('')
let success = $state('')
let smtpHost = $state('')
let smtpPort = $state(587)
let smtpUser = $state('')
let smtpPassword = $state('')
let smtpFrom = $state('')
let smtpFromName = $state('')
let baseURL = $state('')
let testEmail = $state('')
onMount(async () => {
try {
const s = await api.settings.get()
smtpHost = s.smtp_host ?? ''
smtpPort = s.smtp_port ?? 587
smtpUser = s.smtp_user ?? ''
smtpPassword = '' // never pre-filled
smtpFrom = s.smtp_from ?? ''
smtpFromName = s.smtp_from_name ?? ''
baseURL = s.base_url ?? ''
} catch (err) {
error = err.message
} finally {
loading = false
}
})
async function save(e) {
e.preventDefault()
saving = true
error = ''
success = ''
try {
await api.settings.update({
smtp_host: smtpHost,
smtp_port: smtpPort,
smtp_user: smtpUser,
smtp_password: smtpPassword, // empty = keep existing
smtp_from: smtpFrom,
smtp_from_name: smtpFromName,
base_url: baseURL,
})
smtpPassword = ''
success = 'Settings saved.'
} catch (err) {
error = err.message
} finally {
saving = false
}
}
async function sendTest() {
if (!testEmail) return
testing = true
error = ''
success = ''
try {
await api.settings.testEmail(testEmail)
success = `Test email sent to ${testEmail}.`
} catch (err) {
error = err.message
} finally {
testing = false
}
}
</script>
<div class="page">
<div class="page-header">
<h1 class="page-title">Settings</h1>
</div>
{#if error}
<div class="alert alert-error">{error}</div>
{/if}
{#if success}
<div class="alert alert-success">{success}</div>
{/if}
{#if loading}
<div class="text-muted">Loading…</div>
{:else}
<form onsubmit={save}>
<div class="card" style="margin-bottom:1.5rem">
<h2 style="font-size:0.95rem;font-weight:700;margin-bottom:1rem">SMTP Email</h2>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem">
<div class="form-group" style="grid-column:1">
<label for="s-host">SMTP Host</label>
<input id="s-host" bind:value={smtpHost} placeholder="smtp.fastmail.com" />
</div>
<div class="form-group">
<label for="s-port">Port</label>
<input id="s-port" type="number" bind:value={smtpPort} placeholder="587" />
</div>
<div class="form-group">
<label for="s-user">Username</label>
<input id="s-user" bind:value={smtpUser} placeholder="user@example.com" autocomplete="off" />
</div>
<div class="form-group">
<label for="s-pass">Password</label>
<input id="s-pass" type="password" bind:value={smtpPassword}
placeholder="Leave blank to keep existing" autocomplete="new-password" />
</div>
<div class="form-group">
<label for="s-from">From Address</label>
<input id="s-from" type="email" bind:value={smtpFrom} placeholder="events@example.com" />
</div>
<div class="form-group">
<label for="s-fname">From Name</label>
<input id="s-fname" bind:value={smtpFromName} placeholder="Event Team" />
</div>
</div>
<div class="form-group">
<label for="s-url">Base URL <span class="text-muted" style="font-weight:400">(for volunteer token links)</span></label>
<input id="s-url" bind:value={baseURL} placeholder="https://events.example.com" />
</div>
<div class="actions">
<button type="submit" class="btn btn-primary" disabled={saving}>
{saving ? 'Saving…' : 'Save Settings'}
</button>
</div>
</div>
</form>
<!-- Test email -->
<div class="card">
<h2 style="font-size:0.95rem;font-weight:700;margin-bottom:1rem">Test Email</h2>
<div style="display:flex;gap:0.5rem;align-items:flex-end">
<div class="form-group" style="flex:1;margin-bottom:0">
<label for="s-test">Send to</label>
<input id="s-test" type="email" bind:value={testEmail} placeholder="your@email.com" />
</div>
<button class="btn btn-ghost" onclick={sendTest} disabled={testing || !testEmail}>
{testing ? 'Sending…' : 'Send Test'}
</button>
</div>
</div>
{/if}
</div>

View file

@ -0,0 +1,221 @@
<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>

View file

@ -0,0 +1,266 @@
<script>
import { liveQuery } from 'dexie'
import { db } from '../db.js'
import { api } from '../api.js'
let { session } = $props()
let users = $state([])
let loadError = $state('')
let error = $state('')
let loading = $state(true)
let showAdd = $state(false)
let adding = $state(false)
let newUsername = $state('')
let newPassword = $state('')
let newRole = $state('gate')
let newDeptIDs = $state([])
let editID = $state(null)
let editRole = $state('')
let editDeptIDs = $state([])
let editPassword = $state('')
let saving = $state(false)
const allDepts = liveQuery(() =>
db.departments.filter(d => !d.deleted_at).toArray()
.then(arr => arr.sort((a, b) => a.name.localeCompare(b.name)))
)
const roles = ['admin', 'coordinator', 'ticketing', 'gate', 'volunteer_lead']
const me = $derived(session?.user?.id)
async function loadUsers() {
loading = true
try {
users = await api.users.list()
} catch (err) {
loadError = err.message
} finally {
loading = false
}
}
$effect(() => { loadUsers() })
async function addUser(e) {
e.preventDefault()
adding = true
error = ''
try {
const u = await api.users.create({
username: newUsername,
password: newPassword,
role: newRole,
department_ids: newDeptIDs,
})
users = [...users, u]
showAdd = false
newUsername = newPassword = ''
newRole = 'gate'
newDeptIDs = []
} catch (err) {
error = err.message
} finally {
adding = false
}
}
function startEdit(u) {
editID = u.id
editRole = u.role
editDeptIDs = [...(u.department_ids || [])]
editPassword = ''
}
function cancelEdit() {
editID = null
}
async function saveUser(u) {
saving = true
error = ''
try {
const payload = { role: editRole, department_ids: editDeptIDs }
if (editPassword) payload.password = editPassword
const updated = await api.users.update(u.id, payload)
users = users.map(x => x.id === u.id ? updated : x)
editID = null
} catch (err) {
error = err.message
} finally {
saving = false
}
}
async function deleteUser(u) {
if (!confirm(`Delete user "${u.username}"? This cannot be undone.`)) return
try {
await api.users.delete(u.id)
users = users.filter(x => x.id !== u.id)
} catch (err) {
error = err.message
}
}
function toggleDept(id, list) {
const idx = list.indexOf(id)
if (idx === -1) return [...list, id]
return list.filter(x => x !== id)
}
function deptNamesFor(ids) {
const depts = $allDepts ?? []
return ids.map(id => depts.find(d => d.id === id)?.name).filter(Boolean).join(', ') || '—'
}
function roleLabel(r) {
return { admin: 'Admin', coordinator: 'Coordinator', ticketing: 'Ticketing', gate: 'Gate', volunteer_lead: 'Vol. Lead' }[r] || r
}
</script>
<div class="page">
<div class="page-header">
<h1 class="page-title">Users</h1>
<div class="actions">
<button class="btn btn-primary btn-sm" onclick={() => showAdd = !showAdd}>+ Add user</button>
</div>
</div>
{#if loadError}
<div class="alert alert-error">{loadError}</div>
{/if}
{#if error}
<div class="alert alert-error">{error}</div>
{/if}
{#if showAdd}
<div class="card" style="margin-bottom:1.5rem">
<form onsubmit={addUser}>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:1rem">
<div class="form-group">
<label for="u-username">Username *</label>
<input id="u-username" bind:value={newUsername} required placeholder="username" autocomplete="off" />
</div>
<div class="form-group">
<label for="u-password">Password *</label>
<input id="u-password" type="password" bind:value={newPassword} required autocomplete="new-password" />
</div>
<div class="form-group">
<label for="u-role">Role *</label>
<select id="u-role" bind:value={newRole}>
{#each roles as r}
<option value={r}>{roleLabel(r)}</option>
{/each}
</select>
</div>
</div>
{#if ($allDepts ?? []).length > 0}
<div class="form-group">
<span style="font-size:0.82rem;color:var(--c-muted);font-weight:500">Departments</span>
<div style="display:flex;flex-wrap:wrap;gap:0.5rem;margin-top:0.35rem">
{#each $allDepts ?? [] as d}
<label style="display:flex;align-items:center;gap:0.35rem;cursor:pointer;font-size:0.875rem;color:var(--c-text)">
<input type="checkbox" style="width:auto"
checked={newDeptIDs.includes(d.id)}
onchange={() => newDeptIDs = toggleDept(d.id, newDeptIDs)} />
<span class="dept-dot" style="background:{d.color}"></span>
{d.name}
</label>
{/each}
</div>
</div>
{/if}
<div class="actions">
<button type="submit" class="btn btn-primary" disabled={adding}>
{adding ? 'Adding…' : 'Add user'}
</button>
<button type="button" class="btn btn-ghost" onclick={() => showAdd = false}>Cancel</button>
</div>
</form>
</div>
{/if}
{#if loading}
<div class="text-muted" style="padding:2rem 0">Loading…</div>
{:else if users.length === 0}
<div class="empty">
<strong>No users yet</strong>
</div>
{:else}
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Username</th>
<th>Role</th>
<th>Departments</th>
<th></th>
</tr>
</thead>
<tbody>
{#each users as u (u.id)}
{#if editID === u.id}
<tr>
<td><strong>{u.username}</strong> {#if u.id === me}<span class="badge badge-role">you</span>{/if}</td>
<td>
<select bind:value={editRole} style="width:auto;margin:0">
{#each roles as r}
<option value={r}>{roleLabel(r)}</option>
{/each}
</select>
</td>
<td>
{#if ($allDepts ?? []).length > 0}
<div style="display:flex;flex-wrap:wrap;gap:0.4rem">
{#each $allDepts ?? [] as d}
<label style="display:flex;align-items:center;gap:0.3rem;cursor:pointer;font-size:0.82rem;color:var(--c-text)">
<input type="checkbox" style="width:auto"
checked={editDeptIDs.includes(d.id)}
onchange={() => editDeptIDs = toggleDept(d.id, editDeptIDs)} />
{d.name}
</label>
{/each}
</div>
{/if}
<input type="password" bind:value={editPassword}
placeholder="New password (leave blank to keep)"
style="margin-top:0.5rem" autocomplete="new-password" />
</td>
<td>
<div class="actions">
<button class="btn btn-primary btn-sm" onclick={() => saveUser(u)} disabled={saving}>
{saving ? '…' : 'Save'}
</button>
<button class="btn btn-ghost btn-sm" onclick={cancelEdit}>Cancel</button>
</div>
</td>
</tr>
{:else}
<tr>
<td>
<strong>{u.username}</strong>
{#if u.id === me}
<span class="badge badge-role" style="margin-left:0.4rem">you</span>
{/if}
</td>
<td><span class="badge badge-role">{roleLabel(u.role)}</span></td>
<td class="text-muted">{deptNamesFor(u.department_ids || [])}</td>
<td>
<div class="actions">
<button class="btn btn-ghost btn-sm" onclick={() => startEdit(u)}>Edit</button>
{#if u.id !== me}
<button class="btn btn-danger btn-sm" onclick={() => deleteUser(u)}>Delete</button>
{/if}
</div>
</td>
</tr>
{/if}
{/each}
</tbody>
</table>
</div>
{/if}
</div>

View file

@ -0,0 +1,241 @@
<script>
import { liveQuery } from 'dexie'
import { db } from '../db.js'
import { api } from '../api.js'
import CheckInButton from '../components/CheckInButton.svelte'
let { session } = $props()
let search = $state('')
let filterDept = $state('')
let filterChecked = $state('')
let error = $state('')
let showAdd = $state(false)
let adding = $state(false)
let newName = $state('')
let newEmail = $state('')
let newPhone = $state('')
let newDeptID = $state('')
let newIsLead = $state(false)
let newNote = $state('')
const role = $derived(session?.user?.role ?? '')
const canManage = $derived(['admin', 'coordinator', 'volunteer_lead'].includes(role))
const allVolunteers = liveQuery(() =>
db.volunteers.filter(v => !v.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)))
)
const filtered = $derived.by(() => {
const list = $allVolunteers ?? []
const s = search.toLowerCase()
return list
.filter(v => {
if (filterDept && v.department_id !== parseInt(filterDept)) return false
if (filterChecked === 'true' && !v.checked_in) return false
if (filterChecked === 'false' && v.checked_in) return false
if (s && !v.name.toLowerCase().includes(s) &&
!(v.email || '').toLowerCase().includes(s)) return false
return true
})
.sort((a, b) => a.name.localeCompare(b.name))
})
async function checkIn(v) {
try {
const updated = await api.volunteers.checkIn(v.id)
await db.volunteers.put(updated)
} catch (err) {
error = err.message
}
}
async function addVolunteer(e) {
e.preventDefault()
adding = true
error = ''
try {
const data = {
name: newName,
email: newEmail,
phone: newPhone,
is_lead: newIsLead,
note: newNote,
}
if (newDeptID) data.department_id = parseInt(newDeptID)
const v = await api.volunteers.create(data)
await db.volunteers.put(v)
showAdd = false
newName = newEmail = newPhone = newNote = ''
newDeptID = ''
newIsLead = false
} catch (err) {
error = err.message
} finally {
adding = false
}
}
async function deleteVolunteer(v) {
if (!confirm(`Delete volunteer "${v.name}"?`)) return
try {
await api.volunteers.delete(v.id)
await db.volunteers.delete(v.id)
} catch (err) {
error = err.message
}
}
function deptFor(id) {
return ($allDepts ?? []).find(d => d.id === id)
}
</script>
<div class="page">
<div class="page-header">
<h1 class="page-title">Volunteers</h1>
{#if canManage}
<div class="actions">
<button class="btn btn-primary btn-sm" onclick={() => showAdd = !showAdd}>+ Add</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={addVolunteer}>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem">
<div class="form-group">
<label for="v-name">Name *</label>
<input id="v-name" bind:value={newName} required placeholder="Full name" />
</div>
<div class="form-group">
<label for="v-email">Email</label>
<input id="v-email" type="email" bind:value={newEmail} placeholder="email@example.com" />
</div>
<div class="form-group">
<label for="v-phone">Phone</label>
<input id="v-phone" bind:value={newPhone} placeholder="Optional" />
</div>
<div class="form-group">
<label for="v-dept">Department</label>
<select id="v-dept" bind:value={newDeptID}>
<option value="">No department</option>
{#each $allDepts ?? [] as d}
<option value={String(d.id)}>{d.name}</option>
{/each}
</select>
</div>
</div>
<div class="form-group">
<label for="v-note">Note</label>
<input id="v-note" bind:value={newNote} placeholder="Optional note" />
</div>
<div style="margin-bottom:1rem">
<label style="display:flex;align-items:center;gap:0.5rem;cursor:pointer">
<input type="checkbox" style="width:auto" bind:checked={newIsLead} />
Department lead
</label>
</div>
<div class="actions">
<button type="submit" class="btn btn-primary" disabled={adding}>
{adding ? 'Adding…' : 'Add volunteer'}
</button>
<button type="button" class="btn btn-ghost" onclick={() => showAdd = false}>Cancel</button>
</div>
</form>
</div>
{/if}
<div class="search-bar">
<input placeholder="Search name, email…" bind:value={search} />
{#if ($allDepts ?? []).length > 0}
<select bind:value={filterDept} style="width:auto">
<option value="">All departments</option>
{#each $allDepts ?? [] as d}
<option value={String(d.id)}>{d.name}</option>
{/each}
</select>
{/if}
<select bind:value={filterChecked} style="width:auto">
<option value="">All</option>
<option value="false">Not checked in</option>
<option value="true">Checked in</option>
</select>
<span class="text-muted" style="font-size:0.85rem;white-space:nowrap">
{filtered.length} shown
</span>
</div>
{#if ($allVolunteers ?? []).length === 0}
<div class="empty">
<strong>No volunteers yet</strong>
<p>Add volunteers manually.</p>
</div>
{:else}
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Name</th>
<th>Department</th>
<th>Status</th>
<th></th>
{#if canManage}<th></th>{/if}
</tr>
</thead>
<tbody>
{#each filtered as v (v.id)}
{@const dept = deptFor(v.department_id)}
<tr>
<td>
<strong>{v.name}</strong>
{#if v.is_lead}
<span class="badge badge-lead" style="margin-left:0.4rem">Lead</span>
{/if}
{#if v.note}
<div class="text-muted" style="font-size:0.78rem">{v.note}</div>
{/if}
</td>
<td class="text-muted">
{#if dept}
<span class="dept-dot" style="background:{dept.color};margin-right:0.4rem"></span>{dept.name}
{:else}
{/if}
</td>
<td>
<span class="badge {v.checked_in ? 'badge-checked' : 'badge-unchecked'}">
{v.checked_in ? 'Checked in' : 'Pending'}
</span>
{#if v.checked_in_at}
<div class="text-muted" style="font-size:0.75rem">
{new Date(v.checked_in_at).toLocaleTimeString()}
</div>
{/if}
</td>
<td>
{#if !v.checked_in}
<CheckInButton onclick={() => checkIn(v)} />
{/if}
</td>
{#if canManage}
<td>
<button class="btn btn-danger btn-sm" onclick={() => deleteVolunteer(v)}>Delete</button>
</td>
{/if}
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>

115
frontend/src/sync.js Normal file
View file

@ -0,0 +1,115 @@
import { db, getLastSync, setLastSync } from './db.js'
import { api } from './api.js'
let syncing = false
let sseSource = null
export async function syncPull() {
if (syncing) return
syncing = true
try {
const since = await getLastSync()
const data = await api.sync.pull(since)
await db.transaction('rw',
[db.event, db.attendees, db.departments, db.volunteers, db.shifts, db.volunteer_shifts],
async () => {
if (data.event) {
await db.event.put(data.event)
}
if (data.attendees?.length) {
await db.attendees.bulkPut(data.attendees)
// Purge hard-deleted records from Dexie
const deleted = data.attendees.filter(a => a.deleted_at).map(a => a.id)
if (deleted.length) await db.attendees.bulkDelete(deleted)
}
if (data.departments?.length) {
await db.departments.bulkPut(data.departments)
const deleted = data.departments.filter(d => d.deleted_at).map(d => d.id)
if (deleted.length) await db.departments.bulkDelete(deleted)
}
if (data.volunteers?.length) {
await db.volunteers.bulkPut(data.volunteers)
const deleted = data.volunteers.filter(v => v.deleted_at).map(v => v.id)
if (deleted.length) await db.volunteers.bulkDelete(deleted)
}
if (data.shifts?.length) {
await db.shifts.bulkPut(data.shifts)
const deleted = data.shifts.filter(s => s.deleted_at).map(s => s.id)
if (deleted.length) await db.shifts.bulkDelete(deleted)
}
if (data.volunteer_shifts?.length) {
await db.volunteer_shifts.bulkPut(data.volunteer_shifts)
}
}
)
await setLastSync(data.server_time)
return true
} catch (err) {
console.warn('Sync pull failed:', err.message)
return false
} finally {
syncing = false
}
}
export function startSSE(onEvent) {
if (sseSource) return
const connect = () => {
// Get token synchronously from Dexie — SSE doesn't support headers natively,
// so we pass the token as a query param (acceptable since it's same-origin HTTPS).
db.session.get(1).then(session => {
if (!session?.token) return
sseSource = new EventSource(`/api/sync/stream?token=${encodeURIComponent(session.token)}`)
sseSource.onmessage = (e) => {
try {
const payload = JSON.parse(e.data)
if (payload.event === 'checkin') {
// Apply check-in to local Dexie immediately
if (payload.data?.type === 'attendee' && payload.data?.attendee) {
db.attendees.put(payload.data.attendee)
}
if (payload.data?.type === 'volunteer' && payload.data?.volunteer) {
db.volunteers.put(payload.data.volunteer)
}
onEvent?.(payload)
}
} catch {}
}
sseSource.onerror = () => {
sseSource?.close()
sseSource = null
// Reconnect after 5s
setTimeout(connect, 5000)
}
})
}
connect()
}
export function stopSSE() {
sseSource?.close()
sseSource = null
}
// Poll for sync when online, with exponential backoff on failure
let syncInterval = null
export function startSyncLoop(intervalMs = 30000) {
if (syncInterval) return
syncInterval = setInterval(() => {
if (navigator.onLine) syncPull()
}, intervalMs)
window.addEventListener('online', () => syncPull())
}
export function stopSyncLoop() {
clearInterval(syncInterval)
syncInterval = null
}

View file

@ -0,0 +1,8 @@
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'
/** @type {import("@sveltejs/vite-plugin-svelte").SvelteConfig} */
export default {
// Consult https://svelte.dev/docs#compile-time-svelte-preprocess
// for more information about preprocessors
preprocess: vitePreprocess(),
}

15
frontend/vite.config.js Normal file
View file

@ -0,0 +1,15 @@
import { defineConfig } from 'vite'
import { svelte } from '@sveltejs/vite-plugin-svelte'
export default defineConfig({
plugins: [svelte()],
server: {
proxy: {
'/api': 'http://localhost:8180',
},
},
build: {
outDir: 'dist',
emptyOutDir: true,
},
})