Files
lunarfront-app/packages/admin/src/routes/_authenticated/lessons/sessions/index.tsx
Ryan Moon a84530e80e
Some checks failed
Build & Release / build (push) Failing after 32s
fix: replace invalid TanStack Router search casts with typed defaults
Newer TanStack Router enforces strict types on search params — 'search: {} as Record<string, unknown>' no longer satisfies routes with validateSearch. Replace all occurrences with the correct search shape for each destination route (pagination defaults for list routes, tab/field defaults for detail routes).

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

278 lines
12 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 } from '@tanstack/react-query'
import { format, startOfWeek, endOfWeek, addWeeks, subWeeks, addDays, isSameDay } from 'date-fns'
import { sessionListOptions } from '@/api/lessons'
import { instructorListOptions } 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Search, LayoutList, CalendarDays, ChevronLeft, ChevronRight } from 'lucide-react'
import type { LessonSession } from '@/types/lesson'
export const Route = createFileRoute('/_authenticated/lessons/sessions/')({
validateSearch: (search: Record<string, unknown>) => ({
view: (search.view as 'list' | 'week') || 'list',
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') || 'desc',
status: (search.status as string) || undefined,
instructorId: (search.instructorId as string) || undefined,
}),
component: SessionsPage,
})
const STATUS_COLORS: Record<string, string> = {
attended: 'bg-green-100 border-green-400 text-green-800',
missed: 'bg-red-100 border-red-400 text-red-800',
cancelled: 'bg-gray-100 border-gray-300 text-gray-500',
makeup: 'bg-purple-100 border-purple-400 text-purple-800',
scheduled: 'bg-blue-100 border-blue-400 text-blue-800',
}
function sessionStatusBadge(status: string) {
const variants: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
scheduled: 'outline', attended: 'default', missed: 'destructive', makeup: 'secondary', cancelled: 'secondary',
}
return <Badge variant={variants[status] ?? 'outline'}>{status}</Badge>
}
function formatTime(t: string) {
const [h, m] = t.split(':').map(Number)
const ampm = h >= 12 ? 'PM' : 'AM'
const hour = h % 12 || 12
return `${hour}:${String(m).padStart(2, '0')} ${ampm}`
}
const listColumns: Column<LessonSession>[] = [
{
key: 'scheduled_date', header: 'Date', sortable: true,
render: (s) => <>{new Date(s.scheduledDate + 'T00:00:00').toLocaleDateString()}</>,
},
{
key: 'scheduled_time', header: 'Time',
render: (s) => <>{formatTime(s.scheduledTime)}</>,
},
{
key: 'member_name', header: 'Member',
render: (s) => <span className="font-medium">{s.memberName ?? '—'}</span>,
},
{
key: 'instructor_name', header: 'Instructor',
render: (s) => <>{s.instructorName ?? '—'}</>,
},
{
key: 'lesson_type', header: 'Lesson',
render: (s) => <>{s.lessonTypeName ?? '—'}</>,
},
{ key: 'status', header: 'Status', sortable: true, render: (s) => sessionStatusBadge(s.status) },
{
key: 'notes', header: 'Notes',
render: (s) => s.notesCompletedAt ? <Badge variant="outline" className="text-xs">Notes</Badge> : null,
},
]
const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
function SessionsPage() {
const navigate = useNavigate()
const search = Route.useSearch()
const view = search.view ?? 'list'
const { params, setPage, setSearch, setSort } = usePagination()
const [searchInput, setSearchInput] = useState(params.q ?? '')
const [statusFilter, setStatusFilter] = useState(search.status ?? '')
const [weekStart, setWeekStart] = useState(() => startOfWeek(new Date(), { weekStartsOn: 0 }))
const [weekInstructorId, setWeekInstructorId] = useState(search.instructorId ?? '')
const weekEnd = endOfWeek(weekStart, { weekStartsOn: 0 })
function setView(v: 'list' | 'week') {
navigate({ to: '/lessons/sessions', search: { ...search, view: v, page: 1 } })
}
function handleStatusChange(v: string) {
const s = v === 'all' ? '' : v
setStatusFilter(s)
navigate({ to: '/lessons/sessions', search: { ...search, status: s || undefined, page: 1 } })
}
// List query
const listQueryParams: Record<string, unknown> = { ...params }
if (statusFilter) listQueryParams.status = statusFilter
const { data: listData, isLoading: listLoading } = useQuery({
...sessionListOptions(listQueryParams),
enabled: view === 'list',
})
// Week query
const weekQueryParams: Record<string, unknown> = {
page: 1, limit: 100,
sort: 'scheduled_date', order: 'asc',
dateFrom: format(weekStart, 'yyyy-MM-dd'),
dateTo: format(weekEnd, 'yyyy-MM-dd'),
}
if (weekInstructorId) weekQueryParams.instructorId = weekInstructorId
const { data: weekData } = useQuery({
...sessionListOptions(weekQueryParams),
enabled: view === 'week',
})
const { data: instructorsData } = useQuery({
...instructorListOptions({ page: 1, limit: 100, order: 'asc' }),
enabled: view === 'week',
})
function handleSearchSubmit(e: React.FormEvent) {
e.preventDefault()
setSearch(searchInput)
}
const weekSessions = weekData?.data ?? []
const weekDays = Array.from({ length: 7 }, (_, i) => addDays(weekStart, i))
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Sessions</h1>
<div className="flex gap-1 border rounded-md p-1">
<Button variant={view === 'list' ? 'default' : 'ghost'} size="sm" onClick={() => setView('list')}>
<LayoutList className="h-4 w-4 mr-1" />List
</Button>
<Button variant={view === 'week' ? 'default' : 'ghost'} size="sm" onClick={() => setView('week')}>
<CalendarDays className="h-4 w-4 mr-1" />Week
</Button>
</div>
</div>
{view === 'list' && (
<>
<div className="flex gap-3 flex-wrap">
<form onSubmit={handleSearchSubmit} className="flex gap-2">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search sessions..."
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
className="pl-9 w-64"
/>
</div>
<Button type="submit" variant="secondary">Search</Button>
</form>
<Select value={statusFilter || 'all'} onValueChange={handleStatusChange}>
<SelectTrigger className="w-40">
<SelectValue placeholder="All Statuses" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Statuses</SelectItem>
<SelectItem value="scheduled">Scheduled</SelectItem>
<SelectItem value="attended">Attended</SelectItem>
<SelectItem value="missed">Missed</SelectItem>
<SelectItem value="makeup">Makeup</SelectItem>
<SelectItem value="cancelled">Cancelled</SelectItem>
</SelectContent>
</Select>
</div>
<DataTable
columns={listColumns}
data={listData?.data ?? []}
loading={listLoading}
page={params.page}
totalPages={listData?.pagination.totalPages ?? 1}
total={listData?.pagination.total ?? 0}
sort={params.sort}
order={params.order}
onPageChange={setPage}
onSort={setSort}
onRowClick={(s) => navigate({ to: '/lessons/sessions/$sessionId', params: { sessionId: s.id }, search: { page: 1, limit: 25, q: undefined, sort: undefined, order: 'asc' as const } })}
/>
</>
)}
{view === 'week' && (
<div className="space-y-4">
{/* Week nav + instructor filter */}
<div className="flex items-center gap-4 flex-wrap">
<div className="flex items-center gap-1">
<Button variant="outline" size="icon" onClick={() => setWeekStart(subWeeks(weekStart, 1))}>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button variant="outline" size="sm" onClick={() => setWeekStart(startOfWeek(new Date(), { weekStartsOn: 0 }))}>
This Week
</Button>
<Button variant="outline" size="icon" onClick={() => setWeekStart(addWeeks(weekStart, 1))}>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
<span className="text-sm font-medium text-muted-foreground">
{format(weekStart, 'MMM d')} {format(weekEnd, 'MMM d, yyyy')}
</span>
<Select value={weekInstructorId || 'all'} onValueChange={(v) => setWeekInstructorId(v === 'all' ? '' : v)}>
<SelectTrigger className="w-48">
<SelectValue placeholder="All Instructors" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Instructors</SelectItem>
{(instructorsData?.data ?? []).map((i) => (
<SelectItem key={i.id} value={i.id}>{i.displayName}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Week grid */}
<div className="grid grid-cols-7 gap-px bg-border rounded-lg overflow-hidden border">
{/* Day headers */}
{weekDays.map((day) => {
const isToday = isSameDay(day, new Date())
return (
<div key={day.toISOString()} className={`bg-muted/50 px-2 py-1.5 text-center ${isToday ? 'bg-primary/10' : ''}`}>
<p className="text-xs font-medium text-muted-foreground">{DAYS[day.getDay()]}</p>
<p className={`text-sm font-semibold ${isToday ? 'text-primary' : ''}`}>{format(day, 'd')}</p>
</div>
)
})}
{/* Session cells */}
{weekDays.map((day) => {
const daySessions = weekSessions.filter((s) => s.scheduledDate === format(day, 'yyyy-MM-dd'))
const isToday = isSameDay(day, new Date())
return (
<div key={day.toISOString()} className={`bg-background min-h-32 p-1.5 space-y-1 ${isToday ? 'bg-primary/5' : ''}`}>
{daySessions.length === 0 && (
<p className="text-xs text-muted-foreground/40 text-center pt-4"></p>
)}
{daySessions.map((s) => (
<button
key={s.id}
onClick={() => navigate({ to: '/lessons/sessions/$sessionId', params: { sessionId: s.id }, search: { page: 1, limit: 25, q: undefined, sort: undefined, order: 'asc' as const } })}
className={`w-full text-left rounded border px-1.5 py-1 text-xs hover:opacity-80 transition-opacity ${STATUS_COLORS[s.status] ?? STATUS_COLORS.scheduled}`}
>
<p className="font-semibold">{formatTime(s.scheduledTime)}</p>
<p className="truncate">{s.memberName ?? '—'}</p>
{s.lessonTypeName && <p className="truncate text-[10px] opacity-70">{s.lessonTypeName}</p>}
</button>
))}
</div>
)
})}
</div>
{/* Legend */}
<div className="flex gap-3 flex-wrap text-xs text-muted-foreground">
{Object.entries(STATUS_COLORS).map(([status, cls]) => (
<span key={status} className={`inline-flex items-center gap-1 px-2 py-0.5 rounded border ${cls}`}>
{status}
</span>
))}
</div>
</div>
)}
</div>
)
}