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

@@ -0,0 +1,444 @@
import { useState } from 'react'
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import {
instructorListOptions, instructorMutations, instructorKeys,
lessonTypeListOptions, lessonTypeMutations, lessonTypeKeys,
gradingScaleListOptions, gradingScaleMutations, gradingScaleKeys,
storeClosureListOptions, storeClosureMutations, storeClosureKeys,
} from '@/api/lessons'
import { usePagination } from '@/hooks/use-pagination'
import { DataTable, type Column } from '@/components/shared/data-table'
import { InstructorForm } from '@/components/lessons/instructor-form'
import { LessonTypeForm } from '@/components/lessons/lesson-type-form'
import { GradingScaleForm } from '@/components/lessons/grading-scale-form'
import { StoreClosureForm } from '@/components/lessons/store-closure-form'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
import { Plus, Search, Trash2 } from 'lucide-react'
import { toast } from 'sonner'
import { useAuthStore } from '@/stores/auth.store'
import type { Instructor, LessonType, GradingScale, StoreClosure } from '@/types/lesson'
export const Route = createFileRoute('/_authenticated/lessons/schedule/')({
validateSearch: (search: Record<string, unknown>) => ({
tab: (search.tab as string) || 'instructors',
page: Number(search.page) || 1,
limit: Number(search.limit) || 25,
q: (search.q as string) || undefined,
sort: (search.sort as string) || undefined,
order: (search.order as 'asc' | 'desc') || 'asc',
}),
component: ScheduleHubPage,
})
const TABS = [
{ key: 'instructors', label: 'Instructors' },
{ key: 'lesson-types', label: 'Lesson Types' },
{ key: 'grading-scales', label: 'Grading Scales' },
{ key: 'closures', label: 'Store Closures' },
]
function ScheduleHubPage() {
const navigate = useNavigate()
const search = Route.useSearch()
const tab = search.tab
const hasPermission = useAuthStore((s) => s.hasPermission)
const canAdmin = hasPermission('lessons.admin')
function setTab(t: string) {
navigate({ to: '/lessons/schedule', search: { ...search, tab: t, page: 1 } as any })
}
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold">Lessons Setup</h1>
<div className="flex gap-1 border-b">
{TABS.map((t) => (
<button
key={t.key}
onClick={() => setTab(t.key)}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
tab === t.key
? 'border-primary text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground'
}`}
>
{t.label}
</button>
))}
</div>
{tab === 'instructors' && <InstructorsTab canAdmin={canAdmin} search={search} />}
{tab === 'lesson-types' && <LessonTypesTab canAdmin={canAdmin} search={search} />}
{tab === 'grading-scales' && <GradingScalesTab canAdmin={canAdmin} search={search} />}
{tab === 'closures' && <StoreClosuresTab canAdmin={canAdmin} />}
</div>
)
}
// ─── Instructors Tab ──────────────────────────────────────────────────────────
const instructorColumns: Column<Instructor>[] = [
{ key: 'display_name', header: 'Name', sortable: true, render: (i) => <span className="font-medium">{i.displayName}</span> },
{ key: 'instruments', header: 'Instruments', render: (i) => <>{i.instruments?.join(', ') || <span className="text-muted-foreground"></span>}</> },
{
key: 'is_active', header: 'Status', sortable: true,
render: (i) => <Badge variant={i.isActive ? 'default' : 'secondary'}>{i.isActive ? 'Active' : 'Inactive'}</Badge>,
},
]
function InstructorsTab({ canAdmin, search }: { canAdmin: boolean; search: any }) {
const navigate = useNavigate()
const queryClient = useQueryClient()
const { params, setPage, setSearch, setSort } = usePagination()
const [searchInput, setSearchInput] = useState(params.q ?? '')
const [createOpen, setCreateOpen] = useState(false)
const { data, isLoading } = useQuery(instructorListOptions(params))
const createMutation = useMutation({
mutationFn: instructorMutations.create,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: instructorKeys.all })
toast.success('Instructor created')
setCreateOpen(false)
},
onError: (err) => toast.error(err.message),
})
function handleSearchSubmit(e: React.FormEvent) {
e.preventDefault()
setSearch(searchInput)
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<form onSubmit={handleSearchSubmit} className="flex gap-2 max-w-sm">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search instructors..."
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
className="pl-9"
/>
</div>
<Button type="submit" variant="secondary">Search</Button>
</form>
{canAdmin && (
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogTrigger asChild>
<Button><Plus className="mr-2 h-4 w-4" />New Instructor</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader><DialogTitle>Create Instructor</DialogTitle></DialogHeader>
<InstructorForm onSubmit={createMutation.mutate} loading={createMutation.isPending} />
</DialogContent>
</Dialog>
)}
</div>
<DataTable
columns={instructorColumns}
data={data?.data ?? []}
loading={isLoading}
page={params.page}
totalPages={data?.pagination.totalPages ?? 1}
total={data?.pagination.total ?? 0}
sort={params.sort}
order={params.order}
onPageChange={setPage}
onSort={setSort}
onRowClick={(i) => navigate({ to: '/lessons/schedule/instructors/$instructorId', params: { instructorId: i.id }, search: {} as any })}
/>
</div>
)
}
// ─── Lesson Types Tab ─────────────────────────────────────────────────────────
const lessonTypeColumns: Column<LessonType>[] = [
{ key: 'name', header: 'Name', sortable: true, render: (lt) => <span className="font-medium">{lt.name}</span> },
{ key: 'instrument', header: 'Instrument', render: (lt) => <>{lt.instrument ?? <span className="text-muted-foreground"></span>}</> },
{ key: 'duration_minutes', header: 'Duration', sortable: true, render: (lt) => <>{lt.durationMinutes} min</> },
{ key: 'lesson_format', header: 'Format', render: (lt) => <Badge variant="outline">{lt.lessonFormat}</Badge> },
{ key: 'rate_monthly', header: 'Monthly Rate', render: (lt) => <>{lt.rateMonthly ? `$${lt.rateMonthly}` : <span className="text-muted-foreground"></span>}</> },
{ key: 'is_active', header: 'Status', render: (lt) => <Badge variant={lt.isActive ? 'default' : 'secondary'}>{lt.isActive ? 'Active' : 'Inactive'}</Badge> },
]
function LessonTypesTab({ canAdmin, search }: { canAdmin: boolean; search: any }) {
const queryClient = useQueryClient()
const { params, setPage, setSearch, setSort } = usePagination()
const [searchInput, setSearchInput] = useState(params.q ?? '')
const [createOpen, setCreateOpen] = useState(false)
const [editTarget, setEditTarget] = useState<LessonType | null>(null)
const { data, isLoading } = useQuery(lessonTypeListOptions(params))
const createMutation = useMutation({
mutationFn: lessonTypeMutations.create,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: lessonTypeKeys.all })
toast.success('Lesson type created')
setCreateOpen(false)
},
onError: (err) => toast.error(err.message),
})
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) => lessonTypeMutations.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: lessonTypeKeys.all })
toast.success('Lesson type updated')
setEditTarget(null)
},
onError: (err) => toast.error(err.message),
})
const deleteMutation = useMutation({
mutationFn: lessonTypeMutations.delete,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: lessonTypeKeys.all })
toast.success('Lesson type removed')
},
onError: (err) => toast.error(err.message),
})
function handleSearchSubmit(e: React.FormEvent) {
e.preventDefault()
setSearch(searchInput)
}
const columnsWithActions: Column<LessonType>[] = [
...lessonTypeColumns,
...(canAdmin ? [{
key: 'actions' as any,
header: '' as any,
render: (lt: LessonType) => (
<div className="flex gap-1 justify-end">
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); setEditTarget(lt) }}>Edit</Button>
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); deleteMutation.mutate(lt.id) }}>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
),
}] : []),
]
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<form onSubmit={handleSearchSubmit} className="flex gap-2 max-w-sm">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search lesson types..."
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
className="pl-9"
/>
</div>
<Button type="submit" variant="secondary">Search</Button>
</form>
{canAdmin && (
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogTrigger asChild>
<Button><Plus className="mr-2 h-4 w-4" />New Lesson Type</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader><DialogTitle>Create Lesson Type</DialogTitle></DialogHeader>
<LessonTypeForm onSubmit={createMutation.mutate} loading={createMutation.isPending} />
</DialogContent>
</Dialog>
)}
</div>
{editTarget && (
<Dialog open={!!editTarget} onOpenChange={(o) => { if (!o) setEditTarget(null) }}>
<DialogContent>
<DialogHeader><DialogTitle>Edit Lesson Type</DialogTitle></DialogHeader>
<LessonTypeForm
defaultValues={editTarget}
onSubmit={(data) => updateMutation.mutate({ id: editTarget.id, data })}
loading={updateMutation.isPending}
/>
</DialogContent>
</Dialog>
)}
<DataTable
columns={columnsWithActions}
data={data?.data ?? []}
loading={isLoading}
page={params.page}
totalPages={data?.pagination.totalPages ?? 1}
total={data?.pagination.total ?? 0}
sort={params.sort}
order={params.order}
onPageChange={setPage}
onSort={setSort}
/>
</div>
)
}
// ─── Grading Scales Tab ───────────────────────────────────────────────────────
const gradingScaleColumns: Column<GradingScale>[] = [
{ key: 'name', header: 'Name', sortable: true, render: (gs) => <span className="font-medium">{gs.name}</span> },
{
key: 'is_default', header: '', render: (gs) => gs.isDefault
? <Badge variant="default">Default</Badge>
: null,
},
{ key: 'levels', header: 'Levels', render: (gs) => <>{gs.levels?.length ?? 0}</> },
{ key: 'is_active', header: 'Status', render: (gs) => <Badge variant={gs.isActive ? 'default' : 'secondary'}>{gs.isActive ? 'Active' : 'Inactive'}</Badge> },
]
function GradingScalesTab({ canAdmin, search }: { canAdmin: boolean; search: any }) {
const queryClient = useQueryClient()
const { params, setPage, setSort } = usePagination()
const [createOpen, setCreateOpen] = useState(false)
const { data, isLoading } = useQuery(gradingScaleListOptions(params))
const createMutation = useMutation({
mutationFn: gradingScaleMutations.create,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: gradingScaleKeys.all })
toast.success('Grading scale created')
setCreateOpen(false)
},
onError: (err) => toast.error(err.message),
})
const deleteMutation = useMutation({
mutationFn: gradingScaleMutations.delete,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: gradingScaleKeys.all })
toast.success('Grading scale removed')
},
onError: (err) => toast.error(err.message),
})
const columnsWithActions: Column<GradingScale>[] = [
...gradingScaleColumns,
...(canAdmin ? [{
key: 'actions' as any,
header: '' as any,
render: (gs: GradingScale) => (
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); deleteMutation.mutate(gs.id) }}>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
),
}] : []),
]
return (
<div className="space-y-4">
<div className="flex justify-end">
{canAdmin && (
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogTrigger asChild>
<Button><Plus className="mr-2 h-4 w-4" />New Grading Scale</Button>
</DialogTrigger>
<DialogContent className="max-w-lg">
<DialogHeader><DialogTitle>Create Grading Scale</DialogTitle></DialogHeader>
<GradingScaleForm onSubmit={createMutation.mutate} loading={createMutation.isPending} />
</DialogContent>
</Dialog>
)}
</div>
<DataTable
columns={columnsWithActions}
data={data?.data ?? []}
loading={isLoading}
page={params.page}
totalPages={data?.pagination.totalPages ?? 1}
total={data?.pagination.total ?? 0}
sort={params.sort}
order={params.order}
onPageChange={setPage}
onSort={setSort}
/>
</div>
)
}
// ─── Store Closures Tab ───────────────────────────────────────────────────────
function StoreClosuresTab({ canAdmin }: { canAdmin: boolean }) {
const queryClient = useQueryClient()
const [createOpen, setCreateOpen] = useState(false)
const { data, isLoading } = useQuery(storeClosureListOptions())
const createMutation = useMutation({
mutationFn: storeClosureMutations.create,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: storeClosureKeys.all })
toast.success('Store closure added')
setCreateOpen(false)
},
onError: (err) => toast.error(err.message),
})
const deleteMutation = useMutation({
mutationFn: storeClosureMutations.delete,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: storeClosureKeys.all })
toast.success('Closure removed')
},
onError: (err) => toast.error(err.message),
})
const closures: StoreClosure[] = data ?? []
return (
<div className="space-y-4">
<div className="flex justify-end">
{canAdmin && (
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogTrigger asChild>
<Button><Plus className="mr-2 h-4 w-4" />Add Closure</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader><DialogTitle>Add Store Closure</DialogTitle></DialogHeader>
<StoreClosureForm onSubmit={createMutation.mutate} loading={createMutation.isPending} />
</DialogContent>
</Dialog>
)}
</div>
{isLoading ? (
<div className="text-sm text-muted-foreground">Loading...</div>
) : closures.length === 0 ? (
<div className="text-sm text-muted-foreground text-center py-8 border rounded-md">
No store closures configured.
</div>
) : (
<div className="divide-y border rounded-md">
{closures.map((c) => (
<div key={c.id} className="flex items-center justify-between px-4 py-3">
<div>
<div className="font-medium text-sm">{c.name}</div>
<div className="text-xs text-muted-foreground">
{new Date(c.startDate + 'T00:00:00').toLocaleDateString()} {' '}
{new Date(c.endDate + 'T00:00:00').toLocaleDateString()}
</div>
</div>
{canAdmin && (
<Button variant="ghost" size="sm" onClick={() => deleteMutation.mutate(c.id)}>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
)}
</div>
))}
</div>
)}
</div>
)
}