photum/frontend/pages/Finance.tsx

1733 lines
78 KiB
TypeScript

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<FinancialTransaction[]>([]);
const [showAddModal, setShowAddModal] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
const [successMessage, setSuccessMessage] = useState(""); // Success message state
const [selectedTransaction, setSelectedTransaction] =
useState<FinancialTransaction | null>(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<any[]>([]);
const [tiposServicos, setTiposServicos] = useState<any[]>([]);
// 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<HTMLDivElement>(null);
const tableScrollRef = useRef<HTMLDivElement>(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<Partial<FinancialTransaction>>({
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<any[]>([]); // New state
const [showEventSelector, setShowEventSelector] = useState(false);
// FOT Search State
const [fotQuery, setFotQuery] = useState("");
const [fotResults, setFotResults] = useState<any[]>([]);
const [showFotSuggestions, setShowFotSuggestions] = useState(false);
// Professional Search State
const [proQuery, setProQuery] = useState("");
const [proResults, setProResults] = useState<any[]>([]);
const [showProSuggestions, setShowProSuggestions] = useState(false);
const [proFunctions, setProFunctions] = useState<any[]>([]); // Functions of selected professional
const [selectedProId, setSelectedProId] = useState<string | null>(null);
// Bulk Edit State
const [selectedIds, setSelectedIds] = useState<Set<string>>(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 (
<div className="min-h-screen bg-gray-50 pt-20 sm:pt-24 md:pt-28 lg:pt-32 pb-8 sm:pb-12">
<div className="max-w-[98%] mx-auto px-2">
<div className="mb-6 flex justify-between items-center">
<div>
<h1 className="text-3xl font-serif font-bold text-gray-900">Extrato</h1>
<p className="text-gray-500 text-sm">Controle financeiro e transações</p>
</div>
<div className="flex gap-2">
<button
onClick={handleExportExcel}
className="bg-green-600 text-white px-4 py-2 rounded shadow hover:bg-green-700 transition flex items-center gap-2"
>
<Download size={18} /> Exportar Dados
</button>
<button
onClick={() => {
setFormData({ // Clear form to initial state
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,
});
setFotFound(false);
setFotQuery(""); // Clear FOT search query
setFotEvents([]); // Clear found events
setProFunctions([]); // Clear professional functions
setSelectedTransaction(null); // Ensure no transaction is selected for add
setShowAddModal(true);
}}
className="bg-brand-gold text-white px-4 py-2 rounded shadow hover:bg-yellow-600 transition flex items-center gap-2">
<Plus size={18}/> Nova Transação
</button>
</div>
</div>
{/* Advanced Date Filters */}
<div className="mb-4">
<button
onClick={() => setShowDateFilters(!showDateFilters)}
className="text-sm text-gray-600 hover:text-gray-900 flex items-center gap-2 mb-2"
>
{showDateFilters ? "▼" : "▶"} Filtros Avançados de Data
</button>
{showDateFilters && (
<div className="bg-white rounded shadow p-4 grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Data Início</label>
<input
type="date"
className="w-full border rounded px-3 py-2 text-sm"
value={dateFilters.startDate}
onChange={e => setDateFilters({...dateFilters, startDate: e.target.value})}
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Data Final</label>
<input
type="date"
className="w-full border rounded px-3 py-2 text-sm"
value={dateFilters.endDate}
onChange={e => setDateFilters({...dateFilters, endDate: e.target.value})}
/>
</div>
<div className="flex items-end">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
className="w-4 h-4 rounded text-brand-gold focus:ring-brand-gold"
checked={dateFilters.includeWeekends}
onChange={e => setDateFilters({...dateFilters, includeWeekends: e.target.checked})}
/>
<span className="text-sm text-gray-700">Incluir finais de semana</span>
</label>
</div>
{(dateFilters.startDate || dateFilters.endDate || !dateFilters.includeWeekends) && (
<div className="col-span-1 md:col-span-3 flex justify-end">
<button
onClick={() => setDateFilters({ startDate: "", endDate: "", includeWeekends: true })}
className="text-sm text-red-600 hover:text-red-800 flex items-center gap-1"
>
<X size={14} /> Limpar Filtros de Data
</button>
</div>
)}
</div>
)}
</div>
{/* Pagination Controls (Top) */}
<div className="flex justify-between items-center px-4 py-2 bg-gray-50 border-b rounded-t-lg">
<span className="text-xs text-gray-600">
Mostrando {transactions.length} de {total} registros |
<span className="ml-2 font-bold text-gray-900">Total Filtrado: R$ {filteredTotal.toFixed(2).replace('.', ',')}</span>
</span>
<div className="flex gap-2 items-center">
<button
disabled={page === 1 || loading}
onClick={() => setPage(p => Math.max(1, p - 1))}
className="px-3 py-1 border rounded bg-white hover:bg-gray-100 disabled:opacity-50 text-xs"
>
Anterior
</button>
<span className="text-xs font-medium">Página {page}</span>
<button
disabled={page * limit >= total || loading}
onClick={() => setPage(p => p + 1)}
className="px-3 py-1 border rounded bg-white hover:bg-gray-100 disabled:opacity-50 text-xs"
>
Próxima
</button>
</div>
</div>
{/* List */}
<div className="bg-white rounded shadow flex flex-col border-t-0 rounded-t-none">
{/* Top Scrollbar Sync */}
<div
ref={topScrollRef}
onScroll={() => handleScroll('top')}
className="overflow-x-scroll w-full border-b bg-gray-50 mb-1"
>
<div className="h-3 min-w-[1800px]"></div>
</div>
{/* Bulk Actions Bar */}
{selectedIds.size > 0 && (
<div className="bg-blue-50 border-b p-2 flex justify-between items-center px-4 sticky left-0 z-10">
<span className="text-sm font-bold text-blue-800">{selectedIds.size} itens selecionados</span>
<button
onClick={() => setShowBulkEditModal(true)}
className="bg-blue-600 text-white px-3 py-1 rounded text-xs hover:bg-blue-700 shadow"
>
Editar Selecionados
</button>
</div>
)}
<div
ref={tableScrollRef}
onScroll={() => handleScroll('table')}
className="overflow-x-scroll pb-2"
>
<table className="w-full text-xs text-left whitespace-nowrap min-w-[1800px]">
<thead className="bg-gray-100 border-b">
<tr>
<th className="px-3 py-2 w-10 text-center">
<input
type="checkbox"
checked={sortedTransactions.length > 0 && selectedIds.size === sortedTransactions.length}
onChange={toggleSelectAll}
className="rounded text-blue-600 focus:ring-blue-500"
/>
</th>
{[
{ 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) => (
<th
key={h.label}
className="px-3 py-2 font-semibold text-gray-700 align-top cursor-pointer hover:bg-gray-200"
onClick={() => handleSort(h.key as keyof FinancialTransaction)}
>
<div className="flex flex-col gap-1">
<div className="flex items-center gap-1">
<span>{h.label}</span>
{sortConfig?.key === h.key && (
<span>{sortConfig.direction === "asc" ? <ArrowUp size={12}/> : <ArrowDown size={12}/>}</span>
)}
</div>
{/* Filters - Stop Propagation to prevent sort when clicking input */}
<div onClick={(e) => e.stopPropagation()}>
{h.label === "FOT" && <input className="w-full text-[10px] border rounded px-1" placeholder="Filtrar" value={filters.fot} onChange={e => setFilters({...filters, fot: e.target.value})} />}
{h.label === "Data Evento" && <input className="w-full text-[10px] border rounded px-1" placeholder="Filtrar" value={filters.data} onChange={e => setFilters({...filters, data: e.target.value})} />}
{h.label === "Curso" && <input className="w-full text-[10px] border rounded px-1" placeholder="Filtrar" value={filters.curso} onChange={e => setFilters({...filters, curso: e.target.value})} />}
{h.label === "Instituição" && <input className="w-full text-[10px] border rounded px-1" placeholder="Filtrar" value={filters.instituicao} onChange={e => setFilters({...filters, instituicao: e.target.value})} />}
{h.label === "Ano" && <input className="w-full text-[10px] border rounded px-1" placeholder="Filtrar" value={filters.ano} onChange={e => setFilters({...filters, ano: e.target.value})} />}
{h.label === "Empresa" && <input className="w-full text-[10px] border rounded px-1" placeholder="Filtrar" value={filters.empresa} onChange={e => setFilters({...filters, empresa: e.target.value})} />}
{h.label === "Evento" && <input className="w-full text-[10px] border rounded px-1" placeholder="Filtrar" value={filters.evento} onChange={e => setFilters({...filters, evento: e.target.value})} />}
{h.label === "Serviço" && <input className="w-full text-[10px] border rounded px-1" placeholder="Filtrar" value={filters.servico} onChange={e => setFilters({...filters, servico: e.target.value})} />}
{h.label === "Nome" && <input className="w-full text-[10px] border rounded px-1" placeholder="Filtrar" value={filters.nome} onChange={e => setFilters({...filters, nome: e.target.value})} />}
{h.label === "OK" && <input className="w-full text-[10px] border rounded px-1" placeholder="Sim/Não" value={filters.status} onChange={e => setFilters({...filters, status: e.target.value})} />}
</div>
</div>
</th>
))}
</tr>
</thead>
<tbody className="divide-y relative">
{loading && (
<tr>
<td colSpan={14} className="p-8 text-center text-gray-500">
<div className="flex flex-col items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-brand-green mb-2"></div>
Carregando...
</div>
</td>
</tr>
)}
{loading && (
<tr>
<td colSpan={14} className="p-8 text-center text-gray-500">
<div className="flex flex-col items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-brand-green mb-2"></div>
Carregando...
</div>
</td>
</tr>
)}
{!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 (
<React.Fragment key={t.id}>
<tr
className={`hover:bg-gray-50 cursor-pointer ${isNewGroup ? "border-t-[3px] border-gray-400" : ""} ${selectedIds.has(t.id!) ? "bg-blue-50" : ""}`}
onClick={() => handleEdit(t)}
>
<td className="px-3 py-2 text-center" onClick={(e) => e.stopPropagation()}>
<input
type="checkbox"
checked={selectedIds.has(t.id!)}
onChange={(e) => { e.stopPropagation(); toggleSelection(t.id!); }}
className="rounded text-blue-600 focus:ring-blue-500"
/>
</td>
<td className="px-3 py-2 font-bold">{t.fot || "?"}</td>
<td className="px-3 py-2">{t.data}</td>
<td className="px-3 py-2">{t.curso}</td>
<td className="px-3 py-2">{t.instituicao}</td>
<td className="px-3 py-2">{t.anoFormatura}</td>
<td className="px-3 py-2">{t.empresa}</td>
<td className="px-3 py-2">{t.tipoEvento}</td>
<td className="px-3 py-2">{t.tipoServico}</td>
<td className="px-3 py-2">{t.nome}</td>
<td className="px-3 py-2">{t.whatsapp}</td>
<td className="px-3 py-2">{t.cpf}</td>
<td className="px-3 py-2">{t.tabelaFree}</td>
<td className="px-3 py-2 text-right">{t.valorFree != null ? t.valorFree.toFixed(2) : "-"}</td>
<td className="px-3 py-2 text-right">{t.valorExtra != null ? t.valorExtra.toFixed(2) : "-"}</td>
<td className="px-3 py-2 max-w-[150px] truncate" title={t.descricaoExtra}>{t.descricaoExtra}</td>
<td className="px-3 py-2 text-right font-bold text-green-700">{t.totalPagar?.toFixed(2)}</td>
<td className="px-3 py-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 "-";
}
})()}
</td>
<td className="px-3 py-2 text-center">
{t.pgtoOk
? <span className="bg-green-100 text-green-800 px-2 py-0.5 rounded-full text-[10px]">Sim</span>
: <span className="bg-red-100 text-red-800 px-2 py-0.5 rounded-full text-[10px]">Não</span>}
</td>
</tr>
{showSummary && (
<tr className="bg-gray-100 font-bold text-gray-800 border-b-2 border-gray-300">
<td colSpan={11} className="px-3 py-2 text-right uppercase text-[10px] tracking-wide text-gray-500">
{isDateFilterActive ? `Subtotal ${t.nome}:` : `Total FOT ${t.fot}:`}
</td>
<td className="px-3 py-2 text-right text-brand-gold">
{/* 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)}
</td>
<td colSpan={2}></td>
</tr>
)}
</React.Fragment>
);
})}
</tbody>
</table>
{sortedTransactions.length === 0 && !loading && (
<div className="p-8 text-center text-gray-500">Nenhuma transação encontrada.</div>
)}
</div>
{/* Pagination Controls */}
<div className="flex justify-between items-center px-4 py-2 bg-gray-50 border-t rounded-b-lg">
<span className="text-xs text-gray-600">
Mostrando {transactions.length} de {total} registros
</span>
<div className="flex gap-2 items-center">
<button
disabled={page === 1 || loading}
onClick={() => setPage(p => Math.max(1, p - 1))}
className="px-3 py-1 border rounded bg-white hover:bg-gray-100 disabled:opacity-50 text-xs"
>
Anterior
</button>
<span className="text-xs font-medium">Página {page}</span>
<button
disabled={page * limit >= total || loading}
onClick={() => setPage(p => p + 1)}
className="px-3 py-1 border rounded bg-white hover:bg-gray-100 disabled:opacity-50 text-xs"
>
Próxima
</button>
</div>
</div>
</div>
</div>
{/* Modal */}
{(showAddModal || showEditModal) && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg shadow-xl w-full max-w-4xl max-h-[90vh] overflow-y-auto">
<div className="p-4 border-b flex justify-between items-center bg-gray-50 sticky top-0">
<h2 className="text-xl font-bold text-gray-800">
{showAddModal ? "Nova Transação" : "Editar Transação"}
</h2>
<button onClick={() => { setShowAddModal(false); setShowEditModal(false); setSelectedTransaction(null); setProFunctions([]); }} className="p-1 hover:bg-gray-200 rounded">
<X size={20}/>
</button>
</div>
<div className="p-6 grid grid-cols-1 md:grid-cols-3 gap-4">
{/* Auto-fill Section */}
<div className="col-span-1 md:col-span-3 bg-blue-50 p-3 rounded border border-blue-100 mb-2">
<div className="flex gap-2 items-end">
<div className="w-1/3 relative">
<label className="block text-xs font-bold text-blue-700">Buscar por FOT</label>
<div className="flex relative">
<input
type="text"
className="border rounded-l w-full px-2 py-1"
placeholder="Nº FOT"
value={fotQuery}
onChange={e => handleFotSearch(e.target.value)}
onBlur={() => setTimeout(() => setShowFotSuggestions(false), 200)}
/>
<button disabled className="bg-blue-600 text-white px-2 rounded-r hover:bg-blue-700">
<Search size={16}/>
</button>
{showFotSuggestions && fotResults && fotResults.length > 0 && (
<div className="absolute top-10 z-10 w-full bg-white border rounded shadow-lg max-h-60 overflow-y-auto">
{fotResults.map(f => (
<div
key={f.id}
className="p-2 hover:bg-gray-100 cursor-pointer text-xs border-b last:border-0"
onClick={() => selectFot(f)}
>
<div className="font-bold">FOT: {f.fot}</div>
<div className="text-gray-500">{f.curso_nome} | {f.empresa_nome}</div>
<div className="text-gray-400 text-[10px]">{f.ano_formatura_label}</div>
</div>
))}
</div>
)}
</div>
</div>
{fotLoading && <span className="text-xs text-gray-500 mb-2">Buscando...</span>}
</div>
{fotFound && (
<div className="mt-2 text-xs text-green-700 flex flex-col gap-1">
<div className="flex gap-4">
<span><strong>Curso:</strong> {formData.curso}</span>
<span><strong>Inst:</strong> {formData.instituicao}</span>
<span><strong>Ano:</strong> {formData.anoFormatura}</span>
</div>
{fotEvents.length > 0 && (
<div className="bg-yellow-50 p-2 rounded border border-yellow-200 mt-1">
<p className="font-bold text-yellow-800 mb-1">Eventos encontrados:</p>
<div className="flex flex-wrap gap-2">
{fotEvents.map(ev => (
<button
key={ev.id}
onClick={() => selectEvent(ev)}
className="bg-white border px-2 py-1 rounded hover:bg-yellow-100 text-yellow-900 border-yellow-300"
>
{ev.tipo_evento_nome} ({new Date(ev.data_evento).toLocaleDateString()})
</button>
))}
</div>
</div>
)}
</div>
)}
</div>
{/* Data */}
<div>
<label className="block text-xs font-medium text-gray-700">Data Evento</label>
<input type="date" className="w-full border rounded px-2 py-1.5 mt-1"
value={formData.data} onChange={e => setFormData({...formData, data: e.target.value})}
/>
</div>
{/* Tipo Evento */}
<div>
<label className="block text-xs font-medium text-gray-700">Tipo Evento</label>
<select className="w-full border rounded px-2 py-1.5 mt-1"
value={formData.tipoEvento} onChange={e => setFormData({...formData, tipoEvento: e.target.value})}
>
<option value="">Selecione...</option>
{tiposEventos.map(t => <option key={t.id} value={t.nome}>{t.nome}</option>)}
</select>
</div>
{/* Tipo Serviço */}
<div>
<label className="block text-xs font-medium text-gray-700">Tipo Serviço</label>
<select className="w-full border rounded px-2 py-1.5 mt-1"
value={formData.tipoServico} onChange={e => setFormData({...formData, tipoServico: e.target.value})}
>
<option value="">Selecione...</option>
{tiposServicos.map(t => <option key={t.id} value={t.nome}>{t.nome}</option>)}
</select>
</div>
{/* Professional Info */}
<div className="md:col-span-2 relative">
<label className="block text-xs font-medium text-gray-700">Nome Profissional</label>
<input
type="text"
className="w-full border rounded px-2 py-1.5 mt-1"
value={formData.nome}
onChange={e => handleProSearch(e.target.value)}
onBlur={() => setTimeout(() => setShowProSuggestions(false), 200)} // Delay to allow click
placeholder="Digite para buscar..."
/>
{showProSuggestions && proResults && proResults.length > 0 && (
<div className="absolute z-10 w-full bg-white border rounded shadow-lg max-h-40 overflow-y-auto mt-1">
{proResults.map(p => (
<div
key={p.id}
className="p-2 hover:bg-gray-100 cursor-pointer text-xs"
onClick={() => selectProfessional(p)}
>
<strong>{p.nome}</strong> <span className="text-gray-500">({p.funcao_nome})</span>
</div>
))}
</div>
)}
</div>
<div>
<label className="block text-xs font-medium text-gray-700">WhatsApp</label>
<input type="text" className="w-full border rounded px-2 py-1.5 mt-1"
value={formData.whatsapp} onChange={e => setFormData({...formData, whatsapp: e.target.value})}
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700">CPF</label>
<input type="text" className="w-full border rounded px-2 py-1.5 mt-1"
value={formData.cpf} onChange={e => setFormData({...formData, cpf: e.target.value})}
/>
</div>
{/* Values */}
<div>
<label className="block text-xs font-medium text-gray-700">Tabela Free</label>
{proFunctions.length > 0 ? (
<select
className="w-full border rounded px-2 py-1.5 mt-1"
value={formData.tabelaFree || ""}
onChange={e => {
setFormData({...formData, tabelaFree: e.target.value});
// Also set Tipo Servico to match? Or keep them separate?
// Usually Tabela Free matches the function they are performing.
}}
>
{proFunctions.map((fn: any) => (
<option key={fn.id} value={fn.nome}>{fn.nome}</option>
))}
</select>
) : (
<input type="text" className="w-full border rounded px-2 py-1.5 mt-1"
value={formData.tabelaFree || ""} onChange={e => setFormData({...formData, tabelaFree: e.target.value})}
/>
)}
</div>
<div>
<label className="block text-xs font-medium text-gray-700">Valor Free (R$)</label>
<input type="number" step="0.01" className="w-full border rounded px-2 py-1.5 mt-1"
value={formData.valorFree ?? ""}
onChange={e => {
const val = e.target.value;
setFormData({...formData, valorFree: val === "" ? null : parseFloat(val)});
}}
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700">Valor Extra (R$)</label>
<input type="number" step="0.01" className="w-full border rounded px-2 py-1.5 mt-1"
value={formData.valorExtra ?? ""}
onChange={e => {
const val = e.target.value;
setFormData({...formData, valorExtra: val === "" ? null : parseFloat(val)});
}}
/>
</div>
<div className="md:col-span-3">
<label className="block text-xs font-medium text-gray-700">Descrição Extra</label>
<input type="text" className="w-full border rounded px-2 py-1.5 mt-1"
value={formData.descricaoExtra} onChange={e => setFormData({...formData, descricaoExtra: e.target.value})}
/>
</div>
{/* Payment */}
<div>
<label className="block text-xs font-medium text-gray-700">Data Pagamento</label>
<input type="date" className="w-full border rounded px-2 py-1.5 mt-1"
value={formData.dataPgto} onChange={e => setFormData({...formData, dataPgto: e.target.value})}
/>
</div>
<div className="flex items-end pb-3">
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" className="w-4 h-4 rounded text-brand-gold focus:ring-brand-gold"
checked={formData.pgtoOk} onChange={e => setFormData({...formData, pgtoOk: e.target.checked})}
/>
<span className="text-sm font-medium text-gray-700">Pagamento Realizado?</span>
</label>
</div>
<div className="bg-gray-100 p-2 rounded text-right flex flex-col justify-center">
<span className="text-xs text-gray-500">Total a Pagar</span>
<span className="text-xl font-bold text-gray-800">
R$ {formData.totalPagar?.toFixed(2)}
</span>
</div>
</div>
<div className="p-4 border-t bg-gray-50 flex justify-between items-center sticky bottom-0">
<button onClick={() => { setShowAddModal(false); setShowEditModal(false); }} className="px-4 py-2 text-gray-700 hover:bg-gray-200 rounded border bg-white">
Fechar
</button>
{successMessage && (
<span className="text-green-600 font-medium text-sm animate-pulse">
{successMessage}
</span>
)}
<button onClick={handleSmartSave} className="px-6 py-2 bg-brand-gold text-white rounded hover:bg-yellow-600 shadow font-medium">
{formData.id ? "Atualizar" : "Salvar Lançamento"}
</button>
</div>
</div>
</div>
)}
{/* Bulk Edit Modal */}
{showBulkEditModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg shadow-xl w-full max-w-md">
<div className="p-4 border-b flex justify-between items-center bg-gray-50">
<h2 className="text-lg font-bold text-gray-800">Editar {selectedIds.size} Itens</h2>
<button onClick={() => setShowBulkEditModal(false)} className="p-1 hover:bg-gray-200 rounded">
<X size={20}/>
</button>
</div>
<div className="p-6 flex flex-col gap-4">
<div className="bg-blue-50 border border-blue-200 p-3 rounded text-xs text-blue-800">
Atenção: Os valores inseridos serão <strong>adicionados</strong> aos valores existentes. A descrição será concatenada.
</div>
<div>
<label className="block text-xs font-medium text-gray-700">Adicionar Valor Extra (R$)</label>
<input
type="number"
step="0.01"
className="w-full border rounded px-2 py-1.5 mt-1"
value={bulkFormData.valorExtra}
onChange={e => setBulkFormData({...bulkFormData, valorExtra: parseFloat(e.target.value) || 0})}
placeholder="Ex: 50.00"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700">Adicionar Descrição (Será concatenada)</label>
<input
type="text"
className="w-full border rounded px-2 py-1.5 mt-1"
value={bulkFormData.descricaoExtra}
onChange={e => setBulkFormData({...bulkFormData, descricaoExtra: e.target.value})}
placeholder="Ex: + Ajuda de Custo"
/>
</div>
</div>
<div className="p-4 border-t bg-gray-50 flex justify-end gap-2">
<button onClick={() => setShowBulkEditModal(false)} className="px-4 py-2 border rounded hover:bg-gray-100 text-sm">Cancelar</button>
<button onClick={handleBulkUpdate} className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 text-sm font-medium">
Salvar Alterações
</button>
</div>
</div>
</div>
)}
</div>
);
};
export default Finance;