feat: complete platform operations roadmap
This commit is contained in:
@@ -24,13 +24,13 @@ import {
|
||||
Unlock,
|
||||
Globe,
|
||||
Download
|
||||
,Copy, ChevronUp, ChevronDown
|
||||
} from 'lucide-react';
|
||||
import { Course, Comment, Attachment, Lesson, AttachmentType, LessonMedia } from '../types';
|
||||
import { useToast } from '../context/ToastContext';
|
||||
import { getManagedCourses, saveCourse, deleteCourse, getComments, replyToComment, moderateComment } from '../services/db';
|
||||
import { CATEGORIES } from '../constants';
|
||||
import { getManagedCourses, saveCourse, deleteCourse, duplicateCourse, getComments, replyToComment, moderateComment } from '../services/db';
|
||||
import { getAttachmentIcon, getAttachmentBadge } from '../components/MaterialCard';
|
||||
import { BunnyVideo, instructorApi, InstructorAnalytics } from '../services/api';
|
||||
import { BunnyVideo, courseApi, instructorApi, InstructorAnalytics } from '../services/api';
|
||||
|
||||
// 1. Create/Edit Course Modal
|
||||
const CourseModal: React.FC<{
|
||||
@@ -44,8 +44,7 @@ const CourseModal: React.FC<{
|
||||
const [category, setCategory] = useState('');
|
||||
const [status, setStatus] = useState<Course['status']>('draft');
|
||||
const [thumbnailPreview, setThumbnailPreview] = useState<string>('');
|
||||
const [instructor, setInstructor] = useState('Mário Morgado');
|
||||
const [instructorRole, setInstructorRole] = useState('Especialista em Performance');
|
||||
const [categories, setCategories] = useState<string[]>([]);
|
||||
|
||||
// Lesson Management
|
||||
const [lessons, setLessons] = useState<Lesson[]>([]);
|
||||
@@ -83,19 +82,15 @@ const CourseModal: React.FC<{
|
||||
setCategory(initialData.category);
|
||||
setStatus(initialData.status || 'published');
|
||||
setThumbnailPreview(initialData.thumbnail);
|
||||
setInstructor(initialData.instructor || 'Mário Morgado');
|
||||
setInstructorRole(initialData.instructorRole || 'Especialista em Performance');
|
||||
setTips(initialData.tips || []);
|
||||
setAttachments(initialData.attachments || []);
|
||||
setLessons(initialData.lessons || []);
|
||||
} else {
|
||||
setTitle('');
|
||||
setDescription('');
|
||||
setCategory(CATEGORIES[0]);
|
||||
setCategory('');
|
||||
setStatus('draft');
|
||||
setThumbnailPreview('');
|
||||
setInstructor('Mário Morgado');
|
||||
setInstructorRole('Especialista em Performance');
|
||||
setTips([]);
|
||||
setAttachments([]);
|
||||
setLessons([]);
|
||||
@@ -106,6 +101,12 @@ const CourseModal: React.FC<{
|
||||
instructorApi.bunnyConfiguration()
|
||||
.then((response) => setBunnyConfigured(response.data.configured))
|
||||
.catch(() => setBunnyConfigured(false));
|
||||
courseApi.categories()
|
||||
.then((response) => {
|
||||
setCategories(response.data);
|
||||
if (!initialData) setCategory((current) => current || response.data[0] || '');
|
||||
})
|
||||
.catch(() => setCategories([]));
|
||||
}
|
||||
}, [isOpen, initialData]);
|
||||
|
||||
@@ -242,6 +243,17 @@ const CourseModal: React.FC<{
|
||||
setLessons(lessons.map(l => l.id === id ? { ...l, isFree: !l.isFree } : l));
|
||||
};
|
||||
|
||||
const moveLesson = (id: string, direction: -1 | 1) => {
|
||||
setLessons((current) => {
|
||||
const index = current.findIndex((lesson) => lesson.id === id);
|
||||
const nextIndex = index + direction;
|
||||
if (index < 0 || nextIndex < 0 || nextIndex >= current.length) return current;
|
||||
const next = [...current];
|
||||
[next[index], next[nextIndex]] = [next[nextIndex], next[index]];
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddTip = () => {
|
||||
if (newTip.trim()) {
|
||||
setTips([...tips, newTip.trim()]);
|
||||
@@ -295,12 +307,11 @@ const CourseModal: React.FC<{
|
||||
id: initialData?.id || `course-${Date.now()}`,
|
||||
title,
|
||||
description,
|
||||
category: category || CATEGORIES[0],
|
||||
category: category || categories[0],
|
||||
status,
|
||||
thumbnail: thumbnailPreview || 'https://images.unsplash.com/photo-1460925895917-afdab827c52f?auto=format&fit=crop&w=800&q=80',
|
||||
thumbnail: thumbnailPreview || '/course-placeholder.svg',
|
||||
duration: durationStr,
|
||||
instructor,
|
||||
instructorRole,
|
||||
instructor: initialData?.instructor || '',
|
||||
views: initialData?.views || 0,
|
||||
progress: initialData?.progress || 0,
|
||||
lessons: lessons,
|
||||
@@ -379,7 +390,7 @@ const CourseModal: React.FC<{
|
||||
onChange={(e) => setCategory(e.target.value)}
|
||||
className="w-full bg-black/50 text-white border border-white/10 rounded-xl px-3 py-2.5 text-xs focus:outline-none focus:border-orange-500/50"
|
||||
>
|
||||
{CATEGORIES.map(cat => (
|
||||
{categories.map(cat => (
|
||||
<option key={cat} value={cat} className="bg-zinc-900">{cat}</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -393,16 +404,6 @@ const CourseModal: React.FC<{
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 uppercase font-bold tracking-wider mb-1 block">Instrutor</label>
|
||||
<input
|
||||
type="text"
|
||||
value={instructor}
|
||||
onChange={(e) => setInstructor(e.target.value)}
|
||||
placeholder="Nome do Instrutor"
|
||||
className="w-full bg-black/50 text-white border border-white/10 rounded-xl px-3 py-2.5 text-xs focus:outline-none focus:border-orange-500/50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -551,6 +552,8 @@ const CourseModal: React.FC<{
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<button type="button" disabled={index === 0} onClick={() => moveLesson(lesson.id, -1)} className="p-1 text-gray-400 hover:text-white disabled:opacity-30" title="Mover aula para cima"><ChevronUp className="w-4 h-4" /></button>
|
||||
<button type="button" disabled={index === lessons.length - 1} onClick={() => moveLesson(lesson.id, 1)} className="p-1 text-gray-400 hover:text-white disabled:opacity-30" title="Mover aula para baixo"><ChevronDown className="w-4 h-4" /></button>
|
||||
{lesson.media?.[0]?.provider === 'bunny' && lesson.media[0].status !== 'ready' && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -728,6 +731,16 @@ export const ManageCourses: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDuplicate = async (id: string) => {
|
||||
try {
|
||||
await duplicateCourse(id);
|
||||
showToast('Cópia criada como rascunho.', 'success');
|
||||
loadData();
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : 'Não foi possível duplicar este curso.', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async (course: Course) => {
|
||||
await saveCourse(course);
|
||||
showToast('Curso e materiais salvos com sucesso!', 'success');
|
||||
@@ -906,6 +919,8 @@ export const ManageCourses: React.FC = () => {
|
||||
Editar Conteúdo
|
||||
</button>
|
||||
|
||||
<button onClick={() => void handleDuplicate(course.id)} className="p-2.5 rounded-[980px] bg-white/5 hover:bg-white/10 text-gray-400 hover:text-white transition-colors" title="Duplicar curso"><Copy className="w-4 h-4" /></button>
|
||||
|
||||
<button
|
||||
onClick={() => handleDelete(course.id)}
|
||||
className="p-2.5 rounded-[980px] bg-white/5 hover:bg-red-500/20 text-gray-400 hover:text-red-400 transition-colors"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { Download, Loader2, RefreshCw, ScrollText, ShieldCheck, UserCheck, UserX, Users } from 'lucide-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 { useAuth } from '../context/AuthContext';
|
||||
import { useToast } from '../context/ToastContext';
|
||||
@@ -26,14 +26,20 @@ export const SuperAdmin: React.FC = () => {
|
||||
const [selectedUser, setSelectedUser] = useState<ManagedUserDetail | null>(null);
|
||||
const [loadingDetailId, setLoadingDetailId] = useState<string | null>(null);
|
||||
const [auditEntries, setAuditEntries] = useState<AuditEntry[]>([]);
|
||||
const [auditQuery, setAuditQuery] = useState('');
|
||||
const [categories, setCategories] = useState<Array<{ id: string; name: string; position: number; isActive: boolean }>>([]);
|
||||
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 loadUsers = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const [response, dashboard, auditLog] = await Promise.all([adminApi.listUsers(), adminApi.dashboard(), adminApi.auditLog()]);
|
||||
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 {
|
||||
@@ -52,6 +58,16 @@ export const SuperAdmin: React.FC = () => {
|
||||
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<string, number>(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<Pick<ManagedUser, 'role' | 'isActive'>>) => {
|
||||
setUpdatingUserId(account.id);
|
||||
@@ -79,6 +95,42 @@ export const SuperAdmin: React.FC = () => {
|
||||
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<string, number>(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 {
|
||||
@@ -95,6 +147,11 @@ export const SuperAdmin: React.FC = () => {
|
||||
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<string, string> = { '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 (
|
||||
@@ -142,6 +199,33 @@ export const SuperAdmin: React.FC = () => {
|
||||
{accessLink && <div className="mt-4 flex gap-2"><input readOnly value={accessLink} className="flex-1 rounded-xl bg-black border border-white/10 px-3 py-2 text-xs text-gray-300" /><button onClick={() => navigator.clipboard.writeText(accessLink)} className="rounded-xl bg-white/10 px-4 text-xs font-semibold">Copiar</button></div>}
|
||||
</section>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-8">
|
||||
<section className="rounded-2xl border border-white/10 bg-zinc-950/80 p-5">
|
||||
<h2 className="text-lg font-bold text-white mb-1">Categorias de cursos</h2>
|
||||
<p className="text-xs text-gray-500 mb-4">Estas opções aparecem no editor de cursos e na biblioteca.</p>
|
||||
<form onSubmit={createCategory} className="flex gap-2 mb-4">
|
||||
<input value={newCategory} onChange={(event) => 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" />
|
||||
<button className="rounded-xl bg-orange-500 hover:bg-orange-600 px-4 text-sm font-semibold text-white">Adicionar</button>
|
||||
</form>
|
||||
<div className="space-y-2 max-h-56 overflow-y-auto pr-1">
|
||||
{categories.map((category) => <div key={category.id} className="flex items-center justify-between gap-3 rounded-xl bg-white/[0.03] px-3 py-2 text-sm"><span className={category.isActive ? 'text-gray-200' : 'text-gray-600 line-through'}>{category.name}</span><button onClick={() => void toggleCategory(category)} className={`rounded-full px-3 py-1 text-xs font-semibold ${category.isActive ? 'bg-emerald-500/15 text-emerald-400' : 'bg-white/10 text-gray-400'}`}>{category.isActive ? 'Ativa' : 'Inativa'}</button></div>)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-2xl border border-white/10 bg-zinc-950/80 p-5">
|
||||
<h2 className="text-lg font-bold text-white mb-1">Página inicial</h2>
|
||||
<p className="text-xs text-gray-500 mb-4">Defina o curso em destaque e a imagem padrão quando um curso não tem capa.</p>
|
||||
{homeConfiguration && <div className="space-y-3">
|
||||
<select value={homeConfiguration.featuredCourseId || ''} onChange={(event) => setHomeConfiguration((current) => current ? { ...current, featuredCourseId: event.target.value || null } : current)} className="w-full rounded-xl bg-zinc-900 border border-white/10 px-3 py-2 text-sm text-white"><option value="">Nenhum curso em destaque</option>{homeConfiguration.courses.filter((course) => course.status === 'published').map((course) => <option key={course.id} value={course.id}>{course.title}</option>)}</select>
|
||||
<input value={homeConfiguration.defaultCoverImageUrl || ''} onChange={(event) => 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" />
|
||||
<div className="max-h-32 space-y-1 overflow-y-auto rounded-xl bg-black/20 p-2">
|
||||
{homeCourses.map((course, index) => <div key={course.id} className="flex items-center gap-2 text-xs text-gray-300"><span className="flex-1 truncate">{index + 1}. {course.title}</span><button onClick={() => moveHomeCourse(course.id, -1)} disabled={index === 0} className="p-1 disabled:opacity-30"><ChevronUp className="w-3.5 h-3.5" /></button><button onClick={() => moveHomeCourse(course.id, 1)} disabled={index === homeCourses.length - 1} className="p-1 disabled:opacity-30"><ChevronDown className="w-3.5 h-3.5" /></button></div>)}
|
||||
</div>
|
||||
<button onClick={() => void saveHomeConfiguration()} className="rounded-xl bg-white/10 hover:bg-white/15 px-4 py-2 text-sm font-semibold text-white">Salvar página inicial</button>
|
||||
</div>}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{selectedUser && (
|
||||
<section className="rounded-2xl border border-orange-500/30 bg-zinc-950/80 p-5 mb-8">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
@@ -236,8 +320,8 @@ export const SuperAdmin: React.FC = () => {
|
||||
</section>
|
||||
|
||||
<section className="rounded-2xl border border-white/10 bg-zinc-950/80 overflow-hidden mt-8">
|
||||
<div className="p-5 border-b border-white/10 flex items-center gap-2"><ScrollText className="w-5 h-5 text-orange-400" /><h2 className="text-lg font-bold text-white">Registro de atividades</h2></div>
|
||||
{auditEntries.length === 0 ? <p className="px-5 py-10 text-sm text-gray-500">Nenhuma atividade administrativa registrada ainda.</p> : <div className="divide-y divide-white/5">{auditEntries.map((entry) => <div key={entry.id} className="p-4 flex flex-col sm:flex-row sm:items-center gap-1 sm:justify-between text-sm"><div><span className="font-semibold text-white">{auditLabel[entry.action] || entry.action}</span><span className="text-gray-500"> · {entry.actorName}</span></div><time className="text-xs text-gray-500">{new Intl.DateTimeFormat('pt-BR', { dateStyle: 'short', timeStyle: 'short' }).format(new Date(entry.createdAt))}</time></div>)}</div>}
|
||||
<div className="p-5 border-b border-white/10 flex flex-col sm:flex-row gap-3 sm:items-center sm:justify-between"><div className="flex items-center gap-2"><ScrollText className="w-5 h-5 text-orange-400" /><h2 className="text-lg font-bold text-white">Registro de atividades</h2></div><div className="flex gap-2"><input value={auditQuery} onChange={(event) => 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" /><button onClick={exportAudit} className="rounded-xl bg-white/10 px-3 text-xs font-semibold text-white">CSV</button></div></div>
|
||||
{filteredAuditEntries.length === 0 ? <p className="px-5 py-10 text-sm text-gray-500">Nenhuma atividade administrativa encontrada.</p> : <div className="divide-y divide-white/5">{filteredAuditEntries.map((entry) => <div key={entry.id} className="p-4 flex flex-col sm:flex-row sm:items-center gap-1 sm:justify-between text-sm"><div><span className="font-semibold text-white">{auditLabel[entry.action] || entry.action}</span><span className="text-gray-500"> · {entry.actorName}</span></div><time className="text-xs text-gray-500">{new Intl.DateTimeFormat('pt-BR', { dateStyle: 'short', timeStyle: 'short' }).format(new Date(entry.createdAt))}</time></div>)}</div>}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user