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}
+
+
+
+
+ |
+ Título
+ |
+
+ Data
+ |
+
+ Responsável
+ |
+
+ Ação
+ |
+
+ Status
+ |
+
+ Ações
+ |
+
+
+
+ {#each planejamentos as p (p._id)}
+
+ | {p.titulo} |
+ {formatDateYMD(p.data)} |
+ {p.responsavelNome} |
+ {p.acaoNome || '-'} |
+
+
+ {formatStatus(p.status)}
+
+ |
+
+
+
+ Abrir
+
+ |
+
+ {/each}
+ {#if planejamentos.length === 0}
+
+ | Nenhum planejamento encontrado. |
+
+ {/if}
+
+
+
+ {/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}
+
+ {/if}
+
+
+
+
+
+
+
Pedidos gerados
+
+
+ {#if pedidosQuery.isLoading}
+
Carregando pedidos...
+ {:else if pedidosLinks.length === 0}
+
Nenhum pedido gerado ainda.
+ {:else}
+
+ {#each pedidosLinks as row (row._id)}
+
+
+
+ {#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)}
+
+ {/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'])
+};