feat: separate trails and formations in cms
All checks were successful
CI / Validate frontend and API (push) Successful in 41s
CI / Build and publish Docker images (push) Successful in 25s

This commit is contained in:
Cauê Faleiros
2026-09-04 16:32:54 -03:00
parent 79d1c70c44
commit 09d85a7123
6 changed files with 35 additions and 13 deletions

View File

@@ -8,12 +8,12 @@ import { useNavigate } from 'react-router-dom';
const roleLabel: Record<ManagedUser['role'], string> = { admin: 'Superadmin', instructor: 'Instrutor', student: 'Aluno' }; const roleLabel: Record<ManagedUser['role'], string> = { admin: 'Superadmin', instructor: 'Instrutor', student: 'Aluno' };
type CourseOption = { id: string; title: string; status: string }; type CourseOption = { id: string; title: string; status: string };
type HomeConfiguration = { featuredCourseId: string | null; courseOrder: string[]; defaultCoverImageUrl: string | null; courses: CourseOption[] }; 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 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', courseIds: path.courses.map((course) => course.id) }); 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 = () => { export const SuperAdmin: React.FC = () => {
const { user } = useAuth(); const { showToast } = useToast(); const navigate = useNavigate(); 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<ManagedUser[]>([]); const [analytics, setAnalytics] = useState<PlatformAnalytics | null>(null); const [users, setUsers] = useState<ManagedUser[]>([]); const [analytics, setAnalytics] = useState<PlatformAnalytics | null>(null);
const [categories, setCategories] = useState<Array<{ id: string; name: string; position: number; isActive: boolean }>>([]); const [categories, setCategories] = useState<Array<{ id: string; name: string; position: number; isActive: boolean }>>([]);
const [homeConfiguration, setHomeConfiguration] = useState<HomeConfiguration | null>(null); const [paths, setPaths] = useState<LearningPath[]>([]); const [homeConfiguration, setHomeConfiguration] = useState<HomeConfiguration | null>(null); const [paths, setPaths] = useState<LearningPath[]>([]);
@@ -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<string, number>(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 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<string, number>(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 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 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 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 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 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 <main className="min-h-screen pt-28 pb-20 px-6 md:px-12 max-w-[1440px] mx-auto"> return <main className="min-h-screen pt-28 pb-20 px-6 md:px-12 max-w-[1440px] mx-auto">
<div className="flex flex-col md:flex-row md:items-end md:justify-between gap-6 mb-8"><div><div className="flex items-center gap-2 text-orange-400 text-xs font-bold uppercase tracking-wider mb-3"><ShieldCheck className="w-4 h-4" /> Superadmin</div><h1 className="text-3xl md:text-4xl font-bold text-white">Painel Administrativo</h1><p className="text-gray-400 mt-2">Organize conteúdo, formações e acessos da plataforma.</p></div><div className="flex flex-wrap gap-3"><button onClick={() => navigate('/gerenciar')} className="inline-flex items-center justify-center gap-2 rounded-full px-5 py-3 text-sm font-semibold bg-orange-500 hover:bg-orange-600 text-white transition-colors">Gerenciar cursos</button><button onClick={() => void loadData()} className="inline-flex items-center justify-center gap-2 rounded-full px-5 py-3 text-sm font-semibold bg-white/10 hover:bg-white/15 text-white transition-colors"><RefreshCw className="w-4 h-4" /> Atualizar</button></div></div> <div className="flex flex-col md:flex-row md:items-end md:justify-between gap-6 mb-8"><div><div className="flex items-center gap-2 text-orange-400 text-xs font-bold uppercase tracking-wider mb-3"><ShieldCheck className="w-4 h-4" /> Superadmin</div><h1 className="text-3xl md:text-4xl font-bold text-white">Painel Administrativo</h1><p className="text-gray-400 mt-2">Organize conteúdo, formações e acessos da plataforma.</p></div><div className="flex flex-wrap gap-3"><button onClick={() => navigate('/gerenciar')} className="inline-flex items-center justify-center gap-2 rounded-full px-5 py-3 text-sm font-semibold bg-orange-500 hover:bg-orange-600 text-white transition-colors">Gerenciar cursos</button><button onClick={() => void loadData()} className="inline-flex items-center justify-center gap-2 rounded-full px-5 py-3 text-sm font-semibold bg-white/10 hover:bg-white/15 text-white transition-colors"><RefreshCw className="w-4 h-4" /> Atualizar</button></div></div>
<div className="grid grid-cols-2 lg:grid-cols-5 gap-4 mb-8"><StatCard icon={<Users className="w-5 h-5" />} label="Usuários" value={analytics?.totalUsers ?? users.length} /><StatCard icon={<UserCheck className="w-5 h-5" />} label="Contas ativas" value={analytics?.activeUsers ?? activeUsers} /><StatCard icon={<ShieldCheck className="w-5 h-5" />} label="Instrutores" value={instructorUsers} /><StatCard icon={<FolderKanban className="w-5 h-5" />} label="Cursos publicados" value={analytics?.publishedCourses ?? 0} /><StatCard icon={<UserCheck className="w-5 h-5" />} label="Aulas concluídas" value={analytics?.completedLessons ?? 0} /></div> <div className="grid grid-cols-2 lg:grid-cols-5 gap-4 mb-8"><StatCard icon={<Users className="w-5 h-5" />} label="Usuários" value={analytics?.totalUsers ?? users.length} /><StatCard icon={<UserCheck className="w-5 h-5" />} label="Contas ativas" value={analytics?.activeUsers ?? activeUsers} /><StatCard icon={<ShieldCheck className="w-5 h-5" />} label="Instrutores" value={instructorUsers} /><StatCard icon={<FolderKanban className="w-5 h-5" />} label="Cursos publicados" value={analytics?.publishedCourses ?? 0} /><StatCard icon={<UserCheck className="w-5 h-5" />} label="Aulas concluídas" value={analytics?.completedLessons ?? 0} /></div>
<div className="mb-8 flex gap-2 border-b border-white/10"><TabButton active={activeTab === 'content'} onClick={() => setActiveTab('content')} label="Conteúdo" /><TabButton active={activeTab === 'people'} onClick={() => setActiveTab('people')} label="Pessoas e acessos" /></div> <div className="mb-8 flex gap-1 overflow-x-auto border-b border-white/10"><TabButton active={activeTab === 'courses'} onClick={() => setActiveTab('courses')} label="Cursos" /><TabButton active={activeTab === 'trails'} onClick={() => setActiveTab('trails')} label="Trilhas" /><TabButton active={activeTab === 'formations'} onClick={() => setActiveTab('formations')} label="Formações" /><TabButton active={activeTab === 'home'} onClick={() => setActiveTab('home')} label="Página inicial" /><TabButton active={activeTab === 'access'} onClick={() => setActiveTab('access')} label="Acessos" /></div>
{activeTab === 'content' ? <ContentPanel paths={paths} homeConfiguration={homeConfiguration} categories={categories} homeCourses={homeCourses} newCategory={newCategory} setNewCategory={setNewCategory} setHomeConfiguration={setHomeConfiguration} onCreateCategory={createCategory} onToggleCategory={toggleCategory} onMoveHomeCourse={moveHomeCourse} onSaveHome={saveHomeConfiguration} onNewPath={openNewPath} onEditPath={openEditPath} onDeletePath={deletePath} /> : <PeoplePanel users={filteredUsers} isLoading={isLoading} query={query} setQuery={setQuery} accessLink={accessLink} inviteEmail={inviteEmail} setInviteEmail={setInviteEmail} inviteRole={inviteRole} setInviteRole={setInviteRole} selectedUser={selectedUser} setSelectedUser={setSelectedUser} updatingUserId={updatingUserId} loadingDetailId={loadingDetailId} currentUserId={user?.id} onInvite={createInvite} onUpdateUser={updateUser} onShowDetail={showUserDetail} onResetPassword={resetPassword} onExport={exportUsers} />} {activeTab === 'courses' && <CoursesPanel courses={homeConfiguration?.courses || []} onManage={() => navigate('/gerenciar')} />}
{activeTab === 'trails' && <PathManager title="Trilhas" description="Sequências guiadas de cursos existentes." paths={paths.filter((path) => path.kind === 'trail')} onNew={() => openNewPath('trail')} onEdit={openEditPath} onDelete={deletePath} />}
{activeTab === 'formations' && <PathManager title="Formações" description="Currículos organizados a partir de cursos existentes." paths={paths.filter((path) => path.kind === 'formation')} onNew={() => openNewPath('formation')} onEdit={openEditPath} onDelete={deletePath} />}
{activeTab === 'home' && <HomePanel homeConfiguration={homeConfiguration} homeCourses={homeCourses} categories={categories} newCategory={newCategory} setNewCategory={setNewCategory} setHomeConfiguration={setHomeConfiguration} onCreateCategory={createCategory} onToggleCategory={toggleCategory} onMoveHomeCourse={moveHomeCourse} onSaveHome={saveHomeConfiguration} />}
{activeTab === 'access' && <PeoplePanel users={filteredUsers} isLoading={isLoading} query={query} setQuery={setQuery} accessLink={accessLink} inviteEmail={inviteEmail} setInviteEmail={setInviteEmail} inviteRole={inviteRole} setInviteRole={setInviteRole} selectedUser={selectedUser} setSelectedUser={setSelectedUser} updatingUserId={updatingUserId} loadingDetailId={loadingDetailId} currentUserId={user?.id} onInvite={createInvite} onUpdateUser={updateUser} onShowDetail={showUserDetail} onResetPassword={resetPassword} onExport={exportUsers} />}
{isPathEditorOpen && <PathEditor input={pathInput} setInput={setPathInput} courses={homeConfiguration?.courses || []} selectedCourses={selectedPathCourses} editing={Boolean(editingPath)} isSaving={isSavingPath} isUploadingCover={isUploadingCover} coverInputRef={coverInputRef} onClose={closePathEditor} onSave={savePath} onUpload={uploadPathCover} onToggleCourse={togglePathCourse} onMoveCourse={movePathCourse} />} {isPathEditorOpen && <PathEditor input={pathInput} setInput={setPathInput} courses={homeConfiguration?.courses || []} selectedCourses={selectedPathCourses} editing={Boolean(editingPath)} isSaving={isSavingPath} isUploadingCover={isUploadingCover} coverInputRef={coverInputRef} onClose={closePathEditor} onSave={savePath} onUpload={uploadPathCover} onToggleCourse={togglePathCourse} onMoveCourse={movePathCourse} />}
</main>; </main>;
}; };
@@ -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 }) => <button onClick={onClick} className={`border-b-2 px-4 py-3 text-sm font-semibold transition-colors ${active ? 'border-orange-500 text-white' : 'border-transparent text-gray-500 hover:text-gray-300'}`}>{label}</button>; const TabButton: React.FC<{ active: boolean; onClick: () => void; label: string }> = ({ active, onClick, label }) => <button onClick={onClick} className={`border-b-2 px-4 py-3 text-sm font-semibold transition-colors ${active ? 'border-orange-500 text-white' : 'border-transparent text-gray-500 hover:text-gray-300'}`}>{label}</button>;
const StatCard: React.FC<{ icon: React.ReactNode; label: string; value: number }> = ({ icon, label, value }) => <div className="rounded-2xl border border-white/10 bg-zinc-950/80 p-5"><div className="flex items-center gap-2 text-orange-400 mb-3">{icon}<span className="text-xs font-semibold uppercase tracking-wider">{label}</span></div><div className="text-3xl font-bold text-white">{value}</div></div>; const StatCard: React.FC<{ icon: React.ReactNode; label: string; value: number }> = ({ icon, label, value }) => <div className="rounded-2xl border border-white/10 bg-zinc-950/80 p-5"><div className="flex items-center gap-2 text-orange-400 mb-3">{icon}<span className="text-xs font-semibold uppercase tracking-wider">{label}</span></div><div className="text-3xl font-bold text-white">{value}</div></div>;
const DetailMetric: React.FC<{ label: string; value: string }> = ({ label, value }) => <div className="rounded-xl bg-white/[0.03] px-4 py-3"><div className="text-xs text-gray-500">{label}</div><div className="text-sm font-semibold text-white mt-1">{value}</div></div>; const DetailMetric: React.FC<{ label: string; value: string }> = ({ label, value }) => <div className="rounded-xl bg-white/[0.03] px-4 py-3"><div className="text-xs text-gray-500">{label}</div><div className="text-sm font-semibold text-white mt-1">{value}</div></div>;
const CoursesPanel: React.FC<{ courses: CourseOption[]; onManage: () => void }> = ({ courses, onManage }) => <section className="rounded-2xl border border-white/10 bg-zinc-950/80 overflow-hidden"><div className="flex items-center justify-between gap-4 border-b border-white/10 p-5"><div><h2 className="text-lg font-bold text-white">Cursos</h2><p className="mt-1 text-sm text-gray-500">Edite ou exclua cursos rapidamente no gerenciador.</p></div><button onClick={onManage} className="rounded-xl bg-orange-500 px-4 py-2.5 text-sm font-semibold text-white">Gerenciar cursos</button></div><div className="divide-y divide-white/5">{courses.length === 0 ? <p className="p-8 text-sm text-gray-500">Nenhum curso cadastrado.</p> : courses.map((course) => <div key={course.id} className="flex items-center gap-4 p-4"><span className="min-w-0 flex-1 truncate font-medium text-white">{course.title}</span><span className={`rounded-full px-2.5 py-1 text-[10px] font-bold uppercase ${course.status === 'published' ? 'bg-emerald-500/15 text-emerald-400' : 'bg-amber-500/15 text-amber-300'}`}>{course.status === 'published' ? 'Publicado' : 'Rascunho'}</span><button onClick={onManage} className="rounded-lg border border-white/10 px-3 py-1.5 text-xs font-semibold text-gray-300 hover:bg-white/10">Editar</button></div>)}</div></section>;
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 }) => <section className="rounded-2xl border border-white/10 bg-zinc-950/80 overflow-hidden"><div className="flex items-center justify-between gap-4 border-b border-white/10 p-5"><div><h2 className="text-lg font-bold text-white">{title}</h2><p className="mt-1 text-sm text-gray-500">{description}</p></div><button onClick={onNew} className="inline-flex items-center gap-2 rounded-xl bg-orange-500 px-4 py-2.5 text-sm font-semibold text-white"><Plus className="h-4 w-4" /> Nova</button></div><div className="divide-y divide-white/5">{paths.length === 0 ? <div className="p-10 text-center text-sm text-gray-500">Nenhuma {title.toLowerCase()} criada ainda.</div> : paths.map((path) => <div key={path.id} className="flex items-center gap-4 p-4"><div className="h-12 w-20 shrink-0 overflow-hidden rounded-lg bg-zinc-900">{path.coverImageUrl ? <img src={path.coverImageUrl} alt="" className="h-full w-full object-cover" /> : <FolderKanban className="m-3 h-6 w-6 text-orange-400" />}</div><div className="min-w-0 flex-1"><p className="truncate font-semibold text-white">{path.title}</p><p className="mt-1 text-xs text-gray-500">{path.courses.length} {path.courses.length === 1 ? 'curso' : 'cursos'} · {path.status === 'published' ? 'Publicada' : 'Rascunho'}</p></div><button onClick={() => onEdit(path)} className="rounded-lg border border-white/10 p-2 text-gray-300 hover:bg-white/10" title="Editar"><Pencil className="h-4 w-4" /></button><button onClick={() => void onDelete(path)} className="rounded-lg border border-red-500/20 p-2 text-red-400 hover:bg-red-500/10" title="Excluir"><Trash2 className="h-4 w-4" /></button></div>)}</div></section>;
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<React.SetStateAction<HomeConfiguration | null>>; 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 }) => <div className="grid gap-6 xl:grid-cols-[1fr_.8fr]"><section className="rounded-2xl border border-white/10 bg-zinc-950/80 p-5"><h2 className="text-lg font-bold text-white">Página inicial</h2><p className="mb-5 mt-1 text-sm text-gray-500">Defina o conteúdo principal e a ordem dos cursos.</p>{homeConfiguration && <div className="space-y-4"><select value={homeConfiguration.featuredCourseId || ''} onChange={(event) => setHomeConfiguration((current) => current ? { ...current, featuredCourseId: event.target.value || null } : current)} className="w-full rounded-xl border border-white/10 bg-zinc-900 px-3 py-2.5 text-sm text-white"><option value="">Sem conteúdo em destaque</option>{homeConfiguration.courses.filter((course) => course.status === 'published').map((course) => <option key={course.id} value={course.id}>{course.title}</option>)}</select><div className="rounded-xl bg-black/20 p-3">{homeCourses.map((course, index) => <div key={course.id} className="flex items-center gap-2 py-2 text-sm text-gray-300"><span className="w-5 text-xs text-gray-600">{index + 1}</span><span className="flex-1 truncate">{course.title}</span><button onClick={() => onMoveHomeCourse(course.id, -1)} disabled={index === 0} className="p-1 disabled:opacity-20"><ArrowUp className="h-4 w-4" /></button><button onClick={() => onMoveHomeCourse(course.id, 1)} disabled={index === homeCourses.length - 1} className="p-1 disabled:opacity-20"><ArrowDown className="h-4 w-4" /></button></div>)}</div><button onClick={onSaveHome} className="rounded-xl bg-orange-500 px-4 py-2.5 text-sm font-semibold text-white">Salvar página inicial</button></div>}</section><section className="rounded-2xl border border-white/10 bg-zinc-950/80 p-5"><h2 className="text-lg font-bold text-white">Categorias</h2><form onSubmit={onCreateCategory} className="mt-4 flex gap-2"><input value={newCategory} onChange={(event) => 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" /><button className="rounded-xl bg-white/10 px-4 text-sm font-semibold text-white">Adicionar</button></form><div className="mt-4 flex flex-wrap gap-2">{categories.map((category) => <button key={category.id} onClick={() => onToggleCategory(category)} className={`rounded-full px-3 py-1.5 text-xs font-semibold ${category.isActive ? 'bg-emerald-500/15 text-emerald-400' : 'bg-white/10 text-gray-500 line-through'}`}>{category.name}</button>)}</div></section></div>;

View File

@@ -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);

View File

@@ -33,6 +33,7 @@ const learningPathSchema = z.object({
coverImagePositionX: z.number().min(0).max(100).default(50), coverImagePositionX: z.number().min(0).max(100).default(50),
coverImagePositionY: z.number().min(0).max(100).default(50), coverImagePositionY: z.number().min(0).max(100).default(50),
status: z.enum(['draft', 'published']).default('draft'), status: z.enum(['draft', 'published']).default('draft'),
kind: z.enum(['trail', 'formation']).default('trail'),
courseIds: z.array(z.string().uuid()).min(1).max(100), 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_zoom as "coverImageZoom",
p.cover_image_position_x as "coverImagePositionX", p.cover_image_position_x as "coverImagePositionX",
p.cover_image_position_y as "coverImagePositionY", 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( coalesce(jsonb_agg(jsonb_build_object(
'id', c.id, 'title', c.title, 'status', c.status, 'id', c.id, 'title', c.title, 'status', c.status,
'coverImageUrl', c.cover_image_url, 'position', pc.position '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 courseIds = await validatePathCourses(client, input);
const path = await client.query<{ id: string }>( const path = await client.query<{ id: string }>(
`insert into learning_paths ( `insert into learning_paths (
title, description, cover_image_url, cover_image_zoom, cover_image_position_x, cover_image_position_y, status, published_at 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, case when $7 = 'published' then now() else null end) ) values ($1, $2, $3, $4, $5, $6, $7::course_status, $8, case when $7 = 'published' then now() else null end)
returning id`, 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 writePathCourses(client, path.rows[0].id, courseIds);
await client.query('commit'); await client.query('commit');
@@ -155,11 +156,11 @@ export const adminRoutes: FastifyPluginAsync = async (app) => {
const path = await client.query<{ id: string }>( const path = await client.query<{ id: string }>(
`update learning_paths set `update learning_paths set
title = $2, description = $3, cover_image_url = $4, cover_image_zoom = $5, 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 published_at = case when $8 = 'published' then coalesce(published_at, now()) else null end
where id = $1 where id = $1
returning id`, 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]) { if (!path.rows[0]) {
await client.query('rollback'); await client.query('rollback');

View File

@@ -153,7 +153,7 @@ export const courseRoutes: FastifyPluginAsync = async (app) => {
app.get('/paths', async () => { app.get('/paths', async () => {
const result = await pool.query( const result = await pool.query(
`select `select
p.id, p.title, p.description, p.id, p.title, p.description, p.kind,
coalesce(p.cover_image_url, ( coalesce(p.cover_image_url, (
select value #>> '{}' from platform_settings where key = 'media.defaultCoverImageUrl' select value #>> '{}' from platform_settings where key = 'media.defaultCoverImageUrl'
)) as "coverImageUrl", )) as "coverImageUrl",

View File

@@ -43,6 +43,7 @@ export interface LearningPath {
coverImagePositionX: number; coverImagePositionX: number;
coverImagePositionY: number; coverImagePositionY: number;
status: 'draft' | 'published' | 'archived'; status: 'draft' | 'published' | 'archived';
kind: 'trail' | 'formation';
publishedAt: string | null; publishedAt: string | null;
createdAt: string; createdAt: string;
courses: LearningPathCourse[]; courses: LearningPathCourse[];
@@ -55,6 +56,7 @@ export interface LearningPathInput {
coverImagePositionX: number; coverImagePositionX: number;
coverImagePositionY: number; coverImagePositionY: number;
status: 'draft' | 'published'; status: 'draft' | 'published';
kind: 'trail' | 'formation';
courseIds: string[]; courseIds: string[];
} }

View File

@@ -48,6 +48,7 @@ export type PublicLearningPath = {
id: string; id: string;
title: string; title: string;
description: string; description: string;
kind: 'trail' | 'formation';
coverImageUrl: string | null; coverImageUrl: string | null;
coverImageZoom: number; coverImageZoom: number;
coverImagePositionX: number; coverImagePositionX: number;