import React, { useState, useRef, useEffect } from 'react'; import { SchoolData, Student, Class } from '../types'; import { dbService } from '../services/dbService'; import { uploadStudentPhoto } from '../services/supabase'; import { addHeader, pdfService } from '../services/pdfService'; import { useDialog } from '../DialogContext'; import { compressImage } from '../services/imageService'; import { Search, Plus, Edit2, Trash2, User, Camera, Upload, X, CheckCircle, Loader2, Save, Image as ImageIcon, SwitchCamera, FileDown, Eye, FileText, AlertCircle, ArrowRightLeft, UserX, Printer, BookOpen, Barcode, Receipt, RefreshCw, ArrowLeft, Users } from 'lucide-react'; import * as faceapi from '@vladmandic/face-api'; import jsPDF from 'jspdf'; import autoTable from 'jspdf-autotable'; interface StudentsProps { data: SchoolData; updateData: (newData: Partial) => void; deepLinkStudentId?: string | null; deepLinkClassId?: string | null; clearDeepLink?: () => void; } const Students: React.FC = ({ data, updateData, deepLinkStudentId, deepLinkClassId, clearDeepLink }) => { const { showAlert, showConfirm } = useDialog(); const [searchTerm, setSearchTerm] = useState(''); const [showModal, setShowModal] = useState(false); const [isClosing, setIsClosing] = useState(false); const [editingStudent, setEditingStudent] = useState(null); const [viewingStudentHistory, setViewingStudentHistory] = useState(null); const [transferringStudent, setTransferringStudent] = useState(null); const [showDeleteModal, setShowDeleteModal] = useState(null); const [newClassId, setNewClassId] = useState(''); const [isGeneratingPDF, setIsGeneratingPDF] = useState(false); const [isFetchingCarne, setIsFetchingCarne] = useState(false); const [selectedPayments, setSelectedPayments] = useState([]); const [isDeletingBatch, setIsDeletingBatch] = useState(false); const [showDeleteBatchModal, setShowDeleteBatchModal] = useState(false); const [showFallbackModal, setShowFallbackModal] = useState(false); const [fallbackInstallments, setFallbackInstallments] = useState([]); const [activeTab, setActiveTab] = useState<'active' | 'cancelled'>('active'); const [cancellationReason, setCancellationReason] = useState(''); const [selectedClassId, setSelectedClassId] = useState(null); const [isSaving, setIsSaving] = useState(false); // Form State const [formData, setFormData] = useState>({ name: '', email: '', phone: '', birthDate: '', cpf: '', rg: '', rgIssueDate: '', guardianName: '', guardianPhone: '', guardianCpf: '', guardianBirthDate: '', classId: '', status: 'active', registrationDate: new Date().toISOString().split('T')[0], addressZip: '', addressStreet: '', addressNumber: '', addressNeighborhood: '', addressCity: '', addressState: '', discount: 0, hasGuardian: false, contractTemplateId: '', generateFee: false, // UI only generateContract: false // UI only } as any); // Camera State const [cameraActive, setCameraActive] = useState(false); const [facingMode, setFacingMode] = useState<'user' | 'environment'>('user'); const [tempPhoto, setTempPhoto] = useState(null); const [photoFile, setPhotoFile] = useState(null); // Physical file for MinIO upload const [modelsLoaded, setModelsLoaded] = useState(false); const [isProcessingFace, setIsProcessingFace] = useState(false); const videoRef = useRef(null); const streamRef = useRef(null); const fileInputRef = useRef(null); // Process Deep Links (from Classes or Notifications) useEffect(() => { if (deepLinkClassId) { setSelectedClassId(deepLinkClassId); if (clearDeepLink) clearDeepLink(); } if (deepLinkStudentId) { const student = data.students.find(s => s.id === deepLinkStudentId); if (student) { setSearchTerm(student.name); if (student.status === 'cancelled') setActiveTab('cancelled'); else setActiveTab('active'); if (student.classId) setSelectedClassId(student.classId); } if (clearDeepLink) clearDeepLink(); } }, [deepLinkStudentId, deepLinkClassId, data.students]); // Load Models useEffect(() => { const loadModels = async () => { const MODEL_URL = 'https://cdn.jsdelivr.net/npm/@vladmandic/face-api/model/'; try { await Promise.all([ faceapi.nets.tinyFaceDetector.loadFromUri(MODEL_URL), faceapi.nets.faceLandmark68Net.loadFromUri(MODEL_URL), faceapi.nets.faceRecognitionNet.loadFromUri(MODEL_URL), faceapi.nets.ssdMobilenetv1.loadFromUri(MODEL_URL), ]); setModelsLoaded(true); } catch (err) { console.error("Error loading models", err); } }; loadModels(); }, []); // Mask Helpers const maskCPF = (value: string) => { return value .replace(/\D/g, '') .replace(/(\d{3})(\d)/, '$1.$2') .replace(/(\d{3})(\d)/, '$1.$2') .replace(/(\d{3})(\d{1,2})/, '$1-$2') .replace(/(-\d{2})\d+?$/, '$1'); }; const maskPhone = (value: string) => { return value .replace(/\D/g, '') .replace(/(\d{2})(\d)/, '($1) $2') .replace(/(\d{5})(\d)/, '$1-$2') .replace(/(-\d{4})\d+?$/, '$1'); }; const maskCEP = (value: string) => { return value .replace(/\D/g, '') .replace(/(\d{5})(\d)/, '$1-$2') .replace(/(-\d{3})\d+?$/, '$1'); }; const maskDate = (value: string) => { return value .replace(/\D/g, '') .replace(/(\d{2})(\d)/, '$1/$2') .replace(/(\d{2})(\d)/, '$1/$2') .replace(/(\d{4})\d+?$/, '$1'); }; const isValidCPF = (cpf: string) => { if (typeof cpf !== "string") return false; cpf = cpf.replace(/[\s.-]*/igm, ''); if ( !cpf || cpf.length != 11 || cpf == "00000000000" || cpf == "11111111111" || cpf == "22222222222" || cpf == "33333333333" || cpf == "44444444444" || cpf == "55555555555" || cpf == "66666666666" || cpf == "77777777777" || cpf == "88888888888" || cpf == "99999999999" ) { return false; } var soma = 0; var resto; for (var i = 1; i <= 9; i++) soma = soma + parseInt(cpf.substring(i-1, i)) * (11 - i); resto = (soma * 10) % 11; if ((resto == 10) || (resto == 11)) resto = 0; if (resto != parseInt(cpf.substring(9, 10)) ) return false; soma = 0; for (var i = 1; i <= 10; i++) soma = soma + parseInt(cpf.substring(i-1, i)) * (12 - i); resto = (soma * 10) % 11; if ((resto == 10) || (resto == 11)) resto = 0; if (resto != parseInt(cpf.substring(10, 11) ) ) return false; return true; }; const calculateAge = (dateString: string) => { if (!dateString || !dateString.includes('-')) return null; const [year, month, day] = dateString.split('-').map(Number); const birthDate = new Date(year, month - 1, day); const today = new Date(); let age = today.getFullYear() - birthDate.getFullYear(); const m = today.getMonth() - birthDate.getMonth(); if (m < 0 || (m === 0 && today.getDate() < birthDate.getDate())) { age--; } return age; }; const handleCPFChange = (e: React.ChangeEvent, field: 'cpf' | 'guardianCpf') => { setFormData(prev => ({ ...prev, [field]: maskCPF(e.target.value) })); }; const handlePhoneChange = (e: React.ChangeEvent, field: 'phone' | 'guardianPhone' = 'phone') => { setFormData(prev => ({ ...prev, [field]: maskPhone(e.target.value) })); }; const handleCEPChange = (e: React.ChangeEvent) => { const val = maskCEP(e.target.value); setFormData(prev => ({ ...prev, addressZip: val })); // Auto-check CEP when 8 digits (ignoring mask chars) const numericCEP = val.replace(/\D/g, ''); if (numericCEP.length === 8) { checkCEP(val); } }; // Improved Date Handling const [birthDateInput, setBirthDateInput] = useState(''); const [guardianBirthDateInput, setGuardianBirthDateInput] = useState(''); const [rgIssueDateInput, setRgIssueDateInput] = useState(''); useEffect(() => { if (formData.birthDate) { const parts = formData.birthDate.split('-'); if (parts.length === 3) { setBirthDateInput(`${parts[2]}/${parts[1]}/${parts[0]}`); } else { setBirthDateInput(formData.birthDate); } } else { setBirthDateInput(''); } }, [formData.birthDate]); useEffect(() => { if (formData.guardianBirthDate) { const parts = formData.guardianBirthDate.split('-'); if (parts.length === 3) { setGuardianBirthDateInput(`${parts[2]}/${parts[1]}/${parts[0]}`); } else { setGuardianBirthDateInput(formData.guardianBirthDate); } } else { setGuardianBirthDateInput(''); } }, [formData.guardianBirthDate]); useEffect(() => { if (formData.rgIssueDate) { const parts = formData.rgIssueDate.split('-'); if (parts.length === 3) { setRgIssueDateInput(`${parts[2]}/${parts[1]}/${parts[0]}`); } else { setRgIssueDateInput(formData.rgIssueDate); } } else { setRgIssueDateInput(''); } }, [formData.rgIssueDate]); const onBirthDateInputChange = (e: React.ChangeEvent) => { const val = maskDate(e.target.value); setBirthDateInput(val); if (val.length === 10) { const parts = val.split('/'); if (parts.length === 3) { const day = parseInt(parts[0]); const month = parseInt(parts[1]); const year = parseInt(parts[2]); if (day > 0 && day <= 31 && month > 0 && month <= 12 && year > 1900) { const isoDate = `${parts[2]}-${parts[1]}-${parts[0]}`; const age = calculateAge(isoDate); setFormData(prev => ({ ...prev, birthDate: isoDate, hasGuardian: age !== null && age < 18 ? true : prev.hasGuardian })); } } } }; const onGuardianBirthDateInputChange = (e: React.ChangeEvent) => { const val = maskDate(e.target.value); setGuardianBirthDateInput(val); if (val.length === 10) { const parts = val.split('/'); if (parts.length === 3) { const day = parseInt(parts[0]); const month = parseInt(parts[1]); const year = parseInt(parts[2]); if (day > 0 && day <= 31 && month > 0 && month <= 12 && year > 1900) { const isoDate = `${parts[2]}-${parts[1]}-${parts[0]}`; setFormData(prev => ({ ...prev, guardianBirthDate: isoDate })); } } } }; const onRgIssueDateInputChange = (e: React.ChangeEvent) => { const val = maskDate(e.target.value); setRgIssueDateInput(val); if (val.length === 10) { const parts = val.split('/'); if (parts.length === 3) { const day = parseInt(parts[0]); const month = parseInt(parts[1]); const year = parseInt(parts[2]); if (day > 0 && day <= 31 && month > 0 && month <= 12 && year > 1900) { const isoDate = `${parts[2]}-${parts[1]}-${parts[0]}`; setFormData(prev => ({ ...prev, rgIssueDate: isoDate })); } } } }; const generateEnrollmentPDF = async (student?: Student) => { setIsGeneratingPDF(true); try { const targetData = student || formData; if (!targetData.id) { showAlert('Atenção', '⚠️ Salve o aluno antes de gerar a ficha.', 'warning'); return; } await pdfService.generateStudentRegistrationPDF(targetData as Student, data); } catch (error) { console.error('Error generating PDF:', error); showAlert('Erro', 'Ocorreu um erro ao gerar o PDF.', 'error'); } finally { setIsGeneratingPDF(false); } }; const handlePrintCarne = async (studentId: string) => { setIsFetchingCarne(true); try { const response = await fetch(`/api/alunos/${studentId}/carne`); const result = await response.json(); if (response.ok) { if (result.type === 'fallback') { setFallbackInstallments(result.boletos); setShowFallbackModal(true); showAlert('Atenção', result.message, 'info'); } else if (result.type === 'pdf' && result.url) { window.open(result.url, '_blank', 'noopener,noreferrer'); showAlert('Sucesso', 'Carnê localizado com sucesso!', 'success'); } else if (result.url) { window.open(result.url, '_blank', 'noopener,noreferrer'); showAlert('Sucesso', 'Carnê localizado com sucesso!', 'success'); } } else { // O backend agora retorna 400 com mensagem específica se não for parcelamento showAlert('Atenção', result.error || 'Não foi possível encontrar o carnê deste aluno.', response.status === 400 ? 'warning' : 'error'); } } catch (error) { console.error('Erro ao buscar carnê:', error); showAlert('Erro', 'Ocorreu um erro ao processar sua solicitação.', 'error'); } finally { setIsFetchingCarne(false); } }; const handleOpenPaymentLink = async (asaasPaymentId: string, type: 'boleto' | 'recibo') => { try { showAlert('Aguarde', `Buscando ${type}...`, 'info'); const response = await fetch(`/api/cobrancas/${asaasPaymentId}/link`); const result = await response.json(); if (response.ok) { const url = type === 'boleto' ? result.bankSlipUrl : result.transactionReceiptUrl; if (url) { window.open(url, '_blank', 'noopener,noreferrer'); } else { showAlert('Atenção', `${type === 'boleto' ? 'Boleto' : 'Recibo'} não disponível.`, 'warning'); } } else { showAlert('Erro', result.error || `Falha ao buscar ${type}.`, 'error'); } } catch (error) { console.error(`Erro ao buscar ${type}:`, error); showAlert('Erro', 'Ocorreu um erro ao processar sua solicitação.', 'error'); } }; const handleDeleteBatch = async () => { if (selectedPayments.length === 0) return; setIsDeletingBatch(true); try { const response = await fetch('/api/cobrancas/lote', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ids: selectedPayments }) }); if (response.ok || response.status === 207) { const result = await response.json(); showAlert('Sucesso', result.message || 'Cobranças excluídas com sucesso.', 'success'); // Atualizar dados locais (Supabase já foi atualizado pelo backend) const updatedPayments = data.payments.filter(p => !selectedPayments.includes(p.asaasPaymentId || '')); updateData({ payments: updatedPayments }); setSelectedPayments([]); setShowDeleteBatchModal(false); } else { const errorData = await response.json(); showAlert('Erro', errorData.error || 'Falha ao excluir cobranças em lote.', 'error'); } } catch (error) { console.error('Erro na exclusão em lote:', error); showAlert('Erro', 'Erro de conexão ao tentar excluir cobranças.', 'error'); } finally { setIsDeletingBatch(false); } }; const togglePaymentSelection = (asaasId: string) => { if (!asaasId) return; setSelectedPayments(prev => prev.includes(asaasId) ? prev.filter(id => id !== asaasId) : [...prev, asaasId] ); }; const checkCEP = async (cepValue?: string) => { const cep = (cepValue || formData.addressZip)?.replace(/\D/g, ''); if (cep?.length === 8) { try { const res = await fetch(`https://viacep.com.br/ws/${cep}/json/`); const data = await res.json(); if (!data.erro) { setFormData(prev => ({ ...prev, addressStreet: data.logradouro, addressNeighborhood: data.bairro, addressCity: data.localidade, addressState: data.uf })); } } catch (e) { console.error("CEP Error", e); } } }; const processFace = async (imageElement: HTMLImageElement | HTMLVideoElement | HTMLCanvasElement) => { if (!modelsLoaded) return null; setIsProcessingFace(true); try { const detection = await faceapi.detectSingleFace(imageElement, new faceapi.TinyFaceDetectorOptions()) .withFaceLandmarks() .withFaceDescriptor(); if (detection) { return Array.from(detection.descriptor); } return null; } catch (error) { console.error("Face processing error", error); return null; } finally { setIsProcessingFace(false); } }; const handleImageUpload = async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (file) { try { setPhotoFile(file); // Hold physical file for MinIO FormData upload const compressed = await compressImage(file); setFormData(prev => ({ ...prev, photo: compressed })); const img = document.createElement('img'); img.src = compressed; img.onload = async () => { const descriptor = await processFace(img); if (descriptor) { setFormData(prev => ({ ...prev, faceDescriptor: descriptor })); } else { showAlert('Atenção', "Nenhum rosto detectado na foto. Por favor, use uma foto clara do rosto.", 'warning'); } }; } catch (error) { console.error('Erro ao comprimir imagem:', error); showAlert('Erro', 'Falha ao processar imagem.', 'error'); } } }; const startCamera = async () => { try { setTempPhoto(null); if (streamRef.current) { streamRef.current.getTracks().forEach(track => track.stop()); } const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: facingMode } }); streamRef.current = stream; setCameraActive(true); } catch (err) { console.error("Error accessing camera:", err); showAlert('Erro', "Erro ao acessar câmera. Verifique as permissões.", 'error'); } }; const switchCamera = () => { setFacingMode(prev => prev === 'user' ? 'environment' : 'user'); }; // Effect to restart camera when facingMode changes if already active useEffect(() => { if (cameraActive && !tempPhoto) { startCamera(); } }, [facingMode]); // Effect to attach stream to video element when it becomes available useEffect(() => { if (cameraActive && videoRef.current && streamRef.current && !tempPhoto) { videoRef.current.srcObject = streamRef.current; } }, [cameraActive, tempPhoto]); const takePicture = () => { if (videoRef.current) { const canvas = document.createElement('canvas'); canvas.width = videoRef.current.videoWidth; canvas.height = videoRef.current.videoHeight; const ctx = canvas.getContext('2d'); if (ctx) { ctx.drawImage(videoRef.current, 0, 0); // Use WebP for capture too const base64 = canvas.toDataURL('image/webp', 0.8); setTempPhoto(base64); } } }; const retakePhoto = () => { setTempPhoto(null); }; const savePhoto = async () => { if (tempPhoto) { try { // Convert base64 camera shot to physical Blob for MinIO FormData upload const response = await fetch(tempPhoto); const blob = await response.blob(); setPhotoFile(blob); const compressed = await compressImage(tempPhoto); setFormData(prev => ({ ...prev, photo: compressed })); // Process face const img = document.createElement('img'); img.src = compressed; img.onload = async () => { const descriptor = await processFace(img); if (descriptor) { setFormData(prev => ({ ...prev, faceDescriptor: descriptor })); } else { showAlert('Atenção', "Nenhum rosto detectado na foto. Por favor, use uma foto clara do rosto.", 'warning'); } }; stopCamera(); } catch (error) { console.error('Erro ao comprimir foto:', error); showAlert('Erro', 'Falha ao processar foto da câmera.', 'error'); } } }; const stopCamera = () => { if (streamRef.current) { streamRef.current.getTracks().forEach(track => track.stop()); streamRef.current = null; } setCameraActive(false); setTempPhoto(null); }; const closeModal = () => { setIsClosing(true); setTimeout(() => { setShowModal(false); setIsClosing(false); setEditingStudent(null); setPhotoFile(null); // Reset MinIO physical upload file setFormData({ name: '', email: '', phone: '', birthDate: '', cpf: '', rg: '', rgIssueDate: '', guardianName: '', guardianPhone: '', guardianCpf: '', guardianBirthDate: '', classId: '', status: 'active', registrationDate: new Date().toISOString().split('T')[0], addressZip: '', addressStreet: '', addressNumber: '', addressNeighborhood: '', addressCity: '', addressState: '', discount: 0, hasGuardian: false, contractTemplateId: '', generateFee: false, generateContract: false } as any); setBirthDateInput(''); setRgIssueDateInput(''); setGuardianBirthDateInput(''); }, 400); }; const closeHistoryModal = () => { setIsClosing(true); setTimeout(() => { setViewingStudentHistory(null); setSelectedPayments([]); setIsClosing(false); }, 400); }; const closeTransferModal = () => { setIsClosing(true); setTimeout(() => { setTransferringStudent(null); setIsClosing(false); setNewClassId(''); }, 400); }; const closeDeleteModal = () => { setIsClosing(true); setTimeout(() => { setShowDeleteModal(null); setIsClosing(false); }, 400); }; const handleSave = async () => { if (!formData.name || !formData.classId) { showAlert('Atenção', '⚠️ Nome e Turma são obrigatórios', 'warning'); return; } setIsSaving(true); try { // Validation for minors if (formData.birthDate) { const age = calculateAge(formData.birthDate); if (age !== null && age < 18) { if (!formData.hasGuardian) { showAlert('Atenção', '⚠️ Para alunos menores de 18 anos, os dados do responsável são obrigatórios.', 'warning'); return; } if (!formData.guardianName || !formData.guardianCpf || !formData.guardianPhone) { showAlert('Atenção', '⚠️ Nome, CPF e Telefone do responsável são obrigatórios para menores de 18 anos.', 'warning'); return; } if (formData.guardianCpf && !isValidCPF(formData.guardianCpf)) { showAlert('Atenção', '⚠️ O CPF do responsável informado é inválido.', 'warning'); return; } } } let updatedStudents; let newPayments = [...data.payments]; let newContracts = [...data.contracts]; const studentId = editingStudent ? editingStudent.id : crypto.randomUUID(); // Gerar número de matrícula automático para novos alunos let enrollmentNumber = formData.enrollmentNumber || editingStudent?.enrollmentNumber; if (!enrollmentNumber) { const year = new Date().getFullYear(); const existingCount = data.students.filter(s => s.enrollmentNumber?.startsWith(`MAT-${year}`)).length; enrollmentNumber = `MAT-${year}${String(existingCount + 1).padStart(5, '0')}`; } // Gerar senha padrão do portal (6 primeiros dígitos do CPF) let portalPassword = formData.portalPassword || editingStudent?.portalPassword; if (!portalPassword) { const rawCpfForPassword = (formData.cpf || '').replace(/\D/g, ''); portalPassword = rawCpfForPassword.substring(0, 6) || '123456'; } // Processar Foto via Nova Arquitetura FormData MinIO Local let finalPhotoUrl = editingStudent?.photo || ''; if (photoFile) { const uploadData = new FormData(); uploadData.append('photo', photoFile, 'student-avatar.webp'); try { const uploadResponse = await fetch('/api/upload/student-photo', { method: 'POST', body: uploadData }); if (uploadResponse.ok) { const resultData = await uploadResponse.json(); finalPhotoUrl = resultData.url; } else { showAlert('Aviso', 'Erro ao salvar a foto fisicamente no MinIO. Imagem não atualizada.', 'warning'); } } catch (uploadError) { console.error('Erro no upload FormData:', uploadError); showAlert('Aviso', 'Falha de conexão no momento do upload. A foto pode não ter sido salva.', 'warning'); } } else if (formData.photo && !formData.photo.startsWith('data:image')) { finalPhotoUrl = formData.photo; } const studentToSave: Student = { ...(editingStudent || { id: studentId }), ...formData as Student, enrollmentNumber, portalPassword, photo: finalPhotoUrl }; if (editingStudent) { updatedStudents = data.students.map(s => s.id === editingStudent.id ? studentToSave : s ); } else { updatedStudents = [...data.students, studentToSave]; } // Process Generate Fee and Contract const studentClass = data.classes.find(c => c.id === formData.classId); const course = studentClass ? data.courses.find(c => c.id === studentClass.courseId) : null; if ((formData as any).generateFee && course) { const feeAmount = (course.registrationFee || 0) - (formData.discount || 0); if (feeAmount > 0) { newPayments.push({ id: crypto.randomUUID(), studentId: studentToSave.id, amount: feeAmount, dueDate: new Date().toISOString().split('T')[0], status: 'pending', type: 'registration', description: 'Taxa de Matrícula' }); try { const rawCpf = (formData.cpf || formData.guardianCpf || '').replace(/\D/g, ''); const dueDate = new Date(); dueDate.setDate(dueDate.getDate() + 5); const formattedDueDate = dueDate.toISOString().split('T')[0]; const response = await fetch('/api/gerar_cobranca', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ aluno_id: studentToSave.id, nome: studentToSave.name, cpf: rawCpf, email: formData.email, telefone: formData.phone?.replace(/\D/g, ''), cep: formData.addressZip?.replace(/\D/g, ''), endereco: formData.addressStreet, numero: formData.addressNumber, bairro: formData.addressNeighborhood, valor: feeAmount, vencimento: formattedDueDate, multa: 0, juros: 0, parcelas: 1, descricao: 'Taxa de Matrícula' }) }); if (response.ok) { const result = await response.json(); const lastPayment = newPayments[newPayments.length - 1]; if (lastPayment) { lastPayment.asaasPaymentUrl = result.bankSlipUrl; lastPayment.asaasPaymentId = result.paymentId; } } else { const errorData = await response.json().catch(() => ({})); throw new Error(errorData.error || 'Erro na resposta da API'); } } catch (error: any) { console.error('Erro ao gerar cobrança:', error); showAlert('Atenção', `Erro ao gerar boleto no Asaas: ${error.message}. O aluno foi salvo no sistema local.`, 'warning'); } } } if ((formData as any).generateContract && course) { const templateObj = data.contractTemplates?.find(t => t.id === formData.contractTemplateId); let content = templateObj?.content || ''; // Aluno content = content.replace(/{{aluno}}/g, studentToSave.name || ''); content = content.replace(/{{aluno_cpf}}/g, studentToSave.cpf || ''); content = content.replace(/{{aluno_rg}}/g, studentToSave.rg || ''); content = content.replace(/{{aluno_nascimento}}/g, studentToSave.birthDate ? studentToSave.birthDate.split('-').reverse().join('/') : ''); content = content.replace(/{{aluno_email}}/g, studentToSave.email || ''); content = content.replace(/{{aluno_telefone}}/g, studentToSave.phone || ''); content = content.replace(/{{aluno_cep}}/g, studentToSave.addressZip || ''); content = content.replace(/{{aluno_endereco}}/g, `${studentToSave.addressStreet || ''}, ${studentToSave.addressNumber || ''}`); content = content.replace(/{{aluno_bairro}}/g, studentToSave.addressNeighborhood || ''); content = content.replace(/{{aluno_cidade}}/g, studentToSave.addressCity || ''); content = content.replace(/{{aluno_estado}}/g, studentToSave.addressState || ''); // Responsável content = content.replace(/{{responsavel_nome}}/g, studentToSave.guardianName || ''); content = content.replace(/{{responsavel_cpf}}/g, studentToSave.guardianCpf || ''); content = content.replace(/{{responsavel_nascimento}}/g, studentToSave.guardianBirthDate ? studentToSave.guardianBirthDate.split('-').reverse().join('/') : ''); // Curso e Turma content = content.replace(/{{curso}}/g, course.name || ''); content = content.replace(/{{mensalidade}}/g, course.monthlyFee ? `R$ ${course.monthlyFee.toFixed(2)}` : 'R$ 0,00'); content = content.replace(/{{duracao}}/g, course.duration || ''); content = content.replace(/{{curso_taxa_matricula}}/g, course.registrationFee ? `R$ ${course.registrationFee.toFixed(2)}` : 'R$ 0,00'); content = content.replace(/{{turma_nome}}/g, studentClass?.name || ''); content = content.replace(/{{turma_professor}}/g, studentClass?.teacher || ''); content = content.replace(/{{turma_horario}}/g, studentClass?.schedule || ''); // Escola content = content.replace(/{{data}}/g, new Date().toLocaleDateString('pt-BR')); content = content.replace(/{{escola}}/g, data.profile.name || ''); content = content.replace(/{{cnpj_escola}}/g, data.profile.cnpj || ''); newContracts.push({ id: crypto.randomUUID(), studentId: studentToSave.id, title: `Contrato - ${course.name}`, content, createdAt: new Date().toISOString() }); } const newData = { students: updatedStudents, payments: newPayments, contracts: newContracts }; updateData(newData); dbService.saveData({ ...data, ...newData }); showAlert('Sucesso', (formData as any).generateFee ? 'Aluno salvo e nova cobrança gerada com sucesso.' : 'Aluno salvo com sucesso.', 'success'); closeModal(); } catch (error) { console.error(error); showAlert('Erro', 'Ocorreu um erro ao salvar o aluno.', 'error'); } finally { setIsSaving(false); } }; const handleDelete = (student: Student) => { setShowDeleteModal(student); setCancellationReason(''); }; const confirmCancellation = async (generatePDF: boolean) => { if (!showDeleteModal) return; if (!cancellationReason.trim()) { showAlert('Atenção', 'Por favor, informe o motivo do cancelamento.', 'warning'); return; } const updatedStudents = data.students.map(s => s.id === showDeleteModal.id ? { ...s, status: 'cancelled' as const, cancellationReason, classId: '' } : s ); updateData({ students: updatedStudents }); dbService.saveData({ ...data, students: updatedStudents }); if (generatePDF) { await pdfService.generateCancellationTermPDF(showDeleteModal, data, cancellationReason); } showAlert('Sucesso', 'Matrícula cancelada com sucesso.', 'success'); setShowDeleteModal(null); setCancellationReason(''); }; const handleRematricular = async (student: Student) => { showConfirm( 'Rematricular Aluno', `Deseja reativar a matrícula de ${student.name}?`, async () => { try { // Faz a requisição para o backend (apenas para constar, pois o estado é gerenciado pelo dbService) const response = await fetch(`/api/alunos/${student.id}/rematricular`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' } }); if (!response.ok) { throw new Error('Falha ao rematricular no servidor'); } // Atualiza o estado local const updatedStudents = data.students.map(s => s.id === student.id ? { ...s, status: 'active' as const, cancellationReason: undefined } : s ); updateData({ students: updatedStudents }); dbService.saveData({ ...data, students: updatedStudents }); showAlert('Sucesso', 'Aluno rematriculado com sucesso.', 'success'); } catch (error) { console.error('Erro ao rematricular:', error); showAlert('Erro', 'Ocorreu um erro ao rematricular o aluno.', 'error'); } } ); }; const handleDeletePermanently = (student: Student) => { showConfirm( 'Excluir Aluno Definitivamente', `⚠️ Atenção: Esta ação irá remover permanentemente ${student.name} e todo o seu histórico (mensalidades, contratos e presenças). Esta ação NÃO pode ser desfeita. Deseja continuar?`, async () => { const updatedStudents = data.students.filter(s => s.id !== student.id); const updatedPayments = data.payments.filter(p => p.studentId !== student.id); const updatedContracts = data.contracts.filter(c => c.studentId !== student.id); const updatedAttendance = data.attendance?.filter(a => a.studentId !== student.id) || []; const updatedNotifications = data.notifications?.filter(n => n.studentId !== student.id) || []; const newData = { students: updatedStudents, payments: updatedPayments, contracts: updatedContracts, attendance: updatedAttendance, notifications: updatedNotifications }; updateData(newData); dbService.saveData({ ...data, ...newData }); showAlert('Sucesso', 'O aluno e todo o seu histórico foram removidos permanentemente.', 'success'); } ); }; const handleTransferStudent = () => { if (!transferringStudent || !newClassId) return; const updatedStudents = data.students.map(s => s.id === transferringStudent.id ? { ...s, classId: newClassId } : s ); updateData({ students: updatedStudents }); dbService.saveData({ ...data, students: updatedStudents }); showAlert('Sucesso', 'Aluno transferido com sucesso.', 'success'); closeTransferModal(); }; const openModal = (student?: Student) => { const defaultData: any = { name: '', email: '', phone: '', birthDate: '', cpf: '', rg: '', rgIssueDate: '', guardianName: '', guardianPhone: '', guardianCpf: '', guardianBirthDate: '', classId: '', status: 'active', registrationDate: new Date().toISOString().split('T')[0], addressZip: '', addressStreet: '', addressNumber: '', addressNeighborhood: '', addressCity: '', addressState: '', discount: 0, hasGuardian: false, generateFee: true, generateContract: true }; if (student) { setEditingStudent(student); setFormData({ ...defaultData, ...student }); } else { setEditingStudent(null); setFormData(defaultData); } setShowModal(true); }; const filteredStudents = data.students .filter(s => { const matchesSearch = (s.name || '').toLowerCase().includes((searchTerm || '').toLowerCase()) || (s.cpf || '').includes(searchTerm) || (s.email || '').toLowerCase().includes((searchTerm || '').toLowerCase()); const matchesTab = activeTab === 'active' ? s.status !== 'cancelled' : s.status === 'cancelled'; const matchesClass = selectedClassId ? (selectedClassId === 'none' ? !s.classId : s.classId === selectedClassId) : true; return matchesSearch && matchesTab && matchesClass; }) .sort((a, b) => (a.name || '').localeCompare(b.name || '')); const generatePDF = async () => { setIsGeneratingPDF(true); try { await pdfService.generateStudentListPDF(data); } catch (error) { console.error('Erro ao gerar PDF:', error); showAlert('Erro', 'Falha ao gerar o relatório de alunos.', 'error'); } finally { setIsGeneratingPDF(false); } }; return (

Alunos

Gerencie matrículas e dados dos alunos.

{ setSearchTerm(e.target.value); if (e.target.value) setSelectedClassId(null); }} />
{(activeTab === 'active' && !selectedClassId && !searchTerm) ? (
{data.classes.map(cls => { const studentCount = data.students.filter(s => s.classId === cls.id && (activeTab === 'active' ? s.status !== 'cancelled' : s.status === 'cancelled')).length; const course = data.courses.find(c => c.id === cls.courseId); return ( ); })} {/* Card for students without class */} {data.students.some(s => !s.classId && (activeTab === 'active' ? s.status !== 'cancelled' : s.status === 'cancelled')) && ( )}
) : (
{(selectedClassId || searchTerm) && (
{selectedClassId && (

{selectedClassId === 'none' ? 'Alunos Sem Turma' : data.classes.find(c => c.id === selectedClassId)?.name}

)}
)}
{filteredStudents.map(student => { const studentClass = data.classes.find(c => c.id === student.classId); return ( ); })}
Aluno Turma Status Face ID Ações
{student.photo ? ( {student.name} ) : (
)}

{student.name}

{student.email}

{studentClass?.name || '-'} {student.status === 'active' ? 'Ativo' : student.status === 'cancelled' ? 'Cancelado' : 'Inativo'} {student.faceDescriptor ? ( OK ) : ( Pendente )}
{student.status !== 'cancelled' && ( )} {student.status === 'cancelled' && ( <> )} {student.status !== 'cancelled' && ( <> )}
)}
{/* Enrollment Modal */} {showModal && (
{/* Blue Top Bar */}
{/* Header */}

{editingStudent ? 'Editar Matrícula' : 'Nova Matrícula'}

Preencha os dados do aluno e responsável.

{/* Content */}
{/* Left Column: Photo */}
{cameraActive ? ( <> {!tempPhoto ? ( <>
{isProcessingFace && (
Processando Face...
)}
{/* Right Column: Form Data */}
{/* Personal Data */}

Dados Pessoais

setFormData({...formData, name: e.target.value})} placeholder="Ex: João da Silva" />
handleCPFChange(e, 'cpf')} placeholder="000.000.000-00" maxLength={14} /> {formData.cpf && !isValidCPF(formData.cpf) && ( Inválido )}
{formData.birthDate && calculateAge(formData.birthDate) !== null && (
{calculateAge(formData.birthDate)} anos
)}
setFormData({...formData, rg: e.target.value})} placeholder="Número do RG" />
setFormData({...formData, email: e.target.value})} placeholder="email@exemplo.com" />
{/* Portal do Aluno */}

🎓 Portal do Aluno

Gerado automaticamente. Será o login do aluno no portal.
setFormData({...formData, portalPassword: e.target.value})} placeholder={formData.cpf ? `Padrão: ${(formData.cpf || '').replace(/\D/g, '').substring(0, 6)}` : 'Será gerada ao salvar'} /> Padrão: 6 primeiros dígitos do CPF. Pode ser alterada a qualquer momento.
{/* Address Data */}

Endereço Residencial

checkCEP()} placeholder="00000-000" maxLength={9} />
setFormData({...formData, addressStreet: e.target.value})} placeholder="Rua, Avenida..." />
setFormData({...formData, addressNumber: e.target.value})} placeholder="123" />
setFormData({...formData, addressNeighborhood: e.target.value})} />
setFormData({...formData, addressCity: e.target.value})} />
setFormData({...formData, addressState: e.target.value})} maxLength={2} />
{/* Financial Guardian */}

Responsável Financeiro

setFormData({...formData, hasGuardian: e.target.checked})} /> {formData.birthDate && calculateAge(formData.birthDate) !== null && calculateAge(formData.birthDate)! < 18 && !formData.hasGuardian && ( Obrigatório para menores de 18 anos )}
{formData.hasGuardian && (
setFormData({...formData, guardianName: e.target.value})} />
handleCPFChange(e, 'guardianCpf')} maxLength={14} placeholder="000.000.000-00" /> {formData.guardianCpf && !isValidCPF(formData.guardianCpf) && ( Inválido )}
handlePhoneChange(e, 'guardianPhone')} placeholder="(00) 00000-0000" maxLength={15} />
)}
{/* Enrollment Data */}

Dados da Matrícula

setFormData({...formData, generateFee: e.target.checked} as any)} />
setFormData({...formData, generateContract: e.target.checked} as any)} />
{/* Footer */}
)} {/* Transfer Student Modal */} {transferringStudent && (
{/* Blue Top Bar */}

Transferir Aluno

Selecione a nova turma para {transferringStudent.name}:

)} {/* Student History Modal */} {viewingStudentHistory && ( <> {isFetchingCarne && (
Buscando carnê...
)} {isDeletingBatch && (
Apagando parcelas...
)}
{/* Blue Top Bar */}
{viewingStudentHistory.photo ? ( {viewingStudentHistory.name} ) : ( )}

{viewingStudentHistory.name}

Histórico Financeiro e Contratual

{/* Contracts Section */}

Contratos

{data.contracts.filter(c => c.studentId === viewingStudentHistory.id).length > 0 ? ( data.contracts.filter(c => c.studentId === viewingStudentHistory.id).map(contract => ( )) ) : ( )}
Título Data de Criação Ações
{contract.title} {new Date(contract.createdAt).toLocaleDateString()}
Nenhum contrato encontrado.
{/* Payments Section */}

Histórico de Pagamentos

{selectedPayments.length > 0 && ( )}
{data.payments.filter(p => p.studentId === viewingStudentHistory.id).length > 0 ? ( data.payments.filter(p => p.studentId === viewingStudentHistory.id).map(payment => ( )) ) : ( )}
{ const studentPayments = data.payments.filter(p => p.studentId === viewingStudentHistory.id && p.asaasPaymentId); if (e.target.checked) { setSelectedPayments(studentPayments.map(p => p.asaasPaymentId!)); } else { setSelectedPayments([]); } }} checked={ selectedPayments.length > 0 && selectedPayments.length === data.payments.filter(p => p.studentId === viewingStudentHistory.id && p.asaasPaymentId).length } /> Descrição Vencimento Valor Status Data Pagamento Ações
togglePaymentSelection(payment.asaasPaymentId || '')} disabled={!payment.asaasPaymentId} /> {payment.description || (payment.type === 'monthly' ? `Mensalidade ${payment.installmentNumber}/${payment.totalInstallments}` : 'Taxa')} {new Date(payment.dueDate).toLocaleDateString()} {new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(payment.amount)} {(payment.status === 'paid' || payment.status === 'received' || payment.status === 'confirmed') ? 'Pago' : payment.status === 'overdue' ? 'Atrasado' : 'Pendente'} {payment.paidDate ? new Date(payment.paidDate).toLocaleDateString() : '-'} {payment.asaasPaymentId && ( <> {(payment.status === 'pending' || payment.status === 'overdue') && ( )} {(payment.status === 'paid' || payment.status === 'received' || payment.status === 'confirmed') && ( )} )}
Nenhum pagamento registrado.
)} {/* Batch Delete Confirmation Modal */} {showDeleteBatchModal && (

Confirmar Exclusão

Tem a certeza que deseja apagar as {selectedPayments.length} parcelas selecionadas? Esta ação não pode ser desfeita e irá cancelar as cobranças no Asaas.

)} {/* Cancellation Modal */} {showDeleteModal && (

Cancelar Matrícula

Cancelamento de {showDeleteModal.name}

O histórico do aluno será mantido, mas o status será alterado para Cancelado.