Files
lunarfront-app/packages/admin/src/routes/_authenticated/lessons/schedule/index.tsx
Ryan Moon 505f8fd4e4
Some checks failed
Build & Release / build (push) Failing after 33s
fix: correct TanStack Router search types for all navigate/Link calls
Each destination route's search must match its validateSearch shape exactly:
- Detail pages (tab-based): { tab: '...' }
- List pages with extra filters: include status, instructorId, view, categoryId etc.
- Form pages (enrollments/new, repairs/new): include only their specific fields
- use-pagination.ts: fix search reducer to use (prev: any) instead of invalid cast

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 10:37:34 -05:00

445 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 } })
}
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: _search }: { canAdmin: boolean; search: Record<string, unknown> }) {
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: { tab: 'overview' } })}
/>
</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: _search }: { canAdmin: boolean; search: Record<string, unknown> }) {
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',
header: '',
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: _search }: { canAdmin: boolean; search: Record<string, unknown> }) {
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',
header: '',
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>
)
}