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:
parent
b445d69de2
commit
050c164286
5 changed files with 308 additions and 165 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
|
|
||||||
|
|
@ -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()})
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue