685 lines
32 KiB
TypeScript
685 lines
32 KiB
TypeScript
import React, { useState } from 'react';
|
|
import { SchoolData, Class } from '../types';
|
|
import { useDialog } from '../DialogContext';
|
|
import { Plus, Edit2, Trash2, X, Clock, User, Book, Printer, RefreshCw, Calendar, Settings } from 'lucide-react';
|
|
import { pdfService } from '../services/pdfService';
|
|
import LessonSchedule from './LessonSchedule';
|
|
import { dbService } from '../services/dbService';
|
|
|
|
interface ClassesProps {
|
|
data: SchoolData;
|
|
updateData: (newData: Partial<SchoolData>) => void;
|
|
onNavigateToClass: (classId: string, studentId?: string) => void;
|
|
}
|
|
|
|
const Classes: React.FC<ClassesProps> = ({ data, updateData, onNavigateToClass }) => {
|
|
const { showAlert, showConfirm } = useDialog();
|
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
|
const [isClosing, setIsClosing] = useState(false);
|
|
const [editingClass, setEditingClass] = useState<Class | null>(null);
|
|
const [isGeneratingPDF, setIsGeneratingPDF] = useState<string | null>(null);
|
|
const [scheduleClass, setScheduleClass] = useState<Class | null>(null); // For LessonSchedule component
|
|
const [viewingStudentsClass, setViewingStudentsClass] = useState<Class | null>(null); // For student list modal
|
|
|
|
const normalizePhotoUrl = (url?: string) => {
|
|
if (!url || typeof url !== 'string') return '';
|
|
if (url.startsWith('data:image') || url.startsWith('blob:')) return url;
|
|
if (url.startsWith('/storage/')) return url;
|
|
|
|
try {
|
|
const match = url.match(/^https?:\/\/[^\/]+\/(.+)$/);
|
|
if (match) return `/storage/${match[1]}`;
|
|
} catch(e) {}
|
|
|
|
return url;
|
|
};
|
|
|
|
const [classes, setClasses] = useState<Class[]>([]);
|
|
const [courses, setCourses] = useState<any[]>([]); // To display names correctly
|
|
const [employees, setEmployees] = useState<any[]>([]); // To display names
|
|
const [isLoadingData, setIsLoadingData] = useState(true);
|
|
|
|
const loadData = async () => {
|
|
try {
|
|
setIsLoadingData(true);
|
|
const [clsRes, crsRes, empRes] = await Promise.all([
|
|
fetch('/api/turmas'),
|
|
fetch('/api/cursos'),
|
|
fetch('/api/funcionarios')
|
|
]);
|
|
const clsData = await clsRes.json();
|
|
const crsData = await crsRes.json();
|
|
const empData = await empRes.json();
|
|
|
|
const mappedClasses = (clsData.turmas || []).map((t: any) => ({
|
|
id: t.id,
|
|
name: t.nome,
|
|
courseId: t.curso_id,
|
|
teacher: t.professor,
|
|
schedule: t.horario,
|
|
scheduleDay: t.dia_semana,
|
|
maxStudents: Number(t.max_alunos || 0),
|
|
startDate: t.data_inicio ? t.data_inicio.substring(0, 10) : '',
|
|
endDate: t.data_fim ? t.data_fim.substring(0, 10) : '',
|
|
defaultStartTime: t.horario_inicio_padrao,
|
|
defaultEndTime: t.horario_fim_padrao
|
|
}));
|
|
|
|
setClasses(mappedClasses);
|
|
setCourses(crsData.cursos || []);
|
|
setEmployees(empData.funcionarios || []);
|
|
} catch (err) {
|
|
console.error('Erro ao buscar turmas/cursos/funcionários:', err);
|
|
showAlert('Erro', 'Falha ao carregar dados do servidor.', 'error');
|
|
} finally {
|
|
setIsLoadingData(false);
|
|
}
|
|
};
|
|
|
|
React.useEffect(() => {
|
|
loadData();
|
|
}, []);
|
|
|
|
const [formData, setFormData] = useState<Omit<Class, 'id'>>({
|
|
name: '',
|
|
courseId: '',
|
|
teacher: '',
|
|
schedule: '',
|
|
scheduleDay: '',
|
|
maxStudents: 15,
|
|
startDate: '',
|
|
endDate: '',
|
|
defaultStartTime: '',
|
|
defaultEndTime: ''
|
|
});
|
|
|
|
const DAY_NAMES = ['Domingo', 'Segunda-feira', 'Terça-feira', 'Quarta-feira', 'Quinta-feira', 'Sexta-feira', 'Sábado'];
|
|
|
|
const [quickTimeClass, setQuickTimeClass] = useState<Class | null>(null);
|
|
const [quickStartTime, setQuickStartTime] = useState('');
|
|
const [quickEndTime, setQuickEndTime] = useState('');
|
|
|
|
// Auto-calculate end date based on course durationMonths
|
|
React.useEffect(() => {
|
|
if (formData.courseId && formData.startDate) {
|
|
const course = courses.find((c: any) => c.id === formData.courseId);
|
|
if (course && course.duracao_meses) {
|
|
const start = new Date(formData.startDate + 'T12:00:00Z');
|
|
const end = new Date(start);
|
|
end.setUTCMonth(end.getUTCMonth() + course.duracao_meses);
|
|
const endString = end.toISOString().split('T')[0];
|
|
setFormData(prev => ({ ...prev, endDate: endString }));
|
|
}
|
|
}
|
|
}, [formData.courseId, formData.startDate, courses]);
|
|
|
|
const handleSubmit = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
|
|
if (!formData.name || !formData.courseId || !formData.teacher) {
|
|
showAlert('Atenção', '⚠️ Por favor, preencha todos os campos obrigatórios.', 'warning');
|
|
return;
|
|
}
|
|
|
|
const todayStr = new Date().toISOString().split('T')[0];
|
|
|
|
// Removido bloqueio de data retroativa para permitir planejamento histórico
|
|
const newClassId = editingClass ? editingClass.id : crypto.randomUUID();
|
|
const resolvedScheduleName = formData.scheduleDay ? DAY_NAMES[parseInt(formData.scheduleDay)] : formData.schedule;
|
|
|
|
const newClass: Class = {
|
|
...formData,
|
|
id: newClassId,
|
|
schedule: resolvedScheduleName
|
|
};
|
|
|
|
let updatedLessons = [...(data.lessons || [])];
|
|
|
|
// Gerar cronograma automaticamente
|
|
if (newClass.startDate && newClass.endDate && newClass.scheduleDay && newClass.defaultStartTime && newClass.defaultEndTime) {
|
|
|
|
let generationStartStr = newClass.startDate;
|
|
|
|
if (editingClass) {
|
|
// Ao editar, removemos apenas as aulas que coincidem ou são futuras em relação ao ponto de alteração
|
|
// Mas o sistema agora permite gerar todo o período do curso (mesmo retroativo) se solicitado.
|
|
updatedLessons = updatedLessons.filter(l => !(l.classId === newClass.id && l.date >= generationStartStr));
|
|
}
|
|
|
|
const generatedLessons = [];
|
|
let currentDate = new Date(generationStartStr + 'T12:00:00Z');
|
|
const endObject = new Date(newClass.endDate + 'T12:00:00Z');
|
|
const targetDay = parseInt(newClass.scheduleDay);
|
|
|
|
// Avançar até o primeiro dia da semana alvo a partir da data de início (nunca para trás)
|
|
while (currentDate.getUTCDay() !== targetDay) {
|
|
currentDate.setUTCDate(currentDate.getUTCDate() + 1);
|
|
}
|
|
|
|
while (currentDate <= endObject) {
|
|
const dateString = currentDate.toISOString().split('T')[0];
|
|
generatedLessons.push({
|
|
id: crypto.randomUUID(),
|
|
classId: newClass.id,
|
|
date: dateString,
|
|
startTime: newClass.defaultStartTime,
|
|
endTime: newClass.defaultEndTime,
|
|
status: 'scheduled',
|
|
type: 'regular'
|
|
});
|
|
currentDate.setUTCDate(currentDate.getUTCDate() + 7);
|
|
}
|
|
|
|
updatedLessons = [...updatedLessons, ...generatedLessons];
|
|
}
|
|
|
|
const payload = {
|
|
nome: newClass.name,
|
|
curso_id: newClass.courseId,
|
|
professor: newClass.teacher,
|
|
horario: newClass.schedule,
|
|
dia_semana: newClass.scheduleDay,
|
|
max_alunos: newClass.maxStudents,
|
|
data_inicio: newClass.startDate,
|
|
data_fim: newClass.endDate,
|
|
horario_inicio_padrao: newClass.defaultStartTime,
|
|
horario_fim_padrao: newClass.defaultEndTime
|
|
};
|
|
|
|
const saveToServer = async () => {
|
|
try {
|
|
if (editingClass) {
|
|
const res = await fetch(`/api/turmas/${editingClass.id}`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload)
|
|
});
|
|
if (!res.ok) throw new Error('Failed to update class');
|
|
} else {
|
|
const res = await fetch('/api/turmas', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ ...payload, id: newClass.id })
|
|
});
|
|
if (!res.ok) throw new Error('Failed to create class');
|
|
}
|
|
|
|
// Save lessons in both PostgreSQL and JSON for compatibility
|
|
const classLessons = updatedLessons.filter(l => l.classId === newClass.id);
|
|
if (classLessons.length > 0) {
|
|
await fetch('/api/aulas/lote', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ aulas: classLessons })
|
|
}).catch(e => console.warn('Erro ao salvar aulas no SQL:', e));
|
|
}
|
|
updateData({ lessons: updatedLessons });
|
|
dbService.saveData({ ...data, lessons: updatedLessons });
|
|
|
|
await loadData();
|
|
closeModal();
|
|
} catch (err) {
|
|
showAlert('Erro', 'Falha ao salvar turma no banco.', 'error');
|
|
}
|
|
};
|
|
|
|
saveToServer();
|
|
};
|
|
|
|
const closeModal = () => {
|
|
setIsClosing(true);
|
|
setTimeout(() => {
|
|
setIsModalOpen(false);
|
|
setIsClosing(false);
|
|
setEditingClass(null);
|
|
setFormData({ name: '', courseId: '', teacher: '', schedule: '', maxStudents: 15 });
|
|
}, 400);
|
|
};
|
|
|
|
const handleEdit = (cls: Class) => {
|
|
setEditingClass(cls);
|
|
setFormData({ ...cls });
|
|
setIsModalOpen(true);
|
|
};
|
|
|
|
const handleDelete = (id: string) => {
|
|
showConfirm(
|
|
'Excluir Turma',
|
|
'⚠️ Tem certeza que deseja excluir esta turma? Isso não removerá os alunos, mas eles ficarão sem turma.',
|
|
async () => {
|
|
try {
|
|
const res = await fetch(`/api/turmas/${id}`, { method: 'DELETE' });
|
|
if (!res.ok) throw new Error('Failed to delete');
|
|
await loadData();
|
|
} catch (err) {
|
|
showAlert('Erro', 'Ocorreu um erro ao deletar a turma.', 'error');
|
|
}
|
|
}
|
|
);
|
|
};
|
|
|
|
const handleDownloadClassList = async (cls: Class) => {
|
|
setIsGeneratingPDF(cls.id);
|
|
try {
|
|
await pdfService.generateClassListPDF(cls, data);
|
|
} catch (error) {
|
|
console.error('Error generating PDF:', error);
|
|
} finally {
|
|
setIsGeneratingPDF(null);
|
|
}
|
|
};
|
|
|
|
|
|
const handleQuickTimeSave = () => {
|
|
if (!quickTimeClass || !quickStartTime || !quickEndTime) {
|
|
showAlert('Atenção', 'Preencha início e término.', 'warning');
|
|
return;
|
|
}
|
|
|
|
if (quickStartTime >= quickEndTime) {
|
|
showAlert('Atenção', 'Fim deve ser maior que início.', 'warning');
|
|
return;
|
|
}
|
|
|
|
// Save class default times
|
|
const updatedClass = { ...quickTimeClass, defaultStartTime: quickStartTime, defaultEndTime: quickEndTime };
|
|
const updatedClasses = data.classes.map(c => c.id === quickTimeClass.id ? updatedClass : c);
|
|
|
|
// Update all future scheduled lessons for this class
|
|
const today = new Date().toISOString().split('T')[0];
|
|
const updatedLessons = (data.lessons || []).map(l => {
|
|
if (l.classId === quickTimeClass.id && l.status === 'scheduled' && l.date >= today) {
|
|
return { ...l, startTime: quickStartTime, endTime: quickEndTime };
|
|
}
|
|
return l;
|
|
});
|
|
|
|
updateData({ classes: updatedClasses, lessons: updatedLessons });
|
|
dbService.saveData({ ...data, classes: updatedClasses, lessons: updatedLessons });
|
|
|
|
setQuickTimeClass(null);
|
|
showAlert('Sucesso', 'Horário alterado para a turma e todas as aulas futuras atualizadas!', 'success');
|
|
};
|
|
|
|
const calculateAge = (birthDate: string) => {
|
|
if (!birthDate) return null;
|
|
const today = new Date();
|
|
const birth = new Date(birthDate);
|
|
let age = today.getFullYear() - birth.getFullYear();
|
|
const m = today.getMonth() - birth.getMonth();
|
|
if (m < 0 || (m === 0 && today.getDate() < birth.getDate())) {
|
|
age--;
|
|
}
|
|
return age;
|
|
};
|
|
|
|
const inputClass = "w-full px-4 py-3 bg-white text-black border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 transition-all shadow-sm";
|
|
|
|
return (
|
|
<div className="space-y-6 animate-in fade-in duration-300">
|
|
<div className="flex justify-between items-center">
|
|
<div>
|
|
<h2 className="text-3xl font-extrabold text-slate-900 tracking-tight">Turmas <span className="text-sm font-normal text-green-600 bg-green-50 px-2 py-1 rounded-md ml-2 border border-green-200">PostgreSQL</span></h2>
|
|
<p className="text-slate-500">Controle de horários e ocupação das salas.</p>
|
|
</div>
|
|
<button
|
|
onClick={() => setIsModalOpen(true)}
|
|
className="bg-indigo-600 text-white px-6 py-3 rounded-xl flex items-center gap-2 hover:bg-indigo-700 transition-all shadow-lg font-bold"
|
|
>
|
|
<Plus size={20} /> Nova Turma
|
|
</button>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
{classes.map(cls => {
|
|
const studentCount = data.students.filter(s => s.classId === cls.id).length;
|
|
const occupancyPercent = Math.min(100, (studentCount / (cls.maxStudents || 30)) * 100);
|
|
const course = courses.find((c: any) => c.id === cls.courseId);
|
|
|
|
const now = new Date();
|
|
const clsLessons = (data.lessons || []).filter(l => l.classId === cls.id && l.status !== 'cancelled');
|
|
const isOngoing = clsLessons.some(l => {
|
|
if (!l.startTime || !l.endTime) return false;
|
|
const lDate = new Date(l.date + 'T12:00:00Z');
|
|
if (lDate.getDate() !== now.getDate() || lDate.getMonth() !== now.getMonth() || lDate.getFullYear() !== now.getFullYear()) return false;
|
|
const [sh, sm] = l.startTime.split(':').map(Number);
|
|
const lStart = new Date(now); lStart.setHours(sh, sm, 0, 0);
|
|
const [eh, em] = l.endTime.split(':').map(Number);
|
|
const lEnd = new Date(now); lEnd.setHours(eh, em, 0, 0);
|
|
return now >= lStart && now <= lEnd;
|
|
});
|
|
|
|
return (
|
|
<div key={cls.id} className={`bg-white p-7 rounded-xl border shadow-sm hover:shadow-xl transition-all group flex flex-col h-full ${isOngoing ? 'border-blue-400 border-b-4 border-b-blue-500 shadow-blue-100' : 'border-slate-200 border-b-4 border-b-indigo-500/20 hover:border-b-indigo-500'}`}>
|
|
<div className="flex justify-between items-start mb-5 relative">
|
|
<div>
|
|
<div className="flex items-center gap-2 mb-1">
|
|
<h3 className="text-xl font-black text-slate-900 leading-tight">{cls.name}</h3>
|
|
{isOngoing && (
|
|
<span className="px-2 py-0.5 bg-blue-600 text-white text-[9px] font-black uppercase rounded-full animate-pulse shadow-sm flex items-center gap-1">
|
|
<Clock size={10} /> Em andamento
|
|
</span>
|
|
)}
|
|
</div>
|
|
<span className="text-[10px] font-black text-indigo-600 uppercase tracking-[0.2em]">{course?.nome || course?.name || 'Sem Curso Vinculado'}</span>
|
|
{cls.defaultStartTime && cls.defaultEndTime && (
|
|
<div className="mt-2 inline-flex items-center gap-1.5 px-2.5 py-1 bg-indigo-50 text-indigo-700 text-xs font-bold rounded-lg border border-indigo-100">
|
|
<Clock size={12} /> {cls.defaultStartTime} - {cls.defaultEndTime}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="flex flex-col gap-1 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0">
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); handleDownloadClassList(cls); }}
|
|
disabled={isGeneratingPDF === cls.id}
|
|
className="p-2 text-slate-400 hover:text-indigo-600 rounded-lg transition-all disabled:opacity-50 bg-slate-50 hover:bg-indigo-50"
|
|
title="Imprimir Diário"
|
|
>
|
|
{isGeneratingPDF === cls.id ? <RefreshCw size={16} className="animate-spin" /> : <Printer size={16} />}
|
|
</button>
|
|
<button onClick={(e) => { e.stopPropagation(); handleEdit(cls); }} className="p-2 text-slate-400 hover:text-indigo-600 hover:bg-slate-50 rounded-lg transition-all" title="Editar Turma">
|
|
<Edit2 size={16} />
|
|
</button>
|
|
<button onClick={(e) => { e.stopPropagation(); handleDelete(cls.id); }} className="p-2 text-slate-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-all" title="Excluir Turma">
|
|
<Trash2 size={16} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-3 mb-5 flex-1">
|
|
<div className="flex items-center gap-3 text-sm text-slate-600 bg-slate-50 p-3 rounded-lg">
|
|
<User size={18} className="text-indigo-500" />
|
|
<div>
|
|
<p className="text-[10px] uppercase font-bold text-slate-400">Professor</p>
|
|
<p className="font-semibold text-slate-800">{cls.teacher}</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-3 text-sm text-slate-600 bg-slate-50 p-3 rounded-lg">
|
|
<Clock size={18} className="text-indigo-500" />
|
|
<div>
|
|
<p className="text-[10px] uppercase font-bold text-slate-400">Dias de Aula</p>
|
|
<p className="font-semibold text-slate-800 flex items-center gap-2">
|
|
{cls.scheduleDay ? DAY_NAMES[parseInt(cls.scheduleDay)] : cls.schedule}
|
|
{cls.defaultStartTime && cls.defaultEndTime && (
|
|
<span className="text-indigo-600 font-black">
|
|
{cls.defaultStartTime} às {cls.defaultEndTime}
|
|
</span>
|
|
)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
{/* Contagem de Aulas */}
|
|
{(() => {
|
|
const now = new Date();
|
|
const totalLessons = clsLessons.length;
|
|
const completedLessons = clsLessons.filter(l => {
|
|
if (l.status === 'cancelled') return false;
|
|
const lDate = new Date(l.date + 'T12:00:00Z');
|
|
if (!l.endTime) return lDate < now;
|
|
const [eh, em] = l.endTime.split(':').map(Number);
|
|
const lEnd = new Date(lDate);
|
|
lEnd.setUTCHours(eh, em, 0, 0);
|
|
return now > lEnd;
|
|
}).length;
|
|
const cancelledLessons = clsLessons.filter(l => l.status === 'cancelled').length;
|
|
const remainingLessons = totalLessons - completedLessons - cancelledLessons;
|
|
return totalLessons > 0 ? (
|
|
<div className="flex items-center gap-2 flex-wrap text-[10px] font-black">
|
|
<span className="px-2 py-1 bg-indigo-50 text-indigo-600 rounded-lg flex items-center gap-1">
|
|
<Calendar size={10} /> {totalLessons} Total
|
|
</span>
|
|
<span className="px-2 py-1 bg-emerald-50 text-emerald-600 rounded-lg">
|
|
{completedLessons} Concluídas
|
|
</span>
|
|
<span className="px-2 py-1 bg-amber-50 text-amber-600 rounded-lg">
|
|
{remainingLessons} Restantes
|
|
</span>
|
|
</div>
|
|
) : null;
|
|
})()}
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<div className="flex justify-between items-end text-xs font-bold text-slate-500 px-1">
|
|
<span>OCUPAÇÃO</span>
|
|
<span>{studentCount} / {cls.maxStudents}</span>
|
|
</div>
|
|
<div className="w-full bg-slate-100 h-3 rounded-full overflow-hidden">
|
|
<div
|
|
className={`h-full rounded-full transition-all duration-1000 ${
|
|
occupancyPercent > 90 ? 'bg-red-500' : occupancyPercent > 50 ? 'bg-indigo-500' : 'bg-emerald-500'
|
|
}`}
|
|
style={{ width: `${occupancyPercent}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-3 mt-5">
|
|
<button
|
|
onClick={() => setViewingStudentsClass(cls)}
|
|
className="bg-slate-50 border border-slate-200 text-slate-700 hover:bg-slate-100 py-3 rounded-xl font-bold flex items-center justify-center gap-2 transition-all"
|
|
>
|
|
<User size={18} /> Ver Alunos
|
|
</button>
|
|
<button
|
|
onClick={() => setScheduleClass(cls)}
|
|
className="bg-indigo-50 border border-indigo-100 text-indigo-700 hover:bg-indigo-600 hover:text-white py-3 rounded-xl font-black flex items-center justify-center gap-2 transition-all shadow-sm group-hover:shadow-md"
|
|
>
|
|
<Calendar size={18} /> Cronograma
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
{classes.length === 0 && (
|
|
<div className="col-span-full py-20 text-center text-slate-400 border-4 border-dashed border-slate-200 rounded-xl">
|
|
<Book size={48} className="mx-auto mb-4 opacity-10" />
|
|
<p className="font-bold text-lg">Nenhuma turma cadastrada ainda.</p>
|
|
<p className="text-sm">Vincule um curso a uma nova turma para começar.</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{isModalOpen && (
|
|
<div className={`fixed inset-0 bg-transparent flex items-center justify-center p-4 z-50 overflow-y-auto transition-opacity duration-400 ${isClosing ? 'opacity-0' : 'opacity-100 animate-in fade-in'}`}>
|
|
<div className={`bg-white rounded-xl w-full max-w-2xl shadow-2xl my-auto transition-all duration-400 relative overflow-hidden ${isClosing ? 'animate-slide-down-fade-out' : 'animate-slide-up'}`}>
|
|
{/* Blue Top Bar */}
|
|
<div className="bg-indigo-600 h-1.5 w-full absolute top-0 left-0 z-10"></div>
|
|
|
|
<div className="p-8 border-b border-slate-100 flex justify-between items-center bg-indigo-50/30">
|
|
<div>
|
|
<h3 className="text-2xl font-black text-slate-800 tracking-tight">
|
|
{editingClass ? 'Editar Turma' : 'Criar Turma'}
|
|
</h3>
|
|
<p className="text-sm text-slate-500">Selecione o curso e horários.</p>
|
|
</div>
|
|
<button onClick={closeModal} className="p-3 bg-white text-slate-400 hover:text-red-500 rounded-xl shadow-sm transition-all hover:rotate-90">
|
|
<X size={24} />
|
|
</button>
|
|
</div>
|
|
<form onSubmit={handleSubmit} className="p-8 space-y-6">
|
|
|
|
<div className="grid grid-cols-2 gap-6">
|
|
<div>
|
|
<label className="block text-xs font-bold text-slate-500 uppercase tracking-widest mb-2 ml-1">Nome da Turma</label>
|
|
<input required className={inputClass} placeholder="Ex: TURMA A - NOITE"
|
|
value={formData.name} onChange={e => setFormData({...formData, name: e.target.value})} />
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-bold text-slate-500 uppercase tracking-widest mb-2 ml-1">Curso Vinculado</label>
|
|
<select required className={inputClass}
|
|
value={formData.courseId} onChange={e => setFormData({...formData, courseId: e.target.value})}>
|
|
<option value="">Selecione um curso...</option>
|
|
{courses.map((c: any) => <option key={c.id} value={c.id}>{c.nome}</option>)}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-6">
|
|
<div>
|
|
<label className="block text-xs font-bold text-slate-500 uppercase tracking-widest mb-2 ml-1">Data de Início</label>
|
|
<input type="date" required className={inputClass}
|
|
value={formData.startDate} onChange={e => setFormData({...formData, startDate: e.target.value})} />
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-bold text-slate-500 uppercase tracking-widest mb-2 ml-1">Data de Fim (Automática)</label>
|
|
<input type="date" required className={inputClass}
|
|
value={formData.endDate} onChange={e => setFormData({...formData, endDate: e.target.value})} />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-3 gap-6">
|
|
<div>
|
|
<label className="block text-xs font-bold text-slate-500 uppercase tracking-widest mb-2 ml-1">Dia da Semana</label>
|
|
<select required className={inputClass}
|
|
value={formData.scheduleDay} onChange={e => setFormData({...formData, scheduleDay: e.target.value})}>
|
|
<option value="">Selecione...</option>
|
|
{DAY_NAMES.map((d, i) => <option key={i} value={i}>{d}</option>)}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-bold text-slate-500 uppercase tracking-widest mb-2 ml-1">Início (Hora)</label>
|
|
<input type="time" required className={inputClass}
|
|
value={formData.defaultStartTime} onChange={e => setFormData({...formData, defaultStartTime: e.target.value})} />
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-bold text-slate-500 uppercase tracking-widest mb-2 ml-1">Término (Hora)</label>
|
|
<input type="time" required className={inputClass}
|
|
value={formData.defaultEndTime} onChange={e => setFormData({...formData, defaultEndTime: e.target.value})} />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-6">
|
|
<div>
|
|
<label className="block text-xs font-bold text-slate-500 uppercase tracking-widest mb-2 ml-1">Vagas Locais</label>
|
|
<input type="number" required className={inputClass}
|
|
value={formData.maxStudents} onChange={e => setFormData({...formData, maxStudents: parseInt(e.target.value) || 0})} />
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-bold text-slate-500 uppercase tracking-widest mb-2 ml-1">Professor Responsável</label>
|
|
<select required className={inputClass}
|
|
value={formData.teacher} onChange={e => setFormData({...formData, teacher: e.target.value})}>
|
|
<option value="">Selecione um professor...</option>
|
|
{employees
|
|
.map((emp: any) => (
|
|
<option key={emp.id} value={emp.nome || emp.name}>{emp.nome || emp.name}</option>
|
|
))}
|
|
{formData.teacher && !employees.some((e: any) => (e.nome || e.name) === formData.teacher) && (
|
|
<option value={formData.teacher}>{formData.teacher} (Manual)</option>
|
|
)}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="pt-4 flex gap-4 border-t border-slate-100">
|
|
<button type="button" onClick={closeModal} className="flex-1 px-6 py-4 border border-slate-200 rounded-xl text-slate-600 hover:bg-slate-50 transition-colors font-bold">
|
|
Cancelar
|
|
</button>
|
|
<button type="submit" className="flex-1 px-6 py-4 bg-indigo-600 text-white rounded-xl hover:bg-indigo-700 transition-all shadow-lg font-bold">
|
|
{editingClass ? 'Atualizar e Sincronizar Calendário' : 'Criar Turma e Gerar Calendário'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Lesson Schedule Modal */}
|
|
{scheduleClass && (
|
|
<LessonSchedule
|
|
classObj={scheduleClass}
|
|
data={data}
|
|
updateData={updateData}
|
|
onClose={() => setScheduleClass(null)}
|
|
/>
|
|
)}
|
|
|
|
{/* Viewing Students Modal */}
|
|
{viewingStudentsClass && (
|
|
<div className="fixed inset-0 bg-transparent flex items-center justify-center p-4 z-50 overflow-y-auto animate-in fade-in">
|
|
<div className="bg-white rounded-2xl w-full max-w-2xl shadow-2xl my-auto relative overflow-hidden animate-slide-up">
|
|
<div className="bg-indigo-600 h-1.5 w-full absolute top-0 left-0 z-10"></div>
|
|
|
|
<div className="p-8 border-b border-slate-100 flex justify-between items-center bg-indigo-50/30">
|
|
<div>
|
|
<h3 className="text-2xl font-black text-slate-800 tracking-tight">Alunos da Turma</h3>
|
|
<p className="text-sm text-slate-500 mt-1">{viewingStudentsClass.name} • {data.students.filter(s => s.classId === viewingStudentsClass.id).length} alunos matriculados</p>
|
|
</div>
|
|
<button
|
|
onClick={() => setViewingStudentsClass(null)}
|
|
className="p-2 bg-white text-slate-400 hover:text-red-500 rounded-lg shadow-sm transition-all hover:rotate-90"
|
|
>
|
|
<X size={24} />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="p-4 max-h-[60vh] overflow-y-auto">
|
|
<table className="w-full text-left border-collapse">
|
|
<thead>
|
|
<tr className="border-b border-slate-100 text-[10px] uppercase text-slate-400 font-black tracking-widest">
|
|
<th className="p-4">Aluno</th>
|
|
<th className="p-4">Idade</th>
|
|
<th className="p-4 text-right">Ação</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-slate-50">
|
|
{data.students
|
|
.filter(s => s.classId === viewingStudentsClass.id)
|
|
.sort((a,b) => (a.name || '').localeCompare(b.name || ''))
|
|
.map(student => (
|
|
<tr key={student.id} className="hover:bg-slate-50 transition-colors group">
|
|
<td className="p-4">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-10 h-10 rounded-full bg-slate-100 overflow-hidden border border-slate-200">
|
|
{student.photo ? (
|
|
<img src={normalizePhotoUrl(student.photo)} alt={student.name} className="w-full h-full object-cover" />
|
|
) : (
|
|
<div className="w-full h-full flex items-center justify-center text-slate-300">
|
|
<User size={20} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div>
|
|
<p className="font-bold text-slate-800 text-sm">{student.name}</p>
|
|
<p className="text-[10px] text-slate-400 font-medium uppercase tracking-wider">{student.enrollmentNumber || 'Sem matrícula'}</p>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td className="p-4 text-sm font-bold text-slate-600">
|
|
{calculateAge(student.birthDate) !== null ? `${calculateAge(student.birthDate)} anos` : '-'}
|
|
</td>
|
|
<td className="p-4 text-right">
|
|
<button
|
|
onClick={() => onNavigateToClass(viewingStudentsClass.id, student.id)}
|
|
className="px-4 py-2 bg-indigo-50 text-indigo-600 rounded-lg text-xs font-bold hover:bg-indigo-600 hover:text-white transition-all shadow-sm"
|
|
>
|
|
Ver Perfil
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
{data.students.filter(s => s.classId === viewingStudentsClass.id).length === 0 && (
|
|
<tr>
|
|
<td colSpan={3} className="p-10 text-center text-slate-400 italic text-sm">Nenhum aluno matriculado nesta turma.</td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div className="p-6 bg-slate-50 border-t border-slate-100 flex justify-end">
|
|
<button
|
|
onClick={() => setViewingStudentsClass(null)}
|
|
className="px-6 py-2.5 bg-white border border-slate-200 text-slate-600 font-bold hover:bg-slate-50 rounded-xl transition-colors shadow-sm"
|
|
>
|
|
Fechar
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default Classes; |