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).
This commit is contained in:
@@ -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<Cert[]>(initial)
|
||||
const [showNew, setShowNew] = useState(false)
|
||||
const [draft, setDraft] = useState<Draft>(emptyDraft)
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [editDraft, setEditDraft] = useState<Draft>(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 (
|
||||
<>
|
||||
<PageHeader>
|
||||
<PageTitle>Certifications</PageTitle>
|
||||
<Button size="sm" onClick={() => setShowNew(v => !v)}>
|
||||
<Plus className="size-4" /> {showNew ? 'Cancel' : 'New certification'}
|
||||
</Button>
|
||||
</PageHeader>
|
||||
|
||||
<div className="p-4 md:p-6 space-y-4">
|
||||
<p className="font-sans text-sm text-muted-foreground max-w-2xl">
|
||||
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.
|
||||
</p>
|
||||
|
||||
{showNew && (
|
||||
<form onSubmit={onCreate} className="rounded-lg border border-border bg-card p-4 space-y-3">
|
||||
<input
|
||||
autoFocus
|
||||
className={inputCls}
|
||||
placeholder="Name (e.g. Laser Cutter)"
|
||||
maxLength={200}
|
||||
value={draft.name}
|
||||
onChange={e => setDraft({ ...draft, name: e.target.value })}
|
||||
/>
|
||||
<textarea
|
||||
className={inputCls}
|
||||
placeholder="Description (optional)"
|
||||
rows={2}
|
||||
maxLength={2000}
|
||||
value={draft.description}
|
||||
onChange={e => setDraft({ ...draft, description: e.target.value })}
|
||||
/>
|
||||
<div>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
className={inputCls}
|
||||
placeholder="Valid for N months (blank = never expires)"
|
||||
value={draft.validity_months}
|
||||
onChange={e => setDraft({ ...draft, validity_months: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" size="sm" disabled={busy}>
|
||||
Create certification
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{certs.length === 0 ? (
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<Award />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>No certifications yet</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
Create your first certification type. You can award it to members afterwards.
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
<Button onClick={() => setShowNew(true)}>
|
||||
<Plus className="size-4" /> Create your first certification
|
||||
</Button>
|
||||
</Empty>
|
||||
) : (
|
||||
<div className="divide-y rounded-lg border border-border">
|
||||
{certs.map(c => (
|
||||
<div key={c.id} className="p-4">
|
||||
{editingId === c.id ? (
|
||||
<div className="space-y-3">
|
||||
<input
|
||||
className={inputCls}
|
||||
maxLength={200}
|
||||
value={editDraft.name}
|
||||
onChange={e => setEditDraft({ ...editDraft, name: e.target.value })}
|
||||
/>
|
||||
<textarea
|
||||
className={inputCls}
|
||||
rows={2}
|
||||
maxLength={2000}
|
||||
placeholder="Description (optional)"
|
||||
value={editDraft.description}
|
||||
onChange={e => setEditDraft({ ...editDraft, description: e.target.value })}
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
className={inputCls}
|
||||
placeholder="Valid for N months (blank = never expires)"
|
||||
value={editDraft.validity_months}
|
||||
onChange={e => setEditDraft({ ...editDraft, validity_months: e.target.value })}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" disabled={busy} onClick={() => onSaveEdit(c.id)}>
|
||||
Save
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => setEditingId(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<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.name}</span>
|
||||
{!c.is_active && <Badge variant="outline">Archived</Badge>}
|
||||
<span className="font-mono text-[10px] text-muted-foreground">
|
||||
{validityLabel(c.validity_months)}
|
||||
</span>
|
||||
</div>
|
||||
{c.description && (
|
||||
<p className="font-sans text-sm text-muted-foreground mt-1">{c.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button size="sm" variant="outline" onClick={() => startEdit(c)}>
|
||||
Edit
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => onToggleActive(c)}>
|
||||
{c.is_active ? 'Archive' : 'Restore'}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => onDelete(c)}>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { requireCertificationsManagerPage } from '@/lib/certifications-guard'
|
||||
import { CertificationsClient } from './certifications-client'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export default async function CertificationsPage() {
|
||||
const { supabase, member } = await requireCertificationsManagerPage()
|
||||
|
||||
const { data: certifications } = await supabase
|
||||
.from('certifications')
|
||||
.select('id, name, description, validity_months, is_active, created_at, updated_at')
|
||||
.eq('space_id', member.space_id)
|
||||
.order('is_active', { ascending: false })
|
||||
.order('name', { ascending: true })
|
||||
|
||||
return <CertificationsClient initial={certifications ?? []} />
|
||||
}
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
Menu, X, LayoutDashboard, ListChecks, FolderKanban, Settings2, Wrench,
|
||||
MessageSquare, Users, CreditCard, BookUser, Download, LogOut,
|
||||
Vote, ShieldAlert, ScrollText, LineChart, UserCircle, UserSearch,
|
||||
MessagesSquare, SlidersHorizontal, ClipboardList, FileText,
|
||||
MessagesSquare, SlidersHorizontal, ClipboardList, FileText, Award,
|
||||
} from 'lucide-react'
|
||||
|
||||
interface NavLinkProps {
|
||||
@@ -146,6 +146,7 @@ export function AppSidebar({ member, roleName, taskBadge = 0, commsBadge = 0, pa
|
||||
</div>
|
||||
<NavLink href="/customize" label="Customize" icon={SlidersHorizontal} active={isActive('/customize')} onClick={onNav} />
|
||||
<NavLink href="/forms" label="Forms & waivers" icon={ClipboardList} active={isActive('/forms')} onClick={onNav} />
|
||||
<NavLink href="/certifications" label="Certifications" icon={Award} active={isActive('/certifications')} onClick={onNav} />
|
||||
<NavLink href="/import" label="Import / Sync" icon={Download} active={isActive('/import')} onClick={onNav} />
|
||||
<NavLink href="/settings" label="Settings" icon={Settings2} active={isActive('/settings')} onClick={onNav} />
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user