From 79d1c70c44e2499b85585cbae356a2c31ccb087d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Faleiros?= Date: Fri, 4 Sep 2026 16:22:09 -0300 Subject: [PATCH] feat: add managed learning paths --- components/CourseGrid.tsx | 14 +- components/LearningPathRow.tsx | 38 +++ index.css | 15 + pages/SuperAdmin.tsx | 382 +++-------------------- server/migrations/009_learning_paths.sql | 32 ++ server/src/routes/admin.ts | 130 ++++++++ server/src/routes/courses.ts | 28 ++ services/api.ts | 34 ++ services/db.ts | 22 ++ 9 files changed, 358 insertions(+), 337 deletions(-) create mode 100644 components/LearningPathRow.tsx create mode 100644 server/migrations/009_learning_paths.sql diff --git a/components/CourseGrid.tsx b/components/CourseGrid.tsx index a33c884..e4114c5 100644 --- a/components/CourseGrid.tsx +++ b/components/CourseGrid.tsx @@ -1,9 +1,10 @@ import React, { useState, useEffect, useRef, useCallback } from 'react'; import { CourseRow } from './CourseRow'; import { Section, Course } from '../types'; -import { getCourses } from '../services/db'; +import { getCourses, getLearningPaths, PublicLearningPath } from '../services/db'; import { useAuth } from '../context/AuthContext'; import { Loader2, ChevronLeft, ChevronRight } from 'lucide-react'; +import { LearningPathRow } from './LearningPathRow'; interface CourseGridProps { onCourseSelect?: (course: Course) => void; @@ -15,6 +16,7 @@ export const CourseGrid: React.FC = ({ selectedCategoryFilter }) => { const [courses, setCourses] = useState([]); + const [paths, setPaths] = useState([]); const [loading, setLoading] = useState(true); const [activeCategory, setActiveCategory] = useState('all'); const { user } = useAuth(); @@ -39,7 +41,9 @@ export const CourseGrid: React.FC = ({ useEffect(() => { const loadData = async () => { try { - setCourses(await getCourses()); + const [loadedCourses, loadedPaths] = await Promise.all([getCourses(), getLearningPaths()]); + setCourses(loadedCourses); + setPaths(loadedPaths); } finally { setLoading(false); } @@ -138,11 +142,6 @@ export const CourseGrid: React.FC = ({ variant: 'landscape', courses: courses.filter(c => (c.attachments && c.attachments.length > 0) || c.lessons.some(l => l.attachments && l.attachments.length > 0)) }, - { - title: 'Formações Recomendadas', - variant: 'portrait', - courses: courses - } ]; return ( @@ -238,6 +237,7 @@ export const CourseGrid: React.FC = ({ /> ) ))} + {activeCategory === 'all' && paths.length > 0 && } ); }; diff --git a/components/LearningPathRow.tsx b/components/LearningPathRow.tsx new file mode 100644 index 0000000..2cb437d --- /dev/null +++ b/components/LearningPathRow.tsx @@ -0,0 +1,38 @@ +import React, { useState } from 'react'; +import { ArrowRight, Check, Layers3, X } from 'lucide-react'; +import { Course } from '../types'; +import { PublicLearningPath } from '../services/db'; +import { CourseCoverImage } from './CourseCoverImage'; + +interface LearningPathRowProps { + paths: PublicLearningPath[]; + courses: Course[]; + onCourseSelect?: (course: Course) => void; +} + +export const LearningPathRow: React.FC = ({ paths, courses, onCourseSelect }) => { + const [selectedPath, setSelectedPath] = useState(null); + const courseById = new Map(courses.map((course) => [course.id, course])); + + return <> +
+
+

Formações

+ {paths.length} {paths.length === 1 ? 'formação' : 'formações'} +
+
+ {paths.map((path) => )} +
+
+ {selectedPath &&
+
Formação

{selectedPath.title}

{selectedPath.description}

    {selectedPath.courses.map((pathCourse, index) => { const course = courseById.get(pathCourse.id); return
  1. ; })}
Siga os cursos nesta ordem.
+
} + ; +}; diff --git a/index.css b/index.css index dbcc999..73df0f0 100644 --- a/index.css +++ b/index.css @@ -68,6 +68,21 @@ h1, h2, h3, h4, h5, h6, .font-display, .font-heading { user-select: none; } +/* Shared native controls used inside focused admin dialogs. */ +.input { + width: 100%; + border: 1px solid rgb(255 255 255 / 0.1); + border-radius: 0.75rem; + background: #18181b; + padding: 0.625rem 0.75rem; + color: #fff; + font-size: 0.875rem; +} + +.input::placeholder { + color: #71717a; +} + @keyframes fadeInUp { from { opacity: 0; diff --git a/pages/SuperAdmin.tsx b/pages/SuperAdmin.tsx index 2e67143..3fbcc56 100644 --- a/pages/SuperAdmin.tsx +++ b/pages/SuperAdmin.tsx @@ -1,342 +1,64 @@ -import React, { useEffect, useMemo, useState } from 'react'; -import { ChevronDown, ChevronUp, Download, Loader2, RefreshCw, ScrollText, ShieldCheck, UserCheck, UserX, Users } from 'lucide-react'; -import { adminApi, AuditEntry, ManagedUser, ManagedUserDetail, PlatformAnalytics } from '../services/api'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { ArrowDown, ArrowUp, Download, FolderKanban, Loader2, Pencil, Plus, RefreshCw, ShieldCheck, Trash2, Upload, UserCheck, UserX, Users, X } from 'lucide-react'; +import { adminApi, instructorApi, LearningPath, LearningPathInput, ManagedUser, ManagedUserDetail, PlatformAnalytics } from '../services/api'; import { useAuth } from '../context/AuthContext'; import { useToast } from '../context/ToastContext'; import { useNavigate } from 'react-router-dom'; -const roleLabel: Record = { - admin: 'Superadmin', - instructor: 'Instrutor', - student: 'Aluno', -}; +const roleLabel: Record = { admin: 'Superadmin', instructor: 'Instrutor', student: 'Aluno' }; +type CourseOption = { id: string; title: string; status: string }; +type HomeConfiguration = { featuredCourseId: string | null; courseOrder: string[]; defaultCoverImageUrl: string | null; courses: CourseOption[] }; +const emptyPathInput = (): LearningPathInput => ({ title: '', description: '', coverImageUrl: null, coverImageZoom: 1, coverImagePositionX: 50, coverImagePositionY: 50, status: 'draft', courseIds: [] }); +const pathToInput = (path: LearningPath): LearningPathInput => ({ title: path.title, description: path.description, coverImageUrl: path.coverImageUrl, coverImageZoom: path.coverImageZoom, coverImagePositionX: path.coverImagePositionX, coverImagePositionY: path.coverImagePositionY, status: path.status === 'published' ? 'published' : 'draft', courseIds: path.courses.map((course) => course.id) }); export const SuperAdmin: React.FC = () => { - const { user } = useAuth(); - const { showToast } = useToast(); - const navigate = useNavigate(); - const [users, setUsers] = useState([]); - const [isLoading, setIsLoading] = useState(true); - const [updatingUserId, setUpdatingUserId] = useState(null); - const [query, setQuery] = useState(''); - const [analytics, setAnalytics] = useState(null); - const [inviteEmail, setInviteEmail] = useState(''); - const [inviteRole, setInviteRole] = useState<'student' | 'instructor'>('student'); - const [accessLink, setAccessLink] = useState(''); - const [selectedUser, setSelectedUser] = useState(null); - const [loadingDetailId, setLoadingDetailId] = useState(null); - const [auditEntries, setAuditEntries] = useState([]); - const [auditQuery, setAuditQuery] = useState(''); + const { user } = useAuth(); const { showToast } = useToast(); const navigate = useNavigate(); + const [activeTab, setActiveTab] = useState<'content' | 'people'>('content'); + const [users, setUsers] = useState([]); const [analytics, setAnalytics] = useState(null); const [categories, setCategories] = useState>([]); - const [newCategory, setNewCategory] = useState(''); - const [homeConfiguration, setHomeConfiguration] = useState<{ featuredCourseId: string | null; courseOrder: string[]; defaultCoverImageUrl: string | null; courses: Array<{ id: string; title: string; status: string }> } | null>(null); + const [homeConfiguration, setHomeConfiguration] = useState(null); const [paths, setPaths] = useState([]); + const [isLoading, setIsLoading] = useState(true); const [updatingUserId, setUpdatingUserId] = useState(null); const [query, setQuery] = useState(''); + const [newCategory, setNewCategory] = useState(''); const [inviteEmail, setInviteEmail] = useState(''); const [inviteRole, setInviteRole] = useState<'student' | 'instructor'>('student'); const [accessLink, setAccessLink] = useState(''); + const [selectedUser, setSelectedUser] = useState(null); const [loadingDetailId, setLoadingDetailId] = useState(null); + const [editingPath, setEditingPath] = useState(null); const [pathInput, setPathInput] = useState(emptyPathInput); const [isPathEditorOpen, setIsPathEditorOpen] = useState(false); const [isSavingPath, setIsSavingPath] = useState(false); const [isUploadingCover, setIsUploadingCover] = useState(false); const coverInputRef = useRef(null); - const loadUsers = async () => { - setIsLoading(true); - try { - const [response, dashboard, auditLog, categoryResponse, homeResponse] = await Promise.all([adminApi.listUsers(), adminApi.dashboard(), adminApi.auditLog(), adminApi.categories(), adminApi.homeConfiguration()]); - setUsers(response.data); - setAnalytics(dashboard.data); - setAuditEntries(auditLog.data); - setCategories(categoryResponse.data); - setHomeConfiguration(homeResponse.data); - } catch { - showToast('Não foi possível carregar os usuários.', 'error'); - } finally { - setIsLoading(false); - } - }; + const loadData = async () => { setIsLoading(true); try { const [usersResponse, dashboard, categoryResponse, homeResponse, pathResponse] = await Promise.all([adminApi.listUsers(), adminApi.dashboard(), adminApi.categories(), adminApi.homeConfiguration(), adminApi.listPaths()]); setUsers(usersResponse.data); setAnalytics(dashboard.data); setCategories(categoryResponse.data); setHomeConfiguration(homeResponse.data); setPaths(pathResponse.data); } catch { showToast('Não foi possível carregar o painel administrativo.', 'error'); } finally { setIsLoading(false); } }; + useEffect(() => { void loadData(); }, []); + const filteredUsers = useMemo(() => { const normalized = query.trim().toLowerCase(); return normalized ? users.filter((account) => account.name.toLowerCase().includes(normalized) || account.email.toLowerCase().includes(normalized)) : users; }, [query, users]); + const homeCourses = useMemo(() => { if (!homeConfiguration) return []; const order = new Map(homeConfiguration.courseOrder.map((id, index): [string, number] => [id, index])); return homeConfiguration.courses.filter((course) => course.status === 'published').sort((a, b) => (order.get(a.id) ?? 999_999) - (order.get(b.id) ?? 999_999) || a.title.localeCompare(b.title)); }, [homeConfiguration]); + const activeUsers = users.filter((account) => account.isActive).length; const instructorUsers = users.filter((account) => account.role === 'instructor' && account.isActive).length; + const updateUser = async (account: ManagedUser, update: Partial>) => { setUpdatingUserId(account.id); try { const response = await adminApi.updateUser(account.id, update); setUsers((current) => current.map((item) => item.id === account.id ? response.data : item)); showToast('Usuário atualizado.', 'success'); } catch { showToast('Não foi possível atualizar este usuário.', 'error'); } finally { setUpdatingUserId(null); } }; + const createInvite = async (event: React.FormEvent) => { event.preventDefault(); try { const response = await adminApi.invite(inviteEmail, inviteRole); setAccessLink(response.data.inviteUrl); setInviteEmail(''); showToast('Link de convite criado.', 'success'); } catch { showToast('Não foi possível criar o convite.', 'error'); } }; + const resetPassword = async (account: ManagedUser) => { try { const response = await adminApi.passwordReset(account.id); setAccessLink(response.data.resetUrl); showToast('Link de redefinição criado.', 'success'); } catch { showToast('Não foi possível criar o link.', 'error'); } }; + const createCategory = async (event: React.FormEvent) => { event.preventDefault(); if (!newCategory.trim()) return; try { const response = await adminApi.createCategory(newCategory.trim()); setCategories((current) => [...current, response.data].sort((a, b) => a.position - b.position || a.name.localeCompare(b.name))); setNewCategory(''); showToast('Categoria criada.', 'success'); } catch { showToast('Não foi possível criar esta categoria. Ela pode já existir.', 'error'); } }; + const toggleCategory = async (category: { id: string; name: string; position: number; isActive: boolean }) => { try { const response = await adminApi.updateCategory(category.id, { name: category.name, isActive: !category.isActive }); setCategories((current) => current.map((item) => item.id === category.id ? response.data : item)); } catch { showToast('Não foi possível atualizar a categoria.', 'error'); } }; + const saveHomeConfiguration = async () => { if (!homeConfiguration) return; try { await adminApi.updateHomeConfiguration({ featuredCourseId: homeConfiguration.featuredCourseId, courseOrder: homeConfiguration.courseOrder, defaultCoverImageUrl: homeConfiguration.defaultCoverImageUrl || null }); showToast('Página inicial atualizada.', 'success'); } catch { showToast('Não foi possível salvar a configuração inicial.', 'error'); } }; + const moveHomeCourse = (courseId: string, direction: -1 | 1) => setHomeConfiguration((current) => { if (!current) return current; const published = current.courses.filter((course) => course.status === 'published'); const order = new Map(current.courseOrder.map((id, index): [string, number] => [id, index])); const arranged = [...published].sort((a, b) => (order.get(a.id) ?? 999_999) - (order.get(b.id) ?? 999_999) || a.title.localeCompare(b.title)); const index = arranged.findIndex((course) => course.id === courseId); const target = index + direction; if (index < 0 || target < 0 || target >= arranged.length) return current; [arranged[index], arranged[target]] = [arranged[target], arranged[index]]; return { ...current, courseOrder: arranged.map((course) => course.id) }; }); + const showUserDetail = async (account: ManagedUser) => { setLoadingDetailId(account.id); try { const response = await adminApi.userDetail(account.id); setSelectedUser(response.data); } catch { showToast('Não foi possível carregar os detalhes do usuário.', 'error'); } finally { setLoadingDetailId(null); } }; + const exportUsers = () => { const csv = ['Nome,E-mail,Perfil,Status,Cadastro', ...filteredUsers.map((account) => [account.name, account.email, roleLabel[account.role], account.isActive ? 'Ativo' : 'Desativado', new Intl.DateTimeFormat('pt-BR').format(new Date(account.createdAt))].map((value) => `"${value.replaceAll('"', '""')}"`).join(','))].join('\n'); const url = URL.createObjectURL(new Blob([csv], { type: 'text/csv;charset=utf-8' })); const link = document.createElement('a'); link.href = url; link.download = 'usuarios-compor-hub.csv'; link.click(); URL.revokeObjectURL(url); }; + const openNewPath = () => { setEditingPath(null); setPathInput(emptyPathInput()); setIsPathEditorOpen(true); }; const openEditPath = (path: LearningPath) => { setEditingPath(path); setPathInput(pathToInput(path)); setIsPathEditorOpen(true); }; const closePathEditor = () => { if (!isSavingPath) setIsPathEditorOpen(false); }; + const togglePathCourse = (courseId: string) => setPathInput((current) => ({ ...current, courseIds: current.courseIds.includes(courseId) ? current.courseIds.filter((id) => id !== courseId) : [...current.courseIds, courseId] })); + const movePathCourse = (courseId: string, direction: -1 | 1) => setPathInput((current) => { const index = current.courseIds.indexOf(courseId); const target = index + direction; if (index < 0 || target < 0 || target >= current.courseIds.length) return current; const courseIds = [...current.courseIds]; [courseIds[index], courseIds[target]] = [courseIds[target], courseIds[index]]; return { ...current, courseIds }; }); + const uploadPathCover = async (file: File) => { if (!file.type.startsWith('image/')) { showToast('Selecione uma imagem JPG, PNG ou WebP.', 'error'); return; } setIsUploadingCover(true); try { const response = await instructorApi.uploadCover(file); setPathInput((current) => ({ ...current, coverImageUrl: response.data.coverImageUrl })); showToast('Capa da formação enviada.', 'success'); } catch { showToast('Não foi possível enviar a capa.', 'error'); } finally { setIsUploadingCover(false); } }; + const savePath = async (event: React.FormEvent) => { event.preventDefault(); if (!pathInput.courseIds.length) { showToast('Adicione pelo menos um curso à formação.', 'error'); return; } setIsSavingPath(true); try { const response = editingPath ? await adminApi.updatePath(editingPath.id, pathInput) : await adminApi.createPath(pathInput); setPaths((current) => editingPath ? current.map((path) => path.id === response.data.id ? response.data : path) : [response.data, ...current]); setIsPathEditorOpen(false); showToast(editingPath ? 'Formação atualizada.' : 'Formação criada.', 'success'); } catch (error) { showToast(error instanceof Error ? error.message : 'Não foi possível salvar a formação.', 'error'); } finally { setIsSavingPath(false); } }; + const deletePath = async (path: LearningPath) => { if (!window.confirm(`Excluir a formação “${path.title}”? Os cursos não serão removidos.`)) return; try { await adminApi.deletePath(path.id); setPaths((current) => current.filter((item) => item.id !== path.id)); showToast('Formação excluída. Os cursos foram preservados.', 'success'); } catch { showToast('Não foi possível excluir a formação.', 'error'); } }; + const selectedPathCourses = pathInput.courseIds.map((id) => homeConfiguration?.courses.find((course) => course.id === id)).filter((course): course is CourseOption => Boolean(course)); - useEffect(() => { - void loadUsers(); - }, []); - - const filteredUsers = useMemo(() => { - const normalized = query.trim().toLowerCase(); - if (!normalized) return users; - return users.filter((account) => - account.name.toLowerCase().includes(normalized) || account.email.toLowerCase().includes(normalized), - ); - }, [query, users]); - const filteredAuditEntries = useMemo(() => { - const normalized = auditQuery.trim().toLowerCase(); - if (!normalized) return auditEntries; - return auditEntries.filter((entry) => `${entry.action} ${entry.actorName} ${entry.subjectType}`.toLowerCase().includes(normalized)); - }, [auditEntries, auditQuery]); - const homeCourses = useMemo(() => { - if (!homeConfiguration) return []; - const order = new Map(homeConfiguration.courseOrder.map((id, index) => [id, index])); - return homeConfiguration.courses.filter((course) => course.status === 'published').sort((a, b) => (order.get(a.id) ?? 999_999) - (order.get(b.id) ?? 999_999) || a.title.localeCompare(b.title)); - }, [homeConfiguration]); - - const updateUser = async (account: ManagedUser, update: Partial>) => { - setUpdatingUserId(account.id); - try { - const response = await adminApi.updateUser(account.id, update); - setUsers((current) => current.map((item) => item.id === account.id ? response.data : item)); - showToast('Usuário atualizado.', 'success'); - } catch { - showToast('Não foi possível atualizar este usuário.', 'error'); - } finally { - setUpdatingUserId(null); - } - }; - - const activeUsers = users.filter((account) => account.isActive).length; - const instructorUsers = users.filter((account) => account.role === 'instructor' && account.isActive).length; - const createInvite = async (event: React.FormEvent) => { - event.preventDefault(); - try { - const response = await adminApi.invite(inviteEmail, inviteRole); - setAccessLink(response.data.inviteUrl); setInviteEmail(''); showToast('Link de convite criado.', 'success'); - } catch { showToast('Não foi possível criar o convite.', 'error'); } - }; - const resetPassword = async (account: ManagedUser) => { - try { const response = await adminApi.passwordReset(account.id); setAccessLink(response.data.resetUrl); showToast('Link de redefinição criado.', 'success'); } - catch { showToast('Não foi possível criar o link.', 'error'); } - }; - const createCategory = async (event: React.FormEvent) => { - event.preventDefault(); - if (!newCategory.trim()) return; - try { - const response = await adminApi.createCategory(newCategory.trim()); - setCategories((current) => [...current, response.data].sort((a, b) => a.position - b.position || a.name.localeCompare(b.name))); - setNewCategory(''); - showToast('Categoria criada.', 'success'); - } catch { showToast('Não foi possível criar esta categoria. Ela pode já existir.', 'error'); } - }; - const toggleCategory = async (category: { id: string; name: string; position: number; isActive: boolean }) => { - try { - const response = await adminApi.updateCategory(category.id, { name: category.name, isActive: !category.isActive }); - setCategories((current) => current.map((item) => item.id === category.id ? response.data : item)); - } catch { showToast('Não foi possível atualizar a categoria.', 'error'); } - }; - const saveHomeConfiguration = async () => { - if (!homeConfiguration) return; - try { - await adminApi.updateHomeConfiguration({ featuredCourseId: homeConfiguration.featuredCourseId, courseOrder: homeConfiguration.courseOrder, defaultCoverImageUrl: homeConfiguration.defaultCoverImageUrl || null }); - showToast('Configuração da página inicial salva.', 'success'); - } catch { showToast('Não foi possível salvar a configuração inicial.', 'error'); } - }; - const moveHomeCourse = (courseId: string, direction: -1 | 1) => { - setHomeConfiguration((current) => { - if (!current) return current; - const published = current.courses.filter((course) => course.status === 'published'); - const order = new Map(current.courseOrder.map((id, index) => [id, index])); - const arranged = [...published].sort((a, b) => (order.get(a.id) ?? 999_999) - (order.get(b.id) ?? 999_999) || a.title.localeCompare(b.title)); - const index = arranged.findIndex((course) => course.id === courseId); - const target = index + direction; - if (index < 0 || target < 0 || target >= arranged.length) return current; - [arranged[index], arranged[target]] = [arranged[target], arranged[index]]; - return { ...current, courseOrder: arranged.map((course) => course.id) }; - }); - }; - const showUserDetail = async (account: ManagedUser) => { - setLoadingDetailId(account.id); - try { - const response = await adminApi.userDetail(account.id); - setSelectedUser(response.data); - } catch { - showToast('Não foi possível carregar os detalhes do usuário.', 'error'); - } finally { - setLoadingDetailId(null); - } - }; - const exportUsers = () => { - const csv = ['Nome,E-mail,Perfil,Status,Cadastro', ...filteredUsers.map((account) => [account.name, account.email, roleLabel[account.role], account.isActive ? 'Ativo' : 'Desativado', new Intl.DateTimeFormat('pt-BR').format(new Date(account.createdAt))].map((value) => `"${value.replaceAll('"', '""')}"`).join(','))].join('\n'); - const url = URL.createObjectURL(new Blob([csv], { type: 'text/csv;charset=utf-8' })); - const link = document.createElement('a'); link.href = url; link.download = 'usuarios-compor-hub.csv'; link.click(); URL.revokeObjectURL(url); - }; - const exportAudit = () => { - const csv = ['Data,Ação,Responsável,Tipo', ...filteredAuditEntries.map((entry) => [new Date(entry.createdAt).toISOString(), entry.action, entry.actorName, entry.subjectType].map((value) => `"${value.replaceAll('"', '""')}"`).join(','))].join('\n'); - const url = URL.createObjectURL(new Blob([csv], { type: 'text/csv;charset=utf-8' })); - const link = document.createElement('a'); link.href = url; link.download = 'registro-de-atividades-compor-hub.csv'; link.click(); URL.revokeObjectURL(url); - }; - const auditLabel: Record = { 'invitation.created': 'Convite criado', 'password_reset.created': 'Redefinição de senha criada', 'user.updated': 'Usuário atualizado', 'course.published': 'Curso publicado', 'course.drafted': 'Rascunho salvo', 'course.archived': 'Curso arquivado', 'comment.replied': 'Comentário respondido', 'comment.deleted': 'Comentário removido' }; - - return ( -
-
-
-
- Superadmin -
-

Painel Administrativo

-

Gerencie os acessos da plataforma. Cursos e aulas continuam no Painel do Instrutor.

-
-
- - - -
-
- -
- } label="Usuários cadastrados" value={analytics?.totalUsers ?? users.length} /> - } label="Contas ativas" value={analytics?.activeUsers ?? activeUsers} /> - } label="Instrutores ativos" value={instructorUsers} /> - } label="Cursos publicados" value={analytics?.publishedCourses ?? 0} /> - } label="Aulas concluídas" value={analytics?.completedLessons ?? 0} /> -
- -
-

Convidar usuário

-
- setInviteEmail(event.target.value)} placeholder="E-mail do convidado" className="flex-1 rounded-xl bg-zinc-900 border border-white/10 px-4 py-2.5 text-sm text-white" /> - - -
- {accessLink &&
} -
- -
-
-

Categorias de cursos

-

Estas opções aparecem no editor de cursos e na biblioteca.

-
- setNewCategory(event.target.value)} placeholder="Nova categoria" className="min-w-0 flex-1 rounded-xl bg-zinc-900 border border-white/10 px-3 py-2 text-sm text-white" /> - -
-
- {categories.map((category) =>
{category.name}
)} -
-
- -
-

Página inicial

-

Defina o curso em destaque e a imagem padrão quando um curso não tem capa.

- {homeConfiguration &&
- - setHomeConfiguration((current) => current ? { ...current, defaultCoverImageUrl: event.target.value || null } : current)} placeholder="URL da capa padrão (opcional)" className="w-full rounded-xl bg-zinc-900 border border-white/10 px-3 py-2 text-sm text-white" /> -
- {homeCourses.map((course, index) =>
{index + 1}. {course.title}
)} -
- -
} -
-
- - {selectedUser && ( -
-
-
-

Detalhes do usuário

-

{selectedUser.name}

-

{selectedUser.email} · {roleLabel[selectedUser.role]}

-
- -
-
- - - -
-
- )} - -
-
-

Usuários

- setQuery(event.target.value)} - placeholder="Buscar por nome ou e-mail" - className="w-full sm:w-80 rounded-xl bg-zinc-900 border border-white/10 px-4 py-2.5 text-sm text-white placeholder:text-gray-500 focus:outline-none focus:border-orange-500" - /> -
- - {isLoading ? ( -
- ) : filteredUsers.length === 0 ? ( -
Nenhum usuário encontrado.
- ) : ( -
- - - - - - - - - - - - {filteredUsers.map((account) => { - const isCurrentUser = account.id === user?.id; - const isUpdating = updatingUserId === account.id; - return ( - - - - - - - - ); - })} - -
UsuárioPerfilStatusCadastroAções
-
{account.name}
-
{account.email}
-
- - - - {new Intl.DateTimeFormat('pt-BR').format(new Date(account.createdAt))} -
- - -
-
-
- )} -
- -
-

Registro de atividades

setAuditQuery(event.target.value)} placeholder="Filtrar registro" className="min-w-0 rounded-xl bg-zinc-900 border border-white/10 px-3 py-2 text-xs text-white" />
- {filteredAuditEntries.length === 0 ?

Nenhuma atividade administrativa encontrada.

:
{filteredAuditEntries.map((entry) =>
{auditLabel[entry.action] || entry.action} · {entry.actorName}
)}
} -
-
- ); + return
+
Superadmin

Painel Administrativo

Organize conteúdo, formações e acessos da plataforma.

+
} label="Usuários" value={analytics?.totalUsers ?? users.length} />} label="Contas ativas" value={analytics?.activeUsers ?? activeUsers} />} label="Instrutores" value={instructorUsers} />} label="Cursos publicados" value={analytics?.publishedCourses ?? 0} />} label="Aulas concluídas" value={analytics?.completedLessons ?? 0} />
+
setActiveTab('content')} label="Conteúdo" /> setActiveTab('people')} label="Pessoas e acessos" />
+ {activeTab === 'content' ? : } + {isPathEditorOpen && } +
; }; -const StatCard: React.FC<{ icon: React.ReactNode; label: string; value: number }> = ({ icon, label, value }) => ( -
-
{icon}{label}
-
{value}
-
-); +const ContentPanel: React.FC<{ paths: LearningPath[]; homeConfiguration: HomeConfiguration | null; categories: Array<{ id: string; name: string; position: number; isActive: boolean }>; homeCourses: CourseOption[]; newCategory: string; setNewCategory: (value: string) => void; setHomeConfiguration: React.Dispatch>; onCreateCategory: (event: React.FormEvent) => void; onToggleCategory: (category: { id: string; name: string; position: number; isActive: boolean }) => void; onMoveHomeCourse: (courseId: string, direction: -1 | 1) => void; onSaveHome: () => void; onNewPath: () => void; onEditPath: (path: LearningPath) => void; onDeletePath: (path: LearningPath) => void }> = ({ paths, homeConfiguration, categories, homeCourses, newCategory, setNewCategory, setHomeConfiguration, onCreateCategory, onToggleCategory, onMoveHomeCourse, onSaveHome, onNewPath, onEditPath, onDeletePath }) => <>

Formações

Uma sequência ordenada de cursos existentes.

{paths.length === 0 ?
Nenhuma formação criada ainda.
: paths.map((path) =>
{path.coverImageUrl ? : }

{path.title}

{path.status === 'published' ? 'Publicada' : 'Rascunho'}

{path.courses.length} {path.courses.length === 1 ? 'curso' : 'cursos'}

)}

Página inicial

Escolha o curso do banner e a ordem da vitrine de cursos.

{homeConfiguration &&

Ordem da vitrine

{homeCourses.map((course, index) =>
{index + 1}{course.title}
)}
}

Categorias de cursos

Aparecem no editor de cursos e na biblioteca.

setNewCategory(event.target.value)} placeholder="Nome da nova categoria" className="min-w-0 flex-1 rounded-xl border border-white/10 bg-zinc-900 px-3 py-2.5 text-sm text-white" />
{categories.map((category) => )}
; -const DetailMetric: React.FC<{ label: string; value: string }> = ({ label, value }) => ( -
-
{label}
-
{value}
-
-); +const PeoplePanel: React.FC<{ users: ManagedUser[]; isLoading: boolean; query: string; setQuery: (value: string) => void; accessLink: string; inviteEmail: string; setInviteEmail: (value: string) => void; inviteRole: 'student' | 'instructor'; setInviteRole: (value: 'student' | 'instructor') => void; selectedUser: ManagedUserDetail | null; setSelectedUser: (user: ManagedUserDetail | null) => void; updatingUserId: string | null; loadingDetailId: string | null; currentUserId?: string; onInvite: (event: React.FormEvent) => void; onUpdateUser: (account: ManagedUser, update: Partial>) => void; onShowDetail: (account: ManagedUser) => void; onResetPassword: (account: ManagedUser) => void; onExport: () => void }> = ({ users, isLoading, query, setQuery, accessLink, inviteEmail, setInviteEmail, inviteRole, setInviteRole, selectedUser, setSelectedUser, updatingUserId, loadingDetailId, currentUserId, onInvite, onUpdateUser, onShowDetail, onResetPassword, onExport }) => <>

Convidar usuário

setInviteEmail(event.target.value)} placeholder="E-mail do convidado" className="flex-1 rounded-xl bg-zinc-900 border border-white/10 px-4 py-2.5 text-sm text-white" />
{accessLink &&
}
{selectedUser &&

Detalhes do usuário

{selectedUser.name}

{selectedUser.email} · {roleLabel[selectedUser.role]}

}

Usuários

setQuery(event.target.value)} placeholder="Buscar por nome ou e-mail" className="w-full sm:w-80 rounded-xl bg-zinc-900 border border-white/10 px-4 py-2.5 text-sm text-white" />
{isLoading ?
:
{users.map((account) => { const isCurrentUser = account.id === currentUserId; const isUpdating = updatingUserId === account.id; return ; })}
UsuárioPerfilStatusCadastroAções
{account.name}
{account.email}
{new Intl.DateTimeFormat('pt-BR').format(new Date(account.createdAt))}
}
; + +const PathEditor: React.FC<{ input: LearningPathInput; setInput: React.Dispatch>; courses: CourseOption[]; selectedCourses: CourseOption[]; editing: boolean; isSaving: boolean; isUploadingCover: boolean; coverInputRef: React.RefObject; onClose: () => void; onSave: (event: React.FormEvent) => void; onUpload: (file: File) => void; onToggleCourse: (id: string) => void; onMoveCourse: (id: string, direction: -1 | 1) => void }> = ({ input, setInput, courses, selectedCourses, editing, isSaving, isUploadingCover, coverInputRef, onClose, onSave, onUpload, onToggleCourse, onMoveCourse }) =>

Formação

{editing ? 'Editar formação' : 'Nova formação'}

Organize cursos existentes na ordem em que os alunos devem assisti-los.

{ const file = event.target.files?.[0]; if (file) onUpload(file); event.currentTarget.value = ''; }} /> setInput((current) => ({ ...current, title: event.target.value }))} placeholder="Ex.: Formação em Tráfego Pago" className="input" />