diff --git a/packages/admin/src/components/pos/pos-register.tsx b/packages/admin/src/components/pos/pos-register.tsx index 8237549..134f48a 100644 --- a/packages/admin/src/components/pos/pos-register.tsx +++ b/packages/admin/src/components/pos/pos-register.tsx @@ -40,20 +40,25 @@ function configOptions(key: string) { }) } -export function POSRegister() { +interface POSRegisterProps { + embedded?: boolean +} + +export function POSRegister({ embedded }: POSRegisterProps = {}) { const { locationId, setLocation, currentTransactionId, setDrawerSession, locked, lock, touchActivity, token } = usePOSStore() - // Fetch lock timeout from config + // Fetch lock timeout from config (standalone only) const { data: lockTimeoutStr } = useQuery({ ...configOptions('pos_lock_timeout'), - enabled: !!token, + enabled: !!token && !embedded, }) const lockTimeoutMinutes = parseInt(lockTimeoutStr ?? '15') || 15 - // Auto-lock timer + // Auto-lock timer (standalone only — station shell handles this when embedded) const timerRef = useRef | null>(null) useEffect(() => { + if (embedded) return if (locked || lockTimeoutMinutes === 0) { if (timerRef.current) clearInterval(timerRef.current) return @@ -69,26 +74,27 @@ export function POSRegister() { return () => { if (timerRef.current) clearInterval(timerRef.current) } - }, [locked, lockTimeoutMinutes, lock]) + }, [embedded, locked, lockTimeoutMinutes, lock]) - // Track activity on any interaction + // Track activity (standalone only) const handleActivity = useCallback(() => { - if (!locked) touchActivity() - }, [locked, touchActivity]) + if (!embedded && !locked) touchActivity() + }, [embedded, locked, touchActivity]) - // Fetch locations + // Fetch locations (standalone only — station shell handles this when embedded) const { data: locationsData } = useQuery({ ...locationsOptions(), - enabled: !!token, + enabled: !!token && !embedded, }) const locations = locationsData?.data ?? [] - // Auto-select first location + // Auto-select first location (standalone only) useEffect(() => { + if (embedded) return if (!locationId && locations.length > 0) { setLocation(locations[0].id) } - }, [locationId, locations, setLocation]) + }, [embedded, locationId, locations, setLocation]) // Fetch current drawer for selected location const { data: drawer } = useQuery({ @@ -112,6 +118,20 @@ export function POSRegister() { enabled: !!currentTransactionId && !!token, }) + // Embedded mode: just the content panels, no wrapper/lock/topbar + if (embedded) { + return ( +
+
+ +
+
+ +
+
+ ) + } + return (
('today') + + return ( +
+ {/* Sub-view toggle */} +
+ + +
+ + {/* Content */} +
+ {subView === 'today' && } + {subView === 'schedule' && } +
+
+ ) +} diff --git a/packages/admin/src/components/station-lessons/lessons-instructor-view.tsx b/packages/admin/src/components/station-lessons/lessons-instructor-view.tsx new file mode 100644 index 0000000..1e4d551 --- /dev/null +++ b/packages/admin/src/components/station-lessons/lessons-instructor-view.tsx @@ -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 ( +
+ {/* Sub-view toggle */} +
+ + + {cashier && ( + + {cashier.firstName} {cashier.lastName} + + )} +
+ + {/* Content */} +
+ {subView === 'today' && } + {subView === 'week' && } +
+
+ ) +} diff --git a/packages/admin/src/components/station-lessons/lessons-schedule-view.tsx b/packages/admin/src/components/station-lessons/lessons-schedule-view.tsx new file mode 100644 index 0000000..6c78d53 --- /dev/null +++ b/packages/admin/src/components/station-lessons/lessons-schedule-view.tsx @@ -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('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 ( +
+ {/* Controls */} +
+ Weekly Schedule + + {slots.length} slots +
+ + {/* Weekly grid */} +
+
+ {/* Header row */} +
+ {DAYS.map((day) => ( +
+ {day.slice(0, 3)} +
+ ))} + + {/* Time rows */} + {HOURS.map((hour) => ( + <> +
+ {hour > 12 ? hour - 12 : hour}{hour >= 12 ? 'p' : 'a'} +
+ {DAYS.map((_, dayIdx) => { + const daySlots = getSlotsForDay(dayIdx).filter((s: ScheduleSlot) => { + const slotHour = parseInt(s.startTime.split(':')[0]) + return slotHour === hour + }) + return ( +
+ {daySlots.map((slot: ScheduleSlot) => { + const instructor = instructors.find((i: Instructor) => i.id === slot.instructorId) + return ( +
+
{formatTime(slot.startTime)}
+ {instructor &&
{instructor.displayName}
} + Open +
+ ) + })} +
+ ) + })} + + ))} +
+
+
+ ) +} diff --git a/packages/admin/src/components/station-lessons/lessons-station.tsx b/packages/admin/src/components/station-lessons/lessons-station.tsx new file mode 100644 index 0000000..263e908 --- /dev/null +++ b/packages/admin/src/components/station-lessons/lessons-station.tsx @@ -0,0 +1,18 @@ +import { LessonsDeskView } from './lessons-desk-view' +import { LessonsInstructorView } from './lessons-instructor-view' + +interface LessonsStationProps { + permissions: string[] +} + +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 + } + + return +} diff --git a/packages/admin/src/components/station-lessons/lessons-today-overview.tsx b/packages/admin/src/components/station-lessons/lessons-today-overview.tsx new file mode 100644 index 0000000..589d540 --- /dev/null +++ b/packages/admin/src/components/station-lessons/lessons-today-overview.tsx @@ -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 = { + 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(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) + + // 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 ( +
+ {/* Summary bar */} +
+ Today + {sessions.length} sessions + {scheduled > 0 && {scheduled} scheduled} + {attended > 0 && {attended} attended} + {missed > 0 && {missed} missed} +
+
+ + +
+
+ + {/* Split view */} +
+ {/* Session list */} +
+ {isLoading ? ( +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+ ))} +
+ ) : sessions.length === 0 ? ( +
+

No sessions today

+
+ ) : groupBy === 'time' ? ( +
+ {sessions.map((session) => ( + setSelectedSessionId(session.id)} + /> + ))} +
+ ) : ( +
+ {Object.entries(byInstructor).map(([instructor, instructorSessions]) => ( +
+

{instructor}

+
+ {instructorSessions.map((session) => ( + setSelectedSessionId(session.id)} + /> + ))} +
+
+ ))} +
+ )} +
+ + {/* Detail panel */} +
+ +
+
+
+ ) +} + +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 ( + + ) +} diff --git a/packages/admin/src/components/station-lessons/lessons-week-view.tsx b/packages/admin/src/components/station-lessons/lessons-week-view.tsx new file mode 100644 index 0000000..55c19c0 --- /dev/null +++ b/packages/admin/src/components/station-lessons/lessons-week-view.tsx @@ -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 = { + 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(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() + 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 ( +
+ {/* Week navigation */} +
+ +
+ {monday.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })} — {sunday.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })} +
+
+ {weekOffset !== 0 && ( + + )} + +
+
+ + {/* Calendar grid */} +
+
+ {weekDates.map((dateStr, i) => { + const daySessions = byDate.get(dateStr) ?? [] + const isToday = dateStr === todayStr + const dayDate = new Date(dateStr + 'T00:00:00') + + return ( +
+ {/* Day header */} +
+
{DAYS[dayDate.getDay()]}
+
+ {dayDate.getDate()} +
+
+ + {/* Sessions */} +
+ {daySessions.map((session) => ( + + ))} + {daySessions.length === 0 && ( +
+ )} +
+
+ ) + })} +
+
+ + {/* Session detail dialog */} + { if (!open) setSelectedSessionId(null) }}> + + + + +
+ ) +} diff --git a/packages/admin/src/components/station-lessons/session-detail-panel.tsx b/packages/admin/src/components/station-lessons/session-detail-panel.tsx new file mode 100644 index 0000000..2969ce6 --- /dev/null +++ b/packages/admin/src/components/station-lessons/session-detail-panel.tsx @@ -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 = { + 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 ( +
+
+ +

Select a session

+
+
+ ) + } + + if (isLoading || !session) { + return ( +
+
+
+
+ ) + } + + const status = STATUS_CONFIG[session.status] ?? { label: session.status, color: '' } + + return ( +
+ {/* Header */} +
+
+
+

{session.memberName ?? 'Unknown Student'}

+

+ {session.lessonTypeName ?? 'Lesson'} — {session.instructorName ?? 'No instructor'} +

+
+ {status.label} +
+
+ {new Date(session.scheduledDate + 'T00:00:00').toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' })} + {session.scheduledTime} +
+
+ + {/* Attendance buttons */} + {canEdit && session.status === 'scheduled' && ( +
+ + + +
+ )} + + {/* Content */} +
+ {/* Plan items */} + {planItems && planItems.length > 0 && ( +
+ +
+ {planItems.map((item) => ( +
+
+ {item.lessonPlanItemId} +
+ ))} +
+
+ )} + + {/* Notes */} +
+ +