470 lines
18 KiB
JavaScript
470 lines
18 KiB
JavaScript
import React, { useState, useEffect, useCallback } from 'react';
|
|
import { Link, useNavigate } from 'react-router-dom';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { useAuth } from '../context/AuthContext';
|
|
import { dashboardService, accountService } from '../services/api';
|
|
import useFormatters from '../hooks/useFormatters';
|
|
import CashflowChart from '../components/dashboard/CashflowChart';
|
|
import BalanceProjectionChart from '../components/dashboard/BalanceProjectionChart';
|
|
import OverpaymentsAnalysis from '../components/dashboard/OverpaymentsAnalysis';
|
|
import CalendarWidget from '../components/dashboard/CalendarWidget';
|
|
import UpcomingWidget from '../components/dashboard/UpcomingWidget';
|
|
import OverdueWidget from '../components/dashboard/OverdueWidget';
|
|
|
|
const Dashboard = () => {
|
|
const { user } = useAuth();
|
|
const { t, i18n } = useTranslation();
|
|
const { currency } = useFormatters();
|
|
const navigate = useNavigate();
|
|
|
|
// Estados
|
|
const [loading, setLoading] = useState(true);
|
|
const [cashflowLoading, setCashflowLoading] = useState(true);
|
|
const [variancesLoading, setVariancesLoading] = useState(true);
|
|
|
|
// Dados do dashboard
|
|
const [summary, setSummary] = useState(null);
|
|
const [cashflow, setCashflow] = useState(null);
|
|
const [variances, setVariances] = useState(null);
|
|
const [accounts, setAccounts] = useState([]);
|
|
|
|
// Filtros dos gráficos
|
|
const [cashflowMonths, setCashflowMonths] = useState(12);
|
|
const [variancesMonths, setVariancesMonths] = useState(12);
|
|
|
|
// Carregar resumo geral
|
|
const loadSummary = useCallback(async () => {
|
|
try {
|
|
const [summaryData, accountsData] = await Promise.all([
|
|
dashboardService.getSummary(),
|
|
accountService.getAll(),
|
|
]);
|
|
setSummary(summaryData);
|
|
setAccounts(accountsData.data || []);
|
|
} catch (error) {
|
|
console.error('Error loading summary:', error);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
// Carregar fluxo de caixa
|
|
const loadCashflow = useCallback(async () => {
|
|
setCashflowLoading(true);
|
|
try {
|
|
const data = await dashboardService.getCashflow(cashflowMonths);
|
|
setCashflow(data);
|
|
} catch (error) {
|
|
console.error('Error loading cashflow:', error);
|
|
} finally {
|
|
setCashflowLoading(false);
|
|
}
|
|
}, [cashflowMonths]);
|
|
|
|
// Carregar variações de pagamento
|
|
const loadVariances = useCallback(async () => {
|
|
setVariancesLoading(true);
|
|
try {
|
|
const data = await dashboardService.getPaymentVariances(variancesMonths);
|
|
setVariances(data);
|
|
} catch (error) {
|
|
console.error('Error loading variances:', error);
|
|
} finally {
|
|
setVariancesLoading(false);
|
|
}
|
|
}, [variancesMonths]);
|
|
|
|
useEffect(() => {
|
|
loadSummary();
|
|
}, [loadSummary]);
|
|
|
|
useEffect(() => {
|
|
loadCashflow();
|
|
}, [loadCashflow]);
|
|
|
|
useEffect(() => {
|
|
loadVariances();
|
|
}, [loadVariances]);
|
|
|
|
// Handlers
|
|
const handleMonthsChange = (months) => {
|
|
setCashflowMonths(months);
|
|
};
|
|
|
|
const handleVariancesMonthsChange = (months) => {
|
|
setVariancesMonths(months);
|
|
};
|
|
|
|
const handleTransactionClick = (transactionId) => {
|
|
navigate(`/transactions?highlight=${transactionId}`);
|
|
};
|
|
|
|
// Componente de Stat Card modernizado
|
|
const StatCard = ({ icon, label, value, valueColor = 'text-white', trend = null, trendUp = true, subValue = null, accentColor = '#3b82f6' }) => (
|
|
<div className="card border-0 h-100 stat-card" style={{
|
|
background: 'linear-gradient(135deg, #1e293b 0%, #0f172a 100%)',
|
|
borderLeft: `3px solid ${accentColor}`,
|
|
transition: 'transform 0.2s ease, box-shadow 0.2s ease'
|
|
}}>
|
|
<div className="card-body py-3">
|
|
<div className="d-flex justify-content-between align-items-start">
|
|
<div className="flex-grow-1">
|
|
<p className="text-slate-400 mb-1 small text-uppercase" style={{ fontSize: '11px', letterSpacing: '0.5px' }}>
|
|
{label}
|
|
</p>
|
|
{loading ? (
|
|
<div className="placeholder-glow">
|
|
<span className="placeholder col-8" style={{ height: '28px' }}></span>
|
|
</div>
|
|
) : (
|
|
<div className={`mb-0 fw-bold ${valueColor}`} style={{ fontSize: '1.25rem' }}>
|
|
{value}
|
|
</div>
|
|
)}
|
|
{subValue && !loading && (
|
|
<small className="text-slate-500">{subValue}</small>
|
|
)}
|
|
</div>
|
|
<div className="rounded-2 d-flex align-items-center justify-content-center flex-shrink-0"
|
|
style={{
|
|
width: '42px',
|
|
height: '42px',
|
|
background: `${accentColor}20`
|
|
}}>
|
|
<i className={`bi ${icon}`} style={{ color: accentColor, fontSize: '1.25rem' }}></i>
|
|
</div>
|
|
</div>
|
|
{trend !== null && !loading && (
|
|
<div className="mt-2">
|
|
<span className={`small ${trendUp ? 'text-success' : 'text-danger'}`}>
|
|
<i className={`bi bi-arrow-${trendUp ? 'up' : 'down'}-right me-1`}></i>
|
|
{trend}
|
|
</span>
|
|
<span className="text-slate-500 small ms-1">{t('dashboard.vsLastMonth')}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
// Componente de conta
|
|
const AccountItem = ({ account }) => {
|
|
const balance = parseFloat(account.current_balance);
|
|
const isPositive = balance >= 0;
|
|
|
|
const getAccountIcon = (type) => {
|
|
const icons = {
|
|
'checking': 'bi-bank',
|
|
'savings': 'bi-piggy-bank',
|
|
'credit_card': 'bi-credit-card',
|
|
'investment': 'bi-graph-up-arrow',
|
|
'cash': 'bi-wallet2',
|
|
};
|
|
return icons[type] || 'bi-wallet2';
|
|
};
|
|
|
|
return (
|
|
<div className="d-flex align-items-center py-2 px-3 rounded-2 mb-2"
|
|
style={{ background: 'rgba(255,255,255,0.03)' }}>
|
|
<div className="rounded-circle d-flex align-items-center justify-content-center me-3"
|
|
style={{
|
|
width: '40px',
|
|
height: '40px',
|
|
background: 'rgba(59, 130, 246, 0.15)'
|
|
}}>
|
|
<i className={`bi ${getAccountIcon(account.type)} text-primary`}></i>
|
|
</div>
|
|
<div className="flex-grow-1">
|
|
<div className="text-white small fw-medium">{account.name}</div>
|
|
<div className="text-slate-500" style={{ fontSize: '11px' }}>
|
|
{account.bank_name || t(`accounts.types.${account.type}`)}
|
|
</div>
|
|
</div>
|
|
<div className="text-end">
|
|
<div className={`fw-bold ${isPositive ? 'text-success' : 'text-danger'}`}>
|
|
{currency(balance, account.currency || 'BRL')}
|
|
</div>
|
|
{!account.include_in_total && (
|
|
<small className="text-slate-500" style={{ fontSize: '10px' }}>
|
|
<i className="bi bi-eye-slash me-1"></i>
|
|
{t('accounts.notInTotal')}
|
|
</small>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div className="dashboard-container">
|
|
{/* Header */}
|
|
<div className="d-flex justify-content-between align-items-center mb-4">
|
|
<div>
|
|
<h4 className="text-white mb-1 fw-bold">
|
|
{t('dashboard.welcome')}, {user?.name?.split(' ')[0]}!
|
|
<span className="ms-2" style={{ fontSize: '1.5rem' }}>👋</span>
|
|
</h4>
|
|
<p className="text-slate-400 mb-0 small">
|
|
{new Date().toLocaleDateString(i18n.language === 'pt-BR' ? 'pt-BR' : i18n.language === 'es' ? 'es-ES' : 'en-US', {
|
|
weekday: 'long',
|
|
year: 'numeric',
|
|
month: 'long',
|
|
day: 'numeric'
|
|
})}
|
|
</p>
|
|
</div>
|
|
<div className="d-flex gap-2">
|
|
<Link to="/transactions" className="btn btn-outline-light btn-sm">
|
|
<i className="bi bi-list-ul me-1"></i>
|
|
{t('nav.transactions')}
|
|
</Link>
|
|
<Link to="/import" className="btn btn-primary btn-sm">
|
|
<i className="bi bi-upload me-1"></i>
|
|
{t('nav.import')}
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Stats Cards Row - Multi-currency */}
|
|
<div className="row g-3 mb-4">
|
|
<div className="col-6 col-lg-3">
|
|
<StatCard
|
|
icon="bi-wallet2"
|
|
label={t('dashboard.totalBalance')}
|
|
value={
|
|
summary?.balances_by_currency && Object.keys(summary.balances_by_currency).length > 0
|
|
? Object.entries(summary.balances_by_currency).map(([curr, val]) => (
|
|
<div key={curr}>{currency(val, curr)}</div>
|
|
))
|
|
: currency(0, 'BRL')
|
|
}
|
|
valueColor={
|
|
summary?.balances_by_currency
|
|
? Object.values(summary.balances_by_currency).reduce((a, b) => a + b, 0) >= 0
|
|
? 'text-success'
|
|
: 'text-danger'
|
|
: 'text-success'
|
|
}
|
|
accentColor={
|
|
summary?.balances_by_currency
|
|
? Object.values(summary.balances_by_currency).reduce((a, b) => a + b, 0) >= 0
|
|
? '#22c55e'
|
|
: '#ef4444'
|
|
: '#22c55e'
|
|
}
|
|
/>
|
|
</div>
|
|
<div className="col-6 col-lg-3">
|
|
<StatCard
|
|
icon="bi-arrow-up-circle"
|
|
label={t('dashboard.monthIncome')}
|
|
value={
|
|
summary?.current_month_by_currency?.income && Object.keys(summary.current_month_by_currency.income).length > 0
|
|
? Object.entries(summary.current_month_by_currency.income).map(([curr, val]) => (
|
|
<div key={curr}>{currency(val, curr)}</div>
|
|
))
|
|
: currency(0, summary?.primary_currency || 'BRL')
|
|
}
|
|
valueColor="text-success"
|
|
accentColor="#22c55e"
|
|
/>
|
|
</div>
|
|
<div className="col-6 col-lg-3">
|
|
<StatCard
|
|
icon="bi-arrow-down-circle"
|
|
label={t('dashboard.monthExpenses')}
|
|
value={
|
|
summary?.current_month_by_currency?.expense && Object.keys(summary.current_month_by_currency.expense).length > 0
|
|
? Object.entries(summary.current_month_by_currency.expense).map(([curr, val]) => (
|
|
<div key={curr}>{currency(val, curr)}</div>
|
|
))
|
|
: currency(0, summary?.primary_currency || 'BRL')
|
|
}
|
|
valueColor="text-danger"
|
|
accentColor="#ef4444"
|
|
/>
|
|
</div>
|
|
<div className="col-6 col-lg-3">
|
|
<StatCard
|
|
icon="bi-hourglass-split"
|
|
label={t('dashboard.pending')}
|
|
value={summary?.pending?.count || 0}
|
|
subValue={summary?.overdue?.count > 0 ?
|
|
`⚠️ ${summary.overdue.count} ${t('dashboard.overdue')}` : null
|
|
}
|
|
accentColor="#f59e0b"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Calendário e Transações Futuras */}
|
|
<div className="row g-4 mb-4">
|
|
<div className="col-lg-8">
|
|
<CalendarWidget />
|
|
</div>
|
|
<div className="col-lg-4">
|
|
<UpcomingWidget />
|
|
</div>
|
|
</div>
|
|
|
|
{/* Transacciones Vencidas - Fila independiente */}
|
|
<div className="row g-4 mb-4">
|
|
<div className="col-12">
|
|
<OverdueWidget />
|
|
</div>
|
|
</div>
|
|
|
|
{/* Projeção de Saldo - Full Width */}
|
|
<div className="row g-4 mb-4">
|
|
<div className="col-12">
|
|
<BalanceProjectionChart />
|
|
</div>
|
|
</div>
|
|
|
|
{/* Main Content Grid */}
|
|
<div className="row g-4">
|
|
{/* Coluna Principal - Fluxo de Caixa */}
|
|
<div className="col-lg-8">
|
|
<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', borderBottom: '1px solid rgba(59, 130, 246, 0.2)' }}>
|
|
<h6 className="text-white mb-0 d-flex align-items-center">
|
|
<i className="bi bi-bar-chart-line me-2 text-primary"></i>
|
|
{t('dashboard.cashflow')}
|
|
</h6>
|
|
<div className="btn-group btn-group-sm" role="group">
|
|
{[6, 12, 24].map(months => (
|
|
<button
|
|
key={months}
|
|
type="button"
|
|
className={`btn ${cashflowMonths === months ? 'btn-primary' : 'btn-outline-secondary'}`}
|
|
onClick={() => handleMonthsChange(months)}
|
|
style={{ fontSize: '12px' }}
|
|
>
|
|
{months}m
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div className="card-body">
|
|
<CashflowChart
|
|
data={cashflow?.data || []}
|
|
loading={cashflowLoading}
|
|
primaryCurrency={summary?.primary_currency || 'EUR'}
|
|
/>
|
|
|
|
{/* Resumo do período */}
|
|
{cashflow && !cashflowLoading && (
|
|
<div className="row mt-4 pt-3" style={{ borderTop: '1px solid rgba(255,255,255,0.1)' }}>
|
|
<div className="col-6 col-md-3 text-center">
|
|
<small className="text-slate-500 d-block text-uppercase" style={{ fontSize: '10px' }}>
|
|
{t('dashboard.totalIncome')}
|
|
</small>
|
|
<span className="text-success fw-bold">
|
|
{currency(cashflow.totals?.income || 0, summary?.primary_currency || 'EUR')}
|
|
</span>
|
|
</div>
|
|
<div className="col-6 col-md-3 text-center">
|
|
<small className="text-slate-500 d-block text-uppercase" style={{ fontSize: '10px' }}>
|
|
{t('dashboard.totalExpenses')}
|
|
</small>
|
|
<span className="text-danger fw-bold">
|
|
{currency(cashflow.totals?.expense || 0, summary?.primary_currency || 'EUR')}
|
|
</span>
|
|
</div>
|
|
<div className="col-6 col-md-3 text-center mt-2 mt-md-0">
|
|
<small className="text-slate-500 d-block text-uppercase" style={{ fontSize: '10px' }}>
|
|
{t('dashboard.avgIncome')}
|
|
</small>
|
|
<span className="text-success">
|
|
{currency(cashflow.totals?.average_income || 0, summary?.primary_currency || 'EUR')}
|
|
</span>
|
|
</div>
|
|
<div className="col-6 col-md-3 text-center mt-2 mt-md-0">
|
|
<small className="text-slate-500 d-block text-uppercase" style={{ fontSize: '10px' }}>
|
|
{t('dashboard.avgExpense')}
|
|
</small>
|
|
<span className="text-danger">
|
|
{currency(cashflow.totals?.average_expense || 0, summary?.primary_currency || 'EUR')}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Coluna Lateral - Contas */}
|
|
<div className="col-lg-4">
|
|
<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', borderBottom: '1px solid rgba(59, 130, 246, 0.2)' }}>
|
|
<h6 className="text-white mb-0 d-flex align-items-center">
|
|
<i className="bi bi-bank me-2 text-primary"></i>
|
|
{t('dashboard.accountBalances')}
|
|
</h6>
|
|
<Link to="/accounts" className="btn btn-link btn-sm text-primary p-0" style={{ fontSize: '12px' }}>
|
|
{t('common.viewAll')} <i className="bi bi-arrow-right"></i>
|
|
</Link>
|
|
</div>
|
|
<div className="card-body py-2">
|
|
{loading ? (
|
|
<div className="placeholder-glow">
|
|
{[1, 2, 3].map(i => (
|
|
<div key={i} className="placeholder col-12 mb-2" style={{ height: '50px' }}></div>
|
|
))}
|
|
</div>
|
|
) : accounts.length === 0 ? (
|
|
<div className="text-center text-slate-400 py-4">
|
|
<i className="bi bi-bank fs-1 mb-2 d-block"></i>
|
|
<p className="small mb-2">{t('dashboard.noAccounts')}</p>
|
|
<Link to="/accounts" className="btn btn-primary btn-sm">
|
|
{t('dashboard.createAccount')}
|
|
</Link>
|
|
</div>
|
|
) : (
|
|
<div style={{ maxHeight: '320px', overflowY: 'auto' }}>
|
|
{accounts
|
|
.filter(a => a.is_active)
|
|
.sort((a, b) => Math.abs(b.current_balance) - Math.abs(a.current_balance))
|
|
.map(account => (
|
|
<AccountItem key={account.id} account={account} />
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Análise de Sobrepagamentos - Full Width */}
|
|
<div className="row mt-4">
|
|
<div className="col-12">
|
|
<OverpaymentsAnalysis
|
|
data={variances}
|
|
loading={variancesLoading}
|
|
onTransactionClick={handleTransactionClick}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* CSS inline para efeitos hover */}
|
|
<style>{`
|
|
.stat-card:hover {
|
|
transform: translateY(-2px);
|
|
box-shadow: 0 10px 40px rgba(0,0,0,0.3);
|
|
}
|
|
.dashboard-container {
|
|
animation: fadeIn 0.3s ease-in-out;
|
|
}
|
|
@keyframes fadeIn {
|
|
from { opacity: 0; transform: translateY(10px); }
|
|
to { opacity: 1; transform: translateY(0); }
|
|
}
|
|
`}</style>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default Dashboard;
|