Refactored volunteer check_in as ready status.
This commit is contained in:
parent
e722ef055e
commit
4c462c9d47
7 changed files with 51 additions and 28 deletions
49
db.go
49
db.go
|
|
@ -93,8 +93,8 @@ func migrate(db *sql.DB) error {
|
||||||
phone TEXT NOT NULL DEFAULT '',
|
phone TEXT NOT NULL DEFAULT '',
|
||||||
department_id INTEGER REFERENCES departments(id) ON DELETE SET NULL,
|
department_id INTEGER REFERENCES departments(id) ON DELETE SET NULL,
|
||||||
is_lead INTEGER NOT NULL DEFAULT 0,
|
is_lead INTEGER NOT NULL DEFAULT 0,
|
||||||
checked_in INTEGER NOT NULL DEFAULT 0,
|
ready INTEGER NOT NULL DEFAULT 0,
|
||||||
checked_in_at TEXT,
|
ready_at TEXT,
|
||||||
note TEXT NOT NULL DEFAULT '',
|
note TEXT NOT NULL DEFAULT '',
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
|
@ -177,6 +177,8 @@ func migrateV2(db *sql.DB) error {
|
||||||
addColumnIfMissing(db, "volunteers", "confirmed INTEGER NOT NULL DEFAULT 0")
|
addColumnIfMissing(db, "volunteers", "confirmed INTEGER NOT NULL DEFAULT 0")
|
||||||
addColumnIfMissing(db, "volunteers", "confirmed_at TEXT")
|
addColumnIfMissing(db, "volunteers", "confirmed_at TEXT")
|
||||||
addColumnIfMissing(db, "volunteers", "kiosk_code TEXT")
|
addColumnIfMissing(db, "volunteers", "kiosk_code TEXT")
|
||||||
|
renameColumnIfExists(db, "volunteers", "checked_in", "ready")
|
||||||
|
renameColumnIfExists(db, "volunteers", "checked_in_at", "ready_at")
|
||||||
db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_volunteers_kiosk_code ON volunteers(kiosk_code) WHERE kiosk_code IS NOT NULL`)
|
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).
|
// Migrate kiosk codes from tickets to volunteers (idempotent).
|
||||||
db.Exec(`
|
db.Exec(`
|
||||||
|
|
@ -340,6 +342,27 @@ func addColumnIfMissing(db *sql.DB, table, colDef string) {
|
||||||
db.Exec(`ALTER TABLE "` + table + `" ADD COLUMN ` + colDef)
|
db.Exec(`ALTER TABLE "` + table + `" ADD COLUMN ` + colDef)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func renameColumnIfExists(db *sql.DB, table, oldName, newName string) {
|
||||||
|
found := false
|
||||||
|
rows, err := db.Query(`PRAGMA table_info("` + table + `")`)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for rows.Next() {
|
||||||
|
var cid, notNull, pk int
|
||||||
|
var name, typ string
|
||||||
|
var dflt sql.NullString
|
||||||
|
rows.Scan(&cid, &name, &typ, ¬Null, &dflt, &pk)
|
||||||
|
if name == oldName {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
if found {
|
||||||
|
db.Exec(`ALTER TABLE "` + table + `" RENAME COLUMN "` + oldName + `" TO "` + newName + `"`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- Types ---
|
// --- Types ---
|
||||||
|
|
||||||
const attendeeCols = `id, name, email, phone, ticket_id, ticket_type, volunteer_token,
|
const attendeeCols = `id, name, email, phone, ticket_id, ticket_type, volunteer_token,
|
||||||
|
|
@ -407,8 +430,8 @@ type Volunteer struct {
|
||||||
Pronouns string `json:"pronouns"`
|
Pronouns string `json:"pronouns"`
|
||||||
DepartmentID *int `json:"department_id,omitempty"`
|
DepartmentID *int `json:"department_id,omitempty"`
|
||||||
IsLead bool `json:"is_lead"`
|
IsLead bool `json:"is_lead"`
|
||||||
CheckedIn bool `json:"checked_in"`
|
Ready bool `json:"ready"`
|
||||||
CheckedInAt *string `json:"checked_in_at,omitempty"`
|
ReadyAt *string `json:"ready_at,omitempty"`
|
||||||
Confirmed bool `json:"confirmed"`
|
Confirmed bool `json:"confirmed"`
|
||||||
ConfirmedAt *string `json:"confirmed_at,omitempty"`
|
ConfirmedAt *string `json:"confirmed_at,omitempty"`
|
||||||
EmailConfirmed bool `json:"email_confirmed"`
|
EmailConfirmed bool `json:"email_confirmed"`
|
||||||
|
|
@ -1203,14 +1226,14 @@ const volunteerSelect = `v.id, v.participant_id, v.attendee_id,
|
||||||
COALESCE(NULLIF(p.email,''), v.email),
|
COALESCE(NULLIF(p.email,''), v.email),
|
||||||
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.ready, v.ready_at,
|
||||||
v.confirmed, v.confirmed_at,
|
v.confirmed, v.confirmed_at,
|
||||||
v.email_confirmed, v.confirmation_token, v.kiosk_code, v.note,
|
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`
|
||||||
|
|
||||||
// volunteerCols is kept for backward-compat references that expect unqualified column names.
|
// volunteerCols is kept for backward-compat references that expect unqualified column names.
|
||||||
const volunteerCols = `id, attendee_id, name, preferred_name, email, phone, pronouns, department_id, is_lead, checked_in, checked_in_at, email_confirmed, confirmation_token, note, created_at, updated_at, deleted_at`
|
const volunteerCols = `id, attendee_id, name, preferred_name, email, phone, pronouns, department_id, is_lead, ready, ready_at, email_confirmed, confirmation_token, note, created_at, updated_at, deleted_at`
|
||||||
|
|
||||||
func (app *App) listVolunteers(search string, deptID *int, since string) ([]Volunteer, error) {
|
func (app *App) listVolunteers(search string, deptID *int, since string) ([]Volunteer, error) {
|
||||||
q := `SELECT ` + volunteerSelect + ` ` + volunteerFrom + ` WHERE 1=1`
|
q := `SELECT ` + volunteerSelect + ` ` + volunteerFrom + ` WHERE 1=1`
|
||||||
|
|
@ -1292,13 +1315,13 @@ func (app *App) deleteVolunteer(id int) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkInVolunteer marks the volunteer as checked in and, if linked to an attendee,
|
// markVolunteerReady marks the volunteer as ready and, if linked to an attendee,
|
||||||
// also increments the attendee's checked_in_count.
|
// also increments the attendee's checked_in_count.
|
||||||
func (app *App) checkInVolunteer(id, userID int) (*Volunteer, error) {
|
func (app *App) markVolunteerReady(id, userID int) (*Volunteer, error) {
|
||||||
t := now()
|
t := now()
|
||||||
_, err := app.db.Exec(
|
_, err := app.db.Exec(
|
||||||
`UPDATE volunteers SET checked_in=1, checked_in_at=?, updated_at=?
|
`UPDATE volunteers SET ready=1, ready_at=?, updated_at=?
|
||||||
WHERE id=? AND deleted_at IS NULL AND checked_in=0`,
|
WHERE id=? AND deleted_at IS NULL AND ready=0`,
|
||||||
t, t, id,
|
t, t, id,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -1337,12 +1360,12 @@ 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, confirmed, emailConfirmed int
|
var isLead, ready, confirmed, emailConfirmed int
|
||||||
var confirmationToken, confirmedAt, kioskCode 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.ID, &participantID, &attendeeID, &v.Name, &v.PreferredName,
|
||||||
&v.Email, &v.Phone, &v.Pronouns, &deptID,
|
&v.Email, &v.Phone, &v.Pronouns, &deptID,
|
||||||
&isLead, &checkedIn, &v.CheckedInAt,
|
&isLead, &ready, &v.ReadyAt,
|
||||||
&confirmed, &confirmedAt,
|
&confirmed, &confirmedAt,
|
||||||
&emailConfirmed, &confirmationToken, &kioskCode, &v.Note,
|
&emailConfirmed, &confirmationToken, &kioskCode, &v.Note,
|
||||||
&v.CreatedAt, &v.UpdatedAt, &v.DeletedAt,
|
&v.CreatedAt, &v.UpdatedAt, &v.DeletedAt,
|
||||||
|
|
@ -1371,7 +1394,7 @@ func queryVolunteers(db *sql.DB, q string, args ...any) ([]Volunteer, error) {
|
||||||
v.KioskCode = &kioskCode.String
|
v.KioskCode = &kioskCode.String
|
||||||
}
|
}
|
||||||
v.IsLead = isLead == 1
|
v.IsLead = isLead == 1
|
||||||
v.CheckedIn = checkedIn == 1
|
v.Ready = ready == 1
|
||||||
v.Confirmed = confirmed == 1
|
v.Confirmed = confirmed == 1
|
||||||
v.EmailConfirmed = emailConfirmed == 1
|
v.EmailConfirmed = emailConfirmed == 1
|
||||||
result = append(result, v)
|
result = append(result, v)
|
||||||
|
|
|
||||||
|
|
@ -78,7 +78,7 @@ export const api = {
|
||||||
create: (data) => apiJSON('/api/volunteers', { method: 'POST', body: JSON.stringify(data) }),
|
create: (data) => apiJSON('/api/volunteers', { method: 'POST', body: JSON.stringify(data) }),
|
||||||
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' }),
|
markReady: (id) => apiJSON(`/api/volunteers/${id}/ready`, { method: 'POST' }),
|
||||||
confirm: (id) => apiJSON(`/api/volunteers/${id}/confirm`, { 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' }),
|
||||||
|
|
|
||||||
|
|
@ -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 ? '…' : '✓ Ready'}
|
{loading ? '…' : 'Mark ready'}
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@
|
||||||
return vols
|
return vols
|
||||||
})
|
})
|
||||||
const volTotal = $derived(volunteers.length)
|
const volTotal = $derived(volunteers.length)
|
||||||
const volCheckedIn = $derived(volunteers.filter(v => v.checked_in).length)
|
const volCheckedIn = $derived(volunteers.filter(v => v.ready).length)
|
||||||
const volLeads = $derived(volunteers.filter(v => v.is_lead).length)
|
const volLeads = $derived(volunteers.filter(v => v.is_lead).length)
|
||||||
|
|
||||||
// Shift stats (scoped for colead)
|
// Shift stats (scoped for colead)
|
||||||
|
|
|
||||||
|
|
@ -54,8 +54,8 @@
|
||||||
if (filterDept && v.department_id !== parseInt(filterDept)) return false
|
if (filterDept && v.department_id !== parseInt(filterDept)) return false
|
||||||
if (filterStatus === 'unconfirmed' && v.email_confirmed) return false
|
if (filterStatus === 'unconfirmed' && v.email_confirmed) return false
|
||||||
if (filterStatus === 'registered' && (!v.email_confirmed || v.confirmed)) return false
|
if (filterStatus === 'registered' && (!v.email_confirmed || v.confirmed)) return false
|
||||||
if (filterStatus === 'confirmed' && (!v.confirmed || v.checked_in)) return false
|
if (filterStatus === 'confirmed' && (!v.confirmed || v.ready)) return false
|
||||||
if (filterStatus === 'ready' && !v.checked_in) return false
|
if (filterStatus === 'ready' && !v.ready) 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
|
||||||
|
|
@ -63,9 +63,9 @@
|
||||||
.sort((a, b) => a.name.localeCompare(b.name))
|
.sort((a, b) => a.name.localeCompare(b.name))
|
||||||
})
|
})
|
||||||
|
|
||||||
async function checkIn(v) {
|
async function markReady(v) {
|
||||||
try {
|
try {
|
||||||
const updated = await api.volunteers.checkIn(v.id)
|
const updated = await api.volunteers.markReady(v.id)
|
||||||
await db.volunteers.put(updated)
|
await db.volunteers.put(updated)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error = err.message
|
error = err.message
|
||||||
|
|
@ -314,7 +314,7 @@
|
||||||
{/if}
|
{/if}
|
||||||
</td>
|
</td>
|
||||||
<td class="td-status">
|
<td class="td-status">
|
||||||
{#if v.checked_in}
|
{#if v.ready}
|
||||||
<span class="badge badge-checked">Ready</span>
|
<span class="badge badge-checked">Ready</span>
|
||||||
{:else if v.confirmed}
|
{:else if v.confirmed}
|
||||||
<span class="badge badge-confirmed">Confirmed</span>
|
<span class="badge badge-confirmed">Confirmed</span>
|
||||||
|
|
@ -323,15 +323,15 @@
|
||||||
{:else}
|
{:else}
|
||||||
<span class="badge badge-unchecked">Unconfirmed</span>
|
<span class="badge badge-unchecked">Unconfirmed</span>
|
||||||
{/if}
|
{/if}
|
||||||
{#if v.checked_in_at}
|
{#if v.ready_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.ready_at).toLocaleTimeString()}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</td>
|
</td>
|
||||||
<td class="td-ready">
|
<td class="td-ready">
|
||||||
{#if !v.checked_in}
|
{#if v.confirmed && !v.ready}
|
||||||
<CheckInButton onclick={() => checkIn(v)} />
|
<CheckInButton onclick={() => markReady(v)} />
|
||||||
{/if}
|
{/if}
|
||||||
</td>
|
</td>
|
||||||
{#if canManage}
|
{#if canManage}
|
||||||
|
|
|
||||||
|
|
@ -130,14 +130,14 @@ func (app *App) handleDeleteVolunteer(w http.ResponseWriter, r *http.Request) {
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (app *App) handleCheckInVolunteer(w http.ResponseWriter, r *http.Request) {
|
func (app *App) handleMarkVolunteerReady(w http.ResponseWriter, r *http.Request) {
|
||||||
id, err := strconv.Atoi(r.PathValue("id"))
|
id, err := strconv.Atoi(r.PathValue("id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, "invalid id", http.StatusBadRequest)
|
writeError(w, "invalid id", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
claims := claimsFromContext(r)
|
claims := claimsFromContext(r)
|
||||||
v, err := app.checkInVolunteer(id, claims.UserID)
|
v, err := app.markVolunteerReady(id, claims.UserID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
|
|
|
||||||
2
main.go
2
main.go
|
|
@ -125,7 +125,7 @@ func (app *App) registerRoutes(mux *http.ServeMux) {
|
||||||
mux.HandleFunc("GET /api/volunteers/{id}", auth(app.handleGetVolunteer, "admin", "ticketing", "staffing", "colead"))
|
mux.HandleFunc("GET /api/volunteers/{id}", auth(app.handleGetVolunteer, "admin", "ticketing", "staffing", "colead"))
|
||||||
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}/ready", auth(app.handleMarkVolunteerReady, "admin", "ticketing", "staffing", "colead"))
|
||||||
mux.HandleFunc("POST /api/volunteers/{id}/confirm", auth(app.handleConfirmVolunteer, "admin", "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