diff --git a/financeiro-api/src/app.js b/financeiro-api/src/app.js index 6bed304..9d9fbd6 100644 --- a/financeiro-api/src/app.js +++ b/financeiro-api/src/app.js @@ -5,6 +5,11 @@ const authRoutes = require('./modules/auth/routes/auth.routes'); const movimentosRoutes = require('./modules/movimentos/routes/movimentos.routes'); const referenciasRoutes = require('./modules/referencias/routes/referencias.routes'); const relatoriosRoutes = require('./modules/relatorios/routes/relatorios.routes'); +const clientesRoutes = require('./modules/clientes/routes/clientes.routes'); +const bancosRoutes = require('./modules/bancos/routes/bancos.routes'); +const centrosCustoRoutes = require('./modules/centrosCusto/routes/centrosCusto.routes'); +const movimentosFixosRoutes = require('./modules/movimentosFixos/routes/movimentosFixos.routes'); +const usuariosRoutes = require('./modules/usuarios/routes/usuarios.routes'); const app = express(); @@ -32,6 +37,11 @@ app.use('/api/auth', authRoutes); app.use('/api/movimentos', movimentosRoutes); app.use('/api/referencias', referenciasRoutes); app.use('/api/relatorios', relatoriosRoutes); +app.use('/api/clientes', clientesRoutes); +app.use('/api/bancos', bancosRoutes); +app.use('/api/centros-custo', centrosCustoRoutes); +app.use('/api/movimentos-fixos', movimentosFixosRoutes); +app.use('/api/usuarios', usuariosRoutes); app.use((req, res) => { return res.status(404).json({ diff --git a/financeiro-api/src/modules/bancos/controllers/bancos.controller.js b/financeiro-api/src/modules/bancos/controllers/bancos.controller.js new file mode 100644 index 0000000..0eff64b --- /dev/null +++ b/financeiro-api/src/modules/bancos/controllers/bancos.controller.js @@ -0,0 +1,264 @@ +const bancosService = require('../services/bancos.service'); + +function validarDadosBanco(dados) { + if (!dados.descricao || !String(dados.descricao).trim()) { + return 'Descrição é obrigatória.'; + } + + if (dados.saldo !== undefined && dados.saldo !== null && dados.saldo !== '') { + const saldo = Number(dados.saldo); + + if (!Number.isFinite(saldo)) { + return 'Saldo inválido.'; + } + } + + if (dados.debito !== undefined && dados.debito !== null && dados.debito !== '') { + const debito = Number(dados.debito); + + if (![0, 1].includes(debito)) { + return 'Tipo débito inválido.'; + } + } + + if (dados.habilitado !== undefined && dados.habilitado !== null && dados.habilitado !== '') { + const habilitado = Number(dados.habilitado); + + if (![0, 1].includes(habilitado)) { + return 'Status inválido.'; + } + } + + return null; +} + +async function listar(req, res) { + try { + const resultado = await bancosService.listarBancos({ + limite: req.query.limite, + offset: req.query.offset, + page: req.query.page, + busca: req.query.busca, + debito: req.query.debito, + habilitado: req.query.habilitado, + orderBy: req.query.orderBy, + orderDirection: req.query.orderDirection, + }); + + return res.json({ + ok: true, + data: resultado.data, + pagination: resultado.pagination, + summary: resultado.summary, + }); + } catch (error) { + console.error('Erro ao listar bancos:', error); + + return res.status(500).json({ + ok: false, + message: 'Erro ao listar bancos.', + }); + } +} + +async function buscarPorId(req, res) { + try { + const id = Number(req.params.id); + + if (!id) { + return res.status(400).json({ + ok: false, + message: 'ID inválido.', + }); + } + + const banco = await bancosService.buscarBancoPorId(id); + + if (!banco) { + return res.status(404).json({ + ok: false, + message: 'Banco/carteira não encontrado.', + }); + } + + return res.json({ + ok: true, + data: banco, + }); + } catch (error) { + console.error('Erro ao buscar banco:', error); + + return res.status(500).json({ + ok: false, + message: 'Erro ao buscar banco.', + }); + } +} + +async function criar(req, res) { + try { + const erroValidacao = validarDadosBanco(req.body); + + if (erroValidacao) { + return res.status(400).json({ + ok: false, + message: erroValidacao, + }); + } + + const novoBanco = await bancosService.criarBanco(req.body); + + return res.status(201).json({ + ok: true, + message: 'Banco/carteira cadastrado com sucesso.', + data: novoBanco, + }); + } catch (error) { + console.error('Erro ao criar banco:', error); + + return res.status(500).json({ + ok: false, + message: 'Erro ao criar banco/carteira.', + }); + } +} + +async function atualizar(req, res) { + try { + const id = Number(req.params.id); + + if (!id) { + return res.status(400).json({ + ok: false, + message: 'ID inválido.', + }); + } + + const erroValidacao = validarDadosBanco(req.body); + + if (erroValidacao) { + return res.status(400).json({ + ok: false, + message: erroValidacao, + }); + } + + const bancoAtualizado = await bancosService.atualizarBanco(id, req.body); + + if (!bancoAtualizado) { + return res.status(404).json({ + ok: false, + message: 'Banco/carteira não encontrado.', + }); + } + + return res.json({ + ok: true, + message: 'Banco/carteira atualizado com sucesso.', + data: bancoAtualizado, + }); + } catch (error) { + console.error('Erro ao atualizar banco:', error); + + return res.status(500).json({ + ok: false, + message: 'Erro ao atualizar banco/carteira.', + }); + } +} + +async function alterarHabilitado(req, res) { + try { + const id = Number(req.params.id); + + if (!id) { + return res.status(400).json({ + ok: false, + message: 'ID inválido.', + }); + } + + const habilitado = Number(req.body.habilitado); + + if (![0, 1].includes(habilitado)) { + return res.status(400).json({ + ok: false, + message: 'Status inválido.', + }); + } + + const bancoAtualizado = await bancosService.alterarHabilitadoBanco(id, habilitado); + + if (!bancoAtualizado) { + return res.status(404).json({ + ok: false, + message: 'Banco/carteira não encontrado.', + }); + } + + return res.json({ + ok: true, + message: habilitado === 1 + ? 'Banco/carteira habilitado com sucesso.' + : 'Banco/carteira desabilitado com sucesso.', + data: bancoAtualizado, + }); + } catch (error) { + console.error('Erro ao alterar status do banco:', error); + + return res.status(500).json({ + ok: false, + message: 'Erro ao alterar status do banco/carteira.', + }); + } +} + +async function deletar(req, res) { + try { + const id = Number(req.params.id); + + if (!id) { + return res.status(400).json({ + ok: false, + message: 'ID inválido.', + }); + } + + const deletado = await bancosService.deletarBanco(id); + + if (!deletado) { + return res.status(404).json({ + ok: false, + message: 'Banco/carteira não encontrado.', + }); + } + + return res.json({ + ok: true, + message: 'Banco/carteira excluído com sucesso.', + }); + } catch (error) { + console.error('Erro ao excluir banco:', error); + + if (error.code === 'ER_ROW_IS_REFERENCED_2') { + return res.status(400).json({ + ok: false, + message: 'Este banco/carteira possui movimentos vinculados e não pode ser excluído.', + }); + } + + return res.status(500).json({ + ok: false, + message: 'Erro ao excluir banco/carteira.', + }); + } +} + +module.exports = { + listar, + buscarPorId, + criar, + atualizar, + alterarHabilitado, + deletar, +}; \ No newline at end of file diff --git a/financeiro-api/src/modules/bancos/routes/bancos.routes.js b/financeiro-api/src/modules/bancos/routes/bancos.routes.js new file mode 100644 index 0000000..44f7080 --- /dev/null +++ b/financeiro-api/src/modules/bancos/routes/bancos.routes.js @@ -0,0 +1,16 @@ +const express = require('express'); +const authMiddleware = require('../../../middlewares/auth.middleware'); +const bancosController = require('../controllers/bancos.controller'); + +const router = express.Router(); + +router.use(authMiddleware); + +router.get('/', bancosController.listar); +router.get('/:id', bancosController.buscarPorId); +router.post('/', bancosController.criar); +router.put('/:id', bancosController.atualizar); +router.patch('/:id/habilitado', bancosController.alterarHabilitado); +router.delete('/:id', bancosController.deletar); + +module.exports = router; \ No newline at end of file diff --git a/financeiro-api/src/modules/bancos/services/bancos.service.js b/financeiro-api/src/modules/bancos/services/bancos.service.js new file mode 100644 index 0000000..b2ce8e9 --- /dev/null +++ b/financeiro-api/src/modules/bancos/services/bancos.service.js @@ -0,0 +1,306 @@ +const pool = require('../../../database/mysql'); + +const CAMPOS_BANCO_SELECT = ` + idbancos, + descricao, + saldo, + debito, + habilitado, + insert_date, + update_date +`; + +function normalizarTextoOuNull(valor) { + if (valor === undefined || valor === null || valor === '') { + return null; + } + + return String(valor).trim(); +} + +function normalizarNumero(valor, padrao = 0) { + if (valor === undefined || valor === null || valor === '') { + return padrao; + } + + const numero = Number(valor); + + if (!Number.isFinite(numero)) { + return padrao; + } + + return numero; +} + +function limitarNumero(valor, padrao, minimo, maximo) { + const numero = Number(valor); + + if (!Number.isFinite(numero)) { + return padrao; + } + + if (numero < minimo) { + return minimo; + } + + if (numero > maximo) { + return maximo; + } + + return numero; +} + +function resolverOrdenacao(orderBy) { + const camposPermitidos = { + idbancos: 'idbancos', + descricao: 'descricao', + saldo: 'saldo', + debito: 'debito', + habilitado: 'habilitado', + insert_date: 'insert_date', + update_date: 'update_date', + }; + + return camposPermitidos[orderBy] || 'descricao'; +} + +function resolverDirecao(orderDirection) { + return String(orderDirection || '').toUpperCase() === 'DESC' ? 'DESC' : 'ASC'; +} + +function montarWhereBancos(filtros = {}) { + const where = []; + const params = []; + + if (filtros.debito !== undefined && filtros.debito !== null && filtros.debito !== '') { + where.push('debito = ?'); + params.push(Number(filtros.debito)); + } + + if (filtros.habilitado !== undefined && filtros.habilitado !== null && filtros.habilitado !== '') { + where.push('habilitado = ?'); + params.push(Number(filtros.habilitado)); + } + + if (filtros.busca) { + where.push(` + ( + descricao LIKE ? + ) + `); + + const termo = `%${String(filtros.busca).trim()}%`; + params.push(termo); + } + + const whereSql = where.length > 0 ? `WHERE ${where.join(' AND ')}` : ''; + + return { + whereSql, + params, + }; +} + +async function listarBancos(filtros = {}) { + const limite = limitarNumero(filtros.limite, 20, 1, 100); + const page = limitarNumero(filtros.page, 1, 1, 999999); + const offset = filtros.offset !== undefined + ? limitarNumero(filtros.offset, 0, 0, 999999999) + : (page - 1) * limite; + + const orderBy = resolverOrdenacao(filtros.orderBy); + const orderDirection = resolverDirecao(filtros.orderDirection); + + const { whereSql, params } = montarWhereBancos(filtros); + + const [rows] = await pool.query( + ` + SELECT + ${CAMPOS_BANCO_SELECT} + FROM bancos + ${whereSql} + ORDER BY ${orderBy} ${orderDirection}, idbancos ASC + LIMIT ? OFFSET ? + `, + [...params, limite, offset] + ); + + const [countRows] = await pool.query( + ` + SELECT COUNT(*) AS total + FROM bancos + ${whereSql} + `, + params + ); + + const [summaryRows] = await pool.query( + ` + SELECT + COUNT(*) AS quantidade, + COALESCE(SUM(saldo), 0) AS saldoTotal, + COALESCE(SUM(CASE WHEN debito = 0 THEN saldo ELSE 0 END), 0) AS saldoCarteiras, + COALESCE(SUM(CASE WHEN debito = 1 THEN saldo ELSE 0 END), 0) AS saldoDebito + FROM bancos + ${whereSql} + `, + params + ); + + const total = Number(countRows[0]?.total || 0); + const totalPages = Math.max(1, Math.ceil(total / limite)); + + return { + data: rows, + pagination: { + total, + limite, + offset, + page: Math.floor(offset / limite) + 1, + totalPages, + }, + summary: { + quantidade: Number(summaryRows[0]?.quantidade || 0), + saldoTotal: Number(summaryRows[0]?.saldoTotal || 0), + saldoCarteiras: Number(summaryRows[0]?.saldoCarteiras || 0), + saldoDebito: Number(summaryRows[0]?.saldoDebito || 0), + }, + }; +} + +async function buscarBancoPorId(id) { + const [rows] = await pool.query( + ` + SELECT + ${CAMPOS_BANCO_SELECT} + FROM bancos + WHERE idbancos = ? + LIMIT 1 + `, + [id] + ); + + return rows[0] || null; +} + +async function criarBanco(dados) { + const { + descricao, + saldo, + debito, + habilitado, + } = dados; + + const [result] = await pool.query( + ` + INSERT INTO bancos ( + descricao, + saldo, + debito, + habilitado, + insert_date, + update_date + ) VALUES (?, ?, ?, ?, NOW(), NOW()) + `, + [ + normalizarTextoOuNull(descricao), + normalizarNumero(saldo, 0), + Number(debito || 0), + habilitado === undefined || habilitado === null || habilitado === '' + ? 1 + : Number(habilitado), + ] + ); + + return buscarBancoPorId(result.insertId); +} + +async function atualizarBanco(id, dados) { + const bancoAtual = await buscarBancoPorId(id); + + if (!bancoAtual) { + return null; + } + + const { + descricao, + saldo, + debito, + habilitado, + } = dados; + + await pool.query( + ` + UPDATE bancos + SET + descricao = ?, + saldo = ?, + debito = ?, + habilitado = ?, + update_date = NOW() + WHERE idbancos = ? + `, + [ + normalizarTextoOuNull(descricao), + normalizarNumero(saldo, 0), + Number(debito || 0), + habilitado === undefined || habilitado === null || habilitado === '' + ? 1 + : Number(habilitado), + id, + ] + ); + + return buscarBancoPorId(id); +} + +async function alterarHabilitadoBanco(id, habilitado) { + const bancoAtual = await buscarBancoPorId(id); + + if (!bancoAtual) { + return null; + } + + await pool.query( + ` + UPDATE bancos + SET + habilitado = ?, + update_date = NOW() + WHERE idbancos = ? + `, + [ + Number(habilitado), + id, + ] + ); + + return buscarBancoPorId(id); +} + +async function deletarBanco(id) { + const bancoAtual = await buscarBancoPorId(id); + + if (!bancoAtual) { + return false; + } + + await pool.query( + ` + DELETE FROM bancos + WHERE idbancos = ? + `, + [id] + ); + + return true; +} + +module.exports = { + listarBancos, + buscarBancoPorId, + criarBanco, + atualizarBanco, + alterarHabilitadoBanco, + deletarBanco, +}; \ No newline at end of file diff --git a/financeiro-api/src/modules/centrosCusto/controllers/centrosCusto.controller.js b/financeiro-api/src/modules/centrosCusto/controllers/centrosCusto.controller.js new file mode 100644 index 0000000..33c98c4 --- /dev/null +++ b/financeiro-api/src/modules/centrosCusto/controllers/centrosCusto.controller.js @@ -0,0 +1,273 @@ +const centrosCustoService = require('../services/centrosCusto.service'); + +function validarDadosCentroCusto(dados) { + if (!dados.descricao || !String(dados.descricao).trim()) { + return 'Descrição é obrigatória.'; + } + + if (dados.limite !== undefined && dados.limite !== null && dados.limite !== '') { + const limite = Number(dados.limite); + + if (!Number.isFinite(limite)) { + return 'Limite inválido.'; + } + } + + if (dados.simular !== undefined && dados.simular !== null && dados.simular !== '') { + const simular = Number(dados.simular); + + if (![0, 1].includes(simular)) { + return 'Campo simular inválido.'; + } + } + + if (dados.investimento !== undefined && dados.investimento !== null && dados.investimento !== '') { + const investimento = Number(dados.investimento); + + if (![0, 1].includes(investimento)) { + return 'Campo investimento inválido.'; + } + } + + if (dados.habilitado !== undefined && dados.habilitado !== null && dados.habilitado !== '') { + const habilitado = Number(dados.habilitado); + + if (![0, 1].includes(habilitado)) { + return 'Status inválido.'; + } + } + + return null; +} + +async function listar(req, res) { + try { + const resultado = await centrosCustoService.listarCentrosCusto({ + limite: req.query.limite, + offset: req.query.offset, + page: req.query.page, + busca: req.query.busca, + simular: req.query.simular, + investimento: req.query.investimento, + habilitado: req.query.habilitado, + orderBy: req.query.orderBy, + orderDirection: req.query.orderDirection, + }); + + return res.json({ + ok: true, + data: resultado.data, + pagination: resultado.pagination, + summary: resultado.summary, + }); + } catch (error) { + console.error('Erro ao listar centros de custo:', error); + + return res.status(500).json({ + ok: false, + message: 'Erro ao listar centros de custo.', + }); + } +} + +async function buscarPorId(req, res) { + try { + const id = Number(req.params.id); + + if (!id) { + return res.status(400).json({ + ok: false, + message: 'ID inválido.', + }); + } + + const centroCusto = await centrosCustoService.buscarCentroCustoPorId(id); + + if (!centroCusto) { + return res.status(404).json({ + ok: false, + message: 'Centro de custo não encontrado.', + }); + } + + return res.json({ + ok: true, + data: centroCusto, + }); + } catch (error) { + console.error('Erro ao buscar centro de custo:', error); + + return res.status(500).json({ + ok: false, + message: 'Erro ao buscar centro de custo.', + }); + } +} + +async function criar(req, res) { + try { + const erroValidacao = validarDadosCentroCusto(req.body); + + if (erroValidacao) { + return res.status(400).json({ + ok: false, + message: erroValidacao, + }); + } + + const novoCentroCusto = await centrosCustoService.criarCentroCusto(req.body); + + return res.status(201).json({ + ok: true, + message: 'Centro de custo cadastrado com sucesso.', + data: novoCentroCusto, + }); + } catch (error) { + console.error('Erro ao criar centro de custo:', error); + + return res.status(500).json({ + ok: false, + message: 'Erro ao criar centro de custo.', + }); + } +} + +async function atualizar(req, res) { + try { + const id = Number(req.params.id); + + if (!id) { + return res.status(400).json({ + ok: false, + message: 'ID inválido.', + }); + } + + const erroValidacao = validarDadosCentroCusto(req.body); + + if (erroValidacao) { + return res.status(400).json({ + ok: false, + message: erroValidacao, + }); + } + + const centroAtualizado = await centrosCustoService.atualizarCentroCusto(id, req.body); + + if (!centroAtualizado) { + return res.status(404).json({ + ok: false, + message: 'Centro de custo não encontrado.', + }); + } + + return res.json({ + ok: true, + message: 'Centro de custo atualizado com sucesso.', + data: centroAtualizado, + }); + } catch (error) { + console.error('Erro ao atualizar centro de custo:', error); + + return res.status(500).json({ + ok: false, + message: 'Erro ao atualizar centro de custo.', + }); + } +} + +async function alterarHabilitado(req, res) { + try { + const id = Number(req.params.id); + + if (!id) { + return res.status(400).json({ + ok: false, + message: 'ID inválido.', + }); + } + + const habilitado = Number(req.body.habilitado); + + if (![0, 1].includes(habilitado)) { + return res.status(400).json({ + ok: false, + message: 'Status inválido.', + }); + } + + const centroAtualizado = await centrosCustoService.alterarHabilitadoCentroCusto(id, habilitado); + + if (!centroAtualizado) { + return res.status(404).json({ + ok: false, + message: 'Centro de custo não encontrado.', + }); + } + + return res.json({ + ok: true, + message: habilitado === 1 + ? 'Centro de custo habilitado com sucesso.' + : 'Centro de custo desabilitado com sucesso.', + data: centroAtualizado, + }); + } catch (error) { + console.error('Erro ao alterar status do centro de custo:', error); + + return res.status(500).json({ + ok: false, + message: 'Erro ao alterar status do centro de custo.', + }); + } +} + +async function deletar(req, res) { + try { + const id = Number(req.params.id); + + if (!id) { + return res.status(400).json({ + ok: false, + message: 'ID inválido.', + }); + } + + const deletado = await centrosCustoService.deletarCentroCusto(id); + + if (!deletado) { + return res.status(404).json({ + ok: false, + message: 'Centro de custo não encontrado.', + }); + } + + return res.json({ + ok: true, + message: 'Centro de custo excluído com sucesso.', + }); + } catch (error) { + console.error('Erro ao excluir centro de custo:', error); + + if (error.code === 'ER_ROW_IS_REFERENCED_2') { + return res.status(400).json({ + ok: false, + message: 'Este centro de custo possui movimentos vinculados e não pode ser excluído.', + }); + } + + return res.status(500).json({ + ok: false, + message: 'Erro ao excluir centro de custo.', + }); + } +} + +module.exports = { + listar, + buscarPorId, + criar, + atualizar, + alterarHabilitado, + deletar, +}; \ No newline at end of file diff --git a/financeiro-api/src/modules/centrosCusto/routes/centrosCusto.routes.js b/financeiro-api/src/modules/centrosCusto/routes/centrosCusto.routes.js new file mode 100644 index 0000000..a025212 --- /dev/null +++ b/financeiro-api/src/modules/centrosCusto/routes/centrosCusto.routes.js @@ -0,0 +1,16 @@ +const express = require('express'); +const authMiddleware = require('../../../middlewares/auth.middleware'); +const centrosCustoController = require('../controllers/centrosCusto.controller'); + +const router = express.Router(); + +router.use(authMiddleware); + +router.get('/', centrosCustoController.listar); +router.get('/:id', centrosCustoController.buscarPorId); +router.post('/', centrosCustoController.criar); +router.put('/:id', centrosCustoController.atualizar); +router.patch('/:id/habilitado', centrosCustoController.alterarHabilitado); +router.delete('/:id', centrosCustoController.deletar); + +module.exports = router; \ No newline at end of file diff --git a/financeiro-api/src/modules/centrosCusto/services/centrosCusto.service.js b/financeiro-api/src/modules/centrosCusto/services/centrosCusto.service.js new file mode 100644 index 0000000..6b840bc --- /dev/null +++ b/financeiro-api/src/modules/centrosCusto/services/centrosCusto.service.js @@ -0,0 +1,329 @@ +const pool = require('../../../database/mysql'); + +const CAMPOS_CENTRO_CUSTO_SELECT = ` + idcentrodecustos, + descricao, + limite, + simular, + investimento, + habilitado, + insert_date, + update_date +`; + +function normalizarTextoOuNull(valor) { + if (valor === undefined || valor === null || valor === '') { + return null; + } + + return String(valor).trim(); +} + +function normalizarNumero(valor, padrao = 0) { + if (valor === undefined || valor === null || valor === '') { + return padrao; + } + + 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; + } + + const numero = Number(valor); + + return numero === 1 ? 1 : 0; +} + +function limitarNumero(valor, padrao, minimo, maximo) { + const numero = Number(valor); + + if (!Number.isFinite(numero)) { + return padrao; + } + + if (numero < minimo) { + return minimo; + } + + if (numero > maximo) { + return maximo; + } + + return numero; +} + +function resolverOrdenacao(orderBy) { + const camposPermitidos = { + idcentrodecustos: 'idcentrodecustos', + descricao: 'descricao', + limite: 'limite', + simular: 'simular', + investimento: 'investimento', + habilitado: 'habilitado', + insert_date: 'insert_date', + update_date: 'update_date', + }; + + return camposPermitidos[orderBy] || 'descricao'; +} + +function resolverDirecao(orderDirection) { + return String(orderDirection || '').toUpperCase() === 'DESC' ? 'DESC' : 'ASC'; +} + +function montarWhereCentrosCusto(filtros = {}) { + const where = []; + const params = []; + + if (filtros.simular !== undefined && filtros.simular !== null && filtros.simular !== '') { + where.push('simular = ?'); + params.push(Number(filtros.simular)); + } + + if (filtros.investimento !== undefined && filtros.investimento !== null && filtros.investimento !== '') { + where.push('investimento = ?'); + params.push(Number(filtros.investimento)); + } + + if (filtros.habilitado !== undefined && filtros.habilitado !== null && filtros.habilitado !== '') { + where.push('habilitado = ?'); + params.push(Number(filtros.habilitado)); + } + + if (filtros.busca) { + where.push(` + ( + descricao LIKE ? + ) + `); + + const termo = `%${String(filtros.busca).trim()}%`; + params.push(termo); + } + + const whereSql = where.length > 0 ? `WHERE ${where.join(' AND ')}` : ''; + + return { + whereSql, + params, + }; +} + +async function listarCentrosCusto(filtros = {}) { + const limite = limitarNumero(filtros.limite, 20, 1, 100); + const page = limitarNumero(filtros.page, 1, 1, 999999); + const offset = filtros.offset !== undefined + ? limitarNumero(filtros.offset, 0, 0, 999999999) + : (page - 1) * limite; + + const orderBy = resolverOrdenacao(filtros.orderBy); + const orderDirection = resolverDirecao(filtros.orderDirection); + + const { whereSql, params } = montarWhereCentrosCusto(filtros); + + const [rows] = await pool.query( + ` + SELECT + ${CAMPOS_CENTRO_CUSTO_SELECT} + FROM centrodecustos + ${whereSql} + ORDER BY ${orderBy} ${orderDirection}, idcentrodecustos ASC + LIMIT ? OFFSET ? + `, + [...params, limite, offset] + ); + + const [countRows] = await pool.query( + ` + SELECT COUNT(*) AS total + FROM centrodecustos + ${whereSql} + `, + params + ); + + const [summaryRows] = await pool.query( + ` + SELECT + COUNT(*) AS quantidade, + COALESCE(SUM(limite), 0) AS limiteTotal, + COALESCE(SUM(CASE WHEN habilitado = 1 THEN 1 ELSE 0 END), 0) AS habilitados, + COALESCE(SUM(CASE WHEN habilitado = 0 THEN 1 ELSE 0 END), 0) AS desabilitados, + COALESCE(SUM(CASE WHEN investimento = 1 THEN 1 ELSE 0 END), 0) AS investimentos, + COALESCE(SUM(CASE WHEN simular = 1 THEN 1 ELSE 0 END), 0) AS simulaveis + FROM centrodecustos + ${whereSql} + `, + params + ); + + const total = Number(countRows[0]?.total || 0); + const totalPages = Math.max(1, Math.ceil(total / limite)); + + return { + data: rows, + pagination: { + total, + limite, + offset, + page: Math.floor(offset / limite) + 1, + totalPages, + }, + summary: { + quantidade: Number(summaryRows[0]?.quantidade || 0), + limiteTotal: Number(summaryRows[0]?.limiteTotal || 0), + habilitados: Number(summaryRows[0]?.habilitados || 0), + desabilitados: Number(summaryRows[0]?.desabilitados || 0), + investimentos: Number(summaryRows[0]?.investimentos || 0), + simulaveis: Number(summaryRows[0]?.simulaveis || 0), + }, + }; +} + +async function buscarCentroCustoPorId(id) { + const [rows] = await pool.query( + ` + SELECT + ${CAMPOS_CENTRO_CUSTO_SELECT} + FROM centrodecustos + WHERE idcentrodecustos = ? + LIMIT 1 + `, + [id] + ); + + return rows[0] || null; +} + +async function criarCentroCusto(dados) { + const { + descricao, + limite, + simular, + investimento, + habilitado, + } = dados; + + const [result] = await pool.query( + ` + INSERT INTO centrodecustos ( + descricao, + limite, + simular, + investimento, + habilitado, + insert_date, + update_date + ) VALUES (?, ?, ?, ?, ?, NOW(), NOW()) + `, + [ + normalizarTextoOuNull(descricao), + normalizarNumero(limite, 0), + normalizarFlag(simular, 0), + normalizarFlag(investimento, 0), + normalizarFlag(habilitado, 1), + ] + ); + + return buscarCentroCustoPorId(result.insertId); +} + +async function atualizarCentroCusto(id, dados) { + const centroAtual = await buscarCentroCustoPorId(id); + + if (!centroAtual) { + return null; + } + + const { + descricao, + limite, + simular, + investimento, + habilitado, + } = dados; + + await pool.query( + ` + UPDATE centrodecustos + SET + descricao = ?, + limite = ?, + simular = ?, + investimento = ?, + habilitado = ?, + update_date = NOW() + WHERE idcentrodecustos = ? + `, + [ + normalizarTextoOuNull(descricao), + normalizarNumero(limite, 0), + normalizarFlag(simular, 0), + normalizarFlag(investimento, 0), + normalizarFlag(habilitado, 1), + id, + ] + ); + + return buscarCentroCustoPorId(id); +} + +async function alterarHabilitadoCentroCusto(id, habilitado) { + const centroAtual = await buscarCentroCustoPorId(id); + + if (!centroAtual) { + return null; + } + + await pool.query( + ` + UPDATE centrodecustos + SET + habilitado = ?, + update_date = NOW() + WHERE idcentrodecustos = ? + `, + [ + normalizarFlag(habilitado, 1), + id, + ] + ); + + return buscarCentroCustoPorId(id); +} + +async function deletarCentroCusto(id) { + const centroAtual = await buscarCentroCustoPorId(id); + + if (!centroAtual) { + return false; + } + + await pool.query( + ` + DELETE FROM centrodecustos + WHERE idcentrodecustos = ? + `, + [id] + ); + + return true; +} + +module.exports = { + listarCentrosCusto, + buscarCentroCustoPorId, + criarCentroCusto, + atualizarCentroCusto, + alterarHabilitadoCentroCusto, + deletarCentroCusto, +}; \ No newline at end of file diff --git a/financeiro-api/src/modules/clientes/controllers/clientes.controller.js b/financeiro-api/src/modules/clientes/controllers/clientes.controller.js new file mode 100644 index 0000000..08ed227 --- /dev/null +++ b/financeiro-api/src/modules/clientes/controllers/clientes.controller.js @@ -0,0 +1,272 @@ +const clientesService = require('../services/clientes.service'); + +function validarDadosCliente(dados) { + if (!dados.cpf_cnpj || !String(dados.cpf_cnpj).trim()) { + return 'CPF/CNPJ é obrigatório.'; + } + + if (!dados.nome || !String(dados.nome).trim()) { + return 'Nome é obrigatório.'; + } + + if (dados.email && !String(dados.email).includes('@')) { + return 'E-mail inválido.'; + } + + if (dados.pessoafisica && !['F', 'J', 'S', 'N'].includes(String(dados.pessoafisica).toUpperCase())) { + return 'Tipo de pessoa inválido.'; + } + + if (dados.sexo && !['M', 'F', 'O'].includes(String(dados.sexo).toUpperCase())) { + return 'Sexo inválido.'; + } + + if (dados.habilitado !== undefined && dados.habilitado !== null && dados.habilitado !== '') { + const habilitado = Number(dados.habilitado); + + if (![0, 1].includes(habilitado)) { + return 'Status inválido.'; + } + } + + return null; +} + +async function listar(req, res) { + try { + const resultado = await clientesService.listarClientes({ + limite: req.query.limite, + offset: req.query.offset, + page: req.query.page, + busca: req.query.busca, + pessoafisica: req.query.pessoafisica, + habilitado: req.query.habilitado, + cidade: req.query.cidade, + estado: req.query.estado, + orderBy: req.query.orderBy, + orderDirection: req.query.orderDirection, + }); + + return res.json({ + ok: true, + data: resultado.data, + pagination: resultado.pagination, + }); + } catch (error) { + console.error('Erro ao listar clientes:', error); + + return res.status(500).json({ + ok: false, + message: 'Erro ao listar clientes.', + }); + } +} + +async function buscarPorId(req, res) { + try { + const id = Number(req.params.id); + + if (!id) { + return res.status(400).json({ + ok: false, + message: 'ID inválido.', + }); + } + + const cliente = await clientesService.buscarClientePorId(id); + + if (!cliente) { + return res.status(404).json({ + ok: false, + message: 'Cliente não encontrado.', + }); + } + + return res.json({ + ok: true, + data: cliente, + }); + } catch (error) { + console.error('Erro ao buscar cliente:', error); + + return res.status(500).json({ + ok: false, + message: 'Erro ao buscar cliente.', + }); + } +} + +async function criar(req, res) { + try { + const erroValidacao = validarDadosCliente(req.body); + + if (erroValidacao) { + return res.status(400).json({ + ok: false, + message: erroValidacao, + }); + } + + const novoCliente = await clientesService.criarCliente(req.body); + + return res.status(201).json({ + ok: true, + message: 'Cliente cadastrado com sucesso.', + data: novoCliente, + }); + } catch (error) { + console.error('Erro ao criar cliente:', error); + + if (error.code === 'ER_DUP_ENTRY') { + return res.status(400).json({ + ok: false, + message: 'Já existe um cliente cadastrado com esses dados.', + }); + } + + return res.status(500).json({ + ok: false, + message: 'Erro ao criar cliente.', + }); + } +} + +async function atualizar(req, res) { + try { + const id = Number(req.params.id); + + if (!id) { + return res.status(400).json({ + ok: false, + message: 'ID inválido.', + }); + } + + const erroValidacao = validarDadosCliente(req.body); + + if (erroValidacao) { + return res.status(400).json({ + ok: false, + message: erroValidacao, + }); + } + + const clienteAtualizado = await clientesService.atualizarCliente(id, req.body); + + if (!clienteAtualizado) { + return res.status(404).json({ + ok: false, + message: 'Cliente não encontrado.', + }); + } + + return res.json({ + ok: true, + message: 'Cliente atualizado com sucesso.', + data: clienteAtualizado, + }); + } catch (error) { + console.error('Erro ao atualizar cliente:', error); + + return res.status(500).json({ + ok: false, + message: 'Erro ao atualizar cliente.', + }); + } +} + +async function alterarHabilitado(req, res) { + try { + const id = Number(req.params.id); + + if (!id) { + return res.status(400).json({ + ok: false, + message: 'ID inválido.', + }); + } + + const habilitado = Number(req.body.habilitado); + + if (![0, 1].includes(habilitado)) { + return res.status(400).json({ + ok: false, + message: 'Status inválido.', + }); + } + + const clienteAtualizado = await clientesService.alterarHabilitadoCliente(id, habilitado); + + if (!clienteAtualizado) { + return res.status(404).json({ + ok: false, + message: 'Cliente não encontrado.', + }); + } + + return res.json({ + ok: true, + message: habilitado === 1 + ? 'Cliente habilitado com sucesso.' + : 'Cliente desabilitado com sucesso.', + data: clienteAtualizado, + }); + } catch (error) { + console.error('Erro ao alterar status do cliente:', error); + + return res.status(500).json({ + ok: false, + message: 'Erro ao alterar status do cliente.', + }); + } +} + +async function deletar(req, res) { + try { + const id = Number(req.params.id); + + if (!id) { + return res.status(400).json({ + ok: false, + message: 'ID inválido.', + }); + } + + const deletado = await clientesService.deletarCliente(id); + + if (!deletado) { + return res.status(404).json({ + ok: false, + message: 'Cliente não encontrado.', + }); + } + + return res.json({ + ok: true, + message: 'Cliente excluído com sucesso.', + }); + } catch (error) { + console.error('Erro ao excluir cliente:', error); + + if (error.code === 'ER_ROW_IS_REFERENCED_2') { + return res.status(400).json({ + ok: false, + message: 'Este cliente possui movimentos vinculados e não pode ser excluído.', + }); + } + + return res.status(500).json({ + ok: false, + message: 'Erro ao excluir cliente.', + }); + } +} + +module.exports = { + listar, + buscarPorId, + criar, + atualizar, + alterarHabilitado, + deletar, +}; \ No newline at end of file diff --git a/financeiro-api/src/modules/clientes/routes/clientes.routes.js b/financeiro-api/src/modules/clientes/routes/clientes.routes.js new file mode 100644 index 0000000..0215a89 --- /dev/null +++ b/financeiro-api/src/modules/clientes/routes/clientes.routes.js @@ -0,0 +1,16 @@ +const express = require('express'); +const authMiddleware = require('../../../middlewares/auth.middleware'); +const clientesController = require('../controllers/clientes.controller'); + +const router = express.Router(); + +router.use(authMiddleware); + +router.get('/', clientesController.listar); +router.get('/:id', clientesController.buscarPorId); +router.post('/', clientesController.criar); +router.put('/:id', clientesController.atualizar); +router.patch('/:id/habilitado', clientesController.alterarHabilitado); +router.delete('/:id', clientesController.deletar); + +module.exports = router; \ No newline at end of file diff --git a/financeiro-api/src/modules/clientes/services/clientes.service.js b/financeiro-api/src/modules/clientes/services/clientes.service.js new file mode 100644 index 0000000..b167cba --- /dev/null +++ b/financeiro-api/src/modules/clientes/services/clientes.service.js @@ -0,0 +1,379 @@ +const pool = require('../../../database/mysql'); + +const CAMPOS_CLIENTE_SELECT = ` + idclientes, + cpf_cnpj, + nome, + rg, + celular, + email, + cep, + logradouro, + numero, + bairro, + cidade, + estado, + sexo, + pessoafisica, + habilitado, + insert_date, + update_date +`; + +function normalizarTextoOuNull(valor) { + if (valor === undefined || valor === null || valor === '') { + return null; + } + + return String(valor).trim(); +} + +function normalizarFlagOuNull(valor) { + if (valor === undefined || valor === null || valor === '') { + return null; + } + + return String(valor).trim().toUpperCase().substring(0, 1); +} + +function limitarNumero(valor, padrao, minimo, maximo) { + const numero = Number(valor); + + if (!Number.isFinite(numero)) { + return padrao; + } + + if (numero < minimo) { + return minimo; + } + + if (numero > maximo) { + return maximo; + } + + return numero; +} + +function resolverOrdenacao(orderBy) { + const camposPermitidos = { + idclientes: 'idclientes', + nome: 'nome', + cpf_cnpj: 'cpf_cnpj', + email: 'email', + cidade: 'cidade', + estado: 'estado', + pessoafisica: 'pessoafisica', + habilitado: 'habilitado', + insert_date: 'insert_date', + update_date: 'update_date', + }; + + return camposPermitidos[orderBy] || 'nome'; +} + +function resolverDirecao(orderDirection) { + return String(orderDirection || '').toUpperCase() === 'DESC' ? 'DESC' : 'ASC'; +} + +function montarWhereClientes(filtros = {}) { + const where = []; + const params = []; + + if (filtros.pessoafisica) { + where.push('pessoafisica = ?'); + params.push(String(filtros.pessoafisica).toUpperCase().substring(0, 1)); + } + + if (filtros.habilitado !== undefined && filtros.habilitado !== null && filtros.habilitado !== '') { + where.push('habilitado = ?'); + params.push(Number(filtros.habilitado)); + } + + if (filtros.cidade) { + where.push('cidade = ?'); + params.push(filtros.cidade); + } + + if (filtros.estado) { + where.push('estado = ?'); + params.push(filtros.estado); + } + + if (filtros.busca) { + where.push(` + ( + nome LIKE ? + OR cpf_cnpj LIKE ? + OR rg LIKE ? + OR celular LIKE ? + OR email LIKE ? + OR cidade LIKE ? + OR estado LIKE ? + ) + `); + + const termo = `%${String(filtros.busca).trim()}%`; + + params.push( + termo, + termo, + termo, + termo, + termo, + termo, + termo + ); + } + + const whereSql = where.length > 0 ? `WHERE ${where.join(' AND ')}` : ''; + + return { + whereSql, + params, + }; +} + +async function listarClientes(filtros = {}) { + const limite = limitarNumero(filtros.limite, 20, 1, 100); + const page = limitarNumero(filtros.page, 1, 1, 999999); + const offset = filtros.offset !== undefined + ? limitarNumero(filtros.offset, 0, 0, 999999999) + : (page - 1) * limite; + + const orderBy = resolverOrdenacao(filtros.orderBy); + const orderDirection = resolverDirecao(filtros.orderDirection); + + const { whereSql, params } = montarWhereClientes(filtros); + + const [rows] = await pool.query( + ` + SELECT + ${CAMPOS_CLIENTE_SELECT} + FROM clientes + ${whereSql} + ORDER BY ${orderBy} ${orderDirection}, idclientes ASC + LIMIT ? OFFSET ? + `, + [...params, limite, offset] + ); + + const [countRows] = await pool.query( + ` + SELECT COUNT(*) AS total + FROM clientes + ${whereSql} + `, + params + ); + + const total = Number(countRows[0]?.total || 0); + const totalPages = Math.max(1, Math.ceil(total / limite)); + + return { + data: rows, + pagination: { + total, + limite, + offset, + page: Math.floor(offset / limite) + 1, + totalPages, + }, + }; +} + +async function buscarClientePorId(id) { + const [rows] = await pool.query( + ` + SELECT + ${CAMPOS_CLIENTE_SELECT} + FROM clientes + WHERE idclientes = ? + LIMIT 1 + `, + [id] + ); + + return rows[0] || null; +} + +async function criarCliente(dados) { + const { + cpf_cnpj, + nome, + rg, + celular, + email, + cep, + logradouro, + numero, + bairro, + cidade, + estado, + sexo, + pessoafisica, + habilitado, + } = dados; + + const [result] = await pool.query( + ` + INSERT INTO clientes ( + cpf_cnpj, + nome, + rg, + celular, + email, + cep, + logradouro, + numero, + bairro, + cidade, + estado, + sexo, + pessoafisica, + habilitado, + insert_date, + update_date + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW()) + `, + [ + normalizarTextoOuNull(cpf_cnpj), + normalizarTextoOuNull(nome), + normalizarTextoOuNull(rg), + normalizarTextoOuNull(celular), + normalizarTextoOuNull(email), + normalizarTextoOuNull(cep), + normalizarTextoOuNull(logradouro), + normalizarTextoOuNull(numero), + normalizarTextoOuNull(bairro), + normalizarTextoOuNull(cidade), + normalizarTextoOuNull(estado), + normalizarFlagOuNull(sexo), + normalizarFlagOuNull(pessoafisica), + habilitado === undefined || habilitado === null || habilitado === '' + ? 1 + : Number(habilitado), + ] + ); + + return buscarClientePorId(result.insertId); +} + +async function atualizarCliente(id, dados) { + const clienteAtual = await buscarClientePorId(id); + + if (!clienteAtual) { + return null; + } + + const { + cpf_cnpj, + nome, + rg, + celular, + email, + cep, + logradouro, + numero, + bairro, + cidade, + estado, + sexo, + pessoafisica, + habilitado, + } = dados; + + await pool.query( + ` + UPDATE clientes + SET + cpf_cnpj = ?, + nome = ?, + rg = ?, + celular = ?, + email = ?, + cep = ?, + logradouro = ?, + numero = ?, + bairro = ?, + cidade = ?, + estado = ?, + sexo = ?, + pessoafisica = ?, + habilitado = ?, + update_date = NOW() + WHERE idclientes = ? + `, + [ + normalizarTextoOuNull(cpf_cnpj), + normalizarTextoOuNull(nome), + normalizarTextoOuNull(rg), + normalizarTextoOuNull(celular), + normalizarTextoOuNull(email), + normalizarTextoOuNull(cep), + normalizarTextoOuNull(logradouro), + normalizarTextoOuNull(numero), + normalizarTextoOuNull(bairro), + normalizarTextoOuNull(cidade), + normalizarTextoOuNull(estado), + normalizarFlagOuNull(sexo), + normalizarFlagOuNull(pessoafisica), + habilitado === undefined || habilitado === null || habilitado === '' + ? 1 + : Number(habilitado), + id, + ] + ); + + return buscarClientePorId(id); +} + +async function alterarHabilitadoCliente(id, habilitado) { + const clienteAtual = await buscarClientePorId(id); + + if (!clienteAtual) { + return null; + } + + await pool.query( + ` + UPDATE clientes + SET + habilitado = ?, + update_date = NOW() + WHERE idclientes = ? + `, + [ + Number(habilitado), + id, + ] + ); + + return buscarClientePorId(id); +} + +async function deletarCliente(id) { + const clienteAtual = await buscarClientePorId(id); + + if (!clienteAtual) { + return false; + } + + await pool.query( + ` + DELETE FROM clientes + WHERE idclientes = ? + `, + [id] + ); + + return true; +} + +module.exports = { + listarClientes, + buscarClientePorId, + criarCliente, + atualizarCliente, + alterarHabilitadoCliente, + deletarCliente, +}; \ No newline at end of file diff --git a/financeiro-api/src/modules/movimentos/controllers/movimentos.controller.js b/financeiro-api/src/modules/movimentos/controllers/movimentos.controller.js index c6b8b04..e38b17d 100644 --- a/financeiro-api/src/modules/movimentos/controllers/movimentos.controller.js +++ b/financeiro-api/src/modules/movimentos/controllers/movimentos.controller.js @@ -1,19 +1,48 @@ const movimentosService = require('../services/movimentos.service'); +const MOVIMENTOS_VALIDOS = ['Entrada', 'Saida', 'Sangria', 'Estorno']; +const STATUS_VALIDOS = ['Pago', 'A pagar', 'Recebido', 'A receber']; + +function validarStatusPorMovimento(movimento, status) { + if (movimento === 'Entrada' || movimento === 'Estorno') { + return ['A receber', 'Recebido'].includes(status); + } + + if (movimento === 'Saida') { + return ['A pagar', 'Pago'].includes(status); + } + + if (movimento === 'Sangria') { + return status === 'Pago'; + } + + return false; +} + function validarDadosMovimento(dados) { if (!dados.movimento) { return 'Movimento é obrigatório.'; } - if (!['Entrada', 'Saida', 'Sangria', 'Estorno'].includes(dados.movimento)) { + if (!MOVIMENTOS_VALIDOS.includes(dados.movimento)) { return 'Movimento inválido.'; } + if (!dados.descricao || !String(dados.descricao).trim()) { + return 'Descrição é obrigatória.'; + } + if (dados.valor === undefined || dados.valor === null || dados.valor === '') { return 'Valor é obrigatório.'; } - if (Number(dados.valor) <= 0) { + const valor = Number(dados.valor); + + if (!Number.isFinite(valor)) { + return 'Valor inválido.'; + } + + if (valor <= 0) { return 'Valor deve ser maior que zero.'; } @@ -21,21 +50,74 @@ function validarDadosMovimento(dados) { return 'Situação é obrigatória.'; } - if (!['Pago', 'A pagar', 'Recebido', 'A receber'].includes(dados.status)) { + if (!STATUS_VALIDOS.includes(dados.status)) { return 'Situação inválida.'; } - if (!dados.descricao || !String(dados.descricao).trim()) { - return 'Descrição é obrigatória.'; + if (!validarStatusPorMovimento(dados.movimento, dados.status)) { + return 'Situação incompatível com o tipo de movimento.'; } if (!dados.dataentrada) { return 'Data de entrada é obrigatória.'; } + if (!dados.datavencimento) { + return 'Data de vencimento é obrigatória.'; + } + + const parcela = Number(dados.parcela || 1); + const parcelas = Number(dados.parcelas || 1); + + if (!Number.isInteger(parcela) || parcela < 1) { + return 'Parcela inválida.'; + } + + if (!Number.isInteger(parcelas) || parcelas < 1) { + return 'Quantidade de parcelas inválida.'; + } + + if (parcela > parcelas) { + return 'Parcela atual não pode ser maior que o total de parcelas.'; + } + + if (dados.movimento === 'Sangria') { + if (!dados.idbancos) { + return 'Banco de origem é obrigatório para sangria.'; + } + + if (!dados.idbancos_p) { + return 'Banco de destino é obrigatório para sangria.'; + } + + if (Number(dados.idbancos) === Number(dados.idbancos_p)) { + return 'Banco de origem e destino não podem ser iguais.'; + } + } + + if (dados.modoParcelamento && !['valor_parcela', 'valor_total'].includes(dados.modoParcelamento)) { + return 'Modo de parcelamento inválido.'; + } + return null; } +function tratarErro(error, res, mensagemPadrao) { + if (error?.code === 'DUPLICIDADE_MOVIMENTO') { + return res.status(409).json({ + ok: false, + requiresConfirmation: true, + message: error.message, + data: error.data, + }); + } + + return res.status(500).json({ + ok: false, + message: mensagemPadrao, + }); +} + async function listar(req, res) { try { const resultado = await movimentosService.listarMovimentos({ @@ -48,11 +130,17 @@ async function listar(req, res) { idcentrodecustos: req.query.idcentrodecustos, idclientes: req.query.idclientes, idbancos_p: req.query.idbancos_p, + idmovimentosfixos: req.query.idmovimentosfixos, + competencia: req.query.competencia, + grupo_parcelamento: req.query.grupo_parcelamento, + origem: req.query.origem, + saldo_processado: req.query.saldo_processado, referenciaTipo: req.query.referenciaTipo, dataCampo: req.query.dataCampo, dataInicio: req.query.dataInicio, dataFim: req.query.dataFim, busca: req.query.busca, + incluirExcluidos: req.query.incluirExcluidos, orderBy: req.query.orderBy, orderDirection: req.query.orderDirection, }); @@ -107,6 +195,51 @@ async function buscarPorId(req, res) { } } +async function verificarDuplicidade(req, res) { + try { + const duplicado = await movimentosService.verificarDuplicidade(req.body); + + return res.json({ + ok: true, + data: duplicado, + }); + } catch (error) { + console.error('Erro ao verificar duplicidade:', error); + + return res.status(500).json({ + ok: false, + message: 'Erro ao verificar duplicidade.', + }); + } +} + +async function preverImpactoSaldo(req, res) { + try { + const erroValidacao = validarDadosMovimento(req.body); + + if (erroValidacao) { + return res.status(400).json({ + ok: false, + message: erroValidacao, + }); + } + + const impacto = await movimentosService.preverImpactoSaldo(req.body, req.body.idcontasapagar); + + return res.json({ + ok: true, + data: impacto, + }); + } catch (error) { + console.error('Erro ao prever impacto de saldo:', error); + + return res.status(500).json({ + ok: false, + message: 'Erro ao prever impacto de saldo.', + }); + } +} + async function criar(req, res) { try { const erroValidacao = validarDadosMovimento(req.body); @@ -118,20 +251,18 @@ async function criar(req, res) { }); } - const novoMovimento = await movimentosService.criarMovimento(req.user.id, req.body); + const resultado = await movimentosService.criarMovimento(req.user.id, req.body); return res.status(201).json({ ok: true, message: 'Movimento cadastrado com sucesso.', - data: novoMovimento, + data: resultado.data, + parcelasGeradas: resultado.parcelasGeradas || [], + impactoSaldo: resultado.impactoSaldo || [], }); } catch (error) { console.error('Erro ao criar movimento:', error); - - return res.status(500).json({ - ok: false, - message: 'Erro ao criar movimento.', - }); + return tratarErro(error, res, 'Erro ao criar movimento.'); } } @@ -155,9 +286,9 @@ async function atualizar(req, res) { }); } - const movimentoAtualizado = await movimentosService.atualizarMovimento(id, req.body); + const resultado = await movimentosService.atualizarMovimento(id, req.user.id, req.body); - if (!movimentoAtualizado) { + if (!resultado?.data) { return res.status(404).json({ ok: false, message: 'Movimento não encontrado.', @@ -167,14 +298,46 @@ async function atualizar(req, res) { return res.json({ ok: true, message: 'Movimento atualizado com sucesso.', - data: movimentoAtualizado, + data: resultado.data, + impactoSaldo: resultado.impactoSaldo || [], }); } catch (error) { console.error('Erro ao atualizar movimento:', error); + return tratarErro(error, res, 'Erro ao atualizar movimento.'); + } +} + +async function deletar(req, res) { + try { + const id = Number(req.params.id); + + if (!id) { + return res.status(400).json({ + ok: false, + message: 'ID inválido.', + }); + } + + const resultado = await movimentosService.deletarMovimento(id, req.user.id); + + if (!resultado?.ok) { + return res.status(404).json({ + ok: false, + message: 'Movimento não encontrado.', + }); + } + + return res.json({ + ok: true, + message: 'Movimento excluído com sucesso.', + impactoSaldo: resultado.impactoSaldo || [], + }); + } catch (error) { + console.error('Erro ao excluir movimento:', error); return res.status(500).json({ ok: false, - message: 'Erro ao atualizar movimento.', + message: 'Erro ao excluir movimento.', }); } } @@ -182,6 +345,9 @@ async function atualizar(req, res) { module.exports = { listar, buscarPorId, + verificarDuplicidade, + preverImpactoSaldo, criar, atualizar, + deletar, }; \ No newline at end of file diff --git a/financeiro-api/src/modules/movimentos/routes/movimentos.routes.js b/financeiro-api/src/modules/movimentos/routes/movimentos.routes.js index a6b72b3..b1b285d 100644 --- a/financeiro-api/src/modules/movimentos/routes/movimentos.routes.js +++ b/financeiro-api/src/modules/movimentos/routes/movimentos.routes.js @@ -8,7 +8,12 @@ router.use(authMiddleware); router.get('/', movimentosController.listar); router.get('/:id', movimentosController.buscarPorId); + +router.post('/verificar-duplicidade', movimentosController.verificarDuplicidade); +router.post('/prever-impacto-saldo', movimentosController.preverImpactoSaldo); + router.post('/', movimentosController.criar); router.put('/:id', movimentosController.atualizar); +router.delete('/:id', movimentosController.deletar); module.exports = router; \ No newline at end of file diff --git a/financeiro-api/src/modules/movimentos/services/movimentos.service.js b/financeiro-api/src/modules/movimentos/services/movimentos.service.js index 062a499..836abbe 100644 --- a/financeiro-api/src/modules/movimentos/services/movimentos.service.js +++ b/financeiro-api/src/modules/movimentos/services/movimentos.service.js @@ -1,4 +1,6 @@ +const crypto = require('crypto'); const pool = require('../../../database/mysql'); +const movimentosSaldoService = require('./movimentosSaldo.service'); const CAMPOS_MOVIMENTO_SELECT = ` cp.idcontasapagar, @@ -18,13 +20,22 @@ const CAMPOS_MOVIMENTO_SELECT = ` cp.idclientes, cp.idveiculosdetalhes, cp.idbancos_p, + cp.idmovimentosfixos, + cp.competencia, + cp.grupo_parcelamento, + cp.origem, + cp.saldo_processado, cp.observacao, cp.insert_date, cp.update_date, + cp.deleted_at, b.descricao AS banco_descricao, + b.debito AS banco_debito, cc.descricao AS centro_custo_descricao, c.nome AS cliente_nome, - bp.descricao AS banco_referencia_descricao + bp.descricao AS banco_referencia_descricao, + bp.debito AS banco_referencia_debito, + mf.descricao AS movimento_fixo_descricao `; const FROM_MOVIMENTO_JOIN = ` @@ -33,6 +44,7 @@ const FROM_MOVIMENTO_JOIN = ` 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 + LEFT JOIN movimentosfixos mf ON mf.idmovimentosfixos = cp.idmovimentosfixos `; function normalizarNumeroOuNull(valor) { @@ -40,7 +52,13 @@ function normalizarNumeroOuNull(valor) { return null; } - return Number(valor); + const numero = Number(valor); + + if (!Number.isFinite(numero)) { + return null; + } + + return numero; } function normalizarTextoOuNull(valor) { @@ -48,7 +66,9 @@ function normalizarTextoOuNull(valor) { return null; } - return String(valor).trim(); + const texto = String(valor).trim(); + + return texto || null; } function limitarNumero(valor, padrao, minimo, maximo) { @@ -69,6 +89,75 @@ function limitarNumero(valor, padrao, minimo, maximo) { return numero; } +function hojeDataString() { + return new Date().toISOString().slice(0, 10); +} + +function extrairCompetencia(data) { + if (!data) return null; + + return String(data).slice(0, 7); +} + +function addMesesDataString(data, meses) { + if (!data) return null; + + const partes = String(data).slice(0, 10).split('-'); + + if (partes.length !== 3) { + return data; + } + + const ano = Number(partes[0]); + const mes = Number(partes[1]); + const dia = Number(partes[2]); + + const dataBase = new Date(ano, mes - 1, dia); + dataBase.setMonth(dataBase.getMonth() + meses); + + const y = dataBase.getFullYear(); + const m = String(dataBase.getMonth() + 1).padStart(2, '0'); + const d = String(dataBase.getDate()).padStart(2, '0'); + + return `${y}-${m}-${d}`; +} + +function statusBaixado(status) { + return status === 'Pago' || status === 'Recebido'; +} + +function normalizarMovimentoParaSalvar(idUsuario, dados, extras = {}) { + const status = dados.status || null; + const baixado = statusBaixado(status); + + return { + movimento: dados.movimento, + descricao: normalizarTextoOuNull(dados.descricao), + dataentrada: dados.dataentrada || null, + datavencimento: dados.datavencimento || null, + databaixa: baixado ? (dados.databaixa || hojeDataString()) : null, + valor: Number(dados.valor || 0), + parcela: Number(dados.parcela || 1), + parcelas: Number(dados.parcelas || 1), + status, + idcentrodecustos: normalizarNumeroOuNull(dados.idcentrodecustos), + idbancos: normalizarNumeroOuNull(dados.idbancos), + idusuarios_cad: extras.idusuarios_cad || dados.idusuarios_cad || idUsuario || null, + idusuarios_baixa: baixado + ? (normalizarNumeroOuNull(dados.idusuarios_baixa) || idUsuario || null) + : null, + idclientes: normalizarNumeroOuNull(dados.idclientes), + idveiculosdetalhes: normalizarNumeroOuNull(dados.idveiculosdetalhes), + idbancos_p: normalizarNumeroOuNull(dados.idbancos_p), + idmovimentosfixos: normalizarNumeroOuNull(dados.idmovimentosfixos), + competencia: normalizarTextoOuNull(dados.competencia) || extrairCompetencia(dados.datavencimento), + grupo_parcelamento: normalizarTextoOuNull(dados.grupo_parcelamento) || extras.grupo_parcelamento || null, + origem: normalizarTextoOuNull(dados.origem) || extras.origem || 'manual', + saldo_processado: Number(dados.saldo_processado || 0), + observacao: normalizarTextoOuNull(dados.observacao), + }; +} + function resolverCampoData(dataCampo) { const camposPermitidos = { dataentrada: 'cp.dataentrada', @@ -76,6 +165,7 @@ function resolverCampoData(dataCampo) { databaixa: 'cp.databaixa', insert_date: 'cp.insert_date', update_date: 'cp.update_date', + deleted_at: 'cp.deleted_at', }; return camposPermitidos[dataCampo] || 'cp.datavencimento'; @@ -83,6 +173,7 @@ function resolverCampoData(dataCampo) { function resolverOrdenacao(orderBy) { const camposPermitidos = { + idcontasapagar: 'cp.idcontasapagar', dataentrada: 'cp.dataentrada', datavencimento: 'cp.datavencimento', databaixa: 'cp.databaixa', @@ -92,6 +183,12 @@ function resolverOrdenacao(orderBy) { descricao: 'cp.descricao', status: 'cp.status', movimento: 'cp.movimento', + competencia: 'cp.competencia', + origem: 'cp.origem', + saldo_processado: 'cp.saldo_processado', + banco_descricao: 'b.descricao', + centro_custo_descricao: 'cc.descricao', + cliente_nome: 'c.nome', }; return camposPermitidos[orderBy] || 'cp.datavencimento'; @@ -105,6 +202,10 @@ function montarWhereMovimentos(filtros = {}) { const where = []; const params = []; + if (String(filtros.incluirExcluidos || '') !== '1') { + where.push('cp.deleted_at IS NULL'); + } + if (filtros.movimento) { where.push('cp.movimento = ?'); params.push(filtros.movimento); @@ -135,6 +236,31 @@ function montarWhereMovimentos(filtros = {}) { params.push(Number(filtros.idbancos_p)); } + if (filtros.idmovimentosfixos) { + where.push('cp.idmovimentosfixos = ?'); + params.push(Number(filtros.idmovimentosfixos)); + } + + if (filtros.competencia) { + where.push('cp.competencia = ?'); + params.push(filtros.competencia); + } + + if (filtros.grupo_parcelamento) { + where.push('cp.grupo_parcelamento = ?'); + params.push(filtros.grupo_parcelamento); + } + + if (filtros.origem) { + where.push('cp.origem = ?'); + params.push(filtros.origem); + } + + if (filtros.saldo_processado !== undefined && filtros.saldo_processado !== null && filtros.saldo_processado !== '') { + where.push('cp.saldo_processado = ?'); + params.push(Number(filtros.saldo_processado)); + } + if (filtros.referenciaTipo === 'cliente') { where.push('cp.idclientes IS NOT NULL'); } @@ -164,11 +290,14 @@ function montarWhereMovimentos(filtros = {}) { ( cp.descricao LIKE ? OR cp.observacao LIKE ? + OR cp.competencia LIKE ? + OR cp.origem LIKE ? OR b.descricao LIKE ? OR cc.descricao LIKE ? OR c.nome LIKE ? OR c.cpf_cnpj LIKE ? OR bp.descricao LIKE ? + OR mf.descricao LIKE ? ) `); @@ -181,6 +310,9 @@ function montarWhereMovimentos(filtros = {}) { termo, termo, termo, + termo, + termo, + termo, termo ); } @@ -207,7 +339,7 @@ async function listarMovimentos(filtros = {}) { const [rows] = await pool.query( ` - SELECT + SELECT ${CAMPOS_MOVIMENTO_SELECT} ${FROM_MOVIMENTO_JOIN} ${whereSql} @@ -234,8 +366,11 @@ async function listarMovimentos(filtros = {}) { COALESCE(SUM(CASE WHEN cp.movimento = 'Saida' THEN cp.valor ELSE 0 END), 0) AS totalSaidas, COALESCE(SUM(CASE WHEN cp.movimento = 'Sangria' THEN cp.valor ELSE 0 END), 0) AS totalSangrias, COALESCE(SUM(CASE WHEN cp.movimento = 'Estorno' THEN cp.valor ELSE 0 END), 0) AS totalEstornos, + COALESCE(SUM(CASE WHEN cp.status IN ('Pago', 'Recebido') THEN cp.valor ELSE 0 END), 0) AS totalBaixado, + COALESCE(SUM(CASE WHEN cp.status IN ('A pagar', 'A receber') THEN cp.valor ELSE 0 END), 0) AS totalAberto, + COALESCE(SUM(CASE WHEN cp.saldo_processado = 1 THEN 1 ELSE 0 END), 0) AS saldoProcessado, COALESCE(SUM( - CASE + CASE WHEN cp.movimento IN ('Entrada', 'Estorno') THEN cp.valor WHEN cp.movimento IN ('Saida', 'Sangria') THEN -cp.valor ELSE 0 @@ -265,18 +400,25 @@ async function listarMovimentos(filtros = {}) { totalSaidas: Number(summaryRows[0]?.totalSaidas || 0), totalSangrias: Number(summaryRows[0]?.totalSangrias || 0), totalEstornos: Number(summaryRows[0]?.totalEstornos || 0), + totalBaixado: Number(summaryRows[0]?.totalBaixado || 0), + totalAberto: Number(summaryRows[0]?.totalAberto || 0), + saldoProcessado: Number(summaryRows[0]?.saldoProcessado || 0), saldo: Number(summaryRows[0]?.saldo || 0), }, }; } -async function buscarMovimentoPorId(id) { - const [rows] = await pool.query( +async function buscarMovimentoPorId(id, opcoes = {}) { + const executor = opcoes.connection || pool; + const incluirExcluidos = opcoes.incluirExcluidos === true; + + const [rows] = await executor.query( ` - SELECT + SELECT ${CAMPOS_MOVIMENTO_SELECT} ${FROM_MOVIMENTO_JOIN} WHERE cp.idcontasapagar = ? + ${incluirExcluidos ? '' : 'AND cp.deleted_at IS NULL'} LIMIT 1 `, [id] @@ -285,27 +427,47 @@ async function buscarMovimentoPorId(id) { return rows[0] || null; } -async function criarMovimento(idUsuario, dados) { - const { - movimento, - descricao, - dataentrada, - datavencimento, - databaixa, - valor, - parcela, - parcelas, - status, - idcentrodecustos, - idbancos, - idusuarios_baixa, - idclientes, - idveiculosdetalhes, - idbancos_p, - observacao, - } = dados; +async function buscarMovimentoPorIdParaUpdate(connection, id) { + const [rows] = await connection.query( + ` + SELECT + cp.* + FROM contasapagar cp + WHERE cp.idcontasapagar = ? + AND cp.deleted_at IS NULL + LIMIT 1 + FOR UPDATE + `, + [id] + ); - const [result] = await pool.query( + return rows[0] || null; +} + +async function inserirAuditoriaMovimento(connection, dados) { + await connection.query( + ` + INSERT INTO contasapagar_auditoria ( + idcontasapagar, + acao, + dados_antes, + dados_depois, + idusuarios, + insert_date + ) VALUES (?, ?, ?, ?, ?, NOW()) + `, + [ + dados.idcontasapagar || null, + dados.acao, + dados.dados_antes ? JSON.stringify(dados.dados_antes) : null, + dados.dados_depois ? JSON.stringify(dados.dados_depois) : null, + dados.idusuarios || null, + ] + ); +} + +async function inserirMovimento(connection, movimento) { + const [result] = await connection.query( ` INSERT INTO contasapagar ( movimento, @@ -324,62 +486,48 @@ async function criarMovimento(idUsuario, dados) { idclientes, idveiculosdetalhes, idbancos_p, + idmovimentosfixos, + competencia, + grupo_parcelamento, + origem, + saldo_processado, observacao, insert_date, - update_date - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW()) + update_date, + deleted_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW(), NULL) `, [ - movimento, - normalizarTextoOuNull(descricao), - dataentrada || null, - datavencimento || null, - databaixa || null, - Number(valor || 0), - Number(parcela || 1), - Number(parcelas || 1), - status || null, - normalizarNumeroOuNull(idcentrodecustos), - normalizarNumeroOuNull(idbancos), - idUsuario, - normalizarNumeroOuNull(idusuarios_baixa), - normalizarNumeroOuNull(idclientes), - normalizarNumeroOuNull(idveiculosdetalhes), - normalizarNumeroOuNull(idbancos_p), - normalizarTextoOuNull(observacao), + movimento.movimento, + movimento.descricao, + movimento.dataentrada, + movimento.datavencimento, + movimento.databaixa, + movimento.valor, + movimento.parcela, + movimento.parcelas, + movimento.status, + movimento.idcentrodecustos, + movimento.idbancos, + movimento.idusuarios_cad, + movimento.idusuarios_baixa, + movimento.idclientes, + movimento.idveiculosdetalhes, + movimento.idbancos_p, + movimento.idmovimentosfixos, + movimento.competencia, + movimento.grupo_parcelamento, + movimento.origem, + movimento.saldo_processado, + movimento.observacao, ] ); - return buscarMovimentoPorId(result.insertId); + return result.insertId; } -async function atualizarMovimento(id, dados) { - const movimentoAtual = await buscarMovimentoPorId(id); - - if (!movimentoAtual) { - return null; - } - - const { - movimento, - descricao, - dataentrada, - datavencimento, - databaixa, - valor, - parcela, - parcelas, - status, - idcentrodecustos, - idbancos, - idusuarios_baixa, - idclientes, - idveiculosdetalhes, - idbancos_p, - observacao, - } = dados; - - await pool.query( +async function atualizarMovimentoBase(connection, id, movimento, saldoProcessado) { + await connection.query( ` UPDATE contasapagar SET @@ -398,37 +546,472 @@ async function atualizarMovimento(id, dados) { idclientes = ?, idveiculosdetalhes = ?, idbancos_p = ?, + idmovimentosfixos = ?, + competencia = ?, + grupo_parcelamento = ?, + origem = ?, + saldo_processado = ?, observacao = ?, update_date = NOW() WHERE idcontasapagar = ? `, [ - movimento, - normalizarTextoOuNull(descricao), - dataentrada || null, - datavencimento || null, - databaixa || null, - Number(valor || 0), - Number(parcela || 1), - Number(parcelas || 1), - status || null, - normalizarNumeroOuNull(idcentrodecustos), - normalizarNumeroOuNull(idbancos), - normalizarNumeroOuNull(idusuarios_baixa), - normalizarNumeroOuNull(idclientes), - normalizarNumeroOuNull(idveiculosdetalhes), - normalizarNumeroOuNull(idbancos_p), - normalizarTextoOuNull(observacao), + movimento.movimento, + movimento.descricao, + movimento.dataentrada, + movimento.datavencimento, + movimento.databaixa, + movimento.valor, + movimento.parcela, + movimento.parcelas, + movimento.status, + movimento.idcentrodecustos, + movimento.idbancos, + movimento.idusuarios_baixa, + movimento.idclientes, + movimento.idveiculosdetalhes, + movimento.idbancos_p, + movimento.idmovimentosfixos, + movimento.competencia, + movimento.grupo_parcelamento, + movimento.origem, + saldoProcessado, + movimento.observacao, id, ] ); +} - return buscarMovimentoPorId(id); +async function verificarDuplicidade(dados = {}) { + const idIgnorar = normalizarNumeroOuNull(dados.idcontasapagar); + const valor = Number(dados.valor || 0); + const idcentrodecustos = normalizarNumeroOuNull(dados.idcentrodecustos); + const idbancos = normalizarNumeroOuNull(dados.idbancos); + + const params = [ + dados.movimento, + valor, + idcentrodecustos, + idbancos, + dados.datavencimento, + ]; + + let filtroId = ''; + + if (idIgnorar) { + filtroId = 'AND idcontasapagar <> ?'; + params.push(idIgnorar); + } + + const [rows] = await pool.query( + ` + SELECT + idcontasapagar, + movimento, + descricao, + valor, + status, + datavencimento, + idcentrodecustos, + idbancos + FROM contasapagar + WHERE deleted_at IS NULL + AND movimento = ? + AND valor = ? + AND ( + (? IS NULL AND idcentrodecustos IS NULL) + OR idcentrodecustos = ? + ) + AND ( + (? IS NULL AND idbancos IS NULL) + OR idbancos = ? + ) + AND DATE(datavencimento) = DATE(?) + ${filtroId} + LIMIT 1 + `, + [ + dados.movimento, + valor, + idcentrodecustos, + idcentrodecustos, + idbancos, + idbancos, + dados.datavencimento, + ...(idIgnorar ? [idIgnorar] : []), + ] + ); + + return rows[0] || null; +} + +function criarErroDuplicidade(movimentoDuplicado) { + const erro = new Error( + `Já existe um movimento parecido cadastrado: ID ${movimentoDuplicado.idcontasapagar}, descrição "${movimentoDuplicado.descricao}", situação "${movimentoDuplicado.status}".` + ); + + erro.code = 'DUPLICIDADE_MOVIMENTO'; + erro.data = movimentoDuplicado; + + return erro; +} + +function montarParcelas(idUsuario, dados) { + const parcelas = Number(dados.parcelas || 1); + const parcelaInicial = Number(dados.parcela || 1); + const gerarParcelas = dados.gerarParcelas !== false && parcelas > parcelaInicial; + + if (!gerarParcelas) { + return { + grupoParcelamento: normalizarTextoOuNull(dados.grupo_parcelamento), + movimentos: [ + normalizarMovimentoParaSalvar(idUsuario, dados, { + origem: normalizarTextoOuNull(dados.origem) || 'manual', + }), + ], + }; + } + + const grupoParcelamento = normalizarTextoOuNull(dados.grupo_parcelamento) || crypto.randomUUID(); + const modoParcelamento = dados.modoParcelamento || 'valor_parcela'; + + const valorInformado = Number(dados.valor || 0); + const valorParcela = modoParcelamento === 'valor_total' + ? Number((valorInformado / parcelas).toFixed(2)) + : valorInformado; + + const movimentos = []; + + for (let parcela = parcelaInicial; parcela <= parcelas; parcela++) { + const indiceMes = parcela - parcelaInicial; + + const dadosParcela = { + ...dados, + valor: valorParcela, + parcela, + parcelas, + datavencimento: addMesesDataString(dados.datavencimento, indiceMes), + competencia: normalizarTextoOuNull(dados.competencia) + ? addMesesDataString(`${dados.competencia}-01`, indiceMes).slice(0, 7) + : extrairCompetencia(addMesesDataString(dados.datavencimento, indiceMes)), + }; + + movimentos.push( + normalizarMovimentoParaSalvar(idUsuario, dadosParcela, { + grupo_parcelamento: grupoParcelamento, + origem: parcela === parcelaInicial + ? (normalizarTextoOuNull(dados.origem) || 'manual') + : 'parcelamento', + }) + ); + } + + return { + grupoParcelamento, + movimentos, + }; +} + +async function aplicarSaldoMovimento(connection, movimento, contexto) { + const deltas = await movimentosSaldoService.calcularImpactoMovimento(connection, movimento); + + if (deltas.length === 0) { + return { + saldoProcessado: 0, + impactos: [], + }; + } + + const impactos = await movimentosSaldoService.aplicarDeltasSaldo( + connection, + deltas, + contexto + ); + + return { + saldoProcessado: impactos.length > 0 ? 1 : 0, + impactos, + }; +} + +async function criarMovimento(idUsuario, dados) { + const duplicado = await verificarDuplicidade(dados); + + if (duplicado && dados.confirmarDuplicado !== true) { + throw criarErroDuplicidade(duplicado); + } + + const connection = await pool.getConnection(); + + try { + await connection.beginTransaction(); + + const { movimentos } = montarParcelas(idUsuario, dados); + + const movimentosCriados = []; + const impactosTodos = []; + + for (const movimento of movimentos) { + const idCriado = await inserirMovimento(connection, movimento); + + const movimentoCriado = await buscarMovimentoPorId(idCriado, { + connection, + }); + + const { saldoProcessado, impactos } = await aplicarSaldoMovimento( + connection, + movimentoCriado, + { + idcontasapagar: idCriado, + tipo_operacao: 'movimento_aplicado', + origem: movimentoCriado.origem || 'movimento', + idusuarios: idUsuario, + } + ); + + if (saldoProcessado === 1) { + await connection.query( + ` + UPDATE contasapagar + SET saldo_processado = 1 + WHERE idcontasapagar = ? + `, + [idCriado] + ); + } + + const movimentoFinal = await buscarMovimentoPorId(idCriado, { + connection, + }); + + await inserirAuditoriaMovimento(connection, { + idcontasapagar: idCriado, + acao: movimento.parcela > 1 ? 'gerar_parcela' : 'criar', + dados_antes: null, + dados_depois: movimentoFinal, + idusuarios: idUsuario, + }); + + movimentosCriados.push(movimentoFinal); + impactosTodos.push(...impactos); + } + + await connection.commit(); + + return { + data: movimentosCriados[0], + parcelasGeradas: movimentosCriados.slice(1), + impactoSaldo: impactosTodos, + }; + } catch (error) { + await connection.rollback(); + throw error; + } finally { + connection.release(); + } +} + +async function atualizarMovimento(id, idUsuario, dados) { + const duplicado = await verificarDuplicidade({ + ...dados, + idcontasapagar: id, + }); + + if (duplicado && dados.confirmarDuplicado !== true) { + throw criarErroDuplicidade(duplicado); + } + + const connection = await pool.getConnection(); + + try { + await connection.beginTransaction(); + + const movimentoAntigo = await buscarMovimentoPorIdParaUpdate(connection, id); + + if (!movimentoAntigo) { + await connection.rollback(); + + return null; + } + + const dadosNovos = normalizarMovimentoParaSalvar(idUsuario, dados, { + idusuarios_cad: movimentoAntigo.idusuarios_cad, + grupo_parcelamento: dados.grupo_parcelamento || movimentoAntigo.grupo_parcelamento, + origem: dados.origem || movimentoAntigo.origem || 'manual', + }); + + const impactosTodos = []; + + if (Number(movimentoAntigo.saldo_processado) === 1) { + const deltasAntigos = await movimentosSaldoService.calcularImpactoMovimento( + connection, + movimentoAntigo + ); + + const impactosReversao = await movimentosSaldoService.aplicarDeltasSaldo( + connection, + movimentosSaldoService.inverterDeltas(deltasAntigos), + { + idcontasapagar: id, + tipo_operacao: 'movimento_revertido', + origem: 'edicao_movimento', + idusuarios: idUsuario, + } + ); + + impactosTodos.push(...impactosReversao); + } + + await atualizarMovimentoBase(connection, id, dadosNovos, 0); + + const movimentoNovo = await buscarMovimentoPorId(id, { + connection, + }); + + const { saldoProcessado, impactos } = await aplicarSaldoMovimento( + connection, + movimentoNovo, + { + idcontasapagar: id, + tipo_operacao: 'movimento_aplicado', + origem: 'edicao_movimento', + idusuarios: idUsuario, + } + ); + + if (saldoProcessado === 1) { + await connection.query( + ` + UPDATE contasapagar + SET saldo_processado = 1 + WHERE idcontasapagar = ? + `, + [id] + ); + } + + const movimentoFinal = await buscarMovimentoPorId(id, { + connection, + }); + + await inserirAuditoriaMovimento(connection, { + idcontasapagar: id, + acao: 'editar', + dados_antes: movimentoAntigo, + dados_depois: movimentoFinal, + idusuarios: idUsuario, + }); + + impactosTodos.push(...impactos); + + await connection.commit(); + + return { + data: movimentoFinal, + impactoSaldo: impactosTodos, + }; + } catch (error) { + await connection.rollback(); + throw error; + } finally { + connection.release(); + } +} + +async function deletarMovimento(id, idUsuario) { + const connection = await pool.getConnection(); + + try { + await connection.beginTransaction(); + + const movimentoAntigo = await buscarMovimentoPorIdParaUpdate(connection, id); + + if (!movimentoAntigo) { + await connection.rollback(); + + return { + ok: false, + }; + } + + const impactosTodos = []; + + if (Number(movimentoAntigo.saldo_processado) === 1) { + const deltasAntigos = await movimentosSaldoService.calcularImpactoMovimento( + connection, + movimentoAntigo + ); + + const impactosReversao = await movimentosSaldoService.aplicarDeltasSaldo( + connection, + movimentosSaldoService.inverterDeltas(deltasAntigos), + { + idcontasapagar: id, + tipo_operacao: 'movimento_revertido', + origem: 'exclusao_movimento', + idusuarios: idUsuario, + } + ); + + impactosTodos.push(...impactosReversao); + } + + await connection.query( + ` + UPDATE contasapagar + SET + deleted_at = NOW(), + saldo_processado = 0, + update_date = NOW() + WHERE idcontasapagar = ? + `, + [id] + ); + + await inserirAuditoriaMovimento(connection, { + idcontasapagar: id, + acao: 'excluir', + dados_antes: movimentoAntigo, + dados_depois: null, + idusuarios: idUsuario, + }); + + await connection.commit(); + + return { + ok: true, + impactoSaldo: impactosTodos, + }; + } catch (error) { + await connection.rollback(); + throw error; + } finally { + connection.release(); + } +} + +async function preverImpactoSaldo(dados, idcontasapagar = null) { + let movimentoAntigo = null; + + if (idcontasapagar) { + movimentoAntigo = await buscarMovimentoPorId(idcontasapagar); + } + + const movimentoNovo = normalizarMovimentoParaSalvar(null, dados, { + idusuarios_cad: dados.idusuarios_cad || null, + grupo_parcelamento: dados.grupo_parcelamento || null, + origem: dados.origem || 'manual', + }); + + return movimentosSaldoService.preverImpactoSaldo(movimentoNovo, movimentoAntigo); } module.exports = { listarMovimentos, buscarMovimentoPorId, + verificarDuplicidade, + preverImpactoSaldo, criarMovimento, atualizarMovimento, + deletarMovimento, }; \ No newline at end of file diff --git a/financeiro-api/src/modules/movimentos/services/movimentosSaldo.service.js b/financeiro-api/src/modules/movimentos/services/movimentosSaldo.service.js new file mode 100644 index 0000000..414b25b --- /dev/null +++ b/financeiro-api/src/modules/movimentos/services/movimentosSaldo.service.js @@ -0,0 +1,259 @@ +const pool = require('../../../database/mysql'); + +function valorMovimento(movimento) { + return Number(movimento.valor || 0); +} + +function bancoEhDebito(banco) { + return Number(banco?.debito) === 1; +} + +function bancoEhCredito(banco) { + return Number(banco?.debito) === 0; +} + +async function buscarBanco(connection, idbancos) { + if (!idbancos) return null; + + const [rows] = await connection.query( + ` + SELECT + idbancos, + descricao, + saldo, + debito + FROM bancos + WHERE idbancos = ? + LIMIT 1 + `, + [idbancos] + ); + + return rows[0] || null; +} + +async function buscarBancoParaUpdate(connection, idbancos) { + if (!idbancos) return null; + + const [rows] = await connection.query( + ` + SELECT + idbancos, + descricao, + saldo, + debito + FROM bancos + WHERE idbancos = ? + LIMIT 1 + FOR UPDATE + `, + [idbancos] + ); + + return rows[0] || null; +} + +function calcularDeltasParaMovimento(movimento, bancoOrigem, bancoDestino = null) { + const deltas = []; + + if (!movimento || !bancoOrigem) { + return deltas; + } + + const valor = valorMovimento(movimento); + const origemDebito = bancoEhDebito(bancoOrigem); + const origemCredito = bancoEhCredito(bancoOrigem); + + if (valor <= 0) { + return deltas; + } + + if (movimento.movimento === 'Entrada') { + if (origemDebito && movimento.status === 'Recebido') { + deltas.push({ + idbancos: bancoOrigem.idbancos, + valor_delta: valor, + descricao: `Entrada recebida: ${movimento.descricao}`, + }); + } + + return deltas; + } + + if (movimento.movimento === 'Saida') { + if (origemDebito && movimento.status === 'Pago') { + deltas.push({ + idbancos: bancoOrigem.idbancos, + valor_delta: -valor, + descricao: `Saída paga: ${movimento.descricao}`, + }); + } + + if (origemCredito) { + deltas.push({ + idbancos: bancoOrigem.idbancos, + valor_delta: valor, + descricao: `Compra no crédito acumulada: ${movimento.descricao}`, + }); + } + + return deltas; + } + + if (movimento.movimento === 'Estorno') { + if (origemDebito && movimento.status === 'Recebido') { + deltas.push({ + idbancos: bancoOrigem.idbancos, + valor_delta: valor, + descricao: `Estorno recebido: ${movimento.descricao}`, + }); + } + + if (origemCredito) { + deltas.push({ + idbancos: bancoOrigem.idbancos, + valor_delta: -valor, + descricao: `Estorno no crédito: ${movimento.descricao}`, + }); + } + + return deltas; + } + + if (movimento.movimento === 'Sangria') { + if (!origemDebito) { + return deltas; + } + + deltas.push({ + idbancos: bancoOrigem.idbancos, + valor_delta: -valor, + descricao: `Sangria origem: ${movimento.descricao}`, + }); + + if (bancoDestino && bancoEhDebito(bancoDestino)) { + deltas.push({ + idbancos: bancoDestino.idbancos, + valor_delta: valor, + descricao: `Sangria destino: ${movimento.descricao}`, + }); + } + + return deltas; + } + + return deltas; +} + +async function calcularImpactoMovimento(connection, movimento) { + const bancoOrigem = await buscarBanco(connection, movimento.idbancos); + const bancoDestino = await buscarBanco(connection, movimento.idbancos_p); + + return calcularDeltasParaMovimento(movimento, bancoOrigem, bancoDestino); +} + +function inverterDeltas(deltas) { + return deltas.map((delta) => ({ + ...delta, + valor_delta: Number(delta.valor_delta || 0) * -1, + descricao: `Reversão - ${delta.descricao || ''}`.trim(), + })); +} + +async function aplicarDeltasSaldo(connection, deltas, contexto = {}) { + const impactos = []; + + for (const delta of deltas) { + const banco = await buscarBancoParaUpdate(connection, delta.idbancos); + + if (!banco) { + continue; + } + + const saldoAnterior = Number(banco.saldo || 0); + const valorDelta = Number(delta.valor_delta || 0); + const saldoPosterior = saldoAnterior + valorDelta; + + await connection.query( + ` + UPDATE bancos + SET + saldo = ?, + update_date = NOW() + WHERE idbancos = ? + `, + [ + saldoPosterior, + banco.idbancos, + ] + ); + + await connection.query( + ` + INSERT INTO bancos_saldo_auditoria ( + idbancos, + idcontasapagar, + idcontasquitadas, + tipo_operacao, + origem, + valor_delta, + saldo_anterior, + saldo_posterior, + descricao, + idusuarios, + insert_date + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW()) + `, + [ + banco.idbancos, + contexto.idcontasapagar || null, + contexto.idcontasquitadas || null, + contexto.tipo_operacao || null, + contexto.origem || null, + valorDelta, + saldoAnterior, + saldoPosterior, + 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, + }); + } + + return impactos; +} + +async function preverImpactoSaldo(dados, movimentoAntigo = null) { + const connection = await pool.getConnection(); + + try { + const deltas = []; + + if (movimentoAntigo) { + const deltasAntigos = await calcularImpactoMovimento(connection, movimentoAntigo); + deltas.push(...inverterDeltas(deltasAntigos)); + } + + const deltasNovos = await calcularImpactoMovimento(connection, dados); + deltas.push(...deltasNovos); + + return deltas; + } finally { + connection.release(); + } +} + +module.exports = { + calcularImpactoMovimento, + aplicarDeltasSaldo, + inverterDeltas, + preverImpactoSaldo, +}; \ No newline at end of file diff --git a/financeiro-api/src/modules/movimentosFixos/controllers/movimentosFixos.controller.js b/financeiro-api/src/modules/movimentosFixos/controllers/movimentosFixos.controller.js new file mode 100644 index 0000000..d93637f --- /dev/null +++ b/financeiro-api/src/modules/movimentosFixos/controllers/movimentosFixos.controller.js @@ -0,0 +1,292 @@ +const movimentosFixosService = require('../services/movimentosFixos.service'); + +function validarDadosMovimentoFixo(dados) { + if (!dados.movimento) { + return 'Movimento é obrigatório.'; + } + + if (!['Entrada', 'Saida', 'Sangria', 'Estorno'].includes(dados.movimento)) { + return 'Movimento inválido.'; + } + + if (!dados.descricao || !String(dados.descricao).trim()) { + return 'Descrição é obrigatória.'; + } + + if (dados.valor === undefined || dados.valor === null || dados.valor === '') { + return 'Valor é obrigatório.'; + } + + if (!Number.isFinite(Number(dados.valor))) { + return 'Valor inválido.'; + } + + if (Number(dados.valor) <= 0) { + return 'Valor deve ser maior que zero.'; + } + + if (dados.dia_vencimento === undefined || dados.dia_vencimento === null || dados.dia_vencimento === '') { + return 'Dia de vencimento é obrigatório.'; + } + + const diaVencimento = Number(dados.dia_vencimento); + + if (!Number.isInteger(diaVencimento) || diaVencimento < 1 || diaVencimento > 31) { + return 'Dia de vencimento deve estar entre 1 e 31.'; + } + + if (dados.idcentrodecustos !== undefined && dados.idcentrodecustos !== null && dados.idcentrodecustos !== '') { + const idcentrodecustos = Number(dados.idcentrodecustos); + + if (!Number.isInteger(idcentrodecustos) || idcentrodecustos <= 0) { + return 'Centro de custo inválido.'; + } + } + + if (dados.idbancos !== undefined && dados.idbancos !== null && dados.idbancos !== '') { + const idbancos = Number(dados.idbancos); + + if (!Number.isInteger(idbancos) || idbancos <= 0) { + return 'Banco/carteira inválido.'; + } + } + + if (dados.habilitado !== undefined && dados.habilitado !== null && dados.habilitado !== '') { + const habilitado = Number(dados.habilitado); + + if (![0, 1].includes(habilitado)) { + return 'Status inválido.'; + } + } + + return null; +} + +async function listar(req, res) { + try { + const resultado = await movimentosFixosService.listarMovimentosFixos({ + limite: req.query.limite, + offset: req.query.offset, + page: req.query.page, + busca: req.query.busca, + movimento: req.query.movimento, + idbancos: req.query.idbancos, + idcentrodecustos: req.query.idcentrodecustos, + habilitado: req.query.habilitado, + diaInicio: req.query.diaInicio, + diaFim: req.query.diaFim, + orderBy: req.query.orderBy, + orderDirection: req.query.orderDirection, + }); + + return res.json({ + ok: true, + data: resultado.data, + pagination: resultado.pagination, + summary: resultado.summary, + }); + } catch (error) { + console.error('Erro ao listar movimentos fixos:', error); + + return res.status(500).json({ + ok: false, + message: 'Erro ao listar movimentos fixos.', + }); + } +} + +async function buscarPorId(req, res) { + try { + const id = Number(req.params.id); + + if (!id) { + return res.status(400).json({ + ok: false, + message: 'ID inválido.', + }); + } + + const movimentoFixo = await movimentosFixosService.buscarMovimentoFixoPorId(id); + + if (!movimentoFixo) { + return res.status(404).json({ + ok: false, + message: 'Movimento fixo não encontrado.', + }); + } + + return res.json({ + ok: true, + data: movimentoFixo, + }); + } catch (error) { + console.error('Erro ao buscar movimento fixo:', error); + + return res.status(500).json({ + ok: false, + message: 'Erro ao buscar movimento fixo.', + }); + } +} + +async function criar(req, res) { + try { + const erroValidacao = validarDadosMovimentoFixo(req.body); + + if (erroValidacao) { + return res.status(400).json({ + ok: false, + message: erroValidacao, + }); + } + + const novoMovimentoFixo = await movimentosFixosService.criarMovimentoFixo(req.body); + + return res.status(201).json({ + ok: true, + message: 'Movimento fixo cadastrado com sucesso.', + data: novoMovimentoFixo, + }); + } catch (error) { + console.error('Erro ao criar movimento fixo:', error); + + return res.status(500).json({ + ok: false, + message: 'Erro ao criar movimento fixo.', + }); + } +} + +async function atualizar(req, res) { + try { + const id = Number(req.params.id); + + if (!id) { + return res.status(400).json({ + ok: false, + message: 'ID inválido.', + }); + } + + const erroValidacao = validarDadosMovimentoFixo(req.body); + + if (erroValidacao) { + return res.status(400).json({ + ok: false, + message: erroValidacao, + }); + } + + const movimentoAtualizado = await movimentosFixosService.atualizarMovimentoFixo(id, req.body); + + if (!movimentoAtualizado) { + return res.status(404).json({ + ok: false, + message: 'Movimento fixo não encontrado.', + }); + } + + return res.json({ + ok: true, + message: 'Movimento fixo atualizado com sucesso.', + data: movimentoAtualizado, + }); + } catch (error) { + console.error('Erro ao atualizar movimento fixo:', error); + + return res.status(500).json({ + ok: false, + message: 'Erro ao atualizar movimento fixo.', + }); + } +} + +async function alterarHabilitado(req, res) { + try { + const id = Number(req.params.id); + + if (!id) { + return res.status(400).json({ + ok: false, + message: 'ID inválido.', + }); + } + + const habilitado = Number(req.body.habilitado); + + if (![0, 1].includes(habilitado)) { + return res.status(400).json({ + ok: false, + message: 'Status inválido.', + }); + } + + const movimentoAtualizado = + await movimentosFixosService.alterarHabilitadoMovimentoFixo(id, habilitado); + + if (!movimentoAtualizado) { + return res.status(404).json({ + ok: false, + message: 'Movimento fixo não encontrado.', + }); + } + + return res.json({ + ok: true, + message: habilitado === 1 + ? 'Movimento fixo habilitado com sucesso.' + : 'Movimento fixo desabilitado com sucesso.', + data: movimentoAtualizado, + }); + } catch (error) { + console.error('Erro ao alterar status do movimento fixo:', error); + + return res.status(500).json({ + ok: false, + message: 'Erro ao alterar status do movimento fixo.', + }); + } +} + +async function deletar(req, res) { + try { + const id = Number(req.params.id); + + if (!id) { + return res.status(400).json({ + ok: false, + message: 'ID inválido.', + }); + } + + const deletado = await movimentosFixosService.deletarMovimentoFixo(id); + + if (!deletado) { + return res.status(404).json({ + ok: false, + message: 'Movimento fixo não encontrado.', + }); + } + + return res.json({ + ok: true, + message: 'Movimento fixo excluído com sucesso.', + }); + } catch (error) { + console.error('Erro ao excluir movimento fixo:', error); + + return res.status(500).json({ + ok: false, + message: 'Erro ao excluir movimento fixo.', + }); + } +} + +module.exports = { + listar, + buscarPorId, + criar, + atualizar, + alterarHabilitado, + deletar, +}; \ No newline at end of file diff --git a/financeiro-api/src/modules/movimentosFixos/routes/movimentosFixos.routes.js b/financeiro-api/src/modules/movimentosFixos/routes/movimentosFixos.routes.js new file mode 100644 index 0000000..1663a45 --- /dev/null +++ b/financeiro-api/src/modules/movimentosFixos/routes/movimentosFixos.routes.js @@ -0,0 +1,16 @@ +const express = require('express'); +const authMiddleware = require('../../../middlewares/auth.middleware'); +const movimentosFixosController = require('../controllers/movimentosFixos.controller'); + +const router = express.Router(); + +router.use(authMiddleware); + +router.get('/', movimentosFixosController.listar); +router.get('/:id', movimentosFixosController.buscarPorId); +router.post('/', movimentosFixosController.criar); +router.put('/:id', movimentosFixosController.atualizar); +router.patch('/:id/habilitado', movimentosFixosController.alterarHabilitado); +router.delete('/:id', movimentosFixosController.deletar); + +module.exports = router; \ No newline at end of file diff --git a/financeiro-api/src/modules/movimentosFixos/services/movimentosFixos.service.js b/financeiro-api/src/modules/movimentosFixos/services/movimentosFixos.service.js new file mode 100644 index 0000000..9fa9456 --- /dev/null +++ b/financeiro-api/src/modules/movimentosFixos/services/movimentosFixos.service.js @@ -0,0 +1,395 @@ +const pool = require('../../../database/mysql'); + +const CAMPOS_MOVIMENTO_FIXO_SELECT = ` + mf.idmovimentosfixos, + mf.movimento, + mf.descricao, + mf.valor, + mf.dia_vencimento, + mf.idcentrodecustos, + mf.habilitado, + mf.idbancos, + mf.insert_date, + mf.update_date, + b.descricao AS banco_descricao, + cc.descricao AS centro_custo_descricao +`; + +const FROM_MOVIMENTO_FIXO_JOIN = ` + FROM movimentosfixos mf + LEFT JOIN bancos b ON b.idbancos = mf.idbancos + LEFT JOIN centrodecustos cc ON cc.idcentrodecustos = mf.idcentrodecustos +`; + +function normalizarTextoOuNull(valor) { + if (valor === undefined || valor === null || valor === '') { + return null; + } + + return String(valor).trim(); +} + +function normalizarNumeroOuNull(valor) { + if (valor === undefined || valor === null || valor === '') { + return null; + } + + const numero = Number(valor); + + if (!Number.isFinite(numero)) { + return null; + } + + return numero; +} + +function normalizarNumero(valor, padrao = 0) { + if (valor === undefined || valor === null || valor === '') { + return padrao; + } + + 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 limitarNumero(valor, padrao, minimo, maximo) { + const numero = Number(valor); + + if (!Number.isFinite(numero)) { + return padrao; + } + + if (numero < minimo) { + return minimo; + } + + if (numero > maximo) { + return maximo; + } + + return numero; +} + +function resolverOrdenacao(orderBy) { + const camposPermitidos = { + idmovimentosfixos: 'mf.idmovimentosfixos', + movimento: 'mf.movimento', + descricao: 'mf.descricao', + valor: 'mf.valor', + dia_vencimento: 'mf.dia_vencimento', + idcentrodecustos: 'mf.idcentrodecustos', + 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', + }; + + return camposPermitidos[orderBy] || 'mf.dia_vencimento'; +} + +function resolverDirecao(orderDirection) { + return String(orderDirection || '').toUpperCase() === 'DESC' ? 'DESC' : 'ASC'; +} + +function montarWhereMovimentosFixos(filtros = {}) { + const where = []; + const params = []; + + if (filtros.movimento) { + where.push('mf.movimento = ?'); + params.push(filtros.movimento); + } + + if (filtros.idbancos) { + where.push('mf.idbancos = ?'); + params.push(Number(filtros.idbancos)); + } + + if (filtros.idcentrodecustos) { + where.push('mf.idcentrodecustos = ?'); + params.push(Number(filtros.idcentrodecustos)); + } + + if (filtros.habilitado !== undefined && filtros.habilitado !== null && filtros.habilitado !== '') { + where.push('mf.habilitado = ?'); + params.push(Number(filtros.habilitado)); + } + + if (filtros.diaInicio) { + where.push('mf.dia_vencimento >= ?'); + params.push(Number(filtros.diaInicio)); + } + + if (filtros.diaFim) { + where.push('mf.dia_vencimento <= ?'); + params.push(Number(filtros.diaFim)); + } + + if (filtros.busca) { + where.push(` + ( + mf.descricao LIKE ? + OR mf.movimento LIKE ? + OR b.descricao LIKE ? + OR cc.descricao LIKE ? + ) + `); + + const termo = `%${String(filtros.busca).trim()}%`; + + params.push( + termo, + termo, + termo, + termo + ); + } + + const whereSql = where.length > 0 ? `WHERE ${where.join(' AND ')}` : ''; + + return { + whereSql, + params, + }; +} + +async function listarMovimentosFixos(filtros = {}) { + const limite = limitarNumero(filtros.limite, 20, 1, 100); + const page = limitarNumero(filtros.page, 1, 1, 999999); + const offset = filtros.offset !== undefined + ? limitarNumero(filtros.offset, 0, 0, 999999999) + : (page - 1) * limite; + + const orderBy = resolverOrdenacao(filtros.orderBy); + const orderDirection = resolverDirecao(filtros.orderDirection); + + const { whereSql, params } = montarWhereMovimentosFixos(filtros); + + const [rows] = await pool.query( + ` + SELECT + ${CAMPOS_MOVIMENTO_FIXO_SELECT} + ${FROM_MOVIMENTO_FIXO_JOIN} + ${whereSql} + ORDER BY ${orderBy} ${orderDirection}, mf.idmovimentosfixos ASC + LIMIT ? OFFSET ? + `, + [...params, limite, offset] + ); + + const [countRows] = await pool.query( + ` + SELECT COUNT(*) AS total + ${FROM_MOVIMENTO_FIXO_JOIN} + ${whereSql} + `, + params + ); + + const [summaryRows] = await pool.query( + ` + SELECT + COUNT(*) AS quantidade, + COALESCE(SUM(mf.valor), 0) AS valorTotal, + COALESCE(SUM(CASE WHEN mf.movimento = 'Entrada' THEN mf.valor ELSE 0 END), 0) AS totalEntradas, + COALESCE(SUM(CASE WHEN mf.movimento = 'Saida' THEN mf.valor ELSE 0 END), 0) AS totalSaidas, + COALESCE(SUM(CASE WHEN mf.movimento = 'Sangria' THEN mf.valor ELSE 0 END), 0) AS totalSangrias, + COALESCE(SUM(CASE WHEN mf.movimento = 'Estorno' THEN mf.valor ELSE 0 END), 0) AS totalEstornos, + COALESCE(SUM(CASE WHEN mf.habilitado = 1 THEN 1 ELSE 0 END), 0) AS habilitados, + COALESCE(SUM(CASE WHEN mf.habilitado = 0 THEN 1 ELSE 0 END), 0) AS desabilitados + ${FROM_MOVIMENTO_FIXO_JOIN} + ${whereSql} + `, + params + ); + + const total = Number(countRows[0]?.total || 0); + const totalPages = Math.max(1, Math.ceil(total / limite)); + + return { + data: rows, + pagination: { + total, + limite, + offset, + page: Math.floor(offset / limite) + 1, + totalPages, + }, + summary: { + quantidade: Number(summaryRows[0]?.quantidade || 0), + valorTotal: Number(summaryRows[0]?.valorTotal || 0), + totalEntradas: Number(summaryRows[0]?.totalEntradas || 0), + totalSaidas: Number(summaryRows[0]?.totalSaidas || 0), + totalSangrias: Number(summaryRows[0]?.totalSangrias || 0), + totalEstornos: Number(summaryRows[0]?.totalEstornos || 0), + habilitados: Number(summaryRows[0]?.habilitados || 0), + desabilitados: Number(summaryRows[0]?.desabilitados || 0), + }, + }; +} + +async function buscarMovimentoFixoPorId(id) { + const [rows] = await pool.query( + ` + SELECT + ${CAMPOS_MOVIMENTO_FIXO_SELECT} + ${FROM_MOVIMENTO_FIXO_JOIN} + WHERE mf.idmovimentosfixos = ? + LIMIT 1 + `, + [id] + ); + + return rows[0] || null; +} + +async function criarMovimentoFixo(dados) { + const { + movimento, + descricao, + valor, + dia_vencimento, + idcentrodecustos, + habilitado, + idbancos, + } = dados; + + const [result] = await pool.query( + ` + INSERT INTO movimentosfixos ( + movimento, + descricao, + valor, + dia_vencimento, + idcentrodecustos, + habilitado, + idbancos, + insert_date, + update_date + ) VALUES (?, ?, ?, ?, ?, ?, ?, NOW(), NOW()) + `, + [ + normalizarTextoOuNull(movimento), + normalizarTextoOuNull(descricao), + normalizarNumero(valor, 0), + normalizarNumero(dia_vencimento, 1), + normalizarNumeroOuNull(idcentrodecustos), + normalizarFlag(habilitado, 1), + normalizarNumeroOuNull(idbancos), + ] + ); + + return buscarMovimentoFixoPorId(result.insertId); +} + +async function atualizarMovimentoFixo(id, dados) { + const movimentoAtual = await buscarMovimentoFixoPorId(id); + + if (!movimentoAtual) { + return null; + } + + const { + movimento, + descricao, + valor, + dia_vencimento, + idcentrodecustos, + habilitado, + idbancos, + } = dados; + + await pool.query( + ` + UPDATE movimentosfixos + SET + movimento = ?, + descricao = ?, + valor = ?, + dia_vencimento = ?, + idcentrodecustos = ?, + habilitado = ?, + idbancos = ?, + update_date = NOW() + WHERE idmovimentosfixos = ? + `, + [ + normalizarTextoOuNull(movimento), + normalizarTextoOuNull(descricao), + normalizarNumero(valor, 0), + normalizarNumero(dia_vencimento, 1), + normalizarNumeroOuNull(idcentrodecustos), + normalizarFlag(habilitado, 1), + normalizarNumeroOuNull(idbancos), + id, + ] + ); + + return buscarMovimentoFixoPorId(id); +} + +async function alterarHabilitadoMovimentoFixo(id, habilitado) { + const movimentoAtual = await buscarMovimentoFixoPorId(id); + + if (!movimentoAtual) { + return null; + } + + await pool.query( + ` + UPDATE movimentosfixos + SET + habilitado = ?, + update_date = NOW() + WHERE idmovimentosfixos = ? + `, + [ + normalizarFlag(habilitado, 1), + id, + ] + ); + + return buscarMovimentoFixoPorId(id); +} + +async function deletarMovimentoFixo(id) { + const movimentoAtual = await buscarMovimentoFixoPorId(id); + + if (!movimentoAtual) { + return false; + } + + await pool.query( + ` + DELETE FROM movimentosfixos + WHERE idmovimentosfixos = ? + `, + [id] + ); + + return true; +} + +module.exports = { + listarMovimentosFixos, + buscarMovimentoFixoPorId, + criarMovimentoFixo, + atualizarMovimentoFixo, + alterarHabilitadoMovimentoFixo, + deletarMovimentoFixo, +}; \ No newline at end of file diff --git a/financeiro-api/src/modules/referencias/services/referencias.service.js b/financeiro-api/src/modules/referencias/services/referencias.service.js index d3b8de0..2d227d7 100644 --- a/financeiro-api/src/modules/referencias/services/referencias.service.js +++ b/financeiro-api/src/modules/referencias/services/referencias.service.js @@ -9,6 +9,7 @@ async function listarBancos() { saldo, debito FROM bancos + WHERE habilitado = 1 ORDER BY descricao ASC ` ); @@ -26,6 +27,7 @@ async function listarCentrosCusto() { simular, investimento FROM centrodecustos + WHERE habilitado = 1 ORDER BY descricao ASC ` ); @@ -46,6 +48,7 @@ async function listarClientes() { estado, pessoafisica FROM clientes + WHERE habilitado = 1 ORDER BY nome ASC ` ); diff --git a/financeiro-api/src/modules/usuarios/controllers/usuarios.controller.js b/financeiro-api/src/modules/usuarios/controllers/usuarios.controller.js new file mode 100644 index 0000000..1c8612c --- /dev/null +++ b/financeiro-api/src/modules/usuarios/controllers/usuarios.controller.js @@ -0,0 +1,261 @@ +const usuariosService = require('../services/usuarios.service'); + +function validarDadosUsuario(dados, modo = 'create') { + if (!dados.nome || !String(dados.nome).trim()) { + return 'Nome é obrigatório.'; + } + + if (modo === 'create' && (!dados.senha || !String(dados.senha).trim())) { + return 'Senha é obrigatória.'; + } + + if (dados.senha !== undefined && dados.senha !== null && dados.senha !== '') { + if (String(dados.senha).length < 3) { + return 'Senha deve ter pelo menos 3 caracteres.'; + } + + if (String(dados.senha).length > 45) { + return 'Senha deve ter no máximo 45 caracteres.'; + } + } + + if (dados.habilitado !== undefined && dados.habilitado !== null && dados.habilitado !== '') { + const habilitado = Number(dados.habilitado); + + if (![0, 1].includes(habilitado)) { + return 'Status inválido.'; + } + } + + return null; +} + +async function listar(req, res) { + try { + const resultado = await usuariosService.listarUsuarios({ + limite: req.query.limite, + offset: req.query.offset, + page: req.query.page, + busca: req.query.busca, + habilitado: req.query.habilitado, + orderBy: req.query.orderBy, + orderDirection: req.query.orderDirection, + }); + + return res.json({ + ok: true, + data: resultado.data, + pagination: resultado.pagination, + summary: resultado.summary, + }); + } catch (error) { + console.error('Erro ao listar usuários:', error); + + return res.status(500).json({ + ok: false, + message: 'Erro ao listar usuários.', + }); + } +} + +async function buscarPorId(req, res) { + try { + const id = Number(req.params.id); + + if (!id) { + return res.status(400).json({ + ok: false, + message: 'ID inválido.', + }); + } + + const usuario = await usuariosService.buscarUsuarioPorId(id); + + if (!usuario) { + return res.status(404).json({ + ok: false, + message: 'Usuário não encontrado.', + }); + } + + return res.json({ + ok: true, + data: usuario, + }); + } catch (error) { + console.error('Erro ao buscar usuário:', error); + + return res.status(500).json({ + ok: false, + message: 'Erro ao buscar usuário.', + }); + } +} + +async function criar(req, res) { + try { + const erroValidacao = validarDadosUsuario(req.body, 'create'); + + if (erroValidacao) { + return res.status(400).json({ + ok: false, + message: erroValidacao, + }); + } + + const novoUsuario = await usuariosService.criarUsuario(req.body); + + return res.status(201).json({ + ok: true, + message: 'Usuário cadastrado com sucesso.', + data: novoUsuario, + }); + } catch (error) { + console.error('Erro ao criar usuário:', error); + + return res.status(500).json({ + ok: false, + message: 'Erro ao criar usuário.', + }); + } +} + +async function atualizar(req, res) { + try { + const id = Number(req.params.id); + + if (!id) { + return res.status(400).json({ + ok: false, + message: 'ID inválido.', + }); + } + + const erroValidacao = validarDadosUsuario(req.body, 'edit'); + + if (erroValidacao) { + return res.status(400).json({ + ok: false, + message: erroValidacao, + }); + } + + const usuarioAtualizado = await usuariosService.atualizarUsuario(id, req.body); + + if (!usuarioAtualizado) { + return res.status(404).json({ + ok: false, + message: 'Usuário não encontrado.', + }); + } + + return res.json({ + ok: true, + message: 'Usuário atualizado com sucesso.', + data: usuarioAtualizado, + }); + } catch (error) { + console.error('Erro ao atualizar usuário:', error); + + return res.status(500).json({ + ok: false, + message: 'Erro ao atualizar usuário.', + }); + } +} + +async function alterarHabilitado(req, res) { + try { + const id = Number(req.params.id); + + if (!id) { + return res.status(400).json({ + ok: false, + message: 'ID inválido.', + }); + } + + const habilitado = Number(req.body.habilitado); + + if (![0, 1].includes(habilitado)) { + return res.status(400).json({ + ok: false, + message: 'Status inválido.', + }); + } + + const usuarioAtualizado = await usuariosService.alterarHabilitadoUsuario(id, habilitado); + + if (!usuarioAtualizado) { + return res.status(404).json({ + ok: false, + message: 'Usuário não encontrado.', + }); + } + + return res.json({ + ok: true, + message: habilitado === 1 + ? 'Usuário habilitado com sucesso.' + : 'Usuário desabilitado com sucesso.', + data: usuarioAtualizado, + }); + } catch (error) { + console.error('Erro ao alterar status do usuário:', error); + + return res.status(500).json({ + ok: false, + message: 'Erro ao alterar status do usuário.', + }); + } +} + +async function deletar(req, res) { + try { + const id = Number(req.params.id); + + if (!id) { + return res.status(400).json({ + ok: false, + message: 'ID inválido.', + }); + } + + const deletado = await usuariosService.deletarUsuario(id); + + if (!deletado) { + return res.status(404).json({ + ok: false, + message: 'Usuário não encontrado.', + }); + } + + return res.json({ + ok: true, + message: 'Usuário excluído com sucesso.', + }); + } catch (error) { + console.error('Erro ao excluir usuário:', error); + + if (error.code === 'ER_ROW_IS_REFERENCED_2') { + return res.status(400).json({ + ok: false, + message: 'Este usuário possui movimentos vinculados e não pode ser excluído.', + }); + } + + return res.status(500).json({ + ok: false, + message: 'Erro ao excluir usuário.', + }); + } +} + +module.exports = { + listar, + buscarPorId, + criar, + atualizar, + alterarHabilitado, + deletar, +}; \ No newline at end of file diff --git a/financeiro-api/src/modules/usuarios/routes/usuarios.routes.js b/financeiro-api/src/modules/usuarios/routes/usuarios.routes.js new file mode 100644 index 0000000..411e4df --- /dev/null +++ b/financeiro-api/src/modules/usuarios/routes/usuarios.routes.js @@ -0,0 +1,16 @@ +const express = require('express'); +const authMiddleware = require('../../../middlewares/auth.middleware'); +const usuariosController = require('../controllers/usuarios.controller'); + +const router = express.Router(); + +router.use(authMiddleware); + +router.get('/', usuariosController.listar); +router.get('/:id', usuariosController.buscarPorId); +router.post('/', usuariosController.criar); +router.put('/:id', usuariosController.atualizar); +router.patch('/:id/habilitado', usuariosController.alterarHabilitado); +router.delete('/:id', usuariosController.deletar); + +module.exports = router; \ No newline at end of file diff --git a/financeiro-api/src/modules/usuarios/services/usuarios.service.js b/financeiro-api/src/modules/usuarios/services/usuarios.service.js new file mode 100644 index 0000000..ec812bd --- /dev/null +++ b/financeiro-api/src/modules/usuarios/services/usuarios.service.js @@ -0,0 +1,313 @@ +const pool = require('../../../database/mysql'); + +const CAMPOS_USUARIO_SELECT = ` + idusuarios, + nome, + anotacoes, + habilitado, + insert_date, + update_date +`; + +function normalizarTextoOuNull(valor) { + if (valor === undefined || valor === null || valor === '') { + return null; + } + + return String(valor).trim(); +} + +function normalizarFlag(valor, padrao = 0) { + if (valor === undefined || valor === null || valor === '') { + return padrao; + } + + return Number(valor) === 1 ? 1 : 0; +} + +function limitarNumero(valor, padrao, minimo, maximo) { + const numero = Number(valor); + + if (!Number.isFinite(numero)) { + return padrao; + } + + if (numero < minimo) { + return minimo; + } + + if (numero > maximo) { + return maximo; + } + + return numero; +} + +function resolverOrdenacao(orderBy) { + const camposPermitidos = { + idusuarios: 'idusuarios', + nome: 'nome', + habilitado: 'habilitado', + insert_date: 'insert_date', + update_date: 'update_date', + }; + + return camposPermitidos[orderBy] || 'nome'; +} + +function resolverDirecao(orderDirection) { + return String(orderDirection || '').toUpperCase() === 'DESC' ? 'DESC' : 'ASC'; +} + +function montarWhereUsuarios(filtros = {}) { + const where = []; + const params = []; + + if (filtros.habilitado !== undefined && filtros.habilitado !== null && filtros.habilitado !== '') { + where.push('habilitado = ?'); + params.push(Number(filtros.habilitado)); + } + + if (filtros.busca) { + where.push(` + ( + nome LIKE ? + OR anotacoes LIKE ? + ) + `); + + const termo = `%${String(filtros.busca).trim()}%`; + + params.push(termo, termo); + } + + const whereSql = where.length > 0 ? `WHERE ${where.join(' AND ')}` : ''; + + return { + whereSql, + params, + }; +} + +async function listarUsuarios(filtros = {}) { + const limite = limitarNumero(filtros.limite, 20, 1, 100); + const page = limitarNumero(filtros.page, 1, 1, 999999); + const offset = filtros.offset !== undefined + ? limitarNumero(filtros.offset, 0, 0, 999999999) + : (page - 1) * limite; + + const orderBy = resolverOrdenacao(filtros.orderBy); + const orderDirection = resolverDirecao(filtros.orderDirection); + + const { whereSql, params } = montarWhereUsuarios(filtros); + + const [rows] = await pool.query( + ` + SELECT + ${CAMPOS_USUARIO_SELECT} + FROM usuarios + ${whereSql} + ORDER BY ${orderBy} ${orderDirection}, idusuarios ASC + LIMIT ? OFFSET ? + `, + [...params, limite, offset] + ); + + const [countRows] = await pool.query( + ` + SELECT COUNT(*) AS total + FROM usuarios + ${whereSql} + `, + params + ); + + const [summaryRows] = await pool.query( + ` + SELECT + COUNT(*) AS quantidade, + COALESCE(SUM(CASE WHEN habilitado = 1 THEN 1 ELSE 0 END), 0) AS habilitados, + COALESCE(SUM(CASE WHEN habilitado = 0 THEN 1 ELSE 0 END), 0) AS desabilitados + FROM usuarios + ${whereSql} + `, + params + ); + + const total = Number(countRows[0]?.total || 0); + const totalPages = Math.max(1, Math.ceil(total / limite)); + + return { + data: rows, + pagination: { + total, + limite, + offset, + page: Math.floor(offset / limite) + 1, + totalPages, + }, + summary: { + quantidade: Number(summaryRows[0]?.quantidade || 0), + habilitados: Number(summaryRows[0]?.habilitados || 0), + desabilitados: Number(summaryRows[0]?.desabilitados || 0), + }, + }; +} + +async function buscarUsuarioPorId(id) { + const [rows] = await pool.query( + ` + SELECT + ${CAMPOS_USUARIO_SELECT} + FROM usuarios + WHERE idusuarios = ? + LIMIT 1 + `, + [id] + ); + + return rows[0] || null; +} + +async function buscarUsuarioComSenhaPorId(id) { + const [rows] = await pool.query( + ` + SELECT + idusuarios, + nome, + senha, + anotacoes, + habilitado, + insert_date, + update_date + FROM usuarios + WHERE idusuarios = ? + LIMIT 1 + `, + [id] + ); + + return rows[0] || null; +} + +async function criarUsuario(dados) { + const { + nome, + senha, + anotacoes, + habilitado, + } = dados; + + const [result] = await pool.query( + ` + INSERT INTO usuarios ( + nome, + senha, + anotacoes, + habilitado, + insert_date, + update_date + ) VALUES (?, ?, ?, ?, NOW(), NOW()) + `, + [ + normalizarTextoOuNull(nome), + normalizarTextoOuNull(senha), + normalizarTextoOuNull(anotacoes), + normalizarFlag(habilitado, 1), + ] + ); + + return buscarUsuarioPorId(result.insertId); +} + +async function atualizarUsuario(id, dados) { + const usuarioAtual = await buscarUsuarioComSenhaPorId(id); + + if (!usuarioAtual) { + return null; + } + + const { + nome, + senha, + anotacoes, + habilitado, + } = dados; + + const senhaFinal = senha === undefined || senha === null || senha === '' + ? usuarioAtual.senha + : normalizarTextoOuNull(senha); + + await pool.query( + ` + UPDATE usuarios + SET + nome = ?, + senha = ?, + anotacoes = ?, + habilitado = ?, + update_date = NOW() + WHERE idusuarios = ? + `, + [ + normalizarTextoOuNull(nome), + senhaFinal, + normalizarTextoOuNull(anotacoes), + normalizarFlag(habilitado, 1), + id, + ] + ); + + return buscarUsuarioPorId(id); +} + +async function alterarHabilitadoUsuario(id, habilitado) { + const usuarioAtual = await buscarUsuarioPorId(id); + + if (!usuarioAtual) { + return null; + } + + await pool.query( + ` + UPDATE usuarios + SET + habilitado = ?, + update_date = NOW() + WHERE idusuarios = ? + `, + [ + normalizarFlag(habilitado, 1), + id, + ] + ); + + return buscarUsuarioPorId(id); +} + +async function deletarUsuario(id) { + const usuarioAtual = await buscarUsuarioPorId(id); + + if (!usuarioAtual) { + return false; + } + + await pool.query( + ` + DELETE FROM usuarios + WHERE idusuarios = ? + `, + [id] + ); + + return true; +} + +module.exports = { + listarUsuarios, + buscarUsuarioPorId, + criarUsuario, + atualizarUsuario, + alterarHabilitadoUsuario, + deletarUsuario, +}; \ No newline at end of file diff --git a/financeiro-web/src/app/router.tsx b/financeiro-web/src/app/router.tsx index 208aa7f..f0c51df 100644 --- a/financeiro-web/src/app/router.tsx +++ b/financeiro-web/src/app/router.tsx @@ -9,6 +9,21 @@ import { NovoMovimentoPage } from '../features/movimentos/pages/NovoMovimentoPag import { RelatoriosPage } from '../features/relatorios/pages/RelatoriosPage'; import { DashboardPage } from '../pages/DashboardPage'; import { NotFoundPage } from '../pages/NotFoundPage'; +import { BancosPage } from '../features/bancos/pages/BancosPage'; +import { NovoBancoPage } from '../features/bancos/pages/NovoBancoPage'; +import { EditarBancoPage } from '../features/bancos/pages/EditarBancoPage'; +import { ClientesPage } from '../features/clientes/pages/ClientesPage'; +import { NovoClientePage } from '../features/clientes/pages/NovoClientePage'; +import { EditarClientePage } from '../features/clientes/pages/EditarClientePage'; +import { CentrosCustoPage } from '../features/centrosCusto/pages/CentrosCustoPage'; +import { NovoCentroCustoPage } from '../features/centrosCusto/pages/NovoCentroCustoPage'; +import { EditarCentroCustoPage } from '../features/centrosCusto/pages/EditarCentroCustoPage'; +import { MovimentosFixosPage } from '../features/movimentosFixos/pages/MovimentosFixosPage'; +import { NovoMovimentoFixoPage } from '../features/movimentosFixos/pages/NovoMovimentoFixoPage'; +import { EditarMovimentoFixoPage } from '../features/movimentosFixos/pages/EditarMovimentoFixoPage'; +import { UsuariosPage } from '../features/usuarios/pages/UsuariosPage'; +import { NovoUsuarioPage } from '../features/usuarios/pages/NovoUsuarioPage'; +import { EditarUsuarioPage } from '../features/usuarios/pages/EditarUsuarioPage'; export const routes: RouteObject[] = [ { @@ -45,6 +60,66 @@ export const routes: RouteObject[] = [ path: '/relatorios', element: , }, + { + path: 'bancos', + element: , + }, + { + path: 'bancos/novo', + element: , + }, + { + path: 'bancos/:id/editar', + element: , + }, + { + path: 'clientes', + element: , + }, + { + path: 'clientes/novo', + element: , + }, + { + path: 'clientes/:id/editar', + element: , + }, + { + path: 'centros-custo', + element: , + }, + { + path: 'centros-custo/novo', + element: , + }, + { + path: 'centros-custo/:id/editar', + element: , + }, + { + path: 'movimentos-fixos', + element: , + }, + { + path: 'movimentos-fixos/novo', + element: , + }, + { + path: 'movimentos-fixos/:id/editar', + element: , + }, + { + path: 'usuarios', + element: , + }, + { + path: 'usuarios/novo', + element: , + }, + { + path: 'usuarios/:id/editar', + element: , + }, ], }, ], diff --git a/financeiro-web/src/components/layout/Sidebar.tsx b/financeiro-web/src/components/layout/Sidebar.tsx index 75a8ddb..8b26f9f 100644 --- a/financeiro-web/src/components/layout/Sidebar.tsx +++ b/financeiro-web/src/components/layout/Sidebar.tsx @@ -12,6 +12,10 @@ import SwapHorizIcon from '@mui/icons-material/SwapHoriz'; import AssessmentIcon from '@mui/icons-material/Assessment'; import AccountBalanceWalletIcon from '@mui/icons-material/AccountBalanceWallet'; import SettingsIcon from '@mui/icons-material/Settings'; +import PeopleAltIcon from '@mui/icons-material/PeopleAlt'; +import AccountTreeIcon from '@mui/icons-material/AccountTree'; +import EventRepeatIcon from '@mui/icons-material/EventRepeat'; +import ManageAccountsIcon from '@mui/icons-material/ManageAccounts'; import { NavLink } from 'react-router-dom'; type SidebarProps = { @@ -29,11 +33,31 @@ const menuItems = [ path: '/movimentos', icon: , }, + { + label: 'Clientes', + path: '/clientes', + icon: , + }, { label: 'Carteiras', - path: '/carteiras', + path: '/bancos', icon: , }, + { + label: 'Centros de custo', + path: '/centros-custo', + icon: , + }, + { + label: 'Movimentos fixos', + path: '/movimentos-fixos', + icon: , + }, + { + label: 'Usuários', + path: '/usuarios', + icon: , + }, { label: 'Relatórios', path: '/relatorios', @@ -98,6 +122,7 @@ export function Sidebar({ onNavigate }: SidebarProps) { }} > {item.icon} + MVP ativo + - Cadastro e edição de movimentos funcionando. + Movimentos e carteiras em operação. diff --git a/financeiro-web/src/features/bancos/components/BancoForm.tsx b/financeiro-web/src/features/bancos/components/BancoForm.tsx new file mode 100644 index 0000000..e65fe3d --- /dev/null +++ b/financeiro-web/src/features/bancos/components/BancoForm.tsx @@ -0,0 +1,415 @@ +import { useEffect, useState } from 'react'; +import type { FormEvent, ReactNode } from 'react'; +import { + Alert, + Box, + Button, + Card, + CardContent, + Chip, + CircularProgress, + Divider, + MenuItem, + Paper, + Stack, + TextField, + Typography, +} from '@mui/material'; +import AccountBalanceWalletIcon from '@mui/icons-material/AccountBalanceWallet'; +import ArrowBackIcon from '@mui/icons-material/ArrowBack'; +import SaveIcon from '@mui/icons-material/Save'; +import { useNavigate } from 'react-router-dom'; +import { + atualizarBanco, + criarBanco, +} from '../services/bancosService'; +import type { + Banco, + CriarBancoRequest, +} from '../types/bancoTypes'; + +type BancoFormProps = { + mode: 'create' | 'edit'; + initialData?: Banco | null; +}; + +type FormSectionProps = { + title: string; + description?: string; + children: ReactNode; +}; + +function formatarValorResumo(value: string) { + const numero = Number(value || 0); + + return new Intl.NumberFormat('pt-BR', { + style: 'currency', + currency: 'BRL', + }).format(numero); +} + +function FormSection({ title, description, children }: FormSectionProps) { + return ( + + + + {title} + + + {description && ( + + {description} + + )} + + + + {children} + + + ); +} + +const fieldSx = { + '& .MuiOutlinedInput-root': { + borderRadius: 2.5, + backgroundColor: '#FFFFFF', + minHeight: 48, + }, + '& .MuiInputLabel-root': { + backgroundColor: '#FFFFFF', + paddingX: 0.5, + }, +}; + +export function BancoForm({ mode, initialData }: BancoFormProps) { + const navigate = useNavigate(); + + const isEdit = mode === 'edit'; + + const [saving, setSaving] = useState(false); + const [erro, setErro] = useState(''); + const [sucesso, setSucesso] = useState(''); + + const [descricao, setDescricao] = useState(''); + const [saldo, setSaldo] = useState('0'); + const [debito, setDebito] = useState('0'); + const [habilitado, setHabilitado] = useState('1'); + + const titulo = isEdit ? 'Editar banco/carteira' : 'Nova carteira'; + const subtitulo = isEdit + ? 'Atualize os dados da carteira selecionada.' + : 'Cadastre contas, carteiras, caixas físicos ou cartões.'; + + useEffect(() => { + if (!initialData) return; + + setDescricao(initialData.descricao || ''); + setSaldo(String(initialData.saldo ?? 0)); + setDebito(String(initialData.debito ?? 0)); + setHabilitado(String(initialData.habilitado ?? 1)); + }, [initialData]); + + function limparFormulario() { + setDescricao(''); + setSaldo('0'); + setDebito('0'); + setHabilitado('1'); + } + + async function handleSubmit(event: FormEvent) { + event.preventDefault(); + + if (!descricao.trim()) { + setErro('Informe a descrição.'); + return; + } + + if (saldo === '' || !Number.isFinite(Number(saldo))) { + setErro('Informe um saldo válido.'); + return; + } + + try { + setSaving(true); + setErro(''); + setSucesso(''); + + const payload: CriarBancoRequest = { + descricao: descricao.trim(), + saldo: Number(saldo || 0), + debito: Number(debito || 0), + habilitado: Number(habilitado || 1), + }; + + if (isEdit) { + if (!initialData?.idbancos) { + setErro('Banco/carteira inválido para edição.'); + return; + } + + await atualizarBanco(initialData.idbancos, payload); + setSucesso('Banco/carteira atualizado com sucesso.'); + } else { + await criarBanco(payload); + setSucesso('Banco/carteira cadastrado com sucesso.'); + limparFormulario(); + } + } catch (error: any) { + const message = + error?.response?.data?.message || + 'Não foi possível salvar o banco/carteira.'; + + setErro(message); + } finally { + setSaving(false); + } + } + + return ( + + + + } + label={isEdit ? 'Edição de carteira' : 'Cadastro de carteira'} + color="primary" + variant="outlined" + sx={{ marginBottom: 1.25, fontWeight: 700 }} + /> + + + {titulo} + + + + {subtitulo} + + + + + + + {erro && ( + + {erro} + + )} + + {sucesso && ( + + {sucesso} + + )} + + + + + + + setDescricao(event.target.value)} + placeholder="Ex: Caixa, Nubank, Sicredi, Mercado Pago..." + fullWidth + sx={fieldSx} + /> + + setDebito(event.target.value)} + fullWidth + sx={fieldSx} + > + Débito + Crédito + + + setSaldo(event.target.value)} + inputProps={{ + step: '0.01', + }} + fullWidth + sx={fieldSx} + /> + + setHabilitado(event.target.value)} + fullWidth + sx={fieldSx} + > + Habilitado + Desabilitado + + + + + + + + + + + + + + + Resumo + + + + Prévia rápida da carteira antes de salvar. + + + + + + + + Descrição + + + {descricao || 'Sem descrição'} + + + + + + Tipo + + + {Number(debito) === 1 ? 'Débito' : 'Crédito'} + + + + + + Saldo + + = 0 ? 'success.main' : 'error.main'} + > + {formatarValorResumo(saldo)} + + + + + + Status + + + {Number(habilitado) === 1 ? 'Habilitado' : 'Desabilitado'} + + + + + + + ); +} \ No newline at end of file diff --git a/financeiro-web/src/features/bancos/pages/BancosPage.tsx b/financeiro-web/src/features/bancos/pages/BancosPage.tsx new file mode 100644 index 0000000..44145c9 --- /dev/null +++ b/financeiro-web/src/features/bancos/pages/BancosPage.tsx @@ -0,0 +1,742 @@ +import { useEffect, useMemo, useState } from 'react'; +import { + Alert, + Box, + Button, + Card, + CardContent, + Chip, + CircularProgress, + Divider, + IconButton, + MenuItem, + Pagination, + Paper, + Stack, + Switch, + TextField, + Tooltip, + Typography, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + useMediaQuery, + useTheme, +} from '@mui/material'; +import AccountBalanceWalletIcon from '@mui/icons-material/AccountBalanceWallet'; +import AddIcon from '@mui/icons-material/Add'; +import DeleteIcon from '@mui/icons-material/Delete'; +import EditIcon from '@mui/icons-material/Edit'; +import FilterAltIcon from '@mui/icons-material/FilterAlt'; +import RestartAltIcon from '@mui/icons-material/RestartAlt'; +import SearchIcon from '@mui/icons-material/Search'; +import { Link as RouterLink } from 'react-router-dom'; +import { + alterarHabilitadoBanco, + deletarBanco, + listarBancos, + type ListarBancosParams, +} from '../services/bancosService'; +import type { + Banco, + BancosPagination, + BancosSummary, +} from '../types/bancoTypes'; + +const LIMITE_PADRAO = 20; + +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 tipoBancoLabel(debito: number) { + return Number(debito) === 1 ? 'Débito' : 'Crédito'; +} + +function tipoBancoColor(debito: number) { + return Number(debito) === 1 ? 'success' : 'info'; +} + +function statusBancoLabel(habilitado: number) { + return Number(habilitado) === 1 ? 'Habilitado' : 'Desabilitado'; +} + +function statusBancoColor(habilitado: number) { + return Number(habilitado) === 1 ? 'success' : 'default'; +} + +export function BancosPage() { + const [bancos, setBancos] = useState([]); + const [pagination, setPagination] = useState({ + total: 0, + limite: LIMITE_PADRAO, + offset: 0, + page: 1, + totalPages: 1, + }); + const [summary, setSummary] = useState({ + quantidade: 0, + saldoTotal: 0, + saldoCarteiras: 0, + saldoDebito: 0, + }); + + const [alterandoStatusId, setAlterandoStatusId] = useState(null); + + const [loading, setLoading] = useState(true); + const [erro, setErro] = useState(''); + const [sucesso, setSucesso] = useState(''); + + const [page, setPage] = useState(1); + const [busca, setBusca] = useState(''); + const [debito, setDebito] = useState(''); + const [habilitado, setHabilitado] = useState(''); + const [orderBy, setOrderBy] = useState('descricao'); + const [orderDirection, setOrderDirection] = useState<'ASC' | 'DESC'>('ASC'); + + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down('md')); + + const filtrosAtivos = useMemo(() => { + let count = 0; + + if (busca.trim()) count += 1; + if (debito !== '') count += 1; + if (habilitado !== '') count += 1; + + return count; + }, [busca, debito, habilitado]); + + async function carregarBancos(pageToLoad = page) { + try { + setLoading(true); + setErro(''); + + const params: ListarBancosParams = { + limite: LIMITE_PADRAO, + page: pageToLoad, + busca: busca.trim() || undefined, + debito, + habilitado, + orderBy, + orderDirection, + }; + + const response = await listarBancos(params); + + setBancos(response.data); + setPagination(response.pagination); + setSummary(response.summary); + setPage(response.pagination.page); + } catch (error: any) { + const message = + error?.response?.data?.message || + 'Não foi possível carregar os bancos/carteiras.'; + + setErro(message); + } finally { + setLoading(false); + } + } + + function aplicarFiltros() { + setPage(1); + carregarBancos(1); + } + + function limparFiltros() { + setBusca(''); + setDebito(''); + setHabilitado(''); + setOrderBy('descricao'); + setOrderDirection('ASC'); + + setTimeout(() => { + setPage(1); + carregarBancos(1); + }, 0); + } + + async function handleDeletarBanco(item: Banco) { + const confirmou = window.confirm( + `Deseja realmente excluir "${item.descricao}"?` + ); + + if (!confirmou) return; + + try { + setLoading(true); + setErro(''); + setSucesso(''); + + await deletarBanco(item.idbancos); + + setSucesso('Banco/carteira excluído com sucesso.'); + await carregarBancos(page); + } catch (error: any) { + const message = + error?.response?.data?.message || + 'Não foi possível excluir o banco/carteira.'; + + setErro(message); + } finally { + setLoading(false); + } + } + + async function handleAlterarStatusBanco(item: Banco) { + const novoStatus = Number(item.habilitado) === 1 ? 0 : 1; + + try { + setAlterandoStatusId(item.idbancos); + setErro(''); + setSucesso(''); + + const bancoAtualizado = await alterarHabilitadoBanco(item.idbancos, novoStatus); + + setBancos((listaAtual) => + listaAtual.map((banco) => + banco.idbancos === item.idbancos ? bancoAtualizado : banco + ) + ); + + setSucesso( + novoStatus === 1 + ? 'Banco/carteira habilitado com sucesso.' + : 'Banco/carteira desabilitado com sucesso.' + ); + } catch (error: any) { + const message = + error?.response?.data?.message || + 'Não foi possível alterar o status do banco/carteira.'; + + setErro(message); + } finally { + setAlterandoStatusId(null); + } + } + + useEffect(() => { + carregarBancos(1); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + + + + } + label="Carteiras financeiras" + color="primary" + variant="outlined" + sx={{ marginBottom: 1.25, fontWeight: 700 }} + /> + + + Bancos e carteiras + + + + Cadastre, edite e acompanhe as carteiras usadas nos movimentos. + + + + + + + {erro && ( + + {erro} + + )} + + {sucesso && ( + + {sucesso} + + )} + + + + + + Carteiras cadastradas + + + {summary.quantidade} + + + + + + + + Saldo total + + = 0 ? 'success.main' : 'error.main'} + > + {formatarValor(summary.saldoTotal)} + + + + + + + + Carteiras de crédito + + + {formatarValor(summary.saldoCarteiras)} + + + + + + + + Carteiras de débito + + + {formatarValor(summary.saldoDebito)} + + + + + + + + + + + + Filtros + + + + + + + setBusca(event.target.value)} + placeholder="Descrição da carteira..." + fullWidth + /> + + + setDebito(event.target.value === '' ? '' : Number(event.target.value)) + } + fullWidth + > + Todos + Crédito + Débito + + + + setHabilitado(event.target.value === '' ? '' : Number(event.target.value)) + } + fullWidth + > + Todos + Habilitados + Desabilitados + + + setOrderBy(event.target.value)} + fullWidth + > + Descrição + Saldo + Tipo + Cadastro + Atualização + + + + setOrderDirection(event.target.value as 'ASC' | 'DESC') + } + fullWidth + > + Crescente + Decrescente + + + + + + + + + + + + + + {loading ? ( + + + Carregando bancos/carteiras... + + ) : bancos.length === 0 ? ( + + + Nenhum banco/carteira encontrado. + + + Ajuste os filtros ou cadastre uma nova carteira. + + + ) : isMobile ? ( + }> + {bancos.map((item) => ( + + + + + + {item.descricao} + + + + Cadastro: {formatarData(item.insert_date)} + + + + = 0 ? 'success.main' : 'error.main'} + whiteSpace="nowrap" + > + {formatarValor(item.saldo)} + + + + + + + + + + + + + handleAlterarStatusBanco(item)} + /> + + + + + + + + + + ))} + + ) : ( + + + + + Descrição + Tipo + Saldo + Cadastro + Atualização + Ações + + + + + {bancos.map((item) => ( + + + + + {item.descricao} + + + + + + + + + + = 0 ? 'success.main' : 'error.main'} + > + {formatarValor(item.saldo)} + + + + + {formatarData(item.insert_date)} + + + + {formatarData(item.update_date)} + + + + + + + handleAlterarStatusBanco(item)} + /> + + + + + + + + + + + handleDeletarBanco(item)} + > + + + + + + + ))} + +
+
+ )} +
+
+ + {pagination.totalPages > 1 && ( + + { + setPage(novaPagina); + carregarBancos(novaPagina); + }} + /> + + )} + + + + Exibindo página {pagination.page} de {pagination.totalPages}, total de{' '} + {pagination.total} registro(s). + + +
+ ); +} \ No newline at end of file diff --git a/financeiro-web/src/features/bancos/pages/EditarBancoPage.tsx b/financeiro-web/src/features/bancos/pages/EditarBancoPage.tsx new file mode 100644 index 0000000..4e523df --- /dev/null +++ b/financeiro-web/src/features/bancos/pages/EditarBancoPage.tsx @@ -0,0 +1,62 @@ +import { useEffect, useState } from 'react'; +import { Alert, Box, CircularProgress, Typography } from '@mui/material'; +import { useParams } from 'react-router-dom'; +import { BancoForm } from '../components/BancoForm'; +import { buscarBancoPorId } from '../services/bancosService'; +import type { Banco } from '../types/bancoTypes'; + +export function EditarBancoPage() { + const { id } = useParams(); + + const [banco, setBanco] = useState(null); + const [loading, setLoading] = useState(true); + const [erro, setErro] = useState(''); + + useEffect(() => { + async function carregarBanco() { + try { + setLoading(true); + setErro(''); + + const bancoId = Number(id); + + if (!bancoId) { + setErro('ID do banco/carteira inválido.'); + return; + } + + const data = await buscarBancoPorId(bancoId); + setBanco(data); + } catch (error: any) { + const message = + error?.response?.data?.message || + 'Não foi possível carregar o banco/carteira.'; + + setErro(message); + } finally { + setLoading(false); + } + } + + carregarBanco(); + }, [id]); + + if (loading) { + return ( + + + Carregando banco/carteira... + + ); + } + + if (erro) { + return ( + + {erro} + + ); + } + + return ; +} \ No newline at end of file diff --git a/financeiro-web/src/features/bancos/pages/NovoBancoPage.tsx b/financeiro-web/src/features/bancos/pages/NovoBancoPage.tsx new file mode 100644 index 0000000..6a0bd50 --- /dev/null +++ b/financeiro-web/src/features/bancos/pages/NovoBancoPage.tsx @@ -0,0 +1,5 @@ +import { BancoForm } from '../components/BancoForm'; + +export function NovoBancoPage() { + return ; +} \ No newline at end of file diff --git a/financeiro-web/src/features/bancos/services/bancosService.ts b/financeiro-web/src/features/bancos/services/bancosService.ts new file mode 100644 index 0000000..3ad3975 --- /dev/null +++ b/financeiro-web/src/features/bancos/services/bancosService.ts @@ -0,0 +1,66 @@ +import { api } from '../../../services/api'; +import type { + ApiResponse, + AtualizarBancoRequest, + Banco, + BancosListResponse, + CriarBancoRequest, +} from '../types/bancoTypes'; + +export type OrderDirection = 'ASC' | 'DESC'; + +export type ListarBancosParams = { + limite?: number; + offset?: number; + page?: number; + busca?: string; + debito?: number | ''; + habilitado?: number | ''; + orderBy?: string; + orderDirection?: OrderDirection; +}; + +export async function listarBancos( + params?: ListarBancosParams +): Promise { + const response = await api.get('/bancos', { + params, + }); + + return response.data; +} + +export async function buscarBancoPorId(id: number): Promise { + const response = await api.get>(`/bancos/${id}`); + return response.data.data; +} + +export async function criarBanco( + data: CriarBancoRequest +): Promise { + const response = await api.post>('/bancos', data); + return response.data.data; +} + +export async function atualizarBanco( + id: number, + data: AtualizarBancoRequest +): Promise { + const response = await api.put>(`/bancos/${id}`, data); + return response.data.data; +} + +export async function alterarHabilitadoBanco( + id: number, + habilitado: number +): Promise { + const response = await api.patch>(`/bancos/${id}/habilitado`, { + habilitado, + }); + + return response.data.data; +} + +export async function deletarBanco(id: number): Promise { + await api.delete(`/bancos/${id}`); +} \ No newline at end of file diff --git a/financeiro-web/src/features/bancos/types/bancoTypes.ts b/financeiro-web/src/features/bancos/types/bancoTypes.ts new file mode 100644 index 0000000..500e3cb --- /dev/null +++ b/financeiro-web/src/features/bancos/types/bancoTypes.ts @@ -0,0 +1,46 @@ +export type Banco = { + idbancos: number; + descricao: string; + saldo: number; + debito: number; + habilitado: number; + insert_date: string | null; + update_date: string | null; +}; + +export type CriarBancoRequest = { + descricao: string; + saldo: number; + debito: number; + habilitado: number; +}; + +export type AtualizarBancoRequest = CriarBancoRequest; + +export type BancosPagination = { + total: number; + limite: number; + offset: number; + page: number; + totalPages: number; +}; + +export type BancosSummary = { + quantidade: number; + saldoTotal: number; + saldoCarteiras: number; + saldoDebito: number; +}; + +export type ApiResponse = { + ok: boolean; + message?: string; + data: T; +}; + +export type BancosListResponse = { + ok: boolean; + data: Banco[]; + pagination: BancosPagination; + summary: BancosSummary; +}; \ No newline at end of file diff --git a/financeiro-web/src/features/centrosCusto/components/CentroCustoForm.tsx b/financeiro-web/src/features/centrosCusto/components/CentroCustoForm.tsx new file mode 100644 index 0000000..c5786dd --- /dev/null +++ b/financeiro-web/src/features/centrosCusto/components/CentroCustoForm.tsx @@ -0,0 +1,439 @@ +import { useEffect, useState } from 'react'; +import type { FormEvent, ReactNode } from 'react'; +import { + Alert, + Box, + Button, + Card, + CardContent, + Chip, + CircularProgress, + Divider, + MenuItem, + Paper, + Stack, + TextField, + Typography, +} from '@mui/material'; +import AccountTreeIcon from '@mui/icons-material/AccountTree'; +import ArrowBackIcon from '@mui/icons-material/ArrowBack'; +import SaveIcon from '@mui/icons-material/Save'; +import { useNavigate } from 'react-router-dom'; +import { + atualizarCentroCusto, + criarCentroCusto, +} from '../services/centrosCustoService'; +import type { + CentroCusto, + CriarCentroCustoRequest, +} from '../types/centroCustoTypes'; + +type CentroCustoFormProps = { + mode: 'create' | 'edit'; + initialData?: CentroCusto | null; +}; + +type FormSectionProps = { + title: string; + description?: string; + children: ReactNode; +}; + +function formatarValorResumo(value: string) { + const numero = Number(value || 0); + + return new Intl.NumberFormat('pt-BR', { + style: 'currency', + currency: 'BRL', + }).format(numero); +} + +function FormSection({ title, description, children }: FormSectionProps) { + return ( + + + + {title} + + + {description && ( + + {description} + + )} + + + + {children} + + + ); +} + +const fieldSx = { + '& .MuiOutlinedInput-root': { + borderRadius: 2.5, + backgroundColor: '#FFFFFF', + minHeight: 48, + }, + '& .MuiInputLabel-root': { + backgroundColor: '#FFFFFF', + paddingX: 0.5, + }, +}; + +export function CentroCustoForm({ mode, initialData }: CentroCustoFormProps) { + const navigate = useNavigate(); + + const isEdit = mode === 'edit'; + + const [saving, setSaving] = useState(false); + const [erro, setErro] = useState(''); + const [sucesso, setSucesso] = useState(''); + + const [descricao, setDescricao] = useState(''); + const [limite, setLimite] = useState('0'); + const [simular, setSimular] = useState('0'); + const [investimento, setInvestimento] = useState('0'); + const [habilitado, setHabilitado] = useState('1'); + + const titulo = isEdit ? 'Editar centro de custo' : 'Novo centro de custo'; + const subtitulo = isEdit + ? 'Atualize os dados do centro de custo selecionado.' + : 'Cadastre categorias financeiras para classificar os movimentos.'; + + useEffect(() => { + if (!initialData) return; + + setDescricao(initialData.descricao || ''); + setLimite(String(initialData.limite ?? 0)); + setSimular(String(initialData.simular ?? 0)); + setInvestimento(String(initialData.investimento ?? 0)); + setHabilitado(String(initialData.habilitado ?? 1)); + }, [initialData]); + + function limparFormulario() { + setDescricao(''); + setLimite('0'); + setSimular('0'); + setInvestimento('0'); + setHabilitado('1'); + } + + async function handleSubmit(event: FormEvent) { + event.preventDefault(); + + if (!descricao.trim()) { + setErro('Informe a descrição.'); + return; + } + + if (limite === '' || !Number.isFinite(Number(limite))) { + setErro('Informe um limite válido.'); + return; + } + + try { + setSaving(true); + setErro(''); + setSucesso(''); + + const payload: CriarCentroCustoRequest = { + descricao: descricao.trim(), + limite: Number(limite || 0), + simular: Number(simular || 0), + investimento: Number(investimento || 0), + habilitado: Number(habilitado || 1), + }; + + if (isEdit) { + if (!initialData?.idcentrodecustos) { + setErro('Centro de custo inválido para edição.'); + return; + } + + await atualizarCentroCusto(initialData.idcentrodecustos, payload); + setSucesso('Centro de custo atualizado com sucesso.'); + } else { + await criarCentroCusto(payload); + setSucesso('Centro de custo cadastrado com sucesso.'); + limparFormulario(); + } + } catch (error: any) { + const message = + error?.response?.data?.message || + 'Não foi possível salvar o centro de custo.'; + + setErro(message); + } finally { + setSaving(false); + } + } + + return ( + + + + } + label={isEdit ? 'Edição de centro' : 'Cadastro de centro'} + color="primary" + variant="outlined" + sx={{ marginBottom: 1.25, fontWeight: 700 }} + /> + + + {titulo} + + + + {subtitulo} + + + + + + + {erro && ( + + {erro} + + )} + + {sucesso && ( + + {sucesso} + + )} + + + + + + + setDescricao(event.target.value)} + placeholder="Ex: Administrativo, Comercial, Impostos..." + fullWidth + sx={fieldSx} + /> + + setLimite(event.target.value)} + inputProps={{ + step: '0.01', + }} + fullWidth + sx={fieldSx} + /> + + setSimular(event.target.value)} + fullWidth + sx={fieldSx} + > + Sim + Não + + + setInvestimento(event.target.value)} + fullWidth + sx={fieldSx} + > + Sim + Não + + + setHabilitado(event.target.value)} + fullWidth + sx={fieldSx} + > + Habilitado + Desabilitado + + + + + + + + + + + + + + + Resumo + + + + Prévia rápida do centro antes de salvar. + + + + + + + + Descrição + + + {descricao || 'Sem descrição'} + + + + + + Limite + + + {formatarValorResumo(limite)} + + + + + + Simular + + + {Number(simular) === 1 ? 'Sim' : 'Não'} + + + + + + Investimento + + + {Number(investimento) === 1 ? 'Sim' : 'Não'} + + + + + + Status + + + {Number(habilitado) === 1 ? 'Habilitado' : 'Desabilitado'} + + + + + + + ); +} \ No newline at end of file diff --git a/financeiro-web/src/features/centrosCusto/pages/CentrosCustoPage.tsx b/financeiro-web/src/features/centrosCusto/pages/CentrosCustoPage.tsx new file mode 100644 index 0000000..7385c4d --- /dev/null +++ b/financeiro-web/src/features/centrosCusto/pages/CentrosCustoPage.tsx @@ -0,0 +1,783 @@ +import { useEffect, useMemo, useState } from 'react'; +import { + Alert, + Box, + Button, + Card, + CardContent, + Chip, + CircularProgress, + Divider, + IconButton, + MenuItem, + Pagination, + Paper, + Stack, + Switch, + TextField, + Tooltip, + Typography, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + useMediaQuery, + useTheme, +} from '@mui/material'; +import AddIcon from '@mui/icons-material/Add'; +import AccountTreeIcon from '@mui/icons-material/AccountTree'; +import DeleteIcon from '@mui/icons-material/Delete'; +import EditIcon from '@mui/icons-material/Edit'; +import FilterAltIcon from '@mui/icons-material/FilterAlt'; +import RestartAltIcon from '@mui/icons-material/RestartAlt'; +import SearchIcon from '@mui/icons-material/Search'; +import { Link as RouterLink } from 'react-router-dom'; +import { + alterarHabilitadoCentroCusto, + deletarCentroCusto, + listarCentrosCusto, + type ListarCentrosCustoParams, +} from '../services/centrosCustoService'; +import type { + CentroCusto, + CentrosCustoPagination, + CentrosCustoSummary, +} from '../types/centroCustoTypes'; + +const LIMITE_PADRAO = 20; + +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 flagLabel(valor: number, positivo: string, negativo: string) { + return Number(valor) === 1 ? positivo : negativo; +} + +function flagColor(valor: number) { + return Number(valor) === 1 ? 'success' : 'default'; +} + +function statusLabel(habilitado: number) { + return Number(habilitado) === 1 ? 'Habilitado' : 'Desabilitado'; +} + +export function CentrosCustoPage() { + const [centros, setCentros] = useState([]); + const [pagination, setPagination] = useState({ + total: 0, + limite: LIMITE_PADRAO, + offset: 0, + page: 1, + totalPages: 1, + }); + const [summary, setSummary] = useState({ + quantidade: 0, + limiteTotal: 0, + habilitados: 0, + desabilitados: 0, + investimentos: 0, + simulaveis: 0, + }); + + const [alterandoStatusId, setAlterandoStatusId] = useState(null); + + const [loading, setLoading] = useState(true); + const [erro, setErro] = useState(''); + const [sucesso, setSucesso] = useState(''); + + const [page, setPage] = useState(1); + const [busca, setBusca] = useState(''); + const [simular, setSimular] = useState(''); + const [investimento, setInvestimento] = useState(''); + const [habilitado, setHabilitado] = useState(''); + const [orderBy, setOrderBy] = useState('descricao'); + const [orderDirection, setOrderDirection] = useState<'ASC' | 'DESC'>('ASC'); + + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down('md')); + + const filtrosAtivos = useMemo(() => { + let count = 0; + + if (busca.trim()) count += 1; + if (simular !== '') count += 1; + if (investimento !== '') count += 1; + if (habilitado !== '') count += 1; + + return count; + }, [busca, simular, investimento, habilitado]); + + async function carregarCentrosCusto(pageToLoad = page) { + try { + setLoading(true); + setErro(''); + + const params: ListarCentrosCustoParams = { + limite: LIMITE_PADRAO, + page: pageToLoad, + busca: busca.trim() || undefined, + simular, + investimento, + habilitado, + orderBy, + orderDirection, + }; + + const response = await listarCentrosCusto(params); + + setCentros(response.data); + setPagination(response.pagination); + setSummary(response.summary); + setPage(response.pagination.page); + } catch (error: any) { + const message = + error?.response?.data?.message || + 'Não foi possível carregar os centros de custo.'; + + setErro(message); + } finally { + setLoading(false); + } + } + + function aplicarFiltros() { + setPage(1); + carregarCentrosCusto(1); + } + + function limparFiltros() { + setBusca(''); + setSimular(''); + setInvestimento(''); + setHabilitado(''); + setOrderBy('descricao'); + setOrderDirection('ASC'); + + setTimeout(() => { + setPage(1); + carregarCentrosCusto(1); + }, 0); + } + + async function handleDeletarCentroCusto(item: CentroCusto) { + const confirmou = window.confirm( + `Deseja realmente excluir "${item.descricao}"?` + ); + + if (!confirmou) return; + + try { + setLoading(true); + setErro(''); + setSucesso(''); + + await deletarCentroCusto(item.idcentrodecustos); + + setSucesso('Centro de custo excluído com sucesso.'); + await carregarCentrosCusto(page); + } catch (error: any) { + const message = + error?.response?.data?.message || + 'Não foi possível excluir o centro de custo.'; + + setErro(message); + } finally { + setLoading(false); + } + } + + async function handleAlterarStatusCentroCusto(item: CentroCusto) { + const novoStatus = Number(item.habilitado) === 1 ? 0 : 1; + + try { + setAlterandoStatusId(item.idcentrodecustos); + setErro(''); + setSucesso(''); + + const centroAtualizado = await alterarHabilitadoCentroCusto( + item.idcentrodecustos, + novoStatus + ); + + setCentros((listaAtual) => + listaAtual.map((centro) => + centro.idcentrodecustos === item.idcentrodecustos + ? centroAtualizado + : centro + ) + ); + + setSucesso( + novoStatus === 1 + ? 'Centro de custo habilitado com sucesso.' + : 'Centro de custo desabilitado com sucesso.' + ); + } catch (error: any) { + const message = + error?.response?.data?.message || + 'Não foi possível alterar o status do centro de custo.'; + + setErro(message); + } finally { + setAlterandoStatusId(null); + } + } + + useEffect(() => { + carregarCentrosCusto(1); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + + + + } + label="Classificação financeira" + color="primary" + variant="outlined" + sx={{ marginBottom: 1.25, fontWeight: 700 }} + /> + + + Centros de custo + + + + Cadastre e organize categorias financeiras para os movimentos. + + + + + + + {erro && ( + + {erro} + + )} + + {sucesso && ( + + {sucesso} + + )} + + + + + + Centros encontrados + + + {summary.quantidade} + + + + + + + + Limite total + + + {formatarValor(summary.limiteTotal)} + + + + + + + + Investimentos + + + {summary.investimentos} + + + + + + + + Simuláveis + + + {summary.simulaveis} + + + + + + + + + + + + Filtros + + + + + + + setBusca(event.target.value)} + placeholder="Descrição do centro..." + fullWidth + /> + + + setSimular(event.target.value === '' ? '' : Number(event.target.value)) + } + fullWidth + > + Todos + Sim + Não + + + + setInvestimento(event.target.value === '' ? '' : Number(event.target.value)) + } + fullWidth + > + Todos + Sim + Não + + + + setHabilitado(event.target.value === '' ? '' : Number(event.target.value)) + } + fullWidth + > + Todos + Habilitados + Desabilitados + + + setOrderBy(event.target.value)} + fullWidth + > + Descrição + Limite + Simular + Investimento + Status + Cadastro + Atualização + + + + setOrderDirection(event.target.value as 'ASC' | 'DESC') + } + fullWidth + > + Crescente + Decrescente + + + + + + + + + + + + + + {loading ? ( + + + Carregando centros de custo... + + ) : centros.length === 0 ? ( + + + Nenhum centro de custo encontrado. + + + Ajuste os filtros ou cadastre um novo centro de custo. + + + ) : isMobile ? ( + }> + {centros.map((item) => ( + + + + + + {item.descricao} + + + + Cadastro: {formatarData(item.insert_date)} + + + + + {formatarValor(item.limite)} + + + + + + + + + + + + + + + handleAlterarStatusCentroCusto(item)} + /> + + + + + + + + + + ))} + + ) : ( + + + + + Descrição + Limite + Simular + Investimento + Status + Cadastro + Atualização + Ações + + + + + {centros.map((item) => ( + + + + + {item.descricao} + + + + + + + {formatarValor(item.limite)} + + + + + + + + + + + + + + + + {formatarData(item.insert_date)} + + {formatarData(item.update_date)} + + + + + + handleAlterarStatusCentroCusto(item)} + /> + + + + + + + + + + + handleDeletarCentroCusto(item)} + > + + + + + + + ))} + +
+
+ )} +
+
+ + {pagination.totalPages > 1 && ( + + { + setPage(novaPagina); + carregarCentrosCusto(novaPagina); + }} + /> + + )} + + + + Exibindo página {pagination.page} de {pagination.totalPages}, total de{' '} + {pagination.total} registro(s). + + +
+ ); +} \ No newline at end of file diff --git a/financeiro-web/src/features/centrosCusto/pages/EditarCentroCustoPage.tsx b/financeiro-web/src/features/centrosCusto/pages/EditarCentroCustoPage.tsx new file mode 100644 index 0000000..e8e856a --- /dev/null +++ b/financeiro-web/src/features/centrosCusto/pages/EditarCentroCustoPage.tsx @@ -0,0 +1,62 @@ +import { useEffect, useState } from 'react'; +import { Alert, Box, CircularProgress, Typography } from '@mui/material'; +import { useParams } from 'react-router-dom'; +import { CentroCustoForm } from '../components/CentroCustoForm'; +import { buscarCentroCustoPorId } from '../services/centrosCustoService'; +import type { CentroCusto } from '../types/centroCustoTypes'; + +export function EditarCentroCustoPage() { + const { id } = useParams(); + + const [centroCusto, setCentroCusto] = useState(null); + const [loading, setLoading] = useState(true); + const [erro, setErro] = useState(''); + + useEffect(() => { + async function carregarCentroCusto() { + try { + setLoading(true); + setErro(''); + + const centroCustoId = Number(id); + + if (!centroCustoId) { + setErro('ID do centro de custo inválido.'); + return; + } + + const data = await buscarCentroCustoPorId(centroCustoId); + setCentroCusto(data); + } catch (error: any) { + const message = + error?.response?.data?.message || + 'Não foi possível carregar o centro de custo.'; + + setErro(message); + } finally { + setLoading(false); + } + } + + carregarCentroCusto(); + }, [id]); + + if (loading) { + return ( + + + Carregando centro de custo... + + ); + } + + if (erro) { + return ( + + {erro} + + ); + } + + return ; +} \ No newline at end of file diff --git a/financeiro-web/src/features/centrosCusto/pages/NovoCentroCustoPage.tsx b/financeiro-web/src/features/centrosCusto/pages/NovoCentroCustoPage.tsx new file mode 100644 index 0000000..de4c91d --- /dev/null +++ b/financeiro-web/src/features/centrosCusto/pages/NovoCentroCustoPage.tsx @@ -0,0 +1,5 @@ +import { CentroCustoForm } from '../components/CentroCustoForm'; + +export function NovoCentroCustoPage() { + return ; +} \ No newline at end of file diff --git a/financeiro-web/src/features/centrosCusto/services/centrosCustoService.ts b/financeiro-web/src/features/centrosCusto/services/centrosCustoService.ts new file mode 100644 index 0000000..1643272 --- /dev/null +++ b/financeiro-web/src/features/centrosCusto/services/centrosCustoService.ts @@ -0,0 +1,68 @@ +import { api } from '../../../services/api'; +import type { + ApiResponse, + AtualizarCentroCustoRequest, + CentroCusto, + CentrosCustoListResponse, + CriarCentroCustoRequest, +} from '../types/centroCustoTypes'; + +export type OrderDirection = 'ASC' | 'DESC'; + +export type ListarCentrosCustoParams = { + limite?: number; + offset?: number; + page?: number; + busca?: string; + simular?: number | ''; + investimento?: number | ''; + habilitado?: number | ''; + orderBy?: string; + orderDirection?: OrderDirection; +}; + +export async function listarCentrosCusto( + params?: ListarCentrosCustoParams +): Promise { + const response = await api.get('/centros-custo', { + params, + }); + + return response.data; +} + +export async function buscarCentroCustoPorId(id: number): Promise { + const response = await api.get>(`/centros-custo/${id}`); + return response.data.data; +} + +export async function criarCentroCusto( + data: CriarCentroCustoRequest +): Promise { + const response = await api.post>('/centros-custo', data); + return response.data.data; +} + +export async function atualizarCentroCusto( + id: number, + data: AtualizarCentroCustoRequest +): Promise { + const response = await api.put>(`/centros-custo/${id}`, data); + return response.data.data; +} + +export async function alterarHabilitadoCentroCusto( + id: number, + habilitado: number +): Promise { + const response = await api.patch>( + `/centros-custo/${id}/habilitado`, + { habilitado } + ); + + return response.data.data; +} + +export async function deletarCentroCusto(id: number): Promise { + await api.delete(`/centros-custo/${id}`); +} \ No newline at end of file diff --git a/financeiro-web/src/features/centrosCusto/types/centroCustoTypes.ts b/financeiro-web/src/features/centrosCusto/types/centroCustoTypes.ts new file mode 100644 index 0000000..e7df198 --- /dev/null +++ b/financeiro-web/src/features/centrosCusto/types/centroCustoTypes.ts @@ -0,0 +1,50 @@ +export type CentroCusto = { + idcentrodecustos: number; + descricao: string; + limite: number; + simular: number; + investimento: number; + habilitado: number; + insert_date: string | null; + update_date: string | null; +}; + +export type CriarCentroCustoRequest = { + descricao: string; + limite: number; + simular: number; + investimento: number; + habilitado: number; +}; + +export type AtualizarCentroCustoRequest = CriarCentroCustoRequest; + +export type CentrosCustoPagination = { + total: number; + limite: number; + offset: number; + page: number; + totalPages: number; +}; + +export type CentrosCustoSummary = { + quantidade: number; + limiteTotal: number; + habilitados: number; + desabilitados: number; + investimentos: number; + simulaveis: number; +}; + +export type ApiResponse = { + ok: boolean; + message?: string; + data: T; +}; + +export type CentrosCustoListResponse = { + ok: boolean; + data: CentroCusto[]; + pagination: CentrosCustoPagination; + summary: CentrosCustoSummary; +}; \ No newline at end of file diff --git a/financeiro-web/src/features/clientes/components/ClienteForm.tsx b/financeiro-web/src/features/clientes/components/ClienteForm.tsx new file mode 100644 index 0000000..d9c842b --- /dev/null +++ b/financeiro-web/src/features/clientes/components/ClienteForm.tsx @@ -0,0 +1,562 @@ +import { useEffect, useState } from 'react'; +import type { FormEvent, ReactNode } from 'react'; +import { + Alert, + Box, + Button, + Card, + CardContent, + Chip, + CircularProgress, + Divider, + MenuItem, + Paper, + Stack, + TextField, + Typography, +} from '@mui/material'; +import ArrowBackIcon from '@mui/icons-material/ArrowBack'; +import PersonIcon from '@mui/icons-material/Person'; +import SaveIcon from '@mui/icons-material/Save'; +import { useNavigate } from 'react-router-dom'; +import { + atualizarCliente, + criarCliente, +} from '../services/clientesService'; +import type { + Cliente, + CriarClienteRequest, +} from '../types/clienteTypes'; + +type ClienteFormProps = { + mode: 'create' | 'edit'; + initialData?: Cliente | null; +}; + +type FormSectionProps = { + title: string; + description?: string; + children: ReactNode; +}; + +function FormSection({ title, description, children }: FormSectionProps) { + return ( + + + + {title} + + + {description && ( + + {description} + + )} + + + + {children} + + + ); +} + +const fieldSx = { + '& .MuiOutlinedInput-root': { + borderRadius: 2.5, + backgroundColor: '#FFFFFF', + minHeight: 48, + }, + '& .MuiInputLabel-root': { + backgroundColor: '#FFFFFF', + paddingX: 0.5, + }, +}; + +function textoOuNull(valor: string): string | null { + const texto = valor.trim(); + return texto || null; +} + +function normalizarDocumento(valor: string) { + return valor.replace(/\D/g, ''); +} + +export function ClienteForm({ mode, initialData }: ClienteFormProps) { + const navigate = useNavigate(); + + const isEdit = mode === 'edit'; + + const [saving, setSaving] = useState(false); + const [erro, setErro] = useState(''); + const [sucesso, setSucesso] = useState(''); + + const [cpfCnpj, setCpfCnpj] = useState(''); + const [nome, setNome] = useState(''); + const [rg, setRg] = useState(''); + const [celular, setCelular] = useState(''); + const [email, setEmail] = useState(''); + const [cep, setCep] = useState(''); + const [logradouro, setLogradouro] = useState(''); + const [numero, setNumero] = useState(''); + const [bairro, setBairro] = useState(''); + const [cidade, setCidade] = useState(''); + const [estado, setEstado] = useState(''); + const [sexo, setSexo] = useState(''); + const [pessoafisica, setPessoafisica] = useState('F'); + const [habilitado, setHabilitado] = useState('1'); + + const titulo = isEdit ? 'Editar cliente' : 'Novo cliente'; + const subtitulo = isEdit + ? 'Atualize os dados cadastrais do cliente selecionado.' + : 'Cadastre clientes para vincular aos movimentos financeiros.'; + + useEffect(() => { + if (!initialData) return; + + setCpfCnpj(initialData.cpf_cnpj || ''); + setNome(initialData.nome || ''); + setRg(initialData.rg || ''); + setCelular(initialData.celular || ''); + setEmail(initialData.email || ''); + setCep(initialData.cep || ''); + setLogradouro(initialData.logradouro || ''); + setNumero(initialData.numero || ''); + setBairro(initialData.bairro || ''); + setCidade(initialData.cidade || ''); + setEstado(initialData.estado || ''); + setSexo(initialData.sexo || ''); + setPessoafisica(initialData.pessoafisica || 'F'); + setHabilitado(String(initialData.habilitado ?? 1)); + }, [initialData]); + + function limparFormulario() { + setCpfCnpj(''); + setNome(''); + setRg(''); + setCelular(''); + setEmail(''); + setCep(''); + setLogradouro(''); + setNumero(''); + setBairro(''); + setCidade(''); + setEstado(''); + setSexo(''); + setPessoafisica('F'); + setHabilitado('1'); + } + + async function handleSubmit(event: FormEvent) { + event.preventDefault(); + + if (!cpfCnpj.trim()) { + setErro('Informe o CPF/CNPJ.'); + return; + } + + if (!nome.trim()) { + setErro('Informe o nome.'); + return; + } + + if (email.trim() && !email.includes('@')) { + setErro('Informe um e-mail válido.'); + return; + } + + try { + setSaving(true); + setErro(''); + setSucesso(''); + + const payload: CriarClienteRequest = { + cpf_cnpj: normalizarDocumento(cpfCnpj) || cpfCnpj.trim(), + nome: nome.trim(), + rg: textoOuNull(rg), + celular: textoOuNull(celular), + email: textoOuNull(email), + cep: textoOuNull(cep), + logradouro: textoOuNull(logradouro), + numero: textoOuNull(numero), + bairro: textoOuNull(bairro), + cidade: textoOuNull(cidade), + estado: textoOuNull(estado), + sexo: textoOuNull(sexo), + pessoafisica: textoOuNull(pessoafisica), + habilitado: Number(habilitado || 1), + }; + + if (isEdit) { + if (!initialData?.idclientes) { + setErro('Cliente inválido para edição.'); + return; + } + + await atualizarCliente(initialData.idclientes, payload); + setSucesso('Cliente atualizado com sucesso.'); + } else { + await criarCliente(payload); + setSucesso('Cliente cadastrado com sucesso.'); + limparFormulario(); + } + } catch (error: any) { + const message = + error?.response?.data?.message || + 'Não foi possível salvar o cliente.'; + + setErro(message); + } finally { + setSaving(false); + } + } + + return ( + + + + } + label={isEdit ? 'Edição de cliente' : 'Cadastro de cliente'} + color="primary" + variant="outlined" + sx={{ marginBottom: 1.25, fontWeight: 700 }} + /> + + + {titulo} + + + + {subtitulo} + + + + + + + {erro && ( + + {erro} + + )} + + {sucesso && ( + + {sucesso} + + )} + + + + + + + setPessoafisica(event.target.value)} + fullWidth + sx={fieldSx} + > + Pessoa física + Pessoa jurídica + + + setHabilitado(event.target.value)} + fullWidth + sx={fieldSx} + > + Habilitado + Desabilitado + + + setCpfCnpj(event.target.value)} + fullWidth + sx={fieldSx} + /> + + setNome(event.target.value)} + fullWidth + sx={fieldSx} + /> + + setRg(event.target.value)} + fullWidth + sx={fieldSx} + /> + + setSexo(event.target.value)} + fullWidth + sx={fieldSx} + disabled={pessoafisica === 'J'} + > + Não informado + Masculino + Feminino + Outro + + + + + setCelular(event.target.value)} + fullWidth + sx={fieldSx} + /> + + setEmail(event.target.value)} + fullWidth + sx={fieldSx} + /> + + + + setCep(event.target.value)} + fullWidth + sx={fieldSx} + /> + + setLogradouro(event.target.value)} + fullWidth + sx={fieldSx} + /> + + setNumero(event.target.value)} + fullWidth + sx={fieldSx} + /> + + setBairro(event.target.value)} + fullWidth + sx={fieldSx} + /> + + setCidade(event.target.value)} + fullWidth + sx={fieldSx} + /> + + setEstado(event.target.value)} + inputProps={{ maxLength: 2 }} + fullWidth + sx={fieldSx} + /> + + + + + + + + + + + + + + Resumo + + + + Prévia rápida do cliente antes de salvar. + + + + + + + + Nome + + + {nome || 'Sem nome'} + + + + + + CPF/CNPJ + + + {cpfCnpj || '-'} + + + + + + Tipo + + + {pessoafisica === 'J' ? 'Pessoa jurídica' : 'Pessoa física'} + + + + + + Status + + + {Number(habilitado) === 1 ? 'Habilitado' : 'Desabilitado'} + + + + + + Local + + + {[cidade, estado].filter(Boolean).join(' / ') || '-'} + + + + + + + ); +} \ No newline at end of file diff --git a/financeiro-web/src/features/clientes/pages/ClientesPage.tsx b/financeiro-web/src/features/clientes/pages/ClientesPage.tsx new file mode 100644 index 0000000..60b9af2 --- /dev/null +++ b/financeiro-web/src/features/clientes/pages/ClientesPage.tsx @@ -0,0 +1,787 @@ +import { useEffect, useMemo, useState } from 'react'; +import { + Alert, + Box, + Button, + Card, + CardContent, + Chip, + CircularProgress, + Divider, + IconButton, + MenuItem, + Pagination, + Paper, + Stack, + Switch, + TextField, + Tooltip, + Typography, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + useMediaQuery, + useTheme, +} from '@mui/material'; +import AddIcon from '@mui/icons-material/Add'; +import DeleteIcon from '@mui/icons-material/Delete'; +import EditIcon from '@mui/icons-material/Edit'; +import FilterAltIcon from '@mui/icons-material/FilterAlt'; +import PersonIcon from '@mui/icons-material/Person'; +import RestartAltIcon from '@mui/icons-material/RestartAlt'; +import SearchIcon from '@mui/icons-material/Search'; +import { Link as RouterLink } from 'react-router-dom'; +import { + alterarHabilitadoCliente, + deletarCliente, + listarClientes, + type ListarClientesParams, +} from '../services/clientesService'; +import type { + Cliente, + ClientesPagination, +} from '../types/clienteTypes'; + +const LIMITE_PADRAO = 20; + +function formatarData(data: string | null) { + if (!data) return '-'; + + return new Date(`${String(data).slice(0, 10)}T00:00:00`).toLocaleDateString('pt-BR'); +} + +function pessoaLabel(pessoafisica: string | null) { + if (!pessoafisica) return '-'; + + const valor = String(pessoafisica).toUpperCase(); + + if (valor === 'F' || valor === 'S') return 'Pessoa física'; + if (valor === 'J' || valor === 'N') return 'Pessoa jurídica'; + + return valor; +} + +function pessoaColor(pessoafisica: string | null) { + const valor = String(pessoafisica || '').toUpperCase(); + + if (valor === 'F' || valor === 'S') return 'success'; + if (valor === 'J' || valor === 'N') return 'info'; + + return 'default'; +} + +function statusClienteLabel(habilitado: number) { + return Number(habilitado) === 1 ? 'Habilitado' : 'Desabilitado'; +} + +function statusClienteColor(habilitado: number) { + return Number(habilitado) === 1 ? 'success' : 'default'; +} + +function enderecoResumo(item: Cliente) { + const partes = [ + item.cidade, + item.estado, + ].filter(Boolean); + + return partes.length > 0 ? partes.join(' / ') : '-'; +} + +export function ClientesPage() { + const [clientes, setClientes] = useState([]); + const [pagination, setPagination] = useState({ + total: 0, + limite: LIMITE_PADRAO, + offset: 0, + page: 1, + totalPages: 1, + }); + + const [alterandoStatusId, setAlterandoStatusId] = useState(null); + + const [loading, setLoading] = useState(true); + const [erro, setErro] = useState(''); + const [sucesso, setSucesso] = useState(''); + + const [page, setPage] = useState(1); + const [busca, setBusca] = useState(''); + const [pessoafisica, setPessoafisica] = useState(''); + const [habilitado, setHabilitado] = useState(''); + const [cidade, setCidade] = useState(''); + const [estado, setEstado] = useState(''); + const [orderBy, setOrderBy] = useState('nome'); + const [orderDirection, setOrderDirection] = useState<'ASC' | 'DESC'>('ASC'); + + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down('md')); + + const filtrosAtivos = useMemo(() => { + let count = 0; + + if (busca.trim()) count += 1; + if (pessoafisica) count += 1; + if (habilitado !== '') count += 1; + if (cidade.trim()) count += 1; + if (estado.trim()) count += 1; + + return count; + }, [busca, pessoafisica, habilitado, cidade, estado]); + + async function carregarClientes(pageToLoad = page) { + try { + setLoading(true); + setErro(''); + + const params: ListarClientesParams = { + limite: LIMITE_PADRAO, + page: pageToLoad, + busca: busca.trim() || undefined, + pessoafisica: pessoafisica || undefined, + habilitado, + cidade: cidade.trim() || undefined, + estado: estado.trim() || undefined, + orderBy, + orderDirection, + }; + + const response = await listarClientes(params); + + setClientes(response.data); + setPagination(response.pagination); + setPage(response.pagination.page); + } catch (error: any) { + const message = + error?.response?.data?.message || + 'Não foi possível carregar os clientes.'; + + setErro(message); + } finally { + setLoading(false); + } + } + + function aplicarFiltros() { + setPage(1); + carregarClientes(1); + } + + function limparFiltros() { + setBusca(''); + setPessoafisica(''); + setHabilitado(''); + setCidade(''); + setEstado(''); + setOrderBy('nome'); + setOrderDirection('ASC'); + + setTimeout(() => { + setPage(1); + carregarClientes(1); + }, 0); + } + + async function handleDeletarCliente(item: Cliente) { + const confirmou = window.confirm( + `Deseja realmente excluir "${item.nome}"?` + ); + + if (!confirmou) return; + + try { + setLoading(true); + setErro(''); + setSucesso(''); + + await deletarCliente(item.idclientes); + + setSucesso('Cliente excluído com sucesso.'); + await carregarClientes(page); + } catch (error: any) { + const message = + error?.response?.data?.message || + 'Não foi possível excluir o cliente.'; + + setErro(message); + } finally { + setLoading(false); + } + } + + async function handleAlterarStatusCliente(item: Cliente) { + const novoStatus = Number(item.habilitado) === 1 ? 0 : 1; + + try { + setAlterandoStatusId(item.idclientes); + setErro(''); + setSucesso(''); + + const clienteAtualizado = await alterarHabilitadoCliente( + item.idclientes, + novoStatus + ); + + setClientes((listaAtual) => + listaAtual.map((cliente) => + cliente.idclientes === item.idclientes ? clienteAtualizado : cliente + ) + ); + + setSucesso( + novoStatus === 1 + ? 'Cliente habilitado com sucesso.' + : 'Cliente desabilitado com sucesso.' + ); + } catch (error: any) { + const message = + error?.response?.data?.message || + 'Não foi possível alterar o status do cliente.'; + + setErro(message); + } finally { + setAlterandoStatusId(null); + } + } + + useEffect(() => { + carregarClientes(1); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + + + + } + label="Cadastro de clientes" + color="primary" + variant="outlined" + sx={{ marginBottom: 1.25, fontWeight: 700 }} + /> + + + Clientes + + + + Consulte, filtre e gerencie os clientes usados nos movimentos. + + + + + + + {erro && ( + + {erro} + + )} + + {sucesso && ( + + {sucesso} + + )} + + + + + + Clientes encontrados + + + {pagination.total} + + + + + + + + Página atual + + + {pagination.page} / {pagination.totalPages} + + + + + + + + Exibindo + + + {clientes.length} + + + + + + + + + + + + Filtros + + + + + + + setBusca(event.target.value)} + placeholder="Nome, CPF/CNPJ, celular, e-mail..." + fullWidth + /> + + setPessoafisica(event.target.value)} + fullWidth + > + Todos + Pessoa física + Pessoa jurídica + + + + setHabilitado(event.target.value === '' ? '' : Number(event.target.value)) + } + fullWidth + > + Todos + Habilitados + Desabilitados + + + setCidade(event.target.value)} + fullWidth + /> + + setEstado(event.target.value)} + fullWidth + /> + + setOrderBy(event.target.value)} + fullWidth + > + Nome + CPF/CNPJ + E-mail + Cidade + Estado + Tipo + Status + Cadastro + Atualização + + + + setOrderDirection(event.target.value as 'ASC' | 'DESC') + } + fullWidth + > + Crescente + Decrescente + + + + + + + + + + + + + + {loading ? ( + + + Carregando clientes... + + ) : clientes.length === 0 ? ( + + + Nenhum cliente encontrado. + + + Ajuste os filtros ou cadastre um novo cliente. + + + ) : isMobile ? ( + }> + {clientes.map((item) => ( + + + + + + {item.nome} + + + + {item.cpf_cnpj || '-'} + + + + + + + + + + + + Celular: {item.celular || '-'} + + + + E-mail: {item.email || '-'} + + + + Local: {enderecoResumo(item)} + + + + + + handleAlterarStatusCliente(item)} + /> + + + + + + + + + + ))} + + ) : ( + + + + + Nome + CPF/CNPJ + Tipo + Celular + E-mail + Cidade + Estado + Status + Cadastro + Atualização + Ações + + + + + {clientes.map((item) => ( + + + + + {item.nome} + + + + + {item.cpf_cnpj || '-'} + + + + + + {item.celular || '-'} + + + + + {item.email || '-'} + + + + + {item.cidade || '-'} + + {item.estado || '-'} + + + + + + {formatarData(item.insert_date)} + + {formatarData(item.update_date)} + + + + + + handleAlterarStatusCliente(item)} + /> + + + + + + + + + + + handleDeletarCliente(item)} + > + + + + + + + ))} + +
+
+ )} +
+
+ + {pagination.totalPages > 1 && ( + + { + setPage(novaPagina); + carregarClientes(novaPagina); + }} + /> + + )} + + + + Exibindo página {pagination.page} de {pagination.totalPages}, total de{' '} + {pagination.total} registro(s). + + +
+ ); +} \ No newline at end of file diff --git a/financeiro-web/src/features/clientes/pages/EditarClientePage.tsx b/financeiro-web/src/features/clientes/pages/EditarClientePage.tsx new file mode 100644 index 0000000..478a478 --- /dev/null +++ b/financeiro-web/src/features/clientes/pages/EditarClientePage.tsx @@ -0,0 +1,62 @@ +import { useEffect, useState } from 'react'; +import { Alert, Box, CircularProgress, Typography } from '@mui/material'; +import { useParams } from 'react-router-dom'; +import { ClienteForm } from '../components/ClienteForm'; +import { buscarClientePorId } from '../services/clientesService'; +import type { Cliente } from '../types/clienteTypes'; + +export function EditarClientePage() { + const { id } = useParams(); + + const [cliente, setCliente] = useState(null); + const [loading, setLoading] = useState(true); + const [erro, setErro] = useState(''); + + useEffect(() => { + async function carregarCliente() { + try { + setLoading(true); + setErro(''); + + const clienteId = Number(id); + + if (!clienteId) { + setErro('ID do cliente inválido.'); + return; + } + + const data = await buscarClientePorId(clienteId); + setCliente(data); + } catch (error: any) { + const message = + error?.response?.data?.message || + 'Não foi possível carregar o cliente.'; + + setErro(message); + } finally { + setLoading(false); + } + } + + carregarCliente(); + }, [id]); + + if (loading) { + return ( + + + Carregando cliente... + + ); + } + + if (erro) { + return ( + + {erro} + + ); + } + + return ; +} \ No newline at end of file diff --git a/financeiro-web/src/features/clientes/pages/NovoClientePage.tsx b/financeiro-web/src/features/clientes/pages/NovoClientePage.tsx new file mode 100644 index 0000000..c7375da --- /dev/null +++ b/financeiro-web/src/features/clientes/pages/NovoClientePage.tsx @@ -0,0 +1,5 @@ +import { ClienteForm } from '../components/ClienteForm'; + +export function NovoClientePage() { + return ; +} \ No newline at end of file diff --git a/financeiro-web/src/features/clientes/services/clientesService.ts b/financeiro-web/src/features/clientes/services/clientesService.ts new file mode 100644 index 0000000..c8b5a57 --- /dev/null +++ b/financeiro-web/src/features/clientes/services/clientesService.ts @@ -0,0 +1,68 @@ +import { api } from '../../../services/api'; +import type { + ApiResponse, + AtualizarClienteRequest, + Cliente, + ClientesListResponse, + CriarClienteRequest, +} from '../types/clienteTypes'; + +export type OrderDirection = 'ASC' | 'DESC'; + +export type ListarClientesParams = { + limite?: number; + offset?: number; + page?: number; + busca?: string; + pessoafisica?: string; + habilitado?: number | ''; + cidade?: string; + estado?: string; + orderBy?: string; + orderDirection?: OrderDirection; +}; + +export async function listarClientes( + params?: ListarClientesParams +): Promise { + const response = await api.get('/clientes', { + params, + }); + + return response.data; +} + +export async function buscarClientePorId(id: number): Promise { + const response = await api.get>(`/clientes/${id}`); + return response.data.data; +} + +export async function criarCliente( + data: CriarClienteRequest +): Promise { + const response = await api.post>('/clientes', data); + return response.data.data; +} + +export async function atualizarCliente( + id: number, + data: AtualizarClienteRequest +): Promise { + const response = await api.put>(`/clientes/${id}`, data); + return response.data.data; +} + +export async function alterarHabilitadoCliente( + id: number, + habilitado: number +): Promise { + const response = await api.patch>(`/clientes/${id}/habilitado`, { + habilitado, + }); + + return response.data.data; +} + +export async function deletarCliente(id: number): Promise { + await api.delete(`/clientes/${id}`); +} \ No newline at end of file diff --git a/financeiro-web/src/features/clientes/types/clienteTypes.ts b/financeiro-web/src/features/clientes/types/clienteTypes.ts new file mode 100644 index 0000000..4908233 --- /dev/null +++ b/financeiro-web/src/features/clientes/types/clienteTypes.ts @@ -0,0 +1,58 @@ +export type Cliente = { + idclientes: number; + cpf_cnpj: string; + nome: string; + rg: string | null; + celular: string | null; + email: string | null; + cep: string | null; + logradouro: string | null; + numero: string | null; + bairro: string | null; + cidade: string | null; + estado: string | null; + sexo: string | null; + pessoafisica: string | null; + habilitado: number; + insert_date: string | null; + update_date: string | null; +}; + +export type CriarClienteRequest = { + cpf_cnpj: string; + nome: string; + rg: string | null; + celular: string | null; + email: string | null; + cep: string | null; + logradouro: string | null; + numero: string | null; + bairro: string | null; + cidade: string | null; + estado: string | null; + sexo: string | null; + pessoafisica: string | null; + habilitado: number; +}; + +export type AtualizarClienteRequest = CriarClienteRequest; + +export type ClientesPagination = { + total: number; + limite: number; + offset: number; + page: number; + totalPages: number; +}; + +export type ApiResponse = { + ok: boolean; + message?: string; + data: T; +}; + +export type ClientesListResponse = { + ok: boolean; + data: Cliente[]; + pagination: ClientesPagination; +}; \ No newline at end of file diff --git a/financeiro-web/src/features/movimentos/components/MovimentoForm.tsx b/financeiro-web/src/features/movimentos/components/MovimentoForm.tsx index b520ea1..06f9fc1 100644 --- a/financeiro-web/src/features/movimentos/components/MovimentoForm.tsx +++ b/financeiro-web/src/features/movimentos/components/MovimentoForm.tsx @@ -9,8 +9,11 @@ import { Chip, CircularProgress, Divider, + FormControlLabel, MenuItem, Paper, + Radio, + RadioGroup, Stack, TextField, Typography, @@ -32,10 +35,13 @@ import type { import { atualizarMovimento, criarMovimento, + preverImpactoSaldoMovimento, } from '../services/movimentosService'; import type { CriarMovimentoRequest, + ModoParcelamento, Movimento, + MovimentoImpactoSaldo, MovimentoStatus, MovimentoTipo, } from '../types/movimentoTypes'; @@ -74,6 +80,18 @@ function formatarValorResumo(value: string) { }).format(numero); } +function extrairCompetencia(data: string) { + if (!data) return null; + return data.slice(0, 7); +} + +function formatarValorMoeda(valor: number) { + return new Intl.NumberFormat('pt-BR', { + style: 'currency', + currency: 'BRL', + }).format(Number(valor || 0)); +} + function FormSection({ title, description, children }: FormSectionProps) { return ( ('valor_parcela'); + + const [gerarParcelas, setGerarParcelas] = useState(true); + const [impactoSaldo, setImpactoSaldo] = useState([]); + const [loadingImpacto, setLoadingImpacto] = useState(false); + const titulo = isEdit ? 'Editar movimento' : 'Novo movimento'; const subtitulo = isEdit ? 'Atualize os dados do lançamento selecionado.' : 'Cadastre entradas, saídas, sangrias e estornos direto no financeiro.'; + const temParcelamento = Number(parcelas || 1) > 1; + const statusDisponiveis = useMemo(() => { if (movimento === 'Entrada') return ['Recebido', 'A receber']; if (movimento === 'Saida') return ['Pago', 'A pagar']; @@ -200,7 +226,13 @@ export function MovimentoForm({ mode, initialData }: MovimentoFormProps) { setStatus(statusDisponiveis[0]); } - if (movimento !== 'Sangria' && referenciaTipo === 'banco') { + if (movimento === 'Sangria') { + setReferenciaTipo('banco'); + setStatus('Pago'); + return; + } + + if (referenciaTipo === 'banco') { setReferenciaTipo('nenhum'); setIdBancoReferencia(''); } @@ -235,6 +267,25 @@ export function MovimentoForm({ mode, initialData }: MovimentoFormProps) { carregarReferencias(); }, []); + useEffect(() => { + const timer = window.setTimeout(() => { + carregarImpactoSaldo(); + }, 450); + + return () => window.clearTimeout(timer); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + movimento, + status, + valor, + parcelas, + parcela, + idBanco, + idBancoReferencia, + dataVencimento, + dataBaixa, + ]); + function limparFormulario() { setDescricao(''); setValor(''); @@ -264,14 +315,31 @@ export function MovimentoForm({ mode, initialData }: MovimentoFormProps) { return; } + if (!idBanco) { + setErro('Selecione o banco/carteira.'); + return; + } + + if (Number(parcela || 1) > Number(parcelas || 1)) { + setErro('A parcela atual não pode ser maior que o total de parcelas.'); + return; + } + if (referenciaTipo === 'cliente' && !idCliente) { setErro('Selecione o cliente de referência.'); return; } - if (referenciaTipo === 'banco' && !idBancoReferencia) { - setErro('Selecione o banco de referência.'); - return; + if (movimento === 'Sangria') { + if (referenciaTipo !== 'banco' || !idBancoReferencia) { + setErro('Selecione o banco de destino da sangria.'); + return; + } + + if (idBanco === idBancoReferencia) { + setErro('Banco de origem e destino não podem ser iguais.'); + return; + } } try { @@ -279,37 +347,82 @@ export function MovimentoForm({ mode, initialData }: MovimentoFormProps) { setErro(''); setSucesso(''); - const payload: CriarMovimentoRequest = { - movimento, - descricao: descricao.trim(), - valor: Number(valor), - parcela: Number(parcela || 1), - parcelas: Number(parcelas || 1), - status, - dataentrada: dataEntrada, - datavencimento: dataVencimento || null, - databaixa: status === 'Pago' || status === 'Recebido' ? dataBaixa || null : null, - idcentrodecustos: toNumberOrNull(idCentroCusto), - idbancos: toNumberOrNull(idBanco), - idusuarios_baixa: null, - idclientes: referenciaTipo === 'cliente' ? toNumberOrNull(idCliente) : null, - idveiculosdetalhes: null, - idbancos_p: referenciaTipo === 'banco' ? toNumberOrNull(idBancoReferencia) : null, - observacao: observacao.trim() || null, - }; + let payload = montarPayload(false); + + try { + if (isEdit) { + if (!initialData?.idcontasapagar) { + setErro('Movimento inválido para edição.'); + return; + } + + const response = await atualizarMovimento(initialData.idcontasapagar, payload); + + setSucesso( + `Movimento atualizado com sucesso. Impactos de saldo: ${ + response.impactoSaldo?.length || 0 + }.` + ); + + setImpactoSaldo(response.impactoSaldo || []); + } else { + const response = await criarMovimento(payload); + + setSucesso( + `Movimento cadastrado com sucesso. Parcelas geradas: ${ + response.parcelasGeradas?.length || 0 + }. Impactos de saldo: ${response.impactoSaldo?.length || 0}.` + ); + + setImpactoSaldo(response.impactoSaldo || []); + limparFormulario(); + } + } catch (error: any) { + if (error?.response?.status === 409 && error?.response?.data?.requiresConfirmation) { + const duplicado = error.response.data.data; + + const confirmou = window.confirm( + `Já existe um movimento parecido:\n\n` + + `ID: ${duplicado?.idcontasapagar}\n` + + `Descrição: ${duplicado?.descricao}\n` + + `Status: ${duplicado?.status}\n\n` + + `Deseja cadastrar mesmo assim?` + ); + + if (!confirmou) { + setErro('Cadastro cancelado para evitar duplicidade.'); + return; + } + + payload = montarPayload(true); + + if (isEdit) { + if (!initialData?.idcontasapagar) { + setErro('Movimento inválido para edição.'); + return; + } + + const response = await atualizarMovimento(initialData.idcontasapagar, payload); + + setSucesso('Movimento atualizado com sucesso.'); + setImpactoSaldo(response.impactoSaldo || []); + } else { + const response = await criarMovimento(payload); + + setSucesso( + `Movimento cadastrado com sucesso. Parcelas geradas: ${ + response.parcelasGeradas?.length || 0 + }.` + ); + + setImpactoSaldo(response.impactoSaldo || []); + limparFormulario(); + } - if (isEdit) { - if (!initialData?.idcontasapagar) { - setErro('Movimento inválido para edição.'); return; } - await atualizarMovimento(initialData.idcontasapagar, payload); - setSucesso('Movimento atualizado com sucesso.'); - } else { - await criarMovimento(payload); - setSucesso('Movimento cadastrado com sucesso.'); - limparFormulario(); + throw error; } } catch (error: any) { const message = @@ -322,6 +435,62 @@ export function MovimentoForm({ mode, initialData }: MovimentoFormProps) { } } + function montarPayload(confirmarDuplicado = false): CriarMovimentoRequest { + return { + movimento, + descricao: descricao.trim(), + valor: Number(valor), + parcela: Number(parcela || 1), + parcelas: Number(parcelas || 1), + status, + dataentrada: dataEntrada, + datavencimento: dataVencimento || null, + databaixa: status === 'Pago' || status === 'Recebido' ? dataBaixa || null : null, + idcentrodecustos: toNumberOrNull(idCentroCusto), + idbancos: toNumberOrNull(idBanco), + idusuarios_baixa: null, + idclientes: referenciaTipo === 'cliente' ? toNumberOrNull(idCliente) : null, + idveiculosdetalhes: null, + idbancos_p: referenciaTipo === 'banco' ? toNumberOrNull(idBancoReferencia) : null, + + idmovimentosfixos: null, + competencia: extrairCompetencia(dataVencimento || dataEntrada), + grupo_parcelamento: initialData?.grupo_parcelamento || null, + origem: initialData?.origem || 'manual', + saldo_processado: initialData?.saldo_processado || 0, + + observacao: observacao.trim() || null, + + gerarParcelas: temParcelamento ? gerarParcelas : false, + modoParcelamento: temParcelamento ? modoParcelamento : 'valor_parcela', + confirmarDuplicado, + }; + } + + async function carregarImpactoSaldo() { + if (!descricao.trim() || !valor || Number(valor) <= 0 || !idBanco) { + setImpactoSaldo([]); + return; + } + + try { + setLoadingImpacto(true); + + const payload = montarPayload(true); + + const data = await preverImpactoSaldoMovimento({ + ...payload, + idcontasapagar: initialData?.idcontasapagar, + }); + + setImpactoSaldo(data); + } catch { + setImpactoSaldo([]); + } finally { + setLoadingImpacto(false); + } + } + return ( + {temParcelamento && ( + + + Como tratar o valor informado? + + + + setModoParcelamento(event.target.value as ModoParcelamento) + } + > + } + label="Valor de cada parcela" + /> + + } + label="Valor total, dividir entre parcelas" + /> + + + setGerarParcelas(!gerarParcelas)} + /> + } + label="Gerar parcelas automaticamente" + /> + + )} + + + + + + + Impacto previsto no saldo + + + {loadingImpacto ? ( + + + Calculando... + + ) : impactoSaldo.length === 0 ? ( + + Sem impacto imediato + + ) : ( + + {impactoSaldo.map((impacto, index) => ( + + + {impacto.banco_descricao || `Banco ${impacto.idbancos}`} + + + = 0 ? 'success.main' : 'error.main'} + > + {impacto.valor_delta >= 0 ? '+' : ''} + {formatarValorMoeda(impacto.valor_delta)} + + + {impacto.saldo_anterior !== undefined && ( + + {formatarValorMoeda(impacto.saldo_anterior)} →{' '} + {formatarValorMoeda(impacto.saldo_posterior || 0)} + + )} + + ))} + + )} + + + {temParcelamento && ( + + + Parcelamento + + + {parcelas}x ·{' '} + {modoParcelamento === 'valor_total' + ? 'dividindo valor total' + : 'valor por parcela'} + + + )} diff --git a/financeiro-web/src/features/movimentos/pages/MovimentosPage.tsx b/financeiro-web/src/features/movimentos/pages/MovimentosPage.tsx index 2a1934a..aba8259 100644 --- a/financeiro-web/src/features/movimentos/pages/MovimentosPage.tsx +++ b/financeiro-web/src/features/movimentos/pages/MovimentosPage.tsx @@ -31,8 +31,10 @@ import FilterAltIcon from '@mui/icons-material/FilterAlt'; 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 { Link as RouterLink } from 'react-router-dom'; import { + deletarMovimento, listarMovimentos, type DataCampo, type ListarMovimentosParams, @@ -122,6 +124,8 @@ function getReferencia(item: Movimento) { } export function MovimentosPage() { + const [sucesso, setSucesso] = useState(''); + const [movimentos, setMovimentos] = useState([]); const [pagination, setPagination] = useState({ total: 0, @@ -136,6 +140,9 @@ export function MovimentosPage() { totalSaidas: 0, totalSangrias: 0, totalEstornos: 0, + totalBaixado: 0, + totalAberto: 0, + saldoProcessado: 0, saldo: 0, }); @@ -158,6 +165,10 @@ export function MovimentosPage() { const [idCentroCusto, setIdCentroCusto] = useState(''); const [idCliente, setIdCliente] = useState(''); const [referenciaTipo, setReferenciaTipo] = useState(''); + const [competencia, setCompetencia] = useState(''); + const [origem, setOrigem] = useState(''); + const [saldoProcessado, setSaldoProcessado] = useState(''); + const [incluirExcluidos, setIncluirExcluidos] = useState(''); const theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.down('md')); @@ -173,6 +184,10 @@ export function MovimentosPage() { if (idCliente) count += 1; if (referenciaTipo) count += 1; if (dataInicio || dataFim) count += 1; + if (competencia.trim()) count += 1; + if (origem) count += 1; + if (saldoProcessado !== '') count += 1; + if (incluirExcluidos !== '') count += 1; return count; }, [ @@ -185,6 +200,10 @@ export function MovimentosPage() { referenciaTipo, dataInicio, dataFim, + competencia, + origem, + saldoProcessado, + incluirExcluidos, ]); async function carregarReferencias() { @@ -223,6 +242,10 @@ export function MovimentosPage() { idcentrodecustos: idCentroCusto || undefined, idclientes: idCliente || undefined, referenciaTipo: referenciaTipo || undefined, + competencia: competencia.trim() || undefined, + origem: origem || undefined, + saldo_processado: saldoProcessado, + incluirExcluidos, orderBy: dataCampo, orderDirection: 'DESC', }; @@ -260,6 +283,10 @@ export function MovimentosPage() { setIdCentroCusto(''); setIdCliente(''); setReferenciaTipo(''); + setCompetencia(''); + setOrigem(''); + setSaldoProcessado(''); + setIncluirExcluidos(''); setTimeout(() => { setPage(1); @@ -267,6 +294,69 @@ export function MovimentosPage() { }, 0); } + function origemLabel(origem: string | null | undefined) { + if (!origem) return 'Manual'; + + const labels: Record = { + manual: 'Manual', + parcelamento: 'Parcelamento', + movimento_fixo: 'Movimento fixo', + quitacao: 'Quitação', + ajuste_saldo: 'Ajuste de saldo', + }; + + return labels[origem] || origem; + } + + function origemChipColor(origem: string | null | undefined) { + if (origem === 'movimento_fixo') return 'info'; + if (origem === 'parcelamento') return 'secondary'; + if (origem === 'quitacao') return 'success'; + if (origem === 'ajuste_saldo') return 'warning'; + + return 'default'; + } + + function saldoProcessadoLabel(valor: number | null | undefined) { + return Number(valor) === 1 ? 'Saldo processado' : 'Sem impacto'; + } + + function saldoProcessadoColor(valor: number | null | undefined) { + 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` + + 'Se o movimento já impactou saldo, a API vai reverter automaticamente.' + ); + + if (!confirmou) return; + + try { + setLoading(true); + setErro(''); + setSucesso(''); + + await deletarMovimento(item.idcontasapagar); + + setSucesso('Movimento excluído com sucesso. O saldo foi revertido se necessário.'); + await carregarMovimentos(page); + } catch (error: any) { + const message = + error?.response?.data?.message || + 'Não foi possível excluir o movimento.'; + + setErro(message); + } finally { + setLoading(false); + } + } + useEffect(() => { carregarReferencias(); carregarMovimentos(1); @@ -322,11 +412,17 @@ export function MovimentosPage() { )} + {sucesso && ( + + {sucesso} + + )} + + + + + + Em aberto + + + {formatarValor(summary.totalAberto)} + + + + + + + + Baixados + + + {formatarValor(summary.totalBaixado)} + + + @@ -428,6 +546,7 @@ export function MovimentosPage() { Data de baixa Data de cadastro Data de atualização + Data de exclusão Com banco referência Sem referência + + setCompetencia(event.target.value)} + InputLabelProps={{ shrink: true }} + fullWidth + /> + + setOrigem(event.target.value)} + fullWidth + > + Todas + Manual + Parcelamento + Movimento fixo + Quitação + Ajuste de saldo + + + + setSaldoProcessado(event.target.value === '' ? '' : Number(event.target.value)) + } + fullWidth + > + Todos + Sim + Não + + + + setIncluirExcluidos(event.target.value === '' ? '' : Number(event.target.value)) + } + fullWidth + > + Somente ativos + Incluir excluídos + + + + + + + {movimentoExcluido(item) && ( + + )} @@ -664,6 +857,14 @@ export function MovimentosPage() { Referência: {getReferencia(item)} + + Competência: {item.competencia || '-'} + + + + Origem: {origemLabel(item.origem)} + + + + + {erro && ( + + {erro} + + )} + + {sucesso && ( + + {sucesso} + + )} + + + + + {loadingRefs ? ( + + + Carregando dados... + + ) : ( + + + setMovimento(event.target.value as MovimentoFixoTipo)} + fullWidth + sx={fieldSx} + > + Entrada + Saída + Sangria + Estorno + + + setHabilitado(event.target.value)} + fullWidth + sx={fieldSx} + > + Habilitado + Desabilitado + + + setDescricao(event.target.value)} + placeholder="Ex: Internet, aluguel, energia..." + fullWidth + sx={fieldSx} + /> + + setValor(event.target.value)} + inputProps={{ + step: '0.01', + min: '0', + }} + fullWidth + sx={fieldSx} + /> + + setDiaVencimento(event.target.value)} + inputProps={{ + min: '1', + max: '31', + }} + fullWidth + sx={fieldSx} + /> + + + + setIdBanco(event.target.value)} + fullWidth + sx={fieldSx} + > + Nenhum + {bancos.map((banco) => ( + + {banco.descricao} + + ))} + + + setIdCentroCusto(event.target.value)} + fullWidth + sx={fieldSx} + > + Nenhum + {centrosCusto.map((centro) => ( + + {centro.descricao} + + ))} + + + + + + + + + + )} + + + + + + Resumo + + + + Prévia da recorrência antes de salvar. + + + + + + + + Movimento + + + {movimentoLabel(movimento)} + + + + + + Tipo + + + {movimentoDescricao} + + + + + + Valor + + + {formatarValorResumo(valor)} + + + + + + Vencimento + + + Todo dia {diaVencimento || '-'} + + + + + + Status + + + {Number(habilitado) === 1 ? 'Habilitado' : 'Desabilitado'} + + + + + + + ); +} \ No newline at end of file diff --git a/financeiro-web/src/features/movimentosFixos/pages/EditarMovimentoFixoPage.tsx b/financeiro-web/src/features/movimentosFixos/pages/EditarMovimentoFixoPage.tsx new file mode 100644 index 0000000..e5911db --- /dev/null +++ b/financeiro-web/src/features/movimentosFixos/pages/EditarMovimentoFixoPage.tsx @@ -0,0 +1,62 @@ +import { useEffect, useState } from 'react'; +import { Alert, Box, CircularProgress, Typography } from '@mui/material'; +import { useParams } from 'react-router-dom'; +import { MovimentoFixoForm } from '../components/MovimentoFixoForm'; +import { buscarMovimentoFixoPorId } from '../services/movimentosFixosService'; +import type { MovimentoFixo } from '../types/movimentoFixoTypes'; + +export function EditarMovimentoFixoPage() { + const { id } = useParams(); + + const [movimentoFixo, setMovimentoFixo] = useState(null); + const [loading, setLoading] = useState(true); + const [erro, setErro] = useState(''); + + useEffect(() => { + async function carregarMovimentoFixo() { + try { + setLoading(true); + setErro(''); + + const movimentoFixoId = Number(id); + + if (!movimentoFixoId) { + setErro('ID do movimento fixo inválido.'); + return; + } + + const data = await buscarMovimentoFixoPorId(movimentoFixoId); + setMovimentoFixo(data); + } catch (error: any) { + const message = + error?.response?.data?.message || + 'Não foi possível carregar o movimento fixo.'; + + setErro(message); + } finally { + setLoading(false); + } + } + + carregarMovimentoFixo(); + }, [id]); + + if (loading) { + return ( + + + Carregando movimento fixo... + + ); + } + + if (erro) { + return ( + + {erro} + + ); + } + + return ; +} \ No newline at end of file diff --git a/financeiro-web/src/features/movimentosFixos/pages/MovimentosFixosPage.tsx b/financeiro-web/src/features/movimentosFixos/pages/MovimentosFixosPage.tsx new file mode 100644 index 0000000..8af8d6e --- /dev/null +++ b/financeiro-web/src/features/movimentosFixos/pages/MovimentosFixosPage.tsx @@ -0,0 +1,920 @@ +import { useEffect, useMemo, useState } from 'react'; +import { + Alert, + Box, + Button, + Card, + CardContent, + Chip, + CircularProgress, + Divider, + IconButton, + MenuItem, + Pagination, + Paper, + Stack, + Switch, + TextField, + Tooltip, + Typography, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + useMediaQuery, + useTheme, +} from '@mui/material'; +import AddIcon from '@mui/icons-material/Add'; +import DeleteIcon from '@mui/icons-material/Delete'; +import EditIcon from '@mui/icons-material/Edit'; +import EventRepeatIcon from '@mui/icons-material/EventRepeat'; +import FilterAltIcon from '@mui/icons-material/FilterAlt'; +import RestartAltIcon from '@mui/icons-material/RestartAlt'; +import SearchIcon from '@mui/icons-material/Search'; +import { Link as RouterLink } from 'react-router-dom'; +import { + alterarHabilitadoMovimentoFixo, + deletarMovimentoFixo, + listarMovimentosFixos, + type ListarMovimentosFixosParams, +} from '../services/movimentosFixosService'; +import type { + MovimentoFixo, + MovimentoFixoTipo, + MovimentosFixosPagination, + MovimentosFixosSummary, +} from '../types/movimentoFixoTypes'; +import { + listarBancos, + listarCentrosCusto, +} from '../../referencias/services/referenciasService'; +import type { + Banco, + CentroCusto, +} from '../../referencias/types/referenciasTypes'; + +const LIMITE_PADRAO = 20; + +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 movimentoChipColor(movimento: string) { + if (movimento === 'Entrada') return 'success'; + if (movimento === 'Saida') return 'error'; + if (movimento === 'Sangria') return 'warning'; + + return 'default'; +} + +function statusLabel(habilitado: number) { + return Number(habilitado) === 1 ? 'Habilitado' : 'Desabilitado'; +} + +function statusColor(habilitado: number) { + return Number(habilitado) === 1 ? 'success' : 'default'; +} + +export function MovimentosFixosPage() { + const [movimentosFixos, setMovimentosFixos] = useState([]); + const [pagination, setPagination] = useState({ + total: 0, + limite: LIMITE_PADRAO, + offset: 0, + page: 1, + totalPages: 1, + }); + const [summary, setSummary] = useState({ + quantidade: 0, + valorTotal: 0, + totalEntradas: 0, + totalSaidas: 0, + totalSangrias: 0, + totalEstornos: 0, + habilitados: 0, + desabilitados: 0, + }); + + const [bancos, setBancos] = useState([]); + const [centrosCusto, setCentrosCusto] = useState([]); + + const [alterandoStatusId, setAlterandoStatusId] = useState(null); + + const [loading, setLoading] = useState(true); + const [loadingRefs, setLoadingRefs] = useState(true); + const [erro, setErro] = useState(''); + const [sucesso, setSucesso] = useState(''); + + const [page, setPage] = useState(1); + const [busca, setBusca] = useState(''); + const [movimento, setMovimento] = useState(''); + const [idBanco, setIdBanco] = useState(''); + const [idCentroCusto, setIdCentroCusto] = useState(''); + const [habilitado, setHabilitado] = useState(''); + const [diaInicio, setDiaInicio] = useState(''); + const [diaFim, setDiaFim] = useState(''); + const [orderBy, setOrderBy] = useState('dia_vencimento'); + const [orderDirection, setOrderDirection] = useState<'ASC' | 'DESC'>('ASC'); + + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down('md')); + + const filtrosAtivos = useMemo(() => { + let count = 0; + + if (busca.trim()) count += 1; + if (movimento) count += 1; + if (idBanco) count += 1; + if (idCentroCusto) count += 1; + if (habilitado !== '') count += 1; + if (diaInicio !== '') count += 1; + if (diaFim !== '') count += 1; + + return count; + }, [ + busca, + movimento, + idBanco, + idCentroCusto, + habilitado, + diaInicio, + diaFim, + ]); + + async function carregarReferencias() { + try { + setLoadingRefs(true); + + const [bancosData, centrosData] = await Promise.all([ + listarBancos(), + listarCentrosCusto(), + ]); + + setBancos(bancosData); + setCentrosCusto(centrosData); + } finally { + setLoadingRefs(false); + } + } + + async function carregarMovimentosFixos(pageToLoad = page) { + try { + setLoading(true); + setErro(''); + + const params: ListarMovimentosFixosParams = { + limite: LIMITE_PADRAO, + page: pageToLoad, + busca: busca.trim() || undefined, + movimento: movimento || undefined, + idbancos: idBanco || undefined, + idcentrodecustos: idCentroCusto || undefined, + habilitado, + diaInicio, + diaFim, + orderBy, + orderDirection, + }; + + const response = await listarMovimentosFixos(params); + + setMovimentosFixos(response.data); + setPagination(response.pagination); + setSummary(response.summary); + setPage(response.pagination.page); + } catch (error: any) { + const message = + error?.response?.data?.message || + 'Não foi possível carregar os movimentos fixos.'; + + setErro(message); + } finally { + setLoading(false); + } + } + + function aplicarFiltros() { + setPage(1); + carregarMovimentosFixos(1); + } + + function limparFiltros() { + setBusca(''); + setMovimento(''); + setIdBanco(''); + setIdCentroCusto(''); + setHabilitado(''); + setDiaInicio(''); + setDiaFim(''); + setOrderBy('dia_vencimento'); + setOrderDirection('ASC'); + + setTimeout(() => { + setPage(1); + carregarMovimentosFixos(1); + }, 0); + } + + async function handleDeletarMovimentoFixo(item: MovimentoFixo) { + const confirmou = window.confirm( + `Deseja realmente excluir "${item.descricao}"?` + ); + + if (!confirmou) return; + + try { + setLoading(true); + setErro(''); + setSucesso(''); + + await deletarMovimentoFixo(item.idmovimentosfixos); + + setSucesso('Movimento fixo excluído com sucesso.'); + await carregarMovimentosFixos(page); + } catch (error: any) { + const message = + error?.response?.data?.message || + 'Não foi possível excluir o movimento fixo.'; + + setErro(message); + } finally { + setLoading(false); + } + } + + async function handleAlterarStatusMovimentoFixo(item: MovimentoFixo) { + const novoStatus = Number(item.habilitado) === 1 ? 0 : 1; + + try { + setAlterandoStatusId(item.idmovimentosfixos); + setErro(''); + setSucesso(''); + + const movimentoAtualizado = await alterarHabilitadoMovimentoFixo( + item.idmovimentosfixos, + novoStatus + ); + + setMovimentosFixos((listaAtual) => + listaAtual.map((movimentoFixo) => + movimentoFixo.idmovimentosfixos === item.idmovimentosfixos + ? movimentoAtualizado + : movimentoFixo + ) + ); + + setSucesso( + novoStatus === 1 + ? 'Movimento fixo habilitado com sucesso.' + : 'Movimento fixo desabilitado com sucesso.' + ); + } catch (error: any) { + const message = + error?.response?.data?.message || + 'Não foi possível alterar o status do movimento fixo.'; + + setErro(message); + } finally { + setAlterandoStatusId(null); + } + } + + useEffect(() => { + carregarReferencias(); + carregarMovimentosFixos(1); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + + + + } + label="Automação financeira" + color="primary" + variant="outlined" + sx={{ marginBottom: 1.25, fontWeight: 700 }} + /> + + + Movimentos fixos + + + + Cadastre lançamentos recorrentes para gerar movimentos automaticamente depois. + + + + + + + {erro && ( + + {erro} + + )} + + {sucesso && ( + + {sucesso} + + )} + + + + + + Movimentos encontrados + + + {summary.quantidade} + + + + + + + + Entradas fixas + + + {formatarValor(summary.totalEntradas)} + + + + + + + + Saídas fixas + + + {formatarValor(summary.totalSaidas + summary.totalSangrias)} + + + + + + + + Habilitados + + + {summary.habilitados} + + + + + + + + + + + + Filtros + + + + + + + setBusca(event.target.value)} + placeholder="Descrição, banco, centro..." + fullWidth + /> + + setMovimento(event.target.value as MovimentoFixoTipo | '')} + fullWidth + > + Todos + Entrada + Saída + Sangria + Estorno + + + + setIdBanco(event.target.value === '' ? '' : Number(event.target.value)) + } + disabled={loadingRefs} + fullWidth + > + Todos + {bancos.map((banco) => ( + + {banco.descricao} + + ))} + + + + setIdCentroCusto(event.target.value === '' ? '' : Number(event.target.value)) + } + disabled={loadingRefs} + fullWidth + > + Todos + {centrosCusto.map((centro) => ( + + {centro.descricao} + + ))} + + + + setHabilitado(event.target.value === '' ? '' : Number(event.target.value)) + } + fullWidth + > + Todos + Habilitados + Desabilitados + + + + setDiaInicio(event.target.value === '' ? '' : Number(event.target.value)) + } + inputProps={{ min: 1, max: 31 }} + fullWidth + /> + + + setDiaFim(event.target.value === '' ? '' : Number(event.target.value)) + } + inputProps={{ min: 1, max: 31 }} + fullWidth + /> + + setOrderBy(event.target.value)} + fullWidth + > + Dia vencimento + Descrição + Valor + Movimento + Banco + Centro + Status + Cadastro + Atualização + + + + setOrderDirection(event.target.value as 'ASC' | 'DESC') + } + fullWidth + > + Crescente + Decrescente + + + + + + + + + + + + + + {loading ? ( + + + Carregando movimentos fixos... + + ) : movimentosFixos.length === 0 ? ( + + + Nenhum movimento fixo encontrado. + + + Ajuste os filtros ou cadastre um novo movimento fixo. + + + ) : isMobile ? ( + }> + {movimentosFixos.map((item) => ( + + + + + + {item.descricao} + + + + Vence todo dia {item.dia_vencimento} + + + + + {formatarValor(item.valor)} + + + + + + + + + + + Banco: {item.banco_descricao || '-'} + + + + Centro: {item.centro_custo_descricao || '-'} + + + + + + handleAlterarStatusMovimentoFixo(item)} + /> + + + + + + + + + + ))} + + ) : ( + + + + + Descrição + Movimento + Valor + Dia venc. + Centro + Banco + Status + Cadastro + Atualização + Ações + + + + + {movimentosFixos.map((item) => ( + + + + + {item.descricao} + + + + + + + + + + + {formatarValor(item.valor)} + + + + + {item.dia_vencimento} + + + + + {item.centro_custo_descricao || '-'} + + + + + + {item.banco_descricao || '-'} + + + + + + + + + {formatarData(item.insert_date)} + + + + {formatarData(item.update_date)} + + + + + + + handleAlterarStatusMovimentoFixo(item)} + /> + + + + + + + + + + + handleDeletarMovimentoFixo(item)} + > + + + + + + + ))} + +
+
+ )} +
+
+ + {pagination.totalPages > 1 && ( + + { + setPage(novaPagina); + carregarMovimentosFixos(novaPagina); + }} + /> + + )} + + + + Exibindo página {pagination.page} de {pagination.totalPages}, total de{' '} + {pagination.total} registro(s). + + +
+ ); +} \ No newline at end of file diff --git a/financeiro-web/src/features/movimentosFixos/pages/NovoMovimentoFixoPage.tsx b/financeiro-web/src/features/movimentosFixos/pages/NovoMovimentoFixoPage.tsx new file mode 100644 index 0000000..3870f2b --- /dev/null +++ b/financeiro-web/src/features/movimentosFixos/pages/NovoMovimentoFixoPage.tsx @@ -0,0 +1,5 @@ +import { MovimentoFixoForm } from '../components/MovimentoFixoForm'; + +export function NovoMovimentoFixoPage() { + return ; +} \ No newline at end of file diff --git a/financeiro-web/src/features/movimentosFixos/services/movimentosFixosService.ts b/financeiro-web/src/features/movimentosFixos/services/movimentosFixosService.ts new file mode 100644 index 0000000..56f8690 --- /dev/null +++ b/financeiro-web/src/features/movimentosFixos/services/movimentosFixosService.ts @@ -0,0 +1,75 @@ +import { api } from '../../../services/api'; +import type { + ApiResponse, + AtualizarMovimentoFixoRequest, + CriarMovimentoFixoRequest, + MovimentoFixo, + MovimentosFixosListResponse, +} from '../types/movimentoFixoTypes'; + +export type OrderDirection = 'ASC' | 'DESC'; + +export type ListarMovimentosFixosParams = { + limite?: number; + offset?: number; + page?: number; + busca?: string; + movimento?: string; + idbancos?: number | ''; + idcentrodecustos?: number | ''; + habilitado?: number | ''; + diaInicio?: number | ''; + diaFim?: number | ''; + orderBy?: string; + orderDirection?: OrderDirection; +}; + +export async function listarMovimentosFixos( + params?: ListarMovimentosFixosParams +): Promise { + const response = await api.get('/movimentos-fixos', { + params, + }); + + return response.data; +} + +export async function buscarMovimentoFixoPorId(id: number): Promise { + const response = await api.get>(`/movimentos-fixos/${id}`); + return response.data.data; +} + +export async function criarMovimentoFixo( + data: CriarMovimentoFixoRequest +): Promise { + const response = await api.post>('/movimentos-fixos', data); + return response.data.data; +} + +export async function atualizarMovimentoFixo( + id: number, + data: AtualizarMovimentoFixoRequest +): Promise { + const response = await api.put>( + `/movimentos-fixos/${id}`, + data + ); + + return response.data.data; +} + +export async function alterarHabilitadoMovimentoFixo( + id: number, + habilitado: number +): Promise { + const response = await api.patch>( + `/movimentos-fixos/${id}/habilitado`, + { habilitado } + ); + + return response.data.data; +} + +export async function deletarMovimentoFixo(id: number): Promise { + await api.delete(`/movimentos-fixos/${id}`); +} \ No newline at end of file diff --git a/financeiro-web/src/features/movimentosFixos/types/MovimentoFixoTypes.ts b/financeiro-web/src/features/movimentosFixos/types/MovimentoFixoTypes.ts new file mode 100644 index 0000000..c67ff3d --- /dev/null +++ b/financeiro-web/src/features/movimentosFixos/types/MovimentoFixoTypes.ts @@ -0,0 +1,61 @@ +export type MovimentoFixoTipo = 'Entrada' | 'Saida' | 'Sangria' | 'Estorno'; + +export type MovimentoFixo = { + idmovimentosfixos: number; + movimento: MovimentoFixoTipo; + descricao: string; + valor: number; + dia_vencimento: number; + idcentrodecustos: number | null; + habilitado: number; + idbancos: number | null; + insert_date: string | null; + update_date: string | null; + + banco_descricao?: string | null; + centro_custo_descricao?: string | null; +}; + +export type CriarMovimentoFixoRequest = { + movimento: MovimentoFixoTipo; + descricao: string; + valor: number; + dia_vencimento: number; + idcentrodecustos: number | null; + habilitado: number; + idbancos: number | null; +}; + +export type AtualizarMovimentoFixoRequest = CriarMovimentoFixoRequest; + +export type MovimentosFixosPagination = { + total: number; + limite: number; + offset: number; + page: number; + totalPages: number; +}; + +export type MovimentosFixosSummary = { + quantidade: number; + valorTotal: number; + totalEntradas: number; + totalSaidas: number; + totalSangrias: number; + totalEstornos: number; + habilitados: number; + desabilitados: number; +}; + +export type ApiResponse = { + ok: boolean; + message?: string; + data: T; +}; + +export type MovimentosFixosListResponse = { + ok: boolean; + data: MovimentoFixo[]; + pagination: MovimentosFixosPagination; + summary: MovimentosFixosSummary; +}; \ No newline at end of file diff --git a/financeiro-web/src/features/usuarios/components/UsuarioForm.tsx b/financeiro-web/src/features/usuarios/components/UsuarioForm.tsx new file mode 100644 index 0000000..5562982 --- /dev/null +++ b/financeiro-web/src/features/usuarios/components/UsuarioForm.tsx @@ -0,0 +1,461 @@ +import { useEffect, useState } from 'react'; +import type { FormEvent, ReactNode } from 'react'; +import { + Alert, + Box, + Button, + Card, + CardContent, + Chip, + CircularProgress, + Divider, + MenuItem, + Paper, + Stack, + TextField, + Typography, +} from '@mui/material'; +import ArrowBackIcon from '@mui/icons-material/ArrowBack'; +import ManageAccountsIcon from '@mui/icons-material/ManageAccounts'; +import SaveIcon from '@mui/icons-material/Save'; +import { useNavigate } from 'react-router-dom'; +import { + atualizarUsuario, + criarUsuario, +} from '../services/usuariosService'; +import type { + CriarUsuarioRequest, + AtualizarUsuarioRequest, + Usuario, +} from '../types/usuarioTypes'; + +type UsuarioFormProps = { + mode: 'create' | 'edit'; + initialData?: Usuario | null; +}; + +type FormSectionProps = { + title: string; + description?: string; + children: ReactNode; +}; + +function FormSection({ title, description, children }: FormSectionProps) { + return ( + + + + {title} + + + {description && ( + + {description} + + )} + + + + {children} + + + ); +} + +const fieldSx = { + '& .MuiOutlinedInput-root': { + borderRadius: 2.5, + backgroundColor: '#FFFFFF', + minHeight: 48, + }, + '& .MuiInputLabel-root': { + backgroundColor: '#FFFFFF', + paddingX: 0.5, + }, +}; + +function textoOuNull(valor: string): string | null { + const texto = valor.trim(); + return texto || null; +} + +export function UsuarioForm({ mode, initialData }: UsuarioFormProps) { + const navigate = useNavigate(); + + const isEdit = mode === 'edit'; + + const [saving, setSaving] = useState(false); + const [erro, setErro] = useState(''); + const [sucesso, setSucesso] = useState(''); + + const [nome, setNome] = useState(''); + const [senha, setSenha] = useState(''); + const [confirmarSenha, setConfirmarSenha] = useState(''); + const [anotacoes, setAnotacoes] = useState(''); + const [habilitado, setHabilitado] = useState('1'); + + const titulo = isEdit ? 'Editar usuário' : 'Novo usuário'; + const subtitulo = isEdit + ? 'Atualize os dados do usuário selecionado.' + : 'Cadastre um novo usuário para acessar o sistema.'; + + useEffect(() => { + if (!initialData) return; + + setNome(initialData.nome || ''); + setAnotacoes(initialData.anotacoes || ''); + setHabilitado(String(initialData.habilitado ?? 1)); + setSenha(''); + setConfirmarSenha(''); + }, [initialData]); + + function limparFormulario() { + setNome(''); + setSenha(''); + setConfirmarSenha(''); + setAnotacoes(''); + setHabilitado('1'); + } + + async function handleSubmit(event: FormEvent) { + event.preventDefault(); + + if (!nome.trim()) { + setErro('Informe o nome.'); + return; + } + + if (!isEdit && !senha.trim()) { + setErro('Informe a senha.'); + return; + } + + if (senha.trim() && senha.length < 3) { + setErro('A senha deve ter pelo menos 3 caracteres.'); + return; + } + + if (senha.trim() && senha.length > 45) { + setErro('A senha deve ter no máximo 45 caracteres.'); + return; + } + + if (senha.trim() || confirmarSenha.trim()) { + if (senha !== confirmarSenha) { + setErro('A confirmação de senha não confere.'); + return; + } + } + + try { + setSaving(true); + setErro(''); + setSucesso(''); + + if (isEdit) { + if (!initialData?.idusuarios) { + setErro('Usuário inválido para edição.'); + return; + } + + const payload: AtualizarUsuarioRequest = { + nome: nome.trim(), + senha: senha.trim() ? senha.trim() : null, + anotacoes: textoOuNull(anotacoes), + habilitado: Number(habilitado || 1), + }; + + await atualizarUsuario(initialData.idusuarios, payload); + setSucesso('Usuário atualizado com sucesso.'); + setSenha(''); + setConfirmarSenha(''); + } else { + const payload: CriarUsuarioRequest = { + nome: nome.trim(), + senha: senha.trim(), + anotacoes: textoOuNull(anotacoes), + habilitado: Number(habilitado || 1), + }; + + await criarUsuario(payload); + setSucesso('Usuário cadastrado com sucesso.'); + limparFormulario(); + } + } catch (error: any) { + const message = + error?.response?.data?.message || + 'Não foi possível salvar o usuário.'; + + setErro(message); + } finally { + setSaving(false); + } + } + + return ( + + + + } + label={isEdit ? 'Edição de usuário' : 'Cadastro de usuário'} + color="primary" + variant="outlined" + sx={{ marginBottom: 1.25, fontWeight: 700 }} + /> + + + {titulo} + + + + {subtitulo} + + + + + + + {erro && ( + + {erro} + + )} + + {sucesso && ( + + {sucesso} + + )} + + + + + + + setNome(event.target.value)} + fullWidth + sx={fieldSx} + /> + + setHabilitado(event.target.value)} + fullWidth + sx={fieldSx} + > + Habilitado + Desabilitado + + + setAnotacoes(event.target.value)} + multiline + minRows={4} + fullWidth + sx={{ + ...fieldSx, + gridColumn: { xs: 'auto', md: '1 / -1' }, + }} + /> + + + + setSenha(event.target.value)} + fullWidth + sx={fieldSx} + /> + + setConfirmarSenha(event.target.value)} + fullWidth + sx={fieldSx} + /> + + + + + + + + + + + + + + Resumo + + + + Prévia rápida do usuário antes de salvar. + + + + + + + + Nome + + + {nome || 'Sem nome'} + + + + + + Status + + + {Number(habilitado) === 1 ? 'Habilitado' : 'Desabilitado'} + + + + + + Senha + + + {isEdit + ? senha + ? 'Será alterada' + : 'Sem alteração' + : senha + ? 'Definida' + : 'Pendente'} + + + + + + Anotações + + + {anotacoes || '-'} + + + + + + + ); +} \ No newline at end of file diff --git a/financeiro-web/src/features/usuarios/pages/EditarUsuarioPage.tsx b/financeiro-web/src/features/usuarios/pages/EditarUsuarioPage.tsx new file mode 100644 index 0000000..e30730f --- /dev/null +++ b/financeiro-web/src/features/usuarios/pages/EditarUsuarioPage.tsx @@ -0,0 +1,62 @@ +import { useEffect, useState } from 'react'; +import { Alert, Box, CircularProgress, Typography } from '@mui/material'; +import { useParams } from 'react-router-dom'; +import { UsuarioForm } from '../components/UsuarioForm'; +import { buscarUsuarioPorId } from '../services/usuariosService'; +import type { Usuario } from '../types/usuarioTypes'; + +export function EditarUsuarioPage() { + const { id } = useParams(); + + const [usuario, setUsuario] = useState(null); + const [loading, setLoading] = useState(true); + const [erro, setErro] = useState(''); + + useEffect(() => { + async function carregarUsuario() { + try { + setLoading(true); + setErro(''); + + const usuarioId = Number(id); + + if (!usuarioId) { + setErro('ID do usuário inválido.'); + return; + } + + const data = await buscarUsuarioPorId(usuarioId); + setUsuario(data); + } catch (error: any) { + const message = + error?.response?.data?.message || + 'Não foi possível carregar o usuário.'; + + setErro(message); + } finally { + setLoading(false); + } + } + + carregarUsuario(); + }, [id]); + + if (loading) { + return ( + + + Carregando usuário... + + ); + } + + if (erro) { + return ( + + {erro} + + ); + } + + return ; +} \ No newline at end of file diff --git a/financeiro-web/src/features/usuarios/pages/NovoUsuarioPage.tsx b/financeiro-web/src/features/usuarios/pages/NovoUsuarioPage.tsx new file mode 100644 index 0000000..5083d85 --- /dev/null +++ b/financeiro-web/src/features/usuarios/pages/NovoUsuarioPage.tsx @@ -0,0 +1,5 @@ +import { UsuarioForm } from '../components/UsuarioForm'; + +export function NovoUsuarioPage() { + return ; +} \ No newline at end of file diff --git a/financeiro-web/src/features/usuarios/pages/UsuariosPage.tsx b/financeiro-web/src/features/usuarios/pages/UsuariosPage.tsx new file mode 100644 index 0000000..c63445e --- /dev/null +++ b/financeiro-web/src/features/usuarios/pages/UsuariosPage.tsx @@ -0,0 +1,685 @@ +import { useEffect, useMemo, useState } from 'react'; +import { + Alert, + Box, + Button, + Card, + CardContent, + Chip, + CircularProgress, + Divider, + IconButton, + MenuItem, + Pagination, + Paper, + Stack, + Switch, + TextField, + Tooltip, + Typography, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + useMediaQuery, + useTheme, +} from '@mui/material'; +import AddIcon from '@mui/icons-material/Add'; +import DeleteIcon from '@mui/icons-material/Delete'; +import EditIcon from '@mui/icons-material/Edit'; +import ManageAccountsIcon from '@mui/icons-material/ManageAccounts'; +import FilterAltIcon from '@mui/icons-material/FilterAlt'; +import RestartAltIcon from '@mui/icons-material/RestartAlt'; +import SearchIcon from '@mui/icons-material/Search'; +import { Link as RouterLink } from 'react-router-dom'; +import { + alterarHabilitadoUsuario, + deletarUsuario, + listarUsuarios, + type ListarUsuariosParams, +} from '../services/usuariosService'; +import type { + Usuario, + UsuariosPagination, + UsuariosSummary, +} from '../types/usuarioTypes'; + +const LIMITE_PADRAO = 20; + +function formatarData(data: string | null) { + if (!data) return '-'; + + return new Date(`${String(data).slice(0, 10)}T00:00:00`).toLocaleDateString('pt-BR'); +} + +function statusLabel(habilitado: number) { + return Number(habilitado) === 1 ? 'Habilitado' : 'Desabilitado'; +} + +function statusColor(habilitado: number) { + return Number(habilitado) === 1 ? 'success' : 'default'; +} + +export function UsuariosPage() { + const [usuarios, setUsuarios] = useState([]); + const [pagination, setPagination] = useState({ + total: 0, + limite: LIMITE_PADRAO, + offset: 0, + page: 1, + totalPages: 1, + }); + const [summary, setSummary] = useState({ + quantidade: 0, + habilitados: 0, + desabilitados: 0, + }); + + const [alterandoStatusId, setAlterandoStatusId] = useState(null); + + const [loading, setLoading] = useState(true); + const [erro, setErro] = useState(''); + const [sucesso, setSucesso] = useState(''); + + const [page, setPage] = useState(1); + const [busca, setBusca] = useState(''); + const [habilitado, setHabilitado] = useState(''); + const [orderBy, setOrderBy] = useState('nome'); + const [orderDirection, setOrderDirection] = useState<'ASC' | 'DESC'>('ASC'); + + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down('md')); + + const filtrosAtivos = useMemo(() => { + let count = 0; + + if (busca.trim()) count += 1; + if (habilitado !== '') count += 1; + + return count; + }, [busca, habilitado]); + + async function carregarUsuarios(pageToLoad = page) { + try { + setLoading(true); + setErro(''); + + const params: ListarUsuariosParams = { + limite: LIMITE_PADRAO, + page: pageToLoad, + busca: busca.trim() || undefined, + habilitado, + orderBy, + orderDirection, + }; + + const response = await listarUsuarios(params); + + setUsuarios(response.data); + setPagination(response.pagination); + setSummary(response.summary); + setPage(response.pagination.page); + } catch (error: any) { + const message = + error?.response?.data?.message || + 'Não foi possível carregar os usuários.'; + + setErro(message); + } finally { + setLoading(false); + } + } + + function aplicarFiltros() { + setPage(1); + carregarUsuarios(1); + } + + function limparFiltros() { + setBusca(''); + setHabilitado(''); + setOrderBy('nome'); + setOrderDirection('ASC'); + + setTimeout(() => { + setPage(1); + carregarUsuarios(1); + }, 0); + } + + async function handleDeletarUsuario(item: Usuario) { + const confirmou = window.confirm( + `Deseja realmente excluir "${item.nome}"?` + ); + + if (!confirmou) return; + + try { + setLoading(true); + setErro(''); + setSucesso(''); + + await deletarUsuario(item.idusuarios); + + setSucesso('Usuário excluído com sucesso.'); + await carregarUsuarios(page); + } catch (error: any) { + const message = + error?.response?.data?.message || + 'Não foi possível excluir o usuário.'; + + setErro(message); + } finally { + setLoading(false); + } + } + + async function handleAlterarStatusUsuario(item: Usuario) { + const novoStatus = Number(item.habilitado) === 1 ? 0 : 1; + + try { + setAlterandoStatusId(item.idusuarios); + setErro(''); + setSucesso(''); + + const usuarioAtualizado = await alterarHabilitadoUsuario( + item.idusuarios, + novoStatus + ); + + setUsuarios((listaAtual) => + listaAtual.map((usuario) => + usuario.idusuarios === item.idusuarios ? usuarioAtualizado : usuario + ) + ); + + setSucesso( + novoStatus === 1 + ? 'Usuário habilitado com sucesso.' + : 'Usuário desabilitado com sucesso.' + ); + } catch (error: any) { + const message = + error?.response?.data?.message || + 'Não foi possível alterar o status do usuário.'; + + setErro(message); + } finally { + setAlterandoStatusId(null); + } + } + + useEffect(() => { + carregarUsuarios(1); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + + + + } + label="Controle de acesso" + color="primary" + variant="outlined" + sx={{ marginBottom: 1.25, fontWeight: 700 }} + /> + + + Usuários + + + + Cadastre e gerencie os usuários que acessam o sistema. + + + + + + + {erro && ( + + {erro} + + )} + + {sucesso && ( + + {sucesso} + + )} + + + + + + Usuários encontrados + + + {summary.quantidade} + + + + + + + + Habilitados + + + {summary.habilitados} + + + + + + + + Desabilitados + + + {summary.desabilitados} + + + + + + + + + + + + Filtros + + + + + + + setBusca(event.target.value)} + placeholder="Nome ou anotações..." + fullWidth + /> + + + setHabilitado(event.target.value === '' ? '' : Number(event.target.value)) + } + fullWidth + > + Todos + Habilitados + Desabilitados + + + setOrderBy(event.target.value)} + fullWidth + > + Nome + Status + Cadastro + Atualização + + + + setOrderDirection(event.target.value as 'ASC' | 'DESC') + } + fullWidth + > + Crescente + Decrescente + + + + + + + + + + + + + + {loading ? ( + + + Carregando usuários... + + ) : usuarios.length === 0 ? ( + + + Nenhum usuário encontrado. + + + Ajuste os filtros ou cadastre um novo usuário. + + + ) : isMobile ? ( + }> + {usuarios.map((item) => ( + + + + + + {item.nome} + + + + Cadastro: {formatarData(item.insert_date)} + + + + + + + + + + Anotações: {item.anotacoes || '-'} + + + + + + handleAlterarStatusUsuario(item)} + /> + + + + + + + + + + ))} + + ) : ( + + + + + Nome + Anotações + Status + Cadastro + Atualização + Ações + + + + + {usuarios.map((item) => ( + + + + + {item.nome} + + + + + + + + {item.anotacoes || '-'} + + + + + + + + + + {formatarData(item.insert_date)} + + + + {formatarData(item.update_date)} + + + + + + + handleAlterarStatusUsuario(item)} + /> + + + + + + + + + + + handleDeletarUsuario(item)} + > + + + + + + + ))} + +
+
+ )} +
+
+ + {pagination.totalPages > 1 && ( + + { + setPage(novaPagina); + carregarUsuarios(novaPagina); + }} + /> + + )} + + + + Exibindo página {pagination.page} de {pagination.totalPages}, total de{' '} + {pagination.total} registro(s). + + +
+ ); +} \ No newline at end of file diff --git a/financeiro-web/src/features/usuarios/services/usuariosService.ts b/financeiro-web/src/features/usuarios/services/usuariosService.ts new file mode 100644 index 0000000..a3aaee7 --- /dev/null +++ b/financeiro-web/src/features/usuarios/services/usuariosService.ts @@ -0,0 +1,66 @@ +import { api } from '../../../services/api'; +import type { + ApiResponse, + AtualizarUsuarioRequest, + CriarUsuarioRequest, + Usuario, + UsuariosListResponse, +} from '../types/usuarioTypes'; + +export type OrderDirection = 'ASC' | 'DESC'; + +export type ListarUsuariosParams = { + limite?: number; + offset?: number; + page?: number; + busca?: string; + habilitado?: number | ''; + orderBy?: string; + orderDirection?: OrderDirection; +}; + +export async function listarUsuarios( + params?: ListarUsuariosParams +): Promise { + const response = await api.get('/usuarios', { + params, + }); + + return response.data; +} + +export async function buscarUsuarioPorId(id: number): Promise { + const response = await api.get>(`/usuarios/${id}`); + return response.data.data; +} + +export async function criarUsuario( + data: CriarUsuarioRequest +): Promise { + const response = await api.post>('/usuarios', data); + return response.data.data; +} + +export async function atualizarUsuario( + id: number, + data: AtualizarUsuarioRequest +): Promise { + const response = await api.put>(`/usuarios/${id}`, data); + return response.data.data; +} + +export async function alterarHabilitadoUsuario( + id: number, + habilitado: number +): Promise { + const response = await api.patch>( + `/usuarios/${id}/habilitado`, + { habilitado } + ); + + return response.data.data; +} + +export async function deletarUsuario(id: number): Promise { + await api.delete(`/usuarios/${id}`); +} \ No newline at end of file diff --git a/financeiro-web/src/features/usuarios/types/usuarioTypes.ts b/financeiro-web/src/features/usuarios/types/usuarioTypes.ts new file mode 100644 index 0000000..1636627 --- /dev/null +++ b/financeiro-web/src/features/usuarios/types/usuarioTypes.ts @@ -0,0 +1,49 @@ +export type Usuario = { + idusuarios: number; + nome: string; + anotacoes: string | null; + habilitado: number; + insert_date: string | null; + update_date: string | null; +}; + +export type CriarUsuarioRequest = { + nome: string; + senha: string; + anotacoes: string | null; + habilitado: number; +}; + +export type AtualizarUsuarioRequest = { + nome: string; + senha?: string | null; + anotacoes: string | null; + habilitado: number; +}; + +export type UsuariosPagination = { + total: number; + limite: number; + offset: number; + page: number; + totalPages: number; +}; + +export type UsuariosSummary = { + quantidade: number; + habilitados: number; + desabilitados: number; +}; + +export type ApiResponse = { + ok: boolean; + message?: string; + data: T; +}; + +export type UsuariosListResponse = { + ok: boolean; + data: Usuario[]; + pagination: UsuariosPagination; + summary: UsuariosSummary; +}; \ No newline at end of file