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'); 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) { function validarDadosCentroCusto(dados) {
if (!dados.descricao || !String(dados.descricao).trim()) { if (!dados.descricao || !String(dados.descricao).trim()) {
return 'Descrição é obrigatória.'; 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); 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.'; return 'Limite inválido.';
} }
} }
if (dados.simular !== undefined && dados.simular !== null && dados.simular !== '') { const investimento = normalizarFlagEntrada(dados.investimento);
const simular = Number(dados.simular);
if (![0, 1].includes(simular)) { if (investimento !== undefined && ![0, 1].includes(investimento)) {
return 'Campo simular inválido.'; return 'Campo investimento inválido.';
}
} }
if (dados.investimento !== undefined && dados.investimento !== null && dados.investimento !== '') { const habilitado = normalizarFlagEntrada(dados.habilitado);
const investimento = Number(dados.investimento);
if (![0, 1].includes(investimento)) { if (habilitado !== undefined && ![0, 1].includes(habilitado)) {
return 'Campo investimento inválido.'; return 'Status 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; return null;
@ -42,7 +74,17 @@ function validarDadosCentroCusto(dados) {
async function listar(req, res) { async function listar(req, res) {
try { 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({ const resultado = await centrosCustoService.listarCentrosCusto({
idUsuario,
limite: req.query.limite, limite: req.query.limite,
offset: req.query.offset, offset: req.query.offset,
page: req.query.page, page: req.query.page,
@ -73,6 +115,7 @@ async function listar(req, res) {
async function buscarPorId(req, res) { async function buscarPorId(req, res) {
try { try {
const id = Number(req.params.id); const id = Number(req.params.id);
const idUsuario = obterIdUsuario(req);
if (!id) { if (!id) {
return res.status(400).json({ 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) { if (!centroCusto) {
return res.status(404).json({ return res.status(404).json({
@ -106,6 +159,15 @@ async function buscarPorId(req, res) {
async function criar(req, res) { async function criar(req, res) {
try { 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); const erroValidacao = validarDadosCentroCusto(req.body);
if (erroValidacao) { 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({ return res.status(201).json({
ok: true, ok: true,
@ -135,6 +200,7 @@ async function criar(req, res) {
async function atualizar(req, res) { async function atualizar(req, res) {
try { try {
const id = Number(req.params.id); const id = Number(req.params.id);
const idUsuario = obterIdUsuario(req);
if (!id) { if (!id) {
return res.status(400).json({ 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); const erroValidacao = validarDadosCentroCusto(req.body);
if (erroValidacao) { 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) { if (!centroAtualizado) {
return res.status(404).json({ return res.status(404).json({
@ -179,6 +256,7 @@ async function atualizar(req, res) {
async function alterarHabilitado(req, res) { async function alterarHabilitado(req, res) {
try { try {
const id = Number(req.params.id); const id = Number(req.params.id);
const idUsuario = obterIdUsuario(req);
if (!id) { if (!id) {
return res.status(400).json({ 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)) { if (![0, 1].includes(habilitado)) {
return res.status(400).json({ 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) { if (!centroAtualizado) {
return res.status(404).json({ return res.status(404).json({
@ -270,4 +360,4 @@ module.exports = {
atualizar, atualizar,
alterarHabilitado, alterarHabilitado,
deletar, deletar,
}; };

View File

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

View File

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

View File

@ -1,7 +1,15 @@
const dashboardService = require('../services/dashboard.service'); const dashboardService = require('../services/dashboard.service');
function resolverIdUsuario(req) { 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) { async function buscarResumo(req, res) {
@ -27,9 +35,12 @@ async function buscarResumo(req, res) {
} catch (error) { } catch (error) {
console.error('[dashboard.controller] buscarResumo:', error); console.error('[dashboard.controller] buscarResumo:', error);
return res.status(500).json({ return res.status(error.statusCode || 500).json({
ok: false, 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'); 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() { function hojeISO() {
return new Date().toISOString().slice(0, 10); return formatarDataLocal(new Date());
} }
function inicioMesAtualISO() { function inicioMesAtualISO() {
const hoje = new Date(); const hoje = new Date();
return new Date(hoje.getFullYear(), hoje.getMonth(), 1) return formatarDataLocal(
.toISOString() new Date(hoje.getFullYear(), hoje.getMonth(), 1)
.slice(0, 10); );
} }
function fimMesAtualISO() { function fimMesAtualISO() {
const hoje = new Date(); const hoje = new Date();
return new Date(hoje.getFullYear(), hoje.getMonth() + 1, 0) return formatarDataLocal(
.toISOString() new Date(hoje.getFullYear(), hoje.getMonth() + 1, 0)
.slice(0, 10); );
} }
function normalizarDataISO(valor, fallback) { function normalizarDataISO(valor, fallback) {
if (!valor) return fallback; if (!valor) return fallback;
const texto = String(valor).slice(0, 10); 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 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) { function normalizarNumero(valor, padrao = 0) {
@ -84,10 +99,7 @@ async function buscarCardsBancos(idUsuario) {
COUNT(*) AS quantidadeCarteiras, COUNT(*) AS quantidadeCarteiras,
SUM(CASE COUNT(*) AS carteirasHabilitadas,
WHEN habilitado = 1 THEN 1
ELSE 0
END) AS carteirasHabilitadas,
SUM(CASE SUM(CASE
WHEN debito = 1 WHEN debito = 1
@ -113,6 +125,7 @@ async function buscarCardsBancos(idUsuario) {
END) AS quantidadeCreditos END) AS quantidadeCreditos
FROM bancos FROM bancos
WHERE idusuarios = ? WHERE idusuarios = ?
AND habilitado = 1
`, `,
[idUsuario] [idUsuario]
); );
@ -149,16 +162,18 @@ async function buscarCardsMovimentos(idUsuario, dataInicio, dataFim) {
ELSE 0 ELSE 0
END), 0) AS entradas, END), 0) AS entradas,
COALESCE(SUM(CASE COALESCE(SUM(CASE
WHEN cp.movimento = 'Saida' THEN cp.valor WHEN cp.movimento IN ('Saida', 'Sangria')
ELSE 0 AND COALESCE(cc.investimento, 0) = 0
THEN cp.valor
ELSE 0
END), 0) AS saidas, END), 0) AS saidas,
COALESCE(SUM(CASE COALESCE(SUM(CASE
WHEN cp.movimento = 'Sangria' WHEN cp.movimento IN ('Saida', 'Sangria')
AND COALESCE(cc.investimento, 0) = 1 AND COALESCE(cc.investimento, 0) = 1
THEN cp.valor THEN cp.valor
ELSE 0 ELSE 0
END), 0) AS investimentos, END), 0) AS investimentos,
COALESCE(SUM(CASE COALESCE(SUM(CASE
@ -175,34 +190,36 @@ async function buscarCardsMovimentos(idUsuario, dataInicio, dataFim) {
ELSE NULL ELSE NULL
END) AS quantidadeAbertoEntradas, END) AS quantidadeAbertoEntradas,
COALESCE(SUM(CASE COALESCE(SUM(CASE
WHEN cp.status = 'A pagar' WHEN cp.status = 'A pagar'
AND cp.movimento = 'Saida' AND cp.movimento IN ('Saida', 'Sangria')
THEN cp.valor AND COALESCE(cc.investimento, 0) = 0
ELSE 0 THEN cp.valor
ELSE 0
END), 0) AS abertoSaidas, END), 0) AS abertoSaidas,
COUNT(CASE COUNT(CASE
WHEN cp.status = 'A pagar' WHEN cp.status = 'A pagar'
AND cp.movimento = 'Saida' AND cp.movimento IN ('Saida', 'Sangria')
THEN 1 AND COALESCE(cc.investimento, 0) = 0
ELSE NULL THEN 1
ELSE NULL
END) AS quantidadeAbertoSaidas, END) AS quantidadeAbertoSaidas,
COALESCE(SUM(CASE COALESCE(SUM(CASE
WHEN cp.status = 'A pagar' WHEN cp.status = 'A pagar'
AND cp.movimento = 'Sangria' AND cp.movimento IN ('Saida', 'Sangria')
AND COALESCE(cc.investimento, 0) = 1 AND COALESCE(cc.investimento, 0) = 1
THEN cp.valor THEN cp.valor
ELSE 0 ELSE 0
END), 0) AS abertoInvestimentos, END), 0) AS abertoInvestimentos,
COUNT(CASE COUNT(CASE
WHEN cp.status = 'A pagar' WHEN cp.status = 'A pagar'
AND cp.movimento = 'Sangria' AND cp.movimento IN ('Saida', 'Sangria')
AND COALESCE(cc.investimento, 0) = 1 AND COALESCE(cc.investimento, 0) = 1
THEN 1 THEN 1
ELSE NULL ELSE NULL
END) AS quantidadeAbertoInvestimentos, END) AS quantidadeAbertoInvestimentos,
COALESCE(SUM(CASE COALESCE(SUM(CASE
@ -212,7 +229,7 @@ async function buscarCardsMovimentos(idUsuario, dataInicio, dataFim) {
COUNT(*) AS quantidadeMovimentos COUNT(*) AS quantidadeMovimentos
FROM contasapagar cp FROM contasapagar cp
LEFT JOIN bancos b INNER JOIN bancos b
ON b.idbancos = cp.idbancos ON b.idbancos = cp.idbancos
LEFT JOIN centrodecustos cc LEFT JOIN centrodecustos cc
ON cc.idcentrodecustos = cp.idcentrodecustos ON cc.idcentrodecustos = cp.idcentrodecustos
@ -320,8 +337,9 @@ async function buscarAlertasVencimento(idUsuario) {
COALESCE(SUM(CASE COALESCE(SUM(CASE
WHEN DATE(cp.datavencimento) >= ? WHEN DATE(cp.datavencimento) >= ?
AND DATE(cp.datavencimento) <= DATE_ADD(?, INTERVAL 7 DAY) 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 cp.status = 'A pagar'
AND COALESCE(cc.investimento, 0) = 0
THEN cp.valor THEN cp.valor
ELSE 0 ELSE 0
END), 0) AS venceEmBreveSaidas, END), 0) AS venceEmBreveSaidas,
@ -329,8 +347,9 @@ async function buscarAlertasVencimento(idUsuario) {
COUNT(CASE COUNT(CASE
WHEN DATE(cp.datavencimento) >= ? WHEN DATE(cp.datavencimento) >= ?
AND DATE(cp.datavencimento) <= DATE_ADD(?, INTERVAL 7 DAY) 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 cp.status = 'A pagar'
AND COALESCE(cc.investimento, 0) = 0
THEN 1 THEN 1
ELSE NULL ELSE NULL
END) AS quantidadeVenceEmBreveSaidas, END) AS quantidadeVenceEmBreveSaidas,
@ -338,7 +357,7 @@ async function buscarAlertasVencimento(idUsuario) {
COALESCE(SUM(CASE COALESCE(SUM(CASE
WHEN DATE(cp.datavencimento) >= ? WHEN DATE(cp.datavencimento) >= ?
AND DATE(cp.datavencimento) <= DATE_ADD(?, INTERVAL 7 DAY) 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 cp.status = 'A pagar'
AND COALESCE(cc.investimento, 0) = 1 AND COALESCE(cc.investimento, 0) = 1
THEN cp.valor THEN cp.valor
@ -348,7 +367,7 @@ async function buscarAlertasVencimento(idUsuario) {
COUNT(CASE COUNT(CASE
WHEN DATE(cp.datavencimento) >= ? WHEN DATE(cp.datavencimento) >= ?
AND DATE(cp.datavencimento) <= DATE_ADD(?, INTERVAL 7 DAY) 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 cp.status = 'A pagar'
AND COALESCE(cc.investimento, 0) = 1 AND COALESCE(cc.investimento, 0) = 1
THEN 1 THEN 1
@ -430,7 +449,7 @@ async function buscarProximosVencimentos(idUsuario) {
cc.descricao AS centro_custo_descricao, cc.descricao AS centro_custo_descricao,
DATEDIFF(DATE(cp.datavencimento), CURDATE()) AS dias_para_vencer DATEDIFF(DATE(cp.datavencimento), CURDATE()) AS dias_para_vencer
FROM contasapagar cp 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 LEFT JOIN centrodecustos cc ON cc.idcentrodecustos = cp.idcentrodecustos
WHERE cp.deleted_at IS NULL WHERE cp.deleted_at IS NULL
AND bc.idusuarios = ? AND bc.idusuarios = ?
@ -630,7 +649,7 @@ async function buscarUltimosMovimentos(idUsuario) {
cp.idcentrodecustos, cp.idcentrodecustos,
cc.descricao AS centro_custo_descricao cc.descricao AS centro_custo_descricao
FROM contasapagar cp 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 LEFT JOIN centrodecustos cc ON cc.idcentrodecustos = cp.idcentrodecustos
WHERE cp.deleted_at IS NULL WHERE cp.deleted_at IS NULL
AND bc.idusuarios = ? AND bc.idusuarios = ?
@ -655,7 +674,8 @@ async function buscarGraficos(idUsuario, dataInicio, dataFim) {
LEFT JOIN bancos b LEFT JOIN bancos b
ON b.idbancos = cp.idbancos ON b.idbancos = cp.idbancos
WHERE cp.deleted_at IS NULL 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 DATE(cp.datavencimento) BETWEEN ? AND ?
AND b.idusuarios = ? AND b.idusuarios = ?
GROUP BY GROUP BY
@ -676,16 +696,18 @@ async function buscarGraficos(idUsuario, dataInicio, dataFim) {
ELSE 0 ELSE 0
END), 0) AS entradas, END), 0) AS entradas,
COALESCE(SUM(CASE COALESCE(SUM(CASE
WHEN cp.movimento = 'Saida' THEN cp.valor WHEN cp.movimento IN ('Saida', 'Sangria')
ELSE 0 AND COALESCE(cc.investimento, 0) = 0
THEN cp.valor
ELSE 0
END), 0) AS saidas, END), 0) AS saidas,
COALESCE(SUM(CASE COALESCE(SUM(CASE
WHEN cp.movimento = 'Sangria' WHEN cp.movimento IN ('Saida', 'Sangria')
AND COALESCE(cc.investimento, 0) = 1 AND COALESCE(cc.investimento, 0) = 1
THEN cp.valor THEN cp.valor
ELSE 0 ELSE 0
END), 0) AS investimentos END), 0) AS investimentos
FROM contasapagar cp FROM contasapagar cp
LEFT JOIN centrodecustos cc LEFT JOIN centrodecustos cc
@ -753,6 +775,12 @@ async function buscarResumoDashboard(idUsuario, filtros = {}) {
const dataInicio = normalizarDataISO(filtros.dataInicio, inicioMesAtualISO()); const dataInicio = normalizarDataISO(filtros.dataInicio, inicioMesAtualISO());
const dataFim = normalizarDataISO(filtros.dataFim, fimMesAtualISO()); 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 [ const [
cardsBancos, cardsBancos,
cardsMovimentos, cardsMovimentos,

View File

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

View File

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

View File

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

View File

@ -1,3 +1,4 @@
import type { ReactNode } from 'react';
import { import {
Box, Box,
Divider, Divider,
@ -5,170 +6,331 @@ import {
ListItemButton, ListItemButton,
ListItemIcon, ListItemIcon,
ListItemText, ListItemText,
Stack,
Typography, Typography,
} from '@mui/material'; } from '@mui/material';
import DashboardIcon from '@mui/icons-material/Dashboard'; import DashboardRoundedIcon from '@mui/icons-material/DashboardRounded';
import SwapHorizIcon from '@mui/icons-material/SwapHoriz'; import SwapHorizRoundedIcon from '@mui/icons-material/SwapHorizRounded';
import AssessmentIcon from '@mui/icons-material/Assessment'; import AssessmentRoundedIcon from '@mui/icons-material/AssessmentRounded';
import AccountBalanceWalletIcon from '@mui/icons-material/AccountBalanceWallet'; import AccountBalanceWalletRoundedIcon from '@mui/icons-material/AccountBalanceWalletRounded';
import SavingsIcon from '@mui/icons-material/Savings'; import SavingsRoundedIcon from '@mui/icons-material/SavingsRounded';
import SettingsIcon from '@mui/icons-material/Settings'; import SettingsRoundedIcon from '@mui/icons-material/SettingsRounded';
import PeopleAltIcon from '@mui/icons-material/PeopleAlt'; import PeopleAltRoundedIcon from '@mui/icons-material/PeopleAltRounded';
import AccountTreeIcon from '@mui/icons-material/AccountTree'; import AccountTreeRoundedIcon from '@mui/icons-material/AccountTreeRounded';
import EventRepeatIcon from '@mui/icons-material/EventRepeat'; import EventRepeatRoundedIcon from '@mui/icons-material/EventRepeatRounded';
import ManageAccountsIcon from '@mui/icons-material/ManageAccounts'; import ManageAccountsRoundedIcon from '@mui/icons-material/ManageAccountsRounded';
import CreditCardIcon from '@mui/icons-material/CreditCard'; import CreditCardRoundedIcon from '@mui/icons-material/CreditCardRounded';
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome'; import AutoAwesomeRoundedIcon from '@mui/icons-material/AutoAwesomeRounded';
import InsightsRoundedIcon from '@mui/icons-material/InsightsRounded';
import { NavLink } from 'react-router-dom'; import { NavLink } from 'react-router-dom';
type SidebarProps = { type SidebarProps = {
onNavigate?: () => void; onNavigate?: () => void;
}; };
const menuItems = [ type MenuItem = {
label: string;
path: string;
icon: ReactNode;
};
type MenuSection = {
title: string;
items: MenuItem[];
};
const menuSections: MenuSection[] = [
{ {
label: 'Dashboard', title: 'Visão geral',
path: '/dashboard', items: [
icon: <DashboardIcon />, {
label: 'Dashboard',
path: '/dashboard',
icon: <DashboardRoundedIcon />,
},
{
label: 'Simulação mensal',
path: '/simulacao-mensal',
icon: <AutoAwesomeRoundedIcon />,
},
],
}, },
{ {
label: 'Movimentos', title: 'Financeiro',
path: '/movimentos', items: [
icon: <SwapHorizIcon />, {
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', title: 'Cadastros',
path: '/quitacoes-credito', items: [
icon: <CreditCardIcon />, {
label: 'Clientes',
path: '/clientes',
icon: <PeopleAltRoundedIcon />,
},
{
label: 'Centros de custo',
path: '/centros-custo',
icon: <AccountTreeRoundedIcon />,
},
],
}, },
{ {
label: 'Clientes', title: 'Análise',
path: '/clientes', items: [
icon: <PeopleAltIcon />, {
label: 'Relatórios',
path: '/relatorios',
icon: <AssessmentRoundedIcon />,
},
],
}, },
{ {
label: 'Carteiras', title: 'Administração',
path: '/bancos', items: [
icon: <AccountBalanceWalletIcon />, {
}, label: 'Usuários',
{ path: '/usuarios',
label: 'Rendimentos', icon: <ManageAccountsRoundedIcon />,
path: '/rendimentos', },
icon: <SavingsIcon />, {
}, label: 'Configurações',
{ path: '/configuracoes',
label: 'Centros de custo', icon: <SettingsRoundedIcon />,
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 />,
}, },
]; ];
export function Sidebar({ onNavigate }: SidebarProps) { export function Sidebar({ onNavigate }: SidebarProps) {
return ( return (
<Box <Box
component="aside"
sx={{ sx={{
width: 280, width: 280,
height: '100%', height: '100%',
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
background: 'linear-gradient(180deg, #0F172A 0%, #111827 100%)', color: '#FFFFFF',
color: '#fff', 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 }}> <Box
<Typography variant="h6" fontWeight={900}> sx={{
Zendion padding: 3,
</Typography> 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)' }}> <Box sx={{ minWidth: 0 }}>
Financeiro <Typography
</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> </Box>
<Divider sx={{ borderColor: 'rgba(255,255,255,0.08)' }} /> <Divider sx={{ borderColor: 'rgba(255,255,255,0.08)' }} />
<List sx={{ padding: 2, flex: 1 }}> <List
{menuItems.map((item) => ( component="nav"
<ListItemButton aria-label="Menu principal"
key={item.path} sx={{
component={NavLink} flex: 1,
to={item.path} overflowY: 'auto',
onClick={onNavigate} overflowX: 'hidden',
sx={{ padding: 2,
borderRadius: 2, paddingTop: 2.25,
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>
<ListItemText '&::-webkit-scrollbar': {
primary={item.label} width: 6,
primaryTypographyProps={{ },
fontWeight: 700,
'&::-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> </List>
<Divider sx={{ borderColor: 'rgba(255,255,255,0.08)' }} />
<Box sx={{ padding: 2 }}> <Box sx={{ padding: 2 }}>
<Box <Box
sx={{ sx={{
padding: 2, padding: 2,
borderRadius: 3, 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)', border: '1px solid rgba(255,255,255,0.08)',
}} }}
> >
<Typography variant="body2" fontWeight={800}> <Typography
MVP ativo variant="body2"
fontWeight={850}
sx={{ color: 'rgba(255,255,255,0.88)' }}
>
Zendion Finance
</Typography> </Typography>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.62)' }}> <Typography
Movimentos e carteiras em operação. variant="caption"
sx={{
display: 'block',
marginTop: 0.25,
color: 'rgba(255,255,255,0.46)',
}}
>
Versão 1.0.0
</Typography> </Typography>
</Box> </Box>
</Box> </Box>
</Box> </Box>
); );
} }

View File

@ -2,28 +2,146 @@ import {
AppBar, AppBar,
Avatar, Avatar,
Box, Box,
Chip,
IconButton, IconButton,
Stack, Stack,
Toolbar, Toolbar,
Tooltip,
Typography, Typography,
} from '@mui/material'; } from '@mui/material';
import MenuIcon from '@mui/icons-material/Menu'; import MenuRoundedIcon from '@mui/icons-material/MenuRounded';
import LogoutIcon from '@mui/icons-material/Logout'; import LogoutRoundedIcon from '@mui/icons-material/LogoutRounded';
import { useNavigate } from 'react-router-dom'; import { useLocation, useNavigate } from 'react-router-dom';
import { useAuthStore } from '../../features/auth/store/authStore'; import { useAuthStore } from '../../features/auth/store/authStore';
type TopbarProps = { type TopbarProps = {
onMenuClick: () => void; 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) { export function Topbar({ onMenuClick }: TopbarProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation();
const user = useAuthStore((state) => state.user); const user = useAuthStore((state) => state.user);
const logout = useAuthStore((state) => state.logout); const logout = useAuthStore((state) => state.logout);
const routeInfo = resolverInformacaoRota(location.pathname);
const iniciais = obterIniciais(user?.nome);
function handleLogout() { function handleLogout() {
logout(); logout();
navigate('/login'); navigate('/login', { replace: true });
} }
return ( return (
@ -31,60 +149,171 @@ export function Topbar({ onMenuClick }: TopbarProps) {
position="sticky" position="sticky"
elevation={0} elevation={0}
sx={{ sx={{
backgroundColor: '#FFFFFF', zIndex: (theme) => theme.zIndex.drawer - 1,
color: '#111827', color: '#111827',
backgroundColor: 'rgba(255,255,255,0.92)',
backdropFilter: 'blur(14px)',
borderBottom: '1px solid', 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 }}> <Toolbar
<IconButton sx={{
onClick={onMenuClick} minHeight: { xs: 68, md: 76 },
edge="start" paddingX: { xs: 1.5, sm: 2.5, md: 3 },
sx={{ }}
display: { xs: 'inline-flex', md: 'none' }, >
marginRight: 1, <Tooltip title="Abrir menu">
}} <IconButton
> onClick={onMenuClick}
<MenuIcon /> edge="start"
</IconButton> 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 }}> '&:hover': {
<Typography variant="body2" color="text.secondary"> backgroundColor: 'rgba(59,130,246,0.08)',
Painel financeiro },
}}
>
<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>
<Typography variant="h6" fontWeight={900}> <Typography
Controle de movimentos variant="h6"
fontWeight={950}
noWrap
sx={{
marginTop: 0.25,
fontSize: { xs: '1.02rem', sm: '1.18rem' },
letterSpacing: '-0.02em',
}}
>
{routeInfo.title}
</Typography> </Typography>
</Box> </Box>
<Stack direction="row" alignItems="center" spacing={1.5}> <Stack
<Avatar direction="row"
alignItems="center"
spacing={{ xs: 0.75, sm: 1.25 }}
>
<Stack
direction="row"
alignItems="center"
spacing={1.15}
sx={{ sx={{
width: 38, paddingY: 0.6,
height: 38, paddingLeft: 0.65,
bgcolor: 'primary.main', paddingRight: { xs: 0.65, sm: 1.4 },
fontWeight: 800, 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' } }}> <Box
<Typography variant="body2" fontWeight={800}> sx={{
{user?.nome || 'Usuário'} display: { xs: 'none', sm: 'block' },
</Typography> minWidth: 0,
<Typography variant="caption" color="text.secondary"> maxWidth: 180,
Logado }}
</Typography> >
</Box> <Typography
variant="body2"
fontWeight={850}
noWrap
title={user?.nome || 'Usuário'}
>
{user?.nome || 'Usuário'}
</Typography>
<IconButton onClick={handleLogout} title="Sair"> <Typography
<LogoutIcon /> variant="caption"
</IconButton> 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> </Stack>
</Toolbar> </Toolbar>
</AppBar> </AppBar>
); );
} }

View File

@ -1,4 +1,5 @@
import { useState } from 'react'; import { useState } from 'react';
import type { FormEvent } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { import {
Alert, Alert,
@ -9,30 +10,42 @@ import {
Chip, Chip,
CircularProgress, CircularProgress,
Divider, Divider,
IconButton,
InputAdornment,
Stack, Stack,
TextField, TextField,
Tooltip,
Typography, Typography,
} from '@mui/material'; } 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 LockOutlinedIcon from '@mui/icons-material/LockOutlined';
import TrendingUpIcon from '@mui/icons-material/TrendingUp';
import ShieldOutlinedIcon from '@mui/icons-material/ShieldOutlined'; 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 { loginRequest } from '../services/authService';
import { useAuthStore } from '../store/authStore'; import { useAuthStore } from '../store/authStore';
const APP_NAME = 'Zendion Finance';
const APP_VERSION = '1.0.0';
const LOGO_SRC = '/icon.png';
export function LoginPage() { export function LoginPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const setAuth = useAuthStore((state) => state.setAuth); const setAuth = useAuthStore((state) => state.setAuth);
const [usuario, setUsuario] = useState(''); const [usuario, setUsuario] = useState('');
const [senha, setSenha] = useState(''); const [senha, setSenha] = useState('');
const [mostrarSenha, setMostrarSenha] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [erro, setErro] = useState(''); 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(); event.preventDefault();
if (!usuario.trim() || !senha.trim()) { if (!usuario.trim() || !senha) {
setErro('Informe usuário e senha.'); setErro('Informe usuário e senha.');
return; return;
} }
@ -47,11 +60,11 @@ export function LoginPage() {
}); });
setAuth(result.user, result.token); setAuth(result.user, result.token);
navigate('/dashboard'); navigate('/dashboard', { replace: true });
} catch (error: any) { } catch (error: any) {
const message = const message =
error?.response?.data?.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); setErro(message);
} finally { } finally {
@ -65,217 +78,442 @@ export function LoginPage() {
display="grid" display="grid"
gridTemplateColumns={{ gridTemplateColumns={{
xs: '1fr', xs: '1fr',
md: '1.1fr 0.9fr', md: 'minmax(480px, 1.08fr) minmax(420px, 0.92fr)',
}} }}
sx={{ sx={{
background: 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 <Box
component="section"
sx={{ sx={{
display: { xs: 'none', md: 'flex' }, display: { xs: 'none', md: 'flex' },
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
padding: 6, padding: { md: 5, lg: 7 },
background: color: '#FFFFFF',
'linear-gradient(160deg, #0F172A 0%, #111827 45%, #1E3A8A 100%)',
color: '#fff',
position: 'relative', position: 'relative',
overflow: 'hidden', overflow: 'hidden',
background:
'linear-gradient(155deg, #0F172A 0%, #111827 48%, #172554 100%)',
}} }}
> >
<Box <Box
sx={{ sx={{
position: 'absolute', position: 'absolute',
width: 360, width: 430,
height: 360, height: 430,
borderRadius: '50%', borderRadius: '50%',
background: 'rgba(96,165,250,0.18)', top: -170,
top: -120, right: -120,
right: -100, background:
filter: 'blur(4px)', 'radial-gradient(circle, rgba(59,130,246,0.30), rgba(59,130,246,0.02) 68%, transparent 72%)',
filter: 'blur(2px)',
}} }}
/> />
<Box <Box
sx={{ sx={{
position: 'absolute', position: 'absolute',
width: 260, width: 310,
height: 260, height: 310,
borderRadius: '50%', borderRadius: '50%',
background: 'rgba(34,197,94,0.10)', bottom: -120,
bottom: -80, left: -90,
left: -70, background:
filter: 'blur(4px)', 'radial-gradient(circle, rgba(14,165,233,0.18), rgba(14,165,233,0.02) 65%, transparent 70%)',
}} }}
/> />
<Box maxWidth={560} position="relative"> <Box
<Stack direction="row" spacing={1.5} alignItems="center" marginBottom={5}> 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 <Box
sx={{ sx={{
width: 52, width: 58,
height: 52, height: 58,
borderRadius: 3, flexShrink: 0,
borderRadius: 3.5,
display: 'grid', display: 'grid',
placeItems: 'center', placeItems: 'center',
backgroundColor: 'rgba(255,255,255,0.10)', overflow: 'hidden',
border: '1px solid rgba(255,255,255,0.14)', 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>
<Box> <Box>
<Typography variant="h5" fontWeight={900}> <Typography
Zendion variant="h5"
fontWeight={950}
lineHeight={1.05}
letterSpacing="-0.02em"
>
{APP_NAME}
</Typography> </Typography>
<Typography sx={{ color: 'rgba(255,255,255,0.68)' }}>
Financeiro <Typography sx={{ color: 'rgba(255,255,255,0.62)' }}>
Gestão financeira
</Typography> </Typography>
</Box> </Box>
</Stack> </Stack>
<Chip <Chip
label="Controle financeiro web" label="Controle financeiro inteligente"
sx={{ icon={<AutoGraphRoundedIcon />}
color: '#BFDBFE',
borderColor: 'rgba(191,219,254,0.28)',
backgroundColor: 'rgba(37,99,235,0.16)',
marginBottom: 2,
fontWeight: 700,
}}
variant="outlined" 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}> <Typography
Movimentos, carteiras e saldos em um painel limpo. variant="h3"
fontWeight={950}
lineHeight={1.06}
letterSpacing="-0.035em"
marginBottom={2.5}
>
Clareza para decidir.
<br />
Controle para evoluir.
</Typography> </Typography>
<Typography <Typography
variant="h6" variant="h6"
sx={{ sx={{
color: 'rgba(255,255,255,0.72)', maxWidth: 540,
fontWeight: 400,
maxWidth: 520,
marginBottom: 5, 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> </Typography>
<Stack spacing={2}> <Stack spacing={2.25}>
<Stack direction="row" spacing={2} alignItems="center"> <Stack direction="row" spacing={1.75} alignItems="center">
<ShieldOutlinedIcon sx={{ color: '#93C5FD' }} /> <Box
<Typography sx={{ color: 'rgba(255,255,255,0.78)' }}> sx={{
Acesso protegido por autenticação e token. width: 38,
</Typography> 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>
<Stack direction="row" spacing={2} alignItems="center"> <Stack direction="row" spacing={1.75} alignItems="center">
<TrendingUpIcon sx={{ color: '#86EFAC' }} /> <Box
<Typography sx={{ color: 'rgba(255,255,255,0.78)' }}> sx={{
Base pronta para relatórios, dashboards e controle por carteiras. width: 38,
</Typography> 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>
</Stack> </Stack>
</Box> </Box>
</Box> </Box>
<Box <Box
component="main"
display="flex" display="flex"
alignItems="center" alignItems="center"
justifyContent="center" justifyContent="center"
padding={{ xs: 2, sm: 3, md: 6 }} padding={{ xs: 2, sm: 3, md: 5, lg: 7 }}
position="relative"
> >
<Card <Stack
sx={{ width="100%"
width: '100%', maxWidth={450}
maxWidth: 440, spacing={2}
boxShadow: '0 22px 70px rgba(15,23,42,0.12)',
}}
> >
<CardContent sx={{ padding: { xs: 3, sm: 4 } }}> <Stack
<Stack spacing={1} alignItems="center" textAlign="center" marginBottom={3}> direction="row"
<Box spacing={1.25}
sx={{ alignItems="center"
width: 58, justifyContent="center"
height: 58, sx={{
borderRadius: 4, display: { xs: 'flex', md: 'none' },
display: 'grid', marginBottom: 1,
placeItems: 'center', }}
backgroundColor: 'rgba(21,101,192,0.10)', >
color: 'primary.main', <Box
marginBottom: 1, 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> </Box>
<Typography variant="h4" fontWeight={950}> <Divider sx={{ marginY: 3 }} />
Entrar
</Typography>
<Typography color="text.secondary"> <Stack
Acesse o Zendion Financeiro para continuar. direction={{ xs: 'column', sm: 'row' }}
</Typography> alignItems="center"
</Stack> justifyContent="space-between"
spacing={0.5}
{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,
}}
> >
{loading ? 'Entrando...' : 'Entrar no sistema'} <Typography variant="caption" color="text.secondary">
</Button> Sistema privado da Zendion INC.
</Box> </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"> <Typography
Sistema financeiro privado da Zendion INC. variant="caption"
</Typography> color="text.disabled"
</CardContent> textAlign="center"
</Card> >
Use apenas credenciais autorizadas.
</Typography>
</Stack>
</Box> </Box>
</Box> </Box>
); );
} }

View File

@ -9,15 +9,18 @@ import {
Chip, Chip,
CircularProgress, CircularProgress,
Divider, Divider,
FormControlLabel,
MenuItem, MenuItem,
Paper, Paper,
Stack, Stack,
Switch,
TextField, TextField,
Typography, Typography,
} from '@mui/material'; } from '@mui/material';
import AccountTreeIcon from '@mui/icons-material/AccountTree'; import AccountTreeIcon from '@mui/icons-material/AccountTree';
import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import SaveIcon from '@mui/icons-material/Save'; import SaveIcon from '@mui/icons-material/Save';
import SavingsIcon from '@mui/icons-material/Savings';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { import {
atualizarCentroCusto, atualizarCentroCusto,
@ -26,6 +29,7 @@ import {
import type { import type {
CentroCusto, CentroCusto,
CriarCentroCustoRequest, CriarCentroCustoRequest,
FlagNumerica,
} from '../types/centroCustoTypes'; } from '../types/centroCustoTypes';
type CentroCustoFormProps = { type CentroCustoFormProps = {
@ -40,7 +44,11 @@ type FormSectionProps = {
}; };
function formatarValorResumo(value: string) { 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', { return new Intl.NumberFormat('pt-BR', {
style: 'currency', style: 'currency',
@ -101,7 +109,6 @@ const fieldSx = {
export function CentroCustoForm({ mode, initialData }: CentroCustoFormProps) { export function CentroCustoForm({ mode, initialData }: CentroCustoFormProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const isEdit = mode === 'edit'; const isEdit = mode === 'edit';
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@ -109,44 +116,67 @@ export function CentroCustoForm({ mode, initialData }: CentroCustoFormProps) {
const [sucesso, setSucesso] = useState(''); const [sucesso, setSucesso] = useState('');
const [descricao, setDescricao] = useState(''); const [descricao, setDescricao] = useState('');
const [limite, setLimite] = useState('0'); const [investimento, setInvestimento] = useState<FlagNumerica>(0);
const [simular, setSimular] = useState('0'); const [habilitado, setHabilitado] = useState<FlagNumerica>(1);
const [investimento, setInvestimento] = useState('0');
const [habilitado, setHabilitado] = useState('1'); const [simular, setSimular] = useState<FlagNumerica>(0);
const [limite, setLimite] = useState('');
const titulo = isEdit ? 'Editar centro de custo' : 'Novo centro de custo'; const titulo = isEdit ? 'Editar centro de custo' : 'Novo centro de custo';
const subtitulo = isEdit const subtitulo = isEdit
? 'Atualize os dados do centro de custo selecionado.' ? 'Atualize os dados globais e a configuração da sua simulação.'
: 'Cadastre categorias financeiras para classificar os movimentos.'; : 'Cadastre uma categoria financeira e, se desejar, inclua-a na sua simulação.';
useEffect(() => { useEffect(() => {
if (!initialData) return; if (!initialData) return;
setDescricao(initialData.descricao || ''); setDescricao(initialData.descricao || '');
setLimite(String(initialData.limite ?? 0)); setInvestimento(Number(initialData.investimento) === 1 ? 1 : 0);
setSimular(String(initialData.simular ?? 0)); setHabilitado(Number(initialData.habilitado) === 1 ? 1 : 0);
setInvestimento(String(initialData.investimento ?? 0)); setSimular(Number(initialData.simular) === 1 ? 1 : 0);
setHabilitado(String(initialData.habilitado ?? 1)); setLimite(
initialData.limite === null || initialData.limite === undefined
? ''
: String(initialData.limite)
);
}, [initialData]); }, [initialData]);
function limparFormulario() { function limparFormulario() {
setDescricao(''); setDescricao('');
setLimite('0'); setInvestimento(0);
setSimular('0'); setHabilitado(1);
setInvestimento('0'); setSimular(0);
setHabilitado('1'); 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>) { async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
if (!descricao.trim()) { const erroValidacao = validarFormulario();
setErro('Informe a descrição.');
return;
}
if (limite === '' || !Number.isFinite(Number(limite))) { if (erroValidacao) {
setErro('Informe um limite válido.'); setErro(erroValidacao);
return; return;
} }
@ -157,10 +187,10 @@ export function CentroCustoForm({ mode, initialData }: CentroCustoFormProps) {
const payload: CriarCentroCustoRequest = { const payload: CriarCentroCustoRequest = {
descricao: descricao.trim(), descricao: descricao.trim(),
limite: Number(limite || 0), investimento,
simular: Number(simular || 0), habilitado,
investimento: Number(investimento || 0), simular,
habilitado: Number(habilitado || 1), limite: simular === 1 ? Number(limite) : null,
}; };
if (isEdit) { if (isEdit) {
@ -169,7 +199,11 @@ export function CentroCustoForm({ mode, initialData }: CentroCustoFormProps) {
return; return;
} }
await atualizarCentroCusto(initialData.idcentrodecustos, payload); await atualizarCentroCusto(
initialData.idcentrodecustos,
payload
);
setSucesso('Centro de custo atualizado com sucesso.'); setSucesso('Centro de custo atualizado com sucesso.');
} else { } else {
await criarCentroCusto(payload); await criarCentroCusto(payload);
@ -256,7 +290,7 @@ export function CentroCustoForm({ mode, initialData }: CentroCustoFormProps) {
> >
<FormSection <FormSection
title="Dados do centro" 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 <TextField
label="Descrição" label="Descrição"
@ -264,65 +298,111 @@ export function CentroCustoForm({ mode, initialData }: CentroCustoFormProps) {
onChange={(event) => setDescricao(event.target.value)} onChange={(event) => setDescricao(event.target.value)}
placeholder="Ex: Administrativo, Comercial, Impostos..." placeholder="Ex: Administrativo, Comercial, Impostos..."
fullWidth fullWidth
required
sx={fieldSx} 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 <TextField
select select
label="Investimento" label="Investimento"
value={investimento} value={investimento}
onChange={(event) => setInvestimento(event.target.value)} onChange={(event) =>
setInvestimento(Number(event.target.value) === 1 ? 1 : 0)
}
fullWidth fullWidth
sx={fieldSx} sx={fieldSx}
> >
<MenuItem value="1">Sim</MenuItem> <MenuItem value={1}>Sim</MenuItem>
<MenuItem value="0">Não</MenuItem> <MenuItem value={0}>Não</MenuItem>
</TextField> </TextField>
<TextField <TextField
select select
label="Status" label="Status"
value={habilitado} value={habilitado}
onChange={(event) => setHabilitado(event.target.value)} onChange={(event) =>
setHabilitado(Number(event.target.value) === 1 ? 1 : 0)
}
fullWidth fullWidth
sx={fieldSx} sx={fieldSx}
> >
<MenuItem value="1">Habilitado</MenuItem> <MenuItem value={1}>Habilitado</MenuItem>
<MenuItem value="0">Desabilitado</MenuItem> <MenuItem value={0}>Desabilitado</MenuItem>
</TextField> </TextField>
</FormSection> </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 <Stack
direction={{ xs: 'column-reverse', sm: 'row' }} direction={{ xs: 'column-reverse', sm: 'row' }}
justifyContent="flex-end" justifyContent="flex-end"
spacing={1.5} spacing={1.5}
sx={{ sx={{ paddingTop: 1 }}
paddingTop: 1,
}}
> >
<Button <Button
variant="outlined" variant="outlined"
@ -378,7 +458,7 @@ export function CentroCustoForm({ mode, initialData }: CentroCustoFormProps) {
</Typography> </Typography>
<Typography variant="body2" color="text.secondary" marginBottom={2.5}> <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> </Typography>
<Divider sx={{ marginBottom: 2.5 }} /> <Divider sx={{ marginBottom: 2.5 }} />
@ -388,6 +468,7 @@ export function CentroCustoForm({ mode, initialData }: CentroCustoFormProps) {
<Typography variant="caption" color="text.secondary"> <Typography variant="caption" color="text.secondary">
Descrição Descrição
</Typography> </Typography>
<Typography fontWeight={800}> <Typography fontWeight={800}>
{descricao || 'Sem descrição'} {descricao || 'Sem descrição'}
</Typography> </Typography>
@ -395,28 +476,11 @@ export function CentroCustoForm({ mode, initialData }: CentroCustoFormProps) {
<Box> <Box>
<Typography variant="caption" color="text.secondary"> <Typography variant="caption" color="text.secondary">
Limite Tipo
</Typography> </Typography>
<Typography variant="h4" fontWeight={950}>
{formatarValorResumo(limite)}
</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Simular
</Typography>
<Typography fontWeight={800}> <Typography fontWeight={800}>
{Number(simular) === 1 ? 'Sim' : 'Não'} {investimento === 1 ? 'Investimento' : 'Operacional'}
</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Investimento
</Typography>
<Typography fontWeight={800}>
{Number(investimento) === 1 ? 'Sim' : 'Não'}
</Typography> </Typography>
</Box> </Box>
@ -424,16 +488,65 @@ export function CentroCustoForm({ mode, initialData }: CentroCustoFormProps) {
<Typography variant="caption" color="text.secondary"> <Typography variant="caption" color="text.secondary">
Status Status
</Typography> </Typography>
<Typography <Typography
fontWeight={800} 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> </Typography>
</Box> </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> </Stack>
</Paper> </Paper>
</Stack> </Stack>
</Box> </Box>
); );
} }

View File

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

View File

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

View File

@ -1,20 +1,36 @@
export type FlagNumerica = 0 | 1;
export type CentroCusto = { export type CentroCusto = {
idcentrodecustos: number; idcentrodecustos: number;
descricao: string; descricao: string;
limite: number;
simular: number; /**
investimento: number; * Dados globais do centro de custo.
habilitado: number; */
investimento: FlagNumerica;
habilitado: FlagNumerica;
insert_date: string | null; insert_date: string | null;
update_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 = { export type CriarCentroCustoRequest = {
descricao: string; descricao: string;
limite: number; investimento: FlagNumerica;
simular: number; habilitado: FlagNumerica;
investimento: number;
habilitado: number; /**
* 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; export type AtualizarCentroCustoRequest = CriarCentroCustoRequest;
@ -29,10 +45,19 @@ export type CentrosCustoPagination = {
export type CentrosCustoSummary = { export type CentrosCustoSummary = {
quantidade: number; quantidade: number;
/**
* Soma dos limites configurados pelo usuário logado.
*/
limiteTotal: number; limiteTotal: number;
habilitados: number; habilitados: number;
desabilitados: number; desabilitados: number;
investimentos: number; investimentos: number;
/**
* Quantidade de centros vinculados à simulação do usuário.
*/
simulaveis: number; simulaveis: number;
}; };

View File

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

View File

@ -127,7 +127,7 @@ function origemLabel(origem: string) {
movimento_fixo_previsto: 'Fixo previsto', movimento_fixo_previsto: 'Fixo previsto',
saldo_banco: 'Saldo banco', saldo_banco: 'Saldo banco',
cartao_credito_agrupado: 'Cartão', cartao_credito_agrupado: 'Cartão',
limite_centro_custo: 'Limite centro', limite_centro_custo: 'Previsão do centro',
manual: 'Manual', 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 && ( {item.cliente_nome && (
<Chip <Chip
size="small" size="small"
@ -415,7 +440,7 @@ export function SimulacaoMensalPage() {
setBase(data); setBase(data);
setEntradas(data.entradas || []); setEntradas(data.entradas || []);
setSaidas(data.saidas || []); setSaidas(data.saidas || []);
setSucesso('Previsão preenchida com sucesso.'); setSucesso('Simulação mensal montada com sucesso.');
} catch (error: any) { } catch (error: any) {
const message = const message =
error?.response?.data?.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.movimentosExistentes} movimentos existentes`} variant="outlined" />
<Chip label={`${base.fontes.movimentosFixos} fixos analisados`} variant="outlined" /> <Chip label={`${base.fontes.movimentosFixos} fixos analisados`} variant="outlined" />
<Chip label={`${base.fontes.bancos} bancos`} 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> </Stack>
</Paper> </Paper>
)} )}

View File

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

View File

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