diff --git a/apps/web/src/lib/components/Sidebar.svelte b/apps/web/src/lib/components/Sidebar.svelte index 2bc62d2..ff0443c 100644 --- a/apps/web/src/lib/components/Sidebar.svelte +++ b/apps/web/src/lib/components/Sidebar.svelte @@ -237,6 +237,19 @@ } ] }, + { + label: 'Configurações', + icon: 'Settings', + link: '/configuracoes', + permission: { recurso: 'pedidos', acao: 'listar' }, + submenus: [ + { + label: 'Fluxo de Pedidos', + link: '/configuracoes/fluxo-pedidos', + permission: { recurso: 'pedidos', acao: 'listar' } + } + ] + }, { label: 'Painel de TI', icon: 'Settings', diff --git a/apps/web/src/lib/components/pedidos/PedidoTimeline.svelte b/apps/web/src/lib/components/pedidos/PedidoTimeline.svelte new file mode 100644 index 0000000..b2ac6c6 --- /dev/null +++ b/apps/web/src/lib/components/pedidos/PedidoTimeline.svelte @@ -0,0 +1,270 @@ + + +{#if loading} +
+
+ Carregando timeline... +
+{:else if timeline} +
+
+ + {#each passadoVisivel as item, index (item.etapaId + '-' + item.inicioData)} + {@const isAtual = item.atual} + {@const isConcluida = !item.atual} +
+
+ {#if isConcluida} +
+ +
+ {:else} +
+ +
+ {/if} +
+
+
{item.etapaNome}
+
{formatDateShort(item.inicioData)}
+ {#if item.funcionarioNome} +
{item.funcionarioNome}
+ {/if} +
+
+ {#if index < passadoVisivel.length - 1 || futuroVisivel.length > 0} +
+ {/if} + {/each} + + + {#each futuroVisivel as item, index (item.etapaId + '-futuro-' + index)} +
+
+
+ +
+
+
+
{item.etapaNome}
+
+ Prev. + {formatDateShort(item.dataPrevisao)} +
+
+
+ {#if index < futuroVisivel.length - 1} +
+ {/if} + {/each} +
+
+{:else} +
+

Nenhuma etapa registrada ainda.

+
+{/if} + + diff --git a/apps/web/src/routes/(dashboard)/configuracoes/fluxo-pedidos/+page.svelte b/apps/web/src/routes/(dashboard)/configuracoes/fluxo-pedidos/+page.svelte new file mode 100644 index 0000000..c461b93 --- /dev/null +++ b/apps/web/src/routes/(dashboard)/configuracoes/fluxo-pedidos/+page.svelte @@ -0,0 +1,823 @@ + + + + + +
+
+

Fluxo de Pedidos

+

+ Gerencie as etapas e as transições automáticas do sistema +

+
+
+ +
+
+ + {#if loading} +
+ + Sincronizando fluxo... +
+ {:else} + +
+
+
+ +
+
+ Etapas Ativas +
+
{etapas.length}
+
Status do workflow
+
+ +
+
+ +
+
+ Transições +
+
{transicoes.length}
+
Caminhos possíveis
+
+ +
+
+ +
+
+ Transições Padrão +
+
{transicoes.filter((t) => t.isPadrao).length}
+
Avanços automáticos
+
+
+ +
+ +
+
+ +

Etapas Disponíveis

+
+ + {#if etapas.length === 0} +
+
+
+ +
+

Sem etapas

+

+ Nenhuma etapa configurada para o fluxo de pedidos. +

+ +
+
+ {:else} +
+ {#each etapas as etapa (etapa._id)} +
+
+
+ +
+ +
+
+

{etapa.nome}

+ {#if !etapa.incluirNoTimeline} +
+ Oculto +
+ {/if} +
+
+ + {etapa.codigo} + + {#if etapa.tempoEstimadoDias} +
+ + {etapa.tempoEstimadoDias}d +
+ {/if} + {#if etapa.setorNome} +
+ + {etapa.setorNome} +
+ {/if} +
+
+ +
+ + +
+
+
+ {/each} +
+ {/if} +
+ + +
+
+
+ +

Lógica de Transição

+
+ +
+ + {#if transicoes.length === 0} +
+
+
+ +
+

Nenhuma conexão

+

+ Define os caminhos que um pedido pode tomar entre as etapas. +

+ {#if etapas.length >= 2} + + {:else} +
Adicione etapas primeiro
+ {/if} +
+
+ {:else} +
+ {#each transicoes as transicao (transicao._id)} +
+
+
+
+ Origem + {transicao.etapaOrigemNome} +
+ +
+ +
+ +
+ Destino + {transicao.etapaDestinoNome} +
+
+ +
+ {#if transicao.isPadrao} +
+ + PADRÃO +
+ {:else} + + {/if} + +
+ + +
+
+
+ {/each} +
+ {/if} +
+
+ {/if} +
+ + + + +{#if showEtapaModal} + +{/if} + + +{#if showTransicaoModal} + +{/if} + + (confirmModal.open = false)} +/> + + diff --git a/apps/web/src/routes/(dashboard)/pedidos/[id]/+page.svelte b/apps/web/src/routes/(dashboard)/pedidos/[id]/+page.svelte index e57beb8..e0cfa01 100644 --- a/apps/web/src/routes/(dashboard)/pedidos/[id]/+page.svelte +++ b/apps/web/src/routes/(dashboard)/pedidos/[id]/+page.svelte @@ -8,6 +8,7 @@ import ConfirmationModal from '$lib/components/ConfirmationModal.svelte'; import Breadcrumbs from '$lib/components/layout/Breadcrumbs.svelte'; import PageShell from '$lib/components/layout/PageShell.svelte'; + import PedidoTimeline from '$lib/components/pedidos/PedidoTimeline.svelte'; import GlassCard from '$lib/components/ui/GlassCard.svelte'; import { AlertTriangle, @@ -1355,6 +1356,14 @@ + + +
+

Timeline do Pedido

+
+ +
+
diff --git a/packages/backend/convex/_generated/api.d.ts b/packages/backend/convex/_generated/api.d.ts index 5d8de54..66e272e 100644 --- a/packages/backend/convex/_generated/api.d.ts +++ b/packages/backend/convex/_generated/api.d.ts @@ -55,6 +55,7 @@ import type * as logsLogin from "../logsLogin.js"; import type * as menu from "../menu.js"; import type * as monitoramento from "../monitoramento.js"; import type * as objetos from "../objetos.js"; +import type * as pedidoFlow from "../pedidoFlow.js"; import type * as pedidos from "../pedidos.js"; import type * as permissoesAcoes from "../permissoesAcoes.js"; import type * as planejamentos from "../planejamentos.js"; @@ -155,6 +156,7 @@ declare const fullApi: ApiFromModules<{ menu: typeof menu; monitoramento: typeof monitoramento; objetos: typeof objetos; + pedidoFlow: typeof pedidoFlow; pedidos: typeof pedidos; permissoesAcoes: typeof permissoesAcoes; planejamentos: typeof planejamentos; diff --git a/packages/backend/convex/pedidoFlow.ts b/packages/backend/convex/pedidoFlow.ts new file mode 100644 index 0000000..83feff1 --- /dev/null +++ b/packages/backend/convex/pedidoFlow.ts @@ -0,0 +1,851 @@ +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 + }; + } +}); diff --git a/packages/backend/convex/tables/pedidos.ts b/packages/backend/convex/tables/pedidos.ts index bc849ec..55c2054 100644 --- a/packages/backend/convex/tables/pedidos.ts +++ b/packages/backend/convex/tables/pedidos.ts @@ -112,5 +112,46 @@ export const pedidosTables = { }) .index('by_requestId', ['requestId']) .index('by_pedidoId', ['pedidoId']) - .index('by_criadoPor', ['criadoPor']) + .index('by_criadoPor', ['criadoPor']), + + // ========== FLUXO DE PEDIDOS ========== + + // Configuração das etapas do fluxo de pedidos (dinâmico) + pedidoFluxoEtapa: defineTable({ + nome: v.string(), // Nome da etapa (ex: "Rascunho", "Aguardando Aceite") + codigo: v.string(), // Código único (ex: "rascunho", "aguardando_aceite") + descricao: v.optional(v.string()), + setorId: v.optional(v.id('setores')), // Setor responsável por esta etapa + tempoEstimadoDias: v.optional(v.number()), // Tempo estimado em dias + incluirNoTimeline: v.boolean(), // Se false, não aparece no timeline (ex: rascunho) + ordem: v.number(), // Ordem de exibição + criadoEm: v.number(), + atualizadoEm: v.number() + }) + .index('by_codigo', ['codigo']) + .index('by_ordem', ['ordem']), + + // Transições possíveis entre etapas + pedidoFluxoTransicao: defineTable({ + etapaOrigemId: v.id('pedidoFluxoEtapa'), + etapaDestinoId: v.id('pedidoFluxoEtapa'), + isPadrao: v.boolean(), // Se é a transição padrão quando há múltiplas opções + criadoEm: v.number() + }) + .index('by_etapaOrigemId', ['etapaOrigemId']) + .index('by_etapaOrigemId_isPadrao', ['etapaOrigemId', 'isPadrao']), + + // Histórico de etapas do pedido + pedidoEtapasHistorico: defineTable({ + pedidoId: v.id('pedidos'), + etapaId: v.id('pedidoFluxoEtapa'), + inicioData: v.number(), + fimData: v.optional(v.number()), + funcionarioId: v.optional(v.id('funcionarios')), + atual: v.boolean() + }) + .index('by_pedidoId', ['pedidoId']) + .index('by_pedidoId_atual', ['pedidoId', 'atual']) + .index('by_etapaId', ['etapaId']) + .index('by_funcionarioId', ['funcionarioId']) };