Compare commits
18 commits
260e017f79
...
2ff06bdb76
| Author | SHA1 | Date | |
|---|---|---|---|
| 2ff06bdb76 | |||
| cc4dd76438 | |||
| ab3d9a0409 | |||
| 3eec81af7f | |||
| 72b245d6d6 | |||
| 62b3dece84 | |||
| d439306657 | |||
| 07f7d3d245 | |||
| 87da9cf97f | |||
| 2b409c65c1 | |||
| 6c21efcb16 | |||
| fa7ea35fd5 | |||
| 4d3da023fc | |||
| a60ef7d25b | |||
| 6eb72c5091 | |||
| 940cf29d04 | |||
| ecfbfcd53e | |||
| e7b25ea0c6 |
24 changed files with 1031 additions and 348 deletions
25
README.md
25
README.md
|
|
@ -1,21 +1,21 @@
|
||||||
# Turnpike
|
# Turnpike
|
||||||
|
|
||||||
Self-hosted event attendee and volunteer management. One instance, one event.
|
Self-hosted event ticketing and volunteer management. One instance, one event.
|
||||||
|
|
||||||
Turnpike handles gate check-in, volunteer scheduling, and department coordination for events ranging from a single evening to a multi-day festival. It works offline — gate volunteers can check people in without a network connection and sync when connectivity returns.
|
Turnpike handles gate check-in, volunteer scheduling, and department coordination for events ranging from a single evening to a multi-day festival. It works offline — gate volunteers can check people in without a network connection and sync when connectivity returns.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Attendee management** — CSV import (CrowdWork/Zeffy auto-detected), party-size tracking, search, check-in
|
- **Participant & ticket management** — CSV import (CrowdWork/Zeffy auto-detected), search, check-in
|
||||||
- **Volunteer scheduling** — departments, shifts with capacity, conflict detection, reordering
|
- **Volunteer scheduling** — departments, shifts with capacity, conflict detection, reordering
|
||||||
- **Public volunteer signup** — self-registration form with email confirmation, auto-attendee linking
|
- **Public volunteer signup** — self-registration form with email confirmation, auto-participant linking
|
||||||
- **Volunteer kiosk** — token-authenticated self-service shift signup, no login required
|
- **Volunteer kiosk** — public volunteer flow: signup, email confirmation, code-authenticated shift self-scheduling
|
||||||
- **Gate check-in** — full-screen UI with QR scanner, party check-in ("2/3 checked in"), volunteer dual check-in
|
- **Gate kiosk** — full-screen check-in UI with QR scanner for gatekeepers
|
||||||
- **Schedule** — create shifts, assign volunteers, manage assignments with conflict awareness
|
- **Schedule** — create shifts, assign volunteers, manage assignments with conflict awareness
|
||||||
- **Role-based access** — admin, coordinator, volunteer lead (department-scoped), gate
|
- **Role-based access** — admin, ticketing, staffing, colead (department-scoped), gatekeeper
|
||||||
- **Offline-first PWA** — installs on phones/tablets, full offline check-in with background sync
|
- **Offline-first PWA** — installs on phones/tablets, full offline check-in with background sync
|
||||||
- **Real-time** — check-ins and changes broadcast live via SSE
|
- **Real-time** — check-ins and changes broadcast live via SSE
|
||||||
- **SMTP email** — send volunteer token links directly or export CSV for bulk email platforms
|
- **SMTP email** — volunteer confirmation emails, kiosk link distribution when shift signups open
|
||||||
- **Single binary** — Go backend embeds the frontend; no runtime dependencies
|
- **Single binary** — Go backend embeds the frontend; no runtime dependencies
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
@ -60,10 +60,11 @@ See [docs/INSTALLATION.md](docs/INSTALLATION.md) for systemd, Docker, NixOS, and
|
||||||
|
|
||||||
| Role | Access |
|
| Role | Access |
|
||||||
|------|--------|
|
|------|--------|
|
||||||
| `admin` | Full access: attendee import, user management, SMTP settings, all departments and shifts |
|
| `admin` | Full access: participant import, user management, SMTP settings, all departments and shifts |
|
||||||
| `coordinator` | All departments: volunteers, shifts, schedule. No user management or settings |
|
| `ticketing` | Participants, tickets, import. No user management |
|
||||||
| `volunteer_lead` | Own department only: volunteers and shifts scoped to assigned department |
|
| `staffing` | All departments: volunteers, shifts, schedule. No user management or settings |
|
||||||
| `gate` | Full-screen check-in UI with QR scanner. No access to other pages |
|
| `colead` | Own department only: volunteers and shifts scoped to assigned department(s) |
|
||||||
|
| `gatekeeper` | Full-screen Gate Kiosk with QR scanner. No access to other pages |
|
||||||
|
|
||||||
See [docs/USAGE.md](docs/USAGE.md) for detailed workflow documentation.
|
See [docs/USAGE.md](docs/USAGE.md) for detailed workflow documentation.
|
||||||
|
|
||||||
|
|
@ -91,7 +92,7 @@ The Vite dev server runs on `:5173` and proxies `/api` requests to the Go server
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
- [Usage Guide](docs/USAGE.md) — event setup, attendee import, volunteer signup, volunteer kiosk, gate check-in, schedule
|
- [Usage Guide](docs/USAGE.md) — event setup, participant import, volunteer signup, volunteer kiosk, gate check-in, schedule
|
||||||
- [Installation Guide](docs/INSTALLATION.md) — building, deploying, systemd, Docker, NixOS, reverse proxy, backup
|
- [Installation Guide](docs/INSTALLATION.md) — building, deploying, systemd, Docker, NixOS, reverse proxy, backup
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
|
||||||
110
db.go
110
db.go
|
|
@ -174,6 +174,23 @@ func migrateV2(db *sql.DB) error {
|
||||||
addColumnIfMissing(db, "volunteers", "pronouns TEXT NOT NULL DEFAULT ''")
|
addColumnIfMissing(db, "volunteers", "pronouns TEXT NOT NULL DEFAULT ''")
|
||||||
addColumnIfMissing(db, "volunteers", "email_confirmed INTEGER NOT NULL DEFAULT 0")
|
addColumnIfMissing(db, "volunteers", "email_confirmed INTEGER NOT NULL DEFAULT 0")
|
||||||
addColumnIfMissing(db, "volunteers", "confirmation_token TEXT")
|
addColumnIfMissing(db, "volunteers", "confirmation_token TEXT")
|
||||||
|
addColumnIfMissing(db, "volunteers", "confirmed INTEGER NOT NULL DEFAULT 0")
|
||||||
|
addColumnIfMissing(db, "volunteers", "confirmed_at TEXT")
|
||||||
|
addColumnIfMissing(db, "volunteers", "kiosk_code TEXT")
|
||||||
|
db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_volunteers_kiosk_code ON volunteers(kiosk_code) WHERE kiosk_code IS NOT NULL`)
|
||||||
|
// Migrate kiosk codes from tickets to volunteers (idempotent).
|
||||||
|
db.Exec(`
|
||||||
|
UPDATE volunteers SET kiosk_code = (
|
||||||
|
SELECT t.code FROM tickets t
|
||||||
|
WHERE t.participant_id = volunteers.participant_id
|
||||||
|
AND t.code IS NOT NULL AND t.deleted_at IS NULL
|
||||||
|
LIMIT 1
|
||||||
|
) WHERE kiosk_code IS NULL AND participant_id IS NOT NULL`)
|
||||||
|
// Delete stub tickets whose code has been migrated to the volunteer.
|
||||||
|
db.Exec(`
|
||||||
|
DELETE FROM tickets
|
||||||
|
WHERE source = 'manual' AND external_id = '' AND code IS NOT NULL
|
||||||
|
AND participant_id IN (SELECT id FROM volunteers WHERE kiosk_code IS NOT NULL)`)
|
||||||
// Widen the uniqueness constraint from name-only to (name, ticket_id).
|
// Widen the uniqueness constraint from name-only to (name, ticket_id).
|
||||||
db.Exec(`DROP INDEX IF EXISTS idx_attendees_name`)
|
db.Exec(`DROP INDEX IF EXISTS idx_attendees_name`)
|
||||||
db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_attendees_name_ticket ON attendees(name, ticket_id) WHERE deleted_at IS NULL`)
|
db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_attendees_name_ticket ON attendees(name, ticket_id) WHERE deleted_at IS NULL`)
|
||||||
|
|
@ -184,6 +201,7 @@ func migrateV2(db *sql.DB) error {
|
||||||
// and links volunteers to participants via participant_id.
|
// and links volunteers to participants via participant_id.
|
||||||
func migrateV3(db *sql.DB) error {
|
func migrateV3(db *sql.DB) error {
|
||||||
addColumnIfMissing(db, "volunteers", "participant_id INTEGER REFERENCES participants(id)")
|
addColumnIfMissing(db, "volunteers", "participant_id INTEGER REFERENCES participants(id)")
|
||||||
|
addColumnIfMissing(db, "participants", "ticket_name TEXT NOT NULL DEFAULT ''")
|
||||||
|
|
||||||
// Seed participants from volunteers first (better name data: preferred_name).
|
// Seed participants from volunteers first (better name data: preferred_name).
|
||||||
db.Exec(`
|
db.Exec(`
|
||||||
|
|
@ -392,8 +410,11 @@ type Volunteer struct {
|
||||||
IsLead bool `json:"is_lead"`
|
IsLead bool `json:"is_lead"`
|
||||||
CheckedIn bool `json:"checked_in"`
|
CheckedIn bool `json:"checked_in"`
|
||||||
CheckedInAt *string `json:"checked_in_at,omitempty"`
|
CheckedInAt *string `json:"checked_in_at,omitempty"`
|
||||||
|
Confirmed bool `json:"confirmed"`
|
||||||
|
ConfirmedAt *string `json:"confirmed_at,omitempty"`
|
||||||
EmailConfirmed bool `json:"email_confirmed"`
|
EmailConfirmed bool `json:"email_confirmed"`
|
||||||
ConfirmationToken *string `json:"-"`
|
ConfirmationToken *string `json:"-"`
|
||||||
|
KioskCode *string `json:"kiosk_code,omitempty"`
|
||||||
Note string `json:"note"`
|
Note string `json:"note"`
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
UpdatedAt string `json:"updated_at"`
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
|
@ -404,6 +425,7 @@ type Participant struct {
|
||||||
ID int `json:"id"`
|
ID int `json:"id"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
PreferredName string `json:"preferred_name"`
|
PreferredName string `json:"preferred_name"`
|
||||||
|
TicketName string `json:"ticket_name"`
|
||||||
Phone string `json:"phone"`
|
Phone string `json:"phone"`
|
||||||
Pronouns string `json:"pronouns"`
|
Pronouns string `json:"pronouns"`
|
||||||
Note string `json:"note"`
|
Note string `json:"note"`
|
||||||
|
|
@ -840,7 +862,7 @@ func (app *App) attendeeCounts() (total, checkedIn int, err error) {
|
||||||
|
|
||||||
// --- Participants ---
|
// --- Participants ---
|
||||||
|
|
||||||
const participantCols = `id, email, preferred_name, phone, pronouns, note, created_at, updated_at, deleted_at`
|
const participantCols = `id, email, preferred_name, ticket_name, phone, pronouns, note, created_at, updated_at, deleted_at`
|
||||||
|
|
||||||
func (app *App) listParticipants(search, since string) ([]Participant, error) {
|
func (app *App) listParticipants(search, since string) ([]Participant, error) {
|
||||||
var q string
|
var q string
|
||||||
|
|
@ -880,8 +902,8 @@ func (app *App) getParticipantByEmail(email string) (*Participant, error) {
|
||||||
|
|
||||||
func (app *App) createParticipant(p Participant) (*Participant, error) {
|
func (app *App) createParticipant(p Participant) (*Participant, error) {
|
||||||
res, err := app.db.Exec(
|
res, err := app.db.Exec(
|
||||||
`INSERT INTO participants (email, preferred_name, phone, pronouns, note, updated_at) VALUES (?, ?, ?, ?, ?, ?)`,
|
`INSERT INTO participants (email, preferred_name, ticket_name, phone, pronouns, note, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||||
strings.ToLower(p.Email), p.PreferredName, p.Phone, p.Pronouns, p.Note, now(),
|
strings.ToLower(p.Email), p.PreferredName, p.TicketName, p.Phone, p.Pronouns, p.Note, now(),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
@ -892,9 +914,9 @@ func (app *App) createParticipant(p Participant) (*Participant, error) {
|
||||||
|
|
||||||
func (app *App) updateParticipant(p Participant) error {
|
func (app *App) updateParticipant(p Participant) error {
|
||||||
_, err := app.db.Exec(
|
_, err := app.db.Exec(
|
||||||
`UPDATE participants SET email=?, preferred_name=?, phone=?, pronouns=?, note=?, updated_at=?
|
`UPDATE participants SET email=?, preferred_name=?, ticket_name=?, phone=?, pronouns=?, note=?, updated_at=?
|
||||||
WHERE id=? AND deleted_at IS NULL`,
|
WHERE id=? AND deleted_at IS NULL`,
|
||||||
strings.ToLower(p.Email), p.PreferredName, p.Phone, p.Pronouns, p.Note, now(), p.ID,
|
strings.ToLower(p.Email), p.PreferredName, p.TicketName, p.Phone, p.Pronouns, p.Note, now(), p.ID,
|
||||||
)
|
)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -937,7 +959,7 @@ func queryParticipants(db *sql.DB, q string, args ...any) ([]Participant, error)
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var p Participant
|
var p Participant
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&p.ID, &p.Email, &p.PreferredName, &p.Phone, &p.Pronouns, &p.Note,
|
&p.ID, &p.Email, &p.PreferredName, &p.TicketName, &p.Phone, &p.Pronouns, &p.Note,
|
||||||
&p.CreatedAt, &p.UpdatedAt, &p.DeletedAt,
|
&p.CreatedAt, &p.UpdatedAt, &p.DeletedAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
@ -1184,7 +1206,8 @@ const volunteerSelect = `v.id, v.participant_id, v.attendee_id,
|
||||||
COALESCE(NULLIF(p.phone,''), v.phone),
|
COALESCE(NULLIF(p.phone,''), v.phone),
|
||||||
COALESCE(NULLIF(p.pronouns,''), v.pronouns),
|
COALESCE(NULLIF(p.pronouns,''), v.pronouns),
|
||||||
v.department_id, v.is_lead, v.checked_in, v.checked_in_at,
|
v.department_id, v.is_lead, v.checked_in, v.checked_in_at,
|
||||||
v.email_confirmed, v.confirmation_token, v.note,
|
v.confirmed, v.confirmed_at,
|
||||||
|
v.email_confirmed, v.confirmation_token, v.kiosk_code, v.note,
|
||||||
v.created_at, v.updated_at, v.deleted_at`
|
v.created_at, v.updated_at, v.deleted_at`
|
||||||
const volunteerFrom = `FROM volunteers v LEFT JOIN participants p ON p.id = v.participant_id`
|
const volunteerFrom = `FROM volunteers v LEFT JOIN participants p ON p.id = v.participant_id`
|
||||||
|
|
||||||
|
|
@ -1293,6 +1316,19 @@ func (app *App) checkInVolunteer(id, userID int) (*Volunteer, error) {
|
||||||
return v, nil
|
return v, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (app *App) confirmVolunteer(id int) (*Volunteer, error) {
|
||||||
|
t := now()
|
||||||
|
_, err := app.db.Exec(
|
||||||
|
`UPDATE volunteers SET confirmed=1, confirmed_at=?, updated_at=?
|
||||||
|
WHERE id=? AND deleted_at IS NULL AND confirmed=0`,
|
||||||
|
t, t, id,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return app.getVolunteer(id)
|
||||||
|
}
|
||||||
|
|
||||||
func queryVolunteers(db *sql.DB, q string, args ...any) ([]Volunteer, error) {
|
func queryVolunteers(db *sql.DB, q string, args ...any) ([]Volunteer, error) {
|
||||||
rows, err := db.Query(q, args...)
|
rows, err := db.Query(q, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -1303,13 +1339,14 @@ func queryVolunteers(db *sql.DB, q string, args ...any) ([]Volunteer, error) {
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var v Volunteer
|
var v Volunteer
|
||||||
var participantID, attendeeID, deptID sql.NullInt64
|
var participantID, attendeeID, deptID sql.NullInt64
|
||||||
var isLead, checkedIn, emailConfirmed int
|
var isLead, checkedIn, confirmed, emailConfirmed int
|
||||||
var confirmationToken sql.NullString
|
var confirmationToken, confirmedAt, kioskCode sql.NullString
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&v.ID, &participantID, &attendeeID, &v.Name, &v.PreferredName, &v.TicketName,
|
&v.ID, &participantID, &attendeeID, &v.Name, &v.PreferredName, &v.TicketName,
|
||||||
&v.Email, &v.Phone, &v.Pronouns, &deptID,
|
&v.Email, &v.Phone, &v.Pronouns, &deptID,
|
||||||
&isLead, &checkedIn, &v.CheckedInAt,
|
&isLead, &checkedIn, &v.CheckedInAt,
|
||||||
&emailConfirmed, &confirmationToken, &v.Note,
|
&confirmed, &confirmedAt,
|
||||||
|
&emailConfirmed, &confirmationToken, &kioskCode, &v.Note,
|
||||||
&v.CreatedAt, &v.UpdatedAt, &v.DeletedAt,
|
&v.CreatedAt, &v.UpdatedAt, &v.DeletedAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
@ -1329,8 +1366,15 @@ func queryVolunteers(db *sql.DB, q string, args ...any) ([]Volunteer, error) {
|
||||||
if confirmationToken.Valid {
|
if confirmationToken.Valid {
|
||||||
v.ConfirmationToken = &confirmationToken.String
|
v.ConfirmationToken = &confirmationToken.String
|
||||||
}
|
}
|
||||||
|
if confirmedAt.Valid {
|
||||||
|
v.ConfirmedAt = &confirmedAt.String
|
||||||
|
}
|
||||||
|
if kioskCode.Valid {
|
||||||
|
v.KioskCode = &kioskCode.String
|
||||||
|
}
|
||||||
v.IsLead = isLead == 1
|
v.IsLead = isLead == 1
|
||||||
v.CheckedIn = checkedIn == 1
|
v.CheckedIn = checkedIn == 1
|
||||||
|
v.Confirmed = confirmed == 1
|
||||||
v.EmailConfirmed = emailConfirmed == 1
|
v.EmailConfirmed = emailConfirmed == 1
|
||||||
result = append(result, v)
|
result = append(result, v)
|
||||||
}
|
}
|
||||||
|
|
@ -1362,19 +1406,43 @@ func (app *App) confirmVolunteerEmail(id int) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// listConfirmedVolunteersNeedingCode returns confirmed volunteers whose participant
|
func (app *App) getVolunteerByKioskCode(code string) (*Volunteer, error) {
|
||||||
// has no ticket with a code yet.
|
rows, err := queryVolunteers(app.db,
|
||||||
func (app *App) listConfirmedVolunteersNeedingCode() ([]Volunteer, error) {
|
`SELECT `+volunteerSelect+` `+volunteerFrom+` WHERE v.kiosk_code = ? AND v.deleted_at IS NULL LIMIT 1`, code)
|
||||||
|
if err != nil || len(rows) == 0 {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &rows[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) assignKioskCode(id int, code string) error {
|
||||||
|
_, err := app.db.Exec(
|
||||||
|
`UPDATE volunteers SET kiosk_code=?, updated_at=? WHERE id=?`, code, now(), id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// listVolunteersNeedingKioskCode returns email-confirmed volunteers without a kiosk code.
|
||||||
|
func (app *App) listVolunteersNeedingKioskCode() ([]Volunteer, error) {
|
||||||
return queryVolunteers(app.db, `
|
return queryVolunteers(app.db, `
|
||||||
SELECT `+volunteerSelect+` `+volunteerFrom+`
|
SELECT `+volunteerSelect+` `+volunteerFrom+`
|
||||||
WHERE v.email_confirmed = 1 AND v.deleted_at IS NULL
|
WHERE v.email_confirmed = 1 AND v.kiosk_code IS NULL AND v.deleted_at IS NULL`)
|
||||||
AND v.participant_id IS NOT NULL
|
}
|
||||||
AND NOT EXISTS (
|
|
||||||
SELECT 1 FROM tickets t
|
func (app *App) generateVolunteerKioskCode() (string, error) {
|
||||||
WHERE t.participant_id = v.participant_id
|
for range 10 {
|
||||||
AND t.code IS NOT NULL
|
t, err := generateToken()
|
||||||
AND t.deleted_at IS NULL
|
if err != nil {
|
||||||
)`)
|
return "", err
|
||||||
|
}
|
||||||
|
var count int
|
||||||
|
if err := app.db.QueryRow(`SELECT COUNT(*) FROM volunteers WHERE kiosk_code = ?`, t).Scan(&count); err != nil {
|
||||||
|
return "", fmt.Errorf("check kiosk code uniqueness: %w", err)
|
||||||
|
}
|
||||||
|
if count == 0 {
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("failed to generate unique kiosk code")
|
||||||
}
|
}
|
||||||
|
|
||||||
func generateConfirmationToken() (string, error) {
|
func generateConfirmationToken() (string, error) {
|
||||||
|
|
|
||||||
|
|
@ -105,23 +105,27 @@ docker run -p 8180:8180 \
|
||||||
|
|
||||||
## NixOS
|
## NixOS
|
||||||
|
|
||||||
Turnpike builds with `buildGoModule` using the pure-Go SQLite driver (no CGO):
|
Turnpike builds with `buildGoModule` + `buildNpmPackage` (pure-Go SQLite, no CGO). The frontend is built separately and copied into the Go build:
|
||||||
|
|
||||||
```nix
|
```nix
|
||||||
|
frontendDist = pkgs.buildNpmPackage {
|
||||||
|
pname = "turnpike-frontend";
|
||||||
|
src = "${src}/frontend";
|
||||||
|
npmDepsHash = "sha256-...";
|
||||||
|
buildPhase = "npm run build";
|
||||||
|
installPhase = "cp -r dist $out";
|
||||||
|
};
|
||||||
|
|
||||||
turnpike = pkgs.buildGoModule {
|
turnpike = pkgs.buildGoModule {
|
||||||
pname = "turnpike";
|
pname = "turnpike";
|
||||||
version = "0.1.0";
|
src = fetchgit { url = "..."; rev = "v2.0.0"; hash = "sha256-..."; };
|
||||||
src = ./path/to/turnpike; # must include vendor/ and frontend/dist/
|
vendorHash = "sha256-...";
|
||||||
vendorHash = null;
|
|
||||||
env.CGO_ENABLED = 0;
|
env.CGO_ENABLED = 0;
|
||||||
|
preBuild = "cp -r ${frontendDist} frontend/dist";
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
The source directory must contain:
|
A complete NixOS module with `DynamicUser`, `StateDirectory`, and secrets is in the project's `homelab/turnpike.nix`.
|
||||||
- Go source files and `vendor/` (run `go mod vendor`)
|
|
||||||
- Pre-built frontend at `frontend/dist/` (run `cd frontend && npm run build`)
|
|
||||||
|
|
||||||
A complete NixOS module example with `DynamicUser`, `StateDirectory`, and agenix secrets is in the project's `homelab/turnpike.nix`.
|
|
||||||
|
|
||||||
## Reverse Proxy
|
## Reverse Proxy
|
||||||
|
|
||||||
|
|
|
||||||
108
docs/USAGE.md
108
docs/USAGE.md
|
|
@ -12,23 +12,22 @@ After logging in, create accounts for your team under **Users**. Each user gets
|
||||||
|
|
||||||
| Role | What they see | What they can do |
|
| Role | What they see | What they can do |
|
||||||
|------|--------------|------------------|
|
|------|--------------|------------------|
|
||||||
| **admin** | All pages + Settings | Everything: attendee import, user management, SMTP config, departments, shifts, volunteers |
|
| **admin** | All pages + Settings | Everything: participant import, user management, SMTP config, departments, shifts, volunteers |
|
||||||
| **coordinator** | Dashboard, Schedule, Volunteers, Departments | Manage volunteers, departments, and shifts across all departments. Cannot manage users or settings |
|
| **ticketing** | Participants, Tickets, Import | Manage participants and tickets, run CSV imports |
|
||||||
| **volunteer_lead** | Schedule, Volunteers, Departments | Manage volunteers and shifts within their assigned department only |
|
| **staffing** | Dashboard, Schedule, Volunteers, Departments | Manage volunteers, departments, and shifts across all departments. No user management or settings |
|
||||||
| **gate** | Full-screen Gate UI | Check in attendees (search + QR scan). No access to other pages |
|
| **colead** | Dashboard, Schedule, Volunteers | Manage volunteers and shifts within their assigned department(s) only |
|
||||||
|
| **gatekeeper** | Full-screen Gate Kiosk | Check in ticket holders (search + QR scan). No access to other pages |
|
||||||
|
|
||||||
Ticketing and ops staff should use the **admin** role. The `ticketing` role exists in the codebase but is effectively unused — admin covers all ticketing functions.
|
Coleads are scoped to one or more departments. When creating a colead user, assign their department(s).
|
||||||
|
|
||||||
Volunteer leads are scoped to a single department. When creating a volunteer_lead user, assign their department.
|
|
||||||
|
|
||||||
## Event Setup
|
## Event Setup
|
||||||
|
|
||||||
1. **Configure your event** — go to the Dashboard and set the event name and dates.
|
1. **Configure your event** — go to **Settings** and set the event name, venue, dates, and timezone. These appear on the Dashboard and volunteer signup page.
|
||||||
2. **Create departments** — under Departments, add each department your event needs (e.g., Gate, Greeters, Rangers, Build, LNT).
|
2. **Create departments** — under Departments, add each department your event needs (e.g., Gate, Greeters, Rangers, Build, LNT).
|
||||||
3. **Import attendees** — see next section.
|
3. **Import participants** — see next section.
|
||||||
4. **Create shifts** — under Schedule, create shifts for each department with day, start/end time, and capacity.
|
4. **Create shifts** — under Schedule, create shifts for each department with day, start/end time, and capacity.
|
||||||
|
|
||||||
## Importing Attendees
|
## Importing Participants
|
||||||
|
|
||||||
Go to **Import** and upload a CSV file. Turnpike auto-detects two formats:
|
Go to **Import** and upload a CSV file. Turnpike auto-detects two formats:
|
||||||
|
|
||||||
|
|
@ -36,7 +35,7 @@ Go to **Import** and upload a CSV file. Turnpike auto-detects two formats:
|
||||||
|
|
||||||
| Column | Maps to |
|
| Column | Maps to |
|
||||||
|--------|---------|
|
|--------|---------|
|
||||||
| `Patron Name` | Name |
|
| `Patron Name` | Ticket name |
|
||||||
| `Patron Email` | Email |
|
| `Patron Email` | Email |
|
||||||
| `Order Number` | Ticket ID |
|
| `Order Number` | Ticket ID |
|
||||||
| `Tier Name` | Ticket type |
|
| `Tier Name` | Ticket type |
|
||||||
|
|
@ -45,7 +44,7 @@ Go to **Import** and upload a CSV file. Turnpike auto-detects two formats:
|
||||||
|
|
||||||
| Column | Maps to |
|
| Column | Maps to |
|
||||||
|--------|---------|
|
|--------|---------|
|
||||||
| `name` (required) | Name |
|
| `name` (required) | Ticket name |
|
||||||
| `email` | Email |
|
| `email` | Email |
|
||||||
| `ticket_id` | Ticket ID |
|
| `ticket_id` | Ticket ID |
|
||||||
| `ticket_type` | Ticket type |
|
| `ticket_type` | Ticket type |
|
||||||
|
|
@ -53,27 +52,21 @@ Go to **Import** and upload a CSV file. Turnpike auto-detects two formats:
|
||||||
|
|
||||||
Column matching is case-insensitive. Extra columns are ignored. BOM-encoded files (Windows Excel exports) are handled automatically.
|
Column matching is case-insensitive. Extra columns are ignored. BOM-encoded files (Windows Excel exports) are handled automatically.
|
||||||
|
|
||||||
### Party-size dedup
|
### Participants and tickets
|
||||||
|
|
||||||
CrowdWork exports one row per ticket, even when the same person bought multiple tickets in one order. Turnpike handles this automatically:
|
Each row in the CSV creates one **ticket**. Participants are deduplicated by email — multiple tickets with the same email address are linked to a single participant record. The import result shows `inserted` (new tickets) and `skipped` (exact duplicates).
|
||||||
|
|
||||||
- First row for "Titania Fairweather" (order 1234) creates a record with `party_size=1`
|
Re-importing the same CSV is safe — exact duplicates are skipped, not duplicated.
|
||||||
- Subsequent rows with the same name + order number increment `party_size` (no duplicate record)
|
|
||||||
- Result: one attendee record, `party_size=3` if three tickets were purchased
|
|
||||||
|
|
||||||
The import result shows `inserted` (new records), `grouped` (merged into existing party), and `skipped` (exact duplicates).
|
|
||||||
|
|
||||||
Re-importing the same CSV is safe — existing records are skipped, not duplicated.
|
|
||||||
|
|
||||||
## Volunteer Signup
|
## Volunteer Signup
|
||||||
|
|
||||||
Turnpike provides a public signup form for volunteers at `/#/volunteer-signup`. No login is required.
|
Turnpike provides a public signup form for volunteers at `/volunteer-signup`. No login is required.
|
||||||
|
|
||||||
### Signup flow
|
### Signup flow
|
||||||
|
|
||||||
1. Volunteer visits the signup form and fills in: preferred name (required), ticket name, email (required), pronouns, phone, department preference, and an optional note.
|
1. Volunteer visits the signup form and fills in: preferred name (required), ticket name, email (required), pronouns, phone, department preference, and an optional note.
|
||||||
2. Turnpike creates a volunteer record and auto-links it to an existing attendee by email match, or creates a new attendee record.
|
2. Turnpike creates a volunteer record and auto-links it to an existing participant by email match, or creates a new participant record.
|
||||||
3. A confirmation email is sent with a unique link (`/#/confirm/{token}`).
|
3. A confirmation email is sent with a unique link (`/confirm/{token}`).
|
||||||
4. The volunteer clicks the link to confirm their email.
|
4. The volunteer clicks the link to confirm their email.
|
||||||
5. If shift signups are already open, the confirmation page includes a link to the kiosk for shift selection.
|
5. If shift signups are already open, the confirmation page includes a link to the kiosk for shift selection.
|
||||||
|
|
||||||
|
|
@ -90,7 +83,7 @@ In **Settings**, the "Volunteer Signup" card controls:
|
||||||
|
|
||||||
In **Settings**, the "Shift Signups" card has an open/close toggle:
|
In **Settings**, the "Shift Signups" card has an open/close toggle:
|
||||||
|
|
||||||
- **Opening** signups generates kiosk tokens for all confirmed volunteers and emails them their shift signup links. A confirmation dialog warns before sending.
|
- **Opening** signups generates kiosk codes for all registered (email-confirmed) volunteers and emails them their shift signup links. A confirmation dialog warns before sending.
|
||||||
- **Closing** signups prevents new kiosk links from being issued on confirmation, but existing links continue to work.
|
- **Closing** signups prevents new kiosk links from being issued on confirmation, but existing links continue to work.
|
||||||
|
|
||||||
If a volunteer confirms their email while signups are already open, they receive their kiosk link immediately in the confirmation response and via email.
|
If a volunteer confirms their email while signups are already open, they receive their kiosk link immediately in the confirmation response and via email.
|
||||||
|
|
@ -99,12 +92,23 @@ If a volunteer confirms their email while signups are already open, they receive
|
||||||
|
|
||||||
Under **Volunteers**, you can:
|
Under **Volunteers**, you can:
|
||||||
|
|
||||||
- Create volunteers manually (name, email, department)
|
- Create volunteers manually (name, email, department, co-lead, note)
|
||||||
- Link a volunteer to an existing attendee record (for dual check-in at the gate)
|
- Edit existing volunteers (department, co-lead, note) via the inline Edit button
|
||||||
- Assign volunteers to departments
|
- Confirm registered volunteers (admin, staffing, colead)
|
||||||
- Check in volunteers
|
- Mark volunteers as ready (briefed at the volunteer station)
|
||||||
|
|
||||||
Volunteers are separate from attendees. A person can be both an attendee (ticket holder) and a volunteer (shift worker). Linking them enables the gate team to check in both records simultaneously.
|
### Volunteer statuses
|
||||||
|
|
||||||
|
| Status | Meaning | Who sets it |
|
||||||
|
|--------|---------|-------------|
|
||||||
|
| **Unconfirmed** | Signed up but hasn't confirmed their email | Automatic (not yet done) |
|
||||||
|
| **Registered** | Email confirmed — volunteer is in the system | Automatic (email link) |
|
||||||
|
| **Confirmed** | Staff has verified the volunteer is assigned and ready to be scheduled | Admin, staffing, or co-lead |
|
||||||
|
| **Ready** | Briefed at the volunteer station, cleared to report for shifts | Gate staff at check-in |
|
||||||
|
|
||||||
|
**Confirmation** is a deliberate staff action — it signals that you're expecting the volunteer for shifts. Use the **Confirm** button on a registered volunteer's row. Marking a volunteer as a co-lead (`is_lead`) automatically confirms them.
|
||||||
|
|
||||||
|
Volunteers are separate from participants. A person can be both a ticket holder and a volunteer. When a volunteer signs up via the public form, they are automatically linked to their participant record by email.
|
||||||
|
|
||||||
## Shift Scheduling
|
## Shift Scheduling
|
||||||
|
|
||||||
|
|
@ -124,19 +128,21 @@ Shifts can be reordered within a department to reflect priority or sequence usin
|
||||||
|
|
||||||
## Volunteer Kiosk
|
## Volunteer Kiosk
|
||||||
|
|
||||||
The kiosk lets volunteers self-select shifts without logging in.
|
The Volunteer Kiosk is the public-facing flow for volunteers: signup, email confirmation, and shift self-scheduling. The shift scheduling page lets volunteers self-select shifts without logging in.
|
||||||
|
|
||||||
### Setup
|
### Setup
|
||||||
|
|
||||||
1. **Generate tokens** — on the Attendees page, click "Generate Tokens." This creates a unique 8-character code for every attendee that doesn't have one.
|
Kiosk links are generated and distributed automatically through the volunteer signup flow:
|
||||||
2. **Distribute tokens** — two options:
|
|
||||||
- **Export CSV** — downloads a file with columns `Email Address`, `First Name`, `Token`, `Signup Link`. Import this into MailChimp, Zeffy, or any email platform.
|
1. Volunteers sign up via the public signup form (`/volunteer-signup`) and confirm their email.
|
||||||
- **Email directly** — if SMTP is configured (see below), use "Email All" to send token links, or email individually per attendee.
|
2. In **Settings**, open shift signups. This generates kiosk codes for all registered (email-confirmed) volunteers and emails them their links. A confirmation dialog warns before sending.
|
||||||
3. **Set base URL** — in Settings, set the public base URL (e.g., `https://turnpike.example.com`). Token links use this URL.
|
3. If a volunteer confirms their email while signups are already open, they receive their kiosk link immediately.
|
||||||
|
|
||||||
|
**Set base URL** — in Settings, set the public base URL (e.g., `https://turnpike.example.com`). Kiosk links use this URL.
|
||||||
|
|
||||||
### Volunteer experience
|
### Volunteer experience
|
||||||
|
|
||||||
Each volunteer receives a link like `https://turnpike.example.com/#/v/ABC12345`. This opens a mobile-friendly page showing:
|
Each volunteer receives a link like `https://turnpike.example.com/v/ABC12345`. This opens a mobile-friendly page showing:
|
||||||
|
|
||||||
- Their name and department
|
- Their name and department
|
||||||
- Currently assigned shifts
|
- Currently assigned shifts
|
||||||
|
|
@ -144,22 +150,22 @@ Each volunteer receives a link like `https://turnpike.example.com/#/v/ABC12345`.
|
||||||
|
|
||||||
Claiming a shift checks for time conflicts. If a conflict exists, the volunteer sees which shifts overlap and can confirm to proceed anyway.
|
Claiming a shift checks for time conflicts. If a conflict exists, the volunteer sees which shifts overlap and can confirm to proceed anyway.
|
||||||
|
|
||||||
No login is required. The 8-character token authenticates the request.
|
No login is required. The kiosk code authenticates the request.
|
||||||
|
|
||||||
### Token format
|
### Code format
|
||||||
|
|
||||||
Tokens use the character set `A-Z, 2-9` (excluding 0/O, 1/I/L to avoid ambiguity when reading aloud or on printed badges).
|
Kiosk codes use the character set `A-Z, 2-9` (excluding 0/O, 1/I/L to avoid ambiguity when reading aloud or on printed badges).
|
||||||
|
|
||||||
## Gate Check-In
|
## Gate Kiosk
|
||||||
|
|
||||||
Users with the **gate** role see a dedicated full-screen UI:
|
Users with the **gatekeeper** role see a dedicated full-screen Gate Kiosk:
|
||||||
|
|
||||||
- **QR scanner** — uses the device camera via the BarcodeDetector API. Scanned codes populate the search field.
|
- **QR scanner** — uses the device camera via the BarcodeDetector API. Scanned codes populate the search field.
|
||||||
- **Search** — type a name to filter attendees in real-time (searches local IndexedDB, works offline).
|
- **Search** — type a name to filter tickets in real-time (searches local IndexedDB, works offline).
|
||||||
- **Party check-in** — for attendees with `party_size > 1`, the gate UI shows progress ("2/3 checked in") and offers "Check in 1" or "Check in all remaining."
|
|
||||||
- **Volunteer dual check-in** — if an attendee is linked to a volunteer record, the gate UI shows their volunteer status and offers to check in both simultaneously.
|
|
||||||
- **Recent check-ins** — the last 10 check-ins are shown for quick reference.
|
- **Recent check-ins** — the last 10 check-ins are shown for quick reference.
|
||||||
|
|
||||||
|
Admins and ticketing leads can also check in tickets directly from the **Participants** page by expanding a participant's tickets.
|
||||||
|
|
||||||
Gate devices should install Turnpike as a PWA (Add to Home Screen) for the best experience. Check-ins are stored locally and sync when connectivity is available.
|
Gate devices should install Turnpike as a PWA (Add to Home Screen) for the best experience. Check-ins are stored locally and sync when connectivity is available.
|
||||||
|
|
||||||
## Schedule
|
## Schedule
|
||||||
|
|
@ -170,7 +176,7 @@ The Schedule page is the primary UI for managing shifts and volunteer assignment
|
||||||
- Each shift card shows: name, time, capacity (used/total), assigned volunteers
|
- Each shift card shows: name, time, capacity (used/total), assigned volunteers
|
||||||
- Conflict badges when a volunteer has overlapping shifts on the same day
|
- Conflict badges when a volunteer has overlapping shifts on the same day
|
||||||
|
|
||||||
**Coordinators and admins** see all departments. **Volunteer leads** see only their assigned department.
|
**Admins and staffing** see all departments. **Coleads** see only their assigned department(s).
|
||||||
|
|
||||||
Actions available:
|
Actions available:
|
||||||
- Create new shifts (+ Add shift button)
|
- Create new shifts (+ Add shift button)
|
||||||
|
|
@ -182,7 +188,7 @@ Actions available:
|
||||||
|
|
||||||
## SMTP Configuration
|
## SMTP Configuration
|
||||||
|
|
||||||
SMTP enables token email distribution and test emails. Configure in **Settings** (admin only):
|
SMTP enables volunteer confirmation emails, kiosk link distribution, and test emails. Configure in **Settings** (admin only):
|
||||||
|
|
||||||
| Field | Description |
|
| Field | Description |
|
||||||
|-------|-------------|
|
|-------|-------------|
|
||||||
|
|
@ -203,13 +209,13 @@ Turnpike is a Progressive Web App (PWA). After the first load, it works offline:
|
||||||
|
|
||||||
- **Gate check-ins** are stored in the browser's IndexedDB and sync when connectivity returns.
|
- **Gate check-ins** are stored in the browser's IndexedDB and sync when connectivity returns.
|
||||||
- **Real-time updates** use Server-Sent Events (SSE). When the connection drops, the client reconnects automatically.
|
- **Real-time updates** use Server-Sent Events (SSE). When the connection drops, the client reconnects automatically.
|
||||||
- **Sync** pulls all changes from the server on startup and periodically thereafter. Local changes are queued in an outbox and flushed in order.
|
- **Sync** pulls all changes from the server on startup and periodically thereafter.
|
||||||
|
|
||||||
Install Turnpike as a PWA (Add to Home Screen on mobile, or Install App in desktop Chrome) for the best offline experience.
|
Install Turnpike as a PWA (Add to Home Screen on mobile, or Install App in desktop Chrome) for the best offline experience.
|
||||||
|
|
||||||
## CSV Exports
|
## CSV Exports
|
||||||
|
|
||||||
Two CSV exports are available from the Attendees page:
|
CSV exports are available from the Participants page:
|
||||||
|
|
||||||
- **Attendee export** — all attendee records with check-in status
|
- **Participant export** — all participant records with check-in status
|
||||||
- **Token link export** — columns: `Email Address`, `First Name`, `Token`, `Signup Link`. Only includes attendees with tokens. Compatible with MailChimp and Zeffy for bulk email campaigns.
|
- **Ticket export** — all ticket records with codes and check-in status
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,10 @@
|
||||||
import Departments from './pages/Departments.svelte'
|
import Departments from './pages/Departments.svelte'
|
||||||
import Users from './pages/Users.svelte'
|
import Users from './pages/Users.svelte'
|
||||||
import Import from './pages/Import.svelte'
|
import Import from './pages/Import.svelte'
|
||||||
import Kiosk from './pages/Kiosk.svelte'
|
import VolunteerKiosk from './pages/VolunteerKiosk.svelte'
|
||||||
import VolunteerSignup from './pages/VolunteerSignup.svelte'
|
import VolunteerSignup from './pages/VolunteerSignup.svelte'
|
||||||
import ConfirmEmail from './pages/ConfirmEmail.svelte'
|
import ConfirmEmail from './pages/ConfirmEmail.svelte'
|
||||||
import GateUI from './pages/GateUI.svelte'
|
import GateKiosk from './pages/GateKiosk.svelte'
|
||||||
import ScheduleBoard from './pages/ScheduleBoard.svelte'
|
import ScheduleBoard from './pages/ScheduleBoard.svelte'
|
||||||
import Settings from './pages/Settings.svelte'
|
import Settings from './pages/Settings.svelte'
|
||||||
import Nav from './components/Nav.svelte'
|
import Nav from './components/Nav.svelte'
|
||||||
|
|
@ -96,7 +96,7 @@
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<!-- checking session -->
|
<!-- checking session -->
|
||||||
{:else if kioskToken}
|
{:else if kioskToken}
|
||||||
<Kiosk />
|
<VolunteerKiosk />
|
||||||
{:else if isVolunteerSignup}
|
{:else if isVolunteerSignup}
|
||||||
<VolunteerSignup />
|
<VolunteerSignup />
|
||||||
{:else if isConfirmEmail}
|
{:else if isConfirmEmail}
|
||||||
|
|
@ -104,8 +104,8 @@
|
||||||
{:else if !session}
|
{:else if !session}
|
||||||
<Login onlogin={onLogin} />
|
<Login onlogin={onLogin} />
|
||||||
{:else if role === 'gatekeeper'}
|
{:else if role === 'gatekeeper'}
|
||||||
<!-- Gate users get the full-screen GateUI instead of the standard layout -->
|
<!-- Gate users get the full-screen GateKiosk instead of the standard layout -->
|
||||||
<GateUI {session} {onLogout} />
|
<GateKiosk {session} {onLogout} />
|
||||||
{:else}
|
{:else}
|
||||||
<div class="layout">
|
<div class="layout">
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,7 @@ export const api = {
|
||||||
update: (id, data) => apiJSON(`/api/volunteers/${id}`, { method: 'PUT', 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' }),
|
delete: (id) => apiFetch(`/api/volunteers/${id}`, { method: 'DELETE' }),
|
||||||
checkIn: (id) => apiJSON(`/api/volunteers/${id}/checkin`, { method: 'POST' }),
|
checkIn: (id) => apiJSON(`/api/volunteers/${id}/checkin`, { method: 'POST' }),
|
||||||
|
confirm: (id) => apiJSON(`/api/volunteers/${id}/confirm`, { method: 'POST' }),
|
||||||
assignShift: (id, shiftId) => apiFetch(`/api/volunteers/${id}/shifts`, { method: 'POST', body: JSON.stringify({ shift_id: shiftId }) }),
|
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' }),
|
unassignShift: (id, shiftId) => apiFetch(`/api/volunteers/${id}/shifts/${shiftId}`, { method: 'DELETE' }),
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -130,6 +130,8 @@ tr:hover td { background: rgba(255,255,255,0.02); }
|
||||||
text-transform: uppercase; letter-spacing: 0.04em;
|
text-transform: uppercase; letter-spacing: 0.04em;
|
||||||
}
|
}
|
||||||
.badge-checked { background: rgba(34,197,94,0.15); color: var(--c-success); }
|
.badge-checked { background: rgba(34,197,94,0.15); color: var(--c-success); }
|
||||||
|
.badge-confirmed { background: rgba(99,102,241,0.15); color: var(--c-accent-h); }
|
||||||
|
.badge-registered { background: rgba(14,165,233,0.15); color: #0ea5e9; }
|
||||||
.badge-unchecked { background: rgba(122,127,150,0.15); color: var(--c-muted); }
|
.badge-unchecked { background: rgba(122,127,150,0.15); color: var(--c-muted); }
|
||||||
.badge-partial { background: rgba(245,158,11,0.15); color: var(--c-warn); }
|
.badge-partial { background: rgba(245,158,11,0.15); color: var(--c-warn); }
|
||||||
.badge-role { background: rgba(99,102,241,0.15); color: var(--c-accent-h); }
|
.badge-role { background: rgba(99,102,241,0.15); color: var(--c-accent-h); }
|
||||||
|
|
@ -206,4 +208,32 @@ tr:hover td { background: rgba(255,255,255,0.02); }
|
||||||
}
|
}
|
||||||
.page { padding: 1rem; }
|
.page { padding: 1rem; }
|
||||||
.stats { grid-template-columns: repeat(2, 1fr); }
|
.stats { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
|
||||||
|
/* Touch targets */
|
||||||
|
.btn { min-height: 44px; padding: 0.6rem 1rem; }
|
||||||
|
.btn-sm { min-height: 44px; padding: 0.5rem 0.75rem; font-size: 0.85rem; }
|
||||||
|
|
||||||
|
/* Page header & actions */
|
||||||
|
.page-header { flex-wrap: wrap; gap: 0.75rem; }
|
||||||
|
.page-title { width: 100%; }
|
||||||
|
.actions { flex-wrap: wrap; }
|
||||||
|
|
||||||
|
/* Search bar */
|
||||||
|
.search-bar { flex-wrap: wrap; }
|
||||||
|
.search-bar input { max-width: none; flex: 1 1 100%; }
|
||||||
|
|
||||||
|
/* Table → card layout */
|
||||||
|
.table-wrap { overflow-x: visible; }
|
||||||
|
table { display: block; }
|
||||||
|
thead { display: none; }
|
||||||
|
tbody { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||||
|
tr { display: flex; flex-wrap: wrap; gap: 0.25rem 0.75rem; align-items: center;
|
||||||
|
padding: 0.75rem; border: 1px solid var(--c-border); border-radius: var(--radius-lg);
|
||||||
|
background: var(--c-surface); }
|
||||||
|
tr:hover td { background: transparent; }
|
||||||
|
td { display: inline; padding: 0; border: none; }
|
||||||
|
td:empty { display: none; }
|
||||||
|
|
||||||
|
/* Forms */
|
||||||
|
.form-grid { grid-template-columns: 1fr !important; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,5 +9,5 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<button class="btn btn-success btn-sm" onclick={handle} disabled={loading}>
|
<button class="btn btn-success btn-sm" onclick={handle} disabled={loading}>
|
||||||
{loading ? '…' : '✓ Check in'}
|
{loading ? '…' : '✓ Ready'}
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,54 @@
|
||||||
|
|
||||||
let { session } = $props()
|
let { session } = $props()
|
||||||
|
|
||||||
const attendees = liveQuery(() => db.attendees.toArray())
|
const role = $derived(session?.user?.role ?? '')
|
||||||
const event = liveQuery(() => db.event.get(1))
|
const myDeptIDs = $derived(session?.user?.department_ids ?? [])
|
||||||
|
const isTicketing = $derived(['admin', 'ticketing'].includes(role))
|
||||||
|
const isStaffing = $derived(['admin', 'ticketing', 'staffing'].includes(role))
|
||||||
|
const isColead = $derived(role === 'colead')
|
||||||
|
|
||||||
const total = $derived(($attendees ?? []).length)
|
const event = liveQuery(() => db.event.get(1))
|
||||||
const checkedIn = $derived(($attendees ?? []).filter(a => a.checked_in).length)
|
const allTickets = liveQuery(() => db.tickets.toArray())
|
||||||
const remaining = $derived(total - checkedIn)
|
const allVolunteers = liveQuery(() => db.volunteers.filter(v => !v.deleted_at).toArray())
|
||||||
const pct = $derived(total > 0 ? Math.round((checkedIn / total) * 100) : 0)
|
const allShifts = liveQuery(() => db.shifts.filter(s => !s.deleted_at).toArray())
|
||||||
|
const allDepts = liveQuery(() => db.departments.filter(d => !d.deleted_at).toArray())
|
||||||
|
const allVS = liveQuery(() => db.volunteer_shifts.toArray())
|
||||||
|
|
||||||
|
// Ticket stats
|
||||||
|
const tickets = $derived($allTickets ?? [])
|
||||||
|
const ticketTotal = $derived(tickets.length)
|
||||||
|
const ticketCheckedIn = $derived(tickets.filter(t => t.checked_in_at).length)
|
||||||
|
const ticketRemaining = $derived(ticketTotal - ticketCheckedIn)
|
||||||
|
const ticketPct = $derived(ticketTotal > 0 ? Math.round((ticketCheckedIn / ticketTotal) * 100) : 0)
|
||||||
|
|
||||||
|
// Volunteer stats (scoped for colead)
|
||||||
|
const volunteers = $derived.by(() => {
|
||||||
|
const vols = $allVolunteers ?? []
|
||||||
|
if (isColead) return vols.filter(v => myDeptIDs.includes(v.department_id))
|
||||||
|
return vols
|
||||||
|
})
|
||||||
|
const volTotal = $derived(volunteers.length)
|
||||||
|
const volCheckedIn = $derived(volunteers.filter(v => v.checked_in).length)
|
||||||
|
const volLeads = $derived(volunteers.filter(v => v.is_lead).length)
|
||||||
|
|
||||||
|
// Shift stats (scoped for colead)
|
||||||
|
const shifts = $derived.by(() => {
|
||||||
|
const all = $allShifts ?? []
|
||||||
|
if (isColead) return all.filter(s => myDeptIDs.includes(s.department_id))
|
||||||
|
return all
|
||||||
|
})
|
||||||
|
const shiftTotal = $derived(shifts.length)
|
||||||
|
const shiftsFilled = $derived.by(() => {
|
||||||
|
const vs = $allVS ?? []
|
||||||
|
return shifts.filter(s => vs.some(a => a.shift_id === s.id)).length
|
||||||
|
})
|
||||||
|
const shiftFillPct = $derived(shiftTotal > 0 ? Math.round((shiftsFilled / shiftTotal) * 100) : 0)
|
||||||
|
|
||||||
|
// Department names for colead header
|
||||||
|
const myDeptNames = $derived.by(() => {
|
||||||
|
const depts = $allDepts ?? []
|
||||||
|
return myDeptIDs.map(id => depts.find(d => d.id === id)?.name).filter(Boolean)
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="page">
|
<div class="page">
|
||||||
|
|
@ -28,35 +69,113 @@
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if isColead && myDeptNames.length > 0}
|
||||||
|
<p style="margin-bottom:1.5rem;font-size:0.9rem">
|
||||||
|
Your department{myDeptNames.length > 1 ? 's' : ''}:
|
||||||
|
<strong>{myDeptNames.join(', ')}</strong>
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Ticket check-in (admin/ticketing) -->
|
||||||
|
{#if isTicketing}
|
||||||
|
<h2 class="dash-section">Ticket Check-in</h2>
|
||||||
<div class="stats">
|
<div class="stats">
|
||||||
<div class="stat">
|
<div class="stat">
|
||||||
<div class="stat-label">Total</div>
|
<div class="stat-label">Total tickets</div>
|
||||||
<div class="stat-value">{total}</div>
|
<div class="stat-value">{ticketTotal}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat">
|
<div class="stat">
|
||||||
<div class="stat-label">Checked in</div>
|
<div class="stat-label">Checked in</div>
|
||||||
<div class="stat-value" style="color:var(--c-success)">{checkedIn}</div>
|
<div class="stat-value" style="color:var(--c-success)">{ticketCheckedIn}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat">
|
<div class="stat">
|
||||||
<div class="stat-label">Remaining</div>
|
<div class="stat-label">Remaining</div>
|
||||||
<div class="stat-value">{remaining}</div>
|
<div class="stat-value">{ticketRemaining}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat">
|
<div class="stat">
|
||||||
<div class="stat-label">Progress</div>
|
<div class="stat-label">Progress</div>
|
||||||
<div class="stat-value">{pct}%</div>
|
<div class="stat-value">{ticketPct}%</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if total > 0}
|
{#if ticketTotal > 0}
|
||||||
<div class="card" style="margin-bottom:1rem">
|
<div style="height:8px;background:var(--c-border);border-radius:99px;overflow:hidden;margin-bottom:2rem">
|
||||||
<div style="height:8px;background:var(--c-border);border-radius:99px;overflow:hidden">
|
<div style="height:100%;width:{ticketPct}%;background:var(--c-success);border-radius:99px;transition:width 0.4s ease"></div>
|
||||||
<div style="height:100%;width:{pct}%;background:var(--c-success);border-radius:99px;transition:width 0.4s ease"></div>
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Volunteer stats (admin/ticketing/staffing/colead) -->
|
||||||
|
{#if isStaffing || isColead}
|
||||||
|
<h2 class="dash-section">{isColead ? 'My Volunteers' : 'Volunteers'}</h2>
|
||||||
|
<div class="stats">
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-label">Total</div>
|
||||||
|
<div class="stat-value">{volTotal}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-label">Checked in</div>
|
||||||
|
<div class="stat-value" style="color:var(--c-success)">{volCheckedIn}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-label">Leads</div>
|
||||||
|
<div class="stat-value">{volLeads}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<p class="text-muted" style="font-size:0.85rem">
|
<!-- Shift coverage (admin/ticketing/staffing/colead) -->
|
||||||
|
{#if isStaffing || isColead}
|
||||||
|
<h2 class="dash-section">{isColead ? 'My Shifts' : 'Shift Coverage'}</h2>
|
||||||
|
<div class="stats">
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-label">Total shifts</div>
|
||||||
|
<div class="stat-value">{shiftTotal}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-label">With volunteers</div>
|
||||||
|
<div class="stat-value">{shiftsFilled}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-label">Fill rate</div>
|
||||||
|
<div class="stat-value">{shiftFillPct}%</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Quick actions -->
|
||||||
|
{#if isTicketing}
|
||||||
|
<div class="dash-actions">
|
||||||
|
<a href="/import" class="btn btn-ghost btn-sm">Import CSV</a>
|
||||||
|
<a href="/participants" class="btn btn-ghost btn-sm">Manage Participants</a>
|
||||||
|
<a href="/settings" class="btn btn-ghost btn-sm">Settings</a>
|
||||||
|
</div>
|
||||||
|
{:else if isStaffing || isColead}
|
||||||
|
<div class="dash-actions">
|
||||||
|
<a href="/schedule" class="btn btn-ghost btn-sm">View Schedule</a>
|
||||||
|
<a href="/volunteers" class="btn btn-ghost btn-sm">Manage Volunteers</a>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<p class="text-muted" style="font-size:0.85rem;margin-top:2rem">
|
||||||
Welcome, <strong style="color:var(--c-text)">{session?.user?.username}</strong>
|
Welcome, <strong style="color:var(--c-text)">{session?.user?.username}</strong>
|
||||||
· <span class="badge badge-role">{session?.user?.role}</span>
|
· <span class="badge badge-role">{session?.user?.role}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.dash-section {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--c-muted);
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
.dash-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -100,7 +100,7 @@
|
||||||
{#if showAdd && canCreate}
|
{#if showAdd && canCreate}
|
||||||
<div class="card" style="margin-bottom:1.5rem">
|
<div class="card" style="margin-bottom:1.5rem">
|
||||||
<form onsubmit={addDept}>
|
<form onsubmit={addDept}>
|
||||||
<div style="display:grid;grid-template-columns:1fr 1fr auto;gap:1rem;align-items:end">
|
<div class="form-grid" style="display:grid;grid-template-columns:1fr 1fr auto;gap:1rem;align-items:end">
|
||||||
<div class="form-group" style="margin-bottom:0">
|
<div class="form-group" style="margin-bottom:0">
|
||||||
<label for="d-name">Name *</label>
|
<label for="d-name">Name *</label>
|
||||||
<input id="d-name" bind:value={newName} required placeholder="e.g. Security" />
|
<input id="d-name" bind:value={newName} required placeholder="e.g. Security" />
|
||||||
|
|
@ -127,7 +127,7 @@
|
||||||
{#if ($allDepts ?? []).length === 0}
|
{#if ($allDepts ?? []).length === 0}
|
||||||
<div class="empty">
|
<div class="empty">
|
||||||
<strong>No departments yet</strong>
|
<strong>No departments yet</strong>
|
||||||
<p>Add departments to organize your volunteer teams.</p>
|
<p>Create departments to organize shifts and volunteer teams. Coleads are assigned to specific departments.</p>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
|
|
@ -142,8 +142,8 @@
|
||||||
<tbody>
|
<tbody>
|
||||||
{#each $allDepts ?? [] as d (d.id)}
|
{#each $allDepts ?? [] as d (d.id)}
|
||||||
{#if editID === d.id}
|
{#if editID === d.id}
|
||||||
<tr>
|
<tr class="edit-row">
|
||||||
<td>
|
<td class="td-name">
|
||||||
<div style="display:flex;align-items:center;gap:0.5rem">
|
<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 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" />
|
<input bind:value={editName} required placeholder="Name" style="margin:0" />
|
||||||
|
|
@ -153,7 +153,7 @@
|
||||||
<input bind:value={editDesc} placeholder="Description" style="margin:0" />
|
<input bind:value={editDesc} placeholder="Description" style="margin:0" />
|
||||||
</td>
|
</td>
|
||||||
{#if canCreate}
|
{#if canCreate}
|
||||||
<td>
|
<td class="td-actions">
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button class="btn btn-primary btn-sm" onclick={() => saveDept(d)} disabled={saving}>
|
<button class="btn btn-primary btn-sm" onclick={() => saveDept(d)} disabled={saving}>
|
||||||
{saving ? '…' : 'Save'}
|
{saving ? '…' : 'Save'}
|
||||||
|
|
@ -165,13 +165,13 @@
|
||||||
</tr>
|
</tr>
|
||||||
{:else}
|
{:else}
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td class="td-name">
|
||||||
<span class="dept-dot" style="background:{d.color};margin-right:0.5rem"></span>
|
<span class="dept-dot" style="background:{d.color};margin-right:0.5rem"></span>
|
||||||
<strong>{d.name}</strong>
|
<strong>{d.name}</strong>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-muted">{d.description || '—'}</td>
|
<td class="td-desc text-muted">{d.description || '—'}</td>
|
||||||
{#if canCreate}
|
{#if canCreate}
|
||||||
<td>
|
<td class="td-actions">
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button class="btn btn-ghost btn-sm" onclick={() => startEdit(d)}>Edit</button>
|
<button class="btn btn-ghost btn-sm" onclick={() => startEdit(d)}>Edit</button>
|
||||||
{#if canDelete}
|
{#if canDelete}
|
||||||
|
|
@ -188,3 +188,12 @@
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.td-name { width: 100%; }
|
||||||
|
.td-desc { width: 100%; }
|
||||||
|
.td-actions { width: 100%; display: flex; justify-content: flex-end; }
|
||||||
|
.edit-row td { width: 100%; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@
|
||||||
let { session, onLogout } = $props()
|
let { session, onLogout } = $props()
|
||||||
|
|
||||||
let search = $state('')
|
let search = $state('')
|
||||||
|
let manuallySelectedId = $state(null)
|
||||||
|
let showAll = $state(false)
|
||||||
let error = $state('')
|
let error = $state('')
|
||||||
let scannerMsg = $state('')
|
let scannerMsg = $state('')
|
||||||
let qrSupported = $state(false)
|
let qrSupported = $state(false)
|
||||||
|
|
@ -44,22 +46,44 @@
|
||||||
return ($tickets ?? []).find(t => t.external_id?.toLowerCase() === sl) ?? null
|
return ($tickets ?? []).find(t => t.external_id?.toLowerCase() === sl) ?? null
|
||||||
})
|
})
|
||||||
|
|
||||||
// Name/email search across participants
|
const allParticipantsSorted = $derived.by(() =>
|
||||||
|
($participants ?? [])
|
||||||
|
.filter(p => !p.deleted_at)
|
||||||
|
.sort((a, b) => (a.preferred_name || a.email || '').localeCompare(b.preferred_name || b.email || ''))
|
||||||
|
)
|
||||||
|
|
||||||
|
// Clear manual selection whenever search text changes
|
||||||
|
$effect(() => {
|
||||||
|
search
|
||||||
|
manuallySelectedId = null
|
||||||
|
})
|
||||||
|
|
||||||
|
// Name/email/ticket-name search across participants
|
||||||
const filteredParticipants = $derived.by(() => {
|
const filteredParticipants = $derived.by(() => {
|
||||||
if (matchedTicket) return []
|
if (matchedTicket) return []
|
||||||
const s = search.trim().toLowerCase()
|
const s = search.trim().toLowerCase()
|
||||||
if (!s || s.length < 2) return []
|
if (!s || s.length < 2) return []
|
||||||
|
const byTicketName = new Set(
|
||||||
|
($tickets ?? [])
|
||||||
|
.filter(t => t.name?.toLowerCase().includes(s))
|
||||||
|
.map(t => t.participant_id)
|
||||||
|
.filter(Boolean)
|
||||||
|
)
|
||||||
return ($participants ?? [])
|
return ($participants ?? [])
|
||||||
.filter(p =>
|
.filter(p =>
|
||||||
p.preferred_name?.toLowerCase().includes(s) ||
|
p.preferred_name?.toLowerCase().includes(s) ||
|
||||||
p.email?.toLowerCase().includes(s)
|
p.email?.toLowerCase().includes(s) ||
|
||||||
|
byTicketName.has(p.id)
|
||||||
)
|
)
|
||||||
.sort((a, b) => (a.preferred_name || '').localeCompare(b.preferred_name || ''))
|
.sort((a, b) => (a.preferred_name || '').localeCompare(b.preferred_name || ''))
|
||||||
.slice(0, 8)
|
.slice(0, 8)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Auto-select when exactly one participant matches
|
// Manual selection takes priority; fall back to auto-select on single match
|
||||||
const selectedParticipant = $derived.by(() => {
|
const selectedParticipant = $derived.by(() => {
|
||||||
|
if (manuallySelectedId) {
|
||||||
|
return ($participants ?? []).find(p => p.id === manuallySelectedId) ?? null
|
||||||
|
}
|
||||||
if (filteredParticipants.length === 1) return filteredParticipants[0]
|
if (filteredParticipants.length === 1) return filteredParticipants[0]
|
||||||
return null
|
return null
|
||||||
})
|
})
|
||||||
|
|
@ -165,6 +189,9 @@
|
||||||
{scanning ? '■ Stop' : '⊡ Scan QR'}
|
{scanning ? '■ Stop' : '⊡ Scan QR'}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
<button class="gbtn gbtn-ghost" onclick={() => { showAll = !showAll; search = ''; manuallySelectedId = null }}>
|
||||||
|
{showAll ? '✕ Close' : '☰ Browse'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if scanning}
|
{#if scanning}
|
||||||
|
|
@ -211,7 +238,13 @@
|
||||||
{:else if selectedParticipant}
|
{:else if selectedParticipant}
|
||||||
{@const pts = ticketsFor(selectedParticipant.id)}
|
{@const pts = ticketsFor(selectedParticipant.id)}
|
||||||
<div class="gate-match">
|
<div class="gate-match">
|
||||||
|
<div class="gate-match-name-row">
|
||||||
<div class="gate-match-name">{selectedParticipant.preferred_name || selectedParticipant.email || '(unknown)'}</div>
|
<div class="gate-match-name">{selectedParticipant.preferred_name || selectedParticipant.email || '(unknown)'}</div>
|
||||||
|
{#if manuallySelectedId}
|
||||||
|
<button class="gbtn gbtn-ghost" style="font-size:0.8rem;padding:0.3rem 0.6rem"
|
||||||
|
onclick={() => manuallySelectedId = null}>← Back</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
{#if selectedParticipant.email}
|
{#if selectedParticipant.email}
|
||||||
<div class="gate-match-sub text-muted">{selectedParticipant.email}</div>
|
<div class="gate-match-sub text-muted">{selectedParticipant.email}</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -243,7 +276,7 @@
|
||||||
{#each filteredParticipants as p}
|
{#each filteredParticipants as p}
|
||||||
{@const pts = ticketsFor(p.id)}
|
{@const pts = ticketsFor(p.id)}
|
||||||
{@const ci = pts.filter(t => t.checked_in_at).length}
|
{@const ci = pts.filter(t => t.checked_in_at).length}
|
||||||
<button class="gate-result-row" onclick={() => search = p.preferred_name || p.email || ''}>
|
<button class="gate-result-row" onclick={() => manuallySelectedId = p.id}>
|
||||||
<span>
|
<span>
|
||||||
<strong>{p.preferred_name || p.email || '(unknown)'}</strong>
|
<strong>{p.preferred_name || p.email || '(unknown)'}</strong>
|
||||||
{#if p.email && p.preferred_name}
|
{#if p.email && p.preferred_name}
|
||||||
|
|
@ -261,6 +294,27 @@
|
||||||
<div class="gate-msg gate-msg-warn">No matching participants or tickets found.</div>
|
<div class="gate-msg gate-msg-warn">No matching participants or tickets found.</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Browse all participants -->
|
||||||
|
{#if showAll && !matchedTicket && !selectedParticipant && search.trim().length < 2}
|
||||||
|
<div class="gate-results">
|
||||||
|
{#each allParticipantsSorted as p}
|
||||||
|
{@const pts = ticketsFor(p.id)}
|
||||||
|
{@const ci = pts.filter(t => t.checked_in_at).length}
|
||||||
|
<button class="gate-result-row" onclick={() => { manuallySelectedId = p.id; showAll = false }}>
|
||||||
|
<span>
|
||||||
|
<strong>{p.preferred_name || p.email || '(unknown)'}</strong>
|
||||||
|
{#if p.email && p.preferred_name}
|
||||||
|
<span class="text-muted" style="font-size:0.8rem"> · {p.email}</span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
<span class="badge {ci === pts.length && pts.length > 0 ? 'badge-checked' : ci > 0 ? 'badge-partial' : 'badge-unchecked'}">
|
||||||
|
{pts.length > 0 ? `${ci}/${pts.length}` : 'No ticket'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Recent check-ins -->
|
<!-- Recent check-ins -->
|
||||||
<div class="gate-recent">
|
<div class="gate-recent">
|
||||||
<div class="gate-recent-title">Recent Check-ins</div>
|
<div class="gate-recent-title">Recent Check-ins</div>
|
||||||
|
|
@ -385,7 +439,8 @@
|
||||||
padding: 1.25rem;
|
padding: 1.25rem;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
.gate-match-name { font-size: 1.4rem; font-weight: 700; margin-bottom: 0.2rem; }
|
.gate-match-name-row { display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; margin-bottom: 0.2rem; }
|
||||||
|
.gate-match-name { font-size: 1.4rem; font-weight: 700; }
|
||||||
.gate-match-sub { color: var(--c-muted); font-size: 0.875rem; }
|
.gate-match-sub { color: var(--c-muted); font-size: 0.875rem; }
|
||||||
.gate-party {
|
.gate-party {
|
||||||
margin: 0.5rem 0;
|
margin: 0.5rem 0;
|
||||||
|
|
@ -63,6 +63,7 @@
|
||||||
return ($allTickets ?? []).filter(t => t.participant_id === participantId)
|
return ($allTickets ?? []).filter(t => t.participant_id === participantId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function checkedInCount(participantId) {
|
function checkedInCount(participantId) {
|
||||||
return ticketsFor(participantId).filter(t => t.checked_in_at).length
|
return ticketsFor(participantId).filter(t => t.checked_in_at).length
|
||||||
}
|
}
|
||||||
|
|
@ -129,6 +130,16 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function checkInTicket(tk) {
|
||||||
|
error = ''
|
||||||
|
try {
|
||||||
|
const result = await api.tickets.checkIn(tk.id)
|
||||||
|
if (result.ticket) await db.tickets.put(result.ticket)
|
||||||
|
} catch (err) {
|
||||||
|
error = err.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function fmtTime(ts) {
|
function fmtTime(ts) {
|
||||||
if (!ts) return ''
|
if (!ts) return ''
|
||||||
return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||||
|
|
@ -233,7 +244,7 @@
|
||||||
{#if showAdd && canManage}
|
{#if showAdd && canManage}
|
||||||
<div class="card" style="margin-bottom:1.5rem">
|
<div class="card" style="margin-bottom:1.5rem">
|
||||||
<form onsubmit={addParticipant}>
|
<form onsubmit={addParticipant}>
|
||||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem">
|
<div class="form-grid" style="display:grid;grid-template-columns:1fr 1fr;gap:1rem">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="p-name">Name</label>
|
<label for="p-name">Name</label>
|
||||||
<input id="p-name" bind:value={newName} placeholder="Preferred name" />
|
<input id="p-name" bind:value={newName} placeholder="Preferred name" />
|
||||||
|
|
@ -352,11 +363,14 @@
|
||||||
onclick={mergeMode && mergeSource?.id !== p.id ? () => { mergeTarget = p } : null}
|
onclick={mergeMode && mergeSource?.id !== p.id ? () => { mergeTarget = p } : null}
|
||||||
style={mergeMode && mergeSource?.id !== p.id ? 'cursor:pointer' : ''}
|
style={mergeMode && mergeSource?.id !== p.id ? 'cursor:pointer' : ''}
|
||||||
>
|
>
|
||||||
<td>
|
<td class="td-name">
|
||||||
<strong>{p.preferred_name || '—'}</strong>
|
<strong>{p.preferred_name || '—'}</strong>
|
||||||
{#if p.pronouns}
|
{#if p.pronouns}
|
||||||
<span class="text-muted" style="font-size:0.78rem"> · {p.pronouns}</span>
|
<span class="text-muted" style="font-size:0.78rem"> · {p.pronouns}</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if p.ticket_name && p.ticket_name !== p.preferred_name}
|
||||||
|
<div class="text-muted" style="font-size:0.78rem">Ticket: {p.ticket_name}</div>
|
||||||
|
{/if}
|
||||||
{#if p.note}
|
{#if p.note}
|
||||||
<div class="text-muted" style="font-size:0.78rem">{p.note}</div>
|
<div class="text-muted" style="font-size:0.78rem">{p.note}</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -387,7 +401,7 @@
|
||||||
{/if}
|
{/if}
|
||||||
</td>
|
</td>
|
||||||
{#if canManage}
|
{#if canManage}
|
||||||
<td>
|
<td class="td-actions">
|
||||||
{#if !mergeMode}
|
{#if !mergeMode}
|
||||||
<button class="btn btn-ghost btn-sm" onclick={(e) => { e.stopPropagation(); startEdit(p) }}
|
<button class="btn btn-ghost btn-sm" onclick={(e) => { e.stopPropagation(); startEdit(p) }}
|
||||||
title="Edit participant">✎</button>
|
title="Edit participant">✎</button>
|
||||||
|
|
@ -424,9 +438,9 @@
|
||||||
</div>
|
</div>
|
||||||
<div style="text-align:right">
|
<div style="text-align:right">
|
||||||
{#if tk.checked_in_at}
|
{#if tk.checked_in_at}
|
||||||
<span class="badge badge-checked">In {fmtTime(tk.checked_in_at)}</span>
|
<span class="badge badge-checked">Checked in {fmtTime(tk.checked_in_at)}</span>
|
||||||
{:else}
|
{:else}
|
||||||
<span class="badge badge-unchecked">Pending</span>
|
<button class="btn btn-success btn-sm" onclick={() => checkInTicket(tk)}>✓ Check in</button>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="text-muted" style="font-size:0.75rem">{tk.source}</div>
|
<div class="text-muted" style="font-size:0.75rem">{tk.source}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -494,4 +508,12 @@
|
||||||
.edit-fields { display: flex; gap: 0.4rem; flex-wrap: wrap; }
|
.edit-fields { display: flex; gap: 0.4rem; flex-wrap: wrap; }
|
||||||
.edit-fields input { flex: 1; min-width: 120px; font-size: 0.825rem; padding: 0.3rem 0.5rem; width: auto; }
|
.edit-fields input { flex: 1; min-width: 120px; font-size: 0.825rem; padding: 0.3rem 0.5rem; width: auto; }
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.td-name { width: 100%; }
|
||||||
|
.td-actions { width: 100%; display: flex; justify-content: flex-end; }
|
||||||
|
.ticket-rows { padding: 0; border: none; border-radius: 0; margin-top: -0.5rem; }
|
||||||
|
.ticket-rows td { width: 100%; }
|
||||||
|
.edit-row { padding: 0.75rem; }
|
||||||
|
.edit-row td { width: 100%; }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -315,8 +315,8 @@
|
||||||
|
|
||||||
{#if ($allShifts ?? []).length === 0 && !showAdd}
|
{#if ($allShifts ?? []).length === 0 && !showAdd}
|
||||||
<div class="empty">
|
<div class="empty">
|
||||||
<strong>No shifts yet</strong>
|
<strong>No shifts scheduled yet</strong>
|
||||||
<p>Add shifts to schedule your volunteers.</p>
|
<p>Create departments first, then add shifts here. Volunteers can self-select shifts via the kiosk.</p>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
{#each board as { dept, days }}
|
{#each board as { dept, days }}
|
||||||
|
|
@ -397,6 +397,9 @@
|
||||||
{#each assigned as { vs, volunteer }}
|
{#each assigned as { vs, volunteer }}
|
||||||
<div class="board-vol-chip">
|
<div class="board-vol-chip">
|
||||||
{volunteer.name}
|
{volunteer.name}
|
||||||
|
{#if volunteer.is_lead}
|
||||||
|
<span class="chip-lead">Co-Lead</span>
|
||||||
|
{/if}
|
||||||
{#if checkConflict(volunteer.id, shift.id, $allVolunteerShifts ?? [], $allShifts ?? [])}
|
{#if checkConflict(volunteer.id, shift.id, $allVolunteerShifts ?? [], $allShifts ?? [])}
|
||||||
<span title="Scheduling conflict" style="color:var(--c-warn)">⚠</span>
|
<span title="Scheduling conflict" style="color:var(--c-warn)">⚠</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -514,6 +517,14 @@
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
.chip-lead {
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 600;
|
||||||
|
background: rgba(245,158,11,0.2);
|
||||||
|
color: var(--c-warn);
|
||||||
|
padding: 0.05rem 0.3rem;
|
||||||
|
border-radius: 99px;
|
||||||
|
}
|
||||||
.board-vol-remove {
|
.board-vol-remove {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
<script>
|
<script>
|
||||||
import { onMount } from 'svelte'
|
import { onMount } from 'svelte'
|
||||||
import { api } from '../api.js'
|
import { api } from '../api.js'
|
||||||
|
import { db } from '../db.js'
|
||||||
|
|
||||||
let loading = $state(true)
|
let loading = $state(true)
|
||||||
let saving = $state(false)
|
let saving = $state(false)
|
||||||
|
let savingEvent = $state(false)
|
||||||
let testing = $state(false)
|
let testing = $state(false)
|
||||||
let error = $state('')
|
let error = $state('')
|
||||||
let success = $state('')
|
let success = $state('')
|
||||||
|
|
@ -18,10 +20,24 @@
|
||||||
let testEmail = $state('')
|
let testEmail = $state('')
|
||||||
let noteLabel = $state('Additional note')
|
let noteLabel = $state('Additional note')
|
||||||
let noteRequired = $state(false)
|
let noteRequired = $state(false)
|
||||||
|
let eventName = $state('')
|
||||||
|
let eventVenue = $state('')
|
||||||
|
let eventStartDate = $state('')
|
||||||
|
let eventEndDate = $state('')
|
||||||
|
let eventTimezone = $state('')
|
||||||
|
const timezones = Intl.supportedValuesOf('timeZone')
|
||||||
let shiftSignupsOpen = $state(false)
|
let shiftSignupsOpen = $state(false)
|
||||||
let togglingSignups = $state(false)
|
let togglingSignups = $state(false)
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
|
try {
|
||||||
|
const ev = await api.event.get()
|
||||||
|
eventName = ev.name ?? ''
|
||||||
|
eventVenue = ev.venue ?? ''
|
||||||
|
eventStartDate = ev.start_date ?? ''
|
||||||
|
eventEndDate = ev.end_date ?? ''
|
||||||
|
eventTimezone = ev.timezone ?? ''
|
||||||
|
} catch {}
|
||||||
try {
|
try {
|
||||||
const s = await api.settings.get()
|
const s = await api.settings.get()
|
||||||
smtpHost = s.smtp_host ?? ''
|
smtpHost = s.smtp_host ?? ''
|
||||||
|
|
@ -41,6 +57,28 @@
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
async function saveEvent(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
savingEvent = true
|
||||||
|
error = ''
|
||||||
|
success = ''
|
||||||
|
try {
|
||||||
|
const updated = await api.event.update({
|
||||||
|
name: eventName,
|
||||||
|
venue: eventVenue,
|
||||||
|
start_date: eventStartDate,
|
||||||
|
end_date: eventEndDate,
|
||||||
|
timezone: eventTimezone,
|
||||||
|
})
|
||||||
|
await db.event.put({ ...updated, id: 1 })
|
||||||
|
success = 'Event saved.'
|
||||||
|
} catch (err) {
|
||||||
|
error = err.message
|
||||||
|
} finally {
|
||||||
|
savingEvent = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function save(e) {
|
async function save(e) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
saving = true
|
saving = true
|
||||||
|
|
@ -127,6 +165,44 @@
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="text-muted">Loading…</div>
|
<div class="text-muted">Loading…</div>
|
||||||
{:else}
|
{:else}
|
||||||
|
<form onsubmit={saveEvent}>
|
||||||
|
<div class="card" style="margin-bottom:1.5rem">
|
||||||
|
<h2 style="font-size:0.95rem;font-weight:700;margin-bottom:1rem">Event</h2>
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem">
|
||||||
|
<div class="form-group" style="grid-column:1/-1">
|
||||||
|
<label for="e-name">Event Name *</label>
|
||||||
|
<input id="e-name" bind:value={eventName} required placeholder="My Event 2026" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="grid-column:1/-1">
|
||||||
|
<label for="e-venue">Venue</label>
|
||||||
|
<input id="e-venue" bind:value={eventVenue} placeholder="Location name" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="e-start">Start Date *</label>
|
||||||
|
<input id="e-start" type="date" bind:value={eventStartDate} required />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="e-end">End Date *</label>
|
||||||
|
<input id="e-end" type="date" bind:value={eventEndDate} required />
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="grid-column:1/-1">
|
||||||
|
<label for="e-tz">Timezone</label>
|
||||||
|
<input id="e-tz" bind:value={eventTimezone} placeholder="America/Chicago" list="tz-list" />
|
||||||
|
<datalist id="tz-list">
|
||||||
|
{#each timezones as tz}
|
||||||
|
<option value={tz} />
|
||||||
|
{/each}
|
||||||
|
</datalist>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button type="submit" class="btn btn-primary" disabled={savingEvent}>
|
||||||
|
{savingEvent ? 'Saving…' : 'Save Event'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
<form onsubmit={save}>
|
<form onsubmit={save}>
|
||||||
<div class="card" style="margin-bottom:1.5rem">
|
<div class="card" style="margin-bottom:1.5rem">
|
||||||
<h2 style="font-size:0.95rem;font-weight:700;margin-bottom:1rem">SMTP Email</h2>
|
<h2 style="font-size:0.95rem;font-weight:700;margin-bottom:1rem">SMTP Email</h2>
|
||||||
|
|
@ -160,7 +236,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="s-url">Base URL <span class="text-muted" style="font-weight:400">(for volunteer token links)</span></label>
|
<label for="s-url">Base URL <span class="text-muted" style="font-weight:400">(for kiosk links in emails)</span></label>
|
||||||
<input id="s-url" bind:value={baseURL} placeholder="https://events.example.com" />
|
<input id="s-url" bind:value={baseURL} placeholder="https://events.example.com" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -117,7 +117,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function roleLabel(r) {
|
function roleLabel(r) {
|
||||||
return { admin: 'Admin', coordinator: 'Coordinator', ticketing: 'Ticketing', gate: 'Gate', volunteer_lead: 'Vol. Lead' }[r] || r
|
return { admin: 'Admin', ticketing: 'Ticketing', staffing: 'Staffing', colead: 'Colead', gatekeeper: 'Gatekeeper' }[r] || r
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -129,6 +129,15 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<p class="text-muted" style="font-size:0.82rem;margin-bottom:1.5rem;line-height:1.6">
|
||||||
|
<strong style="color:var(--c-text)">Roles:</strong>
|
||||||
|
admin — full access ·
|
||||||
|
ticketing — participants, tickets, import ·
|
||||||
|
staffing — volunteers, shifts, departments ·
|
||||||
|
colead — manage assigned departments only ·
|
||||||
|
gatekeeper — check-in only
|
||||||
|
</p>
|
||||||
|
|
||||||
{#if loadError}
|
{#if loadError}
|
||||||
<div class="alert alert-error">{loadError}</div>
|
<div class="alert alert-error">{loadError}</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -139,7 +148,7 @@
|
||||||
{#if showAdd}
|
{#if showAdd}
|
||||||
<div class="card" style="margin-bottom:1.5rem">
|
<div class="card" style="margin-bottom:1.5rem">
|
||||||
<form onsubmit={addUser}>
|
<form onsubmit={addUser}>
|
||||||
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:1rem">
|
<div class="form-grid" style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:1rem">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="u-username">Username *</label>
|
<label for="u-username">Username *</label>
|
||||||
<input id="u-username" bind:value={newUsername} required placeholder="username" autocomplete="off" />
|
<input id="u-username" bind:value={newUsername} required placeholder="username" autocomplete="off" />
|
||||||
|
|
@ -187,7 +196,8 @@
|
||||||
<div class="text-muted" style="padding:2rem 0">Loading…</div>
|
<div class="text-muted" style="padding:2rem 0">Loading…</div>
|
||||||
{:else if users.length === 0}
|
{:else if users.length === 0}
|
||||||
<div class="empty">
|
<div class="empty">
|
||||||
<strong>No users yet</strong>
|
<strong>No additional users</strong>
|
||||||
|
<p>The admin account was created at setup. Add users above to delegate access.</p>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
|
|
@ -203,8 +213,8 @@
|
||||||
<tbody>
|
<tbody>
|
||||||
{#each users as u (u.id)}
|
{#each users as u (u.id)}
|
||||||
{#if editID === u.id}
|
{#if editID === u.id}
|
||||||
<tr>
|
<tr class="edit-row">
|
||||||
<td><strong>{u.username}</strong> {#if u.id === me}<span class="badge badge-role">you</span>{/if}</td>
|
<td class="td-name"><strong>{u.username}</strong> {#if u.id === me}<span class="badge badge-role">you</span>{/if}</td>
|
||||||
<td>
|
<td>
|
||||||
<select bind:value={editRole} style="width:auto;margin:0">
|
<select bind:value={editRole} style="width:auto;margin:0">
|
||||||
{#each roles as r}
|
{#each roles as r}
|
||||||
|
|
@ -229,7 +239,7 @@
|
||||||
placeholder="New password (leave blank to keep)"
|
placeholder="New password (leave blank to keep)"
|
||||||
style="margin-top:0.5rem" autocomplete="new-password" />
|
style="margin-top:0.5rem" autocomplete="new-password" />
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td class="td-actions">
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button class="btn btn-primary btn-sm" onclick={() => saveUser(u)} disabled={saving}>
|
<button class="btn btn-primary btn-sm" onclick={() => saveUser(u)} disabled={saving}>
|
||||||
{saving ? '…' : 'Save'}
|
{saving ? '…' : 'Save'}
|
||||||
|
|
@ -240,7 +250,7 @@
|
||||||
</tr>
|
</tr>
|
||||||
{:else}
|
{:else}
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td class="td-name">
|
||||||
<strong>{u.username}</strong>
|
<strong>{u.username}</strong>
|
||||||
{#if u.id === me}
|
{#if u.id === me}
|
||||||
<span class="badge badge-role" style="margin-left:0.4rem">you</span>
|
<span class="badge badge-role" style="margin-left:0.4rem">you</span>
|
||||||
|
|
@ -248,7 +258,7 @@
|
||||||
</td>
|
</td>
|
||||||
<td><span class="badge badge-role">{roleLabel(u.role)}</span></td>
|
<td><span class="badge badge-role">{roleLabel(u.role)}</span></td>
|
||||||
<td class="text-muted">{deptNamesFor(u.department_ids || [])}</td>
|
<td class="text-muted">{deptNamesFor(u.department_ids || [])}</td>
|
||||||
<td>
|
<td class="td-actions">
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button class="btn btn-ghost btn-sm" onclick={() => startEdit(u)}>Edit</button>
|
<button class="btn btn-ghost btn-sm" onclick={() => startEdit(u)}>Edit</button>
|
||||||
{#if u.id !== me}
|
{#if u.id !== me}
|
||||||
|
|
@ -264,3 +274,11 @@
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.td-name { width: 100%; }
|
||||||
|
.td-actions { width: 100%; display: flex; justify-content: flex-end; }
|
||||||
|
.edit-row td { width: 100%; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -149,7 +149,7 @@
|
||||||
<div class="kiosk-vol-name">{state.volunteer.name}</div>
|
<div class="kiosk-vol-name">{state.volunteer.name}</div>
|
||||||
<div class="kiosk-vol-meta">
|
<div class="kiosk-vol-meta">
|
||||||
{state.volunteer.email || ''}
|
{state.volunteer.email || ''}
|
||||||
{state.volunteer.is_lead ? ' · Department Lead' : ''}
|
{state.volunteer.is_lead ? ' · Co-Lead' : ''}
|
||||||
</div>
|
</div>
|
||||||
<div class="kiosk-token">Token: <code>{token}</code></div>
|
<div class="kiosk-token">Token: <code>{token}</code></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
|
|
||||||
let search = $state('')
|
let search = $state('')
|
||||||
let filterDept = $state('')
|
let filterDept = $state('')
|
||||||
let filterChecked = $state('')
|
let filterStatus = $state('')
|
||||||
let error = $state('')
|
let error = $state('')
|
||||||
let showAdd = $state(false)
|
let showAdd = $state(false)
|
||||||
let adding = $state(false)
|
let adding = $state(false)
|
||||||
|
|
@ -18,13 +18,29 @@
|
||||||
let newIsLead = $state(false)
|
let newIsLead = $state(false)
|
||||||
let newNote = $state('')
|
let newNote = $state('')
|
||||||
|
|
||||||
|
let editID = $state(null)
|
||||||
|
let editDeptID = $state('')
|
||||||
|
let editIsLead = $state(false)
|
||||||
|
let editNote = $state('')
|
||||||
|
let saving = $state(false)
|
||||||
|
|
||||||
const role = $derived(session?.user?.role ?? '')
|
const role = $derived(session?.user?.role ?? '')
|
||||||
const canManage = $derived(['admin', 'ticketing', 'staffing', 'colead'].includes(role))
|
const canManage = $derived(['admin', 'ticketing', 'staffing', 'colead'].includes(role))
|
||||||
|
const canConfirm = $derived(['admin', 'staffing', 'colead'].includes(role))
|
||||||
|
const myDeptIDs = $derived(session?.user?.department_ids ?? [])
|
||||||
|
|
||||||
|
// Auto-filter coleads to their department on mount
|
||||||
|
$effect(() => {
|
||||||
|
if (role === 'colead' && myDeptIDs.length > 0 && !filterDept) {
|
||||||
|
filterDept = String(myDeptIDs[0])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const allVolunteers = liveQuery(() =>
|
const allVolunteers = liveQuery(() =>
|
||||||
db.volunteers.filter(v => !v.deleted_at).toArray()
|
db.volunteers.filter(v => !v.deleted_at).toArray()
|
||||||
)
|
)
|
||||||
const allParticipants = liveQuery(() => db.participants.toArray())
|
const allParticipants = liveQuery(() => db.participants.toArray())
|
||||||
|
const allTickets = liveQuery(() => db.tickets.filter(t => !t.deleted_at).toArray())
|
||||||
const allDepts = liveQuery(() =>
|
const allDepts = liveQuery(() =>
|
||||||
db.departments.filter(d => !d.deleted_at).toArray()
|
db.departments.filter(d => !d.deleted_at).toArray()
|
||||||
.then(arr => arr.sort((a, b) => a.name.localeCompare(b.name)))
|
.then(arr => arr.sort((a, b) => a.name.localeCompare(b.name)))
|
||||||
|
|
@ -36,8 +52,10 @@
|
||||||
return list
|
return list
|
||||||
.filter(v => {
|
.filter(v => {
|
||||||
if (filterDept && v.department_id !== parseInt(filterDept)) return false
|
if (filterDept && v.department_id !== parseInt(filterDept)) return false
|
||||||
if (filterChecked === 'true' && !v.checked_in) return false
|
if (filterStatus === 'unconfirmed' && v.email_confirmed) return false
|
||||||
if (filterChecked === 'false' && v.checked_in) return false
|
if (filterStatus === 'registered' && (!v.email_confirmed || v.confirmed)) return false
|
||||||
|
if (filterStatus === 'confirmed' && (!v.confirmed || v.checked_in)) return false
|
||||||
|
if (filterStatus === 'ready' && !v.checked_in) return false
|
||||||
if (s && !v.name.toLowerCase().includes(s) &&
|
if (s && !v.name.toLowerCase().includes(s) &&
|
||||||
!(v.email || '').toLowerCase().includes(s)) return false
|
!(v.email || '').toLowerCase().includes(s)) return false
|
||||||
return true
|
return true
|
||||||
|
|
@ -54,6 +72,15 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function confirmVolunteer(v) {
|
||||||
|
try {
|
||||||
|
const updated = await api.volunteers.confirm(v.id)
|
||||||
|
await db.volunteers.put(updated)
|
||||||
|
} catch (err) {
|
||||||
|
error = err.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function addVolunteer(e) {
|
async function addVolunteer(e) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
adding = true
|
adding = true
|
||||||
|
|
@ -89,10 +116,45 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function startEdit(v) {
|
||||||
|
editID = v.id
|
||||||
|
editDeptID = v.department_id ? String(v.department_id) : ''
|
||||||
|
editIsLead = v.is_lead
|
||||||
|
editNote = v.note ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEdit() {
|
||||||
|
editID = null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveVolunteer(v) {
|
||||||
|
saving = true
|
||||||
|
error = ''
|
||||||
|
try {
|
||||||
|
const updated = await api.volunteers.update(v.id, {
|
||||||
|
...v,
|
||||||
|
department_id: editDeptID ? parseInt(editDeptID) : null,
|
||||||
|
is_lead: editIsLead,
|
||||||
|
note: editNote,
|
||||||
|
})
|
||||||
|
await db.volunteers.put(updated)
|
||||||
|
editID = null
|
||||||
|
} catch (err) {
|
||||||
|
error = err.message
|
||||||
|
} finally {
|
||||||
|
saving = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function deptFor(id) {
|
function deptFor(id) {
|
||||||
return ($allDepts ?? []).find(d => d.id === id)
|
return ($allDepts ?? []).find(d => d.id === id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function participantHasTickets(participantId) {
|
||||||
|
if (!participantId) return false
|
||||||
|
return ($allTickets ?? []).some(t => t.participant_id === participantId)
|
||||||
|
}
|
||||||
|
|
||||||
function participantFor(id) {
|
function participantFor(id) {
|
||||||
return ($allParticipants ?? []).find(p => p.id === id) ?? null
|
return ($allParticipants ?? []).find(p => p.id === id) ?? null
|
||||||
}
|
}
|
||||||
|
|
@ -115,7 +177,7 @@
|
||||||
{#if showAdd && canManage}
|
{#if showAdd && canManage}
|
||||||
<div class="card" style="margin-bottom:1.5rem">
|
<div class="card" style="margin-bottom:1.5rem">
|
||||||
<form onsubmit={addVolunteer}>
|
<form onsubmit={addVolunteer}>
|
||||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem">
|
<div class="form-grid" style="display:grid;grid-template-columns:1fr 1fr;gap:1rem">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="v-name">Name *</label>
|
<label for="v-name">Name *</label>
|
||||||
<input id="v-name" bind:value={newName} required placeholder="Full name" />
|
<input id="v-name" bind:value={newName} required placeholder="Full name" />
|
||||||
|
|
@ -164,10 +226,12 @@
|
||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
{/if}
|
{/if}
|
||||||
<select bind:value={filterChecked} style="width:auto">
|
<select bind:value={filterStatus} style="width:auto">
|
||||||
<option value="">All</option>
|
<option value="">All statuses</option>
|
||||||
<option value="false">Not checked in</option>
|
<option value="unconfirmed">Unconfirmed</option>
|
||||||
<option value="true">Checked in</option>
|
<option value="registered">Registered</option>
|
||||||
|
<option value="confirmed">Confirmed</option>
|
||||||
|
<option value="ready">Ready</option>
|
||||||
</select>
|
</select>
|
||||||
<span class="text-muted" style="font-size:0.85rem;white-space:nowrap">
|
<span class="text-muted" style="font-size:0.85rem;white-space:nowrap">
|
||||||
{filtered.length} shown
|
{filtered.length} shown
|
||||||
|
|
@ -177,7 +241,7 @@
|
||||||
{#if ($allVolunteers ?? []).length === 0}
|
{#if ($allVolunteers ?? []).length === 0}
|
||||||
<div class="empty">
|
<div class="empty">
|
||||||
<strong>No volunteers yet</strong>
|
<strong>No volunteers yet</strong>
|
||||||
<p>Add volunteers manually.</p>
|
<p>Add volunteers manually above, or enable public signup in Settings.</p>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
|
|
@ -194,15 +258,46 @@
|
||||||
<tbody>
|
<tbody>
|
||||||
{#each filtered as v (v.id)}
|
{#each filtered as v (v.id)}
|
||||||
{@const dept = deptFor(v.department_id)}
|
{@const dept = deptFor(v.department_id)}
|
||||||
{@const participant = participantFor(v.participant_id)}
|
{#if editID === v.id}
|
||||||
|
<tr class="edit-row">
|
||||||
|
<td class="td-name" style="width:100%">
|
||||||
|
<strong>{v.name}</strong>
|
||||||
|
{#if v.email}<div class="text-muted" style="font-size:0.78rem">{v.email}</div>{/if}
|
||||||
|
</td>
|
||||||
|
<td class="td-edit-dept">
|
||||||
|
<select bind:value={editDeptID} style="margin:0">
|
||||||
|
<option value="">No department</option>
|
||||||
|
{#each $allDepts ?? [] as d}
|
||||||
|
<option value={String(d.id)}>{d.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td class="td-edit-checks">
|
||||||
|
<label style="display:flex;align-items:center;gap:0.4rem;cursor:pointer;white-space:nowrap">
|
||||||
|
<input type="checkbox" style="width:auto" bind:checked={editIsLead} /> Co-Lead
|
||||||
|
</label>
|
||||||
|
</td>
|
||||||
|
<td class="td-edit-note">
|
||||||
|
<input bind:value={editNote} placeholder="Note" style="margin:0" />
|
||||||
|
</td>
|
||||||
|
<td class="td-actions">
|
||||||
|
<button class="btn btn-primary btn-sm" onclick={() => saveVolunteer(v)} disabled={saving}>
|
||||||
|
{saving ? '…' : 'Save'}
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-ghost btn-sm" onclick={cancelEdit}>Cancel</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{:else}
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td class="td-name">
|
||||||
<strong>{v.name}</strong>
|
<strong>{v.name}</strong>
|
||||||
{#if v.is_lead}
|
{#if v.is_lead}
|
||||||
<span class="badge badge-lead" style="margin-left:0.4rem">Lead</span>
|
<span class="badge badge-lead" style="margin-left:0.4rem">Co-Lead</span>
|
||||||
{/if}
|
{/if}
|
||||||
{#if !v.participant_id}
|
{#if !v.participant_id}
|
||||||
<span class="badge badge-unchecked" style="margin-left:0.4rem" title="Not linked to a participant — no ticket record">No ticket</span>
|
<span class="badge badge-unchecked" style="margin-left:0.4rem" title="Not linked to a participant">No ticket</span>
|
||||||
|
{:else if !participantHasTickets(v.participant_id)}
|
||||||
|
<span class="badge badge-partial" style="margin-left:0.4rem" title="No ticket on file">No ticket</span>
|
||||||
{/if}
|
{/if}
|
||||||
{#if v.email}
|
{#if v.email}
|
||||||
<div class="text-muted" style="font-size:0.78rem">{v.email}</div>
|
<div class="text-muted" style="font-size:0.78rem">{v.email}</div>
|
||||||
|
|
@ -211,37 +306,60 @@
|
||||||
<div class="text-muted" style="font-size:0.78rem">{v.note}</div>
|
<div class="text-muted" style="font-size:0.78rem">{v.note}</div>
|
||||||
{/if}
|
{/if}
|
||||||
</td>
|
</td>
|
||||||
<td class="text-muted">
|
<td class="td-dept text-muted">
|
||||||
{#if dept}
|
{#if dept}
|
||||||
<span class="dept-dot" style="background:{dept.color};margin-right:0.4rem"></span>{dept.name}
|
<span class="dept-dot" style="background:{dept.color};margin-right:0.4rem"></span>{dept.name}
|
||||||
{:else}
|
{:else}
|
||||||
—
|
—
|
||||||
{/if}
|
{/if}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td class="td-status">
|
||||||
<span class="badge {v.checked_in ? 'badge-checked' : 'badge-unchecked'}">
|
{#if v.checked_in}
|
||||||
{v.checked_in ? 'Checked in' : 'Pending'}
|
<span class="badge badge-checked">Ready</span>
|
||||||
</span>
|
{:else if v.confirmed}
|
||||||
|
<span class="badge badge-confirmed">Confirmed</span>
|
||||||
|
{:else if v.email_confirmed}
|
||||||
|
<span class="badge badge-registered">Registered</span>
|
||||||
|
{:else}
|
||||||
|
<span class="badge badge-unchecked">Unconfirmed</span>
|
||||||
|
{/if}
|
||||||
{#if v.checked_in_at}
|
{#if v.checked_in_at}
|
||||||
<div class="text-muted" style="font-size:0.75rem">
|
<div class="text-muted" style="font-size:0.75rem">
|
||||||
{new Date(v.checked_in_at).toLocaleTimeString()}
|
{new Date(v.checked_in_at).toLocaleTimeString()}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td class="td-ready">
|
||||||
{#if !v.checked_in}
|
{#if !v.checked_in}
|
||||||
<CheckInButton onclick={() => checkIn(v)} />
|
<CheckInButton onclick={() => checkIn(v)} />
|
||||||
{/if}
|
{/if}
|
||||||
</td>
|
</td>
|
||||||
{#if canManage}
|
{#if canManage}
|
||||||
<td>
|
<td class="td-actions">
|
||||||
|
{#if canConfirm && v.email_confirmed && !v.confirmed}
|
||||||
|
<button class="btn btn-primary btn-sm" onclick={() => confirmVolunteer(v)}>Confirm</button>
|
||||||
|
{/if}
|
||||||
|
<button class="btn btn-ghost btn-sm" onclick={() => startEdit(v)}>Edit</button>
|
||||||
<button class="btn btn-danger btn-sm" onclick={() => deleteVolunteer(v)}>Delete</button>
|
<button class="btn btn-danger btn-sm" onclick={() => deleteVolunteer(v)}>Delete</button>
|
||||||
</td>
|
</td>
|
||||||
{/if}
|
{/if}
|
||||||
</tr>
|
</tr>
|
||||||
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.td-name { flex: 1; min-width: 0; order: 1; }
|
||||||
|
.td-ready { flex-shrink: 0; align-self: flex-start; order: 2; }
|
||||||
|
.td-dept { width: 100%; order: 3; }
|
||||||
|
.td-status { width: 100%; order: 4; }
|
||||||
|
.td-actions { width: 100%; order: 5; display: flex; justify-content: flex-end; }
|
||||||
|
.edit-row td { width: 100%; }
|
||||||
|
.td-edit-dept, .td-edit-checks, .td-edit-note { width: 100%; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -6,26 +6,21 @@ import (
|
||||||
"strconv"
|
"strconv"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func (app *App) volunteerFromKioskToken(token string) (*Volunteer, error) {
|
||||||
|
return app.getVolunteerByKioskCode(token)
|
||||||
|
}
|
||||||
|
|
||||||
// handleKioskGet returns the volunteer's profile, current shift assignments, and
|
// handleKioskGet returns the volunteer's profile, current shift assignments, and
|
||||||
// available open shifts in their department. Authenticated by ticket code only —
|
// available open shifts in their department. Authenticated by kiosk code only —
|
||||||
// no JWT required.
|
// no JWT required.
|
||||||
func (app *App) handleKioskGet(w http.ResponseWriter, r *http.Request) {
|
func (app *App) handleKioskGet(w http.ResponseWriter, r *http.Request) {
|
||||||
token := r.PathValue("token")
|
token := r.PathValue("token")
|
||||||
t, err := app.getTicketByCode(token)
|
v, err := app.volunteerFromKioskToken(token)
|
||||||
if err != nil || t == nil {
|
if err != nil || v == nil {
|
||||||
writeError(w, "not found", http.StatusNotFound)
|
writeError(w, "not found", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var v *Volunteer
|
|
||||||
if t.ParticipantID != nil {
|
|
||||||
v, _ = app.getVolunteerByParticipantID(*t.ParticipantID)
|
|
||||||
}
|
|
||||||
if v == nil {
|
|
||||||
writeError(w, "no volunteer record linked to this token", http.StatusNotFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
assigned, _ := app.listShiftsForVolunteer(v.ID)
|
assigned, _ := app.listShiftsForVolunteer(v.ID)
|
||||||
if assigned == nil {
|
if assigned == nil {
|
||||||
assigned = []Shift{}
|
assigned = []Shift{}
|
||||||
|
|
@ -56,19 +51,11 @@ func (app *App) handleKioskClaim(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
t, err := app.getTicketByCode(token)
|
v, err := app.volunteerFromKioskToken(token)
|
||||||
if err != nil || t == nil {
|
if err != nil || v == nil {
|
||||||
writeError(w, "not found", http.StatusNotFound)
|
writeError(w, "not found", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var v *Volunteer
|
|
||||||
if t.ParticipantID != nil {
|
|
||||||
v, _ = app.getVolunteerByParticipantID(*t.ParticipantID)
|
|
||||||
}
|
|
||||||
if v == nil {
|
|
||||||
writeError(w, "no volunteer linked to this token", http.StatusNotFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
force := r.URL.Query().Get("force") == "true"
|
force := r.URL.Query().Get("force") == "true"
|
||||||
|
|
||||||
|
|
@ -116,19 +103,11 @@ func (app *App) handleKioskUnclaim(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
t, err := app.getTicketByCode(token)
|
v, err := app.volunteerFromKioskToken(token)
|
||||||
if err != nil || t == nil {
|
if err != nil || v == nil {
|
||||||
writeError(w, "not found", http.StatusNotFound)
|
writeError(w, "not found", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var v *Volunteer
|
|
||||||
if t.ParticipantID != nil {
|
|
||||||
v, _ = app.getVolunteerByParticipantID(*t.ParticipantID)
|
|
||||||
}
|
|
||||||
if v == nil {
|
|
||||||
writeError(w, "no volunteer linked to this token", http.StatusNotFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := app.unassignShift(v.ID, shiftID); err != nil {
|
if err := app.unassignShift(v.ID, shiftID); err != nil {
|
||||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
|
|
||||||
|
|
@ -14,14 +14,11 @@ func setupKiosk(t *testing.T) (*App, *http.ServeMux, string) {
|
||||||
dept, _ := app.createDepartment(Department{Name: "Gate"})
|
dept, _ := app.createDepartment(Department{Name: "Gate"})
|
||||||
deptID := dept.ID
|
deptID := dept.ID
|
||||||
|
|
||||||
// Create participant + ticket with code
|
// Create volunteer with a kiosk_code directly on the volunteer record
|
||||||
p, _ := app.createParticipant(Participant{Email: "titania@test.com", PreferredName: "Titania"})
|
p, _ := app.createParticipant(Participant{Email: "titania@test.com", PreferredName: "Titania"})
|
||||||
token, _ := app.generateUniqueToken()
|
v, _ := app.createVolunteer(Volunteer{Name: "Titania", ParticipantID: &p.ID, DepartmentID: &deptID})
|
||||||
tk, _ := app.createTicket(Ticket{ParticipantID: &p.ID, Name: "Titania", Source: "manual", Code: &token})
|
token, _ := app.generateVolunteerKioskCode()
|
||||||
_ = tk
|
app.assignKioskCode(v.ID, token)
|
||||||
|
|
||||||
// Create linked volunteer via participant_id
|
|
||||||
app.createVolunteer(Volunteer{Name: "Titania", ParticipantID: &p.ID, DepartmentID: &deptID})
|
|
||||||
|
|
||||||
// Create shifts
|
// Create shifts
|
||||||
app.createShift(Shift{DepartmentID: deptID, Name: "Morning", Day: "2026-03-15", StartTime: "08:00", EndTime: "12:00", Capacity: 2})
|
app.createShift(Shift{DepartmentID: deptID, Name: "Morning", Day: "2026-03-15", StartTime: "08:00", EndTime: "12:00", Capacity: 2})
|
||||||
|
|
|
||||||
|
|
@ -69,22 +69,19 @@ func (app *App) handlePublicSignup(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find or create participant by email.
|
// Find or create participant by email.
|
||||||
name := body.PreferredName
|
participant, _, err := app.upsertParticipant(body.Email, body.PreferredName)
|
||||||
if body.TicketName != "" {
|
|
||||||
name = body.TicketName
|
|
||||||
}
|
|
||||||
participant, _, err := app.upsertParticipant(body.Email, name)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, "internal error", http.StatusInternalServerError)
|
writeError(w, "internal error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Update participant's personal details if they signed up with more info.
|
// Update participant's personal details if they signed up with more info.
|
||||||
if body.Phone != "" || body.Pronouns != "" {
|
if body.Phone != "" || body.Pronouns != "" || body.TicketName != "" {
|
||||||
app.db.Exec(`UPDATE participants SET
|
app.db.Exec(`UPDATE participants SET
|
||||||
phone = CASE WHEN phone = '' THEN ? ELSE phone END,
|
phone = CASE WHEN phone = '' THEN ? ELSE phone END,
|
||||||
pronouns = CASE WHEN pronouns = '' THEN ? ELSE pronouns END,
|
pronouns = CASE WHEN pronouns = '' THEN ? ELSE pronouns END,
|
||||||
|
ticket_name = CASE WHEN ticket_name = '' THEN ? ELSE ticket_name END,
|
||||||
updated_at = ?
|
updated_at = ?
|
||||||
WHERE id = ?`, body.Phone, body.Pronouns, now(), participant.ID)
|
WHERE id = ?`, body.Phone, body.Pronouns, body.TicketName, now(), participant.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
confirmToken, err := generateConfirmationToken()
|
confirmToken, err := generateConfirmationToken()
|
||||||
|
|
@ -97,7 +94,6 @@ func (app *App) handlePublicSignup(w http.ResponseWriter, r *http.Request) {
|
||||||
ParticipantID: &participant.ID,
|
ParticipantID: &participant.ID,
|
||||||
Name: body.PreferredName,
|
Name: body.PreferredName,
|
||||||
PreferredName: body.PreferredName,
|
PreferredName: body.PreferredName,
|
||||||
TicketName: body.TicketName,
|
|
||||||
Email: body.Email,
|
Email: body.Email,
|
||||||
Phone: body.Phone,
|
Phone: body.Phone,
|
||||||
Pronouns: body.Pronouns,
|
Pronouns: body.Pronouns,
|
||||||
|
|
@ -150,39 +146,11 @@ func (app *App) handleConfirmEmail(w http.ResponseWriter, r *http.Request) {
|
||||||
var signupsOpen string
|
var signupsOpen string
|
||||||
app.db.QueryRow(`SELECT value FROM config WHERE key = 'shift_signups_open'`).Scan(&signupsOpen)
|
app.db.QueryRow(`SELECT value FROM config WHERE key = 'shift_signups_open'`).Scan(&signupsOpen)
|
||||||
|
|
||||||
if signupsOpen == "true" && vol.ParticipantID != nil {
|
if signupsOpen == "true" {
|
||||||
// Find a ticket with a code, or create/assign one.
|
code, err := app.generateVolunteerKioskCode()
|
||||||
tickets, _ := app.listTickets(vol.ParticipantID, "")
|
|
||||||
var code *string
|
|
||||||
for _, tk := range tickets {
|
|
||||||
if tk.Code != nil {
|
|
||||||
code = tk.Code
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if code == nil {
|
|
||||||
// No coded ticket — find any ticket or create a stub, then generate code.
|
|
||||||
var ticketID int
|
|
||||||
if len(tickets) > 0 {
|
|
||||||
ticketID = tickets[0].ID
|
|
||||||
} else {
|
|
||||||
stub, err := app.createTicket(Ticket{
|
|
||||||
ParticipantID: vol.ParticipantID,
|
|
||||||
Source: "manual",
|
|
||||||
})
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
ticketID = stub.ID
|
if err := app.assignKioskCode(vol.ID, code); err == nil {
|
||||||
}
|
kioskLink := fmt.Sprintf("%s/v/%s", app.resolveBaseURL(), code)
|
||||||
}
|
|
||||||
if ticketID > 0 {
|
|
||||||
if t, err := app.generateUniqueToken(); err == nil {
|
|
||||||
app.db.Exec(`UPDATE tickets SET code=?, updated_at=? WHERE id=?`, t, now(), ticketID)
|
|
||||||
code = &t
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if code != nil {
|
|
||||||
kioskLink := fmt.Sprintf("%s/v/%s", app.resolveBaseURL(), *code)
|
|
||||||
response["kiosk_link"] = kioskLink
|
response["kiosk_link"] = kioskLink
|
||||||
go func() {
|
go func() {
|
||||||
if err := app.sendShiftSignupEmail(vol.Email, vol.PreferredName, kioskLink); err != nil {
|
if err := app.sendShiftSignupEmail(vol.Email, vol.PreferredName, kioskLink); err != nil {
|
||||||
|
|
@ -191,6 +159,7 @@ func (app *App) handleConfirmEmail(w http.ResponseWriter, r *http.Request) {
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
writeJSON(w, response)
|
writeJSON(w, response)
|
||||||
}
|
}
|
||||||
|
|
@ -216,57 +185,28 @@ func (app *App) handleToggleShiftSignups(w http.ResponseWriter, r *http.Request)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (app *App) openShiftSignups() {
|
func (app *App) openShiftSignups() {
|
||||||
// Generate codes for tickets belonging to confirmed volunteers that have no code yet.
|
// Assign kiosk codes to email-confirmed volunteers that don't have one yet.
|
||||||
vols, _ := app.listConfirmedVolunteersNeedingCode()
|
vols, _ := app.listVolunteersNeedingKioskCode()
|
||||||
for _, v := range vols {
|
for _, v := range vols {
|
||||||
if v.ParticipantID == nil {
|
code, err := app.generateVolunteerKioskCode()
|
||||||
continue
|
|
||||||
}
|
|
||||||
// Find any ticket for this participant, or create a stub one.
|
|
||||||
tickets, _ := app.listTickets(v.ParticipantID, "")
|
|
||||||
var ticketID int
|
|
||||||
if len(tickets) > 0 {
|
|
||||||
ticketID = tickets[0].ID
|
|
||||||
} else {
|
|
||||||
stub, err := app.createTicket(Ticket{
|
|
||||||
ParticipantID: v.ParticipantID,
|
|
||||||
Source: "manual",
|
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
ticketID = stub.ID
|
app.assignKioskCode(v.ID, code)
|
||||||
}
|
|
||||||
t, err := app.generateUniqueToken()
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
app.db.Exec(`UPDATE tickets SET code=?, updated_at=? WHERE id=?`, t, now(), ticketID)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Email all confirmed volunteers that now have a ticket with a code.
|
// Email all email-confirmed volunteers that now have a kiosk code.
|
||||||
confirmed, _ := queryVolunteers(app.db, `
|
confirmed, _ := queryVolunteers(app.db, `
|
||||||
SELECT `+volunteerSelect+` `+volunteerFrom+`
|
SELECT `+volunteerSelect+` `+volunteerFrom+`
|
||||||
WHERE v.email_confirmed = 1 AND v.deleted_at IS NULL AND v.participant_id IS NOT NULL`)
|
WHERE v.email_confirmed = 1 AND v.kiosk_code IS NOT NULL AND v.deleted_at IS NULL`)
|
||||||
baseURL := app.resolveBaseURL()
|
baseURL := app.resolveBaseURL()
|
||||||
sent := 0
|
sent := 0
|
||||||
|
|
||||||
for _, v := range confirmed {
|
for _, v := range confirmed {
|
||||||
if v.ParticipantID == nil || v.Email == "" {
|
if v.Email == "" || v.KioskCode == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
tickets, _ := app.listTickets(v.ParticipantID, "")
|
kioskLink := fmt.Sprintf("%s/v/%s", baseURL, *v.KioskCode)
|
||||||
var code *string
|
|
||||||
for _, tk := range tickets {
|
|
||||||
if tk.Code != nil {
|
|
||||||
code = tk.Code
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if code == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
kioskLink := fmt.Sprintf("%s/v/%s", baseURL, *code)
|
|
||||||
name := v.PreferredName
|
name := v.PreferredName
|
||||||
if name == "" {
|
if name == "" {
|
||||||
name = v.Name
|
name = v.Name
|
||||||
|
|
|
||||||
|
|
@ -301,17 +301,86 @@ func TestConfirmEmailWithSignupsOpen(t *testing.T) {
|
||||||
t.Error("expected kiosk_link when signups are open")
|
t.Error("expected kiosk_link when signups are open")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ticket for participant should now have a code
|
// Volunteer should now have a kiosk_code, no stub ticket created.
|
||||||
|
vol, _ := app.getVolunteerByEmail("titania@example.com")
|
||||||
|
if vol == nil || vol.KioskCode == nil {
|
||||||
|
t.Error("volunteer should have a kiosk_code after confirm with signups open")
|
||||||
|
}
|
||||||
tickets, _ := app.listTickets(&participant.ID, "")
|
tickets, _ := app.listTickets(&participant.ID, "")
|
||||||
hasCode := false
|
if len(tickets) != 0 {
|
||||||
for _, tk := range tickets {
|
t.Errorf("expected no stub tickets, got %d", len(tickets))
|
||||||
if tk.Code != nil && *tk.Code != "" {
|
|
||||||
hasCode = true
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublicSignupTicketNameDoesNotOverwritePreferredName(t *testing.T) {
|
||||||
|
app := testApp(t)
|
||||||
|
mux := testMux(app)
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(w, testRequest("POST", "/api/public/signup", map[string]any{
|
||||||
|
"preferred_name": "Titania",
|
||||||
|
"ticket_name": "Titania Fairweather",
|
||||||
|
"email": "titania@example.com",
|
||||||
|
}))
|
||||||
|
if w.Code != 200 {
|
||||||
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||||
}
|
}
|
||||||
if !hasCode {
|
|
||||||
t.Error("participant should have a ticket with code after confirm with signups open")
|
vol, _ := app.getVolunteerByEmail("titania@example.com")
|
||||||
|
if vol == nil || vol.ParticipantID == nil {
|
||||||
|
t.Fatal("volunteer/participant not created")
|
||||||
|
}
|
||||||
|
p, _ := app.getParticipant(*vol.ParticipantID)
|
||||||
|
if p == nil {
|
||||||
|
t.Fatal("participant not found")
|
||||||
|
}
|
||||||
|
if p.PreferredName != "Titania" {
|
||||||
|
t.Errorf("participant preferred_name = %q, want %q (not ticket_name)", p.PreferredName, "Titania")
|
||||||
|
}
|
||||||
|
if p.TicketName != "Titania Fairweather" {
|
||||||
|
t.Errorf("participant.TicketName = %q, want %q", p.TicketName, "Titania Fairweather")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfirmEmailAssignsKioskCode(t *testing.T) {
|
||||||
|
app := testApp(t)
|
||||||
|
mux := testMux(app)
|
||||||
|
|
||||||
|
app.db.Exec(`INSERT OR REPLACE INTO config (key, value) VALUES ('shift_signups_open', 'true')`)
|
||||||
|
app.baseURL = "https://example.com"
|
||||||
|
|
||||||
|
participant, _ := app.createParticipant(Participant{PreferredName: "Titania", TicketName: "Titania Fairweather", Email: "titania@example.com"})
|
||||||
|
token := "abc123def456"
|
||||||
|
app.createVolunteer(Volunteer{
|
||||||
|
Name: "Titania",
|
||||||
|
PreferredName: "Titania",
|
||||||
|
Email: "titania@example.com",
|
||||||
|
ParticipantID: &participant.ID,
|
||||||
|
ConfirmationToken: &token,
|
||||||
|
})
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(w, testRequest("POST", "/api/public/confirm", map[string]any{"token": token}))
|
||||||
|
if w.Code != 200 {
|
||||||
|
t.Fatalf("expected 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
result := parseJSON(t, w)
|
||||||
|
if result["status"] != "confirmed" {
|
||||||
|
t.Fatalf("expected confirmed, got %v", result["status"])
|
||||||
|
}
|
||||||
|
if result["kiosk_link"] == nil {
|
||||||
|
t.Error("expected kiosk_link in response when signups are open")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kiosk code should be on the volunteer record, not a stub ticket.
|
||||||
|
vol, _ := app.getVolunteerByEmail("titania@example.com")
|
||||||
|
if vol == nil || vol.KioskCode == nil {
|
||||||
|
t.Fatal("expected volunteer to have a kiosk_code")
|
||||||
|
}
|
||||||
|
// No stub ticket should have been created.
|
||||||
|
tickets, _ := app.listTickets(&participant.ID, "")
|
||||||
|
if len(tickets) != 0 {
|
||||||
|
t.Errorf("expected no stub tickets, got %d", len(tickets))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -109,6 +109,10 @@ func (app *App) handleUpdateVolunteer(w http.ResponseWriter, r *http.Request) {
|
||||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if v.IsLead {
|
||||||
|
app.confirmVolunteer(id)
|
||||||
|
}
|
||||||
updated, _ := app.getVolunteer(id)
|
updated, _ := app.getVolunteer(id)
|
||||||
writeJSON(w, updated)
|
writeJSON(w, updated)
|
||||||
}
|
}
|
||||||
|
|
@ -142,6 +146,20 @@ func (app *App) handleCheckInVolunteer(w http.ResponseWriter, r *http.Request) {
|
||||||
writeJSON(w, v)
|
writeJSON(w, v)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (app *App) handleConfirmVolunteer(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := strconv.Atoi(r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, "invalid id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
v, err := app.confirmVolunteer(id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, v)
|
||||||
|
}
|
||||||
|
|
||||||
func (app *App) handleAssignShift(w http.ResponseWriter, r *http.Request) {
|
func (app *App) handleAssignShift(w http.ResponseWriter, r *http.Request) {
|
||||||
volunteerID, err := strconv.Atoi(r.PathValue("id"))
|
volunteerID, err := strconv.Atoi(r.PathValue("id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
141
handle_volunteers_test.go
Normal file
141
handle_volunteers_test.go
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestConfirmVolunteer(t *testing.T) {
|
||||||
|
app := testApp(t)
|
||||||
|
mux := testMux(app)
|
||||||
|
admin := testAdminUser(t, app)
|
||||||
|
tok := testToken(t, app, admin)
|
||||||
|
|
||||||
|
dept, _ := app.createDepartment(Department{Name: "Gate"})
|
||||||
|
deptID := dept.ID
|
||||||
|
v, _ := app.createVolunteer(Volunteer{
|
||||||
|
Name: "Titania", Email: "titania@test.com",
|
||||||
|
DepartmentID: &deptID, EmailConfirmed: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(w, testAuthRequest("POST", "/api/volunteers/"+itoa(v.ID)+"/confirm", nil, tok))
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
result := parseJSON(t, w)
|
||||||
|
vol := result["confirmed"]
|
||||||
|
if vol != true {
|
||||||
|
t.Error("expected confirmed=true in response")
|
||||||
|
}
|
||||||
|
|
||||||
|
got, _ := app.getVolunteer(v.ID)
|
||||||
|
if got == nil || !got.Confirmed {
|
||||||
|
t.Error("volunteer should be confirmed in DB")
|
||||||
|
}
|
||||||
|
if got.ConfirmedAt == nil {
|
||||||
|
t.Error("confirmed_at should be set")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfirmVolunteerIdempotent(t *testing.T) {
|
||||||
|
app := testApp(t)
|
||||||
|
mux := testMux(app)
|
||||||
|
admin := testAdminUser(t, app)
|
||||||
|
tok := testToken(t, app, admin)
|
||||||
|
|
||||||
|
v, _ := app.createVolunteer(Volunteer{Name: "Puck", Email: "puck@test.com", EmailConfirmed: true})
|
||||||
|
|
||||||
|
// Confirm twice — second should be a no-op, not an error.
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(w, testAuthRequest("POST", "/api/volunteers/"+itoa(v.ID)+"/confirm", nil, tok))
|
||||||
|
if w.Code != 200 {
|
||||||
|
t.Fatalf("first confirm: %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
w = httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(w, testAuthRequest("POST", "/api/volunteers/"+itoa(v.ID)+"/confirm", nil, tok))
|
||||||
|
if w.Code != 200 {
|
||||||
|
t.Fatalf("second confirm: %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfirmVolunteerRequiresRole(t *testing.T) {
|
||||||
|
app := testApp(t)
|
||||||
|
mux := testMux(app)
|
||||||
|
|
||||||
|
// Ticketing role should NOT be able to confirm volunteers.
|
||||||
|
ticketing := testUserWithRole(t, app, "ticket_lead", "ticketing", nil)
|
||||||
|
tok := testToken(t, app, ticketing)
|
||||||
|
|
||||||
|
v, _ := app.createVolunteer(Volunteer{Name: "Helena", EmailConfirmed: true})
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(w, testAuthRequest("POST", "/api/volunteers/"+itoa(v.ID)+"/confirm", nil, tok))
|
||||||
|
if w.Code != http.StatusForbidden {
|
||||||
|
t.Errorf("expected 403 for ticketing role, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateVolunteerDepartment(t *testing.T) {
|
||||||
|
app := testApp(t)
|
||||||
|
mux := testMux(app)
|
||||||
|
admin := testAdminUser(t, app)
|
||||||
|
tok := testToken(t, app, admin)
|
||||||
|
|
||||||
|
dept, _ := app.createDepartment(Department{Name: "Gate"})
|
||||||
|
v, _ := app.createVolunteer(Volunteer{Name: "Hermia"})
|
||||||
|
|
||||||
|
// Assign department via update.
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(w, testAuthRequest("PUT", "/api/volunteers/"+itoa(v.ID), map[string]any{
|
||||||
|
"name": "Hermia", "department_id": dept.ID,
|
||||||
|
}, tok))
|
||||||
|
if w.Code != 200 {
|
||||||
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
got, _ := app.getVolunteer(v.ID)
|
||||||
|
if got.DepartmentID == nil || *got.DepartmentID != dept.ID {
|
||||||
|
t.Errorf("department_id = %v, want %d", got.DepartmentID, dept.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateVolunteerCoLeadAutoConfirms(t *testing.T) {
|
||||||
|
app := testApp(t)
|
||||||
|
mux := testMux(app)
|
||||||
|
admin := testAdminUser(t, app)
|
||||||
|
tok := testToken(t, app, admin)
|
||||||
|
|
||||||
|
dept, _ := app.createDepartment(Department{Name: "Build"})
|
||||||
|
deptID := dept.ID
|
||||||
|
v, _ := app.createVolunteer(Volunteer{
|
||||||
|
Name: "Lysander", Email: "lys@test.com",
|
||||||
|
DepartmentID: &deptID, EmailConfirmed: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Verify not confirmed before update.
|
||||||
|
got, _ := app.getVolunteer(v.ID)
|
||||||
|
if got.Confirmed {
|
||||||
|
t.Fatal("should not be confirmed before update")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update is_lead=true should auto-confirm.
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(w, testAuthRequest("PUT", "/api/volunteers/"+itoa(v.ID), map[string]any{
|
||||||
|
"name": "Lysander", "department_id": deptID, "is_lead": true,
|
||||||
|
}, tok))
|
||||||
|
if w.Code != 200 {
|
||||||
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
got, _ = app.getVolunteer(v.ID)
|
||||||
|
if !got.IsLead {
|
||||||
|
t.Error("expected is_lead=true")
|
||||||
|
}
|
||||||
|
if !got.Confirmed {
|
||||||
|
t.Error("co-lead should be auto-confirmed")
|
||||||
|
}
|
||||||
|
}
|
||||||
1
main.go
1
main.go
|
|
@ -126,6 +126,7 @@ func (app *App) registerRoutes(mux *http.ServeMux) {
|
||||||
mux.HandleFunc("PUT /api/volunteers/{id}", auth(app.handleUpdateVolunteer, "admin", "ticketing", "staffing", "colead"))
|
mux.HandleFunc("PUT /api/volunteers/{id}", auth(app.handleUpdateVolunteer, "admin", "ticketing", "staffing", "colead"))
|
||||||
mux.HandleFunc("DELETE /api/volunteers/{id}", auth(app.handleDeleteVolunteer, "admin", "ticketing", "staffing", "colead"))
|
mux.HandleFunc("DELETE /api/volunteers/{id}", auth(app.handleDeleteVolunteer, "admin", "ticketing", "staffing", "colead"))
|
||||||
mux.HandleFunc("POST /api/volunteers/{id}/checkin", auth(app.handleCheckInVolunteer, "admin", "ticketing", "staffing", "colead"))
|
mux.HandleFunc("POST /api/volunteers/{id}/checkin", auth(app.handleCheckInVolunteer, "admin", "ticketing", "staffing", "colead"))
|
||||||
|
mux.HandleFunc("POST /api/volunteers/{id}/confirm", auth(app.handleConfirmVolunteer, "admin", "staffing", "colead"))
|
||||||
mux.HandleFunc("POST /api/volunteers/{id}/shifts", auth(app.handleAssignShift, "admin", "ticketing", "staffing", "colead"))
|
mux.HandleFunc("POST /api/volunteers/{id}/shifts", auth(app.handleAssignShift, "admin", "ticketing", "staffing", "colead"))
|
||||||
mux.HandleFunc("DELETE /api/volunteers/{id}/shifts/{shift_id}", auth(app.handleUnassignShift, "admin", "ticketing", "staffing", "colead"))
|
mux.HandleFunc("DELETE /api/volunteers/{id}/shifts/{shift_id}", auth(app.handleUnassignShift, "admin", "ticketing", "staffing", "colead"))
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue