v1.43.9 - Drill-down de 3 níveis em Centros de Custo (Centro → Categoria → Subcategoria)

This commit is contained in:
marcoitaloesp-ai 2025-12-16 15:34:02 +00:00 committed by GitHub
parent db87da95c1
commit bb06ca8fae
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 394 additions and 97 deletions

View File

@ -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

View File

@ -1 +1 @@
1.43.8
1.43.9

View File

@ -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']);

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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 (
<div className="row g-4">
<div className="col-md-4">
<div className="card border-0 text-center" style={{ background: '#1e293b' }}>
<div className="card-body">
<small className="text-slate-400">{t('reports.totalIncome')}</small>
<h5 className="text-success mb-0">{currency(costCenterData.total_income || 0, costCenterData.currency)}</h5>
{/* Breadcrumb/Navegação */}
{(selectedCostCenter || selectedCostCenterCategory) && (
<div className="col-12">
<div className="d-flex gap-2 flex-wrap">
{selectedCostCenter && (
<button
className="btn btn-sm btn-outline-secondary"
onClick={handleBackToCostCenters}
>
<i className="bi bi-house me-2"></i>
{t('reports.allCostCenters')}
</button>
)}
{selectedCostCenterCategory && (
<button
className="btn btn-sm btn-outline-secondary"
onClick={handleBackToCategories}
>
<i className="bi bi-arrow-left me-2"></i>
{selectedCostCenter.name}
</button>
)}
{selectedCostCenterCategory && (
<span className="btn btn-sm btn-secondary" disabled>
{selectedCostCenterCategory.name}
</span>
)}
</div>
</div>
)}
{/* Cards de Totais */}
<div className={isMobile ? 'col-12' : 'col-md-4'}>
<div className="card border-0 text-center" style={{ background: '#1e293b', padding: isMobile ? '0.75rem' : '1rem' }}>
<div className="card-body" style={{ padding: isMobile ? '0.5rem' : '1rem' }}>
<small className="text-slate-400" style={{ fontSize: isMobile ? '0.7rem' : '0.875rem' }}>
{t('reports.totalIncome')}
</small>
<h5 className="text-success mb-0" style={{ fontSize: isMobile ? '0.95rem' : '1.25rem' }}>
{currency(costCenterData.total_income || 0, costCenterData.currency)}
</h5>
</div>
</div>
</div>
<div className="col-md-4">
<div className="card border-0 text-center" style={{ background: '#1e293b' }}>
<div className="card-body">
<small className="text-slate-400">{t('reports.totalExpense')}</small>
<h5 className="text-danger mb-0">{currency(costCenterData.total_expense || 0, costCenterData.currency)}</h5>
<div className={isMobile ? 'col-12' : 'col-md-4'}>
<div className="card border-0 text-center" style={{ background: '#1e293b', padding: isMobile ? '0.75rem' : '1rem' }}>
<div className="card-body" style={{ padding: isMobile ? '0.5rem' : '1rem' }}>
<small className="text-slate-400" style={{ fontSize: isMobile ? '0.7rem' : '0.875rem' }}>
{t('reports.totalExpense')}
</small>
<h5 className="text-danger mb-0" style={{ fontSize: isMobile ? '0.95rem' : '1.25rem' }}>
{currency(costCenterData.total_expense || 0, costCenterData.currency)}
</h5>
</div>
</div>
</div>
<div className="col-md-4">
<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 ${(costCenterData.total_income - costCenterData.total_expense) >= 0 ? 'text-success' : 'text-danger'}`}>
<div className={isMobile ? 'col-12' : 'col-md-4'}>
<div className="card border-0 text-center" style={{ background: '#1e293b', padding: isMobile ? '0.75rem' : '1rem' }}>
<div className="card-body" style={{ padding: isMobile ? '0.5rem' : '1rem' }}>
<small className="text-slate-400" style={{ fontSize: isMobile ? '0.7rem' : '0.875rem' }}>
{t('reports.balance')}
</small>
<h5
className={`mb-0 ${(costCenterData.total_income - costCenterData.total_expense) >= 0 ? 'text-success' : 'text-danger'}`}
style={{ fontSize: isMobile ? '0.95rem' : '1.25rem' }}
>
{currency((costCenterData.total_income || 0) - (costCenterData.total_expense || 0), costCenterData.currency)}
</h5>
</div>
</div>
</div>
{/* Tabela/Lista */}
<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-diagram-3 me-2"></i>
{t('reports.byCostCenter')}
{title}
</h6>
</div>
<div className="card-body p-0">
<div className="table-responsive">
<table className="table table-dark table-hover mb-0">
<thead>
<tr>
<th>{t('costCenters.name')}</th>
<th className="text-end">{t('reports.income')}</th>
<th className="text-end">{t('reports.expenses')}</th>
<th className="text-end">{t('reports.balance')}</th>
</tr>
</thead>
<tbody>
{data.map(cc => (
<tr key={cc.id}>
<td>
<span className="me-2" style={{ color: cc.color || '#6b7280' }}></span>
{cc.name}
</td>
<td className="text-end text-success">{currency(cc.income, costCenterData.currency)}</td>
<td className="text-end text-danger">{currency(cc.expense, costCenterData.currency)}</td>
<td className={`text-end ${cc.balance >= 0 ? 'text-success' : 'text-danger'}`}>
{currency(cc.balance, costCenterData.currency)}
</td>
{/* Desktop: Tabela */}
{!isMobile && (
<div className="card-body p-0">
<div className="table-responsive">
<table className="table table-dark table-hover mb-0">
<thead>
<tr>
<th>{selectedCostCenter ? t('categories.category') : t('costCenters.name')}</th>
<th className="text-end">{t('reports.income')}</th>
<th className="text-end">{t('reports.expenses')}</th>
<th className="text-end">{t('reports.balance')}</th>
</tr>
))}
</tbody>
</table>
</thead>
<tbody>
{data.map((item, idx) => (
<tr
key={item.id || idx}
onClick={() => {
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' : ''}
>
<td>
{item.icon && <i className={`bi ${item.icon} me-2`} style={{ color: item.color }}></i>}
{!item.icon && <span className="me-2" style={{ color: item.color || '#6b7280' }}></span>}
{item.name}
</td>
<td className="text-end text-success">{currency(item.income, costCenterData.currency)}</td>
<td className="text-end text-danger">{currency(item.expense, costCenterData.currency)}</td>
<td className={`text-end ${item.balance >= 0 ? 'text-success' : 'text-danger'}`}>
{currency(item.balance, costCenterData.currency)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
)}
{/* Mobile: Cards */}
{isMobile && (
<div className="p-2" style={{ maxHeight: '500px', overflowY: 'auto' }}>
{data.map((item, idx) => (
<div
key={item.id || idx}
className="card mb-2 border-0"
style={{
background: '#1e293b',
cursor: (!selectedCostCenter || (!selectedCostCenterCategory && item.id !== null)) ? 'pointer' : 'default'
}}
onClick={() => {
if (!selectedCostCenter) {
handleCostCenterClick(item);
} else if (!selectedCostCenterCategory && item.id !== null) {
handleCategoryClick(item);
}
}}
>
<div className="card-body p-2">
<div className="d-flex justify-content-between align-items-start mb-2">
<div className="d-flex align-items-center gap-2">
{item.icon && <i className={`bi ${item.icon}`} style={{ color: item.color, fontSize: '1.2rem' }}></i>}
{!item.icon && <span style={{ color: item.color || '#6b7280', fontSize: '1.2rem' }}></span>}
<span className="text-white fw-bold" style={{ fontSize: '0.85rem' }}>{item.name}</span>
</div>
</div>
<div className="row g-2">
<div className="col-4">
<small className="text-slate-400 d-block" style={{ fontSize: '0.7rem' }}>
{t('reports.income')}
</small>
<span className="text-success fw-bold" style={{ fontSize: '0.8rem' }}>
{currency(item.income, costCenterData.currency)}
</span>
</div>
<div className="col-4">
<small className="text-slate-400 d-block" style={{ fontSize: '0.7rem' }}>
{t('reports.expenses')}
</small>
<span className="text-danger fw-bold" style={{ fontSize: '0.8rem' }}>
{currency(item.expense, costCenterData.currency)}
</span>
</div>
<div className="col-4">
<small className="text-slate-400 d-block" style={{ fontSize: '0.7rem' }}>
{t('reports.balance')}
</small>
<span
className={`fw-bold ${item.balance >= 0 ? 'text-success' : 'text-danger'}`}
style={{ fontSize: '0.8rem' }}
>
{currency(item.balance, costCenterData.currency)}
</span>
</div>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>