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