Some checks failed
Build & Release / build (push) Failing after 33s
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>
445 lines
17 KiB
TypeScript
445 lines
17 KiB
TypeScript
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>
|
||
)
|
||
}
|