Micro-SaaS para estúdio de estética automotiva. O problema que ele resolve é o cliente que volta dizendo que o risco no para-lama não estava lá quando deixou o carro.
O operador recebe o veículo no pátio, registra cada dano tocando num mapa do carro na tela do celular e tira as fotos. O sistema gera um laudo com link público, que vai para o cliente por WhatsApp. O cliente abre, confere e aceita; nesse momento o laudo é congelado, com nome, IP e horário do aceite gravados. A ordem de serviço só entra na fila de trabalho depois disso.
Cada pino é um dano registrado pelo operador no pátio. O mapa é o mesmo no celular dele e aqui, porque a posição é guardada relativa ao desenho, não em pixel.
No aceite, à direita, o laudo grava nome, data, hora e IP, e o checklist passa a ser somente leitura. É o que dá valor ao documento depois.
Faturamento do mês e do dia, O.S. ativas, giro de carros, insumos em alerta, receita por semana e os serviços mais vendidos.
As sete fases do ciclo. O arraste entre colunas só aceita as transições válidas. Cada card mostra o tempo parado na fase, o operador responsável e se o laudo já foi aceito.
Insumo abaixo do mínimo fica vermelho e alimenta o card de alerta do painel. No caixa, a receita de cada O.S. concluída entra sozinha; as despesas são lançadas à mão.
O app do operador e o painel do dono não são a mesma interface em tamanhos diferentes. Os contextos de uso são bem distintos:
Operador (apps/mobile) |
Dono (apps/web) |
|
|---|---|---|
| onde | no pátio, de pé, com o carro na frente | no escritório, desktop |
| como | toque, câmera, uma mão | mouse, teclado, tela grande |
| faz | check-in, mapa de danos, fotos, check-out | Kanban, preço, estoque, caixa, configuração |
| rede | Wi-Fi ruim de galpão, funciona offline | conexão normal |
┌── operador, no celular ──┐ ┌─ cliente ─┐ ┌──────── estúdio ────────┐
EM_CHECKIN ─▶ AGUARDANDO_APROVACAO ─▶ APROVADO ─▶ EM_ANDAMENTO ─▶ INSPECAO ─▶ CONCLUIDO ─▶ ENTREGUE
│ │ ▲
│ └─ link público ──┘
│ do laudo (o aceite congela o checklist)
└── fotos + pins de dano no mapa do carro
O portão de aprovação no meio é o ponto do produto: o trabalho não começa antes do cliente dar ciência do estado de entrada.
Estúdio que não quer esse rigor liga o modo FLEXIVEL e o operador aprova direto — cliente
recorrente, pátio corrido. É configuração por estúdio, não fork do código.
O operador não pode parar de trabalhar porque a rede caiu. Cada dano registrado vai para uma
fila no AsyncStorage e a interface responde na hora, de forma otimista; quando a conexão volta,
a fila é drenada.
A parte que importa é a classificação do erro, porque retentar tudo indefinidamente é como se perde dado:
/// Erro permanente = 4xx (cliente/validação), exceto 408/429 (transitórios).
/// Ex.: 409 = laudo já aprovado (checklist travado) → nunca vai dar certo.
function erroPermanente(e: unknown): boolean {
const s = statusDoErro(e);
return s !== null && s >= 400 && s < 500 && s !== 408 && s !== 429;
}- erro de rede ou 5xx: fica na fila e retenta, até 5 tentativas;
- 4xx: descarta, porque não vai passar numa próxima tentativa e um item assim travaria a fila inteira atrás dele;
- 408 e 429 são 4xx, mas são transitórios, então voltam para a fila.
Cada item leva um clientId gerado no celular, então o reenvio não duplica o dano no servidor.
A foto vai embutida no mesmo payload de propósito: dano e foto sobem numa requisição só, e assim
não existe o estado intermediário de dano salvo sem foto.
O pino é salvo como posX/posY entre 0 e 1, relativo ao SVG do carro (react-native-svg),
e não em pixel. Assim o mesmo dano cai no lugar certo no celular do operador, no laudo que o
cliente abre no navegador e num tablet, sem recalcular nada.
O dano também guarda registradoEm, que é a hora em que o operador tocou na tela, e não a hora
em que o servidor recebeu. Num documento que serve como prova isso muda o conteúdo: o carro
entrou às 8h04, não às 11h20 de quando o Wi-Fi voltou.
Todo dado de negócio é particionado por empresaId, e a API filtra por empresa do usuário
logado nas consultas, não no frontend. O modelo Empresa é a raiz do tenant e guarda os módulos
ativos: um estúdio que só quer o laudo desliga estoque e financeiro (usaEstoque,
usaFinanceiro). O plano (FREE/PRO/PREMIUM) também fica ali.
Autorização é @Auth(role) + RolesGuard sobre JWT (Passport): DONO vê o painel, OPERADOR
vê o pátio.
Foto de celular tem vários MB, e passar isso pelo servidor gasta banda e memória do container sem necessidade. O mobile comprime a imagem, pede uma URL pré-assinada ao endpoint de storage e sobe direto para o bucket (S3/Supabase). A API guarda apenas a URL.
RelatorioPublico é 1:1 com a O.S., acessível por hash sem login, e grava aceito,
aceitoEm, aceitoNome e aceitoIp. Depois do aceite o checklist passa a rejeitar alteração,
e é o 409 dessa trava que a fila offline descarta em vez de retentar. Um laudo que pode ser
editado depois de aceito não serviria para nada.
Empresa (tenant) ──< Usuario (DONO | OPERADOR)
│
├──< Cliente ──< Veiculo ──< OrdemServico ──< Servico (o que foi vendido)
│ │ ├──< ChecklistDano (pins no mapa)
│ │ ├──< FotoOS (5 ângulos, entrada e saída)
│ │ └──── RelatorioPublico (1:1, o laudo)
│
├──< Insumo ──< MovimentoEstoque (auditoria de cada entrada/saída)
├──< ServicoCatalogo (preço cadastrado uma vez, reutilizado)
└──< LancamentoFinanceiro (caixa; receita vinculada à O.S. de origem)
Estoque guarda o movimento com motivo, e não só o saldo, para dar como auditar uma divergência depois.
Dinheiro é Decimal(10,2) no Postgres, nunca float.
Node ≥ 20, pnpm ≥ 9, PostgreSQL, e Expo Go (ou simulador) pro mobile.
pnpm install
cp .env.example .env # ajuste DATABASE_URL, DIRECT_URL, JWT_SECRET, STORAGE_*
pnpm db:generate # gera o Prisma Client
pnpm db:migrate # cria as tabelas
pnpm db:seed # dados de demonstraçãoO seed popula um mês de operação de um estúdio: 28 O.S. distribuídas por todas as colunas do Kanban, danos marcados sobre o mapa do carro com coordenada e hora de pátio, laudo já aceito num caso e aguardando aceite noutro, 12 insumos com histórico de movimento e três abaixo do mínimo, e caixa com receita e despesa espalhadas pelas semanas. A semente do gerador é fixa, então rodar duas vezes dá o mesmo resultado.
⚠️ O seed apaga os dados de negócio antes de popular. É para banco de desenvolvimento.E se você já estava logado: saia e entre de novo. O seed recria a empresa com um id novo, e como a API filtra tudo por
empresaIddo usuário do token, uma sessão anterior passa a apontar para um tenant que não existe mais — as telas ficam zeradas mesmo com o banco cheio. É o isolamento multi-tenant funcionando, não um bug.
pnpm dev # api + web + mobile via TurborepoOu separado:
pnpm --filter api dev # NestJS → localhost:3333 (Swagger em /docs)
pnpm --filter web dev # Next.js → localhost:3000
pnpm --filter mobile dev # Expo → QR no Expo GoEm celular físico,
localhostnão resolve: troque por IP da máquina emNEXT_PUBLIC_API_URL/EXPO_PUBLIC_API_URL.
| Papel | Senha | Onde | |
|---|---|---|---|
| Dono | dono@studio.com |
123456 |
web |
| Operador | operador@studio.com |
123456 |
mobile |
O seed grava o hash com bcryptjs, o mesmo algoritmo da API, então o login funciona direto.
- ARCHITECTURE.md — árvore do monorepo, mapa de páginas e regras
- DOCUMENTACAO.md — detalhe funcional
- GUIA-DE-TESTES.md — roteiro pra percorrer o fluxo inteiro
A regra de negócio fica só na API. Web e mobile são clientes dela e nunca falam direto com
o banco; o contrato entre os três é o pacote @repo/types (enums, labels e schemas Zod), e o
schema Prisma em packages/database é a única fonte de verdade do banco.
TypeScript · Turborepo · pnpm workspaces · NestJS (REST + OpenAPI) · Next.js App Router · React Native / Expo · Prisma · PostgreSQL · JWT + Passport · Zod · TailwindCSS · @dnd-kit · react-native-svg · S3/Supabase Storage com URL pré-assinada





