@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.
- package/dist/class-group/class-group.controller.d.ts +1 -0
- package/dist/class-group/class-group.controller.d.ts.map +1 -1
- package/dist/class-group/class-group.service.d.ts +1 -0
- package/dist/class-group/class-group.service.d.ts.map +1 -1
- package/dist/course/course-structure.controller.d.ts +4 -2
- package/dist/course/course-structure.controller.d.ts.map +1 -1
- package/dist/course/course-structure.controller.js +6 -3
- package/dist/course/course-structure.controller.js.map +1 -1
- package/dist/course/course-video-agent-pipeline.service.d.ts +70 -0
- package/dist/course/course-video-agent-pipeline.service.d.ts.map +1 -0
- package/dist/course/course-video-agent-pipeline.service.js +398 -0
- package/dist/course/course-video-agent-pipeline.service.js.map +1 -0
- package/dist/course/course-video-hls.service.d.ts +14 -0
- package/dist/course/course-video-hls.service.d.ts.map +1 -1
- package/dist/course/course-video-hls.service.js +25 -8
- package/dist/course/course-video-hls.service.js.map +1 -1
- package/dist/course/course.controller.d.ts +2 -0
- package/dist/course/course.controller.d.ts.map +1 -1
- package/dist/course/course.module.d.ts.map +1 -1
- package/dist/course/course.module.js +5 -0
- package/dist/course/course.module.js.map +1 -1
- package/dist/course/course.service.d.ts +2 -0
- package/dist/course/course.service.d.ts.map +1 -1
- package/dist/course/course.service.js +36 -2
- package/dist/course/course.service.js.map +1 -1
- package/dist/course/ffmpeg.util.d.ts +10 -0
- package/dist/course/ffmpeg.util.d.ts.map +1 -0
- package/dist/course/ffmpeg.util.js +79 -0
- package/dist/course/ffmpeg.util.js.map +1 -0
- package/dist/course/lms-bulk-upload-automation.service.d.ts +3 -1
- package/dist/course/lms-bulk-upload-automation.service.d.ts.map +1 -1
- package/dist/course/lms-bulk-upload-automation.service.js +7 -3
- package/dist/course/lms-bulk-upload-automation.service.js.map +1 -1
- package/dist/enterprise/training/training-admin.controller.d.ts +2 -0
- package/dist/enterprise/training/training-admin.controller.d.ts.map +1 -1
- package/dist/enterprise/training/training-admin.service.d.ts +2 -0
- package/dist/enterprise/training/training-admin.service.d.ts.map +1 -1
- package/dist/lms.module.d.ts.map +1 -1
- package/dist/lms.module.js +10 -0
- package/dist/lms.module.js.map +1 -1
- package/dist/platforma/dto/heartbeat.dto.d.ts +9 -0
- package/dist/platforma/dto/heartbeat.dto.d.ts.map +1 -0
- package/dist/platforma/dto/heartbeat.dto.js +50 -0
- package/dist/platforma/dto/heartbeat.dto.js.map +1 -0
- package/dist/platforma/handlers/emit-certificate.handler.d.ts +27 -0
- package/dist/platforma/handlers/emit-certificate.handler.d.ts.map +1 -0
- package/dist/platforma/handlers/emit-certificate.handler.js +117 -0
- package/dist/platforma/handlers/emit-certificate.handler.js.map +1 -0
- package/dist/platforma/handlers/lesson-heartbeat.handler.d.ts +31 -0
- package/dist/platforma/handlers/lesson-heartbeat.handler.d.ts.map +1 -0
- package/dist/platforma/handlers/lesson-heartbeat.handler.js +281 -0
- package/dist/platforma/handlers/lesson-heartbeat.handler.js.map +1 -0
- package/dist/platforma/platforma-heartbeat.service.d.ts +10 -0
- package/dist/platforma/platforma-heartbeat.service.d.ts.map +1 -0
- package/dist/platforma/platforma-heartbeat.service.js +50 -0
- package/dist/platforma/platforma-heartbeat.service.js.map +1 -0
- package/dist/platforma/platforma-performance.service.d.ts +121 -0
- package/dist/platforma/platforma-performance.service.d.ts.map +1 -0
- package/dist/platforma/platforma-performance.service.js +500 -0
- package/dist/platforma/platforma-performance.service.js.map +1 -0
- package/dist/platforma/platforma-search.service.d.ts +21 -0
- package/dist/platforma/platforma-search.service.d.ts.map +1 -0
- package/dist/platforma/platforma-search.service.js +64 -0
- package/dist/platforma/platforma-search.service.js.map +1 -0
- package/dist/platforma/platforma.controller.d.ts +115 -1
- package/dist/platforma/platforma.controller.d.ts.map +1 -1
- package/dist/platforma/platforma.controller.js +50 -2
- package/dist/platforma/platforma.controller.js.map +1 -1
- package/dist/realtime/lms-realtime.controller.d.ts +2 -0
- package/dist/realtime/lms-realtime.controller.d.ts.map +1 -1
- package/dist/realtime/lms-realtime.controller.js +31 -0
- package/dist/realtime/lms-realtime.controller.js.map +1 -1
- package/dist/realtime/lms-realtime.service.d.ts +1 -1
- package/dist/realtime/lms-realtime.service.d.ts.map +1 -1
- package/dist/realtime/lms-realtime.service.js.map +1 -1
- package/hedhog/frontend/app/certificates/models/page.tsx.ejs +182 -29
- package/hedhog/frontend/app/classes/_components/classes-calendar-view.tsx.ejs +277 -0
- package/hedhog/frontend/app/classes/page.tsx.ejs +127 -20
- package/hedhog/frontend/app/courses/[id]/structure/_components/course-overview-tab.tsx.ejs +141 -30
- package/hedhog/frontend/app/courses/[id]/structure/_components/course-xp-overview-tab.tsx.ejs +13 -13
- package/hedhog/frontend/app/courses/[id]/structure/_components/detail-lesson-xp-tab.tsx.ejs +11 -23
- package/hedhog/frontend/app/courses/[id]/structure/_components/xp-premium-pills.tsx.ejs +1 -8
- package/hedhog/frontend/app/courses/[id]/structure/_data/use-course-content-overview.ts.ejs +2 -0
- package/hedhog/frontend/app/courses/page.tsx.ejs +40 -9
- package/hedhog/frontend/app/enterprise/[id]/page.tsx.ejs +6 -0
- package/hedhog/frontend/app/enterprise/_components/enterprise-classes-calendar-tab.tsx.ejs +264 -0
- package/hedhog/frontend/app/enterprise/page.tsx.ejs +104 -47
- package/hedhog/frontend/app/exams/page.tsx.ejs +38 -4
- package/hedhog/frontend/app/instructors/page.tsx.ejs +87 -46
- package/hedhog/frontend/app/paths/page.tsx.ejs +38 -4
- package/hedhog/frontend/app/training/page.tsx.ejs +38 -4
- package/hedhog/frontend/messages/en.json +18 -0
- package/hedhog/frontend/messages/pt.json +21 -1
- package/hedhog/table/course_enrollment.yaml +3 -0
- package/hedhog/table/lesson_view_event.yaml +66 -0
- package/package.json +9 -8
- package/src/course/course-structure.controller.ts +3 -1
- package/src/course/course-video-agent-pipeline.service.ts +471 -0
- package/src/course/course-video-hls.service.ts +30 -10
- package/src/course/course.module.ts +5 -0
- package/src/course/course.service.ts +46 -1
- package/src/course/ffmpeg.util.ts +65 -0
- package/src/course/lms-bulk-upload-automation.service.ts +4 -1
- package/src/lms.module.ts +10 -0
- package/src/platforma/dto/heartbeat.dto.ts +30 -0
- package/src/platforma/handlers/emit-certificate.handler.ts +117 -0
- package/src/platforma/handlers/lesson-heartbeat.handler.ts +343 -0
- package/src/platforma/platforma-heartbeat.service.ts +33 -0
- package/src/platforma/platforma-performance.service.ts +606 -0
- package/src/platforma/platforma-search.service.ts +48 -0
- package/src/platforma/platforma.controller.ts +42 -0
- package/src/realtime/lms-realtime.controller.ts +27 -1
- 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
|
|
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:
|
|
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
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
<
|
|
579
|
-
<
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
<
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
<
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
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={
|
|
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
|
+
}
|