Compare commits

...

23 Commits

Author SHA1 Message Date
720596f6f8 adding dynamic hero text header
All checks were successful
Frontend Build and Deploy / build (push) Successful in 44s
2025-09-12 21:36:04 -03:00
dd2af50722 scroll not working for recursos 2025-09-12 21:30:30 -03:00
f56b030435 removing firebase hard-dependency 2025-09-12 21:27:05 -03:00
4e12ab32e8 add filters to statistics 2025-09-12 21:26:58 -03:00
8090fd13a3 add Firebase integration and configuration
All checks were successful
Frontend Build and Deploy / build (push) Successful in 32s
2025-07-11 22:32:45 -03:00
3084a8e27d small fix
All checks were successful
Frontend Build and Deploy / build (push) Successful in 1m4s
2025-06-30 09:37:23 -03:00
3e8ce05f79 refactors
All checks were successful
Frontend Build and Deploy / build (push) Successful in 27s
2025-06-20 20:00:15 -03:00
a1d3add884 fix disabled
All checks were successful
Frontend Build and Deploy / build (push) Successful in 28s
2025-06-19 21:43:23 -03:00
46e76acad3 tech stats + db dump
Some checks failed
Frontend Build and Deploy / build (push) Failing after 57s
2025-06-19 21:40:38 -03:00
ceaffc088e adding logos e melhorias 2025-06-19 20:33:02 -03:00
af7bd64617 solving scrollbar missing "issue" 2025-06-19 20:07:20 -03:00
c347d5ce24 cpf masking 2025-06-19 19:55:35 -03:00
7acbc48f43 mais melhorias gerais 2025-06-19 18:10:49 -03:00
7512f42e2f pequenas mudanças 2025-06-19 09:58:58 -03:00
885b8a6599 aumentando timeout para endpoints de estatistica
All checks were successful
Frontend Build and Deploy / build (push) Successful in 1m46s
2025-06-18 19:17:14 -03:00
2e94f46b5b small improvements
All checks were successful
Frontend Build and Deploy / build (push) Successful in 22s
2025-06-13 20:21:57 -03:00
02e1663594 fix
All checks were successful
Frontend Build and Deploy / build (push) Successful in 23s
2025-06-12 21:02:11 -03:00
748f981e8f melhorias no design
Some checks failed
Frontend Build and Deploy / build (push) Failing after 17s
2025-06-12 21:00:20 -03:00
8cf184fa46 new estatistica page 2025-06-12 18:11:49 -03:00
2764dbdc4e add random candidato
All checks were successful
Frontend Build and Deploy / build (push) Successful in 1m2s
2025-06-10 20:39:19 -03:00
1e4f288ec4 várias mudanças
All checks were successful
Frontend Build and Deploy / build (push) Successful in 1m2s
2025-06-10 20:16:03 -03:00
add1db4376 consertando problema com as pastas
All checks were successful
Frontend Build and Deploy / build (push) Successful in 22s
2025-06-10 13:24:52 -03:00
b141218adf melhorias no design 2025-06-10 13:19:00 -03:00
50 changed files with 3321 additions and 295 deletions

View File

@@ -1,3 +1,11 @@
# OpenCand API Configuration # OpenCand API Configuration
# The base URL for the OpenCand API # The base URL for the OpenCand API
VITE_API_BASE_URL=https://api.example.com VITE_API_BASE_URL=https://api.example.com
VITE_FIREBASE_API_KEY="your_api_key"
VITE_FIREBASE_AUTH_DOMAIN="your_auth_domain"
VITE_FIREBASE_PROJECT_ID="your_project_id"
VITE_FIREBASE_STORAGE_BUCKET="your_storage_bucket"
VITE_FIREBASE_MESSAGING_SENDER_ID="your_messaging_sender_id"
VITE_FIREBASE_APP_ID="your_app_id"
VITE_FIREBASE_MEASUREMENT_ID="your_measurement_id"

View File

@@ -32,9 +32,15 @@ jobs:
docker build \ docker build \
--build-arg VITE_API_BASE_URL="https://api.opencand.ivanch.me" \ --build-arg VITE_API_BASE_URL="https://api.opencand.ivanch.me" \
--build-arg VITE_FIREBASE_API_KEY="${{ secrets.VITE_FIREBASE_API_KEY }}" \
--build-arg VITE_FIREBASE_AUTH_DOMAIN="${{ secrets.VITE_FIREBASE_AUTH_DOMAIN }}" \
--build-arg VITE_FIREBASE_PROJECT_ID="${{ secrets.VITE_FIREBASE_PROJECT_ID }}" \
--build-arg VITE_FIREBASE_STORAGE_BUCKET="${{ secrets.VITE_FIREBASE_STORAGE_BUCKET }}" \
--build-arg VITE_FIREBASE_MESSAGING_SENDER_ID="${{ secrets.VITE_FIREBASE_MESSAGING_SENDER_ID }}" \
--build-arg VITE_FIREBASE_APP_ID="${{ secrets.VITE_FIREBASE_APP_ID }}" \
--build-arg VITE_FIREBASE_MEASUREMENT_ID="${{ secrets.VITE_FIREBASE_MEASUREMENT_ID }}" \
--file OpenCand.UI.dockerfile \ --file OpenCand.UI.dockerfile \
-t "${{ env.IMAGE_FRONTEND }}:${TAG}" \ -t "${{ env.IMAGE_FRONTEND }}:${TAG}" .
.
docker push "${{ env.IMAGE_FRONTEND }}:${TAG}" docker push "${{ env.IMAGE_FRONTEND }}:${TAG}"

View File

@@ -11,6 +11,21 @@ COPY . .
ARG VITE_API_BASE_URL ARG VITE_API_BASE_URL
ENV VITE_API_BASE_URL=${VITE_API_BASE_URL} ENV VITE_API_BASE_URL=${VITE_API_BASE_URL}
ARG VITE_FIREBASE_API_KEY
ENV VITE_FIREBASE_API_KEY=${VITE_FIREBASE_API_KEY}
ARG VITE_FIREBASE_AUTH_DOMAIN
ENV VITE_FIREBASE_AUTH_DOMAIN=${VITE_FIREBASE_AUTH_DOMAIN}
ARG VITE_FIREBASE_PROJECT_ID
ENV VITE_FIREBASE_PROJECT_ID=${VITE_FIREBASE_PROJECT_ID}
ARG VITE_FIREBASE_STORAGE_BUCKET
ENV VITE_FIREBASE_STORAGE_BUCKET=${VITE_FIREBASE_STORAGE_BUCKET}
ARG VITE_FIREBASE_MESSAGING_SENDER_ID
ENV VITE_FIREBASE_MESSAGING_SENDER_ID=${VITE_FIREBASE_MESSAGING_SENDER_ID}
ARG VITE_FIREBASE_APP_ID
ENV VITE_FIREBASE_APP_ID=${VITE_FIREBASE_APP_ID}
ARG VITE_FIREBASE_MEASUREMENT_ID
ENV VITE_FIREBASE_MEASUREMENT_ID=${VITE_FIREBASE_MEASUREMENT_ID}
RUN yarn build RUN yarn build
# ─── Stage 2: Serve ──────────────────────────────────────────────────────── # ─── Stage 2: Serve ────────────────────────────────────────────────────────
@@ -20,6 +35,7 @@ RUN rm -rf /usr/share/nginx/html/*
# Replace default nginx.conf # Replace default nginx.conf
COPY nginx.conf /etc/nginx/nginx.conf COPY nginx.conf /etc/nginx/nginx.conf
# Copy our built files into nginxs html folder # Copy our built files into nginxs html folder
COPY ./public/assets /usr/share/nginx/html
COPY --from=builder /app/dist /usr/share/nginx/html COPY --from=builder /app/dist /usr/share/nginx/html
# (Optional) If you need any custom nginx.conf, COPY it here— # (Optional) If you need any custom nginx.conf, COPY it here—

View File

@@ -3,6 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<title>OpenCand</title> <title>OpenCand</title>
</head> </head>
<body> <body>

996
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -12,6 +12,7 @@
"dependencies": { "dependencies": {
"@tailwindcss/postcss": "^4.1.8", "@tailwindcss/postcss": "^4.1.8",
"@tailwindcss/vite": "^4.1.8", "@tailwindcss/vite": "^4.1.8",
"firebase": "^11.10.0",
"postcss": "^8.5.4", "postcss": "^8.5.4",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",

Binary file not shown.

After

Width:  |  Height:  |  Size: 352 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@@ -13,7 +13,8 @@ body, html {
padding: 0; padding: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
overflow-x: hidden; scrollbar-width: thin;
scrollbar-color: rgba(156, 163, 175, 0.5) transparent;
} }
/* Custom animations */ /* Custom animations */
@@ -26,6 +27,51 @@ body, html {
animation: fadeIn 0.3s ease-in-out forwards; animation: fadeIn 0.3s ease-in-out forwards;
} }
/* New animations for DataStatsPage */
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slide-up {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes fade-in-up {
from { opacity: 0; transform: translateY(30px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes slide-in-left {
from { opacity: 0; transform: translateX(-20px); }
to { opacity: 1; transform: translateX(0); }
}
.animate-fade-in {
animation: fade-in 0.8s ease-out forwards;
}
.animate-slide-up {
animation: slide-up 0.6s ease-out forwards;
}
.animate-fade-in-up {
animation: fade-in-up 0.8s ease-out forwards;
}
.animate-slide-in-left {
animation: slide-in-left 0.6s ease-out forwards;
}
.delay-100 {
animation-delay: 0.1s;
}
.delay-200 {
animation-delay: 0.2s;
}
/* Custom scrollbar for search results */ /* Custom scrollbar for search results */
.custom-scrollbar::-webkit-scrollbar { .custom-scrollbar::-webkit-scrollbar {
width: 6px; width: 6px;
@@ -44,3 +90,58 @@ body, html {
.custom-scrollbar::-webkit-scrollbar-thumb:hover { .custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: #555; background: #555;
} }
.scrollbar-hide {
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* Internet Explorer and Edge */
overflow: -moz-scrollbars-none; /* Old versions of Firefox */
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
/* Custom minimal scrollbar styles */
.custom-scrollbar {
scrollbar-width: thin;
scrollbar-color: rgba(156, 163, 175, 0.5) transparent;
}
.custom-scrollbar::-webkit-scrollbar {
width: 1px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background-color: rgba(156, 163, 175, 0.5);
border-radius: 2px;
transition: background-color 0.2s ease;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background-color: rgba(107, 114, 128, 0.7);
}
.custom-scrollbar::-webkit-scrollbar-corner {
background: transparent;
}
/* Custom fade-in animation for dropdown content */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fadeIn {
animation: fadeIn 0.3s ease-out forwards;
}

View File

@@ -1,24 +1,55 @@
import React from 'react'; import React, { useEffect } from 'react';
import { Routes, Route } from 'react-router-dom'; import { Routes, Route, useLocation } from 'react-router-dom';
import Navbar from './components/Navbar'; import Navbar from './components/Navbar';
import HeroSection from './components/HeroSection'; import HeroSection from './components/HeroSection';
import StatisticsSection from './components/StatisticsSection'; import StatisticsSection from './components/StatisticsSection';
import FeaturesSection from './components/FeaturesSection'; import FeaturesSection from './components/FeaturesSection';
import Footer from './components/Footer'; import Footer from './components/Footer';
import CandidatePage from './components/CandidatePage/CandidatePage'; import CandidatePage from './components/CandidatePage/CandidatePage';
import DataStatsPage from './components/DataStatsPage';
import StatisticsPage from './components/StatisticsPage';
import SobrePage from './components/SobrePage';
import NotFound from './components/NotFound';
import MatrixBackground from './components/MatrixBackground'; import MatrixBackground from './components/MatrixBackground';
import './App.css'; import './App.css';
// HomePage component // HomePage component
const HomePage: React.FC = () => ( const HomePage: React.FC = () => {
<main className="flex-grow"> const location = useLocation();
<HeroSection />
<StatisticsSection /> useEffect(() => {
<FeaturesSection /> if (location.hash) {
</main> const element = document.getElementById(location.hash.substring(1));
); if (element) {
element.scrollIntoView({ behavior: 'smooth' });
}
}
}, [location]);
return (
<main className="flex-grow">
<HeroSection />
<div className="w-32 h-1 bg-gradient-to-r from-indigo-400 to-purple-400 mx-auto mt-12 rounded-full"></div>
<StatisticsSection />
<div className="w-32 h-1 bg-gradient-to-r from-indigo-400 to-purple-400 mx-auto mt-6 rounded-full"></div>
<FeaturesSection />
</main>
);
};
function App() { function App() {
const location = useLocation();
useEffect(() => {
const segment = location.hash;
if (segment) {
const element = document.getElementById(segment.replace('#', ''));
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}
}, [location]);
return ( return (
<div className="min-h-screen w-full flex flex-col relative" style={{ backgroundColor: 'transparent' }}> <div className="min-h-screen w-full flex flex-col relative" style={{ backgroundColor: 'transparent' }}>
<MatrixBackground /> <MatrixBackground />
@@ -28,6 +59,10 @@ function App() {
<Routes> <Routes>
<Route path="/" element={<HomePage />} /> <Route path="/" element={<HomePage />} />
<Route path="/candidato/:id" element={<CandidatePage />} /> <Route path="/candidato/:id" element={<CandidatePage />} />
<Route path="/dados-disponiveis" element={<DataStatsPage />} />
<Route path="/estatisticas" element={<StatisticsPage />} />
<Route path="/sobre" element={<SobrePage />} />
<Route path="*" element={<NotFound />} />
</Routes> </Routes>
</div> </div>
<Footer /> <Footer />

View File

@@ -3,21 +3,25 @@ export interface CandidateSearchResult {
candidatos: Candidate[]; candidatos: Candidate[];
} }
export interface RandomCandidate {
idCandidato: string;
}
export interface Candidate { export interface Candidate {
idCandidato: string; idCandidato: string;
nome: string; nome: string;
cpf: string; cpf: string;
dataNascimento: string; dataNascimento: string;
email: string;
estadoCivil: string;
sexo: string; sexo: string;
ocupacao: string;
apelido: string; apelido: string;
fotoUrl: string; fotoUrl: string;
ultimoano: number;
localidade: string;
} }
export interface CandidateDetails extends Candidate { export interface CandidateDetails extends Candidate {
eleicoes: Election[]; eleicoes: Election[];
candidatoExt: CandidateExt[];
} }
export interface Election { export interface Election {
@@ -33,6 +37,15 @@ export interface Election {
partido: Partido; partido: Partido;
} }
export interface CandidateExt {
ano: number;
apelido: string;
email: string;
estadoCivil: string;
escolaridade: string;
ocupacao: string;
}
export interface CandidateAssets { export interface CandidateAssets {
bens: Asset[]; bens: Asset[];
} }
@@ -124,3 +137,30 @@ export interface Income {
valor: number; valor: number;
} }
export interface OpenCandDataAvailabilityStats {
candidatos: number[];
bemCandidatos: number[];
despesaCandidatos: number[];
receitaCandidatos: number[];
redeSocialCandidatos: number[];
fotosCandidatos: number[];
}
export interface OpenCandDatabaseStats {
tables: {
name: string;
totalSize: number; // in bytes
entries: number; // number of rows
}[];
materializedViews: {
name: string;
totalSize: number; // in bytes
entries: number; // number of rows
}[];
indexes: {
amount: number; // number of indexes
size: number; // total size of indexes in bytes
};
totalSize: number; // total size of the database in bytes
totalEntries: number; // total number of entries across all tables
}

View File

@@ -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;
}

View File

@@ -1,6 +1,8 @@
import { BaseApiClient } from './base'; import { BaseApiClient } from './base';
import { API_CONFIG } from '../config/api'; import { API_CONFIG } from '../config/api';
import type { CandidateAssets, CandidateDetails, CandidateExpenses, CandidateIncome, CandidateRedesSociais, CandidateSearchResult, CpfRevealResult, PlatformStats } from './apiModels'; import type { CandidateAssets, CandidateDetails, CandidateExpenses, CandidateIncome, CandidateRedesSociais, CandidateSearchResult, CpfRevealResult, OpenCandDataAvailabilityStats, OpenCandDatabaseStats, PlatformStats, RandomCandidate } from './apiModels';
import type { EnrichmentResponse, StatisticsConfig, ValueSumRequest, ValueSumResponse } from './apiStatisticsModels';
import type { StatisticsRequestFilters, StatisticsRequestOptions } from '../components/StatisticsPage/statisticsRequests';
/** /**
* OpenCand API client for interacting with the OpenCand platform * OpenCand API client for interacting with the OpenCand platform
@@ -18,6 +20,21 @@ export class OpenCandApi extends BaseApiClient {
return this.get<PlatformStats>('/v1/stats'); return this.get<PlatformStats>('/v1/stats');
} }
/**
* Get platform data stats
* GET /v1/stats
*/
async getDataAvailabilityStats(): Promise<OpenCandDataAvailabilityStats> {
return this.get<OpenCandDataAvailabilityStats>('/v1/stats/data-availability');
}
/**
* Get the database tech stats
*/
async getDatabaseTechStats(): Promise<OpenCandDatabaseStats> {
return this.get<OpenCandDatabaseStats>(`/v1/stats/tech`);
}
/** /**
* Search for candidates by name or other attributes * Search for candidates by name or other attributes
*/ */
@@ -26,6 +43,13 @@ export class OpenCandApi extends BaseApiClient {
return this.get<CandidateSearchResult>(`/v1/candidato/search?q=${encodedQuery}`); return this.get<CandidateSearchResult>(`/v1/candidato/search?q=${encodedQuery}`);
} }
/**
* Get a random candidate from the database
*/
async getRandomCandidate(): Promise<RandomCandidate> {
return this.get<RandomCandidate>(`/v1/candidato/random`);
}
/** /**
* Get detailed information about a specific candidate by ID * Get detailed information about a specific candidate by ID
*/ */
@@ -67,6 +91,47 @@ export class OpenCandApi extends BaseApiClient {
async getCandidateReceitas(id: string): Promise<CandidateIncome> { async getCandidateReceitas(id: string): Promise<CandidateIncome> {
return this.get<CandidateIncome>(`/v1/candidato/${id}/receitas`); return this.get<CandidateIncome>(`/v1/candidato/${id}/receitas`);
} }
/**
* Get the configuration for statistics filters
*/
async getStatisticsConfig(): Promise<StatisticsConfig> {
return this.get<StatisticsConfig>(`/v1/estatistica/configuration`);
}
/**
* Get the enrichment statistics for candidates
*/
async getStatisticsEnrichment(filters?: StatisticsRequestFilters): Promise<EnrichmentResponse[]> {
let url = `/v1/estatistica/enriquecimento`;
if (filters) {
const params = new URLSearchParams();
if (filters.partido !== null && filters.partido !== undefined) {
params.append('partido', filters.partido);
}
if (filters.uf !== null && filters.uf !== undefined) {
params.append('uf', filters.uf);
}
if (filters.ano !== null && filters.ano !== undefined) {
params.append('ano', String(filters.ano));
}
if (filters.cargo !== null && filters.cargo !== undefined) {
params.append('cargo', filters.cargo);
}
const queryString = params.toString();
if (queryString) {
url += `?${queryString}`;
}
}
return this.get<EnrichmentResponse[]>(url, { timeout: 90000 });
}
/**
* Get the sum of values for a specific type and grouping
*/
async getStatisticsValueSum(request: ValueSumRequest): Promise<ValueSumResponse> {
return this.post<ValueSumResponse>(`/v1/estatistica/values-sum`, request, { timeout: 90000 });
}
} }
// Create a default instance for easy usage with proper configuration // Create a default instance for easy usage with proper configuration

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react'; import React, { use, useState } from 'react';
import { UserIcon } from '@heroicons/react/24/outline'; import { UserIcon } from '@heroicons/react/24/outline';
import { type CandidateDetails, openCandApi } from '../../api'; import { type CandidateDetails, openCandApi } from '../../api';
import { formatCpf, maskCpf } from '../../utils/utils'; import { formatCpf, maskCpf } from '../../utils/utils';
@@ -15,6 +15,12 @@ const BasicCandidateInfoComponent: React.FC<BasicCandidateInfoComponentProps> =
const [revealedCpf, setRevealedCpf] = useState<string | null>(null); const [revealedCpf, setRevealedCpf] = useState<string | null>(null);
const [isRevealingCpf, setIsRevealingCpf] = useState(false); const [isRevealingCpf, setIsRevealingCpf] = useState(false);
React.useEffect(() => {
if (candidateDetails) {
setRevealedCpf(null);
}
}, [candidateDetails]);
const formatDate = (dateString: string) => { const formatDate = (dateString: string) => {
try { try {
const date = new Date(dateString); const date = new Date(dateString);
@@ -38,6 +44,82 @@ const BasicCandidateInfoComponent: React.FC<BasicCandidateInfoComponentProps> =
} }
}; };
const getDetailsList = () => {
if (!candidateDetails) return null;
}
const getHistoricalData = () => {
if (!candidateDetails?.candidatoExt || candidateDetails.candidatoExt.length === 0) {
return null;
}
const historicalData = {
emails: new Map<string, number[]>(),
estadosCivis: new Map<string, number[]>(),
escolaridades: new Map<string, number[]>(),
ocupacoes: new Map<string, number[]>()
};
// Group data by value and collect years
candidateDetails.candidatoExt.forEach(ext => {
if (ext.email) {
const years = historicalData.emails.get(ext.email) || [];
years.push(ext.ano);
historicalData.emails.set(ext.email, years);
}
if (ext.estadoCivil) {
const years = historicalData.estadosCivis.get(ext.estadoCivil) || [];
years.push(ext.ano);
historicalData.estadosCivis.set(ext.estadoCivil, years);
}
if (ext.escolaridade) {
const years = historicalData.escolaridades.get(ext.escolaridade) || [];
years.push(ext.ano);
historicalData.escolaridades.set(ext.escolaridade, years);
}
if (ext.ocupacao) {
const years = historicalData.ocupacoes.get(ext.ocupacao) || [];
years.push(ext.ano);
historicalData.ocupacoes.set(ext.ocupacao, years);
}
});
// Sort years for each entry
historicalData.emails.forEach((years, key) => {
historicalData.emails.set(key, years.sort((a, b) => b - a));
});
historicalData.estadosCivis.forEach((years, key) => {
historicalData.estadosCivis.set(key, years.sort((a, b) => b - a));
});
historicalData.escolaridades.forEach((years, key) => {
historicalData.escolaridades.set(key, years.sort((a, b) => b - a));
});
historicalData.ocupacoes.forEach((years, key) => {
historicalData.ocupacoes.set(key, years.sort((a, b) => b - a));
});
return historicalData;
};
const renderHistoricalField = (title: string, dataMap: Map<string, number[]>) => {
if (dataMap.size === 0) return null;
return (
<div>
<label className="text-sm font-semibold text-gray-600 uppercase tracking-wide">{title}</label>
<div className="mt-1 space-y-1">
{Array.from(dataMap.entries()).map(([value, years], index) => (
<div key={index} className="text-center">
<p className="text-lg text-gray-900">
{value} <span className="text-xs text-gray-500 ml-2">{years.join(', ')}</span>
</p>
</div>
))}
</div>
</div>
);
};
return ( return (
<div className="bg-white/95 backdrop-blur-sm rounded-xl shadow-lg hover:shadow-xl transform hover:scale-[1.01] transition-all duration-200 p-6"> <div className="bg-white/95 backdrop-blur-sm rounded-xl shadow-lg hover:shadow-xl transform hover:scale-[1.01] transition-all duration-200 p-6">
<div className="flex items-center mb-6"> <div className="flex items-center mb-6">
@@ -58,7 +140,7 @@ const BasicCandidateInfoComponent: React.FC<BasicCandidateInfoComponentProps> =
<img <img
src={candidateDetails.fotoUrl} src={candidateDetails.fotoUrl}
alt={`Foto de ${candidateDetails.nome}`} alt={`Foto de ${candidateDetails.nome}`}
className="w-32 h-32 rounded-full object-cover border-4 border-blue-200 shadow-lg" className="w-36 h-36 rounded-full object-cover border-4 border-blue-200 shadow-lg"
onError={(e) => { onError={(e) => {
const target = e.target as HTMLImageElement; const target = e.target as HTMLImageElement;
target.style.display = 'none'; target.style.display = 'none';
@@ -101,27 +183,27 @@ const BasicCandidateInfoComponent: React.FC<BasicCandidateInfoComponentProps> =
<p className="text-lg text-gray-900 mt-1">{formatDate(candidateDetails.dataNascimento)}</p> <p className="text-lg text-gray-900 mt-1">{formatDate(candidateDetails.dataNascimento)}</p>
</div> </div>
{candidateDetails.email && (
<div>
<label className="text-sm font-semibold text-gray-600 uppercase tracking-wide">Email</label>
<p className="text-lg text-gray-900 mt-1">{candidateDetails.email}</p>
</div>
)}
<div>
<label className="text-sm font-semibold text-gray-600 uppercase tracking-wide">Estado Civil</label>
<p className="text-lg text-gray-900 mt-1">{candidateDetails.estadoCivil}</p>
</div>
<div> <div>
<label className="text-sm font-semibold text-gray-600 uppercase tracking-wide">Sexo</label> <label className="text-sm font-semibold text-gray-600 uppercase tracking-wide">Sexo</label>
<p className="text-lg text-gray-900 mt-1">{candidateDetails.sexo}</p> <p className="text-lg text-gray-900 mt-1">{candidateDetails.sexo}</p>
</div> </div>
<div> {/* Historical Data Section */}
<label className="text-sm font-semibold text-gray-600 uppercase tracking-wide">Ocupação</label> {(() => {
<p className="text-lg text-gray-900 mt-1">{candidateDetails.ocupacao}</p> const historicalData = getHistoricalData();
</div> if (!historicalData) return null;
return (
<div className="border-t border-gray-200 pt-4 mt-6">
<div className="space-y-4">
{renderHistoricalField("Email", historicalData.emails)}
{renderHistoricalField("Estado Civil", historicalData.estadosCivis)}
{renderHistoricalField("Escolaridade", historicalData.escolaridades)}
{renderHistoricalField("Ocupação", historicalData.ocupacoes)}
</div>
</div>
);
})()}
</div> </div>
) : ( ) : (
<div className="text-center text-gray-500 py-8"> <div className="text-center text-gray-500 py-8">

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom'; import { useParams, useNavigate, Link } from 'react-router-dom';
import { ArrowLeftIcon } from '@heroicons/react/24/outline'; import { ArrowLeftIcon } from '@heroicons/react/24/outline';
import { openCandApi, type CandidateDetails, type CandidateAssets, type CandidateRedesSociais, type CandidateExpenses, type CandidateIncome, ApiError } from '../../api'; import { openCandApi, type CandidateDetails, type CandidateAssets, type CandidateRedesSociais, type CandidateExpenses, type CandidateIncome, ApiError } from '../../api';
import ElectionsComponent from './ElectionsComponent'; import ElectionsComponent from './ElectionsComponent';
@@ -7,6 +7,9 @@ import AssetsComponent from './AssetsComponent';
import BasicCandidateInfoComponent from './BasicCandidateInfoComponent'; import BasicCandidateInfoComponent from './BasicCandidateInfoComponent';
import SocialMediaComponent from './SocialMediaComponent'; import SocialMediaComponent from './SocialMediaComponent';
import IncomeExpenseComponent from './IncomeExpenseComponent'; import IncomeExpenseComponent from './IncomeExpenseComponent';
import Button from '../../shared/Button';
import RandomCandButton from '../../shared/RandomCandButton';
import ErrorPage from '../ErrorPage';
const CandidatePage: React.FC = () => { const CandidatePage: React.FC = () => {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
@@ -112,18 +115,11 @@ const CandidatePage: React.FC = () => {
if (error) { if (error) {
return ( return (
<main className="flex-grow flex items-center justify-center min-h-screen p-4"> <ErrorPage
<div className="text-center text-white"> title="Erro"
<h1 className="text-2xl font-bold mb-4">Erro</h1> description={error}
<p className="text-red-400 mb-4">{error}</p> helperText="Tente novamente ou volte para a página inicial."
<button />
onClick={() => navigate('/')}
className="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-lg transition-colors"
>
Voltar ao Início
</button>
</div>
</main>
); );
} }
@@ -131,13 +127,21 @@ const CandidatePage: React.FC = () => {
<main className="flex-grow p-6 max-w-7xl mx-auto"> <main className="flex-grow p-6 max-w-7xl mx-auto">
{/* Header with back button */} {/* Header with back button */}
<div className="mb-6"> <div className="mb-6">
<button <div className="flex items-center gap-4 mb-4">
onClick={() => navigate('/')} <Button
className="flex items-center text-white hover:text-gray-300 transition-colors mb-4" onClick={() => navigate('/')}
> className="flex items-center text-white"
<ArrowLeftIcon className="h-5 w-5 mr-2" /> hasAnimation={false}
Voltar à busca >
</button> <ArrowLeftIcon className="h-5 w-5 mr-2" />
Voltar à busca
</Button>
<RandomCandButton
className="flex items-center text-white"
hasAnimation={false}
/>
</div>
{candidateDetails && ( {candidateDetails && (
<h1 className="text-3xl font-bold text-white">{candidateDetails.nome}</h1> <h1 className="text-3xl font-bold text-white">{candidateDetails.nome}</h1>

View File

@@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import { DocumentTextIcon } from '@heroicons/react/24/outline'; import { DocumentTextIcon } from '@heroicons/react/24/outline';
import { type Election } from '../../api'; import { type Election } from '../../api';
import Tooltip from '../Tooltip'; import Tooltip from '../../shared/Tooltip';
interface ElectionsComponentProps { interface ElectionsComponentProps {
elections: Election[] | null; elections: Election[] | null;

View File

@@ -11,12 +11,25 @@ interface IncomeExpenseComponentProps {
isLoadingIncome: boolean; isLoadingIncome: boolean;
} }
const hasIncomeData = (income: CandidateIncome | null) => {
if (!income || income.receitas.length === 0) return false;
return true;
};
const hasExpenseData = (expenses: CandidateExpenses | null) => {
if (!expenses || expenses.despesas.length === 0) return false;
return true;
};
const IncomeExpenseComponent: React.FC<IncomeExpenseComponentProps> = ({ const IncomeExpenseComponent: React.FC<IncomeExpenseComponentProps> = ({
expenses, expenses,
income, income,
isLoadingExpenses, isLoadingExpenses,
isLoadingIncome isLoadingIncome
}) => { }) => {
const showIncome = hasIncomeData(income);
const showExpenses = hasExpenseData(expenses);
if (!showIncome && !showExpenses) return null;
return ( return (
<div className="bg-white/95 backdrop-blur-sm rounded-xl shadow-lg hover:shadow-xl transform hover:scale-[1.01] transition-all duration-200 p-6"> <div className="bg-white/95 backdrop-blur-sm rounded-xl shadow-lg hover:shadow-xl transform hover:scale-[1.01] transition-all duration-200 p-6">
<div className="flex items-center mb-6"> <div className="flex items-center mb-6">
@@ -25,25 +38,31 @@ const IncomeExpenseComponent: React.FC<IncomeExpenseComponentProps> = ({
</div> </div>
{/* Income Section */} {/* Income Section */}
<IncomeSection {showIncome && (
income={income} <IncomeSection
isLoadingIncome={isLoadingIncome} income={income}
/> isLoadingIncome={isLoadingIncome}
/>
)}
{/* Separator */} {/* Separator */}
<div className="flex items-center my-8"> {showIncome && showExpenses && (
<div className="flex-grow border-t border-gray-300"></div> <div className="flex items-center my-8">
<div className="flex-shrink-0 px-4"> <div className="flex-grow border-t border-gray-300"></div>
<div className="w-3 h-3 bg-gray-300 rounded-full"></div> <div className="flex-shrink-0 px-4">
<div className="w-3 h-3 bg-gray-300 rounded-full"></div>
</div>
<div className="flex-grow border-t border-gray-300"></div>
</div> </div>
<div className="flex-grow border-t border-gray-300"></div> )}
</div>
{/* Expenses Section */} {/* Expenses Section */}
<ExpenseSection {showExpenses && (
expenses={expenses} <ExpenseSection
isLoadingExpenses={isLoadingExpenses} expenses={expenses}
/> isLoadingExpenses={isLoadingExpenses}
/>
)}
</div> </div>
); );
}; };

View File

@@ -21,35 +21,36 @@ interface SocialMediaComponentProps {
} }
// Helper function to get social media icons // Helper function to get social media icons
const getSocialMediaIcon = (rede: string): React.ReactElement => { const getSocialMediaIcon = (rede: string, isRecent: boolean = true): React.ReactElement => {
const iconClass = "h-5 w-5 mr-2"; const iconClass = "h-5 w-5 mr-2";
const opacityClass = isRecent ? "" : "opacity-50";
switch (rede.toLowerCase()) { switch (rede.toLowerCase()) {
case 'facebook': case 'facebook':
return <FaFacebook className={`${iconClass} text-blue-600`} />; return <FaFacebook className={`${iconClass} ${opacityClass} text-blue-600`} />;
case 'instagram': case 'instagram':
return <FaInstagram className={`${iconClass} text-pink-600`} />; return <FaInstagram className={`${iconClass} ${opacityClass} text-pink-600`} />;
case 'x/twitter': case 'x/twitter':
case 'twitter': case 'twitter':
return <FaXTwitter className={`${iconClass} text-black`} />; return <FaXTwitter className={`${iconClass} ${opacityClass} text-black`} />;
case 'tiktok': case 'tiktok':
return <FaTiktok className={`${iconClass} text-black`} />; return <FaTiktok className={`${iconClass} ${opacityClass} text-black`} />;
case 'youtube': case 'youtube':
return <FaYoutube className={`${iconClass} text-red-600`} />; return <FaYoutube className={`${iconClass} ${opacityClass} text-red-600`} />;
case 'linkedin': case 'linkedin':
return <FaLinkedin className={`${iconClass} text-blue-700`} />; return <FaLinkedin className={`${iconClass} ${opacityClass} text-blue-700`} />;
case 'whatsapp': case 'whatsapp':
return <FaWhatsapp className={`${iconClass} text-green-600`} />; return <FaWhatsapp className={`${iconClass} ${opacityClass} text-green-600`} />;
case 'threads': case 'threads':
return <FaThreads className={`${iconClass} text-black`} />; return <FaThreads className={`${iconClass} ${opacityClass} text-black`} />;
case 'telegram': case 'telegram':
return <FaTelegram className={`${iconClass} text-blue-500`} />; return <FaTelegram className={`${iconClass} ${opacityClass} text-blue-500`} />;
case 'spotify': case 'spotify':
return <FaSpotify className={`${iconClass} text-green-500`} />; return <FaSpotify className={`${iconClass} ${opacityClass} text-green-500`} />;
case 'kwai': case 'kwai':
case 'outros': case 'outros':
default: default:
return <FaLink className={`${iconClass} text-gray-600`} />; return <FaLink className={`${iconClass} ${opacityClass} text-gray-600`} />;
} }
}; };
@@ -57,6 +58,11 @@ const SocialMediaComponent: React.FC<SocialMediaComponentProps> = ({
redesSociais, redesSociais,
isLoading isLoading
}) => { }) => {
// Calculate the most recent year from all social media entries
const mostRecentYear = redesSociais && redesSociais.length > 0
? Math.max(...redesSociais.map(rede => rede.ano))
: null;
return ( return (
<div className="bg-white/95 backdrop-blur-sm rounded-xl shadow-lg hover:shadow-xl transform hover:scale-[1.01] transition-all duration-200 p-6"> <div className="bg-white/95 backdrop-blur-sm rounded-xl shadow-lg hover:shadow-xl transform hover:scale-[1.01] transition-all duration-200 p-6">
<div className="flex items-center mb-6"> <div className="flex items-center mb-6">
@@ -70,27 +76,41 @@ const SocialMediaComponent: React.FC<SocialMediaComponentProps> = ({
</div> </div>
) : redesSociais && redesSociais.length > 0 ? ( ) : redesSociais && redesSociais.length > 0 ? (
<div className="space-y-3"> <div className="space-y-3">
{redesSociais.map((redeSocial: RedeSocial, index: number) => ( {redesSociais.map((redeSocial: RedeSocial, index: number) => {
<div key={`${redeSocial.idCandidato}-${redeSocial.rede}-${index}`} className="border border-gray-200 rounded-lg p-3 hover:bg-gray-50 transition-colors"> const isRecent = redeSocial.ano === mostRecentYear;
<div className="flex items-center justify-between"> return (
<div className="flex-1"> <div
<div className="flex items-center mb-2"> key={`${redeSocial.idCandidato}-${redeSocial.rede}-${index}`}
{getSocialMediaIcon(redeSocial.rede)} className={`border border-gray-200 rounded-lg p-3 hover:bg-gray-50 transition-colors ${
<span className="font-semibold text-gray-900 mr-2">{redeSocial.rede}</span> isRecent ? '' : 'opacity-95 hover:bg-gray-100'
<span className="text-sm text-gray-500">({redeSocial.ano})</span> }`}
>
<div className="flex items-center justify-between">
<div className="flex-1">
<div className="flex items-center mb-2">
{getSocialMediaIcon(redeSocial.rede, isRecent)}
<span className={`font-semibold mr-2 ${isRecent ? 'text-gray-900' : 'text-gray-500'}`}>
{redeSocial.rede}
</span>
<span className="text-sm text-gray-500">({redeSocial.ano})</span>
</div>
<a
href={redeSocial.link}
target="_blank"
rel="noopener noreferrer"
className={`text-sm break-all transition-colors ${
isRecent
? 'text-blue-600 hover:text-blue-800'
: 'text-blue-400 hover:text-blue-600'
}`}
>
{redeSocial.link}
</a>
</div> </div>
<a
href={redeSocial.link}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-800 text-sm break-all transition-colors"
>
{redeSocial.link}
</a>
</div> </div>
</div> </div>
</div> );
))} })}
</div> </div>
) : ( ) : (
<div className="text-center text-gray-500 py-8"> <div className="text-center text-gray-500 py-8">

31
src/components/Card.tsx Normal file
View File

@@ -0,0 +1,31 @@
import React from 'react';
interface CardProps {
children: React.ReactNode;
className?: string;
hasAnimation?: boolean;
disableCursor?: boolean;
height?: number;
width?: number;
}
const Card: React.FC<CardProps> = ({ children, className = "", hasAnimation = false, disableCursor = false, height, width }) => {
const animationClasses = hasAnimation ? "hover:shadow-xl transform hover:scale-[1.01] transition-all duration-200 hover:ring-indigo-300" : "";
const cursorClasses = disableCursor ? "hover:cursor-default" : "";
const sizeStyles = {
...(height && { height: `${height}rem` }),
...(width && { width: `${width}rem` })
};
return (
<div
className={`bg-gray-800/30 p-6 rounded-lg backdrop-blur-xs shadow-xl ring-1 ring-gray-700 max-w-md ${animationClasses} ${cursorClasses} ${className}`}
style={sizeStyles}
>
{children}
</div>
);
};
export default Card;

View File

@@ -0,0 +1,80 @@
import React from 'react';
import type { OpenCandDataAvailabilityStats } from '../../api/apiModels';
interface DataAvailabilityTableProps {
stats: OpenCandDataAvailabilityStats;
sortedYears: number[];
}
const dataTypes = [
{ key: 'candidatos', label: 'Candidatos', icon: '👤' },
{ key: 'bemCandidatos', label: 'Bens de Candidatos', icon: '💰' },
{ key: 'despesaCandidatos', label: 'Despesas de Candidatos', icon: '💸' },
{ key: 'receitaCandidatos', label: 'Receitas de Candidatos', icon: '💵' },
{ key: 'redeSocialCandidatos', label: 'Redes Sociais', icon: '📱' },
{ key: 'fotosCandidatos', label: 'Fotos de Candidatos (API)', icon: '📸' },
];
const DataAvailabilityTable: React.FC<DataAvailabilityTableProps> = ({ stats, sortedYears }) => {
return (
<div className="bg-gray-800/10 backdrop-blur-xs rounded-xl shadow-lg hover:shadow-xl transform hover:scale-[1.005] transition-all duration-200 overflow-hidden ring-1 ring-gray-700 hover:ring-indigo-300">
<div className="p-6 border-b border-gray-700/30 bg-gray-800/10">
<h2 className="text-2xl font-bold text-white">
Matriz de Disponibilidade
</h2>
<p className="text-gray-400 mt-2">
Disponível Não Disponível
</p>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="bg-gray-800/10">
<th className="text-left p-4 text-white font-semibold border-b border-gray-700/30 sticky left-0 bg-gray-800/10">
Tipo de Dado
</th>
{sortedYears.map((year, index) => (
<th
key={year}
className="text-center p-4 text-white font-semibold border-b border-gray-700/30 animate-slide-in-left"
style={{ animationDelay: `${index * 50}ms` }}
>
{year}
</th>
))}
</tr>
</thead>
<tbody>
{dataTypes.map((dataType, rowIndex) => (
<tr
key={dataType.key}
className="hover:bg-gray-800/10 transition-all duration-300 animate-slide-in-left"
style={{ animationDelay: `${rowIndex * 100}ms` }}
>
<td className="p-4 border-b border-gray-700/20 text-white sticky left-0 bg-gray-800/10">
<div className="flex items-center space-x-3">
<span className="text-xl">{dataType.icon}</span>
<span>{dataType.label}</span>
</div>
</td>
{sortedYears.map((year) => {
const isAvailable = (stats[dataType.key as keyof OpenCandDataAvailabilityStats] as number[]).includes(year);
return (
<td key={year} className="text-center p-4 border-b border-gray-700/20">
<div className={`inline-flex items-center justify-center w-8 h-8 rounded-full transition-all duration-100 ${isAvailable ? 'bg-green-500/20 text-green-300 hover:bg-green-500/30 hover:scale-110 hover:cursor-default' : 'bg-red-500/20 text-red-300 hover:bg-red-500/30 hover:cursor-default'}`}>
{isAvailable ? '✅' : '❌'}
</div>
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
</div>
);
};
export default DataAvailabilityTable;

View File

@@ -0,0 +1,39 @@
import React from 'react';
interface DbIndexesStatsProps {
amount: number;
size: number;
}
const formatSize = (sizeInBytes: number): string => {
const sizeMB = sizeInBytes / 1024 / 1024;
return sizeMB > 1024
? `${(sizeMB / 1024).toFixed(2)} GB`
: `${sizeMB.toFixed(2)} MB`;
};
const DbIndexesStats: React.FC<DbIndexesStatsProps> = ({ amount, size }) => {
return (
<div>
<h3 className="text-xl font-semibold text-white mb-2">Índices</h3>
<div className="overflow-x-auto max-h-96 flex justify-center" style={{maxHeight: '28rem'}}>
<table className="w-auto text-left border-collapse mx-auto">
<thead>
<tr className="bg-gray-800/10">
<th className="p-3 text-white font-semibold border-b border-gray-700/30">Quantidade</th>
<th className="p-3 text-white font-semibold border-b border-gray-700/30">Tamanho Total</th>
</tr>
</thead>
<tbody>
<tr>
<td className="p-3 text-gray-300">{amount}</td>
<td className="p-3 text-gray-300">{formatSize(size)}</td>
</tr>
</tbody>
</table>
</div>
</div>
);
};
export default DbIndexesStats;

View File

@@ -0,0 +1,60 @@
import React from 'react';
interface DbStatItem {
name: string;
totalSize: number;
entries: number;
}
interface DbStatsTableProps {
title: string;
items: DbStatItem[];
showTotal?: boolean;
}
const formatSize = (sizeInBytes: number): string => {
const sizeMB = sizeInBytes / 1024 / 1024;
return sizeMB > 1024
? `${(sizeMB / 1024).toFixed(2)} GB`
: `${sizeMB.toFixed(2)} MB`;
};
const DbStatsTable: React.FC<DbStatsTableProps> = ({ title, items, showTotal = false }) => {
const totalSize = showTotal ? items.reduce((acc, item) => acc + item.totalSize, 0) : 0;
const totalEntries = showTotal ? items.reduce((acc, item) => acc + item.entries, 0) : 0;
return (
<div>
<h3 className="text-xl font-semibold text-white mb-2">{title}</h3>
<div className="overflow-x-auto" style={{maxHeight: '32rem'}}>
<table className="w-full text-left border-collapse">
<thead>
<tr className="bg-gray-800/10">
<th className="p-3 text-white font-semibold border-b border-gray-700/30">Nome</th>
<th className="p-3 text-white font-semibold border-b border-gray-700/30">Tamanho Total</th>
<th className="p-3 text-white font-semibold border-b border-gray-700/30">Entradas</th>
</tr>
</thead>
<tbody>
{items.map((item) => (
<tr key={item.name} className="hover:bg-gray-800/10 transition-all duration-200">
<td className="p-3 text-white">{item.name.replace(/^public\./, '')}</td>
<td className="p-3 text-gray-300">{formatSize(item.totalSize)}</td>
<td className="p-3 text-gray-300">{item.entries.toLocaleString()}</td>
</tr>
))}
{showTotal && (
<tr className="font-bold bg-gray-900/30">
<td className="p-3 text-white">Total</td>
<td className="p-3 text-gray-300">{formatSize(totalSize)}</td>
<td className="p-3 text-gray-300">{totalEntries.toLocaleString()}</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
);
};
export default DbStatsTable;

View File

@@ -0,0 +1,174 @@
import React, { useState, useEffect } from 'react';
import { FaCloudDownloadAlt, FaGoogleDrive } from 'react-icons/fa';
import { openCandApi } from '../api';
import type { OpenCandDataAvailabilityStats, OpenCandDatabaseStats } from '../api/apiModels';
import Card from '../shared/Card';
import ErrorPage from './ErrorPage';
import DataAvailabilityTable from './DataStats/DataAvailabilityTable';
import DbStatsTable from './DataStats/DbStatsTable';
import GradientButton from '../shared/GradientButton';
import DbIndexesStats from './DataStats/DbIndexesStats';
const DataStatsPage: React.FC = () => {
const [stats, setStats] = useState<OpenCandDataAvailabilityStats | null>(null);
const [dbStats, setDbStats] = useState<OpenCandDatabaseStats | null>(null);
const [loading, setLoading] = useState(true);
const [dbLoading, setDbLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [dbError, setDbError] = useState<string | null>(null);
useEffect(() => {
const fetchStats = async () => {
try {
setLoading(true);
const data = await openCandApi.getDataAvailabilityStats();
setStats(data);
} catch (err) {
setError('Erro ao carregar matriz de disponibilidade de dados');
console.error('Error fetching data availability stats:', err);
} finally {
setLoading(false);
}
};
const fetchDbStats = async () => {
try {
setDbLoading(true);
const dbData = await openCandApi.getDatabaseTechStats();
setDbStats(dbData);
} catch (err) {
setDbError('Erro ao carregar estatísticas técnicas do banco de dados');
console.error('Error fetching database tech stats:', err);
} finally {
setDbLoading(false);
}
};
fetchStats();
fetchDbStats();
}, []);
if (loading) {
return (
<div className="flex items-center justify-center min-h-[40vh]">
<div className="backdrop-blur-md bg-white/90 rounded-2xl shadow-xl p-8 max-w-md w-full border border-white/30 flex flex-col items-center">
<div className="animate-spin rounded-full h-10 w-10 border-4 border-indigo-600 border-t-transparent mb"></div>
<p className="text-base text-gray-700">Carregando dados...</p>
</div>
</div>
);
}
if (error) {
return (
<ErrorPage
title="Erro"
description={error}
helperText="Tente novamente ou volte para a página inicial."
/>
);
}
if (!stats) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<p className="text-lg text-gray-700">Nenhum dado disponível</p>
</div>
);
}
// Get all unique years from all data types
const allYears = new Set<number>();
Object.values(stats).forEach((yearArray: number[]) => {
yearArray.forEach((year: number) => allYears.add(year));
});
const sortedYears = Array.from(allYears).sort((a, b) => b - a);
return (
<div className="min-h-screen py-20 px-4 hover:cursor-default">
<div className="max-w-7xl mx-auto">
{/* Header */}
<div className="text-center mb-12 animate-slide-in-left">
<h1 className="text-4xl md:text-6xl font-bold mb-4 text-white">
Disponibilidade de Dados
</h1>
<p className="text-xl text-gray-400">
Visualize a disponibilidade dos dados por ano em nossa base
</p>
<div className="w-32 h-1 bg-gradient-to-r from-indigo-400 to-purple-400 mx-auto mt-6 rounded-full"></div>
</div>
{/* Download DB Dump Section */}
<div className="flex justify-center mb-10 animate-slide-in-left backdrop-blur-xs bg-gray-800/10 rounded-xl shadow-lg hover:shadow-xl transform transition-all duration-200 overflow-hidden ring-1 ring-gray-700 hover:ring-indigo-300 px-8 py-6 flex flex-col md:flex-row items-center gap-4 max-w-xl-auto">
<div className="flex items-center gap-3 mb-2 md:mb-0">
<FaCloudDownloadAlt className="text-3xl text-green-400" />
<span className="text-lg font-semibold text-white">Download do Dump do Banco de Dados</span>
</div>
<GradientButton
href="https://drive.google.com/file/d/1cfMItrsAdv8y8YUNp04D33s6pYrRbmDn/view?usp=sharing"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 py-2 px-5"
>
<FaGoogleDrive className="text-xl" />
Google Drive
</GradientButton>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-12">
<Card hasAnimation={true} className="bg-gray-800/10 rounded-xl">
<div className="text-center">
<div className="text-3xl mb-2">📊</div>
<div className="text-2xl font-bold text-white">6</div>
<div className="text-gray-400">Tipos de Dados</div>
</div>
</Card>
<Card hasAnimation={true} className="bg-gray-800/10 rounded-xl">
<div className="text-center">
<div className="text-3xl mb-2">📅</div>
<div className="text-2xl font-bold text-white">{sortedYears.length}</div>
<div className="text-gray-400">Anos Disponíveis</div>
</div>
</Card>
<Card hasAnimation={true} className="bg-gray-800/10 rounded-xl">
<div className="text-center">
<div className="text-3xl mb-2">🗓</div>
<div className="text-2xl font-bold text-white">
{sortedYears.length > 0 ? `${Math.min(...sortedYears)} - ${Math.max(...sortedYears)}` : 'N/A'}
</div>
<div className="text-gray-400">Período</div>
</div>
</Card>
</div>
{/* Data Availability Table */}
<DataAvailabilityTable stats={stats} sortedYears={sortedYears} />
</div>
{/* Database Tech Stats Section */}
<div className="mt-20 flex justify-center">
<div className="bg-gray-800/10 backdrop-blur-xs rounded-xl shadow-lg hover:shadow-xl transform hover:scale-[1.005] transition-all duration-200 overflow-hidden ring-1 ring-gray-700 hover:ring-indigo-300 mb-12 w-full max-w-2xl">
<div className="p-6 border-b border-gray-700/30 bg-gray-800/10">
<h2 className="text-2xl font-bold text-white">Dados Técnicas do Banco de Dados</h2>
<p className="text-gray-400 mt-2">Informações sobre tabelas, views materializadas e índices</p>
</div>
{dbLoading ? (
<div className="flex items-center justify-center p-8">
<div className="animate-spin rounded-full h-8 w-8 border-4 border-indigo-600 border-t-transparent mr-4"></div>
<span className="text-gray-300">Carregando dados do banco de dados...</span>
</div>
) : dbError ? (
<div className="p-8 text-red-400">{dbError}</div>
) : dbStats ? (
<div className="p-6 space-y-10">
<DbStatsTable title="Tabelas" items={dbStats.tables} showTotal />
<DbStatsTable title="Views Materializadas" items={dbStats.materializedViews} />
<DbIndexesStats amount={dbStats.indexes.amount} size={dbStats.indexes.size} />
</div>
) : null}
</div>
</div>
</div>
);
};
export default DataStatsPage;

View File

@@ -0,0 +1,62 @@
import React from 'react';
import { Link } from 'react-router-dom';
import GradientButton from '../shared/GradientButton';
interface ErrorPageProps {
title: string;
description: string;
helperText?: string;
}
const ErrorPage: React.FC<ErrorPageProps> = ({ title, description, helperText }) => {
return (
<div className="min-h-screen flex items-center justify-center px-4">
<div className="text-center">
<div className="mb-8">
<h1 className="text-9xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-indigo-400 to-purple-400 mb-4">
{title}
</h1>
<p className="text-gray-300 text-lg mb-8 max-w-md mx-auto">
{description}
</p>
</div>
<div className="space-y-4">
<GradientButton to="/" className="inline-block px-8 py-3">
Voltar para a página inicial
</GradientButton>
{helperText && (
<div className="mt-6">
<p className="text-gray-400 text-sm">
{helperText}
</p>
<div className="flex justify-center space-x-4 mt-3">
<Link
to="/"
className="text-indigo-400 hover:text-indigo-300 transition-colors duration-200"
>
Início
</Link>
<span className="text-gray-500"></span>
<Link
to="/dados-disponiveis"
className="text-indigo-400 hover:text-indigo-300 transition-colors duration-200"
>
Dados Disponíveis
</Link>
</div>
</div>
)}
</div>
{/* Decorative elements */}
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 -z-10">
<div className="w-96 h-96 bg-gradient-to-r from-indigo-500/10 to-purple-500/10 rounded-full blur-3xl"></div>
</div>
</div>
</div>
);
};
export default ErrorPage;

View File

@@ -1,35 +1,39 @@
import React from 'react'; import React from 'react';
import { ArrowDownOnSquareStackIcon, BookOpenIcon, ChartBarIcon, DocumentTextIcon, LightBulbIcon } from '@heroicons/react/24/outline'; import { ArrowDownOnSquareStackIcon, BookOpenIcon, ChartBarIcon, ChartBarSquareIcon, DocumentMagnifyingGlassIcon, DocumentTextIcon, IdentificationIcon, LightBulbIcon, MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import Card from '../shared/Card';
const FeatureCard: React.FC<{ icon: React.ElementType, title: string, children: React.ReactNode }> = ({ icon: Icon, title, children }) => { const FeatureCard: React.FC<{ icon: React.ElementType, title: string, children: React.ReactNode }> = ({ icon: Icon, title, children }) => {
return ( return (
<div className="bg-gray-800/30 p-6 rounded-lg backdrop-blur-xs shadow-xl ring-1 ring-gray-700 max-w-md"> <Card>
<Icon className="h-10 w-10 text-indigo-400 mb-4 mx-auto" /> <Icon className="h-10 w-10 text-indigo-400 mb-4 mx-auto" />
<h3 className="text-xl font-semibold text-white mb-2">{title}</h3> <h3 className="text-xl font-semibold text-white mb-2">{title}</h3>
<p className="text-gray-400">{children}</p> <p className="text-gray-400">{children}</p>
</div> </Card>
); );
}; };
const FeaturesSection: React.FC = () => { const FeaturesSection: React.FC = () => {
return ( return (
<section id="features" className="py-20 bg-gray-800/30"> <section id="recursos" className="py-20">
<div className="container mx-auto px-4"> <div className="container mx-auto px-4">
<h2 className="text-3xl font-bold text-center text-white mb-12"> <h2 className="text-4xl md:text-5xl font-bold text-center text-white mb-12">
Por que OpenCand? Recursos
</h2> </h2>
<div className="flex flex-wrap justify-center gap-8"> <div className="flex flex-wrap justify-center gap-8">
<FeatureCard icon={DocumentTextIcon} title="Acesso Simplificado"> <FeatureCard icon={DocumentTextIcon} title="Acesso Simplificado">
Navegue facilmente por dados complexos do TSE com uma interface limpa e amigável. Navegue facilmente por dados complexos do TSE com uma interface limpa e amigável.
</FeatureCard> </FeatureCard>
<FeatureCard icon={ChartBarIcon} title="Visualizações Claras"> <FeatureCard icon={IdentificationIcon} title="Visualizações Claras">
Entenda as tendências e padrões com gráficos e resumos visuais dos dados eleitorais. Visualização detalhada de perfis, redes sociais e histórico eleitoral.
</FeatureCard> </FeatureCard>
<FeatureCard icon={LightBulbIcon} title="Insights Valiosos"> <FeatureCard icon={DocumentMagnifyingGlassIcon} title="Declaração de Bens">
Obtenha informações relevantes sobre candidatos, partidos e financiamento de campanhas. Visualização acerca de bens declarados para cada candidato.
</FeatureCard> </FeatureCard>
<FeatureCard icon={BookOpenIcon} title="Open Source"> <FeatureCard icon={MagnifyingGlassIcon} title="Informações de Campanha">
Contribua para um projeto aberto e transparente, ajudando a melhorar a plataforma para todos. Análise de receitas e despesas de campanha quando disponível.
</FeatureCard>
<FeatureCard icon={ChartBarSquareIcon} title="Estatísticas">
Estatísticas e gráficos interativos para entender melhor o cenário eleitoral.
</FeatureCard> </FeatureCard>
<FeatureCard icon={ArrowDownOnSquareStackIcon} title="Dados Abertos"> <FeatureCard icon={ArrowDownOnSquareStackIcon} title="Dados Abertos">
Os dados são acessíveis através do TSE e também disponibilizados em nosso repositório GitHub, garantindo transparência e confiabilidade. Os dados são acessíveis através do TSE e também disponibilizados em nosso repositório GitHub, garantindo transparência e confiabilidade.

View File

@@ -2,13 +2,15 @@ import React from 'react';
const Footer: React.FC = () => { const Footer: React.FC = () => {
return ( return (
<footer className="bg-gray-800/30 text-gray-400 py-8 text-center"> <footer className="bg-gray-800/30 text-gray-400 py-4 text-center backdrop-blur-xs shadow-lg ring-1 ring-gray-800">
<div className="container mx-auto"> <div className="container mx-auto">
<p className="mb-2"> <p className="mb-2 text-md">
&copy; {new Date().getFullYear()} OpenCand. Todos os direitos reservados. OpenCand
</p> </p>
<p className="text-sm"> <p className="text-xs">
Democratizando o acesso à informação eleitoral. Não nos responsabilizamos por quaisquer erros ou inconsistências nos dados apresentados, pois estes são de livre interpretação da plataforma e devem ser verificados com os dados oficiais do TSE.
<br />
Logos desenhados por Freepik.
</p> </p>
</div> </div>
</footer> </footer>

View File

@@ -1,23 +1,59 @@
import React from 'react'; import React, { useState } from 'react';
import SearchBar from './SearchBar'; import SearchBar from './SearchBar';
import RandomCandButton from '../shared/RandomCandButton';
const HeroSection: React.FC = () => { const HeroSection: React.FC = () => {
const headers = [
"Explore Dados Eleitorais",
"Analise Dados Eleitorais",
"Consulte Dados Eleitorais",
"Verifique Dados Eleitorais",
"Descubra Dados Eleitorais",
"Acesse Informações Eleitorais",
"Verifique Candidatos",
"Descubra Candidatos",
"Pesquise Candidaturas",
"Consulte Candidatos",
"Navegue pelos Dados do TSE"
];
const [header] = useState(headers[Math.floor(Math.random() * headers.length)]);
return ( return (
<section <section
className="min-h-screen flex flex-col justify-center items-center text-white bg-cover bg-center bg-no-repeat bg-gray-900 relative" className="min-h-screen flex flex-col justify-center items-center text-white bg-cover bg-center bg-no-repeat bg-gray-900 relative"
style={{ backgroundImage: "url('https://upload.wikimedia.org/wikipedia/commons/thumb/2/21/National_Congress_of_Brazil%2C_Brasilia.jpg/1024px-National_Congress_of_Brazil%2C_Brasilia.jpg')" }} style={{ backgroundImage: "url('/assets/Congresso_Nacional_hero.jpg')" }}
> >
<div className="absolute inset-0 bg-black/60"></div> <div className="absolute inset-0 bg-black/60"></div>
<div className="absolute bottom-0 left-0 right-0 h-24 bg-gradient-to-t from-[#0a0f1a] to-transparent"></div>
<div className="relative z-10 text-center max-w-6xl"> <div className="relative z-10 text-center max-w-6xl">
<h1 className="text-5xl md:text-7xl font-bold mb-6"> <h1 className="text-5xl md:text-7xl font-bold mb-6">
Explore Dados Eleitorais {header}
</h1> </h1>
<p className="text-lg md:text-xl mb-10 text-gray-300"> <p className="text-lg md:text-xl mb-10 text-gray-300">
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).
</p> </p>
<SearchBar /> <SearchBar />
<div className="mt-6 flex justify-center">
<RandomCandButton
className='hover:bg-white/15 hover:scale-[1.01] transition-all duration-200 duration-300 ease-in-out bg-white/5 backdrop-blur-sm'
hasAnimation={false}
/>
</div>
</div> </div>
{/* Image credit */}
<span className="absolute left-4 bottom-2 z-20 text-[10px] text-gray-200 bg-black/30 px-1.5 py-0.5 rounded select-none pointer-events-none opacity-55">
Edilson Rodrigues/Agência Senado. Senado Federal,
<a
href="https://creativecommons.org/licenses/by/2.0"
target="_blank"
rel="noopener noreferrer"
className="ml-1 text-gray-200 hover:text-white"
>
CC BY 2.0
</a>, via Wikimedia Commons
</span>
</section> </section>
); );
}; };

View File

@@ -34,8 +34,8 @@ const MatrixBackground: React.FC = () => {
connectionDistance: 150, connectionDistance: 150,
dotSpeed: 0.3, dotSpeed: 0.3,
hoverRadius: 120, hoverRadius: 120,
baseBrightness: 0.4, // Moderate base brightness for main background baseBrightness: 0.2, // Moderate base brightness for main background
hoverBrightness: 0.7, // Moderate hover brightness hoverBrightness: 0.5, // Moderate hover brightness
baseThickness: 0.6, baseThickness: 0.6,
hoverThickness: 1.8, hoverThickness: 1.8,
fadeSpeed: 0.08, // Slightly faster fade for better responsiveness fadeSpeed: 0.08, // Slightly faster fade for better responsiveness

View File

@@ -1,31 +0,0 @@
import React from 'react';
interface NavButtonProps {
href: string;
children: React.ReactNode;
className?: string;
}
const NavButton: React.FC<NavButtonProps> = ({ href, children, className = '' }) => {
return (
<a
href={href}
className={`
inline-block px-4 py-2
rounded-full
backdrop-blur-sm
bg-gray-800/30
text-gray-100
hover:bg-gray-700/40
hover:text-white
transition-all duration-300 ease-in-out
cursor-pointer
${className}
`.trim()}
>
{children}
</a>
);
};
export default NavButton;

View File

@@ -1,5 +1,5 @@
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import NavButton from './NavButton'; import Button from '../shared/Button';
import NavbarMatrixBackground from './NavbarMatrixBackground'; import NavbarMatrixBackground from './NavbarMatrixBackground';
const Navbar: React.FC = () => { const Navbar: React.FC = () => {
@@ -10,13 +10,15 @@ const Navbar: React.FC = () => {
<NavbarMatrixBackground /> <NavbarMatrixBackground />
<div className="relative z-10 p-4"> <div className="relative z-10 p-4">
<div className="container mx-auto flex justify-between items-center"> <div className="container mx-auto flex justify-between items-center">
<a href="/" className="text-2xl font-bold text-indigo-400 hover:text-indigo-300 transition-colors"> <a href="/" className="flex items-center gap-2 text-2xl font-bold text-indigo-400 hover:text-indigo-300 transition-colors">
OpenCand <img src="/assets/opencand-line.png" alt="OpenCand logo" className="h-8 w-auto" />
<span>OpenCand</span>
</a> </a>
<div className="space-x-4"> <div className="space-x-4">
<NavButton href="#stats">Estatíscas</NavButton> <Button href="/dados-disponiveis">Dados Disponíveis</Button>
<NavButton href="#features">Recursos</NavButton> <Button href="/estatisticas">Estatíscas</Button>
<NavButton href="/about">Sobre</NavButton> <Button href="/#recursos">Recursos</Button>
<Button href="/sobre">Sobre</Button>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,14 @@
import React from 'react';
import ErrorPage from './ErrorPage';
const NotFound: React.FC = () => {
return (
<ErrorPage
title="404"
description="A página que você está procurando não existe ou foi movida para outro local."
helperText="Ou tente uma dessas páginas:"
/>
);
};
export default NotFound;

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect, useCallback, useRef } from 'react'; import React, { useState, useEffect, useCallback, useRef } from 'react';
import { MagnifyingGlassIcon, XMarkIcon } from '@heroicons/react/24/solid'; import { MagnifyingGlassIcon, XMarkIcon, MapPinIcon } from '@heroicons/react/24/solid';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { openCandApi, type Candidate, ApiError } from '../api'; import { openCandApi, type Candidate, ApiError } from '../api';
import { formatDateToDDMMYYYY, maskCpf } from '../utils/utils'; import { formatDateToDDMMYYYY, maskCpf } from '../utils/utils';
@@ -15,9 +15,11 @@ const SearchBar: React.FC<SearchBarProps> = ({ className = '' }) => {
const [showResults, setShowResults] = useState(false); const [showResults, setShowResults] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [isFocused, setIsFocused] = useState(false); const [isFocused, setIsFocused] = useState(false);
const [activeIndex, setActiveIndex] = useState(-1);
const navigate = useNavigate(); const navigate = useNavigate();
const searchTimeoutRef = useRef<number | null>(null); const searchTimeoutRef = useRef<number | null>(null);
const resultsRef = useRef<HTMLDivElement>(null); const resultsRef = useRef<HTMLDivElement>(null);
const resultsContainerRef = useRef<HTMLDivElement>(null);
// Debounced search function // Debounced search function
const performSearch = useCallback(async (query: string) => { const performSearch = useCallback(async (query: string) => {
@@ -74,10 +76,12 @@ const SearchBar: React.FC<SearchBarProps> = ({ className = '' }) => {
// Handle form submission // Handle form submission
const handleSubmit = useCallback((e: React.FormEvent) => { const handleSubmit = useCallback((e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (searchResults.length > 0) { if (activeIndex > -1 && searchResults[activeIndex]) {
handleCandidateSelect(searchResults[activeIndex]);
} else if (searchResults.length > 0) {
handleCandidateSelect(searchResults[0]); handleCandidateSelect(searchResults[0]);
} }
}, [searchResults, handleCandidateSelect]); }, [searchResults, handleCandidateSelect, activeIndex]);
// Close results when clicking outside // Close results when clicking outside
useEffect(() => { useEffect(() => {
@@ -93,12 +97,48 @@ const SearchBar: React.FC<SearchBarProps> = ({ className = '' }) => {
}; };
}, []); }, []);
// Reset active index when search results change
useEffect(() => {
setActiveIndex(-1);
}, [searchResults]);
// Scroll active item into view
useEffect(() => {
if (activeIndex < 0 || !resultsContainerRef.current) return;
const resultsContainer = resultsContainerRef.current;
const activeButton = resultsContainer.children[activeIndex] as HTMLElement;
if (activeButton) {
activeButton.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
});
}
}, [activeIndex]);
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Escape') {
setShowResults(false);
return;
}
if (showResults && searchResults.length > 0) {
if (e.key === 'ArrowDown') {
e.preventDefault();
setActiveIndex(prevIndex => (prevIndex + 1) % searchResults.length);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setActiveIndex(prevIndex => (prevIndex - 1 + searchResults.length) % searchResults.length);
}
}
};
const getCandidateDescription = (candidate: Candidate) => { const getCandidateDescription = (candidate: Candidate) => {
const desc = ['']; const desc = [''];
if (candidate.cpf) desc.push(`CPF: ${maskCpf(candidate.cpf)}`); if (candidate.cpf) desc.push(`CPF: ${maskCpf(candidate.cpf)}`);
if (candidate.apelido) desc.push(`"${candidate.apelido}"`); if (candidate.apelido) desc.push(`"${candidate.apelido}"`);
if (candidate.ocupacao && candidate.ocupacao != 'OUTROS') desc.push(`${candidate.ocupacao}`);
if (desc.length == 0) if (desc.length == 0)
if (candidate.dataNascimento) desc.push(`${formatDateToDDMMYYYY(candidate.dataNascimento)}`); if (candidate.dataNascimento) desc.push(`${formatDateToDDMMYYYY(candidate.dataNascimento)}`);
@@ -127,6 +167,7 @@ const SearchBar: React.FC<SearchBarProps> = ({ className = '' }) => {
onChange={handleInputChange} onChange={handleInputChange}
onFocus={() => setIsFocused(true)} onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)} onBlur={() => setIsFocused(false)}
onKeyDown={handleKeyDown}
placeholder="Pesquisar candidatos..." placeholder="Pesquisar candidatos..."
className="flex-grow bg-transparent text-white placeholder-gray-400 p-3 focus:outline-none" className="flex-grow bg-transparent text-white placeholder-gray-400 p-3 focus:outline-none"
autoComplete="off" autoComplete="off"
@@ -161,19 +202,23 @@ const SearchBar: React.FC<SearchBarProps> = ({ className = '' }) => {
{error} {error}
</div> </div>
) : ( ) : (
<div className="max-h-80 overflow-y-auto custom-scrollbar"> <div ref={resultsContainerRef} className="max-h-80 overflow-y-auto custom-scrollbar">
{searchResults.map((candidate) => ( {searchResults.map((candidate, index) => (
<button <button
key={candidate.idCandidato} key={candidate.idCandidato}
onClick={() => handleCandidateSelect(candidate)} onClick={() => handleCandidateSelect(candidate)}
className="w-full p-4 text-left hover:bg-gray-100 transition-colors border-b border-gray-100 last:border-b-0 focus:outline-none focus:bg-gray-100" onMouseEnter={() => setActiveIndex(index)}
className={`w-full p-4 text-left hover:bg-gray-100 transition-colors border-b border-gray-100 last:border-b-0 focus:outline-none focus:bg-gray-100 ${index === activeIndex ? 'bg-gray-100' : ''}`}
> >
<div className="text-black font-semibold text-base">{candidate.nome}</div> <div className="text-black font-semibold text-base">{candidate.nome}</div>
<div className="text-gray-600 text-sm mt-1"> <div className="text-gray-600 text-sm mt-1">
{getCandidateDescription(candidate)} {getCandidateDescription(candidate)}
</div> </div>
{candidate.email && ( {candidate.localidade && (
<div className="text-gray-500 text-xs mt-1">{candidate.email}</div> <div className="flex items-center text-gray-500 text-xs mt-1">
<MapPinIcon className="h-3 w-3 mr-1" />
{candidate.localidade}
</div>
)} )}
</button> </button>
))} ))}

View File

@@ -0,0 +1,120 @@
import React from 'react';
import { FaGithub, FaLinkedin } from 'react-icons/fa';
const SobrePage: React.FC = () => {
return (
<div className="flex justify-center items-center min-h-[80vh] py-12 px-4">
<div
className="backdrop-blur-md bg-white/90 rounded-2xl shadow-xl p-8 max-w-3xl w-full border border-white/30"
style={{ boxShadow: '0 8px 32px 0 rgba(31, 38, 135, 0.2)' }}
>
<div className="space-y-4 text-gray-700">
<p className="text-lg leading-relaxed">
O <strong>OpenCand</strong> é uma plataforma que visa explorar de forma intuitiva os dados eleitorais brasileiros e possui
o objetivo de ser uma alternativa para o acesso às informações públicas do Tribunal Superior Eleitoral (TSE).
</p>
<p className="leading-relaxed">
O projeto foi desenvolvido por <strong className="inline-flex items-center gap-1">
ivanch
<a
href="https://github.com/ivanch"
target="_blank"
rel="noopener noreferrer"
className="align-middle inline-block hover:scale-110 transition-transform"
aria-label="GitHub"
>
<FaGithub className="text-gray-700 hover:text-black" size={16} />
</a>
<a
href="https://www.linkedin.com/in/joseivanch"
target="_blank"
rel="noopener noreferrer"
className="align-middle inline-block hover:scale-110 transition-transform"
aria-label="LinkedIn"
>
<FaLinkedin className="text-blue-700 hover:text-blue-900" size={16} />
</a>
</strong> como hobby, o código é semi-aberto e está disponível no <strong>GitHub</strong>.
Atualmente apenas o ETL (Extract-Transform-Load) está disponível, juntamente com o banco de dados final.
</p>
<p className="leading-relaxed">
A API também pode ser utilizada para acessar os dados de forma pública, porém ainda não está documentada. A documentação da API será disponibilizada em breve.
</p>
<div className="w-32 h-1 bg-gradient-to-r from-indigo-400 to-purple-400 mx-auto mt-6 rounded-full"></div>
<p className="leading-relaxed">
Não nos responsabilizamos por quaisquer erros ou inconsistências nos dados apresentados, pois estes são de livre interpretação da plataforma e devem ser verificados com os dados oficiais do TSE.
<br />
A plataforma é uma iniciativa independente e não possui qualquer vínculo com o TSE ou órgãos governamentais.
</p>
<div className="w-32 h-1 bg-gradient-to-r from-indigo-400 to-purple-400 mx-auto mt-6 rounded-full"></div>
<div className="mt-6 flex flex-col items-center">
<p className="font-semibold text-gray-800 mb-2 text-center">Links úteis:</p>
<ul className="space-y-2 w-full max-w-md">
<li className="flex items-center justify-center">
<span className="w-2 h-2 bg-indigo-500 rounded-full mr-3"></span>
<a
href="https://divulgacandcontas.tse.jus.br/divulga/#/home"
target="_blank"
rel="noopener noreferrer"
className="text-indigo-700 hover:underline hover:text-indigo-900 transition-colors text-center"
>
DivulgaCand do TSE
</a>
</li>
<li className="flex items-center justify-center">
<span className="w-2 h-2 bg-indigo-500 rounded-full mr-3"></span>
<a
href="https://sig.tse.jus.br/ords/dwapr/r/seai/sig-eleicao/home"
target="_blank"
rel="noopener noreferrer"
className="text-indigo-700 hover:underline hover:text-indigo-900 transition-colors text-center"
>
Estatísticas do TSE
</a>
</li>
<li className="flex items-center justify-center">
<span className="w-2 h-2 bg-indigo-500 rounded-full mr-3"></span>
<a
href="https://dadosabertos.tse.jus.br/dataset/"
target="_blank"
rel="noopener noreferrer"
className="text-indigo-700 hover:underline hover:text-indigo-900 transition-colors text-center"
>
Dataset do TSE
</a>
</li>
</ul>
</div>
<div className="w-32 h-1 bg-gradient-to-r from-indigo-400 to-purple-400 mx-auto mt-6 rounded-full"></div>
<p className="leading-relaxed">
Contato: {[
'opencand',
<span key="at" style={{ userSelect: 'text' }}>@</span>,
'ivanch',
<span key="dot" style={{ userSelect: 'text' }}>.</span>,
'me'
]}
</p>
<div className="mt-6 pt-4 border-t border-gray-300">
<p className="text-sm text-gray-600 text-center">
Desenvolvido com .NET, PostgreSQL, React (TypeScript/Tailwind CSS) e dados abertos do TSE.<br />
<span className="text-xs">
</span>
</p>
</div>
</div>
</div>
</div>
);
};
export default SobrePage;

View File

@@ -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<StatisticsFiltersProps> = ({
filters,
onFiltersChange,
isLoading = false,
}) => {
// Local state for form fields
const [localFilters, setLocalFilters] = useState<FilterState>(filters);
// State for configuration data from API
const [config, setConfig] = useState<StatisticsConfig | null>(null);
const [configLoading, setConfigLoading] = useState(true);
const [configError, setConfigError] = useState<string | null>(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 (
<form
className="h-full flex flex-col justify-between space-y-6"
onSubmit={handleApply}
style={{ minHeight: '400px' }} // optional: ensures enough height for flex
>
<div className="flex-1 flex flex-col space-y-6">
<div>
<h2 className="text-xl font-semibold text-gray-900 mb-4">
Filtros
</h2>
{configError && (
<div className="mb-4 p-3 bg-red-100 border border-red-300 rounded-lg text-red-700 text-sm">
{configError}
</div>
)}
</div>
{/* Party Filter */}
<div className="space-y-2">
<label className="block text-sm font-semibold text-gray-600 uppercase tracking-wide">
Partido
</label>
<select
value={localFilters.partido || ''}
onChange={(e) => handleLocalChange('partido', e.target.value)}
disabled={isLoading || configLoading}
className="w-full px-3 py-2 bg-white border border-gray-300 rounded-lg text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed"
>
<option value="">Todos os Partidos</option>
{config?.partidos.map((partido) => (
<option key={partido} value={partido}>
{partido}
</option>
))}
</select>
</div>
{/* UF Filter */}
<div className="space-y-2">
<label className="block text-sm font-semibold text-gray-600 uppercase tracking-wide">
UF
</label>
<select
value={localFilters.uf || ''}
onChange={(e) => handleLocalChange('uf', e.target.value)}
disabled={isLoading || configLoading}
className="w-full px-3 py-2 bg-white border border-gray-300 rounded-lg text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed"
>
<option value="">Todos os Estados</option>
{config?.siglasUF.map((uf) => (
<option key={uf} value={uf}>
{uf}
</option>
))}
</select>
</div>
{/* Year Filter */}
<div className="space-y-2">
<label className="block text-sm font-semibold text-gray-600 uppercase tracking-wide">
Ano
</label>
<select
value={localFilters.ano || ''}
onChange={(e) => handleLocalChange('ano', e.target.value ? Number(e.target.value) : null)}
disabled={isLoading || configLoading}
className="w-full px-3 py-2 bg-white border border-gray-300 rounded-lg text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed"
>
<option value="">Todos os Anos</option>
{config?.anos.map((year) => (
<option key={year} value={year}>
{year}
</option>
))}
</select>
</div>
{/* Cargo Filter */}
<div className="space-y-2">
<label className="block text-sm font-semibold text-gray-600 uppercase tracking-wide">
Cargo
</label>
<select
value={localFilters.cargo || ''}
onChange={(e) => handleLocalChange('cargo', e.target.value)}
disabled={isLoading || configLoading}
className="w-full px-3 py-2 bg-white border border-gray-300 rounded-lg text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed"
>
<option value="">Todos os Cargos</option>
{config?.cargos.map((cargo) => (
<option key={cargo} value={cargo}>
{cargo}
</option>
))}
</select>
</div>
</div>
{/* Apply Filters Button */}
<div className="pt-4">
<button
type="submit"
disabled={isLoading || configLoading}
className="w-full px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg shadow-md hover:shadow-lg duration-200 disabled:opacity-50 disabled:cursor-not-allowed disabled:transform-none hover:cursor-pointer"
>
{isLoading || configLoading ? 'Carregando...' : 'Aplicar Filtros'}
</button>
</div>
</form>
);
};
export default StatisticsFilters;

View File

@@ -0,0 +1,290 @@
import React from 'react';
import type { StatisticsData } from './statisticsRequests';
import WhiteCard from '../../shared/WhiteCard';
interface StatisticsGraphsProps {
isLoading: boolean;
error: string | null;
statisticsData: StatisticsData | null;
}
const StatisticsGraphs: React.FC<StatisticsGraphsProps> = ({
isLoading,
error,
statisticsData,
}) => {
const [currentEnrichmentIndex, setCurrentEnrichmentIndex] = React.useState(0);
if (error) {
return (
<WhiteCard className="flex items-center justify-center h-64">
<div className="text-center">
<div className="text-red-600 text-lg mb-2"> Erro</div>
<p className="text-gray-700">{error}</p>
</div>
</WhiteCard>
);
}
if (isLoading) {
return (
<WhiteCard className="flex items-center justify-center h-64">
<div className="text-center">
<div className="animate-spin w-8 h-8 border-2 border-blue-600 border-t-transparent rounded-full mx-auto mb-4"></div>
<p className="text-gray-700">Carregando dados...</p>
</div>
</WhiteCard>
);
}
if (!statisticsData) {
return (
<WhiteCard className="flex items-center justify-center h-64">
<div className="text-center">
<div className="text-gray-500 text-4xl mb-4">📊</div>
<h3 className="text-gray-900 text-lg mb-2">Nenhum dado encontrado</h3>
<p className="text-gray-700">
Os dados estatísticos não puderam ser carregados
</p>
</div>
</WhiteCard>
);
}
const renderDataTable = (title: string, data: any[], type: 'candidate' | 'party' | 'state' | 'enrichment') => {
if (!data || data.length === 0) {
return (
<WhiteCard>
<div>
<h3 className="text-lg font-semibold text-gray-900 mb-4">{title}</h3>
<p className="text-gray-700">Nenhum dado disponível</p>
</div>
</WhiteCard>
);
}
if (type === 'enrichment') {
const enrichmentData = data[currentEnrichmentIndex]; // Use current index instead of always first item
const hasMultipleItems = data.length > 1;
const handleNext = () => {
if (currentEnrichmentIndex < data.length - 1) {
setCurrentEnrichmentIndex(currentEnrichmentIndex + 1);
}
};
const handleBack = () => {
if (currentEnrichmentIndex > 0) {
setCurrentEnrichmentIndex(currentEnrichmentIndex - 1);
}
};
return (
<WhiteCard>
<div className="relative">
<h3 className="text-lg font-semibold text-gray-900 mb-4">{title}</h3>
<div className="space-y-3">
<div className="flex justify-between items-center">
<span className="text-gray-600">Candidato:</span>
<a
href={`/candidato/${enrichmentData.idCandidato}`}
className="text-gray-900 font-medium hover:text-blue-600 hover:underline transition-colors duration-200"
>
{enrichmentData.nome}
</a>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-600">Patrimônio Inicial (<span className="text-xs text-gray-500">{enrichmentData.anoInicial}</span>):</span>
<span className="text-gray-900 font-medium">
R$ {enrichmentData.patrimonioInicial?.toLocaleString('pt-BR', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) || '0,00'}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-600">Patrimônio Final (<span className="text-xs text-gray-500">{enrichmentData.anoFinal}</span>):</span>
<span className="text-gray-900 font-medium">
R$ {enrichmentData.patrimonioFinal?.toLocaleString('pt-BR', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) || '0,00'}
</span>
</div>
<div className="border-t border-gray-200 pt-3">
<div className="flex justify-between items-center">
<span className="text-gray-600">Enriquecimento:</span>
<span className={`font-bold text-lg ${
enrichmentData.enriquecimento >= 0 ? 'text-green-600' : 'text-red-600'
}`}>
{enrichmentData.enriquecimento >= 0 ? '+' : ''}
R$ {enrichmentData.enriquecimento?.toLocaleString('pt-BR', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) || '0,00'}
</span>
</div>
</div>
</div>
{/* Navigation buttons */}
{hasMultipleItems && (
<div className="flex justify-between items-center mt-4 pt-3 border-t border-gray-200">
<button
onClick={handleBack}
disabled={currentEnrichmentIndex === 0}
className={`flex items-center px-3 py-2 rounded-lg text-sm font-medium transition-all duration-200 ${
currentEnrichmentIndex === 0
? 'text-gray-400 cursor-not-allowed opacity-50'
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-100 active:bg-gray-200'
}`}
>
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
Anterior
</button>
<div className="text-xs text-gray-500">
{currentEnrichmentIndex + 1} de {data.length}
</div>
<button
onClick={handleNext}
disabled={currentEnrichmentIndex === data.length - 1}
className={`flex items-center px-3 py-2 rounded-lg text-sm font-medium transition-all duration-200 ${
currentEnrichmentIndex === data.length - 1
? 'text-gray-400 cursor-not-allowed opacity-50'
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-100 active:bg-gray-200'
}`}
>
Próximo
<svg className="w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
)}
</div>
</WhiteCard>
);
}
return (
<WhiteCard>
<div>
<h3 className="text-lg font-semibold text-gray-900 mb-4">{title}</h3>
<div className="overflow-auto max-h-90 overflow-y-auto custom-scrollbar pr-2">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200">
{type === 'candidate' && (
<>
<th className="text-left py-2 text-gray-600 font-medium">Nome</th>
</>
)}
{type === 'party' && (
<th className="text-left py-2 text-gray-600 font-medium">Partido</th>
)}
{type === 'state' && (
<th className="text-left py-2 text-gray-600 font-medium">UF</th>
)}
<th className="text-left py-2 text-gray-600 font-medium">Ano</th>
<th className="text-right py-2 text-gray-600 font-medium">Valor</th>
</tr>
</thead>
<tbody>
{data.slice(0, 5).map((item, index) => (
<tr key={index} className="border-b border-gray-100 hover:bg-gray-50 transition-colors duration-150">
{type === 'candidate' && (
<a href={`/candidato/${item.idCandidato}`}
className="text-gray-900 font-medium hover:text-blue-600 hover:underline transition-colors duration-200"
>
<td className="py-3">{item.nome || 'N/A'}</td>
</a>
)}
{type === 'party' && (
<td className="py-3 text-gray-900">{item.sgpartido || 'N/A'}</td>
)}
{type === 'state' && (
<td className="py-3 text-gray-900">{item.siglaUf || 'N/A'}</td>
)}
<td className="py-3 text-gray-900">{item.ano || 'N/A'}</td>
<td className="py-3 text-right font-medium text-gray-900">
R$ {item.valor?.toLocaleString('pt-BR', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) || '0,00'}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</WhiteCard>
);
};
return (
<div className="">
<div className="space-y-6">
{/* First Row - Candidates */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{renderDataTable(
'Candidatos com Maiores Bens',
statisticsData.candidatesWithMostAssets,
'candidate'
)}
{statisticsData.enrichmentData && renderDataTable(
'Análise de Enriquecimento',
statisticsData.enrichmentData,
'enrichment'
)}
</div>
{/* Second Row - Candidates Revenue & Expenses */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{renderDataTable(
'Candidatos com Maiores Receitas',
statisticsData.candidatesWithMostRevenue,
'candidate'
)}
{renderDataTable(
'Candidatos com Maiores Despesas',
statisticsData.candidatesWithMostExpenses,
'candidate'
)}
</div>
{/* Third Row - Parties */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{renderDataTable(
'Partidos com Maiores Bens',
statisticsData.partiesWithMostAssets,
'party'
)}
{renderDataTable(
'Partidos com Maiores Despesas',
statisticsData.partiesWithMostExpenses,
'party'
)}
{renderDataTable(
'Partidos com Maiores Receitas',
statisticsData.partiesWithMostRevenue,
'party'
)}
</div>
{/* Fourth Row - States */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{renderDataTable(
'UFs com Maiores Bens',
statisticsData.statesWithMostAssets,
'state'
)}
{renderDataTable(
'UFs com Maiores Despesas',
statisticsData.statesWithMostExpenses,
'state'
)}
{renderDataTable(
'UFs com Maiores Receitas',
statisticsData.statesWithMostRevenue,
'state'
)}
</div>
</div>
</div>
);
};
export default StatisticsGraphs;

View File

@@ -0,0 +1,104 @@
import React, { useState, useEffect } from 'react';
import WhiteCard from '../../shared/WhiteCard';
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<FilterState>({
type: 'bem',
groupBy: 'candidato',
});
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [statisticsData, setStatisticsData] = useState<StatisticsData | null>(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 (
<div className="p-6 pt-16 pl-16 pr-16">
<div className="max-w-full mx-auto">
<div className="mb-8">
<h1 className="text-4xl md:text-6xl font-bold mb-4 text-white4">
Estatísticas
</h1>
<p className="text-white/70 text-lg">
Análise de dados e estatísticas dos candidatos e partidos brasileiros
</p>
<p className="text-white/70 text-lg">
Para mais informações, acesse a <a href="https://sig.tse.jus.br/ords/dwapr/r/seai/sig-eleicao/home" target="_blank" rel="noopener noreferrer" className="text-indigo-400 hover:underline">página de estatísticas do TSE</a> ou o <a href="https://divulgacandcontas.tse.jus.br/divulga/#/home" target="_blank" rel="noopener noreferrer" className="text-indigo-400 hover:underline">DivulgaCand do TSE</a>.
</p>
<div className="w-32 h-1 bg-gradient-to-r from-indigo-400 to-purple-400 mx-auto mt-6 rounded-full"></div>
</div>
<div className="flex gap-6 ">
{/* Left Sidebar - Filters (20% width) */}
<div className="w-1/5 min-w-[300px] h-[calc(100vh-12rem)]">
<WhiteCard
fullHeight
className="overflow-y-auto"
>
<StatisticsFilters
filters={filters}
onFiltersChange={handleFiltersChange}
isLoading={isLoading}
/>
</WhiteCard>
</div>
{/* Right Content - Graphs (80% width) */}
<div className="flex-1 overflow-auto">
<StatisticsGraphs
isLoading={isLoading}
error={error}
statisticsData={statisticsData}
/>
</div>
</div>
</div>
</div>
);
};
export default StatisticsPage;

View File

@@ -0,0 +1,3 @@
export { default as StatisticsFilters } from './StatisticsFilters';
export { default as StatisticsGraphs } from './StatisticsGraphs';
export { default } from './StatisticsPage';

View File

@@ -0,0 +1,215 @@
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?: StatisticsRequestFilters;
}
export interface StatisticsRequestFilters {
partido?: string | null;
uf?: string | null;
ano?: number | null;
cargo?: CargoFilter;
}
// First Row Requests
export async function getCandidatesWithMostAssets(options?: StatisticsRequestOptions): Promise<ValueSumResponse[]> {
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(filters?: StatisticsRequestFilters): Promise<EnrichmentResponse[] | null> {
try {
return await openCandApi.getStatisticsEnrichment(filters);
} catch (error) {
console.error('Error fetching enrichment data:', error);
return null;
}
}
// Second Row Requests
export async function getCandidatesWithMostRevenue(options?: StatisticsRequestOptions): Promise<ValueSumResponse[]> {
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<ValueSumResponse[]> {
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<ValueSumResponse[]> {
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<ValueSumResponse[]> {
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<ValueSumResponse[]> {
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<ValueSumResponse[]> {
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<ValueSumResponse[]> {
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<ValueSumResponse[]> {
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<StatisticsData> {
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(options?.filters),
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<ValueSumResponse[]> {
const request: ValueSumRequest = {
type,
groupBy,
filter: options?.filters
};
const response = await openCandApi.getStatisticsValueSum(request);
return Array.isArray(response) ? response : [response];
}

View File

@@ -1,5 +1,6 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { openCandApi, type PlatformStats, ApiError } from '../api'; import { openCandApi, type PlatformStats, ApiError } from '../api';
import Card from '../shared/Card';
interface StatCardProps { interface StatCardProps {
title: string; title: string;
@@ -10,17 +11,17 @@ interface StatCardProps {
const StatCard: React.FC<StatCardProps> = ({ title, value, description, isLoading = false }) => { const StatCard: React.FC<StatCardProps> = ({ title, value, description, isLoading = false }) => {
return ( return (
<div className="bg-gray-800/10 backdrop-blur-xs p-6 rounded-lg shadow-xl ring-1 ring-gray-700 hover:shadow-indigo-500/30 transform hover:-translate-y-1 hover:ring-1 hover:ring-white/10 hover:scale-[1.01] transition-all duration-300"> <Card hasAnimation={true} disableCursor={true} height={11} width={20}>
<h3 className="text-indigo-400 text-xl font-semibold mb-2">{title}</h3> <h3 className="text-indigo-400 text-xl font-semibold mb-2">{title}</h3>
{isLoading ? ( {isLoading ? (
<div className="h-12 flex items-center"> <div className="h-12 flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-white border-t-transparent"></div> <div className="animate-spin rounded-full border-4 border-indigo-600 border-t-transparent"></div>
</div> </div>
) : ( ) : (
<p className="text-4xl font-bold text-white mb-3">{value}</p> <p className="text-4xl font-bold text-white mb-3">{value}</p>
)} )}
<p className="text-gray-400 text-sm">{description}</p> <p className="text-gray-400 text-sm">{description}</p>
</div> </Card>
); );
}; };
@@ -105,36 +106,26 @@ const StatisticsSection: React.FC = () => {
]; ];
return ( return (
<section id="stats" className="py-20 bg-gray-800/30"> <section id="stats" className="py-20">
<div className="container mx-auto px-4"> <div className="max-w-7xl mx-auto px-4">
<h2 className="text-3xl font-bold text-center text-white mb-12"> <div className="text-center mb-12 animate-fade-in">
Dados em Números <h2 className="text-4xl md:text-5xl font-bold text-white mb-4">
</h2> Dados em Números
<div className="flex flex-wrap justify-center gap-8 mx-auto"> </h2>
{statisticsData.slice(0, 3).map((stat, index) => ( <p className="text-xl text-gray-400">
<div key={index} className="w-full md:w-80 lg:w-96"> Estatísticas da nossa plataforma de dados eleitorais
<StatCard </p>
title={stat.title} </div>
value={stat.value}
description={stat.description} <div className="flex flex-wrap justify-center gap-8">
isLoading={isLoading} {statisticsData.map((stat) => (
/> <StatCard
</div> title={stat.title}
value={stat.value}
description={stat.description}
isLoading={isLoading}
/>
))} ))}
{statisticsData.length > 3 && (
<div className="w-full flex flex-wrap justify-center gap-8">
{statisticsData.slice(3).map((stat, index) => (
<div key={index + 3} className="w-full md:w-80 lg:w-96">
<StatCard
title={stat.title}
value={stat.value}
description={stat.description}
isLoading={isLoading}
/>
</div>
))}
</div>
)}
</div> </div>
</div> </div>
</section> </section>

25
src/config/firebase.ts Normal file
View File

@@ -0,0 +1,25 @@
import { initializeApp } from "firebase/app";
import { getAnalytics } from "firebase/analytics";
const firebaseConfig = {
apiKey: import.meta.env.VITE_FIREBASE_API_KEY,
authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN,
projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID,
storageBucket: import.meta.env.VITE_FIREBASE_STORAGE_BUCKET,
messagingSenderId: import.meta.env.VITE_FIREBASE_MESSAGING_SENDER_ID,
appId: import.meta.env.VITE_FIREBASE_APP_ID,
measurementId: import.meta.env.VITE_FIREBASE_MEASUREMENT_ID
};
let app = null;
let analytics = null;
if (firebaseConfig.apiKey) {
app = initializeApp(firebaseConfig);
analytics = getAnalytics(app);
} else {
console.warn("Firebase API key is not set. Firebase will not be initialized.");
}
export { app, analytics };

View File

@@ -27,81 +27,3 @@ body {
@apply bg-gray-900 text-white; @apply bg-gray-900 text-white;
} }
h1 {
font-size: 3.2em;
line-height: 1.1;
}
/* button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
} */
/* Custom minimal scrollbar styles */
.custom-scrollbar {
scrollbar-width: thin;
scrollbar-color: rgba(156, 163, 175, 0.5) transparent;
}
.custom-scrollbar::-webkit-scrollbar {
width: 1px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background-color: rgba(156, 163, 175, 0.5);
border-radius: 2px;
transition: background-color 0.2s ease;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background-color: rgba(107, 114, 128, 0.7);
}
.custom-scrollbar::-webkit-scrollbar-corner {
background: transparent;
}
/* Ensure full width layout */
html, body, #root {
width: 100%;
margin: 0;
padding: 0;
}
#root {
min-height: 100vh;
}
/* Custom fade-in animation for dropdown content */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fadeIn {
animation: fadeIn 0.3s ease-out forwards;
}

View File

@@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom' import { BrowserRouter } from 'react-router-dom'
import './index.css' import './index.css'
import App from './App.tsx' import App from './App.tsx'
import './config/firebase.ts'
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>

66
src/shared/Button.tsx Normal file
View File

@@ -0,0 +1,66 @@
import React from 'react';
interface ButtonProps {
children: React.ReactNode;
className?: string;
hasAnimation?: boolean;
disableCursor?: boolean;
disabled?: boolean;
onClick?: () => void;
href?: string;
type?: 'button' | 'submit' | 'reset';
}
const Button: React.FC<ButtonProps> = ({
children,
className = "",
hasAnimation = true,
disableCursor = false,
disabled = false,
onClick,
href,
type = 'button'
}) => {
const animationClasses = hasAnimation ? `hover:shadow-xl
transform
hover:scale-[1.01]`
: "";
const cursorClasses = disableCursor ? "hover:cursor-default"
: "hover:cursor-pointer";
const baseClasses = `bg-gray-800/30
px-4 py-2
rounded-full
backdrop-blur-xs
shadow-xl
transition-all
duration-200
hover:bg-gray-700/40
text-white
transition-colors
${animationClasses} ${cursorClasses} ${className}`;
if (href) {
return (
<a
href={href}
className={baseClasses}
>
{children}
</a>
);
}
return (
<button
type={type}
onClick={onClick}
className={baseClasses}
disabled={disabled}
>
{children}
</button>
);
};
export default Button;

31
src/shared/Card.tsx Normal file
View File

@@ -0,0 +1,31 @@
import React from 'react';
interface CardProps {
children: React.ReactNode;
className?: string;
hasAnimation?: boolean;
disableCursor?: boolean;
height?: number;
width?: number;
}
const Card: React.FC<CardProps> = ({ children, className = "", hasAnimation = false, disableCursor = false, height, width }) => {
const animationClasses = hasAnimation ? "hover:shadow-xl transform hover:scale-[1.01] transition-all duration-200 hover:ring-indigo-300" : "";
const cursorClasses = disableCursor ? "hover:cursor-default" : "";
const sizeStyles = {
...(height && { height: `${height}rem` }),
...(width && { width: `${width}rem` })
};
return (
<div
className={`bg-gray-800/30 p-6 rounded-lg backdrop-blur-xs shadow-xl ring-1 ring-gray-700 max-w-md ${animationClasses} ${cursorClasses} ${className}`}
style={sizeStyles}
>
{children}
</div>
);
};
export default Card;

View File

@@ -0,0 +1,46 @@
import React from 'react';
import { Link, type LinkProps } from 'react-router-dom';
interface GradientButtonProps extends React.PropsWithChildren {
className?: string;
to?: LinkProps['to'];
href?: string;
[x: string]: any; // To allow other props like target, rel, type, etc.
}
const GradientButton: React.FC<GradientButtonProps> = ({
children,
className = '',
to,
href,
...props
}) => {
const baseClasses =
'bg-gradient-to-r from-indigo-500 to-purple-600 text-white font-semibold rounded-lg hover:from-indigo-600 hover:to-purple-700 transition-all duration-300 transform shadow-lg hover:shadow-xl';
const combinedClassName = `${baseClasses} ${className}`;
if (to) {
return (
<Link to={to} {...props} className={combinedClassName}>
{children}
</Link>
);
}
if (href) {
return (
<a href={href} {...props} className={combinedClassName}>
{children}
</a>
);
}
return (
<button type="button" {...props} className={combinedClassName}>
{children}
</button>
);
};
export default GradientButton;

View File

@@ -0,0 +1,54 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { ArrowPathIcon } from '@heroicons/react/24/outline';
import Button from './Button';
import { openCandApi } from '../api/openCandApi';
interface RandomCandButtonProps {
className?: string;
hasAnimation?: boolean;
}
const RandomCandButton: React.FC<RandomCandButtonProps> = ({
className = '',
hasAnimation = false
}) => {
const navigate = useNavigate();
const [loading, setLoading] = React.useState(false);
const handleRandomCandidate = async () => {
setLoading(true);
try {
const randomCandidate = await openCandApi.getRandomCandidate();
navigate(`/candidato/${randomCandidate.idCandidato}`);
} catch (error) {
console.error('Erro ao buscar candidato aleatório:', error);
// You might want to show a toast notification or error message to the user
} finally {
setLoading(false);
}
};
return (
<Button
onClick={handleRandomCandidate}
className={`flex items-center relative overflow-hidden ${className}`}
hasAnimation={hasAnimation}
disabled={loading}
>
<ArrowPathIcon
className={`h-4 w-4 transition-transform duration-500 ${loading ? 'animate-spin' : 'mr-2'}`}
style={{ zIndex: 2 }}
/>
<span
className={`inline-block transition-all duration-300 origin-left whitespace-nowrap ${loading ? 'scale-x-0 max-w-0' : 'scale-x-100 max-w-xs'}`}
style={{ zIndex: 1, transitionProperty: 'transform, max-width' }}
>
Candidato aleatório
</span>
</Button>
);
};
export default RandomCandButton;

30
src/shared/WhiteCard.tsx Normal file
View File

@@ -0,0 +1,30 @@
import React from 'react';
interface WhiteCardProps {
children: React.ReactNode;
className?: string;
fullHeight?: boolean;
padding?: string;
}
const WhiteCard: React.FC<WhiteCardProps> = ({
children,
className = '',
fullHeight = false,
padding = 'py-6 pl-6 pr-4',
}) => {
return (
<div className={`
bg-white/95 border border-white/25 rounded-xl backdrop-blur-sm
shadow-lg hover:shadow-xl
${fullHeight ? 'h-full' : ''}
${className}
`}>
<div className={`${padding} ${fullHeight ? 'h-full flex flex-col' : ''}`}>
{children}
</div>
</div>
);
};
export default WhiteCard;

View File

@@ -1,19 +1,15 @@
/** /**
* Formats a CPF string with masking (123.***.789-10) * Formats a CPF string (might include masking) and masks it for display.
* @param cpf - A CPF string (11 digits without punctuation) * @param cpf - A CPF string (11 digits without punctuation)
* @returns Formatted CPF with masking or the original input if invalid * @returns Formatted CPF with masking or the original input if invalid
*/ */
export function maskCpf(cpf: string): string { export function maskCpf(cpf: string): string {
// Clean input, keeping only digits
const cleanCpf = cpf.replace(/\D/g, '');
// Validate if it's 11 digits // Validate if it's 11 digits
if (cleanCpf.length !== 11) { if (cpf.length !== 11) {
return cpf; return cpf;
} }
// Mask with standard punctuation: 123.456.789-10
// Format with mask: 123.***.789-10 return `${cpf.slice(0, 3)}.${cpf.slice(3, 6)}.${cpf.slice(6, 9)}-${cpf.slice(9)}`;
return `${cleanCpf.slice(0, 3)}.***.${cleanCpf.slice(6, 9)}-${cleanCpf.slice(9)}`;
} }
/** /**