From 09d85a71235aa97b5977e5e9ff3c6ae7dcbec59c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Faleiros?= Date: Fri, 4 Sep 2026 16:32:54 -0300 Subject: [PATCH] feat: separate trails and formations in cms --- pages/SuperAdmin.tsx | 22 ++++++++++++++------ server/migrations/010_learning_path_kind.sql | 8 +++++++ server/src/routes/admin.ts | 13 ++++++------ server/src/routes/courses.ts | 2 +- services/api.ts | 2 ++ services/db.ts | 1 + 6 files changed, 35 insertions(+), 13 deletions(-) create mode 100644 server/migrations/010_learning_path_kind.sql diff --git a/pages/SuperAdmin.tsx b/pages/SuperAdmin.tsx index 3fbcc56..3e76ec3 100644 --- a/pages/SuperAdmin.tsx +++ b/pages/SuperAdmin.tsx @@ -8,12 +8,12 @@ import { useNavigate } from 'react-router-dom'; 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) }); +const emptyPathInput = (): LearningPathInput => ({ title: '', description: '', coverImageUrl: null, coverImageZoom: 1, coverImagePositionX: 50, coverImagePositionY: 50, status: 'draft', kind: 'trail', 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', kind: path.kind, courseIds: path.courses.map((course) => course.id) }); export const SuperAdmin: React.FC = () => { const { user } = useAuth(); const { showToast } = useToast(); const navigate = useNavigate(); - const [activeTab, setActiveTab] = useState<'content' | 'people'>('content'); + const [activeTab, setActiveTab] = useState<'courses' | 'trails' | 'formations' | 'home' | 'access'>('courses'); const [users, setUsers] = useState([]); const [analytics, setAnalytics] = useState(null); const [categories, setCategories] = useState>([]); const [homeConfiguration, setHomeConfiguration] = useState(null); const [paths, setPaths] = useState([]); @@ -36,7 +36,7 @@ export const SuperAdmin: React.FC = () => { 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 openNewPath = (kind: 'trail' | 'formation') => { setEditingPath(null); setPathInput({ ...emptyPathInput(), kind }); 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); } }; @@ -47,8 +47,12 @@ export const SuperAdmin: React.FC = () => { 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' ? : } +
setActiveTab('courses')} label="Cursos" /> setActiveTab('trails')} label="Trilhas" /> setActiveTab('formations')} label="Formações" /> setActiveTab('home')} label="Página inicial" /> setActiveTab('access')} label="Acessos" />
+ {activeTab === 'courses' && navigate('/gerenciar')} />} + {activeTab === 'trails' && path.kind === 'trail')} onNew={() => openNewPath('trail')} onEdit={openEditPath} onDelete={deletePath} />} + {activeTab === 'formations' && path.kind === 'formation')} onNew={() => openNewPath('formation')} onEdit={openEditPath} onDelete={deletePath} />} + {activeTab === 'home' && } + {activeTab === 'access' && } {isPathEditorOpen && }
; }; @@ -62,3 +66,9 @@ const Field: React.FC<{ label: string; children: React.ReactNode }> = ({ label, const TabButton: React.FC<{ active: boolean; onClick: () => void; label: string }> = ({ active, onClick, label }) => ; const StatCard: React.FC<{ icon: React.ReactNode; label: string; value: number }> = ({ icon, label, value }) =>
{icon}{label}
{value}
; const DetailMetric: React.FC<{ label: string; value: string }> = ({ label, value }) =>
{label}
{value}
; + +const CoursesPanel: React.FC<{ courses: CourseOption[]; onManage: () => void }> = ({ courses, onManage }) =>

Cursos

Edite ou exclua cursos rapidamente no gerenciador.

{courses.length === 0 ?

Nenhum curso cadastrado.

: courses.map((course) =>
{course.title}{course.status === 'published' ? 'Publicado' : 'Rascunho'}
)}
; + +const PathManager: React.FC<{ title: string; description: string; paths: LearningPath[]; onNew: () => void; onEdit: (path: LearningPath) => void; onDelete: (path: LearningPath) => void }> = ({ title, description, paths, onNew, onEdit, onDelete }) =>

{title}

{description}

{paths.length === 0 ?
Nenhuma {title.toLowerCase()} criada ainda.
: paths.map((path) =>
{path.coverImageUrl ? : }

{path.title}

{path.courses.length} {path.courses.length === 1 ? 'curso' : 'cursos'} · {path.status === 'published' ? 'Publicada' : 'Rascunho'}

)}
; + +const HomePanel: React.FC<{ homeConfiguration: HomeConfiguration | null; homeCourses: CourseOption[]; categories: Array<{ id: string; name: string; position: number; isActive: boolean }>; 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 }> = ({ homeConfiguration, homeCourses, categories, newCategory, setNewCategory, setHomeConfiguration, onCreateCategory, onToggleCategory, onMoveHomeCourse, onSaveHome }) =>

Página inicial

Defina o conteúdo principal e a ordem dos cursos.

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

Categorias

setNewCategory(event.target.value)} placeholder="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) => )}
; diff --git a/server/migrations/010_learning_path_kind.sql b/server/migrations/010_learning_path_kind.sql new file mode 100644 index 0000000..bae8dae --- /dev/null +++ b/server/migrations/010_learning_path_kind.sql @@ -0,0 +1,8 @@ +-- Both content types are intentionally composed from existing courses. The +-- distinction is editorial: a "trilha" is a guided sequence and a +-- "formação" is a broader curriculum collection. +alter table learning_paths + add column kind text not null default 'trail' + check (kind in ('trail', 'formation')); + +create index learning_paths_kind_index on learning_paths (kind, published_at desc); diff --git a/server/src/routes/admin.ts b/server/src/routes/admin.ts index d7c29b7..d04c1c8 100644 --- a/server/src/routes/admin.ts +++ b/server/src/routes/admin.ts @@ -33,6 +33,7 @@ const learningPathSchema = z.object({ coverImagePositionX: z.number().min(0).max(100).default(50), coverImagePositionY: z.number().min(0).max(100).default(50), status: z.enum(['draft', 'published']).default('draft'), + kind: z.enum(['trail', 'formation']).default('trail'), courseIds: z.array(z.string().uuid()).min(1).max(100), }); @@ -45,7 +46,7 @@ const getLearningPaths = async () => { p.cover_image_zoom as "coverImageZoom", p.cover_image_position_x as "coverImagePositionX", p.cover_image_position_y as "coverImagePositionY", - p.status, p.published_at as "publishedAt", p.created_at as "createdAt", + p.status, p.kind, p.published_at as "publishedAt", p.created_at as "createdAt", coalesce(jsonb_agg(jsonb_build_object( 'id', c.id, 'title', c.title, 'status', c.status, 'coverImageUrl', c.cover_image_url, 'position', pc.position @@ -126,10 +127,10 @@ export const adminRoutes: FastifyPluginAsync = async (app) => { const courseIds = await validatePathCourses(client, input); const path = await client.query<{ id: string }>( `insert into learning_paths ( - title, description, cover_image_url, cover_image_zoom, cover_image_position_x, cover_image_position_y, status, published_at - ) values ($1, $2, $3, $4, $5, $6, $7::course_status, case when $7 = 'published' then now() else null end) + title, description, cover_image_url, cover_image_zoom, cover_image_position_x, cover_image_position_y, status, kind, published_at + ) values ($1, $2, $3, $4, $5, $6, $7::course_status, $8, case when $7 = 'published' then now() else null end) returning id`, - [input.title, input.description, input.coverImageUrl, input.coverImageZoom, input.coverImagePositionX, input.coverImagePositionY, input.status], + [input.title, input.description, input.coverImageUrl, input.coverImageZoom, input.coverImagePositionX, input.coverImagePositionY, input.status, input.kind], ); await writePathCourses(client, path.rows[0].id, courseIds); await client.query('commit'); @@ -155,11 +156,11 @@ export const adminRoutes: FastifyPluginAsync = async (app) => { const path = await client.query<{ id: string }>( `update learning_paths set title = $2, description = $3, cover_image_url = $4, cover_image_zoom = $5, - cover_image_position_x = $6, cover_image_position_y = $7, status = $8::course_status, + cover_image_position_x = $6, cover_image_position_y = $7, status = $8::course_status, kind = $9, published_at = case when $8 = 'published' then coalesce(published_at, now()) else null end where id = $1 returning id`, - [pathId, input.title, input.description, input.coverImageUrl, input.coverImageZoom, input.coverImagePositionX, input.coverImagePositionY, input.status], + [pathId, input.title, input.description, input.coverImageUrl, input.coverImageZoom, input.coverImagePositionX, input.coverImagePositionY, input.status, input.kind], ); if (!path.rows[0]) { await client.query('rollback'); diff --git a/server/src/routes/courses.ts b/server/src/routes/courses.ts index 73e6968..5cbacfc 100644 --- a/server/src/routes/courses.ts +++ b/server/src/routes/courses.ts @@ -153,7 +153,7 @@ export const courseRoutes: FastifyPluginAsync = async (app) => { app.get('/paths', async () => { const result = await pool.query( `select - p.id, p.title, p.description, + p.id, p.title, p.description, p.kind, coalesce(p.cover_image_url, ( select value #>> '{}' from platform_settings where key = 'media.defaultCoverImageUrl' )) as "coverImageUrl", diff --git a/services/api.ts b/services/api.ts index 07dcf85..9ce3b86 100644 --- a/services/api.ts +++ b/services/api.ts @@ -43,6 +43,7 @@ export interface LearningPath { coverImagePositionX: number; coverImagePositionY: number; status: 'draft' | 'published' | 'archived'; + kind: 'trail' | 'formation'; publishedAt: string | null; createdAt: string; courses: LearningPathCourse[]; @@ -55,6 +56,7 @@ export interface LearningPathInput { coverImagePositionX: number; coverImagePositionY: number; status: 'draft' | 'published'; + kind: 'trail' | 'formation'; courseIds: string[]; } diff --git a/services/db.ts b/services/db.ts index 390417e..f30d20d 100644 --- a/services/db.ts +++ b/services/db.ts @@ -48,6 +48,7 @@ export type PublicLearningPath = { id: string; title: string; description: string; + kind: 'trail' | 'formation'; coverImageUrl: string | null; coverImageZoom: number; coverImagePositionX: number;