@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.
- package/dist/class-group/class-group.controller.d.ts +64 -1
- package/dist/class-group/class-group.controller.d.ts.map +1 -1
- package/dist/class-group/class-group.controller.js +35 -0
- package/dist/class-group/class-group.controller.js.map +1 -1
- package/dist/class-group/class-group.service.d.ts +66 -1
- package/dist/class-group/class-group.service.d.ts.map +1 -1
- package/dist/class-group/class-group.service.js +164 -13
- package/dist/class-group/class-group.service.js.map +1 -1
- package/dist/class-group/dto/create-class-group.dto.d.ts.map +1 -1
- package/dist/class-group/dto/create-class-group.dto.js +2 -1
- package/dist/class-group/dto/create-class-group.dto.js.map +1 -1
- package/dist/class-group/dto/material.dto.d.ts +18 -0
- package/dist/class-group/dto/material.dto.d.ts.map +1 -0
- package/dist/class-group/dto/material.dto.js +86 -0
- package/dist/class-group/dto/material.dto.js.map +1 -0
- package/dist/course/course.service.d.ts +2 -0
- package/dist/course/course.service.d.ts.map +1 -1
- package/dist/course/course.service.js +27 -2
- package/dist/course/course.service.js.map +1 -1
- package/dist/course/dto/create-course.dto.d.ts +2 -2
- package/dist/course/dto/create-course.dto.d.ts.map +1 -1
- package/dist/course/dto/create-course.dto.js.map +1 -1
- package/dist/enterprise/enterprise.controller.d.ts +7 -1
- package/dist/enterprise/enterprise.controller.d.ts.map +1 -1
- package/dist/enterprise/enterprise.controller.js +72 -2
- package/dist/enterprise/enterprise.controller.js.map +1 -1
- package/dist/enterprise/enterprise.module.d.ts.map +1 -1
- package/dist/enterprise/enterprise.module.js +2 -1
- package/dist/enterprise/enterprise.module.js.map +1 -1
- package/dist/enterprise/enterprise.service.d.ts +3 -0
- package/dist/enterprise/enterprise.service.d.ts.map +1 -1
- package/dist/enterprise/enterprise.service.js +84 -1
- package/dist/enterprise/enterprise.service.js.map +1 -1
- package/dist/enterprise/training/enterprise-training.module.d.ts +3 -0
- package/dist/enterprise/training/enterprise-training.module.d.ts.map +1 -0
- package/dist/enterprise/training/enterprise-training.module.js +40 -0
- package/dist/enterprise/training/enterprise-training.module.js.map +1 -0
- package/dist/enterprise/training/training-admin.controller.d.ts +525 -0
- package/dist/enterprise/training/training-admin.controller.d.ts.map +1 -0
- package/dist/enterprise/training/training-admin.controller.js +385 -0
- package/dist/enterprise/training/training-admin.controller.js.map +1 -0
- package/dist/enterprise/training/training-admin.service.d.ts +582 -0
- package/dist/enterprise/training/training-admin.service.d.ts.map +1 -0
- package/dist/enterprise/training/training-admin.service.js +2283 -0
- package/dist/enterprise/training/training-admin.service.js.map +1 -0
- package/dist/enterprise/training/training-instructor.controller.d.ts +260 -0
- package/dist/enterprise/training/training-instructor.controller.d.ts.map +1 -0
- package/dist/enterprise/training/training-instructor.controller.js +199 -0
- package/dist/enterprise/training/training-instructor.controller.js.map +1 -0
- package/dist/enterprise/training/training-instructor.service.d.ts +280 -0
- package/dist/enterprise/training/training-instructor.service.d.ts.map +1 -0
- package/dist/enterprise/training/training-instructor.service.js +1218 -0
- package/dist/enterprise/training/training-instructor.service.js.map +1 -0
- package/dist/enterprise/training/training-student.controller.d.ts +168 -0
- package/dist/enterprise/training/training-student.controller.d.ts.map +1 -0
- package/dist/enterprise/training/training-student.controller.js +104 -0
- package/dist/enterprise/training/training-student.controller.js.map +1 -0
- package/dist/enterprise/training/training-student.service.d.ts +185 -0
- package/dist/enterprise/training/training-student.service.d.ts.map +1 -0
- package/dist/enterprise/training/training-student.service.js +674 -0
- package/dist/enterprise/training/training-student.service.js.map +1 -0
- package/dist/enterprise/training/training-viewer.controller.d.ts +298 -0
- package/dist/enterprise/training/training-viewer.controller.d.ts.map +1 -0
- package/dist/enterprise/training/training-viewer.controller.js +178 -0
- package/dist/enterprise/training/training-viewer.controller.js.map +1 -0
- package/dist/evaluation/dto/create-evaluation-topic.dto.d.ts +18 -0
- package/dist/evaluation/dto/create-evaluation-topic.dto.d.ts.map +1 -0
- package/dist/evaluation/dto/create-evaluation-topic.dto.js +59 -0
- package/dist/evaluation/dto/create-evaluation-topic.dto.js.map +1 -0
- package/dist/evaluation/dto/update-evaluation-topic.dto.d.ts +6 -0
- package/dist/evaluation/dto/update-evaluation-topic.dto.d.ts.map +1 -0
- package/dist/evaluation/dto/update-evaluation-topic.dto.js +9 -0
- package/dist/evaluation/dto/update-evaluation-topic.dto.js.map +1 -0
- package/dist/evaluation/evaluation.controller.d.ts +66 -0
- package/dist/evaluation/evaluation.controller.d.ts.map +1 -1
- package/dist/evaluation/evaluation.controller.js +73 -0
- package/dist/evaluation/evaluation.controller.js.map +1 -1
- package/dist/evaluation/evaluation.service.d.ts +71 -0
- package/dist/evaluation/evaluation.service.d.ts.map +1 -1
- package/dist/evaluation/evaluation.service.js +121 -0
- package/dist/evaluation/evaluation.service.js.map +1 -1
- package/dist/instructor/instructor.service.js +6 -6
- package/dist/instructor/instructor.service.js.map +1 -1
- package/dist/lms.module.d.ts.map +1 -1
- package/dist/lms.module.js +3 -0
- package/dist/lms.module.js.map +1 -1
- package/hedhog/data/menu.yaml +19 -2
- package/hedhog/data/route.yaml +730 -0
- package/hedhog/frontend/app/_components/class-form-sheet.tsx.ejs +74 -8
- package/hedhog/frontend/app/_components/course-avatar.tsx.ejs +10 -30
- package/hedhog/frontend/app/classes/[id]/_components/event-summary-popover.tsx.ejs +3 -3
- package/hedhog/frontend/app/classes/[id]/_components/quick-create-session-popover.tsx.ejs +1 -1
- package/hedhog/frontend/app/classes/[id]/page.tsx.ejs +2141 -308
- package/hedhog/frontend/app/classes/page.tsx.ejs +8 -7
- package/hedhog/frontend/app/courses/[id]/page.tsx.ejs +14 -1
- package/hedhog/frontend/app/courses/[id]/structure/_components/editor-course.tsx.ejs +6 -2
- package/hedhog/frontend/app/evaluations/_components/evaluation-topic-form-sheet.tsx.ejs +201 -0
- package/hedhog/frontend/app/evaluations/_components/evaluation-topic-types.ts.ejs +49 -0
- package/hedhog/frontend/app/evaluations/page.tsx.ejs +483 -1112
- package/hedhog/frontend/app/instructors/page.tsx.ejs +22 -20
- package/hedhog/frontend/app/reports/evaluations/page.tsx.ejs +1278 -0
- package/hedhog/frontend/messages/en.json +98 -7
- package/hedhog/frontend/messages/pt.json +98 -7
- package/hedhog/table/course_class_group_material.yaml +45 -0
- package/package.json +8 -8
- package/src/class-group/class-group.controller.ts +30 -0
- package/src/class-group/class-group.service.ts +176 -5
- package/src/class-group/dto/create-class-group.dto.ts +2 -2
- package/src/class-group/dto/material.dto.ts +69 -0
- package/src/course/course.service.ts +41 -8
- package/src/course/dto/create-course.dto.ts +2 -2
- package/src/enterprise/enterprise.controller.ts +62 -1
- package/src/enterprise/enterprise.module.ts +2 -1
- package/src/enterprise/enterprise.service.ts +84 -1
- package/src/enterprise/training/enterprise-training.module.ts +27 -0
- package/src/enterprise/training/training-admin.controller.ts +278 -0
- package/src/enterprise/training/training-admin.service.ts +2523 -0
- package/src/enterprise/training/training-instructor.controller.ts +141 -0
- package/src/enterprise/training/training-instructor.service.ts +1303 -0
- package/src/enterprise/training/training-student.controller.ts +65 -0
- package/src/enterprise/training/training-student.service.ts +762 -0
- package/src/enterprise/training/training-viewer.controller.ts +115 -0
- package/src/evaluation/dto/create-evaluation-topic.dto.ts +48 -0
- package/src/evaluation/dto/update-evaluation-topic.dto.ts +6 -0
- package/src/evaluation/evaluation.controller.ts +63 -1
- package/src/evaluation/evaluation.service.ts +150 -1
- package/src/instructor/instructor.service.ts +4 -4
- 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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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 {
|
|
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
|
-
|
|
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 {
|
|
63
|
-
import {
|
|
64
|
-
import
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
type
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
interface
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
<
|
|
105
|
+
<TableRow
|
|
106
|
+
ref={setNodeRef}
|
|
107
|
+
style={style}
|
|
196
108
|
className={cn(
|
|
197
|
-
'
|
|
198
|
-
|
|
109
|
+
'group cursor-pointer',
|
|
110
|
+
isDragging && 'bg-muted/50 shadow-md'
|
|
199
111
|
)}
|
|
112
|
+
onClick={() => onEdit(item)}
|
|
200
113
|
>
|
|
201
|
-
{
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|
-
|
|
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
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
|
323
|
-
const t = useTranslations('lms.
|
|
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 [
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
const [
|
|
341
|
-
|
|
342
|
-
const [
|
|
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
|
-
//
|
|
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
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
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(
|
|
257
|
+
params.set('pageSize', String(PAGE_SIZE));
|
|
384
258
|
if (debouncedSearch) params.set('search', debouncedSearch);
|
|
385
|
-
if (
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
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) =>
|
|
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
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
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
|
|
406
|
-
|
|
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
|
|
409
|
-
|
|
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
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
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
|
-
|
|
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.
|
|
442
|
-
value:
|
|
443
|
-
icon:
|
|
444
|
-
description: t('kpi.
|
|
445
|
-
loading:
|
|
446
|
-
iconContainerClassName: 'bg-
|
|
447
|
-
accentClassName: 'from-
|
|
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: '
|
|
451
|
-
title: t('kpi.
|
|
452
|
-
value:
|
|
453
|
-
icon:
|
|
454
|
-
description: t('kpi.
|
|
455
|
-
loading:
|
|
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: '
|
|
462
|
-
title: t('kpi.
|
|
463
|
-
value:
|
|
464
|
-
icon:
|
|
465
|
-
description: t('kpi.
|
|
466
|
-
loading:
|
|
467
|
-
iconContainerClassName: 'bg-
|
|
468
|
-
accentClassName: 'from-
|
|
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: '
|
|
472
|
-
title: t('kpi.
|
|
473
|
-
value:
|
|
339
|
+
key: 'withRatings',
|
|
340
|
+
title: t('kpi.withRatings'),
|
|
341
|
+
value: withRatings,
|
|
474
342
|
icon: Users,
|
|
475
|
-
description: t('kpi.
|
|
476
|
-
loading:
|
|
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
|
-
|
|
482
|
-
|
|
348
|
+
];
|
|
349
|
+
}, [t, allTopics]);
|
|
350
|
+
|
|
351
|
+
function openCreate() {
|
|
352
|
+
setEditingItem(null);
|
|
353
|
+
setSheetOpen(true);
|
|
354
|
+
}
|
|
483
355
|
|
|
484
|
-
function
|
|
485
|
-
|
|
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="
|
|
506
|
-
<
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
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
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
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
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
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
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
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
|
-
</
|
|
730
|
-
|
|
731
|
-
</
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
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
|
-
<
|
|
755
|
-
{
|
|
756
|
-
|
|
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
|
-
<
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
</
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
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 >
|
|
571
|
+
{!isLoading && totalItems > PAGE_SIZE && (
|
|
935
572
|
<div className="mt-6">
|
|
936
573
|
<PaginationFooter
|
|
937
574
|
currentPage={currentPage}
|
|
938
|
-
pageSize={
|
|
575
|
+
pageSize={PAGE_SIZE}
|
|
939
576
|
totalItems={totalItems}
|
|
940
577
|
onPageChange={setCurrentPage}
|
|
941
|
-
onPageSizeChange={(
|
|
942
|
-
|
|
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
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
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
|
}
|