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,444 @@
|
||||
import { useState } from 'react'
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
instructorListOptions, instructorMutations, instructorKeys,
|
||||
lessonTypeListOptions, lessonTypeMutations, lessonTypeKeys,
|
||||
gradingScaleListOptions, gradingScaleMutations, gradingScaleKeys,
|
||||
storeClosureListOptions, storeClosureMutations, storeClosureKeys,
|
||||
} from '@/api/lessons'
|
||||
import { usePagination } from '@/hooks/use-pagination'
|
||||
import { DataTable, type Column } from '@/components/shared/data-table'
|
||||
import { InstructorForm } from '@/components/lessons/instructor-form'
|
||||
import { LessonTypeForm } from '@/components/lessons/lesson-type-form'
|
||||
import { GradingScaleForm } from '@/components/lessons/grading-scale-form'
|
||||
import { StoreClosureForm } from '@/components/lessons/store-closure-form'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
|
||||
import { Plus, Search, Trash2 } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import type { Instructor, LessonType, GradingScale, StoreClosure } from '@/types/lesson'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/lessons/schedule/')({
|
||||
validateSearch: (search: Record<string, unknown>) => ({
|
||||
tab: (search.tab as string) || 'instructors',
|
||||
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: ScheduleHubPage,
|
||||
})
|
||||
|
||||
const TABS = [
|
||||
{ key: 'instructors', label: 'Instructors' },
|
||||
{ key: 'lesson-types', label: 'Lesson Types' },
|
||||
{ key: 'grading-scales', label: 'Grading Scales' },
|
||||
{ key: 'closures', label: 'Store Closures' },
|
||||
]
|
||||
|
||||
function ScheduleHubPage() {
|
||||
const navigate = useNavigate()
|
||||
const search = Route.useSearch()
|
||||
const tab = search.tab
|
||||
const hasPermission = useAuthStore((s) => s.hasPermission)
|
||||
const canAdmin = hasPermission('lessons.admin')
|
||||
|
||||
function setTab(t: string) {
|
||||
navigate({ to: '/lessons/schedule', search: { ...search, tab: t, page: 1 } as any })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold">Lessons Setup</h1>
|
||||
<div className="flex gap-1 border-b">
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => setTab(t.key)}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
tab === t.key
|
||||
? 'border-primary text-primary'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === 'instructors' && <InstructorsTab canAdmin={canAdmin} search={search} />}
|
||||
{tab === 'lesson-types' && <LessonTypesTab canAdmin={canAdmin} search={search} />}
|
||||
{tab === 'grading-scales' && <GradingScalesTab canAdmin={canAdmin} search={search} />}
|
||||
{tab === 'closures' && <StoreClosuresTab canAdmin={canAdmin} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Instructors Tab ──────────────────────────────────────────────────────────
|
||||
|
||||
const instructorColumns: Column<Instructor>[] = [
|
||||
{ key: 'display_name', header: 'Name', sortable: true, render: (i) => <span className="font-medium">{i.displayName}</span> },
|
||||
{ key: 'instruments', header: 'Instruments', render: (i) => <>{i.instruments?.join(', ') || <span className="text-muted-foreground">—</span>}</> },
|
||||
{
|
||||
key: 'is_active', header: 'Status', sortable: true,
|
||||
render: (i) => <Badge variant={i.isActive ? 'default' : 'secondary'}>{i.isActive ? 'Active' : 'Inactive'}</Badge>,
|
||||
},
|
||||
]
|
||||
|
||||
function InstructorsTab({ canAdmin, search }: { canAdmin: boolean; search: any }) {
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
const { params, setPage, setSearch, setSort } = usePagination()
|
||||
const [searchInput, setSearchInput] = useState(params.q ?? '')
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
|
||||
const { data, isLoading } = useQuery(instructorListOptions(params))
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: instructorMutations.create,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: instructorKeys.all })
|
||||
toast.success('Instructor created')
|
||||
setCreateOpen(false)
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
function handleSearchSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setSearch(searchInput)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<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 instructors..."
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" variant="secondary">Search</Button>
|
||||
</form>
|
||||
{canAdmin && (
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button><Plus className="mr-2 h-4 w-4" />New Instructor</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>Create Instructor</DialogTitle></DialogHeader>
|
||||
<InstructorForm onSubmit={createMutation.mutate} loading={createMutation.isPending} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
<DataTable
|
||||
columns={instructorColumns}
|
||||
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={(i) => navigate({ to: '/lessons/schedule/instructors/$instructorId', params: { instructorId: i.id }, search: {} as any })}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Lesson Types Tab ─────────────────────────────────────────────────────────
|
||||
|
||||
const lessonTypeColumns: Column<LessonType>[] = [
|
||||
{ key: 'name', header: 'Name', sortable: true, render: (lt) => <span className="font-medium">{lt.name}</span> },
|
||||
{ key: 'instrument', header: 'Instrument', render: (lt) => <>{lt.instrument ?? <span className="text-muted-foreground">—</span>}</> },
|
||||
{ key: 'duration_minutes', header: 'Duration', sortable: true, render: (lt) => <>{lt.durationMinutes} min</> },
|
||||
{ key: 'lesson_format', header: 'Format', render: (lt) => <Badge variant="outline">{lt.lessonFormat}</Badge> },
|
||||
{ key: 'rate_monthly', header: 'Monthly Rate', render: (lt) => <>{lt.rateMonthly ? `$${lt.rateMonthly}` : <span className="text-muted-foreground">—</span>}</> },
|
||||
{ key: 'is_active', header: 'Status', render: (lt) => <Badge variant={lt.isActive ? 'default' : 'secondary'}>{lt.isActive ? 'Active' : 'Inactive'}</Badge> },
|
||||
]
|
||||
|
||||
function LessonTypesTab({ canAdmin, search }: { canAdmin: boolean; search: any }) {
|
||||
const queryClient = useQueryClient()
|
||||
const { params, setPage, setSearch, setSort } = usePagination()
|
||||
const [searchInput, setSearchInput] = useState(params.q ?? '')
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [editTarget, setEditTarget] = useState<LessonType | null>(null)
|
||||
|
||||
const { data, isLoading } = useQuery(lessonTypeListOptions(params))
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: lessonTypeMutations.create,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: lessonTypeKeys.all })
|
||||
toast.success('Lesson type created')
|
||||
setCreateOpen(false)
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) => lessonTypeMutations.update(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: lessonTypeKeys.all })
|
||||
toast.success('Lesson type updated')
|
||||
setEditTarget(null)
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: lessonTypeMutations.delete,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: lessonTypeKeys.all })
|
||||
toast.success('Lesson type removed')
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
function handleSearchSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setSearch(searchInput)
|
||||
}
|
||||
|
||||
const columnsWithActions: Column<LessonType>[] = [
|
||||
...lessonTypeColumns,
|
||||
...(canAdmin ? [{
|
||||
key: 'actions' as any,
|
||||
header: '' as any,
|
||||
render: (lt: LessonType) => (
|
||||
<div className="flex gap-1 justify-end">
|
||||
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); setEditTarget(lt) }}>Edit</Button>
|
||||
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); deleteMutation.mutate(lt.id) }}>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
}] : []),
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<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 types..."
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" variant="secondary">Search</Button>
|
||||
</form>
|
||||
{canAdmin && (
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button><Plus className="mr-2 h-4 w-4" />New Lesson Type</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>Create Lesson Type</DialogTitle></DialogHeader>
|
||||
<LessonTypeForm onSubmit={createMutation.mutate} loading={createMutation.isPending} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{editTarget && (
|
||||
<Dialog open={!!editTarget} onOpenChange={(o) => { if (!o) setEditTarget(null) }}>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>Edit Lesson Type</DialogTitle></DialogHeader>
|
||||
<LessonTypeForm
|
||||
defaultValues={editTarget}
|
||||
onSubmit={(data) => updateMutation.mutate({ id: editTarget.id, data })}
|
||||
loading={updateMutation.isPending}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
|
||||
<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}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Grading Scales Tab ───────────────────────────────────────────────────────
|
||||
|
||||
const gradingScaleColumns: Column<GradingScale>[] = [
|
||||
{ key: 'name', header: 'Name', sortable: true, render: (gs) => <span className="font-medium">{gs.name}</span> },
|
||||
{
|
||||
key: 'is_default', header: '', render: (gs) => gs.isDefault
|
||||
? <Badge variant="default">Default</Badge>
|
||||
: null,
|
||||
},
|
||||
{ key: 'levels', header: 'Levels', render: (gs) => <>{gs.levels?.length ?? 0}</> },
|
||||
{ key: 'is_active', header: 'Status', render: (gs) => <Badge variant={gs.isActive ? 'default' : 'secondary'}>{gs.isActive ? 'Active' : 'Inactive'}</Badge> },
|
||||
]
|
||||
|
||||
function GradingScalesTab({ canAdmin, search }: { canAdmin: boolean; search: any }) {
|
||||
const queryClient = useQueryClient()
|
||||
const { params, setPage, setSort } = usePagination()
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
|
||||
const { data, isLoading } = useQuery(gradingScaleListOptions(params))
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: gradingScaleMutations.create,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: gradingScaleKeys.all })
|
||||
toast.success('Grading scale created')
|
||||
setCreateOpen(false)
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: gradingScaleMutations.delete,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: gradingScaleKeys.all })
|
||||
toast.success('Grading scale removed')
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const columnsWithActions: Column<GradingScale>[] = [
|
||||
...gradingScaleColumns,
|
||||
...(canAdmin ? [{
|
||||
key: 'actions' as any,
|
||||
header: '' as any,
|
||||
render: (gs: GradingScale) => (
|
||||
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); deleteMutation.mutate(gs.id) }}>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
),
|
||||
}] : []),
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-end">
|
||||
{canAdmin && (
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button><Plus className="mr-2 h-4 w-4" />New Grading Scale</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader><DialogTitle>Create Grading Scale</DialogTitle></DialogHeader>
|
||||
<GradingScaleForm onSubmit={createMutation.mutate} loading={createMutation.isPending} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
<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}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Store Closures Tab ───────────────────────────────────────────────────────
|
||||
|
||||
function StoreClosuresTab({ canAdmin }: { canAdmin: boolean }) {
|
||||
const queryClient = useQueryClient()
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
|
||||
const { data, isLoading } = useQuery(storeClosureListOptions())
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: storeClosureMutations.create,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: storeClosureKeys.all })
|
||||
toast.success('Store closure added')
|
||||
setCreateOpen(false)
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: storeClosureMutations.delete,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: storeClosureKeys.all })
|
||||
toast.success('Closure removed')
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const closures: StoreClosure[] = data ?? []
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-end">
|
||||
{canAdmin && (
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button><Plus className="mr-2 h-4 w-4" />Add Closure</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>Add Store Closure</DialogTitle></DialogHeader>
|
||||
<StoreClosureForm onSubmit={createMutation.mutate} loading={createMutation.isPending} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-sm text-muted-foreground">Loading...</div>
|
||||
) : closures.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground text-center py-8 border rounded-md">
|
||||
No store closures configured.
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y border rounded-md">
|
||||
{closures.map((c) => (
|
||||
<div key={c.id} className="flex items-center justify-between px-4 py-3">
|
||||
<div>
|
||||
<div className="font-medium text-sm">{c.name}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{new Date(c.startDate + 'T00:00:00').toLocaleDateString()} –{' '}
|
||||
{new Date(c.endDate + 'T00:00:00').toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
{canAdmin && (
|
||||
<Button variant="ghost" size="sm" onClick={() => deleteMutation.mutate(c.id)}>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user