From 7b9345dc8018009c2ffeac5444145d36b4e4497a Mon Sep 17 00:00:00 2001 From: marcoitaloesp-ai Date: Tue, 16 Dec 2025 15:07:44 +0000 Subject: [PATCH] =?UTF-8?q?v1.43.8=20-=20Drill-down=20de=20subcategorias?= =?UTF-8?q?=20em=20relat=C3=B3rios?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 42 ++ VERSION | 2 +- .../Http/Controllers/Api/ReportController.php | 42 +- frontend/src/i18n/locales/en.json | 2 + frontend/src/i18n/locales/es.json | 3 + frontend/src/i18n/locales/pt-BR.json | 2 + frontend/src/pages/Reports.jsx | 393 +++++++++++++----- 7 files changed, 362 insertions(+), 124 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ee37d4..415475e 100644 --- a/CHANGELOG.md +++ b/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 diff --git a/VERSION b/VERSION index ea67177..f8136f8 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.43.6 +1.43.8 diff --git a/backend/app/Http/Controllers/Api/ReportController.php b/backend/app/Http/Controllers/Api/ReportController.php index a6ace8a..ae4578f 100644 --- a/backend/app/Http/Controllers/Api/ReportController.php +++ b/backend/app/Http/Controllers/Api/ReportController.php @@ -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' => [], diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index c0dd397..5fe704a 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -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", diff --git a/frontend/src/i18n/locales/es.json b/frontend/src/i18n/locales/es.json index 2b7cb07..a563677 100644 --- a/frontend/src/i18n/locales/es.json +++ b/frontend/src/i18n/locales/es.json @@ -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", diff --git a/frontend/src/i18n/locales/pt-BR.json b/frontend/src/i18n/locales/pt-BR.json index 673986a..4d95a50 100644 --- a/frontend/src/i18n/locales/pt-BR.json +++ b/frontend/src/i18n/locales/pt-BR.json @@ -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", diff --git a/frontend/src/pages/Reports.jsx b/frontend/src/pages/Reports.jsx index c2570d0..bc2fee3 100644 --- a/frontend/src/pages/Reports.jsx +++ b/frontend/src/pages/Reports.jsx @@ -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 (
-
+ {/* Botão Voltar */} + {selectedCategory && ( +
+ +
+ )} + {/* Gráfico de Distribuição */} +
-
-
+
+
- {t('reports.expenseDistribution')} + {selectedCategory ? t('reports.subcategoryDistribution') : t('reports.expenseDistribution')}
-
+
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 + }} />
-
+ {/* Tabela/Lista de Detalhes */} +
-
-
+
+
{t('reports.categoryDetail')}
- {currency(categoryData.total, categoryData.currency)} + + {currency(categoryData.total, categoryData.currency)} +
-
- - - - - - - - - - {categoryData.data.map((cat, i) => ( - - - - + + {/* Desktop: Tabela */} + {!isMobile && ( +
+
{t('reports.category')}{t('common.total')}%
- - {cat.category_name} - {currency(cat.total, categoryData.currency)} - {cat.percentage}% -
+ + + + + - ))} - -
{t('reports.category')}{t('common.total')}%
-
+ + + {categoryData.data.map((cat, i) => ( + !selectedCategory && handleCategoryClick(cat)} + style={{ cursor: !selectedCategory ? 'pointer' : 'default' }} + className={!selectedCategory ? 'table-hover-row' : ''} + > + + + {cat.category_name} + + {currency(cat.total, categoryData.currency)} + + {cat.percentage}% + + + ))} + + +
+ )} + + {/* Mobile: Cards */} + {isMobile && ( +
+ {categoryData.data.map((cat, i) => ( +
!selectedCategory && handleCategoryClick(cat)} + > +
+
+
+ + + {cat.category_name} + +
+ + {cat.percentage}% + +
+
+ + {currency(cat.total, categoryData.currency)} + +
+
+
+ ))} +
+ )}
@@ -512,13 +594,15 @@ const Reports = () => { return (
+ {/* Period Selector */}
-
+
{[6, 12, 24].map(m => ( @@ -527,37 +611,51 @@ const Reports = () => {
{/* Averages Cards */} -
+
-
- {t('reports.avgIncome')} -
{currency(evolutionData.averages.income, evolutionData.currency)}
+
+ + {t('reports.avgIncome')} + +
+ {currency(evolutionData.averages.income, evolutionData.currency)} +
-
+
-
- {t('reports.avgExpense')} -
{currency(evolutionData.averages.expense, evolutionData.currency)}
+
+ + {t('reports.avgExpense')} + +
+ {currency(evolutionData.averages.expense, evolutionData.currency)} +
-
+
-
- {t('reports.balance')} -
= 0 ? 'text-success' : 'text-danger'}`}> +
+ + {t('reports.balance')} + +
= 0 ? 'text-success' : 'text-danger'}`} style={{ fontSize: isMobile ? '0.85rem' : '1.25rem' }}> {currency(evolutionData.averages.balance, evolutionData.currency)}
-
+
-
- {t('reports.savingsRate')} -
{evolutionData.averages.savings_rate}%
+
+ + {t('reports.savingsRate')} + +
+ {evolutionData.averages.savings_rate}% +
@@ -565,13 +663,13 @@ const Reports = () => { {/* Evolution Chart */}
-
-
+
+
{t('reports.monthlyEvolution')}
-
+
d.month_label), @@ -611,13 +709,13 @@ const Reports = () => { {/* Savings Rate Chart */}
-
-
+
+
{t('reports.savingsRate')} por mes
-
+
d.month_label), @@ -1073,71 +1171,130 @@ const Reports = () => {
- {/* Tabela Detalhada */} + {/* Tabela/Lista Detalhada */}
-
-
+
+
{t('reports.detailedList')} ({topExpenses.data.length} {t('reports.transactions')})
-
-
- - - - - - - - - - - - - {topExpenses.data.map((item, i) => { - const percentage = (item.amount / topExpenses.total) * 100; - return ( - - - - - - - - - ); - })} - -
#{t('reports.description')}{t('reports.category')}{t('reports.date')}{t('reports.amount')}% {t('reports.total')}
- - {i + 1} - - -
- {item.description} -
-
- {item.category || '-'} - {item.date} - - {currency(item.amount, item.currency || topExpenses.currency)} - - -
-
-
+
+ {/* Desktop: Tabela */} + {!isMobile && ( +
+ + + + + + + + + + + + + {topExpenses.data.map((item, i) => { + const percentage = (item.amount / topExpenses.total) * 100; + return ( + + + + + + + + + ); + })} + +
#{t('reports.description')}{t('reports.category')}{t('reports.date')}{t('reports.amount')}% {t('reports.total')}
+ + {i + 1} + + +
+ {item.description}
- +
+ {item.category || '-'} + {item.date} + + {currency(item.amount, item.currency || topExpenses.currency)} + + +
+
+
+
+ + {percentage.toFixed(1)}% + +
+
+
+ )} + + {/* Mobile: Cards compactos */} + {isMobile && ( +
+ {topExpenses.data.map((item, i) => { + const percentage = (item.amount / topExpenses.total) * 100; + return ( +
+
+ {/* Header: Ranking e Valor */} +
+ + #{i + 1} + +
+
+ {currency(item.amount, item.currency || topExpenses.currency)} +
+ {percentage.toFixed(1)}%
-
-
+
+ + {/* Descrição */} +
+ {item.description} +
+ + {/* Footer: Categoria e Data */} +
+ + {item.category || '-'} + + + {item.date} + +
+ + {/* Progress bar */} +
+
+
+
+
+ ); + })} +
+ )}
@@ -1800,6 +1957,14 @@ const Reports = () => { return (
+ {/* CSS para hover na tabela */} + + {/* Header */}