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