feat: improve learning and platform operations
This commit is contained in:
@@ -42,6 +42,7 @@ const CourseModal: React.FC<{
|
||||
const [title, setTitle] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
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');
|
||||
@@ -76,6 +77,7 @@ const CourseModal: React.FC<{
|
||||
setTitle(initialData.title);
|
||||
setDescription(initialData.description);
|
||||
setCategory(initialData.category);
|
||||
setStatus(initialData.status || 'published');
|
||||
setThumbnailPreview(initialData.thumbnail);
|
||||
setInstructor(initialData.instructor || 'Mário Morgado');
|
||||
setInstructorRole(initialData.instructorRole || 'Especialista em Performance');
|
||||
@@ -86,6 +88,7 @@ const CourseModal: React.FC<{
|
||||
setTitle('');
|
||||
setDescription('');
|
||||
setCategory(CATEGORIES[0]);
|
||||
setStatus('draft');
|
||||
setThumbnailPreview('');
|
||||
setInstructor('Mário Morgado');
|
||||
setInstructorRole('Especialista em Performance');
|
||||
@@ -187,6 +190,7 @@ const CourseModal: React.FC<{
|
||||
title,
|
||||
description,
|
||||
category: category || CATEGORIES[0],
|
||||
status,
|
||||
thumbnail: thumbnailPreview || 'https://images.unsplash.com/photo-1460925895917-afdab827c52f?auto=format&fit=crop&w=800&q=80',
|
||||
duration: durationStr,
|
||||
instructor,
|
||||
@@ -198,8 +202,8 @@ const CourseModal: React.FC<{
|
||||
attachments: attachments
|
||||
});
|
||||
onClose();
|
||||
} catch {
|
||||
alert('We could not save this course. Confirm that you are still signed in and that all asset URLs are valid.');
|
||||
} catch (error) {
|
||||
alert(error instanceof Error ? error.message : 'Não foi possível salvar este curso.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
@@ -275,6 +279,14 @@ const CourseModal: React.FC<{
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 uppercase font-bold tracking-wider mb-1 block">Publicação</label>
|
||||
<select value={status} onChange={(e) => setStatus(e.target.value as Course['status'])} 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">
|
||||
<option value="draft" className="bg-zinc-900">Salvar como rascunho</option>
|
||||
<option value="published" className="bg-zinc-900">Publicar agora</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 uppercase font-bold tracking-wider mb-1 block">Instrutor</label>
|
||||
<input
|
||||
@@ -507,7 +519,7 @@ const CourseModal: React.FC<{
|
||||
disabled={isSubmitting}
|
||||
className="w-full bg-orange-500 hover:bg-orange-600 text-white font-extrabold text-base py-4 rounded-2xl active:scale-95 transition-all shadow-lg shadow-orange-500/25"
|
||||
>
|
||||
{isSubmitting ? 'Salvando...' : (initialData ? 'Salvar Alterações no Curso' : 'Publicar Curso & Materiais')}
|
||||
{isSubmitting ? 'Salvando...' : status === 'draft' ? 'Salvar como Rascunho' : (initialData ? 'Salvar e Publicar Curso' : 'Publicar Curso & Materiais')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -532,21 +544,18 @@ export const ManageCourses: React.FC = () => {
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
const data = await getManagedCourses();
|
||||
setCourses(data);
|
||||
const analyticsResponse = await instructorApi.analytics();
|
||||
setAnalytics(analyticsResponse.data);
|
||||
|
||||
// Load all comments
|
||||
const commentsAccumulator: {comment: Comment, courseTitle: string}[] = [];
|
||||
for (const c of data) {
|
||||
const courseComments = await getComments(c.id);
|
||||
courseComments.forEach(comm => {
|
||||
commentsAccumulator.push({ comment: comm, courseTitle: c.title });
|
||||
});
|
||||
try {
|
||||
const data = await getManagedCourses();
|
||||
setCourses(data);
|
||||
const analyticsResponse = await instructorApi.analytics();
|
||||
setAnalytics(analyticsResponse.data);
|
||||
const commentLists = await Promise.all(data.map(async (course) => ({ course, comments: await getComments(course.id) })));
|
||||
setAllComments(commentLists.flatMap(({ course, comments }) => comments.map((comment) => ({ comment, courseTitle: course.title }))));
|
||||
} catch {
|
||||
showToast('Não foi possível carregar o painel do instrutor.', 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
setAllComments(commentsAccumulator);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -713,6 +722,7 @@ export const ManageCourses: React.FC = () => {
|
||||
<div className="absolute top-3 left-3 bg-black/70 backdrop-blur-md px-2.5 py-1 rounded-[980px] text-caption font-semibold uppercase tracking-caption text-white border border-white/10">
|
||||
{course.category}
|
||||
</div>
|
||||
{course.status === 'draft' && <div className="absolute top-3 right-3 bg-amber-500/20 backdrop-blur-md px-2.5 py-1 rounded-[980px] text-caption font-semibold uppercase tracking-caption text-amber-300 border border-amber-500/30">Rascunho</div>}
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
|
||||
39
pages/MyLearning.tsx
Normal file
39
pages/MyLearning.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { BookOpenCheck, Loader2, PlayCircle } from 'lucide-react';
|
||||
import { Course } from '../types';
|
||||
import { getMyLearningCourses } from '../services/db';
|
||||
|
||||
export const MyLearning: React.FC<{ onPlay: (course: Course) => void }> = ({ onPlay }) => {
|
||||
const [courses, setCourses] = useState<Course[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
getMyLearningCourses().then(setCourses).catch(() => setCourses([])).finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<main className="min-h-screen max-w-[1440px] mx-auto pt-28 pb-20 px-6 md:px-12">
|
||||
<div className="mb-10">
|
||||
<div className="flex items-center gap-2 text-xs font-bold uppercase tracking-wider text-orange-400 mb-3"><BookOpenCheck className="w-4 h-4" /> Minha área</div>
|
||||
<h1 className="text-3xl md:text-4xl font-bold text-white">Continue aprendendo</h1>
|
||||
<p className="mt-2 text-gray-400">Retome suas aulas exatamente de onde parou.</p>
|
||||
</div>
|
||||
{loading ? <div className="h-64 flex justify-center items-center"><Loader2 className="w-8 h-8 animate-spin text-orange-400" /></div> : courses.length === 0 ? (
|
||||
<div className="rounded-2xl border border-white/10 bg-zinc-950/80 py-16 px-6 text-center">
|
||||
<BookOpenCheck className="w-10 h-10 text-orange-400 mx-auto mb-4" />
|
||||
<h2 className="font-bold text-white text-lg">Você ainda não iniciou nenhum curso</h2>
|
||||
<p className="mt-2 text-sm text-gray-400">Escolha uma aula na página de cursos para começar.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-5">
|
||||
{courses.map((course) => (
|
||||
<button key={course.id} onClick={() => onPlay(course)} className="group overflow-hidden rounded-2xl border border-white/10 bg-zinc-950/80 text-left hover:border-orange-500/50 transition-colors">
|
||||
<div className="aspect-video relative bg-zinc-900"><img src={course.thumbnail} alt="" className="h-full w-full object-cover opacity-75 group-hover:opacity-100 transition-opacity" /><div className="absolute inset-0 bg-gradient-to-t from-black/80 to-transparent" /><span className="absolute left-4 bottom-4 inline-flex items-center gap-2 rounded-full bg-orange-500 px-3 py-2 text-xs font-bold text-white"><PlayCircle className="w-4 h-4" /> Continuar</span></div>
|
||||
<div className="p-5"><p className="text-xs text-orange-400 font-semibold uppercase tracking-wider">{course.category}</p><h2 className="mt-2 font-bold text-white">{course.title}</h2><div className="mt-4 h-1.5 overflow-hidden rounded-full bg-white/10"><div className="h-full rounded-full bg-orange-500" style={{ width: `${course.progress || 0}%` }} /></div><p className="mt-2 text-xs text-gray-400">{course.progress || 0}% concluído</p></div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { Loader2, RefreshCw, ShieldCheck, UserCheck, UserX, Users } from 'lucide-react';
|
||||
import { adminApi, ManagedUser, ManagedUserDetail, PlatformAnalytics } from '../services/api';
|
||||
import { 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';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
@@ -25,14 +25,15 @@ export const SuperAdmin: React.FC = () => {
|
||||
const [accessLink, setAccessLink] = useState('');
|
||||
const [selectedUser, setSelectedUser] = useState<ManagedUserDetail | null>(null);
|
||||
const [loadingDetailId, setLoadingDetailId] = useState<string | null>(null);
|
||||
const [auditEntries, setAuditEntries] = useState<AuditEntry[]>([]);
|
||||
|
||||
const loadUsers = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await adminApi.listUsers();
|
||||
const [response, dashboard, auditLog] = await Promise.all([adminApi.listUsers(), adminApi.dashboard(), adminApi.auditLog()]);
|
||||
setUsers(response.data);
|
||||
const dashboard = await adminApi.dashboard();
|
||||
setAnalytics(dashboard.data);
|
||||
setAuditEntries(auditLog.data);
|
||||
} catch {
|
||||
showToast('Não foi possível carregar os usuários.', 'error');
|
||||
} finally {
|
||||
@@ -89,6 +90,12 @@ export const SuperAdmin: React.FC = () => {
|
||||
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 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 (
|
||||
<main className="min-h-screen pt-28 pb-20 px-6 md:px-12 max-w-[1440px] mx-auto">
|
||||
@@ -113,6 +120,7 @@ export const SuperAdmin: React.FC = () => {
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" /> Atualizar
|
||||
</button>
|
||||
<button onClick={exportUsers} 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"><Download className="w-4 h-4" /> Exportar CSV</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -226,6 +234,11 @@ export const SuperAdmin: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
</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>}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user