adicionado extrato e simulação

This commit is contained in:
Diego Freitas 2026-06-02 22:28:10 -03:00
parent 66287cff29
commit 759e642567
26 changed files with 3317 additions and 163 deletions

View File

@ -13,6 +13,7 @@ const movimentosFixosRoutes = require('./modules/movimentosFixos/routes/moviment
const usuariosRoutes = require('./modules/usuarios/routes/usuarios.routes'); const usuariosRoutes = require('./modules/usuarios/routes/usuarios.routes');
const quitacoesCreditoRoutes = require('./modules/quitacoesCredito/routes/quitacoesCredito.routes'); const quitacoesCreditoRoutes = require('./modules/quitacoesCredito/routes/quitacoesCredito.routes');
const dashboardRoutes = require('./modules/dashboard/routes/dashboard.routes'); const dashboardRoutes = require('./modules/dashboard/routes/dashboard.routes');
const simulacaoMensalRoutes = require('./modules/simulacaoMensal/routes/simulacaoMensal.routes');
const app = express(); const app = express();
@ -48,6 +49,7 @@ app.use('/api/movimentos-fixos', movimentosFixosRoutes);
app.use('/api/usuarios', usuariosRoutes); app.use('/api/usuarios', usuariosRoutes);
app.use('/api/quitacoes-credito', quitacoesCreditoRoutes); app.use('/api/quitacoes-credito', quitacoesCreditoRoutes);
app.use('/api/dashboard', dashboardRoutes); app.use('/api/dashboard', dashboardRoutes);
app.use('/api/simulacao-mensal', simulacaoMensalRoutes);
app.use((req, res) => { app.use((req, res) => {
return res.status(404).json({ return res.status(404).json({

View File

@ -238,7 +238,18 @@ async function preverImpactoSaldo(req, res) {
}); });
} }
const impacto = await movimentosService.preverImpactoSaldo(req.body, req.body.idcontasapagar); const idUsuario =
req.usuario?.idusuarios ||
req.user?.idusuarios ||
req.usuario?.id ||
req.user?.id ||
null;
const impacto = await movimentosService.preverImpactoSaldo(
req.body,
req.body.idcontasapagar,
idUsuario
);
return res.json({ return res.json({
ok: true, ok: true,

View File

@ -791,6 +791,7 @@ async function criarMovimento(idUsuario, dados) {
tipo_operacao: 'movimento_aplicado', tipo_operacao: 'movimento_aplicado',
origem: movimentoCriado.origem || 'movimento', origem: movimentoCriado.origem || 'movimento',
idusuarios: idUsuario, idusuarios: idUsuario,
permitirBancoDestinoGlobal: movimentoCriado.movimento === 'Sangria',
} }
); );
@ -882,6 +883,7 @@ async function atualizarMovimento(id, idUsuario, dados) {
tipo_operacao: 'movimento_revertido', tipo_operacao: 'movimento_revertido',
origem: 'edicao_movimento', origem: 'edicao_movimento',
idusuarios: idUsuario, idusuarios: idUsuario,
permitirBancoDestinoGlobal: movimentoAntigo.movimento === 'Sangria',
} }
); );
@ -902,6 +904,7 @@ async function atualizarMovimento(id, idUsuario, dados) {
tipo_operacao: 'movimento_aplicado', tipo_operacao: 'movimento_aplicado',
origem: 'edicao_movimento', origem: 'edicao_movimento',
idusuarios: idUsuario, idusuarios: idUsuario,
permitirBancoDestinoGlobal: movimentoNovo.movimento === 'Sangria',
} }
); );
@ -977,6 +980,7 @@ async function deletarMovimento(id, idUsuario) {
tipo_operacao: 'movimento_revertido', tipo_operacao: 'movimento_revertido',
origem: 'exclusao_movimento', origem: 'exclusao_movimento',
idusuarios: idUsuario, idusuarios: idUsuario,
permitirBancoDestinoGlobal: movimentoAntigo.movimento === 'Sangria',
} }
); );
@ -1017,20 +1021,28 @@ async function deletarMovimento(id, idUsuario) {
} }
} }
async function preverImpactoSaldo(dados, idcontasapagar = null) { async function preverImpactoSaldo(dados, idcontasapagar = null, idUsuario = null) {
let movimentoAntigo = null; let movimentoAntigo = null;
if (idcontasapagar) { if (idcontasapagar) {
movimentoAntigo = await buscarMovimentoPorId(idcontasapagar); movimentoAntigo = await buscarMovimentoPorId(idcontasapagar, {
idUsuario,
});
} }
const movimentoNovo = normalizarMovimentoParaSalvar(null, dados, { const movimentoNovo = normalizarMovimentoParaSalvar(idUsuario, dados, {
idusuarios_cad: dados.idusuarios_cad || null, idusuarios_cad: dados.idusuarios_cad || idUsuario || null,
grupo_parcelamento: dados.grupo_parcelamento || null, grupo_parcelamento: dados.grupo_parcelamento || null,
origem: dados.origem || 'manual', origem: dados.origem || 'manual',
}); });
return movimentosSaldoService.preverImpactoSaldo(movimentoNovo, movimentoAntigo); return movimentosSaldoService.preverImpactoSaldo(
movimentoNovo,
movimentoAntigo,
{
idusuarios: idUsuario || null,
}
);
} }
module.exports = { module.exports = {

View File

@ -12,6 +12,70 @@ function bancoEhCredito(banco) {
return Number(banco?.debito) === 0; return Number(banco?.debito) === 0;
} }
function calcularPercentualDelta(saldoAnterior, valorDelta) {
const anterior = Number(saldoAnterior || 0);
const delta = Number(valorDelta || 0);
if (anterior === 0) {
if (delta === 0) return 0;
return null;
}
return Number(((delta / Math.abs(anterior)) * 100).toFixed(2));
}
function resolverDirecao(valorDelta) {
const delta = Number(valorDelta || 0);
if (delta > 0) return 'alta';
if (delta < 0) return 'queda';
return 'neutro';
}
function resolverTipoVisual(valorDelta) {
const delta = Number(valorDelta || 0);
if (delta > 0) return 'positivo';
if (delta < 0) return 'negativo';
return 'neutro';
}
function montarDescricaoBanco(banco) {
if (!banco) return null;
const usuarioNome = banco.usuario_nome || 'Usuário sem nome';
const bancoNome = banco.banco_nome || banco.descricao || `Banco ${banco.idbancos}`;
return `${usuarioNome} - ${bancoNome}`;
}
function montarImpactoSaldo(delta, banco) {
const saldoAnterior = Number(banco?.saldo || 0);
const valorDelta = Number(delta.valor_delta || 0);
const saldoPosterior = saldoAnterior + valorDelta;
const percentualDelta = calcularPercentualDelta(saldoAnterior, valorDelta);
return {
idbancos: Number(delta.idbancos),
banco_descricao: montarDescricaoBanco(banco) || `Banco ${delta.idbancos}`,
banco_nome: banco?.banco_nome || banco?.descricao || null,
usuario_nome: banco?.usuario_nome || null,
valor_delta: valorDelta,
saldo_anterior: saldoAnterior,
saldo_posterior: saldoPosterior,
percentual_delta: percentualDelta,
direcao: resolverDirecao(valorDelta),
tipo_visual: resolverTipoVisual(valorDelta),
descricao: delta.descricao || null,
};
}
async function buscarBanco(connection, idbancos, idusuarios = null) { async function buscarBanco(connection, idbancos, idusuarios = null) {
if (!idbancos) return null; if (!idbancos) return null;
@ -19,22 +83,27 @@ async function buscarBanco(connection, idbancos, idusuarios = null) {
let filtroUsuario = ''; let filtroUsuario = '';
if (idusuarios) { if (idusuarios) {
filtroUsuario = 'AND idusuarios = ?'; filtroUsuario = 'AND b.idusuarios = ?';
params.push(idusuarios); params.push(idusuarios);
} }
const [rows] = await connection.query( const [rows] = await connection.query(
` `
SELECT SELECT
idbancos, b.idbancos,
descricao, b.descricao,
saldo, b.descricao AS banco_nome,
debito, b.saldo,
investimento, b.debito,
idusuarios b.investimento,
FROM bancos b.idusuarios,
WHERE idbancos = ? u.nome AS usuario_nome
${filtroUsuario} FROM bancos b
LEFT JOIN usuarios u
ON u.idusuarios = b.idusuarios
WHERE b.idbancos = ?
AND b.habilitado = 1
${filtroUsuario}
LIMIT 1 LIMIT 1
`, `,
params params
@ -50,22 +119,27 @@ async function buscarBancoParaUpdate(connection, idbancos, idusuarios = null) {
let filtroUsuario = ''; let filtroUsuario = '';
if (idusuarios) { if (idusuarios) {
filtroUsuario = 'AND idusuarios = ?'; filtroUsuario = 'AND b.idusuarios = ?';
params.push(idusuarios); params.push(idusuarios);
} }
const [rows] = await connection.query( const [rows] = await connection.query(
` `
SELECT SELECT
idbancos, b.idbancos,
descricao, b.descricao,
saldo, b.descricao AS banco_nome,
debito, b.saldo,
investimento, b.debito,
idusuarios b.investimento,
FROM bancos b.idusuarios,
WHERE idbancos = ? u.nome AS usuario_nome
${filtroUsuario} FROM bancos b
LEFT JOIN usuarios u
ON u.idusuarios = b.idusuarios
WHERE b.idbancos = ?
AND b.habilitado = 1
${filtroUsuario}
LIMIT 1 LIMIT 1
FOR UPDATE FOR UPDATE
`, `,
@ -168,8 +242,27 @@ function calcularDeltasParaMovimento(movimento, bancoOrigem, bancoDestino = null
} }
async function calcularImpactoMovimento(connection, movimento, contexto = {}) { async function calcularImpactoMovimento(connection, movimento, contexto = {}) {
const bancoOrigem = await buscarBanco(connection, movimento.idbancos, contexto.idusuarios); const bancoOrigem = await buscarBanco(
const bancoDestino = await buscarBanco(connection, movimento.idbancos_p, contexto.idusuarios); connection,
movimento.idbancos,
contexto.idusuarios || null
);
let bancoDestino = null;
if (movimento.movimento === 'Sangria') {
bancoDestino = await buscarBanco(
connection,
movimento.idbancos_p,
null
);
} else {
bancoDestino = await buscarBanco(
connection,
movimento.idbancos_p,
contexto.idusuarios || null
);
}
return calcularDeltasParaMovimento(movimento, bancoOrigem, bancoDestino); return calcularDeltasParaMovimento(movimento, bancoOrigem, bancoDestino);
} }
@ -186,15 +279,26 @@ async function aplicarDeltasSaldo(connection, deltas, contexto = {}) {
const impactos = []; const impactos = [];
for (const delta of deltas) { for (const delta of deltas) {
const banco = await buscarBancoParaUpdate(connection, delta.idbancos, contexto.idusuarios); const filtrarPorUsuario =
contexto.validarUsuario === false
? null
: contexto.idusuarios || null;
let banco = await buscarBancoParaUpdate(
connection,
delta.idbancos,
filtrarPorUsuario
);
if (!banco && contexto.permitirBancoDestinoGlobal === true) {
banco = await buscarBancoParaUpdate(connection, delta.idbancos, null);
}
if (!banco) { if (!banco) {
continue; continue;
} }
const saldoAnterior = Number(banco.saldo || 0); const impacto = montarImpactoSaldo(delta, banco);
const valorDelta = Number(delta.valor_delta || 0);
const saldoPosterior = saldoAnterior + valorDelta;
await connection.query( await connection.query(
` `
@ -205,7 +309,7 @@ async function aplicarDeltasSaldo(connection, deltas, contexto = {}) {
WHERE idbancos = ? WHERE idbancos = ?
`, `,
[ [
saldoPosterior, impacto.saldo_posterior,
banco.idbancos, banco.idbancos,
] ]
); );
@ -232,42 +336,65 @@ async function aplicarDeltasSaldo(connection, deltas, contexto = {}) {
contexto.idcontasquitadas || null, contexto.idcontasquitadas || null,
contexto.tipo_operacao || null, contexto.tipo_operacao || null,
contexto.origem || null, contexto.origem || null,
valorDelta, impacto.valor_delta,
saldoAnterior, impacto.saldo_anterior,
saldoPosterior, impacto.saldo_posterior,
delta.descricao || null, delta.descricao || null,
contexto.idusuarios || null, contexto.idusuarios || null,
] ]
); );
impactos.push({ impactos.push(impacto);
idbancos: banco.idbancos,
banco_descricao: banco.descricao,
valor_delta: valorDelta,
saldo_anterior: saldoAnterior,
saldo_posterior: saldoPosterior,
descricao: delta.descricao || null,
});
} }
return impactos; return impactos;
} }
async function preverImpactoSaldo(dados, movimentoAntigo = null) { async function enriquecerDeltasComoPrevisao(connection, deltas) {
const impactos = [];
for (const delta of deltas) {
const banco = await buscarBanco(connection, delta.idbancos, null);
if (!banco) {
continue;
}
impactos.push(montarImpactoSaldo(delta, banco));
}
return impactos;
}
async function preverImpactoSaldo(dados, movimentoAntigo = null, contexto = {}) {
const connection = await pool.getConnection(); const connection = await pool.getConnection();
try { try {
const deltas = []; const deltas = [];
if (movimentoAntigo) { if (movimentoAntigo) {
const deltasAntigos = await calcularImpactoMovimento(connection, movimentoAntigo); const deltasAntigos = await calcularImpactoMovimento(
connection,
movimentoAntigo,
{
idusuarios: contexto.idusuarios || null,
}
);
deltas.push(...inverterDeltas(deltasAntigos)); deltas.push(...inverterDeltas(deltasAntigos));
} }
const deltasNovos = await calcularImpactoMovimento(connection, dados); const deltasNovos = await calcularImpactoMovimento(
connection,
dados,
{
idusuarios: contexto.idusuarios || null,
}
);
deltas.push(...deltasNovos); deltas.push(...deltasNovos);
return deltas; return enriquecerDeltasComoPrevisao(connection, deltas);
} finally { } finally {
connection.release(); connection.release();
} }

View File

@ -1,5 +1,15 @@
const movimentosFixosService = require('../services/movimentosFixos.service'); const movimentosFixosService = require('../services/movimentosFixos.service');
function extrairIdUsuarioLogado(req) {
return (
req.usuario?.idusuarios ||
req.user?.idusuarios ||
req.usuario?.id ||
req.user?.id ||
null
);
}
function validarDadosMovimentoFixo(dados) { function validarDadosMovimentoFixo(dados) {
if (!dados.movimento) { if (!dados.movimento) {
return 'Movimento é obrigatório.'; return 'Movimento é obrigatório.';
@ -43,6 +53,14 @@ function validarDadosMovimentoFixo(dados) {
} }
} }
if (dados.idclientes !== undefined && dados.idclientes !== null && dados.idclientes !== '') {
const idclientes = Number(dados.idclientes);
if (!Number.isInteger(idclientes) || idclientes <= 0) {
return 'Cliente inválido.';
}
}
if (dados.idbancos !== undefined && dados.idbancos !== null && dados.idbancos !== '') { if (dados.idbancos !== undefined && dados.idbancos !== null && dados.idbancos !== '') {
const idbancos = Number(dados.idbancos); const idbancos = Number(dados.idbancos);
@ -64,6 +82,8 @@ function validarDadosMovimentoFixo(dados) {
async function listar(req, res) { async function listar(req, res) {
try { try {
const idUsuario = extrairIdUsuarioLogado(req);
const resultado = await movimentosFixosService.listarMovimentosFixos({ const resultado = await movimentosFixosService.listarMovimentosFixos({
limite: req.query.limite, limite: req.query.limite,
offset: req.query.offset, offset: req.query.offset,
@ -72,11 +92,13 @@ async function listar(req, res) {
movimento: req.query.movimento, movimento: req.query.movimento,
idbancos: req.query.idbancos, idbancos: req.query.idbancos,
idcentrodecustos: req.query.idcentrodecustos, idcentrodecustos: req.query.idcentrodecustos,
idclientes: req.query.idclientes,
habilitado: req.query.habilitado, habilitado: req.query.habilitado,
diaInicio: req.query.diaInicio, diaInicio: req.query.diaInicio,
diaFim: req.query.diaFim, diaFim: req.query.diaFim,
orderBy: req.query.orderBy, orderBy: req.query.orderBy,
orderDirection: req.query.orderDirection, orderDirection: req.query.orderDirection,
idUsuario,
}); });
return res.json({ return res.json({
@ -97,6 +119,8 @@ async function listar(req, res) {
async function buscarPorId(req, res) { async function buscarPorId(req, res) {
try { try {
const idUsuario = extrairIdUsuarioLogado(req);
const id = Number(req.params.id); const id = Number(req.params.id);
if (!id) { if (!id) {
@ -106,7 +130,7 @@ async function buscarPorId(req, res) {
}); });
} }
const movimentoFixo = await movimentosFixosService.buscarMovimentoFixoPorId(id); const movimentoFixo = await movimentosFixosService.buscarMovimentoFixoPorId(id, idUsuario);
if (!movimentoFixo) { if (!movimentoFixo) {
return res.status(404).json({ return res.status(404).json({
@ -131,6 +155,8 @@ async function buscarPorId(req, res) {
async function criar(req, res) { async function criar(req, res) {
try { try {
const idUsuario = extrairIdUsuarioLogado(req);
const erroValidacao = validarDadosMovimentoFixo(req.body); const erroValidacao = validarDadosMovimentoFixo(req.body);
if (erroValidacao) { if (erroValidacao) {
@ -140,7 +166,7 @@ async function criar(req, res) {
}); });
} }
const novoMovimentoFixo = await movimentosFixosService.criarMovimentoFixo(req.body); const novoMovimentoFixo = await movimentosFixosService.criarMovimentoFixo(req.body, idUsuario);
return res.status(201).json({ return res.status(201).json({
ok: true, ok: true,
@ -159,6 +185,8 @@ async function criar(req, res) {
async function atualizar(req, res) { async function atualizar(req, res) {
try { try {
const idUsuario = extrairIdUsuarioLogado(req);
const id = Number(req.params.id); const id = Number(req.params.id);
if (!id) { if (!id) {
@ -177,7 +205,7 @@ async function atualizar(req, res) {
}); });
} }
const movimentoAtualizado = await movimentosFixosService.atualizarMovimentoFixo(id, req.body); const movimentoAtualizado = await movimentosFixosService.atualizarMovimentoFixo(id, req.body, idUsuario);
if (!movimentoAtualizado) { if (!movimentoAtualizado) {
return res.status(404).json({ return res.status(404).json({
@ -203,6 +231,8 @@ async function atualizar(req, res) {
async function alterarHabilitado(req, res) { async function alterarHabilitado(req, res) {
try { try {
const idUsuario = extrairIdUsuarioLogado(req);
const id = Number(req.params.id); const id = Number(req.params.id);
if (!id) { if (!id) {
@ -222,7 +252,7 @@ async function alterarHabilitado(req, res) {
} }
const movimentoAtualizado = const movimentoAtualizado =
await movimentosFixosService.alterarHabilitadoMovimentoFixo(id, habilitado); await movimentosFixosService.alterarHabilitadoMovimentoFixo(id, habilitado, idUsuario);
if (!movimentoAtualizado) { if (!movimentoAtualizado) {
return res.status(404).json({ return res.status(404).json({
@ -250,6 +280,8 @@ async function alterarHabilitado(req, res) {
async function deletar(req, res) { async function deletar(req, res) {
try { try {
const idUsuario = extrairIdUsuarioLogado(req);
const id = Number(req.params.id); const id = Number(req.params.id);
if (!id) { if (!id) {
@ -259,7 +291,7 @@ async function deletar(req, res) {
}); });
} }
const deletado = await movimentosFixosService.deletarMovimentoFixo(id); const deletado = await movimentosFixosService.deletarMovimentoFixo(id, idUsuario);
if (!deletado) { if (!deletado) {
return res.status(404).json({ return res.status(404).json({

View File

@ -7,18 +7,21 @@ const CAMPOS_MOVIMENTO_FIXO_SELECT = `
mf.valor, mf.valor,
mf.dia_vencimento, mf.dia_vencimento,
mf.idcentrodecustos, mf.idcentrodecustos,
mf.idclientes,
mf.habilitado, mf.habilitado,
mf.idbancos, mf.idbancos,
mf.insert_date, mf.insert_date,
mf.update_date, mf.update_date,
b.descricao AS banco_descricao, b.descricao AS banco_descricao,
cc.descricao AS centro_custo_descricao cc.descricao AS centro_custo_descricao,
c.nome AS cliente_nome
`; `;
const FROM_MOVIMENTO_FIXO_JOIN = ` const FROM_MOVIMENTO_FIXO_JOIN = `
FROM movimentosfixos mf FROM movimentosfixos mf
LEFT JOIN bancos b ON b.idbancos = mf.idbancos INNER JOIN bancos b ON b.idbancos = mf.idbancos
LEFT JOIN centrodecustos cc ON cc.idcentrodecustos = mf.idcentrodecustos LEFT JOIN centrodecustos cc ON cc.idcentrodecustos = mf.idcentrodecustos
LEFT JOIN clientes c ON c.idclientes = mf.idclientes
`; `;
function normalizarTextoOuNull(valor) { function normalizarTextoOuNull(valor) {
@ -91,12 +94,14 @@ function resolverOrdenacao(orderBy) {
valor: 'mf.valor', valor: 'mf.valor',
dia_vencimento: 'mf.dia_vencimento', dia_vencimento: 'mf.dia_vencimento',
idcentrodecustos: 'mf.idcentrodecustos', idcentrodecustos: 'mf.idcentrodecustos',
idclientes: 'mf.idclientes',
idbancos: 'mf.idbancos', idbancos: 'mf.idbancos',
habilitado: 'mf.habilitado', habilitado: 'mf.habilitado',
insert_date: 'mf.insert_date', insert_date: 'mf.insert_date',
update_date: 'mf.update_date', update_date: 'mf.update_date',
banco_descricao: 'b.descricao', banco_descricao: 'b.descricao',
centro_custo_descricao: 'cc.descricao', centro_custo_descricao: 'cc.descricao',
cliente_nome: 'c.nome',
}; };
return camposPermitidos[orderBy] || 'mf.dia_vencimento'; return camposPermitidos[orderBy] || 'mf.dia_vencimento';
@ -110,6 +115,11 @@ function montarWhereMovimentosFixos(filtros = {}) {
const where = []; const where = [];
const params = []; const params = [];
if (filtros.idUsuario) {
where.push('b.idusuarios = ?');
params.push(Number(filtros.idUsuario));
}
if (filtros.movimento) { if (filtros.movimento) {
where.push('mf.movimento = ?'); where.push('mf.movimento = ?');
params.push(filtros.movimento); params.push(filtros.movimento);
@ -125,6 +135,11 @@ function montarWhereMovimentosFixos(filtros = {}) {
params.push(Number(filtros.idcentrodecustos)); params.push(Number(filtros.idcentrodecustos));
} }
if (filtros.idclientes) {
where.push('mf.idclientes = ?');
params.push(Number(filtros.idclientes));
}
if (filtros.habilitado !== undefined && filtros.habilitado !== null && filtros.habilitado !== '') { if (filtros.habilitado !== undefined && filtros.habilitado !== null && filtros.habilitado !== '') {
where.push('mf.habilitado = ?'); where.push('mf.habilitado = ?');
params.push(Number(filtros.habilitado)); params.push(Number(filtros.habilitado));
@ -147,6 +162,7 @@ function montarWhereMovimentosFixos(filtros = {}) {
OR mf.movimento LIKE ? OR mf.movimento LIKE ?
OR b.descricao LIKE ? OR b.descricao LIKE ?
OR cc.descricao LIKE ? OR cc.descricao LIKE ?
OR c.nome LIKE ?
) )
`); `);
@ -156,6 +172,7 @@ function montarWhereMovimentosFixos(filtros = {}) {
termo, termo,
termo, termo,
termo, termo,
termo,
termo termo
); );
} }
@ -168,6 +185,24 @@ function montarWhereMovimentosFixos(filtros = {}) {
}; };
} }
async function bancoPertenceAoUsuario(idbancos, idUsuario) {
if (!idbancos || !idUsuario) return false;
const [rows] = await pool.query(
`
SELECT idbancos
FROM bancos
WHERE idbancos = ?
AND idusuarios = ?
AND habilitado = 1
LIMIT 1
`,
[Number(idbancos), Number(idUsuario)]
);
return rows.length > 0;
}
async function listarMovimentosFixos(filtros = {}) { async function listarMovimentosFixos(filtros = {}) {
const limite = limitarNumero(filtros.limite, 20, 1, 100); const limite = limitarNumero(filtros.limite, 20, 1, 100);
const page = limitarNumero(filtros.page, 1, 1, 999999); const page = limitarNumero(filtros.page, 1, 1, 999999);
@ -243,32 +278,58 @@ async function listarMovimentosFixos(filtros = {}) {
}; };
} }
async function buscarMovimentoFixoPorId(id) { async function buscarMovimentoFixoPorId(id, idUsuario = null) {
const params = [id];
let filtroUsuario = '';
if (idUsuario) {
filtroUsuario = 'AND b.idusuarios = ?';
params.push(Number(idUsuario));
}
const [rows] = await pool.query( const [rows] = await pool.query(
` `
SELECT SELECT
${CAMPOS_MOVIMENTO_FIXO_SELECT} ${CAMPOS_MOVIMENTO_FIXO_SELECT}
${FROM_MOVIMENTO_FIXO_JOIN} ${FROM_MOVIMENTO_FIXO_JOIN}
WHERE mf.idmovimentosfixos = ? WHERE mf.idmovimentosfixos = ?
${filtroUsuario}
LIMIT 1 LIMIT 1
`, `,
[id] params
); );
return rows[0] || null; return rows[0] || null;
} }
async function criarMovimentoFixo(dados) { async function criarMovimentoFixo(dados, idUsuario) {
const { const {
movimento, movimento,
descricao, descricao,
valor, valor,
dia_vencimento, dia_vencimento,
idcentrodecustos, idcentrodecustos,
idclientes,
habilitado, habilitado,
idbancos, idbancos,
} = dados; } = dados;
const idBancoNormalizado = normalizarNumeroOuNull(idbancos);
if (!idBancoNormalizado) {
const error = new Error('Banco/carteira é obrigatório para movimento fixo.');
error.statusCode = 400;
throw error;
}
const bancoValido = await bancoPertenceAoUsuario(idBancoNormalizado, idUsuario);
if (!bancoValido) {
const error = new Error('Banco/carteira não pertence ao usuário logado.');
error.statusCode = 403;
throw error;
}
const [result] = await pool.query( const [result] = await pool.query(
` `
INSERT INTO movimentosfixos ( INSERT INTO movimentosfixos (
@ -277,11 +338,12 @@ async function criarMovimentoFixo(dados) {
valor, valor,
dia_vencimento, dia_vencimento,
idcentrodecustos, idcentrodecustos,
idclientes,
habilitado, habilitado,
idbancos, idbancos,
insert_date, insert_date,
update_date update_date
) VALUES (?, ?, ?, ?, ?, ?, ?, NOW(), NOW()) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())
`, `,
[ [
normalizarTextoOuNull(movimento), normalizarTextoOuNull(movimento),
@ -289,16 +351,17 @@ async function criarMovimentoFixo(dados) {
normalizarNumero(valor, 0), normalizarNumero(valor, 0),
normalizarNumero(dia_vencimento, 1), normalizarNumero(dia_vencimento, 1),
normalizarNumeroOuNull(idcentrodecustos), normalizarNumeroOuNull(idcentrodecustos),
normalizarNumeroOuNull(idclientes),
normalizarFlag(habilitado, 1), normalizarFlag(habilitado, 1),
normalizarNumeroOuNull(idbancos), idBancoNormalizado,
] ]
); );
return buscarMovimentoFixoPorId(result.insertId); return buscarMovimentoFixoPorId(result.insertId, idUsuario);
} }
async function atualizarMovimentoFixo(id, dados) { async function atualizarMovimentoFixo(id, dados, idUsuario) {
const movimentoAtual = await buscarMovimentoFixoPorId(id); const movimentoAtual = await buscarMovimentoFixoPorId(id, idUsuario);
if (!movimentoAtual) { if (!movimentoAtual) {
return null; return null;
@ -310,10 +373,27 @@ async function atualizarMovimentoFixo(id, dados) {
valor, valor,
dia_vencimento, dia_vencimento,
idcentrodecustos, idcentrodecustos,
idclientes,
habilitado, habilitado,
idbancos, idbancos,
} = dados; } = dados;
const idBancoNormalizado = normalizarNumeroOuNull(idbancos);
if (!idBancoNormalizado) {
const error = new Error('Banco/carteira é obrigatório para movimento fixo.');
error.statusCode = 400;
throw error;
}
const bancoValido = await bancoPertenceAoUsuario(idBancoNormalizado, idUsuario);
if (!bancoValido) {
const error = new Error('Banco/carteira não pertence ao usuário logado.');
error.statusCode = 403;
throw error;
}
await pool.query( await pool.query(
` `
UPDATE movimentosfixos UPDATE movimentosfixos
@ -323,6 +403,7 @@ async function atualizarMovimentoFixo(id, dados) {
valor = ?, valor = ?,
dia_vencimento = ?, dia_vencimento = ?,
idcentrodecustos = ?, idcentrodecustos = ?,
idclientes = ?,
habilitado = ?, habilitado = ?,
idbancos = ?, idbancos = ?,
update_date = NOW() update_date = NOW()
@ -334,17 +415,18 @@ async function atualizarMovimentoFixo(id, dados) {
normalizarNumero(valor, 0), normalizarNumero(valor, 0),
normalizarNumero(dia_vencimento, 1), normalizarNumero(dia_vencimento, 1),
normalizarNumeroOuNull(idcentrodecustos), normalizarNumeroOuNull(idcentrodecustos),
normalizarNumeroOuNull(idclientes),
normalizarFlag(habilitado, 1), normalizarFlag(habilitado, 1),
normalizarNumeroOuNull(idbancos), idBancoNormalizado,
id, id,
] ]
); );
return buscarMovimentoFixoPorId(id); return buscarMovimentoFixoPorId(id, idUsuario);
} }
async function alterarHabilitadoMovimentoFixo(id, habilitado) { async function alterarHabilitadoMovimentoFixo(id, habilitado, idUsuario) {
const movimentoAtual = await buscarMovimentoFixoPorId(id); const movimentoAtual = await buscarMovimentoFixoPorId(id, idUsuario);
if (!movimentoAtual) { if (!movimentoAtual) {
return null; return null;
@ -364,11 +446,11 @@ async function alterarHabilitadoMovimentoFixo(id, habilitado) {
] ]
); );
return buscarMovimentoFixoPorId(id); return buscarMovimentoFixoPorId(id, idUsuario);
} }
async function deletarMovimentoFixo(id) { async function deletarMovimentoFixo(id, idUsuario) {
const movimentoAtual = await buscarMovimentoFixoPorId(id); const movimentoAtual = await buscarMovimentoFixoPorId(id, idUsuario);
if (!movimentoAtual) { if (!movimentoAtual) {
return false; return false;

View File

@ -114,6 +114,7 @@ async function buscarFixosDoUsuario(connection, idUsuario) {
mf.valor, mf.valor,
mf.dia_vencimento, mf.dia_vencimento,
mf.idcentrodecustos, mf.idcentrodecustos,
mf.idclientes,
mf.idbancos mf.idbancos
FROM movimentosfixos mf FROM movimentosfixos mf
INNER JOIN bancos b ON b.idbancos = mf.idbancos INNER JOIN bancos b ON b.idbancos = mf.idbancos
@ -132,7 +133,7 @@ async function jaExisteMovimentoFixo(connection, fixo, competencia) {
const [rowsGeracao] = await connection.query( const [rowsGeracao] = await connection.query(
` `
SELECT idmovimentosfixosgeracoes, idcontasapagar, status SELECT idmovimentosfixosgeracoes, idcontasapagar, status
FROM movimentosfixosgeracoes FROM movimentosfixos_geracoes
WHERE idmovimentosfixos = ? WHERE idmovimentosfixos = ?
AND competencia = ? AND competencia = ?
LIMIT 1 LIMIT 1
@ -161,7 +162,7 @@ async function jaExisteMovimentoFixo(connection, fixo, competencia) {
if (rowsMovimentoVinculado.length > 0) { if (rowsMovimentoVinculado.length > 0) {
await connection.query( await connection.query(
` `
INSERT INTO movimentosfixosgeracoes ( INSERT INTO movimentosfixos_geracoes (
idmovimentosfixos, idmovimentosfixos,
competencia, competencia,
idcontasapagar, idcontasapagar,
@ -230,7 +231,7 @@ async function jaExisteMovimentoFixo(connection, fixo, competencia) {
await connection.query( await connection.query(
` `
INSERT INTO movimentosfixosgeracoes ( INSERT INTO movimentosfixos_geracoes (
idmovimentosfixos, idmovimentosfixos,
competencia, competencia,
idcontasapagar, idcontasapagar,
@ -288,7 +289,7 @@ async function inserirMovimentoFixoGerado(connection, fixo, competencia, idUsuar
insert_date, insert_date,
update_date, update_date,
deleted_at deleted_at
) VALUES (?, ?, ?, ?, ?, ?, 1, 1, ?, ?, ?, ?, ?, NULL, NULL, NULL, ?, ?, NULL, 'movimento_fixo', 0, NULL, NOW(), NOW(), NULL) ) VALUES (?, ?, ?, ?, ?, ?, 1, 1, ?, ?, ?, ?, ?, ?, NULL, NULL, ?, ?, NULL, 'movimento_fixo', 0, NULL, NOW(), NOW(), NULL)
`, `,
[ [
fixo.movimento, fixo.movimento,
@ -302,6 +303,7 @@ async function inserirMovimentoFixoGerado(connection, fixo, competencia, idUsuar
fixo.idbancos || null, fixo.idbancos || null,
idUsuario, idUsuario,
dataBaixa ? idUsuario : null, dataBaixa ? idUsuario : null,
fixo.idclientes || null,
fixo.idmovimentosfixos, fixo.idmovimentosfixos,
competencia, competencia,
] ]

View File

@ -18,6 +18,24 @@ async function listarBancos(req, res) {
} }
} }
async function listarMeusBancos(req, res) {
try {
const bancos = await referenciasService.listarMeusBancos(req.usuario || req.user);
return res.json({
ok: true,
data: bancos,
});
} catch (error) {
console.error('Erro ao listar bancos do usuário:', error);
return res.status(error.statusCode || 500).json({
ok: false,
message: error.message || 'Erro ao listar bancos do usuário.',
});
}
}
async function listarCentrosCusto(req, res) { async function listarCentrosCusto(req, res) {
try { try {
const centrosCusto = await referenciasService.listarCentrosCusto(); const centrosCusto = await referenciasService.listarCentrosCusto();
@ -56,6 +74,7 @@ async function listarClientes(req, res) {
module.exports = { module.exports = {
listarBancos, listarBancos,
listarMeusBancos,
listarCentrosCusto, listarCentrosCusto,
listarClientes, listarClientes,
}; };

View File

@ -7,6 +7,8 @@ const router = express.Router();
router.use(authMiddleware); router.use(authMiddleware);
router.get('/bancos', referenciasController.listarBancos); router.get('/bancos', referenciasController.listarBancos);
router.get('/bancos/meus', referenciasController.listarMeusBancos);
router.get('/centros-custo', referenciasController.listarCentrosCusto); router.get('/centros-custo', referenciasController.listarCentrosCusto);
router.get('/clientes', referenciasController.listarClientes); router.get('/clientes', referenciasController.listarClientes);

View File

@ -1,5 +1,15 @@
const pool = require('../../../database/mysql'); const pool = require('../../../database/mysql');
function extrairIdUsuarioLogado(usuario) {
return (
usuario?.idusuarios ||
usuario?.idusuario ||
usuario?.id ||
usuario?.userId ||
null
);
}
async function listarBancos() { async function listarBancos() {
const [rows] = await pool.query( const [rows] = await pool.query(
` `
@ -23,14 +33,46 @@ async function listarBancos() {
ON u.idusuarios = b.idusuarios ON u.idusuarios = b.idusuarios
WHERE b.habilitado = 1 WHERE b.habilitado = 1
ORDER BY ORDER BY
b.descricao ASC, COALESCE(u.nome, 'Usuário sem nome') ASC,
u.nome ASC b.descricao ASC
` `
); );
return rows; return rows;
} }
async function listarMeusBancos(usuarioLogado) {
const idUsuario = extrairIdUsuarioLogado(usuarioLogado);
if (!idUsuario) {
const error = new Error('Usuário logado não identificado.');
error.statusCode = 401;
throw error;
}
const [rows] = await pool.query(
`
SELECT
b.idbancos AS id,
b.idbancos,
b.descricao AS banco_descricao,
b.descricao,
b.saldo,
b.debito,
b.investimento,
b.dia_vencimento
FROM bancos b
WHERE b.habilitado = 1
AND b.idusuarios = ?
ORDER BY
b.descricao ASC
`,
[idUsuario]
);
return rows;
}
async function listarCentrosCusto() { async function listarCentrosCusto() {
const [rows] = await pool.query( const [rows] = await pool.query(
` `
@ -72,6 +114,7 @@ async function listarClientes() {
module.exports = { module.exports = {
listarBancos, listarBancos,
listarMeusBancos,
listarCentrosCusto, listarCentrosCusto,
listarClientes, listarClientes,
}; };

View File

@ -0,0 +1,51 @@
const simulacaoMensalService = require('../services/simulacaoMensal.service');
function extrairIdUsuarioLogado(req) {
return (
req.usuario?.idusuarios ||
req.user?.idusuarios ||
req.usuario?.id ||
req.user?.id ||
null
);
}
function obterMesAnoPadrao() {
const hoje = new Date();
return {
ano: hoje.getFullYear(),
mes: hoje.getMonth() + 1,
};
}
async function buscarBase(req, res) {
try {
const idUsuario = extrairIdUsuarioLogado(req);
const padrao = obterMesAnoPadrao();
const resultado = await simulacaoMensalService.montarBaseSimulacaoMensal({
idUsuario,
ano: req.query.ano || padrao.ano,
mes: req.query.mes || padrao.mes,
incluirQuitadas: req.query.incluirQuitadas,
incluirFixosVencidosDoMesAtual: req.query.incluirFixosVencidosDoMesAtual,
});
return res.json({
ok: true,
data: resultado,
});
} catch (error) {
console.error('Erro ao montar simulação mensal:', error);
return res.status(error.statusCode || 500).json({
ok: false,
message: error.message || 'Erro ao montar simulação mensal.',
});
}
}
module.exports = {
buscarBase,
};

View File

@ -0,0 +1,11 @@
const express = require('express');
const authMiddleware = require('../../../middlewares/auth.middleware');
const simulacaoMensalController = require('../controllers/simulacaoMensal.controller');
const router = express.Router();
router.use(authMiddleware);
router.get('/base', simulacaoMensalController.buscarBase);
module.exports = router;

View File

@ -0,0 +1,790 @@
const pool = require('../../../database/mysql');
function hojeDataString() {
return new Date().toISOString().slice(0, 10);
}
function normalizarNumero(valor, padrao = 0) {
const numero = Number(valor);
if (!Number.isFinite(numero)) {
return padrao;
}
return numero;
}
function normalizarFlag(valor, padrao = 0) {
if (valor === undefined || valor === null || valor === '') {
return padrao;
}
return Number(valor) === 1 ? 1 : 0;
}
function pad2(valor) {
return String(valor).padStart(2, '0');
}
function ultimoDiaDoMes(ano, mes) {
return new Date(Number(ano), Number(mes), 0).getDate();
}
function montarCompetencia(ano, mes) {
return `${Number(ano)}-${pad2(Number(mes))}`;
}
function montarPeriodo(ano, mes) {
const competencia = montarCompetencia(ano, mes);
const ultimoDia = ultimoDiaDoMes(ano, mes);
return {
competencia,
dataInicio: `${competencia}-01`,
dataFim: `${competencia}-${pad2(ultimoDia)}`,
ultimoDia,
};
}
function montarDataVencimento(ano, mes, diaVencimento) {
const { competencia, ultimoDia } = montarPeriodo(ano, mes);
const dia = Math.min(Math.max(Number(diaVencimento || 1), 1), ultimoDia);
return `${competencia}-${pad2(dia)}`;
}
function movimentoEhEntrada(movimento) {
return movimento === 'Entrada' || movimento === 'Estorno';
}
function movimentoEhSaida(movimento) {
return movimento === 'Saida' || movimento === 'Sangria';
}
function statusEmAberto(status) {
return status === 'A pagar' || status === 'A receber';
}
function statusBaixado(status) {
return status === 'Pago' || status === 'Recebido';
}
function criarItemSimulacao({
tipo,
origem,
descricao,
valor,
movimento = null,
status = null,
data = null,
idcontasapagar = null,
idmovimentosfixos = null,
idbancos = null,
banco_descricao = null,
banco_debito = null,
idcentrodecustos = null,
centro_custo_descricao = null,
idclientes = null,
cliente_nome = null,
investimento = 0,
editavel = true,
detalhes = null,
}) {
const valorNumerico = Number(valor || 0);
return {
id: [
origem,
idcontasapagar || null,
idmovimentosfixos || null,
idbancos || null,
idcentrodecustos || null,
descricao,
].filter(Boolean).join(':'),
tipo,
origem,
descricao,
valor: valorNumerico,
valor_original: valorNumerico,
movimento,
status,
data,
idcontasapagar,
idmovimentosfixos,
idbancos,
banco_descricao,
banco_debito,
idcentrodecustos,
centro_custo_descricao,
idclientes,
cliente_nome,
investimento: Number(investimento || 0),
editavel,
editado: false,
detalhes,
};
}
function calcularPercentual(valor, totalEntradas) {
if (!totalEntradas) return null;
return Number(((Number(valor || 0) / totalEntradas) * 100).toFixed(2));
}
function calcularResumo(entradas, saidas) {
const totalEntradas = entradas.reduce(
(total, item) => total + Number(item.valor || 0),
0
);
const totalSaidas = saidas.reduce(
(total, item) => total + Number(item.valor || 0),
0
);
const totalInvestimentos = saidas
.filter((item) => Number(item.investimento) === 1)
.reduce((total, item) => total + Number(item.valor || 0), 0);
const totalGastos = totalSaidas - totalInvestimentos;
const resultado = totalEntradas - totalSaidas;
return {
totalEntradas,
totalSaidas,
totalGastos,
totalInvestimentos,
resultado,
percentualEntradas: totalEntradas > 0 ? 100 : null,
percentualSaidas: calcularPercentual(totalSaidas, totalEntradas),
percentualGastos: calcularPercentual(totalGastos, totalEntradas),
percentualInvestimentos: calcularPercentual(totalInvestimentos, totalEntradas),
percentualResultado: calcularPercentual(resultado, totalEntradas),
percentualComprometido: calcularPercentual(totalSaidas, totalEntradas),
quantidadeEntradas: entradas.length,
quantidadeSaidas: saidas.length,
quantidadeInvestimentos: saidas.filter((item) => Number(item.investimento) === 1).length,
};
}
async function buscarBancosUsuario(connection, idUsuario) {
const [rows] = await connection.query(
`
SELECT
b.idbancos,
b.descricao,
b.saldo,
b.debito,
b.investimento,
b.idusuarios
FROM bancos b
WHERE b.habilitado = 1
AND b.idusuarios = ?
ORDER BY b.descricao ASC
`,
[idUsuario]
);
return rows;
}
async function buscarMovimentosDoMes(connection, { idUsuario, dataInicio, dataFim }) {
const [rows] = await connection.query(
`
SELECT
cp.idcontasapagar,
cp.movimento,
cp.descricao,
cp.dataentrada,
cp.datavencimento,
cp.databaixa,
cp.valor,
cp.parcela,
cp.parcelas,
cp.status,
cp.idcentrodecustos,
cp.idbancos,
cp.idclientes,
cp.idbancos_p,
cp.idmovimentosfixos,
cp.competencia,
cp.origem,
cp.saldo_processado,
cp.observacao,
b.descricao AS banco_descricao,
b.debito AS banco_debito,
cc.descricao AS centro_custo_descricao,
cc.investimento AS centro_custo_investimento,
c.nome AS cliente_nome,
bp.descricao AS banco_referencia_descricao
FROM contasapagar cp
INNER JOIN bancos b
ON b.idbancos = cp.idbancos
LEFT JOIN centrodecustos cc
ON cc.idcentrodecustos = cp.idcentrodecustos
LEFT JOIN clientes c
ON c.idclientes = cp.idclientes
LEFT JOIN bancos bp
ON bp.idbancos = cp.idbancos_p
WHERE cp.deleted_at IS NULL
AND b.idusuarios = ?
AND DATE(cp.datavencimento) BETWEEN ? AND ?
ORDER BY cp.datavencimento ASC, cp.descricao ASC
`,
[idUsuario, dataInicio, dataFim]
);
return rows;
}
async function buscarMovimentosParaCentrosDoMes(connection, { idUsuario, dataInicio, dataFim }) {
const [rows] = await connection.query(
`
SELECT
cp.idcentrodecustos,
COALESCE(SUM(cp.valor), 0) AS valor_gasto
FROM contasapagar cp
INNER JOIN bancos b
ON b.idbancos = cp.idbancos
WHERE cp.deleted_at IS NULL
AND b.idusuarios = ?
AND cp.idcentrodecustos IS NOT NULL
AND (
(
b.debito = 1
AND DATE(cp.datavencimento) BETWEEN ? AND ?
)
OR
(
b.debito = 0
AND DATE(cp.dataentrada) BETWEEN ? AND ?
)
)
GROUP BY cp.idcentrodecustos
`,
[idUsuario, dataInicio, dataFim, dataInicio, dataFim]
);
return new Map(
rows.map((row) => [
Number(row.idcentrodecustos),
Number(row.valor_gasto || 0),
])
);
}
async function buscarFixosUsuario(connection, idUsuario) {
const [rows] = await connection.query(
`
SELECT
mf.idmovimentosfixos,
mf.movimento,
mf.descricao,
mf.valor,
mf.dia_vencimento,
mf.idcentrodecustos,
mf.idclientes,
mf.idbancos,
b.descricao AS banco_descricao,
b.debito AS banco_debito,
cc.descricao AS centro_custo_descricao,
cc.investimento AS centro_custo_investimento,
c.nome AS cliente_nome
FROM movimentosfixos mf
INNER JOIN bancos b
ON b.idbancos = mf.idbancos
LEFT JOIN centrodecustos cc
ON cc.idcentrodecustos = mf.idcentrodecustos
LEFT JOIN clientes c
ON c.idclientes = mf.idclientes
WHERE mf.habilitado = 1
AND b.idusuarios = ?
AND COALESCE(mf.gerar_automatico, 1) = 1
ORDER BY mf.dia_vencimento ASC, mf.descricao ASC
`,
[idUsuario]
);
return rows;
}
async function buscarCentrosSimulaveis(connection) {
const [rows] = await connection.query(
`
SELECT
idcentrodecustos,
descricao,
limite,
simular,
investimento
FROM centrodecustos
WHERE habilitado = 1
AND simular = 1
ORDER BY descricao ASC
`
);
return rows;
}
function movimentoFixoJaExisteNoMes(fixo, movimentosDoMes) {
return movimentosDoMes.some((movimento) => {
if (
fixo.idmovimentosfixos &&
movimento.idmovimentosfixos &&
Number(fixo.idmovimentosfixos) === Number(movimento.idmovimentosfixos)
) {
return true;
}
const mesmaDescricao =
String(movimento.descricao || '').trim().toLowerCase() ===
String(fixo.descricao || '').trim().toLowerCase();
const mesmoBanco = Number(movimento.idbancos || 0) === Number(fixo.idbancos || 0);
const mesmoCentro =
Number(movimento.idcentrodecustos || 0) === Number(fixo.idcentrodecustos || 0);
return mesmaDescricao && mesmoBanco && mesmoCentro;
});
}
function montarItensMovimentosExistentes(movimentosDoMes, incluirQuitadas) {
const entradas = [];
const saidas = [];
for (const movimento of movimentosDoMes) {
const bancoDebito = Number(movimento.banco_debito) === 1;
const bancoCredito = Number(movimento.banco_debito) === 0;
if (bancoDebito) {
if (!incluirQuitadas && !statusEmAberto(movimento.status)) {
continue;
}
if (movimento.movimento === 'Entrada') {
entradas.push(
criarItemSimulacao({
tipo: 'entrada',
origem: 'movimento_existente',
descricao: movimento.descricao,
valor: movimento.valor,
movimento: movimento.movimento,
status: movimento.status,
data: movimento.datavencimento,
idcontasapagar: movimento.idcontasapagar,
idbancos: movimento.idbancos,
banco_descricao: movimento.banco_descricao,
banco_debito: movimento.banco_debito,
idcentrodecustos: movimento.idcentrodecustos,
centro_custo_descricao: movimento.centro_custo_descricao,
investimento: movimento.centro_custo_investimento,
idclientes: movimento.idclientes,
cliente_nome: movimento.cliente_nome,
detalhes: {
parcela: movimento.parcela,
parcelas: movimento.parcelas,
origem_movimento: movimento.origem,
},
})
);
}
if (movimento.movimento === 'Saida' || movimento.movimento === 'Sangria') {
saidas.push(
criarItemSimulacao({
tipo: 'saida',
origem: 'movimento_existente',
descricao: movimento.descricao,
valor: movimento.valor,
movimento: movimento.movimento,
status: movimento.status,
data: movimento.datavencimento,
idcontasapagar: movimento.idcontasapagar,
idbancos: movimento.idbancos,
banco_descricao: movimento.banco_descricao,
banco_debito: movimento.banco_debito,
idcentrodecustos: movimento.idcentrodecustos,
centro_custo_descricao: movimento.centro_custo_descricao,
investimento: movimento.centro_custo_investimento,
idclientes: movimento.idclientes,
cliente_nome: movimento.cliente_nome,
detalhes: {
parcela: movimento.parcela,
parcelas: movimento.parcelas,
origem_movimento: movimento.origem,
},
})
);
}
if (movimento.movimento === 'Estorno') {
entradas.push(
criarItemSimulacao({
tipo: 'entrada',
origem: 'movimento_existente',
descricao: movimento.descricao,
valor: movimento.valor,
movimento: movimento.movimento,
status: movimento.status,
data: movimento.datavencimento,
idcontasapagar: movimento.idcontasapagar,
idbancos: movimento.idbancos,
banco_descricao: movimento.banco_descricao,
banco_debito: movimento.banco_debito,
idcentrodecustos: movimento.idcentrodecustos,
centro_custo_descricao: movimento.centro_custo_descricao,
investimento: movimento.centro_custo_investimento,
idclientes: movimento.idclientes,
cliente_nome: movimento.cliente_nome,
})
);
}
}
if (bancoCredito) {
// Cartões entram depois agrupados por banco.
continue;
}
}
return {
entradas,
saidas,
};
}
function montarItensCreditoAgrupado(movimentosDoMes) {
const grupos = new Map();
for (const movimento of movimentosDoMes) {
if (Number(movimento.banco_debito) !== 0) {
continue;
}
const chave = Number(movimento.idbancos);
if (!grupos.has(chave)) {
grupos.set(chave, {
idbancos: movimento.idbancos,
banco_descricao: movimento.banco_descricao,
valor: 0,
movimentos: [],
});
}
const grupo = grupos.get(chave);
if (
(movimento.movimento === 'Saida' || movimento.movimento === 'Sangria') &&
movimento.status === 'A pagar'
) {
grupo.valor += Number(movimento.valor || 0);
grupo.movimentos.push(movimento);
}
if (movimento.movimento === 'Entrada' || movimento.movimento === 'Estorno') {
grupo.valor -= Number(movimento.valor || 0);
grupo.movimentos.push(movimento);
}
}
return Array
.from(grupos.values())
.filter((grupo) => Number(grupo.valor || 0) !== 0)
.map((grupo) =>
criarItemSimulacao({
tipo: 'saida',
origem: 'cartao_credito_agrupado',
descricao: `Fatura ${grupo.banco_descricao}`,
valor: grupo.valor,
movimento: 'Saida',
status: 'A pagar',
idbancos: grupo.idbancos,
banco_descricao: grupo.banco_descricao,
banco_debito: 0,
editavel: true,
detalhes: {
quantidade_movimentos: grupo.movimentos.length,
ids: grupo.movimentos.map((item) => item.idcontasapagar),
},
})
);
}
function montarItensFixosPrevistos({
fixos,
movimentosDoMes,
ano,
mes,
competencia,
incluirFixosVencidosDoMesAtual,
}) {
const entradas = [];
const saidas = [];
const hoje = hojeDataString();
const mesAtual = hoje.slice(0, 7);
const diaAtual = Number(hoje.slice(8, 10));
for (const fixo of fixos) {
if (Number(fixo.banco_debito) !== 1) {
continue;
}
if (movimentoFixoJaExisteNoMes(fixo, movimentosDoMes)) {
continue;
}
if (
competencia === mesAtual &&
!incluirFixosVencidosDoMesAtual &&
Number(fixo.dia_vencimento || 1) < diaAtual
) {
continue;
}
const dataVencimento = montarDataVencimento(ano, mes, fixo.dia_vencimento);
if (movimentoEhEntrada(fixo.movimento)) {
entradas.push(
criarItemSimulacao({
tipo: 'entrada',
origem: 'movimento_fixo_previsto',
descricao: fixo.descricao,
valor: fixo.valor,
movimento: fixo.movimento,
status: 'A receber',
data: dataVencimento,
idmovimentosfixos: fixo.idmovimentosfixos,
idbancos: fixo.idbancos,
banco_descricao: fixo.banco_descricao,
banco_debito: fixo.banco_debito,
idcentrodecustos: fixo.idcentrodecustos,
centro_custo_descricao: fixo.centro_custo_descricao,
investimento: fixo.centro_custo_investimento,
idclientes: fixo.idclientes,
cliente_nome: fixo.cliente_nome,
})
);
}
if (movimentoEhSaida(fixo.movimento)) {
saidas.push(
criarItemSimulacao({
tipo: 'saida',
origem: 'movimento_fixo_previsto',
descricao: fixo.descricao,
valor: fixo.valor,
movimento: fixo.movimento,
status: fixo.movimento === 'Sangria' ? 'Pago' : 'A pagar',
data: dataVencimento,
idmovimentosfixos: fixo.idmovimentosfixos,
idbancos: fixo.idbancos,
banco_descricao: fixo.banco_descricao,
banco_debito: fixo.banco_debito,
idcentrodecustos: fixo.idcentrodecustos,
centro_custo_descricao: fixo.centro_custo_descricao,
investimento: fixo.centro_custo_investimento,
idclientes: fixo.idclientes,
cliente_nome: fixo.cliente_nome,
})
);
}
}
return {
entradas,
saidas,
};
}
function montarItensSaldosBancos(bancos) {
return bancos
.filter((banco) => Number(banco.debito) === 1)
.map((banco) =>
criarItemSimulacao({
tipo: 'entrada',
origem: 'saldo_banco',
descricao: `Saldo ${banco.descricao}`,
valor: banco.saldo,
movimento: 'Entrada',
status: 'Disponível',
idbancos: banco.idbancos,
banco_descricao: banco.descricao,
banco_debito: banco.debito,
editavel: true,
})
);
}
function montarItensLimitesCentros({ centrosSimulaveis, gastosPorCentro, avisos }) {
const saidas = [];
for (const centro of centrosSimulaveis) {
const limite = Number(centro.limite || 0);
const gastoAtual = Number(gastosPorCentro.get(Number(centro.idcentrodecustos)) || 0);
const restante = limite - gastoAtual;
if (restante > 0) {
saidas.push(
criarItemSimulacao({
tipo: 'saida',
origem: 'limite_centro_custo',
descricao: `Previsão ${centro.descricao}`,
valor: restante,
movimento: 'Saida',
status: 'Previsto',
idcentrodecustos: centro.idcentrodecustos,
centro_custo_descricao: centro.descricao,
investimento: centro.investimento,
editavel: true,
detalhes: {
limite,
gastoAtual,
restante,
},
})
);
} else if (limite > 0) {
avisos.push({
tipo: 'limite_centro_estourado',
idcentrodecustos: centro.idcentrodecustos,
centro_custo_descricao: centro.descricao,
limite,
gastoAtual,
excesso: Math.abs(restante),
mensagem: `Centro de custo "${centro.descricao}" já passou do limite em ${Math.abs(restante).toFixed(2)}.`,
});
}
}
return saidas;
}
async function montarBaseSimulacaoMensal(opcoes = {}) {
const idUsuario = Number(opcoes.idUsuario || opcoes.idusuarios || 0);
const ano = Number(opcoes.ano);
const mes = Number(opcoes.mes);
if (!idUsuario) {
const error = new Error('Usuário logado não identificado.');
error.statusCode = 401;
throw error;
}
if (!Number.isInteger(ano) || ano < 2000 || ano > 2100) {
const error = new Error('Ano inválido.');
error.statusCode = 400;
throw error;
}
if (!Number.isInteger(mes) || mes < 1 || mes > 12) {
const error = new Error('Mês inválido.');
error.statusCode = 400;
throw error;
}
const incluirQuitadas = normalizarFlag(opcoes.incluirQuitadas, 0) === 1;
const incluirFixosVencidosDoMesAtual =
normalizarFlag(opcoes.incluirFixosVencidosDoMesAtual, 0) === 1;
const {
competencia,
dataInicio,
dataFim,
} = montarPeriodo(ano, mes);
const connection = await pool.getConnection();
try {
const avisos = [];
const [
bancos,
movimentosDoMes,
fixos,
centrosSimulaveis,
gastosPorCentro,
] = await Promise.all([
buscarBancosUsuario(connection, idUsuario),
buscarMovimentosDoMes(connection, {
idUsuario,
dataInicio,
dataFim,
}),
buscarFixosUsuario(connection, idUsuario),
buscarCentrosSimulaveis(connection),
buscarMovimentosParaCentrosDoMes(connection, {
idUsuario,
dataInicio,
dataFim,
}),
]);
const itensExistentes = montarItensMovimentosExistentes(movimentosDoMes, incluirQuitadas);
const itensCredito = montarItensCreditoAgrupado(movimentosDoMes);
const itensFixos = montarItensFixosPrevistos({
fixos,
movimentosDoMes,
ano,
mes,
competencia,
incluirFixosVencidosDoMesAtual,
});
const itensSaldos = montarItensSaldosBancos(bancos);
const itensLimites = montarItensLimitesCentros({
centrosSimulaveis,
gastosPorCentro,
avisos,
});
const entradas = [
...itensSaldos,
...itensExistentes.entradas,
...itensFixos.entradas,
];
const saidas = [
...itensExistentes.saidas,
...itensCredito,
...itensFixos.saidas,
...itensLimites,
];
entradas.sort((a, b) => String(a.data || '').localeCompare(String(b.data || '')));
saidas.sort((a, b) => String(a.data || '').localeCompare(String(b.data || '')));
const resumo = calcularResumo(entradas, saidas);
return {
competencia,
ano,
mes,
dataInicio,
dataFim,
opcoes: {
incluirQuitadas,
incluirFixosVencidosDoMesAtual,
},
entradas,
saidas,
resumo,
fontes: {
movimentosExistentes: movimentosDoMes.length,
movimentosFixos: fixos.length,
bancos: bancos.length,
centrosSimulaveis: centrosSimulaveis.length,
},
avisos,
};
} finally {
connection.release();
}
}
module.exports = {
montarBaseSimulacaoMensal,
};

View File

@ -26,6 +26,7 @@ import { UsuariosPage } from '../features/usuarios/pages/UsuariosPage';
import { NovoUsuarioPage } from '../features/usuarios/pages/NovoUsuarioPage'; import { NovoUsuarioPage } from '../features/usuarios/pages/NovoUsuarioPage';
import { EditarUsuarioPage } from '../features/usuarios/pages/EditarUsuarioPage'; import { EditarUsuarioPage } from '../features/usuarios/pages/EditarUsuarioPage';
import { QuitacoesCreditoPage } from '../features/quitacoesCredito/pages/QuitacoesCreditoPage'; import { QuitacoesCreditoPage } from '../features/quitacoesCredito/pages/QuitacoesCreditoPage';
import { SimulacaoMensalPage } from '../features/simulacaoMensal/pages/SimulacaoMensalPage';
export const routes: RouteObject[] = [ export const routes: RouteObject[] = [
{ {
@ -129,6 +130,10 @@ export const routes: RouteObject[] = [
{ {
path: '/quitacoes-credito', path: '/quitacoes-credito',
element: <QuitacoesCreditoPage />, element: <QuitacoesCreditoPage />,
},
{
path: 'simulacao-mensal',
element: <SimulacaoMensalPage />,
} }
], ],
}, },

View File

@ -18,6 +18,7 @@ import AccountTreeIcon from '@mui/icons-material/AccountTree';
import EventRepeatIcon from '@mui/icons-material/EventRepeat'; import EventRepeatIcon from '@mui/icons-material/EventRepeat';
import ManageAccountsIcon from '@mui/icons-material/ManageAccounts'; import ManageAccountsIcon from '@mui/icons-material/ManageAccounts';
import CreditCardIcon from '@mui/icons-material/CreditCard'; import CreditCardIcon from '@mui/icons-material/CreditCard';
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
import { NavLink } from 'react-router-dom'; import { NavLink } from 'react-router-dom';
type SidebarProps = { type SidebarProps = {
@ -75,6 +76,11 @@ const menuItems = [
path: '/relatorios', path: '/relatorios',
icon: <AssessmentIcon />, icon: <AssessmentIcon />,
}, },
{
label: 'Simulação mensal',
path: '/simulacao-mensal',
icon: <AutoAwesomeIcon />,
},
{ {
label: 'Configurações', label: 'Configurações',
path: '/configuracoes', path: '/configuracoes',

View File

@ -26,6 +26,7 @@ import {
listarBancos, listarBancos,
listarCentrosCusto, listarCentrosCusto,
listarClientes, listarClientes,
listarMeusBancos,
} from '../../referencias/services/referenciasService'; } from '../../referencias/services/referenciasService';
import type { import type {
Banco, Banco,
@ -118,6 +119,68 @@ function formatarValorMoeda(valor: number) {
}).format(Number(valor || 0)); }).format(Number(valor || 0));
} }
function formatarPercentual(valor: number | null | undefined) {
if (valor === null || valor === undefined || !Number.isFinite(Number(valor))) {
return null;
}
const numero = Number(valor);
return `${numero >= 0 ? '+' : ''}${numero.toFixed(2)}%`;
}
function getImpactoVisual(impacto: MovimentoImpactoSaldo) {
const delta = Number(impacto.valor_delta || 0);
const isAlta = impacto.direcao
? impacto.direcao === 'alta'
: delta > 0;
const isQueda = impacto.direcao
? impacto.direcao === 'queda'
: delta < 0;
const chipLabel = isAlta
? '↗ Alta'
: isQueda
? '↘ Queda'
: '→ Neutro';
const color = isAlta
? 'success'
: isQueda
? 'error'
: 'default';
const textColor = isAlta
? 'success.main'
: isQueda
? 'error.main'
: 'text.primary';
const backgroundColor = isAlta
? 'rgba(34,197,94,0.08)'
: isQueda
? 'rgba(239,68,68,0.08)'
: 'rgba(15,23,42,0.04)';
const borderColor = isAlta
? 'rgba(34,197,94,0.25)'
: isQueda
? 'rgba(239,68,68,0.25)'
: 'divider';
return {
isAlta,
isQueda,
chipLabel,
color,
textColor,
backgroundColor,
borderColor,
} as const;
}
function FormSection({ title, description, children }: FormSectionProps) { function FormSection({ title, description, children }: FormSectionProps) {
return ( return (
<Paper <Paper
@ -174,7 +237,8 @@ export function MovimentoForm({ mode, initialData }: MovimentoFormProps) {
const isEdit = mode === 'edit'; const isEdit = mode === 'edit';
const [bancos, setBancos] = useState<Banco[]>([]); const [meusBancos, setMeusBancos] = useState<Banco[]>([]);
const [todosBancos, setTodosBancos] = useState<Banco[]>([]);
const [centrosCusto, setCentrosCusto] = useState<CentroCusto[]>([]); const [centrosCusto, setCentrosCusto] = useState<CentroCusto[]>([]);
const [clientes, setClientes] = useState<Cliente[]>([]); const [clientes, setClientes] = useState<Cliente[]>([]);
@ -215,8 +279,8 @@ export function MovimentoForm({ mode, initialData }: MovimentoFormProps) {
const bancoSelecionado = useMemo(() => { const bancoSelecionado = useMemo(() => {
if (!idBanco) return null; if (!idBanco) return null;
return bancos.find((banco) => String(banco.id) === String(idBanco)) || null; return meusBancos.find((banco) => String(banco.id) === String(idBanco)) || null;
}, [bancos, idBanco]); }, [meusBancos, idBanco]);
const statusDisponiveis = useMemo<MovimentoStatus[]>(() => { const statusDisponiveis = useMemo<MovimentoStatus[]>(() => {
if (movimento === 'Entrada') return ['Recebido', 'A receber']; if (movimento === 'Entrada') return ['Recebido', 'A receber'];
@ -261,6 +325,11 @@ export function MovimentoForm({ mode, initialData }: MovimentoFormProps) {
if (movimento === 'Sangria') { if (movimento === 'Sangria') {
setReferenciaTipo('banco'); setReferenciaTipo('banco');
setStatus('Pago'); setStatus('Pago');
setDataBaixa((dataAtual) => dataAtual || hojeISO());
setParcelas('1');
setParcela('1');
setGerarParcelas(false);
setIdCentroCusto('19');
return; return;
} }
@ -276,13 +345,15 @@ export function MovimentoForm({ mode, initialData }: MovimentoFormProps) {
setLoadingRefs(true); setLoadingRefs(true);
setErro(''); setErro('');
const [bancosData, centrosData, clientesData] = await Promise.all([ const [meusBancosData, todosBancosData, centrosData, clientesData] = await Promise.all([
listarMeusBancos(),
listarBancos(), listarBancos(),
listarCentrosCusto(), listarCentrosCusto(),
listarClientes(), listarClientes(),
]); ]);
setBancos(bancosData); setMeusBancos(meusBancosData);
setTodosBancos(todosBancosData);
setCentrosCusto(centrosData); setCentrosCusto(centrosData);
setClientes(clientesData); setClientes(clientesData);
} catch (error: any) { } catch (error: any) {
@ -494,22 +565,33 @@ export function MovimentoForm({ mode, initialData }: MovimentoFormProps) {
} }
function montarPayload(confirmarDuplicado = false): CriarMovimentoRequest { function montarPayload(confirmarDuplicado = false): CriarMovimentoRequest {
const isSangria = movimento === 'Sangria';
const statusFinal = isSangria ? 'Pago' : status;
const referenciaTipoFinal = isSangria ? 'banco' : referenciaTipo;
const parcelaFinal = isSangria ? 1 : Number(parcela || 1);
const parcelasFinal = isSangria ? 1 : Number(parcelas || 1);
const dataBaixaFinal =
statusFinal === 'Pago' || statusFinal === 'Recebido'
? dataBaixa || hojeISO()
: null;
return { return {
movimento, movimento,
descricao: descricao.trim(), descricao: descricao.trim(),
valor: Number(valor), valor: Number(valor),
parcela: Number(parcela || 1), parcela: parcelaFinal,
parcelas: Number(parcelas || 1), parcelas: parcelasFinal,
status, status: statusFinal,
dataentrada: dataEntrada, dataentrada: dataEntrada,
datavencimento: dataVencimento || null, datavencimento: dataVencimento || null,
databaixa: status === 'Pago' || status === 'Recebido' ? dataBaixa || null : null, databaixa: dataBaixaFinal,
idcentrodecustos: toNumberOrNull(idCentroCusto), idcentrodecustos: toNumberOrNull(idCentroCusto),
idbancos: toNumberOrNull(idBanco), idbancos: toNumberOrNull(idBanco),
idusuarios_baixa: null, idusuarios_baixa: null,
idclientes: referenciaTipo === 'cliente' ? toNumberOrNull(idCliente) : null, idclientes: referenciaTipoFinal === 'cliente' ? toNumberOrNull(idCliente) : null,
idveiculosdetalhes: null, idveiculosdetalhes: null,
idbancos_p: referenciaTipo === 'banco' ? toNumberOrNull(idBancoReferencia) : null, idbancos_p: referenciaTipoFinal === 'banco' ? toNumberOrNull(idBancoReferencia) : null,
idmovimentosfixos: null, idmovimentosfixos: null,
competencia: extrairCompetencia(dataVencimento || dataEntrada), competencia: extrairCompetencia(dataVencimento || dataEntrada),
@ -519,8 +601,12 @@ export function MovimentoForm({ mode, initialData }: MovimentoFormProps) {
observacao: observacao.trim() || null, observacao: observacao.trim() || null,
gerarParcelas: temParcelamento ? gerarParcelas : false, gerarParcelas: isSangria ? false : temParcelamento ? gerarParcelas : false,
modoParcelamento: temParcelamento ? modoParcelamento : 'valor_parcela', modoParcelamento: isSangria
? 'valor_parcela'
: temParcelamento
? modoParcelamento
: 'valor_parcela',
confirmarDuplicado, confirmarDuplicado,
}; };
} }
@ -645,6 +731,7 @@ export function MovimentoForm({ mode, initialData }: MovimentoFormProps) {
label="Situação" label="Situação"
value={status} value={status}
onChange={(event) => setStatus(event.target.value as MovimentoStatus)} onChange={(event) => setStatus(event.target.value as MovimentoStatus)}
disabled={movimento === 'Sangria'}
fullWidth fullWidth
sx={fieldSx} sx={fieldSx}
> >
@ -685,7 +772,7 @@ export function MovimentoForm({ mode, initialData }: MovimentoFormProps) {
sx={fieldSx} sx={fieldSx}
> >
<MenuItem value="">Nenhum</MenuItem> <MenuItem value="">Nenhum</MenuItem>
{bancos.map((banco) => ( {meusBancos.map((banco) => (
<MenuItem key={banco.id} value={String(banco.id)}> <MenuItem key={banco.id} value={String(banco.id)}>
{banco.descricao} {banco.descricao}
{Number(banco.debito) === 0 ? ' · Crédito' : ''} {Number(banco.debito) === 0 ? ' · Crédito' : ''}
@ -698,25 +785,35 @@ export function MovimentoForm({ mode, initialData }: MovimentoFormProps) {
title="Parcelas e datas" title="Parcelas e datas"
description="Controle vencimento, baixa e parcelamento." description="Controle vencimento, baixa e parcelamento."
> >
<TextField {movimento !== 'Sangria' && (
label="Parcela" <>
type="number" <TextField
value={parcela} label="Parcela"
onChange={(event) => setParcela(event.target.value)} type="number"
inputProps={{ min: '1' }} value={parcela}
fullWidth onChange={(event) => setParcela(event.target.value)}
sx={fieldSx} inputProps={{ min: '1' }}
/> fullWidth
sx={fieldSx}
/>
<TextField <TextField
label="Total de parcelas" label="Total de parcelas"
type="number" type="number"
value={parcelas} value={parcelas}
onChange={(event) => setParcelas(event.target.value)} onChange={(event) => setParcelas(event.target.value)}
inputProps={{ min: '1' }} inputProps={{ min: '1' }}
fullWidth fullWidth
sx={fieldSx} sx={fieldSx}
/> />
{temParcelamento && (
<Box sx={{ gridColumn: { xs: 'auto', md: '1 / -1' } }}>
...
</Box>
)}
</>
)}
{temParcelamento && ( {temParcelamento && (
<Box sx={{ gridColumn: { xs: 'auto', md: '1 / -1' } }}> <Box sx={{ gridColumn: { xs: 'auto', md: '1 / -1' } }}>
@ -815,6 +912,7 @@ export function MovimentoForm({ mode, initialData }: MovimentoFormProps) {
onChange={(event) => onChange={(event) =>
setReferenciaTipo(event.target.value as 'nenhum' | 'cliente' | 'banco') setReferenciaTipo(event.target.value as 'nenhum' | 'cliente' | 'banco')
} }
disabled={movimento === 'Sangria'}
fullWidth fullWidth
sx={fieldSx} sx={fieldSx}
> >
@ -853,7 +951,7 @@ export function MovimentoForm({ mode, initialData }: MovimentoFormProps) {
sx={fieldSx} sx={fieldSx}
> >
<MenuItem value="">Selecione</MenuItem> <MenuItem value="">Selecione</MenuItem>
{bancos.map((banco) => ( {todosBancos.map((banco) => (
<MenuItem key={banco.id} value={String(banco.id)}> <MenuItem key={banco.id} value={String(banco.id)}>
{banco.descricao} {banco.descricao}
</MenuItem> </MenuItem>
@ -980,7 +1078,7 @@ export function MovimentoForm({ mode, initialData }: MovimentoFormProps) {
<Box> <Box>
<Typography variant="caption" color="text.secondary"> <Typography variant="caption" color="text.secondary">
Impacto previsto no saldo Impacto previsto no saldo dos bancos
</Typography> </Typography>
{loadingImpacto ? ( {loadingImpacto ? (
@ -990,40 +1088,88 @@ export function MovimentoForm({ mode, initialData }: MovimentoFormProps) {
</Box> </Box>
) : impactoSaldo.length === 0 ? ( ) : impactoSaldo.length === 0 ? (
<Typography fontWeight={700} marginTop={0.5}> <Typography fontWeight={700} marginTop={0.5}>
Sem impacto imediato Sem alteração imediata de saldo
</Typography> </Typography>
) : ( ) : (
<Stack spacing={1} marginTop={1}> <Stack spacing={1} marginTop={1}>
{impactoSaldo.map((impacto, index) => ( {impactoSaldo.map((impacto, index) => {
<Box const visual = getImpactoVisual(impacto);
key={`${impacto.idbancos}-${index}`} const percentualFormatado = formatarPercentual(impacto.percentual_delta);
sx={{
padding: 1.25,
borderRadius: 2,
backgroundColor: 'rgba(15,23,42,0.04)',
}}
>
<Typography variant="body2" fontWeight={900}>
{impacto.banco_descricao || `Banco ${impacto.idbancos}`}
</Typography>
<Typography return (
variant="body2" <Box
fontWeight={900} key={`${impacto.idbancos}-${index}`}
color={impacto.valor_delta >= 0 ? 'success.main' : 'error.main'} sx={{
padding: 1.5,
borderRadius: 2.5,
backgroundColor: visual.backgroundColor,
border: '1px solid',
borderColor: visual.borderColor,
}}
> >
{impacto.valor_delta >= 0 ? '+' : ''} <Stack
{formatarValorMoeda(impacto.valor_delta)} direction="row"
</Typography> justifyContent="space-between"
alignItems="flex-start"
gap={1}
>
<Box>
<Typography variant="body2" fontWeight={900}>
{impacto.banco_descricao || `Banco ${impacto.idbancos}`}
</Typography>
{impacto.saldo_anterior !== undefined && ( {impacto.descricao && (
<Typography variant="caption" color="text.secondary"> <Typography variant="caption" color="text.secondary">
{formatarValorMoeda(impacto.saldo_anterior)} {' '} {impacto.descricao}
{formatarValorMoeda(impacto.saldo_posterior || 0)} </Typography>
)}
</Box>
<Chip
size="small"
label={visual.chipLabel}
color={visual.color}
variant="outlined"
sx={{ fontWeight: 800 }}
/>
</Stack>
<Typography
variant="h6"
fontWeight={950}
color={visual.textColor}
sx={{ marginTop: 1 }}
>
{impacto.valor_delta >= 0 ? '+' : ''}
{formatarValorMoeda(impacto.valor_delta)}
</Typography> </Typography>
)}
</Box> <Stack spacing={0.25} marginTop={0.75}>
))} {impacto.saldo_anterior !== undefined && (
<Typography variant="caption" color="text.secondary">
Saldo atual: {formatarValorMoeda(impacto.saldo_anterior)}
</Typography>
)}
{impacto.saldo_posterior !== undefined && (
<Typography variant="caption" color="text.secondary">
Saldo previsto: {formatarValorMoeda(impacto.saldo_posterior)}
</Typography>
)}
{percentualFormatado && (
<Typography
variant="caption"
fontWeight={800}
color={visual.textColor}
>
{percentualFormatado} em relação ao saldo atual
</Typography>
)}
</Stack>
</Box>
);
})}
</Stack> </Stack>
)} )}
</Box> </Box>

View File

@ -77,10 +77,19 @@ export type AtualizarMovimentoRequest = CriarMovimentoRequest;
export type MovimentoImpactoSaldo = { export type MovimentoImpactoSaldo = {
idbancos: number; idbancos: number;
banco_descricao?: string | null; banco_descricao?: string | null;
banco_nome?: string | null;
usuario_nome?: string | null;
valor_delta: number; valor_delta: number;
saldo_anterior?: number; saldo_anterior?: number;
saldo_posterior?: number; saldo_posterior?: number;
percentual_delta?: number | null;
direcao?: 'alta' | 'queda' | 'neutro';
tipo_visual?: 'positivo' | 'negativo' | 'neutro';
descricao?: string | null; descricao?: string | null;
}; };

View File

@ -20,12 +20,14 @@ import EventRepeatIcon from '@mui/icons-material/EventRepeat';
import SaveIcon from '@mui/icons-material/Save'; import SaveIcon from '@mui/icons-material/Save';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { import {
listarBancos, listarMeusBancos,
listarCentrosCusto, listarCentrosCusto,
listarClientes,
} from '../../referencias/services/referenciasService'; } from '../../referencias/services/referenciasService';
import type { import type {
Banco, Banco,
CentroCusto, CentroCusto,
Cliente,
} from '../../referencias/types/referenciasTypes'; } from '../../referencias/types/referenciasTypes';
import { import {
atualizarMovimentoFixo, atualizarMovimentoFixo,
@ -125,6 +127,7 @@ export function MovimentoFixoForm({ mode, initialData }: MovimentoFixoFormProps)
const [bancos, setBancos] = useState<Banco[]>([]); const [bancos, setBancos] = useState<Banco[]>([]);
const [centrosCusto, setCentrosCusto] = useState<CentroCusto[]>([]); const [centrosCusto, setCentrosCusto] = useState<CentroCusto[]>([]);
const [clientes, setClientes] = useState<Cliente[]>([]);
const [loadingRefs, setLoadingRefs] = useState(true); const [loadingRefs, setLoadingRefs] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@ -136,6 +139,7 @@ export function MovimentoFixoForm({ mode, initialData }: MovimentoFixoFormProps)
const [valor, setValor] = useState(''); const [valor, setValor] = useState('');
const [diaVencimento, setDiaVencimento] = useState('1'); const [diaVencimento, setDiaVencimento] = useState('1');
const [idCentroCusto, setIdCentroCusto] = useState(''); const [idCentroCusto, setIdCentroCusto] = useState('');
const [idCliente, setIdCliente] = useState('');
const [idBanco, setIdBanco] = useState(''); const [idBanco, setIdBanco] = useState('');
const [habilitado, setHabilitado] = useState('1'); const [habilitado, setHabilitado] = useState('1');
@ -151,6 +155,12 @@ export function MovimentoFixoForm({ mode, initialData }: MovimentoFixoFormProps)
return 'Estorno recorrente'; return 'Estorno recorrente';
}, [movimento]); }, [movimento]);
const clienteSelecionado = useMemo(() => {
if (!idCliente) return null;
return clientes.find((cliente) => String(cliente.id) === String(idCliente)) || null;
}, [clientes, idCliente]);
useEffect(() => { useEffect(() => {
if (!initialData) return; if (!initialData) return;
@ -159,6 +169,7 @@ export function MovimentoFixoForm({ mode, initialData }: MovimentoFixoFormProps)
setValor(String(initialData.valor || '')); setValor(String(initialData.valor || ''));
setDiaVencimento(String(initialData.dia_vencimento || 1)); setDiaVencimento(String(initialData.dia_vencimento || 1));
setIdCentroCusto(initialData.idcentrodecustos ? String(initialData.idcentrodecustos) : ''); setIdCentroCusto(initialData.idcentrodecustos ? String(initialData.idcentrodecustos) : '');
setIdCliente(initialData.idclientes ? String(initialData.idclientes) : '');
setIdBanco(initialData.idbancos ? String(initialData.idbancos) : ''); setIdBanco(initialData.idbancos ? String(initialData.idbancos) : '');
setHabilitado(String(initialData.habilitado ?? 1)); setHabilitado(String(initialData.habilitado ?? 1));
}, [initialData]); }, [initialData]);
@ -169,13 +180,15 @@ export function MovimentoFixoForm({ mode, initialData }: MovimentoFixoFormProps)
setLoadingRefs(true); setLoadingRefs(true);
setErro(''); setErro('');
const [bancosData, centrosData] = await Promise.all([ const [bancosData, centrosData, clientesData] = await Promise.all([
listarBancos(), listarMeusBancos(),
listarCentrosCusto(), listarCentrosCusto(),
listarClientes(),
]); ]);
setBancos(bancosData); setBancos(bancosData);
setCentrosCusto(centrosData); setCentrosCusto(centrosData);
setClientes(clientesData);
} catch (error: any) { } catch (error: any) {
const message = const message =
error?.response?.data?.message || error?.response?.data?.message ||
@ -196,6 +209,7 @@ export function MovimentoFixoForm({ mode, initialData }: MovimentoFixoFormProps)
setValor(''); setValor('');
setDiaVencimento('1'); setDiaVencimento('1');
setIdCentroCusto(''); setIdCentroCusto('');
setIdCliente('');
setIdBanco(''); setIdBanco('');
setHabilitado('1'); setHabilitado('1');
} }
@ -231,6 +245,7 @@ export function MovimentoFixoForm({ mode, initialData }: MovimentoFixoFormProps)
valor: Number(valor), valor: Number(valor),
dia_vencimento: dia, dia_vencimento: dia,
idcentrodecustos: toNumberOrNull(idCentroCusto), idcentrodecustos: toNumberOrNull(idCentroCusto),
idclientes: toNumberOrNull(idCliente),
habilitado: Number(habilitado || 1), habilitado: Number(habilitado || 1),
idbancos: toNumberOrNull(idBanco), idbancos: toNumberOrNull(idBanco),
}; };
@ -400,7 +415,7 @@ export function MovimentoFixoForm({ mode, initialData }: MovimentoFixoFormProps)
<FormSection <FormSection
title="Classificação" title="Classificação"
description="Associe banco/carteira e centro de custo ao movimento fixo." description="Associe banco/carteira, centro de custo e cliente ao movimento fixo."
> >
<TextField <TextField
select select
@ -433,6 +448,22 @@ export function MovimentoFixoForm({ mode, initialData }: MovimentoFixoFormProps)
</MenuItem> </MenuItem>
))} ))}
</TextField> </TextField>
<TextField
select
label="Cliente"
value={idCliente}
onChange={(event) => setIdCliente(event.target.value)}
fullWidth
sx={fieldSx}
>
<MenuItem value="">Nenhum</MenuItem>
{clientes.map((cliente) => (
<MenuItem key={cliente.id} value={String(cliente.id)}>
{cliente.nome}
</MenuItem>
))}
</TextField>
</FormSection> </FormSection>
<Stack <Stack
@ -559,6 +590,15 @@ export function MovimentoFixoForm({ mode, initialData }: MovimentoFixoFormProps)
{Number(habilitado) === 1 ? 'Habilitado' : 'Desabilitado'} {Number(habilitado) === 1 ? 'Habilitado' : 'Desabilitado'}
</Typography> </Typography>
</Box> </Box>
<Box>
<Typography variant="caption" color="text.secondary">
Cliente
</Typography>
<Typography fontWeight={800}>
{clienteSelecionado?.nome || 'Nenhum'}
</Typography>
</Box>
</Stack> </Stack>
</Paper> </Paper>
</Stack> </Stack>

View File

@ -49,10 +49,13 @@ import type {
import { import {
listarBancos, listarBancos,
listarCentrosCusto, listarCentrosCusto,
listarClientes,
} from '../../referencias/services/referenciasService'; } from '../../referencias/services/referenciasService';
import type { import type {
Banco, Banco,
CentroCusto, CentroCusto,
Cliente,
} from '../../referencias/types/referenciasTypes'; } from '../../referencias/types/referenciasTypes';
const LIMITE_PADRAO = 20; const LIMITE_PADRAO = 20;
@ -87,7 +90,6 @@ function statusColor(habilitado: number) {
} }
export function MovimentosFixosPage() { export function MovimentosFixosPage() {
const [movimentosFixos, setMovimentosFixos] = useState<MovimentoFixo[]>([]);
const [pagination, setPagination] = useState<MovimentosFixosPagination>({ const [pagination, setPagination] = useState<MovimentosFixosPagination>({
total: 0, total: 0,
limite: LIMITE_PADRAO, limite: LIMITE_PADRAO,
@ -108,6 +110,8 @@ export function MovimentosFixosPage() {
const [bancos, setBancos] = useState<Banco[]>([]); const [bancos, setBancos] = useState<Banco[]>([]);
const [centrosCusto, setCentrosCusto] = useState<CentroCusto[]>([]); const [centrosCusto, setCentrosCusto] = useState<CentroCusto[]>([]);
const [movimentosFixos, setMovimentosFixos] = useState<MovimentoFixo[]>([]);
const [clientes, setClientes] = useState<Cliente[]>([]);
const [alterandoStatusId, setAlterandoStatusId] = useState<number | null>(null); const [alterandoStatusId, setAlterandoStatusId] = useState<number | null>(null);
@ -121,6 +125,7 @@ export function MovimentosFixosPage() {
const [movimento, setMovimento] = useState<MovimentoFixoTipo | ''>(''); const [movimento, setMovimento] = useState<MovimentoFixoTipo | ''>('');
const [idBanco, setIdBanco] = useState<number | ''>(''); const [idBanco, setIdBanco] = useState<number | ''>('');
const [idCentroCusto, setIdCentroCusto] = useState<number | ''>(''); const [idCentroCusto, setIdCentroCusto] = useState<number | ''>('');
const [idCliente, setIdCliente] = useState<number | ''>('');
const [habilitado, setHabilitado] = useState<number | ''>(''); const [habilitado, setHabilitado] = useState<number | ''>('');
const [diaInicio, setDiaInicio] = useState<number | ''>(''); const [diaInicio, setDiaInicio] = useState<number | ''>('');
const [diaFim, setDiaFim] = useState<number | ''>(''); const [diaFim, setDiaFim] = useState<number | ''>('');
@ -137,6 +142,7 @@ export function MovimentosFixosPage() {
if (movimento) count += 1; if (movimento) count += 1;
if (idBanco) count += 1; if (idBanco) count += 1;
if (idCentroCusto) count += 1; if (idCentroCusto) count += 1;
if (idCliente) count += 1;
if (habilitado !== '') count += 1; if (habilitado !== '') count += 1;
if (diaInicio !== '') count += 1; if (diaInicio !== '') count += 1;
if (diaFim !== '') count += 1; if (diaFim !== '') count += 1;
@ -147,6 +153,7 @@ export function MovimentosFixosPage() {
movimento, movimento,
idBanco, idBanco,
idCentroCusto, idCentroCusto,
idCliente,
habilitado, habilitado,
diaInicio, diaInicio,
diaFim, diaFim,
@ -156,13 +163,15 @@ export function MovimentosFixosPage() {
try { try {
setLoadingRefs(true); setLoadingRefs(true);
const [bancosData, centrosData] = await Promise.all([ const [bancosData, centrosData, clientesData] = await Promise.all([
listarBancos(), listarBancos(),
listarCentrosCusto(), listarCentrosCusto(),
listarClientes(),
]); ]);
setBancos(bancosData); setBancos(bancosData);
setCentrosCusto(centrosData); setCentrosCusto(centrosData);
setClientes(clientesData);
} finally { } finally {
setLoadingRefs(false); setLoadingRefs(false);
} }
@ -180,6 +189,7 @@ export function MovimentosFixosPage() {
movimento: movimento || undefined, movimento: movimento || undefined,
idbancos: idBanco || undefined, idbancos: idBanco || undefined,
idcentrodecustos: idCentroCusto || undefined, idcentrodecustos: idCentroCusto || undefined,
idclientes: idCliente || undefined,
habilitado, habilitado,
diaInicio, diaInicio,
diaFim, diaFim,
@ -214,6 +224,7 @@ export function MovimentosFixosPage() {
setMovimento(''); setMovimento('');
setIdBanco(''); setIdBanco('');
setIdCentroCusto(''); setIdCentroCusto('');
setIdCliente('');
setHabilitado(''); setHabilitado('');
setDiaInicio(''); setDiaInicio('');
setDiaFim(''); setDiaFim('');
@ -492,6 +503,24 @@ export function MovimentosFixosPage() {
))} ))}
</TextField> </TextField>
<TextField
select
label="Cliente"
value={idCliente}
onChange={(event) =>
setIdCliente(event.target.value === '' ? '' : Number(event.target.value))
}
disabled={loadingRefs}
fullWidth
>
<MenuItem value="">Todos</MenuItem>
{clientes.map((cliente) => (
<MenuItem key={cliente.id} value={cliente.id}>
{cliente.nome}
</MenuItem>
))}
</TextField>
<TextField <TextField
select select
label="Status" label="Status"
@ -541,6 +570,7 @@ export function MovimentosFixosPage() {
<MenuItem value="movimento">Movimento</MenuItem> <MenuItem value="movimento">Movimento</MenuItem>
<MenuItem value="banco_descricao">Banco</MenuItem> <MenuItem value="banco_descricao">Banco</MenuItem>
<MenuItem value="centro_custo_descricao">Centro</MenuItem> <MenuItem value="centro_custo_descricao">Centro</MenuItem>
<MenuItem value="cliente_nome">Cliente</MenuItem>
<MenuItem value="habilitado">Status</MenuItem> <MenuItem value="habilitado">Status</MenuItem>
<MenuItem value="insert_date">Cadastro</MenuItem> <MenuItem value="insert_date">Cadastro</MenuItem>
<MenuItem value="update_date">Atualização</MenuItem> <MenuItem value="update_date">Atualização</MenuItem>
@ -667,6 +697,10 @@ export function MovimentosFixosPage() {
Centro: <strong>{item.centro_custo_descricao || '-'}</strong> Centro: <strong>{item.centro_custo_descricao || '-'}</strong>
</Typography> </Typography>
<Typography variant="body2" color="text.secondary">
Cliente: <strong>{item.cliente_nome || '-'}</strong>
</Typography>
<Box <Box
display="flex" display="flex"
justifyContent="flex-end" justifyContent="flex-end"
@ -721,7 +755,7 @@ export function MovimentosFixosPage() {
<Table <Table
size="small" size="small"
sx={{ sx={{
minWidth: 1120, minWidth: 1280,
'& th': { '& th': {
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
fontWeight: 900, fontWeight: 900,
@ -739,6 +773,7 @@ export function MovimentosFixosPage() {
<TableCell sx={{ minWidth: 130 }} align="right">Valor</TableCell> <TableCell sx={{ minWidth: 130 }} align="right">Valor</TableCell>
<TableCell sx={{ minWidth: 110 }} align="center">Dia venc.</TableCell> <TableCell sx={{ minWidth: 110 }} align="center">Dia venc.</TableCell>
<TableCell sx={{ minWidth: 180 }}>Centro</TableCell> <TableCell sx={{ minWidth: 180 }}>Centro</TableCell>
<TableCell sx={{ minWidth: 180 }}>Cliente</TableCell>
<TableCell sx={{ minWidth: 180 }}>Banco</TableCell> <TableCell sx={{ minWidth: 180 }}>Banco</TableCell>
<TableCell sx={{ minWidth: 130 }}>Status</TableCell> <TableCell sx={{ minWidth: 130 }}>Status</TableCell>
<TableCell sx={{ minWidth: 130 }}>Cadastro</TableCell> <TableCell sx={{ minWidth: 130 }}>Cadastro</TableCell>
@ -801,6 +836,16 @@ export function MovimentosFixosPage() {
</Typography> </Typography>
</TableCell> </TableCell>
<TableCell>
<Typography
variant="body2"
noWrap
title={item.cliente_nome || '-'}
>
{item.cliente_nome || '-'}
</Typography>
</TableCell>
<TableCell> <TableCell>
<Typography <Typography
variant="body2" variant="body2"

View File

@ -17,6 +17,7 @@ export type ListarMovimentosFixosParams = {
movimento?: string; movimento?: string;
idbancos?: number | ''; idbancos?: number | '';
idcentrodecustos?: number | ''; idcentrodecustos?: number | '';
idclientes?: number | '';
habilitado?: number | ''; habilitado?: number | '';
diaInicio?: number | ''; diaInicio?: number | '';
diaFim?: number | ''; diaFim?: number | '';

View File

@ -7,6 +7,7 @@ export type MovimentoFixo = {
valor: number; valor: number;
dia_vencimento: number; dia_vencimento: number;
idcentrodecustos: number | null; idcentrodecustos: number | null;
idclientes?: number | null;
habilitado: number; habilitado: number;
idbancos: number | null; idbancos: number | null;
insert_date: string | null; insert_date: string | null;
@ -14,6 +15,7 @@ export type MovimentoFixo = {
banco_descricao?: string | null; banco_descricao?: string | null;
centro_custo_descricao?: string | null; centro_custo_descricao?: string | null;
cliente_nome?: string | null;
}; };
export type CriarMovimentoFixoRequest = { export type CriarMovimentoFixoRequest = {
@ -22,6 +24,7 @@ export type CriarMovimentoFixoRequest = {
valor: number; valor: number;
dia_vencimento: number; dia_vencimento: number;
idcentrodecustos: number | null; idcentrodecustos: number | null;
idclientes?: number | null;
habilitado: number; habilitado: number;
idbancos: number | null; idbancos: number | null;
}; };

View File

@ -11,6 +11,11 @@ export async function listarBancos(): Promise<Banco[]> {
return response.data.data; return response.data.data;
} }
export async function listarMeusBancos() {
const response = await api.get('/referencias/bancos/meus');
return response.data.data;
}
export async function listarCentrosCusto(): Promise<CentroCusto[]> { export async function listarCentrosCusto(): Promise<CentroCusto[]> {
const response = await api.get<ApiListResponse<CentroCusto>>('/referencias/centros-custo'); const response = await api.get<ApiListResponse<CentroCusto>>('/referencias/centros-custo');
return response.data.data; return response.data.data;

View File

@ -0,0 +1,757 @@
import { useMemo, useState } from 'react';
import type { ReactNode } from 'react';
import {
Alert,
Box,
Button,
Card,
CardContent,
Chip,
CircularProgress,
Divider,
IconButton,
MenuItem,
Paper,
Stack,
TextField,
Tooltip,
Typography,
} from '@mui/material';
import AddIcon from '@mui/icons-material/Add';
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
import DeleteIcon from '@mui/icons-material/Delete';
import EditNoteIcon from '@mui/icons-material/EditNote';
import RefreshIcon from '@mui/icons-material/Refresh';
import SavingsIcon from '@mui/icons-material/Savings';
import TrendingDownIcon from '@mui/icons-material/TrendingDown';
import TrendingUpIcon from '@mui/icons-material/TrendingUp';
import WarningAmberIcon from '@mui/icons-material/WarningAmber';
import AccountBalanceIcon from '@mui/icons-material/AccountBalance';
import { buscarBaseSimulacaoMensal } from '../services/simulacaoMensalService';
import type {
SimulacaoMensalBase,
SimulacaoMensalItem,
SimulacaoMensalOrigemItem,
SimulacaoMensalResumo,
SimulacaoMensalTipoItem,
} from '../types/simulacaoMensalTypes';
const meses = [
{ value: 1, label: 'Janeiro' },
{ value: 2, label: 'Fevereiro' },
{ value: 3, label: 'Março' },
{ value: 4, label: 'Abril' },
{ value: 5, label: 'Maio' },
{ value: 6, label: 'Junho' },
{ value: 7, label: 'Julho' },
{ value: 8, label: 'Agosto' },
{ value: 9, label: 'Setembro' },
{ value: 10, label: 'Outubro' },
{ value: 11, label: 'Novembro' },
{ value: 12, label: 'Dezembro' },
];
function mesAtual() {
return new Date().getMonth() + 1;
}
function anoAtual() {
return new Date().getFullYear();
}
function formatarValor(valor: number) {
return new Intl.NumberFormat('pt-BR', {
style: 'currency',
currency: 'BRL',
}).format(Number(valor || 0));
}
function formatarData(data?: string | null) {
if (!data) return '-';
return new Date(`${String(data).slice(0, 10)}T00:00:00`).toLocaleDateString('pt-BR');
}
function formatarPercentual(valor?: number | null) {
if (valor === null || valor === undefined || !Number.isFinite(Number(valor))) {
return '-';
}
return `${Number(valor).toFixed(2)}%`;
}
function calcularPercentual(valor: number, totalEntradas: number) {
if (!totalEntradas) return null;
return Number(((Number(valor || 0) / totalEntradas) * 100).toFixed(2));
}
function calcularResumoLocal(
entradas: SimulacaoMensalItem[],
saidas: SimulacaoMensalItem[]
): SimulacaoMensalResumo {
const totalEntradas = entradas.reduce((total, item) => total + Number(item.valor || 0), 0);
const totalSaidas = saidas.reduce((total, item) => total + Number(item.valor || 0), 0);
const totalInvestimentos = saidas
.filter((item) => Number(item.investimento) === 1)
.reduce((total, item) => total + Number(item.valor || 0), 0);
const totalGastos = totalSaidas - totalInvestimentos;
const resultado = totalEntradas - totalSaidas;
return {
totalEntradas,
totalSaidas,
totalGastos,
totalInvestimentos,
resultado,
percentualEntradas: totalEntradas > 0 ? 100 : null,
percentualSaidas: calcularPercentual(totalSaidas, totalEntradas),
percentualGastos: calcularPercentual(totalGastos, totalEntradas),
percentualInvestimentos: calcularPercentual(totalInvestimentos, totalEntradas),
percentualResultado: calcularPercentual(resultado, totalEntradas),
percentualComprometido: calcularPercentual(totalSaidas, totalEntradas),
quantidadeEntradas: entradas.length,
quantidadeSaidas: saidas.length,
quantidadeInvestimentos: saidas.filter((item) => Number(item.investimento) === 1).length,
};
}
function origemLabel(origem: string) {
const labels: Record<string, string> = {
movimento_existente: 'Movimento',
movimento_fixo_previsto: 'Fixo previsto',
saldo_banco: 'Saldo banco',
cartao_credito_agrupado: 'Cartão',
limite_centro_custo: 'Limite centro',
manual: 'Manual',
};
return labels[origem] || origem;
}
function origemColor(origem: string) {
if (origem === 'manual') return 'secondary';
if (origem === 'saldo_banco') return 'success';
if (origem === 'cartao_credito_agrupado') return 'warning';
if (origem === 'movimento_fixo_previsto') return 'info';
if (origem === 'limite_centro_custo') return 'error';
return 'default';
}
function criarItemManual(tipo: SimulacaoMensalTipoItem): SimulacaoMensalItem {
const id = `manual:${tipo}:${Date.now()}:${Math.random().toString(16).slice(2)}`;
return {
id,
tipo,
origem: 'manual',
descricao: tipo === 'entrada' ? 'Nova entrada' : 'Nova saída',
valor: 0,
valor_original: 0,
movimento: tipo === 'entrada' ? 'Entrada' : 'Saida',
status: 'Simulado',
data: null,
editavel: true,
editado: true,
};
}
type SimulacaoListaProps = {
titulo: string;
tipo: SimulacaoMensalTipoItem;
itens: SimulacaoMensalItem[];
total: number;
onAlterarItem: (tipo: SimulacaoMensalTipoItem, id: string, patch: Partial<SimulacaoMensalItem>) => void;
onRemoverItem: (tipo: SimulacaoMensalTipoItem, id: string) => void;
onAdicionarItem: (tipo: SimulacaoMensalTipoItem) => void;
};
function SimulacaoLista({
titulo,
tipo,
itens,
total,
onAlterarItem,
onRemoverItem,
onAdicionarItem,
}: SimulacaoListaProps) {
const isEntrada = tipo === 'entrada';
return (
<Paper
elevation={0}
sx={{
borderRadius: 4,
border: '1px solid',
borderColor: 'divider',
overflow: 'hidden',
backgroundColor: '#FFFFFF',
}}
>
<Box
sx={{
padding: 2,
background: isEntrada
? 'linear-gradient(135deg, rgba(34,197,94,0.10), rgba(255,255,255,0.9))'
: 'linear-gradient(135deg, rgba(239,68,68,0.10), rgba(255,255,255,0.9))',
}}
>
<Stack
direction={{ xs: 'column', sm: 'row' }}
justifyContent="space-between"
alignItems={{ xs: 'stretch', sm: 'center' }}
spacing={1.5}
>
<Stack direction="row" alignItems="center" spacing={1}>
{isEntrada ? <TrendingUpIcon color="success" /> : <TrendingDownIcon color="error" />}
<Box>
<Typography variant="h6" fontWeight={950}>
{titulo}
</Typography>
<Typography variant="body2" color="text.secondary">
{itens.length} item(ns)
</Typography>
</Box>
</Stack>
<Stack direction="row" spacing={1} alignItems="center">
<Chip
label={formatarValor(total)}
color={isEntrada ? 'success' : 'error'}
sx={{ fontWeight: 900 }}
/>
<Button
size="small"
variant="outlined"
startIcon={<AddIcon />}
onClick={() => onAdicionarItem(tipo)}
>
Adicionar
</Button>
</Stack>
</Stack>
</Box>
<Divider />
<Stack spacing={1.25} padding={1.5}>
{itens.length === 0 ? (
<Box padding={2}>
<Typography fontWeight={800}>
Nenhum item.
</Typography>
<Typography variant="body2" color="text.secondary">
Adicione manualmente ou preencha a previsão pela API.
</Typography>
</Box>
) : (
itens.map((item) => (
<Paper
key={item.id}
elevation={0}
sx={{
padding: 1.5,
borderRadius: 2.5,
border: '1px solid',
borderColor: item.editado ? 'primary.light' : 'divider',
backgroundColor: item.editado ? 'rgba(59,130,246,0.05)' : 'rgba(248,250,252,0.75)',
}}
>
<Stack spacing={1.25}>
<Stack
direction={{ xs: 'column', md: 'row' }}
spacing={1}
alignItems={{ xs: 'stretch', md: 'center' }}
>
<TextField
label="Descrição"
value={item.descricao}
size="small"
fullWidth
onChange={(event) =>
onAlterarItem(tipo, item.id, {
descricao: event.target.value,
editado: true,
})
}
/>
<TextField
label="Valor"
type="number"
value={item.valor}
size="small"
sx={{ width: { xs: '100%', md: 170 } }}
inputProps={{
min: 0,
step: '0.01',
}}
onChange={(event) =>
onAlterarItem(tipo, item.id, {
valor: Number(event.target.value || 0),
editado: true,
})
}
/>
<Tooltip title="Remover da simulação">
<IconButton
color="error"
onClick={() => onRemoverItem(tipo, item.id)}
>
<DeleteIcon />
</IconButton>
</Tooltip>
</Stack>
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
<Chip
size="small"
label={origemLabel(item.origem)}
color={origemColor(item.origem) as any}
variant="outlined"
/>
{item.editado && (
<Chip
size="small"
label="Editado"
color="primary"
icon={<EditNoteIcon />}
variant="outlined"
/>
)}
{item.data && (
<Chip
size="small"
label={formatarData(item.data)}
variant="outlined"
/>
)}
{item.banco_descricao && (
<Chip
size="small"
label={item.banco_descricao}
variant="outlined"
/>
)}
{item.centro_custo_descricao && (
<Chip
size="small"
label={item.centro_custo_descricao}
variant="outlined"
/>
)}
{item.cliente_nome && (
<Chip
size="small"
label={item.cliente_nome}
variant="outlined"
/>
)}
{Number(item.valor_original || 0) !== Number(item.valor || 0) && (
<Chip
size="small"
label={`Original ${formatarValor(item.valor_original)}`}
color="warning"
variant="outlined"
/>
)}
</Stack>
</Stack>
</Paper>
))
)}
</Stack>
</Paper>
);
}
export function SimulacaoMensalPage() {
const [ano, setAno] = useState(anoAtual());
const [mes, setMes] = useState(mesAtual());
const [incluirQuitadas, setIncluirQuitadas] = useState(false);
const [incluirFixosVencidos, setIncluirFixosVencidos] = useState(false);
const [base, setBase] = useState<SimulacaoMensalBase | null>(null);
const [entradas, setEntradas] = useState<SimulacaoMensalItem[]>([]);
const [saidas, setSaidas] = useState<SimulacaoMensalItem[]>([]);
const [loading, setLoading] = useState(false);
const [erro, setErro] = useState('');
const [sucesso, setSucesso] = useState('');
const resumo = useMemo(() => {
return calcularResumoLocal(entradas, saidas);
}, [entradas, saidas]);
async function carregarBase() {
try {
setLoading(true);
setErro('');
setSucesso('');
const data = await buscarBaseSimulacaoMensal({
ano,
mes,
incluirQuitadas,
incluirFixosVencidosDoMesAtual: incluirFixosVencidos,
});
setBase(data);
setEntradas(data.entradas || []);
setSaidas(data.saidas || []);
setSucesso('Previsão preenchida com sucesso.');
} catch (error: any) {
const message =
error?.response?.data?.message ||
'Não foi possível montar a simulação mensal.';
setErro(message);
} finally {
setLoading(false);
}
}
function limparSimulacao() {
setBase(null);
setEntradas([]);
setSaidas([]);
setErro('');
setSucesso('');
}
function alterarItem(
tipo: SimulacaoMensalTipoItem,
id: string,
patch: Partial<SimulacaoMensalItem>
) {
const atualizar = (lista: SimulacaoMensalItem[]) =>
lista.map((item) =>
item.id === id
? {
...item,
...patch,
}
: item
);
if (tipo === 'entrada') {
setEntradas((listaAtual) => atualizar(listaAtual));
} else {
setSaidas((listaAtual) => atualizar(listaAtual));
}
}
function removerItem(tipo: SimulacaoMensalTipoItem, id: string) {
if (tipo === 'entrada') {
setEntradas((listaAtual) => listaAtual.filter((item) => item.id !== id));
} else {
setSaidas((listaAtual) => listaAtual.filter((item) => item.id !== id));
}
}
function adicionarItem(tipo: SimulacaoMensalTipoItem) {
const novoItem = criarItemManual(tipo);
if (tipo === 'entrada') {
setEntradas((listaAtual) => [novoItem, ...listaAtual]);
} else {
setSaidas((listaAtual) => [novoItem, ...listaAtual]);
}
}
const resultadoColor =
resumo.resultado > 0
? 'success.main'
: resumo.resultado < 0
? 'error.main'
: 'text.primary';
function ResumoCard({
icon,
label,
valor,
percentual,
color,
}: {
icon: ReactNode;
label: string;
valor: number;
percentual?: number | null;
color: string;
}) {
return (
<Card>
<CardContent>
<Stack direction="row" spacing={1} alignItems="center">
{icon}
<Typography variant="body2" color="text.secondary">
{label}
</Typography>
</Stack>
<Typography variant="h5" fontWeight={950} color={color}>
{formatarValor(valor)}
</Typography>
<Typography variant="caption" color="text.secondary">
{formatarPercentual(percentual)} das entradas
</Typography>
</CardContent>
</Card>
);
}
return (
<Box>
<Stack
direction={{ xs: 'column', md: 'row' }}
justifyContent="space-between"
alignItems={{ xs: 'stretch', md: 'center' }}
spacing={2}
marginBottom={{ xs: 3, md: 4 }}
>
<Box>
<Chip
icon={<AutoAwesomeIcon />}
label="Laboratório financeiro"
color="primary"
variant="outlined"
sx={{ marginBottom: 1.25, fontWeight: 700 }}
/>
<Typography variant="h4" fontWeight={950}>
Simulação mensal
</Typography>
<Typography color="text.secondary" sx={{ marginTop: 0.5 }}>
Monte uma previsão do mês, ajuste valores livremente e veja quanto sobra.
</Typography>
</Box>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1.5}>
<Button
variant="outlined"
startIcon={<RefreshIcon />}
onClick={limparSimulacao}
disabled={loading}
>
Limpar
</Button>
<Button
variant="contained"
startIcon={
loading
? <CircularProgress size={18} color="inherit" />
: <AutoAwesomeIcon />
}
onClick={carregarBase}
disabled={loading}
sx={{ boxShadow: 3 }}
>
{loading ? 'Preenchendo...' : 'Preencher previsão'}
</Button>
</Stack>
</Stack>
{erro && (
<Alert severity="error" sx={{ marginBottom: 2.5 }}>
{erro}
</Alert>
)}
{sucesso && (
<Alert severity="success" sx={{ marginBottom: 2.5 }}>
{sucesso}
</Alert>
)}
<Card sx={{ marginBottom: 3 }}>
<CardContent sx={{ padding: { xs: 2, md: 3 } }}>
<Box
display="grid"
gridTemplateColumns={{
xs: '1fr',
sm: '1fr 1fr',
md: 'repeat(4, 1fr)',
}}
gap={2}
>
<TextField
select
label="Mês"
value={mes}
onChange={(event) => setMes(Number(event.target.value))}
fullWidth
>
{meses.map((item) => (
<MenuItem key={item.value} value={item.value}>
{item.label}
</MenuItem>
))}
</TextField>
<TextField
label="Ano"
type="number"
value={ano}
onChange={(event) => setAno(Number(event.target.value))}
fullWidth
/>
<TextField
select
label="Incluir já quitadas?"
value={incluirQuitadas ? 1 : 0}
onChange={(event) => setIncluirQuitadas(Number(event.target.value) === 1)}
fullWidth
>
<MenuItem value={0}>Não, em aberto</MenuItem>
<MenuItem value={1}>Sim, incluir quitadas</MenuItem>
</TextField>
<TextField
select
label="Fixos vencidos no mês atual"
value={incluirFixosVencidos ? 1 : 0}
onChange={(event) => setIncluirFixosVencidos(Number(event.target.value) === 1)}
fullWidth
>
<MenuItem value={0}>Ignorar vencidos</MenuItem>
<MenuItem value={1}>Incluir vencidos</MenuItem>
</TextField>
</Box>
</CardContent>
</Card>
<Box
display="grid"
gridTemplateColumns={{
xs: '1fr',
sm: '1fr 1fr',
lg: 'repeat(5, 1fr)',
}}
gap={2}
marginBottom={3}
>
<ResumoCard
icon={<TrendingUpIcon color="success" />}
label="Entradas"
valor={resumo.totalEntradas}
percentual={resumo.percentualEntradas}
color="success.main"
/>
<ResumoCard
icon={<TrendingDownIcon color="error" />}
label="Gastos"
valor={resumo.totalGastos || 0}
percentual={resumo.percentualGastos}
color="error.main"
/>
<ResumoCard
icon={<AccountBalanceIcon color="info" />}
label="Investimentos"
valor={resumo.totalInvestimentos || 0}
percentual={resumo.percentualInvestimentos}
color="info.main"
/>
<ResumoCard
icon={<TrendingDownIcon color="warning" />}
label="Saídas totais"
valor={resumo.totalSaidas}
percentual={resumo.percentualSaidas}
color="warning.main"
/>
<ResumoCard
icon={<SavingsIcon color={resumo.resultado >= 0 ? 'success' : 'error'} />}
label="Resultado"
valor={resumo.resultado}
percentual={resumo.percentualResultado}
color={resultadoColor}
/>
</Box>
{base?.avisos && base.avisos.length > 0 && (
<Stack spacing={1} marginBottom={3}>
{base.avisos.map((aviso, index) => (
<Alert key={`${aviso.tipo}-${index}`} severity="warning">
{aviso.mensagem}
</Alert>
))}
</Stack>
)}
{base && (
<Paper
elevation={0}
sx={{
padding: 2,
marginBottom: 3,
borderRadius: 3,
border: '1px solid',
borderColor: 'divider',
backgroundColor: 'rgba(255,255,255,0.75)',
}}
>
<Stack direction="row" flexWrap="wrap" spacing={1} useFlexGap>
<Chip label={`Competência ${base.competencia}`} variant="outlined" />
<Chip label={`${base.fontes.movimentosExistentes} movimentos existentes`} variant="outlined" />
<Chip label={`${base.fontes.movimentosFixos} fixos analisados`} variant="outlined" />
<Chip label={`${base.fontes.bancos} bancos`} variant="outlined" />
<Chip label={`${base.fontes.centrosSimulaveis} centros simuláveis`} variant="outlined" />
</Stack>
</Paper>
)}
<Box
display="grid"
gridTemplateColumns={{
xs: '1fr',
xl: '1fr 1fr',
}}
gap={3}
>
<SimulacaoLista
titulo="Entradas previstas"
tipo="entrada"
itens={entradas}
total={resumo.totalEntradas}
onAlterarItem={alterarItem}
onRemoverItem={removerItem}
onAdicionarItem={adicionarItem}
/>
<SimulacaoLista
titulo="Saídas previstas"
tipo="saida"
itens={saidas}
total={resumo.totalSaidas}
onAlterarItem={alterarItem}
onRemoverItem={removerItem}
onAdicionarItem={adicionarItem}
/>
</Box>
</Box>
);
}

View File

@ -0,0 +1,20 @@
import { api } from '../../../services/api';
import type {
BuscarBaseSimulacaoMensalParams,
SimulacaoMensalBase,
} from '../types/simulacaoMensalTypes';
export async function buscarBaseSimulacaoMensal(
params: BuscarBaseSimulacaoMensalParams
): Promise<SimulacaoMensalBase> {
const response = await api.get('/simulacao-mensal/base', {
params: {
ano: params.ano,
mes: params.mes,
incluirQuitadas: params.incluirQuitadas ? 1 : 0,
incluirFixosVencidosDoMesAtual: params.incluirFixosVencidosDoMesAtual ? 1 : 0,
},
});
return response.data.data;
}

View File

@ -0,0 +1,101 @@
export type SimulacaoMensalTipoItem = 'entrada' | 'saida';
export type SimulacaoMensalOrigemItem =
| 'movimento_existente'
| 'movimento_fixo_previsto'
| 'saldo_banco'
| 'cartao_credito_agrupado'
| 'limite_centro_custo'
| 'manual';
export type SimulacaoMensalItem = {
id: string;
tipo: SimulacaoMensalTipoItem;
origem: SimulacaoMensalOrigemItem | string;
descricao: string;
valor: number;
valor_original: number;
movimento?: string | null;
status?: string | null;
data?: string | null;
idcontasapagar?: number | null;
idmovimentosfixos?: number | null;
idbancos?: number | null;
banco_descricao?: string | null;
banco_debito?: number | null;
idcentrodecustos?: number | null;
centro_custo_descricao?: string | null;
investimento?: number | null;
idclientes?: number | null;
cliente_nome?: string | null;
editavel: boolean;
editado: boolean;
detalhes?: any;
};
export type SimulacaoMensalResumo = {
totalEntradas: number;
totalSaidas: number;
totalGastos?: number;
totalInvestimentos?: number;
resultado: number;
percentualEntradas?: number | null;
percentualSaidas?: number | null;
percentualGastos?: number | null;
percentualInvestimentos?: number | null;
percentualResultado?: number | null;
percentualComprometido: number | null;
quantidadeEntradas: number;
quantidadeSaidas: number;
quantidadeInvestimentos?: number;
};
export type SimulacaoMensalAviso = {
tipo: string;
mensagem: string;
[key: string]: any;
};
export type SimulacaoMensalBase = {
competencia: string;
ano: number;
mes: number;
dataInicio: string;
dataFim: string;
opcoes: {
incluirQuitadas: boolean;
incluirFixosVencidosDoMesAtual: boolean;
};
entradas: SimulacaoMensalItem[];
saidas: SimulacaoMensalItem[];
resumo: SimulacaoMensalResumo;
fontes: {
movimentosExistentes: number;
movimentosFixos: number;
bancos: number;
centrosSimulaveis: number;
};
avisos: SimulacaoMensalAviso[];
};
export type BuscarBaseSimulacaoMensalParams = {
ano: number;
mes: number;
incluirQuitadas?: boolean;
incluirFixosVencidosDoMesAtual?: boolean;
};