Files
hackerspace.sh/app/(app)/members/members-client.tsx
T
Moheeb ZaraandClaude Opus 4.8 9100532bef refactor(members): decompose members-client + fix form-field focus bug (slice 8)
663-line members-client.tsx → a 255-line orchestrator + members/components/
{member-form-dialog,members-table,members-toolbar}.tsx + types.ts. The add/edit
form fields (previously defined INSIDE render, which remounted and lost input
focus every keystroke) are now a stable top-level component in member-form-dialog
— fixing that footgun. tsc 0, build ok.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-04 06:14:25 -07:00

256 lines
9.2 KiB
TypeScript

'use client'
import { useState } from 'react'
import { Plus } from 'lucide-react'
import { addMember, updateMember, approveMember, bulkApproveMembers, removeMember, assignAreaLead } from '@/lib/actions'
import { toast } from 'sonner'
import { useRouter } from 'next/navigation'
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'
import { MembersTabs, MembersToolbar } from './components/members-toolbar'
import { MembersTable } from './components/members-table'
import { MemberFormDialog } from './components/member-form-dialog'
import type { Member, MemberForm, MemberTab, MembersClientProps } from './types'
const isAdmin = (role: string) => role === 'admin' || role === 'board'
export function MembersClient({ members: initialMembers, currentRole, areaLeadRoles = [], inviteSlot, canGrantCerts = false, canManageCards = false, canViewForms = false }: MembersClientProps) {
const router = useRouter()
const confirm = useConfirm()
const admin = isAdmin(currentRole)
const [members, setMembers] = useState<Member[]>(initialMembers)
const [certMember, setCertMember] = useState<Member | null>(null)
const [cardMember, setCardMember] = useState<Member | null>(null)
const [formsMember, setFormsMember] = useState<Member | null>(null)
const [activeTab, setActiveTab] = useState<MemberTab>('all')
const [search, setSearch] = useState('')
const [tierFilter, setTierFilter] = useState('')
const [selected, setSelected] = useState<Set<string>>(new Set())
const [showAdd, setShowAdd] = useState(false)
const [editMember, setEditMember] = useState<Member | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
// Add/Edit form
const [form, setForm] = useState<MemberForm>({
display_name: '', email: '', phone: '', handle: '',
tier: 'basic', role: 'member', joined_at: '', has_card_access: false,
})
const toggleSel = (id: string) =>
setSelected(prev => {
const n = new Set(prev)
n.has(id) ? n.delete(id) : n.add(id)
return n
})
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()
}
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 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'
// display_name and email are both nullable (e.g. imported members), so
// guard before .toLowerCase() — an unguarded access crashed the whole list
// the moment an admin typed into the search box.
const q = search.toLowerCase()
const matchesSearch = !search ||
(m.display_name?.toLowerCase().includes(q) ?? false) ||
(m.email?.toLowerCase().includes(q) ?? false) ||
(m.handle?.toLowerCase().includes(q) ?? false)
const matchesTier = !tierFilter || m.tier === tierFilter
return matchesTab && matchesSearch && matchesTier
})
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 } as Member : 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('')
}
return (
<div className="min-h-screen bg-background">
<div className="bg-sidebar px-4 md:px-6 py-3 flex items-center justify-between">
<div className="flex items-center gap-3">
<PageTitle>Members</PageTitle>
<span className="font-mono text-xs text-white/50">{total} total</span>
</div>
{admin && (
<button
onClick={() => setShowAdd(true)}
className="flex items-center gap-1.5 bg-primary text-white text-xs font-sans px-3 py-1.5 rounded hover:bg-primary/90 transition"
>
<Plus className="w-3.5 h-3.5" /> Add Member
</button>
)}
</div>
<MembersTabs
activeTab={activeTab}
setActiveTab={setActiveTab}
total={total}
paymentIssues={paymentIssues}
unverified={unverified}
/>
<div className="p-4 md:p-6">
{inviteSlot && <div className="mb-6">{inviteSlot}</div>}
<MembersToolbar
search={search}
setSearch={setSearch}
tierFilter={tierFilter}
setTierFilter={setTierFilter}
canBulk={admin}
selectedCount={selected.size}
onBulkApprove={bulkApprove}
onClearSelection={() => setSelected(new Set())}
/>
<MembersTable
members={filteredMembers}
isAdmin={admin}
canGrantCerts={canGrantCerts}
canManageCards={canManageCards}
canViewForms={canViewForms}
areaLeadRoles={areaLeadRoles}
selected={selected}
setSelected={setSelected}
toggleSel={toggleSel}
onApprove={handleApprove}
onRemove={handleRemove}
onEdit={openEdit}
onCerts={setCertMember}
onCards={setCardMember}
onForms={setFormsMember}
onMakeAreaLead={makeAreaLead}
/>
</div>
{/* Add Member Modal */}
<MemberFormDialog
open={showAdd}
isEdit={false}
form={form}
setForm={setForm}
loading={loading}
error={error}
onSubmit={handleAdd}
onClose={() => setShowAdd(false)}
/>
{/* Edit Member Modal */}
<MemberFormDialog
open={!!editMember}
isEdit
memberName={editMember?.display_name}
form={form}
setForm={setForm}
loading={loading}
error={error}
onSubmit={handleEdit}
onClose={() => setEditMember(null)}
/>
{canGrantCerts && (
<MemberCertificationsDialog
member={certMember ? { id: certMember.id, display_name: certMember.display_name } : null}
onClose={() => setCertMember(null)}
/>
)}
{canManageCards && (
<MemberCardsDialog
member={cardMember ? { id: cardMember.id, display_name: cardMember.display_name } : null}
onClose={() => setCardMember(null)}
/>
)}
{canViewForms && (
<MemberFormsDialog
member={formsMember ? { id: formsMember.id, display_name: formsMember.display_name } : null}
onClose={() => setFormsMember(null)}
/>
)}
</div>
)
}