adicionado novos modulos e automacao na tela de movimentos

This commit is contained in:
Diego Freitas 2026-05-30 17:38:53 -03:00
parent 79070aecb8
commit c6418df307
57 changed files with 12390 additions and 186 deletions

View File

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

View File

@ -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,
};

View File

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

View File

@ -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,
};

View File

@ -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,
};

View File

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

View File

@ -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,
};

View File

@ -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,
};

View File

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

View File

@ -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,
};

View File

@ -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,
};

View File

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

View File

@ -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,
};

View File

@ -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,
};

View File

@ -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,
};

View File

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

View File

@ -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,
};

View File

@ -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
`
);

View File

@ -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,
};

View File

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

View File

@ -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,
};

View File

@ -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: <RelatoriosPage />,
},
{
path: 'bancos',
element: <BancosPage />,
},
{
path: 'bancos/novo',
element: <NovoBancoPage />,
},
{
path: 'bancos/:id/editar',
element: <EditarBancoPage />,
},
{
path: 'clientes',
element: <ClientesPage />,
},
{
path: 'clientes/novo',
element: <NovoClientePage />,
},
{
path: 'clientes/:id/editar',
element: <EditarClientePage />,
},
{
path: 'centros-custo',
element: <CentrosCustoPage />,
},
{
path: 'centros-custo/novo',
element: <NovoCentroCustoPage />,
},
{
path: 'centros-custo/:id/editar',
element: <EditarCentroCustoPage />,
},
{
path: 'movimentos-fixos',
element: <MovimentosFixosPage />,
},
{
path: 'movimentos-fixos/novo',
element: <NovoMovimentoFixoPage />,
},
{
path: 'movimentos-fixos/:id/editar',
element: <EditarMovimentoFixoPage />,
},
{
path: 'usuarios',
element: <UsuariosPage />,
},
{
path: 'usuarios/novo',
element: <NovoUsuarioPage />,
},
{
path: 'usuarios/:id/editar',
element: <EditarUsuarioPage />,
},
],
},
],

View File

@ -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: <SwapHorizIcon />,
},
{
label: 'Clientes',
path: '/clientes',
icon: <PeopleAltIcon />,
},
{
label: 'Carteiras',
path: '/carteiras',
path: '/bancos',
icon: <AccountBalanceWalletIcon />,
},
{
label: 'Centros de custo',
path: '/centros-custo',
icon: <AccountTreeIcon />,
},
{
label: 'Movimentos fixos',
path: '/movimentos-fixos',
icon: <EventRepeatIcon />,
},
{
label: 'Usuários',
path: '/usuarios',
icon: <ManageAccountsIcon />,
},
{
label: 'Relatórios',
path: '/relatorios',
@ -98,6 +122,7 @@ export function Sidebar({ onNavigate }: SidebarProps) {
}}
>
<ListItemIcon>{item.icon}</ListItemIcon>
<ListItemText
primary={item.label}
primaryTypographyProps={{
@ -120,8 +145,9 @@ export function Sidebar({ onNavigate }: SidebarProps) {
<Typography variant="body2" fontWeight={800}>
MVP ativo
</Typography>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.62)' }}>
Cadastro e edição de movimentos funcionando.
Movimentos e carteiras em operação.
</Typography>
</Box>
</Box>

View File

@ -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 (
<Paper
elevation={0}
sx={{
padding: { xs: 2.5, md: 3 },
borderRadius: 4,
border: '1px solid',
borderColor: 'divider',
backgroundColor: '#FFFFFF',
}}
>
<Box marginBottom={3}>
<Typography variant="h6" fontWeight={900}>
{title}
</Typography>
{description && (
<Typography variant="body2" color="text.secondary" marginTop={0.25}>
{description}
</Typography>
)}
</Box>
<Box
display="grid"
gridTemplateColumns={{
xs: '1fr',
md: '1fr 1fr',
}}
columnGap={{ xs: 2, md: 2.5 }}
rowGap={{ xs: 3, md: 3.25 }}
>
{children}
</Box>
</Paper>
);
}
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<HTMLFormElement>) {
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 (
<Box>
<Stack
direction={{ xs: 'column', md: 'row' }}
justifyContent="space-between"
alignItems={{ xs: 'stretch', md: 'center' }}
spacing={2}
marginBottom={{ xs: 3, md: 4 }}
>
<Box>
<Chip
icon={<AccountBalanceWalletIcon />}
label={isEdit ? 'Edição de carteira' : 'Cadastro de carteira'}
color="primary"
variant="outlined"
sx={{ marginBottom: 1.25, fontWeight: 700 }}
/>
<Typography variant="h4" fontWeight={950}>
{titulo}
</Typography>
<Typography color="text.secondary" sx={{ marginTop: 0.5 }}>
{subtitulo}
</Typography>
</Box>
<Button
variant="outlined"
startIcon={<ArrowBackIcon />}
onClick={() => navigate('/bancos')}
sx={{
minHeight: 48,
borderRadius: 3,
px: 2.5,
}}
>
Voltar para lista
</Button>
</Stack>
{erro && (
<Alert severity="error" sx={{ marginBottom: 2.5 }}>
{erro}
</Alert>
)}
{sucesso && (
<Alert severity="success" sx={{ marginBottom: 2.5 }}>
{sucesso}
</Alert>
)}
<Stack
direction={{ xs: 'column', xl: 'row' }}
spacing={{ xs: 2.5, md: 3 }}
alignItems="flex-start"
>
<Card sx={{ width: '100%', flex: 1 }}>
<CardContent sx={{ padding: { xs: 2, md: 3 } }}>
<Box
component="form"
onSubmit={handleSubmit}
display="flex"
flexDirection="column"
gap={{ xs: 2.5, md: 3 }}
>
<FormSection
title="Dados da carteira"
description="Defina a descrição, o saldo atual e o tipo da carteira."
>
<TextField
label="Descrição"
value={descricao}
onChange={(event) => setDescricao(event.target.value)}
placeholder="Ex: Caixa, Nubank, Sicredi, Mercado Pago..."
fullWidth
sx={fieldSx}
/>
<TextField
select
label="Tipo"
value={debito}
onChange={(event) => setDebito(event.target.value)}
fullWidth
sx={fieldSx}
>
<MenuItem value="1">Débito</MenuItem>
<MenuItem value="0">Crédito</MenuItem>
</TextField>
<TextField
label="Saldo"
type="number"
value={saldo}
onChange={(event) => setSaldo(event.target.value)}
inputProps={{
step: '0.01',
}}
fullWidth
sx={fieldSx}
/>
<TextField
select
label="Status"
value={habilitado}
onChange={(event) => setHabilitado(event.target.value)}
fullWidth
sx={fieldSx}
>
<MenuItem value="1">Habilitado</MenuItem>
<MenuItem value="0">Desabilitado</MenuItem>
</TextField>
</FormSection>
<Stack
direction={{ xs: 'column-reverse', sm: 'row' }}
justifyContent="flex-end"
spacing={1.5}
sx={{
paddingTop: 1,
}}
>
<Button
variant="outlined"
onClick={() => navigate('/bancos')}
disabled={saving}
sx={{ minHeight: 46 }}
>
Cancelar
</Button>
<Button
type="submit"
variant="contained"
disabled={saving}
startIcon={
saving
? <CircularProgress size={18} color="inherit" />
: <SaveIcon />
}
sx={{
minHeight: 46,
px: 3,
boxShadow: 3,
}}
>
{saving
? 'Salvando...'
: isEdit
? 'Atualizar carteira'
: 'Salvar carteira'}
</Button>
</Stack>
</Box>
</CardContent>
</Card>
<Paper
elevation={0}
sx={{
width: { xs: '100%', xl: 340 },
padding: 3,
borderRadius: 4,
border: '1px solid',
borderColor: 'divider',
position: { xl: 'sticky' },
top: { xl: 24 },
background:
'linear-gradient(180deg, #FFFFFF 0%, #F8FAFC 100%)',
}}
>
<Typography variant="h6" fontWeight={900} gutterBottom>
Resumo
</Typography>
<Typography variant="body2" color="text.secondary" marginBottom={2.5}>
Prévia rápida da carteira antes de salvar.
</Typography>
<Divider sx={{ marginBottom: 2.5 }} />
<Stack spacing={2}>
<Box>
<Typography variant="caption" color="text.secondary">
Descrição
</Typography>
<Typography fontWeight={800}>
{descricao || 'Sem descrição'}
</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Tipo
</Typography>
<Typography fontWeight={800}>
{Number(debito) === 1 ? 'Débito' : 'Crédito'}
</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Saldo
</Typography>
<Typography
variant="h4"
fontWeight={950}
color={Number(saldo || 0) >= 0 ? 'success.main' : 'error.main'}
>
{formatarValorResumo(saldo)}
</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Status
</Typography>
<Typography fontWeight={800}>
{Number(habilitado) === 1 ? 'Habilitado' : 'Desabilitado'}
</Typography>
</Box>
</Stack>
</Paper>
</Stack>
</Box>
);
}

View File

@ -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<Banco[]>([]);
const [pagination, setPagination] = useState<BancosPagination>({
total: 0,
limite: LIMITE_PADRAO,
offset: 0,
page: 1,
totalPages: 1,
});
const [summary, setSummary] = useState<BancosSummary>({
quantidade: 0,
saldoTotal: 0,
saldoCarteiras: 0,
saldoDebito: 0,
});
const [alterandoStatusId, setAlterandoStatusId] = useState<number | null>(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<number | ''>('');
const [habilitado, setHabilitado] = useState<number | ''>('');
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 (
<Box>
<Stack
direction={{ xs: 'column', md: 'row' }}
justifyContent="space-between"
alignItems={{ xs: 'stretch', md: 'center' }}
spacing={2}
marginBottom={{ xs: 3, md: 4 }}
>
<Box>
<Chip
icon={<AccountBalanceWalletIcon />}
label="Carteiras financeiras"
color="primary"
variant="outlined"
sx={{ marginBottom: 1.25, fontWeight: 700 }}
/>
<Typography variant="h4" fontWeight={950}>
Bancos e carteiras
</Typography>
<Typography color="text.secondary" sx={{ marginTop: 0.5 }}>
Cadastre, edite e acompanhe as carteiras usadas nos movimentos.
</Typography>
</Box>
<Button
component={RouterLink}
to="/bancos/novo"
variant="contained"
startIcon={<AddIcon />}
sx={{
minHeight: 48,
borderRadius: 3,
px: 2.5,
boxShadow: 3,
}}
>
Nova carteira
</Button>
</Stack>
{erro && (
<Alert severity="error" sx={{ marginBottom: 2.5 }}>
{erro}
</Alert>
)}
{sucesso && (
<Alert severity="success" sx={{ marginBottom: 2.5 }}>
{sucesso}
</Alert>
)}
<Box
display="grid"
gridTemplateColumns={{
xs: '1fr',
md: 'repeat(4, 1fr)',
}}
gap={{ xs: 2, md: 2.5 }}
marginBottom={{ xs: 2.5, md: 3 }}
>
<Card>
<CardContent>
<Typography variant="body2" color="text.secondary">
Carteiras cadastradas
</Typography>
<Typography variant="h5" fontWeight={950}>
{summary.quantidade}
</Typography>
</CardContent>
</Card>
<Card>
<CardContent>
<Typography variant="body2" color="text.secondary">
Saldo total
</Typography>
<Typography
variant="h5"
fontWeight={950}
color={summary.saldoTotal >= 0 ? 'success.main' : 'error.main'}
>
{formatarValor(summary.saldoTotal)}
</Typography>
</CardContent>
</Card>
<Card>
<CardContent>
<Typography variant="body2" color="text.secondary">
Carteiras de crédito
</Typography>
<Typography variant="h5" fontWeight={950} color="success.main">
{formatarValor(summary.saldoCarteiras)}
</Typography>
</CardContent>
</Card>
<Card>
<CardContent>
<Typography variant="body2" color="text.secondary">
Carteiras de débito
</Typography>
<Typography variant="h5" fontWeight={950} color="warning.main">
{formatarValor(summary.saldoDebito)}
</Typography>
</CardContent>
</Card>
</Box>
<Card sx={{ marginBottom: { xs: 2.5, md: 3 } }}>
<CardContent sx={{ padding: { xs: 2, md: 3 } }}>
<Stack
direction="row"
spacing={1}
alignItems="center"
marginBottom={2.5}
>
<FilterAltIcon color="primary" />
<Typography variant="h6" fontWeight={900}>
Filtros
</Typography>
<Chip
label={`${filtrosAtivos} ativo(s)`}
size="small"
variant="outlined"
/>
</Stack>
<Box
display="grid"
gridTemplateColumns={{
xs: '1fr',
md: 'repeat(4, 1fr)',
}}
gap={{ xs: 2, md: 2.25 }}
>
<TextField
label="Buscar"
value={busca}
onChange={(event) => setBusca(event.target.value)}
placeholder="Descrição da carteira..."
fullWidth
/>
<TextField
select
label="Tipo"
value={debito}
onChange={(event) =>
setDebito(event.target.value === '' ? '' : Number(event.target.value))
}
fullWidth
>
<MenuItem value="">Todos</MenuItem>
<MenuItem value={0}>Crédito</MenuItem>
<MenuItem value={1}>Débito</MenuItem>
</TextField>
<TextField
select
label="Status"
value={habilitado}
onChange={(event) =>
setHabilitado(event.target.value === '' ? '' : Number(event.target.value))
}
fullWidth
>
<MenuItem value="">Todos</MenuItem>
<MenuItem value={1}>Habilitados</MenuItem>
<MenuItem value={0}>Desabilitados</MenuItem>
</TextField>
<TextField
select
label="Ordenar por"
value={orderBy}
onChange={(event) => setOrderBy(event.target.value)}
fullWidth
>
<MenuItem value="descricao">Descrição</MenuItem>
<MenuItem value="saldo">Saldo</MenuItem>
<MenuItem value="debito">Tipo</MenuItem>
<MenuItem value="insert_date">Cadastro</MenuItem>
<MenuItem value="update_date">Atualização</MenuItem>
</TextField>
<TextField
select
label="Direção"
value={orderDirection}
onChange={(event) =>
setOrderDirection(event.target.value as 'ASC' | 'DESC')
}
fullWidth
>
<MenuItem value="ASC">Crescente</MenuItem>
<MenuItem value="DESC">Decrescente</MenuItem>
</TextField>
</Box>
<Stack
direction={{ xs: 'column', sm: 'row' }}
spacing={1.5}
justifyContent="flex-end"
marginTop={2.5}
>
<Button
variant="outlined"
startIcon={<RestartAltIcon />}
onClick={limparFiltros}
>
Limpar
</Button>
<Button
variant="contained"
startIcon={<SearchIcon />}
onClick={aplicarFiltros}
disabled={loading}
>
Aplicar filtros
</Button>
</Stack>
</CardContent>
</Card>
<Card>
<CardContent sx={{ padding: 0 }}>
{loading ? (
<Box padding={3} display="flex" alignItems="center" gap={2}>
<CircularProgress size={22} />
<Typography>Carregando bancos/carteiras...</Typography>
</Box>
) : bancos.length === 0 ? (
<Box padding={3}>
<Typography fontWeight={800}>
Nenhum banco/carteira encontrado.
</Typography>
<Typography color="text.secondary" marginTop={0.5}>
Ajuste os filtros ou cadastre uma nova carteira.
</Typography>
</Box>
) : isMobile ? (
<Stack divider={<Divider />}>
{bancos.map((item) => (
<Box
key={item.idbancos}
sx={{
padding: 2,
'&:hover': {
backgroundColor: 'rgba(15,23,42,0.02)',
},
}}
>
<Stack spacing={1.25}>
<Stack
direction="row"
justifyContent="space-between"
alignItems="flex-start"
spacing={2}
>
<Box sx={{ minWidth: 0 }}>
<Typography fontWeight={900}>
{item.descricao}
</Typography>
<Typography variant="body2" color="text.secondary">
Cadastro: {formatarData(item.insert_date)}
</Typography>
</Box>
<Typography
fontWeight={950}
color={Number(item.saldo || 0) >= 0 ? 'success.main' : 'error.main'}
whiteSpace="nowrap"
>
{formatarValor(item.saldo)}
</Typography>
</Stack>
<Stack direction="row" flexWrap="wrap" spacing={1} useFlexGap>
<Chip
label={tipoBancoLabel(item.debito)}
size="small"
color={tipoBancoColor(item.debito) as any}
variant="outlined"
/>
<Chip
label={statusBancoLabel(item.habilitado)}
size="small"
color={statusBancoColor(item.habilitado) as any}
variant={Number(item.habilitado) === 1 ? 'filled' : 'outlined'}
/>
</Stack>
<Box
display="flex"
justifyContent="flex-end"
alignItems="center"
gap={1}
>
<Tooltip
title={
Number(item.habilitado) === 1
? 'Desabilitar carteira'
: 'Habilitar carteira'
}
>
<span>
<Switch
size="small"
checked={Number(item.habilitado) === 1}
disabled={alterandoStatusId === item.idbancos || loading}
onChange={() => handleAlterarStatusBanco(item)}
/>
</span>
</Tooltip>
<Button
component={RouterLink}
to={`/bancos/${item.idbancos}/editar`}
variant="outlined"
size="small"
startIcon={<EditIcon />}
>
Editar
</Button>
<Button
variant="outlined"
color="error"
size="small"
startIcon={<DeleteIcon />}
onClick={() => handleDeletarBanco(item)}
>
Excluir
</Button>
</Box>
</Stack>
</Box>
))}
</Stack>
) : (
<TableContainer
sx={{
overflowX: 'auto',
}}
>
<Table
size="small"
sx={{
minWidth: 900,
'& th': {
whiteSpace: 'nowrap',
fontWeight: 900,
backgroundColor: 'rgba(15,23,42,0.04)',
},
'& td': {
whiteSpace: 'nowrap',
},
}}
>
<TableHead>
<TableRow>
<TableCell sx={{ minWidth: 260 }}>Descrição</TableCell>
<TableCell sx={{ minWidth: 180 }}>Tipo</TableCell>
<TableCell sx={{ minWidth: 140 }} align="right">Saldo</TableCell>
<TableCell sx={{ minWidth: 130 }}>Cadastro</TableCell>
<TableCell sx={{ minWidth: 130 }}>Atualização</TableCell>
<TableCell sx={{ minWidth: 110 }} align="center">Ações</TableCell>
</TableRow>
</TableHead>
<TableBody>
{bancos.map((item) => (
<TableRow
key={item.idbancos}
hover
sx={{
'&:last-child td': {
borderBottom: 0,
},
}}
>
<TableCell>
<Box sx={{ maxWidth: 280 }}>
<Typography fontWeight={800} noWrap title={item.descricao}>
{item.descricao}
</Typography>
</Box>
</TableCell>
<TableCell>
<Chip
label={tipoBancoLabel(item.debito)}
size="small"
color={tipoBancoColor(item.debito) as any}
variant="outlined"
/>
</TableCell>
<TableCell align="right">
<Typography
fontWeight={950}
color={Number(item.saldo || 0) >= 0 ? 'success.main' : 'error.main'}
>
{formatarValor(item.saldo)}
</Typography>
</TableCell>
<TableCell>
{formatarData(item.insert_date)}
</TableCell>
<TableCell>
{formatarData(item.update_date)}
</TableCell>
<TableCell align="center">
<Stack
direction="row"
spacing={0.5}
alignItems="center"
justifyContent="center"
>
<Tooltip title={Number(item.habilitado) === 1 ? 'Desabilitar carteira' : 'Habilitar carteira'}>
<span>
<Switch
size="small"
checked={Number(item.habilitado) === 1}
disabled={alterandoStatusId === item.idbancos || loading}
onChange={() => handleAlterarStatusBanco(item)}
/>
</span>
</Tooltip>
<Tooltip title="Editar carteira">
<IconButton
component={RouterLink}
to={`/bancos/${item.idbancos}/editar`}
color="primary"
size="small"
>
<EditIcon />
</IconButton>
</Tooltip>
<Tooltip title="Excluir carteira">
<IconButton
color="error"
size="small"
onClick={() => handleDeletarBanco(item)}
>
<DeleteIcon />
</IconButton>
</Tooltip>
</Stack>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
</CardContent>
</Card>
{pagination.totalPages > 1 && (
<Stack alignItems="center" marginTop={3}>
<Pagination
page={pagination.page}
count={pagination.totalPages}
color="primary"
onChange={(_, novaPagina) => {
setPage(novaPagina);
carregarBancos(novaPagina);
}}
/>
</Stack>
)}
<Paper
elevation={0}
sx={{
marginTop: 2,
padding: 2,
border: '1px solid',
borderColor: 'divider',
borderRadius: 3,
backgroundColor: 'rgba(255,255,255,0.65)',
}}
>
<Typography variant="body2" color="text.secondary">
Exibindo página {pagination.page} de {pagination.totalPages}, total de{' '}
<strong>{pagination.total}</strong> registro(s).
</Typography>
</Paper>
</Box>
);
}

View File

@ -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<Banco | null>(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 (
<Box padding={3} display="flex" alignItems="center" gap={2}>
<CircularProgress size={22} />
<Typography>Carregando banco/carteira...</Typography>
</Box>
);
}
if (erro) {
return (
<Box padding={3}>
<Alert severity="error">{erro}</Alert>
</Box>
);
}
return <BancoForm mode="edit" initialData={banco} />;
}

View File

@ -0,0 +1,5 @@
import { BancoForm } from '../components/BancoForm';
export function NovoBancoPage() {
return <BancoForm mode="create" />;
}

View File

@ -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<BancosListResponse> {
const response = await api.get<BancosListResponse>('/bancos', {
params,
});
return response.data;
}
export async function buscarBancoPorId(id: number): Promise<Banco> {
const response = await api.get<ApiResponse<Banco>>(`/bancos/${id}`);
return response.data.data;
}
export async function criarBanco(
data: CriarBancoRequest
): Promise<Banco> {
const response = await api.post<ApiResponse<Banco>>('/bancos', data);
return response.data.data;
}
export async function atualizarBanco(
id: number,
data: AtualizarBancoRequest
): Promise<Banco> {
const response = await api.put<ApiResponse<Banco>>(`/bancos/${id}`, data);
return response.data.data;
}
export async function alterarHabilitadoBanco(
id: number,
habilitado: number
): Promise<Banco> {
const response = await api.patch<ApiResponse<Banco>>(`/bancos/${id}/habilitado`, {
habilitado,
});
return response.data.data;
}
export async function deletarBanco(id: number): Promise<void> {
await api.delete(`/bancos/${id}`);
}

View File

@ -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<T> = {
ok: boolean;
message?: string;
data: T;
};
export type BancosListResponse = {
ok: boolean;
data: Banco[];
pagination: BancosPagination;
summary: BancosSummary;
};

View File

@ -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 (
<Paper
elevation={0}
sx={{
padding: { xs: 2.5, md: 3 },
borderRadius: 4,
border: '1px solid',
borderColor: 'divider',
backgroundColor: '#FFFFFF',
}}
>
<Box marginBottom={3}>
<Typography variant="h6" fontWeight={900}>
{title}
</Typography>
{description && (
<Typography variant="body2" color="text.secondary" marginTop={0.25}>
{description}
</Typography>
)}
</Box>
<Box
display="grid"
gridTemplateColumns={{
xs: '1fr',
md: '1fr 1fr',
}}
columnGap={{ xs: 2, md: 2.5 }}
rowGap={{ xs: 3, md: 3.25 }}
>
{children}
</Box>
</Paper>
);
}
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<HTMLFormElement>) {
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 (
<Box>
<Stack
direction={{ xs: 'column', md: 'row' }}
justifyContent="space-between"
alignItems={{ xs: 'stretch', md: 'center' }}
spacing={2}
marginBottom={{ xs: 3, md: 4 }}
>
<Box>
<Chip
icon={<AccountTreeIcon />}
label={isEdit ? 'Edição de centro' : 'Cadastro de centro'}
color="primary"
variant="outlined"
sx={{ marginBottom: 1.25, fontWeight: 700 }}
/>
<Typography variant="h4" fontWeight={950}>
{titulo}
</Typography>
<Typography color="text.secondary" sx={{ marginTop: 0.5 }}>
{subtitulo}
</Typography>
</Box>
<Button
variant="outlined"
startIcon={<ArrowBackIcon />}
onClick={() => navigate('/centros-custo')}
sx={{
minHeight: 48,
borderRadius: 3,
px: 2.5,
}}
>
Voltar para lista
</Button>
</Stack>
{erro && (
<Alert severity="error" sx={{ marginBottom: 2.5 }}>
{erro}
</Alert>
)}
{sucesso && (
<Alert severity="success" sx={{ marginBottom: 2.5 }}>
{sucesso}
</Alert>
)}
<Stack
direction={{ xs: 'column', xl: 'row' }}
spacing={{ xs: 2.5, md: 3 }}
alignItems="flex-start"
>
<Card sx={{ width: '100%', flex: 1 }}>
<CardContent sx={{ padding: { xs: 2, md: 3 } }}>
<Box
component="form"
onSubmit={handleSubmit}
display="flex"
flexDirection="column"
gap={{ xs: 2.5, md: 3 }}
>
<FormSection
title="Dados do centro"
description="Defina descrição, limite e comportamento do centro de custo."
>
<TextField
label="Descrição"
value={descricao}
onChange={(event) => setDescricao(event.target.value)}
placeholder="Ex: Administrativo, Comercial, Impostos..."
fullWidth
sx={fieldSx}
/>
<TextField
label="Limite"
type="number"
value={limite}
onChange={(event) => setLimite(event.target.value)}
inputProps={{
step: '0.01',
}}
fullWidth
sx={fieldSx}
/>
<TextField
select
label="Simular"
value={simular}
onChange={(event) => setSimular(event.target.value)}
fullWidth
sx={fieldSx}
>
<MenuItem value="1">Sim</MenuItem>
<MenuItem value="0">Não</MenuItem>
</TextField>
<TextField
select
label="Investimento"
value={investimento}
onChange={(event) => setInvestimento(event.target.value)}
fullWidth
sx={fieldSx}
>
<MenuItem value="1">Sim</MenuItem>
<MenuItem value="0">Não</MenuItem>
</TextField>
<TextField
select
label="Status"
value={habilitado}
onChange={(event) => setHabilitado(event.target.value)}
fullWidth
sx={fieldSx}
>
<MenuItem value="1">Habilitado</MenuItem>
<MenuItem value="0">Desabilitado</MenuItem>
</TextField>
</FormSection>
<Stack
direction={{ xs: 'column-reverse', sm: 'row' }}
justifyContent="flex-end"
spacing={1.5}
sx={{
paddingTop: 1,
}}
>
<Button
variant="outlined"
onClick={() => navigate('/centros-custo')}
disabled={saving}
sx={{ minHeight: 46 }}
>
Cancelar
</Button>
<Button
type="submit"
variant="contained"
disabled={saving}
startIcon={
saving
? <CircularProgress size={18} color="inherit" />
: <SaveIcon />
}
sx={{
minHeight: 46,
px: 3,
boxShadow: 3,
}}
>
{saving
? 'Salvando...'
: isEdit
? 'Atualizar centro'
: 'Salvar centro'}
</Button>
</Stack>
</Box>
</CardContent>
</Card>
<Paper
elevation={0}
sx={{
width: { xs: '100%', xl: 340 },
padding: 3,
borderRadius: 4,
border: '1px solid',
borderColor: 'divider',
position: { xl: 'sticky' },
top: { xl: 24 },
background:
'linear-gradient(180deg, #FFFFFF 0%, #F8FAFC 100%)',
}}
>
<Typography variant="h6" fontWeight={900} gutterBottom>
Resumo
</Typography>
<Typography variant="body2" color="text.secondary" marginBottom={2.5}>
Prévia rápida do centro antes de salvar.
</Typography>
<Divider sx={{ marginBottom: 2.5 }} />
<Stack spacing={2}>
<Box>
<Typography variant="caption" color="text.secondary">
Descrição
</Typography>
<Typography fontWeight={800}>
{descricao || 'Sem descrição'}
</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Limite
</Typography>
<Typography variant="h4" fontWeight={950}>
{formatarValorResumo(limite)}
</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Simular
</Typography>
<Typography fontWeight={800}>
{Number(simular) === 1 ? 'Sim' : 'Não'}
</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Investimento
</Typography>
<Typography fontWeight={800}>
{Number(investimento) === 1 ? 'Sim' : 'Não'}
</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Status
</Typography>
<Typography
fontWeight={800}
color={Number(habilitado) === 1 ? 'success.main' : 'text.secondary'}
>
{Number(habilitado) === 1 ? 'Habilitado' : 'Desabilitado'}
</Typography>
</Box>
</Stack>
</Paper>
</Stack>
</Box>
);
}

View File

@ -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<CentroCusto[]>([]);
const [pagination, setPagination] = useState<CentrosCustoPagination>({
total: 0,
limite: LIMITE_PADRAO,
offset: 0,
page: 1,
totalPages: 1,
});
const [summary, setSummary] = useState<CentrosCustoSummary>({
quantidade: 0,
limiteTotal: 0,
habilitados: 0,
desabilitados: 0,
investimentos: 0,
simulaveis: 0,
});
const [alterandoStatusId, setAlterandoStatusId] = useState<number | null>(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<number | ''>('');
const [investimento, setInvestimento] = useState<number | ''>('');
const [habilitado, setHabilitado] = useState<number | ''>('');
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 (
<Box>
<Stack
direction={{ xs: 'column', md: 'row' }}
justifyContent="space-between"
alignItems={{ xs: 'stretch', md: 'center' }}
spacing={2}
marginBottom={{ xs: 3, md: 4 }}
>
<Box>
<Chip
icon={<AccountTreeIcon />}
label="Classificação financeira"
color="primary"
variant="outlined"
sx={{ marginBottom: 1.25, fontWeight: 700 }}
/>
<Typography variant="h4" fontWeight={950}>
Centros de custo
</Typography>
<Typography color="text.secondary" sx={{ marginTop: 0.5 }}>
Cadastre e organize categorias financeiras para os movimentos.
</Typography>
</Box>
<Button
component={RouterLink}
to="/centros-custo/novo"
variant="contained"
startIcon={<AddIcon />}
sx={{
minHeight: 48,
borderRadius: 3,
px: 2.5,
boxShadow: 3,
}}
>
Novo centro
</Button>
</Stack>
{erro && (
<Alert severity="error" sx={{ marginBottom: 2.5 }}>
{erro}
</Alert>
)}
{sucesso && (
<Alert severity="success" sx={{ marginBottom: 2.5 }}>
{sucesso}
</Alert>
)}
<Box
display="grid"
gridTemplateColumns={{
xs: '1fr',
md: 'repeat(4, 1fr)',
}}
gap={{ xs: 2, md: 2.5 }}
marginBottom={{ xs: 2.5, md: 3 }}
>
<Card>
<CardContent>
<Typography variant="body2" color="text.secondary">
Centros encontrados
</Typography>
<Typography variant="h5" fontWeight={950}>
{summary.quantidade}
</Typography>
</CardContent>
</Card>
<Card>
<CardContent>
<Typography variant="body2" color="text.secondary">
Limite total
</Typography>
<Typography variant="h5" fontWeight={950}>
{formatarValor(summary.limiteTotal)}
</Typography>
</CardContent>
</Card>
<Card>
<CardContent>
<Typography variant="body2" color="text.secondary">
Investimentos
</Typography>
<Typography variant="h5" fontWeight={950} color="info.main">
{summary.investimentos}
</Typography>
</CardContent>
</Card>
<Card>
<CardContent>
<Typography variant="body2" color="text.secondary">
Simuláveis
</Typography>
<Typography variant="h5" fontWeight={950} color="success.main">
{summary.simulaveis}
</Typography>
</CardContent>
</Card>
</Box>
<Card sx={{ marginBottom: { xs: 2.5, md: 3 } }}>
<CardContent sx={{ padding: { xs: 2, md: 3 } }}>
<Stack
direction="row"
spacing={1}
alignItems="center"
marginBottom={2.5}
>
<FilterAltIcon color="primary" />
<Typography variant="h6" fontWeight={900}>
Filtros
</Typography>
<Chip
label={`${filtrosAtivos} ativo(s)`}
size="small"
variant="outlined"
/>
</Stack>
<Box
display="grid"
gridTemplateColumns={{
xs: '1fr',
md: 'repeat(4, 1fr)',
}}
gap={{ xs: 2, md: 2.25 }}
>
<TextField
label="Buscar"
value={busca}
onChange={(event) => setBusca(event.target.value)}
placeholder="Descrição do centro..."
fullWidth
/>
<TextField
select
label="Simular"
value={simular}
onChange={(event) =>
setSimular(event.target.value === '' ? '' : Number(event.target.value))
}
fullWidth
>
<MenuItem value="">Todos</MenuItem>
<MenuItem value={1}>Sim</MenuItem>
<MenuItem value={0}>Não</MenuItem>
</TextField>
<TextField
select
label="Investimento"
value={investimento}
onChange={(event) =>
setInvestimento(event.target.value === '' ? '' : Number(event.target.value))
}
fullWidth
>
<MenuItem value="">Todos</MenuItem>
<MenuItem value={1}>Sim</MenuItem>
<MenuItem value={0}>Não</MenuItem>
</TextField>
<TextField
select
label="Status"
value={habilitado}
onChange={(event) =>
setHabilitado(event.target.value === '' ? '' : Number(event.target.value))
}
fullWidth
>
<MenuItem value="">Todos</MenuItem>
<MenuItem value={1}>Habilitados</MenuItem>
<MenuItem value={0}>Desabilitados</MenuItem>
</TextField>
<TextField
select
label="Ordenar por"
value={orderBy}
onChange={(event) => setOrderBy(event.target.value)}
fullWidth
>
<MenuItem value="descricao">Descrição</MenuItem>
<MenuItem value="limite">Limite</MenuItem>
<MenuItem value="simular">Simular</MenuItem>
<MenuItem value="investimento">Investimento</MenuItem>
<MenuItem value="habilitado">Status</MenuItem>
<MenuItem value="insert_date">Cadastro</MenuItem>
<MenuItem value="update_date">Atualização</MenuItem>
</TextField>
<TextField
select
label="Direção"
value={orderDirection}
onChange={(event) =>
setOrderDirection(event.target.value as 'ASC' | 'DESC')
}
fullWidth
>
<MenuItem value="ASC">Crescente</MenuItem>
<MenuItem value="DESC">Decrescente</MenuItem>
</TextField>
</Box>
<Stack
direction={{ xs: 'column', sm: 'row' }}
spacing={1.5}
justifyContent="flex-end"
marginTop={2.5}
>
<Button
variant="outlined"
startIcon={<RestartAltIcon />}
onClick={limparFiltros}
>
Limpar
</Button>
<Button
variant="contained"
startIcon={<SearchIcon />}
onClick={aplicarFiltros}
disabled={loading}
>
Aplicar filtros
</Button>
</Stack>
</CardContent>
</Card>
<Card>
<CardContent sx={{ padding: 0 }}>
{loading ? (
<Box padding={3} display="flex" alignItems="center" gap={2}>
<CircularProgress size={22} />
<Typography>Carregando centros de custo...</Typography>
</Box>
) : centros.length === 0 ? (
<Box padding={3}>
<Typography fontWeight={800}>
Nenhum centro de custo encontrado.
</Typography>
<Typography color="text.secondary" marginTop={0.5}>
Ajuste os filtros ou cadastre um novo centro de custo.
</Typography>
</Box>
) : isMobile ? (
<Stack divider={<Divider />}>
{centros.map((item) => (
<Box
key={item.idcentrodecustos}
sx={{
padding: 2,
'&:hover': {
backgroundColor: 'rgba(15,23,42,0.02)',
},
}}
>
<Stack spacing={1.25}>
<Stack
direction="row"
justifyContent="space-between"
alignItems="flex-start"
spacing={2}
>
<Box sx={{ minWidth: 0 }}>
<Typography fontWeight={900}>
{item.descricao}
</Typography>
<Typography variant="body2" color="text.secondary">
Cadastro: {formatarData(item.insert_date)}
</Typography>
</Box>
<Typography fontWeight={950} whiteSpace="nowrap">
{formatarValor(item.limite)}
</Typography>
</Stack>
<Stack direction="row" flexWrap="wrap" spacing={1} useFlexGap>
<Chip
label={flagLabel(item.simular, 'Simular', 'Não simular')}
size="small"
color={flagColor(item.simular) as any}
variant="outlined"
/>
<Chip
label={flagLabel(item.investimento, 'Investimento', 'Operacional')}
size="small"
color={Number(item.investimento) === 1 ? 'info' : 'default'}
variant="outlined"
/>
<Chip
label={statusLabel(item.habilitado)}
size="small"
color={flagColor(item.habilitado) as any}
variant={Number(item.habilitado) === 1 ? 'filled' : 'outlined'}
/>
</Stack>
<Box
display="flex"
justifyContent="flex-end"
alignItems="center"
gap={1}
>
<Tooltip
title={
Number(item.habilitado) === 1
? 'Desabilitar centro'
: 'Habilitar centro'
}
>
<span>
<Switch
size="small"
checked={Number(item.habilitado) === 1}
disabled={
alterandoStatusId === item.idcentrodecustos || loading
}
onChange={() => handleAlterarStatusCentroCusto(item)}
/>
</span>
</Tooltip>
<Button
component={RouterLink}
to={`/centros-custo/${item.idcentrodecustos}/editar`}
variant="outlined"
size="small"
startIcon={<EditIcon />}
>
Editar
</Button>
<Button
variant="outlined"
color="error"
size="small"
startIcon={<DeleteIcon />}
onClick={() => handleDeletarCentroCusto(item)}
>
Excluir
</Button>
</Box>
</Stack>
</Box>
))}
</Stack>
) : (
<TableContainer sx={{ overflowX: 'auto' }}>
<Table
size="small"
sx={{
minWidth: 1050,
'& th': {
whiteSpace: 'nowrap',
fontWeight: 900,
backgroundColor: 'rgba(15,23,42,0.04)',
},
'& td': {
whiteSpace: 'nowrap',
},
}}
>
<TableHead>
<TableRow>
<TableCell sx={{ minWidth: 260 }}>Descrição</TableCell>
<TableCell sx={{ minWidth: 140 }} align="right">Limite</TableCell>
<TableCell sx={{ minWidth: 120 }}>Simular</TableCell>
<TableCell sx={{ minWidth: 150 }}>Investimento</TableCell>
<TableCell sx={{ minWidth: 130 }}>Status</TableCell>
<TableCell sx={{ minWidth: 130 }}>Cadastro</TableCell>
<TableCell sx={{ minWidth: 130 }}>Atualização</TableCell>
<TableCell sx={{ minWidth: 130 }} align="center">Ações</TableCell>
</TableRow>
</TableHead>
<TableBody>
{centros.map((item) => (
<TableRow
key={item.idcentrodecustos}
hover
sx={{
'&:last-child td': {
borderBottom: 0,
},
}}
>
<TableCell>
<Box sx={{ maxWidth: 280 }}>
<Typography fontWeight={800} noWrap title={item.descricao}>
{item.descricao}
</Typography>
</Box>
</TableCell>
<TableCell align="right">
<Typography fontWeight={950}>
{formatarValor(item.limite)}
</Typography>
</TableCell>
<TableCell>
<Chip
label={Number(item.simular) === 1 ? 'Sim' : 'Não'}
size="small"
color={flagColor(item.simular) as any}
variant="outlined"
/>
</TableCell>
<TableCell>
<Chip
label={Number(item.investimento) === 1 ? 'Sim' : 'Não'}
size="small"
color={Number(item.investimento) === 1 ? 'info' : 'default'}
variant="outlined"
/>
</TableCell>
<TableCell>
<Chip
label={statusLabel(item.habilitado)}
size="small"
color={flagColor(item.habilitado) as any}
variant={Number(item.habilitado) === 1 ? 'filled' : 'outlined'}
/>
</TableCell>
<TableCell>{formatarData(item.insert_date)}</TableCell>
<TableCell>{formatarData(item.update_date)}</TableCell>
<TableCell align="center">
<Stack
direction="row"
spacing={0.5}
alignItems="center"
justifyContent="center"
>
<Tooltip
title={
Number(item.habilitado) === 1
? 'Desabilitar centro'
: 'Habilitar centro'
}
>
<span>
<Switch
size="small"
checked={Number(item.habilitado) === 1}
disabled={
alterandoStatusId === item.idcentrodecustos || loading
}
onChange={() => handleAlterarStatusCentroCusto(item)}
/>
</span>
</Tooltip>
<Tooltip title="Editar centro">
<IconButton
component={RouterLink}
to={`/centros-custo/${item.idcentrodecustos}/editar`}
color="primary"
size="small"
>
<EditIcon />
</IconButton>
</Tooltip>
<Tooltip title="Excluir centro">
<IconButton
color="error"
size="small"
onClick={() => handleDeletarCentroCusto(item)}
>
<DeleteIcon />
</IconButton>
</Tooltip>
</Stack>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
</CardContent>
</Card>
{pagination.totalPages > 1 && (
<Stack alignItems="center" marginTop={3}>
<Pagination
page={pagination.page}
count={pagination.totalPages}
color="primary"
onChange={(_, novaPagina) => {
setPage(novaPagina);
carregarCentrosCusto(novaPagina);
}}
/>
</Stack>
)}
<Paper
elevation={0}
sx={{
marginTop: 2,
padding: 2,
border: '1px solid',
borderColor: 'divider',
borderRadius: 3,
backgroundColor: 'rgba(255,255,255,0.65)',
}}
>
<Typography variant="body2" color="text.secondary">
Exibindo página {pagination.page} de {pagination.totalPages}, total de{' '}
<strong>{pagination.total}</strong> registro(s).
</Typography>
</Paper>
</Box>
);
}

View File

@ -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<CentroCusto | null>(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 (
<Box padding={3} display="flex" alignItems="center" gap={2}>
<CircularProgress size={22} />
<Typography>Carregando centro de custo...</Typography>
</Box>
);
}
if (erro) {
return (
<Box padding={3}>
<Alert severity="error">{erro}</Alert>
</Box>
);
}
return <CentroCustoForm mode="edit" initialData={centroCusto} />;
}

View File

@ -0,0 +1,5 @@
import { CentroCustoForm } from '../components/CentroCustoForm';
export function NovoCentroCustoPage() {
return <CentroCustoForm mode="create" />;
}

View File

@ -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<CentrosCustoListResponse> {
const response = await api.get<CentrosCustoListResponse>('/centros-custo', {
params,
});
return response.data;
}
export async function buscarCentroCustoPorId(id: number): Promise<CentroCusto> {
const response = await api.get<ApiResponse<CentroCusto>>(`/centros-custo/${id}`);
return response.data.data;
}
export async function criarCentroCusto(
data: CriarCentroCustoRequest
): Promise<CentroCusto> {
const response = await api.post<ApiResponse<CentroCusto>>('/centros-custo', data);
return response.data.data;
}
export async function atualizarCentroCusto(
id: number,
data: AtualizarCentroCustoRequest
): Promise<CentroCusto> {
const response = await api.put<ApiResponse<CentroCusto>>(`/centros-custo/${id}`, data);
return response.data.data;
}
export async function alterarHabilitadoCentroCusto(
id: number,
habilitado: number
): Promise<CentroCusto> {
const response = await api.patch<ApiResponse<CentroCusto>>(
`/centros-custo/${id}/habilitado`,
{ habilitado }
);
return response.data.data;
}
export async function deletarCentroCusto(id: number): Promise<void> {
await api.delete(`/centros-custo/${id}`);
}

View File

@ -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<T> = {
ok: boolean;
message?: string;
data: T;
};
export type CentrosCustoListResponse = {
ok: boolean;
data: CentroCusto[];
pagination: CentrosCustoPagination;
summary: CentrosCustoSummary;
};

View File

@ -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 (
<Paper
elevation={0}
sx={{
padding: { xs: 2.5, md: 3 },
borderRadius: 4,
border: '1px solid',
borderColor: 'divider',
backgroundColor: '#FFFFFF',
}}
>
<Box marginBottom={3}>
<Typography variant="h6" fontWeight={900}>
{title}
</Typography>
{description && (
<Typography variant="body2" color="text.secondary" marginTop={0.25}>
{description}
</Typography>
)}
</Box>
<Box
display="grid"
gridTemplateColumns={{
xs: '1fr',
md: '1fr 1fr',
}}
columnGap={{ xs: 2, md: 2.5 }}
rowGap={{ xs: 3, md: 3.25 }}
>
{children}
</Box>
</Paper>
);
}
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<HTMLFormElement>) {
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 (
<Box>
<Stack
direction={{ xs: 'column', md: 'row' }}
justifyContent="space-between"
alignItems={{ xs: 'stretch', md: 'center' }}
spacing={2}
marginBottom={{ xs: 3, md: 4 }}
>
<Box>
<Chip
icon={<PersonIcon />}
label={isEdit ? 'Edição de cliente' : 'Cadastro de cliente'}
color="primary"
variant="outlined"
sx={{ marginBottom: 1.25, fontWeight: 700 }}
/>
<Typography variant="h4" fontWeight={950}>
{titulo}
</Typography>
<Typography color="text.secondary" sx={{ marginTop: 0.5 }}>
{subtitulo}
</Typography>
</Box>
<Button
variant="outlined"
startIcon={<ArrowBackIcon />}
onClick={() => navigate('/clientes')}
sx={{
minHeight: 48,
borderRadius: 3,
px: 2.5,
}}
>
Voltar para lista
</Button>
</Stack>
{erro && (
<Alert severity="error" sx={{ marginBottom: 2.5 }}>
{erro}
</Alert>
)}
{sucesso && (
<Alert severity="success" sx={{ marginBottom: 2.5 }}>
{sucesso}
</Alert>
)}
<Stack
direction={{ xs: 'column', xl: 'row' }}
spacing={{ xs: 2.5, md: 3 }}
alignItems="flex-start"
>
<Card sx={{ width: '100%', flex: 1 }}>
<CardContent sx={{ padding: { xs: 2, md: 3 } }}>
<Box
component="form"
onSubmit={handleSubmit}
display="flex"
flexDirection="column"
gap={{ xs: 2.5, md: 3 }}
>
<FormSection
title="Dados principais"
description="Informe identificação, tipo de pessoa e status do cadastro."
>
<TextField
select
label="Tipo de pessoa"
value={pessoafisica}
onChange={(event) => setPessoafisica(event.target.value)}
fullWidth
sx={fieldSx}
>
<MenuItem value="F">Pessoa física</MenuItem>
<MenuItem value="J">Pessoa jurídica</MenuItem>
</TextField>
<TextField
select
label="Status"
value={habilitado}
onChange={(event) => setHabilitado(event.target.value)}
fullWidth
sx={fieldSx}
>
<MenuItem value="1">Habilitado</MenuItem>
<MenuItem value="0">Desabilitado</MenuItem>
</TextField>
<TextField
label="CPF/CNPJ"
value={cpfCnpj}
onChange={(event) => setCpfCnpj(event.target.value)}
fullWidth
sx={fieldSx}
/>
<TextField
label="Nome"
value={nome}
onChange={(event) => setNome(event.target.value)}
fullWidth
sx={fieldSx}
/>
<TextField
label="RG/IE"
value={rg}
onChange={(event) => setRg(event.target.value)}
fullWidth
sx={fieldSx}
/>
<TextField
select
label="Sexo"
value={sexo}
onChange={(event) => setSexo(event.target.value)}
fullWidth
sx={fieldSx}
disabled={pessoafisica === 'J'}
>
<MenuItem value="">Não informado</MenuItem>
<MenuItem value="M">Masculino</MenuItem>
<MenuItem value="F">Feminino</MenuItem>
<MenuItem value="O">Outro</MenuItem>
</TextField>
</FormSection>
<FormSection
title="Contato"
description="Dados usados para comunicação e identificação rápida."
>
<TextField
label="Celular"
value={celular}
onChange={(event) => setCelular(event.target.value)}
fullWidth
sx={fieldSx}
/>
<TextField
label="E-mail"
type="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
fullWidth
sx={fieldSx}
/>
</FormSection>
<FormSection
title="Endereço"
description="Preencha os dados de localização do cliente."
>
<TextField
label="CEP"
value={cep}
onChange={(event) => setCep(event.target.value)}
fullWidth
sx={fieldSx}
/>
<TextField
label="Logradouro"
value={logradouro}
onChange={(event) => setLogradouro(event.target.value)}
fullWidth
sx={fieldSx}
/>
<TextField
label="Número"
value={numero}
onChange={(event) => setNumero(event.target.value)}
fullWidth
sx={fieldSx}
/>
<TextField
label="Bairro"
value={bairro}
onChange={(event) => setBairro(event.target.value)}
fullWidth
sx={fieldSx}
/>
<TextField
label="Cidade"
value={cidade}
onChange={(event) => setCidade(event.target.value)}
fullWidth
sx={fieldSx}
/>
<TextField
label="Estado"
value={estado}
onChange={(event) => setEstado(event.target.value)}
inputProps={{ maxLength: 2 }}
fullWidth
sx={fieldSx}
/>
</FormSection>
<Stack
direction={{ xs: 'column-reverse', sm: 'row' }}
justifyContent="flex-end"
spacing={1.5}
sx={{
paddingTop: 1,
}}
>
<Button
variant="outlined"
onClick={() => navigate('/clientes')}
disabled={saving}
sx={{ minHeight: 46 }}
>
Cancelar
</Button>
<Button
type="submit"
variant="contained"
disabled={saving}
startIcon={
saving
? <CircularProgress size={18} color="inherit" />
: <SaveIcon />
}
sx={{
minHeight: 46,
px: 3,
boxShadow: 3,
}}
>
{saving
? 'Salvando...'
: isEdit
? 'Atualizar cliente'
: 'Salvar cliente'}
</Button>
</Stack>
</Box>
</CardContent>
</Card>
<Paper
elevation={0}
sx={{
width: { xs: '100%', xl: 340 },
padding: 3,
borderRadius: 4,
border: '1px solid',
borderColor: 'divider',
position: { xl: 'sticky' },
top: { xl: 24 },
background:
'linear-gradient(180deg, #FFFFFF 0%, #F8FAFC 100%)',
}}
>
<Typography variant="h6" fontWeight={900} gutterBottom>
Resumo
</Typography>
<Typography variant="body2" color="text.secondary" marginBottom={2.5}>
Prévia rápida do cliente antes de salvar.
</Typography>
<Divider sx={{ marginBottom: 2.5 }} />
<Stack spacing={2}>
<Box>
<Typography variant="caption" color="text.secondary">
Nome
</Typography>
<Typography fontWeight={800}>
{nome || 'Sem nome'}
</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
CPF/CNPJ
</Typography>
<Typography fontWeight={800}>
{cpfCnpj || '-'}
</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Tipo
</Typography>
<Typography fontWeight={800}>
{pessoafisica === 'J' ? 'Pessoa jurídica' : 'Pessoa física'}
</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Status
</Typography>
<Typography
fontWeight={800}
color={Number(habilitado) === 1 ? 'success.main' : 'text.secondary'}
>
{Number(habilitado) === 1 ? 'Habilitado' : 'Desabilitado'}
</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Local
</Typography>
<Typography fontWeight={800}>
{[cidade, estado].filter(Boolean).join(' / ') || '-'}
</Typography>
</Box>
</Stack>
</Paper>
</Stack>
</Box>
);
}

View File

@ -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<Cliente[]>([]);
const [pagination, setPagination] = useState<ClientesPagination>({
total: 0,
limite: LIMITE_PADRAO,
offset: 0,
page: 1,
totalPages: 1,
});
const [alterandoStatusId, setAlterandoStatusId] = useState<number | null>(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<number | ''>('');
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 (
<Box>
<Stack
direction={{ xs: 'column', md: 'row' }}
justifyContent="space-between"
alignItems={{ xs: 'stretch', md: 'center' }}
spacing={2}
marginBottom={{ xs: 3, md: 4 }}
>
<Box>
<Chip
icon={<PersonIcon />}
label="Cadastro de clientes"
color="primary"
variant="outlined"
sx={{ marginBottom: 1.25, fontWeight: 700 }}
/>
<Typography variant="h4" fontWeight={950}>
Clientes
</Typography>
<Typography color="text.secondary" sx={{ marginTop: 0.5 }}>
Consulte, filtre e gerencie os clientes usados nos movimentos.
</Typography>
</Box>
<Button
component={RouterLink}
to="/clientes/novo"
variant="contained"
startIcon={<AddIcon />}
sx={{
minHeight: 48,
borderRadius: 3,
px: 2.5,
boxShadow: 3,
}}
>
Novo cliente
</Button>
</Stack>
{erro && (
<Alert severity="error" sx={{ marginBottom: 2.5 }}>
{erro}
</Alert>
)}
{sucesso && (
<Alert severity="success" sx={{ marginBottom: 2.5 }}>
{sucesso}
</Alert>
)}
<Box
display="grid"
gridTemplateColumns={{
xs: '1fr',
md: 'repeat(3, 1fr)',
}}
gap={{ xs: 2, md: 2.5 }}
marginBottom={{ xs: 2.5, md: 3 }}
>
<Card>
<CardContent>
<Typography variant="body2" color="text.secondary">
Clientes encontrados
</Typography>
<Typography variant="h5" fontWeight={950}>
{pagination.total}
</Typography>
</CardContent>
</Card>
<Card>
<CardContent>
<Typography variant="body2" color="text.secondary">
Página atual
</Typography>
<Typography variant="h5" fontWeight={950}>
{pagination.page} / {pagination.totalPages}
</Typography>
</CardContent>
</Card>
<Card>
<CardContent>
<Typography variant="body2" color="text.secondary">
Exibindo
</Typography>
<Typography variant="h5" fontWeight={950}>
{clientes.length}
</Typography>
</CardContent>
</Card>
</Box>
<Card sx={{ marginBottom: { xs: 2.5, md: 3 } }}>
<CardContent sx={{ padding: { xs: 2, md: 3 } }}>
<Stack
direction="row"
spacing={1}
alignItems="center"
marginBottom={2.5}
>
<FilterAltIcon color="primary" />
<Typography variant="h6" fontWeight={900}>
Filtros
</Typography>
<Chip
label={`${filtrosAtivos} ativo(s)`}
size="small"
variant="outlined"
/>
</Stack>
<Box
display="grid"
gridTemplateColumns={{
xs: '1fr',
md: 'repeat(4, 1fr)',
}}
gap={{ xs: 2, md: 2.25 }}
>
<TextField
label="Buscar"
value={busca}
onChange={(event) => setBusca(event.target.value)}
placeholder="Nome, CPF/CNPJ, celular, e-mail..."
fullWidth
/>
<TextField
select
label="Tipo de pessoa"
value={pessoafisica}
onChange={(event) => setPessoafisica(event.target.value)}
fullWidth
>
<MenuItem value="">Todos</MenuItem>
<MenuItem value="F">Pessoa física</MenuItem>
<MenuItem value="J">Pessoa jurídica</MenuItem>
</TextField>
<TextField
select
label="Status"
value={habilitado}
onChange={(event) =>
setHabilitado(event.target.value === '' ? '' : Number(event.target.value))
}
fullWidth
>
<MenuItem value="">Todos</MenuItem>
<MenuItem value={1}>Habilitados</MenuItem>
<MenuItem value={0}>Desabilitados</MenuItem>
</TextField>
<TextField
label="Cidade"
value={cidade}
onChange={(event) => setCidade(event.target.value)}
fullWidth
/>
<TextField
label="Estado"
value={estado}
onChange={(event) => setEstado(event.target.value)}
fullWidth
/>
<TextField
select
label="Ordenar por"
value={orderBy}
onChange={(event) => setOrderBy(event.target.value)}
fullWidth
>
<MenuItem value="nome">Nome</MenuItem>
<MenuItem value="cpf_cnpj">CPF/CNPJ</MenuItem>
<MenuItem value="email">E-mail</MenuItem>
<MenuItem value="cidade">Cidade</MenuItem>
<MenuItem value="estado">Estado</MenuItem>
<MenuItem value="pessoafisica">Tipo</MenuItem>
<MenuItem value="habilitado">Status</MenuItem>
<MenuItem value="insert_date">Cadastro</MenuItem>
<MenuItem value="update_date">Atualização</MenuItem>
</TextField>
<TextField
select
label="Direção"
value={orderDirection}
onChange={(event) =>
setOrderDirection(event.target.value as 'ASC' | 'DESC')
}
fullWidth
>
<MenuItem value="ASC">Crescente</MenuItem>
<MenuItem value="DESC">Decrescente</MenuItem>
</TextField>
</Box>
<Stack
direction={{ xs: 'column', sm: 'row' }}
spacing={1.5}
justifyContent="flex-end"
marginTop={2.5}
>
<Button
variant="outlined"
startIcon={<RestartAltIcon />}
onClick={limparFiltros}
>
Limpar
</Button>
<Button
variant="contained"
startIcon={<SearchIcon />}
onClick={aplicarFiltros}
disabled={loading}
>
Aplicar filtros
</Button>
</Stack>
</CardContent>
</Card>
<Card>
<CardContent sx={{ padding: 0 }}>
{loading ? (
<Box padding={3} display="flex" alignItems="center" gap={2}>
<CircularProgress size={22} />
<Typography>Carregando clientes...</Typography>
</Box>
) : clientes.length === 0 ? (
<Box padding={3}>
<Typography fontWeight={800}>
Nenhum cliente encontrado.
</Typography>
<Typography color="text.secondary" marginTop={0.5}>
Ajuste os filtros ou cadastre um novo cliente.
</Typography>
</Box>
) : isMobile ? (
<Stack divider={<Divider />}>
{clientes.map((item) => (
<Box
key={item.idclientes}
sx={{
padding: 2,
'&:hover': {
backgroundColor: 'rgba(15,23,42,0.02)',
},
}}
>
<Stack spacing={1.25}>
<Stack
direction="row"
justifyContent="space-between"
alignItems="flex-start"
spacing={2}
>
<Box sx={{ minWidth: 0 }}>
<Typography fontWeight={900}>
{item.nome}
</Typography>
<Typography variant="body2" color="text.secondary">
{item.cpf_cnpj || '-'}
</Typography>
</Box>
</Stack>
<Stack direction="row" flexWrap="wrap" spacing={1} useFlexGap>
<Chip
label={pessoaLabel(item.pessoafisica)}
size="small"
color={pessoaColor(item.pessoafisica) as any}
variant="outlined"
/>
<Chip
label={statusClienteLabel(item.habilitado)}
size="small"
color={statusClienteColor(item.habilitado) as any}
variant={Number(item.habilitado) === 1 ? 'filled' : 'outlined'}
/>
</Stack>
<Typography variant="body2" color="text.secondary">
Celular: <strong>{item.celular || '-'}</strong>
</Typography>
<Typography variant="body2" color="text.secondary">
E-mail: <strong>{item.email || '-'}</strong>
</Typography>
<Typography variant="body2" color="text.secondary">
Local: <strong>{enderecoResumo(item)}</strong>
</Typography>
<Box
display="flex"
justifyContent="flex-end"
alignItems="center"
gap={1}
>
<Tooltip
title={
Number(item.habilitado) === 1
? 'Desabilitar cliente'
: 'Habilitar cliente'
}
>
<span>
<Switch
size="small"
checked={Number(item.habilitado) === 1}
disabled={alterandoStatusId === item.idclientes || loading}
onChange={() => handleAlterarStatusCliente(item)}
/>
</span>
</Tooltip>
<Button
component={RouterLink}
to={`/clientes/${item.idclientes}/editar`}
variant="outlined"
size="small"
startIcon={<EditIcon />}
>
Editar
</Button>
<Button
variant="outlined"
color="error"
size="small"
startIcon={<DeleteIcon />}
onClick={() => handleDeletarCliente(item)}
>
Excluir
</Button>
</Box>
</Stack>
</Box>
))}
</Stack>
) : (
<TableContainer
sx={{
overflowX: 'auto',
}}
>
<Table
size="small"
sx={{
minWidth: 1250,
'& th': {
whiteSpace: 'nowrap',
fontWeight: 900,
backgroundColor: 'rgba(15,23,42,0.04)',
},
'& td': {
whiteSpace: 'nowrap',
},
}}
>
<TableHead>
<TableRow>
<TableCell sx={{ minWidth: 250 }}>Nome</TableCell>
<TableCell sx={{ minWidth: 150 }}>CPF/CNPJ</TableCell>
<TableCell sx={{ minWidth: 140 }}>Tipo</TableCell>
<TableCell sx={{ minWidth: 150 }}>Celular</TableCell>
<TableCell sx={{ minWidth: 220 }}>E-mail</TableCell>
<TableCell sx={{ minWidth: 150 }}>Cidade</TableCell>
<TableCell sx={{ minWidth: 90 }}>Estado</TableCell>
<TableCell sx={{ minWidth: 130 }}>Status</TableCell>
<TableCell sx={{ minWidth: 130 }}>Cadastro</TableCell>
<TableCell sx={{ minWidth: 130 }}>Atualização</TableCell>
<TableCell sx={{ minWidth: 130 }} align="center">Ações</TableCell>
</TableRow>
</TableHead>
<TableBody>
{clientes.map((item) => (
<TableRow
key={item.idclientes}
hover
sx={{
'&:last-child td': {
borderBottom: 0,
},
}}
>
<TableCell>
<Box sx={{ maxWidth: 280 }}>
<Typography fontWeight={800} noWrap title={item.nome}>
{item.nome}
</Typography>
</Box>
</TableCell>
<TableCell>{item.cpf_cnpj || '-'}</TableCell>
<TableCell>
<Chip
label={pessoaLabel(item.pessoafisica)}
size="small"
color={pessoaColor(item.pessoafisica) as any}
variant="outlined"
/>
</TableCell>
<TableCell>{item.celular || '-'}</TableCell>
<TableCell>
<Box sx={{ maxWidth: 220 }}>
<Typography variant="body2" noWrap title={item.email || '-'}>
{item.email || '-'}
</Typography>
</Box>
</TableCell>
<TableCell>{item.cidade || '-'}</TableCell>
<TableCell>{item.estado || '-'}</TableCell>
<TableCell>
<Chip
label={statusClienteLabel(item.habilitado)}
size="small"
color={statusClienteColor(item.habilitado) as any}
variant={Number(item.habilitado) === 1 ? 'filled' : 'outlined'}
/>
</TableCell>
<TableCell>{formatarData(item.insert_date)}</TableCell>
<TableCell>{formatarData(item.update_date)}</TableCell>
<TableCell align="center">
<Stack
direction="row"
spacing={0.5}
alignItems="center"
justifyContent="center"
>
<Tooltip
title={
Number(item.habilitado) === 1
? 'Desabilitar cliente'
: 'Habilitar cliente'
}
>
<span>
<Switch
size="small"
checked={Number(item.habilitado) === 1}
disabled={alterandoStatusId === item.idclientes || loading}
onChange={() => handleAlterarStatusCliente(item)}
/>
</span>
</Tooltip>
<Tooltip title="Editar cliente">
<IconButton
component={RouterLink}
to={`/clientes/${item.idclientes}/editar`}
color="primary"
size="small"
>
<EditIcon />
</IconButton>
</Tooltip>
<Tooltip title="Excluir cliente">
<IconButton
color="error"
size="small"
onClick={() => handleDeletarCliente(item)}
>
<DeleteIcon />
</IconButton>
</Tooltip>
</Stack>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
</CardContent>
</Card>
{pagination.totalPages > 1 && (
<Stack alignItems="center" marginTop={3}>
<Pagination
page={pagination.page}
count={pagination.totalPages}
color="primary"
onChange={(_, novaPagina) => {
setPage(novaPagina);
carregarClientes(novaPagina);
}}
/>
</Stack>
)}
<Paper
elevation={0}
sx={{
marginTop: 2,
padding: 2,
border: '1px solid',
borderColor: 'divider',
borderRadius: 3,
backgroundColor: 'rgba(255,255,255,0.65)',
}}
>
<Typography variant="body2" color="text.secondary">
Exibindo página {pagination.page} de {pagination.totalPages}, total de{' '}
<strong>{pagination.total}</strong> registro(s).
</Typography>
</Paper>
</Box>
);
}

View File

@ -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<Cliente | null>(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 (
<Box padding={3} display="flex" alignItems="center" gap={2}>
<CircularProgress size={22} />
<Typography>Carregando cliente...</Typography>
</Box>
);
}
if (erro) {
return (
<Box padding={3}>
<Alert severity="error">{erro}</Alert>
</Box>
);
}
return <ClienteForm mode="edit" initialData={cliente} />;
}

View File

@ -0,0 +1,5 @@
import { ClienteForm } from '../components/ClienteForm';
export function NovoClientePage() {
return <ClienteForm mode="create" />;
}

View File

@ -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<ClientesListResponse> {
const response = await api.get<ClientesListResponse>('/clientes', {
params,
});
return response.data;
}
export async function buscarClientePorId(id: number): Promise<Cliente> {
const response = await api.get<ApiResponse<Cliente>>(`/clientes/${id}`);
return response.data.data;
}
export async function criarCliente(
data: CriarClienteRequest
): Promise<Cliente> {
const response = await api.post<ApiResponse<Cliente>>('/clientes', data);
return response.data.data;
}
export async function atualizarCliente(
id: number,
data: AtualizarClienteRequest
): Promise<Cliente> {
const response = await api.put<ApiResponse<Cliente>>(`/clientes/${id}`, data);
return response.data.data;
}
export async function alterarHabilitadoCliente(
id: number,
habilitado: number
): Promise<Cliente> {
const response = await api.patch<ApiResponse<Cliente>>(`/clientes/${id}/habilitado`, {
habilitado,
});
return response.data.data;
}
export async function deletarCliente(id: number): Promise<void> {
await api.delete(`/clientes/${id}`);
}

View File

@ -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<T> = {
ok: boolean;
message?: string;
data: T;
};
export type ClientesListResponse = {
ok: boolean;
data: Cliente[];
pagination: ClientesPagination;
};

View File

@ -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 (
<Paper
@ -155,11 +173,19 @@ export function MovimentoForm({ mode, initialData }: MovimentoFormProps) {
const [idBancoReferencia, setIdBancoReferencia] = useState('');
const [observacao, setObservacao] = useState('');
const [modoParcelamento, setModoParcelamento] = useState<ModoParcelamento>('valor_parcela');
const [gerarParcelas, setGerarParcelas] = useState(true);
const [impactoSaldo, setImpactoSaldo] = useState<MovimentoImpactoSaldo[]>([]);
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<MovimentoStatus[]>(() => {
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 (
<Box>
<Stack
@ -474,6 +643,44 @@ export function MovimentoForm({ mode, initialData }: MovimentoFormProps) {
sx={fieldSx}
/>
{temParcelamento && (
<Box sx={{ gridColumn: { xs: 'auto', md: '1 / -1' } }}>
<Typography variant="body2" fontWeight={800} marginBottom={1}>
Como tratar o valor informado?
</Typography>
<RadioGroup
row
value={modoParcelamento}
onChange={(event) =>
setModoParcelamento(event.target.value as ModoParcelamento)
}
>
<FormControlLabel
value="valor_parcela"
control={<Radio />}
label="Valor de cada parcela"
/>
<FormControlLabel
value="valor_total"
control={<Radio />}
label="Valor total, dividir entre parcelas"
/>
</RadioGroup>
<FormControlLabel
control={
<Radio
checked={gerarParcelas}
onChange={() => setGerarParcelas(!gerarParcelas)}
/>
}
label="Gerar parcelas automaticamente"
/>
</Box>
)}
<TextField
label="Data de entrada"
type="date"
@ -709,6 +916,72 @@ export function MovimentoForm({ mode, initialData }: MovimentoFormProps) {
{descricao || 'Sem descrição'}
</Typography>
</Box>
<Divider />
<Box>
<Typography variant="caption" color="text.secondary">
Impacto previsto no saldo
</Typography>
{loadingImpacto ? (
<Box display="flex" alignItems="center" gap={1} marginTop={1}>
<CircularProgress size={16} />
<Typography variant="body2">Calculando...</Typography>
</Box>
) : impactoSaldo.length === 0 ? (
<Typography fontWeight={700} marginTop={0.5}>
Sem impacto imediato
</Typography>
) : (
<Stack spacing={1} marginTop={1}>
{impactoSaldo.map((impacto, index) => (
<Box
key={`${impacto.idbancos}-${index}`}
sx={{
padding: 1.25,
borderRadius: 2,
backgroundColor: 'rgba(15,23,42,0.04)',
}}
>
<Typography variant="body2" fontWeight={900}>
{impacto.banco_descricao || `Banco ${impacto.idbancos}`}
</Typography>
<Typography
variant="body2"
fontWeight={900}
color={impacto.valor_delta >= 0 ? 'success.main' : 'error.main'}
>
{impacto.valor_delta >= 0 ? '+' : ''}
{formatarValorMoeda(impacto.valor_delta)}
</Typography>
{impacto.saldo_anterior !== undefined && (
<Typography variant="caption" color="text.secondary">
{formatarValorMoeda(impacto.saldo_anterior)} {' '}
{formatarValorMoeda(impacto.saldo_posterior || 0)}
</Typography>
)}
</Box>
))}
</Stack>
)}
</Box>
{temParcelamento && (
<Box>
<Typography variant="caption" color="text.secondary">
Parcelamento
</Typography>
<Typography fontWeight={700}>
{parcelas}x ·{' '}
{modoParcelamento === 'valor_total'
? 'dividindo valor total'
: 'valor por parcela'}
</Typography>
</Box>
)}
</Stack>
</Paper>
</Stack>

View File

@ -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<Movimento[]>([]);
const [pagination, setPagination] = useState<MovimentosPagination>({
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<number | ''>('');
const [idCliente, setIdCliente] = useState<number | ''>('');
const [referenciaTipo, setReferenciaTipo] = useState('');
const [competencia, setCompetencia] = useState('');
const [origem, setOrigem] = useState('');
const [saldoProcessado, setSaldoProcessado] = useState<number | ''>('');
const [incluirExcluidos, setIncluirExcluidos] = useState<number | ''>('');
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<string, string> = {
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() {
</Alert>
)}
{sucesso && (
<Alert severity="success" sx={{ marginBottom: 2.5 }}>
{sucesso}
</Alert>
)}
<Box
display="grid"
gridTemplateColumns={{
xs: '1fr',
md: 'repeat(4, 1fr)',
md: 'repeat(6, 1fr)',
}}
gap={{ xs: 2, md: 2.5 }}
marginBottom={{ xs: 2.5, md: 3 }}
@ -378,6 +474,28 @@ export function MovimentosPage() {
</Typography>
</CardContent>
</Card>
<Card>
<CardContent>
<Typography variant="body2" color="text.secondary">
Em aberto
</Typography>
<Typography variant="h5" fontWeight={950} color="warning.main">
{formatarValor(summary.totalAberto)}
</Typography>
</CardContent>
</Card>
<Card>
<CardContent>
<Typography variant="body2" color="text.secondary">
Baixados
</Typography>
<Typography variant="h5" fontWeight={950} color="info.main">
{formatarValor(summary.totalBaixado)}
</Typography>
</CardContent>
</Card>
</Box>
<Card sx={{ marginBottom: { xs: 2.5, md: 3 } }}>
@ -428,6 +546,7 @@ export function MovimentosPage() {
<MenuItem value="databaixa">Data de baixa</MenuItem>
<MenuItem value="insert_date">Data de cadastro</MenuItem>
<MenuItem value="update_date">Data de atualização</MenuItem>
<MenuItem value="deleted_at">Data de exclusão</MenuItem>
</TextField>
<TextField
@ -542,6 +661,57 @@ export function MovimentosPage() {
<MenuItem value="banco">Com banco referência</MenuItem>
<MenuItem value="sem_referencia">Sem referência</MenuItem>
</TextField>
<TextField
label="Competência"
type="month"
value={competencia}
onChange={(event) => setCompetencia(event.target.value)}
InputLabelProps={{ shrink: true }}
fullWidth
/>
<TextField
select
label="Origem"
value={origem}
onChange={(event) => setOrigem(event.target.value)}
fullWidth
>
<MenuItem value="">Todas</MenuItem>
<MenuItem value="manual">Manual</MenuItem>
<MenuItem value="parcelamento">Parcelamento</MenuItem>
<MenuItem value="movimento_fixo">Movimento fixo</MenuItem>
<MenuItem value="quitacao">Quitação</MenuItem>
<MenuItem value="ajuste_saldo">Ajuste de saldo</MenuItem>
</TextField>
<TextField
select
label="Saldo processado"
value={saldoProcessado}
onChange={(event) =>
setSaldoProcessado(event.target.value === '' ? '' : Number(event.target.value))
}
fullWidth
>
<MenuItem value="">Todos</MenuItem>
<MenuItem value={1}>Sim</MenuItem>
<MenuItem value={0}>Não</MenuItem>
</TextField>
<TextField
select
label="Excluídos"
value={incluirExcluidos}
onChange={(event) =>
setIncluirExcluidos(event.target.value === '' ? '' : Number(event.target.value))
}
fullWidth
>
<MenuItem value="">Somente ativos</MenuItem>
<MenuItem value={1}>Incluir excluídos</MenuItem>
</TextField>
</Box>
<Stack
@ -650,6 +820,29 @@ export function MovimentosPage() {
size="small"
variant="outlined"
/>
<Chip
label={origemLabel(item.origem)}
size="small"
color={origemChipColor(item.origem) as any}
variant="outlined"
/>
<Chip
label={saldoProcessadoLabel(item.saldo_processado)}
size="small"
color={saldoProcessadoColor(item.saldo_processado) as any}
variant={Number(item.saldo_processado) === 1 ? 'filled' : 'outlined'}
/>
{movimentoExcluido(item) && (
<Chip
label="Excluído"
size="small"
color="error"
variant="outlined"
/>
)}
</Stack>
<Typography variant="body2" color="text.secondary">
@ -664,6 +857,14 @@ export function MovimentosPage() {
Referência: <strong>{getReferencia(item)}</strong>
</Typography>
<Typography variant="body2" color="text.secondary">
Competência: <strong>{item.competencia || '-'}</strong>
</Typography>
<Typography variant="body2" color="text.secondary">
Origem: <strong>{origemLabel(item.origem)}</strong>
</Typography>
<Box display="flex" justifyContent="flex-end">
<Button
component={RouterLink}
@ -689,7 +890,7 @@ export function MovimentosPage() {
<Table
size="small"
sx={{
minWidth: 1320,
minWidth: 1650,
'& th': {
whiteSpace: 'nowrap',
fontWeight: 900,
@ -711,11 +912,15 @@ export function MovimentosPage() {
<TableCell sx={{ minWidth: 95 }} align="center">Parcelas</TableCell>
<TableCell sx={{ minWidth: 130 }}>Situação</TableCell>
<TableCell sx={{ minWidth: 150 }}>Movimento</TableCell>
<TableCell sx={{ minWidth: 130 }}>Competência</TableCell>
<TableCell sx={{ minWidth: 150 }}>Origem</TableCell>
<TableCell sx={{ minWidth: 150 }}>Saldo</TableCell>
<TableCell sx={{ minWidth: 180 }}>Centro de custo</TableCell>
<TableCell sx={{ minWidth: 180 }}>Banco</TableCell>
<TableCell sx={{ minWidth: 180 }}>Cliente</TableCell>
<TableCell sx={{ minWidth: 180 }}>Banco ref.</TableCell>
<TableCell sx={{ minWidth: 220 }}>Observação</TableCell>
<TableCell sx={{ minWidth: 130 }}>Exclusão</TableCell>
<TableCell sx={{ minWidth: 90 }} align="center">Ações</TableCell>
</TableRow>
</TableHead>
@ -735,11 +940,11 @@ export function MovimentosPage() {
}}
>
<TableCell>
<Box sx={{ maxWidth: 260 }}>
<Typography fontWeight={800} noWrap title={item.descricao}>
{item.descricao}
</Typography>
</Box>
<Box sx={{ maxWidth: 260 }}>
<Typography fontWeight={800} noWrap title={item.descricao}>
{item.descricao}
</Typography>
</Box>
</TableCell>
<TableCell>
@ -792,6 +997,28 @@ export function MovimentosPage() {
/>
</TableCell>
<TableCell>
{item.competencia || '-'}
</TableCell>
<TableCell>
<Chip
label={origemLabel(item.origem)}
size="small"
color={origemChipColor(item.origem) as any}
variant="outlined"
/>
</TableCell>
<TableCell>
<Chip
label={saldoProcessadoLabel(item.saldo_processado)}
size="small"
color={saldoProcessadoColor(item.saldo_processado) as any}
variant={Number(item.saldo_processado) === 1 ? 'filled' : 'outlined'}
/>
</TableCell>
<TableCell>
<Typography variant="body2" noWrap title={item.centro_custo_descricao || '-'}>
{item.centro_custo_descricao || '-'}
@ -824,17 +1051,42 @@ export function MovimentosPage() {
</Box>
</TableCell>
<TableCell>
{formatarData(item.deleted_at)}
</TableCell>
<TableCell align="center">
<Tooltip title="Editar movimento">
<IconButton
component={RouterLink}
to={`/movimentos/${item.idcontasapagar}/editar`}
color="primary"
size="small"
>
<EditIcon />
</IconButton>
</Tooltip>
<Stack
direction="row"
spacing={0.5}
alignItems="center"
justifyContent="center"
>
{!movimentoExcluido(item) && (
<>
<Tooltip title="Editar movimento">
<IconButton
component={RouterLink}
to={`/movimentos/${item.idcontasapagar}/editar`}
color="primary"
size="small"
>
<EditIcon />
</IconButton>
</Tooltip>
<Tooltip title="Excluir movimento">
<IconButton
color="error"
size="small"
onClick={() => handleDeletarMovimento(item)}
>
<DeleteIcon />
</IconButton>
</Tooltip>
</>
)}
</Stack>
</TableCell>
</TableRow>

View File

@ -4,16 +4,12 @@ import type {
AtualizarMovimentoRequest,
CriarMovimentoRequest,
Movimento,
MovimentoImpactoSaldo,
MovimentosListResponse,
MovimentoSaveResponse,
VerificarDuplicidadeResponse,
} from '../types/movimentoTypes';
export type DataCampo =
| 'dataentrada'
| 'datavencimento'
| 'databaixa'
| 'insert_date'
| 'update_date';
export type OrderDirection = 'ASC' | 'DESC';
export type ListarMovimentosParams = {
@ -26,11 +22,17 @@ export type ListarMovimentosParams = {
idcentrodecustos?: number | '';
idclientes?: number | '';
idbancos_p?: number | '';
idmovimentosfixos?: number | '';
competencia?: string;
grupo_parcelamento?: string;
origem?: string;
saldo_processado?: number | '';
referenciaTipo?: string;
dataCampo?: DataCampo;
dataCampo?: string;
dataInicio?: string;
dataFim?: string;
busca?: string;
incluirExcluidos?: number | '';
orderBy?: string;
orderDirection?: OrderDirection;
};
@ -50,17 +52,43 @@ export async function buscarMovimentoPorId(id: number): Promise<Movimento> {
return response.data.data;
}
export async function verificarDuplicidadeMovimento(
data: CriarMovimentoRequest & { idcontasapagar?: number }
): Promise<VerificarDuplicidadeResponse> {
const response = await api.post<ApiResponse<VerificarDuplicidadeResponse>>(
'/movimentos/verificar-duplicidade',
data
);
return response.data.data;
}
export async function preverImpactoSaldoMovimento(
data: CriarMovimentoRequest & { idcontasapagar?: number }
): Promise<MovimentoImpactoSaldo[]> {
const response = await api.post<ApiResponse<MovimentoImpactoSaldo[]>>(
'/movimentos/prever-impacto-saldo',
data
);
return response.data.data;
}
export async function criarMovimento(
data: CriarMovimentoRequest
): Promise<Movimento> {
const response = await api.post<ApiResponse<Movimento>>('/movimentos', data);
return response.data.data;
): Promise<MovimentoSaveResponse> {
const response = await api.post<MovimentoSaveResponse>('/movimentos', data);
return response.data;
}
export async function atualizarMovimento(
id: number,
data: AtualizarMovimentoRequest
): Promise<Movimento> {
const response = await api.put<ApiResponse<Movimento>>(`/movimentos/${id}`, data);
return response.data.data;
): Promise<MovimentoSaveResponse> {
const response = await api.put<MovimentoSaveResponse>(`/movimentos/${id}`, data);
return response.data;
}
export async function deletarMovimento(id: number): Promise<void> {
await api.delete(`/movimentos/${id}`);
}

View File

@ -2,6 +2,47 @@ export type MovimentoTipo = 'Entrada' | 'Saida' | 'Sangria' | 'Estorno';
export type MovimentoStatus = 'Pago' | 'A pagar' | 'Recebido' | 'A receber';
export type ModoParcelamento = 'valor_parcela' | 'valor_total';
export type Movimento = {
idcontasapagar: number;
movimento: MovimentoTipo;
descricao: string;
dataentrada: string | null;
datavencimento: string | null;
databaixa: string | null;
valor: number;
parcela: number;
parcelas: number;
status: MovimentoStatus;
idcentrodecustos: number | null;
idbancos: number | null;
idusuarios_cad: number | null;
idusuarios_baixa: number | null;
idclientes: number | null;
idveiculosdetalhes: number | null;
idbancos_p: number | null;
idmovimentosfixos: number | null;
competencia: string | null;
grupo_parcelamento: string | null;
origem: string | null;
saldo_processado: number;
observacao: string | null;
insert_date: string | null;
update_date: string | null;
deleted_at: string | null;
banco_descricao?: string | null;
banco_debito?: number | null;
centro_custo_descricao?: string | null;
cliente_nome?: string | null;
banco_referencia_descricao?: string | null;
banco_referencia_debito?: number | null;
movimento_fixo_descricao?: string | null;
};
export type CriarMovimentoRequest = {
movimento: MovimentoTipo;
descricao: string;
@ -18,21 +59,39 @@ export type CriarMovimentoRequest = {
idclientes: number | null;
idveiculosdetalhes: number | null;
idbancos_p: number | null;
idmovimentosfixos?: number | null;
competencia?: string | null;
grupo_parcelamento?: string | null;
origem?: string | null;
saldo_processado?: number;
observacao: string | null;
gerarParcelas?: boolean;
modoParcelamento?: ModoParcelamento;
confirmarDuplicado?: boolean;
};
export type AtualizarMovimentoRequest = CriarMovimentoRequest;
export type Movimento = CriarMovimentoRequest & {
idcontasapagar: number;
idusuarios_cad: number | null;
insert_date: string | null;
update_date: string | null;
export type MovimentoImpactoSaldo = {
idbancos: number;
banco_descricao?: string | null;
centro_custo_descricao?: string | null;
cliente_nome?: string | null;
banco_referencia_descricao?: string | null;
valor_delta: number;
saldo_anterior?: number;
saldo_posterior?: number;
descricao?: string | null;
};
export type VerificarDuplicidadeResponse = Movimento | null;
export type MovimentoSaveResponse = {
ok: boolean;
message?: string;
data: Movimento;
parcelasGeradas?: Movimento[];
impactoSaldo?: MovimentoImpactoSaldo[];
};
export type MovimentosPagination = {
@ -49,6 +108,9 @@ export type MovimentosSummary = {
totalSaidas: number;
totalSangrias: number;
totalEstornos: number;
totalBaixado: number;
totalAberto: number;
saldoProcessado: number;
saldo: number;
};
@ -58,11 +120,6 @@ export type ApiResponse<T> = {
data: T;
};
export type ApiListResponse<T> = {
ok: boolean;
data: T[];
};
export type MovimentosListResponse = {
ok: boolean;
data: Movimento[];

View File

@ -0,0 +1,567 @@
import { useEffect, useMemo, 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 EventRepeatIcon from '@mui/icons-material/EventRepeat';
import SaveIcon from '@mui/icons-material/Save';
import { useNavigate } from 'react-router-dom';
import {
listarBancos,
listarCentrosCusto,
} from '../../referencias/services/referenciasService';
import type {
Banco,
CentroCusto,
} from '../../referencias/types/referenciasTypes';
import {
atualizarMovimentoFixo,
criarMovimentoFixo,
} from '../services/movimentosFixosService';
import type {
CriarMovimentoFixoRequest,
MovimentoFixo,
MovimentoFixoTipo,
} from '../types/movimentoFixoTypes';
type MovimentoFixoFormProps = {
mode: 'create' | 'edit';
initialData?: MovimentoFixo | null;
};
type FormSectionProps = {
title: string;
description?: string;
children: ReactNode;
};
function FormSection({ title, description, children }: FormSectionProps) {
return (
<Paper
elevation={0}
sx={{
padding: { xs: 2.5, md: 3 },
borderRadius: 4,
border: '1px solid',
borderColor: 'divider',
backgroundColor: '#FFFFFF',
}}
>
<Box marginBottom={3}>
<Typography variant="h6" fontWeight={900}>
{title}
</Typography>
{description && (
<Typography variant="body2" color="text.secondary" marginTop={0.25}>
{description}
</Typography>
)}
</Box>
<Box
display="grid"
gridTemplateColumns={{
xs: '1fr',
md: '1fr 1fr',
}}
columnGap={{ xs: 2, md: 2.5 }}
rowGap={{ xs: 3, md: 3.25 }}
>
{children}
</Box>
</Paper>
);
}
const fieldSx = {
'& .MuiOutlinedInput-root': {
borderRadius: 2.5,
backgroundColor: '#FFFFFF',
minHeight: 48,
},
'& .MuiInputLabel-root': {
backgroundColor: '#FFFFFF',
paddingX: 0.5,
},
};
function toNumberOrNull(value: string): number | null {
if (!value) return null;
return Number(value);
}
function formatarValorResumo(value: string) {
const numero = Number(value || 0);
return new Intl.NumberFormat('pt-BR', {
style: 'currency',
currency: 'BRL',
}).format(numero);
}
function movimentoLabel(movimento: MovimentoFixoTipo) {
if (movimento === 'Saida') return 'Saída';
return movimento;
}
export function MovimentoFixoForm({ mode, initialData }: MovimentoFixoFormProps) {
const navigate = useNavigate();
const isEdit = mode === 'edit';
const [bancos, setBancos] = useState<Banco[]>([]);
const [centrosCusto, setCentrosCusto] = useState<CentroCusto[]>([]);
const [loadingRefs, setLoadingRefs] = useState(true);
const [saving, setSaving] = useState(false);
const [erro, setErro] = useState('');
const [sucesso, setSucesso] = useState('');
const [movimento, setMovimento] = useState<MovimentoFixoTipo>('Saida');
const [descricao, setDescricao] = useState('');
const [valor, setValor] = useState('');
const [diaVencimento, setDiaVencimento] = useState('1');
const [idCentroCusto, setIdCentroCusto] = useState('');
const [idBanco, setIdBanco] = useState('');
const [habilitado, setHabilitado] = useState('1');
const titulo = isEdit ? 'Editar movimento fixo' : 'Novo movimento fixo';
const subtitulo = isEdit
? 'Atualize os dados do movimento recorrente selecionado.'
: 'Cadastre lançamentos recorrentes para automatizar o financeiro depois.';
const movimentoDescricao = useMemo(() => {
if (movimento === 'Entrada') return 'Entrada recorrente';
if (movimento === 'Saida') return 'Saída recorrente';
if (movimento === 'Sangria') return 'Sangria recorrente';
return 'Estorno recorrente';
}, [movimento]);
useEffect(() => {
if (!initialData) return;
setMovimento(initialData.movimento);
setDescricao(initialData.descricao || '');
setValor(String(initialData.valor || ''));
setDiaVencimento(String(initialData.dia_vencimento || 1));
setIdCentroCusto(initialData.idcentrodecustos ? String(initialData.idcentrodecustos) : '');
setIdBanco(initialData.idbancos ? String(initialData.idbancos) : '');
setHabilitado(String(initialData.habilitado ?? 1));
}, [initialData]);
useEffect(() => {
async function carregarReferencias() {
try {
setLoadingRefs(true);
setErro('');
const [bancosData, centrosData] = await Promise.all([
listarBancos(),
listarCentrosCusto(),
]);
setBancos(bancosData);
setCentrosCusto(centrosData);
} catch (error: any) {
const message =
error?.response?.data?.message ||
'Não foi possível carregar os dados de referência.';
setErro(message);
} finally {
setLoadingRefs(false);
}
}
carregarReferencias();
}, []);
function limparFormulario() {
setMovimento('Saida');
setDescricao('');
setValor('');
setDiaVencimento('1');
setIdCentroCusto('');
setIdBanco('');
setHabilitado('1');
}
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!descricao.trim()) {
setErro('Informe a descrição.');
return;
}
if (!valor || Number(valor) <= 0) {
setErro('Informe um valor válido.');
return;
}
const dia = Number(diaVencimento);
if (!Number.isInteger(dia) || dia < 1 || dia > 31) {
setErro('Informe um dia de vencimento entre 1 e 31.');
return;
}
try {
setSaving(true);
setErro('');
setSucesso('');
const payload: CriarMovimentoFixoRequest = {
movimento,
descricao: descricao.trim(),
valor: Number(valor),
dia_vencimento: dia,
idcentrodecustos: toNumberOrNull(idCentroCusto),
habilitado: Number(habilitado || 1),
idbancos: toNumberOrNull(idBanco),
};
if (isEdit) {
if (!initialData?.idmovimentosfixos) {
setErro('Movimento fixo inválido para edição.');
return;
}
await atualizarMovimentoFixo(initialData.idmovimentosfixos, payload);
setSucesso('Movimento fixo atualizado com sucesso.');
} else {
await criarMovimentoFixo(payload);
setSucesso('Movimento fixo cadastrado com sucesso.');
limparFormulario();
}
} catch (error: any) {
const message =
error?.response?.data?.message ||
'Não foi possível salvar o movimento fixo.';
setErro(message);
} finally {
setSaving(false);
}
}
return (
<Box>
<Stack
direction={{ xs: 'column', md: 'row' }}
justifyContent="space-between"
alignItems={{ xs: 'stretch', md: 'center' }}
spacing={2}
marginBottom={{ xs: 3, md: 4 }}
>
<Box>
<Chip
icon={<EventRepeatIcon />}
label={isEdit ? 'Edição de recorrência' : 'Cadastro de recorrência'}
color="primary"
variant="outlined"
sx={{ marginBottom: 1.25, fontWeight: 700 }}
/>
<Typography variant="h4" fontWeight={950}>
{titulo}
</Typography>
<Typography color="text.secondary" sx={{ marginTop: 0.5 }}>
{subtitulo}
</Typography>
</Box>
<Button
variant="outlined"
startIcon={<ArrowBackIcon />}
onClick={() => navigate('/movimentos-fixos')}
sx={{
minHeight: 48,
borderRadius: 3,
px: 2.5,
}}
>
Voltar para lista
</Button>
</Stack>
{erro && (
<Alert severity="error" sx={{ marginBottom: 2.5 }}>
{erro}
</Alert>
)}
{sucesso && (
<Alert severity="success" sx={{ marginBottom: 2.5 }}>
{sucesso}
</Alert>
)}
<Stack
direction={{ xs: 'column', xl: 'row' }}
spacing={{ xs: 2.5, md: 3 }}
alignItems="flex-start"
>
<Card sx={{ width: '100%', flex: 1 }}>
<CardContent sx={{ padding: { xs: 2, md: 3 } }}>
{loadingRefs ? (
<Box display="flex" alignItems="center" gap={2}>
<CircularProgress size={22} />
<Typography>Carregando dados...</Typography>
</Box>
) : (
<Box
component="form"
onSubmit={handleSubmit}
display="flex"
flexDirection="column"
gap={{ xs: 2.5, md: 3 }}
>
<FormSection
title="Dados principais"
description="Defina o tipo, descrição, valor e status da recorrência."
>
<TextField
select
label="Movimento"
value={movimento}
onChange={(event) => setMovimento(event.target.value as MovimentoFixoTipo)}
fullWidth
sx={fieldSx}
>
<MenuItem value="Entrada">Entrada</MenuItem>
<MenuItem value="Saida">Saída</MenuItem>
<MenuItem value="Sangria">Sangria</MenuItem>
<MenuItem value="Estorno">Estorno</MenuItem>
</TextField>
<TextField
select
label="Status"
value={habilitado}
onChange={(event) => setHabilitado(event.target.value)}
fullWidth
sx={fieldSx}
>
<MenuItem value="1">Habilitado</MenuItem>
<MenuItem value="0">Desabilitado</MenuItem>
</TextField>
<TextField
label="Descrição"
value={descricao}
onChange={(event) => setDescricao(event.target.value)}
placeholder="Ex: Internet, aluguel, energia..."
fullWidth
sx={fieldSx}
/>
<TextField
label="Valor"
type="number"
value={valor}
onChange={(event) => setValor(event.target.value)}
inputProps={{
step: '0.01',
min: '0',
}}
fullWidth
sx={fieldSx}
/>
<TextField
label="Dia de vencimento"
type="number"
value={diaVencimento}
onChange={(event) => setDiaVencimento(event.target.value)}
inputProps={{
min: '1',
max: '31',
}}
fullWidth
sx={fieldSx}
/>
</FormSection>
<FormSection
title="Classificação"
description="Associe banco/carteira e centro de custo ao movimento fixo."
>
<TextField
select
label="Banco/Carteira"
value={idBanco}
onChange={(event) => setIdBanco(event.target.value)}
fullWidth
sx={fieldSx}
>
<MenuItem value="">Nenhum</MenuItem>
{bancos.map((banco) => (
<MenuItem key={banco.id} value={String(banco.id)}>
{banco.descricao}
</MenuItem>
))}
</TextField>
<TextField
select
label="Centro de custo"
value={idCentroCusto}
onChange={(event) => setIdCentroCusto(event.target.value)}
fullWidth
sx={fieldSx}
>
<MenuItem value="">Nenhum</MenuItem>
{centrosCusto.map((centro) => (
<MenuItem key={centro.id} value={String(centro.id)}>
{centro.descricao}
</MenuItem>
))}
</TextField>
</FormSection>
<Stack
direction={{ xs: 'column-reverse', sm: 'row' }}
justifyContent="flex-end"
spacing={1.5}
sx={{
paddingTop: 1,
}}
>
<Button
variant="outlined"
onClick={() => navigate('/movimentos-fixos')}
disabled={saving}
sx={{ minHeight: 46 }}
>
Cancelar
</Button>
<Button
type="submit"
variant="contained"
disabled={saving}
startIcon={
saving
? <CircularProgress size={18} color="inherit" />
: <SaveIcon />
}
sx={{
minHeight: 46,
px: 3,
boxShadow: 3,
}}
>
{saving
? 'Salvando...'
: isEdit
? 'Atualizar movimento fixo'
: 'Salvar movimento fixo'}
</Button>
</Stack>
</Box>
)}
</CardContent>
</Card>
<Paper
elevation={0}
sx={{
width: { xs: '100%', xl: 340 },
padding: 3,
borderRadius: 4,
border: '1px solid',
borderColor: 'divider',
position: { xl: 'sticky' },
top: { xl: 24 },
background:
'linear-gradient(180deg, #FFFFFF 0%, #F8FAFC 100%)',
}}
>
<Typography variant="h6" fontWeight={900} gutterBottom>
Resumo
</Typography>
<Typography variant="body2" color="text.secondary" marginBottom={2.5}>
Prévia da recorrência antes de salvar.
</Typography>
<Divider sx={{ marginBottom: 2.5 }} />
<Stack spacing={2}>
<Box>
<Typography variant="caption" color="text.secondary">
Movimento
</Typography>
<Typography fontWeight={800}>
{movimentoLabel(movimento)}
</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Tipo
</Typography>
<Typography fontWeight={800}>
{movimentoDescricao}
</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Valor
</Typography>
<Typography
variant="h4"
fontWeight={950}
color={
movimento === 'Entrada' || movimento === 'Estorno'
? 'success.main'
: 'text.primary'
}
>
{formatarValorResumo(valor)}
</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Vencimento
</Typography>
<Typography fontWeight={800}>
Todo dia {diaVencimento || '-'}
</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Status
</Typography>
<Typography
fontWeight={800}
color={Number(habilitado) === 1 ? 'success.main' : 'text.secondary'}
>
{Number(habilitado) === 1 ? 'Habilitado' : 'Desabilitado'}
</Typography>
</Box>
</Stack>
</Paper>
</Stack>
</Box>
);
}

View File

@ -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<MovimentoFixo | null>(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 (
<Box padding={3} display="flex" alignItems="center" gap={2}>
<CircularProgress size={22} />
<Typography>Carregando movimento fixo...</Typography>
</Box>
);
}
if (erro) {
return (
<Box padding={3}>
<Alert severity="error">{erro}</Alert>
</Box>
);
}
return <MovimentoFixoForm mode="edit" initialData={movimentoFixo} />;
}

View File

@ -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<MovimentoFixo[]>([]);
const [pagination, setPagination] = useState<MovimentosFixosPagination>({
total: 0,
limite: LIMITE_PADRAO,
offset: 0,
page: 1,
totalPages: 1,
});
const [summary, setSummary] = useState<MovimentosFixosSummary>({
quantidade: 0,
valorTotal: 0,
totalEntradas: 0,
totalSaidas: 0,
totalSangrias: 0,
totalEstornos: 0,
habilitados: 0,
desabilitados: 0,
});
const [bancos, setBancos] = useState<Banco[]>([]);
const [centrosCusto, setCentrosCusto] = useState<CentroCusto[]>([]);
const [alterandoStatusId, setAlterandoStatusId] = useState<number | null>(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<MovimentoFixoTipo | ''>('');
const [idBanco, setIdBanco] = useState<number | ''>('');
const [idCentroCusto, setIdCentroCusto] = useState<number | ''>('');
const [habilitado, setHabilitado] = useState<number | ''>('');
const [diaInicio, setDiaInicio] = useState<number | ''>('');
const [diaFim, setDiaFim] = useState<number | ''>('');
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 (
<Box>
<Stack
direction={{ xs: 'column', md: 'row' }}
justifyContent="space-between"
alignItems={{ xs: 'stretch', md: 'center' }}
spacing={2}
marginBottom={{ xs: 3, md: 4 }}
>
<Box>
<Chip
icon={<EventRepeatIcon />}
label="Automação financeira"
color="primary"
variant="outlined"
sx={{ marginBottom: 1.25, fontWeight: 700 }}
/>
<Typography variant="h4" fontWeight={950}>
Movimentos fixos
</Typography>
<Typography color="text.secondary" sx={{ marginTop: 0.5 }}>
Cadastre lançamentos recorrentes para gerar movimentos automaticamente depois.
</Typography>
</Box>
<Button
component={RouterLink}
to="/movimentos-fixos/novo"
variant="contained"
startIcon={<AddIcon />}
sx={{
minHeight: 48,
borderRadius: 3,
px: 2.5,
boxShadow: 3,
}}
>
Novo movimento fixo
</Button>
</Stack>
{erro && (
<Alert severity="error" sx={{ marginBottom: 2.5 }}>
{erro}
</Alert>
)}
{sucesso && (
<Alert severity="success" sx={{ marginBottom: 2.5 }}>
{sucesso}
</Alert>
)}
<Box
display="grid"
gridTemplateColumns={{
xs: '1fr',
md: 'repeat(4, 1fr)',
}}
gap={{ xs: 2, md: 2.5 }}
marginBottom={{ xs: 2.5, md: 3 }}
>
<Card>
<CardContent>
<Typography variant="body2" color="text.secondary">
Movimentos encontrados
</Typography>
<Typography variant="h5" fontWeight={950}>
{summary.quantidade}
</Typography>
</CardContent>
</Card>
<Card>
<CardContent>
<Typography variant="body2" color="text.secondary">
Entradas fixas
</Typography>
<Typography variant="h5" fontWeight={950} color="success.main">
{formatarValor(summary.totalEntradas)}
</Typography>
</CardContent>
</Card>
<Card>
<CardContent>
<Typography variant="body2" color="text.secondary">
Saídas fixas
</Typography>
<Typography variant="h5" fontWeight={950} color="error.main">
{formatarValor(summary.totalSaidas + summary.totalSangrias)}
</Typography>
</CardContent>
</Card>
<Card>
<CardContent>
<Typography variant="body2" color="text.secondary">
Habilitados
</Typography>
<Typography variant="h5" fontWeight={950} color="info.main">
{summary.habilitados}
</Typography>
</CardContent>
</Card>
</Box>
<Card sx={{ marginBottom: { xs: 2.5, md: 3 } }}>
<CardContent sx={{ padding: { xs: 2, md: 3 } }}>
<Stack
direction="row"
spacing={1}
alignItems="center"
marginBottom={2.5}
>
<FilterAltIcon color="primary" />
<Typography variant="h6" fontWeight={900}>
Filtros
</Typography>
<Chip
label={`${filtrosAtivos} ativo(s)`}
size="small"
variant="outlined"
/>
</Stack>
<Box
display="grid"
gridTemplateColumns={{
xs: '1fr',
md: 'repeat(4, 1fr)',
}}
gap={{ xs: 2, md: 2.25 }}
>
<TextField
label="Buscar"
value={busca}
onChange={(event) => setBusca(event.target.value)}
placeholder="Descrição, banco, centro..."
fullWidth
/>
<TextField
select
label="Movimento"
value={movimento}
onChange={(event) => setMovimento(event.target.value as MovimentoFixoTipo | '')}
fullWidth
>
<MenuItem value="">Todos</MenuItem>
<MenuItem value="Entrada">Entrada</MenuItem>
<MenuItem value="Saida">Saída</MenuItem>
<MenuItem value="Sangria">Sangria</MenuItem>
<MenuItem value="Estorno">Estorno</MenuItem>
</TextField>
<TextField
select
label="Banco/Carteira"
value={idBanco}
onChange={(event) =>
setIdBanco(event.target.value === '' ? '' : Number(event.target.value))
}
disabled={loadingRefs}
fullWidth
>
<MenuItem value="">Todos</MenuItem>
{bancos.map((banco) => (
<MenuItem key={banco.id} value={banco.id}>
{banco.descricao}
</MenuItem>
))}
</TextField>
<TextField
select
label="Centro de custo"
value={idCentroCusto}
onChange={(event) =>
setIdCentroCusto(event.target.value === '' ? '' : Number(event.target.value))
}
disabled={loadingRefs}
fullWidth
>
<MenuItem value="">Todos</MenuItem>
{centrosCusto.map((centro) => (
<MenuItem key={centro.id} value={centro.id}>
{centro.descricao}
</MenuItem>
))}
</TextField>
<TextField
select
label="Status"
value={habilitado}
onChange={(event) =>
setHabilitado(event.target.value === '' ? '' : Number(event.target.value))
}
fullWidth
>
<MenuItem value="">Todos</MenuItem>
<MenuItem value={1}>Habilitados</MenuItem>
<MenuItem value={0}>Desabilitados</MenuItem>
</TextField>
<TextField
label="Dia inicial"
type="number"
value={diaInicio}
onChange={(event) =>
setDiaInicio(event.target.value === '' ? '' : Number(event.target.value))
}
inputProps={{ min: 1, max: 31 }}
fullWidth
/>
<TextField
label="Dia final"
type="number"
value={diaFim}
onChange={(event) =>
setDiaFim(event.target.value === '' ? '' : Number(event.target.value))
}
inputProps={{ min: 1, max: 31 }}
fullWidth
/>
<TextField
select
label="Ordenar por"
value={orderBy}
onChange={(event) => setOrderBy(event.target.value)}
fullWidth
>
<MenuItem value="dia_vencimento">Dia vencimento</MenuItem>
<MenuItem value="descricao">Descrição</MenuItem>
<MenuItem value="valor">Valor</MenuItem>
<MenuItem value="movimento">Movimento</MenuItem>
<MenuItem value="banco_descricao">Banco</MenuItem>
<MenuItem value="centro_custo_descricao">Centro</MenuItem>
<MenuItem value="habilitado">Status</MenuItem>
<MenuItem value="insert_date">Cadastro</MenuItem>
<MenuItem value="update_date">Atualização</MenuItem>
</TextField>
<TextField
select
label="Direção"
value={orderDirection}
onChange={(event) =>
setOrderDirection(event.target.value as 'ASC' | 'DESC')
}
fullWidth
>
<MenuItem value="ASC">Crescente</MenuItem>
<MenuItem value="DESC">Decrescente</MenuItem>
</TextField>
</Box>
<Stack
direction={{ xs: 'column', sm: 'row' }}
spacing={1.5}
justifyContent="flex-end"
marginTop={2.5}
>
<Button
variant="outlined"
startIcon={<RestartAltIcon />}
onClick={limparFiltros}
>
Limpar
</Button>
<Button
variant="contained"
startIcon={<SearchIcon />}
onClick={aplicarFiltros}
disabled={loading}
>
Aplicar filtros
</Button>
</Stack>
</CardContent>
</Card>
<Card>
<CardContent sx={{ padding: 0 }}>
{loading ? (
<Box padding={3} display="flex" alignItems="center" gap={2}>
<CircularProgress size={22} />
<Typography>Carregando movimentos fixos...</Typography>
</Box>
) : movimentosFixos.length === 0 ? (
<Box padding={3}>
<Typography fontWeight={800}>
Nenhum movimento fixo encontrado.
</Typography>
<Typography color="text.secondary" marginTop={0.5}>
Ajuste os filtros ou cadastre um novo movimento fixo.
</Typography>
</Box>
) : isMobile ? (
<Stack divider={<Divider />}>
{movimentosFixos.map((item) => (
<Box
key={item.idmovimentosfixos}
sx={{
padding: 2,
'&:hover': {
backgroundColor: 'rgba(15,23,42,0.02)',
},
}}
>
<Stack spacing={1.25}>
<Stack
direction="row"
justifyContent="space-between"
alignItems="flex-start"
spacing={2}
>
<Box sx={{ minWidth: 0 }}>
<Typography fontWeight={900}>
{item.descricao}
</Typography>
<Typography variant="body2" color="text.secondary">
Vence todo dia {item.dia_vencimento}
</Typography>
</Box>
<Typography
fontWeight={950}
color={
item.movimento === 'Entrada' || item.movimento === 'Estorno'
? 'success.main'
: 'text.primary'
}
whiteSpace="nowrap"
>
{formatarValor(item.valor)}
</Typography>
</Stack>
<Stack direction="row" flexWrap="wrap" spacing={1} useFlexGap>
<Chip
label={item.movimento}
size="small"
color={movimentoChipColor(item.movimento) as any}
/>
<Chip
label={statusLabel(item.habilitado)}
size="small"
color={statusColor(item.habilitado) as any}
variant={Number(item.habilitado) === 1 ? 'filled' : 'outlined'}
/>
</Stack>
<Typography variant="body2" color="text.secondary">
Banco: <strong>{item.banco_descricao || '-'}</strong>
</Typography>
<Typography variant="body2" color="text.secondary">
Centro: <strong>{item.centro_custo_descricao || '-'}</strong>
</Typography>
<Box
display="flex"
justifyContent="flex-end"
alignItems="center"
gap={1}
>
<Tooltip
title={
Number(item.habilitado) === 1
? 'Desabilitar movimento fixo'
: 'Habilitar movimento fixo'
}
>
<span>
<Switch
size="small"
checked={Number(item.habilitado) === 1}
disabled={
alterandoStatusId === item.idmovimentosfixos || loading
}
onChange={() => handleAlterarStatusMovimentoFixo(item)}
/>
</span>
</Tooltip>
<Button
component={RouterLink}
to={`/movimentos-fixos/${item.idmovimentosfixos}/editar`}
variant="outlined"
size="small"
startIcon={<EditIcon />}
>
Editar
</Button>
<Button
variant="outlined"
color="error"
size="small"
startIcon={<DeleteIcon />}
onClick={() => handleDeletarMovimentoFixo(item)}
>
Excluir
</Button>
</Box>
</Stack>
</Box>
))}
</Stack>
) : (
<TableContainer sx={{ overflowX: 'auto' }}>
<Table
size="small"
sx={{
minWidth: 1120,
'& th': {
whiteSpace: 'nowrap',
fontWeight: 900,
backgroundColor: 'rgba(15,23,42,0.04)',
},
'& td': {
whiteSpace: 'nowrap',
},
}}
>
<TableHead>
<TableRow>
<TableCell sx={{ minWidth: 260 }}>Descrição</TableCell>
<TableCell sx={{ minWidth: 130 }}>Movimento</TableCell>
<TableCell sx={{ minWidth: 130 }} align="right">Valor</TableCell>
<TableCell sx={{ minWidth: 110 }} align="center">Dia venc.</TableCell>
<TableCell sx={{ minWidth: 180 }}>Centro</TableCell>
<TableCell sx={{ minWidth: 180 }}>Banco</TableCell>
<TableCell sx={{ minWidth: 130 }}>Status</TableCell>
<TableCell sx={{ minWidth: 130 }}>Cadastro</TableCell>
<TableCell sx={{ minWidth: 130 }}>Atualização</TableCell>
<TableCell sx={{ minWidth: 130 }} align="center">Ações</TableCell>
</TableRow>
</TableHead>
<TableBody>
{movimentosFixos.map((item) => (
<TableRow
key={item.idmovimentosfixos}
hover
sx={{
'&:last-child td': {
borderBottom: 0,
},
}}
>
<TableCell>
<Box sx={{ maxWidth: 280 }}>
<Typography fontWeight={800} noWrap title={item.descricao}>
{item.descricao}
</Typography>
</Box>
</TableCell>
<TableCell>
<Chip
label={item.movimento}
size="small"
color={movimentoChipColor(item.movimento) as any}
/>
</TableCell>
<TableCell align="right">
<Typography
fontWeight={950}
color={
item.movimento === 'Entrada' || item.movimento === 'Estorno'
? 'success.main'
: 'text.primary'
}
>
{formatarValor(item.valor)}
</Typography>
</TableCell>
<TableCell align="center">
{item.dia_vencimento}
</TableCell>
<TableCell>
<Typography
variant="body2"
noWrap
title={item.centro_custo_descricao || '-'}
>
{item.centro_custo_descricao || '-'}
</Typography>
</TableCell>
<TableCell>
<Typography
variant="body2"
noWrap
title={item.banco_descricao || '-'}
>
{item.banco_descricao || '-'}
</Typography>
</TableCell>
<TableCell>
<Chip
label={statusLabel(item.habilitado)}
size="small"
color={statusColor(item.habilitado) as any}
variant={Number(item.habilitado) === 1 ? 'filled' : 'outlined'}
/>
</TableCell>
<TableCell>
{formatarData(item.insert_date)}
</TableCell>
<TableCell>
{formatarData(item.update_date)}
</TableCell>
<TableCell align="center">
<Stack
direction="row"
spacing={0.5}
alignItems="center"
justifyContent="center"
>
<Tooltip
title={
Number(item.habilitado) === 1
? 'Desabilitar movimento fixo'
: 'Habilitar movimento fixo'
}
>
<span>
<Switch
size="small"
checked={Number(item.habilitado) === 1}
disabled={
alterandoStatusId === item.idmovimentosfixos || loading
}
onChange={() => handleAlterarStatusMovimentoFixo(item)}
/>
</span>
</Tooltip>
<Tooltip title="Editar movimento fixo">
<IconButton
component={RouterLink}
to={`/movimentos-fixos/${item.idmovimentosfixos}/editar`}
color="primary"
size="small"
>
<EditIcon />
</IconButton>
</Tooltip>
<Tooltip title="Excluir movimento fixo">
<IconButton
color="error"
size="small"
onClick={() => handleDeletarMovimentoFixo(item)}
>
<DeleteIcon />
</IconButton>
</Tooltip>
</Stack>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
</CardContent>
</Card>
{pagination.totalPages > 1 && (
<Stack alignItems="center" marginTop={3}>
<Pagination
page={pagination.page}
count={pagination.totalPages}
color="primary"
onChange={(_, novaPagina) => {
setPage(novaPagina);
carregarMovimentosFixos(novaPagina);
}}
/>
</Stack>
)}
<Paper
elevation={0}
sx={{
marginTop: 2,
padding: 2,
border: '1px solid',
borderColor: 'divider',
borderRadius: 3,
backgroundColor: 'rgba(255,255,255,0.65)',
}}
>
<Typography variant="body2" color="text.secondary">
Exibindo página {pagination.page} de {pagination.totalPages}, total de{' '}
<strong>{pagination.total}</strong> registro(s).
</Typography>
</Paper>
</Box>
);
}

View File

@ -0,0 +1,5 @@
import { MovimentoFixoForm } from '../components/MovimentoFixoForm';
export function NovoMovimentoFixoPage() {
return <MovimentoFixoForm mode="create" />;
}

View File

@ -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<MovimentosFixosListResponse> {
const response = await api.get<MovimentosFixosListResponse>('/movimentos-fixos', {
params,
});
return response.data;
}
export async function buscarMovimentoFixoPorId(id: number): Promise<MovimentoFixo> {
const response = await api.get<ApiResponse<MovimentoFixo>>(`/movimentos-fixos/${id}`);
return response.data.data;
}
export async function criarMovimentoFixo(
data: CriarMovimentoFixoRequest
): Promise<MovimentoFixo> {
const response = await api.post<ApiResponse<MovimentoFixo>>('/movimentos-fixos', data);
return response.data.data;
}
export async function atualizarMovimentoFixo(
id: number,
data: AtualizarMovimentoFixoRequest
): Promise<MovimentoFixo> {
const response = await api.put<ApiResponse<MovimentoFixo>>(
`/movimentos-fixos/${id}`,
data
);
return response.data.data;
}
export async function alterarHabilitadoMovimentoFixo(
id: number,
habilitado: number
): Promise<MovimentoFixo> {
const response = await api.patch<ApiResponse<MovimentoFixo>>(
`/movimentos-fixos/${id}/habilitado`,
{ habilitado }
);
return response.data.data;
}
export async function deletarMovimentoFixo(id: number): Promise<void> {
await api.delete(`/movimentos-fixos/${id}`);
}

View File

@ -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<T> = {
ok: boolean;
message?: string;
data: T;
};
export type MovimentosFixosListResponse = {
ok: boolean;
data: MovimentoFixo[];
pagination: MovimentosFixosPagination;
summary: MovimentosFixosSummary;
};

View File

@ -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 (
<Paper
elevation={0}
sx={{
padding: { xs: 2.5, md: 3 },
borderRadius: 4,
border: '1px solid',
borderColor: 'divider',
backgroundColor: '#FFFFFF',
}}
>
<Box marginBottom={3}>
<Typography variant="h6" fontWeight={900}>
{title}
</Typography>
{description && (
<Typography variant="body2" color="text.secondary" marginTop={0.25}>
{description}
</Typography>
)}
</Box>
<Box
display="grid"
gridTemplateColumns={{
xs: '1fr',
md: '1fr 1fr',
}}
columnGap={{ xs: 2, md: 2.5 }}
rowGap={{ xs: 3, md: 3.25 }}
>
{children}
</Box>
</Paper>
);
}
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<HTMLFormElement>) {
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 (
<Box>
<Stack
direction={{ xs: 'column', md: 'row' }}
justifyContent="space-between"
alignItems={{ xs: 'stretch', md: 'center' }}
spacing={2}
marginBottom={{ xs: 3, md: 4 }}
>
<Box>
<Chip
icon={<ManageAccountsIcon />}
label={isEdit ? 'Edição de usuário' : 'Cadastro de usuário'}
color="primary"
variant="outlined"
sx={{ marginBottom: 1.25, fontWeight: 700 }}
/>
<Typography variant="h4" fontWeight={950}>
{titulo}
</Typography>
<Typography color="text.secondary" sx={{ marginTop: 0.5 }}>
{subtitulo}
</Typography>
</Box>
<Button
variant="outlined"
startIcon={<ArrowBackIcon />}
onClick={() => navigate('/usuarios')}
sx={{
minHeight: 48,
borderRadius: 3,
px: 2.5,
}}
>
Voltar para lista
</Button>
</Stack>
{erro && (
<Alert severity="error" sx={{ marginBottom: 2.5 }}>
{erro}
</Alert>
)}
{sucesso && (
<Alert severity="success" sx={{ marginBottom: 2.5 }}>
{sucesso}
</Alert>
)}
<Stack
direction={{ xs: 'column', xl: 'row' }}
spacing={{ xs: 2.5, md: 3 }}
alignItems="flex-start"
>
<Card sx={{ width: '100%', flex: 1 }}>
<CardContent sx={{ padding: { xs: 2, md: 3 } }}>
<Box
component="form"
onSubmit={handleSubmit}
display="flex"
flexDirection="column"
gap={{ xs: 2.5, md: 3 }}
>
<FormSection
title="Dados do usuário"
description="Defina nome, status e observações internas."
>
<TextField
label="Nome"
value={nome}
onChange={(event) => setNome(event.target.value)}
fullWidth
sx={fieldSx}
/>
<TextField
select
label="Status"
value={habilitado}
onChange={(event) => setHabilitado(event.target.value)}
fullWidth
sx={fieldSx}
>
<MenuItem value="1">Habilitado</MenuItem>
<MenuItem value="0">Desabilitado</MenuItem>
</TextField>
<TextField
label="Anotações"
value={anotacoes}
onChange={(event) => setAnotacoes(event.target.value)}
multiline
minRows={4}
fullWidth
sx={{
...fieldSx,
gridColumn: { xs: 'auto', md: '1 / -1' },
}}
/>
</FormSection>
<FormSection
title="Acesso"
description={
isEdit
? 'Preencha a senha somente se quiser alterá-la.'
: 'Defina a senha inicial do usuário.'
}
>
<TextField
label={isEdit ? 'Nova senha' : 'Senha'}
type="password"
value={senha}
onChange={(event) => setSenha(event.target.value)}
fullWidth
sx={fieldSx}
/>
<TextField
label="Confirmar senha"
type="password"
value={confirmarSenha}
onChange={(event) => setConfirmarSenha(event.target.value)}
fullWidth
sx={fieldSx}
/>
</FormSection>
<Stack
direction={{ xs: 'column-reverse', sm: 'row' }}
justifyContent="flex-end"
spacing={1.5}
sx={{
paddingTop: 1,
}}
>
<Button
variant="outlined"
onClick={() => navigate('/usuarios')}
disabled={saving}
sx={{ minHeight: 46 }}
>
Cancelar
</Button>
<Button
type="submit"
variant="contained"
disabled={saving}
startIcon={
saving
? <CircularProgress size={18} color="inherit" />
: <SaveIcon />
}
sx={{
minHeight: 46,
px: 3,
boxShadow: 3,
}}
>
{saving
? 'Salvando...'
: isEdit
? 'Atualizar usuário'
: 'Salvar usuário'}
</Button>
</Stack>
</Box>
</CardContent>
</Card>
<Paper
elevation={0}
sx={{
width: { xs: '100%', xl: 340 },
padding: 3,
borderRadius: 4,
border: '1px solid',
borderColor: 'divider',
position: { xl: 'sticky' },
top: { xl: 24 },
background:
'linear-gradient(180deg, #FFFFFF 0%, #F8FAFC 100%)',
}}
>
<Typography variant="h6" fontWeight={900} gutterBottom>
Resumo
</Typography>
<Typography variant="body2" color="text.secondary" marginBottom={2.5}>
Prévia rápida do usuário antes de salvar.
</Typography>
<Divider sx={{ marginBottom: 2.5 }} />
<Stack spacing={2}>
<Box>
<Typography variant="caption" color="text.secondary">
Nome
</Typography>
<Typography fontWeight={800}>
{nome || 'Sem nome'}
</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Status
</Typography>
<Typography
fontWeight={800}
color={Number(habilitado) === 1 ? 'success.main' : 'text.secondary'}
>
{Number(habilitado) === 1 ? 'Habilitado' : 'Desabilitado'}
</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Senha
</Typography>
<Typography fontWeight={800}>
{isEdit
? senha
? 'Será alterada'
: 'Sem alteração'
: senha
? 'Definida'
: 'Pendente'}
</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Anotações
</Typography>
<Typography fontWeight={700}>
{anotacoes || '-'}
</Typography>
</Box>
</Stack>
</Paper>
</Stack>
</Box>
);
}

View File

@ -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<Usuario | null>(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 (
<Box padding={3} display="flex" alignItems="center" gap={2}>
<CircularProgress size={22} />
<Typography>Carregando usuário...</Typography>
</Box>
);
}
if (erro) {
return (
<Box padding={3}>
<Alert severity="error">{erro}</Alert>
</Box>
);
}
return <UsuarioForm mode="edit" initialData={usuario} />;
}

View File

@ -0,0 +1,5 @@
import { UsuarioForm } from '../components/UsuarioForm';
export function NovoUsuarioPage() {
return <UsuarioForm mode="create" />;
}

View File

@ -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<Usuario[]>([]);
const [pagination, setPagination] = useState<UsuariosPagination>({
total: 0,
limite: LIMITE_PADRAO,
offset: 0,
page: 1,
totalPages: 1,
});
const [summary, setSummary] = useState<UsuariosSummary>({
quantidade: 0,
habilitados: 0,
desabilitados: 0,
});
const [alterandoStatusId, setAlterandoStatusId] = useState<number | null>(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<number | ''>('');
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 (
<Box>
<Stack
direction={{ xs: 'column', md: 'row' }}
justifyContent="space-between"
alignItems={{ xs: 'stretch', md: 'center' }}
spacing={2}
marginBottom={{ xs: 3, md: 4 }}
>
<Box>
<Chip
icon={<ManageAccountsIcon />}
label="Controle de acesso"
color="primary"
variant="outlined"
sx={{ marginBottom: 1.25, fontWeight: 700 }}
/>
<Typography variant="h4" fontWeight={950}>
Usuários
</Typography>
<Typography color="text.secondary" sx={{ marginTop: 0.5 }}>
Cadastre e gerencie os usuários que acessam o sistema.
</Typography>
</Box>
<Button
component={RouterLink}
to="/usuarios/novo"
variant="contained"
startIcon={<AddIcon />}
sx={{
minHeight: 48,
borderRadius: 3,
px: 2.5,
boxShadow: 3,
}}
>
Novo usuário
</Button>
</Stack>
{erro && (
<Alert severity="error" sx={{ marginBottom: 2.5 }}>
{erro}
</Alert>
)}
{sucesso && (
<Alert severity="success" sx={{ marginBottom: 2.5 }}>
{sucesso}
</Alert>
)}
<Box
display="grid"
gridTemplateColumns={{
xs: '1fr',
md: 'repeat(3, 1fr)',
}}
gap={{ xs: 2, md: 2.5 }}
marginBottom={{ xs: 2.5, md: 3 }}
>
<Card>
<CardContent>
<Typography variant="body2" color="text.secondary">
Usuários encontrados
</Typography>
<Typography variant="h5" fontWeight={950}>
{summary.quantidade}
</Typography>
</CardContent>
</Card>
<Card>
<CardContent>
<Typography variant="body2" color="text.secondary">
Habilitados
</Typography>
<Typography variant="h5" fontWeight={950} color="success.main">
{summary.habilitados}
</Typography>
</CardContent>
</Card>
<Card>
<CardContent>
<Typography variant="body2" color="text.secondary">
Desabilitados
</Typography>
<Typography variant="h5" fontWeight={950} color="text.secondary">
{summary.desabilitados}
</Typography>
</CardContent>
</Card>
</Box>
<Card sx={{ marginBottom: { xs: 2.5, md: 3 } }}>
<CardContent sx={{ padding: { xs: 2, md: 3 } }}>
<Stack
direction="row"
spacing={1}
alignItems="center"
marginBottom={2.5}
>
<FilterAltIcon color="primary" />
<Typography variant="h6" fontWeight={900}>
Filtros
</Typography>
<Chip
label={`${filtrosAtivos} ativo(s)`}
size="small"
variant="outlined"
/>
</Stack>
<Box
display="grid"
gridTemplateColumns={{
xs: '1fr',
md: 'repeat(4, 1fr)',
}}
gap={{ xs: 2, md: 2.25 }}
>
<TextField
label="Buscar"
value={busca}
onChange={(event) => setBusca(event.target.value)}
placeholder="Nome ou anotações..."
fullWidth
/>
<TextField
select
label="Status"
value={habilitado}
onChange={(event) =>
setHabilitado(event.target.value === '' ? '' : Number(event.target.value))
}
fullWidth
>
<MenuItem value="">Todos</MenuItem>
<MenuItem value={1}>Habilitados</MenuItem>
<MenuItem value={0}>Desabilitados</MenuItem>
</TextField>
<TextField
select
label="Ordenar por"
value={orderBy}
onChange={(event) => setOrderBy(event.target.value)}
fullWidth
>
<MenuItem value="nome">Nome</MenuItem>
<MenuItem value="habilitado">Status</MenuItem>
<MenuItem value="insert_date">Cadastro</MenuItem>
<MenuItem value="update_date">Atualização</MenuItem>
</TextField>
<TextField
select
label="Direção"
value={orderDirection}
onChange={(event) =>
setOrderDirection(event.target.value as 'ASC' | 'DESC')
}
fullWidth
>
<MenuItem value="ASC">Crescente</MenuItem>
<MenuItem value="DESC">Decrescente</MenuItem>
</TextField>
</Box>
<Stack
direction={{ xs: 'column', sm: 'row' }}
spacing={1.5}
justifyContent="flex-end"
marginTop={2.5}
>
<Button
variant="outlined"
startIcon={<RestartAltIcon />}
onClick={limparFiltros}
>
Limpar
</Button>
<Button
variant="contained"
startIcon={<SearchIcon />}
onClick={aplicarFiltros}
disabled={loading}
>
Aplicar filtros
</Button>
</Stack>
</CardContent>
</Card>
<Card>
<CardContent sx={{ padding: 0 }}>
{loading ? (
<Box padding={3} display="flex" alignItems="center" gap={2}>
<CircularProgress size={22} />
<Typography>Carregando usuários...</Typography>
</Box>
) : usuarios.length === 0 ? (
<Box padding={3}>
<Typography fontWeight={800}>
Nenhum usuário encontrado.
</Typography>
<Typography color="text.secondary" marginTop={0.5}>
Ajuste os filtros ou cadastre um novo usuário.
</Typography>
</Box>
) : isMobile ? (
<Stack divider={<Divider />}>
{usuarios.map((item) => (
<Box
key={item.idusuarios}
sx={{
padding: 2,
'&:hover': {
backgroundColor: 'rgba(15,23,42,0.02)',
},
}}
>
<Stack spacing={1.25}>
<Stack
direction="row"
justifyContent="space-between"
alignItems="flex-start"
spacing={2}
>
<Box sx={{ minWidth: 0 }}>
<Typography fontWeight={900}>
{item.nome}
</Typography>
<Typography variant="body2" color="text.secondary">
Cadastro: {formatarData(item.insert_date)}
</Typography>
</Box>
</Stack>
<Stack direction="row" flexWrap="wrap" spacing={1} useFlexGap>
<Chip
label={statusLabel(item.habilitado)}
size="small"
color={statusColor(item.habilitado) as any}
variant={Number(item.habilitado) === 1 ? 'filled' : 'outlined'}
/>
</Stack>
<Typography variant="body2" color="text.secondary">
Anotações: <strong>{item.anotacoes || '-'}</strong>
</Typography>
<Box
display="flex"
justifyContent="flex-end"
alignItems="center"
gap={1}
>
<Tooltip
title={
Number(item.habilitado) === 1
? 'Desabilitar usuário'
: 'Habilitar usuário'
}
>
<span>
<Switch
size="small"
checked={Number(item.habilitado) === 1}
disabled={alterandoStatusId === item.idusuarios || loading}
onChange={() => handleAlterarStatusUsuario(item)}
/>
</span>
</Tooltip>
<Button
component={RouterLink}
to={`/usuarios/${item.idusuarios}/editar`}
variant="outlined"
size="small"
startIcon={<EditIcon />}
>
Editar
</Button>
<Button
variant="outlined"
color="error"
size="small"
startIcon={<DeleteIcon />}
onClick={() => handleDeletarUsuario(item)}
>
Excluir
</Button>
</Box>
</Stack>
</Box>
))}
</Stack>
) : (
<TableContainer sx={{ overflowX: 'auto' }}>
<Table
size="small"
sx={{
minWidth: 820,
'& th': {
whiteSpace: 'nowrap',
fontWeight: 900,
backgroundColor: 'rgba(15,23,42,0.04)',
},
'& td': {
whiteSpace: 'nowrap',
},
}}
>
<TableHead>
<TableRow>
<TableCell sx={{ minWidth: 220 }}>Nome</TableCell>
<TableCell sx={{ minWidth: 260 }}>Anotações</TableCell>
<TableCell sx={{ minWidth: 130 }}>Status</TableCell>
<TableCell sx={{ minWidth: 130 }}>Cadastro</TableCell>
<TableCell sx={{ minWidth: 130 }}>Atualização</TableCell>
<TableCell sx={{ minWidth: 130 }} align="center">Ações</TableCell>
</TableRow>
</TableHead>
<TableBody>
{usuarios.map((item) => (
<TableRow
key={item.idusuarios}
hover
sx={{
'&:last-child td': {
borderBottom: 0,
},
}}
>
<TableCell>
<Box sx={{ maxWidth: 240 }}>
<Typography fontWeight={800} noWrap title={item.nome}>
{item.nome}
</Typography>
</Box>
</TableCell>
<TableCell>
<Box sx={{ maxWidth: 300 }}>
<Typography variant="body2" noWrap title={item.anotacoes || '-'}>
{item.anotacoes || '-'}
</Typography>
</Box>
</TableCell>
<TableCell>
<Chip
label={statusLabel(item.habilitado)}
size="small"
color={statusColor(item.habilitado) as any}
variant={Number(item.habilitado) === 1 ? 'filled' : 'outlined'}
/>
</TableCell>
<TableCell>
{formatarData(item.insert_date)}
</TableCell>
<TableCell>
{formatarData(item.update_date)}
</TableCell>
<TableCell align="center">
<Stack
direction="row"
spacing={0.5}
alignItems="center"
justifyContent="center"
>
<Tooltip
title={
Number(item.habilitado) === 1
? 'Desabilitar usuário'
: 'Habilitar usuário'
}
>
<span>
<Switch
size="small"
checked={Number(item.habilitado) === 1}
disabled={alterandoStatusId === item.idusuarios || loading}
onChange={() => handleAlterarStatusUsuario(item)}
/>
</span>
</Tooltip>
<Tooltip title="Editar usuário">
<IconButton
component={RouterLink}
to={`/usuarios/${item.idusuarios}/editar`}
color="primary"
size="small"
>
<EditIcon />
</IconButton>
</Tooltip>
<Tooltip title="Excluir usuário">
<IconButton
color="error"
size="small"
onClick={() => handleDeletarUsuario(item)}
>
<DeleteIcon />
</IconButton>
</Tooltip>
</Stack>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
</CardContent>
</Card>
{pagination.totalPages > 1 && (
<Stack alignItems="center" marginTop={3}>
<Pagination
page={pagination.page}
count={pagination.totalPages}
color="primary"
onChange={(_, novaPagina) => {
setPage(novaPagina);
carregarUsuarios(novaPagina);
}}
/>
</Stack>
)}
<Paper
elevation={0}
sx={{
marginTop: 2,
padding: 2,
border: '1px solid',
borderColor: 'divider',
borderRadius: 3,
backgroundColor: 'rgba(255,255,255,0.65)',
}}
>
<Typography variant="body2" color="text.secondary">
Exibindo página {pagination.page} de {pagination.totalPages}, total de{' '}
<strong>{pagination.total}</strong> registro(s).
</Typography>
</Paper>
</Box>
);
}

View File

@ -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<UsuariosListResponse> {
const response = await api.get<UsuariosListResponse>('/usuarios', {
params,
});
return response.data;
}
export async function buscarUsuarioPorId(id: number): Promise<Usuario> {
const response = await api.get<ApiResponse<Usuario>>(`/usuarios/${id}`);
return response.data.data;
}
export async function criarUsuario(
data: CriarUsuarioRequest
): Promise<Usuario> {
const response = await api.post<ApiResponse<Usuario>>('/usuarios', data);
return response.data.data;
}
export async function atualizarUsuario(
id: number,
data: AtualizarUsuarioRequest
): Promise<Usuario> {
const response = await api.put<ApiResponse<Usuario>>(`/usuarios/${id}`, data);
return response.data.data;
}
export async function alterarHabilitadoUsuario(
id: number,
habilitado: number
): Promise<Usuario> {
const response = await api.patch<ApiResponse<Usuario>>(
`/usuarios/${id}/habilitado`,
{ habilitado }
);
return response.data.data;
}
export async function deletarUsuario(id: number): Promise<void> {
await api.delete(`/usuarios/${id}`);
}

View File

@ -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<T> = {
ok: boolean;
message?: string;
data: T;
};
export type UsuariosListResponse = {
ok: boolean;
data: Usuario[];
pagination: UsuariosPagination;
summary: UsuariosSummary;
};