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