first commit

This commit is contained in:
Diego Freitas 2026-05-29 22:51:05 -03:00
commit 79070aecb8
60 changed files with 11257 additions and 0 deletions

110
.gitignore vendored Normal file
View File

@ -0,0 +1,110 @@
# ============================================================
# Node / NPM
# ============================================================
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
package-lock.json.old
# ============================================================
# Build outputs
# ============================================================
dist/
build/
.vite/
.cache/
coverage/
# ============================================================
# Environment files
# ATENÇÃO: não subir senhas, JWT_SECRET, banco etc.
# ============================================================
.env
.env.local
.env.development.local
.env.test.local
.env.production
.env.production.local
# Se quiser versionar exemplos, use:
!.env.example
# ============================================================
# Logs
# ============================================================
logs/
*.log
forever.log
out.log
error.log
# ============================================================
# OS / Editor
# ============================================================
.DS_Store
Thumbs.db
desktop.ini
.vscode/*
!.vscode/extensions.json
!.vscode/settings.example.json
.idea/
# ============================================================
# TypeScript
# ============================================================
*.tsbuildinfo
# ============================================================
# React / Vite
# ============================================================
financeiro-web/dist/
financeiro-web/.vite/
financeiro-web/node_modules/
# ============================================================
# API
# ============================================================
financeiro-api/node_modules/
financeiro-api/logs/
financeiro-api/*.log
# ============================================================
# Temporary / backup files
# ============================================================
tmp/
temp/
*.tmp
*.bak
*.backup
*.old
*.swp
# ============================================================
# Database dumps
# ============================================================
*.sql
*.dump
*.sqlite
*.sqlite3
# ============================================================
# Certificates / keys
# ============================================================
*.pem
*.key
*.crt
*.csr
*.p12
*.pfx
# ============================================================
# Deploy artifacts
# ============================================================
*.zip
*.tar
*.tar.gz
*.rar

1487
financeiro-api/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,24 @@
{
"name": "financeiro-api",
"version": "1.0.0",
"description": "API do Zendion Financeiro",
"main": "src/server.js",
"scripts": {
"dev": "nodemon src/server.js",
"start": "node src/server.js"
},
"keywords": [],
"author": "Zendion INC",
"license": "ISC",
"dependencies": {
"bcryptjs": "^3.0.3",
"cors": "^2.8.6",
"dotenv": "^17.4.2",
"express": "^5.2.1",
"jsonwebtoken": "^9.0.3",
"mysql2": "^3.22.4"
},
"devDependencies": {
"nodemon": "^3.1.14"
}
}

43
financeiro-api/src/app.js Normal file
View File

@ -0,0 +1,43 @@
const express = require('express');
const cors = require('cors');
const authRoutes = require('./modules/auth/routes/auth.routes');
const movimentosRoutes = require('./modules/movimentos/routes/movimentos.routes');
const referenciasRoutes = require('./modules/referencias/routes/referencias.routes');
const relatoriosRoutes = require('./modules/relatorios/routes/relatorios.routes');
const app = express();
app.use(cors({
origin: [
'http://localhost:5173',
'http://127.0.0.1:5173',
'https://financeiro.zendioninc.com.br',
],
credentials: true,
}));
app.use(express.json({ limit: '2mb' }));
app.get('/api/health', (req, res) => {
return res.json({
ok: true,
app: process.env.APP_NAME || 'Zendion Financeiro API',
environment: process.env.NODE_ENV || 'development',
timestamp: new Date().toISOString(),
});
});
app.use('/api/auth', authRoutes);
app.use('/api/movimentos', movimentosRoutes);
app.use('/api/referencias', referenciasRoutes);
app.use('/api/relatorios', relatoriosRoutes);
app.use((req, res) => {
return res.status(404).json({
ok: false,
message: 'Rota não encontrada.',
});
});
module.exports = app;

View File

@ -0,0 +1,18 @@
const mysql = require('mysql2/promise');
const pool = mysql.createPool({
host: process.env.DB_HOST,
port: Number(process.env.DB_PORT || 3306),
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0,
decimalNumbers: true,
dateStrings: true,
});
module.exports = pool;

View File

@ -0,0 +1,48 @@
const jwt = require('jsonwebtoken');
function authMiddleware(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader) {
return res.status(401).json({
ok: false,
message: 'Token não informado.',
});
}
const parts = authHeader.split(' ');
if (parts.length !== 2) {
return res.status(401).json({
ok: false,
message: 'Token inválido.',
});
}
const [scheme, token] = parts;
if (!/^Bearer$/i.test(scheme)) {
return res.status(401).json({
ok: false,
message: 'Formato de token inválido.',
});
}
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = {
id: decoded.id,
nome: decoded.nome,
};
return next();
} catch (error) {
return res.status(401).json({
ok: false,
message: 'Token expirado ou inválido.',
});
}
}
module.exports = authMiddleware;

View File

@ -0,0 +1,66 @@
const jwt = require('jsonwebtoken');
const authService = require('../services/auth.service');
async function login(req, res) {
try {
const { usuario, senha } = req.body;
if (!usuario || !senha) {
return res.status(400).json({
ok: false,
message: 'Usuário e senha são obrigatórios.',
});
}
const usuarioBanco = await authService.buscarUsuarioPorNome(usuario);
if (!usuarioBanco) {
return res.status(401).json({
ok: false,
message: 'Usuário ou senha inválidos.',
});
}
const senhaValida = await authService.validarSenha(senha, usuarioBanco.senha);
if (!senhaValida) {
return res.status(401).json({
ok: false,
message: 'Usuário ou senha inválidos.',
});
}
const token = jwt.sign(
{
id: usuarioBanco.idusuarios,
nome: usuarioBanco.nome,
},
process.env.JWT_SECRET,
{
expiresIn: process.env.JWT_EXPIRES_IN || '8h',
}
);
return res.json({
ok: true,
message: 'Login realizado com sucesso.',
user: {
id: usuarioBanco.idusuarios,
nome: usuarioBanco.nome,
anotacoes: usuarioBanco.anotacoes,
},
token,
});
} catch (error) {
console.error('Erro no login:', error);
return res.status(500).json({
ok: false,
message: 'Erro interno ao realizar login.',
});
}
}
module.exports = {
login,
};

View File

@ -0,0 +1,8 @@
const express = require('express');
const authController = require('../controllers/auth.controller');
const router = express.Router();
router.post('/login', authController.login);
module.exports = router;

View File

@ -0,0 +1,39 @@
const pool = require('../../../database/mysql');
const bcrypt = require('bcryptjs');
async function buscarUsuarioPorNome(nome) {
const [rows] = await pool.query(
`
SELECT
idusuarios,
nome,
senha,
anotacoes
FROM usuarios
WHERE nome = ?
LIMIT 1
`,
[nome]
);
return rows[0] || null;
}
async function validarSenha(senhaInformada, senhaBanco) {
if (!senhaBanco) {
return false;
}
// Se futuramente a senha estiver em bcrypt, valida com bcrypt.
if (senhaBanco.startsWith('$2a$') || senhaBanco.startsWith('$2b$') || senhaBanco.startsWith('$2y$')) {
return bcrypt.compare(senhaInformada, senhaBanco);
}
// Compatibilidade temporária com senha antiga em texto puro.
return senhaInformada === senhaBanco;
}
module.exports = {
buscarUsuarioPorNome,
validarSenha,
};

View File

@ -0,0 +1,187 @@
const movimentosService = require('../services/movimentos.service');
function validarDadosMovimento(dados) {
if (!dados.movimento) {
return 'Movimento é obrigatório.';
}
if (!['Entrada', 'Saida', 'Sangria', 'Estorno'].includes(dados.movimento)) {
return 'Movimento inválido.';
}
if (dados.valor === undefined || dados.valor === null || dados.valor === '') {
return 'Valor é obrigatório.';
}
if (Number(dados.valor) <= 0) {
return 'Valor deve ser maior que zero.';
}
if (!dados.status) {
return 'Situação é obrigatória.';
}
if (!['Pago', 'A pagar', 'Recebido', 'A receber'].includes(dados.status)) {
return 'Situação inválida.';
}
if (!dados.descricao || !String(dados.descricao).trim()) {
return 'Descrição é obrigatória.';
}
if (!dados.dataentrada) {
return 'Data de entrada é obrigatória.';
}
return null;
}
async function listar(req, res) {
try {
const resultado = await movimentosService.listarMovimentos({
limite: req.query.limite,
offset: req.query.offset,
page: req.query.page,
movimento: req.query.movimento,
status: req.query.status,
idbancos: req.query.idbancos,
idcentrodecustos: req.query.idcentrodecustos,
idclientes: req.query.idclientes,
idbancos_p: req.query.idbancos_p,
referenciaTipo: req.query.referenciaTipo,
dataCampo: req.query.dataCampo,
dataInicio: req.query.dataInicio,
dataFim: req.query.dataFim,
busca: req.query.busca,
orderBy: req.query.orderBy,
orderDirection: req.query.orderDirection,
});
return res.json({
ok: true,
data: resultado.data,
pagination: resultado.pagination,
summary: resultado.summary,
});
} catch (error) {
console.error('Erro ao listar movimentos:', error);
return res.status(500).json({
ok: false,
message: 'Erro ao listar movimentos.',
});
}
}
async function buscarPorId(req, res) {
try {
const id = Number(req.params.id);
if (!id) {
return res.status(400).json({
ok: false,
message: 'ID inválido.',
});
}
const movimento = await movimentosService.buscarMovimentoPorId(id);
if (!movimento) {
return res.status(404).json({
ok: false,
message: 'Movimento não encontrado.',
});
}
return res.json({
ok: true,
data: movimento,
});
} catch (error) {
console.error('Erro ao buscar movimento:', error);
return res.status(500).json({
ok: false,
message: 'Erro ao buscar movimento.',
});
}
}
async function criar(req, res) {
try {
const erroValidacao = validarDadosMovimento(req.body);
if (erroValidacao) {
return res.status(400).json({
ok: false,
message: erroValidacao,
});
}
const novoMovimento = await movimentosService.criarMovimento(req.user.id, req.body);
return res.status(201).json({
ok: true,
message: 'Movimento cadastrado com sucesso.',
data: novoMovimento,
});
} catch (error) {
console.error('Erro ao criar movimento:', error);
return res.status(500).json({
ok: false,
message: 'Erro ao criar movimento.',
});
}
}
async function atualizar(req, res) {
try {
const id = Number(req.params.id);
if (!id) {
return res.status(400).json({
ok: false,
message: 'ID inválido.',
});
}
const erroValidacao = validarDadosMovimento(req.body);
if (erroValidacao) {
return res.status(400).json({
ok: false,
message: erroValidacao,
});
}
const movimentoAtualizado = await movimentosService.atualizarMovimento(id, req.body);
if (!movimentoAtualizado) {
return res.status(404).json({
ok: false,
message: 'Movimento não encontrado.',
});
}
return res.json({
ok: true,
message: 'Movimento atualizado com sucesso.',
data: movimentoAtualizado,
});
} catch (error) {
console.error('Erro ao atualizar movimento:', error);
return res.status(500).json({
ok: false,
message: 'Erro ao atualizar movimento.',
});
}
}
module.exports = {
listar,
buscarPorId,
criar,
atualizar,
};

View File

@ -0,0 +1,14 @@
const express = require('express');
const authMiddleware = require('../../../middlewares/auth.middleware');
const movimentosController = require('../controllers/movimentos.controller');
const router = express.Router();
router.use(authMiddleware);
router.get('/', movimentosController.listar);
router.get('/:id', movimentosController.buscarPorId);
router.post('/', movimentosController.criar);
router.put('/:id', movimentosController.atualizar);
module.exports = router;

View File

@ -0,0 +1,434 @@
const pool = require('../../../database/mysql');
const CAMPOS_MOVIMENTO_SELECT = `
cp.idcontasapagar,
cp.movimento,
cp.descricao,
cp.dataentrada,
cp.datavencimento,
cp.databaixa,
cp.valor,
cp.parcela,
cp.parcelas,
cp.status,
cp.idcentrodecustos,
cp.idbancos,
cp.idusuarios_cad,
cp.idusuarios_baixa,
cp.idclientes,
cp.idveiculosdetalhes,
cp.idbancos_p,
cp.observacao,
cp.insert_date,
cp.update_date,
b.descricao AS banco_descricao,
cc.descricao AS centro_custo_descricao,
c.nome AS cliente_nome,
bp.descricao AS banco_referencia_descricao
`;
const FROM_MOVIMENTO_JOIN = `
FROM contasapagar cp
LEFT JOIN bancos b ON b.idbancos = cp.idbancos
LEFT JOIN centrodecustos cc ON cc.idcentrodecustos = cp.idcentrodecustos
LEFT JOIN clientes c ON c.idclientes = cp.idclientes
LEFT JOIN bancos bp ON bp.idbancos = cp.idbancos_p
`;
function normalizarNumeroOuNull(valor) {
if (valor === undefined || valor === null || valor === '') {
return null;
}
return Number(valor);
}
function normalizarTextoOuNull(valor) {
if (valor === undefined || valor === null || valor === '') {
return null;
}
return String(valor).trim();
}
function limitarNumero(valor, padrao, minimo, maximo) {
const numero = Number(valor);
if (!Number.isFinite(numero)) {
return padrao;
}
if (numero < minimo) {
return minimo;
}
if (numero > maximo) {
return maximo;
}
return numero;
}
function resolverCampoData(dataCampo) {
const camposPermitidos = {
dataentrada: 'cp.dataentrada',
datavencimento: 'cp.datavencimento',
databaixa: 'cp.databaixa',
insert_date: 'cp.insert_date',
update_date: 'cp.update_date',
};
return camposPermitidos[dataCampo] || 'cp.datavencimento';
}
function resolverOrdenacao(orderBy) {
const camposPermitidos = {
dataentrada: 'cp.dataentrada',
datavencimento: 'cp.datavencimento',
databaixa: 'cp.databaixa',
insert_date: 'cp.insert_date',
update_date: 'cp.update_date',
valor: 'cp.valor',
descricao: 'cp.descricao',
status: 'cp.status',
movimento: 'cp.movimento',
};
return camposPermitidos[orderBy] || 'cp.datavencimento';
}
function resolverDirecao(orderDirection) {
return String(orderDirection || '').toUpperCase() === 'ASC' ? 'ASC' : 'DESC';
}
function montarWhereMovimentos(filtros = {}) {
const where = [];
const params = [];
if (filtros.movimento) {
where.push('cp.movimento = ?');
params.push(filtros.movimento);
}
if (filtros.status) {
where.push('cp.status = ?');
params.push(filtros.status);
}
if (filtros.idbancos) {
where.push('cp.idbancos = ?');
params.push(Number(filtros.idbancos));
}
if (filtros.idcentrodecustos) {
where.push('cp.idcentrodecustos = ?');
params.push(Number(filtros.idcentrodecustos));
}
if (filtros.idclientes) {
where.push('cp.idclientes = ?');
params.push(Number(filtros.idclientes));
}
if (filtros.idbancos_p) {
where.push('cp.idbancos_p = ?');
params.push(Number(filtros.idbancos_p));
}
if (filtros.referenciaTipo === 'cliente') {
where.push('cp.idclientes IS NOT NULL');
}
if (filtros.referenciaTipo === 'banco') {
where.push('cp.idbancos_p IS NOT NULL');
}
if (filtros.referenciaTipo === 'sem_referencia') {
where.push('cp.idclientes IS NULL AND cp.idbancos_p IS NULL');
}
const campoData = resolverCampoData(filtros.dataCampo);
if (filtros.dataInicio) {
where.push(`DATE(${campoData}) >= ?`);
params.push(filtros.dataInicio);
}
if (filtros.dataFim) {
where.push(`DATE(${campoData}) <= ?`);
params.push(filtros.dataFim);
}
if (filtros.busca) {
where.push(`
(
cp.descricao LIKE ?
OR cp.observacao LIKE ?
OR b.descricao LIKE ?
OR cc.descricao LIKE ?
OR c.nome LIKE ?
OR c.cpf_cnpj LIKE ?
OR bp.descricao LIKE ?
)
`);
const termo = `%${String(filtros.busca).trim()}%`;
params.push(
termo,
termo,
termo,
termo,
termo,
termo,
termo
);
}
const whereSql = where.length > 0 ? `WHERE ${where.join(' AND ')}` : '';
return {
whereSql,
params,
};
}
async function listarMovimentos(filtros = {}) {
const limite = limitarNumero(filtros.limite, 20, 1, 100);
const page = limitarNumero(filtros.page, 1, 1, 999999);
const offset = filtros.offset !== undefined
? limitarNumero(filtros.offset, 0, 0, 999999999)
: (page - 1) * limite;
const orderBy = resolverOrdenacao(filtros.orderBy || filtros.dataCampo);
const orderDirection = resolverDirecao(filtros.orderDirection);
const { whereSql, params } = montarWhereMovimentos(filtros);
const [rows] = await pool.query(
`
SELECT
${CAMPOS_MOVIMENTO_SELECT}
${FROM_MOVIMENTO_JOIN}
${whereSql}
ORDER BY ${orderBy} ${orderDirection}, cp.idcontasapagar DESC
LIMIT ? OFFSET ?
`,
[...params, limite, offset]
);
const [countRows] = await pool.query(
`
SELECT COUNT(*) AS total
${FROM_MOVIMENTO_JOIN}
${whereSql}
`,
params
);
const [summaryRows] = await pool.query(
`
SELECT
COUNT(*) AS quantidade,
COALESCE(SUM(CASE WHEN cp.movimento = 'Entrada' THEN cp.valor ELSE 0 END), 0) AS totalEntradas,
COALESCE(SUM(CASE WHEN cp.movimento = 'Saida' THEN cp.valor ELSE 0 END), 0) AS totalSaidas,
COALESCE(SUM(CASE WHEN cp.movimento = 'Sangria' THEN cp.valor ELSE 0 END), 0) AS totalSangrias,
COALESCE(SUM(CASE WHEN cp.movimento = 'Estorno' THEN cp.valor ELSE 0 END), 0) AS totalEstornos,
COALESCE(SUM(
CASE
WHEN cp.movimento IN ('Entrada', 'Estorno') THEN cp.valor
WHEN cp.movimento IN ('Saida', 'Sangria') THEN -cp.valor
ELSE 0
END
), 0) AS saldo
${FROM_MOVIMENTO_JOIN}
${whereSql}
`,
params
);
const total = Number(countRows[0]?.total || 0);
const totalPages = Math.max(1, Math.ceil(total / limite));
return {
data: rows,
pagination: {
total,
limite,
offset,
page: Math.floor(offset / limite) + 1,
totalPages,
},
summary: {
quantidade: Number(summaryRows[0]?.quantidade || 0),
totalEntradas: Number(summaryRows[0]?.totalEntradas || 0),
totalSaidas: Number(summaryRows[0]?.totalSaidas || 0),
totalSangrias: Number(summaryRows[0]?.totalSangrias || 0),
totalEstornos: Number(summaryRows[0]?.totalEstornos || 0),
saldo: Number(summaryRows[0]?.saldo || 0),
},
};
}
async function buscarMovimentoPorId(id) {
const [rows] = await pool.query(
`
SELECT
${CAMPOS_MOVIMENTO_SELECT}
${FROM_MOVIMENTO_JOIN}
WHERE cp.idcontasapagar = ?
LIMIT 1
`,
[id]
);
return rows[0] || null;
}
async function criarMovimento(idUsuario, dados) {
const {
movimento,
descricao,
dataentrada,
datavencimento,
databaixa,
valor,
parcela,
parcelas,
status,
idcentrodecustos,
idbancos,
idusuarios_baixa,
idclientes,
idveiculosdetalhes,
idbancos_p,
observacao,
} = dados;
const [result] = await pool.query(
`
INSERT INTO contasapagar (
movimento,
descricao,
dataentrada,
datavencimento,
databaixa,
valor,
parcela,
parcelas,
status,
idcentrodecustos,
idbancos,
idusuarios_cad,
idusuarios_baixa,
idclientes,
idveiculosdetalhes,
idbancos_p,
observacao,
insert_date,
update_date
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())
`,
[
movimento,
normalizarTextoOuNull(descricao),
dataentrada || null,
datavencimento || null,
databaixa || null,
Number(valor || 0),
Number(parcela || 1),
Number(parcelas || 1),
status || null,
normalizarNumeroOuNull(idcentrodecustos),
normalizarNumeroOuNull(idbancos),
idUsuario,
normalizarNumeroOuNull(idusuarios_baixa),
normalizarNumeroOuNull(idclientes),
normalizarNumeroOuNull(idveiculosdetalhes),
normalizarNumeroOuNull(idbancos_p),
normalizarTextoOuNull(observacao),
]
);
return buscarMovimentoPorId(result.insertId);
}
async function atualizarMovimento(id, dados) {
const movimentoAtual = await buscarMovimentoPorId(id);
if (!movimentoAtual) {
return null;
}
const {
movimento,
descricao,
dataentrada,
datavencimento,
databaixa,
valor,
parcela,
parcelas,
status,
idcentrodecustos,
idbancos,
idusuarios_baixa,
idclientes,
idveiculosdetalhes,
idbancos_p,
observacao,
} = dados;
await pool.query(
`
UPDATE contasapagar
SET
movimento = ?,
descricao = ?,
dataentrada = ?,
datavencimento = ?,
databaixa = ?,
valor = ?,
parcela = ?,
parcelas = ?,
status = ?,
idcentrodecustos = ?,
idbancos = ?,
idusuarios_baixa = ?,
idclientes = ?,
idveiculosdetalhes = ?,
idbancos_p = ?,
observacao = ?,
update_date = NOW()
WHERE idcontasapagar = ?
`,
[
movimento,
normalizarTextoOuNull(descricao),
dataentrada || null,
datavencimento || null,
databaixa || null,
Number(valor || 0),
Number(parcela || 1),
Number(parcelas || 1),
status || null,
normalizarNumeroOuNull(idcentrodecustos),
normalizarNumeroOuNull(idbancos),
normalizarNumeroOuNull(idusuarios_baixa),
normalizarNumeroOuNull(idclientes),
normalizarNumeroOuNull(idveiculosdetalhes),
normalizarNumeroOuNull(idbancos_p),
normalizarTextoOuNull(observacao),
id,
]
);
return buscarMovimentoPorId(id);
}
module.exports = {
listarMovimentos,
buscarMovimentoPorId,
criarMovimento,
atualizarMovimento,
};

View File

@ -0,0 +1,61 @@
const referenciasService = require('../services/referencias.service');
async function listarBancos(req, res) {
try {
const bancos = await referenciasService.listarBancos();
return res.json({
ok: true,
data: bancos,
});
} catch (error) {
console.error('Erro ao listar bancos:', error);
return res.status(500).json({
ok: false,
message: 'Erro ao listar bancos.',
});
}
}
async function listarCentrosCusto(req, res) {
try {
const centrosCusto = await referenciasService.listarCentrosCusto();
return res.json({
ok: true,
data: centrosCusto,
});
} catch (error) {
console.error('Erro ao listar centros de custo:', error);
return res.status(500).json({
ok: false,
message: 'Erro ao listar centros de custo.',
});
}
}
async function listarClientes(req, res) {
try {
const clientes = await referenciasService.listarClientes();
return res.json({
ok: true,
data: clientes,
});
} catch (error) {
console.error('Erro ao listar clientes:', error);
return res.status(500).json({
ok: false,
message: 'Erro ao listar clientes.',
});
}
}
module.exports = {
listarBancos,
listarCentrosCusto,
listarClientes,
};

View File

@ -0,0 +1,13 @@
const express = require('express');
const authMiddleware = require('../../../middlewares/auth.middleware');
const referenciasController = require('../controllers/referencias.controller');
const router = express.Router();
router.use(authMiddleware);
router.get('/bancos', referenciasController.listarBancos);
router.get('/centros-custo', referenciasController.listarCentrosCusto);
router.get('/clientes', referenciasController.listarClientes);
module.exports = router;

View File

@ -0,0 +1,60 @@
const pool = require('../../../database/mysql');
async function listarBancos() {
const [rows] = await pool.query(
`
SELECT
idbancos AS id,
descricao,
saldo,
debito
FROM bancos
ORDER BY descricao ASC
`
);
return rows;
}
async function listarCentrosCusto() {
const [rows] = await pool.query(
`
SELECT
idcentrodecustos AS id,
descricao,
limite,
simular,
investimento
FROM centrodecustos
ORDER BY descricao ASC
`
);
return rows;
}
async function listarClientes() {
const [rows] = await pool.query(
`
SELECT
idclientes AS id,
cpf_cnpj,
nome,
celular,
email,
cidade,
estado,
pessoafisica
FROM clientes
ORDER BY nome ASC
`
);
return rows;
}
module.exports = {
listarBancos,
listarCentrosCusto,
listarClientes,
};

View File

@ -0,0 +1,32 @@
const relatoriosService = require('../services/relatorios.service');
async function dashboard(req, res) {
try {
const resultado = await relatoriosService.buscarDashboardRelatorios({
dataCampo: req.query.dataCampo,
dataInicio: req.query.dataInicio,
dataFim: req.query.dataFim,
movimento: req.query.movimento,
status: req.query.status,
idbancos: req.query.idbancos,
idcentrodecustos: req.query.idcentrodecustos,
idclientes: req.query.idclientes,
});
return res.json({
ok: true,
data: resultado,
});
} catch (error) {
console.error('Erro ao carregar relatório:', error);
return res.status(500).json({
ok: false,
message: 'Erro ao carregar relatório.',
});
}
}
module.exports = {
dashboard,
};

View File

@ -0,0 +1,11 @@
const express = require('express');
const authMiddleware = require('../../../middlewares/auth.middleware');
const relatoriosController = require('../controllers/relatorios.controller');
const router = express.Router();
router.use(authMiddleware);
router.get('/dashboard', relatoriosController.dashboard);
module.exports = router;

View File

@ -0,0 +1,275 @@
const pool = require('../../../database/mysql');
function resolverCampoData(dataCampo) {
const camposPermitidos = {
dataentrada: 'cp.dataentrada',
datavencimento: 'cp.datavencimento',
databaixa: 'cp.databaixa',
insert_date: 'cp.insert_date',
update_date: 'cp.update_date',
};
return camposPermitidos[dataCampo] || 'cp.datavencimento';
}
function montarWhereRelatorio(filtros = {}) {
const where = [];
const params = [];
const campoData = resolverCampoData(filtros.dataCampo);
if (filtros.dataInicio) {
where.push(`DATE(${campoData}) >= ?`);
params.push(filtros.dataInicio);
}
if (filtros.dataFim) {
where.push(`DATE(${campoData}) <= ?`);
params.push(filtros.dataFim);
}
if (filtros.movimento) {
where.push('cp.movimento = ?');
params.push(filtros.movimento);
}
if (filtros.status) {
where.push('cp.status = ?');
params.push(filtros.status);
}
if (filtros.idbancos) {
where.push('cp.idbancos = ?');
params.push(Number(filtros.idbancos));
}
if (filtros.idcentrodecustos) {
where.push('cp.idcentrodecustos = ?');
params.push(Number(filtros.idcentrodecustos));
}
if (filtros.idclientes) {
where.push('cp.idclientes = ?');
params.push(Number(filtros.idclientes));
}
const whereSql = where.length > 0 ? `WHERE ${where.join(' AND ')}` : '';
return {
campoData,
whereSql,
params,
};
}
async function buscarResumo(filtros = {}) {
const { whereSql, params } = montarWhereRelatorio(filtros);
const [rows] = await pool.query(
`
SELECT
COUNT(*) AS quantidade,
COALESCE(SUM(CASE WHEN cp.movimento = 'Entrada' THEN cp.valor ELSE 0 END), 0) AS totalEntradas,
COALESCE(SUM(CASE WHEN cp.movimento = 'Saida' THEN cp.valor ELSE 0 END), 0) AS totalSaidas,
COALESCE(SUM(CASE WHEN cp.movimento = 'Sangria' THEN cp.valor ELSE 0 END), 0) AS totalSangrias,
COALESCE(SUM(CASE WHEN cp.movimento = 'Estorno' THEN cp.valor ELSE 0 END), 0) AS totalEstornos,
COALESCE(SUM(CASE WHEN cp.status = 'Pago' THEN cp.valor ELSE 0 END), 0) AS totalPago,
COALESCE(SUM(CASE WHEN cp.status = 'A pagar' THEN cp.valor ELSE 0 END), 0) AS totalAPagar,
COALESCE(SUM(CASE WHEN cp.status = 'Recebido' THEN cp.valor ELSE 0 END), 0) AS totalRecebido,
COALESCE(SUM(CASE WHEN cp.status = 'A receber' THEN cp.valor ELSE 0 END), 0) AS totalAReceber,
COALESCE(SUM(
CASE
WHEN cp.movimento IN ('Entrada', 'Estorno') THEN cp.valor
WHEN cp.movimento IN ('Saida', 'Sangria') THEN -cp.valor
ELSE 0
END
), 0) AS saldo
FROM contasapagar cp
${whereSql}
`,
params
);
const item = rows[0] || {};
return {
quantidade: Number(item.quantidade || 0),
totalEntradas: Number(item.totalEntradas || 0),
totalSaidas: Number(item.totalSaidas || 0),
totalSangrias: Number(item.totalSangrias || 0),
totalEstornos: Number(item.totalEstornos || 0),
totalPago: Number(item.totalPago || 0),
totalAPagar: Number(item.totalAPagar || 0),
totalRecebido: Number(item.totalRecebido || 0),
totalAReceber: Number(item.totalAReceber || 0),
saldo: Number(item.saldo || 0),
};
}
async function buscarEvolucao(filtros = {}) {
const { campoData, whereSql, params } = montarWhereRelatorio(filtros);
const [rows] = await pool.query(
`
SELECT
DATE_FORMAT(${campoData}, '%Y-%m') AS periodo,
COALESCE(SUM(CASE WHEN cp.movimento = 'Entrada' THEN cp.valor ELSE 0 END), 0) AS entradas,
COALESCE(SUM(CASE WHEN cp.movimento IN ('Saida', 'Sangria') THEN cp.valor ELSE 0 END), 0) AS saidas,
COALESCE(SUM(CASE WHEN cp.movimento = 'Estorno' THEN cp.valor ELSE 0 END), 0) AS estornos,
COALESCE(SUM(
CASE
WHEN cp.movimento IN ('Entrada', 'Estorno') THEN cp.valor
WHEN cp.movimento IN ('Saida', 'Sangria') THEN -cp.valor
ELSE 0
END
), 0) AS saldo
FROM contasapagar cp
${whereSql}
GROUP BY DATE_FORMAT(${campoData}, '%Y-%m')
ORDER BY periodo ASC
`,
params
);
return rows.map((item) => ({
periodo: item.periodo,
entradas: Number(item.entradas || 0),
saidas: Number(item.saidas || 0),
estornos: Number(item.estornos || 0),
saldo: Number(item.saldo || 0),
}));
}
async function buscarPorCentroCusto(filtros = {}) {
const { whereSql, params } = montarWhereRelatorio(filtros);
const [rows] = await pool.query(
`
SELECT
COALESCE(cc.descricao, 'Sem centro de custo') AS descricao,
COUNT(*) AS quantidade,
COALESCE(SUM(cp.valor), 0) AS total
FROM contasapagar cp
LEFT JOIN centrodecustos cc ON cc.idcentrodecustos = cp.idcentrodecustos
${whereSql}
GROUP BY COALESCE(cc.descricao, 'Sem centro de custo')
ORDER BY total DESC
LIMIT 10
`,
params
);
return rows.map((item) => ({
descricao: item.descricao,
quantidade: Number(item.quantidade || 0),
total: Number(item.total || 0),
}));
}
async function buscarPorBanco(filtros = {}) {
const { whereSql, params } = montarWhereRelatorio(filtros);
const [rows] = await pool.query(
`
SELECT
COALESCE(b.descricao, 'Sem banco/carteira') AS descricao,
COUNT(*) AS quantidade,
COALESCE(SUM(cp.valor), 0) AS total
FROM contasapagar cp
LEFT JOIN bancos b ON b.idbancos = cp.idbancos
${whereSql}
GROUP BY COALESCE(b.descricao, 'Sem banco/carteira')
ORDER BY total DESC
LIMIT 10
`,
params
);
return rows.map((item) => ({
descricao: item.descricao,
quantidade: Number(item.quantidade || 0),
total: Number(item.total || 0),
}));
}
async function buscarPorStatus(filtros = {}) {
const { whereSql, params } = montarWhereRelatorio(filtros);
const [rows] = await pool.query(
`
SELECT
COALESCE(cp.status, 'Sem situação') AS descricao,
COUNT(*) AS quantidade,
COALESCE(SUM(cp.valor), 0) AS total
FROM contasapagar cp
${whereSql}
GROUP BY COALESCE(cp.status, 'Sem situação')
ORDER BY total DESC
`,
params
);
return rows.map((item) => ({
descricao: item.descricao,
quantidade: Number(item.quantidade || 0),
total: Number(item.total || 0),
}));
}
async function buscarPorMovimento(filtros = {}) {
const { whereSql, params } = montarWhereRelatorio(filtros);
const [rows] = await pool.query(
`
SELECT
COALESCE(cp.movimento, 'Sem movimento') AS descricao,
COUNT(*) AS quantidade,
COALESCE(SUM(cp.valor), 0) AS total
FROM contasapagar cp
${whereSql}
GROUP BY COALESCE(cp.movimento, 'Sem movimento')
ORDER BY total DESC
`,
params
);
return rows.map((item) => ({
descricao: item.descricao,
quantidade: Number(item.quantidade || 0),
total: Number(item.total || 0),
}));
}
async function buscarDashboardRelatorios(filtros = {}) {
const [
resumo,
evolucao,
porCentroCusto,
porBanco,
porStatus,
porMovimento,
] = await Promise.all([
buscarResumo(filtros),
buscarEvolucao(filtros),
buscarPorCentroCusto(filtros),
buscarPorBanco(filtros),
buscarPorStatus(filtros),
buscarPorMovimento(filtros),
]);
return {
resumo,
evolucao,
porCentroCusto,
porBanco,
porStatus,
porMovimento,
};
}
module.exports = {
buscarDashboardRelatorios,
};

View File

@ -0,0 +1,28 @@
require('dotenv').config();
const app = require('./app');
const pool = require('./database/mysql');
const port = Number(process.env.PORT || 3005);
async function testarBanco() {
const [rows] = await pool.query('SELECT 1 AS conectado');
return rows?.[0]?.conectado === 1;
}
async function start() {
try {
await testarBanco();
app.listen(port, () => {
console.log(`${process.env.APP_NAME || 'API'} rodando na porta ${port}`);
console.log(`🔎 Health: http://localhost:${port}/api/health`);
});
} catch (error) {
console.error('❌ Erro ao iniciar a API.');
console.error(error.message);
process.exit(1);
}
}
start();

24
financeiro-web/.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

73
financeiro-web/README.md Normal file
View File

@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

View File

@ -0,0 +1,22 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
globals: globals.browser,
},
},
])

13
financeiro-web/index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>financeiro-web</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

4297
financeiro-web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,42 @@
{
"name": "financeiro-web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@hookform/resolvers": "^5.4.0",
"@mui/icons-material": "^9.0.1",
"@mui/material": "^9.0.1",
"axios": "^1.16.1",
"dayjs": "^1.11.21",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-hook-form": "^7.76.1",
"react-router-dom": "^7.16.0",
"recharts": "^3.8.1",
"zod": "^4.4.3",
"zustand": "^5.0.14"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/node": "^24.12.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^10.3.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0",
"typescript": "~6.0.2",
"typescript-eslint": "^8.59.2",
"vite": "^8.0.12"
}
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

View File

@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

View File

@ -0,0 +1,6 @@
import { useRoutes } from 'react-router-dom';
import { routes } from './router';
export default function App() {
return useRoutes(routes);
}

View File

@ -0,0 +1,56 @@
import type { RouteObject } from 'react-router-dom';
import { Navigate } from 'react-router-dom';
import { AppLayout } from '../components/layout/AppLayout';
import { ProtectedRoute } from '../components/routing/ProtectedRoute';
import { LoginPage } from '../features/auth/pages/LoginPage';
import { EditarMovimentoPage } from '../features/movimentos/pages/EditarMovimentoPage';
import { MovimentosPage } from '../features/movimentos/pages/MovimentosPage';
import { NovoMovimentoPage } from '../features/movimentos/pages/NovoMovimentoPage';
import { RelatoriosPage } from '../features/relatorios/pages/RelatoriosPage';
import { DashboardPage } from '../pages/DashboardPage';
import { NotFoundPage } from '../pages/NotFoundPage';
export const routes: RouteObject[] = [
{
path: '/',
element: <Navigate to="/login" replace />,
},
{
path: '/login',
element: <LoginPage />,
},
{
element: <ProtectedRoute />,
children: [
{
element: <AppLayout />,
children: [
{
path: '/dashboard',
element: <DashboardPage />,
},
{
path: '/movimentos',
element: <MovimentosPage />,
},
{
path: '/movimentos/novo',
element: <NovoMovimentoPage />,
},
{
path: '/movimentos/:id/editar',
element: <EditarMovimentoPage />,
},
{
path: '/relatorios',
element: <RelatoriosPage />,
},
],
},
],
},
{
path: '*',
element: <NotFoundPage />,
},
];

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

View File

@ -0,0 +1,71 @@
import { useState } from 'react';
import { Box, Drawer } from '@mui/material';
import { Outlet } from 'react-router-dom';
import { Sidebar } from './Sidebar';
import { Topbar } from './Topbar';
const SIDEBAR_WIDTH = 280;
export function AppLayout() {
const [mobileOpen, setMobileOpen] = useState(false);
return (
<Box sx={{ minHeight: '100vh', backgroundColor: 'background.default' }}>
<Box
component="aside"
sx={{
width: SIDEBAR_WIDTH,
display: { xs: 'none', md: 'block' },
position: 'fixed',
inset: '0 auto 0 0',
zIndex: 1200,
}}
>
<Sidebar />
</Box>
<Drawer
open={mobileOpen}
onClose={() => setMobileOpen(false)}
ModalProps={{ keepMounted: true }}
sx={{
display: { xs: 'block', md: 'none' },
'& .MuiDrawer-paper': {
width: SIDEBAR_WIDTH,
border: 0,
},
}}
>
<Sidebar onNavigate={() => setMobileOpen(false)} />
</Drawer>
<Box
sx={{
marginLeft: { xs: 0, md: `${SIDEBAR_WIDTH}px` },
minHeight: '100vh',
display: 'flex',
flexDirection: 'column',
}}
>
<Topbar onMenuClick={() => setMobileOpen(true)} />
<Box
component="main"
sx={{
flex: 1,
width: '100%',
maxWidth: 1280,
margin: '0 auto',
padding: {
xs: 2,
sm: 2.5,
md: 4,
},
}}
>
<Outlet />
</Box>
</Box>
</Box>
);
}

View File

@ -0,0 +1,130 @@
import {
Box,
Divider,
List,
ListItemButton,
ListItemIcon,
ListItemText,
Typography,
} from '@mui/material';
import DashboardIcon from '@mui/icons-material/Dashboard';
import SwapHorizIcon from '@mui/icons-material/SwapHoriz';
import AssessmentIcon from '@mui/icons-material/Assessment';
import AccountBalanceWalletIcon from '@mui/icons-material/AccountBalanceWallet';
import SettingsIcon from '@mui/icons-material/Settings';
import { NavLink } from 'react-router-dom';
type SidebarProps = {
onNavigate?: () => void;
};
const menuItems = [
{
label: 'Dashboard',
path: '/dashboard',
icon: <DashboardIcon />,
},
{
label: 'Movimentos',
path: '/movimentos',
icon: <SwapHorizIcon />,
},
{
label: 'Carteiras',
path: '/carteiras',
icon: <AccountBalanceWalletIcon />,
},
{
label: 'Relatórios',
path: '/relatorios',
icon: <AssessmentIcon />,
},
{
label: 'Configurações',
path: '/configuracoes',
icon: <SettingsIcon />,
},
];
export function Sidebar({ onNavigate }: SidebarProps) {
return (
<Box
sx={{
width: 280,
height: '100%',
display: 'flex',
flexDirection: 'column',
background: 'linear-gradient(180deg, #0F172A 0%, #111827 100%)',
color: '#fff',
}}
>
<Box sx={{ padding: 3 }}>
<Typography variant="h6" fontWeight={900}>
Zendion
</Typography>
<Typography variant="body2" sx={{ color: 'rgba(255,255,255,0.65)' }}>
Financeiro
</Typography>
</Box>
<Divider sx={{ borderColor: 'rgba(255,255,255,0.08)' }} />
<List sx={{ padding: 2, flex: 1 }}>
{menuItems.map((item) => (
<ListItemButton
key={item.path}
component={NavLink}
to={item.path}
onClick={onNavigate}
sx={{
borderRadius: 2,
marginBottom: 0.75,
color: 'rgba(255,255,255,0.72)',
'& .MuiListItemIcon-root': {
color: 'rgba(255,255,255,0.72)',
minWidth: 40,
},
'&.active': {
backgroundColor: 'rgba(59,130,246,0.22)',
color: '#fff',
'& .MuiListItemIcon-root': {
color: '#60A5FA',
},
},
'&:hover': {
backgroundColor: 'rgba(255,255,255,0.08)',
},
}}
>
<ListItemIcon>{item.icon}</ListItemIcon>
<ListItemText
primary={item.label}
primaryTypographyProps={{
fontWeight: 700,
}}
/>
</ListItemButton>
))}
</List>
<Box sx={{ padding: 2 }}>
<Box
sx={{
padding: 2,
borderRadius: 3,
backgroundColor: 'rgba(255,255,255,0.06)',
border: '1px solid rgba(255,255,255,0.08)',
}}
>
<Typography variant="body2" fontWeight={800}>
MVP ativo
</Typography>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.62)' }}>
Cadastro e edição de movimentos funcionando.
</Typography>
</Box>
</Box>
</Box>
);
}

View File

@ -0,0 +1,90 @@
import {
AppBar,
Avatar,
Box,
IconButton,
Stack,
Toolbar,
Typography,
} from '@mui/material';
import MenuIcon from '@mui/icons-material/Menu';
import LogoutIcon from '@mui/icons-material/Logout';
import { useNavigate } from 'react-router-dom';
import { useAuthStore } from '../../features/auth/store/authStore';
type TopbarProps = {
onMenuClick: () => void;
};
export function Topbar({ onMenuClick }: TopbarProps) {
const navigate = useNavigate();
const user = useAuthStore((state) => state.user);
const logout = useAuthStore((state) => state.logout);
function handleLogout() {
logout();
navigate('/login');
}
return (
<AppBar
position="sticky"
elevation={0}
sx={{
backgroundColor: '#FFFFFF',
color: '#111827',
borderBottom: '1px solid',
borderColor: 'divider',
}}
>
<Toolbar sx={{ minHeight: 72 }}>
<IconButton
onClick={onMenuClick}
edge="start"
sx={{
display: { xs: 'inline-flex', md: 'none' },
marginRight: 1,
}}
>
<MenuIcon />
</IconButton>
<Box sx={{ flex: 1 }}>
<Typography variant="body2" color="text.secondary">
Painel financeiro
</Typography>
<Typography variant="h6" fontWeight={900}>
Controle de movimentos
</Typography>
</Box>
<Stack direction="row" alignItems="center" spacing={1.5}>
<Avatar
sx={{
width: 38,
height: 38,
bgcolor: 'primary.main',
fontWeight: 800,
}}
>
{user?.nome?.charAt(0)?.toUpperCase() || 'U'}
</Avatar>
<Box sx={{ display: { xs: 'none', sm: 'block' } }}>
<Typography variant="body2" fontWeight={800}>
{user?.nome || 'Usuário'}
</Typography>
<Typography variant="caption" color="text.secondary">
Logado
</Typography>
</Box>
<IconButton onClick={handleLogout} title="Sair">
<LogoutIcon />
</IconButton>
</Stack>
</Toolbar>
</AppBar>
);
}

View File

@ -0,0 +1,13 @@
import { Navigate, Outlet, useLocation } from 'react-router-dom';
import { useAuthStore } from '../../features/auth/store/authStore';
export function ProtectedRoute() {
const location = useLocation();
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
if (!isAuthenticated) {
return <Navigate to="/login" replace state={{ from: location }} />;
}
return <Outlet />;
}

View File

@ -0,0 +1,281 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Alert,
Box,
Button,
Card,
CardContent,
Chip,
CircularProgress,
Divider,
Stack,
TextField,
Typography,
} from '@mui/material';
import AccountBalanceWalletIcon from '@mui/icons-material/AccountBalanceWallet';
import LockOutlinedIcon from '@mui/icons-material/LockOutlined';
import TrendingUpIcon from '@mui/icons-material/TrendingUp';
import ShieldOutlinedIcon from '@mui/icons-material/ShieldOutlined';
import { loginRequest } from '../services/authService';
import { useAuthStore } from '../store/authStore';
export function LoginPage() {
const navigate = useNavigate();
const setAuth = useAuthStore((state) => state.setAuth);
const [usuario, setUsuario] = useState('');
const [senha, setSenha] = useState('');
const [loading, setLoading] = useState(false);
const [erro, setErro] = useState('');
async function handleLogin(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!usuario.trim() || !senha.trim()) {
setErro('Informe usuário e senha.');
return;
}
try {
setLoading(true);
setErro('');
const result = await loginRequest({
usuario: usuario.trim(),
senha,
});
setAuth(result.user, result.token);
navigate('/dashboard');
} catch (error: any) {
const message =
error?.response?.data?.message ||
'Não foi possível realizar o login.';
setErro(message);
} finally {
setLoading(false);
}
}
return (
<Box
minHeight="100vh"
display="grid"
gridTemplateColumns={{
xs: '1fr',
md: '1.1fr 0.9fr',
}}
sx={{
background:
'radial-gradient(circle at top left, rgba(37,99,235,0.18), transparent 34%), linear-gradient(135deg, #F8FAFC 0%, #EEF2F7 48%, #E5E7EB 100%)',
}}
>
<Box
sx={{
display: { xs: 'none', md: 'flex' },
alignItems: 'center',
justifyContent: 'center',
padding: 6,
background:
'linear-gradient(160deg, #0F172A 0%, #111827 45%, #1E3A8A 100%)',
color: '#fff',
position: 'relative',
overflow: 'hidden',
}}
>
<Box
sx={{
position: 'absolute',
width: 360,
height: 360,
borderRadius: '50%',
background: 'rgba(96,165,250,0.18)',
top: -120,
right: -100,
filter: 'blur(4px)',
}}
/>
<Box
sx={{
position: 'absolute',
width: 260,
height: 260,
borderRadius: '50%',
background: 'rgba(34,197,94,0.10)',
bottom: -80,
left: -70,
filter: 'blur(4px)',
}}
/>
<Box maxWidth={560} position="relative">
<Stack direction="row" spacing={1.5} alignItems="center" marginBottom={5}>
<Box
sx={{
width: 52,
height: 52,
borderRadius: 3,
display: 'grid',
placeItems: 'center',
backgroundColor: 'rgba(255,255,255,0.10)',
border: '1px solid rgba(255,255,255,0.14)',
}}
>
<AccountBalanceWalletIcon />
</Box>
<Box>
<Typography variant="h5" fontWeight={900}>
Zendion
</Typography>
<Typography sx={{ color: 'rgba(255,255,255,0.68)' }}>
Financeiro
</Typography>
</Box>
</Stack>
<Chip
label="Controle financeiro web"
sx={{
color: '#BFDBFE',
borderColor: 'rgba(191,219,254,0.28)',
backgroundColor: 'rgba(37,99,235,0.16)',
marginBottom: 2,
fontWeight: 700,
}}
variant="outlined"
/>
<Typography variant="h3" fontWeight={950} lineHeight={1.08} marginBottom={2}>
Movimentos, carteiras e saldos em um painel limpo.
</Typography>
<Typography
variant="h6"
sx={{
color: 'rgba(255,255,255,0.72)',
fontWeight: 400,
maxWidth: 520,
marginBottom: 5,
}}
>
Registre entradas, saídas, sangrias e estornos direto do celular ou desktop, com segurança e praticidade.
</Typography>
<Stack spacing={2}>
<Stack direction="row" spacing={2} alignItems="center">
<ShieldOutlinedIcon sx={{ color: '#93C5FD' }} />
<Typography sx={{ color: 'rgba(255,255,255,0.78)' }}>
Acesso protegido por autenticação e token.
</Typography>
</Stack>
<Stack direction="row" spacing={2} alignItems="center">
<TrendingUpIcon sx={{ color: '#86EFAC' }} />
<Typography sx={{ color: 'rgba(255,255,255,0.78)' }}>
Base pronta para relatórios, dashboards e controle por carteiras.
</Typography>
</Stack>
</Stack>
</Box>
</Box>
<Box
display="flex"
alignItems="center"
justifyContent="center"
padding={{ xs: 2, sm: 3, md: 6 }}
>
<Card
sx={{
width: '100%',
maxWidth: 440,
boxShadow: '0 22px 70px rgba(15,23,42,0.12)',
}}
>
<CardContent sx={{ padding: { xs: 3, sm: 4 } }}>
<Stack spacing={1} alignItems="center" textAlign="center" marginBottom={3}>
<Box
sx={{
width: 58,
height: 58,
borderRadius: 4,
display: 'grid',
placeItems: 'center',
backgroundColor: 'rgba(21,101,192,0.10)',
color: 'primary.main',
marginBottom: 1,
}}
>
<LockOutlinedIcon />
</Box>
<Typography variant="h4" fontWeight={950}>
Entrar
</Typography>
<Typography color="text.secondary">
Acesse o Zendion Financeiro para continuar.
</Typography>
</Stack>
{erro && (
<Alert severity="error" sx={{ marginBottom: 2 }}>
{erro}
</Alert>
)}
<Box
component="form"
onSubmit={handleLogin}
display="flex"
flexDirection="column"
gap={2}
>
<TextField
label="Usuário"
value={usuario}
onChange={(event) => setUsuario(event.target.value)}
fullWidth
autoFocus
/>
<TextField
label="Senha"
type="password"
value={senha}
onChange={(event) => setSenha(event.target.value)}
fullWidth
/>
<Button
type="submit"
variant="contained"
size="large"
fullWidth
disabled={loading}
startIcon={loading ? <CircularProgress size={18} color="inherit" /> : null}
sx={{
minHeight: 50,
marginTop: 1,
boxShadow: 3,
}}
>
{loading ? 'Entrando...' : 'Entrar no sistema'}
</Button>
</Box>
<Divider sx={{ marginY: 3 }} />
<Typography variant="body2" color="text.secondary" textAlign="center">
Sistema financeiro privado da Zendion INC.
</Typography>
</CardContent>
</Card>
</Box>
</Box>
);
}

View File

@ -0,0 +1,19 @@
import { api } from '../../../services/api';
import type { AuthUser } from '../store/authStore';
type LoginRequest = {
usuario: string;
senha: string;
};
type LoginResponse = {
ok: boolean;
message: string;
user: AuthUser;
token: string;
};
export async function loginRequest(data: LoginRequest): Promise<LoginResponse> {
const response = await api.post<LoginResponse>('/auth/login', data);
return response.data;
}

View File

@ -0,0 +1,91 @@
import { create } from 'zustand';
export type AuthUser = {
id: number;
nome: string;
anotacoes?: string;
};
type AuthState = {
user: AuthUser | null;
token: string | null;
isAuthenticated: boolean;
setAuth: (user: AuthUser, token: string) => void;
logout: () => void;
loadAuthFromStorage: () => void;
};
const TOKEN_KEY = '@zendion-financeiro:token';
const USER_KEY = '@zendion-financeiro:user';
function getInitialAuth() {
const token = localStorage.getItem(TOKEN_KEY);
const userRaw = localStorage.getItem(USER_KEY);
if (!token || !userRaw) {
return {
user: null,
token: null,
isAuthenticated: false,
};
}
try {
const user = JSON.parse(userRaw) as AuthUser;
return {
user,
token,
isAuthenticated: true,
};
} catch {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(USER_KEY);
return {
user: null,
token: null,
isAuthenticated: false,
};
}
}
const initialAuth = getInitialAuth();
export const useAuthStore = create<AuthState>((set) => ({
user: initialAuth.user,
token: initialAuth.token,
isAuthenticated: initialAuth.isAuthenticated,
setAuth: (user, token) => {
localStorage.setItem(TOKEN_KEY, token);
localStorage.setItem(USER_KEY, JSON.stringify(user));
set({
user,
token,
isAuthenticated: true,
});
},
logout: () => {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(USER_KEY);
set({
user: null,
token: null,
isAuthenticated: false,
});
},
loadAuthFromStorage: () => {
const auth = getInitialAuth();
set({
user: auth.user,
token: auth.token,
isAuthenticated: auth.isAuthenticated,
});
},
}));

View File

@ -0,0 +1,717 @@
import { useEffect, useMemo, useState } from 'react';
import type { FormEvent, ReactNode } from 'react';
import {
Alert,
Box,
Button,
Card,
CardContent,
Chip,
CircularProgress,
Divider,
MenuItem,
Paper,
Stack,
TextField,
Typography,
} from '@mui/material';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import SaveIcon from '@mui/icons-material/Save';
import SwapHorizIcon from '@mui/icons-material/SwapHoriz';
import { useNavigate } from 'react-router-dom';
import {
listarBancos,
listarCentrosCusto,
listarClientes,
} from '../../referencias/services/referenciasService';
import type {
Banco,
CentroCusto,
Cliente,
} from '../../referencias/types/referenciasTypes';
import {
atualizarMovimento,
criarMovimento,
} from '../services/movimentosService';
import type {
CriarMovimentoRequest,
Movimento,
MovimentoStatus,
MovimentoTipo,
} from '../types/movimentoTypes';
type MovimentoFormProps = {
mode: 'create' | 'edit';
initialData?: Movimento | null;
};
type FormSectionProps = {
title: string;
description?: string;
children: ReactNode;
};
function hojeISO() {
return new Date().toISOString().slice(0, 10);
}
function toNumberOrNull(value: string): number | null {
if (!value) return null;
return Number(value);
}
function toDateInputValue(value: string | null | undefined) {
if (!value) return '';
return String(value).slice(0, 10);
}
function formatarValorResumo(value: string) {
const numero = Number(value || 0);
return new Intl.NumberFormat('pt-BR', {
style: 'currency',
currency: 'BRL',
}).format(numero);
}
function FormSection({ title, description, children }: FormSectionProps) {
return (
<Paper
elevation={0}
sx={{
padding: { xs: 2.5, md: 3 },
borderRadius: 4,
border: '1px solid',
borderColor: 'divider',
backgroundColor: '#FFFFFF',
}}
>
<Box marginBottom={3}>
<Typography variant="h6" fontWeight={900}>
{title}
</Typography>
{description && (
<Typography variant="body2" color="text.secondary" marginTop={0.25}>
{description}
</Typography>
)}
</Box>
<Box
display="grid"
gridTemplateColumns={{
xs: '1fr',
md: '1fr 1fr',
}}
columnGap={{ xs: 2, md: 2.5 }}
rowGap={{ xs: 3, md: 3.25 }}
>
{children}
</Box>
</Paper>
);
}
const fieldSx = {
'& .MuiOutlinedInput-root': {
borderRadius: 2.5,
backgroundColor: '#FFFFFF',
minHeight: 48,
},
'& .MuiInputLabel-root': {
backgroundColor: '#FFFFFF',
paddingX: 0.5,
},
};
export function MovimentoForm({ mode, initialData }: MovimentoFormProps) {
const navigate = useNavigate();
const isEdit = mode === 'edit';
const [bancos, setBancos] = useState<Banco[]>([]);
const [centrosCusto, setCentrosCusto] = useState<CentroCusto[]>([]);
const [clientes, setClientes] = useState<Cliente[]>([]);
const [loadingRefs, setLoadingRefs] = useState(true);
const [saving, setSaving] = useState(false);
const [erro, setErro] = useState('');
const [sucesso, setSucesso] = useState('');
const [movimento, setMovimento] = useState<MovimentoTipo>('Saida');
const [status, setStatus] = useState<MovimentoStatus>('Pago');
const [descricao, setDescricao] = useState('');
const [valor, setValor] = useState('');
const [parcela, setParcela] = useState('1');
const [parcelas, setParcelas] = useState('1');
const [dataEntrada, setDataEntrada] = useState(hojeISO());
const [dataVencimento, setDataVencimento] = useState(hojeISO());
const [dataBaixa, setDataBaixa] = useState(hojeISO());
const [idCentroCusto, setIdCentroCusto] = useState('');
const [idBanco, setIdBanco] = useState('');
const [referenciaTipo, setReferenciaTipo] = useState<'nenhum' | 'cliente' | 'banco'>('nenhum');
const [idCliente, setIdCliente] = useState('');
const [idBancoReferencia, setIdBancoReferencia] = useState('');
const [observacao, setObservacao] = useState('');
const titulo = isEdit ? 'Editar movimento' : 'Novo movimento';
const subtitulo = isEdit
? 'Atualize os dados do lançamento selecionado.'
: 'Cadastre entradas, saídas, sangrias e estornos direto no financeiro.';
const statusDisponiveis = useMemo<MovimentoStatus[]>(() => {
if (movimento === 'Entrada') return ['Recebido', 'A receber'];
if (movimento === 'Saida') return ['Pago', 'A pagar'];
if (movimento === 'Sangria') return ['Pago'];
return ['Pago', 'Recebido'];
}, [movimento]);
useEffect(() => {
if (!initialData) return;
setMovimento(initialData.movimento);
setStatus(initialData.status);
setDescricao(initialData.descricao || '');
setValor(String(initialData.valor || ''));
setParcela(String(initialData.parcela || 1));
setParcelas(String(initialData.parcelas || 1));
setDataEntrada(toDateInputValue(initialData.dataentrada) || hojeISO());
setDataVencimento(toDateInputValue(initialData.datavencimento));
setDataBaixa(toDateInputValue(initialData.databaixa));
setIdCentroCusto(initialData.idcentrodecustos ? String(initialData.idcentrodecustos) : '');
setIdBanco(initialData.idbancos ? String(initialData.idbancos) : '');
setIdCliente(initialData.idclientes ? String(initialData.idclientes) : '');
setIdBancoReferencia(initialData.idbancos_p ? String(initialData.idbancos_p) : '');
setObservacao(initialData.observacao || '');
if (initialData.idclientes) {
setReferenciaTipo('cliente');
} else if (initialData.idbancos_p) {
setReferenciaTipo('banco');
} else {
setReferenciaTipo('nenhum');
}
}, [initialData]);
useEffect(() => {
if (!statusDisponiveis.includes(status)) {
setStatus(statusDisponiveis[0]);
}
if (movimento !== 'Sangria' && referenciaTipo === 'banco') {
setReferenciaTipo('nenhum');
setIdBancoReferencia('');
}
}, [movimento, status, statusDisponiveis, referenciaTipo]);
useEffect(() => {
async function carregarReferencias() {
try {
setLoadingRefs(true);
setErro('');
const [bancosData, centrosData, clientesData] = await Promise.all([
listarBancos(),
listarCentrosCusto(),
listarClientes(),
]);
setBancos(bancosData);
setCentrosCusto(centrosData);
setClientes(clientesData);
} catch (error: any) {
const message =
error?.response?.data?.message ||
'Não foi possível carregar os dados de referência.';
setErro(message);
} finally {
setLoadingRefs(false);
}
}
carregarReferencias();
}, []);
function limparFormulario() {
setDescricao('');
setValor('');
setParcela('1');
setParcelas('1');
setDataEntrada(hojeISO());
setDataVencimento(hojeISO());
setDataBaixa(hojeISO());
setIdCentroCusto('');
setIdBanco('');
setReferenciaTipo('nenhum');
setIdCliente('');
setIdBancoReferencia('');
setObservacao('');
}
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!descricao.trim()) {
setErro('Informe a descrição.');
return;
}
if (!valor || Number(valor) <= 0) {
setErro('Informe um valor válido.');
return;
}
if (referenciaTipo === 'cliente' && !idCliente) {
setErro('Selecione o cliente de referência.');
return;
}
if (referenciaTipo === 'banco' && !idBancoReferencia) {
setErro('Selecione o banco de referência.');
return;
}
try {
setSaving(true);
setErro('');
setSucesso('');
const payload: CriarMovimentoRequest = {
movimento,
descricao: descricao.trim(),
valor: Number(valor),
parcela: Number(parcela || 1),
parcelas: Number(parcelas || 1),
status,
dataentrada: dataEntrada,
datavencimento: dataVencimento || null,
databaixa: status === 'Pago' || status === 'Recebido' ? dataBaixa || null : null,
idcentrodecustos: toNumberOrNull(idCentroCusto),
idbancos: toNumberOrNull(idBanco),
idusuarios_baixa: null,
idclientes: referenciaTipo === 'cliente' ? toNumberOrNull(idCliente) : null,
idveiculosdetalhes: null,
idbancos_p: referenciaTipo === 'banco' ? toNumberOrNull(idBancoReferencia) : null,
observacao: observacao.trim() || null,
};
if (isEdit) {
if (!initialData?.idcontasapagar) {
setErro('Movimento inválido para edição.');
return;
}
await atualizarMovimento(initialData.idcontasapagar, payload);
setSucesso('Movimento atualizado com sucesso.');
} else {
await criarMovimento(payload);
setSucesso('Movimento cadastrado com sucesso.');
limparFormulario();
}
} catch (error: any) {
const message =
error?.response?.data?.message ||
'Não foi possível salvar o movimento.';
setErro(message);
} finally {
setSaving(false);
}
}
return (
<Box>
<Stack
direction={{ xs: 'column', md: 'row' }}
justifyContent="space-between"
alignItems={{ xs: 'stretch', md: 'center' }}
spacing={2}
marginBottom={{ xs: 3, md: 4 }}
>
<Box>
<Chip
icon={<SwapHorizIcon />}
label={isEdit ? 'Edição de movimento' : 'Cadastro de movimento'}
color="primary"
variant="outlined"
sx={{ marginBottom: 1.25, fontWeight: 700 }}
/>
<Typography variant="h4" fontWeight={950}>
{titulo}
</Typography>
<Typography color="text.secondary" sx={{ marginTop: 0.5 }}>
{subtitulo}
</Typography>
</Box>
<Button
variant="outlined"
startIcon={<ArrowBackIcon />}
onClick={() => navigate('/movimentos')}
sx={{
minHeight: 48,
borderRadius: 3,
px: 2.5,
}}
>
Voltar para lista
</Button>
</Stack>
{erro && (
<Alert severity="error" sx={{ marginBottom: 2.5 }}>
{erro}
</Alert>
)}
{sucesso && (
<Alert severity="success" sx={{ marginBottom: 2.5 }}>
{sucesso}
</Alert>
)}
<Stack
direction={{ xs: 'column', xl: 'row' }}
spacing={{ xs: 2.5, md: 3 }}
alignItems="flex-start"
>
<Card sx={{ width: '100%', flex: 1 }}>
<CardContent sx={{ padding: { xs: 2, md: 3 } }}>
{loadingRefs ? (
<Box display="flex" alignItems="center" gap={2}>
<CircularProgress size={22} />
<Typography>Carregando dados...</Typography>
</Box>
) : (
<Box
component="form"
onSubmit={handleSubmit}
display="flex"
flexDirection="column"
gap={{ xs: 2.5, md: 3 }}
>
<FormSection
title="Dados principais"
description="Defina o tipo, a situação, a descrição e o valor do movimento."
>
<TextField
select
label="Movimento"
value={movimento}
onChange={(event) => setMovimento(event.target.value as MovimentoTipo)}
fullWidth
sx={fieldSx}
>
<MenuItem value="Entrada">Entrada</MenuItem>
<MenuItem value="Saida">Saída</MenuItem>
<MenuItem value="Sangria">Sangria</MenuItem>
<MenuItem value="Estorno">Estorno</MenuItem>
</TextField>
<TextField
select
label="Situação"
value={status}
onChange={(event) => setStatus(event.target.value as MovimentoStatus)}
fullWidth
sx={fieldSx}
>
{statusDisponiveis.map((item) => (
<MenuItem key={item} value={item}>
{item}
</MenuItem>
))}
</TextField>
<TextField
label="Descrição"
value={descricao}
onChange={(event) => setDescricao(event.target.value)}
fullWidth
sx={fieldSx}
/>
<TextField
label="Valor"
type="number"
value={valor}
onChange={(event) => setValor(event.target.value)}
inputProps={{
step: '0.01',
min: '0',
}}
fullWidth
sx={fieldSx}
/>
</FormSection>
<FormSection
title="Parcelas e datas"
description="Controle vencimento, baixa e parcelamento."
>
<TextField
label="Parcela"
type="number"
value={parcela}
onChange={(event) => setParcela(event.target.value)}
inputProps={{ min: '1' }}
fullWidth
sx={fieldSx}
/>
<TextField
label="Total de parcelas"
type="number"
value={parcelas}
onChange={(event) => setParcelas(event.target.value)}
inputProps={{ min: '1' }}
fullWidth
sx={fieldSx}
/>
<TextField
label="Data de entrada"
type="date"
value={dataEntrada}
onChange={(event) => setDataEntrada(event.target.value)}
InputLabelProps={{ shrink: true }}
fullWidth
sx={fieldSx}
/>
<TextField
label="Data de vencimento"
type="date"
value={dataVencimento}
onChange={(event) => setDataVencimento(event.target.value)}
InputLabelProps={{ shrink: true }}
fullWidth
sx={fieldSx}
/>
<TextField
label="Data de baixa"
type="date"
value={dataBaixa}
onChange={(event) => setDataBaixa(event.target.value)}
InputLabelProps={{ shrink: true }}
disabled={status === 'A pagar' || status === 'A receber'}
fullWidth
sx={fieldSx}
/>
</FormSection>
<FormSection
title="Classificação"
description="Associe centro de custo, banco, cliente ou banco de referência."
>
<TextField
select
label="Centro de custo"
value={idCentroCusto}
onChange={(event) => setIdCentroCusto(event.target.value)}
fullWidth
sx={fieldSx}
>
<MenuItem value="">Nenhum</MenuItem>
{centrosCusto.map((centro) => (
<MenuItem key={centro.id} value={String(centro.id)}>
{centro.descricao}
</MenuItem>
))}
</TextField>
<TextField
select
label="Banco/Carteira"
value={idBanco}
onChange={(event) => setIdBanco(event.target.value)}
fullWidth
sx={fieldSx}
>
<MenuItem value="">Nenhum</MenuItem>
{bancos.map((banco) => (
<MenuItem key={banco.id} value={String(banco.id)}>
{banco.descricao}
</MenuItem>
))}
</TextField>
<TextField
select
label="Referência"
value={referenciaTipo}
onChange={(event) =>
setReferenciaTipo(event.target.value as 'nenhum' | 'cliente' | 'banco')
}
fullWidth
sx={fieldSx}
>
<MenuItem value="nenhum">Nenhuma</MenuItem>
<MenuItem value="cliente">Cliente</MenuItem>
{movimento === 'Sangria' && (
<MenuItem value="banco">Banco</MenuItem>
)}
</TextField>
{referenciaTipo === 'cliente' && (
<TextField
select
label="Cliente"
value={idCliente}
onChange={(event) => setIdCliente(event.target.value)}
fullWidth
sx={fieldSx}
>
<MenuItem value="">Selecione</MenuItem>
{clientes.map((cliente) => (
<MenuItem key={cliente.id} value={String(cliente.id)}>
{cliente.nome}
</MenuItem>
))}
</TextField>
)}
{referenciaTipo === 'banco' && (
<TextField
select
label="Banco de referência"
value={idBancoReferencia}
onChange={(event) => setIdBancoReferencia(event.target.value)}
fullWidth
sx={fieldSx}
>
<MenuItem value="">Selecione</MenuItem>
{bancos.map((banco) => (
<MenuItem key={banco.id} value={String(banco.id)}>
{banco.descricao}
</MenuItem>
))}
</TextField>
)}
<TextField
label="Observação"
value={observacao}
onChange={(event) => setObservacao(event.target.value)}
multiline
minRows={3}
fullWidth
sx={{
...fieldSx,
gridColumn: { xs: 'auto', md: '1 / -1' },
}}
/>
</FormSection>
<Stack
direction={{ xs: 'column-reverse', sm: 'row' }}
justifyContent="flex-end"
spacing={1.5}
sx={{
paddingTop: 1,
}}
>
<Button
variant="outlined"
onClick={() => navigate('/movimentos')}
disabled={saving}
sx={{ minHeight: 46 }}
>
Cancelar
</Button>
<Button
type="submit"
variant="contained"
disabled={saving}
startIcon={
saving
? <CircularProgress size={18} color="inherit" />
: <SaveIcon />
}
sx={{
minHeight: 46,
px: 3,
boxShadow: 3,
}}
>
{saving
? 'Salvando...'
: isEdit
? 'Atualizar movimento'
: 'Salvar movimento'}
</Button>
</Stack>
</Box>
)}
</CardContent>
</Card>
<Paper
elevation={0}
sx={{
width: { xs: '100%', xl: 340 },
padding: 3,
borderRadius: 4,
border: '1px solid',
borderColor: 'divider',
position: { xl: 'sticky' },
top: { xl: 24 },
background:
'linear-gradient(180deg, #FFFFFF 0%, #F8FAFC 100%)',
}}
>
<Typography variant="h6" fontWeight={900} gutterBottom>
Resumo
</Typography>
<Typography variant="body2" color="text.secondary" marginBottom={2.5}>
Prévia rápida do lançamento antes de salvar.
</Typography>
<Divider sx={{ marginBottom: 2.5 }} />
<Stack spacing={2}>
<Box>
<Typography variant="caption" color="text.secondary">
Movimento
</Typography>
<Typography fontWeight={800}>{movimento}</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Situação
</Typography>
<Typography fontWeight={800}>{status}</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Valor
</Typography>
<Typography variant="h4" fontWeight={950}>
{formatarValorResumo(valor)}
</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Descrição
</Typography>
<Typography fontWeight={700}>
{descricao || 'Sem descrição'}
</Typography>
</Box>
</Stack>
</Paper>
</Stack>
</Box>
);
}

View File

@ -0,0 +1,62 @@
import { useEffect, useState } from 'react';
import { Alert, Box, CircularProgress, Typography } from '@mui/material';
import { useParams } from 'react-router-dom';
import { MovimentoForm } from '../components/MovimentoForm';
import { buscarMovimentoPorId } from '../services/movimentosService';
import type { Movimento } from '../types/movimentoTypes';
export function EditarMovimentoPage() {
const { id } = useParams();
const [movimento, setMovimento] = useState<Movimento | null>(null);
const [loading, setLoading] = useState(true);
const [erro, setErro] = useState('');
useEffect(() => {
async function carregarMovimento() {
try {
setLoading(true);
setErro('');
const movimentoId = Number(id);
if (!movimentoId) {
setErro('ID do movimento inválido.');
return;
}
const data = await buscarMovimentoPorId(movimentoId);
setMovimento(data);
} catch (error: any) {
const message =
error?.response?.data?.message ||
'Não foi possível carregar o movimento.';
setErro(message);
} finally {
setLoading(false);
}
}
carregarMovimento();
}, [id]);
if (loading) {
return (
<Box padding={3} display="flex" alignItems="center" gap={2}>
<CircularProgress size={22} />
<Typography>Carregando movimento...</Typography>
</Box>
);
}
if (erro) {
return (
<Box padding={3}>
<Alert severity="error">{erro}</Alert>
</Box>
);
}
return <MovimentoForm mode="edit" initialData={movimento} />;
}

View File

@ -0,0 +1,882 @@
import { useEffect, useMemo, useState } from 'react';
import {
Alert,
Box,
Button,
Card,
CardContent,
Chip,
CircularProgress,
Divider,
IconButton,
MenuItem,
Pagination,
Paper,
Stack,
TextField,
Tooltip,
Typography,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
useMediaQuery,
useTheme,
} from '@mui/material';
import AddIcon from '@mui/icons-material/Add';
import EditIcon from '@mui/icons-material/Edit';
import FilterAltIcon from '@mui/icons-material/FilterAlt';
import RestartAltIcon from '@mui/icons-material/RestartAlt';
import SearchIcon from '@mui/icons-material/Search';
import SwapHorizIcon from '@mui/icons-material/SwapHoriz';
import { Link as RouterLink } from 'react-router-dom';
import {
listarMovimentos,
type DataCampo,
type ListarMovimentosParams,
} from '../services/movimentosService';
import type {
Movimento,
MovimentoStatus,
MovimentoTipo,
MovimentosPagination,
MovimentosSummary,
} from '../types/movimentoTypes';
import {
listarBancos,
listarCentrosCusto,
listarClientes,
} from '../../referencias/services/referenciasService';
import type {
Banco,
CentroCusto,
Cliente,
} from '../../referencias/types/referenciasTypes';
const LIMITE_PADRAO = 20;
function inicioMesAtual() {
const hoje = new Date();
return new Date(hoje.getFullYear(), hoje.getMonth(), 1).toISOString().slice(0, 10);
}
function fimMesAtual() {
const hoje = new Date();
return new Date(hoje.getFullYear(), hoje.getMonth() + 1, 0).toISOString().slice(0, 10);
}
function formatarValor(valor: number) {
return new Intl.NumberFormat('pt-BR', {
style: 'currency',
currency: 'BRL',
}).format(Number(valor || 0));
}
function formatarData(data: string | null) {
if (!data) return '-';
return new Date(`${String(data).slice(0, 10)}T00:00:00`).toLocaleDateString('pt-BR');
}
function campoDataLabel(campo: DataCampo) {
const labels: Record<DataCampo, string> = {
dataentrada: 'Entrada',
datavencimento: 'Vencimento',
databaixa: 'Baixa',
insert_date: 'Cadastro',
update_date: 'Atualização',
};
return labels[campo];
}
function movimentoChipColor(movimento: string) {
if (movimento === 'Entrada') return 'success';
if (movimento === 'Saida') return 'error';
if (movimento === 'Sangria') return 'warning';
return 'default';
}
function statusChipColor(status: string) {
if (status === 'Pago' || status === 'Recebido') return 'success';
if (status === 'A pagar' || status === 'A receber') return 'warning';
return 'default';
}
function getDataPrincipal(item: Movimento, campo: DataCampo) {
if (campo === 'dataentrada') return item.dataentrada;
if (campo === 'datavencimento') return item.datavencimento;
if (campo === 'databaixa') return item.databaixa;
if (campo === 'insert_date') return item.insert_date;
if (campo === 'update_date') return item.update_date;
return item.datavencimento;
}
function getReferencia(item: Movimento) {
if (item.cliente_nome) return item.cliente_nome;
if (item.banco_referencia_descricao) return item.banco_referencia_descricao;
return '-';
}
export function MovimentosPage() {
const [movimentos, setMovimentos] = useState<Movimento[]>([]);
const [pagination, setPagination] = useState<MovimentosPagination>({
total: 0,
limite: LIMITE_PADRAO,
offset: 0,
page: 1,
totalPages: 1,
});
const [summary, setSummary] = useState<MovimentosSummary>({
quantidade: 0,
totalEntradas: 0,
totalSaidas: 0,
totalSangrias: 0,
totalEstornos: 0,
saldo: 0,
});
const [bancos, setBancos] = useState<Banco[]>([]);
const [centrosCusto, setCentrosCusto] = useState<CentroCusto[]>([]);
const [clientes, setClientes] = useState<Cliente[]>([]);
const [loading, setLoading] = useState(true);
const [loadingRefs, setLoadingRefs] = useState(true);
const [erro, setErro] = useState('');
const [page, setPage] = useState(1);
const [dataCampo, setDataCampo] = useState<DataCampo>('datavencimento');
const [dataInicio, setDataInicio] = useState(inicioMesAtual());
const [dataFim, setDataFim] = useState(fimMesAtual());
const [busca, setBusca] = useState('');
const [movimento, setMovimento] = useState<MovimentoTipo | ''>('');
const [status, setStatus] = useState<MovimentoStatus | ''>('');
const [idBanco, setIdBanco] = useState<number | ''>('');
const [idCentroCusto, setIdCentroCusto] = useState<number | ''>('');
const [idCliente, setIdCliente] = useState<number | ''>('');
const [referenciaTipo, setReferenciaTipo] = useState('');
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const filtrosAtivos = useMemo(() => {
let count = 0;
if (busca.trim()) count += 1;
if (movimento) count += 1;
if (status) count += 1;
if (idBanco) count += 1;
if (idCentroCusto) count += 1;
if (idCliente) count += 1;
if (referenciaTipo) count += 1;
if (dataInicio || dataFim) count += 1;
return count;
}, [
busca,
movimento,
status,
idBanco,
idCentroCusto,
idCliente,
referenciaTipo,
dataInicio,
dataFim,
]);
async function carregarReferencias() {
try {
setLoadingRefs(true);
const [bancosData, centrosData, clientesData] = await Promise.all([
listarBancos(),
listarCentrosCusto(),
listarClientes(),
]);
setBancos(bancosData);
setCentrosCusto(centrosData);
setClientes(clientesData);
} finally {
setLoadingRefs(false);
}
}
async function carregarMovimentos(pageToLoad = page) {
try {
setLoading(true);
setErro('');
const params: ListarMovimentosParams = {
limite: LIMITE_PADRAO,
page: pageToLoad,
dataCampo,
dataInicio,
dataFim,
busca: busca.trim() || undefined,
movimento: movimento || undefined,
status: status || undefined,
idbancos: idBanco || undefined,
idcentrodecustos: idCentroCusto || undefined,
idclientes: idCliente || undefined,
referenciaTipo: referenciaTipo || undefined,
orderBy: dataCampo,
orderDirection: 'DESC',
};
const response = await listarMovimentos(params);
setMovimentos(response.data);
setPagination(response.pagination);
setSummary(response.summary);
setPage(response.pagination.page);
} catch (error: any) {
const message =
error?.response?.data?.message ||
'Não foi possível carregar os movimentos.';
setErro(message);
} finally {
setLoading(false);
}
}
function aplicarFiltros() {
setPage(1);
carregarMovimentos(1);
}
function limparFiltros() {
setDataCampo('datavencimento');
setDataInicio(inicioMesAtual());
setDataFim(fimMesAtual());
setBusca('');
setMovimento('');
setStatus('');
setIdBanco('');
setIdCentroCusto('');
setIdCliente('');
setReferenciaTipo('');
setTimeout(() => {
setPage(1);
carregarMovimentos(1);
}, 0);
}
useEffect(() => {
carregarReferencias();
carregarMovimentos(1);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<Box>
<Stack
direction={{ xs: 'column', md: 'row' }}
justifyContent="space-between"
alignItems={{ xs: 'stretch', md: 'center' }}
spacing={2}
marginBottom={{ xs: 3, md: 4 }}
>
<Box>
<Chip
icon={<SwapHorizIcon />}
label="Central de movimentos"
color="primary"
variant="outlined"
sx={{ marginBottom: 1.25, fontWeight: 700 }}
/>
<Typography variant="h4" fontWeight={950}>
Movimentos
</Typography>
<Typography color="text.secondary" sx={{ marginTop: 0.5 }}>
Consulte, filtre e edite os lançamentos financeiros.
</Typography>
</Box>
<Button
component={RouterLink}
to="/movimentos/novo"
variant="contained"
startIcon={<AddIcon />}
sx={{
minHeight: 48,
borderRadius: 3,
px: 2.5,
boxShadow: 3,
}}
>
Novo movimento
</Button>
</Stack>
{erro && (
<Alert severity="error" sx={{ marginBottom: 2.5 }}>
{erro}
</Alert>
)}
<Box
display="grid"
gridTemplateColumns={{
xs: '1fr',
md: 'repeat(4, 1fr)',
}}
gap={{ xs: 2, md: 2.5 }}
marginBottom={{ xs: 2.5, md: 3 }}
>
<Card>
<CardContent>
<Typography variant="body2" color="text.secondary">
Registros encontrados
</Typography>
<Typography variant="h5" fontWeight={950}>
{summary.quantidade}
</Typography>
</CardContent>
</Card>
<Card>
<CardContent>
<Typography variant="body2" color="text.secondary">
Entradas
</Typography>
<Typography variant="h5" fontWeight={950} color="success.main">
{formatarValor(summary.totalEntradas)}
</Typography>
</CardContent>
</Card>
<Card>
<CardContent>
<Typography variant="body2" color="text.secondary">
Saídas
</Typography>
<Typography variant="h5" fontWeight={950} color="error.main">
{formatarValor(summary.totalSaidas + summary.totalSangrias)}
</Typography>
</CardContent>
</Card>
<Card>
<CardContent>
<Typography variant="body2" color="text.secondary">
Saldo filtrado
</Typography>
<Typography
variant="h5"
fontWeight={950}
color={summary.saldo >= 0 ? 'success.main' : 'error.main'}
>
{formatarValor(summary.saldo)}
</Typography>
</CardContent>
</Card>
</Box>
<Card sx={{ marginBottom: { xs: 2.5, md: 3 } }}>
<CardContent sx={{ padding: { xs: 2, md: 3 } }}>
<Stack
direction="row"
spacing={1}
alignItems="center"
marginBottom={2.5}
>
<FilterAltIcon color="primary" />
<Typography variant="h6" fontWeight={900}>
Filtros
</Typography>
<Chip
label={`${filtrosAtivos} ativo(s)`}
size="small"
variant="outlined"
/>
</Stack>
<Box
display="grid"
gridTemplateColumns={{
xs: '1fr',
md: 'repeat(4, 1fr)',
}}
gap={{ xs: 2, md: 2.25 }}
>
<TextField
label="Buscar"
value={busca}
onChange={(event) => setBusca(event.target.value)}
placeholder="Descrição, observação, banco, cliente..."
fullWidth
/>
<TextField
select
label="Campo de data"
value={dataCampo}
onChange={(event) => setDataCampo(event.target.value as DataCampo)}
fullWidth
>
<MenuItem value="datavencimento">Data de vencimento</MenuItem>
<MenuItem value="dataentrada">Data de entrada</MenuItem>
<MenuItem value="databaixa">Data de baixa</MenuItem>
<MenuItem value="insert_date">Data de cadastro</MenuItem>
<MenuItem value="update_date">Data de atualização</MenuItem>
</TextField>
<TextField
label="Data inicial"
type="date"
value={dataInicio}
onChange={(event) => setDataInicio(event.target.value)}
InputLabelProps={{ shrink: true }}
fullWidth
/>
<TextField
label="Data final"
type="date"
value={dataFim}
onChange={(event) => setDataFim(event.target.value)}
InputLabelProps={{ shrink: true }}
fullWidth
/>
<TextField
select
label="Movimento"
value={movimento}
onChange={(event) => setMovimento(event.target.value as MovimentoTipo | '')}
fullWidth
>
<MenuItem value="">Todos</MenuItem>
<MenuItem value="Entrada">Entrada</MenuItem>
<MenuItem value="Saida">Saída</MenuItem>
<MenuItem value="Sangria">Sangria</MenuItem>
<MenuItem value="Estorno">Estorno</MenuItem>
</TextField>
<TextField
select
label="Situação"
value={status}
onChange={(event) => setStatus(event.target.value as MovimentoStatus | '')}
fullWidth
>
<MenuItem value="">Todas</MenuItem>
<MenuItem value="Pago">Pago</MenuItem>
<MenuItem value="A pagar">A pagar</MenuItem>
<MenuItem value="Recebido">Recebido</MenuItem>
<MenuItem value="A receber">A receber</MenuItem>
</TextField>
<TextField
select
label="Banco/Carteira"
value={idBanco}
onChange={(event) =>
setIdBanco(event.target.value === '' ? '' : Number(event.target.value))
}
disabled={loadingRefs}
fullWidth
>
<MenuItem value="">Todos</MenuItem>
{bancos.map((banco) => (
<MenuItem key={banco.id} value={banco.id}>
{banco.descricao}
</MenuItem>
))}
</TextField>
<TextField
select
label="Centro de custo"
value={idCentroCusto}
onChange={(event) =>
setIdCentroCusto(event.target.value === '' ? '' : Number(event.target.value))
}
disabled={loadingRefs}
fullWidth
>
<MenuItem value="">Todos</MenuItem>
{centrosCusto.map((centro) => (
<MenuItem key={centro.id} value={centro.id}>
{centro.descricao}
</MenuItem>
))}
</TextField>
<TextField
select
label="Cliente"
value={idCliente}
onChange={(event) =>
setIdCliente(event.target.value === '' ? '' : Number(event.target.value))
}
disabled={loadingRefs}
fullWidth
>
<MenuItem value="">Todos</MenuItem>
{clientes.map((cliente) => (
<MenuItem key={cliente.id} value={cliente.id}>
{cliente.nome}
</MenuItem>
))}
</TextField>
<TextField
select
label="Referência"
value={referenciaTipo}
onChange={(event) => setReferenciaTipo(event.target.value)}
fullWidth
>
<MenuItem value="">Todas</MenuItem>
<MenuItem value="cliente">Com cliente</MenuItem>
<MenuItem value="banco">Com banco referência</MenuItem>
<MenuItem value="sem_referencia">Sem referência</MenuItem>
</TextField>
</Box>
<Stack
direction={{ xs: 'column', sm: 'row' }}
spacing={1.5}
justifyContent="flex-end"
marginTop={2.5}
>
<Button
variant="outlined"
startIcon={<RestartAltIcon />}
onClick={limparFiltros}
>
Limpar
</Button>
<Button
variant="contained"
startIcon={<SearchIcon />}
onClick={aplicarFiltros}
disabled={loading}
>
Aplicar filtros
</Button>
</Stack>
</CardContent>
</Card>
<Card>
<CardContent sx={{ padding: 0 }}>
{loading ? (
<Box padding={3} display="flex" alignItems="center" gap={2}>
<CircularProgress size={22} />
<Typography>Carregando movimentos...</Typography>
</Box>
) : movimentos.length === 0 ? (
<Box padding={3}>
<Typography fontWeight={800}>
Nenhum movimento encontrado.
</Typography>
<Typography color="text.secondary" marginTop={0.5}>
Ajuste os filtros ou cadastre um novo movimento.
</Typography>
</Box>
) : isMobile ? (
<Stack divider={<Divider />}>
{movimentos.map((item) => {
const dataPrincipal = getDataPrincipal(item, dataCampo);
return (
<Box
key={item.idcontasapagar}
sx={{
padding: 2,
'&:hover': {
backgroundColor: 'rgba(15,23,42,0.02)',
},
}}
>
<Stack spacing={1.25}>
<Stack
direction="row"
justifyContent="space-between"
alignItems="flex-start"
spacing={2}
>
<Box sx={{ minWidth: 0 }}>
<Typography fontWeight={900}>
{item.descricao}
</Typography>
<Typography variant="body2" color="text.secondary">
{campoDataLabel(dataCampo)}: {formatarData(dataPrincipal)}
</Typography>
</Box>
<Typography
fontWeight={950}
color={
item.movimento === 'Entrada' || item.movimento === 'Estorno'
? 'success.main'
: 'text.primary'
}
whiteSpace="nowrap"
>
{formatarValor(item.valor)}
</Typography>
</Stack>
<Stack direction="row" flexWrap="wrap" spacing={1} useFlexGap>
<Chip
label={item.movimento}
size="small"
color={movimentoChipColor(item.movimento) as any}
/>
<Chip
label={item.status}
size="small"
variant="outlined"
color={statusChipColor(item.status) as any}
/>
<Chip
label={`${item.parcela}/${item.parcelas}`}
size="small"
variant="outlined"
/>
</Stack>
<Typography variant="body2" color="text.secondary">
Banco: <strong>{item.banco_descricao || '-'}</strong>
</Typography>
<Typography variant="body2" color="text.secondary">
Centro: <strong>{item.centro_custo_descricao || '-'}</strong>
</Typography>
<Typography variant="body2" color="text.secondary">
Referência: <strong>{getReferencia(item)}</strong>
</Typography>
<Box display="flex" justifyContent="flex-end">
<Button
component={RouterLink}
to={`/movimentos/${item.idcontasapagar}/editar`}
variant="outlined"
size="small"
startIcon={<EditIcon />}
>
Editar
</Button>
</Box>
</Stack>
</Box>
);
})}
</Stack>
) : (
<TableContainer
sx={{
overflowX: 'auto',
}}
>
<Table
size="small"
sx={{
minWidth: 1320,
'& th': {
whiteSpace: 'nowrap',
fontWeight: 900,
backgroundColor: 'rgba(15,23,42,0.04)',
},
'& td': {
whiteSpace: 'nowrap',
},
}}
>
<TableHead>
<TableRow>
<TableCell sx={{ minWidth: 260 }}>Descrição</TableCell>
<TableCell sx={{ minWidth: 120 }}>Entrada</TableCell>
<TableCell sx={{ minWidth: 130 }}>Vencimento</TableCell>
<TableCell sx={{ minWidth: 120 }}>Baixa</TableCell>
<TableCell sx={{ minWidth: 120 }} align="right">Valor</TableCell>
<TableCell sx={{ minWidth: 90 }} align="center">Parcela</TableCell>
<TableCell sx={{ minWidth: 95 }} align="center">Parcelas</TableCell>
<TableCell sx={{ minWidth: 130 }}>Situação</TableCell>
<TableCell sx={{ minWidth: 150 }}>Movimento</TableCell>
<TableCell sx={{ minWidth: 180 }}>Centro de custo</TableCell>
<TableCell sx={{ minWidth: 180 }}>Banco</TableCell>
<TableCell sx={{ minWidth: 180 }}>Cliente</TableCell>
<TableCell sx={{ minWidth: 180 }}>Banco ref.</TableCell>
<TableCell sx={{ minWidth: 220 }}>Observação</TableCell>
<TableCell sx={{ minWidth: 90 }} align="center">Ações</TableCell>
</TableRow>
</TableHead>
<TableBody>
{movimentos.map((item) => {
const dataPrincipal = getDataPrincipal(item, dataCampo);
return (
<TableRow
key={item.idcontasapagar}
hover
sx={{
'&:last-child td': {
borderBottom: 0,
},
}}
>
<TableCell>
<Box sx={{ maxWidth: 260 }}>
<Typography fontWeight={800} noWrap title={item.descricao}>
{item.descricao}
</Typography>
</Box>
</TableCell>
<TableCell>
{formatarData(item.dataentrada)}
</TableCell>
<TableCell>
{formatarData(item.datavencimento)}
</TableCell>
<TableCell>
{formatarData(item.databaixa)}
</TableCell>
<TableCell align="right">
<Typography
fontWeight={950}
color={
item.movimento === 'Entrada' || item.movimento === 'Estorno'
? 'success.main'
: 'text.primary'
}
>
{formatarValor(item.valor)}
</Typography>
</TableCell>
<TableCell align="center">
{item.parcela}
</TableCell>
<TableCell align="center">
{item.parcelas}
</TableCell>
<TableCell>
<Chip
label={item.status}
size="small"
variant="outlined"
color={statusChipColor(item.status) as any}
/>
</TableCell>
<TableCell>
<Chip
label={item.movimento}
size="small"
color={movimentoChipColor(item.movimento) as any}
/>
</TableCell>
<TableCell>
<Typography variant="body2" noWrap title={item.centro_custo_descricao || '-'}>
{item.centro_custo_descricao || '-'}
</Typography>
</TableCell>
<TableCell>
<Typography variant="body2" noWrap title={item.banco_descricao || '-'}>
{item.banco_descricao || '-'}
</Typography>
</TableCell>
<TableCell>
<Typography variant="body2" noWrap title={item.cliente_nome || '-'}>
{item.cliente_nome || '-'}
</Typography>
</TableCell>
<TableCell>
<Typography variant="body2" noWrap title={item.banco_referencia_descricao || '-'}>
{item.banco_referencia_descricao || '-'}
</Typography>
</TableCell>
<TableCell>
<Box sx={{ maxWidth: 220 }}>
<Typography variant="body2" noWrap title={item.observacao || '-'}>
{item.observacao || '-'}
</Typography>
</Box>
</TableCell>
<TableCell align="center">
<Tooltip title="Editar movimento">
<IconButton
component={RouterLink}
to={`/movimentos/${item.idcontasapagar}/editar`}
color="primary"
size="small"
>
<EditIcon />
</IconButton>
</Tooltip>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
)}
</CardContent>
</Card>
{pagination.totalPages > 1 && (
<Stack alignItems="center" marginTop={3}>
<Pagination
page={pagination.page}
count={pagination.totalPages}
color="primary"
onChange={(_, novaPagina) => {
setPage(novaPagina);
carregarMovimentos(novaPagina);
}}
/>
</Stack>
)}
<Paper
elevation={0}
sx={{
marginTop: 2,
padding: 2,
border: '1px solid',
borderColor: 'divider',
borderRadius: 3,
backgroundColor: 'rgba(255,255,255,0.65)',
}}
>
<Typography variant="body2" color="text.secondary">
Exibindo página {pagination.page} de {pagination.totalPages}, total de{' '}
<strong>{pagination.total}</strong> registro(s).
</Typography>
</Paper>
</Box>
);
}

View File

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

View File

@ -0,0 +1,66 @@
import { api } from '../../../services/api';
import type {
ApiResponse,
AtualizarMovimentoRequest,
CriarMovimentoRequest,
Movimento,
MovimentosListResponse,
} from '../types/movimentoTypes';
export type DataCampo =
| 'dataentrada'
| 'datavencimento'
| 'databaixa'
| 'insert_date'
| 'update_date';
export type OrderDirection = 'ASC' | 'DESC';
export type ListarMovimentosParams = {
limite?: number;
offset?: number;
page?: number;
movimento?: string;
status?: string;
idbancos?: number | '';
idcentrodecustos?: number | '';
idclientes?: number | '';
idbancos_p?: number | '';
referenciaTipo?: string;
dataCampo?: DataCampo;
dataInicio?: string;
dataFim?: string;
busca?: string;
orderBy?: string;
orderDirection?: OrderDirection;
};
export async function listarMovimentos(
params?: ListarMovimentosParams
): Promise<MovimentosListResponse> {
const response = await api.get<MovimentosListResponse>('/movimentos', {
params,
});
return response.data;
}
export async function buscarMovimentoPorId(id: number): Promise<Movimento> {
const response = await api.get<ApiResponse<Movimento>>(`/movimentos/${id}`);
return response.data.data;
}
export async function criarMovimento(
data: CriarMovimentoRequest
): Promise<Movimento> {
const response = await api.post<ApiResponse<Movimento>>('/movimentos', data);
return response.data.data;
}
export async function atualizarMovimento(
id: number,
data: AtualizarMovimentoRequest
): Promise<Movimento> {
const response = await api.put<ApiResponse<Movimento>>(`/movimentos/${id}`, data);
return response.data.data;
}

View File

@ -0,0 +1,71 @@
export type MovimentoTipo = 'Entrada' | 'Saida' | 'Sangria' | 'Estorno';
export type MovimentoStatus = 'Pago' | 'A pagar' | 'Recebido' | 'A receber';
export type CriarMovimentoRequest = {
movimento: MovimentoTipo;
descricao: string;
dataentrada: string;
datavencimento: string | null;
databaixa: string | null;
valor: number;
parcela: number;
parcelas: number;
status: MovimentoStatus;
idcentrodecustos: number | null;
idbancos: number | null;
idusuarios_baixa: number | null;
idclientes: number | null;
idveiculosdetalhes: number | null;
idbancos_p: number | null;
observacao: string | null;
};
export type AtualizarMovimentoRequest = CriarMovimentoRequest;
export type Movimento = CriarMovimentoRequest & {
idcontasapagar: number;
idusuarios_cad: number | null;
insert_date: string | null;
update_date: string | null;
banco_descricao?: string | null;
centro_custo_descricao?: string | null;
cliente_nome?: string | null;
banco_referencia_descricao?: string | null;
};
export type MovimentosPagination = {
total: number;
limite: number;
offset: number;
page: number;
totalPages: number;
};
export type MovimentosSummary = {
quantidade: number;
totalEntradas: number;
totalSaidas: number;
totalSangrias: number;
totalEstornos: number;
saldo: number;
};
export type ApiResponse<T> = {
ok: boolean;
message?: string;
data: T;
};
export type ApiListResponse<T> = {
ok: boolean;
data: T[];
};
export type MovimentosListResponse = {
ok: boolean;
data: Movimento[];
pagination: MovimentosPagination;
summary: MovimentosSummary;
};

View File

@ -0,0 +1,22 @@
import { api } from '../../../services/api';
import type {
ApiListResponse,
Banco,
CentroCusto,
Cliente,
} from '../types/referenciasTypes';
export async function listarBancos(): Promise<Banco[]> {
const response = await api.get<ApiListResponse<Banco>>('/referencias/bancos');
return response.data.data;
}
export async function listarCentrosCusto(): Promise<CentroCusto[]> {
const response = await api.get<ApiListResponse<CentroCusto>>('/referencias/centros-custo');
return response.data.data;
}
export async function listarClientes(): Promise<Cliente[]> {
const response = await api.get<ApiListResponse<Cliente>>('/referencias/clientes');
return response.data.data;
}

View File

@ -0,0 +1,30 @@
export type Banco = {
id: number;
descricao: string;
saldo: number | null;
debito: number | null;
};
export type CentroCusto = {
id: number;
descricao: string;
limite: number | null;
simular: number | null;
investimento: number | null;
};
export type Cliente = {
id: number;
cpf_cnpj: string;
nome: string;
celular: string | null;
email: string | null;
cidade: string | null;
estado: string | null;
pessoafisica: string | null;
};
export type ApiListResponse<T> = {
ok: boolean;
data: T[];
};

View File

@ -0,0 +1,618 @@
import { useEffect, useState } from 'react';
import {
Alert,
Box,
Button,
Card,
CardContent,
Chip,
CircularProgress,
MenuItem,
Paper,
Stack,
TextField,
Typography,
} from '@mui/material';
import AssessmentIcon from '@mui/icons-material/Assessment';
import SearchIcon from '@mui/icons-material/Search';
import RestartAltIcon from '@mui/icons-material/RestartAlt';
import {
Bar,
BarChart,
CartesianGrid,
Cell,
Legend,
Pie,
PieChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
Line,
LineChart,
} from 'recharts';
import { buscarDashboardRelatorios } from '../services/relatoriosService';
import type {
DataCampoRelatorio,
RelatorioDashboardData,
} from '../types/relatoriosTypes';
import {
listarBancos,
listarCentrosCusto,
listarClientes,
} from '../../referencias/services/referenciasService';
import type {
Banco,
CentroCusto,
Cliente,
} from '../../referencias/types/referenciasTypes';
function inicioMesAtual() {
const hoje = new Date();
return new Date(hoje.getFullYear(), hoje.getMonth(), 1).toISOString().slice(0, 10);
}
function fimMesAtual() {
const hoje = new Date();
return new Date(hoje.getFullYear(), hoje.getMonth() + 1, 0).toISOString().slice(0, 10);
}
function formatarValor(valor: number) {
return new Intl.NumberFormat('pt-BR', {
style: 'currency',
currency: 'BRL',
}).format(Number(valor || 0));
}
function formatarPeriodo(periodo: string) {
if (!periodo || !periodo.includes('-')) return periodo;
const [ano, mes] = periodo.split('-');
return `${mes}/${ano}`;
}
const chartPalette = [
'#1565C0',
'#2E7D32',
'#EF4444',
'#F59E0B',
'#7C3AED',
'#0891B2',
'#DB2777',
'#475569',
];
function emptyRelatorio(): RelatorioDashboardData {
return {
resumo: {
quantidade: 0,
totalEntradas: 0,
totalSaidas: 0,
totalSangrias: 0,
totalEstornos: 0,
totalPago: 0,
totalAPagar: 0,
totalRecebido: 0,
totalAReceber: 0,
saldo: 0,
},
evolucao: [],
porCentroCusto: [],
porBanco: [],
porStatus: [],
porMovimento: [],
};
}
export function RelatoriosPage() {
const [data, setData] = useState<RelatorioDashboardData>(emptyRelatorio());
const [bancos, setBancos] = useState<Banco[]>([]);
const [centrosCusto, setCentrosCusto] = useState<CentroCusto[]>([]);
const [clientes, setClientes] = useState<Cliente[]>([]);
const [loading, setLoading] = useState(true);
const [loadingRefs, setLoadingRefs] = useState(true);
const [erro, setErro] = useState('');
const [dataCampo, setDataCampo] = useState<DataCampoRelatorio>('datavencimento');
const [dataInicio, setDataInicio] = useState(inicioMesAtual());
const [dataFim, setDataFim] = useState(fimMesAtual());
const [movimento, setMovimento] = useState('');
const [status, setStatus] = useState('');
const [idBanco, setIdBanco] = useState<number | ''>('');
const [idCentroCusto, setIdCentroCusto] = useState<number | ''>('');
const [idCliente, setIdCliente] = useState<number | ''>('');
async function carregarReferencias() {
try {
setLoadingRefs(true);
const [bancosData, centrosData, clientesData] = await Promise.all([
listarBancos(),
listarCentrosCusto(),
listarClientes(),
]);
setBancos(bancosData);
setCentrosCusto(centrosData);
setClientes(clientesData);
} finally {
setLoadingRefs(false);
}
}
async function carregarRelatorio() {
try {
setLoading(true);
setErro('');
const response = await buscarDashboardRelatorios({
dataCampo,
dataInicio,
dataFim,
movimento: movimento || undefined,
status: status || undefined,
idbancos: idBanco || undefined,
idcentrodecustos: idCentroCusto || undefined,
idclientes: idCliente || undefined,
});
setData(response.data);
} catch (error: any) {
const message =
error?.response?.data?.message ||
'Não foi possível carregar os relatórios.';
setErro(message);
} finally {
setLoading(false);
}
}
function limparFiltros() {
setDataCampo('datavencimento');
setDataInicio(inicioMesAtual());
setDataFim(fimMesAtual());
setMovimento('');
setStatus('');
setIdBanco('');
setIdCentroCusto('');
setIdCliente('');
setTimeout(() => {
carregarRelatorio();
}, 0);
}
useEffect(() => {
carregarReferencias();
carregarRelatorio();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const evolucaoFormatada = data.evolucao.map((item) => ({
...item,
periodoLabel: formatarPeriodo(item.periodo),
}));
const statusChart = data.porStatus.map((item) => ({
name: item.descricao,
value: item.total,
quantidade: item.quantidade,
}));
const movimentoChart = data.porMovimento.map((item) => ({
name: item.descricao,
value: item.total,
quantidade: item.quantidade,
}));
return (
<Box>
<Stack
direction={{ xs: 'column', md: 'row' }}
justifyContent="space-between"
alignItems={{ xs: 'stretch', md: 'center' }}
spacing={2}
marginBottom={{ xs: 3, md: 4 }}
>
<Box>
<Chip
icon={<AssessmentIcon />}
label="Painel de relatórios"
color="primary"
variant="outlined"
sx={{ marginBottom: 1.25, fontWeight: 700 }}
/>
<Typography variant="h4" fontWeight={950}>
Relatórios
</Typography>
<Typography color="text.secondary" sx={{ marginTop: 0.5 }}>
Acompanhe entradas, saídas, pendências, saldos e distribuição dos movimentos.
</Typography>
</Box>
</Stack>
{erro && (
<Alert severity="error" sx={{ marginBottom: 2.5 }}>
{erro}
</Alert>
)}
<Card sx={{ marginBottom: { xs: 2.5, md: 3 } }}>
<CardContent sx={{ padding: { xs: 2, md: 3 } }}>
<Typography variant="h6" fontWeight={900} marginBottom={2.5}>
Filtros do relatório
</Typography>
<Box
display="grid"
gridTemplateColumns={{
xs: '1fr',
md: 'repeat(4, 1fr)',
}}
gap={{ xs: 2, md: 2.25 }}
>
<TextField
select
label="Campo de data"
value={dataCampo}
onChange={(event) => setDataCampo(event.target.value as DataCampoRelatorio)}
fullWidth
>
<MenuItem value="datavencimento">Data de vencimento</MenuItem>
<MenuItem value="dataentrada">Data de entrada</MenuItem>
<MenuItem value="databaixa">Data de baixa</MenuItem>
<MenuItem value="insert_date">Data de cadastro</MenuItem>
<MenuItem value="update_date">Data de atualização</MenuItem>
</TextField>
<TextField
label="Data inicial"
type="date"
value={dataInicio}
onChange={(event) => setDataInicio(event.target.value)}
InputLabelProps={{ shrink: true }}
fullWidth
/>
<TextField
label="Data final"
type="date"
value={dataFim}
onChange={(event) => setDataFim(event.target.value)}
InputLabelProps={{ shrink: true }}
fullWidth
/>
<TextField
select
label="Movimento"
value={movimento}
onChange={(event) => setMovimento(event.target.value)}
fullWidth
>
<MenuItem value="">Todos</MenuItem>
<MenuItem value="Entrada">Entrada</MenuItem>
<MenuItem value="Saida">Saída</MenuItem>
<MenuItem value="Sangria">Sangria</MenuItem>
<MenuItem value="Estorno">Estorno</MenuItem>
</TextField>
<TextField
select
label="Situação"
value={status}
onChange={(event) => setStatus(event.target.value)}
fullWidth
>
<MenuItem value="">Todas</MenuItem>
<MenuItem value="Pago">Pago</MenuItem>
<MenuItem value="A pagar">A pagar</MenuItem>
<MenuItem value="Recebido">Recebido</MenuItem>
<MenuItem value="A receber">A receber</MenuItem>
</TextField>
<TextField
select
label="Banco/Carteira"
value={idBanco}
onChange={(event) =>
setIdBanco(event.target.value === '' ? '' : Number(event.target.value))
}
disabled={loadingRefs}
fullWidth
>
<MenuItem value="">Todos</MenuItem>
{bancos.map((banco) => (
<MenuItem key={banco.id} value={banco.id}>
{banco.descricao}
</MenuItem>
))}
</TextField>
<TextField
select
label="Centro de custo"
value={idCentroCusto}
onChange={(event) =>
setIdCentroCusto(event.target.value === '' ? '' : Number(event.target.value))
}
disabled={loadingRefs}
fullWidth
>
<MenuItem value="">Todos</MenuItem>
{centrosCusto.map((centro) => (
<MenuItem key={centro.id} value={centro.id}>
{centro.descricao}
</MenuItem>
))}
</TextField>
<TextField
select
label="Cliente"
value={idCliente}
onChange={(event) =>
setIdCliente(event.target.value === '' ? '' : Number(event.target.value))
}
disabled={loadingRefs}
fullWidth
>
<MenuItem value="">Todos</MenuItem>
{clientes.map((cliente) => (
<MenuItem key={cliente.id} value={cliente.id}>
{cliente.nome}
</MenuItem>
))}
</TextField>
</Box>
<Stack
direction={{ xs: 'column', sm: 'row' }}
spacing={1.5}
justifyContent="flex-end"
marginTop={2.5}
>
<Button
variant="outlined"
startIcon={<RestartAltIcon />}
onClick={limparFiltros}
>
Limpar
</Button>
<Button
variant="contained"
startIcon={<SearchIcon />}
onClick={carregarRelatorio}
disabled={loading}
>
Aplicar filtros
</Button>
</Stack>
</CardContent>
</Card>
{loading ? (
<Paper
elevation={0}
sx={{
padding: 3,
border: '1px solid',
borderColor: 'divider',
borderRadius: 3,
}}
>
<Stack direction="row" alignItems="center" spacing={2}>
<CircularProgress size={22} />
<Typography>Carregando relatórios...</Typography>
</Stack>
</Paper>
) : (
<>
<Box
display="grid"
gridTemplateColumns={{
xs: '1fr',
md: 'repeat(4, 1fr)',
}}
gap={{ xs: 2, md: 2.5 }}
marginBottom={{ xs: 2.5, md: 3 }}
>
<Card>
<CardContent>
<Typography variant="body2" color="text.secondary">
Entradas
</Typography>
<Typography variant="h5" fontWeight={950} color="success.main">
{formatarValor(data.resumo.totalEntradas)}
</Typography>
</CardContent>
</Card>
<Card>
<CardContent>
<Typography variant="body2" color="text.secondary">
Saídas + sangrias
</Typography>
<Typography variant="h5" fontWeight={950} color="error.main">
{formatarValor(data.resumo.totalSaidas + data.resumo.totalSangrias)}
</Typography>
</CardContent>
</Card>
<Card>
<CardContent>
<Typography variant="body2" color="text.secondary">
Saldo
</Typography>
<Typography
variant="h5"
fontWeight={950}
color={data.resumo.saldo >= 0 ? 'success.main' : 'error.main'}
>
{formatarValor(data.resumo.saldo)}
</Typography>
</CardContent>
</Card>
<Card>
<CardContent>
<Typography variant="body2" color="text.secondary">
Registros
</Typography>
<Typography variant="h5" fontWeight={950}>
{data.resumo.quantidade}
</Typography>
</CardContent>
</Card>
</Box>
<Box
display="grid"
gridTemplateColumns={{
xs: '1fr',
lg: '1.4fr 0.6fr',
}}
gap={{ xs: 2.5, md: 3 }}
marginBottom={{ xs: 2.5, md: 3 }}
>
<Card>
<CardContent sx={{ padding: 3 }}>
<Typography variant="h6" fontWeight={900} marginBottom={2}>
Evolução por período
</Typography>
<Box sx={{ width: '100%', minWidth: 0 }}>
<ResponsiveContainer width="100%" height={320}>
<LineChart data={evolucaoFormatada}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="periodoLabel" />
<YAxis tickFormatter={(value) => `R$ ${Number(value).toFixed(0)}`} />
<Tooltip formatter={(value) => formatarValor(Number(value))} />
<Legend />
<Line type="monotone" dataKey="entradas" name="Entradas" stroke="#2E7D32" strokeWidth={3} />
<Line type="monotone" dataKey="saidas" name="Saídas" stroke="#EF4444" strokeWidth={3} />
<Line type="monotone" dataKey="saldo" name="Saldo" stroke="#1565C0" strokeWidth={3} />
</LineChart>
</ResponsiveContainer>
</Box>
</CardContent>
</Card>
<Card>
<CardContent sx={{ padding: 3 }}>
<Typography variant="h6" fontWeight={900} marginBottom={2}>
Por situação
</Typography>
<Box sx={{ width: '100%', minWidth: 0 }}>
<ResponsiveContainer width="100%" height={320}>
<PieChart>
<Pie
data={statusChart}
dataKey="value"
nameKey="name"
outerRadius={105}
label={(item) => item.name}
>
{statusChart.map((_, index) => (
<Cell
key={`status-${index}`}
fill={chartPalette[index % chartPalette.length]}
/>
))}
</Pie>
<Tooltip formatter={(value) => formatarValor(Number(value))} />
</PieChart>
</ResponsiveContainer>
</Box>
</CardContent>
</Card>
</Box>
<Box
display="grid"
gridTemplateColumns={{
xs: '1fr',
lg: '1fr 1fr',
}}
gap={{ xs: 2.5, md: 3 }}
marginBottom={{ xs: 2.5, md: 3 }}
>
<Card>
<CardContent sx={{ padding: 3 }}>
<Typography variant="h6" fontWeight={900} marginBottom={2}>
Top centros de custo
</Typography>
<Box sx={{ width: '100%', minWidth: 0 }}>
<ResponsiveContainer width="100%" height={320}>
<BarChart data={data.porCentroCusto} layout="vertical">
<CartesianGrid strokeDasharray="3 3" />
<XAxis type="number" tickFormatter={(value) => `R$ ${Number(value).toFixed(0)}`} />
<YAxis type="category" dataKey="descricao" width={140} />
<Tooltip formatter={(value) => formatarValor(Number(value))} />
<Bar dataKey="total" name="Total" fill="#1565C0" />
</BarChart>
</ResponsiveContainer>
</Box>
</CardContent>
</Card>
<Card>
<CardContent sx={{ padding: 3 }}>
<Typography variant="h6" fontWeight={900} marginBottom={2}>
Top bancos/carteiras
</Typography>
<Box sx={{ width: '100%', minWidth: 0 }}>
<ResponsiveContainer width="100%" height={320}>
<BarChart data={data.porBanco} layout="vertical">
<CartesianGrid strokeDasharray="3 3" />
<XAxis type="number" tickFormatter={(value) => `R$ ${Number(value).toFixed(0)}`} />
<YAxis type="category" dataKey="descricao" width={140} />
<Tooltip formatter={(value) => formatarValor(Number(value))} />
<Bar dataKey="total" name="Total" fill="#2E7D32" />
</BarChart>
</ResponsiveContainer>
</Box>
</CardContent>
</Card>
</Box>
<Card>
<CardContent sx={{ padding: 3 }}>
<Typography variant="h6" fontWeight={900} marginBottom={2}>
Distribuição por movimento
</Typography>
<Box sx={{ width: '100%', minWidth: 0 }}>
<ResponsiveContainer width="100%" height={320}>
<BarChart data={movimentoChart}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" />
<YAxis tickFormatter={(value) => `R$ ${Number(value).toFixed(0)}`} />
<Tooltip formatter={(value) => formatarValor(Number(value))} />
<Bar dataKey="value" name="Total">
{movimentoChart.map((_, index) => (
<Cell
key={`movimento-${index}`}
fill={chartPalette[index % chartPalette.length]}
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</Box>
</CardContent>
</Card>
</>
)}
</Box>
);
}

View File

@ -0,0 +1,15 @@
import { api } from '../../../services/api';
import type {
RelatorioDashboardParams,
RelatorioDashboardResponse,
} from '../types/relatoriosTypes';
export async function buscarDashboardRelatorios(
params?: RelatorioDashboardParams
): Promise<RelatorioDashboardResponse> {
const response = await api.get<RelatorioDashboardResponse>('/relatorios/dashboard', {
params,
});
return response.data;
}

View File

@ -0,0 +1,58 @@
export type DataCampoRelatorio =
| 'dataentrada'
| 'datavencimento'
| 'databaixa'
| 'insert_date'
| 'update_date';
export type RelatorioResumo = {
quantidade: number;
totalEntradas: number;
totalSaidas: number;
totalSangrias: number;
totalEstornos: number;
totalPago: number;
totalAPagar: number;
totalRecebido: number;
totalAReceber: number;
saldo: number;
};
export type RelatorioEvolucaoItem = {
periodo: string;
entradas: number;
saidas: number;
estornos: number;
saldo: number;
};
export type RelatorioGrupoItem = {
descricao: string;
quantidade: number;
total: number;
};
export type RelatorioDashboardData = {
resumo: RelatorioResumo;
evolucao: RelatorioEvolucaoItem[];
porCentroCusto: RelatorioGrupoItem[];
porBanco: RelatorioGrupoItem[];
porStatus: RelatorioGrupoItem[];
porMovimento: RelatorioGrupoItem[];
};
export type RelatorioDashboardResponse = {
ok: boolean;
data: RelatorioDashboardData;
};
export type RelatorioDashboardParams = {
dataCampo?: DataCampoRelatorio;
dataInicio?: string;
dataFim?: string;
movimento?: string;
status?: string;
idbancos?: number | '';
idcentrodecustos?: number | '';
idclientes?: number | '';
};

View File

@ -0,0 +1,111 @@
:root {
--text: #6b6375;
--text-h: #08060d;
--bg: #fff;
--border: #e5e4e7;
--code-bg: #f4f3ec;
--accent: #aa3bff;
--accent-bg: rgba(170, 59, 255, 0.1);
--accent-border: rgba(170, 59, 255, 0.5);
--social-bg: rgba(244, 243, 236, 0.5);
--shadow:
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
--mono: ui-monospace, Consolas, monospace;
font: 18px/145% var(--sans);
letter-spacing: 0.18px;
color-scheme: light dark;
color: var(--text);
background: var(--bg);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
@media (max-width: 1024px) {
font-size: 16px;
}
}
@media (prefers-color-scheme: dark) {
:root {
--text: #9ca3af;
--text-h: #f3f4f6;
--bg: #16171d;
--border: #2e303a;
--code-bg: #1f2028;
--accent: #c084fc;
--accent-bg: rgba(192, 132, 252, 0.15);
--accent-border: rgba(192, 132, 252, 0.5);
--social-bg: rgba(47, 48, 58, 0.5);
--shadow:
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
}
#social .button-icon {
filter: invert(1) brightness(2);
}
}
#root {
width: 1126px;
max-width: 100%;
margin: 0 auto;
text-align: center;
border-inline: 1px solid var(--border);
min-height: 100svh;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
body {
margin: 0;
}
h1,
h2 {
font-family: var(--heading);
font-weight: 500;
color: var(--text-h);
}
h1 {
font-size: 56px;
letter-spacing: -1.68px;
margin: 32px 0;
@media (max-width: 1024px) {
font-size: 36px;
margin: 20px 0;
}
}
h2 {
font-size: 24px;
line-height: 118%;
letter-spacing: -0.24px;
margin: 0 0 8px;
@media (max-width: 1024px) {
font-size: 20px;
}
}
p {
margin: 0;
}
code,
.counter {
font-family: var(--mono);
display: inline-flex;
border-radius: 4px;
color: var(--text-h);
}
code {
font-size: 15px;
line-height: 135%;
padding: 4px 8px;
background: var(--code-bg);
}

View File

@ -0,0 +1,18 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { CssBaseline, ThemeProvider } from '@mui/material';
import { BrowserRouter } from 'react-router-dom';
import App from './app/App';
import { theme } from './theme/theme';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<ThemeProvider theme={theme}>
<CssBaseline />
<BrowserRouter>
<App />
</BrowserRouter>
</ThemeProvider>
</React.StrictMode>
);

View File

@ -0,0 +1,230 @@
import {
Box,
Button,
Card,
CardContent,
Chip,
Stack,
Typography,
} from '@mui/material';
import AddIcon from '@mui/icons-material/Add';
import SwapHorizIcon from '@mui/icons-material/SwapHoriz';
import AccountBalanceWalletIcon from '@mui/icons-material/AccountBalanceWallet';
import AssessmentIcon from '@mui/icons-material/Assessment';
import { Link as RouterLink } from 'react-router-dom';
export function DashboardPage() {
return (
<Box>
<Stack
direction={{ xs: 'column', md: 'row' }}
justifyContent="space-between"
alignItems={{ xs: 'stretch', md: 'center' }}
spacing={3}
marginBottom={{ xs: 3, md: 4 }}
>
<Box>
<Chip
label="MVP Financeiro"
color="primary"
variant="outlined"
sx={{ marginBottom: 1.5 }}
/>
<Typography variant="h4" fontWeight={900}>
Dashboard
</Typography>
<Typography color="text.secondary" sx={{ marginTop: 0.5 }}>
Seu centro de comando para entradas, saídas, sangrias e estornos.
</Typography>
</Box>
<Button
component={RouterLink}
to="/movimentos/novo"
variant="contained"
size="large"
startIcon={<AddIcon />}
sx={{
minHeight: 52,
px: 3,
borderRadius: 3,
boxShadow: 3,
}}
>
Novo movimento
</Button>
</Stack>
<Box
display="grid"
gridTemplateColumns={{
xs: '1fr',
md: 'repeat(3, 1fr)',
}}
gap={{ xs: 2, md: 3 }}
marginBottom={{ xs: 2.5, md: 3 }}
>
<Card sx={{ height: '100%' }}>
<CardContent sx={{ padding: 3 }}>
<Stack direction="row" spacing={2} alignItems="center">
<Box
sx={{
width: 52,
height: 52,
borderRadius: 3,
display: 'grid',
placeItems: 'center',
backgroundColor: 'rgba(21,101,192,0.10)',
color: 'primary.main',
flexShrink: 0,
}}
>
<SwapHorizIcon />
</Box>
<Box>
<Typography color="text.secondary" variant="body2">
Movimentos
</Typography>
<Typography variant="h5" fontWeight={900}>
Ativo
</Typography>
</Box>
</Stack>
</CardContent>
</Card>
<Card sx={{ height: '100%' }}>
<CardContent sx={{ padding: 3 }}>
<Stack direction="row" spacing={2} alignItems="center">
<Box
sx={{
width: 52,
height: 52,
borderRadius: 3,
display: 'grid',
placeItems: 'center',
backgroundColor: 'rgba(46,125,50,0.10)',
color: 'secondary.main',
flexShrink: 0,
}}
>
<AccountBalanceWalletIcon />
</Box>
<Box>
<Typography color="text.secondary" variant="body2">
Carteiras
</Typography>
<Typography variant="h5" fontWeight={900}>
Em breve
</Typography>
</Box>
</Stack>
</CardContent>
</Card>
<Card sx={{ height: '100%' }}>
<CardContent sx={{ padding: 3 }}>
<Stack direction="row" spacing={2} alignItems="center">
<Box
sx={{
width: 52,
height: 52,
borderRadius: 3,
display: 'grid',
placeItems: 'center',
backgroundColor: 'rgba(124,58,237,0.10)',
color: '#7C3AED',
flexShrink: 0,
}}
>
<AssessmentIcon />
</Box>
<Box>
<Typography color="text.secondary" variant="body2">
Relatórios
</Typography>
<Typography variant="h5" fontWeight={900}>
Em breve
</Typography>
</Box>
</Stack>
</CardContent>
</Card>
</Box>
<Box
display="grid"
gridTemplateColumns={{
xs: '1fr',
lg: '1.35fr 0.65fr',
}}
gap={{ xs: 2, md: 3 }}
>
<Card>
<CardContent sx={{ padding: 3 }}>
<Typography variant="h6" fontWeight={900} gutterBottom>
Acesso rápido
</Typography>
<Typography color="text.secondary" marginBottom={3}>
A operação principal está disponível. Cadastre ou consulte os movimentos financeiros.
</Typography>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1.5}>
<Button
component={RouterLink}
to="/movimentos/novo"
variant="contained"
startIcon={<AddIcon />}
sx={{
borderRadius: 2.5,
minHeight: 44,
}}
>
Cadastrar movimento
</Button>
<Button
component={RouterLink}
to="/movimentos"
variant="outlined"
startIcon={<SwapHorizIcon />}
sx={{
borderRadius: 2.5,
minHeight: 44,
}}
>
Ver movimentos
</Button>
</Stack>
</CardContent>
</Card>
<Card>
<CardContent sx={{ padding: 3 }}>
<Typography variant="h6" fontWeight={900} gutterBottom>
Próximos passos
</Typography>
<Stack spacing={1.25} sx={{ marginTop: 1.5 }}>
<Typography variant="body2" color="text.secondary">
Melhorar lista de movimentos
</Typography>
<Typography variant="body2" color="text.secondary">
Criar filtros por data, banco e status
</Typography>
<Typography variant="body2" color="text.secondary">
Adicionar relatórios e saldos
</Typography>
</Stack>
</CardContent>
</Card>
</Box>
</Box>
);
}

View File

@ -0,0 +1,16 @@
import { Box, Button, Typography } from '@mui/material';
import { Link as RouterLink } from 'react-router-dom';
export function NotFoundPage() {
return (
<Box padding={3}>
<Typography variant="h4" fontWeight={700} gutterBottom>
Página não encontrada
</Typography>
<Button component={RouterLink} to="/dashboard" variant="contained">
Voltar ao início
</Button>
</Box>
);
}

View File

@ -0,0 +1,15 @@
import axios from 'axios';
export const api = axios.create({
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:3005/api',
});
api.interceptors.request.use((config: { headers: { Authorization: string; }; }) => {
const token = localStorage.getItem('@zendion-financeiro:token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});

View File

@ -0,0 +1,45 @@
import { createTheme } from '@mui/material/styles';
export const theme = createTheme({
palette: {
mode: 'light',
primary: {
main: '#1565C0',
},
secondary: {
main: '#2E7D32',
},
background: {
default: '#F4F6F8',
paper: '#FFFFFF',
},
},
shape: {
borderRadius: 14,
},
typography: {
fontFamily: ['Inter', 'Roboto', 'Arial', 'sans-serif'].join(','),
button: {
textTransform: 'none',
fontWeight: 700,
},
},
components: {
MuiCard: {
styleOverrides: {
root: {
borderRadius: 18,
boxShadow: '0 10px 30px rgba(15, 23, 42, 0.06)',
border: '1px solid rgba(15, 23, 42, 0.06)',
},
},
},
MuiButton: {
styleOverrides: {
root: {
borderRadius: 12,
},
},
},
},
});

View File

@ -0,0 +1,25 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

View File

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"module": "esnext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}

View File

@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})