Full-Stack · React + Node.js + PostgreSQL

Controle de Veículos.

Gestão completa de frota corporativa — reservas com aprovação, entrega e devolução com controle de KM, troca de responsável auditada e quiosque com registro fotográfico.

React 18 GSAP 3 Node.js PostgreSQL JWT Auth Supabase Storage
localhost:5173/admin
Dashboard da Frota
🔔
A
⚠️ 1 veículo em atraso · 2 solicitações pendentes
8Frota ativa
2Pendentes
3Em uso
27Finalizadas
Veículos em circulação
Fiat Strada · ABC-1D23em uso · João
VW Saveiro · DEF-4G56devolução hoje
Fiat Mobi · GHI-7J89⚠️ em atraso
0Perfis de acesso
0Status de reserva
0Endpoints REST
0% Responsivo
0Tabelas no banco
O Projeto

O que é o Controle de Veículos?

Uma plataforma completa para gerenciar a frota corporativa — do pedido do colaborador à devolução da chave, com rastreabilidade total de quem está com cada veículo.

🔑

Problema Resolvido

O controle de chaves e veículos da empresa era feito na base da confiança: sem registro de quem retirou, quando devolveu ou quantos KM rodou. O sistema digitaliza todo o ciclo com auditoria completa.

⚙️

Como Funciona

O colaborador solicita um veículo informando finalidade e período. O admin aprova, confirma a entrega com KM inicial e, na devolução, registra o KM final — o sistema calcula a quilometragem rodada.

🔁

Responsabilidade Rastreável

Durante o uso, a chave pode mudar de mãos. A troca de responsável registra atomicamente quem passou, quem recebeu e o motivo — e o histórico mostra a cadeia completa de custódia do veículo.

📸

Quiosque com Foto

Um tablet no local de retirada, protegido por PIN, fotografa o motorista na retirada e na devolução. As fotos vão para storage privado com URLs assinadas — evidência visual de cada movimentação.

Funcionalidades

O que o sistema faz

Seis módulos integrados cobrindo todo o ciclo de vida de uma reserva de veículo.

📋

Reservas com Aprovação

Ciclo completo: pendente → aprovada → em uso → finalizada. Verificação de conflito de horário com buffer de 30 min entre usos para inspeção do veículo.

  • ✓ Detecção de conflito por período
  • ✓ KM inicial e final registrados
  • ✓ Atraso marcado automaticamente
🔁

Troca de Responsável

A chave pode ser repassada durante o uso — para outro colaborador ou motorista externo sem login. Cada troca gera um evento auditável com origem, destino e motivo.

  • ✓ Transação atômica no banco
  • ✓ Cadeia de custódia completa
  • ✓ Suporte a motorista externo
🔑

Liberação Direta

Para demandas imediatas, o admin entrega a chave na hora: a reserva nasce em uso, com KM inicial gravado, pulando a etapa de solicitação — mas mantendo todo o registro.

  • ✓ Reserva criada já em uso
  • ✓ Responsável com ou sem login
  • ✓ Distinguida nos relatórios
📸

Quiosque com PIN

Tablet no ponto de retirada, sem login — protegido por PIN com comparação em tempo constante. Selfie na retirada e na devolução, armazenada em bucket privado.

  • ✓ PIN timing-safe (anti timing attack)
  • ✓ Fotos no Supabase Storage
  • ✓ Auditoria com URLs assinadas
📊

Histórico & Relatórios

Histórico filtrado por veículo, colaborador, status e período. Relatórios com agregados por status, finalidade, veículo e colaborador — separando reservas de liberações diretas.

  • ✓ Filtros combináveis
  • ✓ Agregados em uma única query
  • ✓ KM rodado por veículo
🛡️

Gestão & Segurança

Perfil TI gerencia usuários, reseta senhas e bloqueia acessos com efeito imediato. Cada tentativa de login — sucesso ou falha — é registrada com IP e horário.

  • ✓ Logs de acesso com IP
  • ✓ Rate limit no login e quiosque
  • ✓ Helmet + CORS restritivo
Fluxo Operacional

Como funciona na prática

O ciclo completo — da solicitação à devolução da chave — com todos os atores envolvidos.

1
Colaborador

Solicita Veículo

Escolhe o veículo disponível, informa finalidade, destino e período de uso.

→
2
Admin

Analisa o Pedido

Aprova ou recusa a solicitação — podendo trocar o veículo e anexar observações.

→
3
Admin

Confirma Entrega

Entrega a chave e grava o KM inicial. A reserva passa para "em uso".

→
4
Responsável

Usa o Veículo

Pode transferir a responsabilidade a um colega — cada troca vira evento auditável.

→
5
Concluído

Devolução

Admin registra o KM final, o sistema calcula o KM rodado e atualiza o veículo.

Stack Técnica

Tecnologias utilizadas

Stack moderna escolhida para performance, DX e facilidade de deploy independente.

⚛️

Frontend

⚡
React 18 + Vite 5

SPA com roteamento via React Router v6 e rotas protegidas por perfil. HMR instantâneo e build otimizado.

🎬
GSAP 3

Transições de página e listas com stagger — componentes PageTransition e StaggerList reutilizáveis.

🌐
Axios

Cliente HTTP com interceptor que injeta o token JWT em todas as requisições autenticadas.

📱
PWA

Service Worker registrado no boot — o sistema instala como app no celular dos colaboradores.

🔔
React Hot Toast

Feedback visual imediato para ações de sucesso, erro e validação.

🖥️

Backend

🟢
Node.js + Express

API REST com roteamento modular por entidade: auth, veículos, reservas, usuários, motoristas e checkout.

🔐
JWT + bcryptjs

Autenticação stateless com expiração de 12h. Senhas com hash bcrypt (salt 10) e role guard por perfil.

🐘
PostgreSQL (pg)

Driver nativo com pool de conexões, prepared statements e transações atômicas para operações críticas.

🛡️
Helmet + Rate Limit

Headers de segurança, CORS restritivo em produção e rate limit no login (anti brute-force) e no quiosque.

📎
Multer + Supabase Storage

Upload de fotos em memória (limite 8MB) direto para bucket privado, com paths sanitizados.

☁️

Deploy

▲
Vercel (Frontend)

Deploy automático via Git. SPA routing via vercel.json com rewrite para index.html.

🚀
Render (Backend)

Web service Node.js com auto-deploy via render.yaml. Health check em /api/health.

🌿
Supabase Cloud

PostgreSQL gerenciado via connection pooling + Storage privado para as fotos do quiosque.

Perfis de Acesso

Colaborador, Admin e TI

Três níveis de acesso com permissões distintas, controlados por middleware JWT no backend.

C

Colaborador

Solicita e usa veículos

  • Solicitar reserva de veículo
  • Ver disponibilidade da frota
  • Acompanhar suas reservas
  • Cancelar reserva pendente/aprovada
  • Transferir responsabilidade em uso
  • Adicionar observações à reserva
  • Sem aprovação de solicitações
  • Sem gestão da frota
  • Sem acesso a relatórios
A

Admin

Gestor da frota

  • Aprovar ou recusar solicitações
  • Confirmar entrega com KM inicial
  • Confirmar devolução com KM final
  • Liberação direta (chave na hora)
  • CRUD de veículos e motoristas
  • Criar colaboradores
  • Histórico e relatórios agregados
  • Sem criação de admins ou TI
  • Sem acesso aos logs de acesso
TI

TI

Administrador do sistema

  • Tudo que o Admin faz
  • CRUD completo de usuários
  • Criar admins e contas TI
  • Resetar senhas
  • Bloquear usuários (efeito imediato)
  • Logs de login com IP e horário
  • Auditoria das fotos do quiosque
  • URLs assinadas temporárias
  • Não pode bloquear a si mesmo
Arquitetura

Como as peças se encaixam

Arquitetura desacoplada com frontend SPA e backend REST totalmente separados e implantados de forma independente.

Client Layer
⚛️React AppVite · React Router · PWA
🎬GSAP 3Animações UI
📟QuiosqueTablet · PIN · Câmera
↓ HTTPS + JWT Bearer Token / PIN Header
API Layer — Express
🔐Auth MiddlewareJWT Verify · Role Guard
🛣️30+ Endpoints REST/api/* módulos
🛡️HardeningHelmet · Rate Limit · CORS
↓ pg Pool (SSL) / Supabase SDK
Data Layer — PostgreSQL + Supabase
🐘PostgreSQL7 tabelas · transações
📦StorageFotos do quiosque
🕓Job de AtrasosMarca atrasos a cada 5 min
Destaques de Código

Partes interessantes do código

Trechos reais do projeto com contexto e explicação técnica detalhada.

backend/src/controllers/reservasController.js

A troca de responsável precisa atualizar a reserva e gravar o evento de auditoria — ou tudo, ou nada. A transação garante que nunca exista uma troca sem rastro. Antes de gravar, o código resolve o nome de quem era o responsável (colaborador atual, externo ou o dono original) para o histórico ficar legível.

// Responsavel atual, admin ou TI podem transferir
const podeTransferir =
  req.user.role === 'admin' ||
  req.user.role === 'ti' ||
  ehResponsavelAtual(r, req.user.id);
if (!podeTransferir)
  return res.status(403).json({ error: 'Sem permissao para transferir esta reserva' });

// Novo responsável: colaborador com login OU motorista externo (nome livre)
let novoRespId = null;
let novoExterno = null;
if (novo_responsavel_id) {
  const novoUser = await db.prepare(
    'SELECT id, nome, bloqueado FROM usuarios WHERE id = ?'
  ).get(novo_responsavel_id);
  if (!novoUser)          return res.status(404).json({ error: 'Novo responsavel nao encontrado' });
  if (novoUser.bloqueado) return res.status(400).json({ error: 'Novo responsavel esta bloqueado' });
  novoRespId = novo_responsavel_id;
} else {
  novoExterno = externoNome;
}

// Nome de quem era antes (para a auditoria ficar legível)
let origemNome;
if (r.responsavel_atual_id) {
  origemNome = (await db.prepare('SELECT nome FROM usuarios WHERE id = ?')
    .get(r.responsavel_atual_id))?.nome;
} else if (r.responsavel_externo_nome) {
  origemNome = r.responsavel_externo_nome;
} else {
  origemNome = (await db.prepare('SELECT nome FROM usuarios WHERE id = ?')
    .get(r.usuario_id))?.nome;
}

// TRANSAÇÃO: atualização + evento gravados atomicamente
await db.transaction(async (tx) => {
  await tx.prepare(
    'UPDATE reservas SET responsavel_atual_id = ?, responsavel_externo_nome = ? WHERE id = ?'
  ).run(novoRespId, novoExterno, id);

  await tx.prepare(`
    INSERT INTO reserva_eventos
      (reserva_id, tipo, autor_id, alvo_usuario_id, alvo_externo_nome, origem_nome, texto)
    VALUES (?, 'transferencia', ?, ?, ?, ?, ?)
  `).run(id, req.user.id, novoRespId, novoExterno, origemNome || null, motivo || null);
});

res.json({ ok: true });
backend/src/middlewares/authMiddleware.js

Diferente de um middleware JWT comum, este consulta o banco a cada requisição para verificar o flag bloqueado — quando o TI bloqueia alguém, o efeito é imediato, mesmo que o token ainda esteja válido. O requireRole() é composável: aceita N perfis e é aplicado por rota.

const autenticar = asyncH(async (req, res, next) => {
  const header = req.headers.authorization || '';
  const token = header.startsWith('Bearer ') ? header.slice(7) : null;
  if (!token) return res.status(401).json({ error: 'Token nao informado' });

  let payload;
  try {
    payload = jwt.verify(token, process.env.JWT_SECRET);
  } catch {
    return res.status(401).json({ error: 'Token invalido ou expirado' });
  }

  // Busca o usuário a cada request: bloqueio tem efeito IMEDIATO,
  // mesmo com um JWT ainda dentro da validade de 12h
  const user = await db.prepare(
    'SELECT id, nome, email, role, bloqueado FROM usuarios WHERE id = ?'
  ).get(payload.id);

  if (!user)           return res.status(401).json({ error: 'Usuario nao encontrado' });
  if (user.bloqueado)  return res.status(403).json({ error: 'Usuario bloqueado' });

  req.user = user;
  next();
});

// Guard composável — aceita N perfis
function requireRole(...roles) {
  return (req, res, next) => {
    if (!req.user) return res.status(401).json({ error: 'Nao autenticado' });
    if (!roles.includes(req.user.role)) {
      return res.status(403).json({ error: 'Acesso negado para o seu perfil' });
    }
    next();
  };
}

// Uso nas rotas (composição de middlewares Express):
// router.use(autenticar);                                  → todas exigem login
// router.post('/', requireRole('admin'), ctrl.criar);      → só admin
// router.get('/logs', requireRole('ti'), ctrl.logs);       → só TI
backend/src/middlewares/quiosquePin.js + checkoutController.js

O quiosque roda num tablet sem login — a proteção é um PIN comparado com crypto.timingSafeEqual(), imune a timing attacks. O nome do arquivo da foto usa o timestamp do servidor (não do tablet) e o path passa por sanitização antes de ir para o bucket privado.

// Comparação em tempo constante — sem vazamento por timing
function pinIgual(a, b) {
  const ab = Buffer.from(String(a));
  const bb = Buffer.from(String(b));
  if (ab.length !== bb.length) return false;
  return crypto.timingSafeEqual(ab, bb);
}

// ── No upload da foto (checkoutController.js) ──────────────
// Pasta segregada: motorista cadastrado vs digitado à mão
if (cadastrado && pessoaId) {
  const m = await db.prepare('SELECT id, nome FROM motoristas WHERE id = ?').get(pessoaId);
  if (!m) return res.status(404).json({ error: 'Motorista nao encontrado' });
  motoristaId = m.id; nome = m.nome;
  pastaBase = sanitizeSegment(nome);                       // "João"
} else {
  if (nome.length < 2) return res.status(400).json({ error: 'Informe o nome do motorista' });
  pastaBase = `Motorista sem cadastro/${sanitizeSegment(nome)}`;
}

// Timestamp do SERVIDOR (autoritário — o relógio do tablet não manda)
const { agora } = await db.prepare(`SELECT ${db.NOW_SQL} AS agora`).get();
const fileStamp = agora.replace(' ', '_').replace(/:/g, '-'); // 2026-06-08_14-30-00
const pathKey   = `${pastaBase}/${fileStamp}_${tipo}.jpg`;

// Upload para bucket PRIVADO — leitura só com URL assinada (TI)
await storage.uploadFoto(pathKey, req.file.buffer, req.file.mimetype || 'image/jpeg');

await db.prepare(
  'INSERT INTO checkout_fotos (motorista_id, usuario_id, nome, cadastrado, tipo, storage_path) VALUES (?,?,?,?,?,?)'
).run(motoristaId, usuarioId, nome, cadastrado ? 1 : 0, tipo, pathKey);

res.status(201).json({ ok: true, nome, tipo, path: pathKey });
backend/src/controllers/reservasController.js — GET /api/reservas/relatorio

O relatório monta o WHERE dinamicamente a partir dos filtros recebidos — sempre com placeholders (zero SQL injection) — e calcula os agregados em memória numa única passada pelos resultados: por status, finalidade, veículo e colaborador.

async function relatorio(req, res) {
  const { usuario_id, veiculo_id, status, inicio, fim, tipo } = req.query;

  // WHERE dinâmico — sempre com placeholders, nunca concatenação
  const where = [];
  const params = [];
  if (usuario_id) { where.push('r.usuario_id = ?');      params.push(usuario_id); }
  if (veiculo_id) { where.push('r.veiculo_id = ?');      params.push(veiculo_id); }
  if (status)     { where.push('r.status = ?');          params.push(status); }
  if (inicio)     { where.push('r.data_retirada  >= ?'); params.push(inicio); }
  if (fim)        { where.push('r.data_devolucao <= ?'); params.push(fim); }
  if (tipo === 'reserva')   where.push('r.liberacao_direta = 0');
  if (tipo === 'liberacao') where.push('r.liberacao_direta = 1');
  const whereSql = where.length ? 'WHERE ' + where.join(' AND ') : '';

  const rows = await db.prepare(`
    SELECT r.*, u.nome AS usuario_nome, u.email AS usuario_email,
           v.placa, v.modelo, v.marca, v.km_atual AS veiculo_km_atual
    FROM reservas r
    JOIN usuarios u ON u.id = r.usuario_id
    JOIN veiculos v ON v.id = r.veiculo_id
    ${whereSql}
    ORDER BY r.data_retirada DESC
    LIMIT 2000
  `).all(...params);

  // Agregados em UMA passada — mais rápido que N queries
  const stats = {
    total:      rows.length,
    reservas:   rows.filter(r => !r.liberacao_direta).length,
    liberacoes: rows.filter(r =>  r.liberacao_direta).length,
    porStatus: {}, porFinalidade: {}, porVeiculo: {}, porColaborador: {},
  };

  rows.forEach(r => {
    stats.porStatus[r.status]         = (stats.porStatus[r.status] || 0) + 1;
    stats.porFinalidade[r.finalidade] = (stats.porFinalidade[r.finalidade] || 0) + 1;
    const veic = `${r.marca} ${r.modelo} (${r.placa})`;
    stats.porVeiculo[veic]            = (stats.porVeiculo[veic] || 0) + 1;
    stats.porColaborador[r.usuario_nome] = (stats.porColaborador[r.usuario_nome] || 0) + 1;
  });

  res.json({ data: rows, stats });
}