import { v } from 'convex/values'; import type { Doc, Id } from './_generated/dataModel'; import { mutation, query } from './_generated/server'; import { getCurrentUserFunction } from './auth'; async function getUsuarioAutenticado(ctx: Parameters[0]) { const user = await getCurrentUserFunction(ctx); if (!user) throw new Error('Unauthorized'); return user; } function normalizeOptionalString(value: string | undefined): string | undefined { const trimmed = value?.trim(); return trimmed ? trimmed : undefined; } // ========== QUERIES ========== export const list = query({ args: { status: v.optional(v.union(v.literal('rascunho'), v.literal('gerado'), v.literal('cancelado'))), statuses: v.optional( v.array(v.union(v.literal('rascunho'), v.literal('gerado'), v.literal('cancelado'))) ), responsavelId: v.optional(v.id('funcionarios')), acaoId: v.optional(v.id('acoes')), texto: v.optional(v.string()), periodoInicio: v.optional(v.number()), periodoFim: v.optional(v.number()) }, handler: async (ctx, args) => { const { periodoInicio, periodoFim, texto } = args; let base = await ctx.db.query('planejamentosPedidos').collect(); // Filtros em memória (devido à complexidade de múltiplos índices) if (args.responsavelId) { base = base.filter((p) => p.responsavelId === args.responsavelId); } if (args.acaoId) { base = base.filter((p) => p.acaoId === args.acaoId); } // Status simples ou múltiplo if (args.statuses && args.statuses.length > 0) { base = base.filter((p) => args.statuses!.includes(p.status)); } else if (args.status) { base = base.filter((p) => p.status === args.status); } if (periodoInicio) { base = base.filter((p) => p.data >= new Date(periodoInicio).toISOString().split('T')[0]); } if (periodoFim) { base = base.filter((p) => p.data <= new Date(periodoFim).toISOString().split('T')[0]); } if (texto) { const t = texto.toLowerCase(); base = base.filter( (p) => p.titulo.toLowerCase().includes(t) || p.descricao.toLowerCase().includes(t) ); } base.sort((a, b) => b.criadoEm - a.criadoEm); return await Promise.all( base.map(async (p) => { const [responsavel, acao] = await Promise.all([ ctx.db.get(p.responsavelId), p.acaoId ? ctx.db.get(p.acaoId) : Promise.resolve(null) ]); return { ...p, responsavelNome: responsavel?.nome ?? 'Desconhecido', acaoNome: acao?.nome ?? undefined }; }) ); } }); export const gerarRelatorio = query({ args: { statuses: v.optional( v.array(v.union(v.literal('rascunho'), v.literal('gerado'), v.literal('cancelado'))) ), responsavelId: v.optional(v.id('funcionarios')), acaoId: v.optional(v.id('acoes')), texto: v.optional(v.string()), periodoInicio: v.optional(v.number()), periodoFim: v.optional(v.number()) }, handler: async (ctx, args) => { // Reutilizar lógica de filtro let base = await ctx.db.query('planejamentosPedidos').collect(); if (args.responsavelId) { base = base.filter((p) => p.responsavelId === args.responsavelId); } if (args.acaoId) { base = base.filter((p) => p.acaoId === args.acaoId); } if (args.statuses && args.statuses.length > 0) { base = base.filter((p) => args.statuses!.includes(p.status)); } if (args.periodoInicio) { base = base.filter( (p) => p.data >= new Date(args.periodoInicio!).toISOString().split('T')[0] ); } if (args.periodoFim) { base = base.filter((p) => p.data <= new Date(args.periodoFim!).toISOString().split('T')[0]); } if (args.texto) { const t = args.texto.toLowerCase(); base = base.filter( (p) => p.titulo.toLowerCase().includes(t) || p.descricao.toLowerCase().includes(t) ); } base.sort((a, b) => b.criadoEm - a.criadoEm); // Enriquecer dados const planejamentosEnriquecidos = await Promise.all( base.map(async (p) => { const [responsavel, acao, itens] = await Promise.all([ ctx.db.get(p.responsavelId), p.acaoId ? ctx.db.get(p.acaoId) : Promise.resolve(null), ctx.db .query('planejamentoItens') .withIndex('by_planejamentoId', (q) => q.eq('planejamentoId', p._id)) .collect() ]); let valorEstimadoTotal = 0; for (const item of itens) { // Corrigir string '1.000,00' -> number const val = parseFloat( item.valorEstimado.replace(/\./g, '').replace(',', '.').replace('R$', '').trim() ); if (!isNaN(val)) valorEstimadoTotal += val * item.quantidade; } return { ...p, responsavelNome: responsavel?.nome ?? 'Desconhecido', acaoNome: acao?.nome ?? undefined, itensCount: itens.length, valorEstimadoTotal }; }) ); // Calcular resumo const totalPlanejamentos = base.length; const totalValorEstimado = planejamentosEnriquecidos.reduce( (acc, curr) => acc + curr.valorEstimadoTotal, 0 ); const totalPorStatus = [ { status: 'rascunho', count: 0 }, { status: 'gerado', count: 0 }, { status: 'cancelado', count: 0 } ]; base.forEach((p) => { const st = totalPorStatus.find((s) => s.status === p.status); if (st) st.count++; }); return { filtros: args, resumo: { totalPlanejamentos, totalValorEstimado, totalPorStatus }, planejamentos: planejamentosEnriquecidos }; } }); export const get = query({ args: { id: v.id('planejamentosPedidos') }, handler: async (ctx, args) => { const p = await ctx.db.get(args.id); if (!p) return null; const [responsavel, acao] = await Promise.all([ ctx.db.get(p.responsavelId), p.acaoId ? ctx.db.get(p.acaoId) : Promise.resolve(null) ]); return { ...p, responsavelNome: responsavel?.nome ?? 'Desconhecido', acaoNome: acao?.nome ?? undefined }; } }); export const listItems = query({ args: { planejamentoId: v.id('planejamentosPedidos') }, handler: async (ctx, args) => { const items = await ctx.db .query('planejamentoItens') .withIndex('by_planejamentoId', (q) => q.eq('planejamentoId', args.planejamentoId)) .collect(); // Ordenação útil: primeiro sem pedido, depois por numeroDfd, depois por criadoEm items.sort((a, b) => { const ap = a.pedidoId ? 1 : 0; const bp = b.pedidoId ? 1 : 0; if (ap !== bp) return ap - bp; const ad = (a.numeroDfd ?? '').localeCompare(b.numeroDfd ?? ''); if (ad !== 0) return ad; return a.criadoEm - b.criadoEm; }); return await Promise.all( items.map(async (it) => { const [objeto, pedido] = await Promise.all([ ctx.db.get(it.objetoId), it.pedidoId ? ctx.db.get(it.pedidoId) : Promise.resolve(null) ]); return { ...it, objetoNome: objeto?.nome ?? 'Objeto desconhecido', objetoUnidade: objeto?.unidade ?? '', pedidoNumeroSei: pedido?.numeroSei ?? undefined, pedidoStatus: pedido?.status ?? undefined }; }) ); } }); export const listPedidos = query({ args: { planejamentoId: v.id('planejamentosPedidos') }, handler: async (ctx, args) => { const links = await ctx.db .query('planejamentoPedidosLinks') .withIndex('by_planejamentoId', (q) => q.eq('planejamentoId', args.planejamentoId)) .collect(); links.sort((a, b) => a.numeroDfd.localeCompare(b.numeroDfd)); return await Promise.all( links.map(async (link) => { const pedido = await ctx.db.get(link.pedidoId); if (!pedido) { return { ...link, pedido: null, lastHistory: [] }; } const history = await ctx.db .query('historicoPedidos') .withIndex('by_pedidoId', (q) => q.eq('pedidoId', link.pedidoId)) .order('desc') .take(3); const historyWithNames = await Promise.all( history.map(async (h) => { const usuario = await ctx.db.get(h.usuarioId); return { ...h, usuarioNome: usuario?.nome ?? 'Desconhecido' }; }) ); return { ...link, pedido: { _id: pedido._id, numeroSei: pedido.numeroSei, numeroDfd: pedido.numeroDfd, status: pedido.status, criadoEm: pedido.criadoEm, atualizadoEm: pedido.atualizadoEm }, lastHistory: historyWithNames }; }) ); } }); // ========== MUTATIONS ========== export const create = mutation({ args: { titulo: v.string(), descricao: v.string(), data: v.string(), responsavelId: v.id('funcionarios'), acaoId: v.optional(v.id('acoes')), sourcePlanningId: v.optional(v.id('planejamentosPedidos')) }, returns: v.id('planejamentosPedidos'), handler: async (ctx, args) => { const user = await getUsuarioAutenticado(ctx); const now = Date.now(); const titulo = args.titulo.trim(); const descricao = args.descricao.trim(); const data = args.data.trim(); if (!titulo) throw new Error('Informe um título.'); if (!descricao) throw new Error('Informe uma descrição.'); if (!data) throw new Error('Informe uma data.'); const newItemId = await ctx.db.insert('planejamentosPedidos', { titulo, descricao, data, responsavelId: args.responsavelId, acaoId: args.acaoId, status: 'rascunho', criadoPor: user._id, criadoEm: now, atualizadoEm: now }); const sourcePlanningId = args.sourcePlanningId; if (sourcePlanningId) { const sourceItems = await ctx.db .query('planejamentoItens') .withIndex('by_planejamentoId', (q) => q.eq('planejamentoId', sourcePlanningId)) .collect(); for (const item of sourceItems) { await ctx.db.insert('planejamentoItens', { planejamentoId: newItemId, objetoId: item.objetoId, quantidade: item.quantidade, valorEstimado: item.valorEstimado, numeroDfd: item.numeroDfd, // Não copiamos o pedidoId pois é um novo planejamento criadoEm: now, atualizadoEm: now }); } } return newItemId; } }); export const update = mutation({ args: { id: v.id('planejamentosPedidos'), titulo: v.optional(v.string()), descricao: v.optional(v.string()), data: v.optional(v.string()), responsavelId: v.optional(v.id('funcionarios')), acaoId: v.optional(v.union(v.id('acoes'), v.null())) }, returns: v.null(), handler: async (ctx, args) => { await getUsuarioAutenticado(ctx); const p = await ctx.db.get(args.id); if (!p) throw new Error('Planejamento não encontrado.'); if (p.status !== 'rascunho') throw new Error('Apenas planejamentos em rascunho podem ser editados.'); const patch: Partial> & { acaoId?: Id<'acoes'> | undefined } = {}; if (args.titulo !== undefined) { const t = args.titulo.trim(); if (!t) throw new Error('Título não pode ficar vazio.'); patch.titulo = t; } if (args.descricao !== undefined) { const d = args.descricao.trim(); if (!d) throw new Error('Descrição não pode ficar vazia.'); patch.descricao = d; } if (args.data !== undefined) { const dt = args.data.trim(); if (!dt) throw new Error('Data não pode ficar vazia.'); patch.data = dt; } if (args.responsavelId !== undefined) { patch.responsavelId = args.responsavelId; } if (args.acaoId !== undefined) { patch.acaoId = args.acaoId === null ? undefined : args.acaoId; } patch.atualizadoEm = Date.now(); await ctx.db.patch(args.id, patch); return null; } }); export const addItem = mutation({ args: { planejamentoId: v.id('planejamentosPedidos'), objetoId: v.id('objetos'), quantidade: v.number(), valorEstimado: v.string(), numeroDfd: v.optional(v.string()) }, returns: v.id('planejamentoItens'), handler: async (ctx, args) => { await getUsuarioAutenticado(ctx); const p = await ctx.db.get(args.planejamentoId); if (!p) throw new Error('Planejamento não encontrado.'); if (p.status !== 'rascunho') throw new Error('Apenas planejamentos em rascunho podem ser editados.'); if (!Number.isFinite(args.quantidade) || args.quantidade <= 0) { throw new Error('Quantidade inválida.'); } const now = Date.now(); const numeroDfd = normalizeOptionalString(args.numeroDfd); const valorEstimado = args.valorEstimado.trim(); if (!valorEstimado) throw new Error('Valor estimado inválido.'); const itemId = await ctx.db.insert('planejamentoItens', { planejamentoId: args.planejamentoId, numeroDfd, objetoId: args.objetoId, quantidade: args.quantidade, valorEstimado, criadoEm: now, atualizadoEm: now }); await ctx.db.patch(args.planejamentoId, { atualizadoEm: Date.now() }); return itemId; } }); export const updateItem = mutation({ args: { itemId: v.id('planejamentoItens'), numeroDfd: v.optional(v.union(v.string(), v.null())), quantidade: v.optional(v.number()), valorEstimado: v.optional(v.string()) }, returns: v.null(), handler: async (ctx, args) => { await getUsuarioAutenticado(ctx); const it = await ctx.db.get(args.itemId); if (!it) throw new Error('Item não encontrado.'); const p = await ctx.db.get(it.planejamentoId); if (!p) throw new Error('Planejamento não encontrado.'); if (p.status !== 'rascunho') throw new Error('Apenas planejamentos em rascunho podem ser editados.'); const patch: Partial> = { atualizadoEm: Date.now() }; if (args.numeroDfd !== undefined) { patch.numeroDfd = args.numeroDfd === null ? undefined : (normalizeOptionalString(args.numeroDfd) ?? undefined); } if (args.quantidade !== undefined) { if (!Number.isFinite(args.quantidade) || args.quantidade <= 0) { throw new Error('Quantidade inválida.'); } patch.quantidade = args.quantidade; } if (args.valorEstimado !== undefined) { const vEst = args.valorEstimado.trim(); if (!vEst) throw new Error('Valor estimado inválido.'); patch.valorEstimado = vEst; } await ctx.db.patch(args.itemId, patch); await ctx.db.patch(it.planejamentoId, { atualizadoEm: Date.now() }); return null; } }); export const removeItem = mutation({ args: { itemId: v.id('planejamentoItens') }, returns: v.null(), handler: async (ctx, args) => { await getUsuarioAutenticado(ctx); const it = await ctx.db.get(args.itemId); if (!it) return null; const p = await ctx.db.get(it.planejamentoId); if (!p) throw new Error('Planejamento não encontrado.'); if (p.status !== 'rascunho') throw new Error('Apenas planejamentos em rascunho podem ser editados.'); await ctx.db.delete(args.itemId); await ctx.db.patch(it.planejamentoId, { atualizadoEm: Date.now() }); return null; } }); export const gerarPedidosPorDfd = mutation({ args: { planejamentoId: v.id('planejamentosPedidos'), dfds: v.array( v.object({ numeroDfd: v.string(), numeroSei: v.string() }) ) }, returns: v.array(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.'); } const planejamento = await ctx.db.get(args.planejamentoId); if (!planejamento) throw new Error('Planejamento não encontrado.'); if (planejamento.status !== 'rascunho') { throw new Error('Este planejamento não está em rascunho.'); } const items = await ctx.db .query('planejamentoItens') .withIndex('by_planejamentoId', (q) => q.eq('planejamentoId', args.planejamentoId)) .collect(); if (items.length === 0) { throw new Error('Adicione ao menos um item antes de gerar pedidos.'); } const itensSemDfd = items.filter((i) => !i.numeroDfd || !i.numeroDfd.trim()); if (itensSemDfd.length > 0) { throw new Error( `Existem ${itensSemDfd.length} item(ns) sem DFD. Atribua um DFD a todos os itens antes de gerar pedidos.` ); } const dfdsPayload = args.dfds.map((d) => ({ numeroDfd: d.numeroDfd.trim(), numeroSei: d.numeroSei.trim() })); if (dfdsPayload.length === 0) { throw new Error('Informe ao menos um DFD para gerar.'); } for (const d of dfdsPayload) { if (!d.numeroDfd) throw new Error('DFD inválido.'); if (!d.numeroSei) throw new Error(`Informe o número SEI para o DFD ${d.numeroDfd}.`); } // Validar que todos os DFDs existem nos itens const dfdsFromItems = new Set(items.map((i) => (i.numeroDfd as string).trim())); for (const d of dfdsPayload) { if (!dfdsFromItems.has(d.numeroDfd)) { throw new Error(`DFD ${d.numeroDfd} não existe nos itens do planejamento.`); } } // Evitar duplicidade de DFD no payload const payloadSet = new Set(); for (const d of dfdsPayload) { if (payloadSet.has(d.numeroDfd)) throw new Error(`DFD duplicado no envio: ${d.numeroDfd}.`); payloadSet.add(d.numeroDfd); } // Garantir que será gerado 1 pedido para CADA DFD existente nos itens if (payloadSet.size !== dfdsFromItems.size) { const missing = [...dfdsFromItems].filter((d) => !payloadSet.has(d)); throw new Error(`Informe o número SEI para todos os DFDs. Faltando: ${missing.join(', ')}.`); } // Não permitir gerar se algum item já tiver sido movido para pedido const jaMovidos = items.filter((i) => i.pedidoId); if (jaMovidos.length > 0) { throw new Error('Este planejamento já possui itens vinculados a pedidos.'); } const now = Date.now(); const pedidoIds: Id<'pedidos'>[] = []; for (const dfd of dfdsPayload) { // Criar pedido em rascunho (similar a pedidos.create) const pedidoId = await ctx.db.insert('pedidos', { numeroSei: dfd.numeroSei, numeroDfd: dfd.numeroDfd, status: 'em_rascunho', criadoPor: user._id, criadoEm: now, atualizadoEm: now }); pedidoIds.push(pedidoId); await ctx.db.insert('historicoPedidos', { pedidoId, usuarioId: user._id, acao: 'criacao', detalhes: JSON.stringify({ numeroSei: dfd.numeroSei, numeroDfd: dfd.numeroDfd }), data: now }); await ctx.db.insert('historicoPedidos', { pedidoId, usuarioId: user._id, acao: 'gerado_de_planejamento', detalhes: JSON.stringify({ planejamentoId: args.planejamentoId, numeroDfd: dfd.numeroDfd }), data: now }); await ctx.db.insert('planejamentoPedidosLinks', { planejamentoId: args.planejamentoId, numeroDfd: dfd.numeroDfd, pedidoId, criadoEm: now }); // Mover itens deste DFD para o pedido const itensDfd = items.filter((i) => (i.numeroDfd as string).trim() === dfd.numeroDfd); for (const it of itensDfd) { // Criar item real diretamente no pedido (sem etapa de conversão) await ctx.db.insert('objetoItems', { pedidoId, objetoId: it.objetoId, ataId: undefined, acaoId: undefined, valorEstimado: it.valorEstimado, quantidade: it.quantidade, adicionadoPor: user.funcionarioId, criadoEm: now }); await ctx.db.insert('historicoPedidos', { pedidoId, usuarioId: user._id, acao: 'adicao_item', detalhes: JSON.stringify({ objetoId: it.objetoId, valor: it.valorEstimado, quantidade: it.quantidade, acaoId: null, ataId: null, modalidade: null, origem: { planejamentoId: args.planejamentoId } }), data: now }); await ctx.db.patch(it._id, { pedidoId, atualizadoEm: Date.now() }); } } await ctx.db.patch(args.planejamentoId, { status: 'gerado', atualizadoEm: Date.now() }); return pedidoIds; } });