diff --git a/frontend/src/components/dashboard/TopContentList.tsx b/frontend/src/components/dashboard/TopContentList.tsx index fe1b65900..15e835254 100644 --- a/frontend/src/components/dashboard/TopContentList.tsx +++ b/frontend/src/components/dashboard/TopContentList.tsx @@ -1,5 +1,6 @@ import { OpenContentItem } from '@/types'; import { ExternalLink } from 'lucide-react'; +import { useNavigate } from 'react-router-dom'; interface TopContentListProps { heading: string; @@ -8,12 +9,22 @@ interface TopContentListProps { } function ContentRow({ item }: { item: OpenContentItem }) { + const navigate = useNavigate(); + + const handleClick = () => { + if (item.content_type === 'video') { + navigate(`/viewer/videos/${item.content_id}`); + } else if (item.content_type === 'library') { + navigate(`/viewer/libraries/${item.content_id}`); + } else { + window.open(item.url, '_blank', 'noopener,noreferrer'); + } + }; + return ( - {item.thumbnail_url ? ( )} - + ); } diff --git a/frontend/src/components/schedule/RescheduleSessionModal.tsx b/frontend/src/components/schedule/RescheduleSessionModal.tsx index ca7106f22..f3ccf07ba 100644 --- a/frontend/src/components/schedule/RescheduleSessionModal.tsx +++ b/frontend/src/components/schedule/RescheduleSessionModal.tsx @@ -15,6 +15,7 @@ import { SelectTrigger, SelectValue } from '@/components/ui/select'; +import { toDateInput, toTimeInput, formatDurationStr } from '@/lib/formatters'; interface RescheduleSessionModalProps { open: boolean; @@ -24,22 +25,6 @@ interface RescheduleSessionModalProps { onSuccess: () => void; } -function toDateInput(d: Date): string { - return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; -} - -function toTimeInput(d: Date): string { - return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; -} - -function formatDuration(startTime: string, endTime: string): string { - const [sh, sm] = startTime.split(':').map(Number); - const [eh, em] = endTime.split(':').map(Number); - const totalMin = (eh ?? 0) * 60 + (em ?? 0) - ((sh ?? 0) * 60 + (sm ?? 0)); - if (totalMin <= 0) return '0h0m0s'; - return `${Math.floor(totalMin / 60)}h${totalMin % 60}m0s`; -} - export function RescheduleSessionModal({ open, onOpenChange, @@ -76,7 +61,7 @@ export function RescheduleSessionModal({ const effectiveStart = startTime || toTimeInput(event.start); const effectiveEnd = endTime || toTimeInput(event.end); - const duration = formatDuration(effectiveStart, effectiveEnd); + const duration = formatDurationStr(effectiveStart, effectiveEnd); if (duration === '0h0m0s') { toast.error('End time must be after start time'); return; diff --git a/frontend/src/components/schedule/SessionDetailActions.tsx b/frontend/src/components/schedule/SessionDetailActions.tsx new file mode 100644 index 000000000..2b9fdbbb3 --- /dev/null +++ b/frontend/src/components/schedule/SessionDetailActions.tsx @@ -0,0 +1,97 @@ +import { CalendarClock, CalendarOff, CheckCircle, MapPin, Users } from 'lucide-react'; +import { Button } from '@/components/ui/button'; + +interface SessionDetailActionsProps { + canModify: boolean; + showTakeAttendance: boolean; + isCancelled: boolean; + onTakeAttendance?: () => void; + onRescheduleClick: () => void; + onCancelClick: () => void; + onChangeInstructorClick: () => void; + onChangeRoomClick: () => void; + onViewClassDetails?: () => void; +} + +export function SessionDetailActions({ + canModify, + showTakeAttendance, + isCancelled, + onTakeAttendance, + onRescheduleClick, + onCancelClick, + onChangeInstructorClick, + onChangeRoomClick, + onViewClassDetails +}: SessionDetailActionsProps) { + return ( + <> + {(canModify || showTakeAttendance) && ( +
+

+ Actions +

+
+ {showTakeAttendance && onTakeAttendance && ( + + )} + {canModify && ( + <> + + + + + + )} +
+
+ )} + + {onViewClassDetails && !isCancelled && ( +
+ +
+ )} + + ); +} diff --git a/frontend/src/components/schedule/SessionDetailClassDetails.tsx b/frontend/src/components/schedule/SessionDetailClassDetails.tsx new file mode 100644 index 000000000..03bca2639 --- /dev/null +++ b/frontend/src/components/schedule/SessionDetailClassDetails.tsx @@ -0,0 +1,103 @@ +import { Calendar, Clock, MapPin, Users } from 'lucide-react'; +import { formatClassTimeRange } from '@/lib/formatters'; + +interface SessionDetailClassDetailsProps { + className: string; + programName?: string; + classTime: string; + room: string; + originalRoom?: string; + instructorName?: string; + originalInstructorName?: string; + isCancelled: boolean; + isRescheduledFrom: boolean; + isCancelledReschedule: boolean; +} + +export function SessionDetailClassDetails({ + className, + programName, + classTime, + room, + originalRoom, + instructorName, + originalInstructorName, + isCancelled, + isRescheduledFrom, + isCancelledReschedule +}: SessionDetailClassDetailsProps) { + return ( +
+

+ Class Details +

+
+
+ +
+
+ Class +
+
+ {className} +
+ {programName && ( +
+ {programName} +
+ )} +
+
+
+ +
+
+ Time +
+
+ {formatClassTimeRange(classTime)} +
+
+
+
+ +
+
+ Room +
+
+ {originalRoom ?? room} +
+
+
+ {(originalInstructorName ?? instructorName) && ( +
+ +
+
+ Instructor +
+
+ {originalInstructorName ?? instructorName} +
+
+
+ )} +
+
+ ); +} diff --git a/frontend/src/components/schedule/SessionDetailHeader.tsx b/frontend/src/components/schedule/SessionDetailHeader.tsx new file mode 100644 index 000000000..d861ec6fc --- /dev/null +++ b/frontend/src/components/schedule/SessionDetailHeader.tsx @@ -0,0 +1,129 @@ +import { ReactNode } from 'react'; +import { Badge } from '@/components/ui/badge'; + +interface StatusFlags { + isCancelled: boolean; + isCancelledReschedule: boolean; + isRescheduledFrom: boolean; + isRescheduledTo: boolean; + hasAttendance: boolean; + isUpcoming: boolean; + hideRescheduledBadge: boolean; + showActiveBadge: boolean; +} + +function getStatusBadge(flags: StatusFlags): ReactNode { + const { + isCancelled, + isCancelledReschedule, + isRescheduledFrom, + isRescheduledTo, + hasAttendance, + isUpcoming, + hideRescheduledBadge, + showActiveBadge + } = flags; + + if (isCancelled || isCancelledReschedule) { + return ( + + Cancelled + + ); + } + if (isRescheduledFrom && !hideRescheduledBadge) { + return ( + + Rescheduled + + ); + } + if (isRescheduledTo && !hideRescheduledBadge) { + return ( + + Rescheduled Class + + ); + } + if (hasAttendance) { + return ( + + Completed + + ); + } + if (showActiveBadge) { + return ( + + Active + + ); + } + if (isUpcoming) { + return ( + + Scheduled + + ); + } + return ( + + Missing Attendance + + ); +} + +interface SessionDetailHeaderProps extends StatusFlags { + dateLabel: string; + isToday: boolean; +} + +export function SessionDetailHeader(props: SessionDetailHeaderProps) { + const { + dateLabel, + isToday, + isCancelled, + isRescheduledFrom, + isCancelledReschedule + } = props; + return ( +
+
+

+ {dateLabel} +

+
+ {getStatusBadge(props)} + {isToday && ( + + • Today's class + + )} +
+
+
+ ); +} diff --git a/frontend/src/components/schedule/SessionDetailSheet.tsx b/frontend/src/components/schedule/SessionDetailSheet.tsx index 0f8dda5cc..c38e81c7b 100644 --- a/frontend/src/components/schedule/SessionDetailSheet.tsx +++ b/frontend/src/components/schedule/SessionDetailSheet.tsx @@ -1,16 +1,5 @@ import { useMemo, useState } from 'react'; import useSWR from 'swr'; -import { - Calendar, - Clock, - MapPin, - CalendarOff, - CalendarClock, - CheckCircle, - Users -} from 'lucide-react'; -import { Button } from '@/components/ui/button'; -import { Badge } from '@/components/ui/badge'; import { Sheet, SheetContent, @@ -22,18 +11,20 @@ import { CancelEventModal } from '@/components/schedule/CancelEventModal'; import { ChangeInstructorModal } from '@/components/schedule/ChangeInstructorModal'; import { ChangeRoomModal } from '@/components/schedule/ChangeRoomModal'; import { RescheduleSessionModal } from '@/pages/class-detail/RescheduleSessionModal'; +import { SessionDetailHeader } from './SessionDetailHeader'; +import { SessionDetailClassDetails } from './SessionDetailClassDetails'; +import { SessionDetailStatusSection } from './SessionDetailStatusSection'; +import { SessionDetailActions } from './SessionDetailActions'; import { - findActiveOverride, + buildFacilityEvent, type SessionDisplay } from '@/pages/class-detail/session-utils'; import { FacilityProgramClassEvent, ProgramClassEvent, Room, - ServerResponseMany, - SelectedClassStatus + ServerResponseMany } from '@/types'; -import { formatClassTimeRange } from '@/lib/formatters'; interface SessionDetailSheetProps { session: SessionDisplay | null; @@ -74,51 +65,6 @@ interface SessionDetailSheetProps { disableModifyActions?: boolean; } -function buildFacilityEvent( - session: SessionDisplay, - classId: number, - classEvents: ProgramClassEvent[] -): FacilityProgramClassEvent { - const eventId = session.instance.event_id ?? session.instance.id; - const backingEvent = classEvents.find((e) => e.id === eventId) ?? classEvents[0]; - const activeOverride = findActiveOverride(classEvents, session.instance.date); - - const parts = session.instance.class_time.split('-'); - const [sh = 0, sm = 0] = (parts[0] ?? '').split(':').map(Number); - const [eh = 0, em = 0] = (parts[1] ?? '').split(':').map(Number); - - const start = new Date(session.dateObj); - start.setHours(sh, sm, 0, 0); - const end = new Date(session.dateObj); - end.setHours(eh, em, 0, 0); - - return { - id: eventId, - class_id: classId, - duration: backingEvent?.duration ?? '', - room_id: activeOverride?.room_id ?? backingEvent?.room_id ?? 0, - recurrence_rule: backingEvent?.recurrence_rule ?? '', - is_cancelled: session.instance.is_cancelled, - instructor_id: activeOverride?.instructor_id ?? backingEvent?.instructor_id ?? null, - overrides: backingEvent?.overrides ?? [], - reason: null, - start, - end, - is_override: !!activeOverride || !!session.instance.override_id, - override_id: activeOverride?.id ?? session.instance.override_id ?? 0, - linked_override_event: null as unknown as FacilityProgramClassEvent, - room: '', - instructor_name: '', - program_id: 0, - program_name: '', - title: '', - enrolled_users: '', - frequency: '', - credit_types: '', - class_status: SelectedClassStatus.Scheduled - }; -} - export function SessionDetailSheet({ session, onClose, @@ -223,77 +169,6 @@ export function SessionDetailSheet({ const facilityEvent = facilityEventOverride ?? buildFacilityEvent(session, classId, classEvents); - const getStatusBadge = () => { - if (isCancelled || isCancelledReschedule) { - return ( - - Cancelled - - ); - } - if (isRescheduledFrom && !hideRescheduledBadge) { - return ( - - Rescheduled - - ); - } - if (isRescheduledTo && !hideRescheduledBadge) { - return ( - - Rescheduled Class - - ); - } - if (hasAttendance) { - return ( - - Completed - - ); - } - if (showActiveBadge) { - return ( - - Active - - ); - } - if (session.isUpcoming) { - return ( - - Scheduled - - ); - } - return ( - - Missing Attendance - - ); - }; - const handleUndo = () => { onUndo(); onClose(); @@ -316,387 +191,69 @@ export function SessionDetailSheet({ -
-
-

- {dateLabel} -

-
- {getStatusBadge()} - {isToday && ( - - • Today's class - - )} -
-
-
+ -
-
-

- Class Details -

-
-
- -
-
- Class -
-
- {className} -
- {programName && ( -
- {programName} -
- )} -
-
-
- -
-
- Time -
-
- {formatClassTimeRange(classTime)} -
-
-
-
- -
-
- Room -
-
- {originalRoom ?? room} -
-
-
- {(originalInstructorName ?? instructorName) && ( -
- -
-
- Instructor -
-
- {originalInstructorName ?? instructorName} -
-
-
- )} -
-
- {(isCancelled || - isCancelledReschedule || - isRescheduledFrom || - isRescheduledTo || - hasAttendance || - (!isCancelled && (originalInstructorName ?? originalRoom))) && ( -
-

- Status -

-
- - {!isCancelled && originalInstructorName && instructorName && ( -
- -
-
Instructor Change
-

- Session Instructor: {instructorName} -

-
-
- )} - - {!isCancelled && originalRoom && ( -
- -
-
Room Change
-

- Session Room: {room} -

-
-
- )} - - {isCancelledReschedule && ( -
-
-
- -
-
- Class Cancelled -
- {cancellationReason && ( -

- {cancellationReason} -

- )} -
-
- -
- {rescheduledDate && ( -
-
- -
-
- Rescheduled Class -
-

- Originally scheduled for{' '} - {new Date(rescheduledDate + 'T00:00:00').toLocaleDateString('en-US', { - weekday: 'long', - month: 'long', - day: 'numeric' - })} -

-
-
- -
- )} -
- )} - - {isRescheduledFrom && !isRescheduledTo && rescheduledDate && ( -
-
- -
-
- Class Rescheduled -
-

- Moved to{' '} - {new Date(rescheduledDate + 'T00:00:00').toLocaleDateString('en-US', { - weekday: 'long', - month: 'long', - day: 'numeric' - })} -

-
-
- -
- )} - - {isRescheduledTo && ( -
-
- -
-
- Rescheduled Class -
- {rescheduledDate && ( -

- Originally scheduled for{' '} - {new Date(rescheduledDate + 'T00:00:00').toLocaleDateString('en-US', { - weekday: 'long', - month: 'long', - day: 'numeric' - })} -

- )} -
-
- -
- )} - - {isCancelled && ( -
-
- -
-
- Class Cancelled -
- {cancellationReason && ( -

- {cancellationReason} -

- )} -
-
- -
- )} - - {hasAttendance && ( -
-
- -
-
- Attendance Taken -
-

- This class cannot be - modified because attendance - has been recorded. -

-
-
-
- )} -
-
- )} - - {(canModify || showTakeAttendance) && ( -
-

- Actions -

-
- {showTakeAttendance && onTakeAttendance && ( - - )} - {canModify && ( - <> - - - - - - )} -
-
- )} - - {onViewClassDetails && !isCancelled && ( -
- -
- )} +
+ + + + + { + if (onReschedule) onReschedule(); + else setShowRescheduleModal(true); + }} + onCancelClick={() => setShowCancelModal(true)} + onChangeInstructorClick={() => + setShowChangeInstructor(true) + } + onChangeRoomClick={() => setShowChangeRoom(true)} + onViewClassDetails={onViewClassDetails} + />
diff --git a/frontend/src/components/schedule/SessionDetailStatusSection.tsx b/frontend/src/components/schedule/SessionDetailStatusSection.tsx new file mode 100644 index 000000000..4b9231955 --- /dev/null +++ b/frontend/src/components/schedule/SessionDetailStatusSection.tsx @@ -0,0 +1,252 @@ +import { CalendarOff, CalendarClock, CheckCircle, MapPin, Users } from 'lucide-react'; +import { Button } from '@/components/ui/button'; + +interface SessionDetailStatusSectionProps { + isCancelled: boolean; + isCancelledReschedule: boolean; + isRescheduledFrom: boolean; + isRescheduledTo: boolean; + hasAttendance: boolean; + + originalInstructorName?: string; + instructorName?: string; + originalRoom?: string; + room: string; + cancellationReason?: string; + rescheduledDate?: string; + cancellationActionLabel: string; + + onUndo: () => void; + onUndoCancel?: () => void; + onUndoReschedule?: () => void; + onClose: () => void; +} + +export function SessionDetailStatusSection({ + isCancelled, + isCancelledReschedule, + isRescheduledFrom, + isRescheduledTo, + hasAttendance, + originalInstructorName, + instructorName, + originalRoom, + room, + cancellationReason, + rescheduledDate, + cancellationActionLabel, + onUndo, + onUndoCancel, + onUndoReschedule, + onClose +}: SessionDetailStatusSectionProps) { + const visible = + isCancelled || + isCancelledReschedule || + isRescheduledFrom || + isRescheduledTo || + hasAttendance || + (!isCancelled && (originalInstructorName ?? originalRoom)); + + if (!visible) return null; + + return ( +
+

+ Status +

+
+ + {!isCancelled && originalInstructorName && instructorName && ( +
+ +
+
Instructor Change
+

+ Session Instructor: {instructorName} +

+
+
+ )} + + {!isCancelled && originalRoom && ( +
+ +
+
Room Change
+

+ Session Room: {room} +

+
+
+ )} + + {isCancelledReschedule && ( +
+
+
+ +
+
+ Class Cancelled +
+ {cancellationReason && ( +

+ {cancellationReason} +

+ )} +
+
+ +
+ {rescheduledDate && ( +
+
+ +
+
+ Rescheduled Class +
+

+ Originally scheduled for{' '} + {new Date(rescheduledDate + 'T00:00:00').toLocaleDateString('en-US', { + weekday: 'long', + month: 'long', + day: 'numeric' + })} +

+
+
+ +
+ )} +
+ )} + + {isRescheduledFrom && !isRescheduledTo && rescheduledDate && ( +
+
+ +
+
+ Class Rescheduled +
+

+ Moved to{' '} + {new Date(rescheduledDate + 'T00:00:00').toLocaleDateString('en-US', { + weekday: 'long', + month: 'long', + day: 'numeric' + })} +

+
+
+ +
+ )} + + {isRescheduledTo && ( +
+
+ +
+
+ Rescheduled Class +
+ {rescheduledDate && ( +

+ Originally scheduled for{' '} + {new Date(rescheduledDate + 'T00:00:00').toLocaleDateString('en-US', { + weekday: 'long', + month: 'long', + day: 'numeric' + })} +

+ )} +
+
+ +
+ )} + + {isCancelled && ( +
+
+ +
+
+ Class Cancelled +
+ {cancellationReason && ( +

+ {cancellationReason} +

+ )} +
+
+ +
+ )} + + {hasAttendance && ( +
+
+ +
+
+ Attendance Taken +
+

+ This class cannot be + modified because attendance + has been recorded. +

+
+
+
+ )} +
+
+ ); +} diff --git a/frontend/src/components/shared/StatusBadge.tsx b/frontend/src/components/shared/StatusBadge.tsx index 872e0fe5f..d3ec48f51 100644 --- a/frontend/src/components/shared/StatusBadge.tsx +++ b/frontend/src/components/shared/StatusBadge.tsx @@ -59,36 +59,26 @@ interface StatusBadgeProps { className?: string; } +const STYLE_REGISTRY: { + variant: StatusBadgeProps['variant']; + styles: Record; +}[] = [ + { variant: 'class', styles: classStatusStyles as Record }, + { variant: 'progClass', styles: progClassStatusStyles as Record }, + { variant: 'program', styles: programStatusStyles as Record }, + { variant: 'enrollment', styles: enrollmentStatusStyles }, + { variant: 'resident', styles: residentStatusStyles } +]; + function getStyleForStatus( status: StatusType, variant: StatusBadgeProps['variant'] ): string { - if (variant === 'class' && status in classStatusStyles) { - return classStatusStyles[status as SelectedClassStatus]; - } - if (variant === 'progClass' && status in progClassStatusStyles) { - return progClassStatusStyles[status as ProgClassStatus]; - } - if (variant === 'program' && status in programStatusStyles) { - return programStatusStyles[status as ProgramEffectiveStatus]; - } - if (variant === 'enrollment' && status in enrollmentStatusStyles) { - return enrollmentStatusStyles[status]; - } - if (variant === 'resident' && status in residentStatusStyles) { - return residentStatusStyles[status]; - } - if (variant === 'auto') { - if (status in classStatusStyles) - return classStatusStyles[status as SelectedClassStatus]; - if (status in progClassStatusStyles) - return progClassStatusStyles[status as ProgClassStatus]; - if (status in programStatusStyles) - return programStatusStyles[status as ProgramEffectiveStatus]; - if (status in enrollmentStatusStyles) - return enrollmentStatusStyles[status]; - if (status in residentStatusStyles) - return residentStatusStyles[status]; + const key = String(status); + for (const entry of STYLE_REGISTRY) { + if ((entry.variant === variant || variant === 'auto') && key in entry.styles) { + return entry.styles[key]; + } } return 'bg-muted text-foreground border-border'; } diff --git a/frontend/src/hooks/useUrlPagination.ts b/frontend/src/hooks/useUrlPagination.ts index ef2dad34a..579fdb5d1 100644 --- a/frontend/src/hooks/useUrlPagination.ts +++ b/frontend/src/hooks/useUrlPagination.ts @@ -1,8 +1,10 @@ -import { useCallback } from 'react'; -import { useSearchParams } from 'react-router-dom'; +import { useCallback, useRef } from 'react'; +import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'; export function useUrlPagination(defaultPage = 1, defaultPerPage = 20, prefix = '') { - const [searchParams, setSearchParams] = useSearchParams(); + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + const location = useLocation(); const pageKey = prefix ? `${prefix}_page` : 'page'; const perPageKey = prefix ? `${prefix}_per_page` : 'per_page'; @@ -13,23 +15,29 @@ export function useUrlPagination(defaultPage = 1, defaultPerPage = 20, prefix = const parsedPerPage = parseInt(searchParams.get(perPageKey) ?? '', 10); const perPage = Number.isFinite(parsedPerPage) && parsedPerPage >= 1 ? parsedPerPage : defaultPerPage; + const searchRef = useRef(location.search); + searchRef.current = location.search; + const setPage = useCallback( (newPage: number, options?: { replace?: boolean }) => { - const params = new URLSearchParams(searchParams); + const params = new URLSearchParams(searchRef.current); params.set(pageKey, newPage.toString()); - setSearchParams(params, { replace: options?.replace ?? false }); + navigate( + { search: params.toString() }, + { replace: options?.replace ?? false } + ); }, - [searchParams, setSearchParams, pageKey] + [navigate, pageKey] ); const setPerPage = useCallback( (newPerPage: number) => { - const params = new URLSearchParams(searchParams); + const params = new URLSearchParams(searchRef.current); params.set(perPageKey, newPerPage.toString()); params.set(pageKey, '1'); - setSearchParams(params, { replace: false }); + navigate({ search: params.toString() }, { replace: false }); }, - [searchParams, setSearchParams, pageKey, perPageKey] + [navigate, pageKey, perPageKey] ); return { page, perPage, setPage, setPerPage }; diff --git a/frontend/src/layouts/AuthenticatedLayout.tsx b/frontend/src/layouts/AuthenticatedLayout.tsx index 3349fadc5..58f0dbb36 100644 --- a/frontend/src/layouts/AuthenticatedLayout.tsx +++ b/frontend/src/layouts/AuthenticatedLayout.tsx @@ -46,7 +46,6 @@ export default function AuthenticatedLayout() { const isSchedule = location.pathname === '/schedule'; const isAdmins = location.pathname === '/admins'; const isKnowledgeCenter = location.pathname === '/knowledge-center-management' || location.pathname === '/knowledge-center'; - const isResidentKnowledgeCenter = location.pathname === '/knowledge-center'; const isContentViewer = location.pathname.startsWith('/viewer/'); const isResidentPage = ['/resident-programs', '/home'].includes(location.pathname); const isFullBleed = @@ -103,7 +102,7 @@ export default function AuthenticatedLayout() { const needsGrayBg = isResidentProfile || isResidentsPage || isClassesPage || isFacilities || isAdmins || isKnowledgeCenter || (isProgramDetail && canSwitchFacility(user)); const rootClass = 'h-screen bg-background flex overflow-hidden'; - const contentClass = `flex-1 min-h-full ${isResidentKnowledgeCenter ? 'overflow-hidden' : 'overflow-y-auto'} overflow-x-hidden ${needsGrayBg ? 'bg-[#E2E7EA]' : ''}`; + const contentClass = `flex-1 min-h-full overflow-y-auto overflow-x-hidden ${needsGrayBg ? 'bg-[#E2E7EA]' : ''}`; return (
diff --git a/frontend/src/lib/formatters.ts b/frontend/src/lib/formatters.ts index ff0cddf40..a185f449c 100644 --- a/frontend/src/lib/formatters.ts +++ b/frontend/src/lib/formatters.ts @@ -231,6 +231,25 @@ export function formatMonthYear(monthString: string): string { }); } +export function toDateInput(d: Date): string { + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; +} + +export function toTimeInput(d: Date): string { + return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; +} + +export function formatDurationStr(startTime: string, endTime: string): string { + const startMin = timeToMinutes(startTime); + const endMin = timeToMinutes(endTime); + if (!Number.isFinite(startMin) || !Number.isFinite(endMin)) return '0h0m0s'; + const totalMin = endMin - startMin; + if (totalMin <= 0) return '0h0m0s'; + const hours = Math.floor(totalMin / 60); + const minutes = totalMin % 60; + return `${hours}h${minutes}m0s`; +} + const WEEKDAY_NAMES: Record = { 0: 'Monday', 1: 'Tuesday', diff --git a/frontend/src/pages/class-detail/ClassNotFoundCard.tsx b/frontend/src/pages/class-detail/ClassNotFoundCard.tsx new file mode 100644 index 000000000..8f1b82706 --- /dev/null +++ b/frontend/src/pages/class-detail/ClassNotFoundCard.tsx @@ -0,0 +1,27 @@ +import { Button } from '@/components/ui/button'; + +interface ClassNotFoundCardProps { + onBack: () => void; +} + +export function ClassNotFoundCard({ onBack }: ClassNotFoundCardProps) { + return ( +
+
+

+ Class Not Found +

+

+ The class you are looking for does not exist or you do + not have access to it. +

+ +
+
+ ); +} diff --git a/frontend/src/pages/class-detail/EnrollResidentsModal.tsx b/frontend/src/pages/class-detail/EnrollResidentsModal.tsx index b05bf5e63..68e434c93 100644 --- a/frontend/src/pages/class-detail/EnrollResidentsModal.tsx +++ b/frontend/src/pages/class-detail/EnrollResidentsModal.tsx @@ -61,12 +61,9 @@ export function EnrollResidentsModal({ >(`program-classes/${classId}/enrollment-conflicts`, { user_ids: userIds }).then((resp) => { - if (resp.success) { - const data = resp.data as unknown as { - conflicts: ConflictDetail[]; - }; + if (resp.success && resp.type === 'one') { const map = new Map(); - for (const c of data.conflicts ?? []) { + for (const c of resp.data.conflicts ?? []) { map.set(c.user_id, c); } setConflictMap(map); diff --git a/frontend/src/pages/class-detail/LoadingSkeleton.tsx b/frontend/src/pages/class-detail/LoadingSkeleton.tsx new file mode 100644 index 000000000..2d7f5292e --- /dev/null +++ b/frontend/src/pages/class-detail/LoadingSkeleton.tsx @@ -0,0 +1,27 @@ +import { Skeleton } from '@/components/ui/skeleton'; + +export function LoadingSkeleton() { + return ( +
+
+
+ + + +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+
+
+
+
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+
+
+ ); +} diff --git a/frontend/src/pages/class-detail/SessionRow.tsx b/frontend/src/pages/class-detail/SessionRow.tsx new file mode 100644 index 000000000..1a7ca2630 --- /dev/null +++ b/frontend/src/pages/class-detail/SessionRow.tsx @@ -0,0 +1,318 @@ +import { + Calendar, + AlertCircle, + CheckCircle, + CalendarClock, + CalendarOff, + X, + Undo2 +} from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Checkbox } from '@/components/ui/checkbox'; +import { formatClassTimeRange } from '@/lib/formatters'; +import { formatShortDate, type SessionDisplay } from './session-utils'; + +interface SessionRowProps { + session: SessionDisplay; + selected: boolean; + onToggle: () => void; + onClick: () => void; + onNavigateToAttendance: () => void; + onCancel: () => void; + onReschedule: () => void; + onUndo: () => void; + onUndoCancel: () => void; +} + +export function SessionRow({ + session, + selected, + onToggle, + onClick, + onNavigateToAttendance, + onCancel, + onReschedule, + onUndo, + onUndoCancel +}: SessionRowProps) { + const dateLabel = session.dateObj.toLocaleDateString('en-US', { + month: 'long', + day: 'numeric', + year: 'numeric' + }); + + const { + isCancelled, + isRescheduledFrom, + isRescheduledTo, + isCancelledReschedule, + isToday, + hasAttendance, + isPast, + isUpcoming, + rescheduledDate, + rescheduledClassTime + } = session; + + // Time-only reschedule (same date) renders as a "to" row, not a "from" row. + const isSameDateReschedule = isRescheduledFrom && isRescheduledTo && rescheduledDate === session.instance.date; + const treatAsFrom = isRescheduledFrom && !isSameDateReschedule; + const treatAsTo = isRescheduledTo || isSameDateReschedule; + + const getBorderClass = () => { + if (treatAsFrom) + return 'border-gray-300 border-dashed bg-gray-50 hover:bg-gray-100'; + if (isCancelledReschedule) + return 'border-gray-300 bg-gray-100 hover:bg-gray-200'; + if (treatAsTo) + return 'border-blue-300 bg-blue-50 hover:bg-blue-100'; + if (isCancelled) + return 'border-gray-300 bg-gray-100 hover:bg-gray-200'; + if (isToday) return 'border-blue-200 bg-blue-50 hover:bg-blue-100'; + if (hasAttendance) return 'border-gray-200 hover:bg-[#E2E7EA]/30'; + if (isPast) + return 'border-amber-200 bg-amber-50/30 hover:bg-amber-50'; + return 'border-gray-200 bg-gray-50 hover:bg-[#E2E7EA]/30'; + }; + + const getIcon = () => { + if (treatAsFrom) + return ( + + ); + if (treatAsTo) + return ( + + ); + if (isCancelled) + return ( + + ); + if (hasAttendance) + return ( + + ); + if (isPast) + return ( + + ); + return ; + }; + + const showCheckbox = + isUpcoming && !isCancelled && !treatAsFrom && !isCancelledReschedule; + const showLineThrough = isCancelled || treatAsFrom || isCancelledReschedule; + + return ( +
+
+ {showCheckbox && ( + onToggle()} + onClick={(e) => e.stopPropagation()} + /> + )} + {getIcon()} +
+
+ + {session.dayName}, {dateLabel} + + {isToday && ( + + Today + + )} + {isCancelled && ( + + Cancelled + + )} + {treatAsFrom && ( + + Rescheduled + + )} + {treatAsTo && ( + + Rescheduled Class + + )} + {isCancelledReschedule && ( + <> + + Cancelled + + + Rescheduled Class + + + )} +
+ {treatAsFrom && rescheduledDate ? ( +
+ → Moved to {formatShortDate(rescheduledDate)} + {rescheduledClassTime && + ` at ${formatClassTimeRange(rescheduledClassTime)}`} +
+ ) : (treatAsTo || isCancelledReschedule) ? ( +
+ {formatClassTimeRange(session.instance.class_time)} +
+ ) : ( +
+ {formatClassTimeRange(session.instance.class_time)} +
+ )} +
+
+
e.stopPropagation()} + > + {hasAttendance && !treatAsFrom && ( +
+ {session.attendedCount} / {session.totalEnrolled}{' '} + attended ( + {session.totalEnrolled > 0 + ? Math.round( + (session.attendedCount / + session.totalEnrolled) * + 100 + ) + : 0} + %) +
+ )} + {isPast && + !hasAttendance && + !isCancelled && + !treatAsFrom && ( + + Missing + + )} + {!isCancelled && + !isUpcoming && + !treatAsFrom && ( + + )} + {isCancelled && isUpcoming && ( + + )} + {isRescheduledFrom && isUpcoming && ( + + )} + {isCancelledReschedule && isUpcoming && ( + + )} + {isRescheduledTo && isUpcoming && !isSameDateReschedule && ( + <> + + + + )} + {isUpcoming && + !isCancelled && + !treatAsFrom && + !treatAsTo && + !isCancelledReschedule && ( + <> + + + + )} +
+
+ ); +} diff --git a/frontend/src/pages/class-detail/SessionsTab.tsx b/frontend/src/pages/class-detail/SessionsTab.tsx index 6addfed06..467ce1c92 100644 --- a/frontend/src/pages/class-detail/SessionsTab.tsx +++ b/frontend/src/pages/class-detail/SessionsTab.tsx @@ -1,104 +1,36 @@ import { useMemo, useState } from 'react'; import useSWR from 'swr'; import { useNavigate } from 'react-router-dom'; -import { - Calendar, - AlertCircle, - CheckCircle, - Filter, - CalendarClock, - CalendarOff, - X, - Users, - MapPin, - Undo2 -} from 'lucide-react'; +import { Calendar, AlertCircle } from 'lucide-react'; import API from '@/api/api'; import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; -import { Badge } from '@/components/ui/badge'; -import { Checkbox } from '@/components/ui/checkbox'; import { Class } from '@/types/program'; -import { ClassEventInstance, FacilityProgramClassEvent, ProgramClassEvent } from '@/types/events'; +import { ClassEventInstance } from '@/types/events'; import { ServerResponseMany } from '@/types/server'; -import { SelectedClassStatus } from '@/types/attendance'; -import { RescheduleSessionModal } from './RescheduleSessionModal'; -import { CancelEventModal } from '@/components/schedule/CancelEventModal'; -import { - BulkCancelSessionsModal, - BulkCancelSession -} from './BulkCancelSessionsModal'; -import { - ChangeInstructorModal, - ChangeInstructorSession -} from './ChangeInstructorModal'; -import { ChangeRoomModal, ChangeRoomSession } from './ChangeRoomModal'; -import { SessionDetailSheet } from '@/components/schedule/SessionDetailSheet'; -import { formatClassTimeRange } from '@/lib/formatters'; +import { BulkCancelSession } from './BulkCancelSessionsModal'; +import { ChangeInstructorSession } from './ChangeInstructorModal'; +import { ChangeRoomSession } from './ChangeRoomModal'; +import { SessionRow } from './SessionRow'; +import { SessionsTabFilterBar } from './SessionsTabFilterBar'; +import { SessionsTabBulkActions } from './SessionsTabBulkActions'; +import { SessionsTabModals } from './SessionsTabModals'; import { buildRescheduleMaps, buildRoomOverrideMap, buildCancellationReasonMap, buildSessionDisplays, - findActiveOverride, findCancelOverrideId, - getSessionChangeInfo, + buildSessionPayload, type SessionDisplay } from './session-utils'; -import { getInstructorName } from '@/lib/formatters'; - -function buildFacilityEvent( - session: SessionDisplay, - classId: number, - classEvents: ProgramClassEvent[] -): FacilityProgramClassEvent { - const eventId = session.instance.event_id ?? session.instance.id; - const backingEvent = classEvents.find((e) => e.id === eventId) ?? classEvents[0]; - const activeOverride = findActiveOverride(classEvents, session.instance.date); - const parts = session.instance.class_time.split('-'); - const [sh = 0, sm = 0] = (parts[0] ?? '').split(':').map(Number); - const [eh = 0, em = 0] = (parts[1] ?? '').split(':').map(Number); - const start = new Date(session.dateObj); - start.setHours(sh, sm, 0, 0); - const end = new Date(session.dateObj); - end.setHours(eh, em, 0, 0); - return { - id: eventId, - class_id: classId, - duration: backingEvent?.duration ?? '', - room_id: activeOverride?.room_id ?? backingEvent?.room_id ?? 0, - recurrence_rule: backingEvent?.recurrence_rule ?? '', - is_cancelled: session.instance.is_cancelled, - instructor_id: activeOverride?.instructor_id ?? backingEvent?.instructor_id ?? null, - overrides: backingEvent?.overrides ?? [], - reason: null, - start, - end, - is_override: !!activeOverride || !!session.instance.override_id, - override_id: activeOverride?.id ?? session.instance.override_id ?? 0, - linked_override_event: null as unknown as FacilityProgramClassEvent, - room: '', - instructor_name: '', - program_id: 0, - program_name: '', - title: '', - enrolled_users: '', - frequency: '', - credit_types: '', - class_status: SelectedClassStatus.Scheduled - }; -} - -type StatusFilter = 'all' | 'completed' | 'missing' | 'upcoming' | 'cancelled'; -type TimeFilter = 'week' | '2weeks' | 'month' | 'all'; - -const PAST_DISPLAY_LIMIT = 15; -const UPCOMING_DISPLAY_LIMIT = 10; -const TIME_FILTER_DAYS: Record, number> = { - week: 7, - '2weeks': 14, - month: 28 -}; +import { + useSessionFilters, + PAST_DISPLAY_LIMIT, + UPCOMING_DISPLAY_LIMIT, + type StatusFilter, + type TimeFilter +} from './useSessionFilters'; interface SessionsTabProps { cls: Class; @@ -107,56 +39,6 @@ interface SessionsTabProps { export type { SessionDisplay } from './session-utils'; -function parseLocalDate(dateStr: string): Date { - const [y, m, d] = dateStr.split('-').map(Number); - return new Date(y, m - 1, d); -} - -function formatShortDate(dateStr: string): string { - const d = parseLocalDate(dateStr); - return d.toLocaleDateString('en-US', { - weekday: 'short', - month: 'short', - day: 'numeric' - }); -} - -function getTimeCutoff(tf: TimeFilter): Date | null { - if (tf === 'all') return null; - const cutoff = new Date(); - cutoff.setHours(0, 0, 0, 0); - cutoff.setDate(cutoff.getDate() - TIME_FILTER_DAYS[tf]); - return cutoff; -} - -function FilterButton({ - active, - onClick, - children, - disabled -}: { - active: boolean; - onClick: () => void; - children: React.ReactNode; - disabled?: boolean; -}) { - return ( - - ); -} - export function SessionsTab({ cls, onClassMutate }: SessionsTabProps) { const navigate = useNavigate(); const [statusFilter, setStatusFilter] = useState('all'); @@ -263,29 +145,6 @@ export function SessionsTab({ cls, onClassMutate }: SessionsTabProps) { }); }; - const buildSessionPayload = ( - session: SessionDisplay - ): { - date: string; - dateLabel: string; - eventId: number; - classTime: string; - dateObj: Date; - dayName: string; - } => ({ - date: session.instance.date, - dateLabel: session.dateObj.toLocaleDateString('en-US', { - weekday: 'long', - month: 'long', - day: 'numeric', - year: 'numeric' - }), - eventId: session.instance.event_id ?? session.instance.id, - classTime: session.instance.class_time, - dateObj: session.dateObj, - dayName: session.dayName - }); - const openChangeInstructor = (sessions: SessionDisplay[]) => { setChangeInstructorSessions(sessions.map(buildSessionPayload)); setShowChangeInstructor(true); @@ -308,79 +167,18 @@ export function SessionsTab({ cls, onClassMutate }: SessionsTabProps) { [allSessions, selectedDates] ); - const filtered = useMemo(() => { - let result = allSessions; - const cutoff = getTimeCutoff(timeFilter); - - const today = new Date(); - today.setHours(23, 59, 59, 999); - - if (statusFilter === 'all') { - if (cutoff) { - result = result.filter( - (s) => s.dateObj >= cutoff && s.dateObj <= today - ); - } - } else if (statusFilter === 'completed') { - result = result.filter( - (s) => - (s.isPast || s.isToday) && - s.hasAttendance && - !s.isCancelled && - !s.isRescheduledFrom - ); - if (cutoff) { - result = result.filter((s) => s.dateObj >= cutoff); - } - } else if (statusFilter === 'missing') { - result = result.filter( - (s) => - s.isPast && - !s.hasAttendance && - !s.isCancelled && - !s.isRescheduledFrom - ); - if (cutoff) { - result = result.filter((s) => s.dateObj >= cutoff); - } - } else if (statusFilter === 'upcoming') { - result = result.filter( - (s) => s.isUpcoming && !s.isCancelled && !s.isRescheduledFrom - ); - result = [...result].reverse(); - } else if (statusFilter === 'cancelled') { - result = result.filter( - (s) => s.isCancelled && !s.isRescheduledFrom - ); - if (cutoff) { - result = result.filter((s) => s.dateObj >= cutoff); - } - } - - return result; - }, [allSessions, statusFilter, timeFilter]); - - const pastAndTodaySessions = useMemo( - () => filtered.filter((s) => s.isPast || s.isToday), - [filtered] - ); - - const upcomingSessions = useMemo( - () => - filtered - .filter((s) => s.isUpcoming) - .sort((a, b) => a.dateObj.getTime() - b.dateObj.getTime()), - [filtered] - ); - - const displayedPast = showAllPast - ? pastAndTodaySessions - : pastAndTodaySessions.slice(0, PAST_DISPLAY_LIMIT); - - const displayedUpcoming = upcomingSessions.slice( - 0, - UPCOMING_DISPLAY_LIMIT - ); + const { + filtered, + pastAndTodaySessions, + upcomingSessions, + displayedPast, + displayedUpcoming + } = useSessionFilters({ + allSessions, + statusFilter, + timeFilter, + showAllPast + }); const refreshData = async () => { await mutate(); @@ -462,131 +260,14 @@ export function SessionsTab({ cls, onClassMutate }: SessionsTabProps) { return (
-
-
-
-

Session Management

-

- View, cancel, or reschedule individual sessions -

-
-
- - handleStatusChange( - statusFilter === 'completed' - ? 'all' - : 'completed' - ) - } - colorClass="bg-green-100 text-[#556830]" - > - {stats.completed} Completed - - - handleStatusChange( - statusFilter === 'missing' - ? 'all' - : 'missing' - ) - } - colorClass="bg-amber-100 text-amber-700" - > - {stats.missing} Missing - - - handleStatusChange( - statusFilter === 'upcoming' - ? 'all' - : 'upcoming' - ) - } - colorClass="bg-blue-100 text-blue-700" - > - {stats.upcoming} Upcoming - - - handleStatusChange( - statusFilter === 'cancelled' - ? 'all' - : 'cancelled' - ) - } - colorClass="bg-gray-100 text-gray-700" - > - {stats.cancelled} Cancelled - -
-
- -
- -
- handleStatusChange('all')} - > - All Sessions - - handleStatusChange('completed')} - > - Completed - - handleStatusChange('missing')} - > - Missing - - handleStatusChange('upcoming')} - > - Upcoming - -
-
-
- handleTimeChange('week')} - disabled={hideTimeFilter} - > - Last Week - - handleTimeChange('2weeks')} - disabled={hideTimeFilter} - > - Last 2 Weeks - - handleTimeChange('month')} - disabled={hideTimeFilter} - > - Last Month - - handleTimeChange('all')} - disabled={hideTimeFilter} - > - All Time - -
-
-
+ {stats.missing > 0 && (
@@ -706,527 +387,84 @@ export function SessionsTab({ cls, onClassMutate }: SessionsTabProps) { )}
- {rescheduleTarget && ( - setRescheduleTarget(null)} - classId={cls.id} - eventId={ - rescheduleTarget.instance.event_id ?? - rescheduleTarget.instance.id - } - originalDate={rescheduleTarget.instance.date} - dateLabel={rescheduleTarget.dateObj.toLocaleDateString( - 'en-US', - { - weekday: 'long', - month: 'long', - day: 'numeric' - } - )} - currentRoom={cls.events?.[0]?.room_ref?.name} - classTime={rescheduleTarget.instance.class_time} - onRescheduled={() => void refreshData()} - /> - )} - - {showQuickCancel && quickCancelSession && ( - { - setShowQuickCancel(open); - if (!open) setQuickCancelSession(null); - }} - event={buildFacilityEvent(quickCancelSession, cls.id, cls.events ?? [])} - onSuccess={() => { - setShowQuickCancel(false); - setQuickCancelSession(null); - void refreshData(); - }} - showApplyToFuture={false} - /> - )} - - { + setRescheduleTarget(null)} + quickCancelSession={quickCancelSession} + showQuickCancel={showQuickCancel} + onQuickCancelOpenChange={(open) => { + setShowQuickCancel(open); + if (!open) setQuickCancelSession(null); + }} + onQuickCancelSuccess={() => { + setShowQuickCancel(false); + setQuickCancelSession(null); + void refreshData(); + }} + showBulkCancelModal={showBulkCancelModal} + onBulkCancelOpenChange={setShowBulkCancelModal} + cancelSessions={cancelSessions} + onBulkCancelled={() => { setSelectedDates(new Set()); setCancelSessions([]); void refreshData(); }} + showChangeInstructor={showChangeInstructor} + onCloseChangeInstructor={() => setShowChangeInstructor(false)} + changeInstructorSessions={changeInstructorSessions} + onInstructorChanged={() => { + setSelectedDates(new Set()); + void refreshData(); + }} + showChangeRoom={showChangeRoom} + onCloseChangeRoom={() => setShowChangeRoom(false)} + changeRoomSessions={changeRoomSessions} + onRoomChanged={() => { + setSelectedDates(new Set()); + void refreshData(); + }} + selectedSession={selectedSession} + onCloseDetailSheet={() => setSelectedSession(null)} + onMutate={() => void refreshData()} + onSelectedSessionUndo={() => { + if (selectedSession) void handleUndo(selectedSession); + }} + onSelectedSessionUndoCancel={() => { + if (selectedSession) void handleUndoCancel(selectedSession); + }} + onSelectedSessionUndoReschedule={() => { + if (selectedSession) void handleUndo(selectedSession); + }} /> - {showChangeInstructor && ( - setShowChangeInstructor(false)} - classId={cls.id} - sessions={changeInstructorSessions} - onChanged={() => { - setSelectedDates(new Set()); - void refreshData(); - }} - showSessionsList - /> - )} - - {showChangeRoom && ( - setShowChangeRoom(false)} - classId={cls.id} - sessions={changeRoomSessions} - onChanged={() => { - setSelectedDates(new Set()); - void refreshData(); - }} - showSessionsList - /> - )} - - {(() => { - const changeInfo = selectedSession - ? getSessionChangeInfo(cls.events ?? [], selectedSession.instance.date) - : {}; - const baseRoom = - (selectedSession - ? roomOverrides.get( - `${selectedSession.instance.date}|${selectedSession.instance.class_time?.split('-')[0]}` - ) ?? roomOverrides.get(selectedSession.instance.date) - : undefined) ?? - cls.events?.[0]?.room_ref?.name ?? - 'TBD'; - const baseInstructor = getInstructorName(cls.events ?? []); - return ( - setSelectedSession(null)} - className={cls.name} - facilityId={String(cls.facility_id)} - classEvents={cls.events ?? []} - classTime={ - selectedSession?.instance.class_time ?? - cls.events?.[0]?.duration ?? - '' - } - room={changeInfo.newRoom ?? baseRoom} - originalRoom={changeInfo.originalRoom} - instructorName={changeInfo.newInstructor ?? baseInstructor} - originalInstructorName={changeInfo.originalInstructor} - classId={cls.id} - onMutate={() => void refreshData()} - onUndo={() => { - if (selectedSession) void handleUndo(selectedSession); - }} - onUndoCancel={() => { - if (selectedSession) void handleUndoCancel(selectedSession); - }} - onUndoReschedule={() => { - if (selectedSession) void handleUndo(selectedSession); - }} - allSessions={allSessions} - /> - ); - })()} - - {selectedDates.size > 0 && ( -
-
-
- - {selectedDates.size} - - - {selectedDates.size === 1 - ? 'session' - : 'sessions'}{' '} - selected - -
-
- - - - -
-
-
- )} + setSelectedDates(new Set())} + onBulkCancelClick={() => { + setCancelSessions( + selectedUpcomingSessions.map((s) => ({ + date: s.instance.date, + dateObj: s.dateObj, + dayName: s.dayName, + eventId: + s.instance.event_id ?? s.instance.id, + classTime: s.instance.class_time + })) + ); + setShowBulkCancelModal(true); + }} + onChangeInstructorClick={() => + openChangeInstructor(selectedUpcomingSessions) + } + onChangeRoomClick={() => + openChangeRoom(selectedUpcomingSessions) + } + />
); } -function StatButton({ - active, - onClick, - colorClass, - children -}: { - active: boolean; - onClick: () => void; - colorClass: string; - children: React.ReactNode; -}) { - return ( - - ); -} -function SessionRow({ - session, - selected, - onToggle, - onClick, - onNavigateToAttendance, - onCancel, - onReschedule, - onUndo, - onUndoCancel -}: { - session: SessionDisplay; - selected: boolean; - onToggle: () => void; - onClick: () => void; - onNavigateToAttendance: () => void; - onCancel: () => void; - onReschedule: () => void; - onUndo: () => void; - onUndoCancel: () => void; -}) { - const dateLabel = session.dateObj.toLocaleDateString('en-US', { - month: 'long', - day: 'numeric', - year: 'numeric' - }); - - const { - isCancelled, - isRescheduledFrom, - isRescheduledTo, - isCancelledReschedule, - isToday, - hasAttendance, - isPast, - isUpcoming, - rescheduledDate, - rescheduledClassTime - } = session; - - // Same-date time-only reschedule: both isRescheduledFrom and isRescheduledTo are true. - // Should display as a "to" row (blue) with one undo button, not a "from" row (gray dashed). - const isSameDateReschedule = isRescheduledFrom && isRescheduledTo && rescheduledDate === session.instance.date; - - const getBorderClass = () => { - if (isRescheduledFrom) - return 'border-gray-300 border-dashed bg-gray-50 hover:bg-gray-100'; - if (isCancelledReschedule) - return 'border-gray-300 bg-gray-100 hover:bg-gray-200'; - if (isRescheduledTo) - return 'border-blue-300 bg-blue-50 hover:bg-blue-100'; - if (isCancelled) - return 'border-gray-300 bg-gray-100 hover:bg-gray-200'; - if (isToday) return 'border-blue-200 bg-blue-50 hover:bg-blue-100'; - if (hasAttendance) return 'border-gray-200 hover:bg-[#E2E7EA]/30'; - if (isPast) - return 'border-amber-200 bg-amber-50/30 hover:bg-amber-50'; - return 'border-gray-200 bg-gray-50 hover:bg-[#E2E7EA]/30'; - }; - - const getIcon = () => { - if (isRescheduledFrom) - return ( - - ); - if (isRescheduledTo) - return ( - - ); - if (isCancelled) - return ( - - ); - if (hasAttendance) - return ( - - ); - if (isPast) - return ( - - ); - return ; - }; - - const showCheckbox = - isUpcoming && !isCancelled && !isRescheduledFrom && !isCancelledReschedule; - const showLineThrough = isCancelled || isRescheduledFrom || isCancelledReschedule; - - return ( -
-
- {showCheckbox && ( - onToggle()} - onClick={(e) => e.stopPropagation()} - /> - )} - {getIcon()} -
-
- - {session.dayName}, {dateLabel} - - {isToday && ( - - Today - - )} - {isCancelled && ( - - Cancelled - - )} - {isRescheduledFrom && ( - - Rescheduled - - )} - {isRescheduledTo && ( - - Rescheduled Class - - )} - {isCancelledReschedule && ( - <> - - Cancelled - - - Rescheduled Class - - - )} -
- {isRescheduledFrom && rescheduledDate ? ( -
- → Moved to {formatShortDate(rescheduledDate)} - {rescheduledClassTime && - ` at ${formatClassTimeRange(rescheduledClassTime)}`} -
- ) : (isRescheduledTo || isCancelledReschedule) ? ( -
- {formatClassTimeRange(session.instance.class_time)} -
- ) : ( -
- {formatClassTimeRange(session.instance.class_time)} -
- )} -
-
-
e.stopPropagation()} - > - {hasAttendance && !isRescheduledFrom && ( -
- {session.attendedCount} / {session.totalEnrolled}{' '} - attended ( - {session.totalEnrolled > 0 - ? Math.round( - (session.attendedCount / - session.totalEnrolled) * - 100 - ) - : 0} - %) -
- )} - {isPast && - !hasAttendance && - !isCancelled && - !isRescheduledFrom && ( - - Missing - - )} - {!isCancelled && - !isUpcoming && - !isRescheduledFrom && ( - - )} - {isCancelled && isUpcoming && ( - - )} - {isRescheduledFrom && isUpcoming && ( - - )} - {isCancelledReschedule && isUpcoming && ( - - )} - {isRescheduledTo && isUpcoming && !isSameDateReschedule && ( - <> - - - - )} - {isUpcoming && - !isCancelled && - !isRescheduledFrom && - !isRescheduledTo && - !isCancelledReschedule && ( - <> - - - - )} -
-
- ); -} diff --git a/frontend/src/pages/class-detail/SessionsTabBulkActions.tsx b/frontend/src/pages/class-detail/SessionsTabBulkActions.tsx new file mode 100644 index 000000000..d40d6b806 --- /dev/null +++ b/frontend/src/pages/class-detail/SessionsTabBulkActions.tsx @@ -0,0 +1,69 @@ +import { CalendarOff, Users, MapPin } from 'lucide-react'; +import { Button } from '@/components/ui/button'; + +interface SessionsTabBulkActionsProps { + selectedCount: number; + onClearSelection: () => void; + onBulkCancelClick: () => void; + onChangeInstructorClick: () => void; + onChangeRoomClick: () => void; +} + +export function SessionsTabBulkActions({ + selectedCount, + onClearSelection, + onBulkCancelClick, + onChangeInstructorClick, + onChangeRoomClick +}: SessionsTabBulkActionsProps) { + if (selectedCount === 0) return null; + + return ( +
+
+
+ + {selectedCount} + + + {selectedCount === 1 ? 'session' : 'sessions'}{' '} + selected + +
+
+ + + + +
+
+
+ ); +} diff --git a/frontend/src/pages/class-detail/SessionsTabFilterBar.tsx b/frontend/src/pages/class-detail/SessionsTabFilterBar.tsx new file mode 100644 index 000000000..6857cec2b --- /dev/null +++ b/frontend/src/pages/class-detail/SessionsTabFilterBar.tsx @@ -0,0 +1,208 @@ +import { Filter } from 'lucide-react'; +import type { StatusFilter, TimeFilter } from './useSessionFilters'; + +interface FilterStats { + completed: number; + missing: number; + upcoming: number; + cancelled: number; +} + +interface SessionsTabFilterBarProps { + statusFilter: StatusFilter; + timeFilter: TimeFilter; + stats: FilterStats; + hideTimeFilter: boolean; + onStatusChange: (newStatus: StatusFilter) => void; + onTimeChange: (newTime: TimeFilter) => void; +} + +function FilterButton({ + active, + onClick, + children, + disabled +}: { + active: boolean; + onClick: () => void; + children: React.ReactNode; + disabled?: boolean; +}) { + return ( + + ); +} + +function StatButton({ + active, + onClick, + colorClass, + children +}: { + active: boolean; + onClick: () => void; + colorClass: string; + children: React.ReactNode; +}) { + return ( + + ); +} + +export function SessionsTabFilterBar({ + statusFilter, + timeFilter, + stats, + hideTimeFilter, + onStatusChange, + onTimeChange +}: SessionsTabFilterBarProps) { + return ( +
+
+
+

Session Management

+

+ View, cancel, or reschedule individual sessions +

+
+
+ + onStatusChange( + statusFilter === 'completed' + ? 'all' + : 'completed' + ) + } + colorClass="bg-green-100 text-[#556830]" + > + {stats.completed} Completed + + + onStatusChange( + statusFilter === 'missing' + ? 'all' + : 'missing' + ) + } + colorClass="bg-amber-100 text-amber-700" + > + {stats.missing} Missing + + + onStatusChange( + statusFilter === 'upcoming' + ? 'all' + : 'upcoming' + ) + } + colorClass="bg-blue-100 text-blue-700" + > + {stats.upcoming} Upcoming + + + onStatusChange( + statusFilter === 'cancelled' + ? 'all' + : 'cancelled' + ) + } + colorClass="bg-gray-100 text-gray-700" + > + {stats.cancelled} Cancelled + +
+
+ +
+ +
+ onStatusChange('all')} + > + All Sessions + + onStatusChange('completed')} + > + Completed + + onStatusChange('missing')} + > + Missing + + onStatusChange('upcoming')} + > + Upcoming + +
+
+
+ onTimeChange('week')} + disabled={hideTimeFilter} + > + Last Week + + onTimeChange('2weeks')} + disabled={hideTimeFilter} + > + Last 2 Weeks + + onTimeChange('month')} + disabled={hideTimeFilter} + > + Last Month + + onTimeChange('all')} + disabled={hideTimeFilter} + > + All Time + +
+
+
+ ); +} diff --git a/frontend/src/pages/class-detail/SessionsTabModals.tsx b/frontend/src/pages/class-detail/SessionsTabModals.tsx new file mode 100644 index 000000000..b6a3cf0f9 --- /dev/null +++ b/frontend/src/pages/class-detail/SessionsTabModals.tsx @@ -0,0 +1,189 @@ +import { Class } from '@/types/program'; +import { RescheduleSessionModal } from './RescheduleSessionModal'; +import { CancelEventModal } from '@/components/schedule/CancelEventModal'; +import { + BulkCancelSessionsModal, + BulkCancelSession +} from './BulkCancelSessionsModal'; +import { + ChangeInstructorModal, + ChangeInstructorSession +} from './ChangeInstructorModal'; +import { ChangeRoomModal, ChangeRoomSession } from './ChangeRoomModal'; +import { SessionDetailSheet } from '@/components/schedule/SessionDetailSheet'; +import { + buildFacilityEvent, + getSessionChangeInfo, + type SessionDisplay +} from './session-utils'; +import { getInstructorName } from '@/lib/formatters'; + +interface SessionsTabModalsProps { + cls: Class; + allSessions: SessionDisplay[]; + roomOverrides: Map; + + rescheduleTarget: SessionDisplay | null; + onCloseReschedule: () => void; + + quickCancelSession: SessionDisplay | null; + showQuickCancel: boolean; + onQuickCancelOpenChange: (open: boolean) => void; + onQuickCancelSuccess: () => void; + + showBulkCancelModal: boolean; + onBulkCancelOpenChange: (open: boolean) => void; + cancelSessions: BulkCancelSession[]; + onBulkCancelled: () => void; + + showChangeInstructor: boolean; + onCloseChangeInstructor: () => void; + changeInstructorSessions: ChangeInstructorSession[]; + onInstructorChanged: () => void; + + showChangeRoom: boolean; + onCloseChangeRoom: () => void; + changeRoomSessions: ChangeRoomSession[]; + onRoomChanged: () => void; + + selectedSession: SessionDisplay | null; + onCloseDetailSheet: () => void; + onMutate: () => void; + onSelectedSessionUndo: () => void; + onSelectedSessionUndoCancel: () => void; + onSelectedSessionUndoReschedule: () => void; +} + +export function SessionsTabModals({ + cls, + allSessions, + roomOverrides, + rescheduleTarget, + onCloseReschedule, + quickCancelSession, + showQuickCancel, + onQuickCancelOpenChange, + onQuickCancelSuccess, + showBulkCancelModal, + onBulkCancelOpenChange, + cancelSessions, + onBulkCancelled, + showChangeInstructor, + onCloseChangeInstructor, + changeInstructorSessions, + onInstructorChanged, + showChangeRoom, + onCloseChangeRoom, + changeRoomSessions, + onRoomChanged, + selectedSession, + onCloseDetailSheet, + onMutate, + onSelectedSessionUndo, + onSelectedSessionUndoCancel, + onSelectedSessionUndoReschedule +}: SessionsTabModalsProps) { + const changeInfo = selectedSession + ? getSessionChangeInfo(cls.events ?? [], selectedSession.instance.date) + : {}; + const baseRoom = + (selectedSession + ? roomOverrides.get( + `${selectedSession.instance.date}|${selectedSession.instance.class_time?.split('-')[0]}` + ) ?? roomOverrides.get(selectedSession.instance.date) + : undefined) ?? + cls.events?.[0]?.room_ref?.name ?? + 'TBD'; + const baseInstructor = getInstructorName(cls.events ?? []); + + return ( + <> + {rescheduleTarget && ( + + )} + + {showQuickCancel && quickCancelSession && ( + + )} + + + + {showChangeInstructor && ( + + )} + + {showChangeRoom && ( + + )} + + + + ); +} diff --git a/frontend/src/pages/class-detail/index.tsx b/frontend/src/pages/class-detail/index.tsx index 805a025d9..8660c41eb 100644 --- a/frontend/src/pages/class-detail/index.tsx +++ b/frontend/src/pages/class-detail/index.tsx @@ -4,7 +4,6 @@ import useSWR, { useSWRConfig } from 'swr'; import { Edit, MoreVertical, Trash2 } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { Skeleton } from '@/components/ui/skeleton'; import { DropdownMenu, DropdownMenuContent, @@ -31,35 +30,31 @@ import { AuditTab } from './AuditTab'; import { TakeAttendanceModal } from './TakeAttendanceModal'; import { DeleteClassModal } from './DeleteClassModal'; import { EditClassModal } from './EditClassModal'; +import { LoadingSkeleton } from './LoadingSkeleton'; +import { ClassNotFoundCard } from './ClassNotFoundCard'; + +interface DeleteBlockers { + enrollments?: number; + completions?: number; + attendance_flags?: number; + non_deletable_status?: string; +} + +function getDeleteBlockerReason(blockers: DeleteBlockers | undefined): string { + if (blockers?.non_deletable_status) + return `Only Scheduled classes can be deleted (this class is ${blockers.non_deletable_status})`; + if ((blockers?.enrollments ?? 0) > 0) + return 'Cannot delete class with enrollment records'; + if ((blockers?.completions ?? 0) > 0) + return 'Cannot delete class with program completions'; + if ((blockers?.attendance_flags ?? 0) > 0) + return 'Cannot delete class with attendance records'; + return 'Cannot delete class'; +} const TAB_TRIGGER_CLASS = 'data-[state=active]:bg-[#556830] data-[state=active]:text-white data-[state=active]:shadow-sm data-[state=inactive]:text-gray-600 data-[state=inactive]:hover:text-[#203622] data-[state=inactive]:hover:bg-gray-50 px-4 py-2.5 rounded-lg transition-all duration-200'; -function LoadingSkeleton() { - return ( -
-
-
- - - -
- {Array.from({ length: 5 }).map((_, i) => ( - - ))} -
-
-
-
-
- {Array.from({ length: 3 }).map((_, i) => ( - - ))} -
-
-
- ); -} export default function ClassDetailPage() { const { class_id } = useParams<{ class_id: string }>(); @@ -104,17 +99,7 @@ export default function ClassDetailPage() { const isDeleteCheckReady = deleteCheckResp !== undefined; const canDelete = deleteCheckResp?.data?.can_delete ?? false; const deleteBlockers = deleteCheckResp?.data?.blockers; - const deleteBlockerReason = (() => { - if (deleteBlockers?.non_deletable_status) - return `Only Scheduled classes can be deleted (this class is ${deleteBlockers.non_deletable_status})`; - if ((deleteBlockers?.enrollments ?? 0) > 0) - return 'Cannot delete class with enrollment records'; - if ((deleteBlockers?.completions ?? 0) > 0) - return 'Cannot delete class with program completions'; - if ((deleteBlockers?.attendance_flags ?? 0) > 0) - return 'Cannot delete class with attendance records'; - return 'Cannot delete class'; - })(); + const deleteBlockerReason = getDeleteBlockerReason(deleteBlockers); const cls = classResp?.data; const attendanceRate = rateResp?.data?.attendance_rate ?? 0; @@ -141,25 +126,7 @@ export default function ClassDetailPage() { if (isLoading) return ; if (!cls) { - return ( -
-
-

- Class Not Found -

-

- The class you are looking for does not exist or you do - not have access to it. -

- -
-
- ); + return navigate('/classes')} />; } return ( diff --git a/frontend/src/pages/class-detail/session-utils.ts b/frontend/src/pages/class-detail/session-utils.ts index 8efad20e6..7abd799d6 100644 --- a/frontend/src/pages/class-detail/session-utils.ts +++ b/frontend/src/pages/class-detail/session-utils.ts @@ -1,5 +1,5 @@ -import { ClassEventInstance, ProgramClassEvent, ProgramClassEventOverride } from '@/types/events'; -import { Attendance } from '@/types/attendance'; +import { ClassEventInstance, FacilityProgramClassEvent, ProgramClassEvent, ProgramClassEventOverride } from '@/types/events'; +import { Attendance, SelectedClassStatus } from '@/types/attendance'; export interface SessionDisplay { instance: ClassEventInstance; @@ -28,11 +28,45 @@ interface RescheduleLink { startTime?: string; } -function parseLocalDate(dateStr: string): Date { +export function parseLocalDate(dateStr: string): Date { const [y, m, d] = dateStr.split('-').map(Number); return new Date(y, m - 1, d); } +export function formatShortDate(dateStr: string): string { + const d = parseLocalDate(dateStr); + return d.toLocaleDateString('en-US', { + weekday: 'short', + month: 'short', + day: 'numeric' + }); +} + +export interface SessionPayload { + date: string; + dateLabel: string; + eventId: number; + classTime: string; + dateObj: Date; + dayName: string; +} + +export function buildSessionPayload(session: SessionDisplay): SessionPayload { + return { + date: session.instance.date, + dateLabel: session.dateObj.toLocaleDateString('en-US', { + weekday: 'long', + month: 'long', + day: 'numeric', + year: 'numeric' + }), + eventId: session.instance.event_id ?? session.instance.id, + classTime: session.instance.class_time, + dateObj: session.dateObj, + dayName: session.dayName + }; +} + function parseOverrideDate(rrule: string): string | null { const match = /DTSTART[^:]*:(\d{4})(\d{2})(\d{2})/.exec(rrule); if (!match) return null; @@ -472,3 +506,45 @@ export function buildSessionDisplays( return filtered; } + +export function buildFacilityEvent( + session: SessionDisplay, + classId: number, + classEvents: ProgramClassEvent[] +): FacilityProgramClassEvent { + const eventId = session.instance.event_id ?? session.instance.id; + const backingEvent = classEvents.find((e) => e.id === eventId) ?? classEvents[0]; + const activeOverride = findActiveOverride(classEvents, session.instance.date); + const parts = session.instance.class_time.split('-'); + const [sh = 0, sm = 0] = (parts[0] ?? '').split(':').map(Number); + const [eh = 0, em = 0] = (parts[1] ?? '').split(':').map(Number); + const start = new Date(session.dateObj); + start.setHours(sh, sm, 0, 0); + const end = new Date(session.dateObj); + end.setHours(eh, em, 0, 0); + return { + id: eventId, + class_id: classId, + duration: backingEvent?.duration ?? '', + room_id: activeOverride?.room_id ?? backingEvent?.room_id ?? 0, + recurrence_rule: backingEvent?.recurrence_rule ?? '', + is_cancelled: session.instance.is_cancelled, + instructor_id: activeOverride?.instructor_id ?? backingEvent?.instructor_id ?? null, + overrides: backingEvent?.overrides ?? [], + reason: null, + start, + end, + is_override: !!activeOverride || !!session.instance.override_id, + override_id: activeOverride?.id ?? session.instance.override_id ?? 0, + linked_override_event: null, + room: '', + instructor_name: '', + program_id: 0, + program_name: '', + title: '', + enrolled_users: '', + frequency: '', + credit_types: '', + class_status: SelectedClassStatus.Scheduled + }; +} diff --git a/frontend/src/pages/class-detail/useSessionFilters.ts b/frontend/src/pages/class-detail/useSessionFilters.ts new file mode 100644 index 000000000..919c7d5a8 --- /dev/null +++ b/frontend/src/pages/class-detail/useSessionFilters.ts @@ -0,0 +1,123 @@ +import { useMemo } from 'react'; +import type { SessionDisplay } from './session-utils'; + +export type StatusFilter = 'all' | 'completed' | 'missing' | 'upcoming' | 'cancelled'; +export type TimeFilter = 'week' | '2weeks' | 'month' | 'all'; + +export const PAST_DISPLAY_LIMIT = 15; +export const UPCOMING_DISPLAY_LIMIT = 10; + +const TIME_FILTER_DAYS: Record, number> = { + week: 7, + '2weeks': 14, + month: 28 +}; + +function getTimeCutoff(tf: TimeFilter): Date | null { + if (tf === 'all') return null; + const cutoff = new Date(); + cutoff.setHours(0, 0, 0, 0); + cutoff.setDate(cutoff.getDate() - TIME_FILTER_DAYS[tf]); + return cutoff; +} + +interface UseSessionFiltersArgs { + allSessions: SessionDisplay[]; + statusFilter: StatusFilter; + timeFilter: TimeFilter; + showAllPast: boolean; +} + +interface UseSessionFiltersResult { + filtered: SessionDisplay[]; + pastAndTodaySessions: SessionDisplay[]; + upcomingSessions: SessionDisplay[]; + displayedPast: SessionDisplay[]; + displayedUpcoming: SessionDisplay[]; +} + +export function useSessionFilters({ + allSessions, + statusFilter, + timeFilter, + showAllPast +}: UseSessionFiltersArgs): UseSessionFiltersResult { + const filtered = useMemo(() => { + let result = allSessions; + const cutoff = getTimeCutoff(timeFilter); + + const today = new Date(); + today.setHours(23, 59, 59, 999); + + if (statusFilter === 'all') { + if (cutoff) { + result = result.filter( + (s) => s.dateObj >= cutoff && s.dateObj <= today + ); + } + } else if (statusFilter === 'completed') { + result = result.filter( + (s) => + (s.isPast || s.isToday) && + s.hasAttendance && + !s.isCancelled && + !s.isRescheduledFrom + ); + if (cutoff) { + result = result.filter((s) => s.dateObj >= cutoff); + } + } else if (statusFilter === 'missing') { + result = result.filter( + (s) => + s.isPast && + !s.hasAttendance && + !s.isCancelled && + !s.isRescheduledFrom + ); + if (cutoff) { + result = result.filter((s) => s.dateObj >= cutoff); + } + } else if (statusFilter === 'upcoming') { + result = result.filter( + (s) => s.isUpcoming && !s.isCancelled && !s.isRescheduledFrom + ); + result = [...result].reverse(); + } else if (statusFilter === 'cancelled') { + result = result.filter( + (s) => s.isCancelled && !s.isRescheduledFrom + ); + if (cutoff) { + result = result.filter((s) => s.dateObj >= cutoff); + } + } + + return result; + }, [allSessions, statusFilter, timeFilter]); + + const pastAndTodaySessions = useMemo( + () => filtered.filter((s) => s.isPast || s.isToday), + [filtered] + ); + + const upcomingSessions = useMemo( + () => + filtered + .filter((s) => s.isUpcoming) + .sort((a, b) => a.dateObj.getTime() - b.dateObj.getTime()), + [filtered] + ); + + const displayedPast = showAllPast + ? pastAndTodaySessions + : pastAndTodaySessions.slice(0, PAST_DISPLAY_LIMIT); + + const displayedUpcoming = upcomingSessions.slice(0, UPCOMING_DISPLAY_LIMIT); + + return { + filtered, + pastAndTodaySessions, + upcomingSessions, + displayedPast, + displayedUpcoming + }; +} diff --git a/frontend/src/pages/knowledge-center/ResidentKnowledgeCenter.tsx b/frontend/src/pages/knowledge-center/ResidentKnowledgeCenter.tsx index bf0689958..d9e4e17af 100644 --- a/frontend/src/pages/knowledge-center/ResidentKnowledgeCenter.tsx +++ b/frontend/src/pages/knowledge-center/ResidentKnowledgeCenter.tsx @@ -28,7 +28,6 @@ import { } from '@/components/ui/tooltip'; import { Pagination } from '@/components/Pagination'; import { useDebounceValue } from 'usehooks-ts'; -import { ScrollArea } from '@/components/ui/scroll-area'; import { Library, Video as VideoType, @@ -38,6 +37,8 @@ import { Option } from '@/types'; import { formatVideoDuration } from '@/lib/formatters'; +import { isAdministrator, useAuth } from '@/auth/useAuth'; +import { toast } from 'sonner'; import API from '@/api/api'; const ITEMS_PER_PAGE = 20; @@ -48,6 +49,8 @@ interface ContentItem { title: string; description: string; featured: boolean; + favorited: boolean; + openContentProviderId?: number; imageUrl?: string | null; thumbnailUrl?: string | null; author?: string; @@ -58,7 +61,10 @@ interface ContentItem { export default function ResidentKnowledgeCenter() { const navigate = useNavigate(); + const { user } = useAuth(); + const isAdminPreview = user ? isAdministrator(user) : false; const { tourState, setTourState } = useTourContext(); + const [pendingFavorites, setPendingFavorites] = useState>(new Map()); useEffect(() => { if (tourState?.tourActive) { @@ -111,6 +117,8 @@ export default function ResidentKnowledgeCenter() { title: lib.title, description: lib.description ?? '', featured: !!lib.is_featured, + favorited: lib.is_favorited, + openContentProviderId: lib.open_content_provider_id, imageUrl: lib.thumbnail_url, url: lib.url, categories: lib.tags ?? [] @@ -127,6 +135,7 @@ export default function ResidentKnowledgeCenter() { title: vid.title, description: vid.description, featured: !!vid.is_featured, + favorited: vid.is_favorited, thumbnailUrl: `/api/photos/${vid.external_id}.jpg`, author: vid.channel_title, duration: vid.duration @@ -140,6 +149,7 @@ export default function ResidentKnowledgeCenter() { title: link.title, description: link.description, featured: !!link.is_featured, + favorited: link.is_favorited, url: link.url })); @@ -200,7 +210,54 @@ export default function ResidentKnowledgeCenter() { } }; + const handleToggleFavorite = async (item: ContentItem) => { + const key = `${item.type}-${item.id}`; + const current = pendingFavorites.has(key) + ? pendingFavorites.get(key)! + : item.favorited; + const next = !current; + + setPendingFavorites((prev) => { + const m = new Map(prev); + m.set(key, next); + return m; + }); + + let endpoint = ''; + let payload: object = {}; + if (item.type === 'video') { + endpoint = `videos/${item.id}/favorite`; + } else if (item.type === 'link') { + endpoint = `helpful-links/favorite/${item.id}`; + } else if ( + item.url?.includes('/api/proxy/') && + item.openContentProviderId + ) { + endpoint = `open-content/${item.id}/bookmark`; + payload = { + open_content_provider_id: item.openContentProviderId, + content_url: item.url + }; + } else { + endpoint = `libraries/${item.id}/favorite`; + } + + const resp = await API.put(endpoint, payload); + if (!resp.success) { + setPendingFavorites((prev) => { + const m = new Map(prev); + m.set(key, current); + return m; + }); + toast.error('Failed to update favorites'); + } + }; + const ContentCard = ({ item }: { item: ContentItem }) => { + const favKey = `${item.type}-${item.id}`; + const favorited = pendingFavorites.has(favKey) + ? pendingFavorites.get(favKey)! + : item.favorited; const handleClick = () => { if (item.type === 'library') { navigate(`/viewer/libraries/${item.id}`); @@ -216,11 +273,35 @@ export default function ResidentKnowledgeCenter() { className="bg-white rounded-lg border border-gray-200 p-4 hover:shadow-lg hover:border-[#556830] transition-all cursor-pointer group relative h-full flex flex-col" onClick={handleClick} > - {item.featured && ( -
- -
- )} + {isAdminPreview + ? item.featured && ( +
+ +
+ ) + : ( + + )}
{item.type === 'library' && ( @@ -335,12 +416,8 @@ export default function ResidentKnowledgeCenter() { }; return ( -
- -
+
+

Knowledge Center @@ -480,8 +557,7 @@ export default function ResidentKnowledgeCenter() { /> )} -

- +
); } diff --git a/frontend/src/pages/knowledge-center/VideoViewer.tsx b/frontend/src/pages/knowledge-center/VideoViewer.tsx index 54d5603f6..bfc42bad7 100644 --- a/frontend/src/pages/knowledge-center/VideoViewer.tsx +++ b/frontend/src/pages/knowledge-center/VideoViewer.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { ArrowLeft } from 'lucide-react'; -import { Video, ServerResponseOne } from '@/types'; +import { Video } from '@/types'; import { useAuth, isAdministrator } from '@/auth/useAuth'; import Breadcrumbs from '@/components/navigation/Breadcrumbs'; import { Badge } from '@/components/ui/badge'; @@ -25,10 +25,8 @@ export default function VideoViewer() { useEffect(() => { const fetchVideoData = async () => { - const resp = (await API.get( - `videos/${videoId}` - )) as ServerResponseOne
); } @@ -135,6 +145,7 @@ export default function ResidentHome() { target: '#resident-home' }); } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [tourState.tourActive]); const featuredItems = featured?.data ?? []; diff --git a/frontend/src/types/events.ts b/frontend/src/types/events.ts index 9eae1eea7..7e9fa4d4f 100644 --- a/frontend/src/types/events.ts +++ b/frontend/src/types/events.ts @@ -72,7 +72,7 @@ export interface FacilityProgramClassEvent extends ProgramClassEvent { end: Date; frequency: string; override_id: number; - linked_override_event: FacilityProgramClassEvent; + linked_override_event: FacilityProgramClassEvent | null; credit_types: string; class_status: SelectedClassStatus; }