Add lessons module, rate cycles, EC2 deploy scripts, and help content
- Lessons module: lesson types, instructors, schedule slots, enrollments, sessions (list + week grid view), lesson plans, grading scales, templates - Rate cycles: replace monthly_rate with billing_interval + billing_unit on enrollments; add weekly/monthly/quarterly rate presets to lesson types and schedule slots with auto-fill on enrollment form - Member detail page: tabbed layout for details, identity documents, enrollments - Sessions week view: custom 7-column grid replacing react-big-calendar - Music store seed: instructors, lesson types, slots, enrollments, sessions, grading scale, lesson plan template - Scrollbar styling: themed to match sidebar/app palette - deploy/: EC2 setup and redeploy scripts, nginx config, systemd service - Help: add Lessons category (overview, types, instructors, slots, enrollments, sessions, plans/grading); collapsible sidebar with independent scroll; remove POS/accounting references from docs
This commit is contained in:
@@ -1,22 +1,26 @@
|
||||
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 { 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 { AvatarUpload } from '@/components/shared/avatar-upload'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { Member, MemberIdentifier } from '@/types/account'
|
||||
import { useState } from 'react'
|
||||
import { queryOptions } from '@tanstack/react-query'
|
||||
import type { Enrollment } from '@/types/lesson'
|
||||
|
||||
function memberDetailOptions(id: string) {
|
||||
return queryOptions({
|
||||
@@ -26,9 +30,14 @@ function memberDetailOptions(id: string) {
|
||||
}
|
||||
|
||||
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],
|
||||
@@ -37,13 +46,10 @@ function IdentifierImages({ identifierId }: { identifierId: string }) {
|
||||
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" />}
|
||||
@@ -58,16 +64,45 @@ const ID_TYPE_LABELS: Record<string, string> = {
|
||||
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>[] = [
|
||||
{ key: 'status', header: 'Status', sortable: true, render: (e) => statusBadge(e.status) },
|
||||
{ key: 'instructor_name', header: 'Instructor', render: (e) => <>{(e as any).instructorName ?? e.instructorId}</> },
|
||||
{ key: 'slot_info', header: 'Day / Time', render: (e) => <>{(e as any).slotInfo ?? '—'}</> },
|
||||
{ key: 'lesson_type', header: 'Lesson', render: (e) => <>{(e as any).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))
|
||||
const [createLoading, setCreateLoading] = useState(false)
|
||||
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),
|
||||
@@ -84,23 +119,19 @@ function MemberDetailPage() {
|
||||
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
|
||||
const data = await res.json()
|
||||
return data.id
|
||||
return (await res.json()).id
|
||||
}
|
||||
|
||||
async function handleCreateIdentifier(data: Record<string, unknown>, files: IdentifierFiles) {
|
||||
setCreateLoading(true)
|
||||
try {
|
||||
const identifier = await identifierMutations.create(memberId, data)
|
||||
|
||||
// Upload images and update identifier with file IDs
|
||||
const updates: Record<string, unknown> = {}
|
||||
if (files.front) {
|
||||
const fileId = await uploadIdFile(identifier.id, files.front, 'front')
|
||||
@@ -110,11 +141,7 @@ function MemberDetailPage() {
|
||||
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)
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length > 0) await identifierMutations.update(identifier.id, updates)
|
||||
queryClient.invalidateQueries({ queryKey: identifierKeys.all(memberId) })
|
||||
toast.success('ID added')
|
||||
setAddIdOpen(false)
|
||||
@@ -134,23 +161,33 @@ function MemberDetailPage() {
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
function setTab(t: string) {
|
||||
navigate({ to: '/members/$memberId', params: { memberId }, search: { tab: t } as any })
|
||||
}
|
||||
|
||||
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>
|
||||
}
|
||||
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 max-w-2xl">
|
||||
<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: {} as any })}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
@@ -160,22 +197,34 @@ function MemberDetailPage() {
|
||||
<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"
|
||||
>
|
||||
<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 className="space-y-4">
|
||||
{/* 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>
|
||||
@@ -189,25 +238,26 @@ function MemberDetailPage() {
|
||||
onSubmit={(data) => updateMutation.mutate(data)}
|
||||
loading={updateMutation.isPending}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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={handleCreateIdentifier} loading={createLoading} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* 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-4 text-center">No IDs on file</p>
|
||||
<p className="text-sm text-muted-foreground py-8 text-center">No IDs on file</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{identifiers.map((id) => (
|
||||
@@ -225,9 +275,7 @@ function MemberDetailPage() {
|
||||
{id.issuedDate && <span>Issued: {id.issuedDate}</span>}
|
||||
{id.expiresAt && <span>Expires: {id.expiresAt}</span>}
|
||||
</div>
|
||||
{(id.imageFrontFileId || id.imageBackFileId) && (
|
||||
<IdentifierImages identifierId={id.id} />
|
||||
)}
|
||||
{(id.imageFrontFileId || id.imageBackFileId) && <IdentifierImages identifierId={id.id} />}
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => deleteIdMutation.mutate(id.id)}>
|
||||
@@ -237,8 +285,33 @@ function MemberDetailPage() {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</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 } as any })}>
|
||||
<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: {} as any })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@ function MembersListPage() {
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => navigate({ to: '/members/$memberId', params: { memberId: row.id } })}>
|
||||
<DropdownMenuItem onClick={() => navigate({ to: '/members/$memberId', params: { memberId: row.id }, search: {} as any })}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
@@ -134,7 +134,7 @@ function MembersListPage() {
|
||||
order={params.order}
|
||||
onPageChange={setPage}
|
||||
onSort={setSort}
|
||||
onRowClick={(member) => navigate({ to: '/members/$memberId', params: { memberId: member.id } })}
|
||||
onRowClick={(member) => navigate({ to: '/members/$memberId', params: { memberId: member.id }, search: {} as any })}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user