feat: lessons station + technician workbench + polish
All checks were successful
CI / ci (pull_request) Successful in 26s
CI / e2e (pull_request) Successful in 1m8s

Phase 3: Technician workbench
- Focused single-ticket view with Work, Parts, Photos, Notes sections
- Template quick-add for line items, inline add/delete
- Ticket selector for multiple assigned tickets

Phase 4: Lessons desk view
- Today overview: all instructors' sessions, group by time/instructor
- Quick check-in (attended/missed/cancelled) buttons
- Highlights upcoming and overdue sessions
- Schedule view: weekly grid with instructor filter, open slots

Phase 5: Lessons instructor view
- My Sessions (today) + Week calendar sub-views
- Session detail with attendance, notes, plan items
- Week calendar with session blocks, click to open detail dialog

Phase 6: Polish
- Permission-based routing: desk vs tech/instructor views
- Build and lint clean

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
ryan
2026-04-06 01:46:51 +00:00
parent 0411df57eb
commit 3b0daeae0c
7 changed files with 747 additions and 11 deletions

View File

@@ -0,0 +1,45 @@
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { CalendarDays, Calendar } from 'lucide-react'
import { LessonsTodayOverview } from './lessons-today-overview'
import { LessonsScheduleView } from './lessons-schedule-view'
interface LessonsDeskViewProps {
canEdit: boolean
}
export function LessonsDeskView({ canEdit }: LessonsDeskViewProps) {
const [subView, setSubView] = useState<'today' | 'schedule'>('today')
return (
<div className="flex flex-col h-full">
{/* Sub-view toggle */}
<div className="flex items-center gap-1 px-3 py-2 border-b border-border shrink-0 bg-card/50">
<Button
variant={subView === 'today' ? 'default' : 'ghost'}
size="sm"
className="h-8 gap-1.5"
onClick={() => setSubView('today')}
>
<CalendarDays className="h-3.5 w-3.5" />
Today
</Button>
<Button
variant={subView === 'schedule' ? 'default' : 'ghost'}
size="sm"
className="h-8 gap-1.5"
onClick={() => setSubView('schedule')}
>
<Calendar className="h-3.5 w-3.5" />
Schedule
</Button>
</div>
{/* Content */}
<div className="flex-1 min-h-0 overflow-hidden">
{subView === 'today' && <LessonsTodayOverview canEdit={canEdit} />}
{subView === 'schedule' && <LessonsScheduleView />}
</div>
</div>
)
}

View File

@@ -0,0 +1,56 @@
import { useState } from 'react'
import { usePOSStore } from '@/stores/pos.store'
import { Button } from '@/components/ui/button'
import { CalendarDays, CalendarRange } from 'lucide-react'
import { LessonsTodayOverview } from './lessons-today-overview'
import { LessonsWeekView } from './lessons-week-view'
interface LessonsInstructorViewProps {
canEdit: boolean
}
export function LessonsInstructorView({ canEdit }: LessonsInstructorViewProps) {
const [subView, setSubView] = useState<'today' | 'week'>('today')
const cashier = usePOSStore((s) => s.cashier)
// TODO: Map cashier user ID to instructor ID
// For now, the instructor view shows the same data as desk view
// but filtered by the logged-in user's instructor record
return (
<div className="flex flex-col h-full">
{/* Sub-view toggle */}
<div className="flex items-center gap-1 px-3 py-2 border-b border-border shrink-0 bg-card/50">
<Button
variant={subView === 'today' ? 'default' : 'ghost'}
size="sm"
className="h-8 gap-1.5"
onClick={() => setSubView('today')}
>
<CalendarDays className="h-3.5 w-3.5" />
My Sessions
</Button>
<Button
variant={subView === 'week' ? 'default' : 'ghost'}
size="sm"
className="h-8 gap-1.5"
onClick={() => setSubView('week')}
>
<CalendarRange className="h-3.5 w-3.5" />
Week
</Button>
{cashier && (
<span className="text-xs text-muted-foreground ml-auto">
{cashier.firstName} {cashier.lastName}
</span>
)}
</div>
{/* Content */}
<div className="flex-1 min-h-0 overflow-hidden">
{subView === 'today' && <LessonsTodayOverview canEdit={canEdit} />}
{subView === 'week' && <LessonsWeekView canEdit={canEdit} />}
</div>
</div>
)
}

View File

@@ -0,0 +1,104 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { instructorListOptions, scheduleSlotListOptions } from '@/api/lessons'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Badge } from '@/components/ui/badge'
import type { ScheduleSlot, Instructor } from '@/types/lesson'
const DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
const HOURS = Array.from({ length: 14 }, (_, i) => i + 7) // 7 AM to 8 PM
export function LessonsScheduleView() {
const [selectedInstructorId, setSelectedInstructorId] = useState<string>('all')
const { data: instructorsData } = useQuery({
...instructorListOptions({ page: 1, limit: 100, q: undefined, sort: 'name', order: 'asc' }),
})
const instructors = (instructorsData?.data ?? []).filter((i: Instructor) => i.isActive !== false)
const { data: slotsData } = useQuery({
...scheduleSlotListOptions(
{ page: 1, limit: 500, q: undefined, sort: undefined, order: 'asc' },
selectedInstructorId !== 'all' ? { instructorId: selectedInstructorId } : undefined,
),
})
const slots = slotsData?.data ?? []
// Group slots by day
function getSlotsForDay(dayOfWeek: number) {
return slots.filter((s: ScheduleSlot) => s.dayOfWeek === dayOfWeek)
}
function formatTime(time: string) {
const [h, m] = time.split(':').map(Number)
const ampm = h >= 12 ? 'PM' : 'AM'
const hour = h > 12 ? h - 12 : h === 0 ? 12 : h
return `${hour}:${m.toString().padStart(2, '0')} ${ampm}`
}
return (
<div className="flex flex-col h-full">
{/* Controls */}
<div className="flex items-center gap-3 px-3 py-2 border-b border-border shrink-0 bg-card/50">
<span className="text-sm font-medium">Weekly Schedule</span>
<Select value={selectedInstructorId} onValueChange={setSelectedInstructorId}>
<SelectTrigger className="h-8 w-48">
<SelectValue placeholder="All Instructors" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Instructors</SelectItem>
{instructors.map((inst: Instructor) => (
<SelectItem key={inst.id} value={inst.id}>{inst.displayName}</SelectItem>
))}
</SelectContent>
</Select>
<Badge variant="outline" className="text-xs">{slots.length} slots</Badge>
</div>
{/* Weekly grid */}
<div className="flex-1 overflow-auto">
<div className="grid grid-cols-8 min-w-[800px]">
{/* Header row */}
<div className="sticky top-0 bg-card border-b border-r border-border p-2 text-xs font-medium text-muted-foreground z-10" />
{DAYS.map((day) => (
<div key={day} className="sticky top-0 bg-card border-b border-border p-2 text-xs font-medium text-center z-10">
{day.slice(0, 3)}
</div>
))}
{/* Time rows */}
{HOURS.map((hour) => (
<>
<div key={`h-${hour}`} className="border-r border-b border-border p-1 text-xs text-muted-foreground text-right pr-2">
{hour > 12 ? hour - 12 : hour}{hour >= 12 ? 'p' : 'a'}
</div>
{DAYS.map((_, dayIdx) => {
const daySlots = getSlotsForDay(dayIdx).filter((s: ScheduleSlot) => {
const slotHour = parseInt(s.startTime.split(':')[0])
return slotHour === hour
})
return (
<div key={`${hour}-${dayIdx}`} className="border-b border-border min-h-[48px] p-0.5">
{daySlots.map((slot: ScheduleSlot) => {
const instructor = instructors.find((i: Instructor) => i.id === slot.instructorId)
return (
<div
key={slot.id}
className="bg-primary/10 border border-primary/20 rounded px-1.5 py-0.5 text-xs mb-0.5"
>
<div className="font-medium truncate">{formatTime(slot.startTime)}</div>
{instructor && <div className="text-muted-foreground truncate">{instructor.displayName}</div>}
<Badge variant="outline" className="text-[9px] h-4">Open</Badge>
</div>
)
})}
</div>
)
})}
</>
))}
</div>
</div>
</div>
)
}

View File

@@ -1,17 +1,18 @@
import { GraduationCap } from 'lucide-react'
import { LessonsDeskView } from './lessons-desk-view'
import { LessonsInstructorView } from './lessons-instructor-view'
interface LessonsStationProps {
permissions: string[]
}
export function LessonsStation({ permissions: _permissions }: LessonsStationProps) {
return (
<div className="flex items-center justify-center h-full text-muted-foreground">
<div className="text-center space-y-2">
<GraduationCap className="h-12 w-12 mx-auto opacity-30" />
<p className="text-lg font-medium">Lessons Station</p>
<p className="text-sm">Coming soon</p>
</div>
</div>
)
export function LessonsStation({ permissions }: LessonsStationProps) {
const canEdit = permissions.includes('lessons.edit') || permissions.includes('lessons.admin')
// Front desk (admin/edit) gets desk view with full overview
// Instructor (view only) gets focused instructor view
if (canEdit) {
return <LessonsDeskView canEdit={canEdit} />
}
return <LessonsInstructorView canEdit={false} />
}

View File

@@ -0,0 +1,189 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { sessionListOptions } from '@/api/lessons'
import type { LessonSession } from '@/types/lesson'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { SessionDetailPanel } from './session-detail-panel'
import { CheckCircle, Clock, XCircle, AlertCircle } from 'lucide-react'
const STATUS_CONFIG: Record<string, { label: string; color: string; icon: typeof CheckCircle }> = {
scheduled: { label: 'Scheduled', color: 'bg-blue-500/10 text-blue-500', icon: Clock },
attended: { label: 'Attended', color: 'bg-green-500/10 text-green-500', icon: CheckCircle },
missed: { label: 'Missed', color: 'bg-red-500/10 text-red-500', icon: XCircle },
makeup: { label: 'Makeup', color: 'bg-purple-500/10 text-purple-500', icon: Clock },
cancelled: { label: 'Cancelled', color: 'bg-muted text-muted-foreground', icon: XCircle },
}
interface LessonsTodayOverviewProps {
canEdit: boolean
}
export function LessonsTodayOverview({ canEdit }: LessonsTodayOverviewProps) {
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(null)
const [groupBy, setGroupBy] = useState<'time' | 'instructor'>('time')
const today = new Date().toISOString().slice(0, 10)
const { data, isLoading } = useQuery({
...sessionListOptions({
page: 1,
limit: 100,
sort: 'scheduled_time',
order: 'asc',
scheduledDateFrom: today,
scheduledDateTo: today,
}),
staleTime: 30_000,
refetchInterval: 60_000,
})
const sessions = data?.data ?? []
// Check if a session is upcoming (within next 30 min)
function isUpcoming(session: LessonSession) {
if (session.status !== 'scheduled') return false
const now = new Date()
const [hours, minutes] = session.scheduledTime.split(':').map(Number)
const sessionTime = new Date(session.scheduledDate + 'T00:00:00')
sessionTime.setHours(hours, minutes)
const diff = sessionTime.getTime() - now.getTime()
return diff > 0 && diff <= 30 * 60_000
}
// Check if overdue (scheduled, past time)
function isOverdue(session: LessonSession) {
if (session.status !== 'scheduled') return false
const now = new Date()
const [hours, minutes] = session.scheduledTime.split(':').map(Number)
const sessionTime = new Date(session.scheduledDate + 'T00:00:00')
sessionTime.setHours(hours, minutes)
return sessionTime.getTime() < now.getTime()
}
// Group by instructor
const byInstructor = sessions.reduce((acc, s) => {
const name = s.instructorName ?? 'Unassigned'
if (!acc[name]) acc[name] = []
acc[name].push(s)
return acc
}, {} as Record<string, LessonSession[]>)
// Status counts
const scheduled = sessions.filter(s => s.status === 'scheduled').length
const attended = sessions.filter(s => s.status === 'attended').length
const missed = sessions.filter(s => s.status === 'missed').length
return (
<div className="flex flex-col h-full">
{/* Summary bar */}
<div className="flex items-center gap-3 px-3 py-2 border-b border-border shrink-0 bg-card/50">
<span className="text-sm font-medium">Today</span>
<Badge variant="outline" className="text-xs">{sessions.length} sessions</Badge>
{scheduled > 0 && <Badge variant="outline" className="text-xs bg-blue-500/10 text-blue-500">{scheduled} scheduled</Badge>}
{attended > 0 && <Badge variant="outline" className="text-xs bg-green-500/10 text-green-500">{attended} attended</Badge>}
{missed > 0 && <Badge variant="outline" className="text-xs bg-red-500/10 text-red-500">{missed} missed</Badge>}
<div className="flex-1" />
<div className="flex gap-1">
<Button variant={groupBy === 'time' ? 'default' : 'ghost'} size="sm" className="h-7 text-xs" onClick={() => setGroupBy('time')}>By Time</Button>
<Button variant={groupBy === 'instructor' ? 'default' : 'ghost'} size="sm" className="h-7 text-xs" onClick={() => setGroupBy('instructor')}>By Instructor</Button>
</div>
</div>
{/* Split view */}
<div className="flex flex-1 min-h-0">
{/* Session list */}
<div className="w-[40%] border-r border-border overflow-y-auto">
{isLoading ? (
<div className="p-3 space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="h-14 bg-muted animate-pulse rounded-lg" />
))}
</div>
) : sessions.length === 0 ? (
<div className="flex items-center justify-center h-full text-muted-foreground">
<p className="text-sm">No sessions today</p>
</div>
) : groupBy === 'time' ? (
<div className="px-2 py-2 space-y-1">
{sessions.map((session) => (
<SessionRow
key={session.id}
session={session}
selected={selectedSessionId === session.id}
upcoming={isUpcoming(session)}
overdue={isOverdue(session)}
onClick={() => setSelectedSessionId(session.id)}
/>
))}
</div>
) : (
<div className="px-2 py-2 space-y-3">
{Object.entries(byInstructor).map(([instructor, instructorSessions]) => (
<div key={instructor}>
<p className="text-xs font-semibold text-muted-foreground px-2 mb-1">{instructor}</p>
<div className="space-y-1">
{instructorSessions.map((session) => (
<SessionRow
key={session.id}
session={session}
selected={selectedSessionId === session.id}
upcoming={isUpcoming(session)}
overdue={isOverdue(session)}
onClick={() => setSelectedSessionId(session.id)}
/>
))}
</div>
</div>
))}
</div>
)}
</div>
{/* Detail panel */}
<div className="w-[60%] overflow-hidden">
<SessionDetailPanel sessionId={selectedSessionId} canEdit={canEdit} />
</div>
</div>
</div>
)
}
function SessionRow({ session, selected, upcoming, overdue, onClick }: {
session: LessonSession
selected: boolean
upcoming: boolean
overdue: boolean
onClick: () => void
}) {
const cfg = STATUS_CONFIG[session.status] ?? { label: session.status, color: '', icon: Clock }
const StatusIcon = cfg.icon
return (
<button
className={`w-full text-left p-3 rounded-lg transition-colors ${
selected ? 'bg-primary/10 border border-primary/20' :
upcoming ? 'bg-yellow-500/5 border border-yellow-500/20 hover:bg-yellow-500/10' :
overdue ? 'bg-red-500/5 border border-red-500/20 hover:bg-red-500/10' :
'hover:bg-muted border border-transparent'
}`}
onClick={onClick}
>
<div className="flex items-center justify-between mb-0.5">
<span className="text-sm font-medium">{session.scheduledTime}</span>
<div className="flex items-center gap-1">
{overdue && <AlertCircle className="h-3.5 w-3.5 text-red-500" />}
<Badge variant="outline" className={`text-[10px] ${cfg.color}`}>
<StatusIcon className="h-2.5 w-2.5 mr-0.5" />
{cfg.label}
</Badge>
</div>
</div>
<div className="text-sm">{session.memberName ?? 'Unknown'}</div>
<div className="flex items-center justify-between text-xs text-muted-foreground mt-0.5">
<span>{session.lessonTypeName ?? 'Lesson'}</span>
<span>{session.instructorName ?? ''}</span>
</div>
</button>
)
}

View File

@@ -0,0 +1,140 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { sessionListOptions } from '@/api/lessons'
import type { LessonSession } from '@/types/lesson'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent } from '@/components/ui/dialog'
import { SessionDetailPanel } from './session-detail-panel'
import { ChevronLeft, ChevronRight } from 'lucide-react'
const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
const STATUS_COLORS: Record<string, string> = {
scheduled: 'bg-blue-500/15 border-blue-500/30 text-blue-700 dark:text-blue-300',
attended: 'bg-green-500/15 border-green-500/30 text-green-700 dark:text-green-300',
missed: 'bg-red-500/15 border-red-500/30 text-red-700 dark:text-red-300',
makeup: 'bg-purple-500/15 border-purple-500/30 text-purple-700 dark:text-purple-300',
cancelled: 'bg-muted border-border text-muted-foreground',
}
interface LessonsWeekViewProps {
canEdit: boolean
instructorId?: string
}
export function LessonsWeekView({ canEdit, instructorId }: LessonsWeekViewProps) {
const [weekOffset, setWeekOffset] = useState(0)
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(null)
// Get week start (Monday) and end (Sunday)
const now = new Date()
const monday = new Date(now)
monday.setDate(now.getDate() - ((now.getDay() + 6) % 7) + weekOffset * 7)
monday.setHours(0, 0, 0, 0)
const sunday = new Date(monday)
sunday.setDate(monday.getDate() + 6)
const weekStart = monday.toISOString().slice(0, 10)
const weekEnd = sunday.toISOString().slice(0, 10)
const { data } = useQuery({
...sessionListOptions({
page: 1,
limit: 200,
sort: 'scheduled_time',
order: 'asc',
scheduledDateFrom: weekStart,
scheduledDateTo: weekEnd,
...(instructorId ? { instructorId } : {}),
}),
staleTime: 30_000,
})
const sessions = data?.data ?? []
// Group sessions by date
const byDate = new Map<string, LessonSession[]>()
for (const s of sessions) {
const existing = byDate.get(s.scheduledDate) ?? []
existing.push(s)
byDate.set(s.scheduledDate, existing)
}
// Generate week dates
const weekDates = Array.from({ length: 7 }, (_, i) => {
const d = new Date(monday)
d.setDate(monday.getDate() + i)
return d.toISOString().slice(0, 10)
})
const todayStr = new Date().toISOString().slice(0, 10)
return (
<div className="flex flex-col h-full">
{/* Week navigation */}
<div className="flex items-center justify-between px-3 py-2 border-b border-border shrink-0 bg-card/50">
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => setWeekOffset(weekOffset - 1)}>
<ChevronLeft className="h-4 w-4" />
</Button>
<div className="text-sm font-medium">
{monday.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })} {sunday.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
</div>
<div className="flex gap-1">
{weekOffset !== 0 && (
<Button variant="ghost" size="sm" className="h-8 text-xs" onClick={() => setWeekOffset(0)}>Today</Button>
)}
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => setWeekOffset(weekOffset + 1)}>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
{/* Calendar grid */}
<div className="flex-1 overflow-auto">
<div className="grid grid-cols-7 min-h-full">
{weekDates.map((dateStr, i) => {
const daySessions = byDate.get(dateStr) ?? []
const isToday = dateStr === todayStr
const dayDate = new Date(dateStr + 'T00:00:00')
return (
<div key={dateStr} className={`border-r border-border ${i === 6 ? 'border-r-0' : ''}`}>
{/* Day header */}
<div className={`sticky top-0 z-10 p-2 border-b border-border text-center ${isToday ? 'bg-primary/5' : 'bg-card'}`}>
<div className="text-xs text-muted-foreground">{DAYS[dayDate.getDay()]}</div>
<div className={`text-sm font-medium ${isToday ? 'text-primary' : ''}`}>
{dayDate.getDate()}
</div>
</div>
{/* Sessions */}
<div className="p-1 space-y-1">
{daySessions.map((session) => (
<button
key={session.id}
className={`w-full text-left p-2 rounded border text-xs transition-colors hover:opacity-80 ${STATUS_COLORS[session.status] ?? ''}`}
onClick={() => setSelectedSessionId(session.id)}
>
<div className="font-medium">{session.scheduledTime}</div>
<div className="truncate">{session.memberName ?? 'Unknown'}</div>
<div className="truncate text-muted-foreground">{session.lessonTypeName ?? ''}</div>
</button>
))}
{daySessions.length === 0 && (
<div className="text-xs text-muted-foreground text-center py-4 opacity-30"></div>
)}
</div>
</div>
)
})}
</div>
</div>
{/* Session detail dialog */}
<Dialog open={!!selectedSessionId} onOpenChange={(open) => { if (!open) setSelectedSessionId(null) }}>
<DialogContent className="max-w-lg max-h-[80vh] overflow-hidden p-0">
<SessionDetailPanel sessionId={selectedSessionId} canEdit={canEdit} />
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -0,0 +1,201 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { sessionDetailOptions, sessionKeys, sessionMutations, sessionPlanItemsOptions } from '@/api/lessons'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
import { CheckCircle, XCircle, Clock, GraduationCap } from 'lucide-react'
import { toast } from 'sonner'
const STATUS_CONFIG: Record<string, { label: string; color: string }> = {
scheduled: { label: 'Scheduled', color: 'bg-blue-500/10 text-blue-500' },
attended: { label: 'Attended', color: 'bg-green-500/10 text-green-500' },
missed: { label: 'Missed', color: 'bg-red-500/10 text-red-500' },
makeup: { label: 'Makeup', color: 'bg-purple-500/10 text-purple-500' },
cancelled: { label: 'Cancelled', color: 'bg-muted text-muted-foreground' },
}
interface SessionDetailPanelProps {
sessionId: string | null
canEdit: boolean
}
export function SessionDetailPanel({ sessionId, canEdit }: SessionDetailPanelProps) {
const queryClient = useQueryClient()
const [notes, setNotes] = useState('')
const [notesLoaded, setNotesLoaded] = useState(false)
const { data: session, isLoading } = useQuery({
...sessionDetailOptions(sessionId ?? ''),
enabled: !!sessionId,
})
const { data: planItems } = useQuery({
...sessionPlanItemsOptions(sessionId ?? ''),
enabled: !!sessionId,
})
// Sync notes from session data
if (session && !notesLoaded) {
setNotes(session.instructorNotes ?? '')
setNotesLoaded(true)
}
const statusMutation = useMutation({
mutationFn: (status: string) => sessionMutations.updateStatus(sessionId!, status),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: sessionKeys.detail(sessionId!) })
queryClient.invalidateQueries({ queryKey: sessionKeys.all })
toast.success('Attendance recorded')
},
onError: (err) => toast.error(err.message),
})
const notesMutation = useMutation({
mutationFn: () => sessionMutations.updateNotes(sessionId!, { instructorNotes: notes }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: sessionKeys.detail(sessionId!) })
toast.success('Notes saved')
},
onError: (err) => toast.error(err.message),
})
if (!sessionId) {
return (
<div className="flex items-center justify-center h-full text-muted-foreground">
<div className="text-center space-y-2">
<GraduationCap className="h-10 w-10 mx-auto opacity-20" />
<p className="text-sm">Select a session</p>
</div>
</div>
)
}
if (isLoading || !session) {
return (
<div className="p-4 space-y-4">
<div className="h-8 bg-muted animate-pulse rounded" />
<div className="h-20 bg-muted animate-pulse rounded" />
</div>
)
}
const status = STATUS_CONFIG[session.status] ?? { label: session.status, color: '' }
return (
<div className="flex flex-col h-full overflow-hidden">
{/* Header */}
<div className="p-4 border-b border-border shrink-0">
<div className="flex items-center justify-between mb-2">
<div>
<h3 className="text-lg font-semibold">{session.memberName ?? 'Unknown Student'}</h3>
<p className="text-sm text-muted-foreground">
{session.lessonTypeName ?? 'Lesson'} {session.instructorName ?? 'No instructor'}
</p>
</div>
<Badge variant="outline" className={status.color}>{status.label}</Badge>
</div>
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<span>{new Date(session.scheduledDate + 'T00:00:00').toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' })}</span>
<span>{session.scheduledTime}</span>
</div>
</div>
{/* Attendance buttons */}
{canEdit && session.status === 'scheduled' && (
<div className="flex gap-2 p-3 border-b border-border shrink-0 bg-muted/30">
<Button
className="flex-1 h-12 gap-2 bg-green-600 hover:bg-green-700"
onClick={() => statusMutation.mutate('attended')}
disabled={statusMutation.isPending}
>
<CheckCircle className="h-5 w-5" />
Attended
</Button>
<Button
variant="outline"
className="flex-1 h-12 gap-2 text-red-500 border-red-500/30 hover:bg-red-500/10"
onClick={() => statusMutation.mutate('missed')}
disabled={statusMutation.isPending}
>
<XCircle className="h-5 w-5" />
Missed
</Button>
<Button
variant="outline"
className="flex-1 h-12 gap-2"
onClick={() => statusMutation.mutate('cancelled')}
disabled={statusMutation.isPending}
>
<Clock className="h-5 w-5" />
Cancel
</Button>
</div>
)}
{/* Content */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{/* Plan items */}
{planItems && planItems.length > 0 && (
<div>
<Label className="text-xs text-muted-foreground">Lesson Plan Items</Label>
<div className="mt-2 space-y-1">
{planItems.map((item) => (
<div key={item.id} className="flex items-center gap-2 py-1 text-sm">
<div className="h-4 w-4 rounded border border-border" />
<span>{item.lessonPlanItemId}</span>
</div>
))}
</div>
</div>
)}
{/* Notes */}
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">Instructor Notes</Label>
<Textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Session notes, observations, homework..."
rows={4}
disabled={!canEdit}
/>
{canEdit && (
<Button
size="sm"
onClick={() => notesMutation.mutate()}
disabled={notesMutation.isPending || notes === (session.instructorNotes ?? '')}
>
{notesMutation.isPending ? 'Saving...' : 'Save Notes'}
</Button>
)}
</div>
{/* Previous notes */}
{session.homeworkAssigned && (
<div>
<Label className="text-xs text-muted-foreground">Homework Assigned</Label>
<p className="text-sm mt-1">{session.homeworkAssigned}</p>
</div>
)}
{session.nextLessonGoals && (
<div>
<Label className="text-xs text-muted-foreground">Next Lesson Goals</Label>
<p className="text-sm mt-1">{session.nextLessonGoals}</p>
</div>
)}
{session.topicsCovered && session.topicsCovered.length > 0 && (
<div>
<Label className="text-xs text-muted-foreground">Topics Covered</Label>
<div className="flex gap-1.5 flex-wrap mt-1">
{session.topicsCovered.map((topic, i) => (
<Badge key={i} variant="outline" className="text-xs">{topic}</Badge>
))}
</div>
</div>
)}
</div>
</div>
)
}