import { v } from 'convex/values'; import type { Id } from './_generated/dataModel'; import type { MutationCtx, QueryCtx } from './_generated/server'; import { mutation, query } from './_generated/server'; // ========== HELPERS ========== async function getUsuarioAutenticado(ctx: QueryCtx | MutationCtx) { const identity = await ctx.auth.getUserIdentity(); if (!identity) { throw new Error('Usuário não autenticado'); } const usuario = await ctx.db .query('usuarios') .filter((q) => q.eq(q.field('email'), identity.email)) .first(); if (!usuario) { throw new Error('Usuário não encontrado'); } return usuario; } async function getEtapaAtualDoPedido(ctx: QueryCtx | MutationCtx, pedidoId: Id<'pedidos'>) { const etapaAtual = await ctx.db .query('pedidoEtapasHistorico') .withIndex('by_pedidoId_atual', (q) => q.eq('pedidoId', pedidoId).eq('atual', true)) .first(); return etapaAtual; } // ========== ETAPAS - QUERIES ========== export const listEtapas = query({ args: {}, returns: v.array( v.object({ _id: v.id('pedidoFluxoEtapa'), _creationTime: v.number(), nome: v.string(), codigo: v.string(), descricao: v.optional(v.string()), setorId: v.optional(v.id('setores')), setorNome: v.optional(v.string()), tempoEstimadoDias: v.optional(v.number()), incluirNoTimeline: v.boolean(), ordem: v.number(), criadoEm: v.number(), atualizadoEm: v.number() }) ), handler: async (ctx) => { const etapas = await ctx.db.query('pedidoFluxoEtapa').withIndex('by_ordem').collect(); const result = await Promise.all( etapas.map(async (etapa) => { let setorNome: string | undefined; if (etapa.setorId) { const setor = await ctx.db.get(etapa.setorId); setorNome = setor?.nome; } return { ...etapa, setorNome }; }) ); return result; } }); export const getEtapa = query({ args: { id: v.id('pedidoFluxoEtapa') }, returns: v.union( v.object({ _id: v.id('pedidoFluxoEtapa'), _creationTime: v.number(), nome: v.string(), codigo: v.string(), descricao: v.optional(v.string()), setorId: v.optional(v.id('setores')), tempoEstimadoDias: v.optional(v.number()), incluirNoTimeline: v.boolean(), ordem: v.number(), criadoEm: v.number(), atualizadoEm: v.number() }), v.null() ), handler: async (ctx, args) => { return await ctx.db.get(args.id); } }); export const getEtapaByCodigo = query({ args: { codigo: v.string() }, returns: v.union( v.object({ _id: v.id('pedidoFluxoEtapa'), _creationTime: v.number(), nome: v.string(), codigo: v.string(), descricao: v.optional(v.string()), setorId: v.optional(v.id('setores')), tempoEstimadoDias: v.optional(v.number()), incluirNoTimeline: v.boolean(), ordem: v.number(), criadoEm: v.number(), atualizadoEm: v.number() }), v.null() ), handler: async (ctx, args) => { return await ctx.db .query('pedidoFluxoEtapa') .withIndex('by_codigo', (q) => q.eq('codigo', args.codigo)) .first(); } }); // ========== ETAPAS - MUTATIONS ========== export const createEtapa = mutation({ args: { nome: v.string(), codigo: v.string(), descricao: v.optional(v.string()), setorId: v.optional(v.id('setores')), tempoEstimadoDias: v.optional(v.number()), incluirNoTimeline: v.boolean() }, returns: v.id('pedidoFluxoEtapa'), handler: async (ctx, args) => { await getUsuarioAutenticado(ctx); // Verificar se já existe etapa com este código const existente = await ctx.db .query('pedidoFluxoEtapa') .withIndex('by_codigo', (q) => q.eq('codigo', args.codigo)) .first(); if (existente) { throw new Error(`Já existe uma etapa com o código "${args.codigo}"`); } // Obter a maior ordem atual const todasEtapas = await ctx.db.query('pedidoFluxoEtapa').collect(); const maiorOrdem = todasEtapas.reduce((max, e) => Math.max(max, e.ordem), 0); const now = Date.now(); return await ctx.db.insert('pedidoFluxoEtapa', { nome: args.nome, codigo: args.codigo, descricao: args.descricao, setorId: args.setorId, tempoEstimadoDias: args.tempoEstimadoDias, incluirNoTimeline: args.incluirNoTimeline, ordem: maiorOrdem + 1, criadoEm: now, atualizadoEm: now }); } }); export const updateEtapa = mutation({ args: { id: v.id('pedidoFluxoEtapa'), nome: v.optional(v.string()), codigo: v.optional(v.string()), descricao: v.optional(v.string()), setorId: v.optional(v.id('setores')), tempoEstimadoDias: v.optional(v.number()), incluirNoTimeline: v.optional(v.boolean()), ordem: v.optional(v.number()) }, returns: v.null(), handler: async (ctx, args) => { await getUsuarioAutenticado(ctx); const etapa = await ctx.db.get(args.id); if (!etapa) { throw new Error('Etapa não encontrada'); } const codigo = args.codigo; // Se estiver mudando o código, verificar duplicidade if (codigo && codigo !== etapa.codigo) { const existente = await ctx.db .query('pedidoFluxoEtapa') .withIndex('by_codigo', (q) => q.eq('codigo', codigo)) .first(); if (existente) { throw new Error(`Já existe uma etapa com o código "${args.codigo}"`); } } await ctx.db.patch(args.id, { ...(args.nome !== undefined && { nome: args.nome }), ...(args.codigo !== undefined && { codigo: args.codigo }), ...(args.descricao !== undefined && { descricao: args.descricao }), ...(args.setorId !== undefined && { setorId: args.setorId }), ...(args.tempoEstimadoDias !== undefined && { tempoEstimadoDias: args.tempoEstimadoDias }), ...(args.incluirNoTimeline !== undefined && { incluirNoTimeline: args.incluirNoTimeline }), ...(args.ordem !== undefined && { ordem: args.ordem }), atualizadoEm: Date.now() }); return null; } }); export const deleteEtapa = mutation({ args: { id: v.id('pedidoFluxoEtapa') }, returns: v.null(), handler: async (ctx, args) => { await getUsuarioAutenticado(ctx); const etapa = await ctx.db.get(args.id); if (!etapa) { throw new Error('Etapa não encontrada'); } // Verificar se há transições usando esta etapa const transicoesOrigem = await ctx.db .query('pedidoFluxoTransicao') .withIndex('by_etapaOrigemId', (q) => q.eq('etapaOrigemId', args.id)) .collect(); if (transicoesOrigem.length > 0) { throw new Error('Não é possível excluir: esta etapa possui transições de saída'); } // Verificar se há transições de destino const transicoesDestino = await ctx.db .query('pedidoFluxoTransicao') .filter((q) => q.eq(q.field('etapaDestinoId'), args.id)) .collect(); if (transicoesDestino.length > 0) { throw new Error('Não é possível excluir: esta etapa é destino de outras transições'); } // Verificar se há histórico usando esta etapa const historico = await ctx.db .query('pedidoEtapasHistorico') .withIndex('by_etapaId', (q) => q.eq('etapaId', args.id)) .first(); if (historico) { throw new Error('Não é possível excluir: esta etapa já foi utilizada em pedidos'); } await ctx.db.delete(args.id); return null; } }); export const reordenarEtapas = mutation({ args: { ordens: v.array( v.object({ id: v.id('pedidoFluxoEtapa'), ordem: v.number() }) ) }, returns: v.null(), handler: async (ctx, args) => { await getUsuarioAutenticado(ctx); for (const item of args.ordens) { await ctx.db.patch(item.id, { ordem: item.ordem, atualizadoEm: Date.now() }); } return null; } }); // ========== TRANSIÇÕES - QUERIES ========== export const listTransicoes = query({ args: {}, returns: v.array( v.object({ _id: v.id('pedidoFluxoTransicao'), _creationTime: v.number(), etapaOrigemId: v.id('pedidoFluxoEtapa'), etapaOrigemNome: v.string(), etapaDestinoId: v.id('pedidoFluxoEtapa'), etapaDestinoNome: v.string(), isPadrao: v.boolean(), criadoEm: v.number() }) ), handler: async (ctx) => { const transicoes = await ctx.db.query('pedidoFluxoTransicao').collect(); const result = await Promise.all( transicoes.map(async (t) => { const origem = await ctx.db.get(t.etapaOrigemId); const destino = await ctx.db.get(t.etapaDestinoId); return { ...t, etapaOrigemNome: origem?.nome ?? 'Desconhecido', etapaDestinoNome: destino?.nome ?? 'Desconhecido' }; }) ); return result; } }); export const getProximasEtapas = query({ args: { pedidoId: v.id('pedidos') }, returns: v.array( v.object({ _id: v.id('pedidoFluxoEtapa'), nome: v.string(), codigo: v.string(), isPadrao: v.boolean() }) ), handler: async (ctx, args) => { const etapaAtualHistorico = await getEtapaAtualDoPedido(ctx, args.pedidoId); if (!etapaAtualHistorico) { // Pedido não tem etapa, retornar a primeira etapa do fluxo const primeiraEtapa = await ctx.db.query('pedidoFluxoEtapa').withIndex('by_ordem').first(); if (!primeiraEtapa) return []; return [ { _id: primeiraEtapa._id, nome: primeiraEtapa.nome, codigo: primeiraEtapa.codigo, isPadrao: true } ]; } // Buscar transições a partir da etapa atual const transicoes = await ctx.db .query('pedidoFluxoTransicao') .withIndex('by_etapaOrigemId', (q) => q.eq('etapaOrigemId', etapaAtualHistorico.etapaId)) .collect(); const result = await Promise.all( transicoes.map(async (t) => { const etapa = await ctx.db.get(t.etapaDestinoId); if (!etapa) return null; return { _id: etapa._id, nome: etapa.nome, codigo: etapa.codigo, isPadrao: t.isPadrao }; }) ); return result.filter((r): r is NonNullable => r !== null); } }); // ========== TRANSIÇÕES - MUTATIONS ========== export const createTransicao = mutation({ args: { etapaOrigemId: v.id('pedidoFluxoEtapa'), etapaDestinoId: v.id('pedidoFluxoEtapa') }, returns: v.id('pedidoFluxoTransicao'), handler: async (ctx, args) => { await getUsuarioAutenticado(ctx); if (args.etapaOrigemId === args.etapaDestinoId) { throw new Error('A etapa de origem e destino não podem ser iguais'); } // Verificar se etapas existem const origem = await ctx.db.get(args.etapaOrigemId); const destino = await ctx.db.get(args.etapaDestinoId); if (!origem) throw new Error('Etapa de origem não encontrada'); if (!destino) throw new Error('Etapa de destino não encontrada'); // Verificar se já existe esta transição const existente = await ctx.db .query('pedidoFluxoTransicao') .withIndex('by_etapaOrigemId', (q) => q.eq('etapaOrigemId', args.etapaOrigemId)) .filter((q) => q.eq(q.field('etapaDestinoId'), args.etapaDestinoId)) .first(); if (existente) { throw new Error('Esta transição já existe'); } // Verificar se já existe alguma transição de saída para esta origem const transicoesExistentes = await ctx.db .query('pedidoFluxoTransicao') .withIndex('by_etapaOrigemId', (q) => q.eq('etapaOrigemId', args.etapaOrigemId)) .collect(); // Se for a primeira transição, ela é a padrão const isPadrao = transicoesExistentes.length === 0; return await ctx.db.insert('pedidoFluxoTransicao', { etapaOrigemId: args.etapaOrigemId, etapaDestinoId: args.etapaDestinoId, isPadrao, criadoEm: Date.now() }); } }); export const deleteTransicao = mutation({ args: { id: v.id('pedidoFluxoTransicao') }, returns: v.null(), handler: async (ctx, args) => { await getUsuarioAutenticado(ctx); const transicao = await ctx.db.get(args.id); if (!transicao) { throw new Error('Transição não encontrada'); } await ctx.db.delete(args.id); // Se era a transição padrão, definir outra como padrão if (transicao.isPadrao) { const outraTransicao = await ctx.db .query('pedidoFluxoTransicao') .withIndex('by_etapaOrigemId', (q) => q.eq('etapaOrigemId', transicao.etapaOrigemId)) .first(); if (outraTransicao) { await ctx.db.patch(outraTransicao._id, { isPadrao: true }); } } return null; } }); export const setTransicaoPadrao = mutation({ args: { id: v.id('pedidoFluxoTransicao') }, returns: v.null(), handler: async (ctx, args) => { await getUsuarioAutenticado(ctx); const transicao = await ctx.db.get(args.id); if (!transicao) { throw new Error('Transição não encontrada'); } // Remover isPadrao de todas as transições da mesma origem const todasTransicoes = await ctx.db .query('pedidoFluxoTransicao') .withIndex('by_etapaOrigemId', (q) => q.eq('etapaOrigemId', transicao.etapaOrigemId)) .collect(); for (const t of todasTransicoes) { if (t._id !== args.id && t.isPadrao) { await ctx.db.patch(t._id, { isPadrao: false }); } } // Definir esta como padrão await ctx.db.patch(args.id, { isPadrao: true }); return null; } }); // ========== HISTÓRICO E TIMELINE ========== export const getEtapasHistorico = query({ args: { pedidoId: v.id('pedidos') }, returns: v.array( v.object({ _id: v.id('pedidoEtapasHistorico'), _creationTime: v.number(), pedidoId: v.id('pedidos'), etapaId: v.id('pedidoFluxoEtapa'), etapaNome: v.string(), etapaCodigo: v.string(), inicioData: v.number(), fimData: v.optional(v.number()), funcionarioId: v.optional(v.id('funcionarios')), funcionarioNome: v.optional(v.string()), atual: v.boolean() }) ), handler: async (ctx, args) => { const historico = await ctx.db .query('pedidoEtapasHistorico') .withIndex('by_pedidoId', (q) => q.eq('pedidoId', args.pedidoId)) .collect(); // Ordenar por inicioData historico.sort((a, b) => a.inicioData - b.inicioData); const result = await Promise.all( historico.map(async (h) => { const etapa = await ctx.db.get(h.etapaId); let funcionarioNome: string | undefined; if (h.funcionarioId) { const funcionario = await ctx.db.get(h.funcionarioId); funcionarioNome = funcionario?.nome; } return { ...h, etapaNome: etapa?.nome ?? 'Desconhecido', etapaCodigo: etapa?.codigo ?? 'desconhecido', funcionarioNome }; }) ); return result; } }); export const getPedidoTimeline = query({ args: { pedidoId: v.id('pedidos') }, returns: v.object({ passado: v.array( v.object({ etapaId: v.id('pedidoFluxoEtapa'), etapaNome: v.string(), etapaCodigo: v.string(), inicioData: v.number(), fimData: v.optional(v.number()), funcionarioNome: v.optional(v.string()), atual: v.boolean(), incluirNoTimeline: v.boolean() }) ), futuro: v.array( v.object({ etapaId: v.id('pedidoFluxoEtapa'), etapaNome: v.string(), etapaCodigo: v.string(), dataPrevisao: v.number(), incluirNoTimeline: v.boolean() }) ) }), handler: async (ctx, args) => { // Buscar histórico passado const historico = await ctx.db .query('pedidoEtapasHistorico') .withIndex('by_pedidoId', (q) => q.eq('pedidoId', args.pedidoId)) .collect(); historico.sort((a, b) => a.inicioData - b.inicioData); const passado = await Promise.all( historico.map(async (h) => { const etapa = await ctx.db.get(h.etapaId); let funcionarioNome: string | undefined; if (h.funcionarioId) { const funcionario = await ctx.db.get(h.funcionarioId); funcionarioNome = funcionario?.nome; } return { etapaId: h.etapaId, etapaNome: etapa?.nome ?? 'Desconhecido', etapaCodigo: etapa?.codigo ?? 'desconhecido', inicioData: h.inicioData, fimData: h.fimData, funcionarioNome, atual: h.atual, incluirNoTimeline: etapa?.incluirNoTimeline ?? true }; }) ); // Calcular previsão futura usando transições padrão const futuro: { etapaId: Id<'pedidoFluxoEtapa'>; etapaNome: string; etapaCodigo: string; dataPrevisao: number; incluirNoTimeline: boolean; }[] = []; const etapaAtualHistorico = historico.find((h) => h.atual); if (etapaAtualHistorico) { let etapaAtualId = etapaAtualHistorico.etapaId; let dataPrevisao = Date.now(); // Calcular tempo decorrido na etapa atual const etapaAtual = await ctx.db.get(etapaAtualId); if (etapaAtual?.tempoEstimadoDias) { dataPrevisao += etapaAtual.tempoEstimadoDias * 24 * 60 * 60 * 1000; } // Seguir transições padrão até não ter mais const visitadas = new Set(); let iteracoes = 0; const MAX_ITERACOES = 20; // Evitar loop infinito while (iteracoes < MAX_ITERACOES) { iteracoes++; if (visitadas.has(etapaAtualId)) break; visitadas.add(etapaAtualId); // Buscar transição padrão const transicaoPadrao = await ctx.db .query('pedidoFluxoTransicao') .withIndex('by_etapaOrigemId_isPadrao', (q) => q.eq('etapaOrigemId', etapaAtualId).eq('isPadrao', true) ) .first(); if (!transicaoPadrao) break; const proximaEtapa = await ctx.db.get(transicaoPadrao.etapaDestinoId); if (!proximaEtapa) break; futuro.push({ etapaId: proximaEtapa._id, etapaNome: proximaEtapa.nome, etapaCodigo: proximaEtapa.codigo, dataPrevisao, incluirNoTimeline: proximaEtapa.incluirNoTimeline }); // Atualizar para próxima iteração if (proximaEtapa.tempoEstimadoDias) { dataPrevisao += proximaEtapa.tempoEstimadoDias * 24 * 60 * 60 * 1000; } etapaAtualId = proximaEtapa._id; } } return { passado, futuro }; } }); // ========== MUDANÇA DE ETAPA ========== export const mudarEtapa = mutation({ args: { pedidoId: v.id('pedidos'), novaEtapaId: v.id('pedidoFluxoEtapa'), funcionarioId: v.optional(v.id('funcionarios')) }, returns: v.id('pedidoEtapasHistorico'), handler: async (ctx, args) => { const usuario = await getUsuarioAutenticado(ctx); const pedido = await ctx.db.get(args.pedidoId); if (!pedido) { throw new Error('Pedido não encontrado'); } const novaEtapa = await ctx.db.get(args.novaEtapaId); if (!novaEtapa) { throw new Error('Etapa não encontrada'); } const now = Date.now(); // Buscar etapa atual const etapaAtualHistorico = await getEtapaAtualDoPedido(ctx, args.pedidoId); if (etapaAtualHistorico) { // Validar se a transição é permitida const transicaoPermitida = await ctx.db .query('pedidoFluxoTransicao') .withIndex('by_etapaOrigemId', (q) => q.eq('etapaOrigemId', etapaAtualHistorico.etapaId)) .filter((q) => q.eq(q.field('etapaDestinoId'), args.novaEtapaId)) .first(); if (!transicaoPermitida) { throw new Error('Transição não permitida para esta etapa'); } // Finalizar etapa atual await ctx.db.patch(etapaAtualHistorico._id, { atual: false, fimData: now }); } // Criar novo registro de etapa const novoHistoricoId = await ctx.db.insert('pedidoEtapasHistorico', { pedidoId: args.pedidoId, etapaId: args.novaEtapaId, inicioData: now, funcionarioId: args.funcionarioId, atual: true }); // Registrar no histórico de pedidos await ctx.db.insert('historicoPedidos', { pedidoId: args.pedidoId, usuarioId: usuario._id, acao: 'alteracao_etapa', detalhes: JSON.stringify({ novaEtapa: novaEtapa.codigo, novaEtapaNome: novaEtapa.nome, funcionarioId: args.funcionarioId }), data: now }); return novoHistoricoId; } }); export const iniciarFluxoPedido = mutation({ args: { pedidoId: v.id('pedidos') }, returns: v.id('pedidoEtapasHistorico'), handler: async (ctx, args) => { const usuario = await getUsuarioAutenticado(ctx); const pedido = await ctx.db.get(args.pedidoId); if (!pedido) { throw new Error('Pedido não encontrado'); } // Verificar se já tem histórico const historicoExistente = await ctx.db .query('pedidoEtapasHistorico') .withIndex('by_pedidoId', (q) => q.eq('pedidoId', args.pedidoId)) .first(); if (historicoExistente) { throw new Error('Este pedido já possui histórico de etapas'); } // Buscar primeira etapa do fluxo const primeiraEtapa = await ctx.db.query('pedidoFluxoEtapa').withIndex('by_ordem').first(); if (!primeiraEtapa) { throw new Error('Nenhuma etapa configurada no fluxo'); } const now = Date.now(); // Criar primeiro registro const historicoId = await ctx.db.insert('pedidoEtapasHistorico', { pedidoId: args.pedidoId, etapaId: primeiraEtapa._id, inicioData: now, atual: true }); // Registrar no histórico await ctx.db.insert('historicoPedidos', { pedidoId: args.pedidoId, usuarioId: usuario._id, acao: 'inicio_fluxo', detalhes: JSON.stringify({ etapa: primeiraEtapa.codigo, etapaNome: primeiraEtapa.nome }), data: now }); return historicoId; } }); export const atribuirFuncionario = mutation({ args: { pedidoId: v.id('pedidos'), funcionarioId: v.id('funcionarios') }, returns: v.null(), handler: async (ctx, args) => { await getUsuarioAutenticado(ctx); const etapaAtual = await getEtapaAtualDoPedido(ctx, args.pedidoId); if (!etapaAtual) { throw new Error('Pedido não possui etapa atual'); } const funcionario = await ctx.db.get(args.funcionarioId); if (!funcionario) { throw new Error('Funcionário não encontrado'); } await ctx.db.patch(etapaAtual._id, { funcionarioId: args.funcionarioId }); return null; } }); // ========== QUERY PARA OBTER ETAPA ATUAL ========== export const getEtapaAtual = query({ args: { pedidoId: v.id('pedidos') }, returns: v.union( v.object({ _id: v.id('pedidoEtapasHistorico'), etapaId: v.id('pedidoFluxoEtapa'), etapaNome: v.string(), etapaCodigo: v.string(), setorId: v.optional(v.id('setores')), setorNome: v.optional(v.string()), inicioData: v.number(), funcionarioId: v.optional(v.id('funcionarios')), funcionarioNome: v.optional(v.string()) }), v.null() ), handler: async (ctx, args) => { const etapaAtualHistorico = await getEtapaAtualDoPedido(ctx, args.pedidoId); if (!etapaAtualHistorico) return null; const etapa = await ctx.db.get(etapaAtualHistorico.etapaId); if (!etapa) return null; let setorNome: string | undefined; if (etapa.setorId) { const setor = await ctx.db.get(etapa.setorId); setorNome = setor?.nome; } let funcionarioNome: string | undefined; if (etapaAtualHistorico.funcionarioId) { const funcionario = await ctx.db.get(etapaAtualHistorico.funcionarioId); funcionarioNome = funcionario?.nome; } return { _id: etapaAtualHistorico._id, etapaId: etapa._id, etapaNome: etapa.nome, etapaCodigo: etapa.codigo, setorId: etapa.setorId, setorNome, inicioData: etapaAtualHistorico.inicioData, funcionarioId: etapaAtualHistorico.funcionarioId, funcionarioNome }; } });