diff --git a/components/LearningPathRow.tsx b/components/LearningPathRow.tsx index 2cb437d..07c5580 100644 --- a/components/LearningPathRow.tsx +++ b/components/LearningPathRow.tsx @@ -17,8 +17,8 @@ export const LearningPathRow: React.FC = ({ paths, courses return <>
-

Formações

- {paths.length} {paths.length === 1 ? 'formação' : 'formações'} +

Trilhas

+ {paths.length} {paths.length === 1 ? 'trilha' : 'trilhas'}
{paths.map((path) => )}
diff --git a/pages/SuperAdmin.tsx b/pages/SuperAdmin.tsx index 2e3fd5e..acc5014 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', 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) }); +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 [activeTab, setActiveTab] = useState<'courses' | 'trails' | 'formations' | 'home' | 'access'>('courses'); + const [activeTab, setActiveTab] = useState<'courses' | 'trails' | '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 = (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 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); } }; @@ -47,10 +47,9 @@ 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('courses')} label="Cursos" /> setActiveTab('trails')} label="Trilhas" /> setActiveTab('formations')} label="Formações" /> setActiveTab('home')} label="Página inicial" /> setActiveTab('access')} label="Acessos" />
+
setActiveTab('courses')} label="Cursos" /> setActiveTab('trails')} label="Trilhas" /> 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 === 'trails' && } {activeTab === 'home' && <>} {activeTab === 'access' && } {isPathEditorOpen && } diff --git a/server/migrations/012_consolidate_learning_paths.sql b/server/migrations/012_consolidate_learning_paths.sql new file mode 100644 index 0000000..20fc058 --- /dev/null +++ b/server/migrations/012_consolidate_learning_paths.sql @@ -0,0 +1,5 @@ +-- Trilhas and Formações described the same object: an ordered collection of +-- existing courses. Preserve every record while collapsing the duplicate type. +update learning_paths set kind = 'trail' where kind = 'formation'; +drop index if exists learning_paths_kind_index; +alter table learning_paths drop column kind; diff --git a/server/src/routes/admin.ts b/server/src/routes/admin.ts index 50cdfd6..f57029e 100644 --- a/server/src/routes/admin.ts +++ b/server/src/routes/admin.ts @@ -56,7 +56,6 @@ 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), }); @@ -69,7 +68,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.kind, p.published_at as "publishedAt", p.created_at as "createdAt", + p.status, 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 @@ -176,10 +175,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, kind, published_at - ) values ($1, $2, $3, $4, $5, $6, $7::course_status, $8, 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, published_at + ) values ($1, $2, $3, $4, $5, $6, $7::course_status, 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.kind], + [input.title, input.description, input.coverImageUrl, input.coverImageZoom, input.coverImagePositionX, input.coverImagePositionY, input.status], ); await writePathCourses(client, path.rows[0].id, courseIds); await client.query('commit'); @@ -205,11 +204,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, kind = $9, + cover_image_position_x = $6, cover_image_position_y = $7, status = $8::course_status, 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, input.kind], + [pathId, input.title, input.description, input.coverImageUrl, input.coverImageZoom, input.coverImagePositionX, input.coverImagePositionY, input.status], ); if (!path.rows[0]) { await client.query('rollback'); diff --git a/server/src/routes/courses.ts b/server/src/routes/courses.ts index 2e9d23e..f656aee 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.kind, + p.id, p.title, p.description, 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 d8799c9..05b2a79 100644 --- a/services/api.ts +++ b/services/api.ts @@ -43,7 +43,6 @@ export interface LearningPath { coverImagePositionX: number; coverImagePositionY: number; status: 'draft' | 'published' | 'archived'; - kind: 'trail' | 'formation'; publishedAt: string | null; createdAt: string; courses: LearningPathCourse[]; @@ -56,7 +55,6 @@ export interface LearningPathInput { coverImagePositionX: number; coverImagePositionY: number; status: 'draft' | 'published'; - kind: 'trail' | 'formation'; courseIds: string[]; } export interface HomeBannerInput { diff --git a/services/db.ts b/services/db.ts index f0e5ceb..ea12ee0 100644 --- a/services/db.ts +++ b/services/db.ts @@ -48,7 +48,6 @@ export type PublicLearningPath = { id: string; title: string; description: string; - kind: 'trail' | 'formation'; coverImageUrl: string | null; coverImageZoom: number; coverImagePositionX: number;