feat(classes): member calendar/signup + instructor attendance
/classes lists upcoming sessions for any member with spots-left, sign-up / join-waitlist and cancel (waitlist auto-promotes on cancel via the action). Holders of classes.instruct/manage get an inline attendees panel to mark attendance and complete the session; completion issues the class's certification through the normal grant path and the UI reports when certificates were skipped (no certifications.grant). Sidebar gets a member-visible "Classes" link.
This commit is contained in:
@@ -0,0 +1,168 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { CalendarDays } from 'lucide-react'
|
||||||
|
import { PageHeader, PageTitle } from '@/components/ui/page-title'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription } from '@/components/ui/empty'
|
||||||
|
import { signUpForClass, cancelMySignup } from '@/lib/actions'
|
||||||
|
import { effectiveCapacity, SIGNUP_STATUS_LABEL } from '@/lib/classes-logic'
|
||||||
|
import { SessionAttendance } from '@/components/classes/session-attendance'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
|
||||||
|
type Row = {
|
||||||
|
id: string
|
||||||
|
starts_at: string
|
||||||
|
ends_at: string | null
|
||||||
|
location: string | null
|
||||||
|
capacity: number | null
|
||||||
|
status: string
|
||||||
|
notes: string | null
|
||||||
|
classes: { title: string; description: string | null; payment_link: string | null; capacity: number | null } | Array<{ title: string; description: string | null; payment_link: string | null; capacity: number | null }> | null
|
||||||
|
registered_count: number
|
||||||
|
my_status: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function classOf(r: Row) {
|
||||||
|
return Array.isArray(r.classes) ? r.classes[0] : r.classes
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ClassesBrowseClient({
|
||||||
|
sessions,
|
||||||
|
canRunSessions,
|
||||||
|
}: {
|
||||||
|
sessions: unknown[]
|
||||||
|
canRunSessions: boolean
|
||||||
|
}) {
|
||||||
|
const router = useRouter()
|
||||||
|
const [rows, setRows] = useState<Row[]>(sessions as Row[])
|
||||||
|
const [busyId, setBusyId] = useState<string | null>(null)
|
||||||
|
const [openAttendance, setOpenAttendance] = useState<string | null>(null)
|
||||||
|
|
||||||
|
async function onSignUp(r: Row) {
|
||||||
|
setBusyId(r.id)
|
||||||
|
const res = await signUpForClass({ sessionId: r.id })
|
||||||
|
setBusyId(null)
|
||||||
|
if ('error' in res && res.error) {
|
||||||
|
toast.error(res.error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const status = (res as { data: { status: string } }).data.status
|
||||||
|
toast.success(status === 'waitlisted' ? 'Added to the waitlist' : 'You are signed up')
|
||||||
|
setRows(prev =>
|
||||||
|
prev.map(x =>
|
||||||
|
x.id === r.id ? { ...x, my_status: status, registered_count: x.registered_count + (status === 'registered' ? 1 : 0) } : x,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onCancel(r: Row) {
|
||||||
|
setBusyId(r.id)
|
||||||
|
const res = await cancelMySignup({ sessionId: r.id })
|
||||||
|
setBusyId(null)
|
||||||
|
if ('error' in res && res.error) {
|
||||||
|
toast.error(res.error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
toast.success('Signup cancelled')
|
||||||
|
setRows(prev =>
|
||||||
|
prev.map(x =>
|
||||||
|
x.id === r.id
|
||||||
|
? { ...x, my_status: null, registered_count: Math.max(0, x.registered_count - (r.my_status === 'registered' ? 1 : 0)) }
|
||||||
|
: x,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader>
|
||||||
|
<PageTitle>Classes</PageTitle>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<div className="p-4 md:p-6">
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<Empty>
|
||||||
|
<EmptyHeader>
|
||||||
|
<EmptyMedia variant="icon"><CalendarDays /></EmptyMedia>
|
||||||
|
<EmptyTitle>No upcoming classes</EmptyTitle>
|
||||||
|
<EmptyDescription>Check back later, or ask an organizer to schedule a session.</EmptyDescription>
|
||||||
|
</EmptyHeader>
|
||||||
|
</Empty>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-3">
|
||||||
|
{rows.map(r => {
|
||||||
|
const c = classOf(r)
|
||||||
|
const cap = effectiveCapacity(r.capacity, c?.capacity ?? null)
|
||||||
|
const spots = cap == null ? null : Math.max(0, cap - r.registered_count)
|
||||||
|
const full = cap != null && r.registered_count >= cap
|
||||||
|
return (
|
||||||
|
<li key={r.id} className="rounded-lg border border-border p-4">
|
||||||
|
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="font-sans text-sm font-semibold text-foreground">{c?.title ?? 'Class'}</span>
|
||||||
|
{r.my_status && (
|
||||||
|
<Badge variant={r.my_status === 'registered' ? 'default' : 'outline'}>
|
||||||
|
{SIGNUP_STATUS_LABEL[r.my_status] ?? r.my_status}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{c?.description && <p className="font-sans text-sm text-muted-foreground mt-1">{c.description}</p>}
|
||||||
|
<p className="font-mono text-[10px] text-muted-foreground mt-1">
|
||||||
|
{new Date(r.starts_at).toLocaleString()}
|
||||||
|
{r.ends_at ? ` – ${new Date(r.ends_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}` : ''}
|
||||||
|
{r.location ? ` · ${r.location}` : ''}
|
||||||
|
{cap == null ? ' · open' : ` · ${spots} of ${cap} spots left`}
|
||||||
|
</p>
|
||||||
|
{r.notes && <p className="font-sans text-xs text-muted-foreground mt-1">{r.notes}</p>}
|
||||||
|
{c?.payment_link && (
|
||||||
|
<a href={c.payment_link} target="_blank" rel="noopener noreferrer"
|
||||||
|
className="font-mono text-[10px] text-primary underline">payment link</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-end gap-1.5">
|
||||||
|
{r.my_status ? (
|
||||||
|
<button
|
||||||
|
onClick={() => onCancel(r)}
|
||||||
|
disabled={busyId === r.id}
|
||||||
|
className="font-mono text-[10px] border border-border px-3 py-2 rounded hover:border-red-500 hover:text-red-500 transition disabled:opacity-60"
|
||||||
|
>
|
||||||
|
Cancel signup
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => onSignUp(r)}
|
||||||
|
disabled={busyId === r.id}
|
||||||
|
className="font-mono text-[10px] border border-border px-3 py-2 rounded hover:border-primary hover:text-primary transition disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{full ? 'Join waitlist' : 'Sign up'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{canRunSessions && (
|
||||||
|
<button
|
||||||
|
onClick={() => setOpenAttendance(openAttendance === r.id ? null : r.id)}
|
||||||
|
className="font-mono text-[10px] text-muted-foreground hover:text-primary transition"
|
||||||
|
>
|
||||||
|
{openAttendance === r.id ? 'Hide attendees' : 'Attendees'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{canRunSessions && openAttendance === r.id && (
|
||||||
|
<SessionAttendance
|
||||||
|
sessionId={r.id}
|
||||||
|
sessionStatus={r.status}
|
||||||
|
onCompleted={() => { setOpenAttendance(null); router.refresh() }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { createClient } from '@/lib/supabase/server'
|
||||||
|
import { redirect } from 'next/navigation'
|
||||||
|
import { listUpcomingSessions } from '@/lib/actions'
|
||||||
|
import { ClassesBrowseClient } from './classes-browse-client'
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
export default async function ClassesPage() {
|
||||||
|
const supabase = await createClient()
|
||||||
|
const {
|
||||||
|
data: { user },
|
||||||
|
} = await supabase.auth.getUser()
|
||||||
|
if (!user) redirect('/login')
|
||||||
|
|
||||||
|
const { data: member } = await supabase
|
||||||
|
.from('space_members')
|
||||||
|
.select('space_id')
|
||||||
|
.eq('user_id', user.id)
|
||||||
|
.in('status', ['current', 'unverified', 'late'])
|
||||||
|
.maybeSingle()
|
||||||
|
if (!member) redirect('/signup')
|
||||||
|
|
||||||
|
const [{ data: canInstruct }, { data: canManage }] = await Promise.all([
|
||||||
|
supabase.rpc('user_has_permission', { uid: user.id, sid: member.space_id, perm: 'classes.instruct' }),
|
||||||
|
supabase.rpc('user_has_permission', { uid: user.id, sid: member.space_id, perm: 'classes.manage' }),
|
||||||
|
])
|
||||||
|
|
||||||
|
const res = await listUpcomingSessions()
|
||||||
|
const sessions = 'data' in res ? res.data : []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ClassesBrowseClient
|
||||||
|
sessions={sessions as unknown[]}
|
||||||
|
canRunSessions={!!canInstruct || !!canManage}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -135,6 +135,7 @@ export function AppSidebar({ member, roleName, taskBadge = 0, commsBadge = 0, pa
|
|||||||
<NavLink href="/contacts" label="Contacts" icon={BookUser} active={isActive('/contacts')} onClick={onNav} />
|
<NavLink href="/contacts" label="Contacts" icon={BookUser} active={isActive('/contacts')} onClick={onNav} />
|
||||||
<NavLink href="/my-forms" label="Forms" icon={FileText} active={isActive('/my-forms')} onClick={onNav} />
|
<NavLink href="/my-forms" label="Forms" icon={FileText} active={isActive('/my-forms')} onClick={onNav} />
|
||||||
<NavLink href="/profile" label="My profile" icon={UserCircle} active={isActive('/profile')} onClick={onNav} />
|
<NavLink href="/profile" label="My profile" icon={UserCircle} active={isActive('/profile')} onClick={onNav} />
|
||||||
|
<NavLink href="/classes" label="Classes" icon={GraduationCap} active={pathname === '/classes' || pathname.startsWith('/classes?')} onClick={onNav} />
|
||||||
<NavLink href="/me" label="My access" icon={BadgeCheck} active={isActive('/me')} onClick={onNav} />
|
<NavLink href="/me" label="My access" icon={BadgeCheck} active={isActive('/me')} onClick={onNav} />
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<NavLink href="/recruitment" label="Recruitment" icon={UserSearch} active={isActive('/recruitment')} onClick={onNav} />
|
<NavLink href="/recruitment" label="Recruitment" icon={UserSearch} active={isActive('/recruitment')} onClick={onNav} />
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { listSessionSignups, markAttendance, completeSession } from '@/lib/actions'
|
||||||
|
import { SIGNUP_STATUS_LABEL } from '@/lib/classes-logic'
|
||||||
|
|
||||||
|
type Signup = {
|
||||||
|
id: string
|
||||||
|
member_id: string
|
||||||
|
status: string
|
||||||
|
attended: boolean
|
||||||
|
signed_up_at: string
|
||||||
|
space_members: { display_name: string | null; email: string | null } | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SessionAttendance({
|
||||||
|
sessionId,
|
||||||
|
sessionStatus,
|
||||||
|
onCompleted,
|
||||||
|
}: {
|
||||||
|
sessionId: string
|
||||||
|
sessionStatus: string
|
||||||
|
onCompleted: () => void
|
||||||
|
}) {
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [rows, setRows] = useState<Signup[]>([])
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
const res = await listSessionSignups({ sessionId })
|
||||||
|
setLoading(false)
|
||||||
|
if ('error' in res && res.error) {
|
||||||
|
toast.error(res.error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setRows(((res as { data: Signup[] }).data) ?? [])
|
||||||
|
}, [sessionId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load()
|
||||||
|
}, [load])
|
||||||
|
|
||||||
|
async function toggle(s: Signup) {
|
||||||
|
const res = await markAttendance({ signupId: s.id, attended: !s.attended })
|
||||||
|
if ('error' in res && res.error) {
|
||||||
|
toast.error(res.error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setRows(prev => prev.map(r => (r.id === s.id ? { ...r, attended: !r.attended } : r)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onComplete() {
|
||||||
|
setBusy(true)
|
||||||
|
const res = await completeSession({ sessionId })
|
||||||
|
setBusy(false)
|
||||||
|
if ('error' in res && res.error) {
|
||||||
|
toast.error(res.error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const d = (res as { data: { certificatesIssued: number; certificatesSkipped: boolean } }).data
|
||||||
|
if (d.certificatesSkipped) {
|
||||||
|
toast.success('Session completed. Certificates were NOT issued (requires the certifications.grant permission).')
|
||||||
|
} else if (d.certificatesIssued > 0) {
|
||||||
|
toast.success(`Session completed. ${d.certificatesIssued} certificate(s) issued.`)
|
||||||
|
} else {
|
||||||
|
toast.success('Session completed.')
|
||||||
|
}
|
||||||
|
onCompleted()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-3 rounded border border-border bg-background p-3">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<p className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">Attendees</p>
|
||||||
|
{sessionStatus === 'scheduled' && (
|
||||||
|
<button
|
||||||
|
onClick={onComplete}
|
||||||
|
disabled={busy}
|
||||||
|
className="font-mono text-[10px] border border-border px-2 py-1 rounded hover:border-primary hover:text-primary transition disabled:opacity-60"
|
||||||
|
>
|
||||||
|
Complete session
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{loading ? (
|
||||||
|
<p className="font-mono text-[10px] text-muted-foreground">Loading…</p>
|
||||||
|
) : rows.length === 0 ? (
|
||||||
|
<p className="font-mono text-[10px] text-muted-foreground">No signups yet.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-border">
|
||||||
|
{rows.map(s => (
|
||||||
|
<li key={s.id} className="py-2 flex items-center justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<span className="font-sans text-sm text-foreground">
|
||||||
|
{s.space_members?.display_name ?? s.space_members?.email ?? 'Member'}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-[10px] text-muted-foreground ml-2">
|
||||||
|
{SIGNUP_STATUS_LABEL[s.status] ?? s.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-1.5 font-mono text-[10px] text-muted-foreground cursor-pointer">
|
||||||
|
<input type="checkbox" checked={s.attended} onChange={() => toggle(s)} />
|
||||||
|
attended
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user