filtro em produtos, bug tela de preparo, nome do user nas comandas e itens
This commit is contained in:
237
AGENTS.md
Normal file
237
AGENTS.md
Normal file
@@ -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
|
||||||
@@ -15,6 +15,8 @@ model Usuario {
|
|||||||
role String @default("GARCOM") // ADMIN, CAIXA, GARCOM, COZINHA, BARMAN
|
role String @default("GARCOM") // ADMIN, CAIXA, GARCOM, COZINHA, BARMAN
|
||||||
ativo Boolean @default(true)
|
ativo Boolean @default(true)
|
||||||
criadoEm DateTime @default(now())
|
criadoEm DateTime @default(now())
|
||||||
|
comandas Comanda[]
|
||||||
|
itensPedido ItemPedido[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model Categoria {
|
model Categoria {
|
||||||
@@ -53,6 +55,8 @@ model Comanda {
|
|||||||
motivoCancelamento String?
|
motivoCancelamento String?
|
||||||
criadoEm DateTime @default(now())
|
criadoEm DateTime @default(now())
|
||||||
fechadoEm DateTime?
|
fechadoEm DateTime?
|
||||||
|
usuarioId String?
|
||||||
|
usuario Usuario? @relation(fields: [usuarioId], references: [id])
|
||||||
itens ItemPedido[]
|
itens ItemPedido[]
|
||||||
pagamentos Pagamento[]
|
pagamentos Pagamento[]
|
||||||
}
|
}
|
||||||
@@ -67,6 +71,8 @@ model ItemPedido {
|
|||||||
produto Produto @relation(fields: [produtoId], references: [id])
|
produto Produto @relation(fields: [produtoId], references: [id])
|
||||||
comandaId String
|
comandaId String
|
||||||
comanda Comanda @relation(fields: [comandaId], references: [id])
|
comanda Comanda @relation(fields: [comandaId], references: [id])
|
||||||
|
usuarioId String?
|
||||||
|
usuario Usuario? @relation(fields: [usuarioId], references: [id])
|
||||||
criadoEm DateTime @default(now())
|
criadoEm DateTime @default(now())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -42,16 +42,18 @@ export async function listPaymentsController(request: FastifyRequest, reply: Fas
|
|||||||
|
|
||||||
export async function createOrderController(request: FastifyRequest, reply: FastifyReply) {
|
export async function createOrderController(request: FastifyRequest, reply: FastifyReply) {
|
||||||
const data = createOrderSchema.parse(request.body);
|
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);
|
return reply.status(201).send(order);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function addItemToOrderController(request: FastifyRequest, reply: FastifyReply) {
|
export async function addItemToOrderController(request: FastifyRequest, reply: FastifyReply) {
|
||||||
const { id } = z.object({ id: z.string().uuid() }).parse(request.params);
|
const { id } = z.object({ id: z.string().uuid() }).parse(request.params);
|
||||||
const data = addItemOrderSchema.parse(request.body);
|
const data = addItemOrderSchema.parse(request.body);
|
||||||
|
const user = request.user as { id: string };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const item = await orderService.addItemToOrder(id, data);
|
const item = await orderService.addItemToOrder(id, data, user.id);
|
||||||
return reply.status(201).send(item);
|
return reply.status(201).send(item);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
return reply.status(400).send({ error: error.message });
|
return reply.status(400).send({ error: error.message });
|
||||||
|
|||||||
@@ -64,26 +64,35 @@ export async function listOrders(filter?: ListOrdersFilter) {
|
|||||||
where,
|
where,
|
||||||
orderBy: { criadoEm: 'desc' },
|
orderBy: { criadoEm: 'desc' },
|
||||||
include: {
|
include: {
|
||||||
|
usuario: {
|
||||||
|
select: { id: true, nome: true, email: true },
|
||||||
|
},
|
||||||
itens: {
|
itens: {
|
||||||
include: { produto: true }
|
include: {
|
||||||
|
produto: true,
|
||||||
|
usuario: {
|
||||||
|
select: { id: true, nome: true },
|
||||||
|
},
|
||||||
|
}
|
||||||
},
|
},
|
||||||
pagamentos: true,
|
pagamentos: true,
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createOrder(data: CreateOrderInput) {
|
export async function createOrder(data: CreateOrderInput, usuarioId?: string) {
|
||||||
return prisma.comanda.create({
|
return prisma.comanda.create({
|
||||||
data: {
|
data: {
|
||||||
identificador: data.identificador,
|
identificador: data.identificador,
|
||||||
nomeCliente: data.nomeCliente,
|
nomeCliente: data.nomeCliente,
|
||||||
status: 'ABERTA',
|
status: 'ABERTA',
|
||||||
total: 0,
|
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({
|
const produto = await prisma.produto.findUnique({
|
||||||
where: { id: data.produtoId },
|
where: { id: data.produtoId },
|
||||||
});
|
});
|
||||||
@@ -110,6 +119,7 @@ export async function addItemToOrder(comandaId: string, data: AddItemOrderInput)
|
|||||||
precoUnitario: produto.preco,
|
precoUnitario: produto.preco,
|
||||||
produtoId: data.produtoId,
|
produtoId: data.produtoId,
|
||||||
comandaId: comandaId,
|
comandaId: comandaId,
|
||||||
|
usuarioId: usuarioId ?? null,
|
||||||
status: 'PENDENTE',
|
status: 'PENDENTE',
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -140,6 +150,9 @@ export async function listKitchenBarItems(itemCozinha?: boolean, status?: string
|
|||||||
include: {
|
include: {
|
||||||
produto: true,
|
produto: true,
|
||||||
comanda: true,
|
comanda: true,
|
||||||
|
usuario: {
|
||||||
|
select: { id: true, nome: true },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -320,8 +333,16 @@ export async function updateOrder(comandaId: string, data: UpdateOrderInput) {
|
|||||||
where: { id: comandaId },
|
where: { id: comandaId },
|
||||||
data: updateData,
|
data: updateData,
|
||||||
include: {
|
include: {
|
||||||
|
usuario: {
|
||||||
|
select: { id: true, nome: true, email: true },
|
||||||
|
},
|
||||||
itens: {
|
itens: {
|
||||||
include: { produto: true }
|
include: {
|
||||||
|
produto: true,
|
||||||
|
usuario: {
|
||||||
|
select: { id: true, nome: true },
|
||||||
|
},
|
||||||
|
}
|
||||||
},
|
},
|
||||||
pagamentos: true,
|
pagamentos: true,
|
||||||
}
|
}
|
||||||
@@ -360,6 +381,18 @@ export async function deleteItemFromOrder(itemId: string) {
|
|||||||
|
|
||||||
return prisma.comanda.findUnique({
|
return prisma.comanda.findUnique({
|
||||||
where: { id: item.comandaId },
|
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 },
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
16
test-api.ts
Normal file
16
test-api.ts
Normal file
@@ -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));
|
||||||
|
})();
|
||||||
@@ -59,15 +59,19 @@ export async function renderKitchen(container: HTMLElement) {
|
|||||||
let currentStatus = 'PENDENTE';
|
let currentStatus = 'PENDENTE';
|
||||||
let currentFilter = '';
|
let currentFilter = '';
|
||||||
let autoRefreshInterval: ReturnType<typeof setInterval> | null = null;
|
let autoRefreshInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
let requestId = 0;
|
||||||
|
|
||||||
const loadItems = async () => {
|
const loadItems = async () => {
|
||||||
const grid = document.getElementById('items-grid')!;
|
const grid = document.getElementById('items-grid')!;
|
||||||
grid.innerHTML = '<p class="card-subtitle">Carregando...</p>';
|
grid.innerHTML = '<p class="card-subtitle">Carregando...</p>';
|
||||||
|
const myRequestId = ++requestId;
|
||||||
try {
|
try {
|
||||||
let url = `/painel/itens?status=${currentStatus}`;
|
let url = `/painel/itens?status=${currentStatus}`;
|
||||||
if (currentFilter !== '') url += `&itemCozinha=${currentFilter}`;
|
if (currentFilter !== '') url += `&itemCozinha=${currentFilter}`;
|
||||||
const items = await api.get(url);
|
const items = await api.get(url);
|
||||||
|
|
||||||
|
if (myRequestId !== requestId) return;
|
||||||
|
|
||||||
const currentIds = new Set<string>(items.map((i: any) => i.id));
|
const currentIds = new Set<string>(items.map((i: any) => i.id));
|
||||||
|
|
||||||
const previousIds = previousItemIdsByStatus.get(currentStatus);
|
const previousIds = previousItemIdsByStatus.get(currentStatus);
|
||||||
@@ -106,9 +110,10 @@ export async function renderKitchen(container: HTMLElement) {
|
|||||||
</div>
|
</div>
|
||||||
<h3 class="card-title">${item.quantidade}× ${item.produto.nome}</h3>
|
<h3 class="card-title">${item.quantidade}× ${item.produto.nome}</h3>
|
||||||
${item.observacao ? `<p style="color:var(--warning);font-size:0.875rem;margin-bottom:1rem;">⚠️ ${item.observacao}</p>` : ''}
|
${item.observacao ? `<p style="color:var(--warning);font-size:0.875rem;margin-bottom:1rem;">⚠️ ${item.observacao}</p>` : ''}
|
||||||
<p class="card-subtitle" style="margin-bottom:1.5rem;">
|
<p class="card-subtitle" style="margin-bottom:0.5rem;">
|
||||||
Pedido às ${new Date(item.criadoEm).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' })}
|
Pedido às ${new Date(item.criadoEm).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' })}
|
||||||
</p>
|
</p>
|
||||||
|
${item.usuario ? `<p style="color:var(--text-secondary);font-size:0.8rem;margin-bottom:1rem;">Adicionado por: <strong>${item.usuario.nome}</strong></p>` : '<p style="margin-bottom:1rem;"></p>'}
|
||||||
${action.label ? `
|
${action.label ? `
|
||||||
<button class="btn" style="width:100%;" data-action="advance" data-item-id="${item.id}" data-next-status="${action.next}">
|
<button class="btn" style="width:100%;" data-action="advance" data-item-id="${item.id}" data-next-status="${action.next}">
|
||||||
${action.label}
|
${action.label}
|
||||||
|
|||||||
@@ -87,6 +87,10 @@ export async function renderMenu(container: HTMLElement) {
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
let categorias: any[] = [];
|
let categorias: any[] = [];
|
||||||
|
let allProducts: any[] = [];
|
||||||
|
let filterCategoria = '';
|
||||||
|
let filterAtivo = '';
|
||||||
|
let filterCozinha = '';
|
||||||
|
|
||||||
const loadCategorias = async () => {
|
const loadCategorias = async () => {
|
||||||
categorias = await api.get('/categorias');
|
categorias = await api.get('/categorias');
|
||||||
@@ -96,47 +100,109 @@ export async function renderMenu(container: HTMLElement) {
|
|||||||
|
|
||||||
const renderTabProdutos = async () => {
|
const renderTabProdutos = async () => {
|
||||||
const tc = document.getElementById('tab-content')!;
|
const tc = document.getElementById('tab-content')!;
|
||||||
const products = await api.get('/produtos');
|
allProducts = await api.get('/produtos');
|
||||||
tc.innerHTML = `
|
tc.innerHTML = `
|
||||||
<div class="page-header" style="margin-bottom:1rem;">
|
<div class="page-header" style="margin-bottom:1rem;">
|
||||||
<span class="card-subtitle">${products.length} produto(s) cadastrado(s)</span>
|
<span class="card-subtitle" id="produtos-count"></span>
|
||||||
<button class="btn" id="btn-novo-produto">+ Novo Produto</button>
|
<button class="btn" id="btn-novo-produto">+ Novo Produto</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div style="display:flex;gap:0.5rem;flex-wrap:wrap;margin-bottom:1.5rem;align-items:flex-end;">
|
||||||
|
<div class="form-group" style="margin:0;width:auto;">
|
||||||
|
<label style="color:var(--text-secondary);font-size:0.8rem;">Categoria</label>
|
||||||
|
<select id="f-categoria" class="form-control" style="padding:0.4rem 0.75rem;">
|
||||||
|
<option value="">Todas</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="margin:0;width:auto;">
|
||||||
|
<label style="color:var(--text-secondary);font-size:0.8rem;">Status</label>
|
||||||
|
<select id="f-ativo" class="form-control" style="padding:0.4rem 0.75rem;">
|
||||||
|
<option value="">Todos</option>
|
||||||
|
<option value="true">Ativos</option>
|
||||||
|
<option value="false">Inativos</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="margin:0;width:auto;">
|
||||||
|
<label style="color:var(--text-secondary);font-size:0.8rem;">Tipo</label>
|
||||||
|
<select id="f-cozinha" class="form-control" style="padding:0.4rem 0.75rem;">
|
||||||
|
<option value="">Todos</option>
|
||||||
|
<option value="true">🍳 Cozinha</option>
|
||||||
|
<option value="false">🍹 Bar</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-secondary btn-sm" id="btn-clear-filters" style="margin-bottom:0;">✕ Limpar</button>
|
||||||
|
</div>
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr><th>Nome</th><th>Categoria</th><th>Preço</th><th>Estoque</th><th>Tipo</th><th>Status</th><th>Ações</th></tr>
|
<tr><th>Nome</th><th>Categoria</th><th>Preço</th><th>Estoque</th><th>Tipo</th><th>Status</th><th>Ações</th></tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody id="produtos-tbody"></tbody>
|
||||||
${products.map((p: any) => `
|
|
||||||
<tr>
|
|
||||||
<td data-label="Produto"><strong>${p.nome}</strong>${p.descricao ? `<br><small style="color:var(--text-secondary)">${p.descricao}</small>` : ''}</td>
|
|
||||||
<td data-label="Categoria">${p.categoria?.nome ?? '—'}</td>
|
|
||||||
<td data-label="Preço">${formatBRL(p.preco)}</td>
|
|
||||||
<td data-label="Estoque">
|
|
||||||
<span style="color:${p.estoqueAtual <= p.estoqueMinimo ? 'var(--accent-primary)' : 'var(--text-primary)'}">
|
|
||||||
${p.estoqueAtual} ${p.estoqueAtual <= p.estoqueMinimo ? '⚠️' : ''}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td data-label="Tipo"><span class="badge ${p.itemCozinha ? 'badge-cozinha' : 'badge-barman'}">${p.itemCozinha ? 'Cozinha' : 'Bar'}</span></td>
|
|
||||||
<td data-label="Status"><span class="badge ${p.ativo ? 'badge-success' : 'badge-warning'}">${p.ativo ? 'Ativo' : 'Inativo'}</span></td>
|
|
||||||
<td data-label="Ações" style="display:flex;gap:0.5rem;flex-wrap:wrap;">
|
|
||||||
<button class="btn btn-sm btn-secondary" onclick="window.editProduct(${JSON.stringify(p).replace(/"/g, '"')})">✏️</button>
|
|
||||||
<button class="btn btn-sm btn-danger" onclick="window.deleteProduct('${p.id}','${p.nome}')">🗑</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
`).join('')}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const catSel = document.getElementById('f-categoria') as HTMLSelectElement;
|
||||||
|
catSel.innerHTML = '<option value="">Todas</option>' +
|
||||||
|
categorias.map((c: any) => `<option value="${c.id}">${c.nome}</option>`).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', () => {
|
document.getElementById('btn-novo-produto')?.addEventListener('click', () => {
|
||||||
clearProductForm();
|
clearProductForm();
|
||||||
(document.getElementById('modal-produto') as HTMLElement).style.display = 'flex';
|
(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 = `<tr><td colspan="7" style="text-align:center;padding:2rem;color:var(--text-secondary);">Nenhum produto encontrado com os filtros selecionados.</td></tr>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tbody.innerHTML = filtered.map((p: any) => `
|
||||||
|
<tr>
|
||||||
|
<td data-label="Produto"><strong>${p.nome}</strong>${p.descricao ? `<br><small style="color:var(--text-secondary)">${p.descricao}</small>` : ''}</td>
|
||||||
|
<td data-label="Categoria">${p.categoria?.nome ?? '—'}</td>
|
||||||
|
<td data-label="Preço">${formatBRL(p.preco)}</td>
|
||||||
|
<td data-label="Estoque">
|
||||||
|
<span style="color:${p.estoqueAtual <= p.estoqueMinimo ? 'var(--accent-primary)' : 'var(--text-primary)'}">
|
||||||
|
${p.estoqueAtual} ${p.estoqueAtual <= p.estoqueMinimo ? '⚠️' : ''}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td data-label="Tipo"><span class="badge ${p.itemCozinha ? 'badge-cozinha' : 'badge-barman'}">${p.itemCozinha ? 'Cozinha' : 'Bar'}</span></td>
|
||||||
|
<td data-label="Status"><span class="badge ${p.ativo ? 'badge-success' : 'badge-warning'}">${p.ativo ? 'Ativo' : 'Inativo'}</span></td>
|
||||||
|
<td data-label="Ações" style="display:flex;gap:0.5rem;flex-wrap:wrap;">
|
||||||
|
<button class="btn btn-sm btn-secondary" onclick="window.editProduct(${JSON.stringify(p).replace(/"/g, '"')})">✏️</button>
|
||||||
|
<button class="btn btn-sm btn-danger" onclick="window.deleteProduct('${p.id}','${p.nome}')">🗑</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
};
|
||||||
|
|
||||||
const renderTabCategorias = async () => {
|
const renderTabCategorias = async () => {
|
||||||
const tc = document.getElementById('tab-content')!;
|
const tc = document.getElementById('tab-content')!;
|
||||||
const cats = await api.get('/categorias');
|
const cats = await api.get('/categorias');
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ export async function renderOrders(container: HTMLElement) {
|
|||||||
<span class="detalhes-summary-client" id="detalhes-label-client"></span>
|
<span class="detalhes-summary-client" id="detalhes-label-client"></span>
|
||||||
</div>
|
</div>
|
||||||
<div id="detalhes-label-status"></div>
|
<div id="detalhes-label-status"></div>
|
||||||
|
<div id="detalhes-label-usuario" style="font-size:0.8rem;color:var(--text-secondary);margin-top:0.25rem;"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="detalhes-edit-section">
|
<div class="detalhes-edit-section">
|
||||||
@@ -175,6 +176,7 @@ export async function renderOrders(container: HTMLElement) {
|
|||||||
<th style="text-align:center;">Qtd</th>
|
<th style="text-align:center;">Qtd</th>
|
||||||
<th style="text-align:right;">Subtotal</th>
|
<th style="text-align:right;">Subtotal</th>
|
||||||
<th>Obs</th>
|
<th>Obs</th>
|
||||||
|
<th>Adicionado por</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th>Ações</th>
|
<th>Ações</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -294,6 +296,7 @@ async function loadComandas(params?: { status?: string; dataInicio?: string; dat
|
|||||||
<tr>
|
<tr>
|
||||||
<th>Identificador</th>
|
<th>Identificador</th>
|
||||||
<th>Cliente</th>
|
<th>Cliente</th>
|
||||||
|
<th>Aberto por</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th>Total</th>
|
<th>Total</th>
|
||||||
<th>Aberta em</th>
|
<th>Aberta em</th>
|
||||||
@@ -305,6 +308,7 @@ async function loadComandas(params?: { status?: string; dataInicio?: string; dat
|
|||||||
<tr>
|
<tr>
|
||||||
<td data-label="Mesa" class="cmd-ident"><strong>${c.identificador}</strong></td>
|
<td data-label="Mesa" class="cmd-ident"><strong>${c.identificador}</strong></td>
|
||||||
<td data-label="Cliente" class="cmd-client">${c.nomeCliente ?? ''}</td>
|
<td data-label="Cliente" class="cmd-client">${c.nomeCliente ?? ''}</td>
|
||||||
|
<td data-label="Aberto por" class="cmd-usuario" style="color:var(--text-secondary);font-size:0.85rem;">${c.usuario?.nome ?? '—'}</td>
|
||||||
<td data-label="Status" class="cmd-status"><span class="badge status-${c.status.toLowerCase()}">${c.status}</span></td>
|
<td data-label="Status" class="cmd-status"><span class="badge status-${c.status.toLowerCase()}">${c.status}</span></td>
|
||||||
<td data-label="Total" class="cmd-total"><strong>${formatBRL(calcularTotalFinal(c))}</strong></td>
|
<td data-label="Total" class="cmd-total"><strong>${formatBRL(calcularTotalFinal(c))}</strong></td>
|
||||||
<td data-label="Aberta em" class="cmd-data">${new Date(c.criadoEm).toLocaleString('pt-BR')}</td>
|
<td data-label="Aberta em" class="cmd-data">${new Date(c.criadoEm).toLocaleString('pt-BR')}</td>
|
||||||
@@ -607,6 +611,12 @@ function bindOrderEvents() {
|
|||||||
document.getElementById('detalhes-label-client')!.textContent = c.nomeCliente ? `· ${c.nomeCliente}` : '';
|
document.getElementById('detalhes-label-client')!.textContent = c.nomeCliente ? `· ${c.nomeCliente}` : '';
|
||||||
document.getElementById('detalhes-label-status')!.innerHTML = statusBadge(c.status);
|
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
|
// Render itens
|
||||||
const tbody = document.getElementById('detalhes-itens-tbody')!;
|
const tbody = document.getElementById('detalhes-itens-tbody')!;
|
||||||
const itens = c.itens ?? [];
|
const itens = c.itens ?? [];
|
||||||
@@ -614,7 +624,7 @@ function bindOrderEvents() {
|
|||||||
const canDelete = user && ['ADMIN', 'CAIXA'].includes(user.role) && c.status === 'ABERTA';
|
const canDelete = user && ['ADMIN', 'CAIXA'].includes(user.role) && c.status === 'ABERTA';
|
||||||
|
|
||||||
if (itens.length === 0) {
|
if (itens.length === 0) {
|
||||||
tbody.innerHTML = `<tr><td colspan="6" style="text-align:center;color:var(--text-secondary);">Nenhum item adicionado</td></tr>`;
|
tbody.innerHTML = `<tr><td colspan="7" style="text-align:center;color:var(--text-secondary);">Nenhum item adicionado</td></tr>`;
|
||||||
} else {
|
} else {
|
||||||
tbody.innerHTML = itens.map((i: any) => `
|
tbody.innerHTML = itens.map((i: any) => `
|
||||||
<tr>
|
<tr>
|
||||||
@@ -627,6 +637,7 @@ function bindOrderEvents() {
|
|||||||
: (i.observacao ?? '')
|
: (i.observacao ?? '')
|
||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
|
<td data-label="Adicionado por" class="item-usuario" style="font-size:0.8rem;color:var(--text-secondary);">${i.usuario?.nome ?? '—'}</td>
|
||||||
<td data-label="Status" class="item-status"><span class="badge badge-${i.status === 'ENTREGUE' ? 'success' : 'warning'}">${i.status}</span></td>
|
<td data-label="Status" class="item-status"><span class="badge badge-${i.status === 'ENTREGUE' ? 'success' : 'warning'}">${i.status}</span></td>
|
||||||
<td data-label="Ações" class="item-actions">
|
<td data-label="Ações" class="item-actions">
|
||||||
${canDelete ? `
|
${canDelete ? `
|
||||||
|
|||||||
Reference in New Issue
Block a user