diff --git a/apps/web/src/routes/(dashboard)/pedidos/[id]/+page.svelte b/apps/web/src/routes/(dashboard)/pedidos/[id]/+page.svelte index 9998754..8b5b509 100644 --- a/apps/web/src/routes/(dashboard)/pedidos/[id]/+page.svelte +++ b/apps/web/src/routes/(dashboard)/pedidos/[id]/+page.svelte @@ -44,6 +44,8 @@ let permissions = $derived(permissionsQuery.data); let requests = $derived(requestsQuery.data || []); + let currentFuncionarioId = $derived(permissions?.currentFuncionarioId ?? null); + type Modalidade = 'dispensa' | 'inexgibilidade' | 'adesao' | 'consumo'; type EditingItem = { @@ -251,6 +253,35 @@ async function handleAddItem() { if (!pedido || !newItem.objetoId || !newItem.valorEstimado) return; + + // Validação no front: garantir que todos os itens existentes do pedido + // utilizem a mesma combinação de modalidade e ata (quando houver). + if (items.length > 0) { + const referenceItem = items[0]; + + const referenceModalidade = referenceItem.modalidade as Modalidade; + const referenceAtaId = (('ataId' in referenceItem ? referenceItem.ataId : undefined) ?? + null) as string | null; + + const newAtaId = newItem.ataId || null; + + const sameModalidade = newItem.modalidade === referenceModalidade; + const sameAta = referenceAtaId === newAtaId; + + if (!sameModalidade || !sameAta) { + const refModalidadeLabel = formatModalidade(referenceModalidade); + const refAtaLabel = + referenceAtaId === null ? 'sem Ata vinculada' : 'com uma Ata específica'; + + toast.error( + `Não é possível adicionar este item com esta combinação de modalidade e ata.\n\n` + + `Este pedido já está utilizando Modalidade: ${refModalidadeLabel} e está ${refAtaLabel}.\n` + + `Todos os itens do pedido devem usar a mesma modalidade e a mesma ata (quando houver).` + ); + return; + } + } + addingItem = true; try { await client.mutation(api.pedidos.addItem, { @@ -277,7 +308,27 @@ toast.success('Item adicionado com sucesso!'); } } catch (e) { - toast.error('Erro ao adicionar item: ' + (e as Error).message); + const message = (e as Error).message || String(e); + + if ( + message.includes( + 'Todos os itens do pedido devem usar a mesma modalidade e a mesma ata' + ) + ) { + toast.error( + 'Não é possível adicionar este item, pois o pedido já possui uma combinação diferente de modalidade e ata. Ajuste os itens existentes ou crie um novo pedido para a nova combinação.' + ); + } else if ( + message.includes( + 'Este pedido já possui este produto com outra combinação de modalidade e/ou ata' + ) + ) { + toast.error( + 'Você já adicionou este produto neste pedido com outra combinação de modalidade e/ ou ata. Ajuste o item existente ou crie um novo pedido para a nova combinação.' + ); + } else { + toast.error('Erro ao adicionar item: ' + message); + } } finally { addingItem = false; } @@ -439,6 +490,21 @@ }; } + function formatModalidade(modalidade: Modalidade) { + switch (modalidade) { + case 'consumo': + return 'Consumo'; + case 'dispensa': + return 'Dispensa'; + case 'inexgibilidade': + return 'Inexigibilidade'; + case 'adesao': + return 'Adesão'; + default: + return modalidade; + } + } + function setEditingField( itemId: Id<'objetoItems'>, field: K, @@ -1181,207 +1247,209 @@
Adicionado por: {group.name}
- - - - {#if pedido.status === 'em_rascunho' || pedido.status === 'precisa_ajustes'} - - {/if} - - - - - - - - - - - - {#each group.items as item (item._id)} - - {#if pedido.status === 'em_rascunho' || pedido.status === 'precisa_ajustes'} - + {#each group.items as item (item._id)} + + {#if (pedido.status === 'em_rascunho' || pedido.status === 'precisa_ajustes') && currentFuncionarioId && item.adicionadoPor === currentFuncionarioId} + + {/if} + + + + + + + + + + {/each} + +
- { - const checked = e.currentTarget.checked; - for (const groupItem of group.items) { - if (checked) { - selectedItemIds.add(groupItem._id); - } else { - selectedItemIds.delete(groupItem._id); - } - } - }} - aria-label={`Selecionar todos os itens de ${group.name}`} - /> - - ObjetoQtdValor Est.ModalidadeAçãoAtaTotalAções
+
+ + + + {#if (pedido.status === 'em_rascunho' || pedido.status === 'precisa_ajustes') && currentFuncionarioId && group.items[0]?.adicionadoPor === currentFuncionarioId} + {/if} - - - - - - - - + + + + + + + + - {/each} - -
toggleItemSelection(item._id)} - aria-label={`Selecionar item ${getObjetoName(item.objetoId)}`} + onchange={(e) => { + const checked = e.currentTarget.checked; + for (const groupItem of group.items) { + if (checked) { + selectedItemIds.add(groupItem._id); + } else { + selectedItemIds.delete(groupItem._id); + } + } + }} + aria-label={`Selecionar todos os itens de ${group.name}`} /> - + {getObjetoName(item.objetoId)} - {#if pedido.status === 'em_rascunho' || pedido.status === 'precisa_ajustes' || pedido.status === 'em_analise' || pedido.status === 'aguardando_aceite'} - - handleUpdateQuantity(item._id, parseInt(e.currentTarget.value) || 1)} - class="w-20 rounded border px-2 py-1 text-sm" - /> - {:else} - {item.quantidade} - {/if} - - {#if pedido.status === 'em_rascunho' || pedido.status === 'precisa_ajustes' || pedido.status === 'em_analise' || pedido.status === 'aguardando_aceite'} - - setEditingField( - item._id, - 'valorEstimado', - maskCurrencyBRL(e.currentTarget.value) - )} - onblur={() => persistItemChanges(item)} - placeholder="R$ 0,00" - /> - {:else} - {maskCurrencyBRL(item.valorEstimado) || 'R$ 0,00'} - {/if} - - {#if pedido.status === 'em_rascunho' || pedido.status === 'precisa_ajustes' || pedido.status === 'em_analise' || pedido.status === 'aguardando_aceite'} - - {:else} - {item.modalidade} - {/if} - - {#if pedido.status === 'em_rascunho' || pedido.status === 'precisa_ajustes' || pedido.status === 'em_analise' || pedido.status === 'aguardando_aceite'} - - {:else} - {getAcaoName(item.acaoId)} - {/if} - - {#if pedido.status === 'em_rascunho' || pedido.status === 'precisa_ajustes' || pedido.status === 'em_analise' || pedido.status === 'aguardando_aceite'} - - {:else if item.ataId} - {#each getAtasForObjeto(item.objetoId) as ata (ata._id)} - {#if ata._id === item.ataId} - Ata {ata.numero} - {/if} - {/each} - {:else} - - - {/if} - - R$ {calculateItemTotal(item.valorEstimado, item.quantidade) - .toFixed(2) - .replace('.', ',')} - - - {#if pedido.status === 'em_rascunho' || pedido.status === 'precisa_ajustes' || pedido.status === 'em_analise' || pedido.status === 'aguardando_aceite'} - - {/if} - + ObjetoQtdValor Est.ModalidadeAçãoAtaTotalAções
+ +
+ toggleItemSelection(item._id)} + aria-label={`Selecionar item ${getObjetoName(item.objetoId)}`} + /> + {getObjetoName(item.objetoId)} + {#if pedido.status === 'em_rascunho' || pedido.status === 'precisa_ajustes' || pedido.status === 'em_analise' || pedido.status === 'aguardando_aceite'} + + handleUpdateQuantity(item._id, parseInt(e.currentTarget.value) || 1)} + class="w-20 rounded border px-2 py-1 text-sm" + /> + {:else} + {item.quantidade} + {/if} + + {#if pedido.status === 'em_rascunho' || pedido.status === 'precisa_ajustes' || pedido.status === 'em_analise' || pedido.status === 'aguardando_aceite'} + + setEditingField( + item._id, + 'valorEstimado', + maskCurrencyBRL(e.currentTarget.value) + )} + onblur={() => persistItemChanges(item)} + placeholder="R$ 0,00" + /> + {:else} + {maskCurrencyBRL(item.valorEstimado) || 'R$ 0,00'} + {/if} + + {#if pedido.status === 'em_rascunho' || pedido.status === 'precisa_ajustes' || pedido.status === 'em_analise' || pedido.status === 'aguardando_aceite'} + + {:else} + {item.modalidade} + {/if} + + {#if pedido.status === 'em_rascunho' || pedido.status === 'precisa_ajustes' || pedido.status === 'em_analise' || pedido.status === 'aguardando_aceite'} + + {:else} + {getAcaoName(item.acaoId)} + {/if} + + {#if pedido.status === 'em_rascunho' || pedido.status === 'precisa_ajustes' || pedido.status === 'em_analise' || pedido.status === 'aguardando_aceite'} + + {:else if item.ataId} + {#each getAtasForObjeto(item.objetoId) as ata (ata._id)} + {#if ata._id === item.ataId} + Ata {ata.numero} + {/if} + {/each} + {:else} + - + {/if} + + R$ {calculateItemTotal(item.valorEstimado, item.quantidade) + .toFixed(2) + .replace('.', ',')} + + + {#if pedido.status === 'em_rascunho' || pedido.status === 'precisa_ajustes' || pedido.status === 'em_analise' || pedido.status === 'aguardando_aceite'} + + {/if} +
+ {/each} {#if items.length === 0} diff --git a/packages/backend/convex/pedidos.ts b/packages/backend/convex/pedidos.ts index 9ce2cc5..46a3b92 100644 --- a/packages/backend/convex/pedidos.ts +++ b/packages/backend/convex/pedidos.ts @@ -13,6 +13,41 @@ async function getUsuarioAutenticado(ctx: QueryCtx | MutationCtx) { return user; } +// Garante que todos os itens de um pedido utilizem a mesma +// combinação de modalidade e ata (quando houver). +async function ensurePedidoModalidadeAtaConsistency( + ctx: MutationCtx, + pedidoId: Id<'pedidos'>, + modalidade: Doc<'objetoItems'>['modalidade'], + ataId: Id<'atas'> | undefined, + ignoreItemId?: Id<'objetoItems'> +) { + const normalizedNewAtaId = ataId ?? null; + + const items = await ctx.db + .query('objetoItems') + .withIndex('by_pedidoId', (q) => q.eq('pedidoId', pedidoId)) + .collect(); + + if (items.length === 0) { + return; + } + + for (const item of items) { + if (ignoreItemId && item._id === ignoreItemId) continue; + + const normalizedItemAtaId = (('ataId' in item ? item.ataId : undefined) ?? null) as + | Id<'atas'> + | null; + + if (item.modalidade !== modalidade || normalizedItemAtaId !== normalizedNewAtaId) { + 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.' + ); + } + } +} + // ========== QUERIES ========== export const list = query({ @@ -619,6 +654,15 @@ 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 / ACCEPTANCE MODE --- if (pedido.status === 'em_analise' || pedido.status === 'aguardando_aceite') { await ctx.db.insert('solicitacoesItens', { @@ -651,7 +695,7 @@ export const addItem = mutation({ if (conflict) { throw new Error( - 'Você já adicionou este item com outra modalidade ou ata. Não é permitido adicionar o mesmo produto com configurações diferentes neste pedido.' + 'Este pedido já possui este produto com outra combinação de modalidade e/ou ata para você. Todos os itens do mesmo produto devem usar a mesma modalidade e ata neste pedido. Ajuste o item existente ou crie um novo pedido para a nova combinação.' ); } @@ -1011,6 +1055,16 @@ export const updateItem = mutation({ ataId: 'ataId' in item ? item.ataId : undefined }; + // Regra global: ao alterar um item, garantir que os demais itens do pedido + // continuem (ou passem a estar) com a mesma modalidade e ata. + await ensurePedidoModalidadeAtaConsistency( + ctx, + item.pedidoId, + args.modalidade, + args.ataId, + args.itemId + ); + // Em pedidos em análise ou aguardando aceite, geramos uma solicitação em vez de alterar diretamente if (pedido.status === 'em_analise' || pedido.status === 'aguardando_aceite') { await ctx.db.insert('solicitacoesItens', { @@ -1074,7 +1128,10 @@ export const getPermissions = query({ canStartAnalysis: false, canConclude: false, canRequestAdjustments: false, - canCancel: false + canCancel: false, + canCompleteAdjustments: false, + canManageRequests: false, + currentFuncionarioId: user?.funcionarioId ?? null }; } @@ -1099,6 +1156,14 @@ export const getPermissions = query({ .first(); const hasAddedItems = !!userItem; + // Verificar se existem itens adicionados por OUTROS usuários. + const foreignItem = await ctx.db + .query('objetoItems') + .withIndex('by_pedidoId', (q) => q.eq('pedidoId', args.pedidoId)) + .filter((q) => q.neq(q.field('adicionadoPor'), user.funcionarioId)) + .first(); + const hasOnlyCreatorItems = !foreignItem; + const isCreator = pedido.criadoPor === user._id; return { @@ -1111,8 +1176,13 @@ export const getPermissions = query({ isInComprasSector && pedido.aceitoPor === user.funcionarioId, canCompleteAdjustments: pedido.status === 'precisa_ajustes' && hasAddedItems, - canCancel: pedido.status !== 'cancelado' && pedido.status !== 'concluido' && isCreator, - canManageRequests: pedido.status === 'em_analise' && isInComprasSector + canCancel: + pedido.status !== 'cancelado' && + pedido.status !== 'concluido' && + isCreator && + hasOnlyCreatorItems, + canManageRequests: pedido.status === 'em_analise' && isInComprasSector, + currentFuncionarioId: user.funcionarioId }; } }); @@ -1388,23 +1458,28 @@ export const cancelarPedido = mutation({ throw new Error('Pedido já finalizado.'); } - // Anyone involved (creator or compras) can cancel? Or just creator? - // Logic: If it's creator OR Compras. + // Regra: apenas o criador pode cancelar o pedido const isCreator = pedido.criadoPor === user._id; - let isCompras = false; - - const config = await ctx.db.query('config').first(); - if (config && config.comprasSetorId && user.funcionarioId) { - const fs = await ctx.db - .query('funcionarioSetores') - .withIndex('by_funcionarioId', (q) => q.eq('funcionarioId', user.funcionarioId!)) - .filter((q) => q.eq(q.field('setorId'), config.comprasSetorId)) - .first(); - isCompras = !!fs; + if (!isCreator) { + throw new Error('Apenas quem criou este pedido pode cancelá-lo.'); } - if (!isCreator && !isCompras) { - throw new Error('Permissão negada para cancelar este pedido.'); + if (!user.funcionarioId) { + throw new Error('Usuário sem funcionário vinculado. Não é possível cancelar este pedido.'); + } + + // Regra extra: o pedido só pode ser cancelado se todos os itens + // tiverem sido adicionados pelo mesmo funcionário do criador. + const foreignItem = await ctx.db + .query('objetoItems') + .withIndex('by_pedidoId', (q) => q.eq('pedidoId', args.pedidoId)) + .filter((q) => q.neq(q.field('adicionadoPor'), user.funcionarioId)) + .first(); + + if (foreignItem) { + throw new Error( + 'Não é possível cancelar este pedido porque há itens adicionados por outros usuários.' + ); } const oldStatus = pedido.status; @@ -1597,6 +1672,15 @@ export const approveItemRequest = mutation({ // Reuse addItem logic (simplified: insert or increment) // We trust the request data structure matches addItem args const newItem = data; + + // Regra global: todos os itens do pedido devem ter a mesma + // modalidade e ata (quando houver). + await ensurePedidoModalidadeAtaConsistency( + ctx, + request.pedidoId, + newItem.modalidade, + newItem.ataId + ); // Note: We MUST use the original requester's ID (request.solicitadoPor) as addedBy? // Or should we attribute it to the requester? YES. // BUT `addItem` logic usually checks if `existingItem.adicionadoPor === user`. @@ -1675,6 +1759,15 @@ export const approveItemRequest = mutation({ const item = await ctx.db.get(itemId); if (item) { + // Regra global também se aplica na alteração de detalhes aprovada + await ensurePedidoModalidadeAtaConsistency( + ctx, + item.pedidoId, + para.modalidade, + para.ataId, + itemId + ); + await ctx.db.patch(itemId, { valorEstimado: para.valorEstimado, modalidade: para.modalidade,