Files
lunarfront-app/packages/admin/src/routes/_authenticated/lessons/plans/$planId.tsx
Ryan Moon 5ad27bc196 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
2026-03-30 18:52:57 -05:00

205 lines
8.0 KiB
TypeScript

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