import { v } from 'convex/values'; import { api, internal } from './_generated/api'; import type { Doc, Id } from './_generated/dataModel'; import type { MutationCtx, QueryCtx } from './_generated/server'; import { internalMutation, mutation, query } from './_generated/server'; import { getCurrentUserFunction } from './auth'; // ========== HELPERS ========== async function getUsuarioAutenticado(ctx: QueryCtx | MutationCtx) { const user = await getCurrentUserFunction(ctx); if (!user) throw new Error('Unauthorized'); return user; } // ========== QUERIES ========== export const list = query({ args: {}, returns: v.array( v.object({ _id: v.id('pedidos'), _creationTime: v.number(), numeroSei: v.optional(v.string()), status: v.union( v.literal('em_rascunho'), v.literal('aguardando_aceite'), v.literal('em_analise'), v.literal('precisa_ajustes'), v.literal('cancelado'), v.literal('concluido') ), // acaoId removed from return criadoPor: v.id('usuarios'), criadoEm: v.number(), atualizadoEm: v.number() }) ), handler: async (ctx) => { return await ctx.db.query('pedidos').collect(); } }); export const get = query({ args: { id: v.id('pedidos') }, returns: v.union( v.object({ _id: v.id('pedidos'), _creationTime: v.number(), numeroSei: v.optional(v.string()), status: v.union( v.literal('em_rascunho'), v.literal('aguardando_aceite'), v.literal('em_analise'), v.literal('precisa_ajustes'), v.literal('cancelado'), v.literal('concluido') ), acaoId: v.optional(v.id('acoes')), criadoPor: v.id('usuarios'), criadoEm: v.number(), atualizadoEm: v.number() }), v.null() ), handler: async (ctx, args) => { return await ctx.db.get(args.id); } }); export const getItems = query({ args: { pedidoId: v.id('pedidos') }, returns: v.array( v.object({ _id: v.id('objetoItems'), _creationTime: v.number(), pedidoId: v.id('pedidos'), objetoId: v.id('objetos'), ataId: v.optional(v.id('atas')), acaoId: v.optional(v.id('acoes')), modalidade: v.union( v.literal('dispensa'), v.literal('inexgibilidade'), v.literal('adesao'), v.literal('consumo') ), valorEstimado: v.string(), valorReal: v.optional(v.string()), quantidade: v.number(), adicionadoPor: v.id('funcionarios'), adicionadoPorNome: v.string(), criadoEm: v.number() }) ), handler: async (ctx, args) => { const items = await ctx.db .query('objetoItems') .withIndex('by_pedidoId', (q) => q.eq('pedidoId', args.pedidoId)) .collect(); // Get employee names const itemsWithNames = await Promise.all( items.map(async (item) => { const funcionario = await ctx.db.get(item.adicionadoPor); return { ...item, adicionadoPorNome: funcionario?.nome || 'Desconhecido' }; }) ); return itemsWithNames; } }); export const getHistory = query({ args: { pedidoId: v.id('pedidos') }, handler: async (ctx, args) => { const history = await ctx.db .query('historicoPedidos') .withIndex('by_pedidoId', (q) => q.eq('pedidoId', args.pedidoId)) .order('desc') .collect(); // Get user names const historyWithNames = await Promise.all( history.map(async (entry) => { const usuario = await ctx.db.get(entry.usuarioId); return { _id: entry._id, _creationTime: entry._creationTime, pedidoId: entry.pedidoId, usuarioId: entry.usuarioId, usuarioNome: usuario?.nome || 'Desconhecido', acao: entry.acao, detalhes: entry.detalhes, data: entry.data }; }) ); return historyWithNames; } }); export const checkExisting = query({ args: { numeroSei: v.optional(v.string()), itensFiltro: v.optional( v.array( v.object({ objetoId: v.id('objetos'), modalidade: v.union( v.literal('dispensa'), v.literal('inexgibilidade'), v.literal('adesao'), v.literal('consumo') ) }) ) ) }, returns: v.array( v.object({ _id: v.id('pedidos'), _creationTime: v.number(), numeroSei: v.optional(v.string()), status: v.union( v.literal('em_rascunho'), v.literal('aguardando_aceite'), v.literal('em_analise'), v.literal('precisa_ajustes'), v.literal('cancelado'), v.literal('concluido') ), // acaoId removed criadoPor: v.id('usuarios'), criadoEm: v.number(), atualizadoEm: v.number(), matchingItems: v.optional( v.array( v.object({ objetoId: v.id('objetos'), modalidade: v.union( v.literal('dispensa'), v.literal('inexgibilidade'), v.literal('adesao'), v.literal('consumo') ), quantidade: v.number() }) ) ) }) ), handler: async (ctx, args) => { const user = await getCurrentUserFunction(ctx); if (!user) return []; const openStatuses: Array< 'em_rascunho' | 'aguardando_aceite' | 'em_analise' | 'precisa_ajustes' > = ['em_rascunho', 'aguardando_aceite', 'em_analise', 'precisa_ajustes']; // 1) Buscar todos os pedidos "abertos" usando o índice by_status let pedidosAbertos: Doc<'pedidos'>[] = []; for (const status of openStatuses) { const partial = await ctx.db .query('pedidos') .withIndex('by_status', (q) => q.eq('status', status)) .collect(); pedidosAbertos = pedidosAbertos.concat(partial); } // 2) Filtros opcionais: numeroSei pedidosAbertos = pedidosAbertos.filter((p) => { if (args.numeroSei && p.numeroSei !== args.numeroSei) return false; return true; }); // 3) Filtro por itens (objetoId + modalidade), se informado, e coleta de matchingItems const resultados = []; const itensFiltro = args.itensFiltro ?? []; for (const pedido of pedidosAbertos) { let include = true; let matchingItems: { objetoId: Id<'objetos'>; modalidade: Doc<'objetoItems'>['modalidade']; quantidade: number; }[] = []; // Se houver filtro de itens, verificamos se o pedido tem ALGUM dos itens (objetoId + modalidade) if (itensFiltro.length > 0) { const items = await ctx.db .query('objetoItems') .withIndex('by_pedidoId', (q) => q.eq('pedidoId', pedido._id)) .collect(); const matching = items.filter((i) => itensFiltro.some((f) => f.objetoId === i.objetoId && f.modalidade === i.modalidade) ); if (matching.length > 0) { matchingItems = matching.map((i) => ({ objetoId: i.objetoId, modalidade: i.modalidade, quantidade: i.quantidade })); } else { include = false; } } if (include) { resultados.push({ _id: pedido._id, _creationTime: pedido._creationTime, numeroSei: pedido.numeroSei, status: pedido.status, criadoPor: pedido.criadoPor, criadoEm: pedido.criadoEm, atualizadoEm: pedido.atualizadoEm, matchingItems: matchingItems.length > 0 ? matchingItems : undefined }); } } return resultados; } }); // ========== MUTATIONS ========== export const create = mutation({ args: { numeroSei: v.optional(v.string()) // acaoId removed }, returns: v.id('pedidos'), handler: async (ctx, args) => { const user = await getUsuarioAutenticado(ctx); // 1. Check Config const config = await ctx.db.query('config').first(); if (!config || !config.comprasSetorId) { throw new Error('Setor de Compras não configurado. Contate o administrador.'); } // 2. Check Existing (Double check) - Removed acaoId check here as it's now per item // 3. Create Order const pedidoId = await ctx.db.insert('pedidos', { numeroSei: args.numeroSei, status: 'em_rascunho', criadoPor: user._id, criadoEm: Date.now(), atualizadoEm: Date.now() }); // 4. Create History await ctx.db.insert('historicoPedidos', { pedidoId, usuarioId: user._id, acao: 'criacao', detalhes: JSON.stringify({ numeroSei: args.numeroSei }), data: Date.now() }); return pedidoId; } }); export const updateSeiNumber = mutation({ args: { pedidoId: v.id('pedidos'), numeroSei: v.string() }, returns: v.null(), handler: async (ctx, args) => { const user = await getUsuarioAutenticado(ctx); const pedido = await ctx.db.get(args.pedidoId); if (!pedido) throw new Error('Pedido not found'); // Check if SEI number is already taken by another order const existing = await ctx.db .query('pedidos') .filter((q) => q.and(q.eq(q.field('numeroSei'), args.numeroSei), q.neq(q.field('_id'), args.pedidoId)) ) .first(); if (existing) { throw new Error('Este número SEI já está em uso por outro pedido.'); } const oldSei = pedido.numeroSei; await ctx.db.patch(args.pedidoId, { numeroSei: args.numeroSei, atualizadoEm: Date.now() }); await ctx.db.insert('historicoPedidos', { pedidoId: args.pedidoId, usuarioId: user._id, acao: 'atualizacao_sei', detalhes: JSON.stringify({ de: oldSei, para: args.numeroSei }), data: Date.now() }); } }); export const addItem = mutation({ args: { pedidoId: v.id('pedidos'), objetoId: v.id('objetos'), ataId: v.optional(v.id('atas')), acaoId: v.optional(v.id('acoes')), modalidade: v.union( v.literal('dispensa'), v.literal('inexgibilidade'), v.literal('adesao'), v.literal('consumo') ), valorEstimado: v.string(), quantidade: v.number() }, returns: v.null(), handler: async (ctx, args) => { const user = await getUsuarioAutenticado(ctx); // Ensure user has a funcionarioId linked if (!user.funcionarioId) { throw new Error('Usuário não vinculado a um funcionário.'); } // Check if item already exists with same parameters (user, object, action, modalidade) const existingItem = await ctx.db .query('objetoItems') .withIndex('by_pedidoId', (q) => q.eq('pedidoId', args.pedidoId)) .filter((q) => q.and( q.eq(q.field('objetoId'), args.objetoId), q.eq(q.field('adicionadoPor'), user.funcionarioId), q.eq(q.field('acaoId'), args.acaoId), q.eq(q.field('ataId'), args.ataId), q.eq(q.field('modalidade'), args.modalidade) ) ) .first(); if (existingItem) { // Increment quantity const novaQuantidade = existingItem.quantidade + args.quantidade; await ctx.db.patch(existingItem._id, { quantidade: novaQuantidade }); await ctx.db.insert('historicoPedidos', { pedidoId: args.pedidoId, usuarioId: user._id, acao: 'adicao_item_incremento', detalhes: JSON.stringify({ objetoId: args.objetoId, quantidadeAdicionada: args.quantidade, novaQuantidade }), data: Date.now() }); } else { // Insert new item await ctx.db.insert('objetoItems', { pedidoId: args.pedidoId, objetoId: args.objetoId, ataId: args.ataId, acaoId: args.acaoId, modalidade: args.modalidade, valorEstimado: args.valorEstimado, quantidade: args.quantidade, adicionadoPor: user.funcionarioId, criadoEm: Date.now() }); await ctx.db.insert('historicoPedidos', { pedidoId: args.pedidoId, usuarioId: user._id, acao: 'adicao_item', detalhes: JSON.stringify({ objetoId: args.objetoId, valor: args.valorEstimado, quantidade: args.quantidade, acaoId: args.acaoId, ataId: args.ataId, modalidade: args.modalidade }), data: Date.now() }); } await ctx.db.patch(args.pedidoId, { atualizadoEm: Date.now() }); } }); export const updateItemQuantity = mutation({ args: { itemId: v.id('objetoItems'), novaQuantidade: v.number() }, returns: v.null(), handler: async (ctx, args) => { const user = await getUsuarioAutenticado(ctx); if (!user.funcionarioId) { throw new Error('Usuário não vinculado a um funcionário.'); } const item = await ctx.db.get(args.itemId); if (!item) throw new Error('Item não encontrado.'); const quantidadeAnterior = item.quantidade; // Check permission: only item owner can change quantity const isOwner = item.adicionadoPor === user.funcionarioId; if (!isOwner) { throw new Error('Apenas quem adicionou este item pode alterar a quantidade.'); } // Update quantity await ctx.db.patch(args.itemId, { quantidade: args.novaQuantidade }); await ctx.db.patch(item.pedidoId, { atualizadoEm: Date.now() }); // Create history entry await ctx.db.insert('historicoPedidos', { pedidoId: item.pedidoId, usuarioId: user._id, acao: 'alteracao_quantidade', detalhes: JSON.stringify({ objetoId: item.objetoId, quantidadeAnterior, novaQuantidade: args.novaQuantidade }), data: Date.now() }); } }); export const removeItem = mutation({ args: { itemId: v.id('objetoItems') }, returns: v.null(), handler: async (ctx, args) => { const user = await getUsuarioAutenticado(ctx); const item = await ctx.db.get(args.itemId); if (!item) throw new Error('Item not found'); await ctx.db.delete(args.itemId); await ctx.db.patch(item.pedidoId, { atualizadoEm: Date.now() }); await ctx.db.insert('historicoPedidos', { pedidoId: item.pedidoId, usuarioId: user._id, acao: 'remocao_item', detalhes: JSON.stringify({ objetoId: item.objetoId, valor: item.valorEstimado }), data: Date.now() }); } }); export const removeItemsBatch = mutation({ args: { itemIds: v.array(v.id('objetoItems')) }, returns: v.null(), handler: async (ctx, args) => { const user = await getUsuarioAutenticado(ctx); if (!user.funcionarioId) { throw new Error('Usuário não vinculado a um funcionário.'); } if (args.itemIds.length === 0) { return null; } const firstItem = await ctx.db.get(args.itemIds[0]); if (!firstItem) { throw new Error('Item não encontrado.'); } const pedidoId = firstItem.pedidoId; const pedido = await ctx.db.get(pedidoId); if (!pedido) { throw new Error('Pedido não encontrado.'); } if (pedido.status !== 'em_rascunho' && pedido.status !== 'precisa_ajustes') { throw new Error( 'Só é possível remover itens em pedidos em rascunho ou que precisam de ajustes.' ); } for (const itemId of args.itemIds) { const item = await ctx.db.get(itemId); if (!item) continue; if (item.pedidoId !== pedidoId) { throw new Error('Todos os itens devem pertencer ao mesmo pedido.'); } if (item.adicionadoPor !== user.funcionarioId) { throw new Error('Você só pode remover itens que você adicionou.'); } await ctx.db.delete(itemId); await ctx.db.insert('historicoPedidos', { pedidoId, usuarioId: user._id, acao: 'remocao_item', detalhes: JSON.stringify({ objetoId: item.objetoId, valor: item.valorEstimado }), data: Date.now() }); } await ctx.db.patch(pedidoId, { atualizadoEm: Date.now() }); return null; } }); export const splitPedido = mutation({ args: { pedidoId: v.id('pedidos'), itemIds: v.array(v.id('objetoItems')), numeroSei: v.optional(v.string()) }, returns: v.id('pedidos'), handler: async (ctx, args) => { const user = await getUsuarioAutenticado(ctx); if (!user.funcionarioId) { throw new Error('Usuário não vinculado a um funcionário.'); } if (args.itemIds.length === 0) { throw new Error('Selecione ao menos um item para dividir o pedido.'); } const pedidoOriginal = await ctx.db.get(args.pedidoId); if (!pedidoOriginal) { throw new Error('Pedido não encontrado.'); } if (pedidoOriginal.status !== 'em_rascunho' && pedidoOriginal.status !== 'precisa_ajustes') { throw new Error('Só é possível dividir pedidos em rascunho ou que precisam de ajustes.'); } const itens = []; for (const itemId of args.itemIds) { const item = await ctx.db.get(itemId); if (!item) { continue; } if (item.pedidoId !== args.pedidoId) { throw new Error('Todos os itens devem pertencer ao mesmo pedido.'); } if (item.adicionadoPor !== user.funcionarioId) { throw new Error('Você só pode mover itens que você adicionou.'); } itens.push(item); } if (itens.length === 0) { throw new Error('Nenhum dos itens selecionados pôde ser usado para divisão.'); } const novoPedidoId = await ctx.db.insert('pedidos', { numeroSei: args.numeroSei, status: 'em_rascunho', criadoPor: user._id, criadoEm: Date.now(), atualizadoEm: Date.now() }); for (const item of itens) { await ctx.db.patch(item._id, { pedidoId: novoPedidoId }); } await ctx.db.insert('historicoPedidos', { pedidoId: args.pedidoId, usuarioId: user._id, acao: 'divisao_pedido_origem', detalhes: JSON.stringify({ itensMovidos: itens.map((i) => i._id), novoPedidoId }), data: Date.now() }); await ctx.db.insert('historicoPedidos', { pedidoId: novoPedidoId, usuarioId: user._id, acao: 'divisao_pedido_destino', detalhes: JSON.stringify({ pedidoOriginalId: args.pedidoId, itensRecebidos: itens.map((i) => i._id) }), data: Date.now() }); await ctx.db.patch(args.pedidoId, { atualizadoEm: Date.now() }); return novoPedidoId; } }); export const updateItem = mutation({ args: { itemId: v.id('objetoItems'), valorEstimado: v.string(), modalidade: v.union( v.literal('dispensa'), v.literal('inexgibilidade'), v.literal('adesao'), v.literal('consumo') ), acaoId: v.optional(v.id('acoes')), ataId: v.optional(v.id('atas')) }, returns: v.null(), handler: async (ctx, args) => { const user = await getUsuarioAutenticado(ctx); if (!user.funcionarioId) { throw new Error('Usuário não vinculado a um funcionário.'); } const item = await ctx.db.get(args.itemId); if (!item) throw new Error('Item não encontrado.'); // Apenas quem adicionou o item pode editá-lo const isOwner = item.adicionadoPor === user.funcionarioId; if (!isOwner) { throw new Error('Apenas quem adicionou este item pode editá-lo.'); } const oldValues = { valorEstimado: item.valorEstimado, modalidade: item.modalidade, acaoId: 'acaoId' in item ? item.acaoId : undefined, ataId: 'ataId' in item ? item.ataId : undefined }; await ctx.db.patch(args.itemId, { valorEstimado: args.valorEstimado, modalidade: args.modalidade, acaoId: args.acaoId, ataId: args.ataId }); await ctx.db.patch(item.pedidoId, { atualizadoEm: Date.now() }); await ctx.db.insert('historicoPedidos', { pedidoId: item.pedidoId, usuarioId: user._id, acao: 'edicao_item', detalhes: JSON.stringify({ objetoId: item.objetoId, de: oldValues, para: { valorEstimado: args.valorEstimado, modalidade: args.modalidade, acaoId: args.acaoId, ataId: args.ataId } }), data: Date.now() }); } }); export const updateStatus = mutation({ args: { pedidoId: v.id('pedidos'), novoStatus: v.union( v.literal('em_rascunho'), v.literal('aguardando_aceite'), v.literal('em_analise'), v.literal('precisa_ajustes'), v.literal('cancelado'), v.literal('concluido') ) }, returns: v.null(), handler: async (ctx, args) => { const user = await getUsuarioAutenticado(ctx); const pedido = await ctx.db.get(args.pedidoId); if (!pedido) throw new Error('Pedido not found'); const oldStatus = pedido.status; await ctx.db.patch(args.pedidoId, { status: args.novoStatus, atualizadoEm: Date.now() }); await ctx.db.insert('historicoPedidos', { pedidoId: args.pedidoId, usuarioId: user._id, acao: 'alteracao_status', detalhes: JSON.stringify({ de: oldStatus, para: args.novoStatus }), data: Date.now() }); // Trigger Notifications await ctx.scheduler.runAfter(0, internal.pedidos.notifyStatusChange, { pedidoId: args.pedidoId, oldStatus, newStatus: args.novoStatus, actorId: user._id }); } }); // ========== INTERNAL (NOTIFICATIONS) ========== export const notifyStatusChange = internalMutation({ args: { pedidoId: v.id('pedidos'), oldStatus: v.string(), newStatus: v.string(), actorId: v.id('usuarios') }, returns: v.null(), handler: async (ctx, args) => { const pedido = await ctx.db.get(args.pedidoId); if (!pedido) return; const actor = await ctx.db.get(args.actorId); const actorName = actor ? actor.nome : 'Alguém'; const recipients = new Set(); // Set of User IDs // 1. If status is "aguardando_aceite", notify Purchasing Sector if (args.newStatus === 'aguardando_aceite') { const config = await ctx.db.query('config').first(); if (config && config.comprasSetorId) { // Find all employees in this sector const funcionarioSetores = await ctx.db .query('funcionarioSetores') .withIndex('by_setorId', (q) => q.eq('setorId', config.comprasSetorId!)) .collect(); const funcionarioIds = funcionarioSetores.map((fs) => fs.funcionarioId); // Find users linked to these employees for (const fId of funcionarioIds) { const user = await ctx.db .query('usuarios') .withIndex('by_funcionarioId', (q) => q.eq('funcionarioId', fId)) .first(); if (user) recipients.add(user._id); } } } // 2. Notify "Involved" users (Creator + Item Adders) // Always notify creator (unless they are the actor) if (pedido.criadoPor !== args.actorId) { recipients.add(pedido.criadoPor); } // Notify item adders const items = await ctx.db .query('objetoItems') .withIndex('by_pedidoId', (q) => q.eq('pedidoId', args.pedidoId)) .collect(); for (const item of items) { const user = await ctx.db .query('usuarios') .withIndex('by_funcionarioId', (q) => q.eq('funcionarioId', item.adicionadoPor)) .first(); if (user && user._id !== args.actorId) { recipients.add(user._id); } } // Send Notifications for (const recipientId of recipients) { const recipientIdTyped = recipientId as Id<'usuarios'>; // 1. In-App Notification await ctx.db.insert('notificacoes', { usuarioId: recipientIdTyped, tipo: 'alerta_seguranca', // Using alerta_seguranca as the closest match for system notifications titulo: `Pedido ${pedido.numeroSei || 'sem número SEI'} atualizado`, descricao: `Status alterado de "${args.oldStatus}" para "${args.newStatus}" por ${actorName}.`, lida: false, criadaEm: Date.now(), remetenteId: args.actorId }); // 2. Email Notification (Async) const recipientUser = await ctx.db.get(recipientIdTyped); if (recipientUser && recipientUser.email) { // Using enfileirarEmail directly await ctx.scheduler.runAfter(0, api.email.enfileirarEmail, { destinatario: recipientUser.email, destinatarioId: recipientIdTyped, assunto: `Atualização no Pedido ${pedido.numeroSei || 'sem número SEI'}`, corpo: `O pedido ${pedido.numeroSei || 'sem número SEI'} teve seu status alterado de "${args.oldStatus}" para "${args.newStatus}" por ${actorName}.`, enviadoPor: args.actorId }); } } } });