Add top-level members list, primary member on account, member move, combined create flows

- GET /v1/members with search across all members (includes account name)
- POST /members/:id/move with optional accountId (creates new account if omitted)
- primary_member_id on account table, auto-set when first member added
- isMinor flag on member create (manual override when no DOB provided)
- Account search now includes member names
- New account form includes primary contact fields, auto-generates name
- Members page in sidebar with global search
This commit is contained in:
Ryan Moon
2026-03-28 09:08:06 -05:00
parent 7c64a928e1
commit 572af05a3f
16 changed files with 796 additions and 77 deletions

View File

@@ -14,7 +14,7 @@ import {
DropdownMenuSubTrigger,
DropdownMenuSubContent,
} from '@/components/ui/dropdown-menu'
import { Users, Sun, Moon, Monitor, LogOut, User, Palette } from 'lucide-react'
import { Users, UserRound, Sun, Moon, Monitor, LogOut, User, Palette } from 'lucide-react'
export const Route = createFileRoute('/_authenticated')({
beforeLoad: () => {
@@ -61,6 +61,14 @@ function AuthenticatedLayout() {
<Users className="h-4 w-4" />
Accounts
</Link>
<Link
to="/members"
className="flex items-center gap-2 px-3 py-2 rounded-md text-sm text-sidebar-foreground hover:bg-sidebar-accent"
activeProps={{ className: 'flex items-center gap-2 px-3 py-2 rounded-md text-sm bg-sidebar-accent text-sidebar-accent-foreground' }}
>
<UserRound className="h-4 w-4" />
Members
</Link>
</div>
<div className="p-3 border-t border-sidebar-border">

View File

@@ -1,6 +1,7 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { accountMutations, accountKeys } from '@/api/accounts'
import { memberMutations } from '@/api/members'
import { AccountForm } from '@/components/accounts/account-form'
import { toast } from 'sonner'
@@ -13,7 +14,39 @@ function NewAccountPage() {
const queryClient = useQueryClient()
const mutation = useMutation({
mutationFn: accountMutations.create,
mutationFn: async (data: Record<string, unknown>) => {
// Extract member fields
const memberFirstName = data.memberFirstName as string | undefined
const memberLastName = data.memberLastName as string | undefined
const memberEmail = data.memberEmail as string | undefined
const memberPhone = data.memberPhone as string | undefined
const memberDateOfBirth = data.memberDateOfBirth as string | undefined
const memberIsMinor = data.memberIsMinor as boolean | undefined
// Create account (without member fields)
const { memberFirstName: _, memberLastName: __, memberEmail: ___, memberPhone: ____, memberDateOfBirth: _____, memberIsMinor: ______, ...accountData } = data
// Auto-generate account name from member if not provided
if (!accountData.name && memberFirstName && memberLastName) {
accountData.name = `${memberLastName}, ${memberFirstName}`
}
const account = await accountMutations.create(accountData)
// Create first member if name provided
if (memberFirstName && memberLastName) {
await memberMutations.create(account.id, {
firstName: memberFirstName,
lastName: memberLastName,
email: memberEmail || undefined,
phone: memberPhone || undefined,
dateOfBirth: memberDateOfBirth || undefined,
isMinor: memberIsMinor,
})
}
return account
},
onSuccess: (account) => {
queryClient.invalidateQueries({ queryKey: accountKeys.lists() })
toast.success('Account created')
@@ -27,7 +60,7 @@ function NewAccountPage() {
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold">New Account</h1>
<AccountForm onSubmit={mutation.mutate} loading={mutation.isPending} />
<AccountForm onSubmit={mutation.mutate} loading={mutation.isPending} includeFirstMember />
</div>
)
}

View File

@@ -0,0 +1,101 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { useState } from 'react'
import { globalMemberListOptions, type MemberWithAccount } from '@/api/members'
import { usePagination } from '@/hooks/use-pagination'
import { DataTable, type Column } from '@/components/shared/data-table'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Search } from 'lucide-react'
export const Route = createFileRoute('/_authenticated/members')({
validateSearch: (search: Record<string, unknown>) => ({
page: Number(search.page) || 1,
limit: Number(search.limit) || 25,
q: (search.q as string) || undefined,
sort: (search.sort as string) || undefined,
order: (search.order as 'asc' | 'desc') || 'asc',
}),
component: MembersListPage,
})
const memberColumns: Column<MemberWithAccount>[] = [
{
key: 'last_name',
header: 'Name',
sortable: true,
render: (row) => <span className="font-medium">{row.firstName} {row.lastName}</span>,
},
{
key: 'email',
header: 'Email',
sortable: true,
render: (row) => row.email ?? <span className="text-muted-foreground">-</span>,
},
{
key: 'phone',
header: 'Phone',
render: (row) => row.phone ?? <span className="text-muted-foreground">-</span>,
},
{
key: 'accountName',
header: 'Account',
render: (row) => row.accountName ?? <span className="text-muted-foreground">-</span>,
},
{
key: 'status',
header: 'Status',
render: (row) => row.isMinor ? <Badge variant="secondary">Minor</Badge> : <Badge>Adult</Badge>,
},
]
function MembersListPage() {
const navigate = useNavigate()
const { params, setPage, setSearch, setSort } = usePagination()
const [searchInput, setSearchInput] = useState(params.q ?? '')
const { data, isLoading } = useQuery(globalMemberListOptions(params))
function handleSearchSubmit(e: React.FormEvent) {
e.preventDefault()
setSearch(searchInput)
}
function handleRowClick(member: MemberWithAccount) {
navigate({ to: '/accounts/$accountId/members', params: { accountId: member.accountId } })
}
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold">Members</h1>
<form onSubmit={handleSearchSubmit} className="flex gap-2 max-w-sm">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search members..."
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
className="pl-9"
/>
</div>
<Button type="submit" variant="secondary">Search</Button>
</form>
<DataTable
columns={memberColumns}
data={data?.data ?? []}
loading={isLoading}
page={params.page}
totalPages={data?.pagination.totalPages ?? 1}
total={data?.pagination.total ?? 0}
sort={params.sort}
order={params.order}
onPageChange={setPage}
onSort={setSort}
onRowClick={handleRowClick}
/>
</div>
)
}