From 4feecdaad0b68a7dda94727b8c4405b5e998dfc7 Mon Sep 17 00:00:00 2001 From: Moheeb Zara Date: Sun, 17 May 2026 01:22:03 -0700 Subject: [PATCH] feat(ux): sortable members table (name/tier/joined/last payment/status) Clickable column headers cycle asc -> desc -> none with a caret indicator; pure client state over the already-filtered list. Lets a 100+-member space sort by last payment or join date (the core dues/ onboarding workflows). --- app/(app)/members/members-client.tsx | 53 ++++++++++++++++++++++++---- 1 file changed, 47 insertions(+), 6 deletions(-) diff --git a/app/(app)/members/members-client.tsx b/app/(app)/members/members-client.tsx index ed7feaf..aeecf5c 100644 --- a/app/(app)/members/members-client.tsx +++ b/app/(app)/members/members-client.tsx @@ -15,6 +15,7 @@ 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 { @@ -64,6 +65,7 @@ export function MembersClient({ members: initialMembers, currentRole, areaLeadRo 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 [showAdd, setShowAdd] = useState(false) const [editMember, setEditMember] = useState(null) const [loading, setLoading] = useState(false) @@ -96,6 +98,32 @@ export function MembersClient({ members: initialMembers, currentRole, areaLeadRo 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) @@ -282,11 +310,24 @@ export function MembersClient({ members: initialMembers, currentRole, areaLeadRo - - - - - + {([ + ['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 && ( )} @@ -302,7 +343,7 @@ export function MembersClient({ members: initialMembers, currentRole, areaLeadRo - {filteredMembers.length > 0 ? filteredMembers.map(m => { + {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 (
MEMBERTIERJOINEDLAST PAYMENTSTATUS + + CERTS