analytica-frontend-lib
Version:
Repositório público dos componentes utilizados nas plataformas da Analytica Ensino
1 lines • 112 kB
Source Map (JSON)
{"version":3,"sources":["../src/components/RecommendedLessonDetails/RecommendedLessonDetails.tsx","../src/components/RecommendedLessonDetails/components/Breadcrumb.tsx","../src/components/RecommendedLessonDetails/components/LessonHeader.tsx","../src/components/RecommendedLessonDetails/utils/lessonDetailsUtils.ts","../src/components/RecommendedLessonDetails/components/LoadingSkeleton.tsx","../src/components/RecommendedLessonDetails/components/ResultsSection.tsx","../src/components/RecommendedLessonDetails/components/StudentsTable.tsx","../src/components/RecommendedLessonDetails/components/StudentPerformanceModal.tsx","../src/components/RecommendedLessonDetails/components/StudentActivityPerformanceModal.tsx","../src/components/RecommendedLessonDetails/components/RecommendedLessonViewModal.tsx","../src/components/RecommendedLessonDetails/utils/performanceUtils.ts"],"sourcesContent":["import { useMemo, useState, useCallback } from 'react';\nimport Text from '../Text/Text';\nimport useToastStore from '../Toast/utils/ToastStore';\nimport { cn } from '../../utils/utils';\nimport type { LessonDetailsData } from '../../types/recommendedLessons';\nimport type { SubjectEnum } from '../../enums/SubjectEnum';\nimport type { BaseApiClient } from '../../types/api';\nimport {\n Breadcrumb,\n LessonHeader,\n LoadingSkeleton,\n ResultsSection,\n StudentsTable,\n StudentActivityPerformanceModal,\n RecommendedLessonViewModal,\n} from './components';\nimport { transformStudentForDisplay } from './utils/lessonDetailsUtils';\nimport {\n convertStudentAnswersToPerformanceData,\n type StudentAnswersResponse,\n} from './utils/performanceUtils';\nimport {\n DEFAULT_LABELS,\n type BreadcrumbItem,\n type LessonDetailsLabels,\n type StudentActivityPerformanceData,\n} from './types';\n\n/**\n * Props for RecommendedLessonDetails component\n */\nexport interface RecommendedLessonDetailsProps {\n /** Lesson data to display (from API responses) */\n data: LessonDetailsData | null;\n /** Loading state */\n loading?: boolean;\n /** Error message */\n error?: string | null;\n /** Callback when \"Ver aula\" button is clicked */\n onViewLesson?: () => void;\n /**\n * API client for making requests.\n * When provided, enables the \"Corrigir atividade\" functionality.\n */\n apiClient?: BaseApiClient;\n /** Callback for breadcrumb navigation */\n onBreadcrumbClick?: (path: string) => void;\n /** Function to map subject name to SubjectEnum */\n mapSubjectNameToEnum?: (name: string) => SubjectEnum | null;\n /** Custom breadcrumb items */\n breadcrumbs?: BreadcrumbItem[];\n /** Custom labels */\n labels?: Partial<LessonDetailsLabels>;\n /** Additional CSS classes */\n className?: string;\n}\n\n/**\n * RecommendedLessonDetails component\n *\n * Displays detailed information about a recommended lesson including:\n * - Breadcrumb navigation\n * - Lesson metadata (title, dates, school, subject, class)\n * - Results cards (completion %, best result topic, hardest topic)\n * - Students table with status, progress, and actions\n *\n * @example\n * ```tsx\n * <RecommendedLessonDetails\n * data={{\n * recommendedClass: recommendedClassData, // from /recommendedClass/{id}\n * details: detailsData, // from /recommendedClass/{id}/details\n * breakdown: breakdown, // optional, from /recommended-class/history\n * }}\n * apiClient={api}\n * onViewLesson={() => navigate('/view-lesson')}\n * mapSubjectNameToEnum={mapSubjectNameToEnum}\n * />\n * ```\n */\nconst RecommendedLessonDetails = ({\n data,\n loading = false,\n error = null,\n onViewLesson,\n apiClient,\n onBreadcrumbClick,\n mapSubjectNameToEnum,\n breadcrumbs,\n labels: customLabels,\n className,\n}: RecommendedLessonDetailsProps) => {\n const labels = useMemo(\n () => ({ ...DEFAULT_LABELS, ...customLabels }),\n [customLabels]\n );\n\n // Toast store for notifications\n const addToast = useToastStore((state) => state.addToast);\n\n // Activity performance modal state\n const [performanceModalOpen, setPerformanceModalOpen] = useState(false);\n const [performanceData, setPerformanceData] =\n useState<StudentActivityPerformanceData | null>(null);\n const [performanceLoading, setPerformanceLoading] = useState(false);\n const [performanceError, setPerformanceError] = useState<string | null>(null);\n\n // View lesson modal state\n const [viewLessonModalOpen, setViewLessonModalOpen] = useState(false);\n\n /**\n * Handle correct activity action\n * Fetches all activities and lessons for the student and opens the performance modal\n */\n const handleCorrectActivity = useCallback(\n async (studentId: string) => {\n if (!apiClient || !data?.recommendedClass.id) return;\n\n // Find student from data\n const student = data?.details.students.find(\n (s) => s.userInstitutionId === studentId\n );\n\n // Guard: ensure student and userId are valid before proceeding\n if (!student?.userId) {\n addToast({\n title: 'Erro ao carregar aluno',\n description: 'Não foi possível identificar o aluno. Tente novamente.',\n variant: 'solid',\n action: 'warning',\n position: 'top-right',\n });\n return;\n }\n\n const studentName = student.name;\n const userId = student.userId;\n\n setPerformanceModalOpen(true);\n setPerformanceLoading(true);\n setPerformanceData(null);\n setPerformanceError(null);\n\n try {\n // Fetch all answers for the student in this recommended class\n const response = await apiClient.get<StudentAnswersResponse>(\n `/recommended-class/${data.recommendedClass.id}/student/${studentId}/answers`\n );\n\n // Convert API response to performance data format\n const performanceData = convertStudentAnswersToPerformanceData(\n response.data,\n studentId,\n userId,\n studentName\n );\n\n setPerformanceData(performanceData);\n } catch (err) {\n console.error('Error fetching activity performance data:', err);\n setPerformanceError(\n err instanceof Error\n ? err.message\n : 'Erro ao carregar desempenho do aluno'\n );\n } finally {\n setPerformanceLoading(false);\n }\n },\n [apiClient, data?.recommendedClass.id, data?.details.students, addToast]\n );\n\n /**\n * Handle close performance modal\n */\n const handleClosePerformanceModal = useCallback(() => {\n setPerformanceModalOpen(false);\n setPerformanceData(null);\n setPerformanceError(null);\n }, []);\n\n /**\n * Handle view lesson action\n * If onViewLesson prop is provided, call it; otherwise open internal modal\n */\n const handleViewLesson = useCallback(() => {\n if (onViewLesson) {\n onViewLesson();\n } else {\n setViewLessonModalOpen(true);\n }\n }, [onViewLesson]);\n\n /**\n * Handle close view lesson modal\n */\n const handleCloseViewLessonModal = useCallback(() => {\n setViewLessonModalOpen(false);\n }, []);\n\n const defaultBreadcrumbs: BreadcrumbItem[] = useMemo(\n () => [\n { label: 'Aulas recomendadas', path: '/aulas-recomendadas' },\n { label: data?.recommendedClass.title || 'Detalhes' },\n ],\n [data?.recommendedClass.title]\n );\n\n const breadcrumbItems = breadcrumbs || defaultBreadcrumbs;\n\n // Transform API students to display format\n const displayStudents = useMemo(() => {\n if (!data?.details.students) return [];\n const deadline = data?.recommendedClass.finalDate;\n return data.details.students.map((student) =>\n transformStudentForDisplay(student, deadline)\n );\n }, [data?.details.students, data?.recommendedClass.finalDate]);\n\n if (loading) {\n return (\n <div\n className={cn('flex flex-col gap-6', className)}\n data-testid=\"lesson-details-loading\"\n >\n <LoadingSkeleton />\n </div>\n );\n }\n\n if (error) {\n return (\n <div\n className={cn(\n 'flex flex-col items-center justify-center py-12',\n className\n )}\n data-testid=\"lesson-details-error\"\n >\n <Text size=\"md\" className=\"text-error-700\">\n {error}\n </Text>\n </div>\n );\n }\n\n if (!data) {\n return null;\n }\n\n return (\n <>\n <div\n className={cn('flex flex-col gap-6', className)}\n data-testid=\"recommended-class-details\"\n >\n {/* Breadcrumb */}\n <Breadcrumb items={breadcrumbItems} onItemClick={onBreadcrumbClick} />\n\n {/* Header with metadata */}\n <LessonHeader\n data={data}\n onViewLesson={handleViewLesson}\n mapSubjectNameToEnum={mapSubjectNameToEnum}\n viewLessonLabel={labels.viewLesson}\n />\n\n {/* Results section */}\n <ResultsSection data={data} labels={labels} />\n\n {/* Students table */}\n <StudentsTable\n students={displayStudents}\n onCorrectActivity={apiClient ? handleCorrectActivity : undefined}\n labels={labels}\n />\n </div>\n\n {/* Student Activity Performance Modal */}\n {apiClient && (\n <StudentActivityPerformanceModal\n isOpen={performanceModalOpen}\n onClose={handleClosePerformanceModal}\n data={performanceData}\n loading={performanceLoading}\n error={performanceError}\n apiClient={apiClient}\n />\n )}\n\n {/* View Lesson Modal */}\n <RecommendedLessonViewModal\n isOpen={viewLessonModalOpen}\n onClose={handleCloseViewLessonModal}\n data={data}\n apiClient={apiClient}\n />\n </>\n );\n};\n\nexport default RecommendedLessonDetails;\n","import { CaretRightIcon } from '@phosphor-icons/react';\nimport Text from '../../Text/Text';\nimport type { BreadcrumbItem } from '../types';\n\n/**\n * Props for Breadcrumb component\n */\ninterface BreadcrumbProps {\n /** Breadcrumb items to display */\n items: BreadcrumbItem[];\n /** Callback when a breadcrumb item is clicked */\n onItemClick?: (path: string) => void;\n}\n\n/**\n * Breadcrumb navigation component\n * Displays a path of navigation items with optional click handlers\n */\nexport const Breadcrumb = ({ items, onItemClick }: BreadcrumbProps) => (\n <nav className=\"flex items-center gap-2 text-sm\" aria-label=\"Breadcrumb\">\n {items.map((item, index) => (\n <Text\n key={item.path ?? item.label}\n as=\"span\"\n className=\"flex items-center gap-2\"\n >\n {index > 0 && <CaretRightIcon size={14} className=\"text-text-500\" />}\n {item.path ? (\n <button\n type=\"button\"\n onClick={() => onItemClick?.(item.path!)}\n className=\"text-text-600 hover:text-primary-700 transition-colors\"\n >\n {item.label}\n </button>\n ) : (\n <Text as=\"span\" className=\"text-text-950 font-medium\">\n {item.label}\n </Text>\n )}\n </Text>\n ))}\n </nav>\n);\n\nexport default Breadcrumb;\n","import { BookBookmarkIcon } from '@phosphor-icons/react';\nimport Text from '../../Text/Text';\nimport Button from '../../Button/Button';\nimport { getSubjectInfo } from '../../SubjectInfo/SubjectInfo';\nimport { cn } from '../../../utils/utils';\nimport type { LessonDetailsData } from '../../../types/recommendedLessons';\nimport type { SubjectEnum } from '../../../enums/SubjectEnum';\nimport { formatDate } from '../utils/lessonDetailsUtils';\n\n/**\n * Props for LessonHeader component\n */\ninterface LessonHeaderProps {\n /** Lesson details data */\n data: LessonDetailsData;\n /** Callback when \"Ver aula\" button is clicked */\n onViewLesson?: () => void;\n /** Function to map subject name to SubjectEnum */\n mapSubjectNameToEnum?: (name: string) => SubjectEnum | null;\n /** Label for the view lesson button */\n viewLessonLabel: string;\n}\n\n/**\n * Header section with lesson metadata\n * Displays title, dates, school, subject, and class information\n */\nexport const LessonHeader = ({\n data,\n onViewLesson,\n mapSubjectNameToEnum,\n viewLessonLabel,\n}: LessonHeaderProps) => {\n const { recommendedClass, breakdown } = data;\n\n // Extract subject from first lesson if available\n const subjectName =\n recommendedClass.lessons[0]?.supLessonsProgress?.lesson?.subject?.name ||\n '';\n const subjectEnum = mapSubjectNameToEnum?.(subjectName);\n const subjectInfo = subjectEnum ? getSubjectInfo(subjectEnum) : null;\n\n return (\n <div className=\"bg-background rounded-xl border border-border-50 p-6\">\n <div className=\"flex flex-col lg:flex-row lg:items-start lg:justify-between gap-4\">\n <div className=\"flex flex-col gap-2\">\n <Text\n as=\"h1\"\n size=\"xl\"\n weight=\"bold\"\n className=\"text-text-950 lg:text-2xl\"\n >\n {recommendedClass.title}\n </Text>\n <div className=\"flex flex-wrap items-center gap-2 text-sm text-text-600\">\n <Text as=\"span\" size=\"sm\" className=\"text-text-600\">\n Início em {formatDate(recommendedClass.startDate)}\n </Text>\n <Text as=\"span\" size=\"sm\" className=\"text-text-400\">\n •\n </Text>\n <Text as=\"span\" size=\"sm\" className=\"text-text-600\">\n Prazo final {formatDate(recommendedClass.finalDate)}\n </Text>\n {breakdown?.schoolName && (\n <>\n <Text as=\"span\" size=\"sm\" className=\"text-text-400\">\n •\n </Text>\n <Text as=\"span\" size=\"sm\" className=\"text-text-600\">\n {breakdown.schoolName}\n </Text>\n </>\n )}\n {subjectName && (\n <>\n <Text as=\"span\" size=\"sm\" className=\"text-text-400\">\n •\n </Text>\n <Text as=\"span\" size=\"sm\" className=\"flex items-center gap-1\">\n {subjectInfo && (\n <Text\n as=\"span\"\n className={cn(\n 'size-5 rounded flex items-center justify-center text-white',\n subjectInfo.colorClass\n )}\n >\n {subjectInfo.icon}\n </Text>\n )}\n {subjectName}\n </Text>\n </>\n )}\n {breakdown?.className && (\n <>\n <Text as=\"span\" size=\"sm\" className=\"text-text-400\">\n •\n </Text>\n <Text as=\"span\" size=\"sm\" className=\"text-text-600\">\n {breakdown.className}\n </Text>\n </>\n )}\n </div>\n </div>\n {onViewLesson && (\n <Button\n variant=\"solid\"\n action=\"primary\"\n size=\"small\"\n iconLeft={<BookBookmarkIcon size={16} />}\n onClick={onViewLesson}\n >\n {viewLessonLabel}\n </Button>\n )}\n </div>\n </div>\n );\n};\n\nexport default LessonHeader;\n","import type { RecommendedClassDetailStudent } from '../../../types/recommendedLessons';\nimport {\n deriveStudentStatus,\n formatDaysToComplete,\n} from '../../../types/recommendedLessons';\nimport type { DisplayStudent } from '../types';\n\n/**\n * Format date string to display format (DD/MM/YYYY)\n * @param dateString - ISO date string or null\n * @returns Formatted date string or placeholder\n */\nexport const formatDate = (dateString: string | null): string => {\n if (!dateString) return '-';\n const date = new Date(dateString);\n if (Number.isNaN(date.getTime())) return '-';\n return date.toLocaleDateString('pt-BR', {\n day: '2-digit',\n month: '2-digit',\n year: 'numeric',\n });\n};\n\n/**\n * Transform API student data to display format\n * @param student - Student data from API\n * @param deadline - RecommendedClass deadline to determine NAO_FINALIZADO status\n * @returns Transformed student for table display\n */\nexport const transformStudentForDisplay = (\n student: RecommendedClassDetailStudent,\n deadline?: string | null\n): DisplayStudent => ({\n id: student.userInstitutionId,\n name: student.name,\n status: deriveStudentStatus(student.progress, student.completedAt, deadline),\n completionPercentage: student.progress,\n duration: formatDaysToComplete(student.daysToComplete),\n});\n","import {\n SkeletonText,\n SkeletonRounded,\n SkeletonTable,\n} from '../../Skeleton/Skeleton';\n\n/**\n * Loading skeleton component for RecommendedLessonDetails\n * Uses the shared Skeleton components from the library\n */\nexport const LoadingSkeleton = () => (\n <div className=\"flex flex-col gap-6\">\n {/* Breadcrumb skeleton */}\n <SkeletonText width={256} />\n\n {/* Header skeleton */}\n <div className=\"bg-background rounded-xl border border-border-50 p-6\">\n <div className=\"flex flex-col gap-3\">\n <SkeletonText width=\"75%\" height={28} />\n <SkeletonText width=\"50%\" />\n </div>\n </div>\n\n {/* Results section skeleton */}\n <div className=\"flex flex-col gap-4\">\n <SkeletonText width={192} height={20} />\n <div className=\"grid grid-cols-1 md:grid-cols-3 gap-4\">\n <SkeletonRounded height={140} />\n <SkeletonRounded height={140} />\n <SkeletonRounded height={140} />\n </div>\n </div>\n\n {/* Table skeleton */}\n <div className=\"bg-background rounded-xl border border-border-50 p-4\">\n <SkeletonTable rows={4} columns={5} />\n </div>\n </div>\n);\n\nexport default LoadingSkeleton;\n","import { TrophyIcon, WarningIcon } from '@phosphor-icons/react';\nimport Text from '../../Text/Text';\nimport ProgressCircle from '../../ProgressCircle/ProgressCircle';\nimport type { LessonDetailsData } from '../../../types/recommendedLessons';\nimport type { LessonDetailsLabels } from '../types';\n\n/**\n * Props for ResultsSection component\n */\ninterface ResultsSectionProps {\n /** Lesson details data */\n data: LessonDetailsData;\n /** Labels for the component */\n labels: LessonDetailsLabels;\n}\n\n/**\n * Results section with 3 cards\n * Displays completion percentage, best result, and hardest topic\n */\nexport const ResultsSection = ({ data, labels }: ResultsSectionProps) => {\n const { details } = data;\n const { aggregated, contentPerformance } = details;\n\n return (\n <div className=\"flex flex-col gap-4\">\n <Text as=\"h2\" size=\"md\" weight=\"semibold\" className=\"text-text-950\">\n {labels.resultsTitle}\n </Text>\n {/* White container with the 3 cards */}\n <div className=\"bg-background rounded-xl border border-border-50 p-4\">\n <div className=\"grid grid-cols-1 md:grid-cols-3 gap-4\">\n {/* Completion percentage card */}\n <div className=\"flex flex-col items-center justify-center rounded-xl p-4 min-h-28 bg-primary-50\">\n <ProgressCircle\n value={aggregated.completionPercentage}\n size=\"small\"\n variant=\"blue\"\n label={labels.completedLabel}\n showPercentage\n />\n </div>\n\n {/* Best result topic card */}\n <div className=\"flex flex-col items-center justify-center rounded-xl p-4 min-h-28 bg-success-200\">\n <Text\n as=\"span\"\n className=\"size-8 rounded-full flex items-center justify-center bg-warning-300 mb-2\"\n >\n <TrophyIcon size={18} weight=\"fill\" className=\"text-white\" />\n </Text>\n <Text\n size=\"2xs\"\n weight=\"bold\"\n className=\"text-text-700 uppercase text-center leading-none mb-1\"\n >\n {labels.bestResultLabel}\n </Text>\n <Text\n size=\"xl\"\n weight=\"bold\"\n className=\"text-success-700 text-center leading-none tracking-wide\"\n >\n {contentPerformance.best?.contentName || '-'}\n </Text>\n </div>\n\n {/* Hardest topic card */}\n <div className=\"flex flex-col items-center justify-center rounded-xl p-4 min-h-28 bg-error-100\">\n <Text\n as=\"span\"\n className=\"size-8 rounded-full flex items-center justify-center bg-error-300 mb-2\"\n >\n <WarningIcon size={18} weight=\"fill\" className=\"text-error-700\" />\n </Text>\n <Text\n size=\"2xs\"\n weight=\"bold\"\n className=\"text-text-700 uppercase text-center leading-none mb-1\"\n >\n {labels.hardestTopicLabel}\n </Text>\n <Text\n size=\"xl\"\n weight=\"bold\"\n className=\"text-error-700 text-center leading-none tracking-wide\"\n >\n {contentPerformance.worst?.contentName || '-'}\n </Text>\n </div>\n </div>\n </div>\n </div>\n );\n};\n\nexport default ResultsSection;\n","import { UserIcon } from '@phosphor-icons/react';\nimport Text from '../../Text/Text';\nimport { TruncatedText } from '../../TruncatedText/TruncatedText';\nimport Button from '../../Button/Button';\nimport Badge from '../../Badge/Badge';\nimport ProgressBar from '../../ProgressBar/ProgressBar';\nimport Table, {\n TableHeader,\n TableBody,\n TableHead,\n TableRow,\n TableCell,\n useTableSort,\n} from '../../Table/Table';\nimport { getStudentStatusBadgeAction } from '../../../types/recommendedLessons';\nimport type { DisplayStudent, LessonDetailsLabels } from '../types';\n\n/**\n * Props for StudentsTable component\n */\ninterface StudentsTableProps {\n /** List of students to display */\n students: DisplayStudent[];\n /** Callback when correct activity is clicked */\n onCorrectActivity?: (studentId: string) => void;\n /** Labels for the component */\n labels: LessonDetailsLabels;\n /** Empty state message */\n emptyMessage?: string;\n}\n\n/**\n * Students table component\n * Displays students with their status, completion, and duration\n */\nexport const StudentsTable = ({\n students,\n onCorrectActivity,\n labels,\n emptyMessage = 'Nenhum aluno encontrado',\n}: StudentsTableProps) => {\n const { sortedData, sortColumn, sortDirection, handleSort } =\n useTableSort<DisplayStudent>(students);\n\n return (\n <div className=\"bg-background rounded-xl border border-border-50 overflow-hidden\">\n <Table>\n <TableHeader>\n <TableRow>\n <TableHead\n sortable\n sortDirection={sortColumn === 'name' ? sortDirection : undefined}\n onSort={() => handleSort('name')}\n >\n {labels.studentColumn}\n </TableHead>\n <TableHead\n sortable\n sortDirection={\n sortColumn === 'status' ? sortDirection : undefined\n }\n onSort={() => handleSort('status')}\n >\n {labels.statusColumn}\n </TableHead>\n <TableHead\n sortable\n sortDirection={\n sortColumn === 'completionPercentage'\n ? sortDirection\n : undefined\n }\n onSort={() => handleSort('completionPercentage')}\n >\n {labels.completionColumn}\n </TableHead>\n <TableHead>{labels.durationColumn}</TableHead>\n <TableHead className=\"w-[160px]\" />\n </TableRow>\n </TableHeader>\n <TableBody>\n {sortedData.length === 0 ? (\n <TableRow>\n <TableCell colSpan={5} className=\"text-center py-8\">\n <Text size=\"sm\" className=\"text-text-500\">\n {emptyMessage}\n </Text>\n </TableCell>\n </TableRow>\n ) : (\n sortedData.map((student) => (\n <TableRow key={student.id}>\n <TableCell className=\"max-w-[220px]\">\n <div className=\"flex items-center gap-2 min-w-0\">\n <Text\n as=\"span\"\n className=\"size-8 rounded-full bg-background-100 flex items-center justify-center shrink-0\"\n >\n <UserIcon size={16} className=\"text-text-500\" />\n </Text>\n <TruncatedText size=\"sm\" color=\"text-text-950\">\n {student.name}\n </TruncatedText>\n </div>\n </TableCell>\n <TableCell>\n <Badge\n variant=\"solid\"\n action={getStudentStatusBadgeAction(student.status)}\n size=\"small\"\n >\n {student.status}\n </Badge>\n </TableCell>\n <TableCell>\n <div className=\"flex flex-col gap-1 min-w-[120px]\">\n <Text size=\"sm\" className=\"text-primary-700 font-medium\">\n {student.completionPercentage}%\n </Text>\n <ProgressBar\n value={student.completionPercentage}\n size=\"small\"\n variant=\"blue\"\n className=\"w-full max-w-[100px]\"\n />\n </div>\n </TableCell>\n <TableCell>\n <Text size=\"sm\" className=\"text-text-700\">\n {student.duration ?? '-'}\n </Text>\n </TableCell>\n <TableCell>\n {onCorrectActivity && (\n <Button\n variant=\"outline\"\n size=\"extra-small\"\n onClick={() => onCorrectActivity(student.id)}\n >\n {labels.correctActivity}\n </Button>\n )}\n </TableCell>\n </TableRow>\n ))\n )}\n </TableBody>\n </Table>\n </div>\n );\n};\n\nexport default StudentsTable;\n","import { useMemo, type ReactNode } from 'react';\nimport { LightbulbFilamentIcon } from '@phosphor-icons/react/dist/csr/LightbulbFilament';\nimport { WarningCircleIcon } from '@phosphor-icons/react/dist/csr/WarningCircle';\nimport { UserIcon } from '@phosphor-icons/react/dist/csr/User';\nimport { CheckCircleIcon } from '@phosphor-icons/react/dist/csr/CheckCircle';\nimport { XCircleIcon } from '@phosphor-icons/react/dist/csr/XCircle';\nimport Modal from '../../Modal/Modal';\nimport Text from '../../Text/Text';\nimport Badge from '../../Badge/Badge';\nimport ProgressBar from '../../ProgressBar/ProgressBar';\nimport { CardAccordation } from '../../Accordation/Accordation';\nimport {\n AlternativesList,\n type Alternative,\n} from '../../Alternative/Alternative';\nimport { OptionStatus } from '../../../enums/Options';\nimport type {\n StudentPerformanceData,\n StudentPerformanceLabels,\n LessonProgress,\n LessonQuestion,\n} from '../types';\nimport { DEFAULT_PERFORMANCE_LABELS } from '../types';\nimport { HtmlMathRenderer } from '../../HtmlMathRenderer';\n\n/**\n * Props for StudentPerformanceModal component\n */\nexport interface StudentPerformanceModalProps {\n /** Whether the modal is open */\n isOpen: boolean;\n /** Callback to close the modal */\n onClose: () => void;\n /** Student performance data */\n data: StudentPerformanceData | null;\n /** Loading state */\n loading?: boolean;\n /** Error message */\n error?: string | null;\n /** Custom labels */\n labels?: Partial<StudentPerformanceLabels>;\n}\n\n/**\n * Performance card with colored header and white footer section\n * Matches the Figma design with icon, label, value on top and secondary info below\n */\nconst PerformanceCard = ({\n icon,\n label,\n value,\n secondaryLabel,\n secondaryValue,\n variant,\n}: {\n icon: ReactNode;\n label: string;\n value: string | number;\n secondaryLabel: string;\n secondaryValue: string | null;\n variant: 'success' | 'error';\n}) => {\n const headerBgColor = {\n success: 'bg-success-200',\n error: 'bg-error-100',\n }[variant];\n\n const valueColor = {\n success: 'text-success-700',\n error: 'text-error-700',\n }[variant];\n\n const iconBgColor = {\n success: 'bg-warning-300',\n error: 'bg-error-300',\n }[variant];\n\n const secondaryLabelColor = {\n success: 'text-success-600',\n error: 'text-error-600',\n }[variant];\n\n return (\n <div className=\"flex flex-col rounded-xl border border-border-50 bg-background overflow-hidden\">\n {/* Header section with colored background */}\n <div\n className={`flex flex-col items-center justify-center p-4 gap-1 ${headerBgColor}`}\n >\n <Text\n as=\"span\"\n className={`size-8 rounded-full flex items-center justify-center ${iconBgColor}`}\n >\n {icon}\n </Text>\n <Text\n size=\"2xs\"\n weight=\"medium\"\n className=\"text-text-800 uppercase text-center leading-none\"\n >\n {label}\n </Text>\n <Text size=\"xl\" weight=\"bold\" className={`${valueColor} text-center`}>\n {value}\n </Text>\n </div>\n\n {/* Footer section with white background */}\n <div className=\"flex flex-col items-center gap-2 px-4 py-3\">\n <Text\n size=\"2xs\"\n weight=\"medium\"\n className={`${secondaryLabelColor} uppercase text-center`}\n >\n {secondaryLabel}\n </Text>\n <Badge size=\"large\" action=\"info\">\n {secondaryValue || '-'}\n </Badge>\n </div>\n </div>\n );\n};\n\n/**\n * Determine the status of an alternative based on correctness and selection\n */\nconst getAlternativeStatus = (\n isCorrect: boolean,\n isSelected: boolean\n): OptionStatus | undefined => {\n if (isCorrect) {\n return OptionStatus.CORRECT;\n }\n if (isSelected) {\n return OptionStatus.INCORRECT;\n }\n return undefined;\n};\n\n/**\n * Convert question alternatives to AlternativesList format\n */\nconst mapAlternatives = (question: LessonQuestion): Alternative[] => {\n return question.alternatives.map((alt) => ({\n value: alt.id,\n label: alt.text,\n status: getAlternativeStatus(alt.isCorrect, alt.isSelected),\n }));\n};\n\n/**\n * Get selected alternative value from question\n */\nconst getSelectedValue = (question: LessonQuestion): string | undefined => {\n const selected = question.alternatives.find((alt) => alt.isSelected);\n return selected?.id;\n};\n\n/**\n * Question accordion item inside a lesson\n */\nconst QuestionAccordionItem = ({\n question,\n index,\n}: {\n question: LessonQuestion;\n index: number;\n}) => (\n <CardAccordation\n value={question.id}\n className=\"bg-background rounded-xl border border-border-50\"\n triggerClassName=\"py-5 px-5\"\n contentClassName=\"px-5 pb-5\"\n trigger={\n <div className=\"flex items-center justify-between flex-1\">\n <Text size=\"sm\" weight=\"semibold\" className=\"text-text-950\">\n Questão {index + 1}\n </Text>\n <Badge\n size=\"small\"\n action={question.isCorrect ? 'success' : 'error'}\n variant=\"solid\"\n iconLeft={question.isCorrect ? <CheckCircleIcon /> : <XCircleIcon />}\n >\n {question.isCorrect ? 'Correta' : 'Incorreta'}\n </Badge>\n </div>\n }\n >\n <div className=\"flex flex-col gap-3\">\n {/* Question statement */}\n <HtmlMathRenderer\n content={question.statement}\n className=\"text-sm text-text-700\"\n />\n\n {/* Alternatives accordion */}\n <CardAccordation\n value={`${question.id}-alternatives`}\n className=\"bg-background rounded-lg border border-border-50\"\n triggerClassName=\"py-5 px-5\"\n contentClassName=\"px-5 py-5\"\n trigger={\n <Text size=\"sm\" weight=\"medium\" className=\"text-text-800\">\n Alternativas\n </Text>\n }\n >\n <AlternativesList\n mode=\"readonly\"\n alternatives={mapAlternatives(question)}\n selectedValue={getSelectedValue(question)}\n layout=\"default\"\n />\n </CardAccordation>\n </div>\n </CardAccordation>\n);\n\n/**\n * Lesson accordion item\n */\nconst LessonAccordionItem = ({ lesson }: { lesson: LessonProgress }) => (\n <CardAccordation\n value={lesson.id}\n className=\"bg-background rounded-xl border border-border-50\"\n triggerClassName=\"py-5 px-5\"\n contentClassName=\"px-5 pb-5\"\n trigger={\n <div className=\"flex flex-col gap-1 flex-1\">\n <Text size=\"sm\" weight=\"semibold\" className=\"text-text-950\">\n {lesson.title}\n </Text>\n <ProgressBar\n value={lesson.progress}\n variant=\"blue\"\n size=\"medium\"\n showPercentage\n layout=\"default\"\n />\n </div>\n }\n >\n <div className=\"flex flex-col gap-2\">\n {lesson.questions.map((question, index) => (\n <QuestionAccordionItem\n key={question.id}\n question={question}\n index={index}\n />\n ))}\n </div>\n </CardAccordation>\n);\n\n/**\n * StudentPerformanceModal component\n *\n * Displays a modal with student performance details including:\n * - Student name with search icon\n * - Two performance cards (correct answers, incorrect answers)\n * - Expandable list of lessons with progress\n *\n * @example\n * ```tsx\n * <StudentPerformanceModal\n * isOpen={isModalOpen}\n * onClose={() => setIsModalOpen(false)}\n * data={{\n * studentName: 'Lucas Oliveira',\n * correctAnswers: 8,\n * incorrectAnswers: 7,\n * bestResult: 'Fotossíntese',\n * hardestTopic: 'Células',\n * lessons: [\n * { id: '1', title: 'Categorias Taxonômicas', progress: 50 },\n * ],\n * }}\n * />\n * ```\n */\n/**\n * Loading skeleton for the modal content\n */\nconst LoadingSkeleton = () => (\n <div className=\"flex flex-col gap-4 animate-pulse\">\n <div className=\"h-6 bg-background-200 rounded w-48\" />\n <div className=\"grid grid-cols-2 gap-3\">\n <div className=\"h-44 bg-background-200 rounded-xl\" />\n <div className=\"h-44 bg-background-200 rounded-xl\" />\n </div>\n </div>\n);\n\n/**\n * Error state component\n */\nconst ErrorContent = ({ message }: { message: string }) => (\n <div className=\"flex flex-col items-center justify-center py-8 gap-3\">\n <Text\n as=\"span\"\n className=\"size-12 rounded-full bg-error-100 flex items-center justify-center\"\n >\n <WarningCircleIcon size={24} className=\"text-error-700\" />\n </Text>\n <Text size=\"md\" className=\"text-error-700 text-center\">\n {message}\n </Text>\n </div>\n);\n\n/**\n * Performance content when data is available\n */\nconst PerformanceContent = ({\n data,\n labels,\n}: {\n data: StudentPerformanceData;\n labels: StudentPerformanceLabels;\n}) => (\n <div className=\"flex flex-col gap-5\">\n {/* Student name with icon */}\n <div className=\"flex items-center gap-2\">\n <Text\n as=\"span\"\n className=\"size-8 rounded-full bg-background-100 flex items-center justify-center\"\n >\n <UserIcon size={16} className=\"text-text-500\" />\n </Text>\n <Text size=\"md\" weight=\"medium\" className=\"text-text-950\">\n {data.studentName}\n </Text>\n </div>\n\n {/* Two performance cards in a row */}\n <div className=\"grid grid-cols-2 gap-3\">\n {/* Correct answers card */}\n <PerformanceCard\n icon={\n <LightbulbFilamentIcon\n size={18}\n weight=\"fill\"\n className=\"text-white\"\n />\n }\n label={labels.correctAnswersLabel}\n value={data.correctAnswers}\n secondaryLabel={labels.bestResultLabel}\n secondaryValue={data.bestResult}\n variant=\"success\"\n />\n\n {/* Incorrect answers card */}\n <PerformanceCard\n icon={\n <WarningCircleIcon\n size={18}\n weight=\"fill\"\n className=\"text-error-700\"\n />\n }\n label={labels.incorrectAnswersLabel}\n value={data.incorrectAnswers}\n secondaryLabel={labels.hardestTopicLabel}\n secondaryValue={data.hardestTopic}\n variant=\"error\"\n />\n </div>\n\n {/* Lessons section */}\n {data.lessons.length > 0 && (\n <div className=\"flex flex-col gap-3\">\n <Text size=\"md\" weight=\"semibold\" className=\"text-text-950\">\n {labels.lessonsTitle}\n </Text>\n <div className=\"flex flex-col gap-2\">\n {data.lessons.map((lesson) => (\n <LessonAccordionItem key={lesson.id} lesson={lesson} />\n ))}\n </div>\n </div>\n )}\n </div>\n);\n\n/**\n * Renders the modal content based on loading, error, and data state\n */\nconst renderModalContent = (\n loading: boolean,\n error: string | null | undefined,\n data: StudentPerformanceData | null,\n labels: StudentPerformanceLabels\n): ReactNode => {\n if (loading) {\n return <LoadingSkeleton />;\n }\n\n if (error) {\n return <ErrorContent message={error} />;\n }\n\n if (data) {\n return <PerformanceContent data={data} labels={labels} />;\n }\n\n return null;\n};\n\nexport const StudentPerformanceModal = ({\n isOpen,\n onClose,\n data,\n loading = false,\n error = null,\n labels: customLabels,\n}: StudentPerformanceModalProps) => {\n const labels = useMemo(\n () => ({ ...DEFAULT_PERFORMANCE_LABELS, ...customLabels }),\n [customLabels]\n );\n\n if (!data && !loading && !error) {\n return null;\n }\n\n return (\n <Modal\n isOpen={isOpen}\n onClose={onClose}\n title={labels.title}\n size=\"lg\"\n contentClassName=\"max-h-[70vh] overflow-y-auto\"\n >\n {renderModalContent(loading, error, data, labels)}\n </Modal>\n );\n};\n\nexport default StudentPerformanceModal;\n","import {\n useMemo,\n useState,\n useCallback,\n useEffect,\n type ReactNode,\n} from 'react';\nimport { TimerIcon } from '@phosphor-icons/react/dist/csr/Timer';\nimport { TrophyIcon } from '@phosphor-icons/react/dist/csr/Trophy';\nimport { WarningIcon } from '@phosphor-icons/react/dist/csr/Warning';\nimport { UserIcon } from '@phosphor-icons/react/dist/csr/User';\nimport { WarningCircleIcon } from '@phosphor-icons/react/dist/csr/WarningCircle';\nimport type { Icon } from '@phosphor-icons/react';\nimport Modal from '../../Modal/Modal';\nimport Text from '../../Text/Text';\nimport Badge from '../../Badge/Badge';\nimport Button from '../../Button/Button';\nimport Radio from '../../Radio/Radio';\nimport TextArea from '../../TextArea/TextArea';\nimport ProgressBar from '../../ProgressBar/ProgressBar';\nimport { CardAccordation, AccordionGroup } from '../../Accordation';\nimport {\n AlternativesList,\n type Alternative,\n} from '../../Alternative/Alternative';\nimport { OptionStatus } from '../../../enums/Options';\nimport useToastStore from '../../Toast/utils/ToastStore';\nimport { StatCard } from '../../shared/StatCard';\nimport type { BaseApiClient } from '../../../types/api';\nimport type {\n StudentActivityPerformanceData,\n StudentActivityPerformanceLabels,\n PerformanceActivity,\n PerformanceLesson,\n LessonQuestion,\n} from '../types';\nimport { DEFAULT_ACTIVITY_PERFORMANCE_LABELS } from '../types';\nimport { cn } from '../../../utils/utils';\nimport { HtmlMathRenderer } from '../../HtmlMathRenderer';\n\n/** Field names for essay correction state updates */\nconst EssayCorrectionField = {\n IsCorrect: 'isCorrect',\n TeacherFeedback: 'teacherFeedback',\n} as const;\n\n/**\n * Essay correction state for a question\n */\ninterface EssayCorrectionState {\n isCorrect: boolean | null;\n teacherFeedback: string;\n isSaving: boolean;\n isSaved: boolean;\n}\n\n/**\n * Props for StudentActivityPerformanceModal component\n */\nexport interface StudentActivityPerformanceModalProps {\n /** Whether the modal is open */\n isOpen: boolean;\n /** Callback to close the modal */\n onClose: () => void;\n /** Student activity performance data */\n data: StudentActivityPerformanceData | null;\n /** Loading state */\n loading?: boolean;\n /** Error message */\n error?: string | null;\n /** API client for saving corrections */\n apiClient?: BaseApiClient;\n /** Custom labels */\n labels?: Partial<StudentActivityPerformanceLabels>;\n}\n\n/**\n * Configuration for info card variants\n */\ntype InfoVariant = 'time' | 'best' | 'hardest';\n\nconst infoVariantConfig: Record<\n InfoVariant,\n {\n borderColor: string;\n labelColor: string;\n IconComponent: Icon;\n iconColor: string;\n }\n> = {\n time: {\n borderColor: 'border-primary-200',\n labelColor: 'text-primary-600',\n IconComponent: TimerIcon,\n iconColor: 'text-primary-600',\n },\n best: {\n borderColor: 'border-success-200',\n labelColor: 'text-success-600',\n IconComponent: TrophyIcon,\n iconColor: 'text-success-600',\n },\n hardest: {\n borderColor: 'border-error-200',\n labelColor: 'text-error-600',\n IconComponent: WarningIcon,\n iconColor: 'text-error-600',\n },\n};\n\n/**\n * Info card for time/best result/hardest topic\n */\nconst InfoCard = ({\n label,\n value,\n variant,\n}: {\n label: string;\n value: string | null;\n variant: InfoVariant;\n}) => {\n const config = infoVariantConfig[variant];\n\n return (\n <div\n className={cn(\n 'border rounded-xl py-3 px-4 flex flex-col items-center justify-center gap-2',\n config.borderColor\n )}\n >\n <Text className={cn('text-2xs font-medium uppercase', config.labelColor)}>\n {label}\n </Text>\n <Badge size=\"large\" action=\"info\">\n {value || '-'}\n </Badge>\n </div>\n );\n};\n\n/**\n * Determine the status of an alternative based on correctness and selection\n */\nconst getAlternativeStatus = (\n isCorrect: boolean,\n isSelected: boolean\n): OptionStatus | undefined => {\n if (isCorrect) return OptionStatus.CORRECT;\n if (isSelected) return OptionStatus.INCORRECT;\n return undefined;\n};\n\n/**\n * Convert question alternatives to AlternativesList format\n */\nconst mapAlternatives = (question: LessonQuestion): Alternative[] => {\n return question.alternatives.map((alt) => ({\n value: alt.id,\n label: alt.text,\n status: getAlternativeStatus(alt.isCorrect, alt.isSelected),\n }));\n};\n\n/**\n * Get selected alternative value from question\n */\nconst getSelectedValue = (question: LessonQuestion): string | undefined => {\n const selected = question.alternatives.find((alt) => alt.isSelected);\n return selected?.id;\n};\n\n/**\n * Get badge action based on isCorrect value\n */\nconst getBadgeAction = (\n isCorrect: boolean | null\n): 'success' | 'error' | 'info' => {\n if (isCorrect === true) return 'success';\n if (isCorrect === false) return 'error';\n return 'info';\n};\n\n/**\n * Get badge label based on isCorrect value using provided labels\n */\nconst getBadgeLabel = (\n isCorrect: boolean | null,\n labels: StudentActivityPerformanceLabels\n): string => {\n if (isCorrect === true) return labels.markCorrect;\n if (isCorrect === false) return labels.markIncorrect;\n return labels.pending;\n};\n\n/**\n * Generate a unique key for a question\n * Uses answerId if present, otherwise falls back to activityId-questionId composite\n */\nconst getQuestionKey = (question: LessonQuestion): string => {\n if (question.answerId) {\n return question.answerId;\n }\n return `${question.activityId}-${question.id}`;\n};\n\n/**\n * Lesson item with progress bar\n */\nconst LessonItem = ({ lesson }: { lesson: PerformanceLesson }) => (\n <div className=\"bg-background rounded-xl border border-border-50 py-4 px-4 flex flex-col gap-2\">\n <Text size=\"sm\" weight=\"semibold\" className=\"text-text-950\">\n {lesson.title}\n </Text>\n <div className=\"flex items-center gap-2\">\n <ProgressBar\n value={lesson.progress}\n size=\"small\"\n variant=\"blue\"\n className=\"flex-1\"\n />\n <Text\n size=\"xs\"\n className=\"text-primary-700 font-medium min-w-[40px] text-right\"\n >\n {lesson.progress}%\n </Text>\n </div>\n </div>\n);\n\n/**\n * Loading skeleton\n */\nconst LoadingSkeleton = () => (\n <div\n data-testid=\"loading-skeleton\"\n className=\"flex flex-col gap-4 animate-pulse\"\n >\n <div className=\"h-6 bg-background-200 rounded w-48\" />\n <div className=\"grid grid-cols-3 gap-3\">\n <div className=\"h-28 bg-background-200 rounded-xl\" />\n <div className=\"h-28 bg-background-200 rounded-xl\" />\n <div className=\"h-28 bg-background-200 rounded-xl\" />\n </div>\n <div className=\"grid grid-cols-3 gap-3\">\n <div className=\"h-20 bg-background-200 rounded-xl\" />\n <div className=\"h-20 bg-background-200 rounded-xl\" />\n <div className=\"h-20 bg-background-200 rounded-xl\" />\n </div>\n </div>\n);\n\n/**\n * Error content\n */\nconst ErrorContent = ({ message }: { message: string }) => (\n <div className=\"flex flex-col items-center justify-center py-8 gap-3\">\n <Text\n as=\"span\"\n className=\"size-12 rounded-full bg-error-100 flex items-center justify-center\"\n >\n <WarningCircleIcon size={24} className=\"text-error-700\" />\n </Text>\n <Text size=\"md\" className=\"text-error-700 text-center\">\n {message}\n </Text>\n </div>\n);\n\n/**\n * StudentActivityPerformanceModal component\n *\n * Displays a modal with student activity performance including:\n * - Student name\n * - Result cards (score, correct/incorrect counts, time, best/hardest topics)\n * - Activities list with expandable questions and correction controls\n * - Lessons section with progress bars\n */\nexport const StudentActivityPerformanceModal = ({\n isOpen,\n onClose,\n data,\n loading = false,\n error = null,\n apiClient,\n labels: customLabels,\n}: StudentActivityPerformanceModalProps) => {\n const labels = useMemo(\n () => ({ ...DEFAULT_ACTIVITY_PERFORMANCE_LABELS, ...customLabels }),\n [customLabels]\n );\n\n // Toast store for notifications\n const addToast = useToastStore((state) => state.addToast);\n\n // State for essay question corrections (keyed by question id)\n const [essayCorrections, setEssayCorrections] = useState<\n Record<string, EssayCorrectionState>\n >({});\n\n // Initialize essay corrections from data when modal opens\n useEffect(() => {\n if (isOpen && data) {\n const initialCorrections: Record<string, EssayCorrectionState> = {};\n\n data.activities.forEach((activity) => {\n activity.questions.forEach((question) => {\n // Initialize all questions with their current state using unique key\n const questionKey = getQuestionKey(question);\n initialCorrections[questionKey] = {\n isCorrect: question.isCorrect,\n teacherFeedback: question.teacherFeedback || '',\n isSaving: false,\n isSaved: question.isCorrect != null,\n };\n });\n });\n\n setEssayCorrections(initialCorrections);\n }\n }, [isOpen, data]);\n\n /**\n * Update essay correction state\n * Resets isSaved to false when any field changes so badge only updates after saving\n */\n const updateEssayCorrection = useCallback(\n (\n questionId: string,\n field:\n | (typeof EssayCorrectionField)['IsCorrect']\n | (typeof EssayCorrectionField)['TeacherFeedback'],\n value: boolean | string\n ) => {\n setEssayCorrections((prev) => ({\n ...prev,\n [questionId]: {\n ...prev[questionId],\n [field]: value,\n // Reset isSaved when any field changes so badge doesn't update until saved\n isSaved: false,\n },\n }));\n },\n []\n );\n\n /**\n * Handle saving essay question correction\n */\n const handleSaveEssayCorrection = useCallback(\n async (question: LessonQuestion) => {\n if (!apiClient || !data) return;\n\n const questionKey = getQuestionKey(question);\n const correction = essayCorrections[questionKey];\n if (correction?.isCorrect == null) {\n return;\n }\n\n setEssayCorrections((prev) => ({\n ...prev,\n [questionKey]: { ...prev[questionKey], isSaving: true },\n }));\n\n try {\n await apiClient.post(\n `/activities/${question.activityId}/students/${data.userId}/questions/correction`,\n {\n questionId: question.id,\n isCorrect: correction.isCorrect,\n