Compare commits

...

31 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
83ff2131f7 despesas e receitas
All checks were successful
Frontend Build and Deploy / build (push) Successful in 22s
2025-06-07 15:18:29 -03:00
475979a09a more UI improvements
All checks were successful
Frontend Build and Deploy / build (push) Successful in 22s
2025-06-03 18:25:24 -03:00
222d25f1d2 design improvments 2.0
All checks were successful
Frontend Build and Deploy / build (push) Successful in 21s
2025-06-03 18:07:54 -03:00
d7a6a5ea29 design improvements
All checks were successful
Frontend Build and Deploy / build (push) Successful in 20s
2025-06-03 17:51:17 -03:00
8d49466f5d all about improvements
All checks were successful
Frontend Build and Deploy / build (push) Successful in 21s
2025-06-03 17:30:12 -03:00
c84053741c fixing lint
All checks were successful
Frontend Build and Deploy / build (push) Successful in 15s
2025-06-03 16:46:03 -03:00
0188dcd32b improving list of assets
Some checks failed
Frontend Build and Deploy / build (push) Failing after 49s
2025-06-03 16:26:48 -03:00
98b104b573 partido + melhorias
Some checks failed
Frontend Build and Deploy / build (push) Failing after 11s
2025-05-31 20:45:39 -03:00
57 changed files with 5231 additions and 320 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

@@ -23,6 +23,17 @@ export default tseslint.config(
'warn', 'warn',
{ allowConstantExport: true }, { allowConstantExport: true },
], ],
// no used vars ignored
'@typescript-eslint/no-unused-vars': [
'warn',
{
vars: 'all',
args: 'after-used',
ignoreRestSiblings: true,
varsIgnorePattern: '^_',
argsIgnorePattern: '^_',
},
],
}, },
}, },
) )

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>

1423
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -12,10 +12,13 @@
"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",
"react-router-dom": "^7.6.1" "react-icons": "^5.5.0",
"react-router-dom": "^7.6.1",
"recharts": "^2.15.3"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.25.0", "@eslint/js": "^9.25.0",
@@ -24,6 +27,7 @@
"@types/react": "^19.1.2", "@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2", "@types/react-dom": "^19.1.2",
"@types/react-router-dom": "^5.3.3", "@types/react-router-dom": "^5.3.3",
"@types/recharts": "^1.8.29",
"@vitejs/plugin-react": "^4.4.1", "@vitejs/plugin-react": "^4.4.1",
"autoprefixer": "^10.4.21", "autoprefixer": "^10.4.21",
"eslint": "^9.25.0", "eslint": "^9.25.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,5 +13,135 @@ 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 */
@keyframes fadeIn {
from { opacity: 0; transform: translateY(-10px); }
to { opacity: 1; transform: translateY(0); }
}
.animate-fadeIn {
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::-webkit-scrollbar {
width: 6px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 10px;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: #888;
border-radius: 10px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: #555;
}
.scrollbar-hide {
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* Internet Explorer and Edge */
overflow: -moz-scrollbars-none; /* Old versions of Firefox */
}
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,33 +1,70 @@
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 />
<div className="relative z-10"> <Navbar />
<Navbar /> <div className="relative z-10 flex-1 flex flex-col">
<Routes> <div className="flex-1">
<Route path="/" element={<HomePage />} /> <Routes>
<Route path="/candidato/:id" element={<CandidatePage />} /> <Route path="/" element={<HomePage />} />
</Routes> <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>
</div>
<Footer /> <Footer />
</div> </div>
</div> </div>

View File

@@ -3,20 +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;
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 {
@@ -29,6 +34,16 @@ export interface Election {
nrCandidato: string; nrCandidato: string;
nomeCandidato: string; nomeCandidato: string;
resultado: string; resultado: string;
partido: Partido;
}
export interface CandidateExt {
ano: number;
apelido: string;
email: string;
estadoCivil: string;
escolaridade: string;
ocupacao: string;
} }
export interface CandidateAssets { export interface CandidateAssets {
@@ -65,4 +80,87 @@ export interface PlatformStats {
export interface CpfRevealResult { export interface CpfRevealResult {
cpf: string; cpf: string;
}
export interface Partido {
nome: string;
sigla: string;
numero: number;
}
export interface CandidateExpenses {
despesas: Expense[];
}
export interface Expense {
idDespesa: string;
idCandidato: string;
ano: number;
turno: number;
sqCandidato: string;
sgPartido: string;
tipoFornecedor: string;
cpfFornecedor: string;
cnpjFornecedor: string;
nomeFornecedor: string;
nomeFornecedorRFB: string;
municipioFornecedor: string;
tipoDocumento: string;
dataDespesa: string; // ISO date
descricao: string;
origemDespesa: string;
valor: number;
}
export interface CandidateIncome {
receitas: Income[];
}
export interface Income {
idReceita: string;
idCandidato: string;
ano: number;
turno: number;
sqCandidato: string;
sgPartido: string;
fonteReceita: string;
origemReceita: string;
naturezaReceita: string;
especieReceita: string;
cpfDoador: string;
cnpjDoador: string;
nomeDoador: string;
nomeDoadorRFB: string;
municipioDoador: string;
dataReceita: string; // ISO date
descricao: string;
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

@@ -145,7 +145,7 @@ export class BaseApiClient {
// Create an AbortController for timeout handling // Create an AbortController for timeout handling
const controller = new AbortController(); const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout); const timeoutId = window.setTimeout(() => controller.abort(), timeout);
try { try {
const response = await fetch(url, { const response = await fetch(url, {

View File

@@ -10,6 +10,12 @@ export type {
CandidateRedesSociais, CandidateRedesSociais,
RedeSocial, RedeSocial,
PlatformStats, PlatformStats,
CpfRevealResult,
Partido,
CandidateExpenses,
Expense,
CandidateIncome,
Income,
} from './apiModels'; } from './apiModels';
// Export base API classes for custom implementations // Export base API classes for custom implementations

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, 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
*/ */
@@ -53,6 +77,61 @@ export class OpenCandApi extends BaseApiClient {
async getCandidateCpf(id: string): Promise<CpfRevealResult> { async getCandidateCpf(id: string): Promise<CpfRevealResult> {
return this.get<CpfRevealResult>(`/v1/candidato/${id}/reveal-cpf`); return this.get<CpfRevealResult>(`/v1/candidato/${id}/reveal-cpf`);
} }
/**
* Get the expenses for a specific candidate by ID and year
*/
async getCandidateDepesas(id: string): Promise<CandidateExpenses> {
return this.get<CandidateExpenses>(`/v1/candidato/${id}/despesas`);
}
/**
* Get the expenses for a specific candidate by ID and year
*/
async getCandidateReceitas(id: string): Promise<CandidateIncome> {
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,5 +1,6 @@
import React from 'react'; import React, { useState, useMemo } from 'react';
import { CurrencyDollarIcon } from '@heroicons/react/24/outline'; import { CurrencyDollarIcon, ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/24/outline';
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
import { type Asset } from '../../api'; import { type Asset } from '../../api';
interface AssetsComponentProps { interface AssetsComponentProps {
@@ -8,6 +9,18 @@ interface AssetsComponentProps {
} }
const AssetsComponent: React.FC<AssetsComponentProps> = ({ assets, isLoading }) => { const AssetsComponent: React.FC<AssetsComponentProps> = ({ assets, isLoading }) => {
const [expandedYears, setExpandedYears] = useState<Set<number>>(new Set());
const formatCurrencyCompact = (value: number) => {
if (value >= 1000000) {
return `R$ ${(value / 1000000).toFixed(1)}M`;
} else if (value >= 1000) {
return `R$ ${(value / 1000).toFixed(0)}K`;
} else {
return `R$ ${value.toFixed(0)}`;
}
};
const formatCurrency = (value: number) => { const formatCurrency = (value: number) => {
return new Intl.NumberFormat('pt-BR', { return new Intl.NumberFormat('pt-BR', {
style: 'currency', style: 'currency',
@@ -15,8 +28,46 @@ const AssetsComponent: React.FC<AssetsComponentProps> = ({ assets, isLoading })
}).format(value); }).format(value);
}; };
const groupedAssets = useMemo(() => {
if (!assets) return {};
return assets.reduce((acc, asset) => {
const year = asset.ano;
if (!acc[year]) {
acc[year] = [];
}
acc[year].push(asset);
return acc;
}, {} as Record<number, Asset[]>);
}, [assets]);
const sortedYears = useMemo(() => {
return Object.keys(groupedAssets).map(Number).sort((a, b) => b - a);
}, [groupedAssets]);
const toggleYear = (year: number) => {
setExpandedYears(prev => {
const newSet = new Set(prev);
if (newSet.has(year)) {
newSet.delete(year);
} else {
newSet.add(year);
}
return newSet;
});
};
const getTotalForYear = (yearAssets: Asset[]) => {
return yearAssets.reduce((total, asset) => total + asset.valor, 0);
};
const getTotalAssets = () => {
if (!assets) return 0;
return assets.reduce((total, asset) => total + asset.valor, 0);
};
return ( return (
<div className="bg-white/95 backdrop-blur-sm rounded-xl shadow-lg hover:shadow-xl transform hover:scale-[1.02] 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">
<CurrencyDollarIcon className="h-8 w-8 text-purple-600 mr-3" /> <CurrencyDollarIcon className="h-8 w-8 text-purple-600 mr-3" />
<h2 className="text-2xl font-bold text-gray-900">Patrimônio Declarado</h2> <h2 className="text-2xl font-bold text-gray-900">Patrimônio Declarado</h2>
@@ -28,40 +79,151 @@ const AssetsComponent: React.FC<AssetsComponentProps> = ({ assets, isLoading })
</div> </div>
) : assets && assets.length > 0 ? ( ) : assets && assets.length > 0 ? (
<> <>
<div className="overflow-x-auto"> <div className="space-y-4">
<table className="w-full table-auto"> {sortedYears.map((year) => {
<thead> const yearAssets = groupedAssets[year];
<tr className="border-b border-gray-200"> const isExpanded = expandedYears.has(year);
<th className="text-left py-3 px-2 font-semibold text-gray-700">Ano</th>
<th className="text-left py-3 px-2 font-semibold text-gray-700">Tipo</th> return (
<th className="text-left py-3 px-2 font-semibold text-gray-700">Descrição</th> <div key={year} className="border border-gray-200 rounded-lg overflow-hidden">
<th className="text-right py-3 px-2 font-semibold text-gray-700">Valor</th> {/* Year Header - Clickable */}
</tr> <button
</thead> onClick={() => toggleYear(year)}
<tbody> className="w-full px-4 py-4 bg-gray-50 hover:bg-gray-100 transition-colors duration-200 flex items-center justify-between focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-inset"
{assets.map((asset: Asset, index: number) => ( >
<tr key={`${asset.idCandidato}-${asset.ano}-${index}`} className="border-b border-gray-100 hover:bg-gray-50 transition-colors"> <div className="flex items-center space-x-3">
<td className="py-3 px-2 text-gray-900">{asset.ano}</td> <span className="text-lg font-semibold text-gray-900">{year}</span>
<td className="py-3 px-2 text-gray-900">{asset.tipoBem}</td> <span className="text-sm text-gray-600">
<td className="py-3 px-2 text-gray-900">{asset.descricao}</td> ({yearAssets.length} {yearAssets.length === 1 ? 'item' : 'itens'})
<td className="py-3 px-2 text-right text-gray-900 font-medium"> </span>
{formatCurrency(asset.valor)} </div>
</td> <div className="flex items-center space-x-3">
</tr> <span className="text-lg font-bold text-purple-600">
))} {formatCurrency(getTotalForYear(yearAssets))}
</tbody> </span>
</table> {isExpanded ? (
<ChevronUpIcon className="h-5 w-5 text-gray-500 transition-transform duration-200" />
) : (
<ChevronDownIcon className="h-5 w-5 text-gray-500 transition-transform duration-200" />
)}
</div>
</button>
{/* Assets Table - Collapsible */}
{isExpanded && (
<div className="bg-white animate-fadeIn">
<div className="overflow-x-auto">
<table className="w-full table-auto">
<thead>
<tr className="border-b border-gray-200 bg-gray-50">
<th className="text-left py-3 px-4 font-semibold text-gray-700">Tipo</th>
<th className="text-left py-3 px-4 font-semibold text-gray-700">Descrição</th>
<th className="text-right py-3 px-4 font-semibold text-gray-700">Valor</th>
</tr>
</thead>
<tbody>
{yearAssets.map((asset: Asset, index: number) => (
<tr
key={`${asset.idCandidato}-${asset.ano}-${index}`}
className="border-b border-gray-100 hover:bg-gray-50 transition-colors duration-150"
>
<td className="py-3 px-4 text-gray-900">{asset.tipoBem}</td>
<td className="py-3 px-4 text-gray-900">{asset.descricao}</td>
<td className="py-3 px-4 text-right text-gray-900 font-medium">
{formatCurrency(asset.valor)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
);
})}
</div> </div>
{/* Assets Progression Graph */}
{/* Total Assets */} {sortedYears.length > 1 && (
<div className="mt-4 pt-4 border-t border-gray-200"> <div className="mt-6 pt-6 border-t-2 border-gray-200">
<div className="flex justify-between items-center"> <h3 className="text-lg font-semibold text-gray-900 mb-4">Evolução Patrimonial Declarada</h3>
<span className="text-lg font-semibold text-gray-700">Total Declarado:</span> <div className="bg-gradient-to-br from-purple-50 to-green-50 p-6 rounded-lg shadow-inner">
<span className="text-xl font-bold text-purple-600"> <ResponsiveContainer width="100%" height={300}>
{formatCurrency(assets.reduce((total, asset) => total + asset.valor, 0))} <LineChart
</span> data={sortedYears.map(year => ({
year,
valor: getTotalForYear(groupedAssets[year])
})).sort((a, b) => a.year - b.year)}
margin={{
top: 20,
right: 30,
left: 30,
bottom: 20,
}}
>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis
dataKey="year"
stroke="#6b7280"
fontSize={12}
fontWeight={500}
/>
<YAxis
stroke="#6b7280"
fontSize={12}
fontWeight={500}
tickFormatter={(value) => formatCurrencyCompact(value)}
width={75}
/>
<Tooltip
contentStyle={{
backgroundColor: '#fefefe',
color: '#374151',
border: '1px solid #e5e7eb',
borderRadius: '8px',
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)',
}}
formatter={(value: number) => [formatCurrency(value), 'Patrimônio']}
labelStyle={{ color: '#374151', fontWeight: 600 }}
/>
<Line
type="monotone"
dataKey="valor"
stroke="url(#gradient)"
strokeWidth={3}
dot={{ fill: '#7c3aed', strokeWidth: 2, r: 6 }}
activeDot={{ r: 8, fill: '#059669' }}
/>
<defs>
<linearGradient id="gradient" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="#7c3aed" />
<stop offset="100%" stopColor="#059669" />
</linearGradient>
</defs>
</LineChart>
</ResponsiveContainer>
<div className="mt-3 text-center">
<p className="text-sm text-gray-600">
Variação total: {' '}
<span className={`font-semibold ${
sortedYears.length > 1 &&
getTotalForYear(groupedAssets[Math.max(...sortedYears)]) >
getTotalForYear(groupedAssets[Math.min(...sortedYears)])
? 'text-green-600'
: 'text-red-600'
}`}>
{sortedYears.length > 1 &&
formatCurrency(
getTotalForYear(groupedAssets[Math.max(...sortedYears)]) -
getTotalForYear(groupedAssets[Math.min(...sortedYears)])
)
}
</span>
</p>
</div>
</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,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,8 +44,84 @@ 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.02] 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">
<UserIcon className="h-8 w-8 text-blue-600 mr-3" /> <UserIcon className="h-8 w-8 text-blue-600 mr-3" />
<h2 className="text-2xl font-bold text-gray-900">Informações Básicas</h2> <h2 className="text-2xl font-bold text-gray-900">Informações Básicas</h2>
@@ -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,11 +1,15 @@
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, 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';
import AssetsComponent from './AssetsComponent'; 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 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 }>();
@@ -13,9 +17,13 @@ const CandidatePage: React.FC = () => {
const [candidateDetails, setCandidateDetails] = useState<CandidateDetails | null>(null); const [candidateDetails, setCandidateDetails] = useState<CandidateDetails | null>(null);
const [candidateAssets, setCandidateAssets] = useState<CandidateAssets | null>(null); const [candidateAssets, setCandidateAssets] = useState<CandidateAssets | null>(null);
const [candidateRedesSociais, setCandidateRedesSociais] = useState<CandidateRedesSociais | null>(null); const [candidateRedesSociais, setCandidateRedesSociais] = useState<CandidateRedesSociais | null>(null);
const [candidateExpenses, setCandidateExpenses] = useState<CandidateExpenses | null>(null);
const [candidateIncome, setCandidateIncome] = useState<CandidateIncome | null>(null);
const [isLoadingDetails, setIsLoadingDetails] = useState(true); const [isLoadingDetails, setIsLoadingDetails] = useState(true);
const [isLoadingAssets, setIsLoadingAssets] = useState(true); const [isLoadingAssets, setIsLoadingAssets] = useState(true);
const [isLoadingRedesSociais, setIsLoadingRedesSociais] = useState(true); const [isLoadingRedesSociais, setIsLoadingRedesSociais] = useState(true);
const [isLoadingExpenses, setIsLoadingExpenses] = useState(true);
const [isLoadingIncome, setIsLoadingIncome] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
@@ -70,25 +78,48 @@ const CandidatePage: React.FC = () => {
} }
}; };
// Fetch candidate expenses
const fetchCandidateExpenses = async () => {
try {
setIsLoadingExpenses(true);
const expenses = await openCandApi.getCandidateDepesas(id);
setCandidateExpenses(expenses);
} catch (err) {
console.error('Error fetching candidate expenses:', err);
// Expenses might not be available for all candidates, so we don't set error here
} finally {
setIsLoadingExpenses(false);
}
};
// Fetch candidate income
const fetchCandidateIncome = async () => {
try {
setIsLoadingIncome(true);
const income = await openCandApi.getCandidateReceitas(id);
setCandidateIncome(income);
} catch (err) {
console.error('Error fetching candidate income:', err);
// Income might not be available for all candidates, so we don't set error here
} finally {
setIsLoadingIncome(false);
}
};
fetchCandidateDetails(); fetchCandidateDetails();
fetchCandidateAssets(); fetchCandidateAssets();
fetchCandidateRedesSociais(); fetchCandidateRedesSociais();
fetchCandidateExpenses();
fetchCandidateIncome();
}, [id, navigate]); }, [id, navigate]);
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>
); );
} }
@@ -96,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>
@@ -138,6 +177,14 @@ const CandidatePage: React.FC = () => {
assets={candidateAssets?.bens || null} assets={candidateAssets?.bens || null}
isLoading={isLoadingAssets} isLoading={isLoadingAssets}
/> />
{/* Income and Expenses Panel */}
<IncomeExpenseComponent
expenses={candidateExpenses}
income={candidateIncome}
isLoadingExpenses={isLoadingExpenses}
isLoadingIncome={isLoadingIncome}
/>
</div> </div>
</div> </div>
</main> </main>

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;
@@ -22,7 +22,7 @@ const ElectionsComponent: React.FC<ElectionsComponentProps> = ({ elections, isLo
return ( return (
<div className="bg-white/95 backdrop-blur-sm rounded-xl shadow-lg hover:shadow-xl transform hover:scale-[1.02] 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">
<DocumentTextIcon className="h-8 w-8 text-green-600 mr-3" /> <DocumentTextIcon className="h-8 w-8 text-green-600 mr-3" />
<h2 className="text-2xl font-bold text-gray-900">Histórico de Eleições</h2> <h2 className="text-2xl font-bold text-gray-900">Histórico de Eleições</h2>
@@ -42,6 +42,7 @@ const ElectionsComponent: React.FC<ElectionsComponentProps> = ({ elections, isLo
<th className="text-left py-3 px-2 font-semibold text-gray-700">Cargo</th> <th className="text-left py-3 px-2 font-semibold text-gray-700">Cargo</th>
<th className="text-left py-3 px-2 font-semibold text-gray-700">UF</th> <th className="text-left py-3 px-2 font-semibold text-gray-700">UF</th>
<th className="text-left py-3 px-2 font-semibold text-gray-700">Localidade</th> <th className="text-left py-3 px-2 font-semibold text-gray-700">Localidade</th>
<th className="text-left py-3 px-2 font-semibold text-gray-700">Partido</th>
<th className="text-left py-3 px-2 font-semibold text-gray-700">Número</th> <th className="text-left py-3 px-2 font-semibold text-gray-700">Número</th>
<th className="text-left py-3 px-2 font-semibold text-gray-700">Resultado</th> <th className="text-left py-3 px-2 font-semibold text-gray-700">Resultado</th>
</tr> </tr>
@@ -54,6 +55,7 @@ const ElectionsComponent: React.FC<ElectionsComponentProps> = ({ elections, isLo
<td className="py-3 px-2 text-gray-900">{election.cargo}</td> <td className="py-3 px-2 text-gray-900">{election.cargo}</td>
<td className="py-3 px-2 text-gray-900">{election.siglaUF}</td> <td className="py-3 px-2 text-gray-900">{election.siglaUF}</td>
<td className="py-3 px-2 text-gray-900">{election.nomeUE}</td> <td className="py-3 px-2 text-gray-900">{election.nomeUE}</td>
<td className="py-3 px-2 text-gray-900">{election.partido.sigla}</td>
<td className="py-3 px-2 text-gray-900">{election.nrCandidato}</td> <td className="py-3 px-2 text-gray-900">{election.nrCandidato}</td>
<td className="py-3 px-2"> <td className="py-3 px-2">
<Tooltip <Tooltip

View File

@@ -0,0 +1,321 @@
import React, { useState, useMemo } from 'react';
import {
ChevronDownIcon,
ChevronUpIcon,
ArrowTrendingDownIcon,
CalendarIcon,
UserIcon,
BuildingOfficeIcon
} from '@heroicons/react/24/outline';
import { type CandidateExpenses, type Expense } from '../../../api';
interface ExpenseSectionProps {
expenses: CandidateExpenses | null;
isLoadingExpenses: boolean;
}
const ExpenseSection: React.FC<ExpenseSectionProps> = ({
expenses,
isLoadingExpenses
}) => {
const [expandedYears, setExpandedYears] = useState<Set<number>>(new Set());
const [visibleExpenseItems, setVisibleExpenseItems] = useState<Record<number, number>>({});
const [expandedExpenseItems, setExpandedExpenseItems] = useState<Set<string>>(new Set());
const formatCurrency = (value: number) => {
return new Intl.NumberFormat('pt-BR', {
style: 'currency',
currency: 'BRL'
}).format(value);
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('pt-BR');
};
const groupExpensesByYear = useMemo(() => {
const despesas = expenses?.despesas || [];
if (!despesas.length) return {};
return despesas.reduce((acc: Record<number, Expense[]>, item: Expense) => {
const year = item.ano;
if (!acc[year]) {
acc[year] = [];
}
acc[year].push(item);
return acc;
}, {} as Record<number, Expense[]>);
}, [expenses]);
// Sort items by value (descending) for each year
const sortedExpensesByYear = useMemo(() => {
const sorted: Record<number, Expense[]> = {};
Object.keys(groupExpensesByYear).forEach(year => {
const yearNum = Number(year);
sorted[yearNum] = [...groupExpensesByYear[yearNum]].sort((a, b) => b.valor - a.valor);
});
return sorted;
}, [groupExpensesByYear]);
const sortedExpenseYears = useMemo(() => {
return Object.keys(sortedExpensesByYear).map(Number).sort((a, b) => b - a);
}, [sortedExpensesByYear]);
const getTotalExpenses = () => {
const despesas = expenses?.despesas || [];
return despesas.reduce((total: number, item: Expense) => total + item.valor, 0);
};
const getTotalForExpenseYear = (yearExpenses: Expense[]) => {
return yearExpenses.reduce((total, item) => total + item.valor, 0);
};
const getVisibleExpenseItemsForYear = (year: number) => {
return visibleExpenseItems[year] || 50;
};
const loadMoreExpenseItems = (year: number) => {
setVisibleExpenseItems(prev => ({
...prev,
[year]: (prev[year] || 50) + 50
}));
};
const toggleYear = (year: number) => {
setExpandedYears(prev => {
const newSet = new Set(prev);
if (newSet.has(year)) {
newSet.delete(year);
} else {
newSet.add(year);
}
return newSet;
});
};
const toggleExpenseItem = (itemId: string) => {
setExpandedExpenseItems(prev => {
const newSet = new Set(prev);
if (newSet.has(itemId)) {
newSet.delete(itemId);
} else {
newSet.add(itemId);
}
return newSet;
});
};
const despesas = expenses?.despesas || [];
const isEmpty = despesas.length === 0;
return (
<div>
<div className="flex items-center mb-4">
<ArrowTrendingDownIcon className="h-6 w-6 text-red-600 mr-2" />
<h3 className="text-xl font-bold text-gray-900">Despesas</h3>
</div>
{isLoadingExpenses ? (
<div className="flex items-center justify-center h-24">
<div className="animate-spin rounded-full h-6 w-6 border-4 border-red-600 border-t-transparent"></div>
</div>
) : !isEmpty ? (
<>
{/* Summary Card */}
<div className="bg-gradient-to-r from-red-50 to-orange-50 p-4 rounded-lg mb-4 border border-red-100">
<div className="flex items-center justify-between">
<span className="text-lg font-semibold text-gray-900">Total de Despesas</span>
<span className="text-xl font-bold text-red-600">
{formatCurrency(getTotalExpenses())}
</span>
</div>
</div>
<div className="space-y-3">
{sortedExpenseYears.map((year) => {
const yearExpenses = sortedExpensesByYear[year];
const isExpanded = expandedYears.has(year + 10000); // Add offset to avoid conflicts with income years
const visibleCount = getVisibleExpenseItemsForYear(year);
const hasMore = yearExpenses.length > visibleCount;
const visibleItems = yearExpenses.slice(0, visibleCount);
return (
<div key={`expense-${year}`} className="border border-gray-200 rounded-lg overflow-hidden">
<button
onClick={() => toggleYear(year + 10000)}
className="w-full px-4 py-3 bg-gray-50 hover:bg-gray-100 flex items-center justify-between transition-colors"
>
<div className="flex items-center">
<CalendarIcon className="h-5 w-5 text-gray-600 mr-2" />
<span className="font-semibold text-gray-900">{year}</span>
<span className="ml-2 text-sm text-gray-600">
({yearExpenses.length} {yearExpenses.length === 1 ? 'item' : 'itens'})
</span>
</div>
<div className="flex items-center">
<span className="font-bold text-red-600 mr-2">
{formatCurrency(getTotalForExpenseYear(yearExpenses))}
</span>
{isExpanded ? (
<ChevronUpIcon className="h-5 w-5 text-gray-600" />
) : (
<ChevronDownIcon className="h-5 w-5 text-gray-600" />
)}
</div>
</button>
{isExpanded && (
<div className="bg-white">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-gray-50 border-b">
<tr>
<th className="px-4 py-3 text-left font-medium text-gray-900">Data</th>
<th className="px-4 py-3 text-left font-medium text-gray-900">Fornecedor</th>
<th className="px-4 py-3 text-left font-medium text-gray-900">Descrição</th>
<th className="px-4 py-3 text-right font-medium text-gray-900">Valor</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{visibleItems.map((item, index) => {
const itemId = `expense-${item.ano}-${index}`;
const isItemExpanded = expandedExpenseItems.has(itemId);
return (
<React.Fragment key={itemId}>
<tr
className="hover:bg-gray-50 cursor-pointer transition-colors"
onClick={() => toggleExpenseItem(itemId)}
>
<td className="px-4 py-3 text-gray-900">
{formatDate(item.dataDespesa)}
</td>
<td className="px-4 py-3">
<div className="flex items-center">
{item.cpfFornecedor ? (
<UserIcon className="h-4 w-4 text-gray-400 mr-2" />
) : (
<BuildingOfficeIcon className="h-4 w-4 text-gray-400 mr-2" />
)}
<span className="text-gray-900 font-medium">
{item.nomeFornecedor || item.nomeFornecedorRFB || 'Fornecedor não informado'}
</span>
</div>
</td>
<td className="px-4 py-3 text-gray-600 max-w-xs truncate">
{item.descricao || 'Descrição não informada'}
</td>
<td className="px-4 py-3 text-right font-semibold">
<div className="flex items-center justify-end">
<span className="text-red-600 mr-2">
{formatCurrency(item.valor)}
</span>
{isItemExpanded ? (
<ChevronUpIcon className="h-4 w-4 text-gray-400" />
) : (
<ChevronDownIcon className="h-4 w-4 text-gray-400" />
)}
</div>
</td>
</tr>
{isItemExpanded && (
<tr>
<td colSpan={4} className="px-4 py-3 bg-gray-50 border-l-4 border-red-500">
<div className="space-y-3">
<h4 className="font-semibold text-gray-900 mb-3">Detalhes da Despesa</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
<div>
<span className="text-gray-600 font-medium">ID:</span>
<span className="ml-2 text-gray-900">{item.idDespesa}</span>
</div>
<div>
<span className="text-gray-600 font-medium">Ano:</span>
<span className="ml-2 text-gray-900">{item.ano}</span>
</div>
<div>
<span className="text-gray-600 font-medium">Turno:</span>
<span className="ml-2 text-gray-900">{item.turno}</span>
</div>
<div>
<span className="text-gray-600 font-medium">Partido:</span>
<span className="ml-2 text-gray-900">{item.sgPartido}</span>
</div>
<div>
<span className="text-gray-600 font-medium">Tipo Fornecedor:</span>
<span className="ml-2 text-gray-900">{item.tipoFornecedor || '-'}</span>
</div>
<div>
<span className="text-gray-600 font-medium">Tipo Documento:</span>
<span className="ml-2 text-gray-900">{item.tipoDocumento || '-'}</span>
</div>
<div>
<span className="text-gray-600 font-medium">Origem:</span>
<span className="ml-2 text-gray-900">{item.origemDespesa || '-'}</span>
</div>
{item.cpfFornecedor && (
<div>
<span className="text-gray-600 font-medium">CPF:</span>
<span className="ml-2 text-gray-900">{item.cpfFornecedor}</span>
</div>
)}
{item.cnpjFornecedor && (
<div>
<span className="text-gray-600 font-medium">CNPJ:</span>
<span className="ml-2 text-gray-900">{item.cnpjFornecedor}</span>
</div>
)}
{item.municipioFornecedor && (
<div>
<span className="text-gray-600 font-medium">Município:</span>
<span className="ml-2 text-gray-900">{item.municipioFornecedor}</span>
</div>
)}
</div>
{item.descricao && (
<div className="mt-3 pt-3 border-t border-gray-200">
<span className="text-gray-600 font-medium">Descrição Completa:</span>
<p className="ml-2 text-gray-900 mt-1">{item.descricao}</p>
</div>
)}
</div>
</td>
</tr>
)}
</React.Fragment>
);
})}
</tbody>
</table>
</div>
{/* Load More Button */}
{hasMore && (
<div className="p-4 border-t border-gray-200 bg-gray-50 text-center">
<button
onClick={() => loadMoreExpenseItems(year)}
className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors font-medium"
>
Carregar mais 50 itens ({yearExpenses.length - visibleCount} restantes)
</button>
</div>
)}
</div>
)}
</div>
);
})}
</div>
</>
) : (
<div className="text-center text-gray-500 py-6">
<div className="flex flex-col items-center">
<ArrowTrendingDownIcon className="h-10 w-10 text-gray-300 mb-2" />
<span>Nenhuma despesa encontrada</span>
</div>
</div>
)}
</div>
);
};
export default ExpenseSection;

View File

@@ -0,0 +1,321 @@
import React, { useState, useMemo } from 'react';
import {
ChevronDownIcon,
ChevronUpIcon,
ArrowTrendingUpIcon,
CalendarIcon,
UserIcon,
BuildingOfficeIcon
} from '@heroicons/react/24/outline';
import { type CandidateIncome, type Income } from '../../../api';
interface IncomeSectionProps {
income: CandidateIncome | null;
isLoadingIncome: boolean;
}
const IncomeSection: React.FC<IncomeSectionProps> = ({
income,
isLoadingIncome
}) => {
const [expandedYears, setExpandedYears] = useState<Set<number>>(new Set());
const [visibleIncomeItems, setVisibleIncomeItems] = useState<Record<number, number>>({});
const [expandedIncomeItems, setExpandedIncomeItems] = useState<Set<string>>(new Set());
const formatCurrency = (value: number) => {
return new Intl.NumberFormat('pt-BR', {
style: 'currency',
currency: 'BRL'
}).format(value);
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('pt-BR');
};
const groupIncomeByYear = useMemo(() => {
const receitas = income?.receitas || [];
if (!receitas.length) return {};
return receitas.reduce((acc: Record<number, Income[]>, item: Income) => {
const year = item.ano;
if (!acc[year]) {
acc[year] = [];
}
acc[year].push(item);
return acc;
}, {} as Record<number, Income[]>);
}, [income]);
// Sort items by value (descending) for each year
const sortedIncomeByYear = useMemo(() => {
const sorted: Record<number, Income[]> = {};
Object.keys(groupIncomeByYear).forEach(year => {
const yearNum = Number(year);
sorted[yearNum] = [...groupIncomeByYear[yearNum]].sort((a, b) => b.valor - a.valor);
});
return sorted;
}, [groupIncomeByYear]);
const sortedIncomeYears = useMemo(() => {
return Object.keys(sortedIncomeByYear).map(Number).sort((a, b) => b - a);
}, [sortedIncomeByYear]);
const getTotalIncome = () => {
const receitas = income?.receitas || [];
return receitas.reduce((total: number, item: Income) => total + item.valor, 0);
};
const getTotalForIncomeYear = (yearIncome: Income[]) => {
return yearIncome.reduce((total, item) => total + item.valor, 0);
};
const getVisibleIncomeItemsForYear = (year: number) => {
return visibleIncomeItems[year] || 50;
};
const loadMoreIncomeItems = (year: number) => {
setVisibleIncomeItems(prev => ({
...prev,
[year]: (prev[year] || 50) + 50
}));
};
const toggleYear = (year: number) => {
setExpandedYears(prev => {
const newSet = new Set(prev);
if (newSet.has(year)) {
newSet.delete(year);
} else {
newSet.add(year);
}
return newSet;
});
};
const toggleIncomeItem = (itemId: string) => {
setExpandedIncomeItems(prev => {
const newSet = new Set(prev);
if (newSet.has(itemId)) {
newSet.delete(itemId);
} else {
newSet.add(itemId);
}
return newSet;
});
};
const receitas = income?.receitas || [];
const isEmpty = receitas.length === 0;
return (
<div className="mb-8">
<div className="flex items-center mb-4">
<ArrowTrendingUpIcon className="h-6 w-6 text-green-600 mr-2" />
<h3 className="text-xl font-bold text-gray-900">Receitas</h3>
</div>
{isLoadingIncome ? (
<div className="flex items-center justify-center h-24">
<div className="animate-spin rounded-full h-6 w-6 border-4 border-green-600 border-t-transparent"></div>
</div>
) : !isEmpty ? (
<>
{/* Summary Card */}
<div className="bg-gradient-to-r from-green-50 to-blue-50 p-4 rounded-lg mb-4 border border-green-100">
<div className="flex items-center justify-between">
<span className="text-lg font-semibold text-gray-900">Total de Receitas</span>
<span className="text-xl font-bold text-green-600">
{formatCurrency(getTotalIncome())}
</span>
</div>
</div>
<div className="space-y-3">
{sortedIncomeYears.map((year) => {
const yearIncome = sortedIncomeByYear[year];
const isExpanded = expandedYears.has(year);
const visibleCount = getVisibleIncomeItemsForYear(year);
const hasMore = yearIncome.length > visibleCount;
const visibleItems = yearIncome.slice(0, visibleCount);
return (
<div key={`income-${year}`} className="border border-gray-200 rounded-lg overflow-hidden">
<button
onClick={() => toggleYear(year)}
className="w-full px-4 py-3 bg-gray-50 hover:bg-gray-100 flex items-center justify-between transition-colors"
>
<div className="flex items-center">
<CalendarIcon className="h-5 w-5 text-gray-600 mr-2" />
<span className="font-semibold text-gray-900">{year}</span>
<span className="ml-2 text-sm text-gray-600">
({yearIncome.length} {yearIncome.length === 1 ? 'item' : 'itens'})
</span>
</div>
<div className="flex items-center">
<span className="font-bold text-green-600 mr-2">
{formatCurrency(getTotalForIncomeYear(yearIncome))}
</span>
{isExpanded ? (
<ChevronUpIcon className="h-5 w-5 text-gray-600" />
) : (
<ChevronDownIcon className="h-5 w-5 text-gray-600" />
)}
</div>
</button>
{isExpanded && (
<div className="bg-white">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-gray-50 border-b">
<tr>
<th className="px-4 py-3 text-left font-medium text-gray-900">Data</th>
<th className="px-4 py-3 text-left font-medium text-gray-900">Doador</th>
<th className="px-4 py-3 text-left font-medium text-gray-900">Descrição</th>
<th className="px-4 py-3 text-right font-medium text-gray-900">Valor</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{visibleItems.map((item, index) => {
const itemId = `income-${item.ano}-${index}`;
const isItemExpanded = expandedIncomeItems.has(itemId);
return (
<React.Fragment key={itemId}>
<tr
className="hover:bg-gray-50 cursor-pointer transition-colors"
onClick={() => toggleIncomeItem(itemId)}
>
<td className="px-4 py-3 text-gray-900">
{formatDate(item.dataReceita)}
</td>
<td className="px-4 py-3">
<div className="flex items-center">
{item.cpfDoador ? (
<UserIcon className="h-4 w-4 text-gray-400 mr-2" />
) : (
<BuildingOfficeIcon className="h-4 w-4 text-gray-400 mr-2" />
)}
<span className="text-gray-900 font-medium">
{item.nomeDoador || item.nomeDoadorRFB || 'Doador não informado'}
</span>
</div>
</td>
<td className="px-4 py-3 text-gray-600 max-w-xs truncate">
{item.descricao || 'Descrição não informada'}
</td>
<td className="px-4 py-3 text-right font-semibold">
<div className="flex items-center justify-end">
<span className="text-green-600 mr-2">
{formatCurrency(item.valor)}
</span>
{isItemExpanded ? (
<ChevronUpIcon className="h-4 w-4 text-gray-400" />
) : (
<ChevronDownIcon className="h-4 w-4 text-gray-400" />
)}
</div>
</td>
</tr>
{isItemExpanded && (
<tr>
<td colSpan={4} className="px-4 py-3 bg-gray-50 border-l-4 border-green-500">
<div className="space-y-3">
<h4 className="font-semibold text-gray-900 mb-3">Detalhes da Receita</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
<div>
<span className="text-gray-600 font-medium">ID:</span>
<span className="ml-2 text-gray-900">{item.idReceita}</span>
</div>
<div>
<span className="text-gray-600 font-medium">Ano:</span>
<span className="ml-2 text-gray-900">{item.ano}</span>
</div>
<div>
<span className="text-gray-600 font-medium">Turno:</span>
<span className="ml-2 text-gray-900">{item.turno}</span>
</div>
<div>
<span className="text-gray-600 font-medium">Fonte:</span>
<span className="ml-2 text-gray-900">{item.fonteReceita || '-'}</span>
</div>
<div>
<span className="text-gray-600 font-medium">Origem:</span>
<span className="ml-2 text-gray-900">{item.origemReceita || '-'}</span>
</div>
<div>
<span className="text-gray-600 font-medium">Natureza:</span>
<span className="ml-2 text-gray-900">{item.naturezaReceita || '-'}</span>
</div>
<div>
<span className="text-gray-600 font-medium">Espécie:</span>
<span className="ml-2 text-gray-900">{item.especieReceita || '-'}</span>
</div>
{item.cpfDoador && (
<div>
<span className="text-gray-600 font-medium">CPF:</span>
<span className="ml-2 text-gray-900">{item.cpfDoador}</span>
</div>
)}
{item.cnpjDoador && (
<div>
<span className="text-gray-600 font-medium">CNPJ:</span>
<span className="ml-2 text-gray-900">{item.cnpjDoador}</span>
</div>
)}
{item.municipioDoador && (
<div>
<span className="text-gray-600 font-medium">Município:</span>
<span className="ml-2 text-gray-900">{item.municipioDoador}</span>
</div>
)}
</div>
{item.descricao && (
<div className="mt-3 pt-3 border-t border-gray-200">
<span className="text-gray-600 font-medium">Descrição Completa:</span>
<p className="ml-2 text-gray-900 mt-1">{item.descricao}</p>
</div>
)}
</div>
</td>
</tr>
)}
</React.Fragment>
);
})}
</tbody>
</table>
</div>
{/* Load More Button */}
{hasMore && (
<div className="p-4 border-t border-gray-200 bg-gray-50 text-center">
<button
onClick={() => loadMoreIncomeItems(year)}
className="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg transition-colors font-medium"
>
Carregar mais 50 itens ({yearIncome.length - visibleCount} restantes)
</button>
</div>
)}
</div>
)}
</div>
);
})}
</div>
</>
) : (
<div className="text-center text-gray-500 py-6">
<div className="flex flex-col items-center">
<ArrowTrendingUpIcon className="h-10 w-10 text-gray-300 mb-2" />
<span>Nenhuma receita encontrada</span>
</div>
</div>
)}
</div>
);
};
export default IncomeSection;

View File

@@ -0,0 +1,70 @@
import React from 'react';
import { BanknotesIcon } from '@heroicons/react/24/outline';
import { type CandidateExpenses, type CandidateIncome } from '../../api';
import IncomeSection from './IncomeExpense/IncomeSection';
import ExpenseSection from './IncomeExpense/ExpenseSection';
interface IncomeExpenseComponentProps {
expenses: CandidateExpenses | null;
income: CandidateIncome | null;
isLoadingExpenses: 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> = ({
expenses,
income,
isLoadingExpenses,
isLoadingIncome
}) => {
const showIncome = hasIncomeData(income);
const showExpenses = hasExpenseData(expenses);
if (!showIncome && !showExpenses) return null;
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="flex items-center mb-6">
<BanknotesIcon className="h-8 w-8 text-green-600 mr-3" />
<h2 className="text-2xl font-bold text-gray-900">Receitas e Despesas</h2>
</div>
{/* Income Section */}
{showIncome && (
<IncomeSection
income={income}
isLoadingIncome={isLoadingIncome}
/>
)}
{/* Separator */}
{showIncome && showExpenses && (
<div className="flex items-center my-8">
<div className="flex-grow border-t border-gray-300"></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>
)}
{/* Expenses Section */}
{showExpenses && (
<ExpenseSection
expenses={expenses}
isLoadingExpenses={isLoadingExpenses}
/>
)}
</div>
);
};
export default IncomeExpenseComponent;

View File

@@ -1,5 +1,18 @@
import React from 'react'; import React from 'react';
import { LinkIcon } from '@heroicons/react/24/outline'; import { LinkIcon } from '@heroicons/react/24/outline';
import {
FaFacebook,
FaInstagram,
FaTwitter,
FaTiktok,
FaYoutube,
FaLinkedin,
FaWhatsapp,
FaTelegram,
FaSpotify,
FaLink
} from 'react-icons/fa';
import { FaXTwitter, FaThreads } from 'react-icons/fa6';
import { type RedeSocial } from '../../api'; import { type RedeSocial } from '../../api';
interface SocialMediaComponentProps { interface SocialMediaComponentProps {
@@ -7,12 +20,51 @@ interface SocialMediaComponentProps {
isLoading: boolean; isLoading: boolean;
} }
// Helper function to get social media icons
const getSocialMediaIcon = (rede: string, isRecent: boolean = true): React.ReactElement => {
const iconClass = "h-5 w-5 mr-2";
const opacityClass = isRecent ? "" : "opacity-50";
switch (rede.toLowerCase()) {
case 'facebook':
return <FaFacebook className={`${iconClass} ${opacityClass} text-blue-600`} />;
case 'instagram':
return <FaInstagram className={`${iconClass} ${opacityClass} text-pink-600`} />;
case 'x/twitter':
case 'twitter':
return <FaXTwitter className={`${iconClass} ${opacityClass} text-black`} />;
case 'tiktok':
return <FaTiktok className={`${iconClass} ${opacityClass} text-black`} />;
case 'youtube':
return <FaYoutube className={`${iconClass} ${opacityClass} text-red-600`} />;
case 'linkedin':
return <FaLinkedin className={`${iconClass} ${opacityClass} text-blue-700`} />;
case 'whatsapp':
return <FaWhatsapp className={`${iconClass} ${opacityClass} text-green-600`} />;
case 'threads':
return <FaThreads className={`${iconClass} ${opacityClass} text-black`} />;
case 'telegram':
return <FaTelegram className={`${iconClass} ${opacityClass} text-blue-500`} />;
case 'spotify':
return <FaSpotify className={`${iconClass} ${opacityClass} text-green-500`} />;
case 'kwai':
case 'outros':
default:
return <FaLink className={`${iconClass} ${opacityClass} text-gray-600`} />;
}
};
const SocialMediaComponent: React.FC<SocialMediaComponentProps> = ({ 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.02] 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">
<LinkIcon className="h-8 w-8 text-orange-600 mr-3" /> <LinkIcon className="h-8 w-8 text-orange-600 mr-3" />
<h2 className="text-2xl font-bold text-gray-900">Redes Sociais</h2> <h2 className="text-2xl font-bold text-gray-900">Redes Sociais</h2>
@@ -24,26 +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}`}
<span className="font-semibold text-gray-900 mr-2">{redeSocial.rede}</span> className={`border border-gray-200 rounded-lg p-3 hover:bg-gray-50 transition-colors ${
<span className="text-sm text-gray-500">({redeSocial.ano})</span> isRecent ? '' : 'opacity-95 hover:bg-gray-100'
}`}
>
<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,32 +1,42 @@
import React from 'react'; import React from 'react';
import { 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/40 p-6 rounded-lg"> <Card>
<Icon className="h-10 w-10 text-indigo-400 mb-4" /> <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="grid md:grid-cols-3 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 icon={MagnifyingGlassIcon} title="Informações de Campanha">
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 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.
</FeatureCard> </FeatureCard>
</div> </div>
</div> </div>

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 p-4 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="relative z-10 text-center max-w-3xl"> <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">
<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

@@ -27,17 +27,17 @@ const MatrixBackground: React.FC = () => {
const mouseRef = useRef({ x: 0, y: 0 }); const mouseRef = useRef({ x: 0, y: 0 });
const [, setDimensions] = useState({ width: 0, height: 0 }); const [, setDimensions] = useState({ width: 0, height: 0 });
// Configuration // Configuration - subtle background for main content
const config = { const config = {
dotCount: 300, dotCount: 400,
maxConnections: 3, maxConnections: 3,
connectionDistance: 150, connectionDistance: 150,
dotSpeed: 0.3, dotSpeed: 0.3,
hoverRadius: 120, hoverRadius: 120,
baseBrightness: 0.25, // Reduced to 30% opacity (0.3 * 0.3) baseBrightness: 0.2, // Moderate base brightness for main background
hoverBrightness: 0.6, // Reduced hover brightness hoverBrightness: 0.5, // Moderate hover brightness
baseThickness: 0.5, baseThickness: 0.6,
hoverThickness: 1.5, hoverThickness: 1.8,
fadeSpeed: 0.08, // Slightly faster fade for better responsiveness fadeSpeed: 0.08, // Slightly faster fade for better responsiveness
}; };
@@ -123,25 +123,38 @@ const MatrixBackground: React.FC = () => {
}); });
// Check which dots are near the mouse // Check which dots are near the mouse
let hoveredCount = 0;
dots.forEach((dot, index) => { dots.forEach((dot, index) => {
const dx = dot.x - mouseX; const dx = dot.x - mouseX;
const dy = dot.y - mouseY; const dy = dot.y - mouseY;
const distance = Math.sqrt(dx * dx + dy * dy); const distance = Math.sqrt(dx * dx + dy * dy);
if (distance < config.hoverRadius) { if (distance < config.hoverRadius) {
hoveredCount++;
const intensity = 1 - (distance / config.hoverRadius); const intensity = 1 - (distance / config.hoverRadius);
dot.targetBrightness = config.baseBrightness + dot.targetBrightness = config.baseBrightness + (config.hoverBrightness - config.baseBrightness) * intensity;
(config.hoverBrightness - config.baseBrightness) * intensity;
// Brighten connected lines // Enhance connected dots and their connections
connections.forEach(conn => { dot.connections.forEach(connIndex => {
if (conn.from === index || conn.to === index) { if (dots[connIndex]) {
conn.targetBrightness = config.baseBrightness + dots[connIndex].targetBrightness = Math.max(
(config.hoverBrightness - config.baseBrightness) * intensity * 0.7; dots[connIndex].targetBrightness,
conn.targetThickness = config.baseThickness + config.baseBrightness + (config.hoverBrightness - config.baseBrightness) * intensity * 0.5
(config.hoverThickness - config.baseThickness) * intensity; );
}
// Find and enhance the connection
const connection = connections.find(
conn => (conn.from === index && conn.to === connIndex) ||
(conn.from === connIndex && conn.to === index)
);
if (connection) {
connection.targetBrightness = Math.max(
connection.targetBrightness,
config.baseBrightness + (config.hoverBrightness - config.baseBrightness) * intensity
);
connection.targetThickness = Math.max(
connection.targetThickness,
config.baseThickness + (config.hoverThickness - config.baseThickness) * intensity
);
} }
}); });
} }
@@ -182,11 +195,11 @@ const MatrixBackground: React.FC = () => {
// Bounce off edges // Bounce off edges
if (dot.x <= 0 || dot.x >= width) { if (dot.x <= 0 || dot.x >= width) {
dot.vx *= -1; dot.vx = -dot.vx;
dot.x = Math.max(0, Math.min(width, dot.x)); dot.x = Math.max(0, Math.min(width, dot.x));
} }
if (dot.y <= 0 || dot.y >= height) { if (dot.y <= 0 || dot.y >= height) {
dot.vy *= -1; dot.vy = -dot.vy;
dot.y = Math.max(0, Math.min(height, dot.y)); dot.y = Math.max(0, Math.min(height, dot.y));
} }
}); });
@@ -199,14 +212,14 @@ const MatrixBackground: React.FC = () => {
// Update hover effects // Update hover effects
updateHoverEffects(dots, connections, mouseRef.current.x, mouseRef.current.y); updateHoverEffects(dots, connections, mouseRef.current.x, mouseRef.current.y);
// Draw connections // Draw connections with moderate opacity for main background
connections.forEach(conn => { connections.forEach(conn => {
const fromDot = dots[conn.from]; const fromDot = dots[conn.from];
const toDot = dots[conn.to]; const toDot = dots[conn.to];
if (!fromDot || !toDot) return; if (!fromDot || !toDot) return;
ctx.strokeStyle = `rgba(100, 200, 255, ${conn.brightness * 0.3})`; // Changed to blue with 30% opacity ctx.strokeStyle = `rgba(100, 200, 255, ${conn.brightness * 0.25})`;
ctx.lineWidth = conn.thickness; ctx.lineWidth = conn.thickness;
ctx.lineCap = 'round'; ctx.lineCap = 'round';
@@ -216,15 +229,15 @@ const MatrixBackground: React.FC = () => {
ctx.stroke(); ctx.stroke();
}); });
// Draw dots // Draw dots with moderate opacity for main background
dots.forEach(dot => { dots.forEach(dot => {
ctx.fillStyle = `rgba(100, 200, 255, ${dot.brightness * 0.3})`; // Changed to blue with 30% opacity ctx.fillStyle = `rgba(100, 200, 255, ${dot.brightness * 0.25})`;
ctx.beginPath(); ctx.beginPath();
ctx.arc(dot.x, dot.y, 2, 0, Math.PI * 2); ctx.arc(dot.x, dot.y, 2, 0, Math.PI * 2);
ctx.fill(); ctx.fill();
// Add subtle glow // Add subtle glow
ctx.shadowColor = `rgba(100, 200, 255, ${dot.brightness * 0.15})`; // Reduced glow opacity ctx.shadowColor = `rgba(100, 200, 255, ${dot.brightness * 0.12})`;
ctx.shadowBlur = 4; ctx.shadowBlur = 4;
ctx.beginPath(); ctx.beginPath();
ctx.arc(dot.x, dot.y, 1.5, 0, Math.PI * 2); ctx.arc(dot.x, dot.y, 1.5, 0, Math.PI * 2);
@@ -270,7 +283,7 @@ const MatrixBackground: React.FC = () => {
const canvas = canvasRef.current; const canvas = canvasRef.current;
if (!canvas) { if (!canvas) {
if (process.env.NODE_ENV === 'development') { if (process.env.NODE_ENV === 'development') {
console.log('MatrixBackground: Canvas not found'); console.log('MatrixBackground: Canvas ref not found');
} }
return; return;
} }

View File

@@ -1,19 +1,29 @@
import React from 'react'; import React, { useEffect } from 'react';
import Button from '../shared/Button';
import NavbarMatrixBackground from './NavbarMatrixBackground';
const Navbar: React.FC = () => { const Navbar: React.FC = () => {
return ( return (
<nav className="bg-gray-900/50 backdrop-blur-md text-white p-4 fixed inset-x-0 top-0 z-50"> <>
<div className="container mx-auto flex justify-between items-center"> {/* Matrix background stays at top of page (not sticky) */}
<a href="/" className="text-2xl font-bold text-indigo-400 hover:text-indigo-300 transition-colors"> <div className="relative bg-gray-900/15 backdrop-blur-sm text-white overflow-hidden border-b border-blue-500/20">
OpenCand <NavbarMatrixBackground />
</a> <div className="relative z-10 p-4">
<div className="space-x-4"> <div className="container mx-auto flex justify-between items-center">
<a href="#stats" className="hover:text-indigo-300 transition-colors">Estatíscas</a> <a href="/" className="flex items-center gap-2 text-2xl font-bold text-indigo-400 hover:text-indigo-300 transition-colors">
<a href="#features" className="hover:text-indigo-300 transition-colors">Recursos</a> <img src="/assets/opencand-line.png" alt="OpenCand logo" className="h-8 w-auto" />
<a href="/about" className="hover:text-indigo-300 transition-colors">Sobre</a> <span>OpenCand</span>
</a>
<div className="space-x-4">
<Button href="/dados-disponiveis">Dados Disponíveis</Button>
<Button href="/estatisticas">Estatíscas</Button>
<Button href="/#recursos">Recursos</Button>
<Button href="/sobre">Sobre</Button>
</div>
</div>
</div> </div>
</div> </div>
</nav> </>
); );
}; };

View File

@@ -0,0 +1,322 @@
import React, { useEffect, useRef, useState } from 'react';
interface Dot {
x: number;
y: number;
vx: number;
vy: number;
connections: number[];
brightness: number;
targetBrightness: number;
}
interface Connection {
from: number;
to: number;
brightness: number;
targetBrightness: number;
thickness: number;
targetThickness: number;
}
const NavbarMatrixBackground: React.FC = () => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const animationRef = useRef<number>(0);
const dotsRef = useRef<Dot[]>([]);
const connectionsRef = useRef<Connection[]>([]);
const mouseRef = useRef({ x: 0, y: 0 });
const [dimensions, setDimensions] = useState({ width: 0, height: 20 }); // Navbar height
// Configuration optimized for navbar - higher intensity, more visible
const config = {
dotCount: 60, // Fewer dots for the smaller navbar area
maxConnections: 4,
connectionDistance: 120,
dotSpeed: 0.2, // Slower movement
hoverRadius: 100,
baseBrightness: 0.1, // Much higher base brightness for navbar
hoverBrightness: 0.2, // Maximum brightness on hover
baseThickness: 1.0,
hoverThickness: 2.5,
fadeSpeed: 0.1,
};
// Initialize dots
const initializeDots = (width: number, height: number) => {
const dots: Dot[] = [];
for (let i = 0; i < config.dotCount; i++) {
dots.push({
x: Math.random() * width,
y: Math.random() * height,
vx: (Math.random() - 0.5) * config.dotSpeed,
vy: (Math.random() - 0.5) * config.dotSpeed,
connections: [],
brightness: config.baseBrightness,
targetBrightness: config.baseBrightness,
});
}
return dots;
};
// Calculate connections between dots
const calculateConnections = (dots: Dot[]) => {
const connections: Connection[] = [];
for (let i = 0; i < dots.length; i++) {
const dot = dots[i];
dot.connections = [];
const distances: { index: number; distance: number }[] = [];
for (let j = 0; j < dots.length; j++) {
if (i === j) continue;
const dx = dot.x - dots[j].x;
const dy = dot.y - dots[j].y;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance < config.connectionDistance) {
distances.push({ index: j, distance });
}
}
// Sort by distance and take closest connections
distances.sort((a, b) => a.distance - b.distance);
const maxConnections = Math.min(config.maxConnections, distances.length);
for (let k = 0; k < maxConnections; k++) {
const targetIndex = distances[k].index;
// Avoid duplicate connections
const existing = connections.find(
conn => (conn.from === i && conn.to === targetIndex) ||
(conn.from === targetIndex && conn.to === i)
);
if (!existing) {
dot.connections.push(targetIndex);
connections.push({
from: i,
to: targetIndex,
brightness: config.baseBrightness,
targetBrightness: config.baseBrightness,
thickness: config.baseThickness,
targetThickness: config.baseThickness,
});
}
}
}
return connections;
};
// Update hover effects
const updateHoverEffects = (dots: Dot[], connections: Connection[], mouseX: number, mouseY: number) => {
// Reset all targets to base values
dots.forEach(dot => {
dot.targetBrightness = config.baseBrightness;
});
connections.forEach(conn => {
conn.targetBrightness = config.baseBrightness;
conn.targetThickness = config.baseThickness;
});
// Check which dots are near the mouse (only if mouse is in navbar area)
if (mouseY <= 80) { // Navbar height
dots.forEach((dot, index) => {
const dx = dot.x - mouseX;
const dy = dot.y - mouseY;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance < config.hoverRadius) {
const intensity = 1 - (distance / config.hoverRadius);
dot.targetBrightness = config.baseBrightness + (config.hoverBrightness - config.baseBrightness) * intensity;
// Enhance connected dots and their connections
dot.connections.forEach(connIndex => {
if (dots[connIndex]) {
dots[connIndex].targetBrightness = Math.max(
dots[connIndex].targetBrightness,
config.baseBrightness + (config.hoverBrightness - config.baseBrightness) * intensity * 0.7
);
}
// Find and enhance the connection
const connection = connections.find(
conn => (conn.from === index && conn.to === connIndex) ||
(conn.from === connIndex && conn.to === index)
);
if (connection) {
connection.targetBrightness = Math.max(
connection.targetBrightness,
config.baseBrightness + (config.hoverBrightness - config.baseBrightness) * intensity
);
connection.targetThickness = Math.max(
connection.targetThickness,
config.baseThickness + (config.hoverThickness - config.baseThickness) * intensity
);
}
});
}
});
}
// Smooth transitions
dots.forEach(dot => {
dot.brightness += (dot.targetBrightness - dot.brightness) * config.fadeSpeed;
});
connections.forEach(conn => {
conn.brightness += (conn.targetBrightness - conn.brightness) * config.fadeSpeed;
conn.thickness += (conn.targetThickness - conn.thickness) * config.fadeSpeed;
});
};
// Animation loop
const animate = () => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const { width, height } = canvas;
// Clear canvas with transparent background
ctx.clearRect(0, 0, width, height);
const dots = dotsRef.current;
const connections = connectionsRef.current;
// Update dot positions
dots.forEach(dot => {
dot.x += dot.vx;
dot.y += dot.vy;
// Bounce off edges
if (dot.x <= 0 || dot.x >= width) {
dot.vx = -dot.vx;
dot.x = Math.max(0, Math.min(width, dot.x));
}
if (dot.y <= 0 || dot.y >= height) {
dot.vy = -dot.vy;
dot.y = Math.max(0, Math.min(height, dot.y));
}
});
// Recalculate connections periodically for smooth movement
if (Math.random() < 0.05) { // More frequent recalculation for smaller area
connectionsRef.current = calculateConnections(dots);
}
// Update hover effects
updateHoverEffects(dots, connections, mouseRef.current.x, mouseRef.current.y);
// Draw connections with higher opacity for navbar visibility
connections.forEach(conn => {
const fromDot = dots[conn.from];
const toDot = dots[conn.to];
if (!fromDot || !toDot) return;
ctx.strokeStyle = `rgba(100, 200, 255, ${conn.brightness * 0.4})`;
ctx.lineWidth = conn.thickness;
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(fromDot.x, fromDot.y);
ctx.lineTo(toDot.x, toDot.y);
ctx.stroke();
});
// Draw dots with higher opacity for navbar visibility
dots.forEach(dot => {
ctx.fillStyle = `rgba(100, 200, 255, ${dot.brightness * 0.5})`;
ctx.beginPath();
ctx.arc(dot.x, dot.y, 1.5, 0, Math.PI * 2);
ctx.fill();
// Add brighter glow for navbar
ctx.shadowColor = `rgba(100, 200, 255, ${dot.brightness * 0.3})`;
ctx.shadowBlur = 3;
ctx.beginPath();
ctx.arc(dot.x, dot.y, 1, 0, Math.PI * 2);
ctx.fill();
ctx.shadowBlur = 0;
});
animationRef.current = requestAnimationFrame(animate);
};
// Handle resize
const handleResize = () => {
const canvas = canvasRef.current;
if (!canvas) return;
const navbarHeight = 80; // Fixed navbar height
canvas.width = window.innerWidth;
canvas.height = navbarHeight;
canvas.style.width = `${window.innerWidth}px`;
canvas.style.height = `${navbarHeight}px`;
setDimensions({ width: window.innerWidth, height: navbarHeight });
// Reinitialize dots for new dimensions
dotsRef.current = initializeDots(window.innerWidth, navbarHeight);
connectionsRef.current = calculateConnections(dotsRef.current);
};
// Handle mouse movement
const handleMouseMove = (event: MouseEvent) => {
mouseRef.current = {
x: event.clientX,
y: event.clientY,
};
};
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
// Initial setup
handleResize();
// Start animation
const startAnimation = () => {
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
}
animationRef.current = requestAnimationFrame(animate);
};
startAnimation();
// Event listeners
window.addEventListener('resize', handleResize);
document.addEventListener('mousemove', handleMouseMove);
return () => {
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
}
window.removeEventListener('resize', handleResize);
document.removeEventListener('mousemove', handleMouseMove);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<canvas
ref={canvasRef}
className="absolute inset-0 w-full h-full"
style={{
zIndex: 1,
pointerEvents: 'none'
}}
/>
);
};
export default NavbarMatrixBackground;

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';
@@ -14,9 +14,12 @@ const SearchBar: React.FC<SearchBarProps> = ({ className = '' }) => {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
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 [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) => {
@@ -31,7 +34,7 @@ const SearchBar: React.FC<SearchBarProps> = ({ className = '' }) => {
setError(null); setError(null);
try { try {
let result = await openCandApi.searchCandidates(query.trim()); const result = await openCandApi.searchCandidates(query.trim());
setSearchResults(result.candidatos); // Limit to 8 results setSearchResults(result.candidatos); // Limit to 8 results
setShowResults(true); setShowResults(true);
} catch (err) { } catch (err) {
@@ -73,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(() => {
@@ -92,11 +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) => {
let desc = ['']; const desc = [''];
if (candidate.cpf) desc.push(`CPF: ${maskCpf(candidate.cpf)}`); if (candidate.cpf) desc.push(`CPF: ${maskCpf(candidate.cpf)}`);
if (candidate.ocupacao && candidate.ocupacao != 'OUTROS') desc.push(`${candidate.ocupacao}`); if (candidate.apelido) desc.push(`"${candidate.apelido}"`);
if (desc.length == 0) if (desc.length == 0)
if (candidate.dataNascimento) desc.push(`${formatDateToDDMMYYYY(candidate.dataNascimento)}`); if (candidate.dataNascimento) desc.push(`${formatDateToDDMMYYYY(candidate.dataNascimento)}`);
@@ -113,13 +155,19 @@ const SearchBar: React.FC<SearchBarProps> = ({ className = '' }) => {
}, []); }, []);
return ( return (
<div className={`w-full max-w-xl mx-auto relative ${className}`} ref={resultsRef}> <div className={`w-full max-w-2xl mx-auto relative ${className}`} ref={resultsRef}>
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<div className="flex items-center bg-white/10 backdrop-blur-sm rounded-full shadow-xl p-2 transition-all duration-200 hover:bg-white/15"> <div className={`flex items-center bg-white/10 backdrop-blur-sm rounded-full shadow-xl p-2
<MagnifyingGlassIcon className="h-6 w-6 text-gray-400 ml-3" /> transition-all duration-300 ease-in-out
${isFocused ? 'bg-white/20 scale-[1.01] shadow-2xl ring-2 ring-white/20' : 'hover:bg-white/15 hover:scale-[1.01]'}
`}>
<MagnifyingGlassIcon className={`h-6 w-6 transition-all duration-300 ${isFocused ? 'text-white' : 'text-gray-400'} ml-3`} />
<input <input
value={searchQuery} value={searchQuery}
onChange={handleInputChange} onChange={handleInputChange}
onFocus={() => setIsFocused(true)}
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"
@@ -148,32 +196,36 @@ const SearchBar: React.FC<SearchBarProps> = ({ className = '' }) => {
{/* Search Results Dropdown */} {/* Search Results Dropdown */}
{showResults && (searchResults.length > 0 || error) && ( {showResults && (searchResults.length > 0 || error) && (
<div className="absolute top-full left-0 right-0 mt-2 bg-white rounded-xl shadow-2xl border border-gray-200 overflow-hidden z-50"> <div className="absolute top-full left-0 right-0 mt-2 bg-white rounded-xl shadow-2xl border border-gray-200 overflow-hidden z-50 animate-fadeIn">
{error ? ( {error ? (
<div className="p-4 text-red-600 text-center bg-white"> <div className="p-4 text-red-600 text-center bg-white">
{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>
))} ))}
{searchResults.length === 10 && ( {searchResults.length === 10 && (
<div className="p-3 text-center text-gray-500 text-sm bg-gray-50"> <div className="p-3 text-center text-gray-500 text-sm bg-gray-50">
Mostrando os primeiros 10 resultados Mostrando apenas os primeiros <strong>10 resultados</strong>
</div> </div>
)} )}
</div> </div>
@@ -183,7 +235,7 @@ const SearchBar: React.FC<SearchBarProps> = ({ className = '' }) => {
{/* No results message */} {/* No results message */}
{showResults && searchResults.length === 0 && !error && !isLoading && searchQuery.length >= 2 && ( {showResults && searchResults.length === 0 && !error && !isLoading && searchQuery.length >= 2 && (
<div className="absolute top-full left-0 right-0 mt-2 bg-white rounded-xl shadow-2xl border border-gray-200 overflow-hidden z-50"> <div className="absolute top-full left-0 right-0 mt-2 bg-white rounded-xl shadow-2xl border border-gray-200 overflow-hidden z-50 animate-fadeIn">
<div className="p-4 text-gray-600 text-center"> <div className="p-4 text-gray-600 text-center">
Nenhum candidato encontrado para "{searchQuery}" Nenhum candidato encontrado para "{searchQuery}"
</div> </div>

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/50 backdrop-blur-md p-6 rounded-lg shadow-xl hover:shadow-indigo-500/30 transform hover:-translate-y-1 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

@@ -18,15 +18,6 @@
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
} }
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body { body {
margin: 0; margin: 0;
padding: 0; padding: 0;
@@ -35,66 +26,4 @@ body {
width: 100%; width: 100%;
@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;
}

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;

View File

@@ -58,7 +58,7 @@ const Tooltip: React.FC<TooltipProps> = ({
clearTimeout(timeoutRef.current); clearTimeout(timeoutRef.current);
} }
timeoutRef.current = setTimeout(() => { timeoutRef.current = window.setTimeout(() => {
const position = calculateTooltipPosition(); const position = calculateTooltipPosition();
setTooltipPosition(position); setTooltipPosition(position);
setIsVisible(true); setIsVisible(true);

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)}`;
} }
/** /**

View File

@@ -17,7 +17,7 @@
/* Linting */ /* Linting */
"strict": true, "strict": true,
"noUnusedLocals": true, "noUnusedLocals": false,
"noUnusedParameters": true, "noUnusedParameters": true,
"erasableSyntaxOnly": true, "erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,