initial fixes and enhancements
This commit is contained in:
parent
427c9da08b
commit
557a157226
@ -5,7 +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 CandidatePage from './components/CandidatePage/CandidatePage';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
|
|
||||||
// HomePage component
|
// HomePage component
|
||||||
|
68
src/api/apiModels.ts
Normal file
68
src/api/apiModels.ts
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
// Type definitions based on the API specs
|
||||||
|
export interface CandidateSearchResult {
|
||||||
|
candidatos: Candidate[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Candidate {
|
||||||
|
idCandidato: string;
|
||||||
|
nome: string;
|
||||||
|
cpf: string;
|
||||||
|
dataNascimento: string;
|
||||||
|
email: string;
|
||||||
|
estadoCivil: string;
|
||||||
|
sexo: string;
|
||||||
|
ocupacao: string;
|
||||||
|
fotoUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CandidateDetails extends Candidate {
|
||||||
|
eleicoes: Election[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Election {
|
||||||
|
sqCandidato: string;
|
||||||
|
tipoEleicao: string;
|
||||||
|
cargo: string;
|
||||||
|
ano: number;
|
||||||
|
siglaUF: string;
|
||||||
|
nomeUE: string;
|
||||||
|
nrCandidato: string;
|
||||||
|
nomeCandidato: string;
|
||||||
|
resultado: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CandidateAssets {
|
||||||
|
bens: Asset[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Asset {
|
||||||
|
idCandidato: string;
|
||||||
|
ano: number;
|
||||||
|
tipoBem: string;
|
||||||
|
descricao: string;
|
||||||
|
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 {
|
||||||
|
totalCandidatos: number;
|
||||||
|
totalBemCandidatos: number;
|
||||||
|
totalValorBemCandidatos: number;
|
||||||
|
totalRedesSociais: number;
|
||||||
|
totalEleicoes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CpfRevealResult {
|
||||||
|
cpf: string;
|
||||||
|
}
|
@ -10,7 +10,7 @@ export type {
|
|||||||
CandidateRedesSociais,
|
CandidateRedesSociais,
|
||||||
RedeSocial,
|
RedeSocial,
|
||||||
PlatformStats,
|
PlatformStats,
|
||||||
} from './openCandApi';
|
} from './apiModels';
|
||||||
|
|
||||||
// Export base API classes for custom implementations
|
// Export base API classes for custom implementations
|
||||||
export { BaseApiClient, ApiError } from './base';
|
export { BaseApiClient, ApiError } from './base';
|
||||||
|
@ -1,70 +1,6 @@
|
|||||||
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';
|
||||||
// Type definitions based on the API specs
|
|
||||||
export interface CandidateSearchResult {
|
|
||||||
candidatos: Candidate[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Candidate {
|
|
||||||
idCandidato: string;
|
|
||||||
nome: string;
|
|
||||||
cpf: string;
|
|
||||||
dataNascimento: string;
|
|
||||||
email: string;
|
|
||||||
estadoCivil: string;
|
|
||||||
sexo: string;
|
|
||||||
ocupacao: string;
|
|
||||||
fotoUrl: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CandidateDetails extends Candidate {
|
|
||||||
eleicoes: Election[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Election {
|
|
||||||
sqCandidato: string;
|
|
||||||
tipoEleicao: string;
|
|
||||||
cargo: string;
|
|
||||||
ano: number;
|
|
||||||
siglaUF: string;
|
|
||||||
nomeUE: string;
|
|
||||||
nrCandidato: string;
|
|
||||||
nomeCandidato: string;
|
|
||||||
resultado: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CandidateAssets {
|
|
||||||
bens: Asset[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Asset {
|
|
||||||
idCandidato: string;
|
|
||||||
ano: number;
|
|
||||||
tipoBem: string;
|
|
||||||
descricao: string;
|
|
||||||
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 {
|
|
||||||
totalCandidatos: number;
|
|
||||||
totalBemCandidatos: number;
|
|
||||||
totalValorBemCandidatos: number;
|
|
||||||
totalRedesSociais: number;
|
|
||||||
totalEleicoes: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* OpenCand API client for interacting with the OpenCand platform
|
* OpenCand API client for interacting with the OpenCand platform
|
||||||
@ -84,7 +20,6 @@ export class OpenCandApi extends BaseApiClient {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Search for candidates by name or other attributes
|
* Search for candidates by name or other attributes
|
||||||
* GET /v1/candidato/search?q={query}
|
|
||||||
*/
|
*/
|
||||||
async searchCandidates(query: string): Promise<CandidateSearchResult> {
|
async searchCandidates(query: string): Promise<CandidateSearchResult> {
|
||||||
const encodedQuery = encodeURIComponent(query);
|
const encodedQuery = encodeURIComponent(query);
|
||||||
@ -93,7 +28,6 @@ export class OpenCandApi extends BaseApiClient {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get detailed information about a specific candidate by ID
|
* Get detailed information about a specific candidate by ID
|
||||||
* GET /v1/candidato/{id}
|
|
||||||
*/
|
*/
|
||||||
async getCandidateById(id: string): Promise<CandidateDetails> {
|
async getCandidateById(id: string): Promise<CandidateDetails> {
|
||||||
return this.get<CandidateDetails>(`/v1/candidato/${id}`);
|
return this.get<CandidateDetails>(`/v1/candidato/${id}`);
|
||||||
@ -101,19 +35,24 @@ export class OpenCandApi extends BaseApiClient {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the assets of a specific candidate by ID
|
* Get the assets of a specific candidate by ID
|
||||||
* GET /v1/candidato/{id}/bens
|
|
||||||
*/
|
*/
|
||||||
async getCandidateAssets(id: string): Promise<CandidateAssets> {
|
async getCandidateAssets(id: string): Promise<CandidateAssets> {
|
||||||
return this.get<CandidateAssets>(`/v1/candidato/${id}/bens`);
|
return this.get<CandidateAssets>(`/v1/candidato/${id}/bens`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the assets of a specific candidate by ID
|
* Get the social networks of a specific candidate by ID
|
||||||
* GET /v1/candidato/{id}/bens
|
|
||||||
*/
|
*/
|
||||||
async getCandidateRedesSociais(id: string): Promise<CandidateRedesSociais> {
|
async getCandidateRedesSociais(id: string): Promise<CandidateRedesSociais> {
|
||||||
return this.get<CandidateRedesSociais>(`/v1/candidato/${id}/rede-social`);
|
return this.get<CandidateRedesSociais>(`/v1/candidato/${id}/rede-social`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the social networks of a specific candidate by ID
|
||||||
|
*/
|
||||||
|
async getCandidateCpf(id: string): Promise<CpfRevealResult> {
|
||||||
|
return this.get<CpfRevealResult>(`/v1/candidato/${id}/reveal-cpf`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a default instance for easy usage with proper configuration
|
// Create a default instance for easy usage with proper configuration
|
||||||
|
@ -1,360 +0,0 @@
|
|||||||
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;
|
|
75
src/components/CandidatePage/AssetsComponent.tsx
Normal file
75
src/components/CandidatePage/AssetsComponent.tsx
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { CurrencyDollarIcon } from '@heroicons/react/24/outline';
|
||||||
|
import { type Asset } from '../../api';
|
||||||
|
|
||||||
|
interface AssetsComponentProps {
|
||||||
|
assets: Asset[] | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AssetsComponent: React.FC<AssetsComponentProps> = ({ assets, isLoading }) => {
|
||||||
|
const formatCurrency = (value: number) => {
|
||||||
|
return new Intl.NumberFormat('pt-BR', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'BRL'
|
||||||
|
}).format(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
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="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>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<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>
|
||||||
|
) : assets && assets.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>
|
||||||
|
{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">
|
||||||
|
<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(assets.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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AssetsComponent;
|
135
src/components/CandidatePage/BasicCandidateInfoComponent.tsx
Normal file
135
src/components/CandidatePage/BasicCandidateInfoComponent.tsx
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { UserIcon } from '@heroicons/react/24/outline';
|
||||||
|
import { type CandidateDetails, openCandApi } from '../../api';
|
||||||
|
import { formatCpf, maskCpf } from '../../utils/utils';
|
||||||
|
|
||||||
|
interface BasicCandidateInfoComponentProps {
|
||||||
|
candidateDetails: CandidateDetails | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BasicCandidateInfoComponent: React.FC<BasicCandidateInfoComponentProps> = ({
|
||||||
|
candidateDetails,
|
||||||
|
isLoading
|
||||||
|
}) => {
|
||||||
|
const [revealedCpf, setRevealedCpf] = useState<string | null>(null);
|
||||||
|
const [isRevealingCpf, setIsRevealingCpf] = useState(false);
|
||||||
|
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
try {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date.toLocaleDateString('pt-BR');
|
||||||
|
} catch {
|
||||||
|
return dateString;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCpfClick = async () => {
|
||||||
|
if (!candidateDetails?.idCandidato || isRevealingCpf || revealedCpf) return;
|
||||||
|
|
||||||
|
setIsRevealingCpf(true);
|
||||||
|
try {
|
||||||
|
const cpfResult = await openCandApi.getCandidateCpf(candidateDetails?.idCandidato || '');
|
||||||
|
setRevealedCpf(formatCpf(cpfResult.cpf));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to reveal CPF:', error);
|
||||||
|
} finally {
|
||||||
|
setIsRevealingCpf(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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="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>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{candidateDetails.cpf &&
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-semibold text-gray-600 uppercase tracking-wide">CPF</label>
|
||||||
|
<div
|
||||||
|
className={`text-lg text-gray-900 mt-1 ${
|
||||||
|
!revealedCpf && !isRevealingCpf ? 'cursor-pointer hover:text-blue-600 transition-colors duration-200' : ''
|
||||||
|
}`}
|
||||||
|
onClick={handleCpfClick}
|
||||||
|
>
|
||||||
|
{isRevealingCpf ? (
|
||||||
|
<div className="flex items-center justify-center space-x-2 py-1">
|
||||||
|
<div className="animate-spin rounded-full h-4 w-4 border-2 border-blue-200 border-t-blue-600"></div>
|
||||||
|
<span className="animate-pulse text-blue-600">Carregando...</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className={revealedCpf ? '' : 'select-none'}>
|
||||||
|
{revealedCpf || maskCpf(candidateDetails.cpf)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BasicCandidateInfoComponent;
|
147
src/components/CandidatePage/CandidatePage.tsx
Normal file
147
src/components/CandidatePage/CandidatePage.tsx
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
|
import { ArrowLeftIcon } from '@heroicons/react/24/outline';
|
||||||
|
import { openCandApi, type CandidateDetails, type CandidateAssets, type CandidateRedesSociais, ApiError } from '../../api';
|
||||||
|
import ElectionsComponent from './ElectionsComponent';
|
||||||
|
import AssetsComponent from './AssetsComponent';
|
||||||
|
import BasicCandidateInfoComponent from './BasicCandidateInfoComponent';
|
||||||
|
import SocialMediaComponent from './SocialMediaComponent';
|
||||||
|
|
||||||
|
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]);
|
||||||
|
|
||||||
|
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 */}
|
||||||
|
<BasicCandidateInfoComponent
|
||||||
|
candidateDetails={candidateDetails}
|
||||||
|
isLoading={isLoadingDetails}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Social Media Panel */}
|
||||||
|
<SocialMediaComponent
|
||||||
|
redesSociais={candidateRedesSociais?.redesSociais || null}
|
||||||
|
isLoading={isLoadingRedesSociais}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Column - Elections and Assets */}
|
||||||
|
<div className="lg:col-span-2 space-y-6">
|
||||||
|
{/* Elections Panel */}
|
||||||
|
<ElectionsComponent
|
||||||
|
elections={candidateDetails?.eleicoes || null}
|
||||||
|
isLoading={isLoadingDetails}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Assets Panel */}
|
||||||
|
<AssetsComponent
|
||||||
|
assets={candidateAssets?.bens || null}
|
||||||
|
isLoading={isLoadingAssets}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CandidatePage;
|
87
src/components/CandidatePage/ElectionsComponent.tsx
Normal file
87
src/components/CandidatePage/ElectionsComponent.tsx
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { DocumentTextIcon } from '@heroicons/react/24/outline';
|
||||||
|
import { type Election } from '../../api';
|
||||||
|
import Tooltip from '../Tooltip';
|
||||||
|
|
||||||
|
interface ElectionsComponentProps {
|
||||||
|
elections: Election[] | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ElectionsComponent: React.FC<ElectionsComponentProps> = ({ elections, isLoading }) => {
|
||||||
|
const getResultadoHelper = (resultado: string | null) => {
|
||||||
|
resultado = resultado?.toLowerCase() || '';
|
||||||
|
if (resultado == 'eleito') return 'Eleito!';
|
||||||
|
if (resultado == 'não eleito') return 'Não Eleito';
|
||||||
|
if (resultado == 'eleito por qp') return 'Eleito por Quociente Partidário';
|
||||||
|
if (resultado.includes('nulo')) return 'Dado não disponível';
|
||||||
|
if (resultado == '2º turno') return 'Foi para o segundo Turno';
|
||||||
|
if (resultado == 'suplente') return 'Foi eleito como suplente';
|
||||||
|
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="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>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<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>
|
||||||
|
) : elections && elections.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>
|
||||||
|
{elections.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">
|
||||||
|
<Tooltip
|
||||||
|
content={getResultadoHelper(election.resultado)}
|
||||||
|
position="top"
|
||||||
|
delay={200}
|
||||||
|
>
|
||||||
|
<span className={`px-2 py-1 rounded-full text-xs font-medium ${
|
||||||
|
election.resultado?.toLowerCase() === 'eleito'
|
||||||
|
? 'bg-green-100 text-green-800'
|
||||||
|
: 'bg-gray-100 text-gray-800'
|
||||||
|
}`}>
|
||||||
|
{election.resultado}
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center text-gray-500 py-8">
|
||||||
|
Nenhum histórico de eleições encontrado
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ElectionsComponent;
|
57
src/components/CandidatePage/SocialMediaComponent.tsx
Normal file
57
src/components/CandidatePage/SocialMediaComponent.tsx
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { LinkIcon } from '@heroicons/react/24/outline';
|
||||||
|
import { type RedeSocial } from '../../api';
|
||||||
|
|
||||||
|
interface SocialMediaComponentProps {
|
||||||
|
redesSociais: RedeSocial[] | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SocialMediaComponent: React.FC<SocialMediaComponentProps> = ({
|
||||||
|
redesSociais,
|
||||||
|
isLoading
|
||||||
|
}) => {
|
||||||
|
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="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>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<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>
|
||||||
|
) : redesSociais && redesSociais.length > 0 ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SocialMediaComponent;
|
@ -2,6 +2,7 @@ import React, { useState, useEffect, useCallback, useRef } from 'react';
|
|||||||
import { MagnifyingGlassIcon, XMarkIcon } from '@heroicons/react/24/solid';
|
import { MagnifyingGlassIcon, XMarkIcon } 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';
|
||||||
|
|
||||||
interface SearchBarProps {
|
interface SearchBarProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
@ -91,6 +92,17 @@ const SearchBar: React.FC<SearchBarProps> = ({ className = '' }) => {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const getCandidateDescription = (candidate: Candidate) => {
|
||||||
|
let desc = [''];
|
||||||
|
|
||||||
|
if (candidate.cpf) desc.push(`CPF: ${maskCpf(candidate.cpf)}`);
|
||||||
|
if (candidate.ocupacao && candidate.ocupacao != 'OUTROS') desc.push(`${candidate.ocupacao}`);
|
||||||
|
if (desc.length == 0)
|
||||||
|
if (candidate.dataNascimento) desc.push(`${formatDateToDDMMYYYY(candidate.dataNascimento)}`);
|
||||||
|
|
||||||
|
return desc.filter(Boolean).join(' | ');
|
||||||
|
}
|
||||||
|
|
||||||
// Clear timeout on unmount
|
// Clear timeout on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@ -151,7 +163,7 @@ const SearchBar: React.FC<SearchBarProps> = ({ className = '' }) => {
|
|||||||
>
|
>
|
||||||
<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">
|
||||||
CPF: {candidate.cpf} | {candidate.ocupacao}
|
{getCandidateDescription(candidate)}
|
||||||
</div>
|
</div>
|
||||||
{candidate.email && (
|
{candidate.email && (
|
||||||
<div className="text-gray-500 text-xs mt-1">{candidate.email}</div>
|
<div className="text-gray-500 text-xs mt-1">{candidate.email}</div>
|
||||||
|
180
src/components/Tooltip.tsx
Normal file
180
src/components/Tooltip.tsx
Normal file
@ -0,0 +1,180 @@
|
|||||||
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
|
||||||
|
interface TooltipProps {
|
||||||
|
content: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
position?: 'top' | 'bottom' | 'left' | 'right';
|
||||||
|
delay?: number;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Tooltip: React.FC<TooltipProps> = ({
|
||||||
|
content,
|
||||||
|
children,
|
||||||
|
position = 'top',
|
||||||
|
delay = 300,
|
||||||
|
className = ''
|
||||||
|
}) => {
|
||||||
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
const [showTooltip, setShowTooltip] = useState(false);
|
||||||
|
const [tooltipPosition, setTooltipPosition] = useState({ top: 0, left: 0 });
|
||||||
|
const timeoutRef = useRef<number | null>(null);
|
||||||
|
const tooltipRef = useRef<HTMLDivElement>(null);
|
||||||
|
const triggerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const calculateTooltipPosition = () => {
|
||||||
|
if (!triggerRef.current) return { top: 0, left: 0 };
|
||||||
|
|
||||||
|
const triggerRect = triggerRef.current.getBoundingClientRect();
|
||||||
|
|
||||||
|
let top = 0;
|
||||||
|
let left = 0;
|
||||||
|
|
||||||
|
switch (position) {
|
||||||
|
case 'top':
|
||||||
|
top = triggerRect.top - 8; // 8px offset for arrow and spacing
|
||||||
|
left = triggerRect.left + triggerRect.width / 2;
|
||||||
|
break;
|
||||||
|
case 'bottom':
|
||||||
|
top = triggerRect.bottom + 8;
|
||||||
|
left = triggerRect.left + triggerRect.width / 2;
|
||||||
|
break;
|
||||||
|
case 'left':
|
||||||
|
top = triggerRect.top + triggerRect.height / 2;
|
||||||
|
left = triggerRect.left - 8;
|
||||||
|
break;
|
||||||
|
case 'right':
|
||||||
|
top = triggerRect.top + triggerRect.height / 2;
|
||||||
|
left = triggerRect.right + 8;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { top, left };
|
||||||
|
};
|
||||||
|
|
||||||
|
const showTooltipWithDelay = () => {
|
||||||
|
if (timeoutRef.current) {
|
||||||
|
clearTimeout(timeoutRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
timeoutRef.current = setTimeout(() => {
|
||||||
|
const position = calculateTooltipPosition();
|
||||||
|
setTooltipPosition(position);
|
||||||
|
setIsVisible(true);
|
||||||
|
// Small delay to allow for positioning before showing
|
||||||
|
setTimeout(() => setShowTooltip(true), 10);
|
||||||
|
}, delay);
|
||||||
|
};
|
||||||
|
|
||||||
|
const hideTooltip = () => {
|
||||||
|
if (timeoutRef.current) {
|
||||||
|
clearTimeout(timeoutRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
setShowTooltip(false);
|
||||||
|
setTimeout(() => setIsVisible(false), 150); // Match transition duration
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update tooltip position on scroll or resize
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isVisible) return;
|
||||||
|
|
||||||
|
const updatePosition = () => {
|
||||||
|
const position = calculateTooltipPosition();
|
||||||
|
setTooltipPosition(position);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleScroll = () => updatePosition();
|
||||||
|
const handleResize = () => updatePosition();
|
||||||
|
|
||||||
|
window.addEventListener('scroll', handleScroll, { passive: true });
|
||||||
|
window.addEventListener('resize', handleResize, { passive: true });
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('scroll', handleScroll);
|
||||||
|
window.removeEventListener('resize', handleResize);
|
||||||
|
};
|
||||||
|
}, [isVisible, position]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (timeoutRef.current) {
|
||||||
|
clearTimeout(timeoutRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getTooltipClasses = () => {
|
||||||
|
const baseClasses = `
|
||||||
|
fixed z-[9999] px-3 py-2 text-sm font-medium text-white bg-gray-900
|
||||||
|
rounded-lg shadow-xl backdrop-blur-sm border border-gray-700
|
||||||
|
transition-all duration-150 ease-in-out pointer-events-none
|
||||||
|
max-w-xs break-words
|
||||||
|
`;
|
||||||
|
|
||||||
|
const transformClasses = {
|
||||||
|
top: '-translate-x-1/2 -translate-y-full',
|
||||||
|
bottom: '-translate-x-1/2',
|
||||||
|
left: '-translate-x-full -translate-y-1/2',
|
||||||
|
right: '-translate-y-1/2'
|
||||||
|
};
|
||||||
|
|
||||||
|
const visibilityClasses = showTooltip
|
||||||
|
? 'opacity-100 scale-100'
|
||||||
|
: 'opacity-0 scale-95';
|
||||||
|
|
||||||
|
return `${baseClasses} ${transformClasses[position]} ${visibilityClasses}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getArrowClasses = () => {
|
||||||
|
const baseClasses = 'absolute w-2 h-2 bg-gray-900 border border-gray-700 transform rotate-45';
|
||||||
|
|
||||||
|
const arrowPositions = {
|
||||||
|
top: 'bottom-0 left-1/2 transform -translate-x-1/2 translate-y-1/2 border-t-0 border-l-0',
|
||||||
|
bottom: 'top-0 left-1/2 transform -translate-x-1/2 -translate-y-1/2 border-b-0 border-r-0',
|
||||||
|
left: 'right-0 top-1/2 transform translate-x-1/2 -translate-y-1/2 border-l-0 border-b-0',
|
||||||
|
right: 'left-0 top-1/2 transform -translate-x-1/2 -translate-y-1/2 border-r-0 border-t-0'
|
||||||
|
};
|
||||||
|
|
||||||
|
return `${baseClasses} ${arrowPositions[position]}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!content) {
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
ref={triggerRef}
|
||||||
|
className={`relative inline-block ${className}`}
|
||||||
|
onMouseEnter={showTooltipWithDelay}
|
||||||
|
onMouseLeave={hideTooltip}
|
||||||
|
onFocus={showTooltipWithDelay}
|
||||||
|
onBlur={hideTooltip}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isVisible && createPortal(
|
||||||
|
<div
|
||||||
|
ref={tooltipRef}
|
||||||
|
className={getTooltipClasses()}
|
||||||
|
style={{
|
||||||
|
top: tooltipPosition.top,
|
||||||
|
left: tooltipPosition.left,
|
||||||
|
}}
|
||||||
|
role="tooltip"
|
||||||
|
aria-hidden={!showTooltip}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
<div className={getArrowClasses()} />
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Tooltip;
|
@ -1,27 +0,0 @@
|
|||||||
import { openCandApi, OpenCandApi } from '../api';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Example usage of the OpenCand API client
|
|
||||||
*/
|
|
||||||
export class OpenCandApiExample {
|
|
||||||
private api: OpenCandApi;
|
|
||||||
|
|
||||||
constructor(baseUrl?: string) {
|
|
||||||
// You can use the default instance or create a new one with custom base URL
|
|
||||||
this.api = baseUrl ? new OpenCandApi(baseUrl) : openCandApi;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Setup API configuration
|
|
||||||
*/
|
|
||||||
setupApi(baseUrl: string, additionalHeaders?: Record<string, string>): void {
|
|
||||||
this.api.setBaseUrl(baseUrl);
|
|
||||||
|
|
||||||
if (additionalHeaders) {
|
|
||||||
this.api.setDefaultHeaders(additionalHeaders);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set a custom timeout (optional)
|
|
||||||
this.api.setTimeout(45000); // 45 seconds
|
|
||||||
}
|
|
||||||
}
|
|
58
src/utils/utils.ts
Normal file
58
src/utils/utils.ts
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
/**
|
||||||
|
* Formats a CPF string with masking (123.***.789-10)
|
||||||
|
* @param cpf - A CPF string (11 digits without punctuation)
|
||||||
|
* @returns Formatted CPF with masking or the original input if invalid
|
||||||
|
*/
|
||||||
|
export function maskCpf(cpf: string): string {
|
||||||
|
// Clean input, keeping only digits
|
||||||
|
const cleanCpf = cpf.replace(/\D/g, '');
|
||||||
|
|
||||||
|
// Validate if it's 11 digits
|
||||||
|
if (cleanCpf.length !== 11) {
|
||||||
|
return cpf;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format with mask: 123.***.789-10
|
||||||
|
return `${cleanCpf.slice(0, 3)}.***.${cleanCpf.slice(6, 9)}-${cleanCpf.slice(9)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a CPF string with proper punctuation (123.456.789-10)
|
||||||
|
* @param cpf - A CPF string (11 digits without punctuation)
|
||||||
|
* @returns Formatted CPF or the original input if invalid
|
||||||
|
*/
|
||||||
|
export function formatCpf(cpf: string): string {
|
||||||
|
// Clean input, keeping only digits
|
||||||
|
const cleanCpf = cpf.replace(/\D/g, '');
|
||||||
|
|
||||||
|
// Validate if it's 11 digits
|
||||||
|
if (cleanCpf.length !== 11) {
|
||||||
|
return cpf;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format with standard punctuation: 123.456.789-10
|
||||||
|
return `${cleanCpf.slice(0, 3)}.${cleanCpf.slice(3, 6)}.${cleanCpf.slice(6, 9)}-${cleanCpf.slice(9)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a date to DD/MM/YYYY format
|
||||||
|
* @param date - Date object or string/number that can be parsed into a Date
|
||||||
|
* @returns Formatted date string in DD/MM/YYYY format or empty string if invalid
|
||||||
|
*/
|
||||||
|
export function formatDateToDDMMYYYY(date: Date | string | number): string {
|
||||||
|
try {
|
||||||
|
const dateObj = date instanceof Date ? date : new Date(date);
|
||||||
|
|
||||||
|
if (isNaN(dateObj.getTime())) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const day = String(dateObj.getDate()).padStart(2, '0');
|
||||||
|
const month = String(dateObj.getMonth() + 1).padStart(2, '0'); // Months are 0-indexed
|
||||||
|
const year = dateObj.getFullYear();
|
||||||
|
|
||||||
|
return `${day}/${month}/${year}`;
|
||||||
|
} catch (error) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user