import React, { useState, useEffect, useRef } from "react"; import { Download, Plus, ArrowUpDown, ArrowUp, ArrowDown, X, AlertCircle, Search, Upload, } from "lucide-react"; import { useNavigate } from "react-router-dom"; import ExcelJS from 'exceljs'; import { saveAs } from 'file-saver'; interface FinancialTransaction { id: string; fot: number; fot_id?: string; // Add fot_id data: string; // date string YYYY-MM-DD dataRaw?: string; // Optional raw date for editing curso: string; instituicao: string; anoFormatura: number; // or string label empresa: string; tipoEvento: string; tipoServico: string; nome: string; // professional_name endereco?: string; // Not in DB schema but in UI? Schema doesn't have address. We'll omit or map if needed. whatsapp: string; cpf: string; tabelaFree: string; valorFree: number; valorExtra: number; descricaoExtra: string; totalPagar: number; dataPgto: string; // date string pgtoOk: boolean; } const API_BASE_URL = import.meta.env.VITE_API_URL || "http://localhost:8080"; const Finance: React.FC = () => { const navigate = useNavigate(); const [transactions, setTransactions] = useState([]); const [showAddModal, setShowAddModal] = useState(false); const [showEditModal, setShowEditModal] = useState(false); const [successMessage, setSuccessMessage] = useState(""); // Success message state const [selectedTransaction, setSelectedTransaction] = useState(null); const [sortConfig, setSortConfig] = useState<{ key: keyof FinancialTransaction; direction: "asc" | "desc"; } | null>(null); // API Data States const [loading, setLoading] = useState(false); const [error, setError] = useState(""); const [tiposEventos, setTiposEventos] = useState([]); const [tiposServicos, setTiposServicos] = useState([]); // Filters State (Moved up) const [filters, setFilters] = useState({ fot: "", data: "", evento: "", servico: "", nome: "", status: "", curso: "", instituicao: "", ano: "", empresa: "", }); // Pagination State const [page, setPage] = useState(1); const [limit, setLimit] = useState(50); const [total, setTotal] = useState(0); // Scroll Sync Refs const topScrollRef = useRef(null); const tableScrollRef = useRef(null); const handleScroll = (source: 'top' | 'table') => { if (source === 'top' && topScrollRef.current && tableScrollRef.current) { tableScrollRef.current.scrollLeft = topScrollRef.current.scrollLeft; } else if (source === 'table' && topScrollRef.current && tableScrollRef.current) { topScrollRef.current.scrollLeft = tableScrollRef.current.scrollLeft; } }; // Form State const [formData, setFormData] = useState>({ fot: 0, data: new Date().toISOString().split("T")[0], curso: "", instituicao: "", anoFormatura: new Date().getFullYear(), empresa: "", tipoEvento: "", tipoServico: "", nome: "", whatsapp: "", cpf: "", tabelaFree: "", valorFree: 0, valorExtra: 0, descricaoExtra: "", totalPagar: 0, dataPgto: "", pgtoOk: false, }); const isEditInitializing = useRef(false); // Auto-fill state const [fotLoading, setFotLoading] = useState(false); const [fotFound, setFotFound] = useState(false); const [fotEvents, setFotEvents] = useState([]); // New state const [showEventSelector, setShowEventSelector] = useState(false); // FOT Search State const [fotQuery, setFotQuery] = useState(""); const [fotResults, setFotResults] = useState([]); const [showFotSuggestions, setShowFotSuggestions] = useState(false); // Professional Search State const [proQuery, setProQuery] = useState(""); const [proResults, setProResults] = useState([]); const [showProSuggestions, setShowProSuggestions] = useState(false); const [proFunctions, setProFunctions] = useState([]); // Functions of selected professional const [selectedProId, setSelectedProId] = useState(null); // Bulk Edit State const [selectedIds, setSelectedIds] = useState>(new Set()); const [showBulkEditModal, setShowBulkEditModal] = useState(false); const [bulkFormData, setBulkFormData] = useState({ valorExtra: 0, descricaoExtra: "", }); // Validations const validateCpf = (cpf: string) => { // Simple length check for now, can be enhanced return cpf.replace(/\D/g, "").length === 11; }; const loadTransactions = async () => { const token = localStorage.getItem("token"); if (!token) { setError("Usuário não autenticado"); return; } setLoading(true); try { const queryParams = new URLSearchParams({ page: page.toString(), limit: limit.toString(), fot: filters.fot || "", data: filters.data || "", evento: filters.evento || "", servico: filters.servico || "", nome: filters.nome || "", curso: filters.curso || "", instituicao: filters.instituicao || "", ano: filters.ano || "", empresa: filters.empresa || "", // Send Date Filters to Backend startDate: dateFilters.startDate || "", endDate: dateFilters.endDate || "", includeWeekends: String(dateFilters.includeWeekends), }); const res = await fetch(`${API_BASE_URL}/api/finance?${queryParams.toString()}`, { headers: { "Authorization": `Bearer ${token}`, "x-regiao": localStorage.getItem("photum_selected_region") || "SP" } }); if (res.status === 401) throw new Error("Não autorizado"); if (!res.ok) throw new Error("Falha ao carregar transações"); const result = await res.json(); const data = result.data || result; // Fallback if API returns array (e.g. filtered by FOT) const count = result.total || data.length; setTotal(count); // Map Backend DTO to Frontend Interface const mapped = (Array.isArray(data) ? data : []).map((item: any) => ({ id: item.id, fot: item.fot_numero || 0, fot_id: item.fot_id, // Ensure fot_id is mapped! data: item.data_cobranca ? new Date(item.data_cobranca).toLocaleDateString("pt-BR", {timeZone: "UTC"}) : "", dataRaw: item.data_cobranca ? item.data_cobranca.split("T")[0] : "", curso: item.curso_nome || "", instituicao: item.instituicao_nome || "", anoFormatura: item.ano_formatura || "", empresa: item.empresa_nome || "", tipoEvento: item.tipo_evento, tipoServico: item.tipo_servico, nome: item.professional_name, whatsapp: item.whatsapp, cpf: item.cpf, tabelaFree: item.tabela_free, valorFree: parseFloat(item.valor_free), valorExtra: parseFloat(item.valor_extra), descricaoExtra: item.descricao_extra, totalPagar: parseFloat(item.total_pagar), dataPgto: item.data_pagamento ? item.data_pagamento.split("T")[0] : "", pgtoOk: item.pgto_ok, })); setTransactions(mapped); } catch (err) { console.error(err); setError("Erro ao carregar dados."); } finally { setLoading(false); } }; const loadAuxiliaryData = async () => { const token = localStorage.getItem("token"); if (!token) return; try { const headers = { "Authorization": `Bearer ${token}`, "x-regiao": localStorage.getItem("photum_selected_region") || "SP" }; const [evRes, servRes] = await Promise.all([ fetch(`${API_BASE_URL}/api/tipos-eventos`, { headers }), fetch(`${API_BASE_URL}/api/tipos-servicos`, { headers }), ]); if (evRes.ok) setTiposEventos(await evRes.json()); if (servRes.ok) setTiposServicos(await servRes.json()); } catch (e) { console.error(e); } }; useEffect(() => { loadTransactions(); loadAuxiliaryData(); }, [page, limit]); // Refresh on page/limit change // Advanced date filters const [dateFilters, setDateFilters] = useState({ startDate: "", endDate: "", includeWeekends: true, }); const [showDateFilters, setShowDateFilters] = useState(false); // Debounce filter changes useEffect(() => { const timer = setTimeout(() => { setPage(1); // Reset to page 1 on filter change loadTransactions(); }, 500); return () => clearTimeout(timer); }, [filters, dateFilters]); // Calculate filtered and sorted transactions const sortedTransactions = React.useMemo(() => { let result = [...transactions]; // 1. Filter // Filters are handled by Backend // if (filters.fot) result = result.filter(t => String(t.fot).includes(filters.fot)); // if (filters.data) result = result.filter(t => t.data.includes(filters.data)); // if (filters.evento) result = result.filter(t => t.tipoEvento.toLowerCase().includes(filters.evento.toLowerCase())); // if (filters.servico) result = result.filter(t => t.tipoServico.toLowerCase().includes(filters.servico.toLowerCase())); // if (filters.nome) result = result.filter(t => t.nome.toLowerCase().includes(filters.nome.toLowerCase())); // if (filters.curso) result = result.filter(t => t.curso.toLowerCase().includes(filters.curso.toLowerCase())); // if (filters.instituicao) result = result.filter(t => t.instituicao.toLowerCase().includes(filters.instituicao.toLowerCase())); // if (filters.ano) result = result.filter(t => String(t.anoFormatura).includes(filters.ano)); // if (filters.empresa) result = result.filter(t => t.empresa.toLowerCase().includes(filters.empresa.toLowerCase())); // if (filters.status) { // const s = filters.status.toLowerCase(); // if (s === 'ok' || s === 'sim') result = result.filter(t => t.pgtoOk); // if (s === 'no' || s === 'nao' || s === 'não') result = result.filter(t => !t.pgtoOk); // } // Advanced date filters // Advanced date filters - Handled by Backend now // if (dateFilters.startDate || dateFilters.endDate || !dateFilters.includeWeekends) { // // ... filtering logic removed ... // } // Advanced date filters - Custom Logic // Advanced date filters - Custom Logic (Removed) // if (dateFilters.startDate || dateFilters.endDate || !dateFilters.includeWeekends) { ... } // Applying filtered logic reuse // ... code kept essentially same ... // 2. Sort by FOT (desc) then Date (desc) to group FOTs // Default sort is grouped by FOT if (!sortConfig) { return result.sort((a, b) => { // Group by Professional Name if Date Filters are active if (dateFilters.startDate || dateFilters.endDate) { const nameA = String(a.nome || "").toLowerCase(); const nameB = String(b.nome || "").toLowerCase(); if (nameA !== nameB) return nameA.localeCompare(nameB); // Secondary sort by date within the same professional return new Date(a.dataRaw || a.data).getTime() - new Date(b.dataRaw || b.data).getTime(); } // Default Group by FOT (String comparison to handle "20000MG") const fotA = String(a.fot || ""); const fotB = String(b.fot || ""); if (fotA !== fotB) return fotB.localeCompare(fotA, undefined, { numeric: true }); // Secondary: Date return new Date(b.dataRaw || b.data).getTime() - new Date(a.dataRaw || a.data).getTime(); }); } // Custom sort if implemented return result.sort((a, b) => { // @ts-ignore const aValue = a[sortConfig.key]; // @ts-ignore const bValue = b[sortConfig.key]; // String comparison for specific text fields if (['nome', 'instituicao', 'curso', 'empresa', 'tipoEvento', 'tipoServico'].includes(sortConfig.key)) { const strA = String(aValue || "").toLowerCase(); const strB = String(bValue || "").toLowerCase(); if (strA < strB) return sortConfig.direction === "asc" ? -1 : 1; if (strA > strB) return sortConfig.direction === "asc" ? 1 : -1; return 0; } // Default comparison (numbers, booleans) if (aValue < bValue) return sortConfig.direction === "asc" ? -1 : 1; if (aValue > bValue) return sortConfig.direction === "asc" ? 1 : -1; return 0; }); }, [transactions, filters, sortConfig, dateFilters]); const handleSort = (key: keyof FinancialTransaction) => { let direction: "asc" | "desc" = "asc"; if (sortConfig && sortConfig.key === key && sortConfig.direction === "asc") { direction = "desc"; } setSortConfig({ key, direction }); }; const handleFotSearch = async (query: string) => { setFotQuery(query); // If user types numbers, list options if (query.length < 2) { setFotResults([]); setShowFotSuggestions(false); return; } const token = localStorage.getItem("token"); try { const res = await fetch(`${API_BASE_URL}/api/finance/fot-search?q=${query}`, { headers: { "Authorization": `Bearer ${token}`, "x-regiao": localStorage.getItem("photum_selected_region") || "SP" } }); if(res.ok) { const data = await res.json(); setFotResults(data); setShowFotSuggestions(true); } } catch (e) { console.error(e); } }; const selectFot = (fot: any) => { setFotQuery(String(fot.fot)); setShowFotSuggestions(false); handleAutoFill(fot.fot); }; const handleAutoFill = async (fotNum: number) => { if (!fotNum) return; const token = localStorage.getItem("token"); if (!token) return; setFotLoading(true); setFotEvents([]); setShowEventSelector(false); try { const res = await fetch(`${API_BASE_URL}/api/finance/autofill?fot=${fotNum}`, { headers: { "Authorization": `Bearer ${token}`, "x-regiao": localStorage.getItem("photum_selected_region") || "SP" } }); if (res.ok) { const data = await res.json(); setFormData(prev => ({ ...prev, curso: data.curso_nome, instituicao: data.empresa_nome, empresa: data.empresa_nome, anoFormatura: data.ano_formatura_label, fot: fotNum, })); setFotFound(true); // @ts-ignore const fotId = data.id; setFormData(prev => ({ ...prev, fot_id: fotId })); setFotQuery(String(fotNum)); // Ensure query matches found fot // Now fetch events const evRes = await fetch(`${API_BASE_URL}/api/finance/fot-events?fot_id=${fotId}`, { headers: { "Authorization": `Bearer ${token}`, "x-regiao": localStorage.getItem("photum_selected_region") || "SP" } }); if (evRes.ok) { const events = await evRes.json(); if (events && events.length > 0) { setFotEvents(events); setShowEventSelector(true); } } } else { setFotFound(false); } } catch (error) { console.error(error); setFotFound(false); } finally { setFotLoading(false); } }; const handleProSearch = async (query: string) => { setProQuery(query); setFormData(prev => ({ ...prev, nome: query })); // Update name as typed // Allow empty query to list filtered professionals if Function is selected if (query.length < 3 && !formData.tipoServico) { setProResults([]); setShowProSuggestions(false); return; } const token = localStorage.getItem("token"); try { const fnParam = formData.tipoServico ? `&function=${encodeURIComponent(formData.tipoServico)}` : ""; const res = await fetch(`${API_BASE_URL}/api/finance/professionals?q=${query}${fnParam}`, { headers: { "Authorization": `Bearer ${token}`, "x-regiao": localStorage.getItem("photum_selected_region") || "SP" } }); if(res.ok) { const data = await res.json(); setProResults(data); setShowProSuggestions(true); } } catch (e) { console.error(e); } }; // Bulk Selection Logic const toggleSelection = (id: string) => { const newSet = new Set(selectedIds); if (newSet.has(id)) { newSet.delete(id); } else { newSet.add(id); } setSelectedIds(newSet); }; const toggleSelectAll = () => { if (selectedIds.size === sortedTransactions.length && sortedTransactions.length > 0) { setSelectedIds(new Set()); } else { const newSet = new Set(sortedTransactions.map(t => t.id).filter(id => id)); // ensure valid IDs setSelectedIds(newSet); } }; const handleBulkUpdate = async () => { if (selectedIds.size === 0) return; if (!confirm(`Confirma atualização de ${selectedIds.size} itens?`)) return; const token = localStorage.getItem("token"); if (!token) return; try { const res = await fetch(`${API_BASE_URL}/api/finance/bulk/extras`, { method: "POST", headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}`, "x-regiao": localStorage.getItem("photum_selected_region") || "SP" }, body: JSON.stringify({ ids: Array.from(selectedIds), valor_extra: bulkFormData.valorExtra, descricao_extra: bulkFormData.descricaoExtra }) }); if (res.ok) { alert("Itens atualizados com sucesso!"); setShowBulkEditModal(false); setSelectedIds(new Set()); setBulkFormData({ valorExtra: 0, descricaoExtra: "" }); loadTransactions(); // Refresh list } else { const err = await res.json(); alert(`Erro ao atualizar: ${err.error || "Erro desconhecido"}`); } } catch (e) { console.error(e); alert("Erro de conexão ao atualizar."); } }; const selectProfessional = (pro: any) => { // Parse functions let funcs = []; try { funcs = pro.functions ? (typeof pro.functions === 'string' ? JSON.parse(pro.functions) : pro.functions) : []; } catch(e) { funcs = []; } setProFunctions(funcs); setSelectedProId(pro.id); setFormData(prev => ({ ...prev, nome: pro.nome, whatsapp: pro.whatsapp, cpf: pro.cpf_cnpj_titular, // Default to first function if available, else empty tabelaFree: funcs.length > 0 ? funcs[0].nome : "", })); setProQuery(pro.nome); setShowProSuggestions(false); }; const selectEvent = (ev: any) => { setFormData(prev => ({ ...prev, tipoEvento: ev.tipo_evento_nome, data: ev.data_evento ? ev.data_evento.split("T")[0] : prev.data, })); setShowEventSelector(false); }; const handleEdit = async (t: FinancialTransaction) => { isEditInitializing.current = true; setSelectedTransaction(t); setFormData({ id: t.id, fot_id: t.fot_id, data: t.dataRaw || t.data, // Use raw YYYY-MM-DD for input tipoEvento: t.tipoEvento, tipoServico: t.tipoServico, nome: t.nome, whatsapp: t.whatsapp, cpf: t.cpf, tabelaFree: t.tabelaFree, valorFree: t.valorFree ?? t.totalPagar, // Use Total as fallback if Free is null (legacy/auto records) valorExtra: t.valorExtra, descricaoExtra: t.descricaoExtra, dataPgto: t.dataPgto, pgtoOk: t.pgtoOk, totalPagar: t.totalPagar, }); setFotFound(false); // Reset fotFound state for edit modal // Fetch FOT details if ID exists if (t.fot_id) { const token = localStorage.getItem("token"); if (!token) return; try { const res = await fetch(`${API_BASE_URL}/api/cadastro-fot/${t.fot_id}`, { headers: { "Authorization": `Bearer ${token}`, "x-regiao": localStorage.getItem("photum_selected_region") || "SP" } }); if (res.ok) { const data = await res.json(); setFormData(prev => ({ ...prev, curso: data.curso_nome || "", instituicao: data.empresa_nome || data.instituicao || "", empresa: data.empresa_nome || "", anoFormatura: data.ano_formatura_label || "", fot: data.fot, })); // Update the search query state too setFotQuery(String(data.fot)); setFotFound(true); } } catch (err) { console.error("Error fetching FOT details for edit:", err); } } else if (t.fot) { // Fallback if no ID but we have the number (Legacy records) setFotQuery(String(t.fot)); handleAutoFill(t.fot); // Fetch details using FOT number } // Fetch professional functions if professional name is present if (t.nome) { const token = localStorage.getItem("token"); if (!token) return; try { const res = await fetch(`${API_BASE_URL}/api/finance/professionals?q=${encodeURIComponent(t.nome)}`, { headers: { "Authorization": `Bearer ${token}`, "x-regiao": localStorage.getItem("photum_selected_region") || "SP" } }); if (res.ok) { const data = await res.json(); const professional = data.find((p: any) => p.nome === t.nome); if (professional) { let funcs = []; try { funcs = professional.functions ? (typeof professional.functions === 'string' ? JSON.parse(professional.functions) : professional.functions) : []; } catch(e) { funcs = []; } setProFunctions(funcs); } } } catch (err) { console.error("Error fetching professional functions for edit:", err); } } setShowEditModal(true); }; const handleSmartSave = async () => { const token = localStorage.getItem("token"); if (!token) { alert("Login expirado"); return; } // 1. Duplicate Check // Duplicate if: Same FOT + Same Name + Same Date + Same Event Type (optional) // Let's use FOT + Name + Date as primary key const isDuplicate = transactions.some(t => Number(t.fot) === Number(formData.fot) && t.nome.trim().toLowerCase() === (formData.nome || "").trim().toLowerCase() && t.tipoEvento === formData.tipoEvento && // Event needed to distinguish pre-event vs formatura? (t.dataRaw === formData.data || t.data === new Date(formData.data || "").toLocaleDateString("pt-BR", {timeZone: "UTC"})) ); if (isDuplicate && !selectedTransaction) { // Only check on Create alert("Já existe um lançamento para este Profissional neste Evento e Data."); return; } // 2. Prepare Payload const payload = { fot_id: formData.fot_id, data_cobranca: formData.data, tipo_evento: formData.tipoEvento, tipo_servico: formData.tipoServico, professional_name: formData.nome, whatsapp: formData.whatsapp, cpf: formData.cpf, tabela_free: formData.tabelaFree, valor_free: formData.valorFree ? Number(formData.valorFree) : 0, valor_extra: formData.valorExtra ? Number(formData.valorExtra) : 0, descricao_extra: formData.descricaoExtra, total_pagar: (Number(formData.valorFree) || 0) + (Number(formData.valorExtra) || 0), data_pagamento: formData.dataPgto || null, pgto_ok: formData.pgtoOk }; try { let res; if (formData.id) { // Edit Mode res = await fetch(`${API_BASE_URL}/api/finance/${formData.id}`, { method: "PUT", headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}`, "x-regiao": localStorage.getItem("photum_selected_region") || "SP" }, body: JSON.stringify(payload) }); } else { // Create Mode res = await fetch(`${API_BASE_URL}/api/finance`, { method: "POST", headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}`, "x-regiao": localStorage.getItem("photum_selected_region") || "SP" }, body: JSON.stringify(payload) }); } if (!res.ok) throw new Error("Erro ao salvar"); // 3. Post-Save Logic if (selectedTransaction) { // If editing, close modal setShowEditModal(false); setSelectedTransaction(null); setSuccessMessage("Transação atualizada com sucesso!"); } else { // If creating, KEEP OPEN and CLEAR professional data setSuccessMessage("Lançamento salvo! Pronto para o próximo."); setFormData(prev => ({ ...prev, // Keep Event Data fot: prev.fot, fot_id: prev.fot_id, curso: prev.curso, instituicao: prev.instituicao, empresa: prev.empresa, anoFormatura: prev.anoFormatura, data: prev.data, tipoEvento: prev.tipoEvento, // Clear Professional Data nome: "", whatsapp: "", cpf: "", tipoServico: "", tabelaFree: "", valorFree: 0, valorExtra: 0, descricaoExtra: "", totalPagar: 0, pgtoOk: false })); setProQuery(""); setSelectedProId(null); } // Clear success message after 3s setTimeout(() => setSuccessMessage(""), 3000); // Reload list background await loadTransactions(); } catch (err) { alert("Erro ao salvar: " + err); } }; // Calculations // Calculations useEffect(() => { const vFree = Number(formData.valorFree) || 0; const vExtra = Number(formData.valorExtra) || 0; setFormData((prev) => ({ ...prev, totalPagar: vFree + vExtra })); }, [formData.valorFree, formData.valorExtra]); // Fetch Price on Event/Service/Function Change useEffect(() => { if (isEditInitializing.current) { isEditInitializing.current = false; return; } // Use Tabela Free (Function) if available, otherwise Tipo Servico const serviceParam = formData.tabelaFree || formData.tipoServico; if (!formData.tipoEvento || !serviceParam) return; const fetchPrice = async () => { const token = localStorage.getItem("token"); if (!token) return; try { // Use the new endpoint const res = await fetch(`${API_BASE_URL}/api/finance/price?event=${encodeURIComponent(formData.tipoEvento!)}&service=${encodeURIComponent(serviceParam)}`, { headers: { "Authorization": `Bearer ${token}`, "x-regiao": localStorage.getItem("photum_selected_region") || "SP" } }); if (res.ok) { const data = await res.json(); // Update Valor Free if different // Check if data.valor is a number const price = Number(data.valor); if (!isNaN(price) && price !== formData.valorFree) { setFormData(prev => ({ ...prev, valorFree: price })); } } } catch (err) { console.error("Error fetching price:", err); } }; // Debounce slightly or just call const timeoutId = setTimeout(() => { fetchPrice(); }, 300); return () => clearTimeout(timeoutId); }, [formData.tipoEvento, formData.tipoServico, formData.tabelaFree]); const handleExportExcel = async () => { if (sortedTransactions.length === 0) { alert("Nenhum dado para exportar."); return; } const workbook = new ExcelJS.Workbook(); const worksheet = workbook.addWorksheet("Extrato Financeiro"); // Columns matching the legacy Excel sheet worksheet.columns = [ { header: "", key: "fot", width: 10 }, { header: "", key: "data", width: 12 }, { header: "", key: "curso", width: 20 }, { header: "", key: "instituicao", width: 20 }, { header: "", key: "ano", width: 10 }, { header: "", key: "empresa", width: 15 }, { header: "", key: "evento", width: 15 }, { header: "", key: "servico", width: 15 }, { header: "", key: "nome", width: 25 }, { header: "", key: "whatsapp", width: 15 }, { header: "", key: "cpf", width: 16 }, { header: "", key: "tabelaFree", width: 15 }, { header: "", key: "valorFree", width: 15 }, { header: "", key: "valorExtra", width: 15 }, { header: "", key: "descricaoExtra", width: 40 }, { header: "", key: "totalPagar", width: 15 }, { header: "", key: "dataPgto", width: 12 }, { header: "", key: "pgtoOk", width: 10 }, ]; // Standard Headers (Row 1 now) const headerRow = worksheet.addRow([ "FOT", "Data", "Curso", "Instituição", "Ano Format.", "Empresa", "Tipo Evento", "Tipo de Serviço", "Nome", "WhatsApp", "CPF", "Tabela Free", "Valor Free", "Valor Extra", "Descrição do Extra", "Total a Pagar", "Data Pgto", "Pgto OK" ]); // Header Styling (Red #FF0000 based on screenshot 3) headerRow.eachCell((cell, colNumber) => { cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFFF0000' } // Red Header }; cell.alignment = { vertical: 'middle', horizontal: 'center', wrapText: true }; // Even the header in the screenshot has Yellow for Total? The screenshot 3 shows pure blue/grey for table headers, let's use the standard blue that matches their columns // Looking at screenshot 3, the columns header is actually a light blue! "A4:R4" cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF9BC2E6' } // Light Blue from standard excel }; cell.font = { bold: true, color: { argb: 'FF000000' }, size: 10 }; // Black text on blue if (worksheet.getColumn(colNumber).key === 'totalPagar') { cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFFFFF00' } }; } }); let currentName = ""; let groupSum = 0; const applyRowColor = (row: ExcelJS.Row) => { for (let colNumber = 1; colNumber <= 18; colNumber++) { const cell = row.getCell(colNumber); const colKey = worksheet.getColumn(colNumber).key as string; cell.border = { top: {style:'thin'}, left: {style:'thin'}, bottom: {style:'thin'}, right: {style:'thin'} }; cell.alignment = { vertical: 'middle', horizontal: 'center', wrapText: true }; if (colKey === 'descricaoExtra' || colKey === 'nome') { cell.alignment = { vertical: 'middle', horizontal: 'left', wrapText: true }; } if (colKey === 'totalPagar') { cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFFFFF00' } // Yellow column }; } if (["valorFree", "valorExtra", "totalPagar"].includes(colKey)) { cell.alignment = { vertical: 'middle', horizontal: 'right', wrapText: true }; cell.numFmt = '"R$" #,##0.00'; } } }; if (dateFilters.startDate || dateFilters.endDate) { sortedTransactions.forEach((t, i) => { const tName = t.nome || "Sem Nome"; if (currentName !== "" && tName !== currentName) { // Subtotal Row const subRow = worksheet.addRow({ descricaoExtra: `SUBTOTAL ${currentName}`, totalPagar: groupSum }); subRow.font = { bold: true }; for (let colNumber = 1; colNumber <= 18; colNumber++) { const cell = subRow.getCell(colNumber); const colKey = worksheet.getColumn(colNumber).key as string; cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFFFFF00' } }; // Entire row yellow! cell.border = { top: {style:'thin'}, left: {style:'thin'}, bottom: {style:'thin'}, right: {style:'thin'} }; cell.alignment = { vertical: 'middle', horizontal: 'right' }; if (["valorFree", "valorExtra", "totalPagar"].includes(colKey)) { cell.numFmt = '"R$" #,##0.00'; } } groupSum = 0; } if (currentName === "") { currentName = tName; } currentName = tName; groupSum += t.totalPagar; const row = worksheet.addRow({ fot: t.fot, data: t.data, curso: t.curso, instituicao: t.instituicao, ano: t.anoFormatura, empresa: t.empresa, evento: t.tipoEvento, servico: t.tipoServico, nome: t.nome, whatsapp: t.whatsapp, cpf: t.cpf, tabelaFree: t.tabelaFree, valorFree: t.valorFree, valorExtra: t.valorExtra, descricaoExtra: t.descricaoExtra, totalPagar: t.totalPagar, dataPgto: t.dataPgto, pgtoOk: t.pgtoOk ? "Sim" : "Não" }); applyRowColor(row); // Final subtotal if (i === sortedTransactions.length - 1) { const subRow = worksheet.addRow({ descricaoExtra: `SUBTOTAL ${currentName}`, totalPagar: groupSum }); subRow.font = { bold: true }; for (let colNumber = 1; colNumber <= 18; colNumber++) { const cell = subRow.getCell(colNumber); const colKey = worksheet.getColumn(colNumber).key as string; cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFFFFF00' } }; cell.border = { top: {style:'thin'}, left: {style:'thin'}, bottom: {style:'thin'}, right: {style:'thin'} }; cell.alignment = { vertical: 'middle', horizontal: 'right' }; if (["valorFree", "valorExtra", "totalPagar"].includes(colKey)) { cell.numFmt = '"R$" #,##0.00'; } } } }); } else { // Flat standard export without grouped subtotals if not filtering dates sortedTransactions.forEach(t => { const row = worksheet.addRow({ fot: t.fot, data: t.data, evento: t.tipoEvento, servico: t.tipoServico, nome: t.nome, whatsapp: t.whatsapp, cpf: t.cpf, curso: t.curso, instituicao: t.instituicao, ano: t.anoFormatura, empresa: t.empresa, tabelaFree: t.tabelaFree, valorFree: t.valorFree, valorExtra: t.valorExtra, descricaoExtra: t.descricaoExtra, totalPagar: t.totalPagar, dataPgto: t.dataPgto, pgtoOk: t.pgtoOk ? "Sim" : "Não" }); applyRowColor(row); }); } // Global Total Row const totalValue = sortedTransactions.reduce((sum, t) => sum + t.totalPagar, 0); const sumRow = worksheet.addRow({ descricaoExtra: "TOTAL GERAL", totalPagar: totalValue }); sumRow.font = { bold: true, size: 12 }; for (let colNumber = 1; colNumber <= 18; colNumber++) { const cell = sumRow.getCell(colNumber); const colKey = worksheet.getColumn(colNumber).key as string; if (colKey === 'totalPagar' || colKey === 'descricaoExtra') { cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFFFFF00' } // Yellow }; if (colKey === 'totalPagar') { cell.alignment = { vertical: 'middle', horizontal: 'right' }; cell.numFmt = '"R$" #,##0.00'; } else { cell.alignment = { vertical: 'middle', horizontal: 'right' }; } } } // Save let filename = "extrato_financeiro"; if (filters.nome) { filename += `_${filters.nome.trim().replace(/\s+/g, '_').toLowerCase()}`; } const formatDateFilename = (isoDate: string) => { if (!isoDate) return ""; const [y, m, d] = isoDate.split("-"); return `${d}-${m}-${y}`; }; if (dateFilters.startDate) { filename += `_de_${formatDateFilename(dateFilters.startDate)}`; } if (dateFilters.endDate) { filename += `_ate_${formatDateFilename(dateFilters.endDate)}`; } if (!filters.nome && !dateFilters.startDate && !dateFilters.endDate) { const today = new Date().toLocaleDateString("pt-BR").split("/").join("-"); filename += `_${today}`; } filename += ".xlsx"; const buffer = await workbook.xlsx.writeBuffer(); const blob = new Blob([buffer], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" }); saveAs(blob, filename); }; // Calculate Total for Display const filteredTotal = sortedTransactions.reduce((acc, curr) => acc + curr.totalPagar, 0); return (

Extrato

Controle financeiro e transações

{/* Advanced Date Filters */}
{showDateFilters && (
setDateFilters({...dateFilters, startDate: e.target.value})} />
setDateFilters({...dateFilters, endDate: e.target.value})} />
{(dateFilters.startDate || dateFilters.endDate || !dateFilters.includeWeekends) && (
)}
)}
{/* Pagination Controls (Top) */}
Mostrando {transactions.length} de {total} registros | Total Filtrado: R$ {filteredTotal.toFixed(2).replace('.', ',')}
Página {page}
{/* List */}
{/* Top Scrollbar Sync */}
handleScroll('top')} className="overflow-x-scroll w-full border-b bg-gray-50 mb-1" >
{/* Bulk Actions Bar */} {selectedIds.size > 0 && (
{selectedIds.size} itens selecionados
)}
handleScroll('table')} className="overflow-x-scroll pb-2" > {[ { label: "FOT", key: "fot" }, { label: "Data Evento", key: "data" }, { label: "Curso", key: "curso" }, { label: "Instituição", key: "instituicao" }, { label: "Ano", key: "anoFormatura" }, { label: "Empresa", key: "empresa" }, { label: "Evento", key: "tipoEvento" }, { label: "Serviço", key: "tipoServico" }, { label: "Nome", key: "nome" }, { label: "WhatsApp", key: "whatsapp" }, { label: "CPF", key: "cpf" }, { label: "Tab. Free", key: "tabelaFree" }, { label: "V. Free", key: "valorFree" }, { label: "V. Extra", key: "valorExtra" }, { label: "Desc. Extra", key: "descricaoExtra" }, { label: "Total", key: "totalPagar" }, { label: "Dt. Pgto", key: "dataPgto" }, { label: "OK", key: "pgtoOk" } ].map((h) => ( ))} {loading && ( )} {loading && ( )} {!loading && sortedTransactions.map((t, index) => { const isDateFilterActive = !!(dateFilters.startDate || dateFilters.endDate); const isNewGroup = isDateFilterActive ? index > 0 && t.nome !== sortedTransactions[index - 1].nome : index > 0 && t.fot !== sortedTransactions[index - 1].fot; // Check if this is the last item of the group (or list) to show summary const isLastOfGroup = isDateFilterActive ? index === sortedTransactions.length - 1 || t.nome !== sortedTransactions[index + 1].nome : index === sortedTransactions.length - 1 || t.fot !== sortedTransactions[index + 1].fot; // Only show summary if sorted by default (which groups by FOT) or explicitly sorted by FOT, // OR if Date Filters are active (which groups by Professional Name) const showSummary = isLastOfGroup && ( (!sortConfig || sortConfig.key === 'fot') || isDateFilterActive ); return ( handleEdit(t)} > {showSummary && ( )} ); })}
0 && selectedIds.size === sortedTransactions.length} onChange={toggleSelectAll} className="rounded text-blue-600 focus:ring-blue-500" /> handleSort(h.key as keyof FinancialTransaction)} >
{h.label} {sortConfig?.key === h.key && ( {sortConfig.direction === "asc" ? : } )}
{/* Filters - Stop Propagation to prevent sort when clicking input */}
e.stopPropagation()}> {h.label === "FOT" && setFilters({...filters, fot: e.target.value})} />} {h.label === "Data Evento" && setFilters({...filters, data: e.target.value})} />} {h.label === "Curso" && setFilters({...filters, curso: e.target.value})} />} {h.label === "Instituição" && setFilters({...filters, instituicao: e.target.value})} />} {h.label === "Ano" && setFilters({...filters, ano: e.target.value})} />} {h.label === "Empresa" && setFilters({...filters, empresa: e.target.value})} />} {h.label === "Evento" && setFilters({...filters, evento: e.target.value})} />} {h.label === "Serviço" && setFilters({...filters, servico: e.target.value})} />} {h.label === "Nome" && setFilters({...filters, nome: e.target.value})} />} {h.label === "OK" && setFilters({...filters, status: e.target.value})} />}
Carregando...
Carregando...
e.stopPropagation()}> { e.stopPropagation(); toggleSelection(t.id!); }} className="rounded text-blue-600 focus:ring-blue-500" /> {t.fot || "?"} {t.data} {t.curso} {t.instituicao} {t.anoFormatura} {t.empresa} {t.tipoEvento} {t.tipoServico} {t.nome} {t.whatsapp} {t.cpf} {t.tabelaFree} {t.valorFree != null ? t.valorFree.toFixed(2) : "-"} {t.valorExtra != null ? t.valorExtra.toFixed(2) : "-"} {t.descricaoExtra} {t.totalPagar?.toFixed(2)} {(() => { try { if (!t.dataPgto) return "-"; const d = new Date(t.dataPgto); if (isNaN(d.getTime())) return "-"; return d.toLocaleDateString("pt-BR", {timeZone: "UTC"}); } catch (e) { return "-"; } })()} {t.pgtoOk ? Sim : Não}
{isDateFilterActive ? `Subtotal ${t.nome}:` : `Total FOT ${t.fot}:`} {/* Calculate sum for this group */} {sortedTransactions .filter(tr => isDateFilterActive ? tr.nome === t.nome : tr.fot === t.fot) .reduce((sum, curr) => sum + (curr.totalPagar || 0), 0) .toFixed(2)}
{sortedTransactions.length === 0 && !loading && (
Nenhuma transação encontrada.
)}
{/* Pagination Controls */}
Mostrando {transactions.length} de {total} registros
Página {page}
{/* Modal */} {(showAddModal || showEditModal) && (

{showAddModal ? "Nova Transação" : "Editar Transação"}

{/* Auto-fill Section */}
handleFotSearch(e.target.value)} onBlur={() => setTimeout(() => setShowFotSuggestions(false), 200)} /> {showFotSuggestions && fotResults && fotResults.length > 0 && (
{fotResults.map(f => (
selectFot(f)} >
FOT: {f.fot}
{f.curso_nome} | {f.empresa_nome}
{f.ano_formatura_label}
))}
)}
{fotLoading && Buscando...}
{fotFound && (
Curso: {formData.curso} Inst: {formData.instituicao} Ano: {formData.anoFormatura}
{fotEvents.length > 0 && (

Eventos encontrados:

{fotEvents.map(ev => ( ))}
)}
)}
{/* Data */}
setFormData({...formData, data: e.target.value})} />
{/* Tipo Evento */}
{/* Tipo Serviço */}
{/* Professional Info */}
handleProSearch(e.target.value)} onBlur={() => setTimeout(() => setShowProSuggestions(false), 200)} // Delay to allow click placeholder="Digite para buscar..." /> {showProSuggestions && proResults && proResults.length > 0 && (
{proResults.map(p => (
selectProfessional(p)} > {p.nome} ({p.funcao_nome})
))}
)}
setFormData({...formData, whatsapp: e.target.value})} />
setFormData({...formData, cpf: e.target.value})} />
{/* Values */}
{proFunctions.length > 0 ? ( ) : ( setFormData({...formData, tabelaFree: e.target.value})} /> )}
{ const val = e.target.value; setFormData({...formData, valorFree: val === "" ? null : parseFloat(val)}); }} />
{ const val = e.target.value; setFormData({...formData, valorExtra: val === "" ? null : parseFloat(val)}); }} />
setFormData({...formData, descricaoExtra: e.target.value})} />
{/* Payment */}
setFormData({...formData, dataPgto: e.target.value})} />
Total a Pagar R$ {formData.totalPagar?.toFixed(2)}
{successMessage && ( {successMessage} )}
)} {/* Bulk Edit Modal */} {showBulkEditModal && (

Editar {selectedIds.size} Itens

Atenção: Os valores inseridos serão adicionados aos valores existentes. A descrição será concatenada.
setBulkFormData({...bulkFormData, valorExtra: parseFloat(e.target.value) || 0})} placeholder="Ex: 50.00" />
setBulkFormData({...bulkFormData, descricaoExtra: e.target.value})} placeholder="Ex: + Ajuda de Custo" />
)}
); }; export default Finance;