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; } }); export const listForAcceptance = query({ args: {}, returns: v.array( v.object({ _id: v.id('pedidos'), _creationTime: v.number(), numeroSei: v.optional(v.string()), status: v.string(), criadoPor: v.id('usuarios'), criadoPorNome: v.string(), criadoEm: v.number(), atualizadoEm: v.number() }) ), handler: async (ctx) => { const user = await getUsuarioAutenticado(ctx); // Security Check: Must be in Compras Sector const config = await ctx.db.query('config').first(); if (!config || !config.comprasSetorId) return []; if (!user.funcionarioId) return []; const isInSector = await ctx.db .query('funcionarioSetores') .withIndex('by_funcionarioId', (q) => q.eq('funcionarioId', user.funcionarioId!)) .filter((q) => q.eq(q.field('setorId'), config.comprasSetorId)) .first(); if (!isInSector) return []; // Fetch orders waiting for acceptance const orders = await ctx.db .query('pedidos') .withIndex('by_status', (q) => q.eq('status', 'aguardando_aceite')) .collect(); // Enrich with creator name return await Promise.all( orders.map(async (o) => { const creator = await ctx.db.get(o.criadoPor); return { _id: o._id, _creationTime: o._creationTime, numeroSei: o.numeroSei, status: o.status, criadoPor: o.criadoPor, criadoPorNome: creator?.nome || 'Desconhecido', criadoEm: o.criadoEm, atualizadoEm: o.atualizadoEm }; }) ); } }); export const listMyAnalysis = query({ args: {}, returns: v.array( v.object({ _id: v.id('pedidos'), _creationTime: v.number(), numeroSei: v.optional(v.string()), status: v.string(), criadoPor: v.id('usuarios'), criadoPorNome: v.string(), aceitoPor: v.optional(v.id('funcionarios')), criadoEm: v.number(), atualizadoEm: v.number() }) ), handler: async (ctx) => { const user = await getUsuarioAutenticado(ctx); // Security Check: Must be in Compras Sector const config = await ctx.db.query('config').first(); if (!config || !config.comprasSetorId) return []; if (!user.funcionarioId) return []; const isInSector = await ctx.db .query('funcionarioSetores') .withIndex('by_funcionarioId', (q) => q.eq('funcionarioId', user.funcionarioId!)) .filter((q) => q.eq(q.field('setorId'), config.comprasSetorId)) .first(); if (!isInSector) return []; // Fetch orders accepted by this user // We don't have an index on aceitoPor yet, but we can filter or add index. // Ideally we should add an index, but for now let's filter since volume might be low per user. // Wait, we can't filter efficiently without index if table is huge. // Let's assume we should add index or filter in memory if small. // Given the schema change was just adding the field, let's filter. // Actually, let's add the index in the schema update if possible, but I already did that step. // I'll filter for now. const orders = await ctx.db .query('pedidos') .filter((q) => q.eq(q.field('aceitoPor'), user.funcionarioId)) .collect(); return await Promise.all( orders.map(async (o) => { const creator = await ctx.db.get(o.criadoPor); return { _id: o._id, _creationTime: o._creationTime, numeroSei: o.numeroSei, status: o.status, criadoPor: o.criadoPor, criadoPorNome: creator?.nome || 'Desconhecido', aceitoPor: o.aceitoPor, criadoEm: o.criadoEm, atualizadoEm: o.atualizadoEm }; }) ); } }); export const listByItemCreator = query({ args: {}, returns: v.array( v.object({ _id: v.id('pedidos'), _creationTime: v.number(), numeroSei: v.optional(v.string()), status: v.string(), criadoPor: v.id('usuarios'), criadoPorNome: v.string(), criadoEm: v.number(), atualizadoEm: v.number() }) ), handler: async (ctx) => { const user = await getUsuarioAutenticado(ctx); if (!user.funcionarioId) return []; // Find all items added by this user const myItems = await ctx.db .query('objetoItems') .withIndex('by_adicionadoPor', (q) => q.eq('adicionadoPor', user.funcionarioId!)) .collect(); // Get unique pedidoIds const pedidoIds = [...new Set(myItems.map((i) => i.pedidoId))]; // Fetch orders const orders = await Promise.all(pedidoIds.map((id) => ctx.db.get(id))); // Filter out nulls and enrich const validOrders = orders.filter((o) => o !== null); return await Promise.all( validOrders.map(async (o) => { const creator = await ctx.db.get(o!.criadoPor); return { _id: o!._id, _creationTime: o!._creationTime, numeroSei: o!.numeroSei, status: o!.status, criadoPor: o!.criadoPor, criadoPorNome: creator?.nome || 'Desconhecido', criadoEm: o!.criadoEm, atualizadoEm: o!.atualizadoEm }; }) ); } }); export const acceptOrder = mutation({ args: { pedidoId: v.id('pedidos') }, returns: v.null(), handler: async (ctx, args) => { const user = await getUsuarioAutenticado(ctx); // Security Check: Must be in Compras Sector const config = await ctx.db.query('config').first(); if (!config || !config.comprasSetorId) { throw new Error('Setor de compras não configurado.'); } if (!user.funcionarioId) { throw new Error('Usuário sem funcionário vinculado.'); } const isInSector = await ctx.db .query('funcionarioSetores') .withIndex('by_funcionarioId', (q) => q.eq('funcionarioId', user.funcionarioId!)) .filter((q) => q.eq(q.field('setorId'), config.comprasSetorId)) .first(); if (!isInSector) { throw new Error('Você não tem permissão para aceitar pedidos (Setor inválido).'); } const pedido = await ctx.db.get(args.pedidoId); if (!pedido) throw new Error('Pedido não encontrado.'); if (pedido.status !== 'aguardando_aceite') { throw new Error('Este pedido não está aguardando aceite.'); } if (pedido.aceitoPor) { throw new Error('Este pedido já foi aceito por outro funcionário.'); } await ctx.db.patch(args.pedidoId, { status: 'em_analise', aceitoPor: user.funcionarioId, atualizadoEm: Date.now() }); await ctx.db.insert('historicoPedidos', { pedidoId: args.pedidoId, usuarioId: user._id, acao: 'aceite_pedido', detalhes: JSON.stringify({ aceitoPor: user.funcionarioId }), data: Date.now() }); } }); // ========== 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 getPermissions = query({ args: { pedidoId: v.id('pedidos') }, handler: async (ctx, args) => { const user = await getUsuarioAutenticado(ctx); const pedido = await ctx.db.get(args.pedidoId); if (!pedido || !user.funcionarioId) { return { canSendToAcceptance: false, canStartAnalysis: false, canConclude: false, canRequestAdjustments: false, canCancel: false }; } // Check Compras Sector let isInComprasSector = false; const config = await ctx.db.query('config').first(); if (config && config.comprasSetorId) { const funcionarioSetores = await ctx.db .query('funcionarioSetores') .withIndex('by_funcionarioId', (q) => q.eq('funcionarioId', user.funcionarioId!)) .filter((q) => q.eq(q.field('setorId'), config.comprasSetorId)) .first(); isInComprasSector = !!funcionarioSetores; } // Check if user has added items // Optimized: Check if ANY item in this order was added by this user const userItem = await ctx.db .query('objetoItems') .withIndex('by_pedidoId', (q) => q.eq('pedidoId', args.pedidoId)) .filter((q) => q.eq(q.field('adicionadoPor'), user.funcionarioId)) .first(); const hasAddedItems = !!userItem; const isCreator = pedido.criadoPor === user._id; return { canSendToAcceptance: (pedido.status === 'em_rascunho' || pedido.status === 'precisa_ajustes') && hasAddedItems, canStartAnalysis: pedido.status === 'aguardando_aceite' && isInComprasSector, canConclude: pedido.status === 'em_analise' && isInComprasSector, canRequestAdjustments: pedido.status === 'em_analise' && isInComprasSector, canCancel: pedido.status !== 'cancelado' && pedido.status !== 'concluido' && (isCreator || isInComprasSector) }; } }); export const enviarParaAceite = mutation({ args: { pedidoId: v.id('pedidos') }, returns: v.null(), handler: async (ctx, args) => { const user = await getUsuarioAutenticado(ctx); if (!user.funcionarioId) throw new Error('Usuário sem funcionário vinculado.'); const pedido = await ctx.db.get(args.pedidoId); if (!pedido) throw new Error('Pedido não encontrado.'); if (pedido.status !== 'em_rascunho' && pedido.status !== 'precisa_ajustes') { throw new Error('Status inválido para envio.'); } // Check if user has added items const userItem = await ctx.db .query('objetoItems') .withIndex('by_pedidoId', (q) => q.eq('pedidoId', args.pedidoId)) .filter((q) => q.eq(q.field('adicionadoPor'), user.funcionarioId)) .first(); if (!userItem) { throw new Error( 'Você precisa ter adicionado ao menos um item ao pedido para enviá-lo para aceite.' ); } const oldStatus = pedido.status; const newStatus = 'aguardando_aceite'; await ctx.db.patch(args.pedidoId, { status: newStatus, atualizadoEm: Date.now() }); await ctx.db.insert('historicoPedidos', { pedidoId: args.pedidoId, usuarioId: user._id, acao: 'alteracao_status', detalhes: JSON.stringify({ de: oldStatus, para: newStatus }), data: Date.now() }); await ctx.scheduler.runAfter(0, internal.pedidos.notifyStatusChange, { pedidoId: args.pedidoId, oldStatus, newStatus, actorId: user._id }); } }); export const iniciarAnalise = mutation({ args: { pedidoId: v.id('pedidos') }, 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 não encontrado.'); if (pedido.status !== 'aguardando_aceite') { throw new Error('O pedido não está aguardando aceite.'); } // Security Check: Must be in Compras Sector const config = await ctx.db.query('config').first(); if (!config || !config.comprasSetorId) throw new Error('Setor de compras não configurado.'); const isInSector = await ctx.db .query('funcionarioSetores') .withIndex('by_funcionarioId', (q) => q.eq('funcionarioId', user.funcionarioId!)) .filter((q) => q.eq(q.field('setorId'), config.comprasSetorId)) .first(); if (!isInSector) throw new Error('Acesso não autorizado (Setor de Compras).'); const oldStatus = pedido.status; const newStatus = 'em_analise'; await ctx.db.patch(args.pedidoId, { status: newStatus, aceitoPor: user.funcionarioId, // Also mark as accepted by this user atualizadoEm: Date.now() }); await ctx.db.insert('historicoPedidos', { pedidoId: args.pedidoId, usuarioId: user._id, acao: 'alteracao_status', detalhes: JSON.stringify({ de: oldStatus, para: newStatus }), data: Date.now() }); await ctx.scheduler.runAfter(0, internal.pedidos.notifyStatusChange, { pedidoId: args.pedidoId, oldStatus, newStatus, actorId: user._id }); } }); export const concluirPedido = mutation({ args: { pedidoId: v.id('pedidos') }, 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 não encontrado.'); if (pedido.status !== 'em_analise') { throw new Error('O pedido deve estar em análise para ser concluído.'); } // Security Check: Must be in Compras Sector const config = await ctx.db.query('config').first(); if (!config || !config.comprasSetorId) throw new Error('Setor de compras não configurado.'); const isInSector = await ctx.db .query('funcionarioSetores') .withIndex('by_funcionarioId', (q) => q.eq('funcionarioId', user.funcionarioId!)) .filter((q) => q.eq(q.field('setorId'), config.comprasSetorId)) .first(); if (!isInSector) throw new Error('Acesso não autorizado (Setor de Compras).'); const oldStatus = pedido.status; const newStatus = 'concluido'; await ctx.db.patch(args.pedidoId, { status: newStatus, atualizadoEm: Date.now() }); await ctx.db.insert('historicoPedidos', { pedidoId: args.pedidoId, usuarioId: user._id, acao: 'alteracao_status', detalhes: JSON.stringify({ de: oldStatus, para: newStatus }), data: Date.now() }); await ctx.scheduler.runAfter(0, internal.pedidos.notifyStatusChange, { pedidoId: args.pedidoId, oldStatus, newStatus, actorId: user._id }); } }); export const solicitarAjustes = mutation({ args: { pedidoId: v.id('pedidos') }, 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 não encontrado.'); if (pedido.status !== 'em_analise') { throw new Error('O pedido deve estar em análise para solicitar ajustes.'); } // Security Check: Must be in Compras Sector const config = await ctx.db.query('config').first(); if (!config || !config.comprasSetorId) throw new Error('Setor de compras não configurado.'); const isInSector = await ctx.db .query('funcionarioSetores') .withIndex('by_funcionarioId', (q) => q.eq('funcionarioId', user.funcionarioId!)) .filter((q) => q.eq(q.field('setorId'), config.comprasSetorId)) .first(); if (!isInSector) throw new Error('Acesso não autorizado (Setor de Compras).'); const oldStatus = pedido.status; const newStatus = 'precisa_ajustes'; await ctx.db.patch(args.pedidoId, { status: newStatus, aceitoPor: undefined, // Clear accepted by since it's back to adjustments atualizadoEm: Date.now() }); await ctx.db.insert('historicoPedidos', { pedidoId: args.pedidoId, usuarioId: user._id, acao: 'alteracao_status', detalhes: JSON.stringify({ de: oldStatus, para: newStatus }), data: Date.now() }); await ctx.scheduler.runAfter(0, internal.pedidos.notifyStatusChange, { pedidoId: args.pedidoId, oldStatus, newStatus, actorId: user._id }); } }); export const cancelarPedido = mutation({ args: { pedidoId: v.id('pedidos') }, 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 não encontrado.'); if (pedido.status === 'concluido' || pedido.status === 'cancelado') { throw new Error('Pedido já finalizado.'); } // Anyone involved (creator or compras) can cancel? Or just creator? // Logic: If it's creator OR Compras. const isCreator = pedido.criadoPor === user._id; let isCompras = false; const config = await ctx.db.query('config').first(); if (config && config.comprasSetorId && user.funcionarioId) { const fs = await ctx.db .query('funcionarioSetores') .withIndex('by_funcionarioId', (q) => q.eq('funcionarioId', user.funcionarioId!)) .filter((q) => q.eq(q.field('setorId'), config.comprasSetorId)) .first(); isCompras = !!fs; } if (!isCreator && !isCompras) { throw new Error('Permissão negada para cancelar este pedido.'); } const oldStatus = pedido.status; const newStatus = 'cancelado'; await ctx.db.patch(args.pedidoId, { status: newStatus, atualizadoEm: Date.now() }); await ctx.db.insert('historicoPedidos', { pedidoId: args.pedidoId, usuarioId: user._id, acao: 'alteracao_status', detalhes: JSON.stringify({ de: oldStatus, para: newStatus }), data: Date.now() }); await ctx.scheduler.runAfter(0, internal.pedidos.notifyStatusChange, { pedidoId: args.pedidoId, oldStatus, newStatus, 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 }); } } } });