Add member identifiers UI, member detail page, kebab menus

- Member detail page at /members/:id with edit form and identity documents
- Expandable identity documents on account members tab
- Kebab menu on both members list and account members tab (Edit, View IDs, View Account, Delete)
- Identifier form with image upload (base64), ID type select, dates
- Wiki article for identity documents
This commit is contained in:
Ryan Moon
2026-03-28 13:22:44 -05:00
parent c7e2c141ec
commit 95bf9472e0
8 changed files with 698 additions and 90 deletions

View File

@@ -0,0 +1,25 @@
import { queryOptions } from '@tanstack/react-query'
import { api } from '@/lib/api-client'
import type { MemberIdentifier } from '@/types/account'
export const identifierKeys = {
all: (memberId: string) => ['members', memberId, 'identifiers'] as const,
}
export function identifierListOptions(memberId: string) {
return queryOptions({
queryKey: identifierKeys.all(memberId),
queryFn: () => api.get<{ data: MemberIdentifier[] }>(`/v1/members/${memberId}/identifiers`),
})
}
export const identifierMutations = {
create: (memberId: string, data: Record<string, unknown>) =>
api.post<MemberIdentifier>(`/v1/members/${memberId}/identifiers`, data),
update: (id: string, data: Record<string, unknown>) =>
api.patch<MemberIdentifier>(`/v1/identifiers/${id}`, data),
delete: (id: string) =>
api.del<MemberIdentifier>(`/v1/identifiers/${id}`),
}

View File

@@ -0,0 +1,185 @@
import { useForm } from 'react-hook-form'
import { useRef } from 'react'
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 type { MemberIdentifier } from '@/types/account'
import { Upload } from 'lucide-react'
const ID_TYPES = [
{ value: 'drivers_license', label: "Driver's License / State ID" },
{ value: 'passport', label: 'Passport' },
{ value: 'school_id', label: 'School ID' },
]
interface IdentifierFormProps {
memberId: string
defaultValues?: Partial<MemberIdentifier>
onSubmit: (data: Record<string, unknown>) => void
loading?: boolean
}
function fileToBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result as string)
reader.onerror = reject
reader.readAsDataURL(file)
})
}
export function IdentifierForm({ memberId, defaultValues, onSubmit, loading }: IdentifierFormProps) {
const { register, handleSubmit, setValue, watch } = useForm({
defaultValues: {
type: defaultValues?.type ?? 'drivers_license',
value: defaultValues?.value ?? '',
label: defaultValues?.label ?? '',
issuingAuthority: defaultValues?.issuingAuthority ?? '',
issuedDate: defaultValues?.issuedDate ?? '',
expiresAt: defaultValues?.expiresAt ?? '',
notes: defaultValues?.notes ?? '',
isPrimary: defaultValues?.isPrimary ?? false,
imageFront: defaultValues?.imageFront ?? '',
imageBack: defaultValues?.imageBack ?? '',
},
})
const frontInputRef = useRef<HTMLInputElement>(null)
const backInputRef = useRef<HTMLInputElement>(null)
const imageFront = watch('imageFront')
const imageBack = watch('imageBack')
const idType = watch('type')
async function handleFileSelect(field: 'imageFront' | 'imageBack', file: File) {
const base64 = await fileToBase64(file)
setValue(field, base64)
}
function handleFormSubmit(data: Record<string, unknown>) {
const cleaned: Record<string, unknown> = { memberId }
for (const [key, value] of Object.entries(data)) {
cleaned[key] = value === '' ? undefined : value
}
onSubmit(cleaned)
}
return (
<form onSubmit={handleSubmit(handleFormSubmit)} noValidate className="space-y-4">
<div className="space-y-2">
<Label>ID Type *</Label>
<Select value={idType} onValueChange={(v) => setValue('type', v)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{ID_TYPES.map((t) => (
<SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="idValue">ID Number *</Label>
<Input id="idValue" {...register('value', { required: true })} placeholder="e.g. DL12345678" />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="issuingAuthority">Issuing Authority</Label>
<Input id="issuingAuthority" {...register('issuingAuthority')} placeholder="e.g. State of Texas" />
</div>
<div className="space-y-2">
<Label htmlFor="idLabel">Label</Label>
<Input id="idLabel" {...register('label')} placeholder="e.g. Primary ID" />
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="issuedDate">Issued Date</Label>
<Input id="issuedDate" type="date" {...register('issuedDate')} />
</div>
<div className="space-y-2">
<Label htmlFor="expiresAt">Expires</Label>
<Input id="expiresAt" type="date" {...register('expiresAt')} />
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Front Image</Label>
<input
ref={frontInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => e.target.files?.[0] && handleFileSelect('imageFront', e.target.files[0])}
/>
{imageFront ? (
<div className="relative">
<img src={imageFront} alt="ID front" className="rounded-md border max-h-32 w-full object-cover" />
<Button
type="button"
variant="secondary"
size="sm"
className="absolute top-1 right-1"
onClick={() => setValue('imageFront', '')}
>
Remove
</Button>
</div>
) : (
<Button type="button" variant="outline" className="w-full" onClick={() => frontInputRef.current?.click()}>
<Upload className="mr-2 h-4 w-4" /> Upload Front
</Button>
)}
</div>
<div className="space-y-2">
<Label>Back Image</Label>
<input
ref={backInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => e.target.files?.[0] && handleFileSelect('imageBack', e.target.files[0])}
/>
{imageBack ? (
<div className="relative">
<img src={imageBack} alt="ID back" className="rounded-md border max-h-32 w-full object-cover" />
<Button
type="button"
variant="secondary"
size="sm"
className="absolute top-1 right-1"
onClick={() => setValue('imageBack', '')}
>
Remove
</Button>
</div>
) : (
<Button type="button" variant="outline" className="w-full" onClick={() => backInputRef.current?.click()}>
<Upload className="mr-2 h-4 w-4" /> Upload Back
</Button>
)}
</div>
</div>
<div className="flex items-center gap-2">
<input type="checkbox" id="isPrimary" {...register('isPrimary')} className="rounded" />
<Label htmlFor="isPrimary" className="font-normal">Primary ID</Label>
</div>
<div className="space-y-2">
<Label htmlFor="idNotes">Notes</Label>
<Textarea id="idNotes" {...register('notes')} />
</div>
<Button type="submit" disabled={loading}>
{loading ? 'Saving...' : defaultValues ? 'Update ID' : 'Add ID'}
</Button>
</form>
)
}

View File

@@ -15,6 +15,7 @@ import { Route as AuthenticatedIndexRouteImport } from './routes/_authenticated/
import { Route as AuthenticatedMembersRouteImport } from './routes/_authenticated/members' import { Route as AuthenticatedMembersRouteImport } from './routes/_authenticated/members'
import { Route as AuthenticatedHelpRouteImport } from './routes/_authenticated/help' import { Route as AuthenticatedHelpRouteImport } from './routes/_authenticated/help'
import { Route as AuthenticatedAccountsIndexRouteImport } from './routes/_authenticated/accounts/index' import { Route as AuthenticatedAccountsIndexRouteImport } from './routes/_authenticated/accounts/index'
import { Route as AuthenticatedMembersMemberIdRouteImport } from './routes/_authenticated/members/$memberId'
import { Route as AuthenticatedAccountsNewRouteImport } from './routes/_authenticated/accounts/new' import { Route as AuthenticatedAccountsNewRouteImport } from './routes/_authenticated/accounts/new'
import { Route as AuthenticatedAccountsAccountIdRouteImport } from './routes/_authenticated/accounts/$accountId' import { Route as AuthenticatedAccountsAccountIdRouteImport } from './routes/_authenticated/accounts/$accountId'
import { Route as AuthenticatedAccountsAccountIdIndexRouteImport } from './routes/_authenticated/accounts/$accountId/index' import { Route as AuthenticatedAccountsAccountIdIndexRouteImport } from './routes/_authenticated/accounts/$accountId/index'
@@ -53,6 +54,12 @@ const AuthenticatedAccountsIndexRoute =
path: '/accounts/', path: '/accounts/',
getParentRoute: () => AuthenticatedRoute, getParentRoute: () => AuthenticatedRoute,
} as any) } as any)
const AuthenticatedMembersMemberIdRoute =
AuthenticatedMembersMemberIdRouteImport.update({
id: '/$memberId',
path: '/$memberId',
getParentRoute: () => AuthenticatedMembersRoute,
} as any)
const AuthenticatedAccountsNewRoute = const AuthenticatedAccountsNewRoute =
AuthenticatedAccountsNewRouteImport.update({ AuthenticatedAccountsNewRouteImport.update({
id: '/accounts/new', id: '/accounts/new',
@@ -100,9 +107,10 @@ export interface FileRoutesByFullPath {
'/': typeof AuthenticatedIndexRoute '/': typeof AuthenticatedIndexRoute
'/login': typeof LoginRoute '/login': typeof LoginRoute
'/help': typeof AuthenticatedHelpRoute '/help': typeof AuthenticatedHelpRoute
'/members': typeof AuthenticatedMembersRoute '/members': typeof AuthenticatedMembersRouteWithChildren
'/accounts/$accountId': typeof AuthenticatedAccountsAccountIdRouteWithChildren '/accounts/$accountId': typeof AuthenticatedAccountsAccountIdRouteWithChildren
'/accounts/new': typeof AuthenticatedAccountsNewRoute '/accounts/new': typeof AuthenticatedAccountsNewRoute
'/members/$memberId': typeof AuthenticatedMembersMemberIdRoute
'/accounts/': typeof AuthenticatedAccountsIndexRoute '/accounts/': typeof AuthenticatedAccountsIndexRoute
'/accounts/$accountId/members': typeof AuthenticatedAccountsAccountIdMembersRoute '/accounts/$accountId/members': typeof AuthenticatedAccountsAccountIdMembersRoute
'/accounts/$accountId/payment-methods': typeof AuthenticatedAccountsAccountIdPaymentMethodsRoute '/accounts/$accountId/payment-methods': typeof AuthenticatedAccountsAccountIdPaymentMethodsRoute
@@ -113,9 +121,10 @@ export interface FileRoutesByFullPath {
export interface FileRoutesByTo { export interface FileRoutesByTo {
'/login': typeof LoginRoute '/login': typeof LoginRoute
'/help': typeof AuthenticatedHelpRoute '/help': typeof AuthenticatedHelpRoute
'/members': typeof AuthenticatedMembersRoute '/members': typeof AuthenticatedMembersRouteWithChildren
'/': typeof AuthenticatedIndexRoute '/': typeof AuthenticatedIndexRoute
'/accounts/new': typeof AuthenticatedAccountsNewRoute '/accounts/new': typeof AuthenticatedAccountsNewRoute
'/members/$memberId': typeof AuthenticatedMembersMemberIdRoute
'/accounts': typeof AuthenticatedAccountsIndexRoute '/accounts': typeof AuthenticatedAccountsIndexRoute
'/accounts/$accountId/members': typeof AuthenticatedAccountsAccountIdMembersRoute '/accounts/$accountId/members': typeof AuthenticatedAccountsAccountIdMembersRoute
'/accounts/$accountId/payment-methods': typeof AuthenticatedAccountsAccountIdPaymentMethodsRoute '/accounts/$accountId/payment-methods': typeof AuthenticatedAccountsAccountIdPaymentMethodsRoute
@@ -128,10 +137,11 @@ export interface FileRoutesById {
'/_authenticated': typeof AuthenticatedRouteWithChildren '/_authenticated': typeof AuthenticatedRouteWithChildren
'/login': typeof LoginRoute '/login': typeof LoginRoute
'/_authenticated/help': typeof AuthenticatedHelpRoute '/_authenticated/help': typeof AuthenticatedHelpRoute
'/_authenticated/members': typeof AuthenticatedMembersRoute '/_authenticated/members': typeof AuthenticatedMembersRouteWithChildren
'/_authenticated/': typeof AuthenticatedIndexRoute '/_authenticated/': typeof AuthenticatedIndexRoute
'/_authenticated/accounts/$accountId': typeof AuthenticatedAccountsAccountIdRouteWithChildren '/_authenticated/accounts/$accountId': typeof AuthenticatedAccountsAccountIdRouteWithChildren
'/_authenticated/accounts/new': typeof AuthenticatedAccountsNewRoute '/_authenticated/accounts/new': typeof AuthenticatedAccountsNewRoute
'/_authenticated/members/$memberId': typeof AuthenticatedMembersMemberIdRoute
'/_authenticated/accounts/': typeof AuthenticatedAccountsIndexRoute '/_authenticated/accounts/': typeof AuthenticatedAccountsIndexRoute
'/_authenticated/accounts/$accountId/members': typeof AuthenticatedAccountsAccountIdMembersRoute '/_authenticated/accounts/$accountId/members': typeof AuthenticatedAccountsAccountIdMembersRoute
'/_authenticated/accounts/$accountId/payment-methods': typeof AuthenticatedAccountsAccountIdPaymentMethodsRoute '/_authenticated/accounts/$accountId/payment-methods': typeof AuthenticatedAccountsAccountIdPaymentMethodsRoute
@@ -148,6 +158,7 @@ export interface FileRouteTypes {
| '/members' | '/members'
| '/accounts/$accountId' | '/accounts/$accountId'
| '/accounts/new' | '/accounts/new'
| '/members/$memberId'
| '/accounts/' | '/accounts/'
| '/accounts/$accountId/members' | '/accounts/$accountId/members'
| '/accounts/$accountId/payment-methods' | '/accounts/$accountId/payment-methods'
@@ -161,6 +172,7 @@ export interface FileRouteTypes {
| '/members' | '/members'
| '/' | '/'
| '/accounts/new' | '/accounts/new'
| '/members/$memberId'
| '/accounts' | '/accounts'
| '/accounts/$accountId/members' | '/accounts/$accountId/members'
| '/accounts/$accountId/payment-methods' | '/accounts/$accountId/payment-methods'
@@ -176,6 +188,7 @@ export interface FileRouteTypes {
| '/_authenticated/' | '/_authenticated/'
| '/_authenticated/accounts/$accountId' | '/_authenticated/accounts/$accountId'
| '/_authenticated/accounts/new' | '/_authenticated/accounts/new'
| '/_authenticated/members/$memberId'
| '/_authenticated/accounts/' | '/_authenticated/accounts/'
| '/_authenticated/accounts/$accountId/members' | '/_authenticated/accounts/$accountId/members'
| '/_authenticated/accounts/$accountId/payment-methods' | '/_authenticated/accounts/$accountId/payment-methods'
@@ -233,6 +246,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthenticatedAccountsIndexRouteImport preLoaderRoute: typeof AuthenticatedAccountsIndexRouteImport
parentRoute: typeof AuthenticatedRoute parentRoute: typeof AuthenticatedRoute
} }
'/_authenticated/members/$memberId': {
id: '/_authenticated/members/$memberId'
path: '/$memberId'
fullPath: '/members/$memberId'
preLoaderRoute: typeof AuthenticatedMembersMemberIdRouteImport
parentRoute: typeof AuthenticatedMembersRoute
}
'/_authenticated/accounts/new': { '/_authenticated/accounts/new': {
id: '/_authenticated/accounts/new' id: '/_authenticated/accounts/new'
path: '/accounts/new' path: '/accounts/new'
@@ -285,6 +305,17 @@ declare module '@tanstack/react-router' {
} }
} }
interface AuthenticatedMembersRouteChildren {
AuthenticatedMembersMemberIdRoute: typeof AuthenticatedMembersMemberIdRoute
}
const AuthenticatedMembersRouteChildren: AuthenticatedMembersRouteChildren = {
AuthenticatedMembersMemberIdRoute: AuthenticatedMembersMemberIdRoute,
}
const AuthenticatedMembersRouteWithChildren =
AuthenticatedMembersRoute._addFileChildren(AuthenticatedMembersRouteChildren)
interface AuthenticatedAccountsAccountIdRouteChildren { interface AuthenticatedAccountsAccountIdRouteChildren {
AuthenticatedAccountsAccountIdMembersRoute: typeof AuthenticatedAccountsAccountIdMembersRoute AuthenticatedAccountsAccountIdMembersRoute: typeof AuthenticatedAccountsAccountIdMembersRoute
AuthenticatedAccountsAccountIdPaymentMethodsRoute: typeof AuthenticatedAccountsAccountIdPaymentMethodsRoute AuthenticatedAccountsAccountIdPaymentMethodsRoute: typeof AuthenticatedAccountsAccountIdPaymentMethodsRoute
@@ -314,7 +345,7 @@ const AuthenticatedAccountsAccountIdRouteWithChildren =
interface AuthenticatedRouteChildren { interface AuthenticatedRouteChildren {
AuthenticatedHelpRoute: typeof AuthenticatedHelpRoute AuthenticatedHelpRoute: typeof AuthenticatedHelpRoute
AuthenticatedMembersRoute: typeof AuthenticatedMembersRoute AuthenticatedMembersRoute: typeof AuthenticatedMembersRouteWithChildren
AuthenticatedIndexRoute: typeof AuthenticatedIndexRoute AuthenticatedIndexRoute: typeof AuthenticatedIndexRoute
AuthenticatedAccountsAccountIdRoute: typeof AuthenticatedAccountsAccountIdRouteWithChildren AuthenticatedAccountsAccountIdRoute: typeof AuthenticatedAccountsAccountIdRouteWithChildren
AuthenticatedAccountsNewRoute: typeof AuthenticatedAccountsNewRoute AuthenticatedAccountsNewRoute: typeof AuthenticatedAccountsNewRoute
@@ -323,7 +354,7 @@ interface AuthenticatedRouteChildren {
const AuthenticatedRouteChildren: AuthenticatedRouteChildren = { const AuthenticatedRouteChildren: AuthenticatedRouteChildren = {
AuthenticatedHelpRoute: AuthenticatedHelpRoute, AuthenticatedHelpRoute: AuthenticatedHelpRoute,
AuthenticatedMembersRoute: AuthenticatedMembersRoute, AuthenticatedMembersRoute: AuthenticatedMembersRouteWithChildren,
AuthenticatedIndexRoute: AuthenticatedIndexRoute, AuthenticatedIndexRoute: AuthenticatedIndexRoute,
AuthenticatedAccountsAccountIdRoute: AuthenticatedAccountsAccountIdRoute:
AuthenticatedAccountsAccountIdRouteWithChildren, AuthenticatedAccountsAccountIdRouteWithChildren,

View File

@@ -1,13 +1,22 @@
import { useState } from 'react' import { useState } from 'react'
import { createFileRoute, useParams } from '@tanstack/react-router' import { createFileRoute, useParams, useNavigate } from '@tanstack/react-router'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { memberListOptions, memberMutations, memberKeys } from '@/api/members' import { memberListOptions, memberMutations, memberKeys } from '@/api/members'
import { identifierListOptions, identifierMutations, identifierKeys } from '@/api/identifiers'
import { MemberForm } from '@/components/accounts/member-form' import { MemberForm } from '@/components/accounts/member-form'
import { IdentifierForm } from '@/components/accounts/identifier-form'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Plus, Trash2 } from 'lucide-react' import { Plus, Trash2, CreditCard, ChevronDown, ChevronRight, MoreVertical, Pencil } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import type { Member } from '@/types/account' import type { Member } from '@/types/account'
@@ -15,11 +24,99 @@ export const Route = createFileRoute('/_authenticated/accounts/$accountId/member
component: MembersTab, component: MembersTab,
}) })
const ID_TYPE_LABELS: Record<string, string> = {
drivers_license: "Driver's License",
passport: 'Passport',
school_id: 'School ID',
}
function MemberIdentifiers({ memberId }: { memberId: string }) {
const queryClient = useQueryClient()
const [addOpen, setAddOpen] = useState(false)
const { data, isLoading } = useQuery(identifierListOptions(memberId))
const createMutation = useMutation({
mutationFn: (data: Record<string, unknown>) => identifierMutations.create(memberId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: identifierKeys.all(memberId) })
toast.success('ID added')
setAddOpen(false)
},
onError: (err) => toast.error(err.message),
})
const deleteMutation = useMutation({
mutationFn: identifierMutations.delete,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: identifierKeys.all(memberId) })
toast.success('ID removed')
},
onError: (err) => toast.error(err.message),
})
const identifiers = data?.data ?? []
return (
<div className="pl-8 pr-4 py-3 bg-muted/30 space-y-3">
<div className="flex items-center justify-between">
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">Identity Documents</span>
<Dialog open={addOpen} onOpenChange={setAddOpen}>
<DialogTrigger asChild>
<Button variant="ghost" size="sm"><Plus className="mr-1 h-3 w-3" />Add ID</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader><DialogTitle>Add Identity Document</DialogTitle></DialogHeader>
<IdentifierForm memberId={memberId} onSubmit={createMutation.mutate} loading={createMutation.isPending} />
</DialogContent>
</Dialog>
</div>
{isLoading ? (
<p className="text-sm text-muted-foreground">Loading...</p>
) : identifiers.length === 0 ? (
<p className="text-sm text-muted-foreground">No IDs on file</p>
) : (
<div className="space-y-2">
{identifiers.map((id) => (
<div key={id.id} className="flex items-center justify-between p-2 rounded-md border bg-background">
<div className="flex items-center gap-3">
<CreditCard className="h-4 w-4 text-muted-foreground" />
<div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{ID_TYPE_LABELS[id.type] ?? id.type}</span>
{id.isPrimary && <Badge variant="secondary">Primary</Badge>}
</div>
<span className="text-xs text-muted-foreground font-mono">{id.value}</span>
{id.issuingAuthority && (
<span className="text-xs text-muted-foreground ml-2"> {id.issuingAuthority}</span>
)}
{id.expiresAt && (
<span className="text-xs text-muted-foreground ml-2">Exp: {id.expiresAt}</span>
)}
</div>
</div>
<div className="flex items-center gap-1">
{(id.imageFront || id.imageBack) && (
<Badge variant="outline" className="text-xs">Has images</Badge>
)}
<Button variant="ghost" size="sm" onClick={() => deleteMutation.mutate(id.id)}>
<Trash2 className="h-3 w-3 text-destructive" />
</Button>
</div>
</div>
))}
</div>
)}
</div>
)
}
function MembersTab() { function MembersTab() {
const { accountId } = useParams({ from: '/_authenticated/accounts/$accountId/members' }) const { accountId } = useParams({ from: '/_authenticated/accounts/$accountId/members' })
const navigate = useNavigate()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const [dialogOpen, setDialogOpen] = useState(false) const [dialogOpen, setDialogOpen] = useState(false)
const [editingMember, setEditingMember] = useState<Member | null>(null) const [expandedMember, setExpandedMember] = useState<string | null>(null)
const { data, isLoading } = useQuery(memberListOptions(accountId, { page: 1, limit: 100, order: 'asc' })) const { data, isLoading } = useQuery(memberListOptions(accountId, { page: 1, limit: 100, order: 'asc' }))
@@ -33,16 +130,6 @@ function MembersTab() {
onError: (err) => toast.error(err.message), onError: (err) => toast.error(err.message),
}) })
const updateMutation = useMutation({
mutationFn: (data: Record<string, unknown>) => memberMutations.update(editingMember!.id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: memberKeys.all(accountId) })
toast.success('Member updated')
setEditingMember(null)
},
onError: (err) => toast.error(err.message),
})
const deleteMutation = useMutation({ const deleteMutation = useMutation({
mutationFn: memberMutations.delete, mutationFn: memberMutations.delete,
onSuccess: () => { onSuccess: () => {
@@ -69,21 +156,6 @@ function MembersTab() {
</Dialog> </Dialog>
</div> </div>
{/* Edit dialog */}
<Dialog open={!!editingMember} onOpenChange={(open) => !open && setEditingMember(null)}>
<DialogContent>
<DialogHeader><DialogTitle>Edit Member</DialogTitle></DialogHeader>
{editingMember && (
<MemberForm
accountId={accountId}
defaultValues={editingMember}
onSubmit={updateMutation.mutate}
loading={updateMutation.isPending}
/>
)}
</DialogContent>
</Dialog>
{isLoading ? ( {isLoading ? (
<p className="text-muted-foreground">Loading...</p> <p className="text-muted-foreground">Loading...</p>
) : members.length === 0 ? ( ) : members.length === 0 ? (
@@ -93,33 +165,75 @@ function MembersTab() {
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead className="w-8"></TableHead>
<TableHead>#</TableHead> <TableHead>#</TableHead>
<TableHead>Name</TableHead> <TableHead>Name</TableHead>
<TableHead>Email</TableHead> <TableHead>Email</TableHead>
<TableHead>Phone</TableHead> <TableHead>Phone</TableHead>
<TableHead>Status</TableHead> <TableHead>Status</TableHead>
<TableHead className="w-24">Actions</TableHead> <TableHead className="w-12"></TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{members.map((m) => ( {members.map((m) => (
<TableRow key={m.id}> <>
<TableCell className="font-mono text-sm text-muted-foreground">{m.memberNumber ?? '-'}</TableCell> <TableRow key={m.id}>
<TableCell className="font-medium">{m.firstName} {m.lastName}</TableCell> <TableCell>
<TableCell>{m.email ?? '-'}</TableCell> <Button
<TableCell>{m.phone ?? '-'}</TableCell> variant="ghost"
<TableCell> size="sm"
{m.isMinor ? <Badge variant="secondary">Minor</Badge> : <Badge>Adult</Badge>} className="h-6 w-6 p-0"
</TableCell> onClick={() => setExpandedMember(expandedMember === m.id ? null : m.id)}
<TableCell> title="Show IDs"
<div className="flex gap-1"> >
<Button variant="ghost" size="sm" onClick={() => setEditingMember(m)}>Edit</Button> {expandedMember === m.id
<Button variant="ghost" size="sm" onClick={() => deleteMutation.mutate(m.id)}> ? <ChevronDown className="h-3 w-3" />
<Trash2 className="h-4 w-4 text-destructive" /> : <ChevronRight className="h-3 w-3" />}
</Button> </Button>
</div> </TableCell>
</TableCell> <TableCell className="font-mono text-sm text-muted-foreground">{m.memberNumber ?? '-'}</TableCell>
</TableRow> <TableCell className="font-medium">{m.firstName} {m.lastName}</TableCell>
<TableCell>{m.email ?? '-'}</TableCell>
<TableCell>{m.phone ?? '-'}</TableCell>
<TableCell>
{m.isMinor ? <Badge variant="secondary">Minor</Badge> : <Badge>Adult</Badge>}
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => navigate({
to: '/members/$memberId',
params: { memberId: m.id },
})}>
<Pencil className="mr-2 h-4 w-4" />
Edit
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setExpandedMember(expandedMember === m.id ? null : m.id)}>
<CreditCard className="mr-2 h-4 w-4" />
{expandedMember === m.id ? 'Hide IDs' : 'View IDs'}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="text-destructive" onClick={() => deleteMutation.mutate(m.id)}>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
{expandedMember === m.id && (
<TableRow key={`${m.id}-ids`}>
<TableCell colSpan={7} className="p-0">
<MemberIdentifiers memberId={m.id} />
</TableCell>
</TableRow>
)}
</>
))} ))}
</TableBody> </TableBody>
</Table> </Table>

View File

@@ -7,7 +7,13 @@ import { DataTable, type Column } from '@/components/shared/data-table'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Search, Plus } from 'lucide-react' import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Search, Plus, MoreVertical, Pencil, Users } from 'lucide-react'
export const Route = createFileRoute('/_authenticated/members')({ export const Route = createFileRoute('/_authenticated/members')({
validateSearch: (search: Record<string, unknown>) => ({ validateSearch: (search: Record<string, unknown>) => ({
@@ -20,41 +26,6 @@ export const Route = createFileRoute('/_authenticated/members')({
component: MembersListPage, component: MembersListPage,
}) })
const memberColumns: Column<MemberWithAccount>[] = [
{
key: 'memberNumber',
header: '#',
render: (row) => <span className="font-mono text-sm text-muted-foreground">{row.memberNumber ?? '-'}</span>,
},
{
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() { function MembersListPage() {
const navigate = useNavigate() const navigate = useNavigate()
const { params, setPage, setSearch, setSort } = usePagination() const { params, setPage, setSearch, setSort } = usePagination()
@@ -67,9 +38,63 @@ function MembersListPage() {
setSearch(searchInput) setSearch(searchInput)
} }
function handleRowClick(member: MemberWithAccount) { const memberColumns: Column<MemberWithAccount>[] = [
navigate({ to: '/accounts/$accountId/members', params: { accountId: member.accountId } }) {
} key: 'memberNumber',
header: '#',
render: (row) => <span className="font-mono text-sm text-muted-foreground">{row.memberNumber ?? '-'}</span>,
},
{
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>,
},
{
key: 'actions',
header: '',
render: (row) => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={(e) => e.stopPropagation()}>
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => navigate({ to: '/members/$memberId', params: { memberId: row.id } })}>
<Pencil className="mr-2 h-4 w-4" />
Edit
</DropdownMenuItem>
<DropdownMenuItem onClick={() => navigate({ to: '/accounts/$accountId', params: { accountId: row.accountId } })}>
<Users className="mr-2 h-4 w-4" />
View Account
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
),
},
]
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -105,7 +130,7 @@ function MembersListPage() {
order={params.order} order={params.order}
onPageChange={setPage} onPageChange={setPage}
onSort={setSort} onSort={setSort}
onRowClick={handleRowClick} onRowClick={(member) => navigate({ to: '/members/$memberId', params: { memberId: member.id } })}
/> />
</div> </div>
) )

View File

@@ -0,0 +1,176 @@
import { createFileRoute, useParams, useNavigate, Link } from '@tanstack/react-router'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { api } from '@/lib/api-client'
import { identifierListOptions, identifierMutations, identifierKeys } from '@/api/identifiers'
import { MemberForm } from '@/components/accounts/member-form'
import { IdentifierForm } from '@/components/accounts/identifier-form'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Separator } from '@/components/ui/separator'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
import { Skeleton } from '@/components/ui/skeleton'
import { ArrowLeft, Plus, Trash2, CreditCard } from 'lucide-react'
import { toast } from 'sonner'
import type { Member, MemberIdentifier } from '@/types/account'
import { useState } from 'react'
import { queryOptions } from '@tanstack/react-query'
function memberDetailOptions(id: string) {
return queryOptions({
queryKey: ['members', 'detail', id],
queryFn: () => api.get<Member>(`/v1/members/${id}`),
})
}
export const Route = createFileRoute('/_authenticated/members/$memberId')({
component: MemberDetailPage,
})
const ID_TYPE_LABELS: Record<string, string> = {
drivers_license: "Driver's License",
passport: 'Passport',
school_id: 'School ID',
}
function MemberDetailPage() {
const { memberId } = useParams({ from: '/_authenticated/members/$memberId' })
const navigate = useNavigate()
const queryClient = useQueryClient()
const [addIdOpen, setAddIdOpen] = useState(false)
const { data: member, isLoading } = useQuery(memberDetailOptions(memberId))
const { data: idsData } = useQuery(identifierListOptions(memberId))
const updateMutation = useMutation({
mutationFn: (data: Record<string, unknown>) => api.patch<Member>(`/v1/members/${memberId}`, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['members', 'detail', memberId] })
toast.success('Member updated')
},
onError: (err) => toast.error(err instanceof Error ? err.message : 'Update failed'),
})
const createIdMutation = useMutation({
mutationFn: (data: Record<string, unknown>) => identifierMutations.create(memberId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: identifierKeys.all(memberId) })
toast.success('ID added')
setAddIdOpen(false)
},
onError: (err) => toast.error(err.message),
})
const deleteIdMutation = useMutation({
mutationFn: identifierMutations.delete,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: identifierKeys.all(memberId) })
toast.success('ID removed')
},
onError: (err) => toast.error(err.message),
})
if (isLoading) {
return (
<div className="space-y-4">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-64 w-full max-w-lg" />
</div>
)
}
if (!member) {
return <p className="text-muted-foreground">Member not found</p>
}
const identifiers = idsData?.data ?? []
return (
<div className="space-y-6 max-w-2xl">
<div className="flex items-center gap-3">
<Button variant="ghost" size="sm" onClick={() => navigate({ to: '/accounts/$accountId/members', params: { accountId: member.accountId } })}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-2xl font-bold">{member.firstName} {member.lastName}</h1>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>#{member.memberNumber}</span>
{member.isMinor && <Badge variant="secondary">Minor</Badge>}
<Link
to="/accounts/$accountId"
params={{ accountId: member.accountId }}
className="hover:underline"
>
View Account
</Link>
</div>
</div>
</div>
<Card>
<CardHeader>
<CardTitle className="text-lg">Details</CardTitle>
</CardHeader>
<CardContent>
<MemberForm
accountId={member.accountId}
defaultValues={member}
onSubmit={(data) => updateMutation.mutate(data)}
loading={updateMutation.isPending}
/>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-lg">Identity Documents</CardTitle>
<Dialog open={addIdOpen} onOpenChange={setAddIdOpen}>
<DialogTrigger asChild>
<Button size="sm"><Plus className="mr-2 h-4 w-4" />Add ID</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader><DialogTitle>Add Identity Document</DialogTitle></DialogHeader>
<IdentifierForm memberId={memberId} onSubmit={createIdMutation.mutate} loading={createIdMutation.isPending} />
</DialogContent>
</Dialog>
</CardHeader>
<CardContent>
{identifiers.length === 0 ? (
<p className="text-sm text-muted-foreground py-4 text-center">No IDs on file</p>
) : (
<div className="space-y-3">
{identifiers.map((id) => (
<div key={id.id} className="flex items-start justify-between p-3 rounded-md border">
<div className="flex items-start gap-3">
<CreditCard className="h-5 w-5 text-muted-foreground mt-0.5" />
<div className="space-y-1">
<div className="flex items-center gap-2">
<span className="font-medium">{ID_TYPE_LABELS[id.type] ?? id.type}</span>
{id.isPrimary && <Badge variant="secondary">Primary</Badge>}
</div>
<p className="text-sm font-mono text-muted-foreground">{id.value}</p>
{id.issuingAuthority && <p className="text-xs text-muted-foreground">{id.issuingAuthority}</p>}
<div className="flex gap-4 text-xs text-muted-foreground">
{id.issuedDate && <span>Issued: {id.issuedDate}</span>}
{id.expiresAt && <span>Expires: {id.expiresAt}</span>}
</div>
{(id.imageFront || id.imageBack) && (
<div className="flex gap-2 mt-2">
{id.imageFront && <img src={id.imageFront} alt="Front" className="h-20 rounded border object-cover" />}
{id.imageBack && <img src={id.imageBack} alt="Back" className="h-20 rounded border object-cover" />}
</div>
)}
</div>
</div>
<Button variant="ghost" size="sm" onClick={() => deleteIdMutation.mutate(id.id)}>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
)
}

View File

@@ -64,6 +64,24 @@ export interface PaymentMethod {
createdAt: string createdAt: string
} }
export interface MemberIdentifier {
id: string
memberId: string
companyId: string
type: 'drivers_license' | 'passport' | 'school_id'
label: string | null
value: string
issuingAuthority: string | null
issuedDate: string | null
expiresAt: string | null
imageFront: string | null
imageBack: string | null
notes: string | null
isPrimary: boolean
createdAt: string
updatedAt: string
}
export interface TaxExemption { export interface TaxExemption {
id: string id: string
accountId: string accountId: string

View File

@@ -182,6 +182,40 @@ If a certificate expires or is no longer valid:
All approvals and revocations are logged with who did it and when. All approvals and revocations are logged with who did it and when.
`.trim(), `.trim(),
}, },
{
slug: 'member-ids',
title: 'Identity Documents',
category: 'Accounts',
content: `
# Identity Documents
You can store identity documents (driver's license, passport, school ID) for any member. This is useful for verifying identity during instrument pickups, rentals, or trade-ins.
## Adding an ID
1. Go to an account's **Members** tab
2. Click the arrow next to a member to expand their row, or click the three-dot menu and choose **Edit**
3. In the Identity Documents section, click **Add ID**
4. Select the ID type
5. Enter the ID number
6. Optionally add issuing authority, dates, and upload images of the front and back
7. Click **Add ID**
## ID Types
- **Driver's License / State ID** — most common for adult customers
- **Passport** — for international customers or as secondary ID
- **School ID** — for student members
## Images
You can upload photos of the front and back of the ID. These are stored securely and are only visible to staff. Use your phone's camera or a scanner.
## Primary ID
If a member has multiple IDs, mark one as **Primary** — this is the one shown by default in quick lookups.
`.trim(),
},
] ]
export function getWikiPages(): WikiPage[] { export function getWikiPages(): WikiPage[] {