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,204 @@
import { useState } from 'react'
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { lessonPlanDetailOptions, lessonPlanMutations, lessonPlanKeys, lessonPlanItemMutations } from '@/api/lessons'
import { GradeEntryDialog } from '@/components/lessons/grade-entry-dialog'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { ArrowLeft, Star } from 'lucide-react'
import { toast } from 'sonner'
import { useAuthStore } from '@/stores/auth.store'
import type { LessonPlanItem } from '@/types/lesson'
export const Route = createFileRoute('/_authenticated/lessons/plans/$planId')({
component: LessonPlanDetailPage,
})
const STATUSES = ['not_started', 'in_progress', 'mastered', 'skipped'] as const
type ItemStatus = typeof STATUSES[number]
const STATUS_LABELS: Record<ItemStatus, string> = {
not_started: 'Not Started',
in_progress: 'In Progress',
mastered: 'Mastered',
skipped: 'Skipped',
}
const STATUS_VARIANTS: Record<ItemStatus, 'default' | 'secondary' | 'outline'> = {
not_started: 'outline',
in_progress: 'secondary',
mastered: 'default',
skipped: 'outline',
}
function nextStatus(current: ItemStatus): ItemStatus {
const idx = STATUSES.indexOf(current)
return STATUSES[(idx + 1) % STATUSES.length]
}
function LessonPlanDetailPage() {
const { planId } = Route.useParams()
const navigate = useNavigate()
const queryClient = useQueryClient()
const hasPermission = useAuthStore((s) => s.hasPermission)
const canEdit = hasPermission('lessons.edit')
const { data: plan, isLoading } = useQuery(lessonPlanDetailOptions(planId))
const [gradeItem, setGradeItem] = useState<LessonPlanItem | null>(null)
const [editingTitle, setEditingTitle] = useState(false)
const [titleInput, setTitleInput] = useState('')
const updatePlanMutation = useMutation({
mutationFn: (data: Record<string, unknown>) => lessonPlanMutations.update(planId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: lessonPlanKeys.detail(planId) })
setEditingTitle(false)
},
onError: (err) => toast.error(err.message),
})
const updateItemMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) =>
lessonPlanItemMutations.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: lessonPlanKeys.detail(planId) })
},
onError: (err) => toast.error(err.message),
})
if (isLoading) return <div className="text-sm text-muted-foreground">Loading...</div>
if (!plan) return <div className="text-sm text-destructive">Plan not found.</div>
const totalItems = plan.sections.flatMap((s) => s.items).filter((i) => i.status !== 'skipped').length
const masteredItems = plan.sections.flatMap((s) => s.items).filter((i) => i.status === 'mastered').length
function startEditTitle() {
setTitleInput(plan!.title)
setEditingTitle(true)
}
function saveTitle() {
if (titleInput.trim() && titleInput !== plan!.title) {
updatePlanMutation.mutate({ title: titleInput.trim() })
} else {
setEditingTitle(false)
}
}
function cycleStatus(item: LessonPlanItem) {
updateItemMutation.mutate({ id: item.id, data: { status: nextStatus(item.status as ItemStatus) } })
}
return (
<div className="space-y-6 max-w-3xl">
<div className="flex items-center gap-3">
<Button variant="ghost" size="sm" onClick={() => navigate({ to: '/lessons/plans', search: {} as any })}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="flex-1">
{editingTitle ? (
<div className="flex gap-2 items-center">
<Input
value={titleInput}
onChange={(e) => setTitleInput(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') saveTitle(); if (e.key === 'Escape') setEditingTitle(false) }}
className="text-xl font-bold h-9"
autoFocus
/>
<Button size="sm" onClick={saveTitle} disabled={updatePlanMutation.isPending}>Save</Button>
<Button size="sm" variant="ghost" onClick={() => setEditingTitle(false)}>Cancel</Button>
</div>
) : (
<h1
className={`text-2xl font-bold ${canEdit ? 'cursor-pointer hover:underline decoration-dashed' : ''}`}
onClick={canEdit ? startEditTitle : undefined}
title={canEdit ? 'Click to edit' : undefined}
>
{plan.title}
</h1>
)}
</div>
<Badge variant={plan.isActive ? 'default' : 'secondary'}>{plan.isActive ? 'Active' : 'Inactive'}</Badge>
</div>
{/* Progress */}
<div className="space-y-1">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">{masteredItems} / {totalItems} mastered</span>
<span className="font-medium">{Math.round(plan.progress)}%</span>
</div>
<div className="w-full bg-muted rounded-full h-2.5">
<div className="bg-primary h-2.5 rounded-full transition-all" style={{ width: `${plan.progress}%` }} />
</div>
</div>
{/* Sections */}
<div className="space-y-4">
{plan.sections.map((section) => (
<details key={section.id} open className="border rounded-lg">
<summary className="px-4 py-3 cursor-pointer font-semibold text-sm select-none hover:bg-muted/30 rounded-t-lg">
{section.title}
<span className="ml-2 text-xs font-normal text-muted-foreground">
({section.items.filter((i) => i.status === 'mastered').length}/{section.items.length})
</span>
</summary>
<div className="divide-y">
{section.items.map((item) => (
<div key={item.id} className="flex items-center gap-3 px-4 py-2.5">
{canEdit ? (
<button
onClick={() => cycleStatus(item)}
className="shrink-0"
title={`Click to change: ${STATUS_LABELS[item.status as ItemStatus]}`}
>
<Badge
variant={STATUS_VARIANTS[item.status as ItemStatus]}
className={`text-xs cursor-pointer ${item.status === 'mastered' ? 'bg-green-600 text-white border-green-600' : ''}`}
>
{STATUS_LABELS[item.status as ItemStatus]}
</Badge>
</button>
) : (
<Badge variant={STATUS_VARIANTS[item.status as ItemStatus]} className="text-xs shrink-0">
{STATUS_LABELS[item.status as ItemStatus]}
</Badge>
)}
<div className="flex-1 min-w-0">
<p className="text-sm">{item.title}</p>
{item.description && <p className="text-xs text-muted-foreground">{item.description}</p>}
</div>
{item.currentGradeValue && (
<Badge variant="outline" className="text-xs shrink-0">{item.currentGradeValue}</Badge>
)}
{canEdit && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0"
onClick={() => setGradeItem(item)}
title="Record grade"
>
<Star className="h-3.5 w-3.5" />
</Button>
)}
</div>
))}
</div>
</details>
))}
</div>
{gradeItem && (
<GradeEntryDialog
item={gradeItem}
open={!!gradeItem}
onClose={() => setGradeItem(null)}
/>
)}
</div>
)
}

View File

@@ -0,0 +1,91 @@
import { useState } from 'react'
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { lessonPlanListOptions } from '@/api/lessons'
import { usePagination } from '@/hooks/use-pagination'
import { DataTable, type Column } from '@/components/shared/data-table'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Search } from 'lucide-react'
import type { LessonPlan } from '@/types/lesson'
export const Route = createFileRoute('/_authenticated/lessons/plans/')({
validateSearch: (search: Record<string, unknown>) => ({
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') || 'desc',
}),
component: LessonPlansPage,
})
const columns: Column<LessonPlan>[] = [
{ key: 'title', header: 'Title', sortable: true, render: (p) => <span className="font-medium">{p.title}</span> },
{
key: 'progress', header: 'Progress', sortable: true,
render: (p) => (
<div className="flex items-center gap-2">
<div className="w-24 bg-muted rounded-full h-2">
<div className="bg-primary h-2 rounded-full" style={{ width: `${p.progress}%` }} />
</div>
<span className="text-xs text-muted-foreground">{Math.round(p.progress)}%</span>
</div>
),
},
{
key: 'is_active', header: 'Status',
render: (p) => <Badge variant={p.isActive ? 'default' : 'secondary'}>{p.isActive ? 'Active' : 'Inactive'}</Badge>,
},
{
key: 'created_at', header: 'Created', sortable: true,
render: (p) => <>{new Date(p.createdAt).toLocaleDateString()}</>,
},
]
function LessonPlansPage() {
const navigate = useNavigate()
const { params, setPage, setSearch, setSort } = usePagination()
const [searchInput, setSearchInput] = useState(params.q ?? '')
const { data, isLoading } = useQuery(lessonPlanListOptions(params))
function handleSearchSubmit(e: React.FormEvent) {
e.preventDefault()
setSearch(searchInput)
}
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold">Lesson Plans</h1>
<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 plans..."
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
className="pl-9"
/>
</div>
<Button type="submit" variant="secondary">Search</Button>
</form>
<DataTable
columns={columns}
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={(p) => navigate({ to: '/lessons/plans/$planId', params: { planId: p.id }, search: {} as any })}
/>
</div>
)
}