diff --git a/apps/web/src/lib/components/Sidebar.svelte b/apps/web/src/lib/components/Sidebar.svelte index d040a3f..a13dcb0 100644 --- a/apps/web/src/lib/components/Sidebar.svelte +++ b/apps/web/src/lib/components/Sidebar.svelte @@ -108,11 +108,26 @@ link: '/pedidos', permission: { recurso: 'pedidos', acao: 'listar' }, submenus: [ + { + label: 'Novo Pedido', + link: '/pedidos/novo', + permission: { recurso: 'pedidos', acao: 'criar' } + }, + { + label: 'Planejamentos', + link: '/pedidos/planejamento', + permission: { recurso: 'pedidos', acao: 'listar' } + }, { label: 'Meus Pedidos', link: '/pedidos', permission: { recurso: 'pedidos', acao: 'listar' }, - excludePaths: ['/pedidos/aceite', '/pedidos/minhas-analises'] + excludePaths: [ + '/pedidos/aceite', + '/pedidos/minhas-analises', + '/pedidos/novo', + '/pedidos/planejamento' + ] }, { label: 'Pedidos para Aceite', diff --git a/apps/web/src/routes/(dashboard)/pedidos/[id]/+page.svelte b/apps/web/src/routes/(dashboard)/pedidos/[id]/+page.svelte index af1cecf..7bdba57 100644 --- a/apps/web/src/routes/(dashboard)/pedidos/[id]/+page.svelte +++ b/apps/web/src/routes/(dashboard)/pedidos/[id]/+page.svelte @@ -20,9 +20,10 @@ X, XCircle } from 'lucide-svelte'; - import { goto } from '$app/navigation'; + import { afterNavigate, goto } from '$app/navigation'; import { resolve } from '$app/paths'; import { page } from '$app/state'; + import { onMount } from 'svelte'; import { maskCurrencyBRL } from '$lib/utils/masks'; import { formatarDataBR } from '$lib/utils/datas'; @@ -167,7 +168,6 @@ itemsQuery.isLoading || historyQuery.isLoading || objetosQuery.isLoading || - objetosQuery.isLoading || acoesQuery.isLoading || permissionsQuery.isLoading || requestsQuery.isLoading || @@ -394,6 +394,7 @@ // Add Item State let showAddItem = $state(false); + let hasAppliedAddItemPrefill = $state(false); let newItem = $state({ objetoId: '' as string, valorEstimado: '', @@ -404,7 +405,46 @@ }); let addingItem = $state(false); - let hasAppliedPrefill = $state(false); + function applyAddItemPrefillFromUrl() { + if (hasAppliedAddItemPrefill) return; + + const obj = page.url.searchParams.get('obj'); + if (!obj) return; + + const qtdRaw = page.url.searchParams.get('qtd'); + const qtd = qtdRaw ? Number.parseInt(qtdRaw, 10) : 1; + + const mod = page.url.searchParams.get('mod') ?? ''; + const acao = page.url.searchParams.get('acao') ?? ''; + const ata = page.url.searchParams.get('ata') ?? ''; + + showAddItem = true; + newItem.objetoId = obj; + newItem.quantidade = Number.isFinite(qtd) && qtd > 0 ? qtd : 1; + newItem.modalidade = coerceModalidade(mod); + newItem.acaoId = acao; + newItem.ataId = ata; + + const objeto = objetos.find((o: Doc<'objetos'>) => o._id === obj); + newItem.valorEstimado = maskCurrencyBRL(objeto?.valorEstimado || ''); + + void loadAtasForObjeto(obj); + + hasAppliedAddItemPrefill = true; + void goto(resolve(`/pedidos/${pedidoId}`), { + replaceState: true, + noScroll: true, + keepFocus: true + }); + } + + onMount(() => { + applyAddItemPrefillFromUrl(); + }); + + afterNavigate(() => { + applyAddItemPrefillFromUrl(); + }); // Edit SEI State let editingSei = $state(false); @@ -470,47 +510,6 @@ selectedObjeto = null; } - $effect(() => { - if (hasAppliedPrefill) return; - if (objetosQuery.isLoading || acoesQuery.isLoading) return; - - const url = page.url; - const obj = url.searchParams.get('obj'); - const qtdStr = url.searchParams.get('qtd'); - const mod = url.searchParams.get('mod') as Modalidade | null; - const acao = url.searchParams.get('acao'); - const ata = url.searchParams.get('ata'); - - if (!obj) return; - - const objeto = objetos.find((o) => o._id === obj); - if (!objeto) return; - - let quantidade = parseInt(qtdStr || '1', 10); - if (!Number.isFinite(quantidade) || quantidade <= 0) { - quantidade = 1; - } - - const modalidade: Modalidade = - mod === 'dispensa' || mod === 'inexgibilidade' || mod === 'adesao' || mod === 'consumo' - ? mod - : 'consumo'; - - showAddItem = true; - newItem = { - objetoId: obj, - valorEstimado: maskCurrencyBRL(objeto.valorEstimado || ''), - quantidade, - modalidade, - acaoId: acao || '', - ataId: ata || '' - }; - - void loadAtasForObjeto(obj); - - hasAppliedPrefill = true; - }); - async function handleAddItem() { if (!pedido || !newItem.objetoId || !newItem.valorEstimado) return; @@ -519,17 +518,19 @@ if (items.length > 0) { const referenceItem = items[0]; - const referenceModalidade = referenceItem.modalidade as Modalidade; + const referenceModalidade = (referenceItem.modalidade as Modalidade | undefined) ?? undefined; const referenceAtaId = (('ataId' in referenceItem ? referenceItem.ataId : undefined) ?? null) as string | null; const newAtaId = newItem.ataId || null; - const sameModalidade = newItem.modalidade === referenceModalidade; + const sameModalidade = !referenceModalidade || newItem.modalidade === referenceModalidade; const sameAta = referenceAtaId === newAtaId; if (!sameModalidade || !sameAta) { - const refModalidadeLabel = formatModalidade(referenceModalidade); + const refModalidadeLabel = referenceModalidade + ? formatModalidade(referenceModalidade) + : 'Não definida'; const refAtaLabel = referenceAtaId === null ? 'sem Ata vinculada' : 'com uma Ata específica'; @@ -740,7 +741,7 @@ return { valorEstimado: maskCurrencyBRL(item.valorEstimado || ''), - modalidade: item.modalidade, + modalidade: coerceModalidade((item.modalidade as string | undefined) ?? 'consumo'), acaoId: item.acaoId ?? '', ataId: item.ataId ?? '' }; diff --git a/apps/web/src/routes/(dashboard)/pedidos/planejamento/+page.server.ts b/apps/web/src/routes/(dashboard)/pedidos/planejamento/+page.server.ts new file mode 100644 index 0000000..166f51d --- /dev/null +++ b/apps/web/src/routes/(dashboard)/pedidos/planejamento/+page.server.ts @@ -0,0 +1,3 @@ +export const load = async ({ parent }) => { + await parent(); +}; diff --git a/apps/web/src/routes/(dashboard)/pedidos/planejamento/+page.svelte b/apps/web/src/routes/(dashboard)/pedidos/planejamento/+page.svelte new file mode 100644 index 0000000..dc78d70 --- /dev/null +++ b/apps/web/src/routes/(dashboard)/pedidos/planejamento/+page.svelte @@ -0,0 +1,305 @@ + + +
+
+

Planejamento de Pedidos

+ +
+ + {#if planejamentosQuery.isLoading} +
+ {#each Array(3)} +
+ {/each} +
+ {:else if planejamentosQuery.error} +
+ {planejamentosQuery.error.message} +
+ {:else} +
+ + + + + + + + + + + + + {#each planejamentos as p (p._id)} + + + + + + + + + {/each} + {#if planejamentos.length === 0} + + + + {/if} + +
+ Título + + Data + + Responsável + + Ação + + Status + + Ações +
{p.titulo}{formatDateYMD(p.data)}{p.responsavelNome}{p.acaoNome || '-'} + + {formatStatus(p.status)} + + + + + Abrir + +
Nenhum planejamento encontrado.
+
+ {/if} + + {#if showCreate} +
+
+ + +

Novo planejamento

+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+ + +
+
+
+ {/if} +
diff --git a/apps/web/src/routes/(dashboard)/pedidos/planejamento/[id]/+page.server.ts b/apps/web/src/routes/(dashboard)/pedidos/planejamento/[id]/+page.server.ts new file mode 100644 index 0000000..166f51d --- /dev/null +++ b/apps/web/src/routes/(dashboard)/pedidos/planejamento/[id]/+page.server.ts @@ -0,0 +1,3 @@ +export const load = async ({ parent }) => { + await parent(); +}; diff --git a/apps/web/src/routes/(dashboard)/pedidos/planejamento/[id]/+page.svelte b/apps/web/src/routes/(dashboard)/pedidos/planejamento/[id]/+page.svelte new file mode 100644 index 0000000..febbc21 --- /dev/null +++ b/apps/web/src/routes/(dashboard)/pedidos/planejamento/[id]/+page.svelte @@ -0,0 +1,875 @@ + + +
+ {#if planejamentoQuery.isLoading} +

Carregando...

+ {:else if planejamentoQuery.error} +

{planejamentoQuery.error.message}

+ {:else if planejamento} +
+
+
+
+

{planejamento.titulo}

+ + {formatStatus(planejamento.status)} + +
+ +

{planejamento.descricao}

+ +
+
+
Data
+
{planejamento.data}
+
+
+
Responsável
+
{planejamento.responsavelNome}
+
+
+
Ação
+
{planejamento.acaoNome || '-'}
+
+
+
+ +
+ {#if isRascunho} + {#if editingHeader} +
+ + +
+ {:else} + + {/if} + {/if} +
+
+ + {#if editingHeader} +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ {/if} +
+ + +
+
+
+

Itens

+ {#if itensSemDfdCount > 0} +

+ {itensSemDfdCount} item(ns) sem DFD. Para gerar pedidos, todos os itens precisam ter DFD. +

+ {/if} +
+
+ {#if isRascunho} + + {/if} + {#if isRascunho} +
+ + {#if searchQuery.length > 0} +
+ {#if searchResultsQuery.isLoading} +
Buscando...
+ {:else if searchResults.length === 0} +
Nenhum objeto encontrado.
+ {:else} +
    + {#each searchResults as o (o._id)} +
  • + +
  • + {/each} +
+ {/if} +
+ {/if} +
+ {/if} +
+
+ +
+ {#if itemsQuery.isLoading} +

Carregando itens...

+ {:else if items.length === 0} +

Nenhum item adicionado.

+ {:else} +
+ {#each grouped as group (group.key)} + {@const dfd = group.isSemDfd ? null : group.key} + {@const pedidoLink = dfd ? pedidosByDfd[dfd] : null} +
+
+ +
{group.items.length} item(ns)
+
+ +
+ + + + + + + + + + + + {#each group.items as it (it._id)} + + + + + + + + {/each} + +
ObjetoQtdValor Est.DFDAções
+ {it.objetoNome} + {#if it.objetoUnidade} + ({it.objetoUnidade}) + {/if} + + {#if isRascunho} + + updateItemField(it._id, { + quantidade: parseInt(e.currentTarget.value, 10) || 1 + })} + /> + {:else} + {it.quantidade} + {/if} + + {#if isRascunho} + + updateItemField(it._id, { valorEstimado: e.currentTarget.value })} + /> + {:else} + {it.valorEstimado} + {/if} + + {#if isRascunho} + { + const v = e.currentTarget.value.trim(); + updateItemField(it._id, { numeroDfd: v ? v : null }); + }} + /> + {:else} + {it.numeroDfd || '-'} + {/if} + + {#if isRascunho} + + {:else} + + {/if} +
+
+
+ {/each} +
+ {/if} +
+
+ + +
+
+

Pedidos gerados

+
+
+ {#if pedidosQuery.isLoading} +

Carregando pedidos...

+ {:else if pedidosLinks.length === 0} +

Nenhum pedido gerado ainda.

+ {:else} +
+ {#each pedidosLinks as row (row._id)} +
+
+
+
DFD: {row.numeroDfd}
+ {#if row.pedido} + + Pedido: {row.pedido.numeroSei || row.pedido._id} + + {/if} +
+ {#if row.pedido} + + {formatPedidoStatus(row.pedido.status)} + + {/if} +
+ + {#if row.lastHistory && row.lastHistory.length > 0} +
+
Últimas ações
+
    + {#each row.lastHistory as h (h._id)} +
  • + {h.usuarioNome}: {h.acao} + {new Date(h.data).toLocaleString('pt-BR')} +
  • + {/each} +
+
+ {/if} +
+ {/each} +
+ {/if} +
+
+ + + {#if showAddItemModal && addItemConfig.objeto} +
+
+ + +

Adicionar item

+ +
+

{addItemConfig.objeto.nome}

+

Unidade: {addItemConfig.objeto.unidade}

+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
+
+
+ {/if} + + + {#if showGerarModal} +
+
+ + +

Gerar pedidos

+

+ Será criado 1 pedido por DFD. Informe o número SEI de cada pedido. +

+ +
+ {#each dfdsParaGerar as dfd (dfd)} +
+
+
DFD
+
{dfd}
+
+
+ + +
+
+ {/each} +
+ +
+ + +
+
+
+ {/if} + {/if} +
diff --git a/packages/backend/convex/_generated/api.d.ts b/packages/backend/convex/_generated/api.d.ts index 1b5ac8d..f2a86ce 100644 --- a/packages/backend/convex/_generated/api.d.ts +++ b/packages/backend/convex/_generated/api.d.ts @@ -52,6 +52,7 @@ import type * as monitoramento from "../monitoramento.js"; import type * as objetos from "../objetos.js"; import type * as pedidos from "../pedidos.js"; import type * as permissoesAcoes from "../permissoesAcoes.js"; +import type * as planejamentos from "../planejamentos.js"; import type * as pontos from "../pontos.js"; import type * as preferenciasNotificacao from "../preferenciasNotificacao.js"; import type * as pushNotifications from "../pushNotifications.js"; @@ -77,6 +78,7 @@ import type * as tables_lgpdTables from "../tables/lgpdTables.js"; import type * as tables_licencas from "../tables/licencas.js"; import type * as tables_objetos from "../tables/objetos.js"; import type * as tables_pedidos from "../tables/pedidos.js"; +import type * as tables_planejamentos from "../tables/planejamentos.js"; import type * as tables_ponto from "../tables/ponto.js"; import type * as tables_security from "../tables/security.js"; import type * as tables_setores from "../tables/setores.js"; @@ -144,6 +146,7 @@ declare const fullApi: ApiFromModules<{ objetos: typeof objetos; pedidos: typeof pedidos; permissoesAcoes: typeof permissoesAcoes; + planejamentos: typeof planejamentos; pontos: typeof pontos; preferenciasNotificacao: typeof preferenciasNotificacao; pushNotifications: typeof pushNotifications; @@ -169,6 +172,7 @@ declare const fullApi: ApiFromModules<{ "tables/licencas": typeof tables_licencas; "tables/objetos": typeof tables_objetos; "tables/pedidos": typeof tables_pedidos; + "tables/planejamentos": typeof tables_planejamentos; "tables/ponto": typeof tables_ponto; "tables/security": typeof tables_security; "tables/setores": typeof tables_setores; diff --git a/packages/backend/convex/pedidos.ts b/packages/backend/convex/pedidos.ts index 09f1cfc..8781225 100644 --- a/packages/backend/convex/pedidos.ts +++ b/packages/backend/convex/pedidos.ts @@ -185,7 +185,10 @@ async function ensurePedidoModalidadeAtaConsistency( const normalizedItemAtaId = (('ataId' in item ? item.ataId : undefined) ?? null) as Id<'atas'> | null; - if (item.modalidade !== modalidade || normalizedItemAtaId !== normalizedNewAtaId) { + const modalidadeMismatch = !!item.modalidade && !!modalidade && item.modalidade !== modalidade; + const ataMismatch = normalizedItemAtaId !== normalizedNewAtaId; + + if (modalidadeMismatch || ataMismatch) { throw new Error( 'Todos os itens do pedido devem usar a mesma modalidade e a mesma ata (quando houver). Ajuste os itens existentes ou crie um novo pedido para a nova combinação.' ); @@ -529,6 +532,12 @@ export const getItems = query({ const funcionario = await ctx.db.get(item.adicionadoPor); return { ...item, + // Se a modalidade ainda não foi definida, expõe como "consumo" para manter compatibilidade com UI existente + modalidade: (item.modalidade ?? 'consumo') as + | 'dispensa' + | 'inexgibilidade' + | 'adesao' + | 'consumo', adicionadoPorNome: funcionario?.nome || 'Desconhecido' }; }) @@ -654,7 +663,7 @@ export const checkExisting = query({ let include = true; let matchingItems: { objetoId: Id<'objetos'>; - modalidade: Doc<'objetoItems'>['modalidade']; + modalidade: NonNullable['modalidade']>; quantidade: number; }[] = []; @@ -666,13 +675,19 @@ export const checkExisting = query({ .collect(); const matching = items.filter((i) => - itensFiltro.some((f) => f.objetoId === i.objetoId && f.modalidade === i.modalidade) + itensFiltro.some( + (f) => f.objetoId === i.objetoId && f.modalidade === (i.modalidade ?? 'consumo') + ) ); if (matching.length > 0) { matchingItems = matching.map((i) => ({ objetoId: i.objetoId, - modalidade: i.modalidade, + modalidade: (i.modalidade ?? 'consumo') as + | 'dispensa' + | 'inexgibilidade' + | 'adesao' + | 'consumo', quantidade: i.quantidade })); } else { @@ -959,11 +974,13 @@ export const gerarRelatorio = query({ ataNumero: v.optional(v.string()), acaoId: v.optional(v.id('acoes')), acaoNome: v.optional(v.string()), - modalidade: v.union( - v.literal('dispensa'), - v.literal('inexgibilidade'), - v.literal('adesao'), - v.literal('consumo') + modalidade: v.optional( + v.union( + v.literal('dispensa'), + v.literal('inexgibilidade'), + v.literal('adesao'), + v.literal('consumo') + ) ), quantidade: v.number(), valorEstimado: v.string(), @@ -1125,7 +1142,11 @@ export const gerarRelatorio = query({ ataNumero: ata?.numero ?? undefined, acaoId: it.acaoId, acaoNome: acao?.nome ?? undefined, - modalidade: it.modalidade, + modalidade: (it.modalidade ?? 'consumo') as + | 'dispensa' + | 'inexgibilidade' + | 'adesao' + | 'consumo', quantidade: it.quantidade, valorEstimado: it.valorEstimado, valorReal: it.valorReal, @@ -1380,11 +1401,14 @@ export const addItem = mutation({ 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') + // Opcional: permite criar itens sem definir modalidade upfront (ex: geração via planejamento). + modalidade: v.optional( + v.union( + v.literal('dispensa'), + v.literal('inexgibilidade'), + v.literal('adesao'), + v.literal('consumo') + ) ), valorEstimado: v.string(), quantidade: v.number() @@ -1401,30 +1425,6 @@ export const addItem = mutation({ const pedido = await ctx.db.get(args.pedidoId); if (!pedido) throw new Error('Pedido não encontrado.'); - // Regra global: todos os itens do pedido devem ter a mesma - // modalidade e a mesma ata (quando houver). - await ensurePedidoModalidadeAtaConsistency(ctx, args.pedidoId, args.modalidade, args.ataId); - - // --- CHECK ANALYSIS MODE --- - // Em pedidos em análise, a inclusão de itens deve passar por fluxo de aprovação. - // Em rascunho ou aguardando aceite, a inclusão é direta, sem necessidade de aprovação. - if (pedido.status === 'em_analise') { - if (args.ataId) { - // Não altera consumo aqui (ainda é só solicitação), mas valida limite/configuração. - await assertAtaObjetoCanConsume(ctx, args.ataId, args.objetoId, args.quantidade); - } - await ctx.db.insert('solicitacoesItens', { - pedidoId: args.pedidoId, - tipo: 'adicao', - dados: JSON.stringify(args), - status: 'pendente', - solicitadoPor: user.funcionarioId, - criadoEm: Date.now() - }); - return; - } - - // --- CHECK DUPLICATES (Same Product + User, Different Config) --- // Get all items of this product added by this user in this order const userProductItems = await ctx.db .query('objetoItems') @@ -1437,8 +1437,37 @@ export const addItem = mutation({ ) .collect(); + // Se não foi informada, tenta inferir a modalidade a partir de itens já adicionados por este usuário + // (evita conflito de "mesmo produto com outra combinação" quando modalidade estiver vazia). + const modalidade = + args.modalidade ?? userProductItems.find((i) => !!i.modalidade)?.modalidade ?? undefined; + + // Regra global: todos os itens do pedido devem ter a mesma + // modalidade e a mesma ata (quando houver). + await ensurePedidoModalidadeAtaConsistency(ctx, args.pedidoId, modalidade, args.ataId); + + // --- CHECK ANALYSIS MODE --- + // Em pedidos em análise, a inclusão de itens deve passar por fluxo de aprovação. + // Em rascunho ou aguardando aceite, a inclusão é direta, sem necessidade de aprovação. + if (pedido.status === 'em_analise') { + if (args.ataId) { + // Não altera consumo aqui (ainda é só solicitação), mas valida limite/configuração. + await assertAtaObjetoCanConsume(ctx, args.ataId, args.objetoId, args.quantidade); + } + await ctx.db.insert('solicitacoesItens', { + pedidoId: args.pedidoId, + tipo: 'adicao', + dados: JSON.stringify({ ...args, modalidade }), + status: 'pendente', + solicitadoPor: user.funcionarioId, + criadoEm: Date.now() + }); + return; + } + + // --- CHECK DUPLICATES (Same Product + User, Different Config) --- const conflict = userProductItems.find( - (i) => i.modalidade !== args.modalidade || i.ataId !== args.ataId + (i) => i.modalidade !== modalidade || i.ataId !== args.ataId ); if (conflict) { @@ -1449,7 +1478,7 @@ export const addItem = mutation({ // Check if item already exists with SAME parameters (exact match) to increment const existingItem = userProductItems.find( - (i) => i.acaoId === args.acaoId && i.ataId === args.ataId && i.modalidade === args.modalidade + (i) => i.acaoId === args.acaoId && i.ataId === args.ataId && i.modalidade === modalidade ); if (existingItem) { @@ -1481,7 +1510,7 @@ export const addItem = mutation({ objetoId: args.objetoId, ataId: args.ataId, acaoId: args.acaoId, - modalidade: args.modalidade, + ...(modalidade ? { modalidade } : {}), valorEstimado: args.valorEstimado, quantidade: args.quantidade, adicionadoPor: user.funcionarioId, @@ -1498,7 +1527,7 @@ export const addItem = mutation({ quantidade: args.quantidade, acaoId: args.acaoId, ataId: args.ataId, - modalidade: args.modalidade + modalidade: modalidade ?? null }), data: Date.now() }); diff --git a/packages/backend/convex/planejamentos.ts b/packages/backend/convex/planejamentos.ts new file mode 100644 index 0000000..e00f272 --- /dev/null +++ b/packages/backend/convex/planejamentos.ts @@ -0,0 +1,514 @@ +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'))), + responsavelId: v.optional(v.id('funcionarios')) + }, + handler: async (ctx, args) => { + const status = args.status; + const responsavelId = args.responsavelId; + + let base: Doc<'planejamentosPedidos'>[] = []; + + if (responsavelId) { + base = await ctx.db + .query('planejamentosPedidos') + .withIndex('by_responsavelId', (q) => q.eq('responsavelId', responsavelId)) + .collect(); + } else if (status) { + base = await ctx.db + .query('planejamentosPedidos') + .withIndex('by_status', (q) => q.eq('status', status)) + .collect(); + } else { + base = await ctx.db.query('planejamentosPedidos').collect(); + } + + 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 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')) + }, + 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.'); + + return await ctx.db.insert('planejamentosPedidos', { + titulo, + descricao, + data, + responsavelId: args.responsavelId, + acaoId: args.acaoId, + status: 'rascunho', + criadoPor: user._id, + criadoEm: now, + atualizadoEm: now + }); + } +}); + +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; + } +}); diff --git a/packages/backend/convex/schema.ts b/packages/backend/convex/schema.ts index 6607f42..2dec986 100644 --- a/packages/backend/convex/schema.ts +++ b/packages/backend/convex/schema.ts @@ -14,6 +14,7 @@ import { funcionariosTables } from './tables/funcionarios'; import { licencasTables } from './tables/licencas'; import { objetosTables } from './tables/objetos'; import { pedidosTables } from './tables/pedidos'; +import { planejamentosTables } from './tables/planejamentos'; import { pontoTables } from './tables/ponto'; import { securityTables } from './tables/security'; import { setoresTables } from './tables/setores'; @@ -42,6 +43,7 @@ export default defineSchema({ ...securityTables, ...pontoTables, ...pedidosTables, + ...planejamentosTables, ...objetosTables, ...atasTables, ...lgpdTables diff --git a/packages/backend/convex/tables/pedidos.ts b/packages/backend/convex/tables/pedidos.ts index c3f6685..bc849ec 100644 --- a/packages/backend/convex/tables/pedidos.ts +++ b/packages/backend/convex/tables/pedidos.ts @@ -34,11 +34,14 @@ export const pedidosTables = { objetoId: v.id('objetos'), // was produtoId ataId: v.optional(v.id('atas')), acaoId: v.optional(v.id('acoes')), // Moved from pedidos - modalidade: v.union( - v.literal('dispensa'), - v.literal('inexgibilidade'), - v.literal('adesao'), - v.literal('consumo') + // Opcional: permite criar itens sem definir modalidade upfront (ex: geração via planejamento) + modalidade: v.optional( + v.union( + v.literal('dispensa'), + v.literal('inexgibilidade'), + v.literal('adesao'), + v.literal('consumo') + ) ), valorEstimado: v.string(), valorReal: v.optional(v.string()), diff --git a/packages/backend/convex/tables/planejamentos.ts b/packages/backend/convex/tables/planejamentos.ts new file mode 100644 index 0000000..1366b38 --- /dev/null +++ b/packages/backend/convex/tables/planejamentos.ts @@ -0,0 +1,45 @@ +import { defineTable } from 'convex/server'; +import { v } from 'convex/values'; + +export const planejamentosTables = { + planejamentosPedidos: defineTable({ + titulo: v.string(), + descricao: v.string(), + // Armazenar como yyyy-MM-dd para facilitar input type="date" no frontend. + data: v.string(), + responsavelId: v.id('funcionarios'), + acaoId: v.optional(v.id('acoes')), + status: v.union(v.literal('rascunho'), v.literal('gerado'), v.literal('cancelado')), + criadoPor: v.id('usuarios'), + criadoEm: v.number(), + atualizadoEm: v.number() + }) + .index('by_responsavelId', ['responsavelId']) + .index('by_status', ['status']) + .index('by_criadoEm', ['criadoEm']), + + planejamentoItens: defineTable({ + planejamentoId: v.id('planejamentosPedidos'), + // Opcional no cadastro; obrigatório para gerar pedidos. + numeroDfd: v.optional(v.string()), + objetoId: v.id('objetos'), + quantidade: v.number(), + valorEstimado: v.string(), + // Preenchido após a geração (itens foram materializados no pedido). + pedidoId: v.optional(v.id('pedidos')), + criadoEm: v.number(), + atualizadoEm: v.number() + }) + .index('by_planejamentoId', ['planejamentoId']) + .index('by_planejamentoId_and_numeroDfd', ['planejamentoId', 'numeroDfd']), + + planejamentoPedidosLinks: defineTable({ + planejamentoId: v.id('planejamentosPedidos'), + numeroDfd: v.string(), + pedidoId: v.id('pedidos'), + criadoEm: v.number() + }) + .index('by_planejamentoId', ['planejamentoId']) + .index('by_pedidoId', ['pedidoId']) + .index('by_planejamentoId_and_numeroDfd', ['planejamentoId', 'numeroDfd']) +};