Set up Unconfirmed -> Registered -> Confirmed -> Ready flow for Volunteers
This commit is contained in:
parent
62b3dece84
commit
72b245d6d6
7 changed files with 95 additions and 20 deletions
26
db.go
26
db.go
|
|
@ -174,6 +174,8 @@ 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")
|
||||||
// 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`)
|
||||||
|
|
@ -392,6 +394,8 @@ 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:"-"`
|
||||||
Note string `json:"note"`
|
Note string `json:"note"`
|
||||||
|
|
@ -1184,6 +1188,7 @@ 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.confirmed, v.confirmed_at,
|
||||||
v.email_confirmed, v.confirmation_token, v.note,
|
v.email_confirmed, v.confirmation_token, 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 +1298,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,12 +1321,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 sql.NullString
|
||||||
|
var confirmedAt 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,
|
||||||
|
&confirmed, &confirmedAt,
|
||||||
&emailConfirmed, &confirmationToken, &v.Note,
|
&emailConfirmed, &confirmationToken, &v.Note,
|
||||||
&v.CreatedAt, &v.UpdatedAt, &v.DeletedAt,
|
&v.CreatedAt, &v.UpdatedAt, &v.DeletedAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
|
|
@ -1329,8 +1349,12 @@ 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
|
||||||
|
}
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -99,11 +99,14 @@ Under **Volunteers**, you can:
|
||||||
|
|
||||||
### Volunteer statuses
|
### Volunteer statuses
|
||||||
|
|
||||||
| Status | Meaning |
|
| Status | Meaning | Who sets it |
|
||||||
|--------|---------|
|
|--------|---------|-------------|
|
||||||
| **Unconfirmed** | Signed up but hasn't confirmed their email |
|
| **Unconfirmed** | Signed up but hasn't confirmed their email | Automatic (not yet done) |
|
||||||
| **Confirmed** | Email confirmed, not yet briefed |
|
| **Registered** | Email confirmed — volunteer is in the system | Automatic (email link) |
|
||||||
| **Ready** | Briefed at the volunteer station, has what they need to report for shifts |
|
| **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 a volunteer has been assigned to a department and you're expecting them. Only volunteers who have been assigned a department can be confirmed. Use the **Confirm** button on a registered volunteer's row.
|
||||||
|
|
||||||
Volunteers are separate from participants. A person can be both a ticket holder and a volunteer. When a volunteer signs up via the public form, they are automatically linked to their participant record by email.
|
Volunteers are separate from participants. A person can be both a ticket holder and a volunteer. When a volunteer signs up via the public form, they are automatically linked to their participant record by email.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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' }),
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -131,6 +131,7 @@ tr:hover td { background: rgba(255,255,255,0.02); }
|
||||||
}
|
}
|
||||||
.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-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); }
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -20,6 +20,7 @@
|
||||||
|
|
||||||
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 ?? [])
|
const myDeptIDs = $derived(session?.user?.department_ids ?? [])
|
||||||
|
|
||||||
// Auto-filter coleads to their department on mount
|
// Auto-filter coleads to their department on mount
|
||||||
|
|
@ -33,6 +34,7 @@
|
||||||
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)))
|
||||||
|
|
@ -44,8 +46,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
|
||||||
|
|
@ -62,6 +66,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
|
||||||
|
|
@ -110,6 +123,11 @@
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
@ -181,10 +199,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 ready</option>
|
<option value="unconfirmed">Unconfirmed</option>
|
||||||
<option value="true">Ready</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
|
||||||
|
|
@ -219,7 +239,9 @@
|
||||||
<span class="badge badge-lead" style="margin-left:0.4rem">Co-Lead</span>
|
<span class="badge badge-lead" style="margin-left:0.4rem">Co-Lead</span>
|
||||||
{/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="Registered as volunteer but 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>
|
||||||
|
|
@ -236,9 +258,15 @@
|
||||||
{/if}
|
{/if}
|
||||||
</td>
|
</td>
|
||||||
<td class="td-status">
|
<td class="td-status">
|
||||||
<span class="badge {v.checked_in ? 'badge-checked' : v.email_confirmed ? 'badge-confirmed' : 'badge-unchecked'}">
|
{#if v.checked_in}
|
||||||
{v.checked_in ? 'Ready' : v.email_confirmed ? 'Confirmed' : 'Unconfirmed'}
|
<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()}
|
||||||
|
|
@ -252,6 +280,9 @@
|
||||||
</td>
|
</td>
|
||||||
{#if canManage}
|
{#if canManage}
|
||||||
<td class="td-actions">
|
<td class="td-actions">
|
||||||
|
{#if canConfirm && v.email_confirmed && !v.confirmed && v.department_id}
|
||||||
|
<button class="btn btn-primary btn-sm" onclick={() => confirmVolunteer(v)}>Confirm</button>
|
||||||
|
{/if}
|
||||||
<button class="btn btn-ghost btn-sm" onclick={() => toggleLead(v)}
|
<button class="btn btn-ghost btn-sm" onclick={() => toggleLead(v)}
|
||||||
title={v.is_lead ? 'Remove co-lead' : 'Mark as co-lead'}>
|
title={v.is_lead ? 'Remove co-lead' : 'Mark as co-lead'}>
|
||||||
{v.is_lead ? '− Co-Lead' : '+ Co-Lead'}
|
{v.is_lead ? '− Co-Lead' : '+ Co-Lead'}
|
||||||
|
|
|
||||||
|
|
@ -142,6 +142,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 {
|
||||||
|
|
|
||||||
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