import React, { useState, useMemo } from 'react'; import { SchoolData, Payment, Student } from '../types'; import { useDialog } from '../DialogContext'; import SearchableSelect from './SearchableSelect'; import { CheckCircle, Clock, AlertCircle, RefreshCw, Filter, DollarSign, Plus, X, Download, FileSignature, Printer, Tag, Hash, User, BookOpen, Trash2, Pencil, Eye, Calendar, AlertTriangle, Barcode, Receipt, Layers, ChevronUp, ChevronDown, Database, Search } from 'lucide-react'; import { pdfService } from '../services/pdfService'; import { supabase, isSupabaseConfigured } from '../services/supabase'; interface FinanceProps { data: SchoolData; updateData: (newData: Partial) => void; } const Finance: React.FC = ({ data, updateData }) => { const { showAlert } = useDialog(); const [filterStatus, setFilterStatus] = useState<'all' | 'pending' | 'paid' | 'overdue'>('all'); const [filterType, setFilterType] = useState<'all' | 'avulsas' | 'parcelamentos'>('all'); const [expandedInstallments, setExpandedInstallments] = useState([]); const [filterStudent, setFilterStudent] = useState('all'); const [filterClass, setFilterClass] = useState('all'); // Modais states // Instanciado dinamicamente para manter o form state const [showInstallmentSelectModal, setShowInstallmentSelectModal] = useState(false); const [availableInstallments, setAvailableInstallments] = useState([]); const [isModalOpen, setIsModalOpen] = useState(false); const [isClosing, setIsClosing] = useState(false); const [showHistoryModal, setShowHistoryModal] = useState(false); const [showDeleteModal, setShowDeleteModal] = useState(false); const [showPrintCarneModal, setShowPrintCarneModal] = useState(false); const [showSupabaseModal, setShowSupabaseModal] = useState(false); const [supabaseRecords, setSupabaseRecords] = useState([]); const [isFetchingSupabase, setIsFetchingSupabase] = useState(false); const [supabaseSearch, setSupabaseSearch] = useState(''); const [selectedSupabaseRows, setSelectedSupabaseRows] = useState([]); // Selection states const [selectedStudentHistory, setSelectedStudentHistory] = useState(null); const [selectedStudentForCarne, setSelectedStudentForCarne] = useState(''); const [paymentToDelete, setPaymentToDelete] = useState(null); const [selectedPayments, setSelectedPayments] = useState([]); const [carneToDelete, setCarneToDelete] = useState<{ installmentId: string, payments: any[] } | null>(null); const [carneSelectedPayments, setCarneSelectedPayments] = useState([]); const [paymentToEdit, setPaymentToEdit] = useState(null); const [editValue, setEditValue] = useState(''); const [editDate, setEditDate] = useState(''); const [isEditing, setIsEditing] = useState(false); const [isSyncing, setIsSyncing] = useState(false); const [isGeneratingPDF, setIsGeneratingPDF] = useState(false); const [isFetchingCarne, setIsFetchingCarne] = useState(false); const [isDeleting, setIsDeleting] = useState(false); React.useEffect(() => { syncAsaasPayments(); }, []); const [showFallbackModal, setShowFallbackModal] = useState(false); const [fallbackInstallments, setFallbackInstallments] = useState([]); const handleOpenPaymentLink = async (id: string, type: 'boleto' | 'recibo' | 'carne') => { try { showAlert('Aguarde', `Buscando ${type}...`, 'info'); if (type === 'carne') { const response = await fetch(`/api/parcelamentos/${id}/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 { showAlert('Erro', result.error || 'Falha ao buscar carnê.', 'error'); } return; } const response = await fetch(`/api/cobrancas/${id}/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 checkInstallmentsForStudent = (studentId: string) => { const studentPayments = data.payments.filter(p => p.studentId === studentId && (p.asaasInstallmentId || p.installmentId || p.installment)); const grouped = {} as Record; studentPayments.forEach(p => { const iid = p.asaasInstallmentId || p.installmentId || (typeof p.installment === 'object' ? p.installment.id : p.installment); if (!iid) return; if (!grouped[iid]) grouped[iid] = { id: iid, description: p.description || 'Parcelamento', total: 0, count: 0 }; grouped[iid].total += p.amount; grouped[iid].count++; }); const uniqueInstallments = Object.values(grouped); if (uniqueInstallments.length === 0) { showAlert('Atenção', 'Este aluno não possui nenhum parcelamento ativo no momento.', 'warning'); return; } if (uniqueInstallments.length === 1) { executePrintCarne(uniqueInstallments[0].id); } else { setAvailableInstallments(uniqueInstallments); setShowInstallmentSelectModal(true); } }; // Função reutilizável para a impressão do carnê // Recebe o ID do parcelamento (ex: UUID puro), faz o acesso à rota do back-end que retorna o PDF binário diretamente. const executePrintCarne = async (installmentId: string) => { try { // Garante que é o UUID puro (remove ins_ caso exista) const cleanId = installmentId.replace(/^(ins_|inst_)/, ''); let url = `/api/imprimir-carne/${cleanId}`; // Abre a rota (que retorna Content-Type: application/pdf) em uma nova aba window.open(url, '_blank', 'noopener,noreferrer'); showAlert('Sucesso', 'Abrindo o carnê...', 'info'); } catch (error) { console.error(error); showAlert('Erro', 'Ocorreu um erro ao tentar abrir o carnê.', 'error'); } finally { setShowInstallmentSelectModal(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 { 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 dataPaymentsRef = React.useRef(data.payments); React.useEffect(() => { dataPaymentsRef.current = data.payments; }, [data.payments]); const syncAsaasPayments = async () => { if (isSyncing) return; setIsSyncing(true); try { const resp = await fetch('/api/admin/cobrancas'); if (!resp.ok) throw new Error('API fetch failed'); const cloudPayments = await resp.json(); if (cloudPayments && cloudPayments.length > 0) { let updatedCount = 0; const currentPayments = dataPaymentsRef.current; const updatedPayments = currentPayments.map(p => { const match = cloudPayments.find((cp: any) => { if (p.asaasPaymentId) { return cp.asaas_payment_id === p.asaasPaymentId; } return cp.aluno_id === p.studentId && Math.abs(cp.valor - p.amount) < 0.01 && cp.vencimento === p.dueDate; }); if (match) { const statusStr = (match.status || '').toLowerCase(); const newStatus = statusStr === 'pago' ? 'paid' : statusStr === 'atrasado' ? 'overdue' : statusStr === 'cancelado' ? 'cancelled' : 'pending'; if (p.status !== newStatus || p.amount !== match.valor || p.installmentId !== (match.asaas_installment_id || match.installment) || p.asaasPaymentUrl !== match.link_boleto || p.asaasPaymentId !== match.asaas_payment_id) { updatedCount++; return { ...p, status: newStatus as any, amount: match.valor, paidDate: match.data_pagamento || p.paidDate, installmentId: match.asaas_installment_id || match.installment || p.installmentId, asaasPaymentUrl: match.link_boleto || p.asaasPaymentUrl, asaasPaymentId: match.asaas_payment_id || p.asaasPaymentId }; } } return p; }); if (updatedCount > 0) { updateData({ payments: updatedPayments }); const hasOverdue = updatedPayments.some((p, idx) => { const oldP = currentPayments[idx]; return oldP && oldP.status !== 'overdue' && p.status === 'overdue'; }); const hasPaid = updatedPayments.some((p, idx) => { const oldP = currentPayments[idx]; return oldP && oldP.status !== 'paid' && p.status === 'paid'; }); let message = `${updatedCount} pagamento(s) atualizado(s).`; if (hasPaid && !hasOverdue) message = 'Pagamento confirmado e registrado.'; if (hasOverdue && !hasPaid) message = 'Status atualizado para Atrasado.'; if (hasPaid && hasOverdue) message = 'Pagamentos e atrasos atualizados.'; showAlert('Sincronização', message, 'success'); } } } catch (error) { console.error('Erro ao sincronizar pagamentos:', error); // Suppress alert so it doesn't pop up randomly to the user if the server restarts temporarily } finally { setIsSyncing(false); } }; const fetchSupabaseRecords = async () => { setIsFetchingSupabase(true); setSelectedSupabaseRows([]); try { const resp = await fetch('/api/admin/cobrancas'); if (!resp.ok) throw new Error('API fetch failed'); const records = await resp.json(); setSupabaseRecords(records || []); } catch (error) { console.error('Error fetching billing records from Postgres:', error); showAlert('Erro', 'Falha ao buscar dados do Banco de Dados.', 'error'); } finally { setIsFetchingSupabase(false); } }; const deleteSupabaseRecord = async (id: string) => { try { const resp = await fetch(`/api/admin/cobrancas/${id}`, { method: 'DELETE' }); if (!resp.ok) throw new Error('Delete failed'); setSupabaseRecords(prev => prev.filter(r => r.asaas_payment_id !== id)); showAlert('Sucesso', 'Registro removido do Banco.', 'success'); } catch (error) { console.error('Error deleting record:', error); showAlert('Erro', 'Falha ao excluir do Banco de Dados.', 'error'); } }; const deleteSupabaseRecordsBulk = async () => { if (selectedSupabaseRows.length === 0) return; if(!confirm(`Tem certeza que deseja excluir ${selectedSupabaseRows.length} registros diretamente do Banco de Dados?`)) return; setIsFetchingSupabase(true); try { const resp = await fetch('/api/admin/cobrancas', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ids: selectedSupabaseRows }) }); if (!resp.ok) throw new Error('Bulk delete failed'); setSupabaseRecords(prev => prev.filter(r => !selectedSupabaseRows.includes(r.asaas_payment_id))); setSelectedSupabaseRows([]); showAlert('Sucesso', 'Registros removidos com sucesso.', 'success'); } catch (error) { console.error('Error deleting records in bulk:', error); showAlert('Erro', 'Falha ao excluir registros em massa.', 'error'); } finally { setIsFetchingSupabase(false); } }; React.useEffect(() => { if (showSupabaseModal) { fetchSupabaseRecords(); } }, [showSupabaseModal]); // General form state const [manualInstallments, setManualInstallments] = useState(1); const [dueDateDisplay, setDueDateDisplay] = useState(new Date().toLocaleDateString('pt-BR')); const [selectedItemId, setSelectedItemId] = useState(''); const [selectedItemType, setSelectedItemType] = useState<'course' | 'handout' | ''>(''); const [formData, setFormData] = useState & { fine: number }>({ studentId: '', amount: 150, discount: 0, discountType: 'fixed', fine: 0, interest: 0, dueDate: new Date().toISOString().split('T')[0], type: 'monthly', description: '' }); // Auto-fill fine and interest based on student's course or handout React.useEffect(() => { if (formData.studentId) { const student = data.students.find(s => s.id === formData.studentId); if (student) { let fine = 0; let interest = 0; if (selectedItemId) { if (selectedItemId.startsWith('course_')) { const course = data.courses.find(c => c.id === selectedItemId.replace('course_', '')); fine = course?.finePercentage || 0; interest = course?.interestPercentage || 0; } else if (selectedItemId.startsWith('handout_')) { const handout = data.handouts?.find(h => h.id === selectedItemId.replace('handout_', '')); fine = handout?.finePercentage || 0; interest = handout?.interestPercentage || 0; } } else { const studentClass = data.classes.find(c => c.id === student.classId); const course = data.courses.find(c => c.id === studentClass?.courseId); fine = course?.finePercentage || 0; interest = course?.interestPercentage || 0; } setFormData(prev => ({ ...prev, fine: fine, interest: interest })); } } }, [formData.studentId, selectedItemId, data.students, data.classes, data.courses, data.handouts]); const formatDateMask = (val: string) => { return val.replace(/\D/g, '').replace(/(\d{2})(\d)/, '$1/$2').replace(/(\d{2})(\d)/, '$1/$2').slice(0, 10); }; const dateBrToIso = (br: string) => { if (br.length !== 10) return ''; const [d, m, y] = br.split('/'); return `${y}-${m}-${d}`; }; const paymentIndexMap = useMemo(() => { return new Map(data.payments.map((p, i) => [p.id, i])); }, [data.payments]); const maxIndexMap = useMemo(() => { const map = new Map(); data.payments.forEach(p => { const key = p.installmentId || p.id; const currentIndex = paymentIndexMap.get(p.id) || 0; const maxSoFar = map.get(key) || -1; if (currentIndex > maxSoFar) map.set(key, currentIndex); }); return map; }, [data.payments, paymentIndexMap]); const filteredPayments = data.payments .filter(p => { const statusMatch = filterStatus === 'all' || p.status === filterStatus; const studentMatch = filterStudent === 'all' || p.studentId === filterStudent; let classMatch = true; if (filterClass !== 'all') { const student = data.students.find(s => s.id === p.studentId); classMatch = student?.classId === filterClass; } let typeMatch = true; if (filterType === 'avulsas') { typeMatch = !p.installmentId && !p.asaasInstallmentId; } else if (filterType === 'parcelamentos') { typeMatch = !!p.installmentId || !!p.asaasInstallmentId; } return statusMatch && studentMatch && classMatch && typeMatch; }) .sort((a, b) => { const keyA = maxIndexMap.get(a.installmentId || a.asaasInstallmentId || a.id) || 0; const keyB = maxIndexMap.get(b.installmentId || b.asaasInstallmentId || b.id) || 0; if (keyA !== keyB) return keyB - keyA; return new Date(a.dueDate).getTime() - new Date(b.dueDate).getTime(); }); const groupedInstallments = useMemo(() => { if (filterType !== 'parcelamentos') return []; const groups: Record = {}; filteredPayments.forEach(p => { const groupKey = p.installmentId || p.asaasInstallmentId; if (groupKey) { if (!groups[groupKey]) groups[groupKey] = []; groups[groupKey].push(p); } }); return Object.entries(groups).map(([id, payments]) => { const sorted = payments.sort((a, b) => new Date(a.dueDate).getTime() - new Date(b.dueDate).getTime()); return { installmentId: id, payments: sorted, studentId: sorted[0].studentId, totalAmount: sorted.reduce((sum, p) => sum + p.amount, 0), totalInstallments: sorted[0].totalInstallments || sorted.length, description: sorted[0].description?.split(' (')[0] || 'Parcelamento', dueDate: sorted[0].dueDate }; }).sort((a, b) => { const keyA = maxIndexMap.get(a.installmentId) || 0; const keyB = maxIndexMap.get(b.installmentId) || 0; return keyB - keyA; }); }, [filteredPayments, filterType, maxIndexMap]); const toggleInstallment = (id: string) => { setExpandedInstallments(prev => prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id] ); }; const handleItemSelect = (e: React.ChangeEvent) => { const val = e.target.value; setSelectedItemId(val); if (!val) { setSelectedItemType(''); setFormData(prev => ({ ...prev, amount: 0, description: '' })); return; } if (val.startsWith('course_')) { const courseId = val.replace('course_', ''); const course = data.courses.find(c => c.id === courseId); if (course) { setSelectedItemType('course'); setFormData(prev => ({ ...prev, amount: course.monthlyFee, description: `Mensalidade - ${course.name}`, type: 'monthly', fine: course.finePercentage || 0, interest: course.interestPercentage || 0 })); } } else if (val.startsWith('handout_')) { const handoutId = val.replace('handout_', ''); const handout = data.handouts?.find(h => h.id === handoutId); if (handout) { setSelectedItemType('handout'); setFormData(prev => ({ ...prev, amount: handout.price, description: `Apostila - ${handout.name}`, type: 'other', fine: handout.finePercentage || 0, interest: handout.interestPercentage || 0 })); } } }; const handleCreatePayment = async (e: React.FormEvent) => { e.preventDefault(); if (!formData.studentId || formData.amount <= 0) { showAlert('Atenção', '⚠️ Por favor, selecione um aluno e informe um valor válido.', 'warning'); return; } const student = data.students.find(s => s.id === formData.studentId); if (!student) { showAlert('Erro', 'Aluno não encontrado.', 'error'); return; } const newPayments: Payment[] = []; let baseDateStr = formData.dueDate; if (dueDateDisplay.length === 10) { baseDateStr = dateBrToIso(dueDateDisplay); } const baseDate = new Date(baseDateStr); for (let i = 0; i < manualInstallments; i++) { const dueDate = new Date(baseDate); dueDate.setMonth(baseDate.getMonth() + i); // Enviar o valor integral para o Asaas, o desconto será condicional const baseAmount = formData.amount; const { fine, ...rest } = formData; const paymentDueDate = dueDate.toISOString().split('T')[0]; newPayments.push({ ...rest, lateFee: fine, dueDate: paymentDueDate, id: crypto.randomUUID(), amount: baseAmount, status: 'pending', installmentNumber: manualInstallments > 1 ? i + 1 : undefined, totalInstallments: manualInstallments > 1 ? manualInstallments : undefined, description: manualInstallments > 1 ? `${formData.description || 'Mensalidade'} (${i + 1}/${manualInstallments})` : formData.description }); } try { const isoDueDate = newPayments[0].dueDate; // Cálculo preciso de idade — Bloqueio de bugs de fuso horário const birthDateStr = student.birthDate || student.data_nascimento || ''; let age = 18; // Padrão: Maior de idade se não tiver data if (birthDateStr && birthDateStr.includes('-')) { const [year, month, day] = birthDateStr.split('-').map(Number); const birthDate = new Date(year, month - 1, day); const today = new Date(); age = today.getFullYear() - birthDate.getFullYear(); const m = today.getMonth() - birthDate.getMonth(); if (m < 0 || (m === 0 && today.getDate() < birthDate.getDate())) { age--; } } const isMinor = age < 18; // Fallback robusto: Se for menor, mas não tiver dados do responsável, envia para o aluno mesmo assim para não quebrar. const finalName = (isMinor && student.guardianName && student.guardianName.trim() !== '') ? student.guardianName : student.name; const finalPhone = (isMinor && student.guardianPhone && student.guardianPhone.trim() !== '') ? student.guardianPhone : student.phone; const rawCpf = (student.cpf || '').replace(/\D/g, ''); const rawGuardianCpf = (student.guardianCpf || '').replace(/\D/g, ''); const finalCpf = (isMinor && rawGuardianCpf) ? rawGuardianCpf : rawCpf; // EXTREMAMENTE IMPORTANTE: No Asaas Oficial, a data de nascimento deve pertencer ao dono do CPF enviado. const finalBirthDate = (isMinor && student.guardianBirthDate) ? student.guardianBirthDate : student.birthDate; // Validação de campos obrigatórios para o Asaas Oficial if (!finalCpf || finalCpf.length < 11) { showAlert('Erro de Cadastro', `O ${isMinor ? 'responsável' : 'aluno'} precisa ter um CPF válido cadastrado para gerar cobrança no Asaas Oficial.`, 'error'); return; } if (!student.addressZip || student.addressZip.length < 8) { showAlert('Erro de Cadastro', 'O CEP do aluno é obrigatório e deve ser válido para o Asaas Oficial.', 'error'); return; } if (!student.addressStreet || !student.addressNumber) { showAlert('Erro de Cadastro', 'Endereço e Número são obrigatórios no cadastro do aluno para gerar cobrança.', 'error'); return; } const originalDesc = formData.description || 'Mensalidade'; const finalDescription = isMinor ? `${originalDesc} - Aluno: ${student.name}` : originalDesc; const response = await fetch('/api/gerar_cobranca', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ aluno_id: student.id, nome: finalName, cpf: finalCpf, email: student.email, valor: formData.amount, vencimento: isoDueDate, multa: formData.fine, juros: formData.interest, desconto: Number(formData.discount) || 0, telefone: finalPhone, cep: student.addressZip, endereco: student.addressStreet, numero: student.addressNumber, bairro: student.addressNeighborhood, nascimento: finalBirthDate, descricao: finalDescription, parcelas: manualInstallments }) }); if (response.ok) { const asaasData = await response.json(); if (asaasData.payments && asaasData.payments.length > 0) { newPayments.forEach((p, idx) => { // Se o Asaas retornou menos parcelas que o esperado, usa a última disponível const asaasPayment = asaasData.payments[idx] || asaasData.payments[asaasData.payments.length - 1]; p.asaasPaymentUrl = asaasPayment.link_boleto; p.asaasPaymentId = asaasPayment.asaas_payment_id; if (asaasData.installment) { p.installmentId = asaasData.installment; } }); } } else { throw new Error('Erro na resposta da API'); } } catch (error) { console.error('Erro ao conectar com o Asaas:', error); showAlert('Atenção', 'Erro ao conectar com o Asaas. Lançamentos salvos apenas localmente.', 'warning'); } let newDeliveries = [...(data.handoutDeliveries || [])]; if (selectedItemType === 'handout' && newPayments.length > 0) { const handoutId = selectedItemId.replace('handout_', ''); const firstPayment = newPayments[0]; const existingDeliveryIndex = newDeliveries.findIndex(d => d.studentId === student.id && d.handoutId === handoutId); if (existingDeliveryIndex >= 0) { newDeliveries[existingDeliveryIndex] = { ...newDeliveries[existingDeliveryIndex], asaasPaymentId: firstPayment.asaasPaymentId, asaasPaymentUrl: firstPayment.asaasPaymentUrl }; } else { newDeliveries.push({ id: crypto.randomUUID(), studentId: student.id, handoutId: handoutId, deliveryStatus: 'pending', paymentStatus: 'pending', asaasPaymentId: firstPayment.asaasPaymentId, asaasPaymentUrl: firstPayment.asaasPaymentUrl }); } } updateData({ payments: [...data.payments, ...newPayments], ...(selectedItemType === 'handout' ? { handoutDeliveries: newDeliveries } : {}) }); showAlert('Sucesso', 'Nova cobrança gerada com sucesso.', 'success'); closeModal(); }; const closeModal = () => { setIsClosing(true); setTimeout(() => { setIsModalOpen(false); setShowHistoryModal(false); setShowDeleteModal(false); setIsClosing(false); setManualInstallments(1); const today = new Date(); setDueDateDisplay(today.toLocaleDateString('pt-BR')); setFormData({ studentId: '', amount: 150, discount: 0, discountType: 'fixed', fine: 0, interest: 0, dueDate: today.toISOString().split('T')[0], type: 'monthly', description: '' }); setSelectedStudentHistory(null); setPaymentToDelete(null); }, 300); }; const handleDelete = async (deleteType: 'single' | 'all') => { if (!paymentToDelete || isDeleting) return; console.log('Item a ser excluído:', paymentToDelete); // Determine the ID to send let asaasIdToDelete = ''; let isInstallmentPackage = false; // 1. Se passamos explicitamente o asaasIdParaExcluir (ex: lixeira do grupo de carnê) if ((paymentToDelete as any).asaasIdParaExcluir) { asaasIdToDelete = (paymentToDelete as any).asaasIdParaExcluir; isInstallmentPackage = true; } // 2. Se for para excluir TUDO (o pacote agrupado, ou usuário clicou em 'Excluir Carnê Completo' numa parcela) else if (deleteType === 'all') { asaasIdToDelete = paymentToDelete.installmentId || paymentToDelete.id; isInstallmentPackage = true; } // 3. Se for exclusão de apenas uma parcela individual else { asaasIdToDelete = paymentToDelete.asaasPaymentId || paymentToDelete.id; isInstallmentPackage = false; } if (!asaasIdToDelete) { console.error('Falha ao extrair ID. Objeto paymentToDelete:', paymentToDelete); showAlert('Erro', 'ID da cobrança não encontrado.', 'error'); return; } setIsDeleting(true); try { showAlert('Aguarde', isInstallmentPackage ? 'Excluindo carnê completo no Asaas...' : 'Excluindo cobrança no Asaas...', 'info'); const response = await fetch('/api/excluir_cobranca', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: asaasIdToDelete }) }); const result = await response.json(); if (response.ok) { showAlert('Sucesso', 'Cobrança excluída com sucesso.', 'success'); // SO atualiza se backend confirmou (200 OK) let updatedPayments = [...data.payments]; if (isInstallmentPackage) { updatedPayments = updatedPayments.filter(p => p.installmentId !== asaasIdToDelete); } else { updatedPayments = updatedPayments.filter(p => p.asaasPaymentId !== asaasIdToDelete && p.id !== asaasIdToDelete); } updateData({ payments: updatedPayments }); closeModal(); } else { showAlert('Erro', result.error || 'Não é possível excluir. Verifique se já foi paga.', 'error'); } } catch (error) { console.error('Erro ao excluir:', error); showAlert('Erro', 'Falha na comunicação com o servidor ao excluir.', 'error'); } finally { setIsDeleting(false); } }; const handleEditSave = async (e: React.FormEvent) => { e.preventDefault(); if (!paymentToEdit || isEditing) return; setIsEditing(true); try { const targetId = paymentToEdit.asaasPaymentId || paymentToEdit.id; const response = await fetch(`/api/cobrancas/${targetId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ valor: parseFloat(editValue.replace(',', '.')), vencimento: editDate }) }); const result = await response.json(); if (response.ok) { updateData({ payments: data.payments.map(p => p.id === paymentToEdit.id ? { ...p, amount: parseFloat(editValue.replace(',', '.')), dueDate: editDate } : p) }); showAlert('Sucesso', 'Cobrança atualizada!', 'success'); setPaymentToEdit(null); } else { showAlert('Erro', result.error || 'Falha ao atualizar.', 'error'); } } catch { showAlert('Erro', 'Falha na comunicação com o servidor.', 'error'); } finally { setIsEditing(false); } }; const handleBulkDelete = async (ids: string[], isCarneContext = false) => { if (ids.length === 0 || isDeleting) return; setIsDeleting(true); let successCount = 0; let newPayments = [...data.payments]; showAlert('Aguarde', `Excluindo ${ids.length} cobranças no Asaas...`, 'info'); for (const id of ids) { try { const response = await fetch('/api/excluir_cobranca', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id }) }); if (response.ok) { successCount++; newPayments = newPayments.filter(p => p.id !== id && p.asaasPaymentId !== id); } } catch (e) { console.error('Error batch deleting', id, e); } } if (successCount > 0) { updateData({ payments: newPayments }); showAlert('Sucesso', `${successCount} exclusão(ões) concluída(s) com sucesso.`, 'success'); } else { showAlert('Erro', 'Falha ao excluir selecionados.', 'error'); } if (isCarneContext) { setCarneToDelete(null); setCarneSelectedPayments([]); } else { setSelectedPayments([]); } setIsDeleting(false); }; const openHistory = (studentId: string) => { const student = data.students.find(s => s.id === studentId); if (student) { setSelectedStudentHistory(student); setShowHistoryModal(true); } }; const openDelete = (payment: Payment) => { setPaymentToDelete(payment); setShowDeleteModal(true); }; const getStatusBadge = (payment: Payment) => { const status = (payment.status || '').toLowerCase(); if (status === 'paid' || status === 'pago' || status === 'received' || status === 'confirmed') { const dueDate = new Date(payment.dueDate); const paidDate = payment.paidDate ? new Date(payment.paidDate) : null; if (paidDate) { // Reset hours for comparison dueDate.setHours(0, 0, 0, 0); paidDate.setHours(0, 0, 0, 0); if (paidDate <= dueDate) { return Pagamento em Dia; } else { return Pago com Atraso; } } return Pago; } if (status === 'overdue' || status === 'atrasado') { return Atrasado; } if (status === 'pending' || status === 'pendente' || !status) { return Pendente; } if (status === 'cancelled' || status === 'cancelado') { return Cancelado; } return null; }; const inputClass = "px-4 py-2 bg-white text-black border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 transition-all shadow-sm text-xs"; return (

Financeiro

Gestão de mensalidades vinculadas a contratos e cursos.

Visão:
{(['all', 'avulsas', 'parcelamentos'] as const).map(type => ( ))}
Status:
{(['all', 'pending', 'paid', 'overdue'] as const).map(status => ( ))}
{selectedPayments.length > 0 && (
{selectedPayments.length} lançamento(s) selecionado(s)
)}
{filterType === 'parcelamentos' ? ( groupedInstallments.map(group => { const student = data.students.find(s => s.id === group.studentId); const isExpanded = expandedInstallments.includes(group.installmentId); return ( {isExpanded && group.payments.map(payment => { const payId = payment.asaasPaymentId || payment.id; return ( ); })} ); }) ) : ( filteredPayments.map(payment => { const student = data.students.find(s => s.id === payment.studentId); const payId = payment.asaasPaymentId || payment.id; return ( ); }) )} {((filterType === 'parcelamentos' && groupedInstallments.length === 0) || (filterType !== 'parcelamentos' && filteredPayments.length === 0)) && ( )}
{filterType !== 'parcelamentos' && ( 0 && selectedPayments.length === filteredPayments.filter(p => p.status !== 'paid').length} onChange={e => setSelectedPayments(e.target.checked ? filteredPayments.filter(p => p.status !== 'paid').map(p => p.asaasPaymentId || p.id) : [])} /> )} Aluno / Descrição Vencimento Valor Status Ação
{student?.name || 'Aluno Removido'}
Carnê de {group.totalInstallments}x
{group.description}
{group.payments.length > 0 && ( <> Início: {new Date(group.payments[0].dueDate + 'T12:00:00Z').toLocaleDateString('pt-BR', { timeZone: 'America/Sao_Paulo' })} Fim: {new Date(group.payments[group.payments.length - 1].dueDate + 'T12:00:00Z').toLocaleDateString('pt-BR', { timeZone: 'America/Sao_Paulo' })} )}
R$ {group.totalAmount.toLocaleString('pt-BR', { minimumFractionDigits: 2 })}
Total do Carnê
{group.payments.length} Parcelas
setSelectedPayments(prev => e.target.checked ? [...prev, payId] : prev.filter(x => x !== payId) )} />
Parcela {payment.installmentNumber}/{payment.totalInstallments}
{new Date(payment.dueDate + 'T12:00:00Z').toLocaleDateString('pt-BR', { timeZone: 'America/Sao_Paulo' })}
R$ {payment.amount.toLocaleString('pt-BR', { minimumFractionDigits: 2 })}
{!!payment.discount && payment.discount > 0 && (
- R$ {payment.discount.toLocaleString('pt-BR', { minimumFractionDigits: 2 })}
)}
{getStatusBadge(payment)}
{payment.asaasPaymentId && ( <> {(payment.status === 'pending' || payment.status === 'overdue') && ( )} {(payment.status === 'paid' || payment.status === 'received' || payment.status === 'confirmed') && ( )} )} {(payment.installmentId || payment.asaasInstallmentId) && ( )}
setSelectedPayments(prev => e.target.checked ? [...prev, payId] : prev.filter(x => x !== payId) )} />
{student?.name || 'Aluno Removido'}
{payment.type === 'registration' ? 'Matrícula' : 'Mensalidade'}{payment.installmentNumber && {payment.installmentNumber}/{payment.totalInstallments}}
{payment.description &&
{payment.description}
}
{new Date(payment.dueDate + 'T12:00:00Z').toLocaleDateString('pt-BR', { timeZone: 'America/Sao_Paulo' })}
R$ {payment.amount.toLocaleString('pt-BR', { minimumFractionDigits: 2 })}
{!!payment.discount && payment.discount > 0 &&
- R$ {payment.discount.toFixed(2)}
}
{getStatusBadge(payment)}
{payment.asaasPaymentId && (<> {(payment.status === 'pending' || payment.status === 'overdue') && ()} {(payment.status === 'paid' || payment.status === 'received' || payment.status === 'confirmed') && ()} )} {(payment.installmentId || payment.asaasInstallmentId) && ( )}
Nenhum lançamento encontrado para os filtros selecionados.
{/* NEW PAYMENT MODAL */} {isModalOpen && (
{/* Blue Top Bar */}

Novo Lançamento

Registre cobranças manuais ou parceladas.

({ id: s.id, name: s.name, subtext: data.classes.find(c => c.id === s.classId)?.name || 'Sem Turma' }))} value={formData.studentId} onChange={val => setFormData({ ...formData, studentId: val })} />
setManualInstallments(parseInt(e.target.value) || 1)} />
setFormData({ ...formData, amount: parseFloat(e.target.value) })} />
setFormData({ ...formData, discount: parseFloat(e.target.value) })} />
setFormData({ ...formData, fine: parseFloat(e.target.value) || 0 })} />
setFormData({ ...formData, interest: parseFloat(e.target.value) || 0 })} />
{ const masked = formatDateMask(e.target.value); setDueDateDisplay(masked); if (masked.length === 10) { setFormData(prev => ({ ...prev, dueDate: dateBrToIso(masked) })); } }} maxLength={10} />
setFormData({ ...formData, description: e.target.value })} />
)} {/* STUDENT HISTORY MODAL */} {showHistoryModal && selectedStudentHistory && (
{/* Blue Top Bar */}

{selectedStudentHistory.name}

Histórico completo de pagamentos.

{data.payments.filter(p => p.studentId === selectedStudentHistory.id).sort((a, b) => new Date(a.dueDate).getTime() - new Date(b.dueDate).getTime()).map(p => ( ))}
Descrição Vencimento Valor Status Ação
{p.description || (p.type === 'monthly' ? 'Mensalidade' : 'Taxa')}
{p.installmentNumber &&
{p.installmentNumber}/{p.totalInstallments}
}
{new Date(p.dueDate + 'T12:00:00Z').toLocaleDateString('pt-BR', { timeZone: 'America/Sao_Paulo' })} R$ {p.amount.toFixed(2)} {getStatusBadge(p)} {p.asaasPaymentId && ( <> {(p.status === 'pending' || p.status === 'overdue') && ( )} {(p.status === 'paid' || p.status === 'received' || p.status === 'confirmed') && ( )} )}
)} {/* DELETE CONFIRMATION MODAL */} {showDeleteModal && paymentToDelete && (
{/* Blue Top Bar */}

Excluir Pagamento

Como deseja excluir este lançamento?

{paymentToDelete.id && typeof paymentToDelete.id === 'string' && (paymentToDelete.id.startsWith('inst_') || paymentToDelete.id.startsWith('ins_')) ? ( ) : ( <> {(paymentToDelete.installmentId || paymentToDelete.totalInstallments) && ( )} )}
)} {/* FALLBACK CARNE MODAL */} {showFallbackModal && (

Carnê Digital

O link único do carnê não está disponível. Você pode acessar os boletos individuais abaixo.

{fallbackInstallments.map((parcela) => (
Parcela {parcela.numero}
R$ {parcela.valor.toLocaleString('pt-BR', { minimumFractionDigits: 2 })}
Vencimento
{new Date(parcela.vencimento).toLocaleDateString('pt-BR')}
{parcela.status === 'paid' || parcela.status === 'received' || parcela.status === 'confirmed' ? 'Pago' : parcela.status === 'overdue' ? 'Atrasado' : 'Pendente'} {parcela.linkBoleto ? ( Abrir Boleto ) : ( Boleto indisponível )}
))}
)} {/* PRINT CARNE MODAL */} {carneToDelete && (

Exclusão de Carnê

Selecione as parcelas pendentes para exclusão

{carneToDelete.payments.map(p => ( ))}
)} {paymentToEdit && (

Editar Cobrança

setEditValue(e.target.value)} />
setEditDate(e.target.value)} />
)} {showInstallmentSelectModal && (

Selecione o Parcelamento

Este aluno tem mais de um carnê gerado no sistema. Escolha qual quer imprimir.

{availableInstallments.map((inst) => (

{inst.description}

{inst.count} parcelas vinculadas • Total: R$ {inst.total.toFixed(2)}

))}
)} {showPrintCarneModal && (

Imprimir Carnê

Selecione o aluno para buscar e imprimir o carnê completo do Asaas.

({ id: s.id, name: s.name }))} value={selectedStudentForCarne} onChange={setSelectedStudentForCarne} />
)} {/* Supabase Manager Modal */} {showSupabaseModal && (

Gerenciador de Cobranças (Supabase)

Visualize e gerencie a tabela alunos_cobrancas.

setSupabaseSearch(e.target.value)} className="w-full pl-10 pr-4 py-2 bg-white border border-slate-200 rounded-xl text-xs focus:ring-2 focus:ring-indigo-500 outline-none" />
{supabaseRecords .filter(r => { const searchLower = supabaseSearch.toLowerCase(); return (r.asaas_payment_id || '').toLowerCase().includes(searchLower) || (r.aluno_id || '').toLowerCase().includes(searchLower) || (r.status || '').toLowerCase().includes(searchLower); }) .map((record) => ( ))} {supabaseRecords.length === 0 && !isFetchingSupabase && ( )}
0 && selectedSupabaseRows.length === supabaseRecords.length} onChange={(e) => { if (e.target.checked) { setSelectedSupabaseRows(supabaseRecords.map(r => r.asaas_payment_id)); } else { setSelectedSupabaseRows([]); } }} /> ID Asaas Aluno (ID) Valor Vencimento Status Link Ação
{ if (e.target.checked) { setSelectedSupabaseRows(prev => [...prev, record.asaas_payment_id]); } else { setSelectedSupabaseRows(prev => prev.filter(id => id !== record.asaas_payment_id)); } }} /> {record.asaas_payment_id}
{data.students.find(s => s.id === record.aluno_id)?.name || 'N/A'}
{record.aluno_id}
R$ {Number(record.valor).toFixed(2)} {new Date(record.vencimento + 'T12:00:00Z').toLocaleDateString('pt-BR', { timeZone: 'America/Sao_Paulo' })} {record.status} {record.link_boleto ? ( Link ) : '-'}
Nenhum registro encontrado na tabela alunos_cobrancas.
Total de registros carregados: {supabaseRecords.length}
{selectedSupabaseRows.length > 0 && (
{selectedSupabaseRows.length} selecionados
)}
)}
); }; export default Finance;