diff --git a/app/(app)/classes/manage/classes-manage-client.tsx b/app/(app)/classes/manage/classes-manage-client.tsx new file mode 100644 index 0000000..adb003f --- /dev/null +++ b/app/(app)/classes/manage/classes-manage-client.tsx @@ -0,0 +1,397 @@ +'use client' + +import { useState } from 'react' +import { toast } from 'sonner' +import { GraduationCap, Plus } from 'lucide-react' +import { PageHeader, PageTitle } from '@/components/ui/page-title' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription } from '@/components/ui/empty' +import { useConfirm } from '@/components/ui/confirm' +import { + createClass, + updateClass, + deleteClass, + createSession, + updateSession, + deleteSession, +} from '@/lib/actions' +import { SESSION_STATUS_LABEL } from '@/lib/classes-logic' + +type Cls = { + id: string + title: string + description: string | null + payment_link: string | null + capacity: number | null + is_active: boolean + grants_certification_id: string | null +} +type Session = { + id: string + class_id: string + starts_at: string + ends_at: string | null + location: string | null + capacity: number | null + status: string + notes: string | null +} +type Cert = { id: string; name: string; is_active: boolean } + +const inputCls = + 'w-full bg-background border border-border text-foreground font-sans text-sm rounded px-3 py-2 focus:outline-none focus:border-primary' + +function emptyClassDraft() { + return { title: '', description: '', payment_link: '', capacity: '', grants_certification_id: '' } +} +function emptySessionDraft() { + return { starts_at: '', ends_at: '', location: '', capacity: '', notes: '' } +} +function num(s: string): number | null { + const t = s.trim() + if (t === '') return null + const n = Number(t) + return Number.isInteger(n) && n > 0 ? n : null +} + +export function ClassesManageClient({ + initialClasses, + initialSessions, + certs, +}: { + initialClasses: Cls[] + initialSessions: Session[] + certs: Cert[] +}) { + const confirm = useConfirm() + const [classes, setClasses] = useState(initialClasses) + const [sessions, setSessions] = useState(initialSessions) + const [showNew, setShowNew] = useState(false) + const [draft, setDraft] = useState(emptyClassDraft()) + const [editId, setEditId] = useState(null) + const [editDraft, setEditDraft] = useState(emptyClassDraft()) + const [sessForClass, setSessForClass] = useState(null) + const [sessDraft, setSessDraft] = useState(emptySessionDraft()) + const [busy, setBusy] = useState(false) + + function certName(id: string | null) { + return id ? certs.find(c => c.id === id)?.name ?? null : null + } + + async function onCreate(e: React.FormEvent) { + e.preventDefault() + if (!draft.title.trim()) return toast.error('Title is required') + setBusy(true) + const res = await createClass({ + title: draft.title.trim(), + description: draft.description.trim() || null, + payment_link: draft.payment_link.trim() || null, + capacity: num(draft.capacity), + grants_certification_id: draft.grants_certification_id || null, + }) + setBusy(false) + if ('error' in res && res.error) return toast.error(res.error) + const id = (res as { data: { id: string } }).data.id + setClasses(prev => [ + { + id, + title: draft.title.trim(), + description: draft.description.trim() || null, + payment_link: draft.payment_link.trim() || null, + capacity: num(draft.capacity), + is_active: true, + grants_certification_id: draft.grants_certification_id || null, + }, + ...prev, + ]) + setDraft(emptyClassDraft()) + setShowNew(false) + toast.success('Class created') + } + + function startEdit(c: Cls) { + setEditId(c.id) + setEditDraft({ + title: c.title, + description: c.description ?? '', + payment_link: c.payment_link ?? '', + capacity: c.capacity == null ? '' : String(c.capacity), + grants_certification_id: c.grants_certification_id ?? '', + }) + } + + async function onSaveEdit(id: string) { + if (!editDraft.title.trim()) return toast.error('Title is required') + setBusy(true) + const res = await updateClass({ + classId: id, + title: editDraft.title.trim(), + description: editDraft.description.trim() || null, + payment_link: editDraft.payment_link.trim() || null, + capacity: num(editDraft.capacity), + grants_certification_id: editDraft.grants_certification_id || null, + }) + setBusy(false) + if ('error' in res && res.error) return toast.error(res.error) + setClasses(prev => + prev.map(c => + c.id === id + ? { + ...c, + title: editDraft.title.trim(), + description: editDraft.description.trim() || null, + payment_link: editDraft.payment_link.trim() || null, + capacity: num(editDraft.capacity), + grants_certification_id: editDraft.grants_certification_id || null, + } + : c, + ), + ) + setEditId(null) + toast.success('Saved') + } + + async function onToggleActive(c: Cls) { + const res = await updateClass({ classId: c.id, is_active: !c.is_active }) + if ('error' in res && res.error) return toast.error(res.error) + setClasses(prev => prev.map(x => (x.id === c.id ? { ...x, is_active: !x.is_active } : x))) + toast.success(c.is_active ? 'Archived' : 'Restored') + } + + async function onDeleteClass(c: Cls) { + const ok = await confirm({ + title: 'Delete class', + description: `"${c.title}" will be removed. Only allowed if it has no sessions.`, + confirmText: 'Delete', + destructive: true, + }) + if (!ok) return + const res = await deleteClass({ classId: c.id }) + if ('error' in res && res.error) return toast.error(res.error) + setClasses(prev => prev.filter(x => x.id !== c.id)) + toast.success('Deleted') + } + + async function onAddSession(e: React.FormEvent, classId: string) { + e.preventDefault() + if (!sessDraft.starts_at) return toast.error('Start time is required') + setBusy(true) + const res = await createSession({ + classId, + starts_at: sessDraft.starts_at, + ends_at: sessDraft.ends_at || null, + location: sessDraft.location.trim() || null, + capacity: num(sessDraft.capacity), + notes: sessDraft.notes.trim() || null, + }) + setBusy(false) + if ('error' in res && res.error) return toast.error(res.error) + const id = (res as { data: { id: string } }).data.id + setSessions(prev => [ + ...prev, + { + id, + class_id: classId, + starts_at: sessDraft.starts_at, + ends_at: sessDraft.ends_at || null, + location: sessDraft.location.trim() || null, + capacity: num(sessDraft.capacity), + status: 'scheduled', + notes: sessDraft.notes.trim() || null, + }, + ]) + setSessDraft(emptySessionDraft()) + setSessForClass(null) + toast.success('Session scheduled') + } + + async function onCancelSession(s: Session) { + const ok = await confirm({ + title: 'Cancel session', + description: 'Registered members will no longer be able to attend.', + confirmText: 'Cancel session', + destructive: true, + }) + if (!ok) return + const res = await updateSession({ sessionId: s.id, status: 'cancelled' }) + if ('error' in res && res.error) return toast.error(res.error) + setSessions(prev => prev.map(x => (x.id === s.id ? { ...x, status: 'cancelled' } : x))) + toast.success('Session cancelled') + } + + async function onDeleteSession(s: Session) { + const ok = await confirm({ + title: 'Delete session', + description: 'Only allowed if it has no signups. Cancel it instead to keep history.', + confirmText: 'Delete', + destructive: true, + }) + if (!ok) return + const res = await deleteSession({ sessionId: s.id }) + if ('error' in res && res.error) return toast.error(res.error) + setSessions(prev => prev.filter(x => x.id !== s.id)) + toast.success('Deleted') + } + + return ( + <> + + Manage classes + + + +
+ {showNew && ( +
+ setDraft({ ...draft, title: e.target.value })} /> +