@hed-hog/lms 0.0.319 → 0.0.321

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 +64 -1
  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 +66 -1
  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 +10 -30
  91. package/hedhog/frontend/app/classes/[id]/_components/event-summary-popover.tsx.ejs +3 -3
  92. package/hedhog/frontend/app/classes/[id]/_components/quick-create-session-popover.tsx.ejs +1 -1
  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 +14 -1
  96. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-course.tsx.ejs +6 -2
  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 +483 -1112
  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 +2 -2
  109. package/src/class-group/dto/material.dto.ts +69 -0
  110. package/src/course/course.service.ts +41 -8
  111. package/src/course/dto/create-course.dto.ts +2 -2
  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,36 +1,28 @@
1
1
  'use client';
2
2
 
3
+ // ─── THIS FILE IS THE EVALUATION TOPICS MANAGEMENT PAGE ───────────────────────
4
+ // The ratings/report page was moved to: /lms/reports/evaluations/page.tsx
5
+
3
6
  import {
4
7
  EmptyState,
5
8
  Page,
6
9
  PageHeader,
7
10
  PaginationFooter,
8
- ViewModeToggle,
9
11
  } 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
12
  import {
15
- DropdownMenu,
16
- DropdownMenuContent,
17
- DropdownMenuItem,
18
- DropdownMenuTrigger,
19
- } from '@/components/ui/dropdown-menu';
20
- import { EntityPicker } from '@/components/ui/entity-picker';
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';
21
24
  import { Input } from '@/components/ui/input';
22
25
  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
26
  import { Skeleton } from '@/components/ui/skeleton';
35
27
  import {
36
28
  Table,
@@ -40,306 +32,194 @@ import {
40
32
  TableHeader,
41
33
  TableRow,
42
34
  } from '@/components/ui/table';
43
- import { usePersistedViewMode } from '@/hooks/use-persisted-view-mode';
35
+ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
44
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';
45
52
  import { useApp, useQuery } from '@hed-hog/next-app-provider';
46
- import { format, parseISO } from 'date-fns';
47
- import { motion } from 'framer-motion';
48
53
  import {
49
- Award,
50
54
  BookOpen,
51
- CalendarIcon,
52
- ExternalLink,
53
55
  GraduationCap,
56
+ GripVertical,
57
+ ListChecks,
54
58
  MessageSquare,
55
- MoreHorizontal,
59
+ Pencil,
60
+ Plus,
56
61
  Search,
57
62
  Star,
63
+ Trash2,
58
64
  Users,
59
65
  X,
60
66
  } from 'lucide-react';
61
67
  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
- );
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;
173
85
  }
174
86
 
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
- };
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
+ };
186
103
 
187
- function TargetTypeBadge({
188
- targetType,
189
- label,
190
- }: {
191
- targetType: EvaluationTarget;
192
- label: string;
193
- }) {
194
104
  return (
195
- <span
105
+ <TableRow
106
+ ref={setNodeRef}
107
+ style={style}
196
108
  className={cn(
197
- 'inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium',
198
- TARGET_TYPE_COLORS[targetType]
109
+ 'group cursor-pointer',
110
+ isDragging && 'bg-muted/50 shadow-md'
199
111
  )}
112
+ onClick={() => onEdit(item)}
200
113
  >
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)}
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}`)}
291
142
  </span>
292
- <span className="text-xs text-muted-foreground/70">/ 5</span>
293
- </div>
294
- </div>
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>
295
185
  );
296
186
  }
297
187
 
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
- }
188
+ // ── Constants ─────────────────────────────────────────────────────────────────
308
189
 
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
- }
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
+ };
319
199
 
320
200
  // ── Page ──────────────────────────────────────────────────────────────────────
321
201
 
322
- export default function EvaluationsPage() {
323
- const t = useTranslations('lms.EvaluationsPage');
324
- const router = useRouter();
202
+ export default function EvaluationTopicsPage() {
203
+ const t = useTranslations('lms.EvaluationTopicsPage');
325
204
  const { request } = useApp();
326
205
 
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
206
  const [searchInput, setSearchInput] = useState('');
336
207
  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);
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);
343
223
 
344
224
  // Debounce search
345
225
  const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -354,138 +234,186 @@ export default function EvaluationsPage() {
354
234
  };
355
235
  }, [searchInput]);
356
236
 
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
237
+ // Reset page on filter/tab change
366
238
  useEffect(() => {
367
239
  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],
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
+ ],
380
254
  queryFn: async () => {
381
255
  const params = new URLSearchParams();
382
256
  params.set('page', String(currentPage));
383
- params.set('pageSize', String(pageSize));
257
+ params.set('pageSize', String(PAGE_SIZE));
384
258
  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;
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;
393
265
  },
394
- placeholderData: (previous) => previous ?? { data: [], total: 0, page: 1, pageSize: 12, lastPage: 1 },
266
+ placeholderData: (previous) =>
267
+ previous ?? {
268
+ data: [],
269
+ total: 0,
270
+ page: 1,
271
+ pageSize: PAGE_SIZE,
272
+ lastPage: 1,
273
+ },
395
274
  });
396
275
 
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
- });
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]);
404
283
 
405
- const { data: filterOptionsData } = useQuery<ApiEvaluationFilterOptions>({
406
- queryKey: ['lms-evaluations-filter-options'],
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'],
407
290
  queryFn: async () => {
408
- const response = await request<ApiEvaluationFilterOptions>({ url: '/lms/evaluations/filter-options', method: 'GET' });
409
- return response.data;
291
+ const res = await request<ApiEvaluationTopicList>({
292
+ url: '/lms/evaluations/topics?pageSize=1000',
293
+ method: 'GET',
294
+ });
295
+ return res.data;
410
296
  },
411
297
  });
412
298
 
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;
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;
424
305
 
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
- () => [
306
+ return [
439
307
  {
440
308
  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',
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',
448
316
  },
449
317
  {
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,
318
+ key: 'active',
319
+ title: t('kpi.active'),
320
+ value: active,
321
+ icon: Star,
322
+ description: t('kpi.activeDesc'),
323
+ loading: false,
456
324
  iconContainerClassName: 'bg-emerald-500/10 text-emerald-700',
457
325
  accentClassName:
458
326
  'from-emerald-500/25 via-emerald-500/10 to-transparent',
459
327
  },
460
328
  {
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',
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',
469
337
  },
470
338
  {
471
- key: 'evaluators',
472
- title: t('kpi.totalEvaluators'),
473
- value: statsData?.totalEvaluators ?? '—',
339
+ key: 'withRatings',
340
+ title: t('kpi.withRatings'),
341
+ value: withRatings,
474
342
  icon: Users,
475
- description: t('kpi.totalEvaluatorsDesc'),
476
- loading: isLoading,
343
+ description: t('kpi.withRatingsDesc'),
344
+ loading: false,
477
345
  iconContainerClassName: 'bg-violet-500/10 text-violet-700',
478
346
  accentClassName: 'from-violet-500/25 via-purple-500/10 to-transparent',
479
347
  },
480
- ],
481
- [t, isLoading, statsData]
482
- );
348
+ ];
349
+ }, [t, allTopics]);
350
+
351
+ function openCreate() {
352
+ setEditingItem(null);
353
+ setSheetOpen(true);
354
+ }
483
355
 
484
- function openDetail(item: EvaluationItem) {
485
- setSelectedItem(item);
356
+ function openEdit(item: EvaluationTopicItem) {
357
+ setEditingItem(item);
486
358
  setSheetOpen(true);
487
359
  }
488
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
+
489
417
  return (
490
418
  <Page>
491
419
  <div className="space-y-6">
@@ -497,754 +425,197 @@ export default function EvaluationsPage() {
497
425
  { label: t('breadcrumbs.lms'), href: '/lms' },
498
426
  { label: t('breadcrumbs.evaluations') },
499
427
  ]}
428
+ actions={
429
+ <Button onClick={openCreate}>
430
+ <Plus className="mr-2 size-4" />
431
+ {t('newTopic')}
432
+ </Button>
433
+ }
500
434
  />
501
435
 
502
436
  <KpiCardsGrid items={kpiItems} />
503
437
 
504
438
  {/* 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
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)}
567
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>
568
456
 
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
- />
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>
582
473
 
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')}
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]}
600
485
  </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
486
  )}
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>
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>
728
516
  ))}
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)}
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}
753
534
  >
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)}
535
+ <SortableContext
536
+ items={localTopics.map((item) => item.id)}
537
+ strategy={verticalListSortingStrategy}
890
538
  >
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
- )}
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>
932
569
 
933
570
  {/* Pagination */}
934
- {!isLoading && totalItems > 0 && (
571
+ {!isLoading && totalItems > PAGE_SIZE && (
935
572
  <div className="mt-6">
936
573
  <PaginationFooter
937
574
  currentPage={currentPage}
938
- pageSize={pageSize}
575
+ pageSize={PAGE_SIZE}
939
576
  totalItems={totalItems}
940
577
  onPageChange={setCurrentPage}
941
- onPageSizeChange={(nextSize) => {
942
- setPageSize(nextSize);
943
- setCurrentPage(1);
944
- }}
945
- pageSizeOptions={PAGE_SIZES}
578
+ onPageSizeChange={() => {}}
579
+ pageSizeOptions={[PAGE_SIZE]}
946
580
  />
947
581
  </div>
948
582
  )}
949
583
  </div>
950
584
 
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>
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>
1248
619
  </Page>
1249
620
  );
1250
621
  }