|
|
|
|
@@ -4,9 +4,10 @@ import { adminApi, instructorApi, HomeBanner, HomeBannerInput, LearningPath, Lea
|
|
|
|
|
import { useAuth } from '../context/AuthContext';
|
|
|
|
|
import { useToast } from '../context/ToastContext';
|
|
|
|
|
import { useNavigate } from 'react-router-dom';
|
|
|
|
|
import { CourseCoverImage } from '../components/CourseCoverImage';
|
|
|
|
|
|
|
|
|
|
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; category: string; coverImageUrl: string | null };
|
|
|
|
|
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) });
|
|
|
|
|
@@ -49,7 +50,7 @@ export const SuperAdmin: React.FC = () => {
|
|
|
|
|
<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-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 === 'home'} onClick={() => setActiveTab('home')} label="Página inicial" /><TabButton active={activeTab === 'access'} onClick={() => setActiveTab('access')} label="Acessos" /></div>
|
|
|
|
|
{activeTab === 'courses' && <CoursesPanel courses={homeConfiguration?.courses || []} onManage={() => navigate('/gerenciar')} />}
|
|
|
|
|
{activeTab === 'trails' && <><PathManager title="Trilhas" description="Sequências guiadas de cursos existentes." paths={paths} onNew={openNewPath} onEdit={openEditPath} onDelete={deletePath} /><CategoryManager categories={categories} newCategory={newCategory} setNewCategory={setNewCategory} onCreate={createCategory} onToggle={toggleCategory} /></>}
|
|
|
|
|
{activeTab === 'trails' && <TrailManager trails={categories} newTrail={newCategory} setNewTrail={setNewCategory} onCreate={createCategory} onToggle={toggleCategory} />}
|
|
|
|
|
{activeTab === 'home' && <><BannerManager courses={homeConfiguration?.courses || []} /><HomepageConfigurationPanel homeConfiguration={homeConfiguration} homeCourses={homeCourses} setHomeConfiguration={setHomeConfiguration} 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} />}
|
|
|
|
|
@@ -66,6 +67,8 @@ const TabButton: React.FC<{ active: boolean; onClick: () => void; label: string
|
|
|
|
|
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 TrailManager: React.FC<{ trails: Array<{ id: string; name: string; position: number; isActive: boolean }>; newTrail: string; setNewTrail: (value: string) => void; onCreate: (event: React.FormEvent) => void; onToggle: (trail: { id: string; name: string; position: number; isActive: boolean }) => void }> = ({ trails, newTrail, setNewTrail, onCreate, onToggle }) => <section className="rounded-2xl border border-white/10 bg-zinc-950/80 overflow-hidden"><div className="flex items-center justify-between border-b border-white/10 p-5"><div><h2 className="text-lg font-bold text-white">Trilhas</h2><p className="mt-1 text-sm text-gray-500">Organize os temas que agrupam os cursos da plataforma.</p></div></div><form onSubmit={onCreate} className="flex max-w-xl gap-2 p-5 pb-4"><input value={newTrail} onChange={(event) => setNewTrail(event.target.value)} placeholder="Nome da nova trilha" 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-orange-500 px-4 text-sm font-semibold text-white">Nova trilha</button></form><div className="divide-y divide-white/5">{trails.length === 0 ? <p className="p-10 text-center text-sm text-gray-500">Nenhuma trilha criada ainda.</p> : trails.map((trail) => <div key={trail.id} className="flex items-center gap-3 px-5 py-3"><span className="flex-1 font-medium text-white">{trail.name}</span><button onClick={() => onToggle(trail)} className={`rounded-full px-3 py-1.5 text-xs font-semibold ${trail.isActive ? 'bg-emerald-500/15 text-emerald-400' : 'bg-white/10 text-gray-500'}`}>{trail.isActive ? 'Ativa' : 'Inativa'}</button></div>)}</div></section>;
|
|
|
|
|
|
|
|
|
|
const CategoryManager: React.FC<{ categories: Array<{ id: string; name: string; position: number; isActive: boolean }>; newCategory: string; setNewCategory: (value: string) => void; onCreate: (event: React.FormEvent) => void; onToggle: (category: { id: string; name: string; position: number; isActive: boolean }) => void }> = ({ categories, newCategory, setNewCategory, onCreate, onToggle }) => <section className="mt-6 rounded-2xl border border-white/10 bg-zinc-950/80 p-5"><h2 className="text-lg font-bold text-white">Categorias das trilhas</h2><p className="mt-1 text-sm text-gray-500">Organize os temas disponíveis para cursos e trilhas.</p><form onSubmit={onCreate} className="mt-4 flex max-w-xl gap-2"><input value={newCategory} onChange={(event) => setNewCategory(event.target.value)} placeholder="Nome da 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 hover:bg-white/15">Adicionar</button></form><div className="mt-4 flex flex-wrap gap-2">{categories.map((category) => <button key={category.id} onClick={() => onToggle(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>;
|
|
|
|
|
|
|
|
|
|
const HomepageConfigurationPanel: React.FC<{ homeConfiguration: HomeConfiguration | null; homeCourses: CourseOption[]; setHomeConfiguration: React.Dispatch<React.SetStateAction<HomeConfiguration | null>>; onMoveHomeCourse: (courseId: string, direction: -1 | 1) => void; onSaveHome: () => void }> = ({ homeConfiguration, homeCourses, setHomeConfiguration, onMoveHomeCourse, onSaveHome }) => <section className="rounded-2xl border border-white/10 bg-zinc-950/80 p-5"><h2 className="text-lg font-bold text-white">Vitrine de cursos</h2><p className="mb-5 mt-1 text-sm text-gray-500">Defina a ordem dos cursos abaixo dos banners.</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 curso em destaque adicional</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 vitrine</button></div>}</section>;
|
|
|
|
|
@@ -81,7 +84,13 @@ const BannerManager: React.FC<{ courses: CourseOption[] }> = ({ courses }) => {
|
|
|
|
|
return <section className="mb-6 rounded-2xl border border-white/10 bg-zinc-950/80 p-5"><div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"><div><h2 className="text-lg font-bold text-white">Banners da página inicial</h2><p className="mt-1 text-sm text-gray-500">Envie imagens próprias ou use a capa de cursos publicados. Mais de um banner vira um carrossel.</p></div><button onClick={save} disabled={saving || loading} className="rounded-xl bg-orange-500 px-4 py-2.5 text-sm font-semibold text-white disabled:opacity-50">{saving ? 'Salvando...' : 'Salvar banners'}</button></div><div className="mt-5 grid gap-3 md:grid-cols-2">{banners.map((banner, index) => <div key={banner.id} className="flex gap-3 rounded-xl border border-white/10 bg-black/20 p-3"><div className="h-16 w-28 shrink-0 overflow-hidden rounded-lg bg-zinc-900">{(banner.imageUrl || banner.courseImageUrl) && <img src={banner.imageUrl || banner.courseImageUrl || ''} alt="" className="h-full w-full object-cover" />}</div><div className="min-w-0 flex-1"><p className="truncate text-sm font-semibold text-white">{banner.kind === 'course' ? banner.courseTitle : banner.title || 'Banner personalizado'}</p><p className="mt-1 text-xs text-gray-500">{banner.kind === 'course' ? 'Capa do curso' : 'Imagem enviada'}</p><div className="mt-2 flex gap-1"><button onClick={() => move(index, -1)} disabled={index === 0} className="rounded bg-white/10 p-1 disabled:opacity-20"><ArrowUp className="h-3.5 w-3.5" /></button><button onClick={() => move(index, 1)} disabled={index === banners.length - 1} className="rounded bg-white/10 p-1 disabled:opacity-20"><ArrowDown className="h-3.5 w-3.5" /></button><button onClick={() => remove(banner.id)} className="rounded bg-red-500/15 p-1 text-red-400"><Trash2 className="h-3.5 w-3.5" /></button></div></div></div>)}</div><div className="mt-4 flex flex-col gap-2 sm:flex-row"><input ref={fileInput} type="file" accept="image/jpeg,image/png,image/webp" className="hidden" onChange={(event) => { const file = event.target.files?.[0]; if (file) void upload(file); event.currentTarget.value = ''; }} /><button onClick={() => fileInput.current?.click()} className="rounded-xl border border-dashed border-orange-500/50 px-4 py-2.5 text-sm font-semibold text-orange-300">Enviar imagem de banner</button><select defaultValue="" onChange={(event) => { addCourse(event.target.value); event.currentTarget.value = ''; }} className="min-w-0 flex-1 rounded-xl border border-white/10 bg-zinc-900 px-3 py-2.5 text-sm text-white"><option value="">Adicionar capa de um curso publicado</option>{courses.filter((course) => course.status === 'published').map((course) => <option key={course.id} value={course.id}>{course.title}</option>)}</select></div></section>;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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 CoursesPanel: React.FC<{ courses: CourseOption[]; onManage: () => void }> = ({ courses, onManage }) => {
|
|
|
|
|
const byTrail: Record<string, CourseOption[]> = {};
|
|
|
|
|
courses.forEach((course) => {
|
|
|
|
|
(byTrail[course.category] ||= []).push(course);
|
|
|
|
|
});
|
|
|
|
|
return <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">Visualize cada curso pela sua capa e Trilha.</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>{courses.length === 0 ? <p className="p-8 text-sm text-gray-500">Nenhum curso cadastrado.</p> : <div className="divide-y divide-white/10">{Object.entries(byTrail).sort(([a], [b]) => a.localeCompare(b, 'pt-BR')).map(([trail, trailCourses]) => <section key={trail} className="p-5"><div className="mb-3 flex items-center gap-2"><h3 className="font-semibold text-orange-300">{trail}</h3><span className="text-xs text-gray-600">{trailCourses.length} {trailCourses.length === 1 ? 'curso' : 'cursos'}</span></div><div className="grid gap-3 xl:grid-cols-2">{trailCourses.map((course) => <div key={course.id} className="flex items-center gap-3 rounded-xl border border-white/10 bg-black/20 p-3"><div className="h-14 w-24 shrink-0 overflow-hidden rounded-lg bg-zinc-900">{course.coverImageUrl ? <CourseCoverImage source={course.coverImageUrl} alt="" className="h-full w-full object-cover" /> : <div className="h-full w-full bg-gradient-to-br from-zinc-800 to-zinc-950" />}</div><div className="min-w-0 flex-1"><p className="truncate font-semibold text-white">{course.title}</p><span className={`mt-1 inline-block rounded-full px-2 py-0.5 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></div><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>)}</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>;
|
|
|
|
|
|
|
|
|
|
|