From a5ad843b3e5fdf2e5b569f043f58651417cb5065 Mon Sep 17 00:00:00 2001 From: killer-cf Date: Mon, 15 Dec 2025 14:29:30 -0300 Subject: [PATCH] feat: implement filtering and document management features in dashboard components, enhancing user experience with improved query capabilities and UI for managing documents in pedidos and compras --- .../(dashboard)/compras/atas/+page.svelte | 97 ++- .../(dashboard)/compras/objetos/+page.svelte | 85 ++- .../(dashboard)/pedidos/[id]/+page.svelte | 560 +++++++++++++++++- packages/backend/convex/atas.ts | 34 +- packages/backend/convex/objetos.ts | 31 +- packages/backend/convex/pedidos.ts | 328 +++++++++- packages/backend/convex/tables/pedidos.ts | 34 +- 7 files changed, 1135 insertions(+), 34 deletions(-) diff --git a/apps/web/src/routes/(dashboard)/compras/atas/+page.svelte b/apps/web/src/routes/(dashboard)/compras/atas/+page.svelte index 2b93b0f..9e9c107 100644 --- a/apps/web/src/routes/(dashboard)/compras/atas/+page.svelte +++ b/apps/web/src/routes/(dashboard)/compras/atas/+page.svelte @@ -8,7 +8,21 @@ const client = useConvexClient(); // Reactive queries - const atasQuery = useQuery(api.atas.list, {}); + // Filtros (listagem) + let filtroPeriodoInicio = $state(''); + let filtroPeriodoFim = $state(''); + let filtroNumero = $state(''); + let filtroNumeroSei = $state(''); + + const atasTotalQuery = useQuery(api.atas.list, {}); + let atasTotal = $derived(atasTotalQuery.data || []); + + const atasQuery = useQuery(api.atas.list, () => ({ + periodoInicio: filtroPeriodoInicio || undefined, + periodoFim: filtroPeriodoFim || undefined, + numero: filtroNumero.trim() || undefined, + numeroSei: filtroNumeroSei.trim() || undefined + })); let atas = $derived(atasQuery.data || []); let loadingAtas = $derived(atasQuery.isLoading); let errorAtas = $derived(atasQuery.error?.message || null); @@ -188,6 +202,13 @@ attachmentFiles = Array.from(input.files); } } + + function limparFiltros() { + filtroPeriodoInicio = ''; + filtroPeriodoFim = ''; + filtroNumero = ''; + filtroNumeroSei = ''; + }
@@ -220,6 +241,71 @@ +
+
+
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+
+ {atas.length} de {atasTotal.length} +
+ +
+
+
+
+ {#if loadingAtas}
@@ -258,8 +344,13 @@
-

Nenhuma ata cadastrada

-

Clique em “Nova Ata” para cadastrar.

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

Nenhuma ata cadastrada

+

Clique em “Nova Ata” para cadastrar.

+ {:else} +

Nenhum resultado encontrado

+

Ajuste ou limpe os filtros para ver resultados.

+ {/if}
diff --git a/apps/web/src/routes/(dashboard)/compras/objetos/+page.svelte b/apps/web/src/routes/(dashboard)/compras/objetos/+page.svelte index 81485fb..bf2324d 100644 --- a/apps/web/src/routes/(dashboard)/compras/objetos/+page.svelte +++ b/apps/web/src/routes/(dashboard)/compras/objetos/+page.svelte @@ -9,7 +9,19 @@ const client = useConvexClient(); // Reactive queries - const objetosQuery = useQuery(api.objetos.list, {}); + // Filtros (listagem) + let filtroNome = $state(''); + let filtroTipo = $state<'todos' | 'material' | 'servico'>('todos'); + let filtroCodigos = $state(''); + + const objetosTotalQuery = useQuery(api.objetos.list, {}); + let objetosTotal = $derived(objetosTotalQuery.data || []); + + const objetosQuery = useQuery(api.objetos.list, () => ({ + nome: filtroNome.trim() || undefined, + tipo: filtroTipo === 'todos' ? undefined : filtroTipo, + codigos: filtroCodigos.trim() || undefined + })); let objetos = $derived(objetosQuery.data || []); let loading = $derived(objetosQuery.isLoading); let error = $derived(objetosQuery.error?.message || null); @@ -115,6 +127,12 @@ formData.atas = [...formData.atas, ataId]; } } + + function limparFiltros() { + filtroNome = ''; + filtroTipo = 'todos'; + filtroCodigos = ''; + }
@@ -147,6 +165,62 @@
+
+
+
+
+
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+
+ {objetos.length} de {objetosTotal.length} +
+ +
+
+
+
+ {#if loading}
@@ -185,8 +259,13 @@
-

Nenhum objeto cadastrado

-

Clique em “Novo Objeto” para cadastrar.

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

Nenhum objeto cadastrado

+

Clique em “Novo Objeto” para cadastrar.

+ {:else} +

Nenhum resultado encontrado

+

Ajuste ou limpe os filtros para ver resultados.

+ {/if}
diff --git a/apps/web/src/routes/(dashboard)/pedidos/[id]/+page.svelte b/apps/web/src/routes/(dashboard)/pedidos/[id]/+page.svelte index d6f02f8..a84366b 100644 --- a/apps/web/src/routes/(dashboard)/pedidos/[id]/+page.svelte +++ b/apps/web/src/routes/(dashboard)/pedidos/[id]/+page.svelte @@ -12,6 +12,7 @@ Edit, Eye, Plus, + Upload, Save, Send, Trash2, @@ -34,6 +35,9 @@ const acoesQuery = $derived.by(() => useQuery(api.acoes.list, {})); const permissionsQuery = $derived.by(() => useQuery(api.pedidos.getPermissions, { pedidoId })); const requestsQuery = $derived.by(() => useQuery(api.pedidos.getItemRequests, { pedidoId })); + const pedidoDocumentosQuery = $derived.by(() => + useQuery(api.pedidos.listPedidoDocumentos, { pedidoId }) + ); // Derived state let pedido = $derived(pedidoQuery.data); @@ -43,11 +47,19 @@ let acoes = $derived(acoesQuery.data || []); let permissions = $derived(permissionsQuery.data); let requests = $derived(requestsQuery.data || []); + let pedidoDocumentos = $derived(pedidoDocumentosQuery.data || []); let currentFuncionarioId = $derived(permissions?.currentFuncionarioId ?? null); type Modalidade = 'dispensa' | 'inexgibilidade' | 'adesao' | 'consumo'; + function coerceModalidade(value: string): Modalidade { + if (value === 'dispensa' || value === 'inexgibilidade' || value === 'adesao' || value === 'consumo') { + return value; + } + return 'consumo'; + } + type EditingItem = { valorEstimado: string; modalidade: Modalidade; @@ -125,7 +137,8 @@ objetosQuery.isLoading || acoesQuery.isLoading || permissionsQuery.isLoading || - requestsQuery.isLoading + requestsQuery.isLoading || + pedidoDocumentosQuery.isLoading ); let error = $derived( @@ -137,6 +150,213 @@ null ); + // Documentos do Pedido + let showAddPedidoDocumento = $state(false); + let pedidoDocumentoDescricao = $state(''); + let pedidoDocumentoFile = $state(null); + let salvandoPedidoDocumento = $state(false); + + function formatBytes(bytes: number) { + if (!Number.isFinite(bytes) || bytes <= 0) return '0 B'; + const units = ['B', 'KB', 'MB', 'GB']; + let value = bytes; + let idx = 0; + while (value >= 1024 && idx < units.length - 1) { + value /= 1024; + idx += 1; + } + return `${value.toFixed(idx === 0 ? 0 : 1)} ${units[idx]}`; + } + + async function uploadToStorage(uploadUrl: string, file: File): Promise> { + const result = await fetch(uploadUrl, { + method: 'POST', + headers: { 'Content-Type': file.type }, + body: file + }); + const json = (await result.json()) as { storageId: Id<'_storage'> }; + return json.storageId; + } + + async function handleAddPedidoDocumento() { + if (!pedido) return; + if (!pedidoDocumentoFile) { + toast.error('Selecione um arquivo.'); + return; + } + if (!pedidoDocumentoDescricao.trim()) { + toast.error('Informe uma descrição.'); + return; + } + + salvandoPedidoDocumento = true; + try { + const uploadUrl = await client.mutation(api.pedidos.generatePedidoUploadUrl, { pedidoId }); + const storageId = await uploadToStorage(uploadUrl, pedidoDocumentoFile); + await client.mutation(api.pedidos.addPedidoDocumento, { + pedidoId, + descricao: pedidoDocumentoDescricao.trim(), + nome: pedidoDocumentoFile.name, + storageId, + tipo: pedidoDocumentoFile.type, + tamanho: pedidoDocumentoFile.size + }); + + pedidoDocumentoDescricao = ''; + pedidoDocumentoFile = null; + showAddPedidoDocumento = false; + toast.success('Documento anexado ao pedido.'); + } catch (e) { + toast.error('Erro ao anexar documento: ' + (e as Error).message); + } finally { + salvandoPedidoDocumento = false; + } + } + + function handleOpenPedidoDocumento(url: string | null) { + if (!url) return; + window.open(url, '_blank'); + } + + function handleRemovePedidoDocumento(id: Id<'pedidoDocumentos'>) { + openConfirm( + 'Remover documento', + 'Tem certeza que deseja remover este documento do pedido?', + async () => { + await client.mutation(api.pedidos.removePedidoDocumento, { id }); + toast.success('Documento removido.'); + }, + { isDestructive: true, confirmText: 'Remover' } + ); + } + + // Documentos por Solicitação + let showSolicitacaoDocsModal = $state(false); + let solicitacaoDocsRequestId = $state | null>(null); + let solicitacaoDocsSolicitadoPor = $state | null>(null); + let solicitacaoDocsTipo = $state(null); + let solicitacaoDocs = $state([]); + let carregandoSolicitacaoDocs = $state(false); + + let solicitacaoDocumentoDescricao = $state(''); + let solicitacaoDocumentoFile = $state(null); + let salvandoSolicitacaoDocumento = $state(false); + + const canAddDocsToSelectedRequest = $derived( + showSolicitacaoDocsModal && + solicitacaoDocsTipo === 'adicao' && + currentFuncionarioId !== null && + solicitacaoDocsSolicitadoPor !== null && + currentFuncionarioId === solicitacaoDocsSolicitadoPor + ); + + async function openSolicitacaoDocs(req: { + _id: Id<'solicitacoesItens'>; + solicitadoPor: Id<'funcionarios'>; + tipo: string; + }) { + solicitacaoDocsRequestId = req._id; + solicitacaoDocsSolicitadoPor = req.solicitadoPor; + solicitacaoDocsTipo = req.tipo; + solicitacaoDocumentoDescricao = ''; + solicitacaoDocumentoFile = null; + showSolicitacaoDocsModal = true; + + carregandoSolicitacaoDocs = true; + try { + solicitacaoDocs = await client.query(api.pedidos.listSolicitacaoDocumentos, { + requestId: req._id + }); + } catch (e) { + toast.error('Erro ao carregar documentos da solicitação: ' + (e as Error).message); + solicitacaoDocs = []; + } finally { + carregandoSolicitacaoDocs = false; + } + } + + function closeSolicitacaoDocs() { + showSolicitacaoDocsModal = false; + solicitacaoDocsRequestId = null; + solicitacaoDocsSolicitadoPor = null; + solicitacaoDocsTipo = null; + solicitacaoDocs = []; + solicitacaoDocumentoDescricao = ''; + solicitacaoDocumentoFile = null; + } + + function handleOpenSolicitacaoDocumento(url: string | null) { + if (!url) return; + window.open(url, '_blank'); + } + + async function refreshSolicitacaoDocs() { + if (!solicitacaoDocsRequestId) return; + carregandoSolicitacaoDocs = true; + try { + solicitacaoDocs = await client.query(api.pedidos.listSolicitacaoDocumentos, { + requestId: solicitacaoDocsRequestId + }); + } finally { + carregandoSolicitacaoDocs = false; + } + } + + async function handleAddSolicitacaoDocumento() { + if (!solicitacaoDocsRequestId) return; + + if (!canAddDocsToSelectedRequest) { + toast.error('Apenas quem criou a solicitação pode anexar documentos.'); + return; + } + if (!solicitacaoDocumentoFile) { + toast.error('Selecione um arquivo.'); + return; + } + if (!solicitacaoDocumentoDescricao.trim()) { + toast.error('Informe uma descrição.'); + return; + } + + salvandoSolicitacaoDocumento = true; + try { + const uploadUrl = await client.mutation(api.pedidos.generateSolicitacaoUploadUrl, { + requestId: solicitacaoDocsRequestId + }); + const storageId = await uploadToStorage(uploadUrl, solicitacaoDocumentoFile); + await client.mutation(api.pedidos.addSolicitacaoDocumento, { + requestId: solicitacaoDocsRequestId, + descricao: solicitacaoDocumentoDescricao.trim(), + nome: solicitacaoDocumentoFile.name, + storageId, + tipo: solicitacaoDocumentoFile.type, + tamanho: solicitacaoDocumentoFile.size + }); + + solicitacaoDocumentoDescricao = ''; + solicitacaoDocumentoFile = null; + toast.success('Documento anexado à solicitação.'); + await refreshSolicitacaoDocs(); + } catch (e) { + toast.error('Erro ao anexar documento: ' + (e as Error).message); + } finally { + salvandoSolicitacaoDocumento = false; + } + } + + function handleRemoveSolicitacaoDocumento(id: Id<'solicitacoesItensDocumentos'>) { + openConfirm( + 'Remover documento', + 'Tem certeza que deseja remover este documento da solicitação?', + async () => { + await client.mutation(api.pedidos.removeSolicitacaoDocumento, { id }); + toast.success('Documento removido.'); + await refreshSolicitacaoDocs(); + }, + { isDestructive: true, confirmText: 'Remover' } + ); + } + // Add Item State let showAddItem = $state(false); let newItem = $state({ @@ -1001,6 +1221,142 @@
+ +
+
+

Documentos do Pedido

+ +
+ + {#if showAddPedidoDocumento} +
+
+
+ + +
+
+ + { + const f = e.currentTarget.files?.[0] ?? null; + pedidoDocumentoFile = f; + }} + /> +
+
+ +
+ + +
+
+ {/if} + +
+ {#if pedidoDocumentos.length === 0} +

Nenhum documento anexado ao pedido.

+ {:else} +
+ + + + + + + + + + + + + {#each pedidoDocumentos as doc (doc._id)} + + + + + + + + + {/each} + +
DescriçãoArquivoTamanhoEnviado porDataAções
{doc.descricao} + {doc.nome} + {#if doc.origemSolicitacaoId} + (origem: solicitação) + {/if} + {formatBytes(doc.tamanho)}{doc.criadoPorNome ?? 'Desconhecido'} + {new Date(doc.criadoEm).toLocaleString('pt-BR')} + + + +
+
+ {/if} +
+
+ {#if requests.length > 0}
@@ -1064,24 +1420,53 @@ {/if} - {#if permissions?.canManageRequests} +
+ {#if req.tipo === 'adicao'} + {@const canAddDoc = + !!currentFuncionarioId && req.solicitadoPor === currentFuncionarioId} + + {/if} + - - {:else} - Aguardando Análise - {/if} + + {#if permissions?.canManageRequests} + + + {:else} + Aguardando Análise + {/if} +
{/each} @@ -1380,7 +1765,7 @@ setEditingField( item._id, 'modalidade', - e.currentTarget.value as Modalidade + coerceModalidade(e.currentTarget.value) ); void persistItemChanges(item); }} @@ -1698,6 +2083,147 @@
{/if} + {#if showSolicitacaoDocsModal && solicitacaoDocsRequestId} +
+
+ + +

Documentos da solicitação

+

+ Solicitação: {solicitacaoDocsRequestId.slice(-6)} — tipo: {solicitacaoDocsTipo} +

+ + {#if canAddDocsToSelectedRequest} +
+
+
+ + +
+
+ + { + const f = e.currentTarget.files?.[0] ?? null; + solicitacaoDocumentoFile = f; + }} + /> +
+
+ +
+ +
+
+ {/if} + + {#if carregandoSolicitacaoDocs} +

Carregando documentos...

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

Nenhum documento anexado à solicitação.

+ {:else} +
+ + + + + + + + + + + + + {#each solicitacaoDocs as doc (doc._id)} + + + + + + + + + {/each} + +
DescriçãoArquivoTamanhoEnviado porDataAções
{doc.descricao} + {doc.nome} + {formatBytes(doc.tamanho)}{doc.criadoPorNome ?? 'Desconhecido'} + {new Date(doc.criadoEm).toLocaleString('pt-BR')} + + + {#if canAddDocsToSelectedRequest} + + {/if} +
+
+ {/if} + +
+ +
+
+
+ {/if} + {#if showRequestAdjustmentsModal}
{ + args: { + periodoInicio: v.optional(v.string()), + periodoFim: v.optional(v.string()), + numero: v.optional(v.string()), + numeroSei: v.optional(v.string()) + }, + handler: async (ctx, args) => { await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, { recurso: 'atas', acao: 'listar' }); - return await ctx.db.query('atas').collect(); + + const numero = args.numero?.trim().toLowerCase(); + const numeroSei = args.numeroSei?.trim().toLowerCase(); + const periodoInicio = args.periodoInicio || undefined; + const periodoFim = args.periodoFim || undefined; + + const atas = await ctx.db.query('atas').collect(); + return atas.filter((ata) => { + const numeroOk = !numero || (ata.numero || '').toLowerCase().includes(numero); + const seiOk = !numeroSei || (ata.numeroSei || '').toLowerCase().includes(numeroSei); + + // Filtro por intervalo (range): retorna atas cuja vigência intersecta o período informado. + // Considera datas como strings "YYYY-MM-DD" (lexicograficamente comparáveis). + const ataInicio = ata.dataInicio ?? '0000-01-01'; + const ataFim = ata.dataFim ?? '9999-12-31'; + + const periodoOk = + (!periodoInicio && !periodoFim) || + (periodoInicio && periodoFim && ataInicio <= periodoFim && ataFim >= periodoInicio) || + (periodoInicio && !periodoFim && ataFim >= periodoInicio) || + (!periodoInicio && periodoFim && ataInicio <= periodoFim); + + return numeroOk && seiOk && periodoOk; + }); } }); diff --git a/packages/backend/convex/objetos.ts b/packages/backend/convex/objetos.ts index f63677b..dda5421 100644 --- a/packages/backend/convex/objetos.ts +++ b/packages/backend/convex/objetos.ts @@ -3,9 +3,34 @@ import { mutation, query } from './_generated/server'; import { getCurrentUserFunction } from './auth'; export const list = query({ - args: {}, - handler: async (ctx) => { - return await ctx.db.query('objetos').collect(); + args: { + nome: v.optional(v.string()), + tipo: v.optional(v.union(v.literal('material'), v.literal('servico'))), + codigos: v.optional(v.string()) + }, + handler: async (ctx, args) => { + const nome = args.nome?.trim(); + const codigos = args.codigos?.trim().toLowerCase(); + + const base = + nome && nome.length > 0 + ? await ctx.db + .query('objetos') + .withSearchIndex('search_nome', (q) => q.search('nome', nome)) + .collect() + : await ctx.db.query('objetos').collect(); + + return base.filter((objeto) => { + const tipoOk = !args.tipo || objeto.tipo === args.tipo; + + const codigosOk = + !codigos || + (objeto.codigoEfisco || '').toLowerCase().includes(codigos) || + (objeto.codigoCatmat || '').toLowerCase().includes(codigos) || + (objeto.codigoCatserv || '').toLowerCase().includes(codigos); + + return tipoOk && codigosOk; + }); } }); diff --git a/packages/backend/convex/pedidos.ts b/packages/backend/convex/pedidos.ts index cacf126..7331379 100644 --- a/packages/backend/convex/pedidos.ts +++ b/packages/backend/convex/pedidos.ts @@ -47,6 +47,61 @@ async function ensurePedidoModalidadeAtaConsistency( } } +async function isFuncionarioInComprasSector( + ctx: QueryCtx | MutationCtx, + funcionarioId: Id<'funcionarios'> +) { + const config = await ctx.db.query('config').first(); + if (!config || !config.comprasSetorId) return false; + + const isInSector = await ctx.db + .query('funcionarioSetores') + .withIndex('by_funcionarioId', (q) => q.eq('funcionarioId', funcionarioId)) + .filter((q) => q.eq(q.field('setorId'), config.comprasSetorId)) + .first(); + + return !!isInSector; +} + +async function isUsuarioEnvolvidoNoPedido( + ctx: QueryCtx | MutationCtx, + pedidoId: Id<'pedidos'>, + user: Awaited> +) { + const pedido = await ctx.db.get(pedidoId); + if (!pedido) return false; + + // Criador do pedido (por usuarioId) + if (pedido.criadoPor === user._id) return true; + + // Envolvimento por itens (requer funcionarioId) + if (!user.funcionarioId) return false; + + const hasItem = await ctx.db + .query('objetoItems') + .withIndex('by_pedidoId', (q) => q.eq('pedidoId', pedidoId)) + .filter((q) => q.eq(q.field('adicionadoPor'), user.funcionarioId)) + .first(); + + return !!hasItem; +} + +async function assertPodeGerenciarDocumentosDoPedido( + ctx: QueryCtx | MutationCtx, + pedidoId: Id<'pedidos'>, + user: Awaited> +) { + const isEnvolvido = await isUsuarioEnvolvidoNoPedido(ctx, pedidoId, user); + if (isEnvolvido) return; + + if (user.funcionarioId) { + const isCompras = await isFuncionarioInComprasSector(ctx, user.funcionarioId); + if (isCompras) return; + } + + throw new Error('Acesso negado.'); +} + // ========== QUERIES ========== export const list = query({ @@ -1652,6 +1707,8 @@ export const approveItemRequest = mutation({ returns: v.null(), handler: async (ctx, args) => { const user = await getUsuarioAutenticado(ctx); + const funcionarioId = user.funcionarioId; + if (!funcionarioId) throw new Error('Usuário sem funcionário vinculado.'); const request = await ctx.db.get(args.requestId); if (!request) throw new Error('Solicitação não encontrada.'); @@ -1666,12 +1723,18 @@ export const approveItemRequest = mutation({ const isInSector = await ctx.db .query('funcionarioSetores') - .withIndex('by_funcionarioId', (q) => q.eq('funcionarioId', user.funcionarioId!)) + .withIndex('by_funcionarioId', (q) => q.eq('funcionarioId', funcionarioId)) .filter((q) => q.eq(q.field('setorId'), config.comprasSetorId)) .first(); if (!isInSector) throw new Error('Acesso negado.'); + // Documentos anexados à solicitação (se houver) devem migrar para o pedido ao aprovar. + const solicitacaoDocs = await ctx.db + .query('solicitacoesItensDocumentos') + .withIndex('by_requestId', (q) => q.eq('requestId', request._id)) + .collect(); + // Apply the change const data = JSON.parse(request.dados); @@ -1790,15 +1853,37 @@ export const approveItemRequest = mutation({ // I'll delete it to keep table clean as requested. await ctx.db.delete(request._id); + // Migrar docs: cria em pedidoDocumentos e remove os registros da solicitacao + const documentosMigrados: Array<{ nome: string; descricao: string }> = []; + if (solicitacaoDocs.length > 0) { + for (const doc of solicitacaoDocs) { + await ctx.db.insert('pedidoDocumentos', { + pedidoId: request.pedidoId, + descricao: doc.descricao, + nome: doc.nome, + storageId: doc.storageId, + tipo: doc.tipo, + tamanho: doc.tamanho, + criadoPor: doc.criadoPor, + criadoEm: doc.criadoEm, + origemSolicitacaoId: request._id + }); + documentosMigrados.push({ nome: doc.nome, descricao: doc.descricao }); + await ctx.db.delete(doc._id); + } + } + // History await ctx.db.insert('historicoPedidos', { pedidoId: request.pedidoId, usuarioId: user._id, acao: 'aprovacao_solicitacao', detalhes: JSON.stringify({ + requestId: request._id, tipo: request.tipo, solicitadoPor: request.solicitadoPor, - dados: data + dados: data, + documentosMigrados }), data: Date.now() }); @@ -1814,6 +1899,8 @@ export const rejectItemRequest = mutation({ returns: v.null(), handler: async (ctx, args) => { const user = await getUsuarioAutenticado(ctx); + const funcionarioId = user.funcionarioId; + if (!funcionarioId) throw new Error('Usuário sem funcionário vinculado.'); const request = await ctx.db.get(args.requestId); if (!request) throw new Error('Solicitação não encontrada.'); @@ -1822,12 +1909,25 @@ export const rejectItemRequest = mutation({ const isInSector = await ctx.db .query('funcionarioSetores') - .withIndex('by_funcionarioId', (q) => q.eq('funcionarioId', user.funcionarioId!)) + .withIndex('by_funcionarioId', (q) => q.eq('funcionarioId', funcionarioId)) .filter((q) => q.eq(q.field('setorId'), config.comprasSetorId)) .first(); if (!isInSector) throw new Error('Acesso negado.'); + // Remover documentos anexados à solicitação (para não deixar órfãos no storage) + const solicitacaoDocs = await ctx.db + .query('solicitacoesItensDocumentos') + .withIndex('by_requestId', (q) => q.eq('requestId', request._id)) + .collect(); + + const documentosRemovidos: Array<{ nome: string; descricao: string }> = []; + for (const doc of solicitacaoDocs) { + documentosRemovidos.push({ nome: doc.nome, descricao: doc.descricao }); + await ctx.storage.delete(doc.storageId); + await ctx.db.delete(doc._id); + } + // Delete request await ctx.db.delete(args.requestId); @@ -1837,10 +1937,230 @@ export const rejectItemRequest = mutation({ usuarioId: user._id, acao: 'rejeicao_solicitacao', detalhes: JSON.stringify({ + requestId: request._id, tipo: request.tipo, - solicitadoPor: request.solicitadoPor + solicitadoPor: request.solicitadoPor, + documentosRemovidos }), data: Date.now() }); } }); + +// ========== DOCUMENTOS (PEDIDO / SOLICITAÇÃO) ========== + +export const generatePedidoUploadUrl = mutation({ + args: { pedidoId: v.id('pedidos') }, + returns: v.string(), + handler: async (ctx, args) => { + const user = await getUsuarioAutenticado(ctx); + await assertPodeGerenciarDocumentosDoPedido(ctx, args.pedidoId, user); + return await ctx.storage.generateUploadUrl(); + } +}); + +export const addPedidoDocumento = mutation({ + args: { + pedidoId: v.id('pedidos'), + descricao: v.string(), + nome: v.string(), + storageId: v.id('_storage'), + tipo: v.string(), + tamanho: v.number(), + origemSolicitacaoId: v.optional(v.id('solicitacoesItens')) + }, + returns: v.id('pedidoDocumentos'), + handler: async (ctx, args) => { + const user = await getUsuarioAutenticado(ctx); + await assertPodeGerenciarDocumentosDoPedido(ctx, args.pedidoId, user); + + if (!user.funcionarioId) { + throw new Error('Usuário sem funcionário vinculado.'); + } + + // Garantir que o pedido existe + const pedido = await ctx.db.get(args.pedidoId); + if (!pedido) throw new Error('Pedido não encontrado.'); + + return await ctx.db.insert('pedidoDocumentos', { + pedidoId: args.pedidoId, + descricao: args.descricao, + nome: args.nome, + storageId: args.storageId, + tipo: args.tipo, + tamanho: args.tamanho, + criadoPor: user.funcionarioId, + criadoEm: Date.now(), + origemSolicitacaoId: args.origemSolicitacaoId + }); + } +}); + +export const listPedidoDocumentos = query({ + args: { pedidoId: v.id('pedidos') }, + handler: async (ctx, args) => { + const user = await getUsuarioAutenticado(ctx); + await assertPodeGerenciarDocumentosDoPedido(ctx, args.pedidoId, user); + + const docs = await ctx.db + .query('pedidoDocumentos') + .withIndex('by_pedidoId', (q) => q.eq('pedidoId', args.pedidoId)) + .order('desc') + .collect(); + + return await Promise.all( + docs.map(async (doc) => { + const url = await ctx.storage.getUrl(doc.storageId); + const func = await ctx.db.get(doc.criadoPor); + return { + ...doc, + criadoPorNome: func?.nome ?? 'Desconhecido', + url + }; + }) + ); + } +}); + +export const removePedidoDocumento = mutation({ + args: { id: v.id('pedidoDocumentos') }, + returns: v.null(), + handler: async (ctx, args) => { + const user = await getUsuarioAutenticado(ctx); + + const doc = await ctx.db.get(args.id); + if (!doc) throw new Error('Documento não encontrado.'); + + // Pode remover se for o autor OU se for envolvido/compras + if (!user.funcionarioId || doc.criadoPor !== user.funcionarioId) { + await assertPodeGerenciarDocumentosDoPedido(ctx, doc.pedidoId, user); + } + + await ctx.storage.delete(doc.storageId); + await ctx.db.delete(doc._id); + + return null; + } +}); + +export const generateSolicitacaoUploadUrl = mutation({ + args: { requestId: v.id('solicitacoesItens') }, + returns: v.string(), + handler: async (ctx, args) => { + const user = await getUsuarioAutenticado(ctx); + if (!user.funcionarioId) throw new Error('Usuário sem funcionário vinculado.'); + + const request = await ctx.db.get(args.requestId); + if (!request) throw new Error('Solicitação não encontrada.'); + if (request.tipo !== 'adicao') { + throw new Error('Apenas solicitações de adição permitem anexar documentos.'); + } + if (request.status !== 'pendente') { + throw new Error('Não é possível anexar documentos em uma solicitação não pendente.'); + } + if (request.solicitadoPor !== user.funcionarioId) { + throw new Error('Apenas quem criou a solicitação pode anexar documentos.'); + } + + return await ctx.storage.generateUploadUrl(); + } +}); + +export const addSolicitacaoDocumento = mutation({ + args: { + requestId: v.id('solicitacoesItens'), + descricao: v.string(), + nome: v.string(), + storageId: v.id('_storage'), + tipo: v.string(), + tamanho: v.number() + }, + returns: v.id('solicitacoesItensDocumentos'), + handler: async (ctx, args) => { + const user = await getUsuarioAutenticado(ctx); + if (!user.funcionarioId) throw new Error('Usuário sem funcionário vinculado.'); + + const request = await ctx.db.get(args.requestId); + if (!request) throw new Error('Solicitação não encontrada.'); + if (request.tipo !== 'adicao') { + throw new Error('Apenas solicitações de adição permitem anexar documentos.'); + } + if (request.status !== 'pendente') { + throw new Error('Não é possível anexar documentos em uma solicitação não pendente.'); + } + if (request.solicitadoPor !== user.funcionarioId) { + throw new Error('Apenas quem criou a solicitação pode anexar documentos.'); + } + + return await ctx.db.insert('solicitacoesItensDocumentos', { + requestId: args.requestId, + pedidoId: request.pedidoId, + descricao: args.descricao, + nome: args.nome, + storageId: args.storageId, + tipo: args.tipo, + tamanho: args.tamanho, + criadoPor: user.funcionarioId, + criadoEm: Date.now() + }); + } +}); + +export const listSolicitacaoDocumentos = query({ + args: { requestId: v.id('solicitacoesItens') }, + handler: async (ctx, args) => { + const user = await getUsuarioAutenticado(ctx); + const request = await ctx.db.get(args.requestId); + if (!request) return []; + + await assertPodeGerenciarDocumentosDoPedido(ctx, request.pedidoId, user); + + const docs = await ctx.db + .query('solicitacoesItensDocumentos') + .withIndex('by_requestId', (q) => q.eq('requestId', args.requestId)) + .order('desc') + .collect(); + + return await Promise.all( + docs.map(async (doc) => { + const url = await ctx.storage.getUrl(doc.storageId); + const func = await ctx.db.get(doc.criadoPor); + return { + ...doc, + criadoPorNome: func?.nome ?? 'Desconhecido', + url + }; + }) + ); + } +}); + +export const removeSolicitacaoDocumento = mutation({ + args: { id: v.id('solicitacoesItensDocumentos') }, + returns: v.null(), + handler: async (ctx, args) => { + const user = await getUsuarioAutenticado(ctx); + if (!user.funcionarioId) throw new Error('Usuário sem funcionário vinculado.'); + + const doc = await ctx.db.get(args.id); + if (!doc) throw new Error('Documento não encontrado.'); + + const request = await ctx.db.get(doc.requestId); + if (!request) throw new Error('Solicitação não encontrada.'); + if (request.tipo !== 'adicao') { + throw new Error('Apenas solicitações de adição permitem documentos.'); + } + if (request.status !== 'pendente') { + throw new Error('Não é possível remover documentos de uma solicitação não pendente.'); + } + + if (doc.criadoPor !== user.funcionarioId || request.solicitadoPor !== user.funcionarioId) { + throw new Error('Apenas quem criou a solicitação pode remover documentos.'); + } + + await ctx.storage.delete(doc.storageId); + await ctx.db.delete(doc._id); + + return null; + } +}); diff --git a/packages/backend/convex/tables/pedidos.ts b/packages/backend/convex/tables/pedidos.ts index 6bcd933..0af34ff 100644 --- a/packages/backend/convex/tables/pedidos.ts +++ b/packages/backend/convex/tables/pedidos.ts @@ -70,5 +70,37 @@ export const pedidosTables = { }) .index('by_pedidoId', ['pedidoId']) .index('by_usuarioId', ['usuarioId']) - .index('by_data', ['data']) + .index('by_data', ['data']), + + // Documentos anexados diretamente ao pedido (ilimitado) + pedidoDocumentos: defineTable({ + pedidoId: v.id('pedidos'), + descricao: v.string(), + nome: v.string(), + storageId: v.id('_storage'), + tipo: v.string(), // MIME type + tamanho: v.number(), // bytes + criadoPor: v.id('funcionarios'), + criadoEm: v.number(), + origemSolicitacaoId: v.optional(v.id('solicitacoesItens')) + }) + .index('by_pedidoId', ['pedidoId']) + .index('by_criadoPor', ['criadoPor']) + .index('by_origemSolicitacaoId', ['origemSolicitacaoId']), + + // Documentos anexados a uma solicitação (somente solicitante; pode ter mais de um) + solicitacoesItensDocumentos: defineTable({ + requestId: v.id('solicitacoesItens'), + pedidoId: v.id('pedidos'), + descricao: v.string(), + nome: v.string(), + storageId: v.id('_storage'), + tipo: v.string(), // MIME type + tamanho: v.number(), // bytes + criadoPor: v.id('funcionarios'), + criadoEm: v.number() + }) + .index('by_requestId', ['requestId']) + .index('by_pedidoId', ['pedidoId']) + .index('by_criadoPor', ['criadoPor']) };