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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
101
packages/admin/src/routes/_authenticated/members.tsx
Normal file
101
packages/admin/src/routes/_authenticated/members.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user