Created Turnpike, event attendee and volunteer management
Built after prototype, Traverse, an attendee and volunteer list maintainer.
This commit is contained in:
commit
1033cdb29b
59 changed files with 8663 additions and 0 deletions
102
frontend/src/App.svelte
Normal file
102
frontend/src/App.svelte
Normal 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
140
frontend/src/api.js
Normal 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
177
frontend/src/app.css
Normal 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); }
|
||||
}
|
||||
13
frontend/src/components/CheckInButton.svelte
Normal file
13
frontend/src/components/CheckInButton.svelte
Normal 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>
|
||||
57
frontend/src/components/Nav.svelte
Normal file
57
frontend/src/components/Nav.svelte
Normal 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>
|
||||
46
frontend/src/components/SyncStatus.svelte
Normal file
46
frontend/src/components/SyncStatus.svelte
Normal 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
49
frontend/src/db.js
Normal 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
9
frontend/src/main.js
Normal 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
|
||||
274
frontend/src/pages/Attendees.svelte
Normal file
274
frontend/src/pages/Attendees.svelte
Normal 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>
|
||||
62
frontend/src/pages/Dashboard.svelte
Normal file
62
frontend/src/pages/Dashboard.svelte
Normal 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>
|
||||
190
frontend/src/pages/Departments.svelte
Normal file
190
frontend/src/pages/Departments.svelte
Normal 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>
|
||||
451
frontend/src/pages/GateUI.svelte
Normal file
451
frontend/src/pages/GateUI.svelte
Normal 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> <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>
|
||||
96
frontend/src/pages/Import.svelte
Normal file
96
frontend/src/pages/Import.svelte
Normal 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>
|
||||
356
frontend/src/pages/Kiosk.svelte
Normal file
356
frontend/src/pages/Kiosk.svelte
Normal 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> <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>
|
||||
49
frontend/src/pages/Login.svelte
Normal file
49
frontend/src/pages/Login.svelte
Normal 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>
|
||||
452
frontend/src/pages/ScheduleBoard.svelte
Normal file
452
frontend/src/pages/ScheduleBoard.svelte
Normal 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>
|
||||
151
frontend/src/pages/Settings.svelte
Normal file
151
frontend/src/pages/Settings.svelte
Normal 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>
|
||||
221
frontend/src/pages/Shifts.svelte
Normal file
221
frontend/src/pages/Shifts.svelte
Normal 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>
|
||||
266
frontend/src/pages/Users.svelte
Normal file
266
frontend/src/pages/Users.svelte
Normal 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>
|
||||
241
frontend/src/pages/Volunteers.svelte
Normal file
241
frontend/src/pages/Volunteers.svelte
Normal 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
115
frontend/src/sync.js
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue