feat: sistema GastroBar PDV completo

- Backend: Fastify + Prisma/SQLite com autenticacao JWT
- CRUD completo: usuarios, categorias, produtos, comandas
- Sistema de comandas com itens, status, pagamento
- Controle de estoque (entrada/saida automatica)
- Relatorios e dashboard com KPIs
- Notificacoes sonoras na cozinha
- Frontend: SPA vanilla TS com tema dark glassmorphism
- Mobile-first com bottom-sheet e cards compactos
- Busca de comandas por periodo
- Modal de detalhes com secao editavel colapsavel
This commit is contained in:
2026-07-21 12:47:15 -03:00
commit 402ac776ac
55 changed files with 7403 additions and 0 deletions

View File

@@ -0,0 +1,28 @@
import { FastifyRequest, FastifyReply } from 'fastify';
import { loginSchema, changePasswordSchema } from '../schemas/auth.schema';
import { loginService, changePassword } from '../services/auth.service';
export async function loginController(request: FastifyRequest, reply: FastifyReply) {
const body = loginSchema.parse(request.body);
try {
const user = await loginService(body);
const token = await reply.jwtSign(user, { expiresIn: '1d' });
return reply.send({ token, user });
} catch (error: any) {
return reply.status(401).send({ error: error.message });
}
}
export async function changePasswordController(request: FastifyRequest, reply: FastifyReply) {
const body = changePasswordSchema.parse(request.body);
const user = request.user as { id: string };
try {
await changePassword(user.id, body.senhaAtual, body.novaSenha);
return reply.send({ message: 'Senha alterada com sucesso' });
} catch (error: any) {
return reply.status(400).send({ error: error.message });
}
}

View File

@@ -0,0 +1,49 @@
import { FastifyRequest, FastifyReply } from 'fastify';
import { categorySchema } from '../schemas/category.schema';
import * as categoryService from '../services/category.service';
import { z } from 'zod';
export async function createCategoryController(request: FastifyRequest, reply: FastifyReply) {
const data = categorySchema.parse(request.body);
try {
const category = await categoryService.createCategory(data);
return reply.status(201).send(category);
} catch (error: any) {
return reply.status(400).send({ error: error.message });
}
}
export async function listCategoriesController(request: FastifyRequest, reply: FastifyReply) {
const categories = await categoryService.listCategories();
return reply.send(categories);
}
export async function getCategoryController(request: FastifyRequest, reply: FastifyReply) {
const { id } = z.object({ id: z.string().uuid() }).parse(request.params);
const category = await categoryService.getCategoryById(id);
if (!category) {
return reply.status(404).send({ error: 'Categoria não encontrada' });
}
return reply.send(category);
}
export async function updateCategoryController(request: FastifyRequest, reply: FastifyReply) {
const { id } = z.object({ id: z.string().uuid() }).parse(request.params);
const data = categorySchema.parse(request.body);
try {
const category = await categoryService.updateCategory(id, data);
return reply.send(category);
} catch (error: any) {
return reply.status(400).send({ error: error.message });
}
}
export async function deleteCategoryController(request: FastifyRequest, reply: FastifyReply) {
const { id } = z.object({ id: z.string().uuid() }).parse(request.params);
try {
await categoryService.deleteCategory(id);
return reply.status(204).send();
} catch (error: any) {
return reply.status(400).send({ error: error.message });
}
}

View File

@@ -0,0 +1,94 @@
import { FastifyRequest, FastifyReply } from 'fastify';
import { createOrderSchema, addItemOrderSchema, updateItemStatusSchema, updateOrderSchema, payOrderSchema } from '../schemas/order.schema';
import * as orderService from '../services/order.service';
import { z } from 'zod';
export async function listOrdersController(request: FastifyRequest, reply: FastifyReply) {
const querySchema = z.object({
status: z.string().optional(),
dataInicio: z.string().optional(),
dataFim: z.string().optional(),
});
const query = querySchema.parse(request.query);
const filter: any = {};
if (query.status) filter.statuses = query.status.split(',');
if (query.dataInicio) filter.dataInicio = query.dataInicio;
if (query.dataFim) filter.dataFim = query.dataFim;
const orders = await orderService.listOrders(
(filter.statuses || filter.dataInicio || filter.dataFim) ? filter : undefined
);
return reply.send(orders);
}
export async function createOrderController(request: FastifyRequest, reply: FastifyReply) {
const data = createOrderSchema.parse(request.body);
const order = await orderService.createOrder(data);
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);
try {
const item = await orderService.addItemToOrder(id, data);
return reply.status(201).send(item);
} catch (error: any) {
return reply.status(400).send({ error: error.message });
}
}
export async function listKitchenBarController(request: FastifyRequest, reply: FastifyReply) {
const querySchema = z.object({
status: z.string().optional(),
itemCozinha: z.string().transform(v => v === 'true').optional()
});
const query = querySchema.parse(request.query);
const items = await orderService.listKitchenBarItems(query.itemCozinha, query.status);
return reply.send(items);
}
export async function updateItemStatusController(request: FastifyRequest, reply: FastifyReply) {
const { id } = z.object({ id: z.string().uuid() }).parse(request.params);
const data = updateItemStatusSchema.parse(request.body);
const item = await orderService.updateItemStatus(id, data);
return reply.send(item);
}
export async function payOrderController(request: FastifyRequest, reply: FastifyReply) {
const { id } = z.object({ id: z.string().uuid() }).parse(request.params);
const body = request.body && Object.keys(request.body as object).length > 0
? payOrderSchema.parse(request.body)
: undefined;
try {
const comanda = await orderService.payOrder(id, body);
return reply.send(comanda);
} catch (error: any) {
return reply.status(400).send({ error: error.message });
}
}
export async function updateOrderController(request: FastifyRequest, reply: FastifyReply) {
const { id } = z.object({ id: z.string().uuid() }).parse(request.params);
const data = updateOrderSchema.parse(request.body);
try {
const comanda = await orderService.updateOrder(id, data);
return reply.send(comanda);
} catch (error: any) {
return reply.status(400).send({ error: error.message });
}
}
export async function deleteItemFromOrderController(request: FastifyRequest, reply: FastifyReply) {
const { id } = z.object({ id: z.string().uuid() }).parse(request.params);
try {
const comanda = await orderService.deleteItemFromOrder(id);
return reply.send(comanda);
} catch (error: any) {
return reply.status(400).send({ error: error.message });
}
}

View File

@@ -0,0 +1,54 @@
import { FastifyRequest, FastifyReply } from 'fastify';
import { productSchema } from '../schemas/product.schema';
import * as productService from '../services/product.service';
import { z } from 'zod';
export async function createProductController(request: FastifyRequest, reply: FastifyReply) {
const data = productSchema.parse(request.body);
const product = await productService.createProduct(data);
return reply.status(201).send(product);
}
export async function listProductsController(request: FastifyRequest, reply: FastifyReply) {
const products = await productService.listProducts();
return reply.send(products);
}
export async function getTopProductsController(request: FastifyRequest, reply: FastifyReply) {
const { limit } = z.object({ limit: z.coerce.number().default(10) }).parse(request.query);
const products = await productService.getTopProducts(limit);
return reply.send(products);
}
export async function searchProductsController(request: FastifyRequest, reply: FastifyReply) {
const { q } = z.object({ q: z.string().min(1) }).parse(request.query);
const products = await productService.searchProducts(q);
return reply.send(products);
}
export async function getProductController(request: FastifyRequest, reply: FastifyReply) {
const { id } = z.object({ id: z.string().uuid() }).parse(request.params);
const product = await productService.getProductById(id);
if (!product) {
return reply.status(404).send({ error: 'Produto não encontrado' });
}
return reply.send(product);
}
export async function updateProductController(request: FastifyRequest, reply: FastifyReply) {
const { id } = z.object({ id: z.string().uuid() }).parse(request.params);
const data = productSchema.partial().parse(request.body);
const product = await productService.updateProduct(id, data);
return reply.send(product);
}
export async function deleteProductController(request: FastifyRequest, reply: FastifyReply) {
const { id } = z.object({ id: z.string().uuid() }).parse(request.params);
await productService.deleteProduct(id);
return reply.status(204).send();
}
export async function getLowStockProductsController(request: FastifyRequest, reply: FastifyReply) {
const products = await productService.getLowStockProducts();
return reply.send(products);
}

View File

@@ -0,0 +1,22 @@
import { FastifyRequest, FastifyReply } from 'fastify';
import * as reportService from '../services/report.service';
import { z } from 'zod';
export async function salesReportController(request: FastifyRequest, reply: FastifyReply) {
const querySchema = z.object({
inicio: z.string().optional(),
fim: z.string().optional(),
});
const query = querySchema.parse(request.query);
const inicio = query.inicio ? new Date(query.inicio) : new Date(new Date().setDate(new Date().getDate() - 30));
const fim = query.fim ? new Date(query.fim + 'T23:59:59') : new Date();
const report = await reportService.getSalesReport(inicio, fim);
return reply.send(report);
}
export async function dashboardController(request: FastifyRequest, reply: FastifyReply) {
const dashboard = await reportService.getDashboardData();
return reply.send(dashboard);
}

View File

@@ -0,0 +1,43 @@
import { FastifyRequest, FastifyReply } from 'fastify';
import { userSchema, updateUserSchema, resetPasswordSchema } from '../schemas/user.schema';
import * as userService from '../services/user.service';
import { z } from 'zod';
export async function createUserController(request: FastifyRequest, reply: FastifyReply) {
const data = userSchema.parse(request.body);
try {
const user = await userService.createUser(data);
return reply.status(201).send(user);
} catch (error: any) {
return reply.status(400).send({ error: error.message });
}
}
export async function listUsersController(request: FastifyRequest, reply: FastifyReply) {
const users = await userService.listUsers();
return reply.send(users);
}
export async function updateUserController(request: FastifyRequest, reply: FastifyReply) {
const { id } = z.object({ id: z.string().uuid() }).parse(request.params);
const data = updateUserSchema.parse(request.body);
const user = await userService.updateUser(id, data);
return reply.send(user);
}
export async function deleteUserController(request: FastifyRequest, reply: FastifyReply) {
const { id } = z.object({ id: z.string().uuid() }).parse(request.params);
await userService.deleteUser(id);
return reply.status(204).send();
}
export async function resetPasswordController(request: FastifyRequest, reply: FastifyReply) {
const { id } = z.object({ id: z.string().uuid() }).parse(request.params);
const body = resetPasswordSchema.parse(request.body);
try {
await userService.resetPassword(id, body.novaSenha);
return reply.send({ message: 'Senha redefinida com sucesso' });
} catch (error: any) {
return reply.status(400).send({ error: error.message });
}
}

3
src/lib/prisma.ts Normal file
View File

@@ -0,0 +1,3 @@
import { PrismaClient } from '@prisma/client';
export const prisma = new PrismaClient();

View File

@@ -0,0 +1,24 @@
import { FastifyRequest, FastifyReply } from 'fastify';
export async function authenticate(request: FastifyRequest, reply: FastifyReply) {
try {
await request.jwtVerify();
} catch (err) {
return reply.status(401).send({ error: 'Não autorizado' });
}
}
export function requireRoles(roles: string[]) {
return async (request: FastifyRequest, reply: FastifyReply) => {
try {
await request.jwtVerify();
const user = request.user as { role: string };
if (!roles.includes(user.role)) {
return reply.status(403).send({ error: 'Acesso negado. Permissão insuficiente.' });
}
} catch (err) {
return reply.status(401).send({ error: 'Não autorizado' });
}
};
}

View File

@@ -0,0 +1,8 @@
import { FastifyInstance } from 'fastify';
import { loginController, changePasswordController } from '../controllers/auth.controller';
import { authenticate } from '../middlewares/auth.middleware';
export async function authRoutes(app: FastifyInstance) {
app.post('/login', loginController);
app.patch('/senha', { preHandler: [authenticate] }, changePasswordController);
}

View File

@@ -0,0 +1,14 @@
import { FastifyInstance } from 'fastify';
import * as categoryController from '../controllers/category.controller';
import { authenticate, requireRoles } from '../middlewares/auth.middleware';
export async function categoryRoutes(app: FastifyInstance) {
// Leitura aberta a todos autenticados
app.get('/categorias', { preHandler: [authenticate] }, categoryController.listCategoriesController);
app.get('/categorias/:id', { preHandler: [authenticate] }, categoryController.getCategoryController);
// Escrita apenas para ADMIN
app.post('/categorias', { preHandler: [requireRoles(['ADMIN'])] }, categoryController.createCategoryController);
app.put('/categorias/:id', { preHandler: [requireRoles(['ADMIN'])] }, categoryController.updateCategoryController);
app.delete('/categorias/:id', { preHandler: [requireRoles(['ADMIN'])] }, categoryController.deleteCategoryController);
}

View File

@@ -0,0 +1,23 @@
import { FastifyInstance } from 'fastify';
import * as orderController from '../controllers/order.controller';
import { authenticate, requireRoles } from '../middlewares/auth.middleware';
export async function orderRoutes(app: FastifyInstance) {
// Listagem geral (qualquer autenticado)
app.get('/comandas', { preHandler: [authenticate] }, orderController.listOrdersController);
// Comandas (Garçom, Caixa, Admin)
app.post('/comandas', { preHandler: [requireRoles(['GARCOM', 'CAIXA', 'ADMIN'])] }, orderController.createOrderController);
app.post('/comandas/:id/itens', { preHandler: [requireRoles(['GARCOM', 'CAIXA', 'ADMIN'])] }, orderController.addItemToOrderController);
app.patch('/comandas/:id', { preHandler: [requireRoles(['GARCOM', 'CAIXA', 'ADMIN'])] }, orderController.updateOrderController);
// Pagamento (Caixa, Admin)
app.post('/comandas/:id/pagar', { preHandler: [requireRoles(['CAIXA', 'ADMIN'])] }, orderController.payOrderController);
// Painel Cozinha / Bar
app.get('/painel/itens', { preHandler: [requireRoles(['COZINHA', 'BARMAN', 'ADMIN'])] }, orderController.listKitchenBarController);
app.patch('/painel/itens/:id/status', { preHandler: [requireRoles(['COZINHA', 'BARMAN', 'ADMIN'])] }, orderController.updateItemStatusController);
// Excluir item de comanda (apenas CAIXA e ADMIN)
app.delete('/comandas/itens/:id', { preHandler: [requireRoles(['CAIXA', 'ADMIN'])] }, orderController.deleteItemFromOrderController);
}

View File

@@ -0,0 +1,16 @@
import { FastifyInstance } from 'fastify';
import * as productController from '../controllers/product.controller';
import { authenticate, requireRoles } from '../middlewares/auth.middleware';
export async function productRoutes(app: FastifyInstance) {
app.get('/produtos', { preHandler: [authenticate] }, productController.listProductsController);
app.get('/produtos/mais-vendidos', { preHandler: [authenticate] }, productController.getTopProductsController);
app.get('/produtos/busca', { preHandler: [authenticate] }, productController.searchProductsController);
app.get('/produtos/baixo-estoque', { preHandler: [requireRoles(['ADMIN'])] }, productController.getLowStockProductsController);
app.get('/produtos/:id', { preHandler: [authenticate] }, productController.getProductController);
app.post('/produtos', { preHandler: [requireRoles(['ADMIN'])] }, productController.createProductController);
app.put('/produtos/:id', { preHandler: [requireRoles(['ADMIN'])] }, productController.updateProductController);
app.patch('/produtos/:id', { preHandler: [requireRoles(['ADMIN'])] }, productController.updateProductController);
app.delete('/produtos/:id', { preHandler: [requireRoles(['ADMIN'])] }, productController.deleteProductController);
}

View File

@@ -0,0 +1,8 @@
import { FastifyInstance } from 'fastify';
import * as reportController from '../controllers/report.controller';
import { requireRoles } from '../middlewares/auth.middleware';
export async function reportRoutes(app: FastifyInstance) {
app.get('/relatorios/vendas', { preHandler: [requireRoles(['ADMIN'])] }, reportController.salesReportController);
app.get('/relatorios/dashboard', { preHandler: [requireRoles(['ADMIN'])] }, reportController.dashboardController);
}

13
src/routes/user.routes.ts Normal file
View File

@@ -0,0 +1,13 @@
import { FastifyInstance } from 'fastify';
import * as userController from '../controllers/user.controller';
import { requireRoles } from '../middlewares/auth.middleware';
export async function userRoutes(app: FastifyInstance) {
// Apenas ADMIN pode gerenciar usuários
app.get('/usuarios', { preHandler: [requireRoles(['ADMIN'])] }, userController.listUsersController);
app.post('/usuarios', { preHandler: [requireRoles(['ADMIN'])] }, userController.createUserController);
app.put('/usuarios/:id', { preHandler: [requireRoles(['ADMIN'])] }, userController.updateUserController);
app.patch('/usuarios/:id', { preHandler: [requireRoles(['ADMIN'])] }, userController.updateUserController);
app.patch('/usuarios/:id/senha', { preHandler: [requireRoles(['ADMIN'])] }, userController.resetPasswordController);
app.delete('/usuarios/:id', { preHandler: [requireRoles(['ADMIN'])] }, userController.deleteUserController);
}

View File

@@ -0,0 +1,15 @@
import { z } from 'zod';
export const loginSchema = z.object({
email: z.string().email(),
senha: z.string().min(6),
});
export type LoginInput = z.infer<typeof loginSchema>;
export const changePasswordSchema = z.object({
senhaAtual: z.string().min(6),
novaSenha: z.string().min(6),
});
export type ChangePasswordInput = z.infer<typeof changePasswordSchema>;

View File

@@ -0,0 +1,7 @@
import { z } from 'zod';
export const categorySchema = z.object({
nome: z.string().min(2),
});
export type CategoryInput = z.infer<typeof categorySchema>;

View File

@@ -0,0 +1,37 @@
import { z } from 'zod';
export const createOrderSchema = z.object({
identificador: z.string().min(1),
nomeCliente: z.string().optional(),
});
export const addItemOrderSchema = z.object({
produtoId: z.string().uuid(),
quantidade: z.number().int().positive(),
observacao: z.string().optional(),
});
export const updateItemStatusSchema = z.object({
status: z.enum(['PENDENTE', 'PREPARANDO', 'PRONTO', 'ENTREGUE']),
});
export const updateOrderSchema = z.object({
identificador: z.string().min(1).optional(),
nomeCliente: z.string().optional(),
status: z.enum(['ABERTA', 'FECHADA', 'PAGA', 'CANCELADA']).optional(),
desconto: z.number().min(0).optional(),
acrescimo: z.number().min(0).optional(),
taxaServico: z.boolean().optional(),
motivoCancelamento: z.string().optional(),
});
export const payOrderSchema = z.object({
formaPagamento: z.enum(['DINHEIRO', 'CARTAO_CREDITO', 'CARTAO_DEBITO', 'PIX']),
});
export type CreateOrderInput = z.infer<typeof createOrderSchema>;
export type AddItemOrderInput = z.infer<typeof addItemOrderSchema>;
export type UpdateItemStatusInput = z.infer<typeof updateItemStatusSchema>;
export type UpdateOrderInput = z.infer<typeof updateOrderSchema>;
export type PayOrderInput = z.infer<typeof payOrderSchema>;

View File

@@ -0,0 +1,16 @@
import { z } from 'zod';
export const productSchema = z.object({
nome: z.string().min(2),
descricao: z.string().optional(),
fotoUrl: z.string().url().optional(),
preco: z.number().positive(),
custo: z.number().positive().optional(),
estoqueAtual: z.number().int().nonnegative().default(0),
estoqueMinimo: z.number().int().nonnegative().default(0),
itemCozinha: z.boolean(),
ativo: z.boolean().default(true),
categoriaId: z.string().uuid(),
});
export type ProductInput = z.infer<typeof productSchema>;

View File

@@ -0,0 +1,25 @@
import { z } from 'zod';
export const userSchema = z.object({
nome: z.string().min(2),
email: z.string().email(),
senha: z.string().min(6),
role: z.enum(['ADMIN', 'CAIXA', 'GARCOM', 'COZINHA', 'BARMAN']),
ativo: z.boolean().default(true),
});
export const updateUserSchema = z.object({
nome: z.string().min(2).optional(),
email: z.string().email().optional(),
role: z.enum(['ADMIN', 'CAIXA', 'GARCOM', 'COZINHA', 'BARMAN']).optional(),
ativo: z.boolean().optional(),
});
export type UserInput = z.infer<typeof userSchema>;
export type UpdateUserInput = z.infer<typeof updateUserSchema>;
export const resetPasswordSchema = z.object({
novaSenha: z.string().min(6),
});
export type ResetPasswordInput = z.infer<typeof resetPasswordSchema>;

61
src/server.ts Normal file
View File

@@ -0,0 +1,61 @@
import 'dotenv/config';
import fastify from 'fastify';
import jwt from '@fastify/jwt';
import { ZodError } from 'zod';
import cors from '@fastify/cors';
import { authRoutes } from './routes/auth.routes';
import { categoryRoutes } from './routes/category.routes';
import { productRoutes } from './routes/product.routes';
import { orderRoutes } from './routes/order.routes';
import { userRoutes } from './routes/user.routes';
import { reportRoutes } from './routes/report.routes';
const app = fastify({ logger: true });
// Register CORS
app.register(cors, {
origin: '*', // Permitir de qualquer origem (útil em dev)
});
// Register JWT
app.register(jwt, {
secret: process.env.JWT_SECRET || 'fallback_secret_change_me'
});
// Global Error Handler
app.setErrorHandler((error, request, reply) => {
if (error instanceof ZodError) {
return reply.status(400).send({
message: 'Erro de validação',
issues: error.format(),
});
}
if (error.statusCode) {
return reply.status(error.statusCode).send({ message: error.message });
}
console.error(error);
return reply.status(500).send({ message: 'Erro interno do servidor' });
});
// Register Routes
app.register(authRoutes, { prefix: '/api/auth' });
app.register(categoryRoutes, { prefix: '/api' });
app.register(productRoutes, { prefix: '/api' });
app.register(orderRoutes, { prefix: '/api' });
app.register(userRoutes, { prefix: '/api' });
app.register(reportRoutes, { prefix: '/api' });
// Iniciar o servidor
const start = async () => {
try {
await app.listen({ port: 3333, host: '0.0.0.0' });
console.log('Servidor rodando na porta 3333');
} catch (err) {
app.log.error(err);
process.exit(1);
}
};
start();

View File

@@ -0,0 +1,40 @@
import { prisma } from '../lib/prisma';
import bcrypt from 'bcrypt';
import { LoginInput } from '../schemas/auth.schema';
export async function loginService({ email, senha }: LoginInput) {
const user = await prisma.usuario.findUnique({
where: { email },
});
if (!user || !user.ativo) {
throw new Error('Credenciais inválidas ou usuário inativo');
}
const isPasswordValid = await bcrypt.compare(senha, user.senha);
if (!isPasswordValid) {
throw new Error('Credenciais inválidas ou usuário inativo');
}
return {
id: user.id,
nome: user.nome,
email: user.email,
role: user.role,
};
}
export async function changePassword(userId: string, senhaAtual: string, novaSenha: string) {
const user = await prisma.usuario.findUnique({ where: { id: userId } });
if (!user) throw new Error('Usuário não encontrado');
const isPasswordValid = await bcrypt.compare(senhaAtual, user.senha);
if (!isPasswordValid) throw new Error('Senha atual incorreta');
const passwordHash = await bcrypt.hash(novaSenha, 10);
await prisma.usuario.update({
where: { id: userId },
data: { senha: passwordHash },
});
}

View File

@@ -0,0 +1,47 @@
import { prisma } from '../lib/prisma';
import { CategoryInput } from '../schemas/category.schema';
export async function createCategory(data: CategoryInput) {
return prisma.categoria.create({
data,
});
}
export async function listCategories() {
return prisma.categoria.findMany({
include: { _count: { select: { produtos: true } } },
});
}
export async function getCategoryById(id: string) {
return prisma.categoria.findUnique({
where: { id },
include: { produtos: true },
});
}
export async function updateCategory(id: string, data: CategoryInput) {
return prisma.categoria.update({
where: { id },
data,
});
}
export async function deleteCategory(id: string) {
const categoria = await prisma.categoria.findUnique({
where: { id },
include: { _count: { select: { produtos: true } } },
});
if (!categoria) {
throw new Error('Categoria não encontrada');
}
if (categoria._count.produtos > 0) {
throw new Error(`Não é possível excluir: a categoria possui ${categoria._count.produtos} produto(s). Remova ou reatribua os produtos primeiro.`);
}
return prisma.categoria.delete({
where: { id },
});
}

View File

@@ -0,0 +1,210 @@
import { prisma } from '../lib/prisma';
import { CreateOrderInput, AddItemOrderInput, UpdateItemStatusInput, UpdateOrderInput, PayOrderInput } from '../schemas/order.schema';
export interface ListOrdersFilter {
statuses?: string[];
dataInicio?: string;
dataFim?: string;
}
export async function listOrders(filter?: ListOrdersFilter) {
const where: any = {};
if (filter?.statuses?.length) {
where.status = { in: filter.statuses };
}
if (filter?.dataInicio || filter?.dataFim) {
where.criadoEm = {};
if (filter.dataInicio) where.criadoEm.gte = new Date(filter.dataInicio);
if (filter.dataFim) {
const end = new Date(filter.dataFim);
end.setHours(23, 59, 59, 999);
where.criadoEm.lte = end;
}
}
return prisma.comanda.findMany({
where,
orderBy: { criadoEm: 'desc' },
include: {
itens: {
include: { produto: true }
}
}
});
}
export async function createOrder(data: CreateOrderInput) {
return prisma.comanda.create({
data: {
identificador: data.identificador,
nomeCliente: data.nomeCliente,
status: 'ABERTA',
total: 0,
},
});
}
export async function addItemToOrder(comandaId: string, data: AddItemOrderInput) {
const produto = await prisma.produto.findUnique({
where: { id: data.produtoId },
});
if (!produto) {
throw new Error('Produto não encontrado');
}
if (produto.estoqueAtual < data.quantidade) {
throw new Error(`Estoque insuficiente. Disponível: ${produto.estoqueAtual}`);
}
const precoTotalItem = produto.preco * data.quantidade;
const [, itemPedido] = await prisma.$transaction([
prisma.produto.update({
where: { id: data.produtoId },
data: { estoqueAtual: { decrement: data.quantidade } },
}),
prisma.itemPedido.create({
data: {
quantidade: data.quantidade,
observacao: data.observacao,
precoUnitario: produto.preco,
produtoId: data.produtoId,
comandaId: comandaId,
status: 'PENDENTE',
},
}),
prisma.comanda.update({
where: { id: comandaId },
data: { total: { increment: precoTotalItem } },
}),
]);
return itemPedido;
}
export async function listKitchenBarItems(itemCozinha?: boolean, status?: string) {
const filter: any = {};
if (status) {
filter.status = status;
}
if (typeof itemCozinha === 'boolean') {
filter.produto = {
itemCozinha,
};
}
return prisma.itemPedido.findMany({
where: filter,
include: {
produto: true,
comanda: true,
},
});
}
export async function updateItemStatus(itemId: string, data: UpdateItemStatusInput) {
return prisma.itemPedido.update({
where: { id: itemId },
data: {
status: data.status,
},
});
}
export async function payOrder(comandaId: string, data?: PayOrderInput) {
const comanda = await prisma.comanda.findUnique({
where: { id: comandaId },
});
if (!comanda) {
throw new Error('Comanda não encontrada');
}
if (comanda.status !== 'ABERTA') {
throw new Error('Comanda não está aberta para pagamento');
}
return prisma.comanda.update({
where: { id: comandaId },
data: {
status: 'PAGA',
fechadoEm: new Date(),
formaPagamento: data?.formaPagamento,
},
});
}
export async function updateOrder(comandaId: string, data: UpdateOrderInput) {
const comanda = await prisma.comanda.findUnique({
where: { id: comandaId },
});
if (!comanda) {
throw new Error('Comanda não encontrada');
}
const updateData: any = {};
if (data.identificador !== undefined) updateData.identificador = data.identificador;
if (data.nomeCliente !== undefined) updateData.nomeCliente = data.nomeCliente;
if (data.status !== undefined) updateData.status = data.status;
if (data.desconto !== undefined) updateData.desconto = data.desconto;
if (data.acrescimo !== undefined) updateData.acrescimo = data.acrescimo;
if (data.taxaServico !== undefined) updateData.taxaServico = data.taxaServico;
if (data.motivoCancelamento !== undefined) updateData.motivoCancelamento = data.motivoCancelamento;
if (data.status === 'PAGA' && comanda.status !== 'PAGA') {
updateData.fechadoEm = new Date();
}
return prisma.comanda.update({
where: { id: comandaId },
data: updateData,
include: {
itens: {
include: { produto: true }
}
}
});
}
export async function deleteItemFromOrder(itemId: string) {
const item = await prisma.itemPedido.findUnique({
where: { id: itemId },
include: { comanda: true }
});
if (!item) {
throw new Error('Item do pedido não encontrado');
}
if (item.comanda.status !== 'ABERTA') {
throw new Error('Não é possível remover itens de uma comanda fechada/paga/cancelada');
}
const precoTotalItem = item.precoUnitario * item.quantidade;
const [comanda] = await prisma.$transaction([
prisma.itemPedido.delete({
where: { id: itemId }
}),
prisma.produto.update({
where: { id: item.produtoId },
data: { estoqueAtual: { increment: item.quantidade } },
}),
prisma.comanda.update({
where: { id: item.comandaId },
data: { total: { decrement: precoTotalItem } },
}),
]);
return prisma.comanda.findUnique({
where: { id: item.comandaId },
include: { itens: { include: { produto: true } } },
});
}

View File

@@ -0,0 +1,88 @@
import { prisma } from '../lib/prisma';
import { ProductInput } from '../schemas/product.schema';
export async function getTopProducts(limit = 10) {
// Agrega a soma de quantidades vendidas para cada produto
const topItems = await prisma.itemPedido.groupBy({
by: ['produtoId'],
_sum: { quantidade: true },
orderBy: { _sum: { quantidade: 'desc' } },
take: limit,
});
if (!topItems.length) {
// Fallback: retorna os últimos produtos cadastrados
return prisma.produto.findMany({
where: { ativo: true },
include: { categoria: true },
orderBy: { nome: 'asc' },
take: limit,
});
}
const ids = topItems.map(t => t.produtoId);
const produtos = await prisma.produto.findMany({
where: { id: { in: ids }, ativo: true },
include: { categoria: true },
});
// Mantém a ordem de vendas + injeta totalVendido
return ids.map(id => {
const prod = produtos.find(p => p.id === id)!;
const vendas = topItems.find(t => t.produtoId === id)?._sum.quantidade ?? 0;
return { ...prod, totalVendido: vendas };
}).filter(Boolean);
}
export async function searchProducts(query: string) {
return prisma.produto.findMany({
where: {
ativo: true,
nome: { contains: query },
},
include: { categoria: true },
orderBy: { nome: 'asc' },
take: 20,
});
}
export async function createProduct(data: ProductInput) {
return prisma.produto.create({
data,
});
}
export async function listProducts() {
return prisma.produto.findMany({
include: { categoria: true },
});
}
export async function getProductById(id: string) {
return prisma.produto.findUnique({
where: { id },
include: { categoria: true },
});
}
export async function updateProduct(id: string, data: Partial<ProductInput>) {
return prisma.produto.update({
where: { id },
data,
});
}
export async function deleteProduct(id: string) {
return prisma.produto.delete({
where: { id },
});
}
export async function getLowStockProducts() {
const products = await prisma.produto.findMany({
where: { ativo: true },
include: { categoria: true },
orderBy: { estoqueAtual: 'asc' },
});
return products.filter(p => p.estoqueAtual <= p.estoqueMinimo);
}

View File

@@ -0,0 +1,137 @@
import { prisma } from '../lib/prisma';
export async function getSalesReport(inicio: Date, fim: Date) {
const comandas = await prisma.comanda.findMany({
where: {
status: 'PAGA',
fechadoEm: { gte: inicio, lte: fim },
},
include: {
itens: {
include: { produto: true },
},
},
orderBy: { fechadoEm: 'desc' },
});
const totalVendas = comandas.reduce((acc, c) => acc + c.total, 0);
const totalDescontos = comandas.reduce((acc, c) => acc + c.desconto, 0);
const totalAcrescimos = comandas.reduce((acc, c) => acc + c.acrescimo, 0);
const totalComandas = comandas.length;
// Produtos mais vendidos
const produtoVendas: Record<string, { nome: string; quantidade: number; total: number }> = {};
for (const c of comandas) {
for (const item of c.itens) {
const key = item.produtoId;
if (!produtoVendas[key]) {
produtoVendas[key] = { nome: item.produto.nome, quantidade: 0, total: 0 };
}
produtoVendas[key].quantidade += item.quantidade;
produtoVendas[key].total += item.precoUnitario * item.quantidade;
}
}
const topProdutos = Object.values(produtoVendas)
.sort((a, b) => b.quantidade - a.quantidade)
.slice(0, 10);
// Vendas por dia
const vendasPorDia: Record<string, { vendas: number; comandas: number }> = {};
for (const c of comandas) {
const dia = c.fechadoEm!.toISOString().split('T')[0];
if (!vendasPorDia[dia]) vendasPorDia[dia] = { vendas: 0, comandas: 0 };
vendasPorDia[dia].vendas += c.total;
vendasPorDia[dia].comandas += 1;
}
// Vendas por forma de pagamento
const vendasPorPagamento: Record<string, number> = {};
for (const c of comandas) {
const fp = c.formaPagamento || 'NAO_INFORMADO';
vendasPorPagamento[fp] = (vendasPorPagamento[fp] || 0) + c.total;
}
return {
periodo: { inicio, fim },
resumo: {
totalVendas,
totalDescontos,
totalAcrescimos,
totalComandas,
ticketMedio: totalComandas > 0 ? totalVendas / totalComandas : 0,
},
topProdutos,
vendasPorDia: Object.entries(vendasPorDia).map(([data, v]) => ({ data, ...v })),
vendasPorPagamento: Object.entries(vendasPorPagamento).map(([forma, total]) => ({ forma, total })),
comandas: comandas.map(c => ({
id: c.id,
identificador: c.identificador,
nomeCliente: c.nomeCliente,
total: c.total,
desconto: c.desconto,
acrescimo: c.acrescimo,
formaPagamento: c.formaPagamento,
criadoEm: c.criadoEm,
fechadoEm: c.fechadoEm,
itensCount: c.itens.length,
})),
};
}
export async function getDashboardData() {
const now = new Date();
const inicioDia = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const inicioSemana = new Date(now);
inicioSemana.setDate(now.getDate() - now.getDay());
inicioSemana.setHours(0, 0, 0, 0);
const inicioMes = new Date(now.getFullYear(), now.getMonth(), 1);
const [comandasHoje, comandasSemana, comandasMes, totalProdutos, produtosEstoqueBaixo, comandasAbertas] = await Promise.all([
prisma.comanda.findMany({
where: { status: 'PAGA', fechadoEm: { gte: inicioDia } },
include: { itens: true },
}),
prisma.comanda.findMany({
where: { status: 'PAGA', fechadoEm: { gte: inicioSemana } },
include: { itens: true },
}),
prisma.comanda.findMany({
where: { status: 'PAGA', fechadoEm: { gte: inicioMes } },
include: { itens: true },
}),
prisma.produto.count({ where: { ativo: true } }),
prisma.produto.findMany({
where: { ativo: true },
include: { categoria: true },
}),
prisma.comanda.count({ where: { status: 'ABERTA' } }),
]);
const sumTotal = (cs: any[]) => cs.reduce((acc, c) => acc + c.total, 0);
const sumItens = (cs: any[]) => cs.reduce((acc, c) => acc + c.itens.reduce((a: number, i: any) => a + i.quantidade, 0), 0);
const estoqueBaixo = produtosEstoqueBaixo.filter(p => p.estoqueAtual <= p.estoqueMinimo);
return {
vendasHoje: {
total: sumTotal(comandasHoje),
comandas: comandasHoje.length,
itens: sumItens(comandasHoje),
},
vendasSemana: {
total: sumTotal(comandasSemana),
comandas: comandasSemana.length,
itens: sumItens(comandasSemana),
},
vendasMes: {
total: totalProdutos,
comandas: comandasMes.length,
itens: sumItens(comandasMes),
totalVendas: sumTotal(comandasMes),
},
totalProdutos,
produtosEstoqueBaixo: estoqueBaixo.length,
comandasAbertas,
};
}

View File

@@ -0,0 +1,49 @@
import { prisma } from '../lib/prisma';
import { UserInput, UpdateUserInput } from '../schemas/user.schema';
import bcrypt from 'bcrypt';
export async function createUser(data: UserInput) {
const existingUser = await prisma.usuario.findUnique({ where: { email: data.email } });
if (existingUser) throw new Error('E-mail já cadastrado');
const passwordHash = await bcrypt.hash(data.senha, 10);
return prisma.usuario.create({
data: {
...data,
senha: passwordHash,
},
select: { id: true, nome: true, email: true, role: true, ativo: true, criadoEm: true },
});
}
export async function listUsers() {
return prisma.usuario.findMany({
select: { id: true, nome: true, email: true, role: true, ativo: true, criadoEm: true },
});
}
export async function updateUser(id: string, data: UpdateUserInput) {
return prisma.usuario.update({
where: { id },
data,
select: { id: true, nome: true, email: true, role: true, ativo: true, criadoEm: true },
});
}
export async function deleteUser(id: string) {
return prisma.usuario.delete({
where: { id },
});
}
export async function resetPassword(id: string, novaSenha: string) {
const user = await prisma.usuario.findUnique({ where: { id } });
if (!user) throw new Error('Usuário não encontrado');
const passwordHash = await bcrypt.hash(novaSenha, 10);
await prisma.usuario.update({
where: { id },
data: { senha: passwordHash },
});
}