- 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
321 lines
13 KiB
TypeScript
321 lines
13 KiB
TypeScript
import { useState } from 'react'
|
|
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
|
import {
|
|
lessonPlanTemplateDetailOptions, lessonPlanTemplateMutations, lessonPlanTemplateKeys,
|
|
enrollmentListOptions,
|
|
} from '@/api/lessons'
|
|
import { globalMemberListOptions } from '@/api/members'
|
|
import { TemplateSectionBuilder, type TemplateSectionRow } from '@/components/lessons/template-section-builder'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import { Input } from '@/components/ui/input'
|
|
import { Label } from '@/components/ui/label'
|
|
import { Textarea } from '@/components/ui/textarea'
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
|
import { ArrowLeft, Search, X, Zap } from 'lucide-react'
|
|
import { toast } from 'sonner'
|
|
import { useAuthStore } from '@/stores/auth.store'
|
|
import type { LessonPlanTemplate } from '@/types/lesson'
|
|
import type { MemberWithAccount } from '@/api/members'
|
|
|
|
export const Route = createFileRoute('/_authenticated/lessons/templates/$templateId')({
|
|
component: TemplateDetailPage,
|
|
})
|
|
|
|
function TemplateDetailPage() {
|
|
const { templateId } = Route.useParams()
|
|
const navigate = useNavigate()
|
|
const queryClient = useQueryClient()
|
|
const hasPermission = useAuthStore((s) => s.hasPermission)
|
|
const canAdmin = hasPermission('lessons.admin')
|
|
|
|
const { data: template, isLoading } = useQuery(lessonPlanTemplateDetailOptions(templateId))
|
|
|
|
const [instantiateOpen, setInstantiateOpen] = useState(false)
|
|
|
|
if (isLoading) return <div className="text-sm text-muted-foreground">Loading...</div>
|
|
if (!template) return <div className="text-sm text-destructive">Template not found.</div>
|
|
|
|
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/templates', search: {} as any })}>
|
|
<ArrowLeft className="h-4 w-4" />
|
|
</Button>
|
|
<div className="flex-1">
|
|
<h1 className="text-2xl font-bold">{template.name}</h1>
|
|
{template.instrument && <p className="text-sm text-muted-foreground">{template.instrument}</p>}
|
|
</div>
|
|
<Badge variant={template.isActive ? 'default' : 'secondary'}>{template.isActive ? 'Active' : 'Inactive'}</Badge>
|
|
</div>
|
|
|
|
<div className="flex gap-2">
|
|
<Button onClick={() => setInstantiateOpen(true)}>
|
|
<Zap className="h-4 w-4 mr-2" />Instantiate for Student
|
|
</Button>
|
|
</div>
|
|
|
|
{canAdmin && (
|
|
<EditTemplateForm template={template} templateId={templateId} queryClient={queryClient} />
|
|
)}
|
|
|
|
{/* Read-only curriculum preview */}
|
|
{!canAdmin && (
|
|
<Card>
|
|
<CardHeader><CardTitle className="text-lg">Curriculum</CardTitle></CardHeader>
|
|
<CardContent className="space-y-4">
|
|
{template.sections.map((section) => (
|
|
<div key={section.id}>
|
|
<p className="font-semibold text-sm">{section.title}</p>
|
|
<ul className="mt-1 space-y-0.5 pl-4">
|
|
{section.items.map((item) => (
|
|
<li key={item.id} className="text-sm text-muted-foreground list-disc">{item.title}</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
))}
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
<InstantiateDialog
|
|
template={template}
|
|
templateId={templateId}
|
|
open={instantiateOpen}
|
|
onClose={() => setInstantiateOpen(false)}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ─── Edit Form ────────────────────────────────────────────────────────────────
|
|
|
|
function EditTemplateForm({ template, templateId, queryClient }: { template: LessonPlanTemplate; templateId: string; queryClient: any }) {
|
|
const [name, setName] = useState(template.name)
|
|
const [description, setDescription] = useState(template.description ?? '')
|
|
const [instrument, setInstrument] = useState(template.instrument ?? '')
|
|
const [skillLevel, setSkillLevel] = useState<'beginner' | 'intermediate' | 'advanced' | 'all_levels'>(template.skillLevel)
|
|
const [sections, setSections] = useState<TemplateSectionRow[]>(
|
|
template.sections.map((s) => ({
|
|
id: s.id,
|
|
title: s.title,
|
|
description: s.description ?? '',
|
|
items: s.items.map((i) => ({ id: i.id, title: i.title, description: i.description ?? '' })),
|
|
})),
|
|
)
|
|
|
|
const updateMutation = useMutation({
|
|
mutationFn: () =>
|
|
lessonPlanTemplateMutations.update(templateId, {
|
|
name,
|
|
description: description || undefined,
|
|
instrument: instrument || undefined,
|
|
skillLevel,
|
|
sections: sections.map((s, sIdx) => ({
|
|
title: s.title,
|
|
description: s.description || undefined,
|
|
sortOrder: sIdx,
|
|
items: s.items.map((item, iIdx) => ({
|
|
title: item.title,
|
|
description: item.description || undefined,
|
|
sortOrder: iIdx,
|
|
})),
|
|
})),
|
|
}),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: lessonPlanTemplateKeys.detail(templateId) })
|
|
toast.success('Template updated')
|
|
},
|
|
onError: (err: Error) => toast.error(err.message),
|
|
})
|
|
|
|
const allValid = name.trim() && sections.every((s) => s.title.trim() && s.items.every((i) => i.title.trim()))
|
|
|
|
return (
|
|
<form
|
|
onSubmit={(e) => { e.preventDefault(); updateMutation.mutate() }}
|
|
className="space-y-6"
|
|
>
|
|
<Card>
|
|
<CardHeader><CardTitle className="text-lg">Details</CardTitle></CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="space-y-2">
|
|
<Label>Name *</Label>
|
|
<Input value={name} onChange={(e) => setName(e.target.value)} required />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Description</Label>
|
|
<Textarea value={description} onChange={(e) => setDescription(e.target.value)} rows={2} />
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<Label>Instrument</Label>
|
|
<Input value={instrument} onChange={(e) => setInstrument(e.target.value)} placeholder="e.g. Piano" />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Skill Level</Label>
|
|
<Select value={skillLevel} onValueChange={(v) => setSkillLevel(v as 'beginner' | 'intermediate' | 'advanced' | 'all_levels')}>
|
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="beginner">Beginner</SelectItem>
|
|
<SelectItem value="intermediate">Intermediate</SelectItem>
|
|
<SelectItem value="advanced">Advanced</SelectItem>
|
|
<SelectItem value="all_levels">All Levels</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader><CardTitle className="text-lg">Curriculum</CardTitle></CardHeader>
|
|
<CardContent>
|
|
<TemplateSectionBuilder sections={sections} onChange={setSections} />
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Button type="submit" disabled={updateMutation.isPending || !allValid}>
|
|
{updateMutation.isPending ? 'Saving...' : 'Save Changes'}
|
|
</Button>
|
|
</form>
|
|
)
|
|
}
|
|
|
|
// ─── Instantiate Dialog ───────────────────────────────────────────────────────
|
|
|
|
function InstantiateDialog({ template, templateId, open, onClose }: {
|
|
template: LessonPlanTemplate
|
|
templateId: string
|
|
open: boolean
|
|
onClose: () => void
|
|
}) {
|
|
const navigate = useNavigate()
|
|
const [memberSearch, setMemberSearch] = useState('')
|
|
const [showDropdown, setShowDropdown] = useState(false)
|
|
const [selectedMember, setSelectedMember] = useState<MemberWithAccount | null>(null)
|
|
const [selectedEnrollmentId, setSelectedEnrollmentId] = useState('')
|
|
const [customTitle, setCustomTitle] = useState('')
|
|
|
|
const { data: membersData } = useQuery(
|
|
globalMemberListOptions({ page: 1, limit: 20, q: memberSearch || undefined, order: 'asc', sort: 'first_name' }),
|
|
)
|
|
|
|
const { data: enrollmentsData } = useQuery({
|
|
...enrollmentListOptions({ memberId: selectedMember?.id ?? '', status: 'active', page: 1, limit: 50 }),
|
|
enabled: !!selectedMember?.id,
|
|
})
|
|
|
|
const mutation = useMutation({
|
|
mutationFn: () =>
|
|
lessonPlanTemplateMutations.createPlan(templateId, {
|
|
memberId: selectedMember!.id,
|
|
enrollmentId: selectedEnrollmentId || undefined,
|
|
title: customTitle || undefined,
|
|
}),
|
|
onSuccess: (plan) => {
|
|
toast.success('Plan created from template')
|
|
navigate({ to: '/lessons/plans/$planId', params: { planId: plan.id }, search: {} as any })
|
|
},
|
|
onError: (err) => toast.error(err.message),
|
|
})
|
|
|
|
const members = membersData?.data ?? []
|
|
const enrollments = enrollmentsData?.data ?? []
|
|
|
|
function reset() {
|
|
setMemberSearch('')
|
|
setSelectedMember(null)
|
|
setSelectedEnrollmentId('')
|
|
setCustomTitle('')
|
|
}
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={(o) => { if (!o) { reset(); onClose() } }}>
|
|
<DialogContent className="max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle>Create Plan from "{template.name}"</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="space-y-4">
|
|
{/* Member select */}
|
|
{!selectedMember ? (
|
|
<div className="relative">
|
|
<Label>Student *</Label>
|
|
<div className="relative mt-1">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
<Input
|
|
placeholder="Search member..."
|
|
value={memberSearch}
|
|
onChange={(e) => { setMemberSearch(e.target.value); setShowDropdown(true) }}
|
|
onFocus={() => setShowDropdown(true)}
|
|
className="pl-9"
|
|
/>
|
|
</div>
|
|
{showDropdown && memberSearch && (
|
|
<div className="absolute z-50 mt-1 w-full rounded-md border bg-popover shadow-lg max-h-48 overflow-auto">
|
|
{members.length === 0 ? (
|
|
<div className="p-3 text-sm text-muted-foreground">No members found</div>
|
|
) : (
|
|
members.map((m) => (
|
|
<button
|
|
key={m.id}
|
|
type="button"
|
|
className="w-full text-left px-3 py-2 text-sm hover:bg-accent"
|
|
onClick={() => { setSelectedMember(m); setShowDropdown(false); setMemberSearch('') }}
|
|
>
|
|
<span className="font-medium">{m.firstName} {m.lastName}</span>
|
|
{m.accountName && <span className="text-muted-foreground ml-2">— {m.accountName}</span>}
|
|
</button>
|
|
))
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div>
|
|
<Label>Student</Label>
|
|
<div className="flex items-center justify-between mt-1 p-2 rounded-md border bg-muted/30">
|
|
<p className="text-sm font-medium">{selectedMember.firstName} {selectedMember.lastName}</p>
|
|
<Button type="button" variant="ghost" size="sm" onClick={() => { setSelectedMember(null); setSelectedEnrollmentId('') }}>
|
|
<X className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{selectedMember && enrollments.length > 0 && (
|
|
<div className="space-y-2">
|
|
<Label>Enrollment (optional)</Label>
|
|
<Select value={selectedEnrollmentId || 'none'} onValueChange={(v) => setSelectedEnrollmentId(v === 'none' ? '' : v)}>
|
|
<SelectTrigger><SelectValue placeholder="Not linked to enrollment" /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="none">Not linked to enrollment</SelectItem>
|
|
{enrollments.map((e: any) => (
|
|
<SelectItem key={e.id} value={e.id}>Enrollment {e.id.slice(-6)}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
)}
|
|
|
|
<div className="space-y-2">
|
|
<Label>Custom Title</Label>
|
|
<Input value={customTitle} onChange={(e) => setCustomTitle(e.target.value)} placeholder={`Leave blank to use "${template.name}"`} />
|
|
</div>
|
|
|
|
<Button
|
|
onClick={() => mutation.mutate()}
|
|
disabled={!selectedMember || mutation.isPending}
|
|
className="w-full"
|
|
>
|
|
{mutation.isPending ? 'Creating...' : 'Create Plan'}
|
|
</Button>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
}
|