diff --git a/CHANGELOG.md b/CHANGELOG.md index c31be76..1f806fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,39 @@ O formato segue [Keep a Changelog](https://keepachangelog.com/pt-BR/). Este projeto adota [Versionamento Semântico](https://semver.org/pt-BR/). +## [1.43.9] - 2025-12-16 + +### Added +- **Relatório de Centros de Custo - Drill-down de 3 Níveis** + - Nível 1: Mostra todos os centros de custo + - Nível 2: Clicar em um centro de custo mostra categorias (agrupadas por categoria pai) + - Nível 3: Clicar em uma categoria mostra suas subcategorias + - Navegação com breadcrumbs: "Todos os Centros" > Nome do Centro > Nome da Categoria + - Botões "Voltar" em cada nível + - Otimização mobile: cards responsivos em todos os 3 níveis + - Hover visual em tabelas e cards clicáveis + +- **Backend - API /reports/by-cost-center** + - Parâmetros: `cost_center_id` (nível 2) e `category_id` (nível 3) + - Sem parâmetros: lista centros de custo + - Com `cost_center_id`: lista categorias pai do centro de custo + - Com `cost_center_id` + `category_id`: lista subcategorias da categoria + - Query usa COALESCE para agregar subcategorias na categoria pai + - Suporte para transações sem centro de custo (id=0, "Sem classificar") + +- **Tradução i18n** + - pt-BR: "Todos os Centros", "Voltar para Todos os Centros", "Voltar para {nome}" + - es: "Todos los Centros" + - en: "All Centers" + +### Changed +- **Frontend - Reports.jsx** + - Adicionados estados: `selectedCostCenter` e `selectedCostCenterCategory` + - Função `renderCostCenter` reescrita com lógica de 3 níveis + - useEffect reage a mudanças de `selectedCostCenter` e `selectedCostCenterCategory` + - Estilo condicional: cursor pointer e hover apenas para itens clicáveis + - Breadcrumbs dinâmicos baseados no nível de navegação + ## [1.43.8] - 2025-12-16 ### Added diff --git a/VERSION b/VERSION index f8136f8..53b7a1c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.43.8 +1.43.9 diff --git a/backend/app/Http/Controllers/Api/ReportController.php b/backend/app/Http/Controllers/Api/ReportController.php index 0003863..b4437c9 100644 --- a/backend/app/Http/Controllers/Api/ReportController.php +++ b/backend/app/Http/Controllers/Api/ReportController.php @@ -259,66 +259,160 @@ public function byCategory(Request $request) } /** - * Reporte por centro de costos + * Reporte por centro de costos (3 niveles: centro -> categoría -> subcategoría) */ public function byCostCenter(Request $request) { $this->init(); $startDate = $request->get('start_date', now()->startOfYear()->format('Y-m-d')); $endDate = $request->get('end_date', now()->format('Y-m-d')); + $costCenterId = $request->get('cost_center_id'); + $categoryId = $request->get('category_id'); - $data = DB::select(" - SELECT - 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 - FROM transactions t - LEFT JOIN cost_centers cc ON t.cost_center_id = cc.id - LEFT JOIN accounts a ON t.account_id = a.id - WHERE t.user_id = ? - AND t.effective_date BETWEEN ? AND ? - AND t.deleted_at IS NULL - AND {$this->excludeTransfers()} - 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]); - - // Agrupar por centro de costo - $byCostCenter = []; - foreach ($data as $row) { - $ccId = $row->cost_center_id; - if (!isset($byCostCenter[$ccId])) { - $byCostCenter[$ccId] = [ - 'id' => $ccId == 0 ? null : $ccId, - 'name' => $row->cost_center_name, - 'color' => $row->color, - 'income_converted' => 0, - 'expense_converted' => 0, - 'by_currency' => [], - ]; - } - $byCostCenter[$ccId]['by_currency'][$row->currency] = [ - 'income' => round($row->income, 2), - 'expense' => round($row->expense, 2), - ]; - $byCostCenter[$ccId]['income_converted'] += $this->convertToPrimaryCurrency($row->income, $row->currency); - $byCostCenter[$ccId]['expense_converted'] += $this->convertToPrimaryCurrency($row->expense, $row->currency); + // Nível 3: Subcategorias de uma categoria específica de um centro de custo + if ($costCenterId !== null && $categoryId !== null) { + $data = DB::select(" + SELECT + c.id as category_id, + c.name as category_name, + c.icon as category_icon, + c.color as category_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 + 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 " . ($costCenterId == 0 ? "t.cost_center_id IS NULL" : "t.cost_center_id = ?") . " + 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 expense DESC + ", $costCenterId == 0 + ? [$this->userId, $startDate, $endDate, $categoryId] + : [$this->userId, $startDate, $endDate, $costCenterId, $categoryId] + ); + } + // Nível 2: Categorias pai de um centro de custo específico + else if ($costCenterId !== null) { + $data = DB::select(" + SELECT + COALESCE(c.parent_id, c.id) as category_id, + COALESCE(cp.name, c.name) as category_name, + COALESCE(cp.icon, c.icon) as category_icon, + COALESCE(cp.color, c.color) as category_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 + FROM transactions t + LEFT JOIN categories c ON t.category_id = c.id + LEFT JOIN categories cp ON c.parent_id = cp.id + LEFT JOIN accounts a ON t.account_id = a.id + WHERE t.user_id = ? + AND t.effective_date BETWEEN ? AND ? + AND " . ($costCenterId == 0 ? "t.cost_center_id IS NULL" : "t.cost_center_id = ?") . " + AND t.deleted_at IS NULL + AND {$this->excludeTransfers()} + GROUP BY COALESCE(c.parent_id, c.id), COALESCE(cp.name, c.name), COALESCE(cp.icon, c.icon), COALESCE(cp.color, c.color), COALESCE(a.currency, 'EUR') + ORDER BY expense DESC + ", $costCenterId == 0 + ? [$this->userId, $startDate, $endDate] + : [$this->userId, $startDate, $endDate, $costCenterId] + ); + } + // Nível 1: Centros de custo + else { + $data = DB::select(" + SELECT + 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 + FROM transactions t + LEFT JOIN cost_centers cc ON t.cost_center_id = cc.id + LEFT JOIN accounts a ON t.account_id = a.id + WHERE t.user_id = ? + AND t.effective_date BETWEEN ? AND ? + AND t.deleted_at IS NULL + AND {$this->excludeTransfers()} + 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]); } - $result = array_map(function($cc) { + // Agrupar y procesar resultados + $results = []; + + // Nível 3: Subcategorias + if ($costCenterId !== null && $categoryId !== null) { + foreach ($data as $row) { + $id = $row->category_id ?? 0; + if (!isset($results[$id])) { + $results[$id] = [ + 'id' => $id == 0 ? null : $id, + 'name' => $row->category_name ?? 'Sin categoría', + 'icon' => $row->category_icon ?? 'bi-tag', + 'color' => $row->category_color ?? '#6b7280', + 'income_converted' => 0, + 'expense_converted' => 0, + ]; + } + $results[$id]['income_converted'] += $this->convertToPrimaryCurrency($row->income, $row->currency); + $results[$id]['expense_converted'] += $this->convertToPrimaryCurrency($row->expense, $row->currency); + } + } + // Nível 2: Categorias + else if ($costCenterId !== null) { + foreach ($data as $row) { + $id = $row->category_id ?? 0; + if (!isset($results[$id])) { + $results[$id] = [ + 'id' => $id == 0 ? null : $id, + 'name' => $row->category_name ?? 'Sin categoría', + 'icon' => $row->category_icon ?? 'bi-tag', + 'color' => $row->category_color ?? '#6b7280', + 'income_converted' => 0, + 'expense_converted' => 0, + ]; + } + $results[$id]['income_converted'] += $this->convertToPrimaryCurrency($row->income, $row->currency); + $results[$id]['expense_converted'] += $this->convertToPrimaryCurrency($row->expense, $row->currency); + } + } + // Nível 1: Centros de custo + else { + foreach ($data as $row) { + $id = $row->cost_center_id; + if (!isset($results[$id])) { + $results[$id] = [ + 'id' => $id == 0 ? null : $id, + 'name' => $row->cost_center_name, + 'color' => $row->color, + 'income_converted' => 0, + 'expense_converted' => 0, + ]; + } + $results[$id]['income_converted'] += $this->convertToPrimaryCurrency($row->income, $row->currency); + $results[$id]['expense_converted'] += $this->convertToPrimaryCurrency($row->expense, $row->currency); + } + } + + $result = array_map(function($item) { return [ - 'id' => $cc['id'], - 'name' => $cc['name'], - 'color' => $cc['color'], - 'income' => round($cc['income_converted'], 2), - 'expense' => round($cc['expense_converted'], 2), - 'balance' => round($cc['income_converted'] - $cc['expense_converted'], 2), - 'by_currency' => $cc['by_currency'], + 'id' => $item['id'], + 'name' => $item['name'], + 'icon' => $item['icon'] ?? null, + 'color' => $item['color'], + 'income' => round($item['income_converted'], 2), + 'expense' => round($item['expense_converted'], 2), + 'balance' => round($item['income_converted'] - $item['expense_converted'], 2), ]; - }, $byCostCenter); + }, $results); usort($result, fn($a, $b) => $b['expense'] <=> $a['expense']); diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 5fe704a..23554cb 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -1803,6 +1803,7 @@ "balance": "Balance", "byCategory": "By Category", "byCostCenter": "By Cost Center", + "allCostCenters": "All Centers", "comparison": "Comparison", "periodComparison": "Period Comparison", "vsPreviousPeriod": "vs previous period", diff --git a/frontend/src/i18n/locales/es.json b/frontend/src/i18n/locales/es.json index a563677..10b1a88 100644 --- a/frontend/src/i18n/locales/es.json +++ b/frontend/src/i18n/locales/es.json @@ -1788,6 +1788,7 @@ "summary": "Resumen", "byCategory": "Por Categoría", "byCostCenter": "Por Centro de Costo", + "allCostCenters": "Todos los Centros", "monthlyEvolution": "Evolución Mensual", "comparison": "Comparativa", "periodComparison": "Comparación de Períodos", diff --git a/frontend/src/i18n/locales/pt-BR.json b/frontend/src/i18n/locales/pt-BR.json index 4d95a50..3ec9ff6 100644 --- a/frontend/src/i18n/locales/pt-BR.json +++ b/frontend/src/i18n/locales/pt-BR.json @@ -1809,6 +1809,7 @@ "balance": "Saldo", "byCategory": "Por Categoria", "byCostCenter": "Por Centro de Custo", + "allCostCenters": "Todos os Centros", "comparison": "Comparação", "periodComparison": "Comparação de Períodos", "vsPreviousPeriod": "vs período anterior", diff --git a/frontend/src/pages/Reports.jsx b/frontend/src/pages/Reports.jsx index 37c4838..b52410d 100644 --- a/frontend/src/pages/Reports.jsx +++ b/frontend/src/pages/Reports.jsx @@ -59,6 +59,8 @@ const Reports = () => { const [projection, setProjection] = useState(null); const [comparison, setComparison] = useState(null); const [costCenterData, setCostCenterData] = useState(null); + const [selectedCostCenter, setSelectedCostCenter] = useState(null); + const [selectedCostCenterCategory, setSelectedCostCenterCategory] = useState(null); const [recurringData, setRecurringData] = useState(null); const [liabilitiesData, setLiabilitiesData] = useState(null); const [futureData, setFutureData] = useState(null); @@ -102,7 +104,14 @@ const Reports = () => { setComparison(compRes); break; case 'costCenter': - const ccRes = await reportService.getByCostCenter(); + const ccParams = {}; + if (selectedCostCenter) { + ccParams.cost_center_id = selectedCostCenter.id ?? 0; + } + if (selectedCostCenterCategory) { + ccParams.category_id = selectedCostCenterCategory.id; + } + const ccRes = await reportService.getByCostCenter(ccParams); setCostCenterData(ccRes); break; case 'recurring': @@ -127,7 +136,7 @@ const Reports = () => { } finally { setLoading(false); } - }, [activeTab, year, months, selectedCategory]); + }, [activeTab, year, months, selectedCategory, selectedCostCenter, selectedCostCenterCategory]); useEffect(() => { loadData(); @@ -140,6 +149,13 @@ const Reports = () => { } }, [selectedCategory]); + // Recarrega dados quando centro de custo ou categoria selecionada muda + useEffect(() => { + if (activeTab === 'costCenter') { + loadData(); + } + }, [selectedCostCenter, selectedCostCenterCategory]); + const tabs = [ { id: 'summary', label: t('reports.summary'), icon: 'bi-clipboard-data' }, { id: 'category', label: t('reports.byCategory'), icon: 'bi-pie-chart' }, @@ -1488,72 +1504,223 @@ const Reports = () => { if (!costCenterData) return null; const data = costCenterData.data || []; + const handleCostCenterClick = (cc) => { + setSelectedCostCenter(cc); + }; + + const handleCategoryClick = (cat) => { + setSelectedCostCenterCategory(cat); + }; + + const handleBackToCostCenters = () => { + setSelectedCostCenter(null); + setSelectedCostCenterCategory(null); + }; + + const handleBackToCategories = () => { + setSelectedCostCenterCategory(null); + }; + + // Determinar título baseado no nível + let title = t('reports.byCostCenter'); + if (selectedCostCenter && selectedCostCenterCategory) { + title = t('reports.subcategoryDistribution'); + } else if (selectedCostCenter) { + title = t('reports.expenseDistribution'); + } + return (
-
-
-
- {t('reports.totalIncome')} -
{currency(costCenterData.total_income || 0, costCenterData.currency)}
+ {/* Breadcrumb/Navegação */} + {(selectedCostCenter || selectedCostCenterCategory) && ( +
+
+ {selectedCostCenter && ( + + )} + {selectedCostCenterCategory && ( + + )} + {selectedCostCenterCategory && ( + + {selectedCostCenterCategory.name} + + )} +
+
+ )} + + {/* Cards de Totais */} +
+
+
+ + {t('reports.totalIncome')} + +
+ {currency(costCenterData.total_income || 0, costCenterData.currency)} +
-
-
-
- {t('reports.totalExpense')} -
{currency(costCenterData.total_expense || 0, costCenterData.currency)}
+
+
+
+ + {t('reports.totalExpense')} + +
+ {currency(costCenterData.total_expense || 0, costCenterData.currency)} +
-
-
-
- {t('reports.balance')} -
= 0 ? 'text-success' : 'text-danger'}`}> +
+
+
+ + {t('reports.balance')} + +
= 0 ? 'text-success' : 'text-danger'}`} + style={{ fontSize: isMobile ? '0.95rem' : '1.25rem' }} + > {currency((costCenterData.total_income || 0) - (costCenterData.total_expense || 0), costCenterData.currency)}
+ {/* Tabela/Lista */}
-
-
+
+
- {t('reports.byCostCenter')} + {title}
-
-
- - - - - - - - - - - {data.map(cc => ( - - - - - + + {/* Desktop: Tabela */} + {!isMobile && ( +
+
+
{t('costCenters.name')}{t('reports.income')}{t('reports.expenses')}{t('reports.balance')}
- - {cc.name} - {currency(cc.income, costCenterData.currency)}{currency(cc.expense, costCenterData.currency)}= 0 ? 'text-success' : 'text-danger'}`}> - {currency(cc.balance, costCenterData.currency)} -
+ + + + + + - ))} - -
{selectedCostCenter ? t('categories.category') : t('costCenters.name')}{t('reports.income')}{t('reports.expenses')}{t('reports.balance')}
+ + + {data.map((item, idx) => ( + { + if (!selectedCostCenter) { + handleCostCenterClick(item); + } else if (!selectedCostCenterCategory && item.id !== null) { + handleCategoryClick(item); + } + }} + style={{ + cursor: (!selectedCostCenter || (!selectedCostCenterCategory && item.id !== null)) ? 'pointer' : 'default' + }} + className={(!selectedCostCenter || (!selectedCostCenterCategory && item.id !== null)) ? 'table-hover-row' : ''} + > + + {item.icon && } + {!item.icon && } + {item.name} + + {currency(item.income, costCenterData.currency)} + {currency(item.expense, costCenterData.currency)} + = 0 ? 'text-success' : 'text-danger'}`}> + {currency(item.balance, costCenterData.currency)} + + + ))} + + +
-
+ )} + + {/* Mobile: Cards */} + {isMobile && ( +
+ {data.map((item, idx) => ( +
{ + if (!selectedCostCenter) { + handleCostCenterClick(item); + } else if (!selectedCostCenterCategory && item.id !== null) { + handleCategoryClick(item); + } + }} + > +
+
+
+ {item.icon && } + {!item.icon && } + {item.name} +
+
+
+
+ + {t('reports.income')} + + + {currency(item.income, costCenterData.currency)} + +
+
+ + {t('reports.expenses')} + + + {currency(item.expense, costCenterData.currency)} + +
+
+ + {t('reports.balance')} + + = 0 ? 'text-success' : 'text-danger'}`} + style={{ fontSize: '0.8rem' }} + > + {currency(item.balance, costCenterData.currency)} + +
+
+
+
+ ))} +
+ )}