@hed-hog/lms 0.0.365 → 0.0.366

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 (113) hide show
  1. package/dist/class-group/class-group.controller.d.ts +1 -0
  2. package/dist/class-group/class-group.controller.d.ts.map +1 -1
  3. package/dist/class-group/class-group.service.d.ts +1 -0
  4. package/dist/class-group/class-group.service.d.ts.map +1 -1
  5. package/dist/course/course-structure.controller.d.ts +4 -2
  6. package/dist/course/course-structure.controller.d.ts.map +1 -1
  7. package/dist/course/course-structure.controller.js +6 -3
  8. package/dist/course/course-structure.controller.js.map +1 -1
  9. package/dist/course/course-video-agent-pipeline.service.d.ts +70 -0
  10. package/dist/course/course-video-agent-pipeline.service.d.ts.map +1 -0
  11. package/dist/course/course-video-agent-pipeline.service.js +398 -0
  12. package/dist/course/course-video-agent-pipeline.service.js.map +1 -0
  13. package/dist/course/course-video-hls.service.d.ts +14 -0
  14. package/dist/course/course-video-hls.service.d.ts.map +1 -1
  15. package/dist/course/course-video-hls.service.js +25 -8
  16. package/dist/course/course-video-hls.service.js.map +1 -1
  17. package/dist/course/course.controller.d.ts +2 -0
  18. package/dist/course/course.controller.d.ts.map +1 -1
  19. package/dist/course/course.module.d.ts.map +1 -1
  20. package/dist/course/course.module.js +5 -0
  21. package/dist/course/course.module.js.map +1 -1
  22. package/dist/course/course.service.d.ts +2 -0
  23. package/dist/course/course.service.d.ts.map +1 -1
  24. package/dist/course/course.service.js +36 -2
  25. package/dist/course/course.service.js.map +1 -1
  26. package/dist/course/ffmpeg.util.d.ts +10 -0
  27. package/dist/course/ffmpeg.util.d.ts.map +1 -0
  28. package/dist/course/ffmpeg.util.js +79 -0
  29. package/dist/course/ffmpeg.util.js.map +1 -0
  30. package/dist/course/lms-bulk-upload-automation.service.d.ts +3 -1
  31. package/dist/course/lms-bulk-upload-automation.service.d.ts.map +1 -1
  32. package/dist/course/lms-bulk-upload-automation.service.js +7 -3
  33. package/dist/course/lms-bulk-upload-automation.service.js.map +1 -1
  34. package/dist/enterprise/training/training-admin.controller.d.ts +2 -0
  35. package/dist/enterprise/training/training-admin.controller.d.ts.map +1 -1
  36. package/dist/enterprise/training/training-admin.service.d.ts +2 -0
  37. package/dist/enterprise/training/training-admin.service.d.ts.map +1 -1
  38. package/dist/lms.module.d.ts.map +1 -1
  39. package/dist/lms.module.js +10 -0
  40. package/dist/lms.module.js.map +1 -1
  41. package/dist/platforma/dto/heartbeat.dto.d.ts +9 -0
  42. package/dist/platforma/dto/heartbeat.dto.d.ts.map +1 -0
  43. package/dist/platforma/dto/heartbeat.dto.js +50 -0
  44. package/dist/platforma/dto/heartbeat.dto.js.map +1 -0
  45. package/dist/platforma/handlers/emit-certificate.handler.d.ts +27 -0
  46. package/dist/platforma/handlers/emit-certificate.handler.d.ts.map +1 -0
  47. package/dist/platforma/handlers/emit-certificate.handler.js +117 -0
  48. package/dist/platforma/handlers/emit-certificate.handler.js.map +1 -0
  49. package/dist/platforma/handlers/lesson-heartbeat.handler.d.ts +31 -0
  50. package/dist/platforma/handlers/lesson-heartbeat.handler.d.ts.map +1 -0
  51. package/dist/platforma/handlers/lesson-heartbeat.handler.js +281 -0
  52. package/dist/platforma/handlers/lesson-heartbeat.handler.js.map +1 -0
  53. package/dist/platforma/platforma-heartbeat.service.d.ts +10 -0
  54. package/dist/platforma/platforma-heartbeat.service.d.ts.map +1 -0
  55. package/dist/platforma/platforma-heartbeat.service.js +50 -0
  56. package/dist/platforma/platforma-heartbeat.service.js.map +1 -0
  57. package/dist/platforma/platforma-performance.service.d.ts +121 -0
  58. package/dist/platforma/platforma-performance.service.d.ts.map +1 -0
  59. package/dist/platforma/platforma-performance.service.js +500 -0
  60. package/dist/platforma/platforma-performance.service.js.map +1 -0
  61. package/dist/platforma/platforma-search.service.d.ts +21 -0
  62. package/dist/platforma/platforma-search.service.d.ts.map +1 -0
  63. package/dist/platforma/platforma-search.service.js +64 -0
  64. package/dist/platforma/platforma-search.service.js.map +1 -0
  65. package/dist/platforma/platforma.controller.d.ts +115 -1
  66. package/dist/platforma/platforma.controller.d.ts.map +1 -1
  67. package/dist/platforma/platforma.controller.js +50 -2
  68. package/dist/platforma/platforma.controller.js.map +1 -1
  69. package/dist/realtime/lms-realtime.controller.d.ts +2 -0
  70. package/dist/realtime/lms-realtime.controller.d.ts.map +1 -1
  71. package/dist/realtime/lms-realtime.controller.js +31 -0
  72. package/dist/realtime/lms-realtime.controller.js.map +1 -1
  73. package/dist/realtime/lms-realtime.service.d.ts +1 -1
  74. package/dist/realtime/lms-realtime.service.d.ts.map +1 -1
  75. package/dist/realtime/lms-realtime.service.js.map +1 -1
  76. package/hedhog/frontend/app/certificates/models/page.tsx.ejs +182 -29
  77. package/hedhog/frontend/app/classes/_components/classes-calendar-view.tsx.ejs +277 -0
  78. package/hedhog/frontend/app/classes/page.tsx.ejs +127 -20
  79. package/hedhog/frontend/app/courses/[id]/structure/_components/course-overview-tab.tsx.ejs +141 -30
  80. package/hedhog/frontend/app/courses/[id]/structure/_components/course-xp-overview-tab.tsx.ejs +13 -13
  81. package/hedhog/frontend/app/courses/[id]/structure/_components/detail-lesson-xp-tab.tsx.ejs +11 -23
  82. package/hedhog/frontend/app/courses/[id]/structure/_components/xp-premium-pills.tsx.ejs +1 -8
  83. package/hedhog/frontend/app/courses/[id]/structure/_data/use-course-content-overview.ts.ejs +2 -0
  84. package/hedhog/frontend/app/courses/page.tsx.ejs +40 -9
  85. package/hedhog/frontend/app/enterprise/[id]/page.tsx.ejs +6 -0
  86. package/hedhog/frontend/app/enterprise/_components/enterprise-classes-calendar-tab.tsx.ejs +264 -0
  87. package/hedhog/frontend/app/enterprise/page.tsx.ejs +104 -47
  88. package/hedhog/frontend/app/exams/page.tsx.ejs +38 -4
  89. package/hedhog/frontend/app/instructors/page.tsx.ejs +87 -46
  90. package/hedhog/frontend/app/paths/page.tsx.ejs +38 -4
  91. package/hedhog/frontend/app/training/page.tsx.ejs +38 -4
  92. package/hedhog/frontend/messages/en.json +18 -0
  93. package/hedhog/frontend/messages/pt.json +21 -1
  94. package/hedhog/table/course_enrollment.yaml +3 -0
  95. package/hedhog/table/lesson_view_event.yaml +66 -0
  96. package/package.json +9 -8
  97. package/src/course/course-structure.controller.ts +3 -1
  98. package/src/course/course-video-agent-pipeline.service.ts +471 -0
  99. package/src/course/course-video-hls.service.ts +30 -10
  100. package/src/course/course.module.ts +5 -0
  101. package/src/course/course.service.ts +46 -1
  102. package/src/course/ffmpeg.util.ts +65 -0
  103. package/src/course/lms-bulk-upload-automation.service.ts +4 -1
  104. package/src/lms.module.ts +10 -0
  105. package/src/platforma/dto/heartbeat.dto.ts +30 -0
  106. package/src/platforma/handlers/emit-certificate.handler.ts +117 -0
  107. package/src/platforma/handlers/lesson-heartbeat.handler.ts +343 -0
  108. package/src/platforma/platforma-heartbeat.service.ts +33 -0
  109. package/src/platforma/platforma-performance.service.ts +606 -0
  110. package/src/platforma/platforma-search.service.ts +48 -0
  111. package/src/platforma/platforma.controller.ts +42 -0
  112. package/src/realtime/lms-realtime.controller.ts +27 -1
  113. package/src/realtime/lms-realtime.service.ts +2 -1
@@ -7,6 +7,7 @@ import {
7
7
  PageHeader,
8
8
  PaginationFooter,
9
9
  SearchBar,
10
+ ViewModeToggle,
10
11
  } from '@/components/entity-list';
11
12
  import { Badge } from '@/components/ui/badge';
12
13
  import { Button } from '@/components/ui/button';
@@ -38,8 +39,17 @@ import {
38
39
  SheetTitle,
39
40
  } from '@/components/ui/sheet';
40
41
  import { Skeleton } from '@/components/ui/skeleton';
42
+ import {
43
+ Table,
44
+ TableBody,
45
+ TableCell,
46
+ TableHead,
47
+ TableHeader,
48
+ TableRow,
49
+ } from '@/components/ui/table';
41
50
  import { Textarea } from '@/components/ui/textarea';
42
51
  import { usePersistedPageSize } from '@/hooks/use-persisted-page-size';
52
+ import { usePersistedViewMode } from '@/hooks/use-persisted-view-mode';
43
53
  import { useApp, useQuery } from '@hed-hog/next-app-provider';
44
54
  import { zodResolver } from '@hookform/resolvers/zod';
45
55
  import {
@@ -96,7 +106,8 @@ type UpdateCertificateTemplatePayload = {
96
106
  status: TemplateStatus;
97
107
  };
98
108
 
99
- const PAGE_SIZES = [6, 12, 24];
109
+ const DEFAULT_PAGE_SIZES = [6, 12, 24, 48, 96] as const;
110
+ type ViewMode = 'cards' | 'list';
100
111
 
101
112
  const createTemplateSchema = (t: (key: string) => string) =>
102
113
  z.object({
@@ -140,10 +151,49 @@ export default function ModelsPage() {
140
151
  const [searchQuery, setSearchQuery] = useState('');
141
152
  const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
142
153
  const [currentPage, setCurrentPage] = useState(1);
154
+ const [viewMode, setViewMode] = usePersistedViewMode<ViewMode>({
155
+ storageKey: 'lms:certificate-models:view-mode',
156
+ defaultValue: 'cards',
157
+ allowedValues: ['cards', 'list'],
158
+ });
159
+
160
+ const { data: generalSettings } = useQuery<{
161
+ data: Array<{ slug: string; value: string }>;
162
+ }>({
163
+ queryKey: ['setting-group-general'],
164
+ queryFn: async () => {
165
+ const response = await request<{
166
+ data: Array<{ slug: string; value: string }>;
167
+ }>({
168
+ url: '/setting/group/general',
169
+ method: 'GET',
170
+ });
171
+ return response.data;
172
+ },
173
+ staleTime: 5 * 60 * 1000,
174
+ });
175
+
176
+ const pageSizeOptions = useMemo(() => {
177
+ const setting = generalSettings?.data?.find(
178
+ (s) => s.slug === 'pagination-page-sizes'
179
+ );
180
+ if (!setting?.value) return DEFAULT_PAGE_SIZES;
181
+ try {
182
+ const parsed = JSON.parse(setting.value) as string[];
183
+ const sizes = parsed
184
+ .map(Number)
185
+ .filter((n) => !isNaN(n) && n > 0)
186
+ .sort((a, b) => a - b);
187
+ return sizes.length > 0 ? sizes : DEFAULT_PAGE_SIZES;
188
+ } catch {
189
+ return DEFAULT_PAGE_SIZES;
190
+ }
191
+ }, [generalSettings]);
192
+
143
193
  const [pageSize, setPageSize] = usePersistedPageSize({
144
194
  storageKey: 'pagination:global:pageSize',
145
195
  defaultValue: 12,
146
- allowedValues: PAGE_SIZES,
196
+ allowedValues: pageSizeOptions,
147
197
  });
148
198
  const [isSheetOpen, setIsSheetOpen] = useState(false);
149
199
  const [isEditSheetOpen, setIsEditSheetOpen] = useState(false);
@@ -540,6 +590,14 @@ export default function ModelsPage() {
540
590
  ],
541
591
  },
542
592
  ]}
593
+ actions={
594
+ <ViewModeToggle
595
+ viewMode={viewMode}
596
+ onViewModeChange={setViewMode}
597
+ listLabel={t('viewMode.list')}
598
+ cardsLabel={t('viewMode.cards')}
599
+ />
600
+ }
543
601
  />
544
602
 
545
603
  <div className="flex items-center justify-between gap-3">
@@ -567,31 +625,67 @@ export default function ModelsPage() {
567
625
  </div>
568
626
 
569
627
  {loading ? (
570
- <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
571
- {Array.from({ length: 6 }).map((_, index) => (
572
- <Card
573
- key={index}
574
- className="overflow-hidden border-border/70 py-0"
575
- >
576
- <div className="h-1 w-full bg-linear-to-r from-slate-300/70 via-slate-200 to-transparent" />
577
- <CardContent className="space-y-4 p-5">
578
- <div className="flex items-center gap-2">
579
- <Skeleton className="h-6 w-24 rounded-full" />
580
- <Skeleton className="h-6 w-20 rounded-full" />
581
- </div>
582
- <div>
583
- <Skeleton className="mb-2 h-5 w-3/4" />
584
- <Skeleton className="h-4 w-1/2" />
585
- </div>
586
- <div className="grid grid-cols-2 gap-3">
587
- <Skeleton className="h-14 w-full rounded-xl" />
588
- <Skeleton className="h-14 w-full rounded-xl" />
589
- </div>
590
- <Skeleton className="h-9 w-36 rounded-md" />
591
- </CardContent>
592
- </Card>
593
- ))}
594
- </div>
628
+ viewMode === 'cards' ? (
629
+ <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
630
+ {Array.from({ length: 6 }).map((_, index) => (
631
+ <Card
632
+ key={index}
633
+ className="overflow-hidden border-border/70 py-0"
634
+ >
635
+ <div className="h-1 w-full bg-linear-to-r from-slate-300/70 via-slate-200 to-transparent" />
636
+ <CardContent className="space-y-4 p-5">
637
+ <div className="flex items-center gap-2">
638
+ <Skeleton className="h-6 w-24 rounded-full" />
639
+ <Skeleton className="h-6 w-20 rounded-full" />
640
+ </div>
641
+ <div>
642
+ <Skeleton className="mb-2 h-5 w-3/4" />
643
+ <Skeleton className="h-4 w-1/2" />
644
+ </div>
645
+ <div className="grid grid-cols-2 gap-3">
646
+ <Skeleton className="h-14 w-full rounded-xl" />
647
+ <Skeleton className="h-14 w-full rounded-xl" />
648
+ </div>
649
+ <Skeleton className="h-9 w-36 rounded-md" />
650
+ </CardContent>
651
+ </Card>
652
+ ))}
653
+ </div>
654
+ ) : (
655
+ <div className="overflow-hidden rounded-xl border border-border/70">
656
+ <Table>
657
+ <TableHeader>
658
+ <TableRow>
659
+ <TableHead>{t('table.name')}</TableHead>
660
+ <TableHead>{t('table.status')}</TableHead>
661
+ <TableHead>{t('table.updatedAt')}</TableHead>
662
+ <TableHead className="w-12" />
663
+ </TableRow>
664
+ </TableHeader>
665
+ <TableBody>
666
+ {Array.from({ length: 6 }).map((_, i) => (
667
+ <TableRow key={i}>
668
+ <TableCell>
669
+ <div className="space-y-1.5">
670
+ <Skeleton className="h-4 w-48" />
671
+ <Skeleton className="h-3 w-32" />
672
+ </div>
673
+ </TableCell>
674
+ <TableCell>
675
+ <Skeleton className="h-5 w-20 rounded-full" />
676
+ </TableCell>
677
+ <TableCell>
678
+ <Skeleton className="h-4 w-24" />
679
+ </TableCell>
680
+ <TableCell>
681
+ <Skeleton className="ml-auto size-8 rounded-md" />
682
+ </TableCell>
683
+ </TableRow>
684
+ ))}
685
+ </TableBody>
686
+ </Table>
687
+ </div>
688
+ )
595
689
  ) : totalItems === 0 ? (
596
690
  <EmptyState
597
691
  icon={<Files className="size-12 text-muted-foreground/40" />}
@@ -602,7 +696,7 @@ export default function ModelsPage() {
602
696
  onAction={openCreateSheet}
603
697
  className="py-20"
604
698
  />
605
- ) : (
699
+ ) : viewMode === 'cards' ? (
606
700
  <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
607
701
  {templates.map((template) => (
608
702
  <Card
@@ -684,6 +778,65 @@ export default function ModelsPage() {
684
778
  </Card>
685
779
  ))}
686
780
  </div>
781
+ ) : (
782
+ <div className="overflow-hidden rounded-xl border border-border/70">
783
+ <Table>
784
+ <TableHeader>
785
+ <TableRow>
786
+ <TableHead>{t('table.name')}</TableHead>
787
+ <TableHead>{t('table.status')}</TableHead>
788
+ <TableHead>{t('table.updatedAt')}</TableHead>
789
+ <TableHead className="w-12" />
790
+ </TableRow>
791
+ </TableHeader>
792
+ <TableBody>
793
+ {templates.map((template) => (
794
+ <TableRow
795
+ key={template.id}
796
+ className="cursor-pointer"
797
+ onClick={() => handleCardClick(template)}
798
+ >
799
+ <TableCell>
800
+ <p className="font-semibold text-foreground">
801
+ {template.name}
802
+ </p>
803
+ <p className="font-mono text-xs text-muted-foreground">
804
+ {template.slug}
805
+ </p>
806
+ </TableCell>
807
+ <TableCell>{renderStatusBadge(template.status)}</TableCell>
808
+ <TableCell className="text-sm text-muted-foreground">
809
+ {formatDate(resolveUpdatedAt(template))}
810
+ </TableCell>
811
+ <TableCell onClick={(e) => e.stopPropagation()}>
812
+ <IconActionGroup
813
+ className="ml-auto"
814
+ actions={[
815
+ {
816
+ key: 'editor',
817
+ label: t('cards.actions.editTemplate'),
818
+ icon: <FileEdit className="size-4" />,
819
+ onClick: () =>
820
+ router.push(
821
+ `/lms/certificates/models/editor?templateId=${template.id}`
822
+ ),
823
+ },
824
+ {
825
+ key: 'delete',
826
+ label: t('cards.actions.deleteTemplate'),
827
+ icon: <Trash2 className="size-4" />,
828
+ onClick: () => onDeleteTemplate(template),
829
+ destructive: true,
830
+ disabled: deletingTemplateId === template.id,
831
+ },
832
+ ]}
833
+ />
834
+ </TableCell>
835
+ </TableRow>
836
+ ))}
837
+ </TableBody>
838
+ </Table>
839
+ </div>
687
840
  )}
688
841
 
689
842
  {!loading && totalItems > 0 ? (
@@ -696,7 +849,7 @@ export default function ModelsPage() {
696
849
  setPageSize(value);
697
850
  setCurrentPage(1);
698
851
  }}
699
- pageSizeOptions={PAGE_SIZES}
852
+ pageSizeOptions={pageSizeOptions}
700
853
  />
701
854
  ) : null}
702
855
  </div>
@@ -0,0 +1,277 @@
1
+ 'use client';
2
+
3
+ import { Badge } from '@/components/ui/badge';
4
+ import {
5
+ addDays,
6
+ addMonths,
7
+ eachDayOfInterval,
8
+ format,
9
+ isAfter,
10
+ isBefore,
11
+ isSameDay,
12
+ isSameMonth,
13
+ isToday,
14
+ parseISO,
15
+ startOfMonth,
16
+ startOfWeek,
17
+ subMonths,
18
+ } from 'date-fns';
19
+ import { ptBR } from 'date-fns/locale/pt-BR';
20
+ import { ChevronLeft, ChevronRight } from 'lucide-react';
21
+ import { useLocale } from 'next-intl';
22
+ import { useState } from 'react';
23
+ import { useRouter } from 'next/navigation';
24
+
25
+ interface Turma {
26
+ id: number;
27
+ codigo: string;
28
+ curso: string;
29
+ dataInicio: string;
30
+ dataFim: string;
31
+ status: 'aberta' | 'em_andamento' | 'concluida' | 'cancelada';
32
+ professor: string;
33
+ vagas: number;
34
+ matriculados: number;
35
+ }
36
+
37
+ const STATUS_COLORS: Record<string, string> = {
38
+ aberta:
39
+ 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300',
40
+ em_andamento:
41
+ 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300',
42
+ concluida:
43
+ 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300',
44
+ cancelada: 'bg-muted text-muted-foreground line-through',
45
+ };
46
+
47
+ const STATUS_LABELS: Record<string, string> = {
48
+ aberta: 'Aberta',
49
+ em_andamento: 'Em andamento',
50
+ concluida: 'Concluída',
51
+ cancelada: 'Cancelada',
52
+ };
53
+
54
+ const DAY_HEADERS_PT = ['Seg', 'Ter', 'Qua', 'Qui', 'Sex', 'Sáb', 'Dom'];
55
+ const DAY_HEADERS_EN = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
56
+
57
+ function parseDate(value: string | null | undefined): Date | null {
58
+ if (!value) return null;
59
+ try {
60
+ const d = parseISO(String(value).slice(0, 10));
61
+ return isNaN(d.getTime()) ? null : d;
62
+ } catch {
63
+ return null;
64
+ }
65
+ }
66
+
67
+ function isTurmaActiveOnDay(turma: Turma, day: Date): boolean {
68
+ const start = parseDate(turma.dataInicio);
69
+ const end = parseDate(turma.dataFim);
70
+ if (!start) return false;
71
+ const dayStart = new Date(day.getFullYear(), day.getMonth(), day.getDate());
72
+ const startDay = new Date(
73
+ start.getFullYear(),
74
+ start.getMonth(),
75
+ start.getDate()
76
+ );
77
+ const endDay = end
78
+ ? new Date(end.getFullYear(), end.getMonth(), end.getDate())
79
+ : startDay;
80
+ return (
81
+ (isSameDay(dayStart, startDay) || isAfter(dayStart, startDay)) &&
82
+ (isSameDay(dayStart, endDay) || isBefore(dayStart, endDay))
83
+ );
84
+ }
85
+
86
+ export function ClassesCalendarView({ turmas }: { turmas: Turma[] }) {
87
+ const router = useRouter();
88
+ const locale = useLocale();
89
+ const dateLocale = locale.startsWith('pt') ? ptBR : undefined;
90
+ const dayHeaders = locale.startsWith('pt') ? DAY_HEADERS_PT : DAY_HEADERS_EN;
91
+
92
+ const [currentDate, setCurrentDate] = useState(() => new Date());
93
+ const [selectedDay, setSelectedDay] = useState<Date | null>(null);
94
+
95
+ const monthStart = startOfMonth(currentDate);
96
+ const gridStart = startOfWeek(monthStart, { weekStartsOn: 1 });
97
+ const days = eachDayOfInterval({
98
+ start: gridStart,
99
+ end: addDays(gridStart, 41),
100
+ });
101
+
102
+ const getTurmasForDay = (day: Date) =>
103
+ turmas.filter((t) => isTurmaActiveOnDay(t, day));
104
+
105
+ const selectedDayTurmas = selectedDay ? getTurmasForDay(selectedDay) : [];
106
+
107
+ const monthLabel = format(
108
+ currentDate,
109
+ locale.startsWith('pt') ? "MMMM 'de' yyyy" : 'MMMM yyyy',
110
+ { locale: dateLocale }
111
+ );
112
+
113
+ return (
114
+ <div className="space-y-4 pb-6">
115
+ {/* Month navigation */}
116
+ <div className="flex items-center justify-between rounded-xl border bg-muted/20 px-3 py-2">
117
+ <button
118
+ type="button"
119
+ onClick={() => {
120
+ setCurrentDate((d) => subMonths(d, 1));
121
+ setSelectedDay(null);
122
+ }}
123
+ className="flex size-8 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
124
+ aria-label="Mês anterior"
125
+ >
126
+ <ChevronLeft className="size-4" />
127
+ </button>
128
+ <span className="text-sm font-semibold capitalize">{monthLabel}</span>
129
+ <button
130
+ type="button"
131
+ onClick={() => {
132
+ setCurrentDate((d) => addMonths(d, 1));
133
+ setSelectedDay(null);
134
+ }}
135
+ className="flex size-8 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
136
+ aria-label="Próximo mês"
137
+ >
138
+ <ChevronRight className="size-4" />
139
+ </button>
140
+ </div>
141
+
142
+ {/* Day headers */}
143
+ <div className="grid grid-cols-7 text-center">
144
+ {dayHeaders.map((h) => (
145
+ <div
146
+ key={h}
147
+ className="pb-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground"
148
+ >
149
+ {h}
150
+ </div>
151
+ ))}
152
+ </div>
153
+
154
+ {/* Calendar grid */}
155
+ <div className="grid grid-cols-7 gap-px overflow-hidden rounded-lg border bg-border/40">
156
+ {days.map((day) => {
157
+ const dayTurmas = getTurmasForDay(day);
158
+ const isCurrentMonth = isSameMonth(day, currentDate);
159
+ const isSelected = selectedDay ? isSameDay(day, selectedDay) : false;
160
+ const todayDay = isToday(day);
161
+
162
+ return (
163
+ <button
164
+ key={day.toISOString()}
165
+ type="button"
166
+ onClick={() => setSelectedDay(isSelected ? null : day)}
167
+ className={[
168
+ 'flex min-h-16 flex-col gap-0.5 bg-background p-1 text-left transition-colors hover:bg-muted/40',
169
+ !isCurrentMonth && 'opacity-40',
170
+ isSelected && 'ring-2 ring-inset ring-primary',
171
+ ]
172
+ .filter(Boolean)
173
+ .join(' ')}
174
+ >
175
+ <span
176
+ className={[
177
+ 'flex size-5 items-center justify-center self-end rounded-full text-[11px] font-medium',
178
+ todayDay
179
+ ? 'bg-primary text-primary-foreground'
180
+ : 'text-foreground',
181
+ ].join(' ')}
182
+ >
183
+ {format(day, 'd')}
184
+ </span>
185
+ <div className="flex min-h-0 flex-col gap-0.5 overflow-hidden">
186
+ {dayTurmas.slice(0, 3).map((turma) => (
187
+ <span
188
+ key={turma.id}
189
+ className={[
190
+ 'truncate rounded px-1 text-[9px] font-medium leading-4',
191
+ STATUS_COLORS[turma.status] ?? STATUS_COLORS.aberta,
192
+ ].join(' ')}
193
+ >
194
+ {turma.curso}
195
+ </span>
196
+ ))}
197
+ {dayTurmas.length > 3 && (
198
+ <span className="px-1 text-[9px] text-muted-foreground">
199
+ +{dayTurmas.length - 3}
200
+ </span>
201
+ )}
202
+ </div>
203
+ </button>
204
+ );
205
+ })}
206
+ </div>
207
+
208
+ {/* Selected day detail */}
209
+ {selectedDay && (
210
+ <div className="space-y-2 rounded-xl border bg-muted/20 p-3">
211
+ <p className="text-xs font-semibold text-muted-foreground">
212
+ {format(
213
+ selectedDay,
214
+ locale.startsWith('pt') ? "d 'de' MMMM" : 'MMMM d',
215
+ { locale: dateLocale }
216
+ )}
217
+ </p>
218
+ {selectedDayTurmas.length === 0 ? (
219
+ <p className="text-sm text-muted-foreground">
220
+ Nenhuma turma neste dia.
221
+ </p>
222
+ ) : (
223
+ <div className="space-y-1.5">
224
+ {selectedDayTurmas.map((turma) => (
225
+ <button
226
+ key={turma.id}
227
+ type="button"
228
+ onClick={() => router.push(`/lms/classes/${turma.id}`)}
229
+ className="flex w-full items-center gap-3 rounded-lg border bg-background px-3 py-2 text-left transition-colors hover:bg-muted/50"
230
+ >
231
+ <div className="min-w-0 flex-1">
232
+ <p className="truncate text-sm font-medium">{turma.curso}</p>
233
+ <p className="truncate text-xs text-muted-foreground">
234
+ {[turma.codigo, turma.professor !== '-' ? turma.professor : null]
235
+ .filter(Boolean)
236
+ .join(' • ')}
237
+ </p>
238
+ </div>
239
+ <div className="flex shrink-0 flex-col items-end gap-1">
240
+ <Badge
241
+ variant="outline"
242
+ className={[
243
+ 'px-1.5 py-0 text-[10px]',
244
+ STATUS_COLORS[turma.status],
245
+ ].join(' ')}
246
+ >
247
+ {STATUS_LABELS[turma.status] ?? turma.status}
248
+ </Badge>
249
+ <span className="text-[10px] text-muted-foreground">
250
+ {turma.matriculados}/{turma.vagas}
251
+ </span>
252
+ </div>
253
+ </button>
254
+ ))}
255
+ </div>
256
+ )}
257
+ </div>
258
+ )}
259
+
260
+ {/* Legend */}
261
+ <div className="flex flex-wrap gap-3">
262
+ {Object.entries(STATUS_LABELS).map(([status, label]) => (
263
+ <div key={status} className="flex items-center gap-1.5">
264
+ <span
265
+ className={[
266
+ 'rounded px-1.5 py-0.5 text-[10px] font-medium',
267
+ STATUS_COLORS[status],
268
+ ].join(' ')}
269
+ >
270
+ {label}
271
+ </span>
272
+ </div>
273
+ ))}
274
+ </div>
275
+ </div>
276
+ );
277
+ }