feat(finance): implementa exportação csv, ordenação e totalização

- Substitui importação por exportação de dados em CSV
- Adiciona ordenação alfabética e por data nas colunas da tabela
- Exibe somatório total dos registros filtrados na interface e no export
- Corrige escopo de variáveis no useEffect de filtros
This commit is contained in:
NANDO9322 2026-02-09 19:42:22 -03:00
parent b445d69de2
commit 050c164286
5 changed files with 308 additions and 165 deletions

View file

@ -39,20 +39,28 @@ WHERE
($7::text = '' OR c.nome ILIKE '%' || $7 || '%') AND ($7::text = '' OR c.nome ILIKE '%' || $7 || '%') AND
($8::text = '' OR f.instituicao ILIKE '%' || $8 || '%') AND ($8::text = '' OR f.instituicao ILIKE '%' || $8 || '%') AND
($9::text = '' OR CAST(a.ano_semestre AS TEXT) ILIKE '%' || $9 || '%') AND ($9::text = '' OR CAST(a.ano_semestre AS TEXT) ILIKE '%' || $9 || '%') AND
($10::text = '' OR e.nome ILIKE '%' || $10 || '%') ($10::text = '' OR e.nome ILIKE '%' || $10 || '%') AND
-- Date Range Logic
($11::text = '' OR t.data_cobranca >= CAST($11 AS DATE)) AND
($12::text = '' OR t.data_cobranca <= CAST($12 AS DATE)) AND
-- Weekend Logic
($13::boolean = true OR EXTRACT(DOW FROM t.data_cobranca) NOT IN (0, 6))
` `
type CountTransactionsFilteredParams struct { type CountTransactionsFilteredParams struct {
Regiao pgtype.Text `json:"regiao"` Regiao pgtype.Text `json:"regiao"`
Fot string `json:"fot"` Fot string `json:"fot"`
Data string `json:"data"` Data string `json:"data"`
Evento string `json:"evento"` Evento string `json:"evento"`
Servico string `json:"servico"` Servico string `json:"servico"`
Nome string `json:"nome"` Nome string `json:"nome"`
Curso string `json:"curso"` Curso string `json:"curso"`
Instituicao string `json:"instituicao"` Instituicao string `json:"instituicao"`
Ano string `json:"ano"` Ano string `json:"ano"`
Empresa string `json:"empresa"` Empresa string `json:"empresa"`
StartDate string `json:"start_date"`
EndDate string `json:"end_date"`
IncludeWeekends bool `json:"include_weekends"`
} }
func (q *Queries) CountTransactionsFiltered(ctx context.Context, arg CountTransactionsFilteredParams) (int64, error) { func (q *Queries) CountTransactionsFiltered(ctx context.Context, arg CountTransactionsFilteredParams) (int64, error) {
@ -67,6 +75,9 @@ func (q *Queries) CountTransactionsFiltered(ctx context.Context, arg CountTransa
arg.Instituicao, arg.Instituicao,
arg.Ano, arg.Ano,
arg.Empresa, arg.Empresa,
arg.StartDate,
arg.EndDate,
arg.IncludeWeekends,
) )
var count int64 var count int64
err := row.Scan(&count) err := row.Scan(&count)
@ -523,24 +534,32 @@ WHERE
($9::text = '' OR c.nome ILIKE '%' || $9 || '%') AND ($9::text = '' OR c.nome ILIKE '%' || $9 || '%') AND
($10::text = '' OR f.instituicao ILIKE '%' || $10 || '%') AND ($10::text = '' OR f.instituicao ILIKE '%' || $10 || '%') AND
($11::text = '' OR CAST(a.ano_semestre AS TEXT) ILIKE '%' || $11 || '%') AND ($11::text = '' OR CAST(a.ano_semestre AS TEXT) ILIKE '%' || $11 || '%') AND
($12::text = '' OR e.nome ILIKE '%' || $12 || '%') ($12::text = '' OR e.nome ILIKE '%' || $12 || '%') AND
-- Date Range Logic
($13::text = '' OR t.data_cobranca >= CAST($13 AS DATE)) AND
($14::text = '' OR t.data_cobranca <= CAST($14 AS DATE)) AND
-- Weekend Logic (0=Sunday, 6=Saturday)
($15::boolean = true OR EXTRACT(DOW FROM t.data_cobranca) NOT IN (0, 6))
ORDER BY t.data_cobranca DESC NULLS LAST ORDER BY t.data_cobranca DESC NULLS LAST
LIMIT $1 OFFSET $2 LIMIT $1 OFFSET $2
` `
type ListTransactionsPaginatedFilteredParams struct { type ListTransactionsPaginatedFilteredParams struct {
Limit int32 `json:"limit"` Limit int32 `json:"limit"`
Offset int32 `json:"offset"` Offset int32 `json:"offset"`
Regiao pgtype.Text `json:"regiao"` Regiao pgtype.Text `json:"regiao"`
Fot string `json:"fot"` Fot string `json:"fot"`
Data string `json:"data"` Data string `json:"data"`
Evento string `json:"evento"` Evento string `json:"evento"`
Servico string `json:"servico"` Servico string `json:"servico"`
Nome string `json:"nome"` Nome string `json:"nome"`
Curso string `json:"curso"` Curso string `json:"curso"`
Instituicao string `json:"instituicao"` Instituicao string `json:"instituicao"`
Ano string `json:"ano"` Ano string `json:"ano"`
Empresa string `json:"empresa"` Empresa string `json:"empresa"`
StartDate string `json:"start_date"`
EndDate string `json:"end_date"`
IncludeWeekends bool `json:"include_weekends"`
} }
type ListTransactionsPaginatedFilteredRow struct { type ListTransactionsPaginatedFilteredRow struct {
@ -584,6 +603,9 @@ func (q *Queries) ListTransactionsPaginatedFiltered(ctx context.Context, arg Lis
arg.Instituicao, arg.Instituicao,
arg.Ano, arg.Ano,
arg.Empresa, arg.Empresa,
arg.StartDate,
arg.EndDate,
arg.IncludeWeekends,
) )
if err != nil { if err != nil {
return nil, err return nil, err

View file

@ -91,7 +91,12 @@ WHERE
(@curso::text = '' OR c.nome ILIKE '%' || @curso || '%') AND (@curso::text = '' OR c.nome ILIKE '%' || @curso || '%') AND
(@instituicao::text = '' OR f.instituicao ILIKE '%' || @instituicao || '%') AND (@instituicao::text = '' OR f.instituicao ILIKE '%' || @instituicao || '%') AND
(@ano::text = '' OR CAST(a.ano_semestre AS TEXT) ILIKE '%' || @ano || '%') AND (@ano::text = '' OR CAST(a.ano_semestre AS TEXT) ILIKE '%' || @ano || '%') AND
(@empresa::text = '' OR e.nome ILIKE '%' || @empresa || '%') (@empresa::text = '' OR e.nome ILIKE '%' || @empresa || '%') AND
-- Date Range Logic
(@start_date::text = '' OR t.data_cobranca >= CAST(@start_date AS DATE)) AND
(@end_date::text = '' OR t.data_cobranca <= CAST(@end_date AS DATE)) AND
-- Weekend Logic (0=Sunday, 6=Saturday)
(@include_weekends::boolean = true OR EXTRACT(DOW FROM t.data_cobranca) NOT IN (0, 6))
ORDER BY t.data_cobranca DESC NULLS LAST ORDER BY t.data_cobranca DESC NULLS LAST
LIMIT $1 OFFSET $2; LIMIT $1 OFFSET $2;
@ -115,4 +120,9 @@ WHERE
(@curso::text = '' OR c.nome ILIKE '%' || @curso || '%') AND (@curso::text = '' OR c.nome ILIKE '%' || @curso || '%') AND
(@instituicao::text = '' OR f.instituicao ILIKE '%' || @instituicao || '%') AND (@instituicao::text = '' OR f.instituicao ILIKE '%' || @instituicao || '%') AND
(@ano::text = '' OR CAST(a.ano_semestre AS TEXT) ILIKE '%' || @ano || '%') AND (@ano::text = '' OR CAST(a.ano_semestre AS TEXT) ILIKE '%' || @ano || '%') AND
(@empresa::text = '' OR e.nome ILIKE '%' || @empresa || '%'); (@empresa::text = '' OR e.nome ILIKE '%' || @empresa || '%') AND
-- Date Range Logic
(@start_date::text = '' OR t.data_cobranca >= CAST(@start_date AS DATE)) AND
(@end_date::text = '' OR t.data_cobranca <= CAST(@end_date AS DATE)) AND
-- Weekend Logic
(@include_weekends::boolean = true OR EXTRACT(DOW FROM t.data_cobranca) NOT IN (0, 6));

View file

@ -210,18 +210,28 @@ func (h *Handler) List(c *gin.Context) {
instituicao := c.Query("instituicao") instituicao := c.Query("instituicao")
ano := c.Query("ano") ano := c.Query("ano")
empresa := c.Query("empresa") empresa := c.Query("empresa")
startDate := c.Query("startDate")
endDate := c.Query("endDate")
includeWeekendsStr := c.Query("includeWeekends")
includeWeekends := true
if includeWeekendsStr == "false" {
includeWeekends = false
}
regiao := c.GetString("regiao") regiao := c.GetString("regiao")
list, count, err := h.service.ListPaginated(c.Request.Context(), int32(page), int32(limit), FilterParams{ list, count, err := h.service.ListPaginated(c.Request.Context(), int32(page), int32(limit), FilterParams{
Fot: fot, Fot: fot,
Data: data, Data: data,
Evento: evento, Evento: evento,
Servico: servico, Servico: servico,
Nome: nome, Nome: nome,
Curso: curso, Curso: curso,
Instituicao: instituicao, Instituicao: instituicao,
Ano: ano, Ano: ano,
Empresa: empresa, Empresa: empresa,
StartDate: startDate,
EndDate: endDate,
IncludeWeekends: includeWeekends,
}, regiao) }, regiao)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})

View file

@ -93,15 +93,18 @@ func (s *Service) ListAll(ctx context.Context, regiao string) ([]generated.ListT
} }
type FilterParams struct { type FilterParams struct {
Fot string Fot string
Data string Data string
Evento string Evento string
Servico string Servico string
Nome string Nome string
Curso string Curso string
Instituicao string Instituicao string
Ano string Ano string
Empresa string Empresa string
StartDate string
EndDate string
IncludeWeekends bool
} }
func (s *Service) ListPaginated(ctx context.Context, page int32, limit int32, filters FilterParams, regiao string) ([]generated.ListTransactionsPaginatedFilteredRow, int64, error) { func (s *Service) ListPaginated(ctx context.Context, page int32, limit int32, filters FilterParams, regiao string) ([]generated.ListTransactionsPaginatedFilteredRow, int64, error) {
@ -114,34 +117,40 @@ func (s *Service) ListPaginated(ctx context.Context, page int32, limit int32, fi
offset := (page - 1) * limit offset := (page - 1) * limit
rows, err := s.queries.ListTransactionsPaginatedFiltered(ctx, generated.ListTransactionsPaginatedFilteredParams{ rows, err := s.queries.ListTransactionsPaginatedFiltered(ctx, generated.ListTransactionsPaginatedFilteredParams{
Limit: limit, Limit: limit,
Offset: offset, Offset: offset,
Fot: filters.Fot, Fot: filters.Fot,
Data: filters.Data, Data: filters.Data,
Evento: filters.Evento, Evento: filters.Evento,
Servico: filters.Servico, Servico: filters.Servico,
Nome: filters.Nome, Nome: filters.Nome,
Curso: filters.Curso, Curso: filters.Curso,
Instituicao: filters.Instituicao, Instituicao: filters.Instituicao,
Ano: filters.Ano, Ano: filters.Ano,
Empresa: filters.Empresa, Empresa: filters.Empresa,
Regiao: pgtype.Text{String: regiao, Valid: true}, StartDate: filters.StartDate,
EndDate: filters.EndDate,
IncludeWeekends: filters.IncludeWeekends,
Regiao: pgtype.Text{String: regiao, Valid: true},
}) })
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
} }
count, err := s.queries.CountTransactionsFiltered(ctx, generated.CountTransactionsFilteredParams{ count, err := s.queries.CountTransactionsFiltered(ctx, generated.CountTransactionsFilteredParams{
Fot: filters.Fot, Fot: filters.Fot,
Data: filters.Data, Data: filters.Data,
Evento: filters.Evento, Evento: filters.Evento,
Servico: filters.Servico, Servico: filters.Servico,
Nome: filters.Nome, Nome: filters.Nome,
Curso: filters.Curso, Curso: filters.Curso,
Instituicao: filters.Instituicao, Instituicao: filters.Instituicao,
Ano: filters.Ano, Ano: filters.Ano,
Empresa: filters.Empresa, Empresa: filters.Empresa,
Regiao: pgtype.Text{String: regiao, Valid: true}, StartDate: filters.StartDate,
EndDate: filters.EndDate,
IncludeWeekends: filters.IncludeWeekends,
Regiao: pgtype.Text{String: regiao, Valid: true},
}) })
if err != nil { if err != nil {
count = 0 count = 0

View file

@ -155,6 +155,11 @@ const Finance: React.FC = () => {
instituicao: filters.instituicao || "", instituicao: filters.instituicao || "",
ano: filters.ano || "", ano: filters.ano || "",
empresa: filters.empresa || "", 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()}`, { const res = await fetch(`${API_BASE_URL}/api/finance?${queryParams.toString()}`, {
@ -228,14 +233,7 @@ const Finance: React.FC = () => {
loadAuxiliaryData(); loadAuxiliaryData();
}, [page, limit]); // Refresh on page/limit change }, [page, limit]); // Refresh on page/limit change
// Debounce filter changes
useEffect(() => {
const timer = setTimeout(() => {
setPage(1); // Reset to page 1 on filter change
loadTransactions();
}, 500);
return () => clearTimeout(timer);
}, [filters]);
// Advanced date filters // Advanced date filters
const [dateFilters, setDateFilters] = useState({ const [dateFilters, setDateFilters] = useState({
@ -245,80 +243,45 @@ const Finance: React.FC = () => {
}); });
const [showDateFilters, setShowDateFilters] = useState(false); 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 // Calculate filtered and sorted transactions
const sortedTransactions = React.useMemo(() => { const sortedTransactions = React.useMemo(() => {
let result = [...transactions]; let result = [...transactions];
// 1. Filter // 1. Filter
if (filters.fot) result = result.filter(t => String(t.fot).includes(filters.fot)); // Filters are handled by Backend
if (filters.data) result = result.filter(t => t.data.includes(filters.data)); // if (filters.fot) result = result.filter(t => String(t.fot).includes(filters.fot));
if (filters.evento) result = result.filter(t => t.tipoEvento.toLowerCase().includes(filters.evento.toLowerCase())); // if (filters.data) result = result.filter(t => t.data.includes(filters.data));
if (filters.servico) result = result.filter(t => t.tipoServico.toLowerCase().includes(filters.servico.toLowerCase())); // if (filters.evento) result = result.filter(t => t.tipoEvento.toLowerCase().includes(filters.evento.toLowerCase()));
if (filters.nome) result = result.filter(t => t.nome.toLowerCase().includes(filters.nome.toLowerCase())); // if (filters.servico) result = result.filter(t => t.tipoServico.toLowerCase().includes(filters.servico.toLowerCase()));
if (filters.curso) result = result.filter(t => t.curso.toLowerCase().includes(filters.curso.toLowerCase())); // if (filters.nome) result = result.filter(t => t.nome.toLowerCase().includes(filters.nome.toLowerCase()));
if (filters.instituicao) result = result.filter(t => t.instituicao.toLowerCase().includes(filters.instituicao.toLowerCase())); // if (filters.curso) result = result.filter(t => t.curso.toLowerCase().includes(filters.curso.toLowerCase()));
if (filters.ano) result = result.filter(t => String(t.anoFormatura).includes(filters.ano)); // if (filters.instituicao) result = result.filter(t => t.instituicao.toLowerCase().includes(filters.instituicao.toLowerCase()));
if (filters.empresa) result = result.filter(t => t.empresa.toLowerCase().includes(filters.empresa.toLowerCase())); // if (filters.ano) result = result.filter(t => String(t.anoFormatura).includes(filters.ano));
if (filters.status) { // if (filters.empresa) result = result.filter(t => t.empresa.toLowerCase().includes(filters.empresa.toLowerCase()));
const s = filters.status.toLowerCase(); // if (filters.status) {
if (s === 'ok' || s === 'sim') result = result.filter(t => t.pgtoOk); // const s = filters.status.toLowerCase();
if (s === 'no' || s === 'nao' || s === 'não') result = result.filter(t => !t.pgtoOk); // 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
if (dateFilters.startDate || dateFilters.endDate || !dateFilters.includeWeekends) { // Advanced date filters - Handled by Backend now
result = result.filter(t => { // if (dateFilters.startDate || dateFilters.endDate || !dateFilters.includeWeekends) {
// Parse date from dataRaw (YYYY-MM-DD) or data (DD/MM/YYYY) // // ... filtering logic removed ...
let dateToCheck: Date; // }
if (t.dataRaw) {
dateToCheck = new Date(t.dataRaw);
} else {
// Parse DD/MM/YYYY
const parts = t.data.split('/');
if (parts.length === 3) {
dateToCheck = new Date(parseInt(parts[2]), parseInt(parts[1]) - 1, parseInt(parts[0]));
} else {
return true; // Keep if can't parse
}
}
// Check date range
if (dateFilters.startDate) {
const startDate = new Date(dateFilters.startDate);
if (dateToCheck < startDate) return false;
}
if (dateFilters.endDate) {
const endDate = new Date(dateFilters.endDate);
endDate.setHours(23, 59, 59, 999); // Include the entire end date
if (dateToCheck > endDate) return false;
}
// Check weekends
if (!dateFilters.includeWeekends) {
const dayOfWeek = dateToCheck.getDay();
if (dayOfWeek === 0 || dayOfWeek === 6) return false; // 0 = Sunday, 6 = Saturday
}
return true;
});
}
// Advanced date filters - Custom Logic // Advanced date filters - Custom Logic
if (dateFilters.startDate || dateFilters.endDate || !dateFilters.includeWeekends) { // Advanced date filters - Custom Logic (Removed)
result = result.filter(t => { // if (dateFilters.startDate || dateFilters.endDate || !dateFilters.includeWeekends) { ... }
// Logic handled above but ensure it covers empty dates if strict?
// Current implementation from previous read is fine, assuming it exists.
// Wait, previous read showed it was implemented. I will trust it or should I re-implement to be sure?
// The previous read showed the logic block I want to keep.
// I will inject the UI controls for it below.
return true;
});
// Actually, let's refine the filter logic block if needed.
// Based on File read, it seemed complete.
// I'll leave the logic alone and just add the UI inputs.
// Wait, I need to check if the filter inputs are actually rendered.
// Previous read stopped at line 600. I check render now.
}
// Applying filtered logic reuse // Applying filtered logic reuse
// ... code kept essentially same ... // ... code kept essentially same ...
@ -344,6 +307,17 @@ const Finance: React.FC = () => {
const aValue = a[sortConfig.key]; const aValue = a[sortConfig.key];
// @ts-ignore // @ts-ignore
const bValue = b[sortConfig.key]; 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;
if (aValue > bValue) return sortConfig.direction === "asc" ? 1 : -1; if (aValue > bValue) return sortConfig.direction === "asc" ? 1 : -1;
return 0; return 0;
@ -748,10 +722,97 @@ const Finance: React.FC = () => {
// Calculations // Calculations
useEffect(() => { useEffect(() => {
const total = (Number(formData.valorFree) || 0) + (Number(formData.valorExtra) || 0);
setFormData((prev) => ({ ...prev, totalPagar: total })); setFormData((prev) => ({ ...prev, totalPagar: total }));
}, [formData.valorFree, formData.valorExtra]); }, [formData.valorFree, formData.valorExtra]);
const handleExportCSV = () => {
if (sortedTransactions.length === 0) {
alert("Nenhum dado para exportar.");
return;
}
// CSV Header
const headers = [
"FOT", "Data", "Evento", "Serviço", "Nome", "WhatsApp", "CPF",
"Curso", "Instituição", "Ano", "Empresa",
"Valor Free", "Valor Extra", "Total Pagar", "Data Pagamento", "Pgto OK"
];
// CSV Rows
const rows = sortedTransactions.map(t => [
t.fot,
t.data,
t.tipoEvento,
t.tipoServico,
`"${t.nome}"`, // Quote names to handle commas
t.whatsapp ? `="${t.whatsapp}"` : "", // Force string in Excel to avoid scientific notation
t.cpf ? `="${t.cpf}"` : "",
`"${t.curso}"`,
`"${t.instituicao}"`,
t.anoFormatura,
`"${t.empresa}"`,
t.valorFree.toFixed(2).replace('.', ','),
t.valorExtra.toFixed(2).replace('.', ','),
t.totalPagar.toFixed(2).replace('.', ','),
t.dataPgto,
t.pgtoOk ? "Sim" : "Não"
]);
// Summation Row
const totalValue = sortedTransactions.reduce((sum, t) => sum + t.totalPagar, 0);
const sumRow = [
"TOTAL", "", "", "", "", "", "", "", "", "", "", "", "",
totalValue.toFixed(2).replace('.', ','), "", ""
];
// Combine
const csvContent = [
headers.join(";"),
...rows.map(r => r.join(";")),
sumRow.join(";")
].join("\n");
// Create Blob with BOM for Excel UTF-8 support
const blob = new Blob(["\uFEFF" + csvContent], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
// Dynamic Filename
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)}`;
}
// Fallback or just append timestamp if no specific filters?
// Let's always append a short date/time or just date if no filters to avoid overwrites,
// but user asked for specific format. Let's stick to user request + date fallback if empty.
if (!filters.nome && !dateFilters.startDate && !dateFilters.endDate) {
const today = new Date().toLocaleDateString("pt-BR").split("/").join("-"); // DD-MM-YYYY
filename += `_${today}`;
}
filename += ".csv";
const link = document.createElement("a");
link.href = url;
link.setAttribute("download", filename);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
// Calculate Total for Display
const filteredTotal = sortedTransactions.reduce((acc, curr) => acc + curr.totalPagar, 0);
return ( 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="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="max-w-[98%] mx-auto px-2">
@ -761,12 +822,12 @@ const Finance: React.FC = () => {
<p className="text-gray-500 text-sm">Controle financeiro e transações</p> <p className="text-gray-500 text-sm">Controle financeiro e transações</p>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<button <button
onClick={() => navigate("/importacao?tab=financeiro")} onClick={handleExportCSV}
className="bg-blue-600 text-white px-4 py-2 rounded shadow hover:bg-blue-700 transition flex items-center gap-2" className="bg-green-600 text-white px-4 py-2 rounded shadow hover:bg-green-700 transition flex items-center gap-2"
> >
<Upload size={18} /> Importar Dados <Download size={18} /> Exportar Dados
</button> </button>
<button <button
onClick={() => { onClick={() => {
setFormData({ // Clear form to initial state setFormData({ // Clear form to initial state
@ -860,7 +921,8 @@ const Finance: React.FC = () => {
{/* Pagination Controls (Top) */} {/* Pagination Controls (Top) */}
<div className="flex justify-between items-center px-4 py-2 bg-gray-50 border-b rounded-t-lg"> <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"> <span className="text-xs text-gray-600">
Mostrando {transactions.length} de {total} registros Mostrando {transactions.length} de {total} registros |
<span className="ml-2 font-bold text-gray-900">Total Filtrado: R$ {filteredTotal.toFixed(2).replace('.', ',')}</span>
</span> </span>
<div className="flex gap-2 items-center"> <div className="flex gap-2 items-center">
<button <button
@ -900,24 +962,54 @@ const Finance: React.FC = () => {
<table className="w-full text-xs text-left whitespace-nowrap min-w-[1500px]"> <table className="w-full text-xs text-left whitespace-nowrap min-w-[1500px]">
<thead className="bg-gray-100 border-b"> <thead className="bg-gray-100 border-b">
<tr> <tr>
{["FOT", "Data Evento", "Curso", "Instituição", "Ano", "Empresa", "Evento", "Serviço", "Nome", "WhatsApp", "CPF", "Tab. Free", "V. Free", "V. Extra", "Desc. Extra", "Total", "Dt. Pgto", "OK"].map(h => ( {[
<th key={h} className="px-3 py-2 font-semibold text-gray-700 align-top"> { label: "FOT", key: "fot" },
<div className="flex flex-col gap-1"> { label: "Data Evento", key: "data" },
<span>{h}</span> { label: "Curso", key: "curso" },
{/* Filters */} { label: "Instituição", key: "instituicao" },
{h === "FOT" && <input className="w-full text-[10px] border rounded px-1" placeholder="Filtrar" value={filters.fot} onChange={e => setFilters({...filters, fot: e.target.value})} />} { label: "Ano", key: "anoFormatura" },
{h === "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})} />} { label: "Empresa", key: "empresa" },
{h === "Curso" && <input className="w-full text-[10px] border rounded px-1" placeholder="Filtrar" value={filters.curso} onChange={e => setFilters({...filters, curso: e.target.value})} />} { label: "Evento", key: "tipoEvento" },
{h === "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})} />} { label: "Serviço", key: "tipoServico" },
{h === "Ano" && <input className="w-full text-[10px] border rounded px-1" placeholder="Filtrar" value={filters.ano} onChange={e => setFilters({...filters, ano: e.target.value})} />} { label: "Nome", key: "nome" },
{h === "Empresa" && <input className="w-full text-[10px] border rounded px-1" placeholder="Filtrar" value={filters.empresa} onChange={e => setFilters({...filters, empresa: e.target.value})} />} { label: "WhatsApp", key: "whatsapp" },
{h === "Evento" && <input className="w-full text-[10px] border rounded px-1" placeholder="Filtrar" value={filters.evento} onChange={e => setFilters({...filters, evento: e.target.value})} />} { label: "CPF", key: "cpf" },
{h === "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})} />} { label: "Tab. Free", key: "tabelaFree" },
{h === "Nome" && <input className="w-full text-[10px] border rounded px-1" placeholder="Filtrar" value={filters.nome} onChange={e => setFilters({...filters, nome: e.target.value})} />} { label: "V. Free", key: "valorFree" },
{h === "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})} />} { label: "V. Extra", key: "valorExtra" },
</div> { label: "Desc. Extra", key: "descricaoExtra" },
</th> { 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> </tr>
</thead> </thead>
<tbody className="divide-y relative"> <tbody className="divide-y relative">