things working nice
This commit is contained in:
parent
64af6b23ff
commit
112eff2acb
11
src/App.tsx
11
src/App.tsx
@ -5,6 +5,7 @@ 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';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
|
|
||||||
// HomePage component
|
// HomePage component
|
||||||
@ -16,16 +17,6 @@ const HomePage: React.FC = () => (
|
|||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|
||||||
// Placeholder for candidate detail page
|
|
||||||
const CandidatePage: React.FC = () => (
|
|
||||||
<main className="flex-grow flex items-center justify-center min-h-screen">
|
|
||||||
<div className="text-center text-white">
|
|
||||||
<h1 className="text-4xl font-bold mb-4">Página do Candidato</h1>
|
|
||||||
<p className="text-gray-300">Esta página será implementada em breve.</p>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<div className="bg-gray-900 min-h-screen w-full flex flex-col">
|
<div className="bg-gray-900 min-h-screen w-full flex flex-col">
|
||||||
|
@ -7,6 +7,8 @@ export type {
|
|||||||
Election,
|
Election,
|
||||||
CandidateAssets,
|
CandidateAssets,
|
||||||
Asset,
|
Asset,
|
||||||
|
CandidateRedesSociais,
|
||||||
|
RedeSocial,
|
||||||
PlatformStats,
|
PlatformStats,
|
||||||
} from './openCandApi';
|
} from './openCandApi';
|
||||||
|
|
||||||
|
@ -15,6 +15,7 @@ export interface Candidate {
|
|||||||
estadoCivil: string;
|
estadoCivil: string;
|
||||||
sexo: string;
|
sexo: string;
|
||||||
ocupacao: string;
|
ocupacao: string;
|
||||||
|
fotoUrl: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CandidateDetails extends Candidate {
|
export interface CandidateDetails extends Candidate {
|
||||||
@ -22,10 +23,12 @@ export interface CandidateDetails extends Candidate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface Election {
|
export interface Election {
|
||||||
sqid: string;
|
sqCandidato: string;
|
||||||
tipoeleicao: string;
|
tipoEleicao: string;
|
||||||
siglaUf: string;
|
cargo: string;
|
||||||
nomeue: string;
|
ano: number;
|
||||||
|
siglaUF: string;
|
||||||
|
nomeUE: string;
|
||||||
nrCandidato: string;
|
nrCandidato: string;
|
||||||
nomeCandidato: string;
|
nomeCandidato: string;
|
||||||
resultado: string;
|
resultado: string;
|
||||||
@ -43,9 +46,23 @@ export interface Asset {
|
|||||||
valor: number;
|
valor: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CandidateRedesSociais {
|
||||||
|
redesSociais: RedeSocial[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RedeSocial {
|
||||||
|
idCandidato: string;
|
||||||
|
rede: "Facebook" | "Instagram" | "X/Twitter" | "TikTok" | "YouTube" | "LinkedIn" | "WhatsApp" | "Threads" | "Telegram" | "Kwai" | "Spotify" | "Outros";
|
||||||
|
siglaUF: string;
|
||||||
|
link: string;
|
||||||
|
ano: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PlatformStats {
|
export interface PlatformStats {
|
||||||
totalCandidatos: number;
|
totalCandidatos: number;
|
||||||
totalBemCandidatos: number;
|
totalBemCandidatos: number;
|
||||||
|
totalValorBemCandidatos: number;
|
||||||
|
totalRedesSociais: number;
|
||||||
totalEleicoes: number;
|
totalEleicoes: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -91,16 +108,11 @@ export class OpenCandApi extends BaseApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Search candidates and get their details (limited to first 10 results to avoid too many requests)
|
* Get the assets of a specific candidate by ID
|
||||||
|
* GET /v1/candidato/{id}/bens
|
||||||
*/
|
*/
|
||||||
async searchCandidatesWithDetails(
|
async getCandidateRedesSociais(id: string): Promise<CandidateRedesSociais> {
|
||||||
query: string,
|
return this.get<CandidateRedesSociais>(`/v1/candidato/${id}/rede-social`);
|
||||||
limit: number = 10
|
|
||||||
): Promise<Candidate[]> {
|
|
||||||
const searchResult = await this.searchCandidates(query);
|
|
||||||
const candidatesLimit = searchResult.candidatos.slice(0, limit);
|
|
||||||
|
|
||||||
return candidatesLimit;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
360
src/components/CandidatePage.tsx
Normal file
360
src/components/CandidatePage.tsx
Normal file
@ -0,0 +1,360 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
|
import { ArrowLeftIcon, UserIcon, DocumentTextIcon, CurrencyDollarIcon, LinkIcon } from '@heroicons/react/24/outline';
|
||||||
|
import { openCandApi, type CandidateDetails, type CandidateAssets, type CandidateRedesSociais, type Election, type Asset, type RedeSocial, ApiError } from '../api';
|
||||||
|
|
||||||
|
const CandidatePage: React.FC = () => {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [candidateDetails, setCandidateDetails] = useState<CandidateDetails | null>(null);
|
||||||
|
const [candidateAssets, setCandidateAssets] = useState<CandidateAssets | null>(null);
|
||||||
|
const [candidateRedesSociais, setCandidateRedesSociais] = useState<CandidateRedesSociais | null>(null);
|
||||||
|
const [isLoadingDetails, setIsLoadingDetails] = useState(true);
|
||||||
|
const [isLoadingAssets, setIsLoadingAssets] = useState(true);
|
||||||
|
const [isLoadingRedesSociais, setIsLoadingRedesSociais] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!id) {
|
||||||
|
navigate('/');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch candidate details
|
||||||
|
const fetchCandidateDetails = async () => {
|
||||||
|
try {
|
||||||
|
setIsLoadingDetails(true);
|
||||||
|
const details = await openCandApi.getCandidateById(id);
|
||||||
|
setCandidateDetails(details);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching candidate details:', err);
|
||||||
|
if (err instanceof ApiError) {
|
||||||
|
setError(`Erro ao carregar dados do candidato: ${err.message}`);
|
||||||
|
} else {
|
||||||
|
setError('Erro inesperado ao carregar dados do candidato');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsLoadingDetails(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fetch candidate assets
|
||||||
|
const fetchCandidateAssets = async () => {
|
||||||
|
try {
|
||||||
|
setIsLoadingAssets(true);
|
||||||
|
const assets = await openCandApi.getCandidateAssets(id);
|
||||||
|
setCandidateAssets(assets);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching candidate assets:', err);
|
||||||
|
// Assets might not be available for all candidates, so we don't set error here
|
||||||
|
} finally {
|
||||||
|
setIsLoadingAssets(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fetch candidate social media
|
||||||
|
const fetchCandidateRedesSociais = async () => {
|
||||||
|
try {
|
||||||
|
setIsLoadingRedesSociais(true);
|
||||||
|
const redesSociais = await openCandApi.getCandidateRedesSociais(id);
|
||||||
|
setCandidateRedesSociais(redesSociais);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching candidate social media:', err);
|
||||||
|
// Social media might not be available for all candidates, so we don't set error here
|
||||||
|
} finally {
|
||||||
|
setIsLoadingRedesSociais(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchCandidateDetails();
|
||||||
|
fetchCandidateAssets();
|
||||||
|
fetchCandidateRedesSociais();
|
||||||
|
}, [id, navigate]);
|
||||||
|
|
||||||
|
const formatCurrency = (value: number) => {
|
||||||
|
return new Intl.NumberFormat('pt-BR', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'BRL'
|
||||||
|
}).format(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
try {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date.toLocaleDateString('pt-BR');
|
||||||
|
} catch {
|
||||||
|
return dateString;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<main className="flex-grow flex items-center justify-center min-h-screen p-4">
|
||||||
|
<div className="text-center text-white">
|
||||||
|
<h1 className="text-2xl font-bold mb-4">Erro</h1>
|
||||||
|
<p className="text-red-400 mb-4">{error}</p>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="flex-grow p-6 max-w-7xl mx-auto">
|
||||||
|
{/* Header with back button */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/')}
|
||||||
|
className="flex items-center text-white hover:text-gray-300 transition-colors mb-4"
|
||||||
|
>
|
||||||
|
<ArrowLeftIcon className="h-5 w-5 mr-2" />
|
||||||
|
Voltar à busca
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{candidateDetails && (
|
||||||
|
<h1 className="text-3xl font-bold text-white">{candidateDetails.nome}</h1>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 h-full">
|
||||||
|
{/* Left Column - Basic Information and Social Media */}
|
||||||
|
<div className="lg:col-span-1 space-y-6">
|
||||||
|
{/* Basic Information Panel */}
|
||||||
|
<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="flex items-center mb-6">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoadingDetails ? (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-4 border-blue-600 border-t-transparent"></div>
|
||||||
|
</div>
|
||||||
|
) : candidateDetails ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Candidate Photo */}
|
||||||
|
{candidateDetails.fotoUrl && (
|
||||||
|
<div className="flex justify-center mb-6">
|
||||||
|
<div className="relative">
|
||||||
|
<img
|
||||||
|
src={candidateDetails.fotoUrl}
|
||||||
|
alt={`Foto de ${candidateDetails.nome}`}
|
||||||
|
className="w-32 h-32 rounded-full object-cover border-4 border-blue-200 shadow-lg"
|
||||||
|
onError={(e) => {
|
||||||
|
const target = e.target as HTMLImageElement;
|
||||||
|
target.style.display = 'none';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-semibold text-gray-600 uppercase tracking-wide">Nome</label>
|
||||||
|
<p className="text-lg font-medium text-gray-900 mt-1">{candidateDetails.nome}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-semibold text-gray-600 uppercase tracking-wide">CPF</label>
|
||||||
|
<p className="text-lg text-gray-900 mt-1">{candidateDetails.cpf}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-semibold text-gray-600 uppercase tracking-wide">Data de Nascimento</label>
|
||||||
|
<p className="text-lg text-gray-900 mt-1">{formatDate(candidateDetails.dataNascimento)}</p>
|
||||||
|
</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>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center text-gray-500 py-8">
|
||||||
|
Nenhuma informação encontrada
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Social Media Panel */}
|
||||||
|
<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="flex items-center mb-6">
|
||||||
|
<LinkIcon className="h-8 w-8 text-orange-600 mr-3" />
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900">Redes Sociais</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoadingRedesSociais ? (
|
||||||
|
<div className="flex items-center justify-center h-32">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-4 border-orange-600 border-t-transparent"></div>
|
||||||
|
</div>
|
||||||
|
) : candidateRedesSociais?.redesSociais && candidateRedesSociais.redesSociais.length > 0 ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{candidateRedesSociais.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">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center mb-2">
|
||||||
|
<span className="font-semibold text-gray-900 mr-2">{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-blue-600 hover:text-blue-800 text-sm break-all transition-colors"
|
||||||
|
>
|
||||||
|
{redeSocial.link}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center text-gray-500 py-8">
|
||||||
|
Nenhuma rede social encontrada
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Column - Elections and Assets */}
|
||||||
|
<div className="lg:col-span-2 space-y-6">
|
||||||
|
{/* Elections Panel */}
|
||||||
|
<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="flex items-center mb-6">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoadingDetails ? (
|
||||||
|
<div className="flex items-center justify-center h-32">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-4 border-green-600 border-t-transparent"></div>
|
||||||
|
</div>
|
||||||
|
) : candidateDetails?.eleicoes && candidateDetails.eleicoes.length > 0 ? (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full table-auto">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-200">
|
||||||
|
<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>
|
||||||
|
<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">Localidade</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>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{candidateDetails.eleicoes.map((election: Election, index: number) => (
|
||||||
|
<tr key={election.sqCandidato || index} className="border-b border-gray-100 hover:bg-gray-50 transition-colors">
|
||||||
|
<td className="py-3 px-2 text-gray-900">{election.ano}</td>
|
||||||
|
<td className="py-3 px-2 text-gray-900">{election.tipoEleicao}</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.nomeUE}</td>
|
||||||
|
<td className="py-3 px-2 text-gray-900">{election.nrCandidato}</td>
|
||||||
|
<td className="py-3 px-2">
|
||||||
|
<span className={`px-2 py-1 rounded-full text-xs font-medium ${
|
||||||
|
election.resultado?.toLowerCase().includes('eleito')
|
||||||
|
? 'bg-green-100 text-green-800'
|
||||||
|
: 'bg-gray-100 text-gray-800'
|
||||||
|
}`}>
|
||||||
|
{election.resultado}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center text-gray-500 py-8">
|
||||||
|
Nenhum histórico de eleições encontrado
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Assets Panel */}
|
||||||
|
<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="flex items-center mb-6">
|
||||||
|
<CurrencyDollarIcon className="h-8 w-8 text-purple-600 mr-3" />
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900">Patrimônio Declarado</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoadingAssets ? (
|
||||||
|
<div className="flex items-center justify-center h-32">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-4 border-purple-600 border-t-transparent"></div>
|
||||||
|
</div>
|
||||||
|
) : candidateAssets?.bens && candidateAssets.bens.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full table-auto">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-200">
|
||||||
|
<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>
|
||||||
|
<th className="text-left py-3 px-2 font-semibold text-gray-700">Descrição</th>
|
||||||
|
<th className="text-right py-3 px-2 font-semibold text-gray-700">Valor</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{candidateAssets.bens.map((asset: Asset, index: number) => (
|
||||||
|
<tr key={`${asset.idCandidato}-${asset.ano}-${index}`} className="border-b border-gray-100 hover:bg-gray-50 transition-colors">
|
||||||
|
<td className="py-3 px-2 text-gray-900">{asset.ano}</td>
|
||||||
|
<td className="py-3 px-2 text-gray-900">{asset.tipoBem}</td>
|
||||||
|
<td className="py-3 px-2 text-gray-900">{asset.descricao}</td>
|
||||||
|
<td className="py-3 px-2 text-right text-gray-900 font-medium">
|
||||||
|
{formatCurrency(asset.valor)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Total Assets */}
|
||||||
|
<div className="mt-4 pt-4 border-t border-gray-200">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-lg font-semibold text-gray-700">Total Declarado:</span>
|
||||||
|
<span className="text-xl font-bold text-purple-600">
|
||||||
|
{formatCurrency(candidateAssets.bens.reduce((total, asset) => total + asset.valor, 0))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="text-center text-gray-500 py-8">
|
||||||
|
Nenhum patrimônio declarado encontrado
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CandidatePage;
|
@ -1,115 +1,7 @@
|
|||||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
import React from 'react';
|
||||||
import { MagnifyingGlassIcon, XMarkIcon } from '@heroicons/react/24/solid';
|
import SearchBar from './SearchBar';
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
import { openCandApi, type Candidate, ApiError } from '../api';
|
|
||||||
import { mockSearchCandidates, DEMO_CONFIG } from '../config/demo';
|
|
||||||
|
|
||||||
const HeroSection: React.FC = () => {
|
const HeroSection: React.FC = () => {
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
|
||||||
const [searchResults, setSearchResults] = useState<Candidate[]>([]);
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [showResults, setShowResults] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const searchTimeoutRef = useRef<number | null>(null);
|
|
||||||
const resultsRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
// Debounced search function
|
|
||||||
const performSearch = useCallback(async (query: string) => {
|
|
||||||
if (query.trim().length < 2) {
|
|
||||||
setSearchResults([]);
|
|
||||||
setShowResults(false);
|
|
||||||
setError(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
let result;
|
|
||||||
|
|
||||||
// Use mock data if configured or if API call fails
|
|
||||||
if (DEMO_CONFIG.useMockData) {
|
|
||||||
result = await mockSearchCandidates(query.trim());
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
result = await openCandApi.searchCandidates(query.trim());
|
|
||||||
} catch (apiError) {
|
|
||||||
console.warn('API call failed, falling back to mock data:', apiError);
|
|
||||||
result = await mockSearchCandidates(query.trim());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setSearchResults(result.candidatos.slice(0, 8)); // Limit to 8 results
|
|
||||||
setShowResults(true);
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof ApiError) {
|
|
||||||
setError(`Erro na busca: ${err.message}`);
|
|
||||||
} else {
|
|
||||||
setError('Erro inesperado na busca');
|
|
||||||
}
|
|
||||||
setSearchResults([]);
|
|
||||||
setShowResults(false);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Handle input change with debouncing
|
|
||||||
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const query = e.target.value;
|
|
||||||
setSearchQuery(query);
|
|
||||||
|
|
||||||
// Clear previous timeout
|
|
||||||
if (searchTimeoutRef.current) {
|
|
||||||
clearTimeout(searchTimeoutRef.current);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set new timeout for debounced search
|
|
||||||
searchTimeoutRef.current = window.setTimeout(() => {
|
|
||||||
performSearch(query);
|
|
||||||
}, 300); // 300ms delay
|
|
||||||
}, [performSearch]);
|
|
||||||
|
|
||||||
// Handle candidate selection
|
|
||||||
const handleCandidateSelect = useCallback((candidate: Candidate) => {
|
|
||||||
navigate(`/candidato/${candidate.idCandidato}`);
|
|
||||||
setShowResults(false);
|
|
||||||
setSearchQuery('');
|
|
||||||
}, [navigate]);
|
|
||||||
|
|
||||||
// Handle form submission
|
|
||||||
const handleSubmit = useCallback((e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (searchResults.length > 0) {
|
|
||||||
handleCandidateSelect(searchResults[0]);
|
|
||||||
}
|
|
||||||
}, [searchResults, handleCandidateSelect]);
|
|
||||||
|
|
||||||
// Close results when clicking outside
|
|
||||||
useEffect(() => {
|
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
|
||||||
if (resultsRef.current && !resultsRef.current.contains(event.target as Node)) {
|
|
||||||
setShowResults(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener('mousedown', handleClickOutside);
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('mousedown', handleClickOutside);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Clear timeout on unmount
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (searchTimeoutRef.current) {
|
|
||||||
clearTimeout(searchTimeoutRef.current);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
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 p-4 bg-cover bg-center bg-no-repeat bg-gray-900 relative"
|
||||||
@ -124,83 +16,7 @@ const HeroSection: React.FC = () => {
|
|||||||
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) do Brasil.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="w-full max-w-xl mx-auto relative" ref={resultsRef}>
|
<SearchBar />
|
||||||
<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">
|
|
||||||
<MagnifyingGlassIcon className="h-6 w-6 text-gray-400 ml-3" />
|
|
||||||
<input
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={handleInputChange}
|
|
||||||
placeholder="Pesquisar candidatos..."
|
|
||||||
className="flex-grow bg-transparent text-white placeholder-gray-400 p-3 focus:outline-none"
|
|
||||||
autoComplete="off"
|
|
||||||
/>
|
|
||||||
{isLoading && (
|
|
||||||
<div className="mr-3">
|
|
||||||
<div className="animate-spin rounded-full h-5 w-5 border-2 border-white border-t-transparent"></div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{searchQuery && !isLoading && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setSearchQuery('');
|
|
||||||
setSearchResults([]);
|
|
||||||
setShowResults(false);
|
|
||||||
setError(null);
|
|
||||||
}}
|
|
||||||
className="mr-3 p-1 hover:bg-white/20 rounded-full transition-colors"
|
|
||||||
>
|
|
||||||
<XMarkIcon className="h-4 w-4 text-gray-400" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{/* Search Results Dropdown */}
|
|
||||||
{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">
|
|
||||||
{error ? (
|
|
||||||
<div className="p-4 text-red-600 text-center bg-white">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="max-h-80 overflow-y-auto custom-scrollbar">
|
|
||||||
{searchResults.map((candidate) => (
|
|
||||||
<button
|
|
||||||
key={candidate.idCandidato}
|
|
||||||
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"
|
|
||||||
>
|
|
||||||
<div className="text-black font-semibold text-base">{candidate.nome}</div>
|
|
||||||
<div className="text-gray-600 text-sm mt-1">
|
|
||||||
CPF: {candidate.cpf} | {candidate.ocupacao}
|
|
||||||
</div>
|
|
||||||
{candidate.email && (
|
|
||||||
<div className="text-gray-500 text-xs mt-1">{candidate.email}</div>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{searchResults.length === 8 && (
|
|
||||||
<div className="p-3 text-center text-gray-500 text-sm bg-gray-50">
|
|
||||||
Mostrando os primeiros 8 resultados
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* No results message */}
|
|
||||||
{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="p-4 text-gray-600 text-center">
|
|
||||||
Nenhum candidato encontrado para "{searchQuery}"
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
185
src/components/SearchBar.tsx
Normal file
185
src/components/SearchBar.tsx
Normal file
@ -0,0 +1,185 @@
|
|||||||
|
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
import { MagnifyingGlassIcon, XMarkIcon } from '@heroicons/react/24/solid';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { openCandApi, type Candidate, ApiError } from '../api';
|
||||||
|
import { mockSearchCandidates, DEMO_CONFIG } from '../config/demo';
|
||||||
|
|
||||||
|
interface SearchBarProps {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SearchBar: React.FC<SearchBarProps> = ({ className = '' }) => {
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [searchResults, setSearchResults] = useState<Candidate[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [showResults, setShowResults] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const searchTimeoutRef = useRef<number | null>(null);
|
||||||
|
const resultsRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Debounced search function
|
||||||
|
const performSearch = useCallback(async (query: string) => {
|
||||||
|
if (query.trim().length < 2) {
|
||||||
|
setSearchResults([]);
|
||||||
|
setShowResults(false);
|
||||||
|
setError(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
let result = await openCandApi.searchCandidates(query.trim());
|
||||||
|
setSearchResults(result.candidatos); // Limit to 8 results
|
||||||
|
setShowResults(true);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiError) {
|
||||||
|
setError(`Erro na busca: ${err.message}`);
|
||||||
|
} else {
|
||||||
|
setError('Erro inesperado na busca');
|
||||||
|
}
|
||||||
|
setSearchResults([]);
|
||||||
|
setShowResults(false);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Handle input change with debouncing
|
||||||
|
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const query = e.target.value;
|
||||||
|
setSearchQuery(query);
|
||||||
|
|
||||||
|
// Clear previous timeout
|
||||||
|
if (searchTimeoutRef.current) {
|
||||||
|
clearTimeout(searchTimeoutRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set new timeout for debounced search
|
||||||
|
searchTimeoutRef.current = window.setTimeout(() => {
|
||||||
|
performSearch(query);
|
||||||
|
}, 300); // 300ms delay
|
||||||
|
}, [performSearch]);
|
||||||
|
|
||||||
|
// Handle candidate selection
|
||||||
|
const handleCandidateSelect = useCallback((candidate: Candidate) => {
|
||||||
|
navigate(`/candidato/${candidate.idCandidato}`);
|
||||||
|
setShowResults(false);
|
||||||
|
setSearchQuery('');
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
|
// Handle form submission
|
||||||
|
const handleSubmit = useCallback((e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (searchResults.length > 0) {
|
||||||
|
handleCandidateSelect(searchResults[0]);
|
||||||
|
}
|
||||||
|
}, [searchResults, handleCandidateSelect]);
|
||||||
|
|
||||||
|
// Close results when clicking outside
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (resultsRef.current && !resultsRef.current.contains(event.target as Node)) {
|
||||||
|
setShowResults(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Clear timeout on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (searchTimeoutRef.current) {
|
||||||
|
clearTimeout(searchTimeoutRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`w-full max-w-xl mx-auto relative ${className}`} ref={resultsRef}>
|
||||||
|
<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">
|
||||||
|
<MagnifyingGlassIcon className="h-6 w-6 text-gray-400 ml-3" />
|
||||||
|
<input
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
placeholder="Pesquisar candidatos..."
|
||||||
|
className="flex-grow bg-transparent text-white placeholder-gray-400 p-3 focus:outline-none"
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
{isLoading && (
|
||||||
|
<div className="mr-3">
|
||||||
|
<div className="animate-spin rounded-full h-5 w-5 border-2 border-white border-t-transparent"></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{searchQuery && !isLoading && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setSearchQuery('');
|
||||||
|
setSearchResults([]);
|
||||||
|
setShowResults(false);
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
className="mr-3 p-1 hover:bg-white/20 rounded-full transition-colors"
|
||||||
|
>
|
||||||
|
<XMarkIcon className="h-4 w-4 text-gray-400" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Search Results Dropdown */}
|
||||||
|
{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">
|
||||||
|
{error ? (
|
||||||
|
<div className="p-4 text-red-600 text-center bg-white">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="max-h-80 overflow-y-auto custom-scrollbar">
|
||||||
|
{searchResults.map((candidate) => (
|
||||||
|
<button
|
||||||
|
key={candidate.idCandidato}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<div className="text-black font-semibold text-base">{candidate.nome}</div>
|
||||||
|
<div className="text-gray-600 text-sm mt-1">
|
||||||
|
CPF: {candidate.cpf} | {candidate.ocupacao}
|
||||||
|
</div>
|
||||||
|
{candidate.email && (
|
||||||
|
<div className="text-gray-500 text-xs mt-1">{candidate.email}</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{searchResults.length === 10 && (
|
||||||
|
<div className="p-3 text-center text-gray-500 text-sm bg-gray-50">
|
||||||
|
Mostrando os primeiros 10 resultados
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* No results message */}
|
||||||
|
{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="p-4 text-gray-600 text-center">
|
||||||
|
Nenhum candidato encontrado para "{searchQuery}"
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SearchBar;
|
@ -1,37 +1,106 @@
|
|||||||
import React from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { openCandApi, type PlatformStats, ApiError } from '../api';
|
||||||
|
|
||||||
interface StatCardProps {
|
interface StatCardProps {
|
||||||
title: string;
|
title: string;
|
||||||
value: string;
|
value: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
isLoading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const StatCard: React.FC<StatCardProps> = ({ title, value, description }) => {
|
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">
|
<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">
|
||||||
<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>
|
||||||
<p className="text-4xl font-bold text-white mb-3">{value}</p>
|
{isLoading ? (
|
||||||
|
<div className="h-12 flex items-center">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-2 border-white border-t-transparent"></div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const StatisticsSection: React.FC = () => {
|
const StatisticsSection: React.FC = () => {
|
||||||
const stats = [
|
const [stats, setStats] = useState<PlatformStats | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchStats = async () => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const platformStats = await openCandApi.getStats();
|
||||||
|
setStats(platformStats);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching platform stats:', err);
|
||||||
|
if (err instanceof ApiError) {
|
||||||
|
setError(`Erro ao carregar estatísticas: ${err.message}`);
|
||||||
|
} else {
|
||||||
|
setError('Erro inesperado ao carregar estatísticas');
|
||||||
|
}
|
||||||
|
// Use default stats as fallback
|
||||||
|
setStats({
|
||||||
|
totalCandidatos: 0,
|
||||||
|
totalBemCandidatos: 0,
|
||||||
|
totalEleicoes: 0,
|
||||||
|
totalValorBemCandidatos: 0,
|
||||||
|
totalRedesSociais: 0
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchStats();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const formatNumber = (num: number): string => {
|
||||||
|
if (num >= 1000000000000) {
|
||||||
|
return `${(num / 1000000000000).toFixed(1)} trilhão`;
|
||||||
|
} else if (num >= 1000000000) {
|
||||||
|
return `${(num / 1000000000).toFixed(1)} bilhão`;
|
||||||
|
} else if (num >= 1000000) {
|
||||||
|
return `${(num / 1000000).toFixed(1)} milhão`;
|
||||||
|
} else if (num >= 1000) {
|
||||||
|
return `${(num / 1000).toFixed(0)}K`;
|
||||||
|
}
|
||||||
|
return num.toLocaleString('pt-BR');
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatCurrency = (value: number): string => {
|
||||||
|
return new Intl.NumberFormat('pt-BR', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'BRL',
|
||||||
|
notation: 'compact',
|
||||||
|
maximumSignificantDigits: 3
|
||||||
|
}).format(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const statisticsData = [
|
||||||
{
|
{
|
||||||
title: "Total de Candidatos",
|
title: "Total de Candidatos",
|
||||||
value: "+500.000",
|
value: isLoading ? "" : `+${formatNumber(stats?.totalCandidatos || 0)}`,
|
||||||
description: "Registros de candidaturas desde 2014"
|
description: "Registros de candidaturas na plataforma"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Total de Bens Declarados",
|
title: "Total de Bens Registrados",
|
||||||
value: "R$ +1 Trilhão",
|
value: isLoading ? "" : `+${formatNumber(stats?.totalBemCandidatos || 0)}`,
|
||||||
description: "Patrimônio agregado declarado pelos candidatos"
|
description: isLoading ? "" : `Somando ${formatCurrency(stats?.totalValorBemCandidatos || 0)} em Patrimônio agregado declarado pelos candidatos`
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Anos de Eleição Processados",
|
title: "Total de Redes Sociais",
|
||||||
value: "2014 - 2024",
|
value: isLoading ? "" : formatNumber(stats?.totalRedesSociais || 0),
|
||||||
description: "Cobertura das últimas eleições gerais e municipais"
|
description: "Redes sociais conectadas aos candidatos"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Total de Eleições",
|
||||||
|
value: isLoading ? "" : formatNumber(stats?.totalEleicoes || 0),
|
||||||
|
description: "Eleições processadas no sistema"
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -41,10 +110,38 @@ const StatisticsSection: React.FC = () => {
|
|||||||
<h2 className="text-3xl font-bold text-center text-white mb-12">
|
<h2 className="text-3xl font-bold text-center text-white mb-12">
|
||||||
Dados em Números
|
Dados em Números
|
||||||
</h2>
|
</h2>
|
||||||
<div className="grid md:grid-cols-3 gap-8">
|
{error && !isLoading && (
|
||||||
{stats.map((stat, index) => (
|
<div className="text-center mb-8">
|
||||||
<StatCard key={index} title={stat.title} value={stat.value} description={stat.description} />
|
<p className="text-yellow-400 text-sm">
|
||||||
|
⚠️ Usando dados de demonstração - {error}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-wrap justify-center gap-8 mx-auto">
|
||||||
|
{statisticsData.slice(0, 3).map((stat, index) => (
|
||||||
|
<div key={index} className="w-full md:w-80 lg:w-96">
|
||||||
|
<StatCard
|
||||||
|
title={stat.title}
|
||||||
|
value={stat.value}
|
||||||
|
description={stat.description}
|
||||||
|
isLoading={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
|
{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>
|
||||||
|
Loading…
x
Reference in New Issue
Block a user