@hed-hog/lms 0.0.331 → 0.0.338

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/dist/class-group/class-group.controller.d.ts +3 -3
  2. package/dist/class-group/class-group.service.d.ts +3 -3
  3. package/dist/course/course.service.d.ts.map +1 -1
  4. package/dist/course/course.service.js +12 -20
  5. package/dist/course/course.service.js.map +1 -1
  6. package/dist/enterprise/enterprise.controller.d.ts +72 -0
  7. package/dist/enterprise/enterprise.controller.d.ts.map +1 -1
  8. package/dist/enterprise/enterprise.controller.js +10 -0
  9. package/dist/enterprise/enterprise.controller.js.map +1 -1
  10. package/dist/enterprise/enterprise.service.d.ts +78 -0
  11. package/dist/enterprise/enterprise.service.d.ts.map +1 -1
  12. package/dist/enterprise/enterprise.service.js +413 -40
  13. package/dist/enterprise/enterprise.service.js.map +1 -1
  14. package/dist/enterprise/training/training-admin.controller.d.ts +6 -3
  15. package/dist/enterprise/training/training-admin.controller.d.ts.map +1 -1
  16. package/dist/enterprise/training/training-admin.controller.js +10 -6
  17. package/dist/enterprise/training/training-admin.controller.js.map +1 -1
  18. package/dist/enterprise/training/training-admin.service.d.ts +8 -2
  19. package/dist/enterprise/training/training-admin.service.d.ts.map +1 -1
  20. package/dist/enterprise/training/training-admin.service.js +108 -52
  21. package/dist/enterprise/training/training-admin.service.js.map +1 -1
  22. package/dist/enterprise/training/training-viewer.controller.d.ts +3 -0
  23. package/dist/enterprise/training/training-viewer.controller.d.ts.map +1 -1
  24. package/dist/evaluation/evaluation.controller.d.ts +4 -4
  25. package/dist/evaluation/evaluation.service.d.ts +4 -4
  26. package/dist/instructor/dto/create-instructor-skill.dto.d.ts +0 -4
  27. package/dist/instructor/dto/create-instructor-skill.dto.d.ts.map +1 -1
  28. package/dist/instructor/dto/create-instructor-skill.dto.js +0 -21
  29. package/dist/instructor/dto/create-instructor-skill.dto.js.map +1 -1
  30. package/dist/instructor/dto/update-instructor-skill.dto.d.ts +0 -4
  31. package/dist/instructor/dto/update-instructor-skill.dto.d.ts.map +1 -1
  32. package/dist/instructor/dto/update-instructor-skill.dto.js +0 -22
  33. package/dist/instructor/dto/update-instructor-skill.dto.js.map +1 -1
  34. package/dist/instructor/instructor-skill.controller.d.ts +4 -4
  35. package/dist/instructor/instructor-skill.service.d.ts +4 -7
  36. package/dist/instructor/instructor-skill.service.d.ts.map +1 -1
  37. package/dist/instructor/instructor-skill.service.js +2 -89
  38. package/dist/instructor/instructor-skill.service.js.map +1 -1
  39. package/dist/instructor/instructor.controller.d.ts +20 -0
  40. package/dist/instructor/instructor.controller.d.ts.map +1 -1
  41. package/dist/instructor/instructor.controller.js +19 -0
  42. package/dist/instructor/instructor.controller.js.map +1 -1
  43. package/dist/instructor/instructor.service.d.ts +25 -0
  44. package/dist/instructor/instructor.service.d.ts.map +1 -1
  45. package/dist/instructor/instructor.service.js +70 -18
  46. package/dist/instructor/instructor.service.js.map +1 -1
  47. package/dist/lms.module.d.ts.map +1 -1
  48. package/dist/lms.module.js.map +1 -1
  49. package/hedhog/data/route.yaml +23 -1
  50. package/hedhog/frontend/app/_components/class-form-sheet.tsx.ejs +42 -24
  51. package/hedhog/frontend/app/_components/course-form-sheet.tsx.ejs +3 -3
  52. package/hedhog/frontend/app/_components/create-lms-instructor-sheet.tsx.ejs +591 -0
  53. package/hedhog/frontend/app/certificates/issued/page.tsx.ejs +6 -1
  54. package/hedhog/frontend/app/certificates/models/page.tsx.ejs +6 -1
  55. package/hedhog/frontend/app/classes/page.tsx.ejs +6 -1
  56. package/hedhog/frontend/app/courses/[id]/_components/CourseClassificationCard.tsx.ejs +3 -33
  57. package/hedhog/frontend/app/courses/[id]/_components/CourseContentCard.tsx.ejs +9 -9
  58. package/hedhog/frontend/app/courses/[id]/_components/CourseMainInfoCard.tsx.ejs +109 -0
  59. package/hedhog/frontend/app/courses/[id]/_components/CourseMultiEntityPicker.tsx.ejs +40 -13
  60. package/hedhog/frontend/app/courses/[id]/_components/CourseRelationsCard.tsx.ejs +76 -81
  61. package/hedhog/frontend/app/courses/[id]/_components/CourseSummaryCard.tsx.ejs +60 -0
  62. package/hedhog/frontend/app/courses/[id]/structure/_components/course-scheduled-classes-tab.tsx.ejs +406 -0
  63. package/hedhog/frontend/app/courses/[id]/structure/_components/course-tree.tsx.ejs +134 -0
  64. package/hedhog/frontend/app/courses/[id]/structure/_components/detail-course.tsx.ejs +113 -0
  65. package/hedhog/frontend/app/courses/[id]/structure/_components/detail-lesson.tsx.ejs +314 -0
  66. package/hedhog/frontend/app/courses/[id]/structure/_components/detail-session.tsx.ejs +174 -0
  67. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-course.tsx.ejs +242 -33
  68. package/hedhog/frontend/app/courses/[id]/structure/_components/mock-data.ts.ejs +185 -0
  69. package/hedhog/frontend/app/courses/page.tsx.ejs +6 -1
  70. package/hedhog/frontend/app/enterprise/_components/enterprise-activity-timeline.tsx.ejs +87 -0
  71. package/hedhog/frontend/app/enterprise/_components/enterprise-admin-create-sheet.tsx.ejs +4 -0
  72. package/hedhog/frontend/app/enterprise/_components/enterprise-administrators-tab.tsx.ejs +31 -5
  73. package/hedhog/frontend/app/enterprise/_components/enterprise-classes-tab.tsx.ejs +79 -20
  74. package/hedhog/frontend/app/enterprise/_components/enterprise-company-identity-card.tsx.ejs +11 -2
  75. package/hedhog/frontend/app/enterprise/_components/enterprise-course-edit-sheet.tsx.ejs +201 -0
  76. package/hedhog/frontend/app/enterprise/_components/enterprise-courses-tab.tsx.ejs +55 -24
  77. package/hedhog/frontend/app/enterprise/_components/enterprise-detail-sheet.tsx.ejs +430 -296
  78. package/hedhog/frontend/app/enterprise/_components/enterprise-mocks.ts.ejs +277 -0
  79. package/hedhog/frontend/app/enterprise/_components/enterprise-overview-analytics.tsx.ejs +205 -0
  80. package/hedhog/frontend/app/enterprise/_components/enterprise-person-edit-sheet.tsx.ejs +97 -0
  81. package/hedhog/frontend/app/enterprise/_components/enterprise-sheet.tsx.ejs +82 -57
  82. package/hedhog/frontend/app/enterprise/_components/enterprise-student-create-sheet.tsx.ejs +4 -0
  83. package/hedhog/frontend/app/enterprise/_components/enterprise-students-tab.tsx.ejs +60 -22
  84. package/hedhog/frontend/app/enterprise/_components/enterprise-types.ts.ejs +54 -0
  85. package/hedhog/frontend/app/enterprise/_components/enterprise-user-create-sheet.tsx.ejs +211 -0
  86. package/hedhog/frontend/app/enterprise/page.tsx.ejs +39 -7
  87. package/hedhog/frontend/app/exams/[id]/questions/page.tsx.ejs +6 -1
  88. package/hedhog/frontend/app/exams/page.tsx.ejs +6 -1
  89. package/hedhog/frontend/app/instructor-skills/page.tsx.ejs +51 -104
  90. package/hedhog/frontend/app/instructors/_components/instructor-form-sheet.tsx.ejs +625 -366
  91. package/hedhog/frontend/app/instructors/page.tsx.ejs +6 -1
  92. package/hedhog/frontend/app/paths/page.tsx.ejs +9 -4
  93. package/hedhog/frontend/app/reports/evaluations/page.tsx.ejs +6 -1
  94. package/hedhog/frontend/app/training/page.tsx.ejs +9 -4
  95. package/hedhog/frontend/messages/en.json +101 -10
  96. package/hedhog/frontend/messages/pt.json +101 -10
  97. package/hedhog/table/enterprise_student_license_event.yaml +30 -0
  98. package/hedhog/table/instructor_skill.yaml +0 -11
  99. package/package.json +7 -7
  100. package/src/course/course.service.ts +12 -24
  101. package/src/enterprise/enterprise.controller.ts +5 -0
  102. package/src/enterprise/enterprise.service.ts +507 -29
  103. package/src/enterprise/training/training-admin.controller.ts +4 -0
  104. package/src/enterprise/training/training-admin.service.ts +115 -51
  105. package/src/instructor/dto/create-instructor-skill.dto.ts +0 -17
  106. package/src/instructor/dto/update-instructor-skill.dto.ts +0 -18
  107. package/src/instructor/instructor-skill.service.ts +2 -97
  108. package/src/instructor/instructor.controller.ts +16 -0
  109. package/src/instructor/instructor.service.ts +85 -10
  110. package/src/lms.module.ts +1 -0
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
 
3
- import { Avatar, AvatarFallback } from '@/components/ui/avatar';
3
+ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
4
4
  import { Badge } from '@/components/ui/badge';
5
5
  import { Button } from '@/components/ui/button';
6
6
  import type { EntityPickerValue } from '@/components/ui/entity-picker';
@@ -19,11 +19,14 @@ import {
19
19
  TableHeader,
20
20
  TableRow,
21
21
  } from '@/components/ui/table';
22
+ import { usePersistedPageSize } from '@/hooks/use-persisted-page-size';
23
+ import { getPhotoUrl } from '@/lib/get-photo-url';
22
24
  import { useApp, useQuery } from '@hed-hog/next-app-provider';
23
- import { Trash2 } from 'lucide-react';
25
+ import { Pencil, Trash2 } from 'lucide-react';
24
26
  import { useTranslations } from 'next-intl';
25
27
  import { useCallback, useState } from 'react';
26
28
  import { toast } from 'sonner';
29
+ import { UserSheet } from '../../../core/users/user-sheet';
27
30
  import { EnterpriseAdminCreateSheet } from './enterprise-admin-create-sheet';
28
31
  import {
29
32
  USER_ROLE_LABEL,
@@ -44,8 +47,6 @@ const ADMIN_ROLES: EnterpriseUserRole[] = [
44
47
  'viewer',
45
48
  ];
46
49
 
47
- const ADMINS_PAGE_SIZE_DEFAULT = 10;
48
-
49
50
  export function AdministratorsTab({
50
51
  enterpriseId,
51
52
  onMutate,
@@ -57,13 +58,18 @@ export function AdministratorsTab({
57
58
  const { request } = useApp();
58
59
  const [search, setSearch] = useState('');
59
60
  const [page, setPage] = useState(1);
60
- const [pageSize, setPageSize] = useState(ADMINS_PAGE_SIZE_DEFAULT);
61
+ const [pageSize, setPageSize] = usePersistedPageSize({
62
+ storageKey: 'pagination:global:pageSize',
63
+ defaultValue: 6,
64
+ allowedValues: [6, 12, 24, 48],
65
+ });
61
66
  const [pickerValue, setPickerValue] = useState<EntityPickerValue>(null);
62
67
  const [selectedUser, setSelectedUser] =
63
68
  useState<SystemUserPickerOption | null>(null);
64
69
  const [roleForLink, setRoleForLink] =
65
70
  useState<EnterpriseUserRole>('enterprise_admin');
66
71
  const [createSheetOpen, setCreateSheetOpen] = useState(false);
72
+ const [editingUserId, setEditingUserId] = useState<number | null>(null);
67
73
  const [savingRoleUserId, setSavingRoleUserId] = useState<number | null>(null);
68
74
  const [removingUserId, setRemovingUserId] = useState<number | null>(null);
69
75
 
@@ -257,6 +263,7 @@ export function AdministratorsTab({
257
263
  <Avatar
258
264
  className={`size-7 shrink-0 rounded-full ${getAvatarColor(u.name).bg}`}
259
265
  >
266
+ <AvatarImage src={getPhotoUrl(u.photoId)} />
260
267
  <AvatarFallback
261
268
  className={`text-xs font-semibold ${getAvatarColor(u.name).bg} ${getAvatarColor(u.name).text}`}
262
269
  >
@@ -295,6 +302,14 @@ export function AdministratorsTab({
295
302
  </Badge>
296
303
  </TableCell>
297
304
  <TableCell className="text-right">
305
+ <Button
306
+ variant="ghost"
307
+ size="icon"
308
+ className="h-7 w-7"
309
+ onClick={() => setEditingUserId(u.userId)}
310
+ >
311
+ <Pencil className="size-4" />
312
+ </Button>
298
313
  <Button
299
314
  variant="ghost"
300
315
  size="icon"
@@ -329,6 +344,17 @@ export function AdministratorsTab({
329
344
  onMutate?.();
330
345
  }}
331
346
  />
347
+ <UserSheet
348
+ open={editingUserId !== null}
349
+ onOpenChange={(nextOpen) => {
350
+ if (!nextOpen) setEditingUserId(null);
351
+ }}
352
+ userId={editingUserId}
353
+ onSuccess={() => {
354
+ void refetch();
355
+ onMutate?.();
356
+ }}
357
+ />
332
358
  </>
333
359
  );
334
360
  }
@@ -1,6 +1,7 @@
1
1
  'use client';
2
2
 
3
- import { Avatar, AvatarFallback } from '@/components/ui/avatar';
3
+ import { CourseAvatar } from '@/app/(app)/(libraries)/lms/_components/course-avatar';
4
+ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
4
5
  import { Badge } from '@/components/ui/badge';
5
6
  import { Button } from '@/components/ui/button';
6
7
  import {
@@ -18,13 +19,20 @@ import {
18
19
  TableHeader,
19
20
  TableRow,
20
21
  } from '@/components/ui/table';
22
+ import { usePersistedPageSize } from '@/hooks/use-persisted-page-size';
21
23
  import { formatDate } from '@/lib/format-date';
22
24
  import { useApp, useQuery } from '@hed-hog/next-app-provider';
23
- import { CalendarDays, SquareArrowOutUpRight, Trash2 } from 'lucide-react';
24
- import { useRouter } from 'next/navigation';
25
+ import {
26
+ CalendarDays,
27
+ Pencil,
28
+ SquareArrowOutUpRight,
29
+ Trash2,
30
+ } from 'lucide-react';
25
31
  import { useTranslations } from 'next-intl';
32
+ import { useRouter } from 'next/navigation';
26
33
  import { useCallback, useState } from 'react';
27
34
  import { toast } from 'sonner';
35
+ import { ClassFormSheet } from '../../_components/class-form-sheet';
28
36
  import { EnterpriseClassCreateSheet } from './enterprise-class-create-sheet';
29
37
  import {
30
38
  CLASS_STATUS_LABEL,
@@ -37,7 +45,13 @@ import type {
37
45
  EnterpriseClassStatus,
38
46
  } from './enterprise-types';
39
47
 
40
- const CLASSES_PAGE_SIZE_DEFAULT = 6;
48
+ type EnterpriseClassListResponse = {
49
+ data: EnterpriseClass[];
50
+ total: number;
51
+ page: number;
52
+ pageSize: number;
53
+ lastPage: number;
54
+ };
41
55
 
42
56
  async function loadClassGroupOptions(
43
57
  request: ReturnType<typeof useApp>['request'],
@@ -101,10 +115,15 @@ export function ClassesTab({
101
115
  'all' | EnterpriseClassStatus
102
116
  >('all');
103
117
  const [page, setPage] = useState(1);
104
- const [pageSize, setPageSize] = useState(CLASSES_PAGE_SIZE_DEFAULT);
118
+ const [pageSize, setPageSize] = usePersistedPageSize({
119
+ storageKey: 'pagination:global:pageSize',
120
+ defaultValue: 6,
121
+ allowedValues: [6, 12, 24, 48],
122
+ });
105
123
  const [pickerValue, setPickerValue] = useState<string | number | null>(null);
106
124
  const [pickerClass, setPickerClass] = useState<EnterpriseClass | null>(null);
107
125
  const [createSheetOpen, setCreateSheetOpen] = useState(false);
126
+ const [editingClassId, setEditingClassId] = useState<string | null>(null);
108
127
 
109
128
  const loadPickerOptions = useCallback(
110
129
  (args: { page: number; pageSize: number; search: string }) =>
@@ -129,11 +148,11 @@ export function ClassesTab({
129
148
  statusFilter,
130
149
  ],
131
150
  queryFn: async () => {
132
- const res = await request<any>({
151
+ const res = await request<EnterpriseClassListResponse>({
133
152
  url: `/lms/enterprise/${enterpriseId}/classes?${queryParams}`,
134
153
  method: 'GET',
135
154
  });
136
- return (res as any).data ?? res;
155
+ return res.data;
137
156
  },
138
157
  });
139
158
 
@@ -267,9 +286,12 @@ export function ClassesTab({
267
286
  <TableHeader>
268
287
  <TableRow>
269
288
  <TableHead>{t('table.class')}</TableHead>
289
+ <TableHead>{t('table.instructor')}</TableHead>
270
290
  <TableHead>{t('table.status')}</TableHead>
271
291
  <TableHead>{t('table.period')}</TableHead>
272
- <TableHead className="text-center">{t('table.capacity')}</TableHead>
292
+ <TableHead className="text-center">
293
+ {t('table.capacity')}
294
+ </TableHead>
273
295
  <TableHead className="w-10" />
274
296
  </TableRow>
275
297
  </TableHeader>
@@ -286,15 +308,12 @@ export function ClassesTab({
286
308
  >
287
309
  <TableCell>
288
310
  <div className="flex items-center gap-3">
289
- <Avatar
290
- className={`size-8 rounded-lg ${getAvatarColor(cls.courseTitle ?? '').bg}`}
291
- >
292
- <AvatarFallback
293
- className={`rounded-lg text-xs font-semibold ${getAvatarColor(cls.courseTitle ?? '').bg} ${getAvatarColor(cls.courseTitle ?? '').text}`}
294
- >
295
- {getInitials(cls.courseTitle ?? cls.code ?? '')}
296
- </AvatarFallback>
297
- </Avatar>
311
+ <CourseAvatar
312
+ fileId={cls.logoFileId}
313
+ title={cls.courseTitle ?? cls.code ?? 'Curso'}
314
+ className="size-8 rounded-lg"
315
+ iconSize="size-4"
316
+ />
298
317
  <div className="min-w-0">
299
318
  <p className="font-mono text-sm">
300
319
  {cls.code ?? cls.title ?? '—'}
@@ -305,6 +324,24 @@ export function ClassesTab({
305
324
  </div>
306
325
  </div>
307
326
  </TableCell>
327
+ <TableCell>
328
+ <div className="flex items-center gap-2 min-w-0">
329
+ <Avatar className="h-7 w-7 shrink-0 border">
330
+ {cls.instructorAvatarId ? (
331
+ <AvatarImage
332
+ src={`${process.env.NEXT_PUBLIC_API_BASE_URL}/person/avatar/${cls.instructorAvatarId}`}
333
+ alt={cls.instructorName ?? 'Instructor'}
334
+ />
335
+ ) : null}
336
+ <AvatarFallback className="text-[10px]">
337
+ {getInitials(cls.instructorName ?? '') || '—'}
338
+ </AvatarFallback>
339
+ </Avatar>
340
+ <span className="truncate text-sm">
341
+ {cls.instructorName ?? '—'}
342
+ </span>
343
+ </div>
344
+ </TableCell>
308
345
  <TableCell>
309
346
  <Badge
310
347
  variant={
@@ -336,14 +373,24 @@ export function ClassesTab({
336
373
  : '—'}
337
374
  </TableCell>
338
375
  <TableCell className="text-center text-sm">
339
- {cls.capacity ?? (
340
- <span className="text-muted-foreground">—</span>
341
- )}
376
+ {cls.enrolledCount} / {cls.capacity ?? '—'}
342
377
  </TableCell>
343
378
  <TableCell
344
379
  className="flex items-center gap-1"
345
380
  onClick={(e) => e.stopPropagation()}
346
381
  >
382
+ <Button
383
+ variant="ghost"
384
+ size="icon"
385
+ className="h-8 w-8"
386
+ onClick={() =>
387
+ setEditingClassId(
388
+ String(cls.courseClassGroupId ?? cls.id)
389
+ )
390
+ }
391
+ >
392
+ <Pencil className="h-4 w-4" />
393
+ </Button>
347
394
  <Button
348
395
  variant="ghost"
349
396
  size="icon"
@@ -387,6 +434,18 @@ export function ClassesTab({
387
434
  onMutate?.();
388
435
  }}
389
436
  />
437
+ <ClassFormSheet
438
+ open={editingClassId !== null}
439
+ onOpenChange={(nextOpen) => {
440
+ if (!nextOpen) setEditingClassId(null);
441
+ }}
442
+ classId={editingClassId ?? undefined}
443
+ sheetTitle={t('actions.edit')}
444
+ onSuccess={() => {
445
+ void refetch();
446
+ onMutate?.();
447
+ }}
448
+ />
390
449
  </>
391
450
  );
392
451
  }
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
 
3
- import { Avatar, AvatarFallback } from '@/components/ui/avatar';
3
+ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
4
4
  import { Badge } from '@/components/ui/badge';
5
5
  import { Card, CardContent } from '@/components/ui/card';
6
6
  import { formatDate } from '@/lib/format-date';
@@ -22,16 +22,25 @@ export function CompanyIdentityCard({
22
22
  const { currentLocaleCode, getSettingValue } = useApp();
23
23
  const t = useTranslations('lms.EnterpriseDetailPage');
24
24
 
25
+ function getPersonAvatarUrl(avatarId?: number | null) {
26
+ return typeof avatarId === 'number' && avatarId > 0
27
+ ? `${process.env.NEXT_PUBLIC_API_BASE_URL}/person/avatar/${avatarId}`
28
+ : undefined;
29
+ }
30
+
25
31
  return (
26
32
  <Card
27
33
  className="cursor-pointer border-border/60 transition-all hover:border-primary/40 hover:shadow-md"
28
34
  onClick={onEditClick}
29
35
  title={t('identityCard.editTooltip')}
30
36
  >
31
- <CardContent className="p-5">
37
+ <CardContent className="px-5">
32
38
  <div className="flex flex-col gap-4 sm:flex-row sm:items-start">
33
39
  {/* Avatar */}
34
40
  <Avatar className={`size-16 shrink-0 rounded-2xl ${bg}`}>
41
+ <AvatarImage
42
+ src={getPersonAvatarUrl(account.crmAccount?.avatarId ?? null)}
43
+ />
35
44
  <AvatarFallback
36
45
  className={`rounded-2xl text-xl font-bold ${bg} ${text}`}
37
46
  >
@@ -0,0 +1,201 @@
1
+ 'use client';
2
+
3
+ import { useApp, useQuery } from '@hed-hog/next-app-provider';
4
+ import { zodResolver } from '@hookform/resolvers/zod';
5
+ import { useTranslations } from 'next-intl';
6
+ import { useEffect, useMemo, useState } from 'react';
7
+ import { useForm } from 'react-hook-form';
8
+ import { toast } from 'sonner';
9
+ import {
10
+ CourseFormSheet,
11
+ DEFAULT_COURSE_FORM_VALUES,
12
+ getCourseSheetSchema,
13
+ type CourseCategoryOption,
14
+ type CourseSheetFormValues,
15
+ } from '../../_components/course-form-sheet';
16
+
17
+ type ApiCategory = {
18
+ id: number;
19
+ slug: string;
20
+ name: string;
21
+ };
22
+
23
+ type ApiCategoryList = {
24
+ data: ApiCategory[];
25
+ };
26
+
27
+ type ApiCourse = {
28
+ id: number;
29
+ name: string;
30
+ slug: string;
31
+ title: string;
32
+ description?: string | null;
33
+ level?: string | null;
34
+ status?: string | null;
35
+ categories?: string[];
36
+ primaryColor?: string | null;
37
+ secondaryColor?: string | null;
38
+ logoFileId?: number | null;
39
+ };
40
+
41
+ export interface EnterpriseCourseEditSheetProps {
42
+ open: boolean;
43
+ onOpenChange: (open: boolean) => void;
44
+ courseId: number | null;
45
+ onSaved?: () => void;
46
+ }
47
+
48
+ function toPtLevel(level?: string | null): CourseSheetFormValues['nivel'] {
49
+ if (level === 'beginner') return 'iniciante';
50
+ if (level === 'intermediate') return 'intermediario';
51
+ if (level === 'advanced') return 'avancado';
52
+ return 'iniciante';
53
+ }
54
+
55
+ function toPtStatus(status?: string | null): CourseSheetFormValues['status'] {
56
+ if (status === 'published' || status === 'active') return 'ativo';
57
+ if (status === 'draft') return 'rascunho';
58
+ if (status === 'archived') return 'arquivado';
59
+ return 'rascunho';
60
+ }
61
+
62
+ function toApiLevel(level: CourseSheetFormValues['nivel']) {
63
+ if (level === 'iniciante') return 'beginner';
64
+ if (level === 'intermediario') return 'intermediate';
65
+ return 'advanced';
66
+ }
67
+
68
+ function toApiStatus(status: CourseSheetFormValues['status']) {
69
+ if (status === 'ativo') return 'published';
70
+ if (status === 'rascunho') return 'draft';
71
+ return 'archived';
72
+ }
73
+
74
+ function getContrastColor(hex: string) {
75
+ const cleaned = hex.replace('#', '');
76
+ if (cleaned.length !== 6) return '#FFFFFF';
77
+
78
+ const r = parseInt(cleaned.slice(0, 2), 16);
79
+ const g = parseInt(cleaned.slice(2, 4), 16);
80
+ const b = parseInt(cleaned.slice(4, 6), 16);
81
+
82
+ const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
83
+ return luminance > 0.6 ? '#111827' : '#FFFFFF';
84
+ }
85
+
86
+ export function EnterpriseCourseEditSheet({
87
+ open,
88
+ onOpenChange,
89
+ courseId,
90
+ onSaved,
91
+ }: EnterpriseCourseEditSheetProps) {
92
+ const t = useTranslations('lms.CoursesPage');
93
+ const { request } = useApp();
94
+ const [saving, setSaving] = useState(false);
95
+
96
+ const form = useForm<CourseSheetFormValues>({
97
+ resolver: zodResolver(getCourseSheetSchema(t)),
98
+ defaultValues: DEFAULT_COURSE_FORM_VALUES,
99
+ });
100
+
101
+ const { data: categoriesData } = useQuery<ApiCategoryList>({
102
+ queryKey: ['enterprise-course-edit-categories'],
103
+ queryFn: async () => {
104
+ const response = await request<ApiCategoryList>({
105
+ url: '/category?page=1&pageSize=500&status=all',
106
+ method: 'GET',
107
+ });
108
+
109
+ return response.data;
110
+ },
111
+ placeholderData: (previous) => previous,
112
+ });
113
+
114
+ useEffect(() => {
115
+ if (!open || typeof courseId !== 'number') return;
116
+
117
+ request<ApiCourse>({
118
+ url: `/lms/courses/${courseId}`,
119
+ method: 'GET',
120
+ })
121
+ .then((response) => {
122
+ const course = response.data;
123
+ form.reset({
124
+ nomeInterno: course.name ?? '',
125
+ slug: course.slug ?? '',
126
+ tituloComercial: course.title ?? '',
127
+ descricao: course.description ?? '',
128
+ nivel: toPtLevel(course.level),
129
+ status: toPtStatus(course.status),
130
+ categorias: course.categories ?? [],
131
+ primaryColor: course.primaryColor ?? '#1D4ED8',
132
+ secondaryColor: course.secondaryColor ?? '#111827',
133
+ logoFileId: course.logoFileId ?? null,
134
+ });
135
+ })
136
+ .catch(() => {
137
+ toast.error(t('toasts.courseUpdateError'));
138
+ onOpenChange(false);
139
+ });
140
+ }, [courseId, form, onOpenChange, open, request, t]);
141
+
142
+ const categoryOptions = useMemo<CourseCategoryOption[]>(
143
+ () =>
144
+ (categoriesData?.data ?? [])
145
+ .filter((category) => !!category.slug)
146
+ .map((category) => ({
147
+ value: category.slug,
148
+ label: category.name || category.slug,
149
+ }))
150
+ .sort((a, b) => a.label.localeCompare(b.label)),
151
+ [categoriesData]
152
+ );
153
+
154
+ async function onSubmit(values: CourseSheetFormValues) {
155
+ if (typeof courseId !== 'number') return;
156
+
157
+ setSaving(true);
158
+ try {
159
+ await request({
160
+ url: `/lms/courses/${courseId}`,
161
+ method: 'PATCH',
162
+ data: {
163
+ name: values.nomeInterno.trim(),
164
+ slug: values.slug.trim().toLowerCase(),
165
+ title: values.tituloComercial,
166
+ description: values.descricao,
167
+ level: toApiLevel(values.nivel),
168
+ status: toApiStatus(values.status),
169
+ categorySlugs: values.categorias,
170
+ primaryColor: values.primaryColor,
171
+ primaryContrastColor: getContrastColor(values.primaryColor),
172
+ secondaryColor: values.secondaryColor,
173
+ secondaryContrastColor: getContrastColor(values.secondaryColor),
174
+ logoFileId: values.logoFileId ?? null,
175
+ },
176
+ });
177
+
178
+ toast.success(t('toasts.courseUpdated'));
179
+ onSaved?.();
180
+ onOpenChange(false);
181
+ } catch {
182
+ toast.error(t('toasts.courseUpdateError'));
183
+ } finally {
184
+ setSaving(false);
185
+ }
186
+ }
187
+
188
+ return (
189
+ <CourseFormSheet
190
+ open={open}
191
+ onOpenChange={onOpenChange}
192
+ editing={true}
193
+ saving={saving}
194
+ form={form}
195
+ onSubmit={onSubmit}
196
+ categories={categoryOptions}
197
+ initialLogoFileId={form.watch('logoFileId') ?? null}
198
+ t={t}
199
+ />
200
+ );
201
+ }
@@ -1,5 +1,6 @@
1
1
  'use client';
2
2
 
3
+ import { CourseAvatar } from '@/app/(app)/(libraries)/lms/_components/course-avatar';
3
4
  import { Avatar, AvatarFallback } from '@/components/ui/avatar';
4
5
  import { Badge } from '@/components/ui/badge';
5
6
  import { Button } from '@/components/ui/button';
@@ -18,14 +19,16 @@ import {
18
19
  TableHeader,
19
20
  TableRow,
20
21
  } from '@/components/ui/table';
22
+ import { usePersistedPageSize } from '@/hooks/use-persisted-page-size';
21
23
  import { formatDate } from '@/lib/format-date';
22
24
  import { useApp, useQuery } from '@hed-hog/next-app-provider';
23
- import { BookOpen, SquareArrowOutUpRight, Trash2 } from 'lucide-react';
24
- import { useRouter } from 'next/navigation';
25
+ import { BookOpen, Pencil, SquareArrowOutUpRight, Trash2 } from 'lucide-react';
25
26
  import { useTranslations } from 'next-intl';
27
+ import { useRouter } from 'next/navigation';
26
28
  import { useCallback, useState } from 'react';
27
29
  import { toast } from 'sonner';
28
30
  import { EnterpriseCourseCreateSheet } from './enterprise-course-create-sheet';
31
+ import { EnterpriseCourseEditSheet } from './enterprise-course-edit-sheet';
29
32
  import {
30
33
  COURSE_STATUS_LABEL,
31
34
  COURSE_STATUS_VARIANT,
@@ -37,7 +40,13 @@ import type {
37
40
  EnterpriseCourseStatus,
38
41
  } from './enterprise-types';
39
42
 
40
- const COURSES_PAGE_SIZE_DEFAULT = 6;
43
+ type EnterpriseCourseListResponse = {
44
+ data: EnterpriseCourse[];
45
+ total: number;
46
+ page: number;
47
+ pageSize: number;
48
+ lastPage: number;
49
+ };
41
50
 
42
51
  async function loadCourseOptions(
43
52
  request: ReturnType<typeof useApp>['request'],
@@ -53,6 +62,7 @@ async function loadCourseOptions(
53
62
  id: number;
54
63
  title: string;
55
64
  slug: string | null;
65
+ logoFileId?: number | null;
56
66
  status: string;
57
67
  level: string | null;
58
68
  offering_type: string | null;
@@ -70,6 +80,7 @@ async function loadCourseOptions(
70
80
  courseId: c.id,
71
81
  title: c.title ?? '',
72
82
  slug: c.slug ?? null,
83
+ logoFileId: c.logoFileId ?? null,
73
84
  status: c.status ?? 'draft',
74
85
  level: c.level ?? null,
75
86
  modality: c.offering_type ?? null,
@@ -97,12 +108,17 @@ export function CoursesTab({
97
108
  'all' | EnterpriseCourseStatus
98
109
  >('all');
99
110
  const [page, setPage] = useState(1);
100
- const [pageSize, setPageSize] = useState(COURSES_PAGE_SIZE_DEFAULT);
111
+ const [pageSize, setPageSize] = usePersistedPageSize({
112
+ storageKey: 'pagination:global:pageSize',
113
+ defaultValue: 6,
114
+ allowedValues: [6, 12, 24, 48],
115
+ });
101
116
  const [pickerValue, setPickerValue] = useState<string | number | null>(null);
102
117
  const [pickerCourse, setPickerCourse] = useState<EnterpriseCourse | null>(
103
118
  null
104
119
  );
105
120
  const [createSheetOpen, setCreateSheetOpen] = useState(false);
121
+ const [editingCourseId, setEditingCourseId] = useState<number | null>(null);
106
122
 
107
123
  const loadPickerOptions = useCallback(
108
124
  (args: { page: number; pageSize: number; search: string }) =>
@@ -127,11 +143,11 @@ export function CoursesTab({
127
143
  statusFilter,
128
144
  ],
129
145
  queryFn: async () => {
130
- const res = await request<any>({
146
+ const res = await request<EnterpriseCourseListResponse>({
131
147
  url: `/lms/enterprise/${enterpriseId}/courses?${queryParams}`,
132
148
  method: 'GET',
133
149
  });
134
- return (res as any).data ?? res;
150
+ return res.data;
135
151
  },
136
152
  });
137
153
 
@@ -210,15 +226,12 @@ export function CoursesTab({
210
226
  getPickerOptionDescription={(c) => c.modality ?? undefined}
211
227
  renderPickerOption={({ option: c }) => (
212
228
  <div className="flex items-center gap-2 min-w-0">
213
- <Avatar
214
- className={`size-7 shrink-0 rounded-lg ${getAvatarColor(c.title).bg}`}
215
- >
216
- <AvatarFallback
217
- className={`rounded-lg text-xs font-semibold ${getAvatarColor(c.title).bg} ${getAvatarColor(c.title).text}`}
218
- >
219
- {getInitials(c.title)}
220
- </AvatarFallback>
221
- </Avatar>
229
+ <CourseAvatar
230
+ fileId={c.logoFileId}
231
+ title={c.title}
232
+ className="size-7 shrink-0 rounded-lg"
233
+ iconSize="size-3.5"
234
+ />
222
235
  <div className="min-w-0">
223
236
  <div className="truncate font-medium">{c.title}</div>
224
237
  {c.modality && (
@@ -278,15 +291,12 @@ export function CoursesTab({
278
291
  >
279
292
  <TableCell>
280
293
  <div className="flex items-center gap-3">
281
- <Avatar
282
- className={`size-8 rounded-lg ${getAvatarColor(course.title).bg}`}
283
- >
284
- <AvatarFallback
285
- className={`rounded-lg text-xs font-semibold ${getAvatarColor(course.title).bg} ${getAvatarColor(course.title).text}`}
286
- >
287
- {getInitials(course.title)}
288
- </AvatarFallback>
289
- </Avatar>
294
+ <CourseAvatar
295
+ fileId={course.logoFileId}
296
+ title={course.title}
297
+ className="size-8 rounded-lg"
298
+ iconSize="size-4"
299
+ />
290
300
  <p className="font-medium">{course.title}</p>
291
301
  </div>
292
302
  </TableCell>
@@ -319,6 +329,16 @@ export function CoursesTab({
319
329
  className="flex items-center gap-1"
320
330
  onClick={(e) => e.stopPropagation()}
321
331
  >
332
+ <Button
333
+ variant="ghost"
334
+ size="icon"
335
+ className="h-8 w-8"
336
+ onClick={() =>
337
+ setEditingCourseId(course.courseId ?? course.id)
338
+ }
339
+ >
340
+ <Pencil className="h-4 w-4" />
341
+ </Button>
322
342
  <Button
323
343
  variant="ghost"
324
344
  size="icon"
@@ -360,6 +380,17 @@ export function CoursesTab({
360
380
  onMutate?.();
361
381
  }}
362
382
  />
383
+ <EnterpriseCourseEditSheet
384
+ open={editingCourseId !== null}
385
+ onOpenChange={(nextOpen) => {
386
+ if (!nextOpen) setEditingCourseId(null);
387
+ }}
388
+ courseId={editingCourseId}
389
+ onSaved={() => {
390
+ void refetch();
391
+ onMutate?.();
392
+ }}
393
+ />
363
394
  </>
364
395
  );
365
396
  }