adicionado novos modulos e automacao na tela de movimentos
This commit is contained in:
parent
79070aecb8
commit
c6418df307
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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
|
||||
`
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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 />,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import { BancoForm } from '../components/BancoForm';
|
||||
|
||||
export function NovoBancoPage() {
|
||||
return <BancoForm mode="create" />;
|
||||
}
|
||||
|
|
@ -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}`);
|
||||
}
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import { CentroCustoForm } from '../components/CentroCustoForm';
|
||||
|
||||
export function NovoCentroCustoPage() {
|
||||
return <CentroCustoForm mode="create" />;
|
||||
}
|
||||
|
|
@ -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}`);
|
||||
}
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import { ClienteForm } from '../components/ClienteForm';
|
||||
|
||||
export function NovoClientePage() {
|
||||
return <ClienteForm mode="create" />;
|
||||
}
|
||||
|
|
@ -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}`);
|
||||
}
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
}
|
||||
|
|
@ -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[];
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import { MovimentoFixoForm } from '../components/MovimentoFixoForm';
|
||||
|
||||
export function NovoMovimentoFixoPage() {
|
||||
return <MovimentoFixoForm mode="create" />;
|
||||
}
|
||||
|
|
@ -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}`);
|
||||
}
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import { UsuarioForm } from '../components/UsuarioForm';
|
||||
|
||||
export function NovoUsuarioPage() {
|
||||
return <UsuarioForm mode="create" />;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}`);
|
||||
}
|
||||
|
|
@ -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;
|
||||
};
|
||||
Loading…
Reference in New Issue