v1.43.8 - Drill-down de subcategorias em relatórios
ADDED: - Relatório Por Categoria agora suporta drill-down clicável - Clicar em categoria pai exibe gráfico com suas subcategorias - Botão Voltar para retornar às categorias principais - Hover visual em linhas de tabela e cards clicáveis - Backend aceita parent_id como parâmetro em /reports/by-category CHANGED: - Backend: query padrão mostra apenas categorias pai (parent_id IS NULL) - Backend: nova query para subcategorias quando parent_id é fornecido - Frontend: estado selectedCategory para rastrear navegação - Frontend: onClick handlers em gráfico, tabela e cards TRANSLATION: - pt-BR: Distribuição de Subcategorias - es: Distribución de Subcategorías - en: Subcategory Distribution
This commit is contained in:
parent
b0724d7b2c
commit
7b9345dc80
42
CHANGELOG.md
42
CHANGELOG.md
@ -5,6 +5,48 @@ O formato segue [Keep a Changelog](https://keepachangelog.com/pt-BR/).
|
||||
Este projeto adota [Versionamento Semântico](https://semver.org/pt-BR/).
|
||||
|
||||
|
||||
## [1.43.8] - 2025-12-16
|
||||
|
||||
### Added
|
||||
- **Relatório de Categorias - Drill-down para Subcategorias**
|
||||
- Clicar em uma categoria pai agora mostra suas subcategorias em um novo gráfico
|
||||
- Mesmo layout visual (gráfico de pizza + lista/cards)
|
||||
- Botão "Voltar" para retornar às categorias principais
|
||||
- Hover visual em tabelas e cards clicáveis
|
||||
- Backend suporta filtro `parent_id` para mostrar apenas subcategorias de uma categoria específica
|
||||
|
||||
- **Tradução i18n**
|
||||
- pt-BR: "Distribuição de Subcategorias"
|
||||
- es: "Distribución de Subcategorías"
|
||||
- en: "Subcategory Distribution"
|
||||
|
||||
### Changed
|
||||
- **Backend - API /reports/by-category**
|
||||
- Novo parâmetro `parent_id` para filtrar subcategorias
|
||||
- Query padrão agora mostra apenas categorias pai (`parent_id IS NULL`)
|
||||
- Query com `parent_id` filtra subcategorias da categoria especificada
|
||||
|
||||
## [1.43.7] - 2025-12-16
|
||||
|
||||
### Changed
|
||||
- **Backend - Relatório de Centros de Custo incluindo transações sem centro**
|
||||
- Transações sem centro de custo (`cost_center_id = NULL`) agora aparecem como categoria "General"
|
||||
- Cor padrão: #6b7280 para transações não classificadas
|
||||
- Query modificada: COALESCE para tratar NULLs como id=0, nome='General'
|
||||
- Relatório completo: todas as transações aparecem no relatório byCostCenter
|
||||
|
||||
### Added
|
||||
- **Frontend - Tradução i18n 'general'**
|
||||
- pt-BR: "Geral"
|
||||
- es: "General"
|
||||
- en: "General"
|
||||
|
||||
- **Frontend - Reports Mobile Optimization (Continuação)**
|
||||
- **Tab Category**: col-12 mobile, gráfico 300px, lista em cards compactos
|
||||
- **Tab Evolution**: Cards 2x2, gráficos 250px/200px, botões xs, fontes 0.7-0.85rem
|
||||
- **Tab TopExpenses - Lista Detalhada**: Cards compactos mobile vs tabela desktop
|
||||
|
||||
|
||||
## [1.43.6] - 2025-12-16
|
||||
|
||||
### Improved
|
||||
|
||||
@ -150,9 +150,33 @@ public function byCategory(Request $request)
|
||||
$endDate = $request->get('end_date', now()->format('Y-m-d'));
|
||||
$type = $request->get('type', 'debit');
|
||||
$groupByParent = $request->get('group_by_parent', false);
|
||||
$parentId = $request->get('parent_id');
|
||||
|
||||
// Se filtrar por parent_id, mostra subcategorias dessa categoria pai
|
||||
if ($parentId) {
|
||||
$data = DB::select("
|
||||
SELECT
|
||||
c.id as category_id,
|
||||
c.name as category_name,
|
||||
c.icon,
|
||||
c.color,
|
||||
COALESCE(a.currency, 'EUR') as currency,
|
||||
SUM(ABS(t.amount)) as total
|
||||
FROM transactions t
|
||||
LEFT JOIN categories c ON t.category_id = c.id
|
||||
LEFT JOIN accounts a ON t.account_id = a.id
|
||||
WHERE t.user_id = ?
|
||||
AND t.effective_date BETWEEN ? AND ?
|
||||
AND t.type = ?
|
||||
AND c.parent_id = ?
|
||||
AND t.deleted_at IS NULL
|
||||
AND {$this->excludeTransfers()}
|
||||
GROUP BY c.id, c.name, c.icon, c.color, COALESCE(a.currency, 'EUR')
|
||||
ORDER BY total DESC
|
||||
", [$this->userId, $startDate, $endDate, $type, $parentId]);
|
||||
}
|
||||
// Si se quiere agrupar por categoría padre, obtenemos el nombre del padre
|
||||
if ($groupByParent) {
|
||||
else if ($groupByParent) {
|
||||
$data = DB::select("
|
||||
SELECT
|
||||
COALESCE(c.parent_id, c.id) as category_id,
|
||||
@ -174,7 +198,7 @@ public function byCategory(Request $request)
|
||||
ORDER BY total DESC
|
||||
", [$this->userId, $startDate, $endDate, $type]);
|
||||
} else {
|
||||
// Sin agrupar: cada subcategoría se muestra individualmente
|
||||
// Sin agrupar: solo categorías padre (sin parent_id)
|
||||
$data = DB::select("
|
||||
SELECT
|
||||
c.id as category_id,
|
||||
@ -189,6 +213,7 @@ public function byCategory(Request $request)
|
||||
WHERE t.user_id = ?
|
||||
AND t.effective_date BETWEEN ? AND ?
|
||||
AND t.type = ?
|
||||
AND c.parent_id IS NULL
|
||||
AND t.deleted_at IS NULL
|
||||
AND {$this->excludeTransfers()}
|
||||
GROUP BY c.id, c.name, c.icon, c.color, COALESCE(a.currency, 'EUR')
|
||||
@ -244,9 +269,9 @@ public function byCostCenter(Request $request)
|
||||
|
||||
$data = DB::select("
|
||||
SELECT
|
||||
cc.id as cost_center_id,
|
||||
cc.name as cost_center_name,
|
||||
cc.color,
|
||||
COALESCE(cc.id, 0) as cost_center_id,
|
||||
COALESCE(cc.name, 'Sem classificar') as cost_center_name,
|
||||
COALESCE(cc.color, '#6b7280') as color,
|
||||
COALESCE(a.currency, 'EUR') as currency,
|
||||
SUM(CASE WHEN t.type = 'credit' THEN t.amount ELSE 0 END) as income,
|
||||
SUM(CASE WHEN t.type = 'debit' THEN ABS(t.amount) ELSE 0 END) as expense
|
||||
@ -257,8 +282,7 @@ public function byCostCenter(Request $request)
|
||||
AND t.effective_date BETWEEN ? AND ?
|
||||
AND t.deleted_at IS NULL
|
||||
AND {$this->excludeTransfers()}
|
||||
AND t.cost_center_id IS NOT NULL
|
||||
GROUP BY cc.id, cc.name, cc.color, COALESCE(a.currency, 'EUR')
|
||||
GROUP BY COALESCE(cc.id, 0), COALESCE(cc.name, 'Sem classificar'), COALESCE(cc.color, '#6b7280'), COALESCE(a.currency, 'EUR')
|
||||
ORDER BY expense DESC
|
||||
", [$this->userId, $startDate, $endDate]);
|
||||
|
||||
@ -268,9 +292,9 @@ public function byCostCenter(Request $request)
|
||||
$ccId = $row->cost_center_id;
|
||||
if (!isset($byCostCenter[$ccId])) {
|
||||
$byCostCenter[$ccId] = [
|
||||
'id' => $ccId,
|
||||
'id' => $ccId == 0 ? null : $ccId,
|
||||
'name' => $row->cost_center_name,
|
||||
'color' => $row->color ?? '#6b7280',
|
||||
'color' => $row->color,
|
||||
'income_converted' => 0,
|
||||
'expense_converted' => 0,
|
||||
'by_currency' => [],
|
||||
|
||||
@ -53,6 +53,7 @@
|
||||
"items": "items",
|
||||
"difference": "Difference",
|
||||
"months": "months",
|
||||
"unclassified": "Unclassified",
|
||||
"viewAll": "View all",
|
||||
"today": "Today",
|
||||
"selectTransactions": "Select transactions",
|
||||
@ -1856,6 +1857,7 @@
|
||||
"canImprove": "Can improve",
|
||||
"total": "Total",
|
||||
"expenseDistribution": "Expense Distribution",
|
||||
"subcategoryDistribution": "Subcategory Distribution",
|
||||
"categoryDetail": "Category Detail",
|
||||
"category": "Category",
|
||||
"amount": "Amount",
|
||||
|
||||
@ -54,6 +54,7 @@
|
||||
"items": "elementos",
|
||||
"difference": "Diferencia",
|
||||
"months": "meses",
|
||||
"unclassified": "Sin clasificar",
|
||||
"viewAll": "Ver todos",
|
||||
"today": "Hoy",
|
||||
"selectTransactions": "Seleccionar transacciones",
|
||||
@ -1843,6 +1844,8 @@
|
||||
"vsAverage": "vs promedio histórico",
|
||||
"yearComparison": "Comparativa Anual",
|
||||
"expenseDistribution": "Distribución de Gastos",
|
||||
"subcategoryDistribution": "Distribución de Subcategorías",
|
||||
"subcategoryDistribution": "Distribución de Subcategorías",
|
||||
"categoryDetail": "Detalle por Categoría",
|
||||
"category": "Categoría",
|
||||
"amount": "Monto",
|
||||
|
||||
@ -54,6 +54,7 @@
|
||||
"items": "itens",
|
||||
"difference": "Diferença",
|
||||
"months": "meses",
|
||||
"unclassified": "Sem classificar",
|
||||
"viewAll": "Ver todos",
|
||||
"date": "Data",
|
||||
"today": "Hoje",
|
||||
@ -1862,6 +1863,7 @@
|
||||
"canImprove": "Pode melhorar",
|
||||
"total": "Total",
|
||||
"expenseDistribution": "Distribuição de Despesas",
|
||||
"subcategoryDistribution": "Distribuição de Subcategorias",
|
||||
"categoryDetail": "Detalhes por Categoria",
|
||||
"category": "Categoria",
|
||||
"amount": "Valor",
|
||||
|
||||
@ -52,6 +52,7 @@ const Reports = () => {
|
||||
// Data states
|
||||
const [summary, setSummary] = useState(null);
|
||||
const [categoryData, setCategoryData] = useState(null);
|
||||
const [selectedCategory, setSelectedCategory] = useState(null);
|
||||
const [evolutionData, setEvolutionData] = useState(null);
|
||||
const [dayOfWeekData, setDayOfWeekData] = useState(null);
|
||||
const [topExpenses, setTopExpenses] = useState(null);
|
||||
@ -73,7 +74,11 @@ const Reports = () => {
|
||||
setSummary(summaryRes);
|
||||
break;
|
||||
case 'category':
|
||||
const catRes = await reportService.getByCategory({ type: 'debit' });
|
||||
const params = { type: 'debit' };
|
||||
if (selectedCategory) {
|
||||
params.parent_id = selectedCategory.category_id;
|
||||
}
|
||||
const catRes = await reportService.getByCategory(params);
|
||||
setCategoryData(catRes);
|
||||
break;
|
||||
case 'evolution':
|
||||
@ -440,17 +445,40 @@ const Reports = () => {
|
||||
`hsl(${(i * 360) / categoryData.data.length}, 70%, 50%)`
|
||||
);
|
||||
|
||||
const handleCategoryClick = (cat) => {
|
||||
setSelectedCategory(cat);
|
||||
loadData();
|
||||
};
|
||||
|
||||
const handleBackToCategories = () => {
|
||||
setSelectedCategory(null);
|
||||
loadData();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="row g-4">
|
||||
<div className="col-lg-6">
|
||||
{/* Botão Voltar */}
|
||||
{selectedCategory && (
|
||||
<div className="col-12">
|
||||
<button
|
||||
className="btn btn-sm btn-outline-secondary"
|
||||
onClick={handleBackToCategories}
|
||||
>
|
||||
<i className="bi bi-arrow-left me-2"></i>
|
||||
{t('common.back')} - {selectedCategory.category_name}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{/* Gráfico de Distribuição */}
|
||||
<div className={isMobile ? 'col-12' : 'col-lg-6'}>
|
||||
<div className="card border-0" style={{ background: '#0f172a' }}>
|
||||
<div className="card-header border-0 bg-transparent">
|
||||
<h6 className="text-white mb-0">
|
||||
<div className={`card-header border-0 bg-transparent ${isMobile ? 'p-2' : ''}`}>
|
||||
<h6 className="text-white mb-0" style={{ fontSize: isMobile ? '0.85rem' : '1rem' }}>
|
||||
<i className="bi bi-pie-chart me-2"></i>
|
||||
{t('reports.expenseDistribution')}
|
||||
{selectedCategory ? t('reports.subcategoryDistribution') : t('reports.expenseDistribution')}
|
||||
</h6>
|
||||
</div>
|
||||
<div className="card-body" style={{ height: '400px' }}>
|
||||
<div className={isMobile ? 'p-2' : 'card-body'} style={{ height: isMobile ? '300px' : '400px' }}>
|
||||
<Doughnut
|
||||
data={{
|
||||
labels: categoryData.data.map(c => c.category_name),
|
||||
@ -460,46 +488,100 @@ const Reports = () => {
|
||||
borderWidth: 0,
|
||||
}],
|
||||
}}
|
||||
options={doughnutOptions}
|
||||
options={{
|
||||
...doughnutOptions,
|
||||
onClick: !selectedCategory ? (event, elements) => {
|
||||
if (elements.length > 0) {
|
||||
const index = elements[0].index;
|
||||
const cat = categoryData.data[index];
|
||||
handleCategoryClick(cat);
|
||||
}
|
||||
} : undefined
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-lg-6">
|
||||
{/* Tabela/Lista de Detalhes */}
|
||||
<div className={isMobile ? 'col-12' : 'col-lg-6'}>
|
||||
<div className="card border-0" style={{ background: '#0f172a' }}>
|
||||
<div className="card-header border-0 bg-transparent d-flex justify-content-between">
|
||||
<h6 className="text-white mb-0">
|
||||
<div className={`card-header border-0 bg-transparent d-flex justify-content-between ${isMobile ? 'p-2' : ''}`}>
|
||||
<h6 className="text-white mb-0" style={{ fontSize: isMobile ? '0.85rem' : '1rem' }}>
|
||||
<i className="bi bi-list-ol me-2"></i>
|
||||
{t('reports.categoryDetail')}
|
||||
</h6>
|
||||
<span className="text-success fw-bold">{currency(categoryData.total, categoryData.currency)}</span>
|
||||
<span className="text-success fw-bold" style={{ fontSize: isMobile ? '0.85rem' : '1rem' }}>
|
||||
{currency(categoryData.total, categoryData.currency)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="card-body p-0" style={{ maxHeight: '400px', overflowY: 'auto' }}>
|
||||
<table className="table table-dark table-hover mb-0">
|
||||
<thead className="sticky-top" style={{ background: '#1e293b' }}>
|
||||
<tr>
|
||||
<th>{t('reports.category')}</th>
|
||||
<th className="text-end">{t('common.total')}</th>
|
||||
<th className="text-end">%</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{categoryData.data.map((cat, i) => (
|
||||
<tr key={cat.category_id}>
|
||||
<td>
|
||||
<i className={`bi ${cat.icon} me-2`} style={{ color: colors[i] }}></i>
|
||||
{cat.category_name}
|
||||
</td>
|
||||
<td className="text-end">{currency(cat.total, categoryData.currency)}</td>
|
||||
<td className="text-end">
|
||||
<span className="badge bg-secondary">{cat.percentage}%</span>
|
||||
</td>
|
||||
|
||||
{/* Desktop: Tabela */}
|
||||
{!isMobile && (
|
||||
<div className="card-body p-0" style={{ maxHeight: '400px', overflowY: 'auto' }}>
|
||||
<table className="table table-dark table-hover mb-0">
|
||||
<thead className="sticky-top" style={{ background: '#1e293b' }}>
|
||||
<tr>
|
||||
<th>{t('reports.category')}</th>
|
||||
<th className="text-end">{t('common.total')}</th>
|
||||
<th className="text-end">%</th>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</thead>
|
||||
<tbody>
|
||||
{categoryData.data.map((cat, i) => (
|
||||
<tr
|
||||
key={cat.category_id}
|
||||
onClick={() => !selectedCategory && handleCategoryClick(cat)}
|
||||
style={{ cursor: !selectedCategory ? 'pointer' : 'default' }}
|
||||
className={!selectedCategory ? 'table-hover-row' : ''}
|
||||
>
|
||||
<td>
|
||||
<i className={`bi ${cat.icon} me-2`} style={{ color: colors[i] }}></i>
|
||||
{cat.category_name}
|
||||
</td>
|
||||
<td className="text-end">{currency(cat.total, categoryData.currency)}</td>
|
||||
<td className="text-end">
|
||||
<span className="badge bg-secondary">{cat.percentage}%</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mobile: Cards */}
|
||||
{isMobile && (
|
||||
<div className="p-2" style={{ maxHeight: '400px', overflowY: 'auto' }}>
|
||||
{categoryData.data.map((cat, i) => (
|
||||
<div
|
||||
key={cat.category_id}
|
||||
className="card mb-2 border-0"
|
||||
style={{ background: '#1e293b', cursor: !selectedCategory ? 'pointer' : 'default' }}
|
||||
onClick={() => !selectedCategory && handleCategoryClick(cat)}
|
||||
>
|
||||
<div className="card-body p-2">
|
||||
<div className="d-flex justify-content-between align-items-start mb-1">
|
||||
<div className="d-flex align-items-center gap-2">
|
||||
<i className={`bi ${cat.icon}`} style={{ color: colors[i], fontSize: '1rem' }}></i>
|
||||
<span className="text-white" style={{ fontSize: '0.85rem' }}>
|
||||
{cat.category_name}
|
||||
</span>
|
||||
</div>
|
||||
<span className="badge bg-secondary" style={{ fontSize: '0.7rem' }}>
|
||||
{cat.percentage}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-end">
|
||||
<span className="text-success fw-bold" style={{ fontSize: '0.9rem' }}>
|
||||
{currency(cat.total, categoryData.currency)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -512,13 +594,15 @@ const Reports = () => {
|
||||
|
||||
return (
|
||||
<div className="row g-4">
|
||||
{/* Period Selector */}
|
||||
<div className="col-12">
|
||||
<div className="d-flex gap-2 mb-3">
|
||||
<div className={`d-flex gap-2 ${isMobile ? 'mb-2' : 'mb-3'}`}>
|
||||
{[6, 12, 24].map(m => (
|
||||
<button
|
||||
key={m}
|
||||
className={`btn btn-sm ${months === m ? 'btn-primary' : 'btn-outline-secondary'}`}
|
||||
className={`btn ${isMobile ? 'btn-xs' : 'btn-sm'} ${months === m ? 'btn-primary' : 'btn-outline-secondary'}`}
|
||||
onClick={() => setMonths(m)}
|
||||
style={isMobile ? { fontSize: '0.75rem', padding: '0.25rem 0.5rem' } : {}}
|
||||
>
|
||||
{m} {t('common.months')}
|
||||
</button>
|
||||
@ -527,37 +611,51 @@ const Reports = () => {
|
||||
</div>
|
||||
|
||||
{/* Averages Cards */}
|
||||
<div className="col-md-3">
|
||||
<div className={isMobile ? 'col-6' : 'col-md-3'}>
|
||||
<div className="card border-0 text-center" style={{ background: '#1e293b' }}>
|
||||
<div className="card-body">
|
||||
<small className="text-slate-400">{t('reports.avgIncome')}</small>
|
||||
<h5 className="text-success mb-0">{currency(evolutionData.averages.income, evolutionData.currency)}</h5>
|
||||
<div className={isMobile ? 'card-body p-2' : 'card-body'}>
|
||||
<small className="text-slate-400" style={{ fontSize: isMobile ? '0.7rem' : '0.875rem' }}>
|
||||
{t('reports.avgIncome')}
|
||||
</small>
|
||||
<h5 className="text-success mb-0" style={{ fontSize: isMobile ? '0.85rem' : '1.25rem' }}>
|
||||
{currency(evolutionData.averages.income, evolutionData.currency)}
|
||||
</h5>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-md-3">
|
||||
<div className={isMobile ? 'col-6' : 'col-md-3'}>
|
||||
<div className="card border-0 text-center" style={{ background: '#1e293b' }}>
|
||||
<div className="card-body">
|
||||
<small className="text-slate-400">{t('reports.avgExpense')}</small>
|
||||
<h5 className="text-danger mb-0">{currency(evolutionData.averages.expense, evolutionData.currency)}</h5>
|
||||
<div className={isMobile ? 'card-body p-2' : 'card-body'}>
|
||||
<small className="text-slate-400" style={{ fontSize: isMobile ? '0.7rem' : '0.875rem' }}>
|
||||
{t('reports.avgExpense')}
|
||||
</small>
|
||||
<h5 className="text-danger mb-0" style={{ fontSize: isMobile ? '0.85rem' : '1.25rem' }}>
|
||||
{currency(evolutionData.averages.expense, evolutionData.currency)}
|
||||
</h5>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-md-3">
|
||||
<div className={isMobile ? 'col-6' : 'col-md-3'}>
|
||||
<div className="card border-0 text-center" style={{ background: '#1e293b' }}>
|
||||
<div className="card-body">
|
||||
<small className="text-slate-400">{t('reports.balance')}</small>
|
||||
<h5 className={`mb-0 ${evolutionData.averages.balance >= 0 ? 'text-success' : 'text-danger'}`}>
|
||||
<div className={isMobile ? 'card-body p-2' : 'card-body'}>
|
||||
<small className="text-slate-400" style={{ fontSize: isMobile ? '0.7rem' : '0.875rem' }}>
|
||||
{t('reports.balance')}
|
||||
</small>
|
||||
<h5 className={`mb-0 ${evolutionData.averages.balance >= 0 ? 'text-success' : 'text-danger'}`} style={{ fontSize: isMobile ? '0.85rem' : '1.25rem' }}>
|
||||
{currency(evolutionData.averages.balance, evolutionData.currency)}
|
||||
</h5>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-md-3">
|
||||
<div className={isMobile ? 'col-6' : 'col-md-3'}>
|
||||
<div className="card border-0 text-center" style={{ background: '#1e293b' }}>
|
||||
<div className="card-body">
|
||||
<small className="text-slate-400">{t('reports.savingsRate')}</small>
|
||||
<h5 className="text-primary mb-0">{evolutionData.averages.savings_rate}%</h5>
|
||||
<div className={isMobile ? 'card-body p-2' : 'card-body'}>
|
||||
<small className="text-slate-400" style={{ fontSize: isMobile ? '0.7rem' : '0.875rem' }}>
|
||||
{t('reports.savingsRate')}
|
||||
</small>
|
||||
<h5 className="text-primary mb-0" style={{ fontSize: isMobile ? '0.85rem' : '1.25rem' }}>
|
||||
{evolutionData.averages.savings_rate}%
|
||||
</h5>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -565,13 +663,13 @@ const Reports = () => {
|
||||
{/* Evolution Chart */}
|
||||
<div className="col-12">
|
||||
<div className="card border-0" style={{ background: '#0f172a' }}>
|
||||
<div className="card-header border-0 bg-transparent">
|
||||
<h6 className="text-white mb-0">
|
||||
<div className={`card-header border-0 bg-transparent ${isMobile ? 'p-2' : ''}`}>
|
||||
<h6 className="text-white mb-0" style={{ fontSize: isMobile ? '0.85rem' : '1rem' }}>
|
||||
<i className="bi bi-graph-up me-2"></i>
|
||||
{t('reports.monthlyEvolution')}
|
||||
</h6>
|
||||
</div>
|
||||
<div className="card-body" style={{ height: '350px' }}>
|
||||
<div className={isMobile ? 'p-2' : 'card-body'} style={{ height: isMobile ? '250px' : '350px' }}>
|
||||
<Line
|
||||
data={{
|
||||
labels: evolutionData.data.map(d => d.month_label),
|
||||
@ -611,13 +709,13 @@ const Reports = () => {
|
||||
{/* Savings Rate Chart */}
|
||||
<div className="col-12">
|
||||
<div className="card border-0" style={{ background: '#0f172a' }}>
|
||||
<div className="card-header border-0 bg-transparent">
|
||||
<h6 className="text-white mb-0">
|
||||
<div className={`card-header border-0 bg-transparent ${isMobile ? 'p-2' : ''}`}>
|
||||
<h6 className="text-white mb-0" style={{ fontSize: isMobile ? '0.85rem' : '1rem' }}>
|
||||
<i className="bi bi-percent me-2"></i>
|
||||
{t('reports.savingsRate')} por mes
|
||||
</h6>
|
||||
</div>
|
||||
<div className="card-body" style={{ height: '250px' }}>
|
||||
<div className={isMobile ? 'p-2' : 'card-body'} style={{ height: isMobile ? '200px' : '250px' }}>
|
||||
<Bar
|
||||
data={{
|
||||
labels: evolutionData.data.map(d => d.month_label),
|
||||
@ -1073,71 +1171,130 @@ const Reports = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabela Detalhada */}
|
||||
{/* Tabela/Lista Detalhada */}
|
||||
<div className="col-12">
|
||||
<div className="card border-0" style={{ background: '#0f172a' }}>
|
||||
<div className="card-header border-0 bg-transparent">
|
||||
<h6 className="text-white mb-0">
|
||||
<div className={`card-header border-0 bg-transparent ${isMobile ? 'p-2' : ''}`}>
|
||||
<h6 className="text-white mb-0" style={{ fontSize: isMobile ? '0.85rem' : '1rem' }}>
|
||||
<i className="bi bi-table me-2"></i>
|
||||
{t('reports.detailedList')} ({topExpenses.data.length} {t('reports.transactions')})
|
||||
</h6>
|
||||
</div>
|
||||
<div className="card-body p-0">
|
||||
<div className="table-responsive" style={{ maxHeight: '500px' }}>
|
||||
<table className="table table-dark table-hover mb-0">
|
||||
<thead className="sticky-top" style={{ background: '#1e293b' }}>
|
||||
<tr>
|
||||
<th className="text-center" style={{ width: '60px' }}>#</th>
|
||||
<th>{t('reports.description')}</th>
|
||||
<th>{t('reports.category')}</th>
|
||||
<th className="text-center">{t('reports.date')}</th>
|
||||
<th className="text-end">{t('reports.amount')}</th>
|
||||
<th className="text-center" style={{ width: '100px' }}>% {t('reports.total')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{topExpenses.data.map((item, i) => {
|
||||
const percentage = (item.amount / topExpenses.total) * 100;
|
||||
return (
|
||||
<tr key={item.id}>
|
||||
<td className="text-center">
|
||||
<span className={`badge ${i < 3 ? 'bg-danger' : i < 10 ? 'bg-warning text-dark' : 'bg-secondary'}`}>
|
||||
{i + 1}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div className="text-white text-truncate" style={{ maxWidth: '300px' }}>
|
||||
{item.description}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span className="badge bg-primary">{item.category || '-'}</span>
|
||||
</td>
|
||||
<td className="text-center text-slate-400 small">{item.date}</td>
|
||||
<td className="text-end">
|
||||
<span className="text-danger fw-bold">
|
||||
{currency(item.amount, item.currency || topExpenses.currency)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="text-center">
|
||||
<div className="d-flex align-items-center justify-content-center gap-2">
|
||||
<div className="progress flex-grow-1" style={{ height: '6px', width: '50px' }}>
|
||||
<div
|
||||
className="progress-bar bg-danger"
|
||||
style={{ width: `${percentage}%` }}
|
||||
></div>
|
||||
<div className={isMobile ? 'p-2' : 'card-body p-0'}>
|
||||
{/* Desktop: Tabela */}
|
||||
{!isMobile && (
|
||||
<div className="table-responsive" style={{ maxHeight: '500px' }}>
|
||||
<table className="table table-dark table-hover mb-0">
|
||||
<thead className="sticky-top" style={{ background: '#1e293b' }}>
|
||||
<tr>
|
||||
<th className="text-center" style={{ width: '60px' }}>#</th>
|
||||
<th>{t('reports.description')}</th>
|
||||
<th>{t('reports.category')}</th>
|
||||
<th className="text-center">{t('reports.date')}</th>
|
||||
<th className="text-end">{t('reports.amount')}</th>
|
||||
<th className="text-center" style={{ width: '100px' }}>% {t('reports.total')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{topExpenses.data.map((item, i) => {
|
||||
const percentage = (item.amount / topExpenses.total) * 100;
|
||||
return (
|
||||
<tr key={item.id}>
|
||||
<td className="text-center">
|
||||
<span className={`badge ${i < 3 ? 'bg-danger' : i < 10 ? 'bg-warning text-dark' : 'bg-secondary'}`}>
|
||||
{i + 1}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div className="text-white text-truncate" style={{ maxWidth: '300px' }}>
|
||||
{item.description}
|
||||
</div>
|
||||
<small className="text-slate-400" style={{ minWidth: '45px' }}>
|
||||
</td>
|
||||
<td>
|
||||
<span className="badge bg-primary">{item.category || '-'}</span>
|
||||
</td>
|
||||
<td className="text-center text-slate-400 small">{item.date}</td>
|
||||
<td className="text-end">
|
||||
<span className="text-danger fw-bold">
|
||||
{currency(item.amount, item.currency || topExpenses.currency)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="text-center">
|
||||
<div className="d-flex align-items-center justify-content-center gap-2">
|
||||
<div className="progress flex-grow-1" style={{ height: '6px', width: '50px' }}>
|
||||
<div
|
||||
className="progress-bar bg-danger"
|
||||
style={{ width: `${percentage}%` }}
|
||||
></div>
|
||||
</div>
|
||||
<small className="text-slate-400" style={{ minWidth: '45px' }}>
|
||||
{percentage.toFixed(1)}%
|
||||
</small>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mobile: Cards compactos */}
|
||||
{isMobile && (
|
||||
<div style={{ maxHeight: '500px', overflowY: 'auto' }}>
|
||||
{topExpenses.data.map((item, i) => {
|
||||
const percentage = (item.amount / topExpenses.total) * 100;
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className="card mb-2 border-0"
|
||||
style={{ background: '#1e293b' }}
|
||||
>
|
||||
<div className="card-body p-2">
|
||||
{/* Header: Ranking e Valor */}
|
||||
<div className="d-flex justify-content-between align-items-start mb-2">
|
||||
<span className={`badge ${i < 3 ? 'bg-danger' : i < 10 ? 'bg-warning text-dark' : 'bg-secondary'}`} style={{ fontSize: '0.75rem' }}>
|
||||
#{i + 1}
|
||||
</span>
|
||||
<div className="text-end">
|
||||
<div className="text-danger fw-bold" style={{ fontSize: '0.95rem' }}>
|
||||
{currency(item.amount, item.currency || topExpenses.currency)}
|
||||
</div>
|
||||
<small className="text-slate-400" style={{ fontSize: '0.7rem' }}>
|
||||
{percentage.toFixed(1)}%
|
||||
</small>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Descrição */}
|
||||
<div className="text-white mb-1" style={{ fontSize: '0.85rem', lineHeight: '1.3' }}>
|
||||
{item.description}
|
||||
</div>
|
||||
|
||||
{/* Footer: Categoria e Data */}
|
||||
<div className="d-flex justify-content-between align-items-center">
|
||||
<span className="badge bg-primary" style={{ fontSize: '0.7rem' }}>
|
||||
{item.category || '-'}
|
||||
</span>
|
||||
<small className="text-slate-400" style={{ fontSize: '0.7rem' }}>
|
||||
{item.date}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="progress mt-2" style={{ height: '4px' }}>
|
||||
<div
|
||||
className="progress-bar bg-danger"
|
||||
style={{ width: `${percentage}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -1800,6 +1957,14 @@ const Reports = () => {
|
||||
|
||||
return (
|
||||
<div className="reports-container">
|
||||
{/* CSS para hover na tabela */}
|
||||
<style>{`
|
||||
.table-hover-row:hover {
|
||||
background-color: rgba(59, 130, 246, 0.1) !important;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
`}</style>
|
||||
|
||||
{/* Header */}
|
||||
<div className={`d-flex ${isMobile ? 'flex-column gap-2' : 'justify-content-between align-items-center'} mb-4`}>
|
||||
<div>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user