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,320 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { useState } from 'react'
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { lessonPlanTemplateListOptions, lessonPlanTemplateMutations, lessonPlanTemplateKeys } 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 { Plus, Search, Trash2 } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import type { LessonPlanTemplate } from '@/types/lesson'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/lessons/templates/')({
|
||||
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') || 'asc',
|
||||
}),
|
||||
component: TemplatesListPage,
|
||||
})
|
||||
|
||||
const SKILL_LABELS: Record<string, string> = {
|
||||
beginner: 'Beginner',
|
||||
intermediate: 'Intermediate',
|
||||
advanced: 'Advanced',
|
||||
all_levels: 'All Levels',
|
||||
}
|
||||
|
||||
const SKILL_VARIANTS: Record<string, 'default' | 'secondary' | 'outline'> = {
|
||||
beginner: 'outline',
|
||||
intermediate: 'secondary',
|
||||
advanced: 'default',
|
||||
all_levels: 'outline',
|
||||
}
|
||||
|
||||
const columns: Column<LessonPlanTemplate>[] = [
|
||||
{ key: 'name', header: 'Name', sortable: true, render: (t) => <span className="font-medium">{t.name}</span> },
|
||||
{ key: 'instrument', header: 'Instrument', render: (t) => <>{t.instrument ?? <span className="text-muted-foreground">—</span>}</> },
|
||||
{
|
||||
key: 'skill_level', header: 'Level', sortable: true,
|
||||
render: (t) => <Badge variant={SKILL_VARIANTS[t.skillLevel] ?? 'outline'}>{SKILL_LABELS[t.skillLevel] ?? t.skillLevel}</Badge>,
|
||||
},
|
||||
{
|
||||
key: 'sections', header: 'Sections',
|
||||
render: (t) => <>{t.sections?.length ?? 0} sections</>,
|
||||
},
|
||||
{
|
||||
key: 'is_active', header: 'Status',
|
||||
render: (t) => <Badge variant={t.isActive ? 'default' : 'secondary'}>{t.isActive ? 'Active' : 'Inactive'}</Badge>,
|
||||
},
|
||||
]
|
||||
|
||||
function TemplatesListPage() {
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
const hasPermission = useAuthStore((s) => s.hasPermission)
|
||||
const canAdmin = hasPermission('lessons.admin')
|
||||
const { params, setPage, setSearch, setSort } = usePagination()
|
||||
const [searchInput, setSearchInput] = useState(params.q ?? '')
|
||||
|
||||
const { data, isLoading } = useQuery(lessonPlanTemplateListOptions(params))
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: lessonPlanTemplateMutations.delete,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: lessonPlanTemplateKeys.all })
|
||||
toast.success('Template deleted')
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
function handleSearchSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setSearch(searchInput)
|
||||
}
|
||||
|
||||
const columnsWithActions: Column<LessonPlanTemplate>[] = [
|
||||
...columns,
|
||||
...(canAdmin ? [{
|
||||
key: 'actions' as any,
|
||||
header: '' as any,
|
||||
render: (t: LessonPlanTemplate) => (
|
||||
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); deleteMutation.mutate(t.id) }}>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
),
|
||||
}] : []),
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">Lesson Plan Templates</h1>
|
||||
{canAdmin && (
|
||||
<Button onClick={() => navigate({ to: '/lessons/templates/new', search: {} as any })}>
|
||||
<Plus className="mr-2 h-4 w-4" />New Template
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<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 templates..."
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" variant="secondary">Search</Button>
|
||||
</form>
|
||||
|
||||
<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}
|
||||
onRowClick={(t) => navigate({ to: '/lessons/templates/$templateId', params: { templateId: t.id }, search: {} as any })}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { useState } from 'react'
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { lessonPlanTemplateMutations } from '@/api/lessons'
|
||||
import { TemplateSectionBuilder, type TemplateSectionRow } from '@/components/lessons/template-section-builder'
|
||||
import { Button } from '@/components/ui/button'
|
||||
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 { ArrowLeft } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/lessons/templates/new')({
|
||||
component: NewTemplatePage,
|
||||
})
|
||||
|
||||
function NewTemplatePage() {
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [name, setName] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [instrument, setInstrument] = useState('')
|
||||
const [skillLevel, setSkillLevel] = useState('all_levels')
|
||||
const [sections, setSections] = useState<TemplateSectionRow[]>([])
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () =>
|
||||
lessonPlanTemplateMutations.create({
|
||||
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: (template) => {
|
||||
toast.success('Template created')
|
||||
navigate({ to: '/lessons/templates/$templateId', params: { templateId: template.id }, search: {} as any })
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!name.trim()) return
|
||||
mutation.mutate()
|
||||
}
|
||||
|
||||
const allSectionsValid = sections.every(
|
||||
(s) => s.title.trim() && s.items.every((i) => i.title.trim()),
|
||||
)
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6 max-w-3xl">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => navigate({ to: '/lessons/templates', search: {} as any })}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<h1 className="text-2xl font-bold">New Template</h1>
|
||||
</div>
|
||||
|
||||
<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)} placeholder="e.g. Piano Foundations — Beginner" required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Description</Label>
|
||||
<Textarea value={description} onChange={(e) => setDescription(e.target.value)} rows={2} placeholder="What this curriculum covers..." />
|
||||
</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, Guitar" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Skill Level</Label>
|
||||
<Select value={skillLevel} onValueChange={setSkillLevel}>
|
||||
<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>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button type="submit" disabled={mutation.isPending || !name.trim() || !allSectionsValid} size="lg">
|
||||
{mutation.isPending ? 'Creating...' : 'Create Template'}
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" size="lg" onClick={() => navigate({ to: '/lessons/templates', search: {} as any })}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user