Files
lunarfront-app/packages/admin/src/routes/_authenticated/members/$memberId.tsx
Ryan Moon 505f8fd4e4
Some checks failed
Build & Release / build (push) Failing after 33s
fix: correct TanStack Router search types for all navigate/Link calls
Each destination route's search must match its validateSearch shape exactly:
- Detail pages (tab-based): { tab: '...' }
- List pages with extra filters: include status, instructorId, view, categoryId etc.
- Form pages (enrollments/new, repairs/new): include only their specific fields
- use-pagination.ts: fix search reducer to use (prev: any) instead of invalid cast

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 10:37:34 -05:00

317 lines
14 KiB
TypeScript

import { useState } from 'react'
import { createFileRoute, useParams, useNavigate, Link } from '@tanstack/react-router'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { api } from '@/lib/api-client'
import { queryOptions } from '@tanstack/react-query'
import { identifierListOptions, identifierMutations, identifierKeys } from '@/api/identifiers'
import { enrollmentListOptions } from '@/api/lessons'
import { moduleListOptions } from '@/api/modules'
import { MemberForm } from '@/components/accounts/member-form'
import { IdentifierForm, type IdentifierFiles } from '@/components/accounts/identifier-form'
import { DataTable, type Column } from '@/components/shared/data-table'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
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 { AvatarUpload } from '@/components/shared/avatar-upload'
import { useAuthStore } from '@/stores/auth.store'
import { cn } from '@/lib/utils'
import type { Member } from '@/types/account'
import type { Enrollment } from '@/types/lesson'
function memberDetailOptions(id: string) {
return queryOptions({
queryKey: ['members', 'detail', id],
queryFn: () => api.get<Member>(`/v1/members/${id}`),
})
}
export const Route = createFileRoute('/_authenticated/members/$memberId')({
validateSearch: (search: Record<string, unknown>) => ({
tab: (search.tab as string) || 'details',
}),
component: MemberDetailPage,
})
// ─── Identifier images ────────────────────────────────────────────────────────
function IdentifierImages({ identifierId }: { identifierId: string }) {
const { data } = useQuery({
queryKey: ['files', 'member_identifier', identifierId],
queryFn: () => api.get<{ data: { id: string; path: string; category: string }[] }>('/v1/files', {
entityType: 'member_identifier',
entityId: identifierId,
}),
})
const files = data?.data ?? []
const frontFile = files.find((f) => f.category === 'front')
const backFile = files.find((f) => f.category === 'back')
if (!frontFile && !backFile) return null
return (
<div className="flex gap-2 mt-2">
{frontFile && <img src={`/v1/files/serve/${frontFile.path}`} alt="Front" className="h-20 rounded border object-cover" />}
{backFile && <img src={`/v1/files/serve/${backFile.path}`} alt="Back" className="h-20 rounded border object-cover" />}
</div>
)
}
const ID_TYPE_LABELS: Record<string, string> = {
drivers_license: "Driver's License",
passport: 'Passport',
school_id: 'School ID',
}
function statusBadge(status: string) {
const variants: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
active: 'default', paused: 'secondary', cancelled: 'destructive', completed: 'outline',
}
return <Badge variant={variants[status] ?? 'outline'}>{status}</Badge>
}
const enrollmentColumns: Column<Enrollment & { instructorName?: string; slotInfo?: string; lessonTypeName?: string }>[] = [
{ key: 'status', header: 'Status', sortable: true, render: (e) => statusBadge(e.status) },
{ key: 'instructor_name', header: 'Instructor', render: (e) => <>{e.instructorName ?? e.instructorId}</> },
{ key: 'slot_info', header: 'Day / Time', render: (e) => <>{e.slotInfo ?? '—'}</> },
{ key: 'lesson_type', header: 'Lesson', render: (e) => <>{e.lessonTypeName ?? '—'}</> },
{ key: 'start_date', header: 'Start', sortable: true, render: (e) => <>{new Date(e.startDate + 'T00:00:00').toLocaleDateString()}</> },
{ key: 'rate', header: 'Rate', render: (e) => <>{e.rate ? `$${e.rate} / ${e.billingInterval} ${e.billingUnit}` : <span className="text-muted-foreground"></span>}</> },
]
// ─── Page ─────────────────────────────────────────────────────────────────────
function MemberDetailPage() {
const { memberId } = useParams({ from: '/_authenticated/members/$memberId' })
const search = Route.useSearch()
const navigate = useNavigate()
const queryClient = useQueryClient()
const [addIdOpen, setAddIdOpen] = useState(false)
const [createLoading, setCreateLoading] = useState(false)
const tab = search.tab ?? 'details'
const token = useAuthStore((s) => s.token)
const hasPermission = useAuthStore((s) => s.hasPermission)
const { data: member, isLoading } = useQuery(memberDetailOptions(memberId))
const { data: idsData } = useQuery({ ...identifierListOptions(memberId), enabled: tab === 'identity' })
const { data: modulesData } = useQuery(moduleListOptions())
const lessonsEnabled = (modulesData?.data ?? []).some((m) => m.slug === 'lessons' && m.enabled && m.licensed)
const { data: enrollmentsData } = useQuery({
...enrollmentListOptions({ memberId, page: 1, limit: 100, order: 'asc' }),
enabled: tab === 'enrollments' && lessonsEnabled,
})
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'),
})
async function uploadIdFile(identifierId: string, file: File, category: string): Promise<string | null> {
const formData = new FormData()
formData.append('file', file)
formData.append('entityType', 'member_identifier')
formData.append('entityId', identifierId)
formData.append('category', category)
const res = await fetch('/v1/files', {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
body: formData,
})
if (!res.ok) return null
return (await res.json()).id
}
async function handleCreateIdentifier(data: Record<string, unknown>, files: IdentifierFiles) {
setCreateLoading(true)
try {
const identifier = await identifierMutations.create(memberId, data)
const updates: Record<string, unknown> = {}
if (files.front) {
const fileId = await uploadIdFile(identifier.id, files.front, 'front')
if (fileId) updates.imageFrontFileId = fileId
}
if (files.back) {
const fileId = await uploadIdFile(identifier.id, files.back, 'back')
if (fileId) updates.imageBackFileId = fileId
}
if (Object.keys(updates).length > 0) await identifierMutations.update(identifier.id, updates)
queryClient.invalidateQueries({ queryKey: identifierKeys.all(memberId) })
toast.success('ID added')
setAddIdOpen(false)
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to add ID')
} finally {
setCreateLoading(false)
}
}
const deleteIdMutation = useMutation({
mutationFn: identifierMutations.delete,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: identifierKeys.all(memberId) })
toast.success('ID removed')
},
onError: (err) => toast.error(err.message),
})
function setTab(t: string) {
navigate({ to: '/members/$memberId', params: { memberId }, search: { tab: t } })
}
if (isLoading) {
return (
<div className="space-y-4">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-10 w-full" />
<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 ?? []
const tabs = [
{ key: 'details', label: 'Details' },
{ key: 'identity', label: 'Identity Documents' },
...(lessonsEnabled ? [{ key: 'enrollments', label: 'Enrollments' }] : []),
]
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-3">
<Button variant="ghost" size="sm" onClick={() => navigate({ to: '/accounts/$accountId/members', params: { accountId: member.accountId }, search: { page: 1, limit: 25, q: undefined, sort: undefined, order: 'asc' as const } })}>
<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>
{/* Tabs */}
<nav className="flex gap-1 border-b">
{tabs.map((t) => (
<button
key={t.key}
onClick={() => setTab(t.key)}
className={cn(
'px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors',
tab === t.key
? 'border-primary text-foreground'
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border',
)}
>
{t.label}
</button>
))}
</nav>
{/* Details tab */}
{tab === 'details' && (
<div className="max-w-lg space-y-4">
<div className="flex items-center gap-4">
<AvatarUpload entityType="member" entityId={memberId} size="lg" />
<div>
<p className="font-medium">{member.firstName} {member.lastName}</p>
<p className="text-sm text-muted-foreground">{member.email ?? 'No email'}</p>
</div>
</div>
<MemberForm
accountId={member.accountId}
defaultValues={member}
onSubmit={(data) => updateMutation.mutate(data)}
loading={updateMutation.isPending}
/>
</div>
)}
{/* Identity Documents tab */}
{tab === 'identity' && (
<div className="space-y-4 max-w-2xl">
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">{identifiers.length} document(s) on file</p>
<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={handleCreateIdentifier} loading={createLoading} />
</DialogContent>
</Dialog>
</div>
{identifiers.length === 0 ? (
<p className="text-sm text-muted-foreground py-8 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.imageFrontFileId || id.imageBackFileId) && <IdentifierImages identifierId={id.id} />}
</div>
</div>
<Button variant="ghost" size="sm" onClick={() => deleteIdMutation.mutate(id.id)}>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
))}
</div>
)}
</div>
)}
{/* Enrollments tab */}
{tab === 'enrollments' && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">{enrollmentsData?.pagination.total ?? 0} enrollment(s)</p>
{hasPermission('lessons.edit') && (
<Button size="sm" onClick={() => navigate({ to: '/lessons/enrollments/new', search: { memberId, accountId: undefined } })}>
<Plus className="h-4 w-4 mr-1" />Enroll
</Button>
)}
</div>
<DataTable
columns={enrollmentColumns}
data={enrollmentsData?.data ?? []}
loading={!enrollmentsData && tab === 'enrollments'}
page={1}
totalPages={1}
total={enrollmentsData?.data?.length ?? 0}
onPageChange={() => {}}
onSort={() => {}}
onRowClick={(e) => navigate({ to: '/lessons/enrollments/$enrollmentId', params: { enrollmentId: e.id }, search: { tab: 'details' } })}
/>
</div>
)}
</div>
)
}