From 759e642567180d0648e9ee735394031309c1ad24 Mon Sep 17 00:00:00 2001 From: Diego Freitas Date: Tue, 2 Jun 2026 22:28:10 -0300 Subject: [PATCH] =?UTF-8?q?adicionado=20extrato=20e=20simula=C3=A7=C3=A3o?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- financeiro-api/src/app.js | 2 + .../controllers/movimentos.controller.js | 13 +- .../movimentos/services/movimentos.service.js | 22 +- .../services/movimentosSaldo.service.js | 211 ++++- .../controllers/movimentosFixos.controller.js | 42 +- .../services/movimentosFixos.service.js | 116 ++- .../movimentosFixosGeracao.service.js | 10 +- .../controllers/referencias.controller.js | 19 + .../referencias/routes/referencias.routes.js | 2 + .../services/referencias.service.js | 47 +- .../controllers/simulacaoMensal.controller.js | 51 + .../routes/simulacaoMensal.routes.js | 11 + .../services/simulacaoMensal.service.js | 790 ++++++++++++++++ financeiro-web/src/app/router.tsx | 5 + .../src/components/layout/Sidebar.tsx | 6 + .../movimentos/components/MovimentoForm.tsx | 268 ++++-- .../movimentos/pages/MovimentosPage.tsx | 870 +++++++++++++++++- .../movimentos/types/movimentoTypes.ts | 9 + .../components/MovimentoFIxoForm.tsx | 48 +- .../pages/MovimentosFixosPage.tsx | 51 +- .../services/movimentosFixosService.ts | 1 + .../types/MovimentoFixoTypes.ts | 3 + .../services/referenciasService.ts | 5 + .../pages/SimulacaoMensalPage.tsx | 757 +++++++++++++++ .../services/simulacaoMensalService.ts | 20 + .../types/simulacaoMensalTypes.ts | 101 ++ 26 files changed, 3317 insertions(+), 163 deletions(-) create mode 100644 financeiro-api/src/modules/simulacaoMensal/controllers/simulacaoMensal.controller.js create mode 100644 financeiro-api/src/modules/simulacaoMensal/routes/simulacaoMensal.routes.js create mode 100644 financeiro-api/src/modules/simulacaoMensal/services/simulacaoMensal.service.js create mode 100644 financeiro-web/src/features/simulacaoMensal/pages/SimulacaoMensalPage.tsx create mode 100644 financeiro-web/src/features/simulacaoMensal/services/simulacaoMensalService.ts create mode 100644 financeiro-web/src/features/simulacaoMensal/types/simulacaoMensalTypes.ts diff --git a/financeiro-api/src/app.js b/financeiro-api/src/app.js index ed0be74..c7072c1 100644 --- a/financeiro-api/src/app.js +++ b/financeiro-api/src/app.js @@ -13,6 +13,7 @@ const movimentosFixosRoutes = require('./modules/movimentosFixos/routes/moviment const usuariosRoutes = require('./modules/usuarios/routes/usuarios.routes'); const quitacoesCreditoRoutes = require('./modules/quitacoesCredito/routes/quitacoesCredito.routes'); const dashboardRoutes = require('./modules/dashboard/routes/dashboard.routes'); +const simulacaoMensalRoutes = require('./modules/simulacaoMensal/routes/simulacaoMensal.routes'); const app = express(); @@ -48,6 +49,7 @@ app.use('/api/movimentos-fixos', movimentosFixosRoutes); app.use('/api/usuarios', usuariosRoutes); app.use('/api/quitacoes-credito', quitacoesCreditoRoutes); app.use('/api/dashboard', dashboardRoutes); +app.use('/api/simulacao-mensal', simulacaoMensalRoutes); app.use((req, res) => { return res.status(404).json({ diff --git a/financeiro-api/src/modules/movimentos/controllers/movimentos.controller.js b/financeiro-api/src/modules/movimentos/controllers/movimentos.controller.js index cade1e6..2ede1e3 100644 --- a/financeiro-api/src/modules/movimentos/controllers/movimentos.controller.js +++ b/financeiro-api/src/modules/movimentos/controllers/movimentos.controller.js @@ -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({ ok: true, diff --git a/financeiro-api/src/modules/movimentos/services/movimentos.service.js b/financeiro-api/src/modules/movimentos/services/movimentos.service.js index dd9aca8..320b702 100644 --- a/financeiro-api/src/modules/movimentos/services/movimentos.service.js +++ b/financeiro-api/src/modules/movimentos/services/movimentos.service.js @@ -791,6 +791,7 @@ async function criarMovimento(idUsuario, dados) { tipo_operacao: 'movimento_aplicado', origem: movimentoCriado.origem || 'movimento', idusuarios: idUsuario, + permitirBancoDestinoGlobal: movimentoCriado.movimento === 'Sangria', } ); @@ -882,6 +883,7 @@ async function atualizarMovimento(id, idUsuario, dados) { tipo_operacao: 'movimento_revertido', origem: 'edicao_movimento', idusuarios: idUsuario, + permitirBancoDestinoGlobal: movimentoAntigo.movimento === 'Sangria', } ); @@ -902,6 +904,7 @@ async function atualizarMovimento(id, idUsuario, dados) { tipo_operacao: 'movimento_aplicado', origem: 'edicao_movimento', idusuarios: idUsuario, + permitirBancoDestinoGlobal: movimentoNovo.movimento === 'Sangria', } ); @@ -977,6 +980,7 @@ async function deletarMovimento(id, idUsuario) { tipo_operacao: 'movimento_revertido', origem: 'exclusao_movimento', 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; if (idcontasapagar) { - movimentoAntigo = await buscarMovimentoPorId(idcontasapagar); + movimentoAntigo = await buscarMovimentoPorId(idcontasapagar, { + idUsuario, + }); } - const movimentoNovo = normalizarMovimentoParaSalvar(null, dados, { - idusuarios_cad: dados.idusuarios_cad || null, + const movimentoNovo = normalizarMovimentoParaSalvar(idUsuario, dados, { + idusuarios_cad: dados.idusuarios_cad || idUsuario || null, grupo_parcelamento: dados.grupo_parcelamento || null, origem: dados.origem || 'manual', }); - return movimentosSaldoService.preverImpactoSaldo(movimentoNovo, movimentoAntigo); + return movimentosSaldoService.preverImpactoSaldo( + movimentoNovo, + movimentoAntigo, + { + idusuarios: idUsuario || null, + } + ); } module.exports = { diff --git a/financeiro-api/src/modules/movimentos/services/movimentosSaldo.service.js b/financeiro-api/src/modules/movimentos/services/movimentosSaldo.service.js index d18cac3..e7a95cc 100644 --- a/financeiro-api/src/modules/movimentos/services/movimentosSaldo.service.js +++ b/financeiro-api/src/modules/movimentos/services/movimentosSaldo.service.js @@ -12,6 +12,70 @@ function bancoEhCredito(banco) { 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) { if (!idbancos) return null; @@ -19,22 +83,27 @@ async function buscarBanco(connection, idbancos, idusuarios = null) { let filtroUsuario = ''; if (idusuarios) { - filtroUsuario = 'AND idusuarios = ?'; + filtroUsuario = 'AND b.idusuarios = ?'; params.push(idusuarios); } const [rows] = await connection.query( ` SELECT - idbancos, - descricao, - saldo, - debito, - investimento, - idusuarios - FROM bancos - WHERE idbancos = ? - ${filtroUsuario} + b.idbancos, + b.descricao, + b.descricao AS banco_nome, + b.saldo, + b.debito, + b.investimento, + b.idusuarios, + u.nome AS usuario_nome + FROM bancos b + LEFT JOIN usuarios u + ON u.idusuarios = b.idusuarios + WHERE b.idbancos = ? + AND b.habilitado = 1 + ${filtroUsuario} LIMIT 1 `, params @@ -50,22 +119,27 @@ async function buscarBancoParaUpdate(connection, idbancos, idusuarios = null) { let filtroUsuario = ''; if (idusuarios) { - filtroUsuario = 'AND idusuarios = ?'; + filtroUsuario = 'AND b.idusuarios = ?'; params.push(idusuarios); } const [rows] = await connection.query( ` SELECT - idbancos, - descricao, - saldo, - debito, - investimento, - idusuarios - FROM bancos - WHERE idbancos = ? - ${filtroUsuario} + b.idbancos, + b.descricao, + b.descricao AS banco_nome, + b.saldo, + b.debito, + b.investimento, + b.idusuarios, + u.nome AS usuario_nome + FROM bancos b + LEFT JOIN usuarios u + ON u.idusuarios = b.idusuarios + WHERE b.idbancos = ? + AND b.habilitado = 1 + ${filtroUsuario} LIMIT 1 FOR UPDATE `, @@ -168,8 +242,27 @@ function calcularDeltasParaMovimento(movimento, bancoOrigem, bancoDestino = null } async function calcularImpactoMovimento(connection, movimento, contexto = {}) { - const bancoOrigem = await buscarBanco(connection, movimento.idbancos, contexto.idusuarios); - const bancoDestino = await buscarBanco(connection, movimento.idbancos_p, contexto.idusuarios); + const bancoOrigem = await buscarBanco( + 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); } @@ -186,15 +279,26 @@ async function aplicarDeltasSaldo(connection, deltas, contexto = {}) { const impactos = []; 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) { continue; } - const saldoAnterior = Number(banco.saldo || 0); - const valorDelta = Number(delta.valor_delta || 0); - const saldoPosterior = saldoAnterior + valorDelta; + const impacto = montarImpactoSaldo(delta, banco); await connection.query( ` @@ -205,7 +309,7 @@ async function aplicarDeltasSaldo(connection, deltas, contexto = {}) { WHERE idbancos = ? `, [ - saldoPosterior, + impacto.saldo_posterior, banco.idbancos, ] ); @@ -232,42 +336,65 @@ async function aplicarDeltasSaldo(connection, deltas, contexto = {}) { contexto.idcontasquitadas || null, contexto.tipo_operacao || null, contexto.origem || null, - valorDelta, - saldoAnterior, - saldoPosterior, + impacto.valor_delta, + impacto.saldo_anterior, + impacto.saldo_posterior, delta.descricao || null, contexto.idusuarios || null, ] ); - impactos.push({ - idbancos: banco.idbancos, - banco_descricao: banco.descricao, - valor_delta: valorDelta, - saldo_anterior: saldoAnterior, - saldo_posterior: saldoPosterior, - descricao: delta.descricao || null, - }); + impactos.push(impacto); } 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(); try { const deltas = []; if (movimentoAntigo) { - const deltasAntigos = await calcularImpactoMovimento(connection, movimentoAntigo); + const deltasAntigos = await calcularImpactoMovimento( + connection, + movimentoAntigo, + { + idusuarios: contexto.idusuarios || null, + } + ); + deltas.push(...inverterDeltas(deltasAntigos)); } - const deltasNovos = await calcularImpactoMovimento(connection, dados); + const deltasNovos = await calcularImpactoMovimento( + connection, + dados, + { + idusuarios: contexto.idusuarios || null, + } + ); + deltas.push(...deltasNovos); - return deltas; + return enriquecerDeltasComoPrevisao(connection, deltas); } finally { connection.release(); } diff --git a/financeiro-api/src/modules/movimentosFixos/controllers/movimentosFixos.controller.js b/financeiro-api/src/modules/movimentosFixos/controllers/movimentosFixos.controller.js index d93637f..3b2d12c 100644 --- a/financeiro-api/src/modules/movimentosFixos/controllers/movimentosFixos.controller.js +++ b/financeiro-api/src/modules/movimentosFixos/controllers/movimentosFixos.controller.js @@ -1,5 +1,15 @@ 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) { if (!dados.movimento) { 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 !== '') { const idbancos = Number(dados.idbancos); @@ -64,6 +82,8 @@ function validarDadosMovimentoFixo(dados) { async function listar(req, res) { try { + const idUsuario = extrairIdUsuarioLogado(req); + const resultado = await movimentosFixosService.listarMovimentosFixos({ limite: req.query.limite, offset: req.query.offset, @@ -72,11 +92,13 @@ async function listar(req, res) { movimento: req.query.movimento, idbancos: req.query.idbancos, idcentrodecustos: req.query.idcentrodecustos, + idclientes: req.query.idclientes, habilitado: req.query.habilitado, diaInicio: req.query.diaInicio, diaFim: req.query.diaFim, orderBy: req.query.orderBy, orderDirection: req.query.orderDirection, + idUsuario, }); return res.json({ @@ -97,6 +119,8 @@ async function listar(req, res) { async function buscarPorId(req, res) { try { + const idUsuario = extrairIdUsuarioLogado(req); + const id = Number(req.params.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) { return res.status(404).json({ @@ -131,6 +155,8 @@ async function buscarPorId(req, res) { async function criar(req, res) { try { + const idUsuario = extrairIdUsuarioLogado(req); + const erroValidacao = validarDadosMovimentoFixo(req.body); 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({ ok: true, @@ -159,6 +185,8 @@ async function criar(req, res) { async function atualizar(req, res) { try { + const idUsuario = extrairIdUsuarioLogado(req); + const id = Number(req.params.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) { return res.status(404).json({ @@ -203,6 +231,8 @@ async function atualizar(req, res) { async function alterarHabilitado(req, res) { try { + const idUsuario = extrairIdUsuarioLogado(req); + const id = Number(req.params.id); if (!id) { @@ -222,7 +252,7 @@ async function alterarHabilitado(req, res) { } const movimentoAtualizado = - await movimentosFixosService.alterarHabilitadoMovimentoFixo(id, habilitado); + await movimentosFixosService.alterarHabilitadoMovimentoFixo(id, habilitado, idUsuario); if (!movimentoAtualizado) { return res.status(404).json({ @@ -250,6 +280,8 @@ async function alterarHabilitado(req, res) { async function deletar(req, res) { try { + const idUsuario = extrairIdUsuarioLogado(req); + const id = Number(req.params.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) { return res.status(404).json({ diff --git a/financeiro-api/src/modules/movimentosFixos/services/movimentosFixos.service.js b/financeiro-api/src/modules/movimentosFixos/services/movimentosFixos.service.js index 9fa9456..d7698d6 100644 --- a/financeiro-api/src/modules/movimentosFixos/services/movimentosFixos.service.js +++ b/financeiro-api/src/modules/movimentosFixos/services/movimentosFixos.service.js @@ -7,18 +7,21 @@ const CAMPOS_MOVIMENTO_FIXO_SELECT = ` mf.valor, mf.dia_vencimento, mf.idcentrodecustos, + mf.idclientes, mf.habilitado, mf.idbancos, mf.insert_date, mf.update_date, 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 = ` 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 clientes c ON c.idclientes = mf.idclientes `; function normalizarTextoOuNull(valor) { @@ -91,12 +94,14 @@ function resolverOrdenacao(orderBy) { valor: 'mf.valor', dia_vencimento: 'mf.dia_vencimento', idcentrodecustos: 'mf.idcentrodecustos', + idclientes: 'mf.idclientes', idbancos: 'mf.idbancos', habilitado: 'mf.habilitado', insert_date: 'mf.insert_date', update_date: 'mf.update_date', banco_descricao: 'b.descricao', centro_custo_descricao: 'cc.descricao', + cliente_nome: 'c.nome', }; return camposPermitidos[orderBy] || 'mf.dia_vencimento'; @@ -110,6 +115,11 @@ function montarWhereMovimentosFixos(filtros = {}) { const where = []; const params = []; + if (filtros.idUsuario) { + where.push('b.idusuarios = ?'); + params.push(Number(filtros.idUsuario)); + } + if (filtros.movimento) { where.push('mf.movimento = ?'); params.push(filtros.movimento); @@ -125,6 +135,11 @@ function montarWhereMovimentosFixos(filtros = {}) { 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 !== '') { where.push('mf.habilitado = ?'); params.push(Number(filtros.habilitado)); @@ -147,6 +162,7 @@ function montarWhereMovimentosFixos(filtros = {}) { OR mf.movimento LIKE ? OR b.descricao LIKE ? OR cc.descricao LIKE ? + OR c.nome LIKE ? ) `); @@ -156,6 +172,7 @@ function montarWhereMovimentosFixos(filtros = {}) { 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 = {}) { const limite = limitarNumero(filtros.limite, 20, 1, 100); 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( ` SELECT ${CAMPOS_MOVIMENTO_FIXO_SELECT} ${FROM_MOVIMENTO_FIXO_JOIN} WHERE mf.idmovimentosfixos = ? + ${filtroUsuario} LIMIT 1 `, - [id] + params ); return rows[0] || null; } -async function criarMovimentoFixo(dados) { +async function criarMovimentoFixo(dados, idUsuario) { const { movimento, descricao, valor, dia_vencimento, idcentrodecustos, + idclientes, habilitado, idbancos, } = 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( ` INSERT INTO movimentosfixos ( @@ -277,11 +338,12 @@ async function criarMovimentoFixo(dados) { valor, dia_vencimento, idcentrodecustos, + idclientes, habilitado, idbancos, insert_date, update_date - ) VALUES (?, ?, ?, ?, ?, ?, ?, NOW(), NOW()) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW()) `, [ normalizarTextoOuNull(movimento), @@ -289,16 +351,17 @@ async function criarMovimentoFixo(dados) { normalizarNumero(valor, 0), normalizarNumero(dia_vencimento, 1), normalizarNumeroOuNull(idcentrodecustos), + normalizarNumeroOuNull(idclientes), normalizarFlag(habilitado, 1), - normalizarNumeroOuNull(idbancos), + idBancoNormalizado, ] ); - return buscarMovimentoFixoPorId(result.insertId); + return buscarMovimentoFixoPorId(result.insertId, idUsuario); } -async function atualizarMovimentoFixo(id, dados) { - const movimentoAtual = await buscarMovimentoFixoPorId(id); +async function atualizarMovimentoFixo(id, dados, idUsuario) { + const movimentoAtual = await buscarMovimentoFixoPorId(id, idUsuario); if (!movimentoAtual) { return null; @@ -310,10 +373,27 @@ async function atualizarMovimentoFixo(id, dados) { valor, dia_vencimento, idcentrodecustos, + idclientes, habilitado, idbancos, } = 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( ` UPDATE movimentosfixos @@ -323,6 +403,7 @@ async function atualizarMovimentoFixo(id, dados) { valor = ?, dia_vencimento = ?, idcentrodecustos = ?, + idclientes = ?, habilitado = ?, idbancos = ?, update_date = NOW() @@ -334,17 +415,18 @@ async function atualizarMovimentoFixo(id, dados) { normalizarNumero(valor, 0), normalizarNumero(dia_vencimento, 1), normalizarNumeroOuNull(idcentrodecustos), + normalizarNumeroOuNull(idclientes), normalizarFlag(habilitado, 1), - normalizarNumeroOuNull(idbancos), + idBancoNormalizado, id, ] ); - return buscarMovimentoFixoPorId(id); + return buscarMovimentoFixoPorId(id, idUsuario); } -async function alterarHabilitadoMovimentoFixo(id, habilitado) { - const movimentoAtual = await buscarMovimentoFixoPorId(id); +async function alterarHabilitadoMovimentoFixo(id, habilitado, idUsuario) { + const movimentoAtual = await buscarMovimentoFixoPorId(id, idUsuario); if (!movimentoAtual) { return null; @@ -364,11 +446,11 @@ async function alterarHabilitadoMovimentoFixo(id, habilitado) { ] ); - return buscarMovimentoFixoPorId(id); + return buscarMovimentoFixoPorId(id, idUsuario); } -async function deletarMovimentoFixo(id) { - const movimentoAtual = await buscarMovimentoFixoPorId(id); +async function deletarMovimentoFixo(id, idUsuario) { + const movimentoAtual = await buscarMovimentoFixoPorId(id, idUsuario); if (!movimentoAtual) { return false; diff --git a/financeiro-api/src/modules/movimentosFixos/services/movimentosFixosGeracao.service.js b/financeiro-api/src/modules/movimentosFixos/services/movimentosFixosGeracao.service.js index 27575c6..efa7163 100644 --- a/financeiro-api/src/modules/movimentosFixos/services/movimentosFixosGeracao.service.js +++ b/financeiro-api/src/modules/movimentosFixos/services/movimentosFixosGeracao.service.js @@ -114,6 +114,7 @@ async function buscarFixosDoUsuario(connection, idUsuario) { mf.valor, mf.dia_vencimento, mf.idcentrodecustos, + mf.idclientes, mf.idbancos FROM movimentosfixos mf INNER JOIN bancos b ON b.idbancos = mf.idbancos @@ -132,7 +133,7 @@ async function jaExisteMovimentoFixo(connection, fixo, competencia) { const [rowsGeracao] = await connection.query( ` SELECT idmovimentosfixosgeracoes, idcontasapagar, status - FROM movimentosfixosgeracoes + FROM movimentosfixos_geracoes WHERE idmovimentosfixos = ? AND competencia = ? LIMIT 1 @@ -161,7 +162,7 @@ async function jaExisteMovimentoFixo(connection, fixo, competencia) { if (rowsMovimentoVinculado.length > 0) { await connection.query( ` - INSERT INTO movimentosfixosgeracoes ( + INSERT INTO movimentosfixos_geracoes ( idmovimentosfixos, competencia, idcontasapagar, @@ -230,7 +231,7 @@ async function jaExisteMovimentoFixo(connection, fixo, competencia) { await connection.query( ` - INSERT INTO movimentosfixosgeracoes ( + INSERT INTO movimentosfixos_geracoes ( idmovimentosfixos, competencia, idcontasapagar, @@ -288,7 +289,7 @@ async function inserirMovimentoFixoGerado(connection, fixo, competencia, idUsuar insert_date, update_date, 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, @@ -302,6 +303,7 @@ async function inserirMovimentoFixoGerado(connection, fixo, competencia, idUsuar fixo.idbancos || null, idUsuario, dataBaixa ? idUsuario : null, + fixo.idclientes || null, fixo.idmovimentosfixos, competencia, ] diff --git a/financeiro-api/src/modules/referencias/controllers/referencias.controller.js b/financeiro-api/src/modules/referencias/controllers/referencias.controller.js index e0205ec..54aee70 100644 --- a/financeiro-api/src/modules/referencias/controllers/referencias.controller.js +++ b/financeiro-api/src/modules/referencias/controllers/referencias.controller.js @@ -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) { try { const centrosCusto = await referenciasService.listarCentrosCusto(); @@ -56,6 +74,7 @@ async function listarClientes(req, res) { module.exports = { listarBancos, + listarMeusBancos, listarCentrosCusto, listarClientes, }; \ No newline at end of file diff --git a/financeiro-api/src/modules/referencias/routes/referencias.routes.js b/financeiro-api/src/modules/referencias/routes/referencias.routes.js index 04b296f..de9cd57 100644 --- a/financeiro-api/src/modules/referencias/routes/referencias.routes.js +++ b/financeiro-api/src/modules/referencias/routes/referencias.routes.js @@ -7,6 +7,8 @@ const router = express.Router(); router.use(authMiddleware); router.get('/bancos', referenciasController.listarBancos); +router.get('/bancos/meus', referenciasController.listarMeusBancos); + router.get('/centros-custo', referenciasController.listarCentrosCusto); router.get('/clientes', referenciasController.listarClientes); diff --git a/financeiro-api/src/modules/referencias/services/referencias.service.js b/financeiro-api/src/modules/referencias/services/referencias.service.js index bbff766..827132f 100644 --- a/financeiro-api/src/modules/referencias/services/referencias.service.js +++ b/financeiro-api/src/modules/referencias/services/referencias.service.js @@ -1,5 +1,15 @@ const pool = require('../../../database/mysql'); +function extrairIdUsuarioLogado(usuario) { + return ( + usuario?.idusuarios || + usuario?.idusuario || + usuario?.id || + usuario?.userId || + null + ); +} + async function listarBancos() { const [rows] = await pool.query( ` @@ -23,14 +33,46 @@ async function listarBancos() { ON u.idusuarios = b.idusuarios WHERE b.habilitado = 1 ORDER BY - b.descricao ASC, - u.nome ASC + COALESCE(u.nome, 'Usuário sem nome') ASC, + b.descricao ASC ` ); 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() { const [rows] = await pool.query( ` @@ -72,6 +114,7 @@ async function listarClientes() { module.exports = { listarBancos, + listarMeusBancos, listarCentrosCusto, listarClientes, }; \ No newline at end of file diff --git a/financeiro-api/src/modules/simulacaoMensal/controllers/simulacaoMensal.controller.js b/financeiro-api/src/modules/simulacaoMensal/controllers/simulacaoMensal.controller.js new file mode 100644 index 0000000..e35baf1 --- /dev/null +++ b/financeiro-api/src/modules/simulacaoMensal/controllers/simulacaoMensal.controller.js @@ -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, +}; \ No newline at end of file diff --git a/financeiro-api/src/modules/simulacaoMensal/routes/simulacaoMensal.routes.js b/financeiro-api/src/modules/simulacaoMensal/routes/simulacaoMensal.routes.js new file mode 100644 index 0000000..6885e04 --- /dev/null +++ b/financeiro-api/src/modules/simulacaoMensal/routes/simulacaoMensal.routes.js @@ -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; \ No newline at end of file diff --git a/financeiro-api/src/modules/simulacaoMensal/services/simulacaoMensal.service.js b/financeiro-api/src/modules/simulacaoMensal/services/simulacaoMensal.service.js new file mode 100644 index 0000000..1b7bc3e --- /dev/null +++ b/financeiro-api/src/modules/simulacaoMensal/services/simulacaoMensal.service.js @@ -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, +}; \ No newline at end of file diff --git a/financeiro-web/src/app/router.tsx b/financeiro-web/src/app/router.tsx index d7d7571..1910291 100644 --- a/financeiro-web/src/app/router.tsx +++ b/financeiro-web/src/app/router.tsx @@ -26,6 +26,7 @@ import { UsuariosPage } from '../features/usuarios/pages/UsuariosPage'; import { NovoUsuarioPage } from '../features/usuarios/pages/NovoUsuarioPage'; import { EditarUsuarioPage } from '../features/usuarios/pages/EditarUsuarioPage'; import { QuitacoesCreditoPage } from '../features/quitacoesCredito/pages/QuitacoesCreditoPage'; +import { SimulacaoMensalPage } from '../features/simulacaoMensal/pages/SimulacaoMensalPage'; export const routes: RouteObject[] = [ { @@ -129,6 +130,10 @@ export const routes: RouteObject[] = [ { path: '/quitacoes-credito', element: , + }, + { + path: 'simulacao-mensal', + element: , } ], }, diff --git a/financeiro-web/src/components/layout/Sidebar.tsx b/financeiro-web/src/components/layout/Sidebar.tsx index 4d95b80..77d508b 100644 --- a/financeiro-web/src/components/layout/Sidebar.tsx +++ b/financeiro-web/src/components/layout/Sidebar.tsx @@ -18,6 +18,7 @@ import AccountTreeIcon from '@mui/icons-material/AccountTree'; import EventRepeatIcon from '@mui/icons-material/EventRepeat'; import ManageAccountsIcon from '@mui/icons-material/ManageAccounts'; import CreditCardIcon from '@mui/icons-material/CreditCard'; +import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome'; import { NavLink } from 'react-router-dom'; type SidebarProps = { @@ -75,6 +76,11 @@ const menuItems = [ path: '/relatorios', icon: , }, + { + label: 'Simulação mensal', + path: '/simulacao-mensal', + icon: , + }, { label: 'Configurações', path: '/configuracoes', diff --git a/financeiro-web/src/features/movimentos/components/MovimentoForm.tsx b/financeiro-web/src/features/movimentos/components/MovimentoForm.tsx index e85a027..7014266 100644 --- a/financeiro-web/src/features/movimentos/components/MovimentoForm.tsx +++ b/financeiro-web/src/features/movimentos/components/MovimentoForm.tsx @@ -26,6 +26,7 @@ import { listarBancos, listarCentrosCusto, listarClientes, + listarMeusBancos, } from '../../referencias/services/referenciasService'; import type { Banco, @@ -118,6 +119,68 @@ function formatarValorMoeda(valor: number) { }).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) { return ( ([]); + const [meusBancos, setMeusBancos] = useState([]); + const [todosBancos, setTodosBancos] = useState([]); const [centrosCusto, setCentrosCusto] = useState([]); const [clientes, setClientes] = useState([]); @@ -215,8 +279,8 @@ export function MovimentoForm({ mode, initialData }: MovimentoFormProps) { const bancoSelecionado = useMemo(() => { if (!idBanco) return null; - return bancos.find((banco) => String(banco.id) === String(idBanco)) || null; - }, [bancos, idBanco]); + return meusBancos.find((banco) => String(banco.id) === String(idBanco)) || null; + }, [meusBancos, idBanco]); const statusDisponiveis = useMemo(() => { if (movimento === 'Entrada') return ['Recebido', 'A receber']; @@ -261,6 +325,11 @@ export function MovimentoForm({ mode, initialData }: MovimentoFormProps) { if (movimento === 'Sangria') { setReferenciaTipo('banco'); setStatus('Pago'); + setDataBaixa((dataAtual) => dataAtual || hojeISO()); + setParcelas('1'); + setParcela('1'); + setGerarParcelas(false); + setIdCentroCusto('19'); return; } @@ -276,13 +345,15 @@ export function MovimentoForm({ mode, initialData }: MovimentoFormProps) { setLoadingRefs(true); setErro(''); - const [bancosData, centrosData, clientesData] = await Promise.all([ + const [meusBancosData, todosBancosData, centrosData, clientesData] = await Promise.all([ + listarMeusBancos(), listarBancos(), listarCentrosCusto(), listarClientes(), ]); - setBancos(bancosData); + setMeusBancos(meusBancosData); + setTodosBancos(todosBancosData); setCentrosCusto(centrosData); setClientes(clientesData); } catch (error: any) { @@ -494,22 +565,33 @@ export function MovimentoForm({ mode, initialData }: MovimentoFormProps) { } 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 { movimento, descricao: descricao.trim(), valor: Number(valor), - parcela: Number(parcela || 1), - parcelas: Number(parcelas || 1), - status, + parcela: parcelaFinal, + parcelas: parcelasFinal, + status: statusFinal, dataentrada: dataEntrada, datavencimento: dataVencimento || null, - databaixa: status === 'Pago' || status === 'Recebido' ? dataBaixa || null : null, + databaixa: dataBaixaFinal, idcentrodecustos: toNumberOrNull(idCentroCusto), idbancos: toNumberOrNull(idBanco), idusuarios_baixa: null, - idclientes: referenciaTipo === 'cliente' ? toNumberOrNull(idCliente) : null, + idclientes: referenciaTipoFinal === 'cliente' ? toNumberOrNull(idCliente) : null, idveiculosdetalhes: null, - idbancos_p: referenciaTipo === 'banco' ? toNumberOrNull(idBancoReferencia) : null, + idbancos_p: referenciaTipoFinal === 'banco' ? toNumberOrNull(idBancoReferencia) : null, idmovimentosfixos: null, competencia: extrairCompetencia(dataVencimento || dataEntrada), @@ -519,8 +601,12 @@ export function MovimentoForm({ mode, initialData }: MovimentoFormProps) { observacao: observacao.trim() || null, - gerarParcelas: temParcelamento ? gerarParcelas : false, - modoParcelamento: temParcelamento ? modoParcelamento : 'valor_parcela', + gerarParcelas: isSangria ? false : temParcelamento ? gerarParcelas : false, + modoParcelamento: isSangria + ? 'valor_parcela' + : temParcelamento + ? modoParcelamento + : 'valor_parcela', confirmarDuplicado, }; } @@ -645,6 +731,7 @@ export function MovimentoForm({ mode, initialData }: MovimentoFormProps) { label="Situação" value={status} onChange={(event) => setStatus(event.target.value as MovimentoStatus)} + disabled={movimento === 'Sangria'} fullWidth sx={fieldSx} > @@ -685,7 +772,7 @@ export function MovimentoForm({ mode, initialData }: MovimentoFormProps) { sx={fieldSx} > Nenhum - {bancos.map((banco) => ( + {meusBancos.map((banco) => ( {banco.descricao} {Number(banco.debito) === 0 ? ' · Crédito' : ''} @@ -698,25 +785,35 @@ export function MovimentoForm({ mode, initialData }: MovimentoFormProps) { title="Parcelas e datas" description="Controle vencimento, baixa e parcelamento." > - setParcela(event.target.value)} - inputProps={{ min: '1' }} - fullWidth - sx={fieldSx} - /> + {movimento !== 'Sangria' && ( + <> + setParcela(event.target.value)} + inputProps={{ min: '1' }} + fullWidth + sx={fieldSx} + /> - setParcelas(event.target.value)} - inputProps={{ min: '1' }} - fullWidth - sx={fieldSx} - /> + setParcelas(event.target.value)} + inputProps={{ min: '1' }} + fullWidth + sx={fieldSx} + /> + + {temParcelamento && ( + + ... + + )} + + )} {temParcelamento && ( @@ -815,6 +912,7 @@ export function MovimentoForm({ mode, initialData }: MovimentoFormProps) { onChange={(event) => setReferenciaTipo(event.target.value as 'nenhum' | 'cliente' | 'banco') } + disabled={movimento === 'Sangria'} fullWidth sx={fieldSx} > @@ -853,7 +951,7 @@ export function MovimentoForm({ mode, initialData }: MovimentoFormProps) { sx={fieldSx} > Selecione - {bancos.map((banco) => ( + {todosBancos.map((banco) => ( {banco.descricao} @@ -980,7 +1078,7 @@ export function MovimentoForm({ mode, initialData }: MovimentoFormProps) { - Impacto previsto no saldo + Impacto previsto no saldo dos bancos {loadingImpacto ? ( @@ -990,40 +1088,88 @@ export function MovimentoForm({ mode, initialData }: MovimentoFormProps) { ) : impactoSaldo.length === 0 ? ( - Sem impacto imediato + Sem alteração imediata de saldo ) : ( - {impactoSaldo.map((impacto, index) => ( - - - {impacto.banco_descricao || `Banco ${impacto.idbancos}`} - + {impactoSaldo.map((impacto, index) => { + const visual = getImpactoVisual(impacto); + const percentualFormatado = formatarPercentual(impacto.percentual_delta); - = 0 ? 'success.main' : 'error.main'} + return ( + - {impacto.valor_delta >= 0 ? '+' : ''} - {formatarValorMoeda(impacto.valor_delta)} - + + + + {impacto.banco_descricao || `Banco ${impacto.idbancos}`} + - {impacto.saldo_anterior !== undefined && ( - - {formatarValorMoeda(impacto.saldo_anterior)} →{' '} - {formatarValorMoeda(impacto.saldo_posterior || 0)} + {impacto.descricao && ( + + {impacto.descricao} + + )} + + + + + + + {impacto.valor_delta >= 0 ? '+' : ''} + {formatarValorMoeda(impacto.valor_delta)} - )} - - ))} + + + {impacto.saldo_anterior !== undefined && ( + + Saldo atual: {formatarValorMoeda(impacto.saldo_anterior)} + + )} + + {impacto.saldo_posterior !== undefined && ( + + Saldo previsto: {formatarValorMoeda(impacto.saldo_posterior)} + + )} + + {percentualFormatado && ( + + {percentualFormatado} em relação ao saldo atual + + )} + + + ); + })} )} diff --git a/financeiro-web/src/features/movimentos/pages/MovimentosPage.tsx b/financeiro-web/src/features/movimentos/pages/MovimentosPage.tsx index aba8259..58875fc 100644 --- a/financeiro-web/src/features/movimentos/pages/MovimentosPage.tsx +++ b/financeiro-web/src/features/movimentos/pages/MovimentosPage.tsx @@ -7,6 +7,7 @@ import { CardContent, Chip, CircularProgress, + Collapse, Divider, IconButton, MenuItem, @@ -14,6 +15,8 @@ import { Paper, Stack, TextField, + ToggleButton, + ToggleButtonGroup, Tooltip, Typography, Table, @@ -32,6 +35,17 @@ import RestartAltIcon from '@mui/icons-material/RestartAlt'; import SearchIcon from '@mui/icons-material/Search'; import SwapHorizIcon from '@mui/icons-material/SwapHoriz'; import DeleteIcon from '@mui/icons-material/Delete'; +import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward'; +import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward'; +import UndoIcon from '@mui/icons-material/Undo'; +import HelpIcon from '@mui/icons-material/Help'; +import AccountBalanceIcon from '@mui/icons-material/AccountBalance'; +import CalendarMonthIcon from '@mui/icons-material/CalendarMonth'; +import CategoryIcon from '@mui/icons-material/Category'; +import ExpandLessIcon from '@mui/icons-material/ExpandLess'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import TableRowsIcon from '@mui/icons-material/TableRows'; +import ViewAgendaIcon from '@mui/icons-material/ViewAgenda'; import { Link as RouterLink } from 'react-router-dom'; import { deletarMovimento, @@ -58,6 +72,40 @@ import type { } from '../../referencias/types/referenciasTypes'; const LIMITE_PADRAO = 20; +const DIAS_ALERTA_VENCIMENTO = 5; +const LIMITE_AGRUPADO = 500; + +type ModoVisualizacao = 'tabela' | 'agrupado'; + +type GrupoCentroMovimentos = { + chave: string; + nome: string; + totalEntradas: number; + totalSaidas: number; + saldo: number; + quantidade: number; + movimentos: Movimento[]; +}; + +type GrupoBancoMovimentos = { + chave: string; + nome: string; + totalEntradas: number; + totalSaidas: number; + saldo: number; + quantidade: number; + centros: GrupoCentroMovimentos[]; +}; + +type GrupoDiaMovimentos = { + chave: string; + dataLabel: string; + totalEntradas: number; + totalSaidas: number; + saldo: number; + quantidade: number; + bancos: GrupoBancoMovimentos[]; +}; function inicioMesAtual() { const hoje = new Date(); @@ -89,6 +137,7 @@ function campoDataLabel(campo: DataCampo) { databaixa: 'Baixa', insert_date: 'Cadastro', update_date: 'Atualização', + deleted_at: 'Exclusão', }; return labels[campo]; @@ -123,6 +172,290 @@ function getReferencia(item: Movimento) { return '-'; } +function dataSomenteDia(data: string | null | undefined) { + if (!data) return null; + + const texto = String(data).slice(0, 10); + + if (!/^\d{4}-\d{2}-\d{2}$/.test(texto)) { + return null; + } + + return new Date(`${texto}T00:00:00`); +} + +function diferencaDias(dataAlvo: Date, dataBase: Date) { + const umDiaMs = 24 * 60 * 60 * 1000; + + const alvo = new Date( + dataAlvo.getFullYear(), + dataAlvo.getMonth(), + dataAlvo.getDate() + ); + + const base = new Date( + dataBase.getFullYear(), + dataBase.getMonth(), + dataBase.getDate() + ); + + return Math.floor((alvo.getTime() - base.getTime()) / umDiaMs); +} + +function movimentoCompensado(item: Movimento) { + return item.status === 'Pago' || item.status === 'Recebido'; +} + +function movimentoEmAberto(item: Movimento) { + return item.status === 'A pagar' || item.status === 'A receber'; +} + +function getMovimentoRowVisual(item: Movimento) { + if (movimentoExcluido(item)) { + return { + backgroundColor: 'rgba(148,163,184,0.10)', + hoverColor: 'rgba(148,163,184,0.16)', + borderLeftColor: 'rgba(100,116,139,0.65)', + }; + } + + if (movimentoCompensado(item)) { + return { + backgroundColor: 'rgba(34,197,94,0.08)', + hoverColor: 'rgba(34,197,94,0.13)', + borderLeftColor: 'rgba(34,197,94,0.75)', + }; + } + + if (movimentoEmAberto(item)) { + const vencimento = dataSomenteDia(item.datavencimento); + + if (vencimento) { + const dias = diferencaDias(vencimento, new Date()); + + if (dias < 0) { + return { + backgroundColor: 'rgba(239,68,68,0.08)', + hoverColor: 'rgba(239,68,68,0.14)', + borderLeftColor: 'rgba(239,68,68,0.78)', + }; + } + + if (dias <= DIAS_ALERTA_VENCIMENTO) { + return { + backgroundColor: 'rgba(245,158,11,0.10)', + hoverColor: 'rgba(245,158,11,0.16)', + borderLeftColor: 'rgba(245,158,11,0.85)', + }; + } + } + } + + return { + backgroundColor: '#FFFFFF', + hoverColor: 'rgba(15,23,42,0.02)', + borderLeftColor: 'transparent', + }; +} + +function getMovimentoIconVisual(movimento: MovimentoTipo) { + if (movimento === 'Entrada') { + return { + icon: , + label: 'Entrada', + color: 'success' as const, + }; + } + + if (movimento === 'Saida') { + return { + icon: , + label: 'Saída', + color: 'error' as const, + }; + } + + if (movimento === 'Sangria') { + return { + icon: , + label: 'Sangria', + color: 'warning' as const, + }; + } + + if (movimento === 'Estorno') { + return { + icon: , + label: 'Estorno', + color: 'info' as const, + }; + } + + return { + icon: , + label: movimento || 'Movimento', + color: 'default' as const, + }; +} + +function formatarDataGrupo(dataISO: string) { + if (!dataISO || dataISO === 'sem-data') return 'Sem data'; + + return new Date(`${dataISO}T00:00:00`).toLocaleDateString('pt-BR', { + weekday: 'short', + day: '2-digit', + month: '2-digit', + year: 'numeric', + }); +} + +function getDataGrupo(item: Movimento, campo: DataCampo) { + const data = getDataPrincipal(item, campo); + const texto = data ? String(data).slice(0, 10) : ''; + + if (!/^\d{4}-\d{2}-\d{2}$/.test(texto)) { + return 'sem-data'; + } + + return texto; +} + +function valorAssinadoMovimento(item: Movimento) { + const valor = Number(item.valor || 0); + + if (item.movimento === 'Entrada' || item.movimento === 'Estorno') { + return valor; + } + + return valor * -1; +} + +function criarResumoGrupo() { + return { + totalEntradas: 0, + totalSaidas: 0, + saldo: 0, + quantidade: 0, + }; +} + +function aplicarMovimentoNoResumo>( + resumo: T, + item: Movimento +) { + const valorAssinado = valorAssinadoMovimento(item); + + if (valorAssinado >= 0) { + resumo.totalEntradas += valorAssinado; + } else { + resumo.totalSaidas += Math.abs(valorAssinado); + } + + resumo.saldo += valorAssinado; + resumo.quantidade += 1; +} + +function agruparMovimentos(movimentos: Movimento[], campo: DataCampo): GrupoDiaMovimentos[] { + const gruposDia = new Map; + bancos: Map; + centros: Map; + movimentos: Movimento[]; + }>; + }>; + }>(); + + for (const item of movimentos) { + const chaveDia = getDataGrupo(item, campo); + const bancoNome = item.banco_descricao || 'Sem banco'; + const centroNome = item.centro_custo_descricao || 'Sem centro de custo'; + + const chaveBanco = String(item.idbancos || bancoNome); + const chaveCentro = String(item.idcentrodecustos || centroNome); + + if (!gruposDia.has(chaveDia)) { + gruposDia.set(chaveDia, { + resumo: criarResumoGrupo(), + bancos: new Map(), + }); + } + + const grupoDia = gruposDia.get(chaveDia)!; + aplicarMovimentoNoResumo(grupoDia.resumo, item); + + if (!grupoDia.bancos.has(chaveBanco)) { + grupoDia.bancos.set(chaveBanco, { + nome: bancoNome, + resumo: criarResumoGrupo(), + centros: new Map(), + }); + } + + const grupoBanco = grupoDia.bancos.get(chaveBanco)!; + aplicarMovimentoNoResumo(grupoBanco.resumo, item); + + if (!grupoBanco.centros.has(chaveCentro)) { + grupoBanco.centros.set(chaveCentro, { + nome: centroNome, + resumo: criarResumoGrupo(), + movimentos: [], + }); + } + + const grupoCentro = grupoBanco.centros.get(chaveCentro)!; + aplicarMovimentoNoResumo(grupoCentro.resumo, item); + grupoCentro.movimentos.push(item); + } + + return Array + .from(gruposDia.entries()) + .sort(([a], [b]) => b.localeCompare(a)) + .map(([chaveDia, dia]) => ({ + chave: chaveDia, + dataLabel: formatarDataGrupo(chaveDia), + totalEntradas: dia.resumo.totalEntradas, + totalSaidas: dia.resumo.totalSaidas, + saldo: dia.resumo.saldo, + quantidade: dia.resumo.quantidade, + bancos: Array + .from(dia.bancos.entries()) + .sort(([, a], [, b]) => a.nome.localeCompare(b.nome)) + .map(([chaveBanco, banco]) => ({ + chave: chaveBanco, + nome: banco.nome, + totalEntradas: banco.resumo.totalEntradas, + totalSaidas: banco.resumo.totalSaidas, + saldo: banco.resumo.saldo, + quantidade: banco.resumo.quantidade, + centros: Array + .from(banco.centros.entries()) + .sort(([, a], [, b]) => a.nome.localeCompare(b.nome)) + .map(([chaveCentro, centro]) => ({ + chave: chaveCentro, + nome: centro.nome, + totalEntradas: centro.resumo.totalEntradas, + totalSaidas: centro.resumo.totalSaidas, + saldo: centro.resumo.saldo, + quantidade: centro.resumo.quantidade, + movimentos: centro.movimentos.sort((a, b) => { + const dataA = String(getDataPrincipal(a, campo) || ''); + const dataB = String(getDataPrincipal(b, campo) || ''); + + return dataB.localeCompare(dataA); + }), + })), + })), + })); +} + +function movimentoExcluido(item: Movimento) { + return Boolean(item.deleted_at); +} + export function MovimentosPage() { const [sucesso, setSucesso] = useState(''); @@ -146,6 +479,9 @@ export function MovimentosPage() { saldo: 0, }); + const [modoVisualizacao, setModoVisualizacao] = useState('tabela'); + const [gruposAbertos, setGruposAbertos] = useState>({}); + const [bancos, setBancos] = useState([]); const [centrosCusto, setCentrosCusto] = useState([]); const [clientes, setClientes] = useState([]); @@ -155,7 +491,7 @@ export function MovimentosPage() { const [erro, setErro] = useState(''); const [page, setPage] = useState(1); - const [dataCampo, setDataCampo] = useState('datavencimento'); + const [dataCampo, setDataCampo] = useState('dataentrada'); const [dataInicio, setDataInicio] = useState(inicioMesAtual()); const [dataFim, setDataFim] = useState(fimMesAtual()); const [busca, setBusca] = useState(''); @@ -206,6 +542,12 @@ export function MovimentosPage() { incluirExcluidos, ]); + const movimentosAgrupados = useMemo(() => { + return agruparMovimentos(movimentos, dataCampo); + }, [movimentos, dataCampo]); + + const totalMaiorQueLimiteAgrupado = modoVisualizacao === 'agrupado' && pagination.total > LIMITE_AGRUPADO; + async function carregarReferencias() { try { setLoadingRefs(true); @@ -224,14 +566,17 @@ export function MovimentosPage() { } } - async function carregarMovimentos(pageToLoad = page) { + async function carregarMovimentos( + pageToLoad = page, + modoParaCarregar = modoVisualizacao + ) { try { setLoading(true); setErro(''); const params: ListarMovimentosParams = { - limite: LIMITE_PADRAO, - page: pageToLoad, + limite: modoParaCarregar === 'agrupado' ? LIMITE_AGRUPADO : LIMITE_PADRAO, + page: modoParaCarregar === 'agrupado' ? 1 : pageToLoad, dataCampo, dataInicio, dataFim, @@ -268,12 +613,13 @@ export function MovimentosPage() { } function aplicarFiltros() { + setGruposAbertos({}); setPage(1); - carregarMovimentos(1); + carregarMovimentos(1, modoVisualizacao); } function limparFiltros() { - setDataCampo('datavencimento'); + setDataCampo('dataentrada'); setDataInicio(inicioMesAtual()); setDataFim(fimMesAtual()); setBusca(''); @@ -287,10 +633,11 @@ export function MovimentosPage() { setOrigem(''); setSaldoProcessado(''); setIncluirExcluidos(''); + setGruposAbertos({}); setTimeout(() => { setPage(1); - carregarMovimentos(1); + carregarMovimentos(1, modoVisualizacao); }, 0); } @@ -325,10 +672,6 @@ export function MovimentosPage() { return Number(valor) === 1 ? 'success' : 'default'; } - function movimentoExcluido(item: Movimento) { - return Boolean(item.deleted_at); - } - async function handleDeletarMovimento(item: Movimento) { const confirmou = window.confirm( `Deseja realmente excluir "${item.descricao}"?\n\n` + @@ -363,6 +706,370 @@ export function MovimentosPage() { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + function toggleGrupo(chave: string) { + setGruposAbertos((estadoAtual) => ({ + ...estadoAtual, + [chave]: !estadoAtual[chave], + })); + } + + function grupoAberto(chave: string) { + return gruposAbertos[chave] === true; + } + + function mudarModoVisualizacao(novoModo: ModoVisualizacao | null) { + if (!novoModo) return; + + setModoVisualizacao(novoModo); + setGruposAbertos({}); + setPage(1); + + setTimeout(() => { + carregarMovimentos(1, novoModo); + }, 0); + } + + function ResumoGrupoValores({ + entradas, + saidas, + saldo, + }: { + entradas: number; + saidas: number; + saldo: number; + }) { + return ( + + + + = 0 ? 'success' : 'error'} + /> + + ); + } + + function renderExtratoAgrupado() { + if (movimentosAgrupados.length === 0) { + return ( + + + Nenhum movimento encontrado. + + + Ajuste os filtros ou cadastre um novo movimento. + + + ); + } + + return ( + + {movimentosAgrupados.map((dia) => { + const chaveDia = `dia:${dia.chave}`; + const abertoDia = grupoAberto(chaveDia); + + return ( + + toggleGrupo(chaveDia)} + sx={{ + cursor: 'pointer', + padding: { xs: 1.5, md: 2 }, + backgroundColor: 'rgba(15,23,42,0.035)', + }} + > + + + + + + + {dia.dataLabel} + + + {dia.quantidade} movimento(s) + + + + + + + + {abertoDia ? : } + + + + + + + {dia.bancos.map((banco) => { + const chaveBanco = `${chaveDia}:banco:${banco.chave}`; + const abertoBanco = grupoAberto(chaveBanco); + + return ( + + toggleGrupo(chaveBanco)} + sx={{ + cursor: 'pointer', + padding: 1.5, + }} + > + + + + + + + {banco.nome} + + + {banco.quantidade} movimento(s) + + + + + + + + {abertoBanco ? : } + + + + + + + {banco.centros.map((centro) => { + const chaveCentro = `${chaveBanco}:centro:${centro.chave}`; + const abertoCentro = grupoAberto(chaveCentro); + + return ( + + toggleGrupo(chaveCentro)} + sx={{ + cursor: 'pointer', + padding: 1.25, + }} + > + + + + + + + {centro.nome} + + + {centro.quantidade} movimento(s) + + + + + + + + {abertoCentro ? : } + + + + + + + {centro.movimentos.map((item) => { + const rowVisual = getMovimentoRowVisual(item); + const movimentoIcon = getMovimentoIconVisual(item.movimento); + const dataPrincipal = getDataPrincipal(item, dataCampo); + + return ( + + + + + + + + + + {item.descricao} + + + + {campoDataLabel(dataCampo)}: {formatarData(dataPrincipal)} ·{' '} + Ref.: {getReferencia(item)} · {origemLabel(item.origem)} + + + + + + + + + {item.movimento === 'Entrada' || item.movimento === 'Estorno' + ? '+' + : '-'} + {formatarValor(item.valor)} + + + {!movimentoExcluido(item) && ( + + + + + + )} + + + + ); + })} + + + + ); + })} + + + + ); + })} + + + + ); + })} + + ); + } + return ( + + + + + + + + + + + Resultado + + + Alterne entre a tabela detalhada e o extrato agrupado por dia, banco e centro. + + + + mudarModoVisualizacao(novoModo)} + > + + + + Tabela + + + + + + + Extrato + + + + + + {totalMaiorQueLimiteAgrupado && ( + + Exibindo os primeiros {LIMITE_AGRUPADO} movimentos do filtro no modo extrato. + Refine o período para uma análise completa. + + )} + {loading ? ( @@ -747,6 +1512,8 @@ export function MovimentosPage() { Carregando movimentos... + ) : modoVisualizacao === 'agrupado' ? ( + renderExtratoAgrupado() ) : movimentos.length === 0 ? ( @@ -760,14 +1527,19 @@ export function MovimentosPage() { }> {movimentos.map((item) => { const dataPrincipal = getDataPrincipal(item, dataCampo); + const rowVisual = getMovimentoRowVisual(item); + const movimentoIcon = getMovimentoIconVisual(item.movimento); return ( @@ -779,9 +1551,30 @@ export function MovimentosPage() { spacing={2} > - - {item.descricao} - + + + + + + + {item.descricao} + + {campoDataLabel(dataCampo)}: {formatarData(dataPrincipal)} @@ -890,7 +1683,7 @@ export function MovimentosPage() { + Tipo Descrição Entrada Vencimento @@ -928,17 +1722,46 @@ export function MovimentosPage() { {movimentos.map((item) => { const dataPrincipal = getDataPrincipal(item, dataCampo); + const rowVisual = getMovimentoRowVisual(item); + const movimentoIcon = getMovimentoIconVisual(item.movimento); return ( + + + + + + @@ -1099,7 +1922,7 @@ export function MovimentosPage() { - {pagination.totalPages > 1 && ( + {modoVisualizacao === 'tabela' && pagination.totalPages > 1 && ( - Exibindo página {pagination.page} de {pagination.totalPages}, total de{' '} - {pagination.total} registro(s). + {modoVisualizacao === 'agrupado' ? ( + <> + Exibindo {movimentos.length} movimento(s) no extrato agrupado, + de um total de {pagination.total} registro(s) filtrado(s). + + ) : ( + <> + Exibindo página {pagination.page} de {pagination.totalPages}, total de{' '} + {pagination.total} registro(s). + + )} diff --git a/financeiro-web/src/features/movimentos/types/movimentoTypes.ts b/financeiro-web/src/features/movimentos/types/movimentoTypes.ts index c743f74..e2a956d 100644 --- a/financeiro-web/src/features/movimentos/types/movimentoTypes.ts +++ b/financeiro-web/src/features/movimentos/types/movimentoTypes.ts @@ -77,10 +77,19 @@ export type AtualizarMovimentoRequest = CriarMovimentoRequest; export type MovimentoImpactoSaldo = { idbancos: number; + banco_descricao?: string | null; + banco_nome?: string | null; + usuario_nome?: string | null; + valor_delta: number; saldo_anterior?: number; saldo_posterior?: number; + + percentual_delta?: number | null; + direcao?: 'alta' | 'queda' | 'neutro'; + tipo_visual?: 'positivo' | 'negativo' | 'neutro'; + descricao?: string | null; }; diff --git a/financeiro-web/src/features/movimentosFixos/components/MovimentoFIxoForm.tsx b/financeiro-web/src/features/movimentosFixos/components/MovimentoFIxoForm.tsx index d92b2ba..81a1e10 100644 --- a/financeiro-web/src/features/movimentosFixos/components/MovimentoFIxoForm.tsx +++ b/financeiro-web/src/features/movimentosFixos/components/MovimentoFIxoForm.tsx @@ -20,12 +20,14 @@ import EventRepeatIcon from '@mui/icons-material/EventRepeat'; import SaveIcon from '@mui/icons-material/Save'; import { useNavigate } from 'react-router-dom'; import { - listarBancos, + listarMeusBancos, listarCentrosCusto, + listarClientes, } from '../../referencias/services/referenciasService'; import type { Banco, CentroCusto, + Cliente, } from '../../referencias/types/referenciasTypes'; import { atualizarMovimentoFixo, @@ -125,6 +127,7 @@ export function MovimentoFixoForm({ mode, initialData }: MovimentoFixoFormProps) const [bancos, setBancos] = useState([]); const [centrosCusto, setCentrosCusto] = useState([]); + const [clientes, setClientes] = useState([]); const [loadingRefs, setLoadingRefs] = useState(true); const [saving, setSaving] = useState(false); @@ -136,6 +139,7 @@ export function MovimentoFixoForm({ mode, initialData }: MovimentoFixoFormProps) const [valor, setValor] = useState(''); const [diaVencimento, setDiaVencimento] = useState('1'); const [idCentroCusto, setIdCentroCusto] = useState(''); + const [idCliente, setIdCliente] = useState(''); const [idBanco, setIdBanco] = useState(''); const [habilitado, setHabilitado] = useState('1'); @@ -151,6 +155,12 @@ export function MovimentoFixoForm({ mode, initialData }: MovimentoFixoFormProps) return 'Estorno recorrente'; }, [movimento]); + const clienteSelecionado = useMemo(() => { + if (!idCliente) return null; + + return clientes.find((cliente) => String(cliente.id) === String(idCliente)) || null; + }, [clientes, idCliente]); + useEffect(() => { if (!initialData) return; @@ -159,6 +169,7 @@ export function MovimentoFixoForm({ mode, initialData }: MovimentoFixoFormProps) setValor(String(initialData.valor || '')); setDiaVencimento(String(initialData.dia_vencimento || 1)); setIdCentroCusto(initialData.idcentrodecustos ? String(initialData.idcentrodecustos) : ''); + setIdCliente(initialData.idclientes ? String(initialData.idclientes) : ''); setIdBanco(initialData.idbancos ? String(initialData.idbancos) : ''); setHabilitado(String(initialData.habilitado ?? 1)); }, [initialData]); @@ -169,13 +180,15 @@ export function MovimentoFixoForm({ mode, initialData }: MovimentoFixoFormProps) setLoadingRefs(true); setErro(''); - const [bancosData, centrosData] = await Promise.all([ - listarBancos(), + const [bancosData, centrosData, clientesData] = await Promise.all([ + listarMeusBancos(), listarCentrosCusto(), + listarClientes(), ]); setBancos(bancosData); setCentrosCusto(centrosData); + setClientes(clientesData); } catch (error: any) { const message = error?.response?.data?.message || @@ -196,6 +209,7 @@ export function MovimentoFixoForm({ mode, initialData }: MovimentoFixoFormProps) setValor(''); setDiaVencimento('1'); setIdCentroCusto(''); + setIdCliente(''); setIdBanco(''); setHabilitado('1'); } @@ -231,6 +245,7 @@ export function MovimentoFixoForm({ mode, initialData }: MovimentoFixoFormProps) valor: Number(valor), dia_vencimento: dia, idcentrodecustos: toNumberOrNull(idCentroCusto), + idclientes: toNumberOrNull(idCliente), habilitado: Number(habilitado || 1), idbancos: toNumberOrNull(idBanco), }; @@ -400,7 +415,7 @@ export function MovimentoFixoForm({ mode, initialData }: MovimentoFixoFormProps) ))} + + setIdCliente(event.target.value)} + fullWidth + sx={fieldSx} + > + Nenhum + {clientes.map((cliente) => ( + + {cliente.nome} + + ))} + + + + + Cliente + + + {clienteSelecionado?.nome || 'Nenhum'} + + diff --git a/financeiro-web/src/features/movimentosFixos/pages/MovimentosFixosPage.tsx b/financeiro-web/src/features/movimentosFixos/pages/MovimentosFixosPage.tsx index 8af8d6e..077718b 100644 --- a/financeiro-web/src/features/movimentosFixos/pages/MovimentosFixosPage.tsx +++ b/financeiro-web/src/features/movimentosFixos/pages/MovimentosFixosPage.tsx @@ -49,10 +49,13 @@ import type { import { listarBancos, listarCentrosCusto, + listarClientes, } from '../../referencias/services/referenciasService'; + import type { Banco, CentroCusto, + Cliente, } from '../../referencias/types/referenciasTypes'; const LIMITE_PADRAO = 20; @@ -87,7 +90,6 @@ function statusColor(habilitado: number) { } export function MovimentosFixosPage() { - const [movimentosFixos, setMovimentosFixos] = useState([]); const [pagination, setPagination] = useState({ total: 0, limite: LIMITE_PADRAO, @@ -108,6 +110,8 @@ export function MovimentosFixosPage() { const [bancos, setBancos] = useState([]); const [centrosCusto, setCentrosCusto] = useState([]); + const [movimentosFixos, setMovimentosFixos] = useState([]); + const [clientes, setClientes] = useState([]); const [alterandoStatusId, setAlterandoStatusId] = useState(null); @@ -121,6 +125,7 @@ export function MovimentosFixosPage() { const [movimento, setMovimento] = useState(''); const [idBanco, setIdBanco] = useState(''); const [idCentroCusto, setIdCentroCusto] = useState(''); + const [idCliente, setIdCliente] = useState(''); const [habilitado, setHabilitado] = useState(''); const [diaInicio, setDiaInicio] = useState(''); const [diaFim, setDiaFim] = useState(''); @@ -137,6 +142,7 @@ export function MovimentosFixosPage() { if (movimento) count += 1; if (idBanco) count += 1; if (idCentroCusto) count += 1; + if (idCliente) count += 1; if (habilitado !== '') count += 1; if (diaInicio !== '') count += 1; if (diaFim !== '') count += 1; @@ -147,6 +153,7 @@ export function MovimentosFixosPage() { movimento, idBanco, idCentroCusto, + idCliente, habilitado, diaInicio, diaFim, @@ -156,13 +163,15 @@ export function MovimentosFixosPage() { try { setLoadingRefs(true); - const [bancosData, centrosData] = await Promise.all([ + const [bancosData, centrosData, clientesData] = await Promise.all([ listarBancos(), listarCentrosCusto(), + listarClientes(), ]); setBancos(bancosData); setCentrosCusto(centrosData); + setClientes(clientesData); } finally { setLoadingRefs(false); } @@ -180,6 +189,7 @@ export function MovimentosFixosPage() { movimento: movimento || undefined, idbancos: idBanco || undefined, idcentrodecustos: idCentroCusto || undefined, + idclientes: idCliente || undefined, habilitado, diaInicio, diaFim, @@ -214,6 +224,7 @@ export function MovimentosFixosPage() { setMovimento(''); setIdBanco(''); setIdCentroCusto(''); + setIdCliente(''); setHabilitado(''); setDiaInicio(''); setDiaFim(''); @@ -492,6 +503,24 @@ export function MovimentosFixosPage() { ))} + + setIdCliente(event.target.value === '' ? '' : Number(event.target.value)) + } + disabled={loadingRefs} + fullWidth + > + Todos + {clientes.map((cliente) => ( + + {cliente.nome} + + ))} + + Movimento Banco Centro + Cliente Status Cadastro Atualização @@ -667,6 +697,10 @@ export function MovimentosFixosPage() { Centro: {item.centro_custo_descricao || '-'} + + Cliente: {item.cliente_nome || '-'} + + Valor Dia venc. Centro + Cliente Banco Status Cadastro @@ -801,6 +836,16 @@ export function MovimentosFixosPage() { + + + {item.cliente_nome || '-'} + + + { 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 { const response = await api.get>('/referencias/centros-custo'); return response.data.data; diff --git a/financeiro-web/src/features/simulacaoMensal/pages/SimulacaoMensalPage.tsx b/financeiro-web/src/features/simulacaoMensal/pages/SimulacaoMensalPage.tsx new file mode 100644 index 0000000..a63b8ac --- /dev/null +++ b/financeiro-web/src/features/simulacaoMensal/pages/SimulacaoMensalPage.tsx @@ -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 = { + 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) => 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 ( + + + + + {isEntrada ? : } + + + + {titulo} + + + + {itens.length} item(ns) + + + + + + + + + + + + + + + + {itens.length === 0 ? ( + + + Nenhum item. + + + Adicione manualmente ou preencha a previsão pela API. + + + ) : ( + itens.map((item) => ( + + + + + onAlterarItem(tipo, item.id, { + descricao: event.target.value, + editado: true, + }) + } + /> + + + onAlterarItem(tipo, item.id, { + valor: Number(event.target.value || 0), + editado: true, + }) + } + /> + + + onRemoverItem(tipo, item.id)} + > + + + + + + + + + {item.editado && ( + } + variant="outlined" + /> + )} + + {item.data && ( + + )} + + {item.banco_descricao && ( + + )} + + {item.centro_custo_descricao && ( + + )} + + {item.cliente_nome && ( + + )} + + {Number(item.valor_original || 0) !== Number(item.valor || 0) && ( + + )} + + + + )) + )} + + + ); +} + +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(null); + const [entradas, setEntradas] = useState([]); + const [saidas, setSaidas] = useState([]); + + 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 + ) { + 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 ( + + + + {icon} + + {label} + + + + + {formatarValor(valor)} + + + + {formatarPercentual(percentual)} das entradas + + + + ); + } + + return ( + + + + } + label="Laboratório financeiro" + color="primary" + variant="outlined" + sx={{ marginBottom: 1.25, fontWeight: 700 }} + /> + + + Simulação mensal + + + + Monte uma previsão do mês, ajuste valores livremente e veja quanto sobra. + + + + + + + + + + + {erro && ( + + {erro} + + )} + + {sucesso && ( + + {sucesso} + + )} + + + + + setMes(Number(event.target.value))} + fullWidth + > + {meses.map((item) => ( + + {item.label} + + ))} + + + setAno(Number(event.target.value))} + fullWidth + /> + + setIncluirQuitadas(Number(event.target.value) === 1)} + fullWidth + > + Não, só em aberto + Sim, incluir quitadas + + + setIncluirFixosVencidos(Number(event.target.value) === 1)} + fullWidth + > + Ignorar vencidos + Incluir vencidos + + + + + + + } + label="Entradas" + valor={resumo.totalEntradas} + percentual={resumo.percentualEntradas} + color="success.main" + /> + + } + label="Gastos" + valor={resumo.totalGastos || 0} + percentual={resumo.percentualGastos} + color="error.main" + /> + + } + label="Investimentos" + valor={resumo.totalInvestimentos || 0} + percentual={resumo.percentualInvestimentos} + color="info.main" + /> + + } + label="Saídas totais" + valor={resumo.totalSaidas} + percentual={resumo.percentualSaidas} + color="warning.main" + /> + + = 0 ? 'success' : 'error'} />} + label="Resultado" + valor={resumo.resultado} + percentual={resumo.percentualResultado} + color={resultadoColor} + /> + + + {base?.avisos && base.avisos.length > 0 && ( + + {base.avisos.map((aviso, index) => ( + + {aviso.mensagem} + + ))} + + )} + + {base && ( + + + + + + + + + + )} + + + + + + + + ); +} \ No newline at end of file diff --git a/financeiro-web/src/features/simulacaoMensal/services/simulacaoMensalService.ts b/financeiro-web/src/features/simulacaoMensal/services/simulacaoMensalService.ts new file mode 100644 index 0000000..87cc888 --- /dev/null +++ b/financeiro-web/src/features/simulacaoMensal/services/simulacaoMensalService.ts @@ -0,0 +1,20 @@ +import { api } from '../../../services/api'; +import type { + BuscarBaseSimulacaoMensalParams, + SimulacaoMensalBase, +} from '../types/simulacaoMensalTypes'; + +export async function buscarBaseSimulacaoMensal( + params: BuscarBaseSimulacaoMensalParams +): Promise { + 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; +} \ No newline at end of file diff --git a/financeiro-web/src/features/simulacaoMensal/types/simulacaoMensalTypes.ts b/financeiro-web/src/features/simulacaoMensal/types/simulacaoMensalTypes.ts new file mode 100644 index 0000000..5d88900 --- /dev/null +++ b/financeiro-web/src/features/simulacaoMensal/types/simulacaoMensalTypes.ts @@ -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; +}; \ No newline at end of file