'use client' import { useState, type ReactNode } from 'react' import { Plus, ChevronDown, Users } from 'lucide-react' import { Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription } from '@/components/ui/empty' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { addMember, updateMember, approveMember, bulkApproveMembers, removeMember, assignAreaLead } from '@/lib/actions' import { toast } from 'sonner' import { useRouter } from 'next/navigation' import type { Tables } from '@/types/database' import { PageTitle } from '@/components/ui/page-title' import { useConfirm } from '@/components/ui/confirm' import { MemberCertificationsDialog } from '@/components/certifications/member-certifications-dialog' import { MemberCardsDialog } from '@/components/door/member-cards-dialog' import { MemberFormsDialog } from '@/components/forms/member-forms-dialog' type Member = Tables<'space_members'> type SortKey = 'name' | 'tier' | 'joined' | 'last_payment' | 'status' type AreaLeadRole = { id: string; area_name: string; lead_id: string | null } interface Props { members: Member[] currentRole: string areaLeadRoles?: AreaLeadRole[] // Composed by the server page for admin/board: the existing InvitesPanel, // so the members page is a real place to create and share a join link // (the first-run "Invite or add members" step lands here). inviteSlot?: ReactNode // True when the viewer holds certifications.grant (the Instructor // capability), independent of admin/board. Adds a per-member // certifications panel reachable by non-admin instructors. canGrantCerts?: boolean // True when the viewer holds door.manage. Adds a per-member access-cards // panel (card UID is a credential; managers only). canManageCards?: boolean // True when the viewer holds forms.manage. Adds a per-member panel // listing the forms that member has submitted. canViewForms?: boolean } const TIER_COLORS: Record = { plus: 'text-blue-600 bg-blue-50 border-blue-200', basic: 'text-muted-foreground bg-muted border-border', associate: 'text-purple-600 bg-purple-50 border-purple-200', admin: 'text-primary bg-primary/5 border-primary/20', } const isAdmin = (role: string) => role === 'admin' || role === 'board' export function MembersClient({ members: initialMembers, currentRole, areaLeadRoles = [], inviteSlot, canGrantCerts = false, canManageCards = false, canViewForms = false }: Props) { const router = useRouter() const [certMember, setCertMember] = useState(null) const [cardMember, setCardMember] = useState(null) const [formsMember, setFormsMember] = useState(null) const confirm = useConfirm() const [members, setMembers] = useState(initialMembers) async function makeAreaLead(memberId: string, areaLeadRoleId: string) { if (!areaLeadRoleId) return const result = await assignAreaLead({ area_lead_role_id: areaLeadRoleId, member_id: memberId }) if (result.error) { toast.error(result.error); return } toast.success('Assigned as area lead') router.refresh() } const [activeTab, setActiveTab] = useState<'all' | 'payment_issues' | 'unverified' | 'inactive'>('all') const [search, setSearch] = useState('') const [tierFilter, setTierFilter] = useState('') const [sort, setSort] = useState<{ key: SortKey; dir: 'asc' | 'desc' } | null>(null) const [selected, setSelected] = useState>(new Set()) const canBulk = isAdmin(currentRole) const toggleSel = (id: string) => setSelected(prev => { const n = new Set(prev) n.has(id) ? n.delete(id) : n.add(id) return n }) async function bulkApprove() { const ids = [...selected] if (ids.length === 0) return const res = await bulkApproveMembers(ids) if ('error' in res && res.error) { toast.error(res.error); return } const n = (res as { data: { approved: number } }).data.approved setMembers(prev => prev.map(m => (selected.has(m.id) && m.status === 'unverified' ? { ...m, status: 'current', approved: true } : m))) setSelected(new Set()) toast.success(n > 0 ? `Approved ${n} member(s)` : 'No unverified members in the selection') } const [showAdd, setShowAdd] = useState(false) const [editMember, setEditMember] = useState(null) const [loading, setLoading] = useState(false) const [error, setError] = useState('') // Add/Edit form const [form, setForm] = useState({ display_name: '', email: '', phone: '', handle: '', tier: 'basic', role: 'member', joined_at: '', has_card_access: false, }) const total = members.length const paymentIssues = members.filter(m => m.status === 'current' && m.payment_status && m.payment_status !== 'current').length const unverified = members.filter(m => m.status === 'unverified').length const filteredMembers = members.filter(m => { const matchesTab = activeTab === 'all' ? (m.status !== 'inactive') : activeTab === 'payment_issues' ? (m.payment_status && m.payment_status !== 'current') : activeTab === 'unverified' ? m.status === 'unverified' : m.status === 'inactive' const matchesSearch = !search || m.display_name.toLowerCase().includes(search.toLowerCase()) || m.email.toLowerCase().includes(search.toLowerCase()) || (m.handle?.toLowerCase().includes(search.toLowerCase()) ?? false) const matchesTier = !tierFilter || m.tier === tierFilter return matchesTab && matchesSearch && matchesTier }) const sortedMembers = sort ? [...filteredMembers].sort((a, b) => { const dir = sort.dir === 'asc' ? 1 : -1 const val = (m: Member): string => { switch (sort.key) { case 'name': return (m.display_name ?? '').toLowerCase() case 'tier': return m.tier ?? '' case 'status': return m.status ?? '' case 'joined': return m.joined_at ?? '' case 'last_payment': return (m.last_paid_at || m.last_payment_at || '') as string default: return '' } } return val(a).localeCompare(val(b)) * dir }) : filteredMembers function toggleSort(key: SortKey) { setSort(s => s && s.key === key ? (s.dir === 'asc' ? { key, dir: 'desc' } : null) : { key, dir: 'asc' }, ) } const caret = (key: SortKey) => (sort?.key === key ? (sort.dir === 'asc' ? ' ↑' : ' ↓') : '') async function handleAdd(e: React.FormEvent) { e.preventDefault() setLoading(true) setError('') const result = await addMember(form) if (result.error) { setError(result.error); setLoading(false); return } if (result.data) setMembers(prev => [...prev, result.data as Member]) setShowAdd(false) setForm({ display_name: '', email: '', phone: '', handle: '', tier: 'basic', role: 'member', joined_at: '', has_card_access: false }) setLoading(false) } async function handleEdit(e: React.FormEvent) { e.preventDefault() if (!editMember) return setLoading(true) setError('') const result = await updateMember(editMember.id, form) if (result.error) { setError(result.error); setLoading(false); return } setMembers(prev => prev.map(m => m.id === editMember.id ? { ...m, ...form } : m)) setEditMember(null) setLoading(false) } async function handleApprove(memberId: string) { const result = await approveMember(memberId) if (result?.error) { toast.error(result.error); return } setMembers(prev => prev.map(m => m.id === memberId ? { ...m, status: 'current', approved: true } : m)) } async function handleRemove(memberId: string) { if (!(await confirm({ title: 'Remove member', description: 'This member will be removed from the space.', confirmText: 'Remove', destructive: true }))) return const result = await removeMember(memberId) if (result?.error) { toast.error(result.error); return } setMembers(prev => prev.filter(m => m.id !== memberId)) } function openEdit(m: Member) { setEditMember(m) setForm({ display_name: m.display_name, email: m.email, phone: m.phone ?? '', handle: m.handle ?? '', tier: m.tier, role: m.role, joined_at: m.joined_at ?? '', has_card_access: m.has_card_access ?? false, }) setError('') } const MemberFormFields = () => ( <>
setForm(f => ({ ...f, display_name: e.target.value }))} className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary transition" />
setForm(f => ({ ...f, email: e.target.value }))} className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary transition" />
setForm(f => ({ ...f, phone: e.target.value }))} className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary transition" />
setForm(f => ({ ...f, handle: e.target.value }))} className="w-full bg-background border border-border rounded px-3 py-2 font-mono text-sm text-foreground focus:outline-none focus:border-primary transition" />
setForm(f => ({ ...f, joined_at: e.target.value }))} className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary" />
) return (
Members {total} total
{isAdmin(currentRole) && ( )}
{[ { key: 'all', label: `All ${total}` }, { key: 'payment_issues', label: `Payment Issues ${paymentIssues}` }, { key: 'unverified', label: `Pending Approval ${unverified}` }, { key: 'inactive', label: 'Inactive' }, ].map(({ key, label }) => ( ))}
{inviteSlot &&
{inviteSlot}
}
setSearch(e.target.value)} className="w-full bg-card border border-border rounded pl-9 pr-4 py-2 font-sans text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-primary transition" />
{canBulk && selected.size > 0 && (
{selected.size} selected
)}
{canBulk && ( )} {([ ['name', 'MEMBER', ''], ['tier', 'TIER', ''], ['joined', 'JOINED', 'hidden md:table-cell'], ['last_payment', 'LAST PAYMENT', 'hidden lg:table-cell'], ['status', 'STATUS', ''], ] as [SortKey, string, string][]).map(([key, label, cls]) => ( ))} {canGrantCerts && ( )} {canManageCards && ( )} {canViewForms && ( )} {isAdmin(currentRole) && ( )} {sortedMembers.length > 0 ? sortedMembers.map(m => { const initials = (m.display_name || 'U').split(' ').map((n: string) => n[0]).join('').toUpperCase().slice(0, 2) const hasIssue = m.payment_status && m.payment_status !== 'current' return ( {canBulk && ( )} {canGrantCerts && ( )} {canManageCards && ( )} {canViewForms && ( )} {isAdmin(currentRole) && ( )} ) }) : ( )}
0 && sortedMembers.every(m => selected.has(m.id))} onChange={e => setSelected(e.target.checked ? new Set(sortedMembers.map(m => m.id)) : new Set()) } /> CERTSCARDSFORMSACTIONS
toggleSel(m.id)} />
{initials}

{m.display_name}

{m.email}{m.handle ? ` · @${m.handle}` : ''}{m.payment_note ? ` — ${m.payment_note}` : ''}

{m.tier?.toUpperCase()} {m.joined_at ? new Date(m.joined_at).toLocaleDateString('en-US', { month: 'short', year: 'numeric' }) : '-'} {(m.last_paid_at || m.last_payment_at) ? new Date((m.last_paid_at || m.last_payment_at)!).toLocaleDateString('en-US', { month: 'short', year: 'numeric' }) : '-'} {m.status === 'unverified' ? ( unverified ) : m.payment_status === 'current' ? ( current ) : m.payment_status ? ( {m.payment_status} ) : ( {m.status} )}
{m.status === 'unverified' && ( )} {areaLeadRoles.length > 0 && ( )}
No members match this filter Try a different tab, clear the search, or change the tier filter.
{/* Mobile: stacked cards (the table is unusable on phones). */}
{sortedMembers.length > 0 ? sortedMembers.map(m => { const hasIssue = m.payment_status && m.payment_status !== 'current' return (
{canBulk && ( toggleSel(m.id)} /> )}

{m.display_name}

{m.email}

{m.tier?.toUpperCase()}

{m.status === 'unverified' ? 'unverified' : (m.payment_status || m.status)} {m.joined_at ? ` · joined ${new Date(m.joined_at).toLocaleDateString('en-US', { month: 'short', year: 'numeric' })}` : ''}

{isAdmin(currentRole) && m.status === 'unverified' && ( )} {isAdmin(currentRole) && ( )} {canGrantCerts && ( )} {canManageCards && ( )} {canViewForms && ( )} {isAdmin(currentRole) && ( )}
) }) : ( No members match this filter Try a different tab, clear the search, or change the tier filter. )}
{/* Add Member Modal */} { if (!o) setShowAdd(false) }}> Add Member
{error &&

{error}

}
{/* Edit Member Modal */} { if (!o) setEditMember(null) }}> Edit {editMember?.display_name}
{error &&

{error}

}
{canGrantCerts && ( setCertMember(null)} /> )} {canManageCards && ( setCardMember(null)} /> )} {canViewForms && ( setFormsMember(null)} /> )}
) }