despesas e receitas
All checks were successful
Frontend Build and Deploy / build (push) Successful in 22s
All checks were successful
Frontend Build and Deploy / build (push) Successful in 22s
This commit is contained in:
parent
475979a09a
commit
83ff2131f7
@ -73,4 +73,54 @@ export interface Partido {
|
||||
nome: string;
|
||||
sigla: string;
|
||||
numero: number;
|
||||
}
|
||||
}
|
||||
|
||||
export interface CandidateExpenses {
|
||||
despesas: Expense[];
|
||||
}
|
||||
|
||||
export interface Expense {
|
||||
idDespesa: string;
|
||||
idCandidato: string;
|
||||
ano: number;
|
||||
turno: number;
|
||||
sqCandidato: string;
|
||||
sgPartido: string;
|
||||
tipoFornecedor: string;
|
||||
cpfFornecedor: string;
|
||||
cnpjFornecedor: string;
|
||||
nomeFornecedor: string;
|
||||
nomeFornecedorRFB: string;
|
||||
municipioFornecedor: string;
|
||||
tipoDocumento: string;
|
||||
dataDespesa: string; // ISO date
|
||||
descricao: string;
|
||||
origemDespesa: string;
|
||||
valor: number;
|
||||
}
|
||||
|
||||
export interface CandidateIncome {
|
||||
receitas: Income[];
|
||||
}
|
||||
|
||||
export interface Income {
|
||||
idReceita: string;
|
||||
idCandidato: string;
|
||||
ano: number;
|
||||
turno: number;
|
||||
sqCandidato: string;
|
||||
sgPartido: string;
|
||||
fonteReceita: string;
|
||||
origemReceita: string;
|
||||
naturezaReceita: string;
|
||||
especieReceita: string;
|
||||
cpfDoador: string;
|
||||
cnpjDoador: string;
|
||||
nomeDoador: string;
|
||||
nomeDoadorRFB: string;
|
||||
municipioDoador: string;
|
||||
dataReceita: string; // ISO date
|
||||
descricao: string;
|
||||
valor: number;
|
||||
}
|
||||
|
||||
|
@ -10,6 +10,12 @@ export type {
|
||||
CandidateRedesSociais,
|
||||
RedeSocial,
|
||||
PlatformStats,
|
||||
CpfRevealResult,
|
||||
Partido,
|
||||
CandidateExpenses,
|
||||
Expense,
|
||||
CandidateIncome,
|
||||
Income,
|
||||
} from './apiModels';
|
||||
|
||||
// Export base API classes for custom implementations
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { BaseApiClient } from './base';
|
||||
import { API_CONFIG } from '../config/api';
|
||||
import type { CandidateAssets, CandidateDetails, CandidateRedesSociais, CandidateSearchResult, CpfRevealResult, PlatformStats } from './apiModels';
|
||||
import type { CandidateAssets, CandidateDetails, CandidateExpenses, CandidateIncome, CandidateRedesSociais, CandidateSearchResult, CpfRevealResult, PlatformStats } from './apiModels';
|
||||
|
||||
/**
|
||||
* OpenCand API client for interacting with the OpenCand platform
|
||||
@ -53,6 +53,20 @@ export class OpenCandApi extends BaseApiClient {
|
||||
async getCandidateCpf(id: string): Promise<CpfRevealResult> {
|
||||
return this.get<CpfRevealResult>(`/v1/candidato/${id}/reveal-cpf`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the expenses for a specific candidate by ID and year
|
||||
*/
|
||||
async getCandidateDepesas(id: string): Promise<CandidateExpenses> {
|
||||
return this.get<CandidateExpenses>(`/v1/candidato/${id}/despesas`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the expenses for a specific candidate by ID and year
|
||||
*/
|
||||
async getCandidateReceitas(id: string): Promise<CandidateIncome> {
|
||||
return this.get<CandidateIncome>(`/v1/candidato/${id}/receitas`);
|
||||
}
|
||||
}
|
||||
|
||||
// Create a default instance for easy usage with proper configuration
|
||||
|
@ -1,11 +1,12 @@
|
||||
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 { openCandApi, type CandidateDetails, type CandidateAssets, type CandidateRedesSociais, type CandidateExpenses, type CandidateIncome, ApiError } from '../../api';
|
||||
import ElectionsComponent from './ElectionsComponent';
|
||||
import AssetsComponent from './AssetsComponent';
|
||||
import BasicCandidateInfoComponent from './BasicCandidateInfoComponent';
|
||||
import SocialMediaComponent from './SocialMediaComponent';
|
||||
import IncomeExpenseComponent from './IncomeExpenseComponent';
|
||||
|
||||
const CandidatePage: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
@ -13,9 +14,13 @@ const CandidatePage: React.FC = () => {
|
||||
const [candidateDetails, setCandidateDetails] = useState<CandidateDetails | null>(null);
|
||||
const [candidateAssets, setCandidateAssets] = useState<CandidateAssets | null>(null);
|
||||
const [candidateRedesSociais, setCandidateRedesSociais] = useState<CandidateRedesSociais | null>(null);
|
||||
const [candidateExpenses, setCandidateExpenses] = useState<CandidateExpenses | null>(null);
|
||||
const [candidateIncome, setCandidateIncome] = useState<CandidateIncome | null>(null);
|
||||
const [isLoadingDetails, setIsLoadingDetails] = useState(true);
|
||||
const [isLoadingAssets, setIsLoadingAssets] = useState(true);
|
||||
const [isLoadingRedesSociais, setIsLoadingRedesSociais] = useState(true);
|
||||
const [isLoadingExpenses, setIsLoadingExpenses] = useState(true);
|
||||
const [isLoadingIncome, setIsLoadingIncome] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@ -70,9 +75,39 @@ const CandidatePage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch candidate expenses
|
||||
const fetchCandidateExpenses = async () => {
|
||||
try {
|
||||
setIsLoadingExpenses(true);
|
||||
const expenses = await openCandApi.getCandidateDepesas(id);
|
||||
setCandidateExpenses(expenses);
|
||||
} catch (err) {
|
||||
console.error('Error fetching candidate expenses:', err);
|
||||
// Expenses might not be available for all candidates, so we don't set error here
|
||||
} finally {
|
||||
setIsLoadingExpenses(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch candidate income
|
||||
const fetchCandidateIncome = async () => {
|
||||
try {
|
||||
setIsLoadingIncome(true);
|
||||
const income = await openCandApi.getCandidateReceitas(id);
|
||||
setCandidateIncome(income);
|
||||
} catch (err) {
|
||||
console.error('Error fetching candidate income:', err);
|
||||
// Income might not be available for all candidates, so we don't set error here
|
||||
} finally {
|
||||
setIsLoadingIncome(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchCandidateDetails();
|
||||
fetchCandidateAssets();
|
||||
fetchCandidateRedesSociais();
|
||||
fetchCandidateExpenses();
|
||||
fetchCandidateIncome();
|
||||
}, [id, navigate]);
|
||||
|
||||
if (error) {
|
||||
@ -138,6 +173,14 @@ const CandidatePage: React.FC = () => {
|
||||
assets={candidateAssets?.bens || null}
|
||||
isLoading={isLoadingAssets}
|
||||
/>
|
||||
|
||||
{/* Income and Expenses Panel */}
|
||||
<IncomeExpenseComponent
|
||||
expenses={candidateExpenses}
|
||||
income={candidateIncome}
|
||||
isLoadingExpenses={isLoadingExpenses}
|
||||
isLoadingIncome={isLoadingIncome}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
321
src/components/CandidatePage/IncomeExpense/ExpenseSection.tsx
Normal file
321
src/components/CandidatePage/IncomeExpense/ExpenseSection.tsx
Normal file
@ -0,0 +1,321 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
ChevronUpIcon,
|
||||
ArrowTrendingDownIcon,
|
||||
CalendarIcon,
|
||||
UserIcon,
|
||||
BuildingOfficeIcon
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { type CandidateExpenses, type Expense } from '../../../api';
|
||||
|
||||
interface ExpenseSectionProps {
|
||||
expenses: CandidateExpenses | null;
|
||||
isLoadingExpenses: boolean;
|
||||
}
|
||||
|
||||
const ExpenseSection: React.FC<ExpenseSectionProps> = ({
|
||||
expenses,
|
||||
isLoadingExpenses
|
||||
}) => {
|
||||
const [expandedYears, setExpandedYears] = useState<Set<number>>(new Set());
|
||||
const [visibleExpenseItems, setVisibleExpenseItems] = useState<Record<number, number>>({});
|
||||
const [expandedExpenseItems, setExpandedExpenseItems] = useState<Set<string>>(new Set());
|
||||
|
||||
const formatCurrency = (value: number) => {
|
||||
return new Intl.NumberFormat('pt-BR', {
|
||||
style: 'currency',
|
||||
currency: 'BRL'
|
||||
}).format(value);
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('pt-BR');
|
||||
};
|
||||
|
||||
const groupExpensesByYear = useMemo(() => {
|
||||
const despesas = expenses?.despesas || [];
|
||||
if (!despesas.length) return {};
|
||||
|
||||
return despesas.reduce((acc: Record<number, Expense[]>, item: Expense) => {
|
||||
const year = item.ano;
|
||||
if (!acc[year]) {
|
||||
acc[year] = [];
|
||||
}
|
||||
acc[year].push(item);
|
||||
return acc;
|
||||
}, {} as Record<number, Expense[]>);
|
||||
}, [expenses]);
|
||||
|
||||
// Sort items by value (descending) for each year
|
||||
const sortedExpensesByYear = useMemo(() => {
|
||||
const sorted: Record<number, Expense[]> = {};
|
||||
Object.keys(groupExpensesByYear).forEach(year => {
|
||||
const yearNum = Number(year);
|
||||
sorted[yearNum] = [...groupExpensesByYear[yearNum]].sort((a, b) => b.valor - a.valor);
|
||||
});
|
||||
return sorted;
|
||||
}, [groupExpensesByYear]);
|
||||
|
||||
const sortedExpenseYears = useMemo(() => {
|
||||
return Object.keys(sortedExpensesByYear).map(Number).sort((a, b) => b - a);
|
||||
}, [sortedExpensesByYear]);
|
||||
|
||||
const getTotalExpenses = () => {
|
||||
const despesas = expenses?.despesas || [];
|
||||
return despesas.reduce((total: number, item: Expense) => total + item.valor, 0);
|
||||
};
|
||||
|
||||
const getTotalForExpenseYear = (yearExpenses: Expense[]) => {
|
||||
return yearExpenses.reduce((total, item) => total + item.valor, 0);
|
||||
};
|
||||
|
||||
const getVisibleExpenseItemsForYear = (year: number) => {
|
||||
return visibleExpenseItems[year] || 50;
|
||||
};
|
||||
|
||||
const loadMoreExpenseItems = (year: number) => {
|
||||
setVisibleExpenseItems(prev => ({
|
||||
...prev,
|
||||
[year]: (prev[year] || 50) + 50
|
||||
}));
|
||||
};
|
||||
|
||||
const toggleYear = (year: number) => {
|
||||
setExpandedYears(prev => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(year)) {
|
||||
newSet.delete(year);
|
||||
} else {
|
||||
newSet.add(year);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleExpenseItem = (itemId: string) => {
|
||||
setExpandedExpenseItems(prev => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(itemId)) {
|
||||
newSet.delete(itemId);
|
||||
} else {
|
||||
newSet.add(itemId);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
const despesas = expenses?.despesas || [];
|
||||
const isEmpty = despesas.length === 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center mb-4">
|
||||
<ArrowTrendingDownIcon className="h-6 w-6 text-red-600 mr-2" />
|
||||
<h3 className="text-xl font-bold text-gray-900">Despesas</h3>
|
||||
</div>
|
||||
|
||||
{isLoadingExpenses ? (
|
||||
<div className="flex items-center justify-center h-24">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-4 border-red-600 border-t-transparent"></div>
|
||||
</div>
|
||||
) : !isEmpty ? (
|
||||
<>
|
||||
{/* Summary Card */}
|
||||
<div className="bg-gradient-to-r from-red-50 to-orange-50 p-4 rounded-lg mb-4 border border-red-100">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-lg font-semibold text-gray-900">Total de Despesas</span>
|
||||
<span className="text-xl font-bold text-red-600">
|
||||
{formatCurrency(getTotalExpenses())}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{sortedExpenseYears.map((year) => {
|
||||
const yearExpenses = sortedExpensesByYear[year];
|
||||
const isExpanded = expandedYears.has(year + 10000); // Add offset to avoid conflicts with income years
|
||||
const visibleCount = getVisibleExpenseItemsForYear(year);
|
||||
const hasMore = yearExpenses.length > visibleCount;
|
||||
const visibleItems = yearExpenses.slice(0, visibleCount);
|
||||
|
||||
return (
|
||||
<div key={`expense-${year}`} className="border border-gray-200 rounded-lg overflow-hidden">
|
||||
<button
|
||||
onClick={() => toggleYear(year + 10000)}
|
||||
className="w-full px-4 py-3 bg-gray-50 hover:bg-gray-100 flex items-center justify-between transition-colors"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<CalendarIcon className="h-5 w-5 text-gray-600 mr-2" />
|
||||
<span className="font-semibold text-gray-900">{year}</span>
|
||||
<span className="ml-2 text-sm text-gray-600">
|
||||
({yearExpenses.length} {yearExpenses.length === 1 ? 'item' : 'itens'})
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<span className="font-bold text-red-600 mr-2">
|
||||
{formatCurrency(getTotalForExpenseYear(yearExpenses))}
|
||||
</span>
|
||||
{isExpanded ? (
|
||||
<ChevronUpIcon className="h-5 w-5 text-gray-600" />
|
||||
) : (
|
||||
<ChevronDownIcon className="h-5 w-5 text-gray-600" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="bg-white">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 border-b">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-900">Data</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-900">Fornecedor</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-900">Descrição</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-gray-900">Valor</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{visibleItems.map((item, index) => {
|
||||
const itemId = `expense-${item.ano}-${index}`;
|
||||
const isItemExpanded = expandedExpenseItems.has(itemId);
|
||||
|
||||
return (
|
||||
<React.Fragment key={itemId}>
|
||||
<tr
|
||||
className="hover:bg-gray-50 cursor-pointer transition-colors"
|
||||
onClick={() => toggleExpenseItem(itemId)}
|
||||
>
|
||||
<td className="px-4 py-3 text-gray-900">
|
||||
{formatDate(item.dataDespesa)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center">
|
||||
{item.cpfFornecedor ? (
|
||||
<UserIcon className="h-4 w-4 text-gray-400 mr-2" />
|
||||
) : (
|
||||
<BuildingOfficeIcon className="h-4 w-4 text-gray-400 mr-2" />
|
||||
)}
|
||||
<span className="text-gray-900 font-medium">
|
||||
{item.nomeFornecedor || item.nomeFornecedorRFB || 'Fornecedor não informado'}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-600 max-w-xs truncate">
|
||||
{item.descricao || 'Descrição não informada'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right font-semibold">
|
||||
<div className="flex items-center justify-end">
|
||||
<span className="text-red-600 mr-2">
|
||||
{formatCurrency(item.valor)}
|
||||
</span>
|
||||
{isItemExpanded ? (
|
||||
<ChevronUpIcon className="h-4 w-4 text-gray-400" />
|
||||
) : (
|
||||
<ChevronDownIcon className="h-4 w-4 text-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{isItemExpanded && (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-4 py-3 bg-gray-50 border-l-4 border-red-500">
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-semibold text-gray-900 mb-3">Detalhes da Despesa</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-600 font-medium">ID:</span>
|
||||
<span className="ml-2 text-gray-900">{item.idDespesa}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-600 font-medium">Ano:</span>
|
||||
<span className="ml-2 text-gray-900">{item.ano}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-600 font-medium">Turno:</span>
|
||||
<span className="ml-2 text-gray-900">{item.turno}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-600 font-medium">Partido:</span>
|
||||
<span className="ml-2 text-gray-900">{item.sgPartido}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-600 font-medium">Tipo Fornecedor:</span>
|
||||
<span className="ml-2 text-gray-900">{item.tipoFornecedor || '-'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-600 font-medium">Tipo Documento:</span>
|
||||
<span className="ml-2 text-gray-900">{item.tipoDocumento || '-'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-600 font-medium">Origem:</span>
|
||||
<span className="ml-2 text-gray-900">{item.origemDespesa || '-'}</span>
|
||||
</div>
|
||||
{item.cpfFornecedor && (
|
||||
<div>
|
||||
<span className="text-gray-600 font-medium">CPF:</span>
|
||||
<span className="ml-2 text-gray-900">{item.cpfFornecedor}</span>
|
||||
</div>
|
||||
)}
|
||||
{item.cnpjFornecedor && (
|
||||
<div>
|
||||
<span className="text-gray-600 font-medium">CNPJ:</span>
|
||||
<span className="ml-2 text-gray-900">{item.cnpjFornecedor}</span>
|
||||
</div>
|
||||
)}
|
||||
{item.municipioFornecedor && (
|
||||
<div>
|
||||
<span className="text-gray-600 font-medium">Município:</span>
|
||||
<span className="ml-2 text-gray-900">{item.municipioFornecedor}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{item.descricao && (
|
||||
<div className="mt-3 pt-3 border-t border-gray-200">
|
||||
<span className="text-gray-600 font-medium">Descrição Completa:</span>
|
||||
<p className="ml-2 text-gray-900 mt-1">{item.descricao}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Load More Button */}
|
||||
{hasMore && (
|
||||
<div className="p-4 border-t border-gray-200 bg-gray-50 text-center">
|
||||
<button
|
||||
onClick={() => loadMoreExpenseItems(year)}
|
||||
className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors font-medium"
|
||||
>
|
||||
Carregar mais 50 itens ({yearExpenses.length - visibleCount} restantes)
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center text-gray-500 py-6">
|
||||
<div className="flex flex-col items-center">
|
||||
<ArrowTrendingDownIcon className="h-10 w-10 text-gray-300 mb-2" />
|
||||
<span>Nenhuma despesa encontrada</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExpenseSection;
|
321
src/components/CandidatePage/IncomeExpense/IncomeSection.tsx
Normal file
321
src/components/CandidatePage/IncomeExpense/IncomeSection.tsx
Normal file
@ -0,0 +1,321 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
ChevronUpIcon,
|
||||
ArrowTrendingUpIcon,
|
||||
CalendarIcon,
|
||||
UserIcon,
|
||||
BuildingOfficeIcon
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { type CandidateIncome, type Income } from '../../../api';
|
||||
|
||||
interface IncomeSectionProps {
|
||||
income: CandidateIncome | null;
|
||||
isLoadingIncome: boolean;
|
||||
}
|
||||
|
||||
const IncomeSection: React.FC<IncomeSectionProps> = ({
|
||||
income,
|
||||
isLoadingIncome
|
||||
}) => {
|
||||
const [expandedYears, setExpandedYears] = useState<Set<number>>(new Set());
|
||||
const [visibleIncomeItems, setVisibleIncomeItems] = useState<Record<number, number>>({});
|
||||
const [expandedIncomeItems, setExpandedIncomeItems] = useState<Set<string>>(new Set());
|
||||
|
||||
const formatCurrency = (value: number) => {
|
||||
return new Intl.NumberFormat('pt-BR', {
|
||||
style: 'currency',
|
||||
currency: 'BRL'
|
||||
}).format(value);
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('pt-BR');
|
||||
};
|
||||
|
||||
const groupIncomeByYear = useMemo(() => {
|
||||
const receitas = income?.receitas || [];
|
||||
if (!receitas.length) return {};
|
||||
|
||||
return receitas.reduce((acc: Record<number, Income[]>, item: Income) => {
|
||||
const year = item.ano;
|
||||
if (!acc[year]) {
|
||||
acc[year] = [];
|
||||
}
|
||||
acc[year].push(item);
|
||||
return acc;
|
||||
}, {} as Record<number, Income[]>);
|
||||
}, [income]);
|
||||
|
||||
// Sort items by value (descending) for each year
|
||||
const sortedIncomeByYear = useMemo(() => {
|
||||
const sorted: Record<number, Income[]> = {};
|
||||
Object.keys(groupIncomeByYear).forEach(year => {
|
||||
const yearNum = Number(year);
|
||||
sorted[yearNum] = [...groupIncomeByYear[yearNum]].sort((a, b) => b.valor - a.valor);
|
||||
});
|
||||
return sorted;
|
||||
}, [groupIncomeByYear]);
|
||||
|
||||
const sortedIncomeYears = useMemo(() => {
|
||||
return Object.keys(sortedIncomeByYear).map(Number).sort((a, b) => b - a);
|
||||
}, [sortedIncomeByYear]);
|
||||
|
||||
const getTotalIncome = () => {
|
||||
const receitas = income?.receitas || [];
|
||||
return receitas.reduce((total: number, item: Income) => total + item.valor, 0);
|
||||
};
|
||||
|
||||
const getTotalForIncomeYear = (yearIncome: Income[]) => {
|
||||
return yearIncome.reduce((total, item) => total + item.valor, 0);
|
||||
};
|
||||
|
||||
const getVisibleIncomeItemsForYear = (year: number) => {
|
||||
return visibleIncomeItems[year] || 50;
|
||||
};
|
||||
|
||||
const loadMoreIncomeItems = (year: number) => {
|
||||
setVisibleIncomeItems(prev => ({
|
||||
...prev,
|
||||
[year]: (prev[year] || 50) + 50
|
||||
}));
|
||||
};
|
||||
|
||||
const toggleYear = (year: number) => {
|
||||
setExpandedYears(prev => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(year)) {
|
||||
newSet.delete(year);
|
||||
} else {
|
||||
newSet.add(year);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleIncomeItem = (itemId: string) => {
|
||||
setExpandedIncomeItems(prev => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(itemId)) {
|
||||
newSet.delete(itemId);
|
||||
} else {
|
||||
newSet.add(itemId);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
const receitas = income?.receitas || [];
|
||||
const isEmpty = receitas.length === 0;
|
||||
|
||||
return (
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center mb-4">
|
||||
<ArrowTrendingUpIcon className="h-6 w-6 text-green-600 mr-2" />
|
||||
<h3 className="text-xl font-bold text-gray-900">Receitas</h3>
|
||||
</div>
|
||||
|
||||
{isLoadingIncome ? (
|
||||
<div className="flex items-center justify-center h-24">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-4 border-green-600 border-t-transparent"></div>
|
||||
</div>
|
||||
) : !isEmpty ? (
|
||||
<>
|
||||
{/* Summary Card */}
|
||||
<div className="bg-gradient-to-r from-green-50 to-blue-50 p-4 rounded-lg mb-4 border border-green-100">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-lg font-semibold text-gray-900">Total de Receitas</span>
|
||||
<span className="text-xl font-bold text-green-600">
|
||||
{formatCurrency(getTotalIncome())}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{sortedIncomeYears.map((year) => {
|
||||
const yearIncome = sortedIncomeByYear[year];
|
||||
const isExpanded = expandedYears.has(year);
|
||||
const visibleCount = getVisibleIncomeItemsForYear(year);
|
||||
const hasMore = yearIncome.length > visibleCount;
|
||||
const visibleItems = yearIncome.slice(0, visibleCount);
|
||||
|
||||
return (
|
||||
<div key={`income-${year}`} className="border border-gray-200 rounded-lg overflow-hidden">
|
||||
<button
|
||||
onClick={() => toggleYear(year)}
|
||||
className="w-full px-4 py-3 bg-gray-50 hover:bg-gray-100 flex items-center justify-between transition-colors"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<CalendarIcon className="h-5 w-5 text-gray-600 mr-2" />
|
||||
<span className="font-semibold text-gray-900">{year}</span>
|
||||
<span className="ml-2 text-sm text-gray-600">
|
||||
({yearIncome.length} {yearIncome.length === 1 ? 'item' : 'itens'})
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<span className="font-bold text-green-600 mr-2">
|
||||
{formatCurrency(getTotalForIncomeYear(yearIncome))}
|
||||
</span>
|
||||
{isExpanded ? (
|
||||
<ChevronUpIcon className="h-5 w-5 text-gray-600" />
|
||||
) : (
|
||||
<ChevronDownIcon className="h-5 w-5 text-gray-600" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="bg-white">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 border-b">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-900">Data</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-900">Doador</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-900">Descrição</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-gray-900">Valor</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{visibleItems.map((item, index) => {
|
||||
const itemId = `income-${item.ano}-${index}`;
|
||||
const isItemExpanded = expandedIncomeItems.has(itemId);
|
||||
|
||||
return (
|
||||
<React.Fragment key={itemId}>
|
||||
<tr
|
||||
className="hover:bg-gray-50 cursor-pointer transition-colors"
|
||||
onClick={() => toggleIncomeItem(itemId)}
|
||||
>
|
||||
<td className="px-4 py-3 text-gray-900">
|
||||
{formatDate(item.dataReceita)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center">
|
||||
{item.cpfDoador ? (
|
||||
<UserIcon className="h-4 w-4 text-gray-400 mr-2" />
|
||||
) : (
|
||||
<BuildingOfficeIcon className="h-4 w-4 text-gray-400 mr-2" />
|
||||
)}
|
||||
<span className="text-gray-900 font-medium">
|
||||
{item.nomeDoador || item.nomeDoadorRFB || 'Doador não informado'}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-600 max-w-xs truncate">
|
||||
{item.descricao || 'Descrição não informada'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right font-semibold">
|
||||
<div className="flex items-center justify-end">
|
||||
<span className="text-green-600 mr-2">
|
||||
{formatCurrency(item.valor)}
|
||||
</span>
|
||||
{isItemExpanded ? (
|
||||
<ChevronUpIcon className="h-4 w-4 text-gray-400" />
|
||||
) : (
|
||||
<ChevronDownIcon className="h-4 w-4 text-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{isItemExpanded && (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-4 py-3 bg-gray-50 border-l-4 border-green-500">
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-semibold text-gray-900 mb-3">Detalhes da Receita</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-600 font-medium">ID:</span>
|
||||
<span className="ml-2 text-gray-900">{item.idReceita}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-600 font-medium">Ano:</span>
|
||||
<span className="ml-2 text-gray-900">{item.ano}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-600 font-medium">Turno:</span>
|
||||
<span className="ml-2 text-gray-900">{item.turno}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-600 font-medium">Fonte:</span>
|
||||
<span className="ml-2 text-gray-900">{item.fonteReceita || '-'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-600 font-medium">Origem:</span>
|
||||
<span className="ml-2 text-gray-900">{item.origemReceita || '-'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-600 font-medium">Natureza:</span>
|
||||
<span className="ml-2 text-gray-900">{item.naturezaReceita || '-'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-600 font-medium">Espécie:</span>
|
||||
<span className="ml-2 text-gray-900">{item.especieReceita || '-'}</span>
|
||||
</div>
|
||||
{item.cpfDoador && (
|
||||
<div>
|
||||
<span className="text-gray-600 font-medium">CPF:</span>
|
||||
<span className="ml-2 text-gray-900">{item.cpfDoador}</span>
|
||||
</div>
|
||||
)}
|
||||
{item.cnpjDoador && (
|
||||
<div>
|
||||
<span className="text-gray-600 font-medium">CNPJ:</span>
|
||||
<span className="ml-2 text-gray-900">{item.cnpjDoador}</span>
|
||||
</div>
|
||||
)}
|
||||
{item.municipioDoador && (
|
||||
<div>
|
||||
<span className="text-gray-600 font-medium">Município:</span>
|
||||
<span className="ml-2 text-gray-900">{item.municipioDoador}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{item.descricao && (
|
||||
<div className="mt-3 pt-3 border-t border-gray-200">
|
||||
<span className="text-gray-600 font-medium">Descrição Completa:</span>
|
||||
<p className="ml-2 text-gray-900 mt-1">{item.descricao}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Load More Button */}
|
||||
{hasMore && (
|
||||
<div className="p-4 border-t border-gray-200 bg-gray-50 text-center">
|
||||
<button
|
||||
onClick={() => loadMoreIncomeItems(year)}
|
||||
className="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg transition-colors font-medium"
|
||||
>
|
||||
Carregar mais 50 itens ({yearIncome.length - visibleCount} restantes)
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center text-gray-500 py-6">
|
||||
<div className="flex flex-col items-center">
|
||||
<ArrowTrendingUpIcon className="h-10 w-10 text-gray-300 mb-2" />
|
||||
<span>Nenhuma receita encontrada</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default IncomeSection;
|
51
src/components/CandidatePage/IncomeExpenseComponent.tsx
Normal file
51
src/components/CandidatePage/IncomeExpenseComponent.tsx
Normal file
@ -0,0 +1,51 @@
|
||||
import React from 'react';
|
||||
import { BanknotesIcon } from '@heroicons/react/24/outline';
|
||||
import { type CandidateExpenses, type CandidateIncome } from '../../api';
|
||||
import IncomeSection from './IncomeExpense/IncomeSection';
|
||||
import ExpenseSection from './IncomeExpense/ExpenseSection';
|
||||
|
||||
interface IncomeExpenseComponentProps {
|
||||
expenses: CandidateExpenses | null;
|
||||
income: CandidateIncome | null;
|
||||
isLoadingExpenses: boolean;
|
||||
isLoadingIncome: boolean;
|
||||
}
|
||||
|
||||
const IncomeExpenseComponent: React.FC<IncomeExpenseComponentProps> = ({
|
||||
expenses,
|
||||
income,
|
||||
isLoadingExpenses,
|
||||
isLoadingIncome
|
||||
}) => {
|
||||
return (
|
||||
<div className="bg-white/95 backdrop-blur-sm rounded-xl shadow-lg hover:shadow-xl transform hover:scale-[1.01] transition-all duration-200 p-6">
|
||||
<div className="flex items-center mb-6">
|
||||
<BanknotesIcon className="h-8 w-8 text-green-600 mr-3" />
|
||||
<h2 className="text-2xl font-bold text-gray-900">Receitas e Despesas</h2>
|
||||
</div>
|
||||
|
||||
{/* Income Section */}
|
||||
<IncomeSection
|
||||
income={income}
|
||||
isLoadingIncome={isLoadingIncome}
|
||||
/>
|
||||
|
||||
{/* Separator */}
|
||||
<div className="flex items-center my-8">
|
||||
<div className="flex-grow border-t border-gray-300"></div>
|
||||
<div className="flex-shrink-0 px-4">
|
||||
<div className="w-3 h-3 bg-gray-300 rounded-full"></div>
|
||||
</div>
|
||||
<div className="flex-grow border-t border-gray-300"></div>
|
||||
</div>
|
||||
|
||||
{/* Expenses Section */}
|
||||
<ExpenseSection
|
||||
expenses={expenses}
|
||||
isLoadingExpenses={isLoadingExpenses}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default IncomeExpenseComponent;
|
Loading…
x
Reference in New Issue
Block a user