webmoney/frontend/src/components/dashboard/OverpaymentsAnalysis.jsx
marcoitaloesp-ai 5f3bf18b99
v1.43.4 - Padronização de badges e botões em todo o sistema
- Badges: Estilo translúcido uniforme (bg-opacity-25 + text-color) via CSS global
- Afetados: RecurringTransactions, Accounts, Categories, TransactionsByWeek
- Widgets: UpcomingWidget, OverdueWidget, CalendarWidget, OverpaymentsAnalysis
- Botões: Estilo outline padronizado (btn-outline-*) em RecurringTransactions
- Simplificação: Remover classes redundantes dos JSX
2025-12-16 12:48:08 +00:00

458 lines
19 KiB
JavaScript

import React, { useState, useRef, useEffect } from 'react';
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
BarElement,
BarController,
Title,
Tooltip,
Legend,
} from 'chart.js';
import { Bar } from 'react-chartjs-2';
import { useTranslation } from 'react-i18next';
import useFormatters from '../../hooks/useFormatters';
// Registrar componentes do Chart.js
ChartJS.register(
CategoryScale,
LinearScale,
BarElement,
BarController,
Title,
Tooltip,
Legend
);
const OverpaymentsAnalysis = ({ data, loading, onTransactionClick }) => {
const { t, i18n } = useTranslation();
const { currency } = useFormatters();
const chartRef = useRef(null);
const [selectedMonth, setSelectedMonth] = useState(null);
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
const [isExpanded, setIsExpanded] = useState(false);
// Helper para locale dinâmico
const getLocale = () => i18n.language === 'pt-BR' ? 'pt-BR' : i18n.language === 'es' ? 'es-ES' : 'en-US';
// Agrupar transações por mês (apenas sobrepagamentos - variance > 0)
const overpaymentsByMonth = {};
if (data?.transactions) {
data.transactions
.filter(t => t.variance > 0)
.forEach(t => {
const month = t.effective_date.substring(0, 7);
if (!overpaymentsByMonth[month]) {
overpaymentsByMonth[month] = {
month,
total: 0,
count: 0,
transactions: []
};
}
overpaymentsByMonth[month].total += t.variance;
overpaymentsByMonth[month].count += 1;
overpaymentsByMonth[month].transactions.push(t);
});
}
// Criar array de meses ordenados
const monthsData = Object.values(overpaymentsByMonth).sort((a, b) => a.month.localeCompare(b.month));
useEffect(() => {
const handleResize = () => {
const mobile = window.innerWidth < 768;
setIsMobile(mobile);
if (!mobile) {
setIsExpanded(false);
}
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
// Auto-expand if we have data in mobile
useEffect(() => {
if (isMobile && monthsData.length > 0 && !isExpanded) {
setIsExpanded(true);
}
}, [isMobile, monthsData.length, isExpanded]);
if (loading) {
return (
<div className="card border-0 h-100" style={{ background: '#0f172a' }}>
<div className="card-body d-flex justify-content-center align-items-center" style={{ minHeight: '400px' }}>
<div className="spinner-border text-warning" role="status">
<span className="visually-hidden">Loading...</span>
</div>
</div>
</div>
);
}
if (!data || !data.transactions || data.transactions.length === 0) {
return (
<div className="card border-0 h-100" style={{ background: '#0f172a' }}>
<div className="card-header border-0 d-flex justify-content-between align-items-center py-3"
style={{ background: 'transparent' }}>
<h6 className="text-white mb-0 d-flex align-items-center">
<i className="bi bi-graph-up-arrow me-2 text-warning"></i>
{t('dashboard.overpaymentsAnalysis')}
</h6>
</div>
<div className="card-body d-flex justify-content-center align-items-center" style={{ minHeight: '300px' }}>
<div className="text-center text-slate-400">
<i className="bi bi-check-circle fs-1 mb-2 text-success"></i>
<p className="mb-0">{t('dashboard.noOverpayments')}</p>
</div>
</div>
</div>
);
}
// Calcular total geral
const totalOverpayment = monthsData.reduce((sum, m) => sum + m.total, 0);
// Labels do gráfico formatados
const labels = monthsData.map(d => {
const [year, month] = d.month.split('-');
const date = new Date(year, month - 1);
return date.toLocaleDateString(getLocale(), { month: 'short', year: 'numeric' });
});
const chartData = {
labels,
datasets: [
{
label: t('dashboard.overpayment'),
data: monthsData.map(d => d.total),
backgroundColor: monthsData.map((_, i) =>
selectedMonth === i ? 'rgba(251, 191, 36, 1)' : 'rgba(251, 191, 36, 0.7)'
),
borderColor: 'rgba(251, 191, 36, 1)',
borderWidth: 0,
borderRadius: 4,
hoverBackgroundColor: 'rgba(251, 191, 36, 1)',
},
],
};
const options = {
responsive: true,
maintainAspectRatio: false,
onClick: (event, elements) => {
if (elements.length > 0) {
const index = elements[0].index;
setSelectedMonth(selectedMonth === index ? null : index);
}
},
plugins: {
legend: {
display: false,
},
tooltip: {
backgroundColor: 'rgba(15, 23, 42, 0.95)',
titleColor: '#fbbf24',
bodyColor: '#f1f5f9',
borderColor: '#fbbf24',
borderWidth: 1,
padding: 12,
cornerRadius: 8,
displayColors: false,
callbacks: {
title: (items) => {
const idx = items[0].dataIndex;
return monthsData[idx]?.month || '';
},
label: (context) => {
const idx = context.dataIndex;
const monthData = monthsData[idx];
return [
`${t('dashboard.overpayment')}: ${currency(monthData.total, 'BRL')}`,
`${t('dashboard.transactions')}: ${monthData.count}`
];
},
},
},
},
scales: {
x: {
grid: {
display: false,
},
ticks: {
color: '#64748b',
font: { size: 11 },
},
},
y: {
grid: {
color: 'rgba(100, 116, 139, 0.1)',
drawBorder: false,
},
ticks: {
color: '#64748b',
font: { size: 11 },
callback: (value) => `+${currency(value, 'BRL').replace('R$', '').trim()}`,
},
},
},
};
// Dados do mês selecionado
const selectedMonthData = selectedMonth !== null ? monthsData[selectedMonth] : null;
return (
<div className="card border-0 h-100" style={{ background: '#0f172a', height: isMobile ? 'auto' : undefined }}>
{/* Header */}
<div className="card-header border-0 py-3"
style={{ background: 'transparent', borderBottom: '1px solid rgba(251, 191, 36, 0.2)' }}>
<div className="d-flex justify-content-between align-items-center mb-2">
<h6 className="text-white mb-0 d-flex align-items-center flex-grow-1">
<i className="bi bi-graph-up-arrow me-2 text-warning"></i>
<span className={isMobile ? 'small' : ''}>
{t('dashboard.overpaymentsAnalysis')} {!isMobile && `(${data.period?.months || 12} ${t('common.months')})`}
</span>
</h6>
{isMobile && (
<button
className="btn btn-sm btn-outline-warning ms-2"
onClick={() => setIsExpanded(!isExpanded)}
aria-label={isExpanded ? "Colapsar" : "Expandir"}
>
<i className={`bi bi-chevron-${isExpanded ? 'up' : 'down'}`}></i>
</button>
)}
</div>
{(!isMobile || isExpanded) && (
<span className={`badge bg-warning ${isMobile ? 'px-2 py-1' : 'px-3 py-2'}`}>
<i className="bi bi-arrow-up-right me-1"></i>
Total: {currency(totalOverpayment, 'BRL')}
</span>
)}
</div>
<div className="card-body" style={{ display: isMobile && !isExpanded ? 'none' : 'block' }}>
{/* Instrução */}
<p className={`text-slate-500 ${isMobile ? 'mb-2' : 'mb-3'}`} style={{ fontSize: isMobile ? '0.75rem' : undefined }}>
<i className="bi bi-info-circle me-1"></i>
{t('dashboard.clickBarToSeeDetails')}
</p>
{/* Gráfico */}
<div style={{ height: isMobile ? '180px' : '220px' }}>
<Bar ref={chartRef} data={chartData} options={options} />
</div>
{/* Painel de detalhes do mês selecionado */}
{selectedMonthData && (
<div className="mt-4 p-3 rounded-3" style={{
background: 'linear-gradient(135deg, rgba(251, 191, 36, 0.1) 0%, rgba(251, 191, 36, 0.05) 100%)',
border: '1px solid rgba(251, 191, 36, 0.3)'
}}>
{/* Header do painel */}
<div className="d-flex justify-content-between align-items-center mb-3">
<h6 className="text-warning mb-0 d-flex align-items-center">
<i className="bi bi-calendar-event me-2"></i>
{t('dashboard.overpayments')} - {(() => {
const [year, month] = selectedMonthData.month.split('-');
return new Date(year, month - 1).toLocaleDateString(getLocale(), { month: 'long', year: 'numeric' });
})()}
</h6>
<button
className="btn btn-outline-warning btn-sm"
onClick={() => setSelectedMonth(null)}
>
<i className="bi bi-x-lg me-1"></i>
{t('common.close')}
</button>
</div>
{/* Resumo do mês */}
<div className={`row ${isMobile ? 'g-2' : 'g-3'} mb-3`}>
<div className="col-4">
<div className={`text-center ${isMobile ? 'p-1' : 'p-2'} rounded`} style={{ background: 'rgba(0,0,0,0.2)' }}>
<small className="text-slate-400 d-block text-uppercase" style={{ fontSize: isMobile ? '0.6rem' : '10px', whiteSpace: isMobile ? 'nowrap' : 'normal' }}>
{isMobile ? 'TOTAL' : t('dashboard.totalOverpaid')}
</small>
<span className={`text-warning fw-bold d-block ${isMobile ? 'fs-6' : 'fs-5'}`} style={{ fontSize: isMobile ? '0.8rem' : undefined }}>
+{currency(selectedMonthData.total, 'BRL')}
</span>
</div>
</div>
<div className="col-4">
<div className={`text-center ${isMobile ? 'p-1' : 'p-2'} rounded`} style={{ background: 'rgba(0,0,0,0.2)' }}>
<small className="text-slate-400 d-block text-uppercase" style={{ fontSize: isMobile ? '0.6rem' : '10px', whiteSpace: isMobile ? 'nowrap' : 'normal' }}>
{isMobile ? 'QTD' : t('dashboard.transactions')}
</small>
<span className={`text-white fw-bold d-block ${isMobile ? 'fs-6' : 'fs-5'}`} style={{ fontSize: isMobile ? '0.8rem' : undefined }}>
{selectedMonthData.count}
</span>
</div>
</div>
<div className="col-4">
<div className={`text-center ${isMobile ? 'p-1' : 'p-2'} rounded`} style={{ background: 'rgba(0,0,0,0.2)' }}>
<small className="text-slate-400 d-block text-uppercase" style={{ fontSize: isMobile ? '0.6rem' : '10px', whiteSpace: isMobile ? 'nowrap' : 'normal' }}>
{isMobile ? 'MÉDIA' : t('dashboard.avgPerTransaction')}
</small>
<span className={`text-warning fw-bold d-block ${isMobile ? 'fs-6' : 'fs-5'}`} style={{ fontSize: isMobile ? '0.8rem' : undefined }}>
+{currency(selectedMonthData.total / selectedMonthData.count, 'BRL')}
</span>
</div>
</div>
</div>
{/* Tabela de transações */}
{isMobile ? (
/* Layout Mobile - Cards */
<div style={{ maxHeight: '200px', overflowY: 'auto' }}>
{selectedMonthData.transactions.map((tx) => (
<div
key={tx.id}
className="p-2 mb-2 rounded"
onClick={() => onTransactionClick?.(tx.id)}
style={{
cursor: 'pointer',
background: 'rgba(0,0,0,0.3)',
border: '1px solid rgba(251, 191, 36, 0.2)'
}}
>
<div className="d-flex justify-content-between align-items-start mb-1">
<span className="text-white" style={{ fontSize: '0.75rem', fontWeight: '500' }}>
{tx.description.length > 25 ? tx.description.substring(0, 25) + '...' : tx.description}
</span>
<span className="badge bg-warning ms-2" style={{ fontSize: '0.65rem' }}>
+{currency(tx.variance, 'BRL')}
</span>
</div>
<div className="d-flex justify-content-between align-items-center">
<div className="d-flex align-items-center gap-2">
{tx.category && (
<span
className="badge rounded-pill"
style={{
background: tx.category.color || '#6b7280',
fontSize: '0.6rem'
}}
>
{tx.category.name}
</span>
)}
<span className="text-slate-400" style={{ fontSize: '0.65rem' }}>
{new Date(tx.effective_date).toLocaleDateString(getLocale(), { day: '2-digit', month: '2-digit' })}
</span>
</div>
<span className="text-warning" style={{ fontSize: '0.65rem' }}>
+{tx.variance_percent}%
</span>
</div>
</div>
))}
</div>
) : (
/* Layout Desktop - Tabela */
<div className="table-responsive" style={{ maxHeight: '200px', overflowY: 'auto', overflowX: 'auto' }}>
<table className="table table-sm mb-0" style={{ '--bs-table-bg': 'transparent' }}>
<thead style={{ position: 'sticky', top: 0, background: '#0f172a' }}>
<tr>
<th className="text-slate-400 small border-0 text-uppercase" style={{ fontSize: '10px' }}>{t('common.date')}</th>
<th className="text-slate-400 small border-0 text-uppercase" style={{ fontSize: '10px' }}>{t('common.description')}</th>
<th className="text-slate-400 small border-0 text-uppercase" style={{ fontSize: '10px' }}>{t('dashboard.category')}</th>
<th className="text-slate-400 small border-0 text-end text-uppercase" style={{ fontSize: '10px' }}>{t('dashboard.plannedValue')}</th>
<th className="text-slate-400 small border-0 text-end text-uppercase" style={{ fontSize: '10px' }}>{t('dashboard.actualValue')}</th>
<th className="text-slate-400 small border-0 text-end text-uppercase" style={{ fontSize: '10px' }}>{t('dashboard.difference')}</th>
<th className="text-slate-400 small border-0 text-center text-uppercase" style={{ fontSize: '10px' }}>{t('dashboard.delay')}</th>
<th className="text-slate-400 small border-0 text-end text-uppercase" style={{ fontSize: '10px' }}>%</th>
</tr>
</thead>
<tbody>
{selectedMonthData.transactions.map((tx) => (
<tr
key={tx.id}
className="cursor-pointer"
onClick={() => onTransactionClick?.(tx.id)}
style={{ cursor: 'pointer' }}
>
<td className="text-slate-300 small border-0">
{new Date(tx.effective_date).toLocaleDateString(getLocale())}
</td>
<td className="border-0">
<span className="text-white small">
{tx.description.length > 30 ? tx.description.substring(0, 30) + '...' : tx.description}
</span>
</td>
<td className="border-0">
{tx.category ? (
<span
className="badge rounded-pill small"
style={{
background: tx.category.color || '#6b7280',
fontSize: '10px'
}}
>
{tx.category.name}
</span>
) : (
<span className="text-slate-500 small">-</span>
)}
</td>
<td className="text-slate-400 small text-end border-0">
{currency(tx.planned_amount, 'BRL')}
</td>
<td className="text-white small text-end border-0">
{currency(tx.actual_amount, 'BRL')}
</td>
<td className="text-end border-0">
<span className="badge bg-warning small">
<i className="bi bi-arrow-up me-1" style={{ fontSize: '10px' }}></i>
+{currency(tx.variance, 'BRL')}
</span>
</td>
<td className="text-center border-0">
{tx.delay_days !== null && tx.delay_days !== 0 ? (
<span
className="badge small"
style={{
fontSize: '10px',
background: tx.delay_days > 0 ? 'rgba(239, 68, 68, 0.8)' : 'rgba(34, 197, 94, 0.8)'
}}
>
<i className={`bi bi-clock${tx.delay_days > 0 ? '-history' : ''} me-1`} style={{ fontSize: '9px' }}></i>
{tx.delay_days > 0 ? `+${tx.delay_days}d` : `${tx.delay_days}d`}
</span>
) : (
<span className="text-slate-500 small">-</span>
)}
</td>
<td className="text-warning small text-end border-0">
+{tx.variance_percent}%
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
{/* Dica */}
{!selectedMonthData && monthsData.length > 0 && (
<div className="mt-3 p-2 rounded" style={{
background: 'rgba(251, 191, 36, 0.1)',
borderLeft: '3px solid #fbbf24'
}}>
<small className="text-slate-300">
<i className="bi bi-lightbulb text-warning me-2"></i>
<strong className="text-warning">{t('dashboard.tip')}:</strong> {t('dashboard.overpaymentTip')}
</small>
</div>
)}
</div>
</div>
);
};
export default OverpaymentsAnalysis;