ajustes em simulacoes por usuario

This commit is contained in:
Diego Freitas 2026-06-06 14:28:06 -03:00
parent 09ef64f491
commit 180d756195
21 changed files with 1925 additions and 725 deletions

View File

@ -1,40 +1,72 @@
const centrosCustoService = require('../services/centrosCusto.service');
function obterIdUsuario(req) {
return Number(
req.user?.idusuarios
?? req.user?.idusuario
?? req.user?.id
);
}
function normalizarFlagEntrada(valor) {
if (valor === undefined || valor === null || valor === '') {
return undefined;
}
if (valor === true || valor === 'true') {
return 1;
}
if (valor === false || valor === 'false') {
return 0;
}
return Number(valor);
}
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 simular = normalizarFlagEntrada(dados.simular);
if (simular !== undefined && ![0, 1].includes(simular)) {
return 'Campo simular inválido.';
}
if (simular === 1) {
if (dados.limite === undefined || dados.limite === null || dados.limite === '') {
return 'Informe o limite para incluir o centro de custo na simulação.';
}
const limite = Number(dados.limite);
if (!Number.isFinite(limite)) {
if (!Number.isFinite(limite) || limite < 0) {
return 'Limite inválido.';
}
} else if (
dados.limite !== undefined
&& dados.limite !== null
&& dados.limite !== ''
) {
const limite = Number(dados.limite);
if (!Number.isFinite(limite) || limite < 0) {
return 'Limite inválido.';
}
}
if (dados.simular !== undefined && dados.simular !== null && dados.simular !== '') {
const simular = Number(dados.simular);
const investimento = normalizarFlagEntrada(dados.investimento);
if (![0, 1].includes(simular)) {
return 'Campo simular inválido.';
}
if (investimento !== undefined && ![0, 1].includes(investimento)) {
return 'Campo investimento inválido.';
}
if (dados.investimento !== undefined && dados.investimento !== null && dados.investimento !== '') {
const investimento = Number(dados.investimento);
const habilitado = normalizarFlagEntrada(dados.habilitado);
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.';
}
if (habilitado !== undefined && ![0, 1].includes(habilitado)) {
return 'Status inválido.';
}
return null;
@ -42,7 +74,17 @@ function validarDadosCentroCusto(dados) {
async function listar(req, res) {
try {
const idUsuario = obterIdUsuario(req);
if (!idUsuario) {
return res.status(401).json({
ok: false,
message: 'Usuário não identificado.',
});
}
const resultado = await centrosCustoService.listarCentrosCusto({
idUsuario,
limite: req.query.limite,
offset: req.query.offset,
page: req.query.page,
@ -73,6 +115,7 @@ async function listar(req, res) {
async function buscarPorId(req, res) {
try {
const id = Number(req.params.id);
const idUsuario = obterIdUsuario(req);
if (!id) {
return res.status(400).json({
@ -81,7 +124,17 @@ async function buscarPorId(req, res) {
});
}
const centroCusto = await centrosCustoService.buscarCentroCustoPorId(id);
if (!idUsuario) {
return res.status(401).json({
ok: false,
message: 'Usuário não identificado.',
});
}
const centroCusto = await centrosCustoService.buscarCentroCustoPorId(
id,
idUsuario
);
if (!centroCusto) {
return res.status(404).json({
@ -106,6 +159,15 @@ async function buscarPorId(req, res) {
async function criar(req, res) {
try {
const idUsuario = obterIdUsuario(req);
if (!idUsuario) {
return res.status(401).json({
ok: false,
message: 'Usuário não identificado.',
});
}
const erroValidacao = validarDadosCentroCusto(req.body);
if (erroValidacao) {
@ -115,7 +177,10 @@ async function criar(req, res) {
});
}
const novoCentroCusto = await centrosCustoService.criarCentroCusto(req.body);
const novoCentroCusto = await centrosCustoService.criarCentroCusto(
req.body,
idUsuario
);
return res.status(201).json({
ok: true,
@ -135,6 +200,7 @@ async function criar(req, res) {
async function atualizar(req, res) {
try {
const id = Number(req.params.id);
const idUsuario = obterIdUsuario(req);
if (!id) {
return res.status(400).json({
@ -143,6 +209,13 @@ async function atualizar(req, res) {
});
}
if (!idUsuario) {
return res.status(401).json({
ok: false,
message: 'Usuário não identificado.',
});
}
const erroValidacao = validarDadosCentroCusto(req.body);
if (erroValidacao) {
@ -152,7 +225,11 @@ async function atualizar(req, res) {
});
}
const centroAtualizado = await centrosCustoService.atualizarCentroCusto(id, req.body);
const centroAtualizado = await centrosCustoService.atualizarCentroCusto(
id,
req.body,
idUsuario
);
if (!centroAtualizado) {
return res.status(404).json({
@ -179,6 +256,7 @@ async function atualizar(req, res) {
async function alterarHabilitado(req, res) {
try {
const id = Number(req.params.id);
const idUsuario = obterIdUsuario(req);
if (!id) {
return res.status(400).json({
@ -187,7 +265,14 @@ async function alterarHabilitado(req, res) {
});
}
const habilitado = Number(req.body.habilitado);
if (!idUsuario) {
return res.status(401).json({
ok: false,
message: 'Usuário não identificado.',
});
}
const habilitado = normalizarFlagEntrada(req.body.habilitado);
if (![0, 1].includes(habilitado)) {
return res.status(400).json({
@ -196,7 +281,12 @@ async function alterarHabilitado(req, res) {
});
}
const centroAtualizado = await centrosCustoService.alterarHabilitadoCentroCusto(id, habilitado);
const centroAtualizado =
await centrosCustoService.alterarHabilitadoCentroCusto(
id,
habilitado,
idUsuario
);
if (!centroAtualizado) {
return res.status(404).json({
@ -270,4 +360,4 @@ module.exports = {
atualizar,
alterarHabilitado,
deletar,
};
};

View File

@ -13,4 +13,4 @@ router.put('/:id', centrosCustoController.atualizar);
router.patch('/:id/habilitado', centrosCustoController.alterarHabilitado);
router.delete('/:id', centrosCustoController.deletar);
module.exports = router;
module.exports = router;

View File

@ -1,14 +1,17 @@
const pool = require('../../../database/mysql');
const CAMPOS_CENTRO_CUSTO_SELECT = `
idcentrodecustos,
descricao,
limite,
simular,
investimento,
habilitado,
insert_date,
update_date
cc.idcentrodecustos,
cc.descricao,
cc.investimento,
cc.habilitado,
cc.insert_date,
cc.update_date,
CASE
WHEN ccs.idcentrodecustossimulacao IS NOT NULL THEN 1
ELSE 0
END AS simular,
ccs.limite
`;
function normalizarTextoOuNull(valor) {
@ -38,9 +41,15 @@ function normalizarFlag(valor, padrao = 0) {
return padrao;
}
const numero = Number(valor);
if (valor === true || valor === 'true') {
return 1;
}
return numero === 1 ? 1 : 0;
if (valor === false || valor === 'false') {
return 0;
}
return Number(valor) === 1 ? 1 : 0;
}
function limitarNumero(valor, padrao, minimo, maximo) {
@ -63,17 +72,17 @@ function limitarNumero(valor, padrao, minimo, maximo) {
function resolverOrdenacao(orderBy) {
const camposPermitidos = {
idcentrodecustos: 'idcentrodecustos',
descricao: 'descricao',
limite: 'limite',
idcentrodecustos: 'cc.idcentrodecustos',
descricao: 'cc.descricao',
limite: 'ccs.limite',
simular: 'simular',
investimento: 'investimento',
habilitado: 'habilitado',
insert_date: 'insert_date',
update_date: 'update_date',
investimento: 'cc.investimento',
habilitado: 'cc.habilitado',
insert_date: 'cc.insert_date',
update_date: 'cc.update_date',
};
return camposPermitidos[orderBy] || 'descricao';
return camposPermitidos[orderBy] || 'cc.descricao';
}
function resolverDirecao(orderDirection) {
@ -84,41 +93,51 @@ 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.simular !== undefined
&& filtros.simular !== null
&& filtros.simular !== ''
) {
const simular = normalizarFlag(filtros.simular, 0);
where.push(
simular === 1
? 'ccs.idcentrodecustossimulacao IS NOT NULL'
: 'ccs.idcentrodecustossimulacao IS NULL'
);
}
if (filtros.investimento !== undefined && filtros.investimento !== null && filtros.investimento !== '') {
where.push('investimento = ?');
params.push(Number(filtros.investimento));
if (
filtros.investimento !== undefined
&& filtros.investimento !== null
&& filtros.investimento !== ''
) {
where.push('cc.investimento = ?');
params.push(normalizarFlag(filtros.investimento, 0));
}
if (filtros.habilitado !== undefined && filtros.habilitado !== null && filtros.habilitado !== '') {
where.push('habilitado = ?');
params.push(Number(filtros.habilitado));
if (
filtros.habilitado !== undefined
&& filtros.habilitado !== null
&& filtros.habilitado !== ''
) {
where.push('cc.habilitado = ?');
params.push(normalizarFlag(filtros.habilitado, 0));
}
if (filtros.busca) {
where.push(`
(
descricao LIKE ?
)
`);
const termo = `%${String(filtros.busca).trim()}%`;
params.push(termo);
where.push('cc.descricao LIKE ?');
params.push(`%${String(filtros.busca).trim()}%`);
}
const whereSql = where.length > 0 ? `WHERE ${where.join(' AND ')}` : '';
return {
whereSql,
whereSql: where.length > 0 ? `WHERE ${where.join(' AND ')}` : '',
params,
};
}
async function listarCentrosCusto(filtros = {}) {
const idUsuario = Number(filtros.idUsuario);
const limite = limitarNumero(filtros.limite, 20, 1, 100);
const page = limitarNumero(filtros.page, 1, 1, 999999);
const offset = filtros.offset !== undefined
@ -127,50 +146,69 @@ async function listarCentrosCusto(filtros = {}) {
const orderBy = resolverOrdenacao(filtros.orderBy);
const orderDirection = resolverDirecao(filtros.orderDirection);
const { whereSql, params } = montarWhereCentrosCusto(filtros);
const joinSql = `
FROM centrodecustos cc
LEFT JOIN centrodecustossimulacao ccs
ON ccs.idcentrodecustos = cc.idcentrodecustos
AND ccs.idusuarios = ?
`;
const queryParams = [idUsuario, ...params];
const [rows] = await pool.query(
`
SELECT
${CAMPOS_CENTRO_CUSTO_SELECT}
FROM centrodecustos
${joinSql}
${whereSql}
ORDER BY ${orderBy} ${orderDirection}, idcentrodecustos ASC
ORDER BY ${orderBy} ${orderDirection}, cc.idcentrodecustos ASC
LIMIT ? OFFSET ?
`,
[...params, limite, offset]
[...queryParams, limite, offset]
);
const [countRows] = await pool.query(
`
SELECT COUNT(*) AS total
FROM centrodecustos
${joinSql}
${whereSql}
`,
params
queryParams
);
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
COALESCE(SUM(ccs.limite), 0) AS limiteTotal,
COALESCE(SUM(CASE WHEN cc.habilitado = 1 THEN 1 ELSE 0 END), 0) AS habilitados,
COALESCE(SUM(CASE WHEN cc.habilitado = 0 THEN 1 ELSE 0 END), 0) AS desabilitados,
COALESCE(SUM(CASE WHEN cc.investimento = 1 THEN 1 ELSE 0 END), 0) AS investimentos,
COALESCE(SUM(
CASE
WHEN ccs.idcentrodecustossimulacao IS NOT NULL THEN 1
ELSE 0
END
), 0) AS simulaveis
${joinSql}
${whereSql}
`,
params
queryParams
);
const total = Number(countRows[0]?.total || 0);
const totalPages = Math.max(1, Math.ceil(total / limite));
return {
data: rows,
data: rows.map((row) => ({
...row,
limite: row.limite === null ? null : Number(row.limite),
simular: Number(row.simular),
investimento: Number(row.investimento),
habilitado: Number(row.habilitado),
})),
pagination: {
total,
limite,
@ -189,102 +227,178 @@ async function listarCentrosCusto(filtros = {}) {
};
}
async function buscarCentroCustoPorId(id) {
const [rows] = await pool.query(
async function buscarCentroCustoPorId(id, idUsuario, executor = pool) {
const [rows] = await executor.query(
`
SELECT
${CAMPOS_CENTRO_CUSTO_SELECT}
FROM centrodecustos
WHERE idcentrodecustos = ?
FROM centrodecustos cc
LEFT JOIN centrodecustossimulacao ccs
ON ccs.idcentrodecustos = cc.idcentrodecustos
AND ccs.idusuarios = ?
WHERE cc.idcentrodecustos = ?
LIMIT 1
`,
[id]
[idUsuario, id]
);
return rows[0] || null;
const centro = rows[0];
if (!centro) {
return null;
}
return {
...centro,
limite: centro.limite === null ? null : Number(centro.limite),
simular: Number(centro.simular),
investimento: Number(centro.investimento),
habilitado: Number(centro.habilitado),
};
}
async function criarCentroCusto(dados) {
const {
descricao,
limite,
simular,
investimento,
habilitado,
} = dados;
async function sincronizarSimulacao(
executor,
idCentroCusto,
idUsuario,
dados
) {
const simular = normalizarFlag(dados.simular, 0);
if (simular === 1) {
await executor.query(
`
INSERT INTO centrodecustossimulacao (
idcentrodecustos,
idusuarios,
limite,
insert_date,
update_date
) VALUES (?, ?, ?, NOW(), NOW())
ON DUPLICATE KEY UPDATE
limite = VALUES(limite),
update_date = NOW()
`,
[
idCentroCusto,
idUsuario,
normalizarNumero(dados.limite, 0),
]
);
return;
}
await executor.query(
`
DELETE FROM centrodecustossimulacao
WHERE idcentrodecustos = ?
AND idusuarios = ?
`,
[idCentroCusto, idUsuario]
);
}
async function criarCentroCusto(dados, idUsuario) {
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
const [result] = await connection.query(
`
INSERT INTO centrodecustos (
descricao,
investimento,
habilitado,
insert_date,
update_date
) VALUES (?, ?, ?, NOW(), NOW())
`,
[
normalizarTextoOuNull(dados.descricao),
normalizarFlag(dados.investimento, 0),
normalizarFlag(dados.habilitado, 1),
]
);
await sincronizarSimulacao(
connection,
result.insertId,
idUsuario,
dados
);
await connection.commit();
return buscarCentroCustoPorId(result.insertId, idUsuario);
} catch (error) {
await connection.rollback();
throw error;
} finally {
connection.release();
}
}
async function atualizarCentroCusto(id, dados, idUsuario) {
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
const [rows] = await connection.query(
`
SELECT idcentrodecustos
FROM centrodecustos
WHERE idcentrodecustos = ?
LIMIT 1
`,
[id]
);
if (!rows[0]) {
await connection.rollback();
return null;
}
await connection.query(
`
UPDATE centrodecustos
SET
descricao = ?,
investimento = ?,
habilitado = ?,
update_date = NOW()
WHERE idcentrodecustos = ?
`,
[
normalizarTextoOuNull(dados.descricao),
normalizarFlag(dados.investimento, 0),
normalizarFlag(dados.habilitado, 1),
id,
]
);
await sincronizarSimulacao(
connection,
id,
idUsuario,
dados
);
await connection.commit();
return buscarCentroCustoPorId(id, idUsuario);
} catch (error) {
await connection.rollback();
throw error;
} finally {
connection.release();
}
}
async function alterarHabilitadoCentroCusto(id, habilitado, idUsuario) {
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
@ -298,17 +412,15 @@ async function alterarHabilitadoCentroCusto(id, habilitado) {
]
);
return buscarCentroCustoPorId(id);
if (result.affectedRows === 0) {
return null;
}
return buscarCentroCustoPorId(id, idUsuario);
}
async function deletarCentroCusto(id) {
const centroAtual = await buscarCentroCustoPorId(id);
if (!centroAtual) {
return false;
}
await pool.query(
const [result] = await pool.query(
`
DELETE FROM centrodecustos
WHERE idcentrodecustos = ?
@ -316,7 +428,7 @@ async function deletarCentroCusto(id) {
[id]
);
return true;
return result.affectedRows > 0;
}
module.exports = {
@ -326,4 +438,4 @@ module.exports = {
atualizarCentroCusto,
alterarHabilitadoCentroCusto,
deletarCentroCusto,
};
};

View File

@ -1,7 +1,15 @@
const dashboardService = require('../services/dashboard.service');
function resolverIdUsuario(req) {
return Number(req.user?.idusuarios || req.user?.id);
return Number(
req.usuario?.idusuarios
?? req.user?.idusuarios
?? req.usuario?.idusuario
?? req.user?.idusuario
?? req.usuario?.id
?? req.user?.id
?? 0
);
}
async function buscarResumo(req, res) {
@ -27,9 +35,12 @@ async function buscarResumo(req, res) {
} catch (error) {
console.error('[dashboard.controller] buscarResumo:', error);
return res.status(500).json({
return res.status(error.statusCode || 500).json({
ok: false,
message: 'Não foi possível carregar o resumo da dashboard.',
message:
error.statusCode
? error.message
: 'Não foi possível carregar o resumo da dashboard.',
});
}
}

View File

@ -1,34 +1,49 @@
const pool = require('../../../database/mysql');
function formatarDataLocal(data) {
const ano = data.getFullYear();
const mes = String(data.getMonth() + 1).padStart(2, '0');
const dia = String(data.getDate()).padStart(2, '0');
return `${ano}-${mes}-${dia}`;
}
function hojeISO() {
return new Date().toISOString().slice(0, 10);
return formatarDataLocal(new Date());
}
function inicioMesAtualISO() {
const hoje = new Date();
return new Date(hoje.getFullYear(), hoje.getMonth(), 1)
.toISOString()
.slice(0, 10);
return formatarDataLocal(
new Date(hoje.getFullYear(), hoje.getMonth(), 1)
);
}
function fimMesAtualISO() {
const hoje = new Date();
return new Date(hoje.getFullYear(), hoje.getMonth() + 1, 0)
.toISOString()
.slice(0, 10);
return formatarDataLocal(
new Date(hoje.getFullYear(), hoje.getMonth() + 1, 0)
);
}
function normalizarDataISO(valor, fallback) {
if (!valor) return fallback;
const texto = String(valor).slice(0, 10);
const data = new Date(`${texto}T00:00:00`);
if (Number.isNaN(data.getTime())) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(texto)) {
return fallback;
}
return texto;
const [ano, mes, dia] = texto.split('-').map(Number);
const data = new Date(ano, mes - 1, dia);
const valida =
data.getFullYear() === ano
&& data.getMonth() === mes - 1
&& data.getDate() === dia;
return valida ? texto : fallback;
}
function normalizarNumero(valor, padrao = 0) {
@ -84,10 +99,7 @@ async function buscarCardsBancos(idUsuario) {
COUNT(*) AS quantidadeCarteiras,
SUM(CASE
WHEN habilitado = 1 THEN 1
ELSE 0
END) AS carteirasHabilitadas,
COUNT(*) AS carteirasHabilitadas,
SUM(CASE
WHEN debito = 1
@ -113,6 +125,7 @@ async function buscarCardsBancos(idUsuario) {
END) AS quantidadeCreditos
FROM bancos
WHERE idusuarios = ?
AND habilitado = 1
`,
[idUsuario]
);
@ -149,16 +162,18 @@ async function buscarCardsMovimentos(idUsuario, dataInicio, dataFim) {
ELSE 0
END), 0) AS entradas,
COALESCE(SUM(CASE
WHEN cp.movimento = 'Saida' THEN cp.valor
ELSE 0
COALESCE(SUM(CASE
WHEN cp.movimento IN ('Saida', 'Sangria')
AND COALESCE(cc.investimento, 0) = 0
THEN cp.valor
ELSE 0
END), 0) AS saidas,
COALESCE(SUM(CASE
WHEN cp.movimento = 'Sangria'
COALESCE(SUM(CASE
WHEN cp.movimento IN ('Saida', 'Sangria')
AND COALESCE(cc.investimento, 0) = 1
THEN cp.valor
ELSE 0
THEN cp.valor
ELSE 0
END), 0) AS investimentos,
COALESCE(SUM(CASE
@ -175,34 +190,36 @@ async function buscarCardsMovimentos(idUsuario, dataInicio, dataFim) {
ELSE NULL
END) AS quantidadeAbertoEntradas,
COALESCE(SUM(CASE
COALESCE(SUM(CASE
WHEN cp.status = 'A pagar'
AND cp.movimento = 'Saida'
THEN cp.valor
ELSE 0
AND cp.movimento IN ('Saida', 'Sangria')
AND COALESCE(cc.investimento, 0) = 0
THEN cp.valor
ELSE 0
END), 0) AS abertoSaidas,
COUNT(CASE
COUNT(CASE
WHEN cp.status = 'A pagar'
AND cp.movimento = 'Saida'
THEN 1
ELSE NULL
AND cp.movimento IN ('Saida', 'Sangria')
AND COALESCE(cc.investimento, 0) = 0
THEN 1
ELSE NULL
END) AS quantidadeAbertoSaidas,
COALESCE(SUM(CASE
COALESCE(SUM(CASE
WHEN cp.status = 'A pagar'
AND cp.movimento = 'Sangria'
AND cp.movimento IN ('Saida', 'Sangria')
AND COALESCE(cc.investimento, 0) = 1
THEN cp.valor
ELSE 0
THEN cp.valor
ELSE 0
END), 0) AS abertoInvestimentos,
COUNT(CASE
COUNT(CASE
WHEN cp.status = 'A pagar'
AND cp.movimento = 'Sangria'
AND cp.movimento IN ('Saida', 'Sangria')
AND COALESCE(cc.investimento, 0) = 1
THEN 1
ELSE NULL
THEN 1
ELSE NULL
END) AS quantidadeAbertoInvestimentos,
COALESCE(SUM(CASE
@ -212,7 +229,7 @@ async function buscarCardsMovimentos(idUsuario, dataInicio, dataFim) {
COUNT(*) AS quantidadeMovimentos
FROM contasapagar cp
LEFT JOIN bancos b
INNER JOIN bancos b
ON b.idbancos = cp.idbancos
LEFT JOIN centrodecustos cc
ON cc.idcentrodecustos = cp.idcentrodecustos
@ -320,8 +337,9 @@ async function buscarAlertasVencimento(idUsuario) {
COALESCE(SUM(CASE
WHEN DATE(cp.datavencimento) >= ?
AND DATE(cp.datavencimento) <= DATE_ADD(?, INTERVAL 7 DAY)
AND cp.movimento = 'Saida'
AND cp.movimento IN ('Saida', 'Sangria')
AND cp.status = 'A pagar'
AND COALESCE(cc.investimento, 0) = 0
THEN cp.valor
ELSE 0
END), 0) AS venceEmBreveSaidas,
@ -329,8 +347,9 @@ async function buscarAlertasVencimento(idUsuario) {
COUNT(CASE
WHEN DATE(cp.datavencimento) >= ?
AND DATE(cp.datavencimento) <= DATE_ADD(?, INTERVAL 7 DAY)
AND cp.movimento = 'Saida'
AND cp.movimento IN ('Saida', 'Sangria')
AND cp.status = 'A pagar'
AND COALESCE(cc.investimento, 0) = 0
THEN 1
ELSE NULL
END) AS quantidadeVenceEmBreveSaidas,
@ -338,7 +357,7 @@ async function buscarAlertasVencimento(idUsuario) {
COALESCE(SUM(CASE
WHEN DATE(cp.datavencimento) >= ?
AND DATE(cp.datavencimento) <= DATE_ADD(?, INTERVAL 7 DAY)
AND cp.movimento = 'Sangria'
AND cp.movimento IN ('Saida', 'Sangria')
AND cp.status = 'A pagar'
AND COALESCE(cc.investimento, 0) = 1
THEN cp.valor
@ -348,7 +367,7 @@ async function buscarAlertasVencimento(idUsuario) {
COUNT(CASE
WHEN DATE(cp.datavencimento) >= ?
AND DATE(cp.datavencimento) <= DATE_ADD(?, INTERVAL 7 DAY)
AND cp.movimento = 'Sangria'
AND cp.movimento IN ('Saida', 'Sangria')
AND cp.status = 'A pagar'
AND COALESCE(cc.investimento, 0) = 1
THEN 1
@ -430,7 +449,7 @@ async function buscarProximosVencimentos(idUsuario) {
cc.descricao AS centro_custo_descricao,
DATEDIFF(DATE(cp.datavencimento), CURDATE()) AS dias_para_vencer
FROM contasapagar cp
LEFT JOIN bancos bc ON bc.idbancos = cp.idbancos
INNER JOIN bancos bc ON bc.idbancos = cp.idbancos
LEFT JOIN centrodecustos cc ON cc.idcentrodecustos = cp.idcentrodecustos
WHERE cp.deleted_at IS NULL
AND bc.idusuarios = ?
@ -630,7 +649,7 @@ async function buscarUltimosMovimentos(idUsuario) {
cp.idcentrodecustos,
cc.descricao AS centro_custo_descricao
FROM contasapagar cp
LEFT JOIN bancos bc ON bc.idbancos = cp.idbancos
INNER JOIN bancos bc ON bc.idbancos = cp.idbancos
LEFT JOIN centrodecustos cc ON cc.idcentrodecustos = cp.idcentrodecustos
WHERE cp.deleted_at IS NULL
AND bc.idusuarios = ?
@ -655,7 +674,8 @@ async function buscarGraficos(idUsuario, dataInicio, dataFim) {
LEFT JOIN bancos b
ON b.idbancos = cp.idbancos
WHERE cp.deleted_at IS NULL
AND cp.movimento IN ('Saida')
AND cp.movimento IN ('Saida', 'Sangria')
AND COALESCE(cc.investimento, 0) = 0
AND DATE(cp.datavencimento) BETWEEN ? AND ?
AND b.idusuarios = ?
GROUP BY
@ -676,16 +696,18 @@ async function buscarGraficos(idUsuario, dataInicio, dataFim) {
ELSE 0
END), 0) AS entradas,
COALESCE(SUM(CASE
WHEN cp.movimento = 'Saida' THEN cp.valor
ELSE 0
COALESCE(SUM(CASE
WHEN cp.movimento IN ('Saida', 'Sangria')
AND COALESCE(cc.investimento, 0) = 0
THEN cp.valor
ELSE 0
END), 0) AS saidas,
COALESCE(SUM(CASE
WHEN cp.movimento = 'Sangria'
AND COALESCE(cc.investimento, 0) = 1
THEN cp.valor
ELSE 0
COALESCE(SUM(CASE
WHEN cp.movimento IN ('Saida', 'Sangria')
AND COALESCE(cc.investimento, 0) = 1
THEN cp.valor
ELSE 0
END), 0) AS investimentos
FROM contasapagar cp
LEFT JOIN centrodecustos cc
@ -753,6 +775,12 @@ async function buscarResumoDashboard(idUsuario, filtros = {}) {
const dataInicio = normalizarDataISO(filtros.dataInicio, inicioMesAtualISO());
const dataFim = normalizarDataISO(filtros.dataFim, fimMesAtualISO());
if (dataInicio > dataFim) {
const error = new Error('A data inicial não pode ser maior que a data final.');
error.statusCode = 400;
throw error;
}
const [
cardsBancos,
cardsMovimentos,

View File

@ -1,12 +1,14 @@
const simulacaoMensalService = require('../services/simulacaoMensal.service');
function extrairIdUsuarioLogado(req) {
return (
req.usuario?.idusuarios ||
req.user?.idusuarios ||
req.usuario?.id ||
req.user?.id ||
null
return Number(
req.usuario?.idusuarios
?? req.user?.idusuarios
?? req.usuario?.idusuario
?? req.user?.idusuario
?? req.usuario?.id
?? req.user?.id
?? 0
);
}

View File

@ -310,23 +310,29 @@ async function buscarFixosUsuario(connection, idUsuario) {
return rows;
}
async function buscarCentrosSimulaveis(connection) {
async function buscarCentrosSimulaveis(connection, idUsuario) {
const [rows] = await connection.query(
`
SELECT
idcentrodecustos,
descricao,
limite,
simular,
investimento
FROM centrodecustos
WHERE habilitado = 1
AND simular = 1
ORDER BY descricao ASC
`
cc.idcentrodecustos,
cc.descricao,
ccs.limite,
cc.investimento
FROM centrodecustossimulacao ccs
INNER JOIN centrodecustos cc
ON cc.idcentrodecustos = ccs.idcentrodecustos
WHERE ccs.idusuarios = ?
AND cc.habilitado = 1
ORDER BY cc.descricao ASC
`,
[idUsuario]
);
return rows;
return rows.map((row) => ({
...row,
limite: Number(row.limite || 0),
investimento: Number(row.investimento || 0),
}));
}
function movimentoFixoJaExisteNoMes(fixo, movimentosDoMes) {
@ -716,7 +722,7 @@ async function montarBaseSimulacaoMensal(opcoes = {}) {
dataFim,
}),
buscarFixosUsuario(connection, idUsuario),
buscarCentrosSimulaveis(connection),
buscarCentrosSimulaveis(connection, idUsuario),
buscarMovimentosParaCentrosDoMes(connection, {
idUsuario,
dataInicio,

View File

@ -2,9 +2,16 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/svg+xml" href="/icon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>financeiro-web</title>
<title>Zendion Finance</title>
<meta
name="description"
content="Gestão financeira, simulações e controle de carteiras."
/>
<meta name="theme-color" content="#0F172A" />
</head>
<body>
<div id="root"></div>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

View File

@ -9,12 +9,27 @@ const SIDEBAR_WIDTH = 280;
export function AppLayout() {
const [mobileOpen, setMobileOpen] = useState(false);
function abrirMenuMobile() {
setMobileOpen(true);
}
function fecharMenuMobile() {
setMobileOpen(false);
}
return (
<Box sx={{ minHeight: '100vh', backgroundColor: 'background.default' }}>
<Box
sx={{
minHeight: '100vh',
overflowX: 'hidden',
backgroundColor: 'background.default',
}}
>
<Box
component="aside"
sx={{
width: SIDEBAR_WIDTH,
height: '100vh',
display: { xs: 'none', md: 'block' },
position: 'fixed',
inset: '0 auto 0 0',
@ -26,40 +41,49 @@ export function AppLayout() {
<Drawer
open={mobileOpen}
onClose={() => setMobileOpen(false)}
ModalProps={{ keepMounted: true }}
onClose={fecharMenuMobile}
ModalProps={{
keepMounted: true,
}}
sx={{
display: { xs: 'block', md: 'none' },
'& .MuiDrawer-paper': {
width: SIDEBAR_WIDTH,
border: 0,
overflow: 'hidden',
},
}}
>
<Sidebar onNavigate={() => setMobileOpen(false)} />
<Sidebar onNavigate={fecharMenuMobile} />
</Drawer>
<Box
sx={{
marginLeft: { xs: 0, md: `${SIDEBAR_WIDTH}px` },
marginLeft: {
xs: 0,
md: `${SIDEBAR_WIDTH}px`,
},
minHeight: '100vh',
display: 'flex',
flexDirection: 'column',
overflowX: 'hidden',
}}
>
<Topbar onMenuClick={() => setMobileOpen(true)} />
<Topbar onMenuClick={abrirMenuMobile} />
<Box
component="main"
sx={{
flex: 1,
width: '100%',
maxWidth: 1280,
margin: '0 auto',
maxWidth: 1440,
marginX: 'auto',
padding: {
xs: 2,
sm: 2.5,
md: 4,
md: 3.5,
lg: 4,
},
}}
>

View File

@ -1,3 +1,4 @@
import type { ReactNode } from 'react';
import {
Box,
Divider,
@ -5,170 +6,331 @@ import {
ListItemButton,
ListItemIcon,
ListItemText,
Stack,
Typography,
} from '@mui/material';
import DashboardIcon from '@mui/icons-material/Dashboard';
import SwapHorizIcon from '@mui/icons-material/SwapHoriz';
import AssessmentIcon from '@mui/icons-material/Assessment';
import AccountBalanceWalletIcon from '@mui/icons-material/AccountBalanceWallet';
import SavingsIcon from '@mui/icons-material/Savings';
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 CreditCardIcon from '@mui/icons-material/CreditCard';
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
import DashboardRoundedIcon from '@mui/icons-material/DashboardRounded';
import SwapHorizRoundedIcon from '@mui/icons-material/SwapHorizRounded';
import AssessmentRoundedIcon from '@mui/icons-material/AssessmentRounded';
import AccountBalanceWalletRoundedIcon from '@mui/icons-material/AccountBalanceWalletRounded';
import SavingsRoundedIcon from '@mui/icons-material/SavingsRounded';
import SettingsRoundedIcon from '@mui/icons-material/SettingsRounded';
import PeopleAltRoundedIcon from '@mui/icons-material/PeopleAltRounded';
import AccountTreeRoundedIcon from '@mui/icons-material/AccountTreeRounded';
import EventRepeatRoundedIcon from '@mui/icons-material/EventRepeatRounded';
import ManageAccountsRoundedIcon from '@mui/icons-material/ManageAccountsRounded';
import CreditCardRoundedIcon from '@mui/icons-material/CreditCardRounded';
import AutoAwesomeRoundedIcon from '@mui/icons-material/AutoAwesomeRounded';
import InsightsRoundedIcon from '@mui/icons-material/InsightsRounded';
import { NavLink } from 'react-router-dom';
type SidebarProps = {
onNavigate?: () => void;
};
const menuItems = [
type MenuItem = {
label: string;
path: string;
icon: ReactNode;
};
type MenuSection = {
title: string;
items: MenuItem[];
};
const menuSections: MenuSection[] = [
{
label: 'Dashboard',
path: '/dashboard',
icon: <DashboardIcon />,
title: 'Visão geral',
items: [
{
label: 'Dashboard',
path: '/dashboard',
icon: <DashboardRoundedIcon />,
},
{
label: 'Simulação mensal',
path: '/simulacao-mensal',
icon: <AutoAwesomeRoundedIcon />,
},
],
},
{
label: 'Movimentos',
path: '/movimentos',
icon: <SwapHorizIcon />,
title: 'Financeiro',
items: [
{
label: 'Movimentos',
path: '/movimentos',
icon: <SwapHorizRoundedIcon />,
},
{
label: 'Movimentos fixos',
path: '/movimentos-fixos',
icon: <EventRepeatRoundedIcon />,
},
{
label: 'Quitar créditos',
path: '/quitacoes-credito',
icon: <CreditCardRoundedIcon />,
},
{
label: 'Carteiras',
path: '/bancos',
icon: <AccountBalanceWalletRoundedIcon />,
},
{
label: 'Rendimentos',
path: '/rendimentos',
icon: <SavingsRoundedIcon />,
},
],
},
{
label: 'Quitar créditos',
path: '/quitacoes-credito',
icon: <CreditCardIcon />,
title: 'Cadastros',
items: [
{
label: 'Clientes',
path: '/clientes',
icon: <PeopleAltRoundedIcon />,
},
{
label: 'Centros de custo',
path: '/centros-custo',
icon: <AccountTreeRoundedIcon />,
},
],
},
{
label: 'Clientes',
path: '/clientes',
icon: <PeopleAltIcon />,
title: 'Análise',
items: [
{
label: 'Relatórios',
path: '/relatorios',
icon: <AssessmentRoundedIcon />,
},
],
},
{
label: 'Carteiras',
path: '/bancos',
icon: <AccountBalanceWalletIcon />,
},
{
label: 'Rendimentos',
path: '/rendimentos',
icon: <SavingsIcon />,
},
{
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',
icon: <AssessmentIcon />,
},
{
label: 'Simulação mensal',
path: '/simulacao-mensal',
icon: <AutoAwesomeIcon />,
},
{
label: 'Configurações',
path: '/configuracoes',
icon: <SettingsIcon />,
title: 'Administração',
items: [
{
label: 'Usuários',
path: '/usuarios',
icon: <ManageAccountsRoundedIcon />,
},
{
label: 'Configurações',
path: '/configuracoes',
icon: <SettingsRoundedIcon />,
},
],
},
];
export function Sidebar({ onNavigate }: SidebarProps) {
return (
<Box
component="aside"
sx={{
width: 280,
height: '100%',
display: 'flex',
flexDirection: 'column',
background: 'linear-gradient(180deg, #0F172A 0%, #111827 100%)',
color: '#fff',
color: '#FFFFFF',
background:
'linear-gradient(180deg, #0F172A 0%, #111827 52%, #0B1220 100%)',
borderRight: '1px solid rgba(255,255,255,0.06)',
boxShadow: '10px 0 30px rgba(15,23,42,0.16)',
}}
>
<Box sx={{ padding: 3 }}>
<Typography variant="h6" fontWeight={900}>
Zendion
</Typography>
<Box
sx={{
padding: 3,
paddingBottom: 2.5,
}}
>
<Stack direction="row" spacing={1.5} alignItems="center">
<Box
sx={{
width: 46,
height: 46,
flexShrink: 0,
borderRadius: 3,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background:
'linear-gradient(135deg, #3B82F6 0%, #2563EB 58%, #1D4ED8 100%)',
boxShadow: '0 12px 28px rgba(37,99,235,0.32)',
border: '1px solid rgba(255,255,255,0.18)',
}}
>
<InsightsRoundedIcon sx={{ fontSize: 27, color: '#FFFFFF' }} />
</Box>
<Typography variant="body2" sx={{ color: 'rgba(255,255,255,0.65)' }}>
Financeiro
</Typography>
<Box sx={{ minWidth: 0 }}>
<Typography
variant="h6"
fontWeight={950}
lineHeight={1.05}
noWrap
>
Zendion Finance
</Typography>
<Typography
variant="caption"
sx={{
color: 'rgba(255,255,255,0.58)',
fontWeight: 600,
letterSpacing: '0.02em',
}}
>
Gestão financeira
</Typography>
</Box>
</Stack>
</Box>
<Divider sx={{ borderColor: 'rgba(255,255,255,0.08)' }} />
<List sx={{ padding: 2, flex: 1 }}>
{menuItems.map((item) => (
<ListItemButton
key={item.path}
component={NavLink}
to={item.path}
onClick={onNavigate}
sx={{
borderRadius: 2,
marginBottom: 0.75,
color: 'rgba(255,255,255,0.72)',
'& .MuiListItemIcon-root': {
color: 'rgba(255,255,255,0.72)',
minWidth: 40,
},
'&.active': {
backgroundColor: 'rgba(59,130,246,0.22)',
color: '#fff',
'& .MuiListItemIcon-root': {
color: '#60A5FA',
},
},
'&:hover': {
backgroundColor: 'rgba(255,255,255,0.08)',
},
}}
>
<ListItemIcon>{item.icon}</ListItemIcon>
<List
component="nav"
aria-label="Menu principal"
sx={{
flex: 1,
overflowY: 'auto',
overflowX: 'hidden',
padding: 2,
paddingTop: 2.25,
<ListItemText
primary={item.label}
primaryTypographyProps={{
fontWeight: 700,
'&::-webkit-scrollbar': {
width: 6,
},
'&::-webkit-scrollbar-thumb': {
borderRadius: 999,
backgroundColor: 'rgba(255,255,255,0.12)',
},
'&::-webkit-scrollbar-track': {
backgroundColor: 'transparent',
},
}}
>
{menuSections.map((section) => (
<Box key={section.title} sx={{ marginBottom: 2.2 }}>
<Typography
variant="overline"
sx={{
display: 'block',
paddingX: 1.5,
marginBottom: 0.7,
color: 'rgba(255,255,255,0.36)',
fontWeight: 900,
fontSize: '0.68rem',
lineHeight: 1.8,
letterSpacing: '0.12em',
}}
/>
</ListItemButton>
>
{section.title}
</Typography>
{section.items.map((item) => (
<ListItemButton
key={item.path}
component={NavLink}
to={item.path}
onClick={onNavigate}
sx={{
minHeight: 46,
paddingX: 1.45,
marginBottom: 0.55,
borderRadius: 2.5,
color: 'rgba(255,255,255,0.70)',
transition:
'background-color 160ms ease, color 160ms ease, transform 160ms ease',
'& .MuiListItemIcon-root': {
minWidth: 39,
color: 'rgba(255,255,255,0.52)',
transition: 'color 160ms ease',
},
'& .MuiListItemText-primary': {
fontWeight: 750,
fontSize: '0.92rem',
},
'&:hover': {
color: '#FFFFFF',
backgroundColor: 'rgba(255,255,255,0.07)',
transform: 'translateX(2px)',
'& .MuiListItemIcon-root': {
color: 'rgba(255,255,255,0.82)',
},
},
'&.active': {
color: '#FFFFFF',
background:
'linear-gradient(90deg, rgba(59,130,246,0.30), rgba(59,130,246,0.12))',
boxShadow:
'inset 3px 0 0 #60A5FA, 0 8px 18px rgba(15,23,42,0.18)',
'& .MuiListItemIcon-root': {
color: '#60A5FA',
},
'&:hover': {
background:
'linear-gradient(90deg, rgba(59,130,246,0.34), rgba(59,130,246,0.15))',
},
},
}}
>
<ListItemIcon>{item.icon}</ListItemIcon>
<ListItemText primary={item.label} />
</ListItemButton>
))}
</Box>
))}
</List>
<Divider sx={{ borderColor: 'rgba(255,255,255,0.08)' }} />
<Box sx={{ padding: 2 }}>
<Box
sx={{
padding: 2,
borderRadius: 3,
backgroundColor: 'rgba(255,255,255,0.06)',
background:
'linear-gradient(135deg, rgba(255,255,255,0.07), rgba(255,255,255,0.035))',
border: '1px solid rgba(255,255,255,0.08)',
}}
>
<Typography variant="body2" fontWeight={800}>
MVP ativo
<Typography
variant="body2"
fontWeight={850}
sx={{ color: 'rgba(255,255,255,0.88)' }}
>
Zendion Finance
</Typography>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.62)' }}>
Movimentos e carteiras em operação.
<Typography
variant="caption"
sx={{
display: 'block',
marginTop: 0.25,
color: 'rgba(255,255,255,0.46)',
}}
>
Versão 1.0.0
</Typography>
</Box>
</Box>
</Box>
);
}
}

View File

@ -2,28 +2,146 @@ import {
AppBar,
Avatar,
Box,
Chip,
IconButton,
Stack,
Toolbar,
Tooltip,
Typography,
} from '@mui/material';
import MenuIcon from '@mui/icons-material/Menu';
import LogoutIcon from '@mui/icons-material/Logout';
import { useNavigate } from 'react-router-dom';
import MenuRoundedIcon from '@mui/icons-material/MenuRounded';
import LogoutRoundedIcon from '@mui/icons-material/LogoutRounded';
import { useLocation, useNavigate } from 'react-router-dom';
import { useAuthStore } from '../../features/auth/store/authStore';
type TopbarProps = {
onMenuClick: () => void;
};
type RouteInfo = {
eyebrow: string;
title: string;
};
const routeInfoMap: Record<string, RouteInfo> = {
'/dashboard': {
eyebrow: 'Visão geral',
title: 'Dashboard',
},
'/movimentos': {
eyebrow: 'Financeiro',
title: 'Movimentos',
},
'/quitacoes-credito': {
eyebrow: 'Financeiro',
title: 'Quitar créditos',
},
'/clientes': {
eyebrow: 'Cadastros',
title: 'Clientes',
},
'/bancos': {
eyebrow: 'Financeiro',
title: 'Carteiras',
},
'/rendimentos': {
eyebrow: 'Financeiro',
title: 'Rendimentos',
},
'/centros-custo': {
eyebrow: 'Cadastros',
title: 'Centros de custo',
},
'/movimentos-fixos': {
eyebrow: 'Financeiro',
title: 'Movimentos fixos',
},
'/usuarios': {
eyebrow: 'Administração',
title: 'Usuários',
},
'/relatorios': {
eyebrow: 'Análise',
title: 'Relatórios',
},
'/simulacao-mensal': {
eyebrow: 'Visão geral',
title: 'Simulação mensal',
},
'/configuracoes': {
eyebrow: 'Administração',
title: 'Configurações',
},
};
function resolverInformacaoRota(pathname: string): RouteInfo {
const rotaExata = routeInfoMap[pathname];
if (rotaExata) {
return rotaExata;
}
const rotaBase = Object.keys(routeInfoMap)
.sort((a, b) => b.length - a.length)
.find((rota) => pathname.startsWith(`${rota}/`));
if (rotaBase) {
const base = routeInfoMap[rotaBase];
if (pathname.endsWith('/novo')) {
return {
eyebrow: base.eyebrow,
title: `Novo ${base.title.toLowerCase()}`,
};
}
if (pathname.endsWith('/editar')) {
return {
eyebrow: base.eyebrow,
title: `Editar ${base.title.toLowerCase()}`,
};
}
return base;
}
return {
eyebrow: 'Zendion Finance',
title: 'Gestão financeira',
};
}
function obterIniciais(nome?: string | null) {
const partes = String(nome || '')
.trim()
.split(/\s+/)
.filter(Boolean);
if (partes.length === 0) {
return 'U';
}
if (partes.length === 1) {
return partes[0].charAt(0).toUpperCase();
}
return `${partes[0].charAt(0)}${partes[partes.length - 1].charAt(0)}`
.toUpperCase();
}
export function Topbar({ onMenuClick }: TopbarProps) {
const navigate = useNavigate();
const location = useLocation();
const user = useAuthStore((state) => state.user);
const logout = useAuthStore((state) => state.logout);
const routeInfo = resolverInformacaoRota(location.pathname);
const iniciais = obterIniciais(user?.nome);
function handleLogout() {
logout();
navigate('/login');
navigate('/login', { replace: true });
}
return (
@ -31,60 +149,171 @@ export function Topbar({ onMenuClick }: TopbarProps) {
position="sticky"
elevation={0}
sx={{
backgroundColor: '#FFFFFF',
zIndex: (theme) => theme.zIndex.drawer - 1,
color: '#111827',
backgroundColor: 'rgba(255,255,255,0.92)',
backdropFilter: 'blur(14px)',
borderBottom: '1px solid',
borderColor: 'divider',
borderColor: 'rgba(15,23,42,0.08)',
boxShadow: '0 8px 24px rgba(15,23,42,0.04)',
}}
>
<Toolbar sx={{ minHeight: 72 }}>
<IconButton
onClick={onMenuClick}
edge="start"
sx={{
display: { xs: 'inline-flex', md: 'none' },
marginRight: 1,
}}
>
<MenuIcon />
</IconButton>
<Toolbar
sx={{
minHeight: { xs: 68, md: 76 },
paddingX: { xs: 1.5, sm: 2.5, md: 3 },
}}
>
<Tooltip title="Abrir menu">
<IconButton
onClick={onMenuClick}
edge="start"
aria-label="Abrir menu"
sx={{
display: { xs: 'inline-flex', md: 'none' },
marginRight: 1.25,
width: 42,
height: 42,
borderRadius: 2.5,
color: 'text.primary',
backgroundColor: 'rgba(15,23,42,0.04)',
border: '1px solid rgba(15,23,42,0.06)',
<Box sx={{ flex: 1 }}>
<Typography variant="body2" color="text.secondary">
Painel financeiro
'&:hover': {
backgroundColor: 'rgba(59,130,246,0.08)',
},
}}
>
<MenuRoundedIcon />
</IconButton>
</Tooltip>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography
variant="caption"
sx={{
display: 'block',
color: 'text.secondary',
fontWeight: 750,
letterSpacing: '0.04em',
textTransform: 'uppercase',
lineHeight: 1.2,
}}
>
{routeInfo.eyebrow}
</Typography>
<Typography variant="h6" fontWeight={900}>
Controle de movimentos
<Typography
variant="h6"
fontWeight={950}
noWrap
sx={{
marginTop: 0.25,
fontSize: { xs: '1.02rem', sm: '1.18rem' },
letterSpacing: '-0.02em',
}}
>
{routeInfo.title}
</Typography>
</Box>
<Stack direction="row" alignItems="center" spacing={1.5}>
<Avatar
<Stack
direction="row"
alignItems="center"
spacing={{ xs: 0.75, sm: 1.25 }}
>
<Stack
direction="row"
alignItems="center"
spacing={1.15}
sx={{
width: 38,
height: 38,
bgcolor: 'primary.main',
fontWeight: 800,
paddingY: 0.6,
paddingLeft: 0.65,
paddingRight: { xs: 0.65, sm: 1.4 },
borderRadius: 3,
border: '1px solid rgba(15,23,42,0.07)',
backgroundColor: 'rgba(248,250,252,0.88)',
}}
>
{user?.nome?.charAt(0)?.toUpperCase() || 'U'}
</Avatar>
<Avatar
sx={{
width: 40,
height: 40,
fontSize: '0.9rem',
fontWeight: 900,
color: '#FFFFFF',
background:
'linear-gradient(135deg, #3B82F6 0%, #2563EB 100%)',
boxShadow: '0 8px 18px rgba(37,99,235,0.24)',
}}
>
{iniciais}
</Avatar>
<Box sx={{ display: { xs: 'none', sm: 'block' } }}>
<Typography variant="body2" fontWeight={800}>
{user?.nome || 'Usuário'}
</Typography>
<Typography variant="caption" color="text.secondary">
Logado
</Typography>
</Box>
<Box
sx={{
display: { xs: 'none', sm: 'block' },
minWidth: 0,
maxWidth: 180,
}}
>
<Typography
variant="body2"
fontWeight={850}
noWrap
title={user?.nome || 'Usuário'}
>
{user?.nome || 'Usuário'}
</Typography>
<IconButton onClick={handleLogout} title="Sair">
<LogoutIcon />
</IconButton>
<Typography
variant="caption"
color="text.secondary"
noWrap
sx={{ display: 'block' }}
>
Sessão ativa
</Typography>
</Box>
<Chip
label="Online"
size="small"
color="success"
variant="outlined"
sx={{
display: { xs: 'none', lg: 'inline-flex' },
height: 24,
fontWeight: 750,
fontSize: '0.68rem',
}}
/>
</Stack>
<Tooltip title="Sair da conta">
<IconButton
onClick={handleLogout}
aria-label="Sair da conta"
sx={{
width: 42,
height: 42,
borderRadius: 2.5,
color: 'text.secondary',
border: '1px solid rgba(15,23,42,0.07)',
backgroundColor: 'rgba(255,255,255,0.9)',
'&:hover': {
color: 'error.main',
backgroundColor: 'rgba(239,68,68,0.07)',
borderColor: 'rgba(239,68,68,0.18)',
},
}}
>
<LogoutRoundedIcon />
</IconButton>
</Tooltip>
</Stack>
</Toolbar>
</AppBar>
);
}
}

View File

@ -1,4 +1,5 @@
import { useState } from 'react';
import type { FormEvent } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Alert,
@ -9,30 +10,42 @@ import {
Chip,
CircularProgress,
Divider,
IconButton,
InputAdornment,
Stack,
TextField,
Tooltip,
Typography,
} from '@mui/material';
import AccountBalanceWalletIcon from '@mui/icons-material/AccountBalanceWallet';
import AutoGraphRoundedIcon from '@mui/icons-material/AutoGraphRounded';
import LockOutlinedIcon from '@mui/icons-material/LockOutlined';
import TrendingUpIcon from '@mui/icons-material/TrendingUp';
import ShieldOutlinedIcon from '@mui/icons-material/ShieldOutlined';
import TrendingUpRoundedIcon from '@mui/icons-material/TrendingUpRounded';
import VisibilityOffRoundedIcon from '@mui/icons-material/VisibilityOffRounded';
import VisibilityRoundedIcon from '@mui/icons-material/VisibilityRounded';
import { loginRequest } from '../services/authService';
import { useAuthStore } from '../store/authStore';
const APP_NAME = 'Zendion Finance';
const APP_VERSION = '1.0.0';
const LOGO_SRC = '/icon.png';
export function LoginPage() {
const navigate = useNavigate();
const setAuth = useAuthStore((state) => state.setAuth);
const [usuario, setUsuario] = useState('');
const [senha, setSenha] = useState('');
const [mostrarSenha, setMostrarSenha] = useState(false);
const [loading, setLoading] = useState(false);
const [erro, setErro] = useState('');
async function handleLogin(event: React.FormEvent<HTMLFormElement>) {
const formularioValido = Boolean(usuario.trim() && senha);
async function handleLogin(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!usuario.trim() || !senha.trim()) {
if (!usuario.trim() || !senha) {
setErro('Informe usuário e senha.');
return;
}
@ -47,11 +60,11 @@ export function LoginPage() {
});
setAuth(result.user, result.token);
navigate('/dashboard');
navigate('/dashboard', { replace: true });
} catch (error: any) {
const message =
error?.response?.data?.message ||
'Não foi possível realizar o login.';
'Não foi possível realizar o login. Verifique seus dados e tente novamente.';
setErro(message);
} finally {
@ -65,217 +78,442 @@ export function LoginPage() {
display="grid"
gridTemplateColumns={{
xs: '1fr',
md: '1.1fr 0.9fr',
md: 'minmax(480px, 1.08fr) minmax(420px, 0.92fr)',
}}
sx={{
background:
'radial-gradient(circle at top left, rgba(37,99,235,0.18), transparent 34%), linear-gradient(135deg, #F8FAFC 0%, #EEF2F7 48%, #E5E7EB 100%)',
'radial-gradient(circle at top right, rgba(59,130,246,0.12), transparent 32%), linear-gradient(135deg, #F8FAFC 0%, #EEF2F7 48%, #E5E7EB 100%)',
}}
>
<Box
component="section"
sx={{
display: { xs: 'none', md: 'flex' },
alignItems: 'center',
justifyContent: 'center',
padding: 6,
background:
'linear-gradient(160deg, #0F172A 0%, #111827 45%, #1E3A8A 100%)',
color: '#fff',
padding: { md: 5, lg: 7 },
color: '#FFFFFF',
position: 'relative',
overflow: 'hidden',
background:
'linear-gradient(155deg, #0F172A 0%, #111827 48%, #172554 100%)',
}}
>
<Box
sx={{
position: 'absolute',
width: 360,
height: 360,
width: 430,
height: 430,
borderRadius: '50%',
background: 'rgba(96,165,250,0.18)',
top: -120,
right: -100,
filter: 'blur(4px)',
top: -170,
right: -120,
background:
'radial-gradient(circle, rgba(59,130,246,0.30), rgba(59,130,246,0.02) 68%, transparent 72%)',
filter: 'blur(2px)',
}}
/>
<Box
sx={{
position: 'absolute',
width: 260,
height: 260,
width: 310,
height: 310,
borderRadius: '50%',
background: 'rgba(34,197,94,0.10)',
bottom: -80,
left: -70,
filter: 'blur(4px)',
bottom: -120,
left: -90,
background:
'radial-gradient(circle, rgba(14,165,233,0.18), rgba(14,165,233,0.02) 65%, transparent 70%)',
}}
/>
<Box maxWidth={560} position="relative">
<Stack direction="row" spacing={1.5} alignItems="center" marginBottom={5}>
<Box
sx={{
position: 'absolute',
inset: 0,
opacity: 0.28,
backgroundImage:
'linear-gradient(rgba(255,255,255,0.025) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,0.025) 1px, transparent 1px)',
backgroundSize: '48px 48px',
maskImage:
'linear-gradient(to bottom, rgba(0,0,0,0.8), transparent 92%)',
}}
/>
<Box maxWidth={570} position="relative" zIndex={1}>
<Stack
direction="row"
spacing={1.5}
alignItems="center"
marginBottom={5.5}
>
<Box
sx={{
width: 52,
height: 52,
borderRadius: 3,
width: 58,
height: 58,
flexShrink: 0,
borderRadius: 3.5,
display: 'grid',
placeItems: 'center',
backgroundColor: 'rgba(255,255,255,0.10)',
border: '1px solid rgba(255,255,255,0.14)',
overflow: 'hidden',
background:
'linear-gradient(135deg, rgba(59,130,246,0.30), rgba(37,99,235,0.12))',
border: '1px solid rgba(147,197,253,0.24)',
boxShadow: '0 16px 36px rgba(37,99,235,0.24)',
}}
>
<AccountBalanceWalletIcon />
<Box
component="img"
src={LOGO_SRC}
alt="Zendion Finance"
sx={{
width: 46,
height: 46,
objectFit: 'contain',
}}
/>
</Box>
<Box>
<Typography variant="h5" fontWeight={900}>
Zendion
<Typography
variant="h5"
fontWeight={950}
lineHeight={1.05}
letterSpacing="-0.02em"
>
{APP_NAME}
</Typography>
<Typography sx={{ color: 'rgba(255,255,255,0.68)' }}>
Financeiro
<Typography sx={{ color: 'rgba(255,255,255,0.62)' }}>
Gestão financeira
</Typography>
</Box>
</Stack>
<Chip
label="Controle financeiro web"
sx={{
color: '#BFDBFE',
borderColor: 'rgba(191,219,254,0.28)',
backgroundColor: 'rgba(37,99,235,0.16)',
marginBottom: 2,
fontWeight: 700,
}}
label="Controle financeiro inteligente"
icon={<AutoGraphRoundedIcon />}
variant="outlined"
sx={{
marginBottom: 2.5,
color: '#BFDBFE',
borderColor: 'rgba(191,219,254,0.24)',
backgroundColor: 'rgba(37,99,235,0.12)',
fontWeight: 750,
'& .MuiChip-icon': {
color: '#93C5FD',
},
}}
/>
<Typography variant="h3" fontWeight={950} lineHeight={1.08} marginBottom={2}>
Movimentos, carteiras e saldos em um painel limpo.
<Typography
variant="h3"
fontWeight={950}
lineHeight={1.06}
letterSpacing="-0.035em"
marginBottom={2.5}
>
Clareza para decidir.
<br />
Controle para evoluir.
</Typography>
<Typography
variant="h6"
sx={{
color: 'rgba(255,255,255,0.72)',
fontWeight: 400,
maxWidth: 520,
maxWidth: 540,
marginBottom: 5,
color: 'rgba(255,255,255,0.68)',
fontWeight: 400,
lineHeight: 1.55,
}}
>
Registre entradas, saídas, sangrias e estornos direto do celular ou desktop, com segurança e praticidade.
Acompanhe movimentos, carteiras, cartões, investimentos e
simulações em um único ambiente.
</Typography>
<Stack spacing={2}>
<Stack direction="row" spacing={2} alignItems="center">
<ShieldOutlinedIcon sx={{ color: '#93C5FD' }} />
<Typography sx={{ color: 'rgba(255,255,255,0.78)' }}>
Acesso protegido por autenticação e token.
</Typography>
<Stack spacing={2.25}>
<Stack direction="row" spacing={1.75} alignItems="center">
<Box
sx={{
width: 38,
height: 38,
borderRadius: 2.5,
display: 'grid',
placeItems: 'center',
backgroundColor: 'rgba(59,130,246,0.12)',
border: '1px solid rgba(147,197,253,0.14)',
}}
>
<ShieldOutlinedIcon
sx={{ color: '#93C5FD', fontSize: 21 }}
/>
</Box>
<Box>
<Typography fontWeight={800}>
Ambiente protegido
</Typography>
<Typography
variant="body2"
sx={{ color: 'rgba(255,255,255,0.56)' }}
>
Acesso autenticado e dados separados por usuário.
</Typography>
</Box>
</Stack>
<Stack direction="row" spacing={2} alignItems="center">
<TrendingUpIcon sx={{ color: '#86EFAC' }} />
<Typography sx={{ color: 'rgba(255,255,255,0.78)' }}>
Base pronta para relatórios, dashboards e controle por carteiras.
</Typography>
<Stack direction="row" spacing={1.75} alignItems="center">
<Box
sx={{
width: 38,
height: 38,
borderRadius: 2.5,
display: 'grid',
placeItems: 'center',
backgroundColor: 'rgba(34,197,94,0.10)',
border: '1px solid rgba(134,239,172,0.12)',
}}
>
<TrendingUpRoundedIcon
sx={{ color: '#86EFAC', fontSize: 21 }}
/>
</Box>
<Box>
<Typography fontWeight={800}>
Visão financeira completa
</Typography>
<Typography
variant="body2"
sx={{ color: 'rgba(255,255,255,0.56)' }}
>
Dashboards, relatórios e planejamento mensal.
</Typography>
</Box>
</Stack>
</Stack>
</Box>
</Box>
<Box
component="main"
display="flex"
alignItems="center"
justifyContent="center"
padding={{ xs: 2, sm: 3, md: 6 }}
padding={{ xs: 2, sm: 3, md: 5, lg: 7 }}
position="relative"
>
<Card
sx={{
width: '100%',
maxWidth: 440,
boxShadow: '0 22px 70px rgba(15,23,42,0.12)',
}}
<Stack
width="100%"
maxWidth={450}
spacing={2}
>
<CardContent sx={{ padding: { xs: 3, sm: 4 } }}>
<Stack spacing={1} alignItems="center" textAlign="center" marginBottom={3}>
<Box
sx={{
width: 58,
height: 58,
borderRadius: 4,
display: 'grid',
placeItems: 'center',
backgroundColor: 'rgba(21,101,192,0.10)',
color: 'primary.main',
marginBottom: 1,
}}
<Stack
direction="row"
spacing={1.25}
alignItems="center"
justifyContent="center"
sx={{
display: { xs: 'flex', md: 'none' },
marginBottom: 1,
}}
>
<Box
component="img"
src={LOGO_SRC}
alt="Zendion Finance"
sx={{
width: 46,
height: 46,
objectFit: 'contain',
borderRadius: 2.5,
}}
/>
<Box>
<Typography fontWeight={950} lineHeight={1.05}>
{APP_NAME}
</Typography>
<Typography variant="caption" color="text.secondary">
Gestão financeira
</Typography>
</Box>
</Stack>
<Card
sx={{
width: '100%',
borderRadius: 5,
border: '1px solid rgba(15,23,42,0.06)',
boxShadow: '0 24px 70px rgba(15,23,42,0.12)',
overflow: 'visible',
}}
>
<CardContent sx={{ padding: { xs: 3, sm: 4.5 } }}>
<Stack
spacing={1}
alignItems="center"
textAlign="center"
marginBottom={3.5}
>
<LockOutlinedIcon />
<Box
sx={{
width: 60,
height: 60,
borderRadius: 4,
display: 'grid',
placeItems: 'center',
marginBottom: 1,
color: 'primary.main',
background:
'linear-gradient(135deg, rgba(59,130,246,0.14), rgba(37,99,235,0.05))',
border: '1px solid rgba(59,130,246,0.10)',
}}
>
<LockOutlinedIcon />
</Box>
<Typography
variant="h4"
fontWeight={950}
letterSpacing="-0.025em"
>
Bem-vindo
</Typography>
<Typography color="text.secondary">
Entre com suas credenciais para continuar.
</Typography>
</Stack>
{erro && (
<Alert
severity="error"
onClose={() => setErro('')}
sx={{ marginBottom: 2.5, borderRadius: 2.5 }}
>
{erro}
</Alert>
)}
<Box
component="form"
onSubmit={handleLogin}
display="flex"
flexDirection="column"
gap={2.25}
>
<TextField
label="Usuário"
value={usuario}
onChange={(event) => {
setUsuario(event.target.value);
if (erro) setErro('');
}}
fullWidth
autoFocus
autoComplete="username"
disabled={loading}
inputProps={{
maxLength: 100,
}}
/>
<TextField
label="Senha"
type={mostrarSenha ? 'text' : 'password'}
value={senha}
onChange={(event) => {
setSenha(event.target.value);
if (erro) setErro('');
}}
fullWidth
autoComplete="current-password"
disabled={loading}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<Tooltip
title={mostrarSenha ? 'Ocultar senha' : 'Mostrar senha'}
>
<IconButton
type="button"
edge="end"
onClick={() => setMostrarSenha((valor) => !valor)}
aria-label={
mostrarSenha
? 'Ocultar senha'
: 'Mostrar senha'
}
disabled={loading}
>
{mostrarSenha ? (
<VisibilityOffRoundedIcon />
) : (
<VisibilityRoundedIcon />
)}
</IconButton>
</Tooltip>
</InputAdornment>
),
}}
/>
<Button
type="submit"
variant="contained"
size="large"
fullWidth
disabled={loading || !formularioValido}
startIcon={
loading
? <CircularProgress size={18} color="inherit" />
: <LockOutlinedIcon fontSize="small" />
}
sx={{
minHeight: 52,
marginTop: 0.5,
borderRadius: 2.75,
fontWeight: 850,
boxShadow: '0 12px 28px rgba(37,99,235,0.24)',
}}
>
{loading ? 'Entrando...' : 'Entrar no sistema'}
</Button>
</Box>
<Typography variant="h4" fontWeight={950}>
Entrar
</Typography>
<Divider sx={{ marginY: 3 }} />
<Typography color="text.secondary">
Acesse o Zendion Financeiro para continuar.
</Typography>
</Stack>
{erro && (
<Alert severity="error" sx={{ marginBottom: 2 }}>
{erro}
</Alert>
)}
<Box
component="form"
onSubmit={handleLogin}
display="flex"
flexDirection="column"
gap={2}
>
<TextField
label="Usuário"
value={usuario}
onChange={(event) => setUsuario(event.target.value)}
fullWidth
autoFocus
/>
<TextField
label="Senha"
type="password"
value={senha}
onChange={(event) => setSenha(event.target.value)}
fullWidth
/>
<Button
type="submit"
variant="contained"
size="large"
fullWidth
disabled={loading}
startIcon={loading ? <CircularProgress size={18} color="inherit" /> : null}
sx={{
minHeight: 50,
marginTop: 1,
boxShadow: 3,
}}
<Stack
direction={{ xs: 'column', sm: 'row' }}
alignItems="center"
justifyContent="space-between"
spacing={0.5}
>
{loading ? 'Entrando...' : 'Entrar no sistema'}
</Button>
</Box>
<Typography variant="caption" color="text.secondary">
Sistema privado da Zendion INC.
</Typography>
<Divider sx={{ marginY: 3 }} />
<Typography variant="caption" color="text.disabled">
Versão {APP_VERSION}
</Typography>
</Stack>
</CardContent>
</Card>
<Typography variant="body2" color="text.secondary" textAlign="center">
Sistema financeiro privado da Zendion INC.
</Typography>
</CardContent>
</Card>
<Typography
variant="caption"
color="text.disabled"
textAlign="center"
>
Use apenas credenciais autorizadas.
</Typography>
</Stack>
</Box>
</Box>
);
}
}

View File

@ -9,15 +9,18 @@ import {
Chip,
CircularProgress,
Divider,
FormControlLabel,
MenuItem,
Paper,
Stack,
Switch,
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 SavingsIcon from '@mui/icons-material/Savings';
import { useNavigate } from 'react-router-dom';
import {
atualizarCentroCusto,
@ -26,6 +29,7 @@ import {
import type {
CentroCusto,
CriarCentroCustoRequest,
FlagNumerica,
} from '../types/centroCustoTypes';
type CentroCustoFormProps = {
@ -40,7 +44,11 @@ type FormSectionProps = {
};
function formatarValorResumo(value: string) {
const numero = Number(value || 0);
const numero = Number(value);
if (!value || !Number.isFinite(numero)) {
return 'Não informado';
}
return new Intl.NumberFormat('pt-BR', {
style: 'currency',
@ -101,7 +109,6 @@ const fieldSx = {
export function CentroCustoForm({ mode, initialData }: CentroCustoFormProps) {
const navigate = useNavigate();
const isEdit = mode === 'edit';
const [saving, setSaving] = useState(false);
@ -109,44 +116,67 @@ export function CentroCustoForm({ mode, initialData }: CentroCustoFormProps) {
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 [investimento, setInvestimento] = useState<FlagNumerica>(0);
const [habilitado, setHabilitado] = useState<FlagNumerica>(1);
const [simular, setSimular] = useState<FlagNumerica>(0);
const [limite, setLimite] = useState('');
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.';
? 'Atualize os dados globais e a configuração da sua simulação.'
: 'Cadastre uma categoria financeira e, se desejar, inclua-a na sua simulação.';
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));
setInvestimento(Number(initialData.investimento) === 1 ? 1 : 0);
setHabilitado(Number(initialData.habilitado) === 1 ? 1 : 0);
setSimular(Number(initialData.simular) === 1 ? 1 : 0);
setLimite(
initialData.limite === null || initialData.limite === undefined
? ''
: String(initialData.limite)
);
}, [initialData]);
function limparFormulario() {
setDescricao('');
setLimite('0');
setSimular('0');
setInvestimento('0');
setHabilitado('1');
setInvestimento(0);
setHabilitado(1);
setSimular(0);
setLimite('');
}
function validarFormulario() {
if (!descricao.trim()) {
return 'Informe a descrição.';
}
if (simular === 1) {
if (limite === '') {
return 'Informe o limite da simulação.';
}
const limiteNumero = Number(limite);
if (!Number.isFinite(limiteNumero) || limiteNumero < 0) {
return 'Informe um limite válido para a simulação.';
}
}
return null;
}
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!descricao.trim()) {
setErro('Informe a descrição.');
return;
}
const erroValidacao = validarFormulario();
if (limite === '' || !Number.isFinite(Number(limite))) {
setErro('Informe um limite válido.');
if (erroValidacao) {
setErro(erroValidacao);
return;
}
@ -157,10 +187,10 @@ export function CentroCustoForm({ mode, initialData }: CentroCustoFormProps) {
const payload: CriarCentroCustoRequest = {
descricao: descricao.trim(),
limite: Number(limite || 0),
simular: Number(simular || 0),
investimento: Number(investimento || 0),
habilitado: Number(habilitado || 1),
investimento,
habilitado,
simular,
limite: simular === 1 ? Number(limite) : null,
};
if (isEdit) {
@ -169,7 +199,11 @@ export function CentroCustoForm({ mode, initialData }: CentroCustoFormProps) {
return;
}
await atualizarCentroCusto(initialData.idcentrodecustos, payload);
await atualizarCentroCusto(
initialData.idcentrodecustos,
payload
);
setSucesso('Centro de custo atualizado com sucesso.');
} else {
await criarCentroCusto(payload);
@ -256,7 +290,7 @@ export function CentroCustoForm({ mode, initialData }: CentroCustoFormProps) {
>
<FormSection
title="Dados do centro"
description="Defina descrição, limite e comportamento do centro de custo."
description="Esses dados pertencem ao cadastro global do centro de custo."
>
<TextField
label="Descrição"
@ -264,65 +298,111 @@ export function CentroCustoForm({ mode, initialData }: CentroCustoFormProps) {
onChange={(event) => setDescricao(event.target.value)}
placeholder="Ex: Administrativo, Comercial, Impostos..."
fullWidth
required
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)}
onChange={(event) =>
setInvestimento(Number(event.target.value) === 1 ? 1 : 0)
}
fullWidth
sx={fieldSx}
>
<MenuItem value="1">Sim</MenuItem>
<MenuItem value="0">Não</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)}
onChange={(event) =>
setHabilitado(Number(event.target.value) === 1 ? 1 : 0)
}
fullWidth
sx={fieldSx}
>
<MenuItem value="1">Habilitado</MenuItem>
<MenuItem value="0">Desabilitado</MenuItem>
<MenuItem value={1}>Habilitado</MenuItem>
<MenuItem value={0}>Desabilitado</MenuItem>
</TextField>
</FormSection>
<FormSection
title="Simulação do usuário"
description="Defina se este centro participa da sua simulação e qual será o limite previsto."
>
<Box
sx={{
gridColumn: { md: '1 / -1' },
padding: 2,
borderRadius: 3,
border: '1px solid',
borderColor: simular === 1 ? 'success.light' : 'divider',
backgroundColor:
simular === 1
? 'rgba(46, 125, 50, 0.05)'
: 'rgba(15, 23, 42, 0.02)',
}}
>
<FormControlLabel
control={
<Switch
checked={simular === 1}
onChange={(event) =>
setSimular(event.target.checked ? 1 : 0)
}
color="success"
/>
}
label={
<Box>
<Typography fontWeight={850}>
Incluir na minha simulação
</Typography>
<Typography variant="body2" color="text.secondary">
Ao desativar, o vínculo deste centro com a sua
simulação será removido.
</Typography>
</Box>
}
sx={{
margin: 0,
alignItems: 'center',
}}
/>
</Box>
<TextField
label="Limite da simulação"
type="number"
value={limite}
onChange={(event) => setLimite(event.target.value)}
disabled={simular === 0}
required={simular === 1}
inputProps={{
step: '0.01',
min: '0',
}}
helperText={
simular === 1
? 'Valor previsto para este centro na sua simulação.'
: 'Ative a simulação para informar um limite.'
}
fullWidth
sx={fieldSx}
/>
</FormSection>
<Stack
direction={{ xs: 'column-reverse', sm: 'row' }}
justifyContent="flex-end"
spacing={1.5}
sx={{
paddingTop: 1,
}}
sx={{ paddingTop: 1 }}
>
<Button
variant="outlined"
@ -378,7 +458,7 @@ export function CentroCustoForm({ mode, initialData }: CentroCustoFormProps) {
</Typography>
<Typography variant="body2" color="text.secondary" marginBottom={2.5}>
Prévia rápida do centro antes de salvar.
Prévia rápida antes de salvar.
</Typography>
<Divider sx={{ marginBottom: 2.5 }} />
@ -388,6 +468,7 @@ export function CentroCustoForm({ mode, initialData }: CentroCustoFormProps) {
<Typography variant="caption" color="text.secondary">
Descrição
</Typography>
<Typography fontWeight={800}>
{descricao || 'Sem descrição'}
</Typography>
@ -395,28 +476,11 @@ export function CentroCustoForm({ mode, initialData }: CentroCustoFormProps) {
<Box>
<Typography variant="caption" color="text.secondary">
Limite
Tipo
</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'}
{investimento === 1 ? 'Investimento' : 'Operacional'}
</Typography>
</Box>
@ -424,16 +488,65 @@ export function CentroCustoForm({ mode, initialData }: CentroCustoFormProps) {
<Typography variant="caption" color="text.secondary">
Status
</Typography>
<Typography
fontWeight={800}
color={Number(habilitado) === 1 ? 'success.main' : 'text.secondary'}
color={
habilitado === 1
? 'success.main'
: 'text.secondary'
}
>
{Number(habilitado) === 1 ? 'Habilitado' : 'Desabilitado'}
{habilitado === 1 ? 'Habilitado' : 'Desabilitado'}
</Typography>
</Box>
<Divider />
<Stack direction="row" spacing={1} alignItems="center">
<SavingsIcon
color={simular === 1 ? 'success' : 'disabled'}
fontSize="small"
/>
<Typography fontWeight={900}>
Simulação
</Typography>
</Stack>
<Box>
<Typography variant="caption" color="text.secondary">
Participação
</Typography>
<Typography
fontWeight={800}
color={
simular === 1
? 'success.main'
: 'text.secondary'
}
>
{simular === 1
? 'Incluído na simulação'
: 'Fora da simulação'}
</Typography>
</Box>
{simular === 1 && (
<Box>
<Typography variant="caption" color="text.secondary">
Limite previsto
</Typography>
<Typography variant="h4" fontWeight={950}>
{formatarValorResumo(limite)}
</Typography>
</Box>
)}
</Stack>
</Paper>
</Stack>
</Box>
);
}
}

View File

@ -48,11 +48,24 @@ import type {
const LIMITE_PADRAO = 20;
function formatarValor(valor: number) {
type FiltrosCentrosCusto = {
busca: string;
simular: 0 | 1 | '';
investimento: 0 | 1 | '';
habilitado: 0 | 1 | '';
orderBy: string;
orderDirection: 'ASC' | 'DESC';
};
function formatarValor(valor: number | null | undefined) {
if (valor === null || valor === undefined) {
return '-';
}
return new Intl.NumberFormat('pt-BR', {
style: 'currency',
currency: 'BRL',
}).format(Number(valor || 0));
}).format(Number(valor));
}
function formatarData(data: string | null) {
@ -119,7 +132,10 @@ export function CentrosCustoPage() {
return count;
}, [busca, simular, investimento, habilitado]);
async function carregarCentrosCusto(pageToLoad = page) {
async function carregarCentrosCusto(
pageToLoad = page,
filtros?: Partial<FiltrosCentrosCusto>
) {
try {
setLoading(true);
setErro('');
@ -127,12 +143,30 @@ export function CentrosCustoPage() {
const params: ListarCentrosCustoParams = {
limite: LIMITE_PADRAO,
page: pageToLoad,
busca: busca.trim() || undefined,
simular,
investimento,
habilitado,
orderBy,
orderDirection,
busca:
filtros?.busca !== undefined
? filtros.busca.trim() || undefined
: busca.trim() || undefined,
simular:
filtros?.simular !== undefined
? filtros.simular
: simular,
investimento:
filtros?.investimento !== undefined
? filtros.investimento
: investimento,
habilitado:
filtros?.habilitado !== undefined
? filtros.habilitado
: habilitado,
orderBy:
filtros?.orderBy !== undefined
? filtros.orderBy
: orderBy,
orderDirection:
filtros?.orderDirection !== undefined
? filtros.orderDirection
: orderDirection,
};
const response = await listarCentrosCusto(params);
@ -158,17 +192,24 @@ export function CentrosCustoPage() {
}
function limparFiltros() {
setBusca('');
setSimular('');
setInvestimento('');
setHabilitado('');
setOrderBy('descricao');
setOrderDirection('ASC');
const filtrosLimpos = {
busca: '',
simular: '' as const,
investimento: '' as const,
habilitado: '' as const,
orderBy: 'descricao',
orderDirection: 'ASC' as const,
};
setTimeout(() => {
setPage(1);
carregarCentrosCusto(1);
}, 0);
setBusca(filtrosLimpos.busca);
setSimular(filtrosLimpos.simular);
setInvestimento(filtrosLimpos.investimento);
setHabilitado(filtrosLimpos.habilitado);
setOrderBy(filtrosLimpos.orderBy);
setOrderDirection(filtrosLimpos.orderDirection);
setPage(1);
carregarCentrosCusto(1, filtrosLimpos);
}
async function handleDeletarCentroCusto(item: CentroCusto) {
@ -199,31 +240,26 @@ export function CentrosCustoPage() {
}
async function handleAlterarStatusCentroCusto(item: CentroCusto) {
const novoStatus = Number(item.habilitado) === 1 ? 0 : 1;
const novoStatus: 0 | 1 =
Number(item.habilitado) === 1 ? 0 : 1;
try {
setAlterandoStatusId(item.idcentrodecustos);
setErro('');
setSucesso('');
const centroAtualizado = await alterarHabilitadoCentroCusto(
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.'
);
await carregarCentrosCusto(page);
} catch (error: any) {
const message =
error?.response?.data?.message ||
@ -307,7 +343,7 @@ export function CentrosCustoPage() {
<Card>
<CardContent>
<Typography variant="body2" color="text.secondary">
Centros encontrados
Centros de custo
</Typography>
<Typography variant="h5" fontWeight={950}>
{summary.quantidade}
@ -318,8 +354,9 @@ export function CentrosCustoPage() {
<Card>
<CardContent>
<Typography variant="body2" color="text.secondary">
Limite total
Limite simulado
</Typography>
<Typography variant="h5" fontWeight={950}>
{formatarValor(summary.limiteTotal)}
</Typography>
@ -340,7 +377,7 @@ export function CentrosCustoPage() {
<Card>
<CardContent>
<Typography variant="body2" color="text.secondary">
Simuláveis
Na simulação
</Typography>
<Typography variant="h5" fontWeight={950} color="success.main">
{summary.simulaveis}
@ -388,10 +425,14 @@ export function CentrosCustoPage() {
<TextField
select
label="Simular"
label="Participa da simulação"
value={simular}
onChange={(event) =>
setSimular(event.target.value === '' ? '' : Number(event.target.value))
setSimular(
event.target.value === ''
? ''
: Number(event.target.value) as 0 | 1
)
}
fullWidth
>
@ -529,16 +570,30 @@ export function CentrosCustoPage() {
</Typography>
</Box>
<Typography fontWeight={950} whiteSpace="nowrap">
{formatarValor(item.limite)}
</Typography>
{Number(item.simular) === 1 ? (
<Typography fontWeight={950} whiteSpace="nowrap">
{formatarValor(item.limite)}
</Typography>
) : (
<Typography
variant="body2"
color="text.secondary"
whiteSpace="nowrap"
>
Fora da simulação
</Typography>
)}
</Stack>
<Stack direction="row" flexWrap="wrap" spacing={1} useFlexGap>
<Chip
label={flagLabel(item.simular, 'Simular', 'Não simular')}
label={
Number(item.simular) === 1
? 'Na simulação'
: 'Fora da simulação'
}
size="small"
color={flagColor(item.simular) as any}
color={Number(item.simular) === 1 ? 'success' : 'default'}
variant="outlined"
/>
@ -655,16 +710,22 @@ export function CentrosCustoPage() {
</TableCell>
<TableCell align="right">
<Typography fontWeight={950}>
{formatarValor(item.limite)}
</Typography>
{Number(item.simular) === 1 ? (
<Typography fontWeight={950}>
{formatarValor(item.limite)}
</Typography>
) : (
<Typography color="text.secondary">
-
</Typography>
)}
</TableCell>
<TableCell>
<Chip
label={Number(item.simular) === 1 ? 'Sim' : 'Não'}
size="small"
color={flagColor(item.simular) as any}
color={Number(item.simular) === 1 ? 'success' : 'default'}
variant="outlined"
/>
</TableCell>

View File

@ -5,10 +5,20 @@ import type {
CentroCusto,
CentrosCustoListResponse,
CriarCentroCustoRequest,
FlagNumerica,
} from '../types/centroCustoTypes';
export type OrderDirection = 'ASC' | 'DESC';
export type CentroCustoOrderBy =
| 'descricao'
| 'limite'
| 'simular'
| 'investimento'
| 'habilitado'
| 'insert_date'
| 'update_date';
export type ListarCentrosCustoParams = {
limite?: number;
offset?: number;
@ -17,29 +27,39 @@ export type ListarCentrosCustoParams = {
simular?: number | '';
investimento?: number | '';
habilitado?: number | '';
orderBy?: string;
orderBy?: CentroCustoOrderBy;
orderDirection?: OrderDirection;
};
export async function listarCentrosCusto(
params?: ListarCentrosCustoParams
): Promise<CentrosCustoListResponse> {
const response = await api.get<CentrosCustoListResponse>('/centros-custo', {
params,
});
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}`);
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);
const response = await api.post<ApiResponse<CentroCusto>>(
'/centros-custo',
data
);
return response.data.data;
}
@ -47,13 +67,17 @@ export async function atualizarCentroCusto(
id: number,
data: AtualizarCentroCustoRequest
): Promise<CentroCusto> {
const response = await api.put<ApiResponse<CentroCusto>>(`/centros-custo/${id}`, data);
const response = await api.put<ApiResponse<CentroCusto>>(
`/centros-custo/${id}`,
data
);
return response.data.data;
}
export async function alterarHabilitadoCentroCusto(
id: number,
habilitado: number
habilitado: FlagNumerica
): Promise<CentroCusto> {
const response = await api.patch<ApiResponse<CentroCusto>>(
`/centros-custo/${id}/habilitado`,

View File

@ -1,20 +1,36 @@
export type FlagNumerica = 0 | 1;
export type CentroCusto = {
idcentrodecustos: number;
descricao: string;
limite: number;
simular: number;
investimento: number;
habilitado: number;
/**
* Dados globais do centro de custo.
*/
investimento: FlagNumerica;
habilitado: FlagNumerica;
insert_date: string | null;
update_date: string | null;
/**
* Dados da simulação do usuário logado.
* Não são mais colunas diretas de centrodecustos.
*/
simular: FlagNumerica;
limite: number | null;
};
export type CriarCentroCustoRequest = {
descricao: string;
limite: number;
simular: number;
investimento: number;
habilitado: number;
investimento: FlagNumerica;
habilitado: FlagNumerica;
/**
* Quando simular = 1, limite deve ser informado.
* Quando simular = 0, o vínculo será removido ou não será criado.
*/
simular: FlagNumerica;
limite: number | null;
};
export type AtualizarCentroCustoRequest = CriarCentroCustoRequest;
@ -29,10 +45,19 @@ export type CentrosCustoPagination = {
export type CentrosCustoSummary = {
quantidade: number;
/**
* Soma dos limites configurados pelo usuário logado.
*/
limiteTotal: number;
habilitados: number;
desabilitados: number;
investimentos: number;
/**
* Quantidade de centros vinculados à simulação do usuário.
*/
simulaveis: number;
};

View File

@ -61,7 +61,7 @@ import type {
MovimentosSummary,
} from '../types/movimentoTypes';
import {
listarBancos,
listarMeusBancos,
listarCentrosCusto,
listarClientes,
} from '../../referencias/services/referenciasService';
@ -559,7 +559,7 @@ export function MovimentosPage() {
setLoadingRefs(true);
const [bancosData, centrosData, clientesData] = await Promise.all([
listarBancos(),
listarMeusBancos(),
listarCentrosCusto(),
listarClientes(),
]);

View File

@ -127,7 +127,7 @@ function origemLabel(origem: string) {
movimento_fixo_previsto: 'Fixo previsto',
saldo_banco: 'Saldo banco',
cartao_credito_agrupado: 'Cartão',
limite_centro_custo: 'Limite centro',
limite_centro_custo: 'Previsão do centro',
manual: 'Manual',
};
@ -355,6 +355,31 @@ function SimulacaoLista({
/>
)}
{item.origem === 'limite_centro_custo' && item.detalhes && (
<>
<Chip
size="small"
label={`Limite ${formatarValor(item.detalhes.limite)}`}
color="info"
variant="outlined"
/>
<Chip
size="small"
label={`Já lançado ${formatarValor(item.detalhes.gastoAtual)}`}
color="warning"
variant="outlined"
/>
<Chip
size="small"
label={`Restante ${formatarValor(item.detalhes.restante)}`}
color="success"
variant="outlined"
/>
</>
)}
{item.cliente_nome && (
<Chip
size="small"
@ -415,7 +440,7 @@ export function SimulacaoMensalPage() {
setBase(data);
setEntradas(data.entradas || []);
setSaidas(data.saidas || []);
setSucesso('Previsão preenchida com sucesso.');
setSucesso('Simulação mensal montada com sucesso.');
} catch (error: any) {
const message =
error?.response?.data?.message ||
@ -719,7 +744,10 @@ export function SimulacaoMensalPage() {
<Chip label={`${base.fontes.movimentosExistentes} movimentos existentes`} variant="outlined" />
<Chip label={`${base.fontes.movimentosFixos} fixos analisados`} variant="outlined" />
<Chip label={`${base.fontes.bancos} bancos`} variant="outlined" />
<Chip label={`${base.fontes.centrosSimulaveis} centros simuláveis`} variant="outlined" />
<Chip
label={`${base.fontes.centrosSimulaveis} centros configurados na simulação`}
variant="outlined"
/>
</Stack>
</Paper>
)}

View File

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

View File

@ -8,10 +8,34 @@ export type SimulacaoMensalOrigemItem =
| 'limite_centro_custo'
| 'manual';
export type SimulacaoMensalDetalhesLimiteCentro = {
limite: number;
gastoAtual: number;
restante: number;
};
export type SimulacaoMensalDetalhesCartao = {
quantidade_movimentos: number;
ids: number[];
};
export type SimulacaoMensalDetalhesMovimento = {
parcela?: number | null;
parcelas?: number | null;
origem_movimento?: string | null;
};
export type SimulacaoMensalDetalhes =
| SimulacaoMensalDetalhesLimiteCentro
| SimulacaoMensalDetalhesCartao
| SimulacaoMensalDetalhesMovimento
| Record<string, unknown>
| null;
export type SimulacaoMensalItem = {
id: string;
tipo: SimulacaoMensalTipoItem;
origem: SimulacaoMensalOrigemItem | string;
origem: SimulacaoMensalOrigemItem;
descricao: string;
valor: number;
@ -38,32 +62,37 @@ export type SimulacaoMensalItem = {
editavel: boolean;
editado: boolean;
detalhes?: any;
detalhes?: SimulacaoMensalDetalhes;
};
export type SimulacaoMensalResumo = {
totalEntradas: number;
totalSaidas: number;
totalGastos?: number;
totalInvestimentos?: number;
totalGastos: number;
totalInvestimentos: number;
resultado: number;
percentualEntradas?: number | null;
percentualSaidas?: number | null;
percentualGastos?: number | null;
percentualInvestimentos?: number | null;
percentualResultado?: number | null;
percentualEntradas: number | null;
percentualSaidas: number | null;
percentualGastos: number | null;
percentualInvestimentos: number | null;
percentualResultado: number | null;
percentualComprometido: number | null;
quantidadeEntradas: number;
quantidadeSaidas: number;
quantidadeInvestimentos?: number;
quantidadeInvestimentos: number;
};
export type SimulacaoMensalAviso = {
tipo: string;
mensagem: string;
[key: string]: any;
idcentrodecustos?: number;
centro_custo_descricao?: string;
limite?: number;
gastoAtual?: number;
excesso?: number;
[key: string]: unknown;
};
export type SimulacaoMensalBase = {
@ -98,4 +127,10 @@ export type BuscarBaseSimulacaoMensalParams = {
mes: number;
incluirQuitadas?: boolean;
incluirFixosVencidosDoMesAtual?: boolean;
};
};
export type ApiResponse<T> = {
ok: boolean;
message?: string;
data: T;
};