diff --git a/src/App.css b/src/App.css index 5b1acdf..b3bd993 100644 --- a/src/App.css +++ b/src/App.css @@ -88,4 +88,10 @@ body, html { .custom-scrollbar::-webkit-scrollbar-thumb:hover { background: #555; +} + +.scrollbar-hide { + scrollbar-width: none; /* Firefox */ + -ms-overflow-style: none; /* Internet Explorer and Edge */ + overflow: -moz-scrollbars-none; /* Old versions of Firefox */ } \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index 9bf8520..11c76fb 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,6 +7,7 @@ import FeaturesSection from './components/FeaturesSection'; import Footer from './components/Footer'; import CandidatePage from './components/CandidatePage/CandidatePage'; import DataStatsPage from './components/DataStatsPage'; +import StatisticsPage from './components/StatisticsPage'; import NotFound from './components/NotFound'; import MatrixBackground from './components/MatrixBackground'; import './App.css'; @@ -32,6 +33,7 @@ function App() { } /> } /> } /> + } /> } /> diff --git a/src/api/apiStatisticsModels.ts b/src/api/apiStatisticsModels.ts new file mode 100644 index 0000000..6590b4a --- /dev/null +++ b/src/api/apiStatisticsModels.ts @@ -0,0 +1,58 @@ +// Type definitions based on the API specs for statistics endpoints + +export interface StatisticsConfig { + partidos: string[]; + siglasUF: string[]; + anos: number[]; + cargos: string[]; +} + + +export interface EnrichmentResponse { + idCandidato: string; + nome: string; + patrimonioInicial: number; + anoInicial: number; + patrimonioFinal: number; + anoFinal: number; + enriquecimento: number; +} + +export interface ValueSumRequest { + type: 'bem' | 'despesa' | 'receita'; + groupBy: 'candidato' | 'partido' | 'uf' | 'cargo'; + filter?: { + partido?: string | null; // Optional, can be null + uf?: string | null; // Optional, can be null + ano?: number | null; // Optional, can be null + cargo?: CargoFilter; // Optional, can be null + } +} + +export type CargoFilter = + | '1º SUPLENTE SENADOR' + | 'VICE-GOVERNADOR' + | '2º SUPLENTE' + | 'PRESIDENTE' + | 'DEPUTADO DISTRITAL' + | 'PREFEITO' + | 'VICE-PRESIDENTE' + | '2º SUPLENTE SENADOR' + | 'SENADOR' + | 'DEPUTADO ESTADUAL' + | '1º SUPLENTE' + | 'GOVERNADOR' + | 'VICE-PREFEITO' + | 'VEREADOR' + | 'DEPUTADO FEDERAL' + | null; + +export interface ValueSumResponse { + idCandidato?: string; + sgpartido?: string; + siglaUf?: string; + cargo?: string; + nome?: string; + ano: number; + valor: number; +} \ No newline at end of file diff --git a/src/api/openCandApi.ts b/src/api/openCandApi.ts index 9842775..7b0d100 100644 --- a/src/api/openCandApi.ts +++ b/src/api/openCandApi.ts @@ -1,6 +1,7 @@ import { BaseApiClient } from './base'; import { API_CONFIG } from '../config/api'; import type { CandidateAssets, CandidateDetails, CandidateExpenses, CandidateIncome, CandidateRedesSociais, CandidateSearchResult, CpfRevealResult, OpenCandDataAvailabilityStats, PlatformStats, RandomCandidate } from './apiModels'; +import type { EnrichmentResponse, StatisticsConfig, ValueSumRequest, ValueSumResponse } from './apiStatisticsModels'; /** * OpenCand API client for interacting with the OpenCand platform @@ -82,6 +83,27 @@ export class OpenCandApi extends BaseApiClient { async getCandidateReceitas(id: string): Promise { return this.get(`/v1/candidato/${id}/receitas`); } + + /** + * Get the configuration for statistics filters + */ + async getStatisticsConfig(): Promise { + return this.get(`/v1/estatistica/configuration`); + } + + /** + * Get the enrichment statistics for candidates + */ + async getStatisticsEnrichment(): Promise { + return this.get(`/v1/estatistica/enriquecimento`); + } + + /** + * Get the sum of values for a specific type and grouping + */ + async getStatisticsValueSum(request: ValueSumRequest): Promise { + return this.post(`/v1/estatistica/values-sum`, request); + } } // Create a default instance for easy usage with proper configuration diff --git a/src/components/CandidatePage/BasicCandidateInfoComponent.tsx b/src/components/CandidatePage/BasicCandidateInfoComponent.tsx index 3265650..e98f7c8 100644 --- a/src/components/CandidatePage/BasicCandidateInfoComponent.tsx +++ b/src/components/CandidatePage/BasicCandidateInfoComponent.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { use, useState } from 'react'; import { UserIcon } from '@heroicons/react/24/outline'; import { type CandidateDetails, openCandApi } from '../../api'; import { formatCpf, maskCpf } from '../../utils/utils'; @@ -15,6 +15,12 @@ const BasicCandidateInfoComponent: React.FC = const [revealedCpf, setRevealedCpf] = useState(null); const [isRevealingCpf, setIsRevealingCpf] = useState(false); + React.useEffect(() => { + if (candidateDetails) { + setRevealedCpf(null); + } + }, [candidateDetails]); + const formatDate = (dateString: string) => { try { const date = new Date(dateString); diff --git a/src/components/DataStatsPage.tsx b/src/components/DataStatsPage.tsx index 2e79edd..f88d65b 100644 --- a/src/components/DataStatsPage.tsx +++ b/src/components/DataStatsPage.tsx @@ -72,7 +72,7 @@ const DataStatsPage: React.FC = () => { ]; return ( -
+
{/* Header */}
@@ -154,15 +154,14 @@ const DataStatsPage: React.FC = () => { {dataType.label}
- {sortedYears.map((year, cellIndex) => { + {sortedYears.map((year) => { const isAvailable = (stats[dataType.key as keyof OpenCandDataAvailabilityStats] as number[]).includes(year); return ( -
{ Explore Dados Eleitorais

- OpenCand oferece acesso fácil e visualizações intuitivas de dados abertos do Tribunal Superior Eleitoral (TSE) do Brasil. + OpenCand oferece acesso fácil e visualizações intuitivas de dados abertos do Tribunal Superior Eleitoral (TSE).

diff --git a/src/components/StatisticsPage/StatisticsFilters.tsx b/src/components/StatisticsPage/StatisticsFilters.tsx new file mode 100644 index 0000000..caa6dbc --- /dev/null +++ b/src/components/StatisticsPage/StatisticsFilters.tsx @@ -0,0 +1,174 @@ +import React, { useState, useEffect } from 'react'; +import type { FilterState } from './StatisticsPage'; +import type { CargoFilter, StatisticsConfig } from '../../api/apiStatisticsModels'; +import { openCandApi } from '../../api/openCandApi'; +import Button from '../../shared/Button'; + +interface StatisticsFiltersProps { + filters: FilterState; + onFiltersChange: (filters: FilterState) => void; + isLoading?: boolean; +} + +const StatisticsFilters: React.FC = ({ + filters, + onFiltersChange, + isLoading = false, +}) => { + // Local state for form fields + const [localFilters, setLocalFilters] = useState(filters); + // State for configuration data from API + const [config, setConfig] = useState(null); + const [configLoading, setConfigLoading] = useState(true); + const [configError, setConfigError] = useState(null); + + // Sync local state when parent filters change + useEffect(() => { + setLocalFilters(filters); + }, [filters]); + + // Fetch configuration data on component mount + useEffect(() => { + const fetchConfig = async () => { + try { + setConfigLoading(true); + setConfigError(null); + const configData = await openCandApi.getStatisticsConfig(); + setConfig(configData); + } catch (error) { + console.error('Error fetching statistics config:', error); + setConfigError('Erro ao carregar configurações'); + } finally { + setConfigLoading(false); + } + }; + + fetchConfig(); + }, []); + + const handleLocalChange = (key: keyof FilterState, value: any) => { + setLocalFilters((prev) => ({ + ...prev, + [key]: value === '' ? null : value, + })); + }; + + const handleApply = (e: React.FormEvent) => { + e.preventDefault(); + onFiltersChange(localFilters); + }; + + return ( +
+
+
+

+ Filtros +

+ {configError && ( +
+ {configError} +
+ )} +
+ + {/* Party Filter */} +
+ + +
+ + {/* UF Filter */} +
+ + +
+ + {/* Year Filter */} +
+ + +
+ + {/* Cargo Filter */} +
+ + +
+
+ + {/* Apply Filters Button */} +
+ +
+
+ ); +}; + +export default StatisticsFilters; diff --git a/src/components/StatisticsPage/StatisticsGraphs.tsx b/src/components/StatisticsPage/StatisticsGraphs.tsx new file mode 100644 index 0000000..4300277 --- /dev/null +++ b/src/components/StatisticsPage/StatisticsGraphs.tsx @@ -0,0 +1,229 @@ +import React from 'react'; +import type { StatisticsData } from './statisticsRequests'; +import GlassCard from '../../shared/GlassCard'; + +interface StatisticsGraphsProps { + isLoading: boolean; + error: string | null; + statisticsData: StatisticsData | null; +} + +const StatisticsGraphs: React.FC = ({ + isLoading, + error, + statisticsData, +}) => { + if (error) { + return ( + +
+
⚠️ Erro
+

{error}

+
+
+ ); + } + + if (isLoading) { + return ( + +
+
+

Carregando dados...

+
+
+ ); + } + + if (!statisticsData) { + return ( + +
+
📊
+

Nenhum dado encontrado

+

+ Os dados estatísticos não puderam ser carregados +

+
+
+ ); + } + + const renderDataTable = (title: string, data: any[], type: 'candidate' | 'party' | 'state' | 'enrichment') => { + if (!data || data.length === 0) { + return ( + +
+

{title}

+

Nenhum dado disponível

+
+
+ ); + } + + if (type === 'enrichment') { + const enrichmentData = data[0]; // Single enrichment response + return ( + +
+

{title}

+
+
+ Candidato: + {enrichmentData.nome} +
+
+ Patrimônio Inicial ({enrichmentData.anoInicial}): + + R$ {enrichmentData.patrimonioInicial?.toLocaleString('pt-BR') || '0'} + + +
+
+ Patrimônio Final ({enrichmentData.anoFinal}): + + R$ {enrichmentData.patrimonioFinal?.toLocaleString('pt-BR') || '0'} + +
+
+
+ Enriquecimento: + = 0 ? 'text-green-600' : 'text-red-600' + }`}> + {enrichmentData.enriquecimento >= 0 ? '+' : ''} + R$ {enrichmentData.enriquecimento?.toLocaleString('pt-BR') || '0'} + +
+
+
+
+
+ ); + } + + return ( + +
+

{title}

+
+ + + + {type === 'candidate' && ( + <> + + + )} + {type === 'party' && ( + + )} + {type === 'state' && ( + + )} + + + + + + {data.slice(0, 5).map((item, index) => ( + + {type === 'candidate' && ( + <> + + + )} + {type === 'party' && ( + + )} + {type === 'state' && ( + + )} + + + + ))} + +
NomePartidoUFAnoValor
{item.nome || 'N/A'}{item.sgpartido || 'N/A'}{item.siglaUf || 'N/A'}{item.ano || 'N/A'} + R$ {item.valor?.toLocaleString('pt-BR') || '0'} +
+
+
+
+ ); + }; + + return ( +
+
+ {/* First Row - Candidates */} +
+ {renderDataTable( + 'Candidatos com Maiores Bens', + statisticsData.candidatesWithMostAssets, + 'candidate' + )} + {statisticsData.enrichmentData && renderDataTable( + 'Análise de Enriquecimento', + statisticsData.enrichmentData, + 'enrichment' + )} +
+ + {/* Second Row - Candidates Revenue & Expenses */} +
+ {renderDataTable( + 'Candidatos com Maiores Receitas', + statisticsData.candidatesWithMostRevenue, + 'candidate' + )} + {renderDataTable( + 'Candidatos com Maiores Despesas', + statisticsData.candidatesWithMostExpenses, + 'candidate' + )} +
+ + {/* Third Row - Parties */} +
+ {renderDataTable( + 'Partidos com Maiores Bens', + statisticsData.partiesWithMostAssets, + 'party' + )} + {renderDataTable( + 'Partidos com Maiores Despesas', + statisticsData.partiesWithMostExpenses, + 'party' + )} + {renderDataTable( + 'Partidos com Maiores Receitas', + statisticsData.partiesWithMostRevenue, + 'party' + )} +
+ + {/* Fourth Row - States */} +
+ {renderDataTable( + 'UFs com Maiores Bens', + statisticsData.statesWithMostAssets, + 'state' + )} + {renderDataTable( + 'UFs com Maiores Despesas', + statisticsData.statesWithMostExpenses, + 'state' + )} + {renderDataTable( + 'UFs com Maiores Receitas', + statisticsData.statesWithMostRevenue, + 'state' + )} +
+
+
+ ); +}; + +export default StatisticsGraphs; diff --git a/src/components/StatisticsPage/StatisticsPage.tsx b/src/components/StatisticsPage/StatisticsPage.tsx new file mode 100644 index 0000000..add7971 --- /dev/null +++ b/src/components/StatisticsPage/StatisticsPage.tsx @@ -0,0 +1,101 @@ +import React, { useState, useEffect } from 'react'; +import GlassCard from '../../shared/GlassCard'; +import StatisticsFilters from './StatisticsFilters'; +import StatisticsGraphs from './StatisticsGraphs'; +import { fetchAllStatisticsData, type StatisticsData, type StatisticsRequestOptions } from './statisticsRequests'; +import type { CargoFilter } from '../../api/apiStatisticsModels'; + +export interface FilterState { + type: 'bem' | 'despesa' | 'receita'; + groupBy: 'candidato' | 'partido' | 'uf' | 'cargo'; + partido?: string | null; + uf?: string | null; + ano?: number | null; + cargo?: CargoFilter; +} + +const StatisticsPage: React.FC = () => { + const [filters, setFilters] = useState({ + type: 'bem', + groupBy: 'candidato', + }); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [statisticsData, setStatisticsData] = useState(null); + + // Load statistics data when component mounts or filters change + useEffect(() => { + const loadStatisticsData = async () => { + try { + setIsLoading(true); + setError(null); + + const options: StatisticsRequestOptions = { + filters: { + partido: filters.partido, + uf: filters.uf, + ano: filters.ano, + cargo: filters.cargo, + }, + }; + + const data = await fetchAllStatisticsData(options); + setStatisticsData(data); + } catch (err) { + setError('Erro ao carregar dados estatísticos'); + console.error('Error loading statistics data:', err); + } finally { + setIsLoading(false); + } + }; + + loadStatisticsData(); + }, [filters]); + + const handleFiltersChange = (newFilters: FilterState) => { + setFilters(newFilters); + }; + + return ( +
+
+
+

+ Estatísticas +

+

+ Análise de dados e estatísticas dos candidatos e partidos brasileiros +

+
+
+ +
+ {/* Left Sidebar - Filters (20% width) */} +
+ + + +
+ + {/* Right Content - Graphs (80% width) */} +
+ +
+
+
+
+ ); +}; + +export default StatisticsPage; diff --git a/src/components/StatisticsPage/index.ts b/src/components/StatisticsPage/index.ts new file mode 100644 index 0000000..13080f5 --- /dev/null +++ b/src/components/StatisticsPage/index.ts @@ -0,0 +1,3 @@ +export { default as StatisticsFilters } from './StatisticsFilters'; +export { default as StatisticsGraphs } from './StatisticsGraphs'; +export { default } from './StatisticsPage'; diff --git a/src/components/StatisticsPage/statisticsRequests.ts b/src/components/StatisticsPage/statisticsRequests.ts new file mode 100644 index 0000000..b100ef4 --- /dev/null +++ b/src/components/StatisticsPage/statisticsRequests.ts @@ -0,0 +1,213 @@ + +import { openCandApi } from '../../api/openCandApi'; +import type { EnrichmentResponse, ValueSumRequest, ValueSumResponse, CargoFilter } from '../../api/apiStatisticsModels'; + +// Statistics data interfaces +export interface StatisticsData { + // First Row + candidatesWithMostAssets: ValueSumResponse[]; + enrichmentData: EnrichmentResponse[] | null; + + // Second Row + candidatesWithMostRevenue: ValueSumResponse[]; + candidatesWithMostExpenses: ValueSumResponse[]; + + // Third Row + partiesWithMostAssets: ValueSumResponse[]; + partiesWithMostExpenses: ValueSumResponse[]; + partiesWithMostRevenue: ValueSumResponse[]; + + // Fourth Row + statesWithMostAssets: ValueSumResponse[]; + statesWithMostExpenses: ValueSumResponse[]; + statesWithMostRevenue: ValueSumResponse[]; +} + +export interface StatisticsRequestOptions { + filters?: { + partido?: string | null; + uf?: string | null; + ano?: number | null; + cargo?: CargoFilter; + }; +} + +// First Row Requests +export async function getCandidatesWithMostAssets(options?: StatisticsRequestOptions): Promise { + const request: ValueSumRequest = { + type: "bem", + groupBy: "candidato", + filter: options?.filters + }; + + const response = await openCandApi.getStatisticsValueSum(request); + return Array.isArray(response) ? response : [response]; +} + +export async function getEnrichmentData(): Promise { + try { + return await openCandApi.getStatisticsEnrichment(); + } catch (error) { + console.error('Error fetching enrichment data:', error); + return null; + } +} + +// Second Row Requests +export async function getCandidatesWithMostRevenue(options?: StatisticsRequestOptions): Promise { + const request: ValueSumRequest = { + type: "receita", + groupBy: "candidato", + filter: options?.filters + }; + + const response = await openCandApi.getStatisticsValueSum(request); + return Array.isArray(response) ? response : [response]; +} + +export async function getCandidatesWithMostExpenses(options?: StatisticsRequestOptions): Promise { + const request: ValueSumRequest = { + type: "despesa", + groupBy: "candidato", + filter: options?.filters + }; + + const response = await openCandApi.getStatisticsValueSum(request); + return Array.isArray(response) ? response : [response]; +} + +// Third Row Requests +export async function getPartiesWithMostAssets(options?: StatisticsRequestOptions): Promise { + const request: ValueSumRequest = { + type: "bem", + groupBy: "partido", + filter: options?.filters + }; + + const response = await openCandApi.getStatisticsValueSum(request); + return Array.isArray(response) ? response : [response]; +} + +export async function getPartiesWithMostExpenses(options?: StatisticsRequestOptions): Promise { + const request: ValueSumRequest = { + type: "despesa", + groupBy: "partido", + filter: options?.filters + }; + + const response = await openCandApi.getStatisticsValueSum(request); + return Array.isArray(response) ? response : [response]; +} + +export async function getPartiesWithMostRevenue(options?: StatisticsRequestOptions): Promise { + const request: ValueSumRequest = { + type: "receita", + groupBy: "partido", + filter: options?.filters + }; + + const response = await openCandApi.getStatisticsValueSum(request); + return Array.isArray(response) ? response : [response]; +} + +// Fourth Row Requests +export async function getStatesWithMostAssets(options?: StatisticsRequestOptions): Promise { + const request: ValueSumRequest = { + type: "bem", + groupBy: "uf", + filter: options?.filters + }; + + const response = await openCandApi.getStatisticsValueSum(request); + return Array.isArray(response) ? response : [response]; +} + +export async function getStatesWithMostExpenses(options?: StatisticsRequestOptions): Promise { + const request: ValueSumRequest = { + type: "despesa", + groupBy: "uf", + filter: options?.filters + }; + + const response = await openCandApi.getStatisticsValueSum(request); + return Array.isArray(response) ? response : [response]; +} + +export async function getStatesWithMostRevenue(options?: StatisticsRequestOptions): Promise { + const request: ValueSumRequest = { + type: "receita", + groupBy: "uf", + filter: options?.filters + }; + + const response = await openCandApi.getStatisticsValueSum(request); + return Array.isArray(response) ? response : [response]; +} + +// Main function to fetch all statistics data +export async function fetchAllStatisticsData(options?: StatisticsRequestOptions): Promise { + try { + const [ + // First Row + candidatesWithMostAssets, + enrichmentData, + + // Second Row + candidatesWithMostRevenue, + candidatesWithMostExpenses, + + // Third Row + partiesWithMostAssets, + partiesWithMostExpenses, + partiesWithMostRevenue, + + // Fourth Row + statesWithMostAssets, + statesWithMostExpenses, + statesWithMostRevenue + ] = await Promise.all([ + getCandidatesWithMostAssets(options), + getEnrichmentData(), + getCandidatesWithMostRevenue(options), + getCandidatesWithMostExpenses(options), + getPartiesWithMostAssets(options), + getPartiesWithMostExpenses(options), + getPartiesWithMostRevenue(options), + getStatesWithMostAssets(options), + getStatesWithMostExpenses(options), + getStatesWithMostRevenue(options) + ]); + + return { + candidatesWithMostAssets, + enrichmentData, + candidatesWithMostRevenue, + candidatesWithMostExpenses, + partiesWithMostAssets, + partiesWithMostExpenses, + partiesWithMostRevenue, + statesWithMostAssets, + statesWithMostExpenses, + statesWithMostRevenue + }; + } catch (error) { + console.error('Error fetching statistics data:', error); + throw error; + } +} + +// Helper function to fetch data for specific category +export async function fetchStatisticsByCategory( + type: 'bem' | 'despesa' | 'receita', + groupBy: 'candidato' | 'partido' | 'uf' | 'cargo', + options?: StatisticsRequestOptions +): Promise { + const request: ValueSumRequest = { + type, + groupBy, + filter: options?.filters + }; + + const response = await openCandApi.getStatisticsValueSum(request); + return Array.isArray(response) ? response : [response]; +} \ No newline at end of file diff --git a/src/index.css b/src/index.css index 09252ee..334f324 100644 --- a/src/index.css +++ b/src/index.css @@ -3,6 +3,8 @@ @tailwind components; @tailwind utilities; +@plugin 'tailwind-scrollbar'; + :root { font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; line-height: 1.5; diff --git a/src/shared/GlassCard.tsx b/src/shared/GlassCard.tsx new file mode 100644 index 0000000..f58eb79 --- /dev/null +++ b/src/shared/GlassCard.tsx @@ -0,0 +1,30 @@ +import React from 'react'; + +interface GlassCardProps { + children: React.ReactNode; + className?: string; + fullHeight?: boolean; + padding?: string; +} + +const GlassCard: React.FC = ({ + children, + className = '', + fullHeight = false, + padding = 'p-6', +}) => { + return ( +
+
+ {children} +
+
+ ); +}; + +export default GlassCard;