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

@@ -3,9 +3,15 @@ import { api } from '@/lib/api-client'
import type { Member } from '@/types/account'
import type { PaginatedResponse, PaginationInput } from '@forte/shared/schemas'
interface MemberWithAccount extends Member {
accountName: string | null
}
export const memberKeys = {
all: (accountId: string) => ['accounts', accountId, 'members'] as const,
list: (accountId: string, params: PaginationInput) => [...memberKeys.all(accountId), params] as const,
globalAll: ['members'] as const,
globalList: (params: PaginationInput) => ['members', 'list', params] as const,
}
export function memberListOptions(accountId: string, params: PaginationInput) {
@@ -15,6 +21,13 @@ export function memberListOptions(accountId: string, params: PaginationInput) {
})
}
export function globalMemberListOptions(params: PaginationInput) {
return queryOptions({
queryKey: memberKeys.globalList(params),
queryFn: () => api.get<PaginatedResponse<MemberWithAccount>>('/v1/members', params),
})
}
export const memberMutations = {
create: (accountId: string, data: Record<string, unknown>) =>
api.post<Member>(`/v1/accounts/${accountId}/members`, data),
@@ -24,4 +37,9 @@ export const memberMutations = {
delete: (id: string) =>
api.del<Member>(`/v1/members/${id}`),
move: (id: string, accountId?: string) =>
api.post<Member>(`/v1/members/${id}/move`, accountId ? { accountId } : {}),
}
export type { MemberWithAccount }

View File

@@ -2,31 +2,26 @@
@variant dark (&:is(.dark, .dark *));
@theme {
/* Defaults — overridden at runtime by theme store */
--color-background: hsl(210 20% 97%);
--color-foreground: hsl(222 47% 11%);
--color-card: hsl(0 0% 100%);
--color-card-foreground: hsl(222 47% 11%);
--color-popover: hsl(0 0% 100%);
--color-popover-foreground: hsl(222 47% 11%);
--color-primary: hsl(215 25% 27%);
--color-primary-foreground: hsl(210 40% 98%);
--color-secondary: hsl(210 40% 94%);
--color-secondary-foreground: hsl(222 47% 11%);
--color-muted: hsl(210 40% 94%);
--color-muted-foreground: hsl(215 16% 47%);
--color-accent: hsl(210 40% 94%);
--color-accent-foreground: hsl(222 47% 11%);
--color-destructive: hsl(0 72% 51%);
--color-destructive-foreground: hsl(210 40% 98%);
--color-border: hsl(214 32% 89%);
--color-input: hsl(214 32% 89%);
--color-ring: hsl(215 25% 27%);
--radius: 0.5rem;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
@@ -35,9 +30,29 @@
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--radius: 0.5rem;
}
:root {
--background: hsl(210 20% 97%);
--foreground: hsl(222 47% 11%);
--card: hsl(0 0% 100%);
--card-foreground: hsl(222 47% 11%);
--popover: hsl(0 0% 100%);
--popover-foreground: hsl(222 47% 11%);
--primary: hsl(215 25% 27%);
--primary-foreground: hsl(210 40% 98%);
--secondary: hsl(210 40% 94%);
--secondary-foreground: hsl(222 47% 11%);
--muted: hsl(210 40% 94%);
--muted-foreground: hsl(215 16% 47%);
--accent: hsl(210 40% 94%);
--accent-foreground: hsl(222 47% 11%);
--destructive: hsl(0 72% 51%);
--destructive-foreground: hsl(210 40% 98%);
--border: hsl(214 32% 89%);
--input: hsl(214 32% 89%);
--ring: hsl(215 25% 27%);
--sidebar: hsl(210 25% 95%);
--sidebar-foreground: hsl(215 16% 47%);
--sidebar-primary: hsl(215 25% 27%);
@@ -55,6 +70,8 @@ body {
system-ui,
-apple-system,
sans-serif;
background-color: var(--background);
color: var(--foreground);
}
.login-input {

View File

@@ -1,29 +1,33 @@
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { AccountCreateSchema } from '@forte/shared/schemas'
import type { AccountCreateInput } from '@forte/shared/schemas'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Separator } from '@/components/ui/separator'
import type { Account } from '@/types/account'
interface AccountFormProps {
defaultValues?: Partial<Account>
onSubmit: (data: AccountCreateInput) => void
onSubmit: (data: Record<string, unknown>) => void
loading?: boolean
includeFirstMember?: boolean
}
export function AccountForm({ defaultValues, onSubmit, loading }: AccountFormProps) {
export function AccountForm({ defaultValues, onSubmit, loading, includeFirstMember }: AccountFormProps) {
const optionalNameSchema = AccountCreateSchema.extend({ name: AccountCreateSchema.shape.name.optional() })
const schema = includeFirstMember ? optionalNameSchema : AccountCreateSchema
const {
register,
handleSubmit,
setValue,
watch,
formState: { errors },
} = useForm<AccountCreateInput>({
resolver: zodResolver(AccountCreateSchema),
} = useForm({
resolver: zodResolver(schema),
defaultValues: {
name: defaultValues?.name ?? '',
email: defaultValues?.email ?? undefined,
@@ -36,12 +40,31 @@ export function AccountForm({ defaultValues, onSubmit, loading }: AccountFormPro
const billingMode = watch('billingMode')
function handleFormSubmit(accountData: Record<string, unknown>) {
if (includeFirstMember) {
// Grab member fields from the native form
const form = document.getElementById('account-form') as HTMLFormElement
const formData = new FormData(form)
onSubmit({
...accountData,
memberFirstName: formData.get('memberFirstName') || undefined,
memberLastName: formData.get('memberLastName') || undefined,
memberEmail: formData.get('memberEmail') || undefined,
memberPhone: formData.get('memberPhone') || undefined,
memberDateOfBirth: formData.get('memberDateOfBirth') || undefined,
memberIsMinor: formData.get('memberIsMinor') === 'on' || undefined,
})
} else {
onSubmit(accountData)
}
}
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4 max-w-lg">
<form id="account-form" onSubmit={handleSubmit(handleFormSubmit)} className="space-y-4 max-w-lg">
<div className="space-y-2">
<Label htmlFor="name">Name *</Label>
<Input id="name" {...register('name')} />
{errors.name && <p className="text-sm text-destructive">{errors.name.message}</p>}
<Label htmlFor="name">Account Name {includeFirstMember ? '' : '*'}</Label>
<Input id="name" {...register('name')} placeholder={includeFirstMember ? 'Auto-generated from member name if blank' : 'e.g. Smith Family, Lincoln Elementary'} />
{errors.name && !includeFirstMember && <p className="text-sm text-destructive">{String(errors.name.message)}</p>}
</div>
<div className="grid grid-cols-2 gap-4">
@@ -88,6 +111,41 @@ export function AccountForm({ defaultValues, onSubmit, loading }: AccountFormPro
<Textarea id="notes" {...register('notes')} />
</div>
{includeFirstMember && (
<>
<Separator />
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">Primary Contact</h3>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="memberFirstName">First Name</Label>
<Input id="memberFirstName" name="memberFirstName" />
</div>
<div className="space-y-2">
<Label htmlFor="memberLastName">Last Name</Label>
<Input id="memberLastName" name="memberLastName" />
</div>
</div>
<div className="space-y-2">
<Label htmlFor="memberEmail">Email</Label>
<Input id="memberEmail" name="memberEmail" type="email" />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="memberDateOfBirth">Date of Birth</Label>
<Input id="memberDateOfBirth" name="memberDateOfBirth" type="date" />
</div>
<div className="space-y-2">
<Label htmlFor="memberPhone">Phone</Label>
<Input id="memberPhone" name="memberPhone" />
</div>
</div>
<div className="flex items-center gap-2">
<input type="checkbox" id="memberIsMinor" name="memberIsMinor" className="rounded" />
<Label htmlFor="memberIsMinor" className="font-normal">This person is a minor</Label>
</div>
</>
)}
<Button type="submit" disabled={loading}>
{loading ? 'Saving...' : defaultValues ? 'Update Account' : 'Create Account'}
</Button>

View File

@@ -19,6 +19,7 @@ export function MemberForm({ accountId, defaultValues, onSubmit, loading }: Memb
const {
register,
handleSubmit,
watch,
formState: { errors },
} = useForm<MemberCreateInput>({
resolver: zodResolver(MemberCreateSchema),
@@ -27,12 +28,15 @@ export function MemberForm({ accountId, defaultValues, onSubmit, loading }: Memb
firstName: defaultValues?.firstName ?? '',
lastName: defaultValues?.lastName ?? '',
dateOfBirth: defaultValues?.dateOfBirth ?? undefined,
isMinor: defaultValues?.isMinor ?? undefined,
email: defaultValues?.email ?? undefined,
phone: defaultValues?.phone ?? undefined,
notes: defaultValues?.notes ?? undefined,
},
})
const dateOfBirth = watch('dateOfBirth')
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<input type="hidden" {...register('accountId')} />
@@ -48,20 +52,26 @@ export function MemberForm({ accountId, defaultValues, onSubmit, loading }: Memb
{errors.lastName && <p className="text-sm text-destructive">{errors.lastName.message}</p>}
</div>
</div>
<div className="space-y-2">
<Label htmlFor="memberEmail">Email</Label>
<Input id="memberEmail" type="email" {...register('email')} />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="dateOfBirth">Date of Birth</Label>
<Input id="dateOfBirth" type="date" {...register('dateOfBirth')} />
</div>
<div className="space-y-2">
<Label htmlFor="memberEmail">Email</Label>
<Input id="memberEmail" type="email" {...register('email')} />
<Label htmlFor="memberPhone">Phone</Label>
<Input id="memberPhone" {...register('phone')} />
</div>
</div>
<div className="space-y-2">
<Label htmlFor="memberPhone">Phone</Label>
<Input id="memberPhone" {...register('phone')} />
</div>
{!dateOfBirth && (
<div className="flex items-center gap-2">
<input type="checkbox" id="isMinor" {...register('isMinor')} className="rounded" />
<Label htmlFor="isMinor" className="font-normal">This person is a minor (under 18)</Label>
</div>
)}
<div className="space-y-2">
<Label htmlFor="memberNotes">Notes</Label>
<Textarea id="memberNotes" {...register('notes')} />

View File

@@ -337,40 +337,20 @@ export const themes: ThemePreset[] = [
},
]
const CSS_VAR_MAP: Record<keyof ThemeColors, string> = {
background: '--color-background',
foreground: '--color-foreground',
card: '--color-card',
cardForeground: '--color-card-foreground',
popover: '--color-popover',
popoverForeground: '--color-popover-foreground',
primary: '--color-primary',
primaryForeground: '--color-primary-foreground',
secondary: '--color-secondary',
secondaryForeground: '--color-secondary-foreground',
muted: '--color-muted',
mutedForeground: '--color-muted-foreground',
accent: '--color-accent',
accentForeground: '--color-accent-foreground',
destructive: '--color-destructive',
destructiveForeground: '--color-destructive-foreground',
border: '--color-border',
input: '--color-input',
ring: '--color-ring',
sidebar: '--sidebar',
sidebarForeground: '--sidebar-foreground',
sidebarPrimary: '--sidebar-primary',
sidebarPrimaryForeground: '--sidebar-primary-foreground',
sidebarAccent: '--sidebar-accent',
sidebarAccentForeground: '--sidebar-accent-foreground',
sidebarBorder: '--sidebar-border',
function camelToKebab(str: string): string {
return str.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase())
}
export function applyThemeColors(colors: ThemeColors) {
const root = document.documentElement
for (const [key, cssVar] of Object.entries(CSS_VAR_MAP)) {
const value = colors[key as keyof ThemeColors]
root.style.setProperty(cssVar, `hsl(${value})`)
for (const [key, value] of Object.entries(colors)) {
const prop = `--${camelToKebab(key)}`
const hsl = `hsl(${value})`
root.style.setProperty(prop, hsl)
}
// Also set on body for portaled components (dialogs, dropdowns, popovers)
for (const [key, value] of Object.entries(colors)) {
document.body.style.setProperty(`--${camelToKebab(key)}`, `hsl(${value})`)
}
}

View File

@@ -12,6 +12,7 @@ import { Route as rootRouteImport } from './routes/__root'
import { Route as LoginRouteImport } from './routes/login'
import { Route as AuthenticatedRouteImport } from './routes/_authenticated'
import { Route as AuthenticatedIndexRouteImport } from './routes/_authenticated/index'
import { Route as AuthenticatedMembersRouteImport } from './routes/_authenticated/members'
import { Route as AuthenticatedAccountsIndexRouteImport } from './routes/_authenticated/accounts/index'
import { Route as AuthenticatedAccountsNewRouteImport } from './routes/_authenticated/accounts/new'
import { Route as AuthenticatedAccountsAccountIdRouteImport } from './routes/_authenticated/accounts/$accountId'
@@ -35,6 +36,11 @@ const AuthenticatedIndexRoute = AuthenticatedIndexRouteImport.update({
path: '/',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthenticatedMembersRoute = AuthenticatedMembersRouteImport.update({
id: '/members',
path: '/members',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthenticatedAccountsIndexRoute =
AuthenticatedAccountsIndexRouteImport.update({
id: '/accounts/',
@@ -87,6 +93,7 @@ const AuthenticatedAccountsAccountIdMembersRoute =
export interface FileRoutesByFullPath {
'/': typeof AuthenticatedIndexRoute
'/login': typeof LoginRoute
'/members': typeof AuthenticatedMembersRoute
'/accounts/$accountId': typeof AuthenticatedAccountsAccountIdRouteWithChildren
'/accounts/new': typeof AuthenticatedAccountsNewRoute
'/accounts/': typeof AuthenticatedAccountsIndexRoute
@@ -98,6 +105,7 @@ export interface FileRoutesByFullPath {
}
export interface FileRoutesByTo {
'/login': typeof LoginRoute
'/members': typeof AuthenticatedMembersRoute
'/': typeof AuthenticatedIndexRoute
'/accounts/new': typeof AuthenticatedAccountsNewRoute
'/accounts': typeof AuthenticatedAccountsIndexRoute
@@ -111,6 +119,7 @@ export interface FileRoutesById {
__root__: typeof rootRouteImport
'/_authenticated': typeof AuthenticatedRouteWithChildren
'/login': typeof LoginRoute
'/_authenticated/members': typeof AuthenticatedMembersRoute
'/_authenticated/': typeof AuthenticatedIndexRoute
'/_authenticated/accounts/$accountId': typeof AuthenticatedAccountsAccountIdRouteWithChildren
'/_authenticated/accounts/new': typeof AuthenticatedAccountsNewRoute
@@ -126,6 +135,7 @@ export interface FileRouteTypes {
fullPaths:
| '/'
| '/login'
| '/members'
| '/accounts/$accountId'
| '/accounts/new'
| '/accounts/'
@@ -137,6 +147,7 @@ export interface FileRouteTypes {
fileRoutesByTo: FileRoutesByTo
to:
| '/login'
| '/members'
| '/'
| '/accounts/new'
| '/accounts'
@@ -149,6 +160,7 @@ export interface FileRouteTypes {
| '__root__'
| '/_authenticated'
| '/login'
| '/_authenticated/members'
| '/_authenticated/'
| '/_authenticated/accounts/$accountId'
| '/_authenticated/accounts/new'
@@ -188,6 +200,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthenticatedIndexRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_authenticated/members': {
id: '/_authenticated/members'
path: '/members'
fullPath: '/members'
preLoaderRoute: typeof AuthenticatedMembersRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_authenticated/accounts/': {
id: '/_authenticated/accounts/'
path: '/accounts'
@@ -275,6 +294,7 @@ const AuthenticatedAccountsAccountIdRouteWithChildren =
)
interface AuthenticatedRouteChildren {
AuthenticatedMembersRoute: typeof AuthenticatedMembersRoute
AuthenticatedIndexRoute: typeof AuthenticatedIndexRoute
AuthenticatedAccountsAccountIdRoute: typeof AuthenticatedAccountsAccountIdRouteWithChildren
AuthenticatedAccountsNewRoute: typeof AuthenticatedAccountsNewRoute
@@ -282,6 +302,7 @@ interface AuthenticatedRouteChildren {
}
const AuthenticatedRouteChildren: AuthenticatedRouteChildren = {
AuthenticatedMembersRoute: AuthenticatedMembersRoute,
AuthenticatedIndexRoute: AuthenticatedIndexRoute,
AuthenticatedAccountsAccountIdRoute:
AuthenticatedAccountsAccountIdRouteWithChildren,

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>
)
}