diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..af17fa0 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,237 @@ +# Contexto do Projeto - Gastrobar PDV (Café em Saturno) + +## Visão Geral +Sistema de PDV (Ponto de Venda) e gerenciamento para gastrobar/restaurante. Gerencia comandas de clientes, pedidos, cardápio de produtos, usuários com diferentes perfis de acesso e fluxo de preparo na cozinha/bar. + +## Stack + +### Backend (`src/`) +- **Runtime:** Node.js + TypeScript (CommonJS) +- **Framework:** Fastify v4.27 (porta 3333) +- **ORM:** Prisma v5.14 com SQLite (`prisma/dev.db`) +- **Validação:** Zod v3.23 +- **Auth:** JWT (`@fastify/jwt`) + bcrypt +- **Dev:** tsx watch (`npm run dev`) + +### Frontend (`web/`) +- **Bundler:** Vite v8.1 +- **Linguagem:** TypeScript vanilla (sem framework, SPA) +- **Dev server:** porta 5173 +- **API URL:** `http://localhost:3333/api` +- **Token storage:** `localStorage('@gastrobar:token')` e `localStorage('@gastrobar:user')` + +## Estrutura do Projeto + +``` +app_ces/ +├── .env # JWT_SECRET +├── package.json # Backend dependencies +├── tsconfig.json +├── prisma/ +│ ├── schema.prisma # Schema do banco (SQLite) +│ ├── seed.ts # Seed: admin@gastrobar.com / 123456 +│ └── dev.db # Banco SQLite (gerado) +├── src/ # Backend API +│ ├── server.ts # Entrypoint Fastify, porta 3333 +│ ├── lib/ +│ │ └── prisma.ts # Instância PrismaClient +│ ├── middlewares/ +│ │ └── auth.middleware.ts # authenticate + requireRoles +│ ├── controllers/ +│ │ ├── auth.controller.ts +│ │ ├── category.controller.ts +│ │ ├── product.controller.ts +│ │ ├── order.controller.ts +│ │ ├── user.controller.ts +│ │ └── report.controller.ts +│ ├── services/ +│ │ ├── auth.service.ts +│ │ ├── category.service.ts +│ │ ├── product.service.ts +│ │ ├── order.service.ts +│ │ ├── user.service.ts +│ │ └── report.service.ts +│ ├── schemas/ # Validação Zod +│ │ ├── auth.schema.ts +│ │ ├── user.schema.ts +│ │ ├── product.schema.ts +│ │ ├── category.schema.ts +│ │ └── order.schema.ts +│ └── routes/ +│ ├── auth.routes.ts +│ ├── category.routes.ts +│ ├── product.routes.ts +│ ├── order.routes.ts +│ ├── user.routes.ts +│ └── report.routes.ts +└── web/ # Frontend SPA + ├── package.json # Vite + TypeScript + ├── tsconfig.json + └── src/ + ├── main.ts # Router SPA + navegação + ├── api.ts # Cliente HTTP centralizado + ├── utils.ts # renderNavbar, toast, helpers + ├── style.css # Dark theme + glassmorphism + └── views/ + ├── login.ts + ├── kitchen.ts # Painel de preparo + ├── orders.ts # Gerenciamento de comandas + ├── checkout.ts # Caixa/pagamento + ├── menu.ts # Cardápio CRUD + ├── users.ts # Gestão de equipe + └── dashboard.ts # KPIs admin +``` + +## Banco de Dados (Schema Prisma) + +### Models + +**Usuario** +- `id` (UUID), `nome`, `email` (unique), `senha` (hash bcrypt), `role` (ADMIN|CAIXA|GARCOM|COZINHA|BARMAN), `ativo`, `criadoEm` +- Relations: `comandas` (1:N Comanda), `itensPedido` (1:N ItemPedido) + +**Categoria** +- `id` (UUID), `nome`, `produtos` (relation 1:N Produto) + +**Produto** +- `id` (UUID), `nome`, `descricao?`, `fotoUrl?`, `preco` (Float), `custo?`, `estoqueAtual`, `estoqueMinimo`, `itemCozinha` (boolean: true=cozinha, false=bar), `ativo`, `categoriaId` (FK) + +**Comanda** +- `id` (UUID), `identificador` (mesa/código), `nomeCliente?`, `status` (ABERTA|FECHADA|PAGA|CANCELADA), `total`, `valorPago`, `desconto`, `acrescimo`, `taxaServico` (boolean, +10%), `formaPagamento?`, `motivoCancelamento?`, `criadoEm`, `fechadoEm?` +- `usuarioId?` (FK) - Usuário que abriu a comanda +- Relations: `itens` (1:N ItemPedido), `pagamentos` (1:N Pagamento), `usuario` (N:1 Usuario) + +**ItemPedido** +- `id` (UUID), `quantidade`, `precoUnitario`, `observacao?`, `status` (PENDENTE|PREPARANDO|PRONTO|ENTREGUE), `produtoId` (FK), `comandaId` (FK), `criadoEm` +- `usuarioId?` (FK) - Usuário que adicionou o item +- Relations: `produto` (N:1 Produto), `comanda` (N:1 Comanda), `usuario` (N:1 Usuario) + +**Pagamento** +- `id` (UUID), `comandaId` (FK), `valor`, `formaPagamento` (DINHEIRO|CARTAO_CREDITO|CARTAO_DEBITO|PIX), `observacao?`, `criadoEm` + +## Rotas da API + +### Auth (`/api/auth`) +| Método | Rota | Permissão | Descrição | +|--------|------|-----------|-----------| +| POST | `/auth/login` | Público | Login, retorna JWT + user | +| PATCH | `/auth/senha` | Autenticado | Alterar própria senha | + +### Categorias (`/api`) +| Método | Rota | Permissão | +|--------|------|-----------| +| GET | `/categorias` | Autenticado | +| GET | `/categorias/:id` | Autenticado | +| POST | `/categorias` | ADMIN | +| PUT | `/categorias/:id` | ADMIN | +| DELETE | `/categorias/:id` | ADMIN | + +### Produtos (`/api`) +| Método | Rota | Permissão | +|--------|------|-----------| +| GET | `/produtos` | Autenticado | +| GET | `/produtos/mais-vendidos` | Autenticado | +| GET | `/produtos/busca` | Autenticado | +| GET | `/produtos/baixo-estoque` | ADMIN | +| GET | `/produtos/:id` | Autenticado | +| POST | `/produtos` | ADMIN | +| PUT | `/produtos/:id` | ADMIN | +| DELETE | `/produtos/:id` | ADMIN | + +### Comandas (`/api`) +| Método | Rota | Permissão | +|--------|------|-----------| +| GET | `/comandas` | Autenticado | +| GET | `/pagamentos` | Autenticado | +| POST | `/comandas` | GARCOM/CAIXA/ADMIN | +| POST | `/comandas/:id/itens` | GARCOM/CAIXA/ADMIN | +| PATCH | `/comandas/:id` | GARCOM/CAIXA/ADMIN | +| POST | `/comandas/:id/pagar` | CAIXA/ADMIN | +| POST | `/comandas/:id/pagamentos` | CAIXA/ADMIN | +| PATCH | `/comandas/itens/:id/observacao` | GARCOM/CAIXA/ADMIN | +| DELETE | `/comandas/itens/:id` | CAIXA/ADMIN | + +### Painel Cozinha/Bar (`/api`) +| Método | Rota | Permissão | +|--------|------|-----------| +| GET | `/painel/itens` | COZINHA/BARMAN/ADMIN | +| PATCH | `/painel/itens/:id/status` | COZINHA/BARMAN/ADMIN | + +### Relatórios (`/api`) +| Método | Rota | Permissão | +|--------|------|-----------| +| GET | `/relatorios/vendas` | ADMIN | +| GET | `/relatorios/dashboard` | ADMIN | + +### Usuários (`/api`) +| Método | Rota | Permissão | +|--------|------|-----------| +| GET | `/usuarios` | ADMIN | +| POST | `/usuarios` | ADMIN | +| PATCH | `/usuarios/:id` | ADMIN | +| PATCH | `/usuarios/:id/senha` | ADMIN | +| DELETE | `/usuarios/:id` | ADMIN | + +## Roles e Permissões + +| Role | Descrição | Acesso Principal | +|------|-----------|-----------------| +| `ADMIN` | Administrador | Acesso total ao sistema | +| `CAIXA` | Operador de caixa | Comandas, checkout, pagamento, exclusão de itens | +| `GARCOM` | Garçom | Criar/gerenciar comandas, adicionar itens | +| `COZINHA` | Cozinheiro | Painel de preparo (itens `itemCozinha=true`) | +| `BARMAN` | Bartender | Painel de preparo (itens `itemCozinha=false`) | + +## Lógica de Negócio Importante + +### Fluxo de Comanda +1. Criar comanda (identificador = mesa/código) - **registra usuário logado** +2. Adicionar itens (baixa estoque automaticamente, atualiza total) - **registra usuário logado** +3. Itens aparecem no painel de cozinha/bar com status `PENDENTE` +4. Cozinha/bar avança: `PENDENTE → PREPARANDO → PRONTO → ENTREGUE` +5. Caixa fecha com pagamento (integral ou parcial) +6. Status da comanda: `ABERTA → PAGA` + +### Pagamento +- **Integral**: `POST /comandas/:id/pagar` - registra pagamento do restante e fecha comanda +- **Parcial**: `POST /comandas/:id/pagamentos` - registra pagamento parcial, pode selecionar itens específicos +- **Cálculo do total final**: `total - desconto + acrescimo + (taxaServico ? 10% : 0)` +- Formas: `DINHEIRO`, `CARTAO_CREDITO`, `CARTAO_DEBITO`, `PIX` + +### Estoque +- Decrementa ao adicionar item na comanda +- Incrementa ao remover item da comanda +- Validação de estoque insuficiente no backend + +### Frontend +- Rotas SPA manuais via `window.history.pushState` +- Auto-refresh no painel de cozinha a cada 10 segundos +- Notificações sonoras via Web Audio API para novos pedidos +- Navbar dinâmica baseada no role do usuário logado + +## Comandos Úteis + +```bash +# Backend +npm run dev # Iniciar dev server (tsx watch) +npm run build # Compilar TypeScript +npm run db:push # Aplicar schema no banco +npm run db:seed # Popular banco com dados iniciais + +# Frontend +cd web && npm run dev # Iniciar Vite dev server +cd web && npm run build # Build de produção +``` + +## Credenciais Padrão (Seed) +- **Email:** admin@gastrobar.com +- **Senha:** 123456 +- **Role:** ADMIN + +## Notas Técnicas +- CORS habilitado para qualquer origem (`origin: '*'`) +- JWT secret definido em `.env` (fallback: `fallback_secret_change_me`) +- Error handler global trata `ZodError` (400) e erros HTTP +- Frontend não usa framework UI - renderiza HTML via template literals +- Utiliza `event delegation` para botões dinâmicos no painel +- Cleanup de listeners ao navegar entre views diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a2ce683..724109d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -15,6 +15,8 @@ model Usuario { role String @default("GARCOM") // ADMIN, CAIXA, GARCOM, COZINHA, BARMAN ativo Boolean @default(true) criadoEm DateTime @default(now()) + comandas Comanda[] + itensPedido ItemPedido[] } model Categoria { @@ -53,6 +55,8 @@ model Comanda { motivoCancelamento String? criadoEm DateTime @default(now()) fechadoEm DateTime? + usuarioId String? + usuario Usuario? @relation(fields: [usuarioId], references: [id]) itens ItemPedido[] pagamentos Pagamento[] } @@ -67,6 +71,8 @@ model ItemPedido { produto Produto @relation(fields: [produtoId], references: [id]) comandaId String comanda Comanda @relation(fields: [comandaId], references: [id]) + usuarioId String? + usuario Usuario? @relation(fields: [usuarioId], references: [id]) criadoEm DateTime @default(now()) } diff --git a/src/controllers/order.controller.ts b/src/controllers/order.controller.ts index d0d1cd6..56a5e8f 100644 --- a/src/controllers/order.controller.ts +++ b/src/controllers/order.controller.ts @@ -42,16 +42,18 @@ export async function listPaymentsController(request: FastifyRequest, reply: Fas export async function createOrderController(request: FastifyRequest, reply: FastifyReply) { const data = createOrderSchema.parse(request.body); - const order = await orderService.createOrder(data); + const user = request.user as { id: string }; + const order = await orderService.createOrder(data, user.id); return reply.status(201).send(order); } export async function addItemToOrderController(request: FastifyRequest, reply: FastifyReply) { const { id } = z.object({ id: z.string().uuid() }).parse(request.params); const data = addItemOrderSchema.parse(request.body); + const user = request.user as { id: string }; try { - const item = await orderService.addItemToOrder(id, data); + const item = await orderService.addItemToOrder(id, data, user.id); return reply.status(201).send(item); } catch (error: any) { return reply.status(400).send({ error: error.message }); diff --git a/src/services/order.service.ts b/src/services/order.service.ts index 315f5b4..3b01efb 100644 --- a/src/services/order.service.ts +++ b/src/services/order.service.ts @@ -64,26 +64,35 @@ export async function listOrders(filter?: ListOrdersFilter) { where, orderBy: { criadoEm: 'desc' }, include: { + usuario: { + select: { id: true, nome: true, email: true }, + }, itens: { - include: { produto: true } + include: { + produto: true, + usuario: { + select: { id: true, nome: true }, + }, + } }, pagamentos: true, } }); } -export async function createOrder(data: CreateOrderInput) { +export async function createOrder(data: CreateOrderInput, usuarioId?: string) { return prisma.comanda.create({ data: { identificador: data.identificador, nomeCliente: data.nomeCliente, status: 'ABERTA', total: 0, + usuarioId: usuarioId ?? null, }, }); } -export async function addItemToOrder(comandaId: string, data: AddItemOrderInput) { +export async function addItemToOrder(comandaId: string, data: AddItemOrderInput, usuarioId?: string) { const produto = await prisma.produto.findUnique({ where: { id: data.produtoId }, }); @@ -110,6 +119,7 @@ export async function addItemToOrder(comandaId: string, data: AddItemOrderInput) precoUnitario: produto.preco, produtoId: data.produtoId, comandaId: comandaId, + usuarioId: usuarioId ?? null, status: 'PENDENTE', }, }), @@ -140,6 +150,9 @@ export async function listKitchenBarItems(itemCozinha?: boolean, status?: string include: { produto: true, comanda: true, + usuario: { + select: { id: true, nome: true }, + }, }, }); } @@ -320,8 +333,16 @@ export async function updateOrder(comandaId: string, data: UpdateOrderInput) { where: { id: comandaId }, data: updateData, include: { + usuario: { + select: { id: true, nome: true, email: true }, + }, itens: { - include: { produto: true } + include: { + produto: true, + usuario: { + select: { id: true, nome: true }, + }, + } }, pagamentos: true, } @@ -360,6 +381,18 @@ export async function deleteItemFromOrder(itemId: string) { return prisma.comanda.findUnique({ where: { id: item.comandaId }, - include: { itens: { include: { produto: true } } }, + include: { + itens: { + include: { + produto: true, + usuario: { + select: { id: true, nome: true }, + }, + } + }, + usuario: { + select: { id: true, nome: true, email: true }, + }, + }, }); } diff --git a/test-api.ts b/test-api.ts new file mode 100644 index 0000000..5114f08 --- /dev/null +++ b/test-api.ts @@ -0,0 +1,16 @@ +(async () => { + const res = await fetch('http://localhost:3333/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: 'admin@gastrobar.com', senha: '123456' }), + }); + const data = await res.json(); + console.log('TOKEN:', data.token); + + const res2 = await fetch('http://localhost:3333/api/comandas?status=ABERTA,FECHADA', { + headers: { Authorization: `Bearer ${data.token}` }, + }); + const data2 = await res2.json(); + console.log('COMANDAS STATUS:', res2.status); + console.log('COMANDAS:', JSON.stringify(data2, null, 2)); +})(); diff --git a/web/src/views/kitchen.ts b/web/src/views/kitchen.ts index 0395dfa..2ff441a 100644 --- a/web/src/views/kitchen.ts +++ b/web/src/views/kitchen.ts @@ -59,15 +59,19 @@ export async function renderKitchen(container: HTMLElement) { let currentStatus = 'PENDENTE'; let currentFilter = ''; let autoRefreshInterval: ReturnType | null = null; + let requestId = 0; const loadItems = async () => { const grid = document.getElementById('items-grid')!; grid.innerHTML = '

Carregando...

'; + const myRequestId = ++requestId; try { let url = `/painel/itens?status=${currentStatus}`; if (currentFilter !== '') url += `&itemCozinha=${currentFilter}`; const items = await api.get(url); + if (myRequestId !== requestId) return; + const currentIds = new Set(items.map((i: any) => i.id)); const previousIds = previousItemIdsByStatus.get(currentStatus); @@ -106,9 +110,10 @@ export async function renderKitchen(container: HTMLElement) {

${item.quantidade}× ${item.produto.nome}

${item.observacao ? `

⚠️ ${item.observacao}

` : ''} -

+

Pedido às ${new Date(item.criadoEm).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' })}

+ ${item.usuario ? `

Adicionado por: ${item.usuario.nome}

` : '

'} ${action.label ? ` +
+
+ + +
+
+ + +
+
+ + +
+ +
- - ${products.map((p: any) => ` - - - - - - - - - - `).join('')} - +
NomeCategoriaPreçoEstoqueTipoStatusAções
${p.nome}${p.descricao ? `
${p.descricao}` : ''}
${p.categoria?.nome ?? '—'}${formatBRL(p.preco)} - - ${p.estoqueAtual} ${p.estoqueAtual <= p.estoqueMinimo ? '⚠️' : ''} - - ${p.itemCozinha ? 'Cozinha' : 'Bar'}${p.ativo ? 'Ativo' : 'Inativo'} - - -
`; + const catSel = document.getElementById('f-categoria') as HTMLSelectElement; + catSel.innerHTML = '' + + categorias.map((c: any) => ``).join(''); + catSel.value = filterCategoria; + (document.getElementById('f-ativo') as HTMLSelectElement).value = filterAtivo; + (document.getElementById('f-cozinha') as HTMLSelectElement).value = filterCozinha; + + catSel.addEventListener('change', () => { filterCategoria = catSel.value; renderProductsTable(); }); + (document.getElementById('f-ativo') as HTMLSelectElement).addEventListener('change', (e) => { filterAtivo = (e.target as HTMLSelectElement).value; renderProductsTable(); }); + (document.getElementById('f-cozinha') as HTMLSelectElement).addEventListener('change', (e) => { filterCozinha = (e.target as HTMLSelectElement).value; renderProductsTable(); }); + document.getElementById('btn-clear-filters')?.addEventListener('click', () => { + filterCategoria = ''; + filterAtivo = ''; + filterCozinha = ''; + (document.getElementById('f-categoria') as HTMLSelectElement).value = ''; + (document.getElementById('f-ativo') as HTMLSelectElement).value = ''; + (document.getElementById('f-cozinha') as HTMLSelectElement).value = ''; + renderProductsTable(); + }); + + renderProductsTable(); + document.getElementById('btn-novo-produto')?.addEventListener('click', () => { clearProductForm(); (document.getElementById('modal-produto') as HTMLElement).style.display = 'flex'; }); }; + const renderProductsTable = () => { + let filtered = allProducts; + if (filterCategoria) filtered = filtered.filter((p: any) => p.categoriaId === filterCategoria); + if (filterAtivo !== '') filtered = filtered.filter((p: any) => String(p.ativo) === filterAtivo); + if (filterCozinha !== '') filtered = filtered.filter((p: any) => String(p.itemCozinha) === filterCozinha); + + const countEl = document.getElementById('produtos-count'); + if (countEl) countEl.textContent = `${filtered.length} de ${allProducts.length} produto(s)`; + + const tbody = document.getElementById('produtos-tbody')!; + if (!filtered.length) { + tbody.innerHTML = `Nenhum produto encontrado com os filtros selecionados.`; + return; + } + tbody.innerHTML = filtered.map((p: any) => ` + + ${p.nome}${p.descricao ? `
${p.descricao}` : ''} + ${p.categoria?.nome ?? '—'} + ${formatBRL(p.preco)} + + + ${p.estoqueAtual} ${p.estoqueAtual <= p.estoqueMinimo ? '⚠️' : ''} + + + ${p.itemCozinha ? 'Cozinha' : 'Bar'} + ${p.ativo ? 'Ativo' : 'Inativo'} + + + + + + `).join(''); + }; + const renderTabCategorias = async () => { const tc = document.getElementById('tab-content')!; const cats = await api.get('/categorias'); diff --git a/web/src/views/orders.ts b/web/src/views/orders.ts index 955c54f..f61a1b3 100644 --- a/web/src/views/orders.ts +++ b/web/src/views/orders.ts @@ -108,6 +108,7 @@ export async function renderOrders(container: HTMLElement) {
+
@@ -175,6 +176,7 @@ export async function renderOrders(container: HTMLElement) { Qtd Subtotal Obs + Adicionado por Status Ações @@ -294,6 +296,7 @@ async function loadComandas(params?: { status?: string; dataInicio?: string; dat Identificador Cliente + Aberto por Status Total Aberta em @@ -305,6 +308,7 @@ async function loadComandas(params?: { status?: string; dataInicio?: string; dat ${c.identificador} ${c.nomeCliente ?? ''} + ${c.usuario?.nome ?? '—'} ${c.status} ${formatBRL(calcularTotalFinal(c))} ${new Date(c.criadoEm).toLocaleString('pt-BR')} @@ -606,6 +610,12 @@ function bindOrderEvents() { document.getElementById('detalhes-label-ident')!.textContent = c.identificador; document.getElementById('detalhes-label-client')!.textContent = c.nomeCliente ? `· ${c.nomeCliente}` : ''; document.getElementById('detalhes-label-status')!.innerHTML = statusBadge(c.status); + + // Exibir quem abriu a comanda + const detalhesUsuario = document.getElementById('detalhes-label-usuario'); + if (detalhesUsuario) { + detalhesUsuario.textContent = c.usuario ? `Aberto por: ${c.usuario.nome}` : ''; + } // Render itens const tbody = document.getElementById('detalhes-itens-tbody')!; @@ -614,7 +624,7 @@ function bindOrderEvents() { const canDelete = user && ['ADMIN', 'CAIXA'].includes(user.role) && c.status === 'ABERTA'; if (itens.length === 0) { - tbody.innerHTML = `Nenhum item adicionado`; + tbody.innerHTML = `Nenhum item adicionado`; } else { tbody.innerHTML = itens.map((i: any) => ` @@ -627,6 +637,7 @@ function bindOrderEvents() { : (i.observacao ?? '') } + ${i.usuario?.nome ?? '—'} ${i.status} ${canDelete ? `