@hed-hog/lms 0.0.319 → 0.0.320

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/dist/class-group/class-group.controller.d.ts +65 -2
  2. package/dist/class-group/class-group.controller.d.ts.map +1 -1
  3. package/dist/class-group/class-group.controller.js +35 -0
  4. package/dist/class-group/class-group.controller.js.map +1 -1
  5. package/dist/class-group/class-group.service.d.ts +67 -2
  6. package/dist/class-group/class-group.service.d.ts.map +1 -1
  7. package/dist/class-group/class-group.service.js +164 -13
  8. package/dist/class-group/class-group.service.js.map +1 -1
  9. package/dist/class-group/dto/create-class-group.dto.d.ts.map +1 -1
  10. package/dist/class-group/dto/create-class-group.dto.js +2 -1
  11. package/dist/class-group/dto/create-class-group.dto.js.map +1 -1
  12. package/dist/class-group/dto/material.dto.d.ts +18 -0
  13. package/dist/class-group/dto/material.dto.d.ts.map +1 -0
  14. package/dist/class-group/dto/material.dto.js +86 -0
  15. package/dist/class-group/dto/material.dto.js.map +1 -0
  16. package/dist/course/course.service.d.ts +2 -0
  17. package/dist/course/course.service.d.ts.map +1 -1
  18. package/dist/course/course.service.js +27 -2
  19. package/dist/course/course.service.js.map +1 -1
  20. package/dist/course/dto/create-course.dto.d.ts +2 -2
  21. package/dist/course/dto/create-course.dto.d.ts.map +1 -1
  22. package/dist/course/dto/create-course.dto.js.map +1 -1
  23. package/dist/enterprise/enterprise.controller.d.ts +7 -1
  24. package/dist/enterprise/enterprise.controller.d.ts.map +1 -1
  25. package/dist/enterprise/enterprise.controller.js +72 -2
  26. package/dist/enterprise/enterprise.controller.js.map +1 -1
  27. package/dist/enterprise/enterprise.module.d.ts.map +1 -1
  28. package/dist/enterprise/enterprise.module.js +2 -1
  29. package/dist/enterprise/enterprise.module.js.map +1 -1
  30. package/dist/enterprise/enterprise.service.d.ts +3 -0
  31. package/dist/enterprise/enterprise.service.d.ts.map +1 -1
  32. package/dist/enterprise/enterprise.service.js +84 -1
  33. package/dist/enterprise/enterprise.service.js.map +1 -1
  34. package/dist/enterprise/training/enterprise-training.module.d.ts +3 -0
  35. package/dist/enterprise/training/enterprise-training.module.d.ts.map +1 -0
  36. package/dist/enterprise/training/enterprise-training.module.js +40 -0
  37. package/dist/enterprise/training/enterprise-training.module.js.map +1 -0
  38. package/dist/enterprise/training/training-admin.controller.d.ts +525 -0
  39. package/dist/enterprise/training/training-admin.controller.d.ts.map +1 -0
  40. package/dist/enterprise/training/training-admin.controller.js +385 -0
  41. package/dist/enterprise/training/training-admin.controller.js.map +1 -0
  42. package/dist/enterprise/training/training-admin.service.d.ts +582 -0
  43. package/dist/enterprise/training/training-admin.service.d.ts.map +1 -0
  44. package/dist/enterprise/training/training-admin.service.js +2283 -0
  45. package/dist/enterprise/training/training-admin.service.js.map +1 -0
  46. package/dist/enterprise/training/training-instructor.controller.d.ts +260 -0
  47. package/dist/enterprise/training/training-instructor.controller.d.ts.map +1 -0
  48. package/dist/enterprise/training/training-instructor.controller.js +199 -0
  49. package/dist/enterprise/training/training-instructor.controller.js.map +1 -0
  50. package/dist/enterprise/training/training-instructor.service.d.ts +280 -0
  51. package/dist/enterprise/training/training-instructor.service.d.ts.map +1 -0
  52. package/dist/enterprise/training/training-instructor.service.js +1218 -0
  53. package/dist/enterprise/training/training-instructor.service.js.map +1 -0
  54. package/dist/enterprise/training/training-student.controller.d.ts +168 -0
  55. package/dist/enterprise/training/training-student.controller.d.ts.map +1 -0
  56. package/dist/enterprise/training/training-student.controller.js +104 -0
  57. package/dist/enterprise/training/training-student.controller.js.map +1 -0
  58. package/dist/enterprise/training/training-student.service.d.ts +185 -0
  59. package/dist/enterprise/training/training-student.service.d.ts.map +1 -0
  60. package/dist/enterprise/training/training-student.service.js +674 -0
  61. package/dist/enterprise/training/training-student.service.js.map +1 -0
  62. package/dist/enterprise/training/training-viewer.controller.d.ts +298 -0
  63. package/dist/enterprise/training/training-viewer.controller.d.ts.map +1 -0
  64. package/dist/enterprise/training/training-viewer.controller.js +178 -0
  65. package/dist/enterprise/training/training-viewer.controller.js.map +1 -0
  66. package/dist/evaluation/dto/create-evaluation-topic.dto.d.ts +18 -0
  67. package/dist/evaluation/dto/create-evaluation-topic.dto.d.ts.map +1 -0
  68. package/dist/evaluation/dto/create-evaluation-topic.dto.js +59 -0
  69. package/dist/evaluation/dto/create-evaluation-topic.dto.js.map +1 -0
  70. package/dist/evaluation/dto/update-evaluation-topic.dto.d.ts +6 -0
  71. package/dist/evaluation/dto/update-evaluation-topic.dto.d.ts.map +1 -0
  72. package/dist/evaluation/dto/update-evaluation-topic.dto.js +9 -0
  73. package/dist/evaluation/dto/update-evaluation-topic.dto.js.map +1 -0
  74. package/dist/evaluation/evaluation.controller.d.ts +66 -0
  75. package/dist/evaluation/evaluation.controller.d.ts.map +1 -1
  76. package/dist/evaluation/evaluation.controller.js +73 -0
  77. package/dist/evaluation/evaluation.controller.js.map +1 -1
  78. package/dist/evaluation/evaluation.service.d.ts +71 -0
  79. package/dist/evaluation/evaluation.service.d.ts.map +1 -1
  80. package/dist/evaluation/evaluation.service.js +121 -0
  81. package/dist/evaluation/evaluation.service.js.map +1 -1
  82. package/dist/instructor/instructor.service.js +6 -6
  83. package/dist/instructor/instructor.service.js.map +1 -1
  84. package/dist/lms.module.d.ts.map +1 -1
  85. package/dist/lms.module.js +3 -0
  86. package/dist/lms.module.js.map +1 -1
  87. package/hedhog/data/menu.yaml +19 -2
  88. package/hedhog/data/route.yaml +730 -0
  89. package/hedhog/frontend/app/_components/class-form-sheet.tsx.ejs +74 -8
  90. package/hedhog/frontend/app/_components/course-avatar.tsx.ejs +27 -47
  91. package/hedhog/frontend/app/classes/[id]/_components/event-summary-popover.tsx.ejs +15 -15
  92. package/hedhog/frontend/app/classes/[id]/_components/quick-create-session-popover.tsx.ejs +5 -5
  93. package/hedhog/frontend/app/classes/[id]/page.tsx.ejs +2141 -308
  94. package/hedhog/frontend/app/classes/page.tsx.ejs +8 -7
  95. package/hedhog/frontend/app/courses/[id]/page.tsx.ejs +21 -8
  96. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-course.tsx.ejs +10 -6
  97. package/hedhog/frontend/app/evaluations/_components/evaluation-topic-form-sheet.tsx.ejs +201 -0
  98. package/hedhog/frontend/app/evaluations/_components/evaluation-topic-types.ts.ejs +49 -0
  99. package/hedhog/frontend/app/evaluations/page.tsx.ejs +621 -1250
  100. package/hedhog/frontend/app/instructors/page.tsx.ejs +22 -20
  101. package/hedhog/frontend/app/reports/evaluations/page.tsx.ejs +1278 -0
  102. package/hedhog/frontend/messages/en.json +98 -7
  103. package/hedhog/frontend/messages/pt.json +98 -7
  104. package/hedhog/table/course_class_group_material.yaml +45 -0
  105. package/package.json +8 -8
  106. package/src/class-group/class-group.controller.ts +30 -0
  107. package/src/class-group/class-group.service.ts +176 -5
  108. package/src/class-group/dto/create-class-group.dto.ts +8 -8
  109. package/src/class-group/dto/material.dto.ts +69 -0
  110. package/src/course/course.service.ts +54 -21
  111. package/src/course/dto/create-course.dto.ts +8 -8
  112. package/src/enterprise/enterprise.controller.ts +62 -1
  113. package/src/enterprise/enterprise.module.ts +2 -1
  114. package/src/enterprise/enterprise.service.ts +84 -1
  115. package/src/enterprise/training/enterprise-training.module.ts +27 -0
  116. package/src/enterprise/training/training-admin.controller.ts +278 -0
  117. package/src/enterprise/training/training-admin.service.ts +2523 -0
  118. package/src/enterprise/training/training-instructor.controller.ts +141 -0
  119. package/src/enterprise/training/training-instructor.service.ts +1303 -0
  120. package/src/enterprise/training/training-student.controller.ts +65 -0
  121. package/src/enterprise/training/training-student.service.ts +762 -0
  122. package/src/enterprise/training/training-viewer.controller.ts +115 -0
  123. package/src/evaluation/dto/create-evaluation-topic.dto.ts +48 -0
  124. package/src/evaluation/dto/update-evaluation-topic.dto.ts +6 -0
  125. package/src/evaluation/evaluation.controller.ts +63 -1
  126. package/src/evaluation/evaluation.service.ts +150 -1
  127. package/src/instructor/instructor.service.ts +4 -4
  128. package/src/lms.module.ts +3 -0
@@ -0,0 +1,1278 @@
1
+ 'use client';
2
+
3
+ import {
4
+ EmptyState,
5
+ Page,
6
+ PageHeader,
7
+ PaginationFooter,
8
+ ViewModeToggle,
9
+ } from '@/components/entity-list';
10
+ import { Avatar, AvatarFallback } from '@/components/ui/avatar';
11
+ import { Button } from '@/components/ui/button';
12
+ import { Calendar } from '@/components/ui/calendar';
13
+ import { Card, CardContent } from '@/components/ui/card';
14
+ import {
15
+ DropdownMenu,
16
+ DropdownMenuContent,
17
+ DropdownMenuItem,
18
+ DropdownMenuTrigger,
19
+ } from '@/components/ui/dropdown-menu';
20
+ import { EntityPicker } from '@/components/ui/entity-picker';
21
+ import { Input } from '@/components/ui/input';
22
+ import { KpiCardsGrid, type KpiCardItem } from '@/components/ui/kpi-cards-grid';
23
+ import {
24
+ Popover,
25
+ PopoverContent,
26
+ PopoverTrigger,
27
+ } from '@/components/ui/popover';
28
+ import {
29
+ Sheet,
30
+ SheetContent,
31
+ SheetHeader,
32
+ SheetTitle,
33
+ } from '@/components/ui/sheet';
34
+ import { Skeleton } from '@/components/ui/skeleton';
35
+ import {
36
+ Table,
37
+ TableBody,
38
+ TableCell,
39
+ TableHead,
40
+ TableHeader,
41
+ TableRow,
42
+ } from '@/components/ui/table';
43
+ import { usePersistedViewMode } from '@/hooks/use-persisted-view-mode';
44
+ import { cn } from '@/lib/utils';
45
+ import { useApp, useQuery } from '@hed-hog/next-app-provider';
46
+ import { format, parseISO } from 'date-fns';
47
+ import { motion } from 'framer-motion';
48
+ import {
49
+ Award,
50
+ BookOpen,
51
+ CalendarIcon,
52
+ ExternalLink,
53
+ GraduationCap,
54
+ MessageSquare,
55
+ MoreHorizontal,
56
+ Search,
57
+ Star,
58
+ Users,
59
+ X,
60
+ } from 'lucide-react';
61
+ import { useTranslations } from 'next-intl';
62
+ import { useRouter } from 'next/navigation';
63
+ import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
64
+ import type { DateRange } from 'react-day-picker';
65
+
66
+ // ── Types ─────────────────────────────────────────────────────────────────────
67
+
68
+ type EvaluationTarget =
69
+ | 'course'
70
+ | 'course_lesson'
71
+ | 'course_class_session'
72
+ | 'question'
73
+ | 'exam';
74
+
75
+ interface EvaluationItem {
76
+ id: number;
77
+ topicName: string;
78
+ topicDescription: string | null;
79
+ targetType: EvaluationTarget;
80
+ targetName: string;
81
+ score: number;
82
+ comment: string | null;
83
+ evaluatorName: string | null;
84
+ evaluatorId: number | null;
85
+ courseName: string | null;
86
+ courseId: number | null;
87
+ className: string | null;
88
+ classId: number | null;
89
+ instructorName: string | null;
90
+ instructorId: number | null;
91
+ createdAt: string;
92
+ topicAvgScore: number;
93
+ topicRatingCount: number;
94
+ }
95
+
96
+ type ApiEvaluationList = {
97
+ data: EvaluationItem[];
98
+ total: number;
99
+ page: number;
100
+ pageSize: number;
101
+ lastPage: number;
102
+ };
103
+
104
+ type ApiEvaluationStats = {
105
+ totalEvaluations: number;
106
+ averageScore: number;
107
+ topicsEvaluated: number;
108
+ totalEvaluators: number;
109
+ };
110
+
111
+ type FilterOption = { id: number; label: string };
112
+
113
+ type ApiEvaluationFilterOptions = {
114
+ courses: FilterOption[];
115
+ classes: FilterOption[];
116
+ instructors: FilterOption[];
117
+ evaluators: FilterOption[];
118
+ };
119
+
120
+ type ViewMode = 'cards' | 'list';
121
+
122
+ // ── Constants ─────────────────────────────────────────────────────────────────
123
+
124
+ const PAGE_SIZES = [12, 24, 48];
125
+
126
+ // ── Animations ────────────────────────────────────────────────────────────────
127
+
128
+ const fadeUp = {
129
+ hidden: { opacity: 0, y: 16 },
130
+ show: { opacity: 1, y: 0, transition: { duration: 0.3 } } as const,
131
+ };
132
+
133
+ const stagger = {
134
+ hidden: {},
135
+ show: { transition: { staggerChildren: 0.05 } },
136
+ };
137
+
138
+ // ── Helpers ───────────────────────────────────────────────────────────────────
139
+
140
+ function StarRating({ score, size = 16 }: { score: number; size?: number }) {
141
+ return (
142
+ <div className="flex items-center gap-0.5">
143
+ {Array.from({ length: 5 }, (_, i) => {
144
+ const filled = score >= i + 1;
145
+ const half = !filled && score >= i + 0.5;
146
+ return (
147
+ <div
148
+ key={i}
149
+ className="relative shrink-0"
150
+ style={{ width: size, height: size }}
151
+ >
152
+ <Star
153
+ className="absolute text-muted-foreground/25"
154
+ style={{ width: size, height: size }}
155
+ />
156
+ {(filled || half) && (
157
+ <div
158
+ className="absolute overflow-hidden"
159
+ style={{ width: filled ? '100%' : '50%' }}
160
+ >
161
+ <Star
162
+ className="text-yellow-400"
163
+ fill="currentColor"
164
+ style={{ width: size, height: size }}
165
+ />
166
+ </div>
167
+ )}
168
+ </div>
169
+ );
170
+ })}
171
+ </div>
172
+ );
173
+ }
174
+
175
+ const TARGET_TYPE_COLORS: Record<EvaluationTarget, string> = {
176
+ course:
177
+ 'bg-blue-100 text-blue-700 border-blue-200 dark:bg-blue-950 dark:text-blue-300 dark:border-blue-800',
178
+ course_lesson:
179
+ 'bg-green-100 text-green-700 border-green-200 dark:bg-green-950 dark:text-green-300 dark:border-green-800',
180
+ course_class_session:
181
+ 'bg-purple-100 text-purple-700 border-purple-200 dark:bg-purple-950 dark:text-purple-300 dark:border-purple-800',
182
+ question:
183
+ 'bg-orange-100 text-orange-700 border-orange-200 dark:bg-orange-950 dark:text-orange-300 dark:border-orange-800',
184
+ exam: 'bg-red-100 text-red-700 border-red-200 dark:bg-red-950 dark:text-red-300 dark:border-red-800',
185
+ };
186
+
187
+ function TargetTypeBadge({
188
+ targetType,
189
+ label,
190
+ }: {
191
+ targetType: EvaluationTarget;
192
+ label: string;
193
+ }) {
194
+ return (
195
+ <span
196
+ className={cn(
197
+ 'inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium',
198
+ TARGET_TYPE_COLORS[targetType]
199
+ )}
200
+ >
201
+ {label}
202
+ </span>
203
+ );
204
+ }
205
+
206
+ // ── Card helpers ──────────────────────────────────────────────────────────────
207
+
208
+ const COURSE_AVATAR_COLORS = [
209
+ 'bg-blue-500',
210
+ 'bg-emerald-500',
211
+ 'bg-violet-500',
212
+ 'bg-orange-500',
213
+ 'bg-rose-500',
214
+ ];
215
+
216
+ function getCourseColor(courseId: number | null): string {
217
+ if (courseId === null) return 'bg-blue-500';
218
+ return (
219
+ COURSE_AVATAR_COLORS[(courseId - 1) % COURSE_AVATAR_COLORS.length] ??
220
+ 'bg-blue-500'
221
+ );
222
+ }
223
+
224
+ function getInitials(name: string | null): string {
225
+ if (!name) return '?';
226
+ return name
227
+ .split(' ')
228
+ .slice(0, 2)
229
+ .map((n) => n[0])
230
+ .join('')
231
+ .toUpperCase();
232
+ }
233
+
234
+ function getScoreColor(score: number): string {
235
+ if (score >= 4) return 'stroke-emerald-500';
236
+ if (score >= 3) return 'stroke-yellow-400';
237
+ if (score >= 2) return 'stroke-orange-500';
238
+ return 'stroke-red-500';
239
+ }
240
+
241
+ function getScoreBgColor(score: number): string {
242
+ if (score >= 4) return 'bg-emerald-500';
243
+ if (score >= 3) return 'bg-yellow-400';
244
+ if (score >= 2) return 'bg-orange-500';
245
+ return 'bg-red-500';
246
+ }
247
+
248
+ function ScoreArcChart({ score }: { score: number }) {
249
+ const size = 110;
250
+ const cx = size / 2;
251
+ const cy = size / 2;
252
+ const radius = 42;
253
+ const strokeWidth = 10;
254
+ const circumference = 2 * Math.PI * radius;
255
+ const filled = (score / 5) * circumference;
256
+ const empty = circumference - filled;
257
+ return (
258
+ <div
259
+ className="relative flex shrink-0 items-center justify-center"
260
+ style={{ width: size, height: size }}
261
+ >
262
+ <svg
263
+ width={size}
264
+ height={size}
265
+ viewBox={`0 0 ${size} ${size}`}
266
+ className="absolute inset-0"
267
+ >
268
+ <circle
269
+ cx={cx}
270
+ cy={cy}
271
+ r={radius}
272
+ fill="none"
273
+ strokeWidth={strokeWidth}
274
+ className="stroke-muted"
275
+ />
276
+ <circle
277
+ cx={cx}
278
+ cy={cy}
279
+ r={radius}
280
+ fill="none"
281
+ strokeWidth={strokeWidth}
282
+ strokeDasharray={`${filled} ${empty}`}
283
+ strokeLinecap="round"
284
+ transform={`rotate(-90 ${cx} ${cy})`}
285
+ className={getScoreColor(score)}
286
+ />
287
+ </svg>
288
+ <div className="relative flex flex-col items-center">
289
+ <span className="text-2xl font-bold tabular-nums leading-none">
290
+ {score.toFixed(1)}
291
+ </span>
292
+ <span className="text-xs text-muted-foreground/70">/ 5</span>
293
+ </div>
294
+ </div>
295
+ );
296
+ }
297
+
298
+ function getTargetUrl(targetType: EvaluationTarget): string {
299
+ const urls: Record<EvaluationTarget, string> = {
300
+ course: '/lms/courses',
301
+ course_lesson: '/lms/courses',
302
+ course_class_session: '/lms/classes',
303
+ question: '/lms/exams',
304
+ exam: '/lms/exams',
305
+ };
306
+ return urls[targetType];
307
+ }
308
+
309
+ function getTargetIcon(targetType: EvaluationTarget): ReactNode {
310
+ const icons: Record<EvaluationTarget, ReactNode> = {
311
+ course: <BookOpen className="size-4 text-muted-foreground" />,
312
+ course_lesson: <BookOpen className="size-4 text-muted-foreground" />,
313
+ course_class_session: <Users className="size-4 text-muted-foreground" />,
314
+ question: <MessageSquare className="size-4 text-muted-foreground" />,
315
+ exam: <GraduationCap className="size-4 text-muted-foreground" />,
316
+ };
317
+ return icons[targetType];
318
+ }
319
+
320
+ // ── Page ──────────────────────────────────────────────────────────────────────
321
+
322
+ export default function EvaluationsReportPage() {
323
+ const t = useTranslations('lms.EvaluationsPage');
324
+ const router = useRouter();
325
+ const { request } = useApp();
326
+
327
+ // View mode
328
+ const [viewMode, setViewMode] = usePersistedViewMode<ViewMode>({
329
+ storageKey: 'lms:reports:evaluations:view-mode',
330
+ defaultValue: 'cards',
331
+ allowedValues: ['cards', 'list'],
332
+ });
333
+
334
+ // Filters
335
+ const [searchInput, setSearchInput] = useState('');
336
+ const [debouncedSearch, setDebouncedSearch] = useState('');
337
+ const [filtroCurso, setFiltroCurso] = useState<number | null>(null);
338
+ const [filtroTurma, setFiltroTurma] = useState<number | null>(null);
339
+ const [filtroInstrutor, setFiltroInstrutor] = useState<number | null>(null);
340
+ const [filtroAluno, setFiltroAluno] = useState<number | null>(null);
341
+ const [dateRange, setDateRange] = useState<DateRange | undefined>();
342
+ const [datePopoverOpen, setDatePopoverOpen] = useState(false);
343
+
344
+ // Debounce search
345
+ const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
346
+ useEffect(() => {
347
+ if (debounceRef.current) clearTimeout(debounceRef.current);
348
+ debounceRef.current = setTimeout(
349
+ () => setDebouncedSearch(searchInput),
350
+ 400
351
+ );
352
+ return () => {
353
+ if (debounceRef.current) clearTimeout(debounceRef.current);
354
+ };
355
+ }, [searchInput]);
356
+
357
+ // Sheet
358
+ const [selectedItem, setSelectedItem] = useState<EvaluationItem | null>(null);
359
+ const [sheetOpen, setSheetOpen] = useState(false);
360
+
361
+ // Pagination
362
+ const [currentPage, setCurrentPage] = useState(1);
363
+ const [pageSize, setPageSize] = useState(12);
364
+
365
+ // Reset page when filters change
366
+ useEffect(() => {
367
+ setCurrentPage(1);
368
+ }, [
369
+ debouncedSearch,
370
+ filtroCurso,
371
+ filtroTurma,
372
+ filtroInstrutor,
373
+ filtroAluno,
374
+ dateRange,
375
+ ]);
376
+
377
+ // API queries
378
+ const { data: evaluationList, isLoading: listLoading } =
379
+ useQuery<ApiEvaluationList>({
380
+ queryKey: [
381
+ 'lms-evaluations',
382
+ currentPage,
383
+ pageSize,
384
+ debouncedSearch,
385
+ filtroCurso,
386
+ filtroTurma,
387
+ filtroInstrutor,
388
+ filtroAluno,
389
+ dateRange,
390
+ ],
391
+ queryFn: async () => {
392
+ const params = new URLSearchParams();
393
+ params.set('page', String(currentPage));
394
+ params.set('pageSize', String(pageSize));
395
+ if (debouncedSearch) params.set('search', debouncedSearch);
396
+ if (filtroCurso !== null) params.set('courseId', String(filtroCurso));
397
+ if (filtroTurma !== null) params.set('classId', String(filtroTurma));
398
+ if (filtroInstrutor !== null)
399
+ params.set('instructorId', String(filtroInstrutor));
400
+ if (filtroAluno !== null)
401
+ params.set('evaluatorId', String(filtroAluno));
402
+ if (dateRange?.from)
403
+ params.set('dateFrom', dateRange.from.toISOString());
404
+ if (dateRange?.to) params.set('dateTo', dateRange.to.toISOString());
405
+ const response = await request<ApiEvaluationList>({
406
+ url: `/lms/evaluations?${params.toString()}`,
407
+ method: 'GET',
408
+ });
409
+ return response.data;
410
+ },
411
+ placeholderData: (previous) =>
412
+ previous ?? { data: [], total: 0, page: 1, pageSize: 12, lastPage: 1 },
413
+ });
414
+
415
+ const { data: statsData, isLoading: statsLoading } =
416
+ useQuery<ApiEvaluationStats>({
417
+ queryKey: ['lms-evaluations-stats'],
418
+ queryFn: async () => {
419
+ const response = await request<ApiEvaluationStats>({
420
+ url: '/lms/evaluations/stats',
421
+ method: 'GET',
422
+ });
423
+ return response.data;
424
+ },
425
+ });
426
+
427
+ const { data: filterOptionsData } = useQuery<ApiEvaluationFilterOptions>({
428
+ queryKey: ['lms-evaluations-filter-options'],
429
+ queryFn: async () => {
430
+ const response = await request<ApiEvaluationFilterOptions>({
431
+ url: '/lms/evaluations/filter-options',
432
+ method: 'GET',
433
+ });
434
+ return response.data;
435
+ },
436
+ });
437
+
438
+ const isLoading = listLoading || statsLoading;
439
+ const paginatedItems: EvaluationItem[] = evaluationList?.data ?? [];
440
+ const totalItems = evaluationList?.total ?? 0;
441
+
442
+ const hasActiveFilters =
443
+ debouncedSearch !== '' ||
444
+ filtroCurso !== null ||
445
+ filtroTurma !== null ||
446
+ filtroInstrutor !== null ||
447
+ filtroAluno !== null ||
448
+ dateRange !== undefined;
449
+
450
+ function clearFilters() {
451
+ setSearchInput('');
452
+ setDebouncedSearch('');
453
+ setFiltroCurso(null);
454
+ setFiltroTurma(null);
455
+ setFiltroInstrutor(null);
456
+ setFiltroAluno(null);
457
+ setDateRange(undefined);
458
+ setCurrentPage(1);
459
+ }
460
+
461
+ // KPI cards
462
+ const kpiItems = useMemo<KpiCardItem[]>(
463
+ () => [
464
+ {
465
+ key: 'total',
466
+ title: t('kpi.totalEvaluations'),
467
+ value: statsData?.totalEvaluations ?? '—',
468
+ icon: Star,
469
+ description: t('kpi.totalEvaluationsDesc'),
470
+ loading: isLoading,
471
+ iconContainerClassName: 'bg-yellow-500/10 text-yellow-600',
472
+ accentClassName: 'from-yellow-500/25 via-amber-500/10 to-transparent',
473
+ },
474
+ {
475
+ key: 'average',
476
+ title: t('kpi.averageScore'),
477
+ value: statsData ? `${statsData.averageScore.toFixed(1)} / 5.0` : '—',
478
+ icon: Award,
479
+ description: t('kpi.averageScoreDesc'),
480
+ loading: isLoading,
481
+ iconContainerClassName: 'bg-emerald-500/10 text-emerald-700',
482
+ accentClassName:
483
+ 'from-emerald-500/25 via-emerald-500/10 to-transparent',
484
+ },
485
+ {
486
+ key: 'topics',
487
+ title: t('kpi.topicsEvaluated'),
488
+ value: statsData?.topicsEvaluated ?? '—',
489
+ icon: BookOpen,
490
+ description: t('kpi.topicsEvaluatedDesc'),
491
+ loading: isLoading,
492
+ iconContainerClassName: 'bg-sky-500/10 text-sky-700',
493
+ accentClassName: 'from-sky-500/25 via-blue-500/10 to-transparent',
494
+ },
495
+ {
496
+ key: 'evaluators',
497
+ title: t('kpi.totalEvaluators'),
498
+ value: statsData?.totalEvaluators ?? '—',
499
+ icon: Users,
500
+ description: t('kpi.totalEvaluatorsDesc'),
501
+ loading: isLoading,
502
+ iconContainerClassName: 'bg-violet-500/10 text-violet-700',
503
+ accentClassName: 'from-violet-500/25 via-purple-500/10 to-transparent',
504
+ },
505
+ ],
506
+ [t, isLoading, statsData]
507
+ );
508
+
509
+ function openDetail(item: EvaluationItem) {
510
+ setSelectedItem(item);
511
+ setSheetOpen(true);
512
+ }
513
+
514
+ return (
515
+ <Page>
516
+ <div className="space-y-6">
517
+ <PageHeader
518
+ title={t('title')}
519
+ description={t('description')}
520
+ breadcrumbs={[
521
+ { label: t('breadcrumbs.home'), href: '/' },
522
+ { label: t('breadcrumbs.lms'), href: '/lms' },
523
+ { label: t('breadcrumbs.reports'), href: '/lms/reports' },
524
+ { label: t('breadcrumbs.evaluations') },
525
+ ]}
526
+ />
527
+
528
+ <KpiCardsGrid items={kpiItems} />
529
+
530
+ {/* Toolbar */}
531
+ <div className="space-y-3">
532
+ <form
533
+ onSubmit={(e) => e.preventDefault()}
534
+ className="flex flex-wrap items-center gap-2"
535
+ >
536
+ <div className="flex min-w-40 flex-1 items-center gap-2">
537
+ <Input
538
+ className="min-w-0 flex-1"
539
+ placeholder={t('filters.searchPlaceholder')}
540
+ value={searchInput}
541
+ onChange={(e) => setSearchInput(e.target.value)}
542
+ />
543
+ <Button
544
+ type="submit"
545
+ variant="default"
546
+ size="icon"
547
+ className="shrink-0"
548
+ >
549
+ <Search className="size-4" />
550
+ </Button>
551
+ </div>
552
+
553
+ <EntityPicker
554
+ className="w-50 shrink-0"
555
+ placeholder={t('filters.allCourses')}
556
+ value={filtroCurso}
557
+ onChange={(val) =>
558
+ setFiltroCurso(val === null ? null : Number(val))
559
+ }
560
+ options={filterOptionsData?.courses ?? []}
561
+ allowEmptySelection
562
+ emptySelectionLabel={t('filters.allCourses')}
563
+ showCreateButton={false}
564
+ clearable
565
+ />
566
+
567
+ <EntityPicker
568
+ className="w-50 shrink-0"
569
+ placeholder={t('filters.allClasses')}
570
+ value={filtroTurma}
571
+ onChange={(val) =>
572
+ setFiltroTurma(val === null ? null : Number(val))
573
+ }
574
+ options={filterOptionsData?.classes ?? []}
575
+ allowEmptySelection
576
+ emptySelectionLabel={t('filters.allClasses')}
577
+ showCreateButton={false}
578
+ clearable
579
+ />
580
+
581
+ <EntityPicker
582
+ className="w-50 shrink-0"
583
+ placeholder={t('filters.allInstructors')}
584
+ value={filtroInstrutor}
585
+ onChange={(val) =>
586
+ setFiltroInstrutor(val === null ? null : Number(val))
587
+ }
588
+ options={filterOptionsData?.instructors ?? []}
589
+ allowEmptySelection
590
+ emptySelectionLabel={t('filters.allInstructors')}
591
+ showCreateButton={false}
592
+ clearable
593
+ />
594
+
595
+ <EntityPicker
596
+ className="w-50 shrink-0"
597
+ placeholder={t('filters.allStudents')}
598
+ value={filtroAluno}
599
+ onChange={(val) =>
600
+ setFiltroAluno(val === null ? null : Number(val))
601
+ }
602
+ options={filterOptionsData?.evaluators ?? []}
603
+ allowEmptySelection
604
+ emptySelectionLabel={t('filters.allStudents')}
605
+ showCreateButton={false}
606
+ clearable
607
+ />
608
+
609
+ <Popover open={datePopoverOpen} onOpenChange={setDatePopoverOpen}>
610
+ <PopoverTrigger asChild>
611
+ <Button
612
+ variant="outline"
613
+ size="sm"
614
+ className={cn(
615
+ 'h-9 shrink-0 gap-1.5 text-sm font-normal',
616
+ dateRange && 'border-primary/50 bg-primary/5 text-primary'
617
+ )}
618
+ >
619
+ <CalendarIcon className="size-4 shrink-0" />
620
+ <span>
621
+ {dateRange?.from
622
+ ? dateRange.to
623
+ ? `${format(dateRange.from, 'MMM d')} – ${format(dateRange.to, 'MMM d')}`
624
+ : format(dateRange.from, 'MMM d')
625
+ : t('filters.dateRange')}
626
+ </span>
627
+ {dateRange && (
628
+ <span
629
+ role="button"
630
+ aria-label="Clear date range"
631
+ onClick={(e) => {
632
+ e.stopPropagation();
633
+ setDateRange(undefined);
634
+ setDatePopoverOpen(false);
635
+ }}
636
+ className="ml-0.5 rounded-sm opacity-60 hover:opacity-100"
637
+ >
638
+ <X className="size-3" />
639
+ </span>
640
+ )}
641
+ </Button>
642
+ </PopoverTrigger>
643
+ <PopoverContent className="w-auto p-0" align="end">
644
+ <Calendar
645
+ mode="range"
646
+ selected={dateRange}
647
+ onSelect={setDateRange}
648
+ numberOfMonths={2}
649
+ initialFocus
650
+ />
651
+ {dateRange?.from && (
652
+ <div className="flex items-center justify-between border-t border-border/50 px-3 py-2">
653
+ <span className="text-xs text-muted-foreground">
654
+ {dateRange.to
655
+ ? `${format(dateRange.from, 'MMM d')} – ${format(dateRange.to, 'MMM d, yyyy')}`
656
+ : format(dateRange.from, 'MMM d, yyyy')}
657
+ </span>
658
+ <Button
659
+ type="button"
660
+ size="sm"
661
+ className="h-7 px-3 text-xs"
662
+ onClick={() => setDatePopoverOpen(false)}
663
+ >
664
+ {t('filters.apply')}
665
+ </Button>
666
+ </div>
667
+ )}
668
+ </PopoverContent>
669
+ </Popover>
670
+
671
+ <ViewModeToggle
672
+ viewMode={viewMode}
673
+ onViewModeChange={setViewMode}
674
+ listLabel={t('viewMode.list')}
675
+ cardsLabel={t('viewMode.cards')}
676
+ />
677
+ </form>
678
+
679
+ <div className="flex items-center justify-between gap-3">
680
+ <p className="text-sm text-muted-foreground">
681
+ {totalItems}{' '}
682
+ {totalItems !== 1
683
+ ? t('pagination.itemsPlural')
684
+ : t('pagination.items')}
685
+ </p>
686
+ {hasActiveFilters && (
687
+ <Button
688
+ type="button"
689
+ variant="ghost"
690
+ size="sm"
691
+ onClick={clearFilters}
692
+ className="h-8 px-2 text-muted-foreground"
693
+ >
694
+ <X className="mr-1 size-3.5" />
695
+ {t('filters.clear')}
696
+ </Button>
697
+ )}
698
+ </div>
699
+ </div>
700
+
701
+ {/* Content */}
702
+ {isLoading ? (
703
+ viewMode === 'cards' ? (
704
+ <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
705
+ {Array.from({ length: 6 }).map((_, i) => (
706
+ <Card key={i} className="overflow-hidden">
707
+ <CardContent className="p-4">
708
+ <div className="mb-2 flex items-center justify-between">
709
+ <Skeleton className="h-5 w-16 rounded-full" />
710
+ <Skeleton className="h-4 w-20" />
711
+ </div>
712
+ <div className="mb-3 flex items-start gap-3">
713
+ <Skeleton className="size-10 shrink-0 rounded-lg" />
714
+ <div className="min-w-0 flex-1 space-y-1.5">
715
+ <Skeleton className="h-4 w-3/4" />
716
+ <Skeleton className="h-3.5 w-1/2" />
717
+ </div>
718
+ </div>
719
+ <div className="mb-3 flex gap-1">
720
+ {Array.from({ length: 5 }).map((__, j) => (
721
+ <Skeleton key={j} className="size-3.5 rounded-sm" />
722
+ ))}
723
+ </div>
724
+ <div className="flex items-center gap-2 border-t border-border/50 pt-3">
725
+ <Skeleton className="size-6 rounded-full" />
726
+ <Skeleton className="h-3 w-24" />
727
+ </div>
728
+ </CardContent>
729
+ </Card>
730
+ ))}
731
+ </div>
732
+ ) : (
733
+ <div className="overflow-hidden rounded-xl border border-border/70">
734
+ <Table>
735
+ <TableHeader>
736
+ <TableRow>
737
+ <TableHead>{t('columns.topic')}</TableHead>
738
+ <TableHead>{t('columns.target')}</TableHead>
739
+ <TableHead>{t('columns.score')}</TableHead>
740
+ <TableHead>{t('columns.course')}</TableHead>
741
+ <TableHead>{t('columns.class')}</TableHead>
742
+ <TableHead>{t('columns.student')}</TableHead>
743
+ <TableHead>{t('columns.instructor')}</TableHead>
744
+ <TableHead>{t('columns.date')}</TableHead>
745
+ </TableRow>
746
+ </TableHeader>
747
+ <TableBody>
748
+ {Array.from({ length: 6 }).map((_, i) => (
749
+ <TableRow key={i}>
750
+ {Array.from({ length: 8 }).map((__, j) => (
751
+ <TableCell key={j}>
752
+ <Skeleton className="h-4 w-full" />
753
+ </TableCell>
754
+ ))}
755
+ </TableRow>
756
+ ))}
757
+ </TableBody>
758
+ </Table>
759
+ </div>
760
+ )
761
+ ) : paginatedItems.length === 0 ? (
762
+ <EmptyState
763
+ icon={<Star className="size-8" />}
764
+ title={t('empty.title')}
765
+ description={t('empty.description')}
766
+ />
767
+ ) : viewMode === 'cards' ? (
768
+ <motion.div
769
+ className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"
770
+ variants={stagger}
771
+ initial="hidden"
772
+ animate="show"
773
+ >
774
+ {paginatedItems.map((item) => (
775
+ <motion.div key={item.id} variants={fadeUp}>
776
+ <Card
777
+ className="cursor-pointer overflow-hidden transition-shadow hover:shadow-md"
778
+ onClick={() => openDetail(item)}
779
+ >
780
+ <CardContent className="p-4">
781
+ {/* Header: badge + date + menu */}
782
+ <div className="mb-2 flex items-center justify-between gap-2">
783
+ <TargetTypeBadge
784
+ targetType={item.targetType}
785
+ label={t(`targetType.${item.targetType}`)}
786
+ />
787
+ <div className="flex items-center gap-1">
788
+ <span className="text-xs text-muted-foreground">
789
+ {format(parseISO(item.createdAt), 'MMM d, yyyy')}
790
+ </span>
791
+ <DropdownMenu>
792
+ <DropdownMenuTrigger asChild>
793
+ <Button
794
+ variant="ghost"
795
+ size="icon"
796
+ className="size-6 shrink-0"
797
+ onClick={(e) => e.stopPropagation()}
798
+ >
799
+ <MoreHorizontal className="size-3.5" />
800
+ </Button>
801
+ </DropdownMenuTrigger>
802
+ <DropdownMenuContent align="end">
803
+ <DropdownMenuItem
804
+ onClick={() => router.push('/lms/courses')}
805
+ >
806
+ <BookOpen className="mr-2 size-3.5" />
807
+ {t('dropdown.viewCourse')}
808
+ </DropdownMenuItem>
809
+ {item.className && (
810
+ <DropdownMenuItem
811
+ onClick={() => router.push('/lms/classes')}
812
+ >
813
+ <Users className="mr-2 size-3.5" />
814
+ {t('dropdown.viewClass')}
815
+ </DropdownMenuItem>
816
+ )}
817
+ <DropdownMenuItem
818
+ onClick={() => router.push('/lms/paths')}
819
+ >
820
+ <GraduationCap className="mr-2 size-3.5" />
821
+ {t('dropdown.viewStudent')}
822
+ </DropdownMenuItem>
823
+ <DropdownMenuItem onClick={() => openDetail(item)}>
824
+ <Star className="mr-2 size-3.5" />
825
+ {t('dropdown.viewDetails')}
826
+ </DropdownMenuItem>
827
+ </DropdownMenuContent>
828
+ </DropdownMenu>
829
+ </div>
830
+ </div>
831
+
832
+ {/* Course logo + topic */}
833
+ <div className="mb-3 flex items-start gap-3">
834
+ <div
835
+ className={cn(
836
+ 'flex size-10 shrink-0 items-center justify-center rounded-lg text-sm font-bold text-white',
837
+ getCourseColor(item.courseId)
838
+ )}
839
+ >
840
+ {item.courseName?.[0] ?? '?'}
841
+ </div>
842
+ <div className="min-w-0 flex-1">
843
+ <p className="font-semibold leading-snug line-clamp-1">
844
+ {item.topicName}
845
+ </p>
846
+ <p className="text-sm text-muted-foreground line-clamp-1">
847
+ {item.targetName}
848
+ </p>
849
+ </div>
850
+ </div>
851
+
852
+ {/* Score */}
853
+ <div className="mb-3 flex items-center gap-2">
854
+ <StarRating score={item.score} size={14} />
855
+ <span className="text-sm font-medium tabular-nums">
856
+ {item.score.toFixed(1)}
857
+ </span>
858
+ </div>
859
+
860
+ {/* Footer: student avatar + class/instructor */}
861
+ <div className="flex items-center justify-between gap-2 border-t border-border/50 pt-3">
862
+ <div className="flex min-w-0 items-center gap-2">
863
+ <Avatar className="size-6 shrink-0">
864
+ <AvatarFallback className="text-xs">
865
+ {getInitials(item.evaluatorName)}
866
+ </AvatarFallback>
867
+ </Avatar>
868
+ <span className="truncate text-xs font-medium">
869
+ {item.evaluatorName}
870
+ </span>
871
+ </div>
872
+ {(item.className || item.instructorName) && (
873
+ <div className="flex shrink-0 items-center gap-1 text-xs text-muted-foreground">
874
+ {item.className && (
875
+ <span className="max-w-24 truncate">
876
+ {item.className}
877
+ </span>
878
+ )}
879
+ {item.className && item.instructorName && (
880
+ <span>·</span>
881
+ )}
882
+ {item.instructorName && (
883
+ <span className="max-w-24 truncate">
884
+ {item.instructorName}
885
+ </span>
886
+ )}
887
+ </div>
888
+ )}
889
+ </div>
890
+ </CardContent>
891
+ </Card>
892
+ </motion.div>
893
+ ))}
894
+ </motion.div>
895
+ ) : (
896
+ <div className="overflow-hidden rounded-xl border border-border/70">
897
+ <Table>
898
+ <TableHeader>
899
+ <TableRow>
900
+ <TableHead>{t('columns.topic')}</TableHead>
901
+ <TableHead>{t('columns.target')}</TableHead>
902
+ <TableHead>{t('columns.score')}</TableHead>
903
+ <TableHead>{t('columns.course')}</TableHead>
904
+ <TableHead>{t('columns.class')}</TableHead>
905
+ <TableHead>{t('columns.student')}</TableHead>
906
+ <TableHead>{t('columns.instructor')}</TableHead>
907
+ <TableHead>{t('columns.date')}</TableHead>
908
+ </TableRow>
909
+ </TableHeader>
910
+ <TableBody>
911
+ {paginatedItems.map((item) => (
912
+ <TableRow
913
+ key={item.id}
914
+ className="cursor-pointer"
915
+ onClick={() => openDetail(item)}
916
+ >
917
+ <TableCell className="font-medium">
918
+ {item.topicName}
919
+ </TableCell>
920
+ <TableCell>
921
+ <div className="flex flex-col gap-1">
922
+ <TargetTypeBadge
923
+ targetType={item.targetType}
924
+ label={t(`targetType.${item.targetType}`)}
925
+ />
926
+ <span className="text-xs text-muted-foreground">
927
+ {item.targetName}
928
+ </span>
929
+ </div>
930
+ </TableCell>
931
+ <TableCell>
932
+ <div className="flex items-center gap-1.5">
933
+ <StarRating score={item.score} size={13} />
934
+ <span className="text-sm tabular-nums">
935
+ {item.score.toFixed(1)}
936
+ </span>
937
+ </div>
938
+ </TableCell>
939
+ <TableCell className="text-sm">{item.courseName}</TableCell>
940
+ <TableCell className="text-sm text-muted-foreground">
941
+ {item.className ?? '—'}
942
+ </TableCell>
943
+ <TableCell className="text-sm">
944
+ {item.evaluatorName}
945
+ </TableCell>
946
+ <TableCell className="text-sm text-muted-foreground">
947
+ {item.instructorName ?? '—'}
948
+ </TableCell>
949
+ <TableCell className="text-sm text-muted-foreground">
950
+ {format(parseISO(item.createdAt), 'MMM d, yyyy')}
951
+ </TableCell>
952
+ </TableRow>
953
+ ))}
954
+ </TableBody>
955
+ </Table>
956
+ </div>
957
+ )}
958
+
959
+ {/* Pagination */}
960
+ {!isLoading && totalItems > 0 && (
961
+ <div className="mt-6">
962
+ <PaginationFooter
963
+ currentPage={currentPage}
964
+ pageSize={pageSize}
965
+ totalItems={totalItems}
966
+ onPageChange={setCurrentPage}
967
+ onPageSizeChange={(nextSize) => {
968
+ setPageSize(nextSize);
969
+ setCurrentPage(1);
970
+ }}
971
+ pageSizeOptions={PAGE_SIZES}
972
+ />
973
+ </div>
974
+ )}
975
+ </div>
976
+
977
+ {/* Detail Sheet */}
978
+ <Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
979
+ <SheetContent
980
+ side="right"
981
+ className="w-full overflow-y-auto p-0 sm:max-w-xl"
982
+ >
983
+ {selectedItem && (
984
+ <>
985
+ {/* Hero band */}
986
+ <div className="border-b border-border/50 px-6 pb-5 pt-6">
987
+ <SheetHeader className="space-y-0">
988
+ <div className="flex items-start gap-4">
989
+ <div
990
+ className={cn(
991
+ 'flex size-14 shrink-0 items-center justify-center rounded-xl text-2xl font-bold text-white shadow-sm',
992
+ getCourseColor(selectedItem.courseId)
993
+ )}
994
+ >
995
+ {selectedItem.courseName?.[0] ?? '?'}
996
+ </div>
997
+ <div className="min-w-0 flex-1 space-y-1">
998
+ <div className="mb-2">
999
+ <TargetTypeBadge
1000
+ targetType={selectedItem.targetType}
1001
+ label={t(`targetType.${selectedItem.targetType}`)}
1002
+ />
1003
+ </div>
1004
+ <SheetTitle className="text-xl leading-snug">
1005
+ {selectedItem.topicName}
1006
+ </SheetTitle>
1007
+ {selectedItem.topicDescription && (
1008
+ <p className="mt-1.5 text-sm text-muted-foreground">
1009
+ {selectedItem.topicDescription}
1010
+ </p>
1011
+ )}
1012
+ </div>
1013
+ </div>
1014
+ </SheetHeader>
1015
+ </div>
1016
+
1017
+ {/* Body */}
1018
+ <div className="space-y-6 px-6 py-6">
1019
+ {/* Score + comparison */}
1020
+ {(() => {
1021
+ const avg = selectedItem.topicAvgScore;
1022
+ const count = selectedItem.topicRatingCount;
1023
+ const delta =
1024
+ Math.round((selectedItem.score - avg) * 10) / 10;
1025
+ return (
1026
+ <div className="rounded-xl border border-border/50 bg-muted/20 p-5">
1027
+ {/* Arc + stars row */}
1028
+ <div className="flex items-center gap-6">
1029
+ <ScoreArcChart score={selectedItem.score} />
1030
+ <div className="flex-1 space-y-2">
1031
+ <p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
1032
+ {t('sheet.scoreLabel')}
1033
+ </p>
1034
+ <StarRating score={selectedItem.score} size={22} />
1035
+ <p className="text-sm text-muted-foreground">
1036
+ <span className="text-lg font-bold text-foreground tabular-nums">
1037
+ {selectedItem.score.toFixed(1)}
1038
+ </span>{' '}
1039
+ / 5.0
1040
+ </p>
1041
+ </div>
1042
+ </div>
1043
+
1044
+ {/* Comparison bars */}
1045
+ <div className="mt-4 space-y-3 border-t border-border/40 pt-4">
1046
+ <div className="flex items-center justify-between">
1047
+ <p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
1048
+ {t('sheet.comparison')}
1049
+ </p>
1050
+ {delta !== 0 && (
1051
+ <span
1052
+ className={cn(
1053
+ 'text-xs font-semibold tabular-nums',
1054
+ delta > 0
1055
+ ? 'text-emerald-600 dark:text-emerald-400'
1056
+ : 'text-red-500 dark:text-red-400'
1057
+ )}
1058
+ >
1059
+ {delta > 0 ? '▲' : '▼'}{' '}
1060
+ {Math.abs(delta).toFixed(1)}
1061
+ </span>
1062
+ )}
1063
+ </div>
1064
+
1065
+ <div className="space-y-1">
1066
+ <div className="flex items-center justify-between text-xs">
1067
+ <span className="text-muted-foreground">
1068
+ {t('sheet.thisEvaluation')}
1069
+ </span>
1070
+ <span className="font-semibold tabular-nums">
1071
+ {selectedItem.score.toFixed(1)}
1072
+ </span>
1073
+ </div>
1074
+ <div className="h-2.5 w-full overflow-hidden rounded-full bg-muted">
1075
+ <div
1076
+ className={cn(
1077
+ 'h-full rounded-full transition-all duration-500',
1078
+ getScoreBgColor(selectedItem.score)
1079
+ )}
1080
+ style={{
1081
+ width: `${(selectedItem.score / 5) * 100}%`,
1082
+ }}
1083
+ />
1084
+ </div>
1085
+ </div>
1086
+
1087
+ <div className="space-y-1">
1088
+ <div className="flex items-center justify-between text-xs">
1089
+ <span className="text-muted-foreground">
1090
+ {t('sheet.targetAverage')} ({count}{' '}
1091
+ {t('sheet.ratings')})
1092
+ </span>
1093
+ <span className="tabular-nums text-muted-foreground">
1094
+ {avg.toFixed(1)}
1095
+ </span>
1096
+ </div>
1097
+ <div className="h-2.5 w-full overflow-hidden rounded-full bg-muted">
1098
+ <div
1099
+ className="h-full rounded-full bg-muted-foreground/40 transition-all duration-500"
1100
+ style={{ width: `${(avg / 5) * 100}%` }}
1101
+ />
1102
+ </div>
1103
+ </div>
1104
+ </div>
1105
+ </div>
1106
+ );
1107
+ })()}
1108
+
1109
+ {/* Target */}
1110
+ <div>
1111
+ <p className="mb-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
1112
+ {t('sheet.targetLabel')}
1113
+ </p>
1114
+ <button
1115
+ type="button"
1116
+ onClick={() =>
1117
+ window.open(
1118
+ getTargetUrl(selectedItem.targetType),
1119
+ '_blank'
1120
+ )
1121
+ }
1122
+ className="flex w-full items-center gap-3 rounded-lg border border-border/50 bg-card p-3 text-left transition-colors hover:bg-muted/40"
1123
+ >
1124
+ <span className="flex size-8 shrink-0 items-center justify-center rounded-md border border-border/60 bg-background">
1125
+ {getTargetIcon(selectedItem.targetType)}
1126
+ </span>
1127
+ <div className="min-w-0 flex-1">
1128
+ <p className="truncate text-sm font-medium">
1129
+ {selectedItem.targetName}
1130
+ </p>
1131
+ <p className="text-xs text-muted-foreground">
1132
+ {t(`targetType.${selectedItem.targetType}`)}
1133
+ </p>
1134
+ </div>
1135
+ <ExternalLink className="size-3.5 shrink-0 text-muted-foreground" />
1136
+ </button>
1137
+ </div>
1138
+
1139
+ {/* Comment */}
1140
+ {selectedItem.comment ? (
1141
+ <div>
1142
+ <p className="mb-2 flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
1143
+ <MessageSquare className="size-3.5" />
1144
+ Comment
1145
+ </p>
1146
+ <p className="rounded-lg border border-border/50 bg-muted/20 px-4 py-3 text-sm leading-relaxed">
1147
+ {selectedItem.comment}
1148
+ </p>
1149
+ </div>
1150
+ ) : (
1151
+ <p className="text-xs italic text-muted-foreground">
1152
+ {t('sheet.noComment')}
1153
+ </p>
1154
+ )}
1155
+
1156
+ {/* People */}
1157
+ <div>
1158
+ <p className="mb-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
1159
+ {t('sheet.people')}
1160
+ </p>
1161
+ <div className="grid grid-cols-2 gap-3">
1162
+ <button
1163
+ type="button"
1164
+ onClick={() => window.open('/contact/accounts', '_blank')}
1165
+ className="flex items-center gap-3 rounded-lg border border-border/50 bg-card p-3 text-left transition-colors hover:bg-muted/40"
1166
+ >
1167
+ <Avatar className="size-9 shrink-0">
1168
+ <AvatarFallback className="bg-sky-100 text-xs font-semibold text-sky-700 dark:bg-sky-950 dark:text-sky-300">
1169
+ {getInitials(selectedItem.evaluatorName)}
1170
+ </AvatarFallback>
1171
+ </Avatar>
1172
+ <div className="min-w-0">
1173
+ <p className="truncate text-sm font-medium">
1174
+ {selectedItem.evaluatorName}
1175
+ </p>
1176
+ <p className="text-xs text-muted-foreground">
1177
+ {t('sheet.evaluatorLabel')}
1178
+ </p>
1179
+ </div>
1180
+ </button>
1181
+
1182
+ {selectedItem.instructorName && (
1183
+ <button
1184
+ type="button"
1185
+ onClick={() =>
1186
+ window.open('/contact/accounts', '_blank')
1187
+ }
1188
+ className="flex items-center gap-3 rounded-lg border border-border/50 bg-card p-3 text-left transition-colors hover:bg-muted/40"
1189
+ >
1190
+ <Avatar className="size-9 shrink-0">
1191
+ <AvatarFallback className="bg-violet-100 text-xs font-semibold text-violet-700 dark:bg-violet-950 dark:text-violet-300">
1192
+ {getInitials(selectedItem.instructorName)}
1193
+ </AvatarFallback>
1194
+ </Avatar>
1195
+ <div className="min-w-0">
1196
+ <p className="truncate text-sm font-medium">
1197
+ {selectedItem.instructorName}
1198
+ </p>
1199
+ <p className="text-xs text-muted-foreground">
1200
+ {t('sheet.instructorLabel')}
1201
+ </p>
1202
+ </div>
1203
+ </button>
1204
+ )}
1205
+ </div>
1206
+ </div>
1207
+
1208
+ {/* Course & Class */}
1209
+ <div>
1210
+ <p className="mb-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
1211
+ {t('sheet.courseLabel')}
1212
+ </p>
1213
+ <div className="space-y-2">
1214
+ <button
1215
+ type="button"
1216
+ onClick={() => window.open('/lms/courses', '_blank')}
1217
+ className="flex w-full items-center gap-3 rounded-lg border border-border/50 bg-card p-3 text-left transition-colors hover:bg-muted/40"
1218
+ >
1219
+ <div
1220
+ className={cn(
1221
+ 'flex size-8 shrink-0 items-center justify-center rounded-lg text-sm font-bold text-white',
1222
+ getCourseColor(selectedItem.courseId)
1223
+ )}
1224
+ >
1225
+ {selectedItem.courseName?.[0] ?? '?'}
1226
+ </div>
1227
+ <div className="min-w-0 flex-1">
1228
+ <p className="truncate text-sm font-medium">
1229
+ {selectedItem.courseName}
1230
+ </p>
1231
+ <p className="text-xs text-muted-foreground">
1232
+ {t('sheet.courseLabel')}
1233
+ </p>
1234
+ </div>
1235
+ <ExternalLink className="size-3.5 shrink-0 text-muted-foreground" />
1236
+ </button>
1237
+
1238
+ {selectedItem.className && (
1239
+ <button
1240
+ type="button"
1241
+ onClick={() => window.open('/lms/classes', '_blank')}
1242
+ className="flex w-full items-center gap-3 rounded-lg border border-border/50 bg-card p-3 text-left transition-colors hover:bg-muted/40"
1243
+ >
1244
+ <span className="flex size-8 shrink-0 items-center justify-center rounded-md border border-border/60 bg-background">
1245
+ <Users className="size-4 text-muted-foreground" />
1246
+ </span>
1247
+ <div className="min-w-0 flex-1">
1248
+ <p className="truncate text-sm font-medium">
1249
+ {selectedItem.className}
1250
+ </p>
1251
+ <p className="text-xs text-muted-foreground">
1252
+ {t('sheet.classLabel')}
1253
+ </p>
1254
+ </div>
1255
+ <ExternalLink className="size-3.5 shrink-0 text-muted-foreground" />
1256
+ </button>
1257
+ )}
1258
+ </div>
1259
+ </div>
1260
+
1261
+ {/* Date */}
1262
+ <div className="flex items-center justify-between border-t border-border/50 pt-4">
1263
+ <span className="flex items-center gap-1.5 text-xs text-muted-foreground">
1264
+ <CalendarIcon className="size-3.5" />
1265
+ {t('sheet.dateLabel')}
1266
+ </span>
1267
+ <span className="text-sm font-medium">
1268
+ {format(parseISO(selectedItem.createdAt), 'MMMM d, yyyy')}
1269
+ </span>
1270
+ </div>
1271
+ </div>
1272
+ </>
1273
+ )}
1274
+ </SheetContent>
1275
+ </Sheet>
1276
+ </Page>
1277
+ );
1278
+ }