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:
Ryan Moon
2026-03-30 18:52:57 -05:00
parent 7680a73d88
commit 5ad27bc196
47 changed files with 6303 additions and 139 deletions

View File

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