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:
185
packages/admin/src/components/accounts/identifier-form.tsx
Normal file
185
packages/admin/src/components/accounts/identifier-form.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user