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}
-
-
-
-
-
- | {t('costCenters.name')} |
- {t('reports.income')} |
- {t('reports.expenses')} |
- {t('reports.balance')} |
-
-
-
- {data.map(cc => (
-
- |
- ●
- {cc.name}
- |
- {currency(cc.income, costCenterData.currency)} |
- {currency(cc.expense, costCenterData.currency)} |
- = 0 ? 'text-success' : 'text-danger'}`}>
- {currency(cc.balance, costCenterData.currency)}
- |
+
+ {/* Desktop: Tabela */}
+ {!isMobile && (
+
+
+
+
+
+ | {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)}
+
+
+
+
+
+ ))}
+
+ )}