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