first commit
This commit is contained in:
commit
79070aecb8
|
|
@ -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
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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();
|
||||
|
|
@ -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?
|
||||
|
|
@ -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...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
])
|
||||
|
|
@ -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>
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -0,0 +1,6 @@
|
|||
import { useRoutes } from 'react-router-dom';
|
||||
import { routes } from './router';
|
||||
|
||||
export default function App() {
|
||||
return useRoutes(routes);
|
||||
}
|
||||
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 />;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import { MovimentoForm } from '../components/MovimentoForm';
|
||||
|
||||
export function NovoMovimentoPage() {
|
||||
return <MovimentoForm mode="create" />;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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[];
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 | '';
|
||||
};
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
@ -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 já 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
});
|
||||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -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"]
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
|
|
@ -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"]
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
})
|
||||
Loading…
Reference in New Issue