diff --git a/backend/internal/db/generated/financial_transactions.sql.go b/backend/internal/db/generated/financial_transactions.sql.go index 0888006..08c5ef5 100644 --- a/backend/internal/db/generated/financial_transactions.sql.go +++ b/backend/internal/db/generated/financial_transactions.sql.go @@ -39,20 +39,28 @@ WHERE ($7::text = '' OR c.nome ILIKE '%' || $7 || '%') AND ($8::text = '' OR f.instituicao ILIKE '%' || $8 || '%') 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 { - Regiao pgtype.Text `json:"regiao"` - Fot string `json:"fot"` - Data string `json:"data"` - Evento string `json:"evento"` - Servico string `json:"servico"` - Nome string `json:"nome"` - Curso string `json:"curso"` - Instituicao string `json:"instituicao"` - Ano string `json:"ano"` - Empresa string `json:"empresa"` + Regiao pgtype.Text `json:"regiao"` + Fot string `json:"fot"` + Data string `json:"data"` + Evento string `json:"evento"` + Servico string `json:"servico"` + Nome string `json:"nome"` + Curso string `json:"curso"` + Instituicao string `json:"instituicao"` + Ano string `json:"ano"` + 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) { @@ -67,6 +75,9 @@ func (q *Queries) CountTransactionsFiltered(ctx context.Context, arg CountTransa arg.Instituicao, arg.Ano, arg.Empresa, + arg.StartDate, + arg.EndDate, + arg.IncludeWeekends, ) var count int64 err := row.Scan(&count) @@ -523,24 +534,32 @@ WHERE ($9::text = '' OR c.nome ILIKE '%' || $9 || '%') AND ($10::text = '' OR f.instituicao ILIKE '%' || $10 || '%') 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 LIMIT $1 OFFSET $2 ` type ListTransactionsPaginatedFilteredParams struct { - Limit int32 `json:"limit"` - Offset int32 `json:"offset"` - Regiao pgtype.Text `json:"regiao"` - Fot string `json:"fot"` - Data string `json:"data"` - Evento string `json:"evento"` - Servico string `json:"servico"` - Nome string `json:"nome"` - Curso string `json:"curso"` - Instituicao string `json:"instituicao"` - Ano string `json:"ano"` - Empresa string `json:"empresa"` + Limit int32 `json:"limit"` + Offset int32 `json:"offset"` + Regiao pgtype.Text `json:"regiao"` + Fot string `json:"fot"` + Data string `json:"data"` + Evento string `json:"evento"` + Servico string `json:"servico"` + Nome string `json:"nome"` + Curso string `json:"curso"` + Instituicao string `json:"instituicao"` + Ano string `json:"ano"` + Empresa string `json:"empresa"` + StartDate string `json:"start_date"` + EndDate string `json:"end_date"` + IncludeWeekends bool `json:"include_weekends"` } type ListTransactionsPaginatedFilteredRow struct { @@ -584,6 +603,9 @@ func (q *Queries) ListTransactionsPaginatedFiltered(ctx context.Context, arg Lis arg.Instituicao, arg.Ano, arg.Empresa, + arg.StartDate, + arg.EndDate, + arg.IncludeWeekends, ) if err != nil { return nil, err diff --git a/backend/internal/db/queries/financial_transactions.sql b/backend/internal/db/queries/financial_transactions.sql index 9e99123..a19ea56 100644 --- a/backend/internal/db/queries/financial_transactions.sql +++ b/backend/internal/db/queries/financial_transactions.sql @@ -91,7 +91,12 @@ WHERE (@curso::text = '' OR c.nome ILIKE '%' || @curso || '%') AND (@instituicao::text = '' OR f.instituicao ILIKE '%' || @instituicao || '%') 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 LIMIT $1 OFFSET $2; @@ -115,4 +120,9 @@ WHERE (@curso::text = '' OR c.nome ILIKE '%' || @curso || '%') AND (@instituicao::text = '' OR f.instituicao ILIKE '%' || @instituicao || '%') 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)); diff --git a/backend/internal/finance/handler.go b/backend/internal/finance/handler.go index c883cd7..3c2deaa 100644 --- a/backend/internal/finance/handler.go +++ b/backend/internal/finance/handler.go @@ -210,18 +210,28 @@ func (h *Handler) List(c *gin.Context) { instituicao := c.Query("instituicao") ano := c.Query("ano") 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") list, count, err := h.service.ListPaginated(c.Request.Context(), int32(page), int32(limit), FilterParams{ - Fot: fot, - Data: data, - Evento: evento, - Servico: servico, - Nome: nome, - Curso: curso, - Instituicao: instituicao, - Ano: ano, - Empresa: empresa, + Fot: fot, + Data: data, + Evento: evento, + Servico: servico, + Nome: nome, + Curso: curso, + Instituicao: instituicao, + Ano: ano, + Empresa: empresa, + StartDate: startDate, + EndDate: endDate, + IncludeWeekends: includeWeekends, }, regiao) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) diff --git a/backend/internal/finance/service.go b/backend/internal/finance/service.go index 4b096bf..bfad04d 100644 --- a/backend/internal/finance/service.go +++ b/backend/internal/finance/service.go @@ -93,15 +93,18 @@ func (s *Service) ListAll(ctx context.Context, regiao string) ([]generated.ListT } type FilterParams struct { - Fot string - Data string - Evento string - Servico string - Nome string - Curso string - Instituicao string - Ano string - Empresa string + Fot string + Data string + Evento string + Servico string + Nome string + Curso string + Instituicao string + Ano 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) { @@ -114,34 +117,40 @@ func (s *Service) ListPaginated(ctx context.Context, page int32, limit int32, fi offset := (page - 1) * limit rows, err := s.queries.ListTransactionsPaginatedFiltered(ctx, generated.ListTransactionsPaginatedFilteredParams{ - Limit: limit, - Offset: offset, - 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, - Regiao: pgtype.Text{String: regiao, Valid: true}, + Limit: limit, + Offset: offset, + 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, + StartDate: filters.StartDate, + EndDate: filters.EndDate, + IncludeWeekends: filters.IncludeWeekends, + Regiao: pgtype.Text{String: regiao, Valid: true}, }) if err != nil { return nil, 0, err } count, err := s.queries.CountTransactionsFiltered(ctx, generated.CountTransactionsFilteredParams{ - 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, - Regiao: pgtype.Text{String: regiao, Valid: true}, + 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, + StartDate: filters.StartDate, + EndDate: filters.EndDate, + IncludeWeekends: filters.IncludeWeekends, + Regiao: pgtype.Text{String: regiao, Valid: true}, }) if err != nil { count = 0 diff --git a/frontend/pages/Finance.tsx b/frontend/pages/Finance.tsx index 92642c5..349d25c 100644 --- a/frontend/pages/Finance.tsx +++ b/frontend/pages/Finance.tsx @@ -155,6 +155,11 @@ const Finance: React.FC = () => { 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()}`, { @@ -228,14 +233,7 @@ const Finance: React.FC = () => { loadAuxiliaryData(); }, [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 const [dateFilters, setDateFilters] = useState({ @@ -245,80 +243,45 @@ const Finance: React.FC = () => { }); 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 - 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); - } + // 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 - if (dateFilters.startDate || dateFilters.endDate || !dateFilters.includeWeekends) { - result = result.filter(t => { - // Parse date from dataRaw (YYYY-MM-DD) or data (DD/MM/YYYY) - 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 - Handled by Backend now + // if (dateFilters.startDate || dateFilters.endDate || !dateFilters.includeWeekends) { + // // ... filtering logic removed ... + // } // Advanced date filters - Custom Logic - if (dateFilters.startDate || dateFilters.endDate || !dateFilters.includeWeekends) { - result = result.filter(t => { - // 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. - } + // Advanced date filters - Custom Logic (Removed) + // if (dateFilters.startDate || dateFilters.endDate || !dateFilters.includeWeekends) { ... } // Applying filtered logic reuse // ... code kept essentially same ... @@ -344,6 +307,17 @@ const Finance: React.FC = () => { 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; @@ -748,10 +722,97 @@ const Finance: React.FC = () => { // Calculations useEffect(() => { - const total = (Number(formData.valorFree) || 0) + (Number(formData.valorExtra) || 0); setFormData((prev) => ({ ...prev, totalPagar: total })); }, [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 (
@@ -761,12 +822,12 @@ const Finance: React.FC = () => {

Controle financeiro e transações

- +