feat: unified station mode (POS + Repairs + Lessons) #14
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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} />
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user