- 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
458 lines
19 KiB
JavaScript
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;
|