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:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user