@hed-hog/lms 0.0.320 → 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.
@@ -1,621 +1,621 @@
1
- 'use client';
2
-
3
- // ─── THIS FILE IS THE EVALUATION TOPICS MANAGEMENT PAGE ───────────────────────
4
- // The ratings/report page was moved to: /lms/reports/evaluations/page.tsx
5
-
6
- import {
7
- EmptyState,
8
- Page,
9
- PageHeader,
10
- PaginationFooter,
11
- } from '@/components/entity-list';
12
- import {
13
- AlertDialog,
14
- AlertDialogAction,
15
- AlertDialogCancel,
16
- AlertDialogContent,
17
- AlertDialogDescription,
18
- AlertDialogFooter,
19
- AlertDialogHeader,
20
- AlertDialogTitle,
21
- } from '@/components/ui/alert-dialog';
22
- import { Badge } from '@/components/ui/badge';
23
- import { Button } from '@/components/ui/button';
24
- import { Input } from '@/components/ui/input';
25
- import { KpiCardsGrid, type KpiCardItem } from '@/components/ui/kpi-cards-grid';
26
- import { Skeleton } from '@/components/ui/skeleton';
27
- import {
28
- Table,
29
- TableBody,
30
- TableCell,
31
- TableHead,
32
- TableHeader,
33
- TableRow,
34
- } from '@/components/ui/table';
35
- import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
36
- import { cn } from '@/lib/utils';
37
- import {
38
- DndContext,
39
- PointerSensor,
40
- closestCenter,
41
- useSensor,
42
- useSensors,
43
- type DragEndEvent,
44
- } from '@dnd-kit/core';
45
- import {
46
- SortableContext,
47
- arrayMove,
48
- useSortable,
49
- verticalListSortingStrategy,
50
- } from '@dnd-kit/sortable';
51
- import { CSS } from '@dnd-kit/utilities';
52
- import { useApp, useQuery } from '@hed-hog/next-app-provider';
53
- import {
54
- BookOpen,
55
- GraduationCap,
56
- GripVertical,
57
- ListChecks,
58
- MessageSquare,
59
- Pencil,
60
- Plus,
61
- Search,
62
- Star,
63
- Trash2,
64
- Users,
65
- X,
66
- } from 'lucide-react';
67
- import { useTranslations } from 'next-intl';
68
- import { useEffect, useMemo, useRef, useState } from 'react';
69
- import { toast } from 'sonner';
70
- import { EvaluationTopicFormSheet } from './_components/evaluation-topic-form-sheet';
71
- import {
72
- TARGET_TYPE_COLORS,
73
- TARGET_TYPE_OPTIONS,
74
- type ApiEvaluationTopicList,
75
- type EvaluationTargetType,
76
- type EvaluationTopicItem,
77
- } from './_components/evaluation-topic-types';
78
-
79
- // ── SortableTopicRow ──────────────────────────────────────────────────────────
80
-
81
- interface SortableTopicRowProps {
82
- item: EvaluationTopicItem;
83
- onEdit: (item: EvaluationTopicItem) => void;
84
- onDelete: (item: EvaluationTopicItem) => void;
85
- }
86
-
87
- function SortableTopicRow({ item, onEdit, onDelete }: SortableTopicRowProps) {
88
- const t = useTranslations('lms.EvaluationTopicsPage');
89
- const {
90
- attributes,
91
- listeners,
92
- setNodeRef,
93
- transform,
94
- transition,
95
- isDragging,
96
- } = useSortable({ id: item.id });
97
-
98
- const style: React.CSSProperties = {
99
- transform: CSS.Transform.toString(transform),
100
- transition,
101
- opacity: isDragging ? 0.4 : undefined,
102
- };
103
-
104
- return (
105
- <TableRow
106
- ref={setNodeRef}
107
- style={style}
108
- className={cn(
109
- 'group cursor-pointer',
110
- isDragging && 'bg-muted/50 shadow-md'
111
- )}
112
- onClick={() => onEdit(item)}
113
- >
114
- <TableCell className="w-8 pr-0" onClick={(e) => e.stopPropagation()}>
115
- <button
116
- {...attributes}
117
- {...listeners}
118
- className="flex cursor-grab touch-none items-center justify-center rounded p-1 text-muted-foreground/40 opacity-0 transition-opacity group-hover:opacity-100 active:cursor-grabbing"
119
- tabIndex={-1}
120
- >
121
- <GripVertical className="size-4" />
122
- </button>
123
- </TableCell>
124
- <TableCell>
125
- <div className="flex flex-col gap-0.5">
126
- <span className="font-medium">{item.name}</span>
127
- {item.description && (
128
- <span className="max-w-xs truncate text-xs text-muted-foreground">
129
- {item.description}
130
- </span>
131
- )}
132
- </div>
133
- </TableCell>
134
- <TableCell>
135
- <span
136
- className={cn(
137
- 'inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium',
138
- TARGET_TYPE_COLORS[item.targetType]
139
- )}
140
- >
141
- {t(`targetType.${item.targetType}`)}
142
- </span>
143
- </TableCell>
144
- <TableCell className="text-sm text-muted-foreground">
145
- {item.ratingCount}
146
- </TableCell>
147
- <TableCell>
148
- <Badge
149
- variant={item.isActive ? 'default' : 'secondary'}
150
- className={cn(
151
- 'text-xs',
152
- item.isActive
153
- ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300'
154
- : ''
155
- )}
156
- >
157
- {item.isActive ? t('status.active') : t('status.inactive')}
158
- </Badge>
159
- </TableCell>
160
- <TableCell onClick={(e) => e.stopPropagation()}>
161
- <div className="flex items-center gap-1">
162
- <Button
163
- variant="ghost"
164
- size="icon"
165
- className="size-8"
166
- onClick={() => onEdit(item)}
167
- >
168
- <Pencil className="size-3.5" />
169
- </Button>
170
- <Button
171
- variant="ghost"
172
- size="icon"
173
- className="size-8 text-destructive hover:text-destructive"
174
- onClick={() => onDelete(item)}
175
- disabled={item.ratingCount > 0}
176
- title={
177
- item.ratingCount > 0 ? t('delete.disabledTooltip') : undefined
178
- }
179
- >
180
- <Trash2 className="size-3.5" />
181
- </Button>
182
- </div>
183
- </TableCell>
184
- </TableRow>
185
- );
186
- }
187
-
188
- // ── Constants ─────────────────────────────────────────────────────────────────
189
-
190
- const PAGE_SIZE = 20;
191
-
192
- const TARGET_TYPE_ICONS: Record<EvaluationTargetType, React.ReactNode> = {
193
- course: <BookOpen className="size-4" />,
194
- course_lesson: <BookOpen className="size-4" />,
195
- course_class_session: <Users className="size-4" />,
196
- question: <MessageSquare className="size-4" />,
197
- exam: <GraduationCap className="size-4" />,
198
- };
199
-
200
- // ── Page ──────────────────────────────────────────────────────────────────────
201
-
202
- export default function EvaluationTopicsPage() {
203
- const t = useTranslations('lms.EvaluationTopicsPage');
204
- const { request } = useApp();
205
-
206
- const [searchInput, setSearchInput] = useState('');
207
- const [debouncedSearch, setDebouncedSearch] = useState('');
208
- const [activeTab, setActiveTab] = useState<EvaluationTargetType | 'all'>(
209
- 'all'
210
- );
211
- const [currentPage, setCurrentPage] = useState(1);
212
-
213
- const [sheetOpen, setSheetOpen] = useState(false);
214
- const [editingItem, setEditingItem] = useState<EvaluationTopicItem | null>(
215
- null
216
- );
217
- const [deletingItem, setDeletingItem] = useState<EvaluationTopicItem | null>(
218
- null
219
- );
220
- const [deleting, setDeleting] = useState(false);
221
- const [localTopics, setLocalTopics] = useState<EvaluationTopicItem[]>([]);
222
- const [reordering, setReordering] = useState(false);
223
-
224
- // Debounce search
225
- const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
226
- useEffect(() => {
227
- if (debounceRef.current) clearTimeout(debounceRef.current);
228
- debounceRef.current = setTimeout(
229
- () => setDebouncedSearch(searchInput),
230
- 400
231
- );
232
- return () => {
233
- if (debounceRef.current) clearTimeout(debounceRef.current);
234
- };
235
- }, [searchInput]);
236
-
237
- // Reset page on filter/tab change
238
- useEffect(() => {
239
- setCurrentPage(1);
240
- }, [debouncedSearch, activeTab]);
241
-
242
- const {
243
- data: topicList,
244
- isLoading,
245
- refetch,
246
- } = useQuery<ApiEvaluationTopicList>({
247
- queryKey: [
248
- 'lms-evaluation-topics',
249
- currentPage,
250
- PAGE_SIZE,
251
- debouncedSearch,
252
- activeTab,
253
- ],
254
- queryFn: async () => {
255
- const params = new URLSearchParams();
256
- params.set('page', String(currentPage));
257
- params.set('pageSize', String(PAGE_SIZE));
258
- if (debouncedSearch) params.set('search', debouncedSearch);
259
- if (activeTab !== 'all') params.set('targetType', activeTab);
260
- const res = await request<ApiEvaluationTopicList>({
261
- url: `/lms/evaluations/topics?${params.toString()}`,
262
- method: 'GET',
263
- });
264
- return res.data;
265
- },
266
- placeholderData: (previous) =>
267
- previous ?? {
268
- data: [],
269
- total: 0,
270
- page: 1,
271
- pageSize: PAGE_SIZE,
272
- lastPage: 1,
273
- },
274
- });
275
-
276
- const topics = topicList?.data ?? [];
277
- const totalItems = topicList?.total ?? 0;
278
-
279
- // Sync local order whenever server data refreshes
280
- useEffect(() => {
281
- setLocalTopics(topics);
282
- }, [topics]);
283
-
284
- const sensors = useSensors(
285
- useSensor(PointerSensor, { activationConstraint: { distance: 8 } })
286
- );
287
-
288
- const { data: allTopics } = useQuery<ApiEvaluationTopicList>({
289
- queryKey: ['lms-evaluation-topics-all-summary'],
290
- queryFn: async () => {
291
- const res = await request<ApiEvaluationTopicList>({
292
- url: '/lms/evaluations/topics?pageSize=1000',
293
- method: 'GET',
294
- });
295
- return res.data;
296
- },
297
- });
298
-
299
- const kpiItems = useMemo<KpiCardItem[]>(() => {
300
- const all = allTopics?.data ?? [];
301
- const total = allTopics?.total ?? 0;
302
- const active = all.filter((t) => t.isActive).length;
303
- const inactive = all.filter((t) => !t.isActive).length;
304
- const withRatings = all.filter((t) => t.ratingCount > 0).length;
305
-
306
- return [
307
- {
308
- key: 'total',
309
- title: t('kpi.total'),
310
- value: total,
311
- icon: ListChecks,
312
- description: t('kpi.totalDesc'),
313
- loading: false,
314
- iconContainerClassName: 'bg-blue-500/10 text-blue-600',
315
- accentClassName: 'from-blue-500/25 via-blue-500/10 to-transparent',
316
- },
317
- {
318
- key: 'active',
319
- title: t('kpi.active'),
320
- value: active,
321
- icon: Star,
322
- description: t('kpi.activeDesc'),
323
- loading: false,
324
- iconContainerClassName: 'bg-emerald-500/10 text-emerald-700',
325
- accentClassName:
326
- 'from-emerald-500/25 via-emerald-500/10 to-transparent',
327
- },
328
- {
329
- key: 'inactive',
330
- title: t('kpi.inactive'),
331
- value: inactive,
332
- icon: X,
333
- description: t('kpi.inactiveDesc'),
334
- loading: false,
335
- iconContainerClassName: 'bg-red-500/10 text-red-600',
336
- accentClassName: 'from-red-500/25 via-red-500/10 to-transparent',
337
- },
338
- {
339
- key: 'withRatings',
340
- title: t('kpi.withRatings'),
341
- value: withRatings,
342
- icon: Users,
343
- description: t('kpi.withRatingsDesc'),
344
- loading: false,
345
- iconContainerClassName: 'bg-violet-500/10 text-violet-700',
346
- accentClassName: 'from-violet-500/25 via-purple-500/10 to-transparent',
347
- },
348
- ];
349
- }, [t, allTopics]);
350
-
351
- function openCreate() {
352
- setEditingItem(null);
353
- setSheetOpen(true);
354
- }
355
-
356
- function openEdit(item: EvaluationTopicItem) {
357
- setEditingItem(item);
358
- setSheetOpen(true);
359
- }
360
-
361
- async function handleDelete() {
362
- if (!deletingItem) return;
363
- setDeleting(true);
364
- try {
365
- await request({
366
- url: `/lms/evaluations/topics/${deletingItem.id}`,
367
- method: 'DELETE',
368
- });
369
- toast.success(t('delete.success'));
370
- setDeletingItem(null);
371
- refetch();
372
- } catch (err: any) {
373
- const msg = err?.response?.data?.message ?? t('delete.error');
374
- toast.error(msg);
375
- } finally {
376
- setDeleting(false);
377
- }
378
- }
379
-
380
- function handleSheetSuccess() {
381
- refetch();
382
- }
383
-
384
- async function handleDragEnd(event: DragEndEvent) {
385
- const { active, over } = event;
386
- if (!over || active.id === over.id) return;
387
- const oldIndex = localTopics.findIndex((t) => t.id === Number(active.id));
388
- const newIndex = localTopics.findIndex((t) => t.id === Number(over.id));
389
- if (oldIndex === -1 || newIndex === -1) return;
390
- const reordered = arrayMove(localTopics, oldIndex, newIndex);
391
- setLocalTopics(reordered);
392
- setReordering(true);
393
- try {
394
- await request({
395
- url: '/lms/evaluations/topics/reorder',
396
- method: 'PATCH',
397
- data: { ids: reordered.map((item) => item.id) },
398
- });
399
- toast.success(t('reorder.success'));
400
- refetch();
401
- } catch {
402
- setLocalTopics(topics);
403
- toast.error(t('reorder.error'));
404
- } finally {
405
- setReordering(false);
406
- }
407
- }
408
-
409
- const tabItems: { value: EvaluationTargetType | 'all'; label: string }[] = [
410
- { value: 'all', label: t('tabs.all') },
411
- ...TARGET_TYPE_OPTIONS.map((opt) => ({
412
- value: opt.value,
413
- label: t(`targetType.${opt.labelKey}`),
414
- })),
415
- ];
416
-
417
- return (
418
- <Page>
419
- <div className="space-y-6">
420
- <PageHeader
421
- title={t('title')}
422
- description={t('description')}
423
- breadcrumbs={[
424
- { label: t('breadcrumbs.home'), href: '/' },
425
- { label: t('breadcrumbs.lms'), href: '/lms' },
426
- { label: t('breadcrumbs.evaluations') },
427
- ]}
428
- actions={
429
- <Button onClick={openCreate}>
430
- <Plus className="mr-2 size-4" />
431
- {t('newTopic')}
432
- </Button>
433
- }
434
- />
435
-
436
- <KpiCardsGrid items={kpiItems} />
437
-
438
- {/* Toolbar */}
439
- <div className="flex flex-wrap items-center gap-2">
440
- <div className="flex min-w-40 flex-1 items-center gap-2">
441
- <Input
442
- className="min-w-0 flex-1"
443
- placeholder={t('filters.searchPlaceholder')}
444
- value={searchInput}
445
- onChange={(e) => setSearchInput(e.target.value)}
446
- />
447
- <Button
448
- type="button"
449
- variant="default"
450
- size="icon"
451
- className="shrink-0"
452
- >
453
- <Search className="size-4" />
454
- </Button>
455
- </div>
456
-
457
- {searchInput && (
458
- <Button
459
- type="button"
460
- variant="ghost"
461
- size="sm"
462
- onClick={() => {
463
- setSearchInput('');
464
- setDebouncedSearch('');
465
- }}
466
- className="h-9 px-2 text-muted-foreground"
467
- >
468
- <X className="mr-1 size-3.5" />
469
- {t('filters.clear')}
470
- </Button>
471
- )}
472
- </div>
473
-
474
- {/* Tabs by target type */}
475
- <Tabs
476
- value={activeTab}
477
- onValueChange={(v) => setActiveTab(v as EvaluationTargetType | 'all')}
478
- >
479
- <TabsList className="flex-wrap">
480
- {tabItems.map((tab) => (
481
- <TabsTrigger key={tab.value} value={tab.value}>
482
- {tab.value !== 'all' && (
483
- <span className="mr-1.5">
484
- {TARGET_TYPE_ICONS[tab.value as EvaluationTargetType]}
485
- </span>
486
- )}
487
- {tab.label}
488
- </TabsTrigger>
489
- ))}
490
- </TabsList>
491
-
492
- {tabItems.map((tab) => (
493
- <TabsContent key={tab.value} value={tab.value} className="mt-4">
494
- {isLoading ? (
495
- <div className="overflow-hidden rounded-xl border border-border/70">
496
- <Table>
497
- <TableHeader>
498
- <TableRow>
499
- <TableHead className="w-8 pr-0" />
500
- <TableHead>{t('columns.name')}</TableHead>
501
- <TableHead>{t('columns.targetType')}</TableHead>
502
- <TableHead>{t('columns.ratings')}</TableHead>
503
- <TableHead>{t('columns.status')}</TableHead>
504
- <TableHead className="w-20" />
505
- </TableRow>
506
- </TableHeader>
507
- <TableBody>
508
- {Array.from({ length: 5 }).map((_, i) => (
509
- <TableRow key={i}>
510
- {Array.from({ length: 6 }).map((__, j) => (
511
- <TableCell key={j}>
512
- <Skeleton className="h-4 w-full" />
513
- </TableCell>
514
- ))}
515
- </TableRow>
516
- ))}
517
- </TableBody>
518
- </Table>
519
- </div>
520
- ) : topics.length === 0 ? (
521
- <EmptyState
522
- icon={<ListChecks className="size-8" />}
523
- title={t('empty.title')}
524
- description={t('empty.description')}
525
- actionLabel={t('newTopic')}
526
- actionIcon={<Plus className="mr-2 size-4" />}
527
- onAction={openCreate}
528
- />
529
- ) : (
530
- <DndContext
531
- sensors={sensors}
532
- collisionDetection={closestCenter}
533
- onDragEnd={handleDragEnd}
534
- >
535
- <SortableContext
536
- items={localTopics.map((item) => item.id)}
537
- strategy={verticalListSortingStrategy}
538
- >
539
- <div className="overflow-hidden rounded-xl border border-border/70">
540
- <Table>
541
- <TableHeader>
542
- <TableRow>
543
- <TableHead className="w-8 pr-0" />
544
- <TableHead>{t('columns.name')}</TableHead>
545
- <TableHead>{t('columns.targetType')}</TableHead>
546
- <TableHead>{t('columns.ratings')}</TableHead>
547
- <TableHead>{t('columns.status')}</TableHead>
548
- <TableHead className="w-20" />
549
- </TableRow>
550
- </TableHeader>
551
- <TableBody>
552
- {localTopics.map((item) => (
553
- <SortableTopicRow
554
- key={item.id}
555
- item={item}
556
- onEdit={openEdit}
557
- onDelete={setDeletingItem}
558
- />
559
- ))}
560
- </TableBody>
561
- </Table>
562
- </div>
563
- </SortableContext>
564
- </DndContext>
565
- )}
566
- </TabsContent>
567
- ))}
568
- </Tabs>
569
-
570
- {/* Pagination */}
571
- {!isLoading && totalItems > PAGE_SIZE && (
572
- <div className="mt-6">
573
- <PaginationFooter
574
- currentPage={currentPage}
575
- pageSize={PAGE_SIZE}
576
- totalItems={totalItems}
577
- onPageChange={setCurrentPage}
578
- onPageSizeChange={() => {}}
579
- pageSizeOptions={[PAGE_SIZE]}
580
- />
581
- </div>
582
- )}
583
- </div>
584
-
585
- <EvaluationTopicFormSheet
586
- open={sheetOpen}
587
- onOpenChange={setSheetOpen}
588
- editingItem={editingItem}
589
- onSuccess={handleSheetSuccess}
590
- />
591
-
592
- <AlertDialog
593
- open={!!deletingItem}
594
- onOpenChange={(open) => {
595
- if (!open) setDeletingItem(null);
596
- }}
597
- >
598
- <AlertDialogContent>
599
- <AlertDialogHeader>
600
- <AlertDialogTitle>{t('delete.title')}</AlertDialogTitle>
601
- <AlertDialogDescription>
602
- {t('delete.description', { name: deletingItem?.name ?? '' })}
603
- </AlertDialogDescription>
604
- </AlertDialogHeader>
605
- <AlertDialogFooter>
606
- <AlertDialogCancel disabled={deleting}>
607
- {t('delete.cancel')}
608
- </AlertDialogCancel>
609
- <AlertDialogAction
610
- onClick={handleDelete}
611
- disabled={deleting}
612
- className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
613
- >
614
- {deleting ? t('delete.deleting') : t('delete.confirm')}
615
- </AlertDialogAction>
616
- </AlertDialogFooter>
617
- </AlertDialogContent>
618
- </AlertDialog>
619
- </Page>
620
- );
621
- }
1
+ 'use client';
2
+
3
+ // ─── THIS FILE IS THE EVALUATION TOPICS MANAGEMENT PAGE ───────────────────────
4
+ // The ratings/report page was moved to: /lms/reports/evaluations/page.tsx
5
+
6
+ import {
7
+ EmptyState,
8
+ Page,
9
+ PageHeader,
10
+ PaginationFooter,
11
+ } from '@/components/entity-list';
12
+ import {
13
+ AlertDialog,
14
+ AlertDialogAction,
15
+ AlertDialogCancel,
16
+ AlertDialogContent,
17
+ AlertDialogDescription,
18
+ AlertDialogFooter,
19
+ AlertDialogHeader,
20
+ AlertDialogTitle,
21
+ } from '@/components/ui/alert-dialog';
22
+ import { Badge } from '@/components/ui/badge';
23
+ import { Button } from '@/components/ui/button';
24
+ import { Input } from '@/components/ui/input';
25
+ import { KpiCardsGrid, type KpiCardItem } from '@/components/ui/kpi-cards-grid';
26
+ import { Skeleton } from '@/components/ui/skeleton';
27
+ import {
28
+ Table,
29
+ TableBody,
30
+ TableCell,
31
+ TableHead,
32
+ TableHeader,
33
+ TableRow,
34
+ } from '@/components/ui/table';
35
+ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
36
+ import { cn } from '@/lib/utils';
37
+ import {
38
+ DndContext,
39
+ PointerSensor,
40
+ closestCenter,
41
+ useSensor,
42
+ useSensors,
43
+ type DragEndEvent,
44
+ } from '@dnd-kit/core';
45
+ import {
46
+ SortableContext,
47
+ arrayMove,
48
+ useSortable,
49
+ verticalListSortingStrategy,
50
+ } from '@dnd-kit/sortable';
51
+ import { CSS } from '@dnd-kit/utilities';
52
+ import { useApp, useQuery } from '@hed-hog/next-app-provider';
53
+ import {
54
+ BookOpen,
55
+ GraduationCap,
56
+ GripVertical,
57
+ ListChecks,
58
+ MessageSquare,
59
+ Pencil,
60
+ Plus,
61
+ Search,
62
+ Star,
63
+ Trash2,
64
+ Users,
65
+ X,
66
+ } from 'lucide-react';
67
+ import { useTranslations } from 'next-intl';
68
+ import { useEffect, useMemo, useRef, useState } from 'react';
69
+ import { toast } from 'sonner';
70
+ import { EvaluationTopicFormSheet } from './_components/evaluation-topic-form-sheet';
71
+ import {
72
+ TARGET_TYPE_COLORS,
73
+ TARGET_TYPE_OPTIONS,
74
+ type ApiEvaluationTopicList,
75
+ type EvaluationTargetType,
76
+ type EvaluationTopicItem,
77
+ } from './_components/evaluation-topic-types';
78
+
79
+ // ── SortableTopicRow ──────────────────────────────────────────────────────────
80
+
81
+ interface SortableTopicRowProps {
82
+ item: EvaluationTopicItem;
83
+ onEdit: (item: EvaluationTopicItem) => void;
84
+ onDelete: (item: EvaluationTopicItem) => void;
85
+ }
86
+
87
+ function SortableTopicRow({ item, onEdit, onDelete }: SortableTopicRowProps) {
88
+ const t = useTranslations('lms.EvaluationTopicsPage');
89
+ const {
90
+ attributes,
91
+ listeners,
92
+ setNodeRef,
93
+ transform,
94
+ transition,
95
+ isDragging,
96
+ } = useSortable({ id: item.id });
97
+
98
+ const style: React.CSSProperties = {
99
+ transform: CSS.Transform.toString(transform),
100
+ transition,
101
+ opacity: isDragging ? 0.4 : undefined,
102
+ };
103
+
104
+ return (
105
+ <TableRow
106
+ ref={setNodeRef}
107
+ style={style}
108
+ className={cn(
109
+ 'group cursor-pointer',
110
+ isDragging && 'bg-muted/50 shadow-md'
111
+ )}
112
+ onClick={() => onEdit(item)}
113
+ >
114
+ <TableCell className="w-8 pr-0" onClick={(e) => e.stopPropagation()}>
115
+ <button
116
+ {...attributes}
117
+ {...listeners}
118
+ className="flex cursor-grab touch-none items-center justify-center rounded p-1 text-muted-foreground/40 opacity-0 transition-opacity group-hover:opacity-100 active:cursor-grabbing"
119
+ tabIndex={-1}
120
+ >
121
+ <GripVertical className="size-4" />
122
+ </button>
123
+ </TableCell>
124
+ <TableCell>
125
+ <div className="flex flex-col gap-0.5">
126
+ <span className="font-medium">{item.name}</span>
127
+ {item.description && (
128
+ <span className="max-w-xs truncate text-xs text-muted-foreground">
129
+ {item.description}
130
+ </span>
131
+ )}
132
+ </div>
133
+ </TableCell>
134
+ <TableCell>
135
+ <span
136
+ className={cn(
137
+ 'inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium',
138
+ TARGET_TYPE_COLORS[item.targetType]
139
+ )}
140
+ >
141
+ {t(`targetType.${item.targetType}`)}
142
+ </span>
143
+ </TableCell>
144
+ <TableCell className="text-sm text-muted-foreground">
145
+ {item.ratingCount}
146
+ </TableCell>
147
+ <TableCell>
148
+ <Badge
149
+ variant={item.isActive ? 'default' : 'secondary'}
150
+ className={cn(
151
+ 'text-xs',
152
+ item.isActive
153
+ ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300'
154
+ : ''
155
+ )}
156
+ >
157
+ {item.isActive ? t('status.active') : t('status.inactive')}
158
+ </Badge>
159
+ </TableCell>
160
+ <TableCell onClick={(e) => e.stopPropagation()}>
161
+ <div className="flex items-center gap-1">
162
+ <Button
163
+ variant="ghost"
164
+ size="icon"
165
+ className="size-8"
166
+ onClick={() => onEdit(item)}
167
+ >
168
+ <Pencil className="size-3.5" />
169
+ </Button>
170
+ <Button
171
+ variant="ghost"
172
+ size="icon"
173
+ className="size-8 text-destructive hover:text-destructive"
174
+ onClick={() => onDelete(item)}
175
+ disabled={item.ratingCount > 0}
176
+ title={
177
+ item.ratingCount > 0 ? t('delete.disabledTooltip') : undefined
178
+ }
179
+ >
180
+ <Trash2 className="size-3.5" />
181
+ </Button>
182
+ </div>
183
+ </TableCell>
184
+ </TableRow>
185
+ );
186
+ }
187
+
188
+ // ── Constants ─────────────────────────────────────────────────────────────────
189
+
190
+ const PAGE_SIZE = 20;
191
+
192
+ const TARGET_TYPE_ICONS: Record<EvaluationTargetType, React.ReactNode> = {
193
+ course: <BookOpen className="size-4" />,
194
+ course_lesson: <BookOpen className="size-4" />,
195
+ course_class_session: <Users className="size-4" />,
196
+ question: <MessageSquare className="size-4" />,
197
+ exam: <GraduationCap className="size-4" />,
198
+ };
199
+
200
+ // ── Page ──────────────────────────────────────────────────────────────────────
201
+
202
+ export default function EvaluationTopicsPage() {
203
+ const t = useTranslations('lms.EvaluationTopicsPage');
204
+ const { request } = useApp();
205
+
206
+ const [searchInput, setSearchInput] = useState('');
207
+ const [debouncedSearch, setDebouncedSearch] = useState('');
208
+ const [activeTab, setActiveTab] = useState<EvaluationTargetType | 'all'>(
209
+ 'all'
210
+ );
211
+ const [currentPage, setCurrentPage] = useState(1);
212
+
213
+ const [sheetOpen, setSheetOpen] = useState(false);
214
+ const [editingItem, setEditingItem] = useState<EvaluationTopicItem | null>(
215
+ null
216
+ );
217
+ const [deletingItem, setDeletingItem] = useState<EvaluationTopicItem | null>(
218
+ null
219
+ );
220
+ const [deleting, setDeleting] = useState(false);
221
+ const [localTopics, setLocalTopics] = useState<EvaluationTopicItem[]>([]);
222
+ const [reordering, setReordering] = useState(false);
223
+
224
+ // Debounce search
225
+ const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
226
+ useEffect(() => {
227
+ if (debounceRef.current) clearTimeout(debounceRef.current);
228
+ debounceRef.current = setTimeout(
229
+ () => setDebouncedSearch(searchInput),
230
+ 400
231
+ );
232
+ return () => {
233
+ if (debounceRef.current) clearTimeout(debounceRef.current);
234
+ };
235
+ }, [searchInput]);
236
+
237
+ // Reset page on filter/tab change
238
+ useEffect(() => {
239
+ setCurrentPage(1);
240
+ }, [debouncedSearch, activeTab]);
241
+
242
+ const {
243
+ data: topicList,
244
+ isLoading,
245
+ refetch,
246
+ } = useQuery<ApiEvaluationTopicList>({
247
+ queryKey: [
248
+ 'lms-evaluation-topics',
249
+ currentPage,
250
+ PAGE_SIZE,
251
+ debouncedSearch,
252
+ activeTab,
253
+ ],
254
+ queryFn: async () => {
255
+ const params = new URLSearchParams();
256
+ params.set('page', String(currentPage));
257
+ params.set('pageSize', String(PAGE_SIZE));
258
+ if (debouncedSearch) params.set('search', debouncedSearch);
259
+ if (activeTab !== 'all') params.set('targetType', activeTab);
260
+ const res = await request<ApiEvaluationTopicList>({
261
+ url: `/lms/evaluations/topics?${params.toString()}`,
262
+ method: 'GET',
263
+ });
264
+ return res.data;
265
+ },
266
+ placeholderData: (previous) =>
267
+ previous ?? {
268
+ data: [],
269
+ total: 0,
270
+ page: 1,
271
+ pageSize: PAGE_SIZE,
272
+ lastPage: 1,
273
+ },
274
+ });
275
+
276
+ const topics = topicList?.data ?? [];
277
+ const totalItems = topicList?.total ?? 0;
278
+
279
+ // Sync local order whenever server data refreshes
280
+ useEffect(() => {
281
+ setLocalTopics(topics);
282
+ }, [topics]);
283
+
284
+ const sensors = useSensors(
285
+ useSensor(PointerSensor, { activationConstraint: { distance: 8 } })
286
+ );
287
+
288
+ const { data: allTopics } = useQuery<ApiEvaluationTopicList>({
289
+ queryKey: ['lms-evaluation-topics-all-summary'],
290
+ queryFn: async () => {
291
+ const res = await request<ApiEvaluationTopicList>({
292
+ url: '/lms/evaluations/topics?pageSize=1000',
293
+ method: 'GET',
294
+ });
295
+ return res.data;
296
+ },
297
+ });
298
+
299
+ const kpiItems = useMemo<KpiCardItem[]>(() => {
300
+ const all = allTopics?.data ?? [];
301
+ const total = allTopics?.total ?? 0;
302
+ const active = all.filter((t) => t.isActive).length;
303
+ const inactive = all.filter((t) => !t.isActive).length;
304
+ const withRatings = all.filter((t) => t.ratingCount > 0).length;
305
+
306
+ return [
307
+ {
308
+ key: 'total',
309
+ title: t('kpi.total'),
310
+ value: total,
311
+ icon: ListChecks,
312
+ description: t('kpi.totalDesc'),
313
+ loading: false,
314
+ iconContainerClassName: 'bg-blue-500/10 text-blue-600',
315
+ accentClassName: 'from-blue-500/25 via-blue-500/10 to-transparent',
316
+ },
317
+ {
318
+ key: 'active',
319
+ title: t('kpi.active'),
320
+ value: active,
321
+ icon: Star,
322
+ description: t('kpi.activeDesc'),
323
+ loading: false,
324
+ iconContainerClassName: 'bg-emerald-500/10 text-emerald-700',
325
+ accentClassName:
326
+ 'from-emerald-500/25 via-emerald-500/10 to-transparent',
327
+ },
328
+ {
329
+ key: 'inactive',
330
+ title: t('kpi.inactive'),
331
+ value: inactive,
332
+ icon: X,
333
+ description: t('kpi.inactiveDesc'),
334
+ loading: false,
335
+ iconContainerClassName: 'bg-red-500/10 text-red-600',
336
+ accentClassName: 'from-red-500/25 via-red-500/10 to-transparent',
337
+ },
338
+ {
339
+ key: 'withRatings',
340
+ title: t('kpi.withRatings'),
341
+ value: withRatings,
342
+ icon: Users,
343
+ description: t('kpi.withRatingsDesc'),
344
+ loading: false,
345
+ iconContainerClassName: 'bg-violet-500/10 text-violet-700',
346
+ accentClassName: 'from-violet-500/25 via-purple-500/10 to-transparent',
347
+ },
348
+ ];
349
+ }, [t, allTopics]);
350
+
351
+ function openCreate() {
352
+ setEditingItem(null);
353
+ setSheetOpen(true);
354
+ }
355
+
356
+ function openEdit(item: EvaluationTopicItem) {
357
+ setEditingItem(item);
358
+ setSheetOpen(true);
359
+ }
360
+
361
+ async function handleDelete() {
362
+ if (!deletingItem) return;
363
+ setDeleting(true);
364
+ try {
365
+ await request({
366
+ url: `/lms/evaluations/topics/${deletingItem.id}`,
367
+ method: 'DELETE',
368
+ });
369
+ toast.success(t('delete.success'));
370
+ setDeletingItem(null);
371
+ refetch();
372
+ } catch (err: any) {
373
+ const msg = err?.response?.data?.message ?? t('delete.error');
374
+ toast.error(msg);
375
+ } finally {
376
+ setDeleting(false);
377
+ }
378
+ }
379
+
380
+ function handleSheetSuccess() {
381
+ refetch();
382
+ }
383
+
384
+ async function handleDragEnd(event: DragEndEvent) {
385
+ const { active, over } = event;
386
+ if (!over || active.id === over.id) return;
387
+ const oldIndex = localTopics.findIndex((t) => t.id === Number(active.id));
388
+ const newIndex = localTopics.findIndex((t) => t.id === Number(over.id));
389
+ if (oldIndex === -1 || newIndex === -1) return;
390
+ const reordered = arrayMove(localTopics, oldIndex, newIndex);
391
+ setLocalTopics(reordered);
392
+ setReordering(true);
393
+ try {
394
+ await request({
395
+ url: '/lms/evaluations/topics/reorder',
396
+ method: 'PATCH',
397
+ data: { ids: reordered.map((item) => item.id) },
398
+ });
399
+ toast.success(t('reorder.success'));
400
+ refetch();
401
+ } catch {
402
+ setLocalTopics(topics);
403
+ toast.error(t('reorder.error'));
404
+ } finally {
405
+ setReordering(false);
406
+ }
407
+ }
408
+
409
+ const tabItems: { value: EvaluationTargetType | 'all'; label: string }[] = [
410
+ { value: 'all', label: t('tabs.all') },
411
+ ...TARGET_TYPE_OPTIONS.map((opt) => ({
412
+ value: opt.value,
413
+ label: t(`targetType.${opt.labelKey}`),
414
+ })),
415
+ ];
416
+
417
+ return (
418
+ <Page>
419
+ <div className="space-y-6">
420
+ <PageHeader
421
+ title={t('title')}
422
+ description={t('description')}
423
+ breadcrumbs={[
424
+ { label: t('breadcrumbs.home'), href: '/' },
425
+ { label: t('breadcrumbs.lms'), href: '/lms' },
426
+ { label: t('breadcrumbs.evaluations') },
427
+ ]}
428
+ actions={
429
+ <Button onClick={openCreate}>
430
+ <Plus className="mr-2 size-4" />
431
+ {t('newTopic')}
432
+ </Button>
433
+ }
434
+ />
435
+
436
+ <KpiCardsGrid items={kpiItems} />
437
+
438
+ {/* Toolbar */}
439
+ <div className="flex flex-wrap items-center gap-2">
440
+ <div className="flex min-w-40 flex-1 items-center gap-2">
441
+ <Input
442
+ className="min-w-0 flex-1"
443
+ placeholder={t('filters.searchPlaceholder')}
444
+ value={searchInput}
445
+ onChange={(e) => setSearchInput(e.target.value)}
446
+ />
447
+ <Button
448
+ type="button"
449
+ variant="default"
450
+ size="icon"
451
+ className="shrink-0"
452
+ >
453
+ <Search className="size-4" />
454
+ </Button>
455
+ </div>
456
+
457
+ {searchInput && (
458
+ <Button
459
+ type="button"
460
+ variant="ghost"
461
+ size="sm"
462
+ onClick={() => {
463
+ setSearchInput('');
464
+ setDebouncedSearch('');
465
+ }}
466
+ className="h-9 px-2 text-muted-foreground"
467
+ >
468
+ <X className="mr-1 size-3.5" />
469
+ {t('filters.clear')}
470
+ </Button>
471
+ )}
472
+ </div>
473
+
474
+ {/* Tabs by target type */}
475
+ <Tabs
476
+ value={activeTab}
477
+ onValueChange={(v) => setActiveTab(v as EvaluationTargetType | 'all')}
478
+ >
479
+ <TabsList className="flex-wrap">
480
+ {tabItems.map((tab) => (
481
+ <TabsTrigger key={tab.value} value={tab.value}>
482
+ {tab.value !== 'all' && (
483
+ <span className="mr-1.5">
484
+ {TARGET_TYPE_ICONS[tab.value as EvaluationTargetType]}
485
+ </span>
486
+ )}
487
+ {tab.label}
488
+ </TabsTrigger>
489
+ ))}
490
+ </TabsList>
491
+
492
+ {tabItems.map((tab) => (
493
+ <TabsContent key={tab.value} value={tab.value} className="mt-4">
494
+ {isLoading ? (
495
+ <div className="overflow-hidden rounded-xl border border-border/70">
496
+ <Table>
497
+ <TableHeader>
498
+ <TableRow>
499
+ <TableHead className="w-8 pr-0" />
500
+ <TableHead>{t('columns.name')}</TableHead>
501
+ <TableHead>{t('columns.targetType')}</TableHead>
502
+ <TableHead>{t('columns.ratings')}</TableHead>
503
+ <TableHead>{t('columns.status')}</TableHead>
504
+ <TableHead className="w-20" />
505
+ </TableRow>
506
+ </TableHeader>
507
+ <TableBody>
508
+ {Array.from({ length: 5 }).map((_, i) => (
509
+ <TableRow key={i}>
510
+ {Array.from({ length: 6 }).map((__, j) => (
511
+ <TableCell key={j}>
512
+ <Skeleton className="h-4 w-full" />
513
+ </TableCell>
514
+ ))}
515
+ </TableRow>
516
+ ))}
517
+ </TableBody>
518
+ </Table>
519
+ </div>
520
+ ) : topics.length === 0 ? (
521
+ <EmptyState
522
+ icon={<ListChecks className="size-8" />}
523
+ title={t('empty.title')}
524
+ description={t('empty.description')}
525
+ actionLabel={t('newTopic')}
526
+ actionIcon={<Plus className="mr-2 size-4" />}
527
+ onAction={openCreate}
528
+ />
529
+ ) : (
530
+ <DndContext
531
+ sensors={sensors}
532
+ collisionDetection={closestCenter}
533
+ onDragEnd={handleDragEnd}
534
+ >
535
+ <SortableContext
536
+ items={localTopics.map((item) => item.id)}
537
+ strategy={verticalListSortingStrategy}
538
+ >
539
+ <div className="overflow-hidden rounded-xl border border-border/70">
540
+ <Table>
541
+ <TableHeader>
542
+ <TableRow>
543
+ <TableHead className="w-8 pr-0" />
544
+ <TableHead>{t('columns.name')}</TableHead>
545
+ <TableHead>{t('columns.targetType')}</TableHead>
546
+ <TableHead>{t('columns.ratings')}</TableHead>
547
+ <TableHead>{t('columns.status')}</TableHead>
548
+ <TableHead className="w-20" />
549
+ </TableRow>
550
+ </TableHeader>
551
+ <TableBody>
552
+ {localTopics.map((item) => (
553
+ <SortableTopicRow
554
+ key={item.id}
555
+ item={item}
556
+ onEdit={openEdit}
557
+ onDelete={setDeletingItem}
558
+ />
559
+ ))}
560
+ </TableBody>
561
+ </Table>
562
+ </div>
563
+ </SortableContext>
564
+ </DndContext>
565
+ )}
566
+ </TabsContent>
567
+ ))}
568
+ </Tabs>
569
+
570
+ {/* Pagination */}
571
+ {!isLoading && totalItems > PAGE_SIZE && (
572
+ <div className="mt-6">
573
+ <PaginationFooter
574
+ currentPage={currentPage}
575
+ pageSize={PAGE_SIZE}
576
+ totalItems={totalItems}
577
+ onPageChange={setCurrentPage}
578
+ onPageSizeChange={() => {}}
579
+ pageSizeOptions={[PAGE_SIZE]}
580
+ />
581
+ </div>
582
+ )}
583
+ </div>
584
+
585
+ <EvaluationTopicFormSheet
586
+ open={sheetOpen}
587
+ onOpenChange={setSheetOpen}
588
+ editingItem={editingItem}
589
+ onSuccess={handleSheetSuccess}
590
+ />
591
+
592
+ <AlertDialog
593
+ open={!!deletingItem}
594
+ onOpenChange={(open) => {
595
+ if (!open) setDeletingItem(null);
596
+ }}
597
+ >
598
+ <AlertDialogContent>
599
+ <AlertDialogHeader>
600
+ <AlertDialogTitle>{t('delete.title')}</AlertDialogTitle>
601
+ <AlertDialogDescription>
602
+ {t('delete.description', { name: deletingItem?.name ?? '' })}
603
+ </AlertDialogDescription>
604
+ </AlertDialogHeader>
605
+ <AlertDialogFooter>
606
+ <AlertDialogCancel disabled={deleting}>
607
+ {t('delete.cancel')}
608
+ </AlertDialogCancel>
609
+ <AlertDialogAction
610
+ onClick={handleDelete}
611
+ disabled={deleting}
612
+ className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
613
+ >
614
+ {deleting ? t('delete.deleting') : t('delete.confirm')}
615
+ </AlertDialogAction>
616
+ </AlertDialogFooter>
617
+ </AlertDialogContent>
618
+ </AlertDialog>
619
+ </Page>
620
+ );
621
+ }