From 446177bf863a136eb00fcfb8cc2c4f7639354466 Mon Sep 17 00:00:00 2001 From: Moheeb Zara Date: Sat, 16 May 2026 18:57:02 -0700 Subject: [PATCH] feat(certifications): admin cert-type pages + sidebar nav /certifications (guarded by certifications.manage) lists cert types with inline create/edit, archive/restore (is_active), and delete (refused server-side once granted). Sidebar gets a Certifications link in the Admin block next to Forms & waivers. Awarding to members is a separate permission handled from the Members page (next phase). --- .../certifications/certifications-client.tsx | 301 ++++++++++++++++++ app/(app)/certifications/page.tsx | 17 + components/app-sidebar.tsx | 3 +- 3 files changed, 320 insertions(+), 1 deletion(-) create mode 100644 app/(app)/certifications/certifications-client.tsx create mode 100644 app/(app)/certifications/page.tsx diff --git a/app/(app)/certifications/certifications-client.tsx b/app/(app)/certifications/certifications-client.tsx new file mode 100644 index 0000000..039e4bc --- /dev/null +++ b/app/(app)/certifications/certifications-client.tsx @@ -0,0 +1,301 @@ +'use client' + +import { useState } from 'react' +import { toast } from 'sonner' +import { Award, 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 { + createCertification, + updateCertification, + deleteCertification, +} from '@/lib/actions' + +type Cert = { + id: string + name: string + description: string | null + validity_months: number | null + is_active: boolean + created_at: string + updated_at: string +} + +type Draft = { name: string; description: string; validity_months: string } + +const emptyDraft: Draft = { name: '', description: '', validity_months: '' } + +function validityLabel(months: number | null): string { + if (months == null) return 'Never expires' + if (months === 1) return 'Expires 1 month after award' + if (months % 12 === 0) { + const y = months / 12 + return `Expires ${y} year${y === 1 ? '' : 's'} after award` + } + return `Expires ${months} months after award` +} + +function parseMonths(raw: string): number | null { + const t = raw.trim() + if (t === '') return null + const n = Number(t) + return Number.isInteger(n) && n > 0 ? n : null +} + +export function CertificationsClient({ initial }: { initial: Cert[] }) { + const confirm = useConfirm() + const [certs, setCerts] = useState(initial) + const [showNew, setShowNew] = useState(false) + const [draft, setDraft] = useState(emptyDraft) + const [editingId, setEditingId] = useState(null) + const [editDraft, setEditDraft] = useState(emptyDraft) + const [busy, setBusy] = useState(false) + + async function onCreate(e: React.FormEvent) { + e.preventDefault() + if (!draft.name.trim()) { + toast.error('Name is required') + return + } + setBusy(true) + const res = await createCertification({ + name: draft.name.trim(), + description: draft.description.trim() || null, + validity_months: parseMonths(draft.validity_months), + }) + setBusy(false) + if ('error' in res && res.error) { + toast.error(res.error) + return + } + const id = (res as { data: { id: string } }).data.id + setCerts(prev => [ + { + id, + name: draft.name.trim(), + description: draft.description.trim() || null, + validity_months: parseMonths(draft.validity_months), + is_active: true, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }, + ...prev, + ]) + setDraft(emptyDraft) + setShowNew(false) + toast.success('Certification created') + } + + function startEdit(c: Cert) { + setEditingId(c.id) + setEditDraft({ + name: c.name, + description: c.description ?? '', + validity_months: c.validity_months == null ? '' : String(c.validity_months), + }) + } + + async function onSaveEdit(id: string) { + if (!editDraft.name.trim()) { + toast.error('Name is required') + return + } + setBusy(true) + const res = await updateCertification({ + certificationId: id, + name: editDraft.name.trim(), + description: editDraft.description.trim() || null, + validity_months: parseMonths(editDraft.validity_months), + }) + setBusy(false) + if ('error' in res && res.error) { + toast.error(res.error) + return + } + setCerts(prev => + prev.map(c => + c.id === id + ? { + ...c, + name: editDraft.name.trim(), + description: editDraft.description.trim() || null, + validity_months: parseMonths(editDraft.validity_months), + } + : c, + ), + ) + setEditingId(null) + toast.success('Saved') + } + + async function onToggleActive(c: Cert) { + const res = await updateCertification({ certificationId: c.id, is_active: !c.is_active }) + if ('error' in res && res.error) { + toast.error(res.error) + return + } + setCerts(prev => prev.map(x => (x.id === c.id ? { ...x, is_active: !x.is_active } : x))) + toast.success(c.is_active ? 'Archived' : 'Restored') + } + + async function onDelete(c: Cert) { + const ok = await confirm({ + title: 'Delete certification', + description: `"${c.name}" will be permanently removed. This is only allowed if it has never been granted.`, + confirmText: 'Delete', + destructive: true, + }) + if (!ok) return + const res = await deleteCertification({ certificationId: c.id }) + if ('error' in res && res.error) { + toast.error(res.error) + return + } + setCerts(prev => prev.filter(x => x.id !== c.id)) + toast.success('Deleted') + } + + 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' + + return ( + <> + + Certifications + + + +
+

+ Define the certifications your space tracks (e.g. a tool sign-off). Awarding and + revoking a certification to a member is a separate permission + (“Award and revoke certifications” / Instructor) and is done from a + member’s row on the Members page. +

+ + {showNew && ( +
+ setDraft({ ...draft, name: e.target.value })} + /> +