351 lines
12 KiB
JavaScript
351 lines
12 KiB
JavaScript
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||
import { extratoService } from '@/services/extratoService';
|
||
|
||
/**
|
||
* Retorna o último saldo armazenado (mais recente por data).
|
||
* Usado como "último saldo do mês passado" no cruzamento do fluxo de caixa.
|
||
* @param {Array<{ data?, data_saldo?, saldo }>} saldosArmazenados
|
||
* @returns {number}
|
||
*/
|
||
function getUltimoSaldoArmazenado(saldosArmazenados) {
|
||
if (!Array.isArray(saldosArmazenados) || saldosArmazenados.length === 0) return 0;
|
||
const comData = saldosArmazenados
|
||
.map((item) => ({
|
||
...item,
|
||
data: item.data ?? item.data_saldo ?? item.dataSaldo,
|
||
saldo: Number(item.saldo) || 0
|
||
}))
|
||
.filter((item) => item.data);
|
||
if (comData.length === 0) return Number(saldosArmazenados[0]?.saldo) || 0;
|
||
comData.sort((a, b) => new Date(b.data) - new Date(a.data));
|
||
return comData[0].saldo;
|
||
}
|
||
|
||
/**
|
||
* Hook para Fluxo de Caixa (financeiro_v2).
|
||
* Cruzamento: Último saldo (armazenado) + Receitas (extrato C) − Despesas (extrato D) = Resultado
|
||
* O resultado deve coincidir com o Saldo disponível (/saldo).
|
||
*/
|
||
export function useFluxoCaixa() {
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState(null);
|
||
const [extrato, setExtrato] = useState([]);
|
||
const [saldo, setSaldo] = useState({ disponivel: 0 });
|
||
const [saldoArmazenado, setSaldoArmazenado] = useState([]);
|
||
const [fluxoData, setFluxoData] = useState({ mensal: [] });
|
||
const [somaCategoria, setSomaCategoria] = useState({ por_categoria: [] });
|
||
const [saldoConsolidado, setSaldoConsolidado] = useState(null);
|
||
const [porCaixinha, setPorCaixinha] = useState([]);
|
||
|
||
const [filtroMes, setFiltroMes] = useState(String(new Date().getMonth() + 1));
|
||
const [filtroAno, setFiltroAno] = useState(new Date().getFullYear().toString());
|
||
const [filtroTipo, setFiltroTipo] = useState('mes'); // 'mes' | 'ano' | 'todos'
|
||
|
||
const loadData = useCallback(async () => {
|
||
try {
|
||
setLoading(true);
|
||
setError(null);
|
||
|
||
const mesAtu = filtroMes;
|
||
const anoAtu = filtroAno;
|
||
const tipo = filtroTipo;
|
||
|
||
// Monta os parâmetros de forma dinâmica conforme o tipo de filtro
|
||
const params = {};
|
||
if (tipo === 'mes') {
|
||
params.mes = mesAtu;
|
||
params.ano = anoAtu;
|
||
} else if (tipo === 'ano') {
|
||
params.ano = anoAtu;
|
||
}
|
||
// Se for 'todos', params fica vazio
|
||
|
||
const [extratoData, saldoData, armazenadoData, fluxoResponse, somaCategoriaData, consolidadoData, caixinhasList, categoriasList] = await Promise.all([
|
||
extratoService.fetchExtrato(params),
|
||
extratoService.fetchSaldo(),
|
||
extratoService.fetchSaldoArmazenado(),
|
||
extratoService.fetchFluxo(params),
|
||
extratoService.getSomaPorCategoria(params),
|
||
extratoService.fetchSaldoConsolidado(params),
|
||
extratoService.fetchCaixinhas(),
|
||
extratoService.fetchCategorias()
|
||
]);
|
||
|
||
// --- CLIENT-SIDE FILTERING FALLBACK ---
|
||
const matchesPeriod = (itemDate) => {
|
||
if (!itemDate || tipo === 'todos') return true;
|
||
|
||
// formats: YYYY-MM-DD or DD/MM/YYYY or ISO or "Wed, 11 Feb 2026 00:00:00 GMT"
|
||
let d;
|
||
if (typeof itemDate === 'string') {
|
||
if (itemDate.includes('/')) {
|
||
const [day, month, year] = itemDate.split('/');
|
||
d = new Date(year, month - 1, day);
|
||
} else {
|
||
d = new Date(itemDate);
|
||
}
|
||
} else {
|
||
d = new Date(itemDate);
|
||
}
|
||
|
||
if (Number.isNaN(d.getTime())) return true;
|
||
|
||
const itemMonth = String(d.getMonth() + 1);
|
||
const itemYear = String(d.getFullYear());
|
||
|
||
if (tipo === 'mes') {
|
||
return itemMonth === mesAtu && itemYear === anoAtu;
|
||
} else if (tipo === 'ano') {
|
||
return itemYear === anoAtu;
|
||
}
|
||
return true;
|
||
};
|
||
|
||
// 0. Build Comprehensive Categories Map
|
||
const categoriesMap = {};
|
||
|
||
// Source A: /categorias/apresentar
|
||
if (Array.isArray(categoriasList)) {
|
||
categoriasList.forEach(c => {
|
||
const cId = String(c.idcategoria ?? c.idcategorias ?? c.id ?? '');
|
||
if (cId) {
|
||
categoriesMap[cId] = c.categoria || c.name || c.nome || c.descricao;
|
||
}
|
||
});
|
||
}
|
||
|
||
// Source B: Labels already in /extrato/soma_por_categoria
|
||
const rawPorCategoria = somaCategoriaData?.por_categoria || [];
|
||
rawPorCategoria.forEach(cat => {
|
||
const cId = String(cat.idcategoria ?? '');
|
||
if (cId && cat.categoria && isNaN(cat.categoria)) {
|
||
if (!categoriesMap[cId] || categoriesMap[cId].startsWith('Categoria ')) {
|
||
categoriesMap[cId] = cat.categoria;
|
||
}
|
||
}
|
||
});
|
||
|
||
// 1. Process Extrato (Table Data) - Consolidate labels
|
||
const rawExtrato = Array.isArray(extratoData) ? extratoData : [];
|
||
const filteredExtrato = rawExtrato.filter(item =>
|
||
matchesPeriod(item.dataEntrada || item.data || item.data_entrada)
|
||
);
|
||
|
||
setExtrato(filteredExtrato.map(item => {
|
||
// ID can be in 'categoria' or 'idcategoria'
|
||
let idVal = item.idcategoria || '';
|
||
if (!idVal && item.categoria && !isNaN(item.categoria)) idVal = item.categoria;
|
||
const id = String(idVal || '0');
|
||
|
||
const catName = categoriesMap[id] ||
|
||
item.categoria_nome ||
|
||
(isNaN(item.categoria) ? item.categoria : null) ||
|
||
(id === '0' ? 'Sem Categoria' : `Categoria ${id}`);
|
||
|
||
return {
|
||
...item,
|
||
categoria_nome: catName
|
||
};
|
||
}));
|
||
|
||
// 2. Aggregate Categories (Chart Data) - Unified C and D for the same category
|
||
const aggregatedSummary = {};
|
||
rawPorCategoria.forEach(cat => {
|
||
const id = String(cat.idcategoria ?? '0');
|
||
const name = categoriesMap[id] || cat.categoria || (id === '0' ? 'Sem Categoria' : `Categoria ${id}`);
|
||
|
||
if (!aggregatedSummary[id]) {
|
||
aggregatedSummary[id] = {
|
||
idcategoria: id,
|
||
categoria: name,
|
||
total_entradas: 0,
|
||
total_saidas: 0,
|
||
total: 0,
|
||
total_transacoes: 0
|
||
};
|
||
}
|
||
|
||
const value = Math.abs(Number(cat.total) || 0);
|
||
if (cat.tipoOperacao === 'C') {
|
||
aggregatedSummary[id].total_entradas += value;
|
||
} else {
|
||
aggregatedSummary[id].total_saidas += value;
|
||
}
|
||
aggregatedSummary[id].total += value;
|
||
aggregatedSummary[id].total_transacoes += Number(cat.total_transacoes || 0);
|
||
});
|
||
|
||
setSomaCategoria({
|
||
por_categoria: Object.values(aggregatedSummary).sort((a, b) => b.total - a.total)
|
||
});
|
||
|
||
// 3. Process Caixinhas
|
||
const caixinhasMap = {};
|
||
if (Array.isArray(caixinhasList)) {
|
||
caixinhasList.forEach(c => {
|
||
caixinhasMap[String(c.idcaixinhas_financeiro || c.id)] = c.caixinha || c.name;
|
||
});
|
||
}
|
||
|
||
const groupedCaixinha = {};
|
||
filteredExtrato.forEach(item => {
|
||
const id = String(item.caixinha || '0');
|
||
if (!groupedCaixinha[id]) {
|
||
groupedCaixinha[id] = {
|
||
id: id,
|
||
caixinha: caixinhasMap[id] || (id === '0' ? '(sem caixinha)' : `Caixinha ${id}`),
|
||
total_entradas: 0,
|
||
total_saidas: 0,
|
||
total: 0,
|
||
diferenca: 0
|
||
};
|
||
}
|
||
|
||
const value = Math.abs(Number(item.valor) || 0);
|
||
if (item.tipoOperacao === 'C') {
|
||
groupedCaixinha[id].total_entradas += value;
|
||
} else {
|
||
groupedCaixinha[id].total_saidas += value;
|
||
}
|
||
groupedCaixinha[id].total = groupedCaixinha[id].total_entradas + groupedCaixinha[id].total_saidas;
|
||
groupedCaixinha[id].diferenca = groupedCaixinha[id].total_entradas - groupedCaixinha[id].total_saidas;
|
||
});
|
||
|
||
setPorCaixinha(Object.values(groupedCaixinha).sort((a, b) => b.total - a.total));
|
||
|
||
setSaldo(saldoData || { disponivel: 0 });
|
||
setSaldoArmazenado(Array.isArray(armazenadoData) ? armazenadoData : []);
|
||
|
||
const filteredFluxo = {
|
||
mensal: (fluxoResponse?.mensal || []).filter(item => {
|
||
if (tipo === 'ano') return String(item.ano) === anoAtu;
|
||
return true;
|
||
}),
|
||
diario: (fluxoResponse?.diario || []).filter(item => {
|
||
if (tipo === 'mes') return String(item.mes) === mesAtu && String(item.ano) === anoAtu;
|
||
return true;
|
||
}),
|
||
anual: fluxoResponse?.anual || []
|
||
};
|
||
setFluxoData(filteredFluxo);
|
||
setSaldoConsolidado(consolidadoData || null);
|
||
} catch (err) {
|
||
console.error('[useFluxoCaixa] Erro ao carregar dados:', err);
|
||
setError(err.message || 'Erro ao carregar fluxo de caixa');
|
||
setExtrato([]);
|
||
setSaldo({ disponivel: 0 });
|
||
setSaldoArmazenado([]);
|
||
setFluxoData({ mensal: [] });
|
||
setSomaCategoria({ por_categoria: [] });
|
||
setPorCaixinha([]);
|
||
setSaldoConsolidado(null);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [filtroMes, filtroAno, filtroTipo]);
|
||
|
||
useEffect(() => {
|
||
loadData();
|
||
}, [loadData]);
|
||
|
||
const receitas = useMemo(() => {
|
||
return extrato
|
||
.filter((item) => item.tipoOperacao === 'C')
|
||
.reduce((acc, item) => acc + Math.abs(Number(item.valor) || 0), 0);
|
||
}, [extrato]);
|
||
|
||
const despesas = useMemo(() => {
|
||
return extrato
|
||
.filter((item) => item.tipoOperacao === 'D')
|
||
.reduce((acc, item) => acc + Math.abs(Number(item.valor) || 0), 0);
|
||
}, [extrato]);
|
||
|
||
const ultimoSaldo = useMemo(
|
||
() => getUltimoSaldoArmazenado(saldoArmazenado),
|
||
[saldoArmazenado]
|
||
);
|
||
|
||
const resultadoCruzamento = useMemo(() => {
|
||
return ultimoSaldo + receitas - despesas;
|
||
}, [ultimoSaldo, receitas, despesas]);
|
||
|
||
/** Card Receitas: total receitas (extrato C) + último saldo do mês passado. Reflete "entradas" para o saldo atual. */
|
||
const receitasCard = useMemo(() => ultimoSaldo + receitas, [ultimoSaldo, receitas]);
|
||
|
||
const saldoDisponivel = useMemo(() => {
|
||
const d = saldo?.disponivel;
|
||
return typeof d === 'number' ? d : Number(d) || 0;
|
||
}, [saldo]);
|
||
|
||
const bateComSaldo = useMemo(() => {
|
||
const diff = Math.abs(resultadoCruzamento - saldoDisponivel);
|
||
return diff < 0.01;
|
||
}, [resultadoCruzamento, saldoDisponivel]);
|
||
|
||
/**
|
||
* Transforma dados de fluxo para o formato Recharts
|
||
* Processa tanto mensal quanto anual se disponível
|
||
*/
|
||
const getChartData = useCallback((type = 'mensal') => {
|
||
const data = fluxoData[type] || [];
|
||
|
||
// Agrupa por período (mês, ano ou dia) e tipo de operação
|
||
const periodGroups = data.reduce((acc, item) => {
|
||
let key = '';
|
||
let label = '';
|
||
|
||
if (type === 'diario') {
|
||
key = `${item.ano}-${String(item.mes).padStart(2, '0')}-${String(item.dia).padStart(2, '0')}`;
|
||
label = `${String(item.dia).padStart(2, '0')}/${String(item.mes).padStart(2, '0')}`;
|
||
} else if (type === 'mensal') {
|
||
key = `${item.ano}-${String(item.mes).padStart(2, '0')}`;
|
||
label = `${item.mes}/${item.ano}`;
|
||
} else {
|
||
key = `${item.ano}`;
|
||
label = `${item.ano}`;
|
||
}
|
||
|
||
if (!acc[key]) {
|
||
acc[key] = {
|
||
name: label,
|
||
periodo: key,
|
||
receitas: 0,
|
||
despesas: 0
|
||
};
|
||
}
|
||
|
||
const valor = Math.abs(Number(item.total) || 0);
|
||
if (item.tipoOperacao === 'C') acc[key].receitas += valor;
|
||
if (item.tipoOperacao === 'D') acc[key].despesas += valor;
|
||
|
||
return acc;
|
||
}, {});
|
||
|
||
return Object.values(periodGroups).sort((a, b) => a.periodo.localeCompare(b.periodo));
|
||
}, [fluxoData]);
|
||
|
||
return {
|
||
loading,
|
||
error,
|
||
extrato,
|
||
receitas,
|
||
receitasCard,
|
||
despesas,
|
||
ultimoSaldo,
|
||
resultadoCruzamento,
|
||
saldoDisponivel,
|
||
bateComSaldo,
|
||
getChartData,
|
||
somaCategoria,
|
||
porCaixinha,
|
||
saldoConsolidado,
|
||
filtroMes,
|
||
setFiltroMes,
|
||
filtroAno,
|
||
setFiltroAno,
|
||
filtroTipo,
|
||
setFiltroTipo,
|
||
reload: loadData
|
||
};
|
||
}
|