refactor: simplify pedidos item management by removing modalidade from item configuration and validation, ensuring all items use the same ata while enhancing code clarity and maintainability

This commit is contained in:
2025-12-18 08:48:40 -03:00
parent 69914170bf
commit 94373c6b94
3 changed files with 91 additions and 295 deletions

View File

@@ -514,30 +514,23 @@
if (!pedido || !newItem.objetoId || !newItem.valorEstimado) return; if (!pedido || !newItem.objetoId || !newItem.valorEstimado) return;
// Validação no front: garantir que todos os itens existentes do pedido // Validação no front: garantir que todos os itens existentes do pedido
// utilizem a mesma combinação de modalidade e ata (quando houver). // utilizem a mesma ata (quando houver).
if (items.length > 0) { if (items.length > 0) {
const referenceItem = items[0]; const referenceItem = items[0];
const referenceModalidade = (referenceItem.modalidade as Modalidade | undefined) ?? undefined;
const referenceAtaId = (('ataId' in referenceItem ? referenceItem.ataId : undefined) ?? const referenceAtaId = (('ataId' in referenceItem ? referenceItem.ataId : undefined) ??
null) as string | null; null) as string | null;
const newAtaId = newItem.ataId || null; const newAtaId = newItem.ataId || null;
const sameModalidade = !referenceModalidade || newItem.modalidade === referenceModalidade;
const sameAta = referenceAtaId === newAtaId; const sameAta = referenceAtaId === newAtaId;
if (!sameModalidade || !sameAta) { if (!sameAta) {
const refModalidadeLabel = referenceModalidade
? formatModalidade(referenceModalidade)
: 'Não definida';
const refAtaLabel = const refAtaLabel =
referenceAtaId === null ? 'sem Ata vinculada' : 'com uma Ata específica'; referenceAtaId === null ? 'sem Ata vinculada' : 'com uma Ata específica';
toast.error( toast.error(
`Não é possível adicionar este item com esta combinação de modalidade e ata.\n\n` + `Não é possível adicionar este item com esta ata.\n\n` +
`Este pedido já está utilizando Modalidade: ${refModalidadeLabel} e está ${refAtaLabel}.\n` + `Este pedido já está vinculado a: ${refAtaLabel}.\n` +
`Todos os itens do pedido devem usar a mesma modalidade e a mesma ata (quando houver).` `Todos os itens do pedido devem usar a mesma ata (quando houver).`
); );
return; return;
} }
@@ -550,7 +543,6 @@
objetoId: newItem.objetoId as Id<'objetos'>, objetoId: newItem.objetoId as Id<'objetos'>,
valorEstimado: newItem.valorEstimado, valorEstimado: newItem.valorEstimado,
quantidade: newItem.quantidade, quantidade: newItem.quantidade,
modalidade: newItem.modalidade,
acaoId: newItem.acaoId ? (newItem.acaoId as Id<'acoes'>) : undefined, acaoId: newItem.acaoId ? (newItem.acaoId as Id<'acoes'>) : undefined,
ataId: newItem.ataId ? (newItem.ataId as Id<'atas'>) : undefined ataId: newItem.ataId ? (newItem.ataId as Id<'atas'>) : undefined
}); });
@@ -1664,22 +1656,8 @@
placeholder="R$ 0,00" placeholder="R$ 0,00"
/> />
</div> </div>
<div>
<label for="modalidade-select" class="mb-1 block text-xs font-medium text-gray-500" {#if newItem.objetoId && permissions?.canEditAta}
>Modalidade</label
>
<select
id="modalidade-select"
bind:value={newItem.modalidade}
class="w-full rounded-md border-gray-300 text-sm shadow-sm"
>
<option value="consumo">Consumo</option>
<option value="dispensa">Dispensa</option>
<option value="inexgibilidade">Inexigibilidade</option>
<option value="adesao">Adesão</option>
</select>
</div>
{#if newItem.objetoId}
<div> <div>
<label for="ata-select" class="mb-1 block text-xs font-medium text-gray-500" <label for="ata-select" class="mb-1 block text-xs font-medium text-gray-500"
>Ata (Opcional)</label >Ata (Opcional)</label
@@ -1900,7 +1878,7 @@
{/if} {/if}
</td> </td>
<td class="px-6 py-4 text-sm whitespace-nowrap text-gray-600"> <td class="px-6 py-4 text-sm whitespace-nowrap text-gray-600">
{#if pedido.status === 'em_rascunho' || pedido.status === 'precisa_ajustes' || pedido.status === 'em_analise' || pedido.status === 'aguardando_aceite'} {#if permissions?.canEditModalidade}
<select <select
class="rounded border px-2 py-1 text-xs" class="rounded border px-2 py-1 text-xs"
value={ensureEditingItem(item).modalidade} value={ensureEditingItem(item).modalidade}
@@ -1919,7 +1897,7 @@
<option value="adesao">Adesão</option> <option value="adesao">Adesão</option>
</select> </select>
{:else} {:else}
{item.modalidade} {formatModalidade(item.modalidade as Modalidade) || '-'}
{/if} {/if}
</td> </td>
<td class="px-6 py-4 text-sm whitespace-nowrap text-gray-600"> <td class="px-6 py-4 text-sm whitespace-nowrap text-gray-600">
@@ -1942,7 +1920,7 @@
{/if} {/if}
</td> </td>
<td class="px-6 py-4 text-sm whitespace-nowrap text-gray-600"> <td class="px-6 py-4 text-sm whitespace-nowrap text-gray-600">
{#if pedido.status === 'em_rascunho' || pedido.status === 'precisa_ajustes' || pedido.status === 'em_analise' || pedido.status === 'aguardando_aceite'} {#if permissions?.canEditAta}
<select <select
class="rounded border px-2 py-1 text-xs" class="rounded border px-2 py-1 text-xs"
value={ensureEditingItem(item).ataId} value={ensureEditingItem(item).ataId}

View File

@@ -1,12 +1,10 @@
<script lang="ts"> <script lang="ts">
import { api } from '@sgse-app/backend/convex/_generated/api'; import { api } from '@sgse-app/backend/convex/_generated/api';
import type { Doc, Id } from '@sgse-app/backend/convex/_generated/dataModel'; import type { Doc, Id } from '@sgse-app/backend/convex/_generated/dataModel';
import type { FunctionReturnType } from 'convex/server';
import { useConvexClient, useQuery } from 'convex-svelte'; import { useConvexClient, useQuery } from 'convex-svelte';
import { Plus, Trash2, X, Info } from 'lucide-svelte'; import { Plus, Trash2, X, Info } from 'lucide-svelte';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import { formatarDataBR } from '$lib/utils/datas';
const client = useConvexClient(); const client = useConvexClient();
@@ -27,40 +25,28 @@
let warning = $state<string | null>(null); let warning = $state<string | null>(null);
// Item selection state // Item selection state
// Nota: modalidade é opcional aqui pois será definida pelo Setor de Compras posteriormente
type SelectedItem = { type SelectedItem = {
objeto: Doc<'objetos'>; objeto: Doc<'objetos'>;
quantidade: number; quantidade: number;
modalidade: 'dispensa' | 'inexgibilidade' | 'adesao' | 'consumo';
acaoId?: Id<'acoes'>; acaoId?: Id<'acoes'>;
ataId?: Id<'atas'>;
ataNumero?: string; // For display
ata?: FunctionReturnType<typeof api.objetos.getAtasComLimite>[number]; // dados mínimos p/ exibir detalhes
}; };
let selectedItems = $state<SelectedItem[]>([]); let selectedItems = $state<SelectedItem[]>([]);
let selectedObjetoIds = $derived(selectedItems.map((i) => i.objeto._id)); let selectedObjetoIds = $derived(selectedItems.map((i) => i.objeto._id));
let hasMixedModalidades = $derived(new Set(selectedItems.map((i) => i.modalidade)).size > 1);
// Item configuration modal // Item configuration modal
let showItemModal = $state(false); let showItemModal = $state(false);
let itemConfig = $state<{ let itemConfig = $state<{
objeto: Doc<'objetos'> | null; objeto: Doc<'objetos'> | null;
quantidade: number; quantidade: number;
modalidade: 'dispensa' | 'inexgibilidade' | 'adesao' | 'consumo';
acaoId: string; // using string to handle empty select acaoId: string; // using string to handle empty select
ataId: string; // using string to handle empty select
}>({ }>({
objeto: null, objeto: null,
quantidade: 1, quantidade: 1,
modalidade: 'consumo', acaoId: ''
acaoId: '',
ataId: ''
}); });
type AtasComLimite = FunctionReturnType<typeof api.objetos.getAtasComLimite>;
let availableAtas = $state<AtasComLimite>([]);
// Item Details Modal
let showDetailsModal = $state(false); let showDetailsModal = $state(false);
let detailsItem = $state<SelectedItem | null>(null); let detailsItem = $state<SelectedItem | null>(null);
@@ -75,16 +61,10 @@
} }
async function openItemModal(objeto: Doc<'objetos'>) { async function openItemModal(objeto: Doc<'objetos'>) {
// Fetch linked Atas for this object
const linkedAtas = await client.query(api.objetos.getAtasComLimite, { objetoId: objeto._id });
availableAtas = linkedAtas;
itemConfig = { itemConfig = {
objeto, objeto,
quantidade: 1, quantidade: 1,
modalidade: 'consumo', acaoId: ''
acaoId: '',
ataId: ''
}; };
showItemModal = true; showItemModal = true;
searchQuery = ''; // Clear search searchQuery = ''; // Clear search
@@ -93,24 +73,17 @@
function closeItemModal() { function closeItemModal() {
showItemModal = false; showItemModal = false;
itemConfig.objeto = null; itemConfig.objeto = null;
availableAtas = [];
} }
function confirmAddItem() { function confirmAddItem() {
if (!itemConfig.objeto) return; if (!itemConfig.objeto) return;
const selectedAta = availableAtas.find((a) => a._id === itemConfig.ataId);
selectedItems = [ selectedItems = [
...selectedItems, ...selectedItems,
{ {
objeto: itemConfig.objeto, objeto: itemConfig.objeto,
quantidade: itemConfig.quantidade, quantidade: itemConfig.quantidade,
modalidade: itemConfig.modalidade, acaoId: itemConfig.acaoId ? (itemConfig.acaoId as Id<'acoes'>) : undefined
acaoId: itemConfig.acaoId ? (itemConfig.acaoId as Id<'acoes'>) : undefined,
ataId: itemConfig.ataId ? (itemConfig.ataId as Id<'atas'>) : undefined,
ataNumero: selectedAta?.numero,
ata: selectedAta
} }
]; ];
checkExisting(); checkExisting();
@@ -131,7 +104,6 @@
criadoEm: number; criadoEm: number;
matchingItems?: { matchingItems?: {
objetoId: Id<'objetos'>; objetoId: Id<'objetos'>;
modalidade: SelectedItem['modalidade'];
quantidade: number; quantidade: number;
}[]; }[];
}[] }[]
@@ -157,36 +129,6 @@
} }
} }
function formatModalidade(modalidade: SelectedItem['modalidade']) {
switch (modalidade) {
case 'consumo':
return 'Consumo';
case 'dispensa':
return 'Dispensa';
case 'inexgibilidade':
return 'Inexigibilidade';
case 'adesao':
return 'Adesão';
default:
return modalidade;
}
}
function getModalidadeBadgeClasses(modalidade: SelectedItem['modalidade']) {
switch (modalidade) {
case 'consumo':
return 'bg-blue-100 text-blue-800';
case 'dispensa':
return 'bg-yellow-100 text-yellow-800';
case 'inexgibilidade':
return 'bg-purple-100 text-purple-800';
case 'adesao':
return 'bg-green-100 text-green-800';
default:
return 'bg-gray-100 text-gray-800';
}
}
function getAcaoNome(acaoId: Id<'acoes'> | undefined) { function getAcaoNome(acaoId: Id<'acoes'> | undefined) {
if (!acaoId) return '-'; if (!acaoId) return '-';
const acao = acoes.find((a) => a._id === acaoId); const acao = acoes.find((a) => a._id === acaoId);
@@ -206,8 +148,7 @@
.map((match) => { .map((match) => {
// Find name from selected items (might be multiple with same object, just pick one name) // Find name from selected items (might be multiple with same object, just pick one name)
const item = selectedItems.find((p) => p.objeto._id === match.objetoId); const item = selectedItems.find((p) => p.objeto._id === match.objetoId);
const modalidadeLabel = formatModalidade(match.modalidade); return `${item?.objeto.nome}: ${match.quantidade} un.`;
return `${item?.objeto.nome} (${modalidadeLabel}): ${match.quantidade} un.`;
}) })
.join(', '); .join(', ');
@@ -218,9 +159,7 @@
if (!pedido.matchingItems || pedido.matchingItems.length === 0) return null; if (!pedido.matchingItems || pedido.matchingItems.length === 0) return null;
for (const match of pedido.matchingItems) { for (const match of pedido.matchingItems) {
const item = selectedItems.find( const item = selectedItems.find((p) => p.objeto._id === match.objetoId);
(p) => p.objeto._id === match.objetoId && p.modalidade === match.modalidade
);
if (item) { if (item) {
return item; return item;
} }
@@ -239,16 +178,11 @@
const params = new URLSearchParams(); const params = new URLSearchParams();
params.set('obj', matchedItem.objeto._id); params.set('obj', matchedItem.objeto._id);
params.set('qtd', String(matchedItem.quantidade)); params.set('qtd', String(matchedItem.quantidade));
params.set('mod', matchedItem.modalidade);
if (matchedItem.acaoId) { if (matchedItem.acaoId) {
params.set('acao', matchedItem.acaoId); params.set('acao', matchedItem.acaoId);
} }
if (matchedItem.ataId) {
params.set('ata', matchedItem.ataId);
}
return `/pedidos/${pedido._id}?${params.toString()}` as `/pedidos/${string}`; return `/pedidos/${pedido._id}?${params.toString()}` as `/pedidos/${string}`;
} }
@@ -261,13 +195,11 @@
checking = true; checking = true;
try { try {
// Importante: ação (acaoId) NÃO entra no filtro de similaridade. // Importante: O filtro considera apenas objetoId (modalidade não é mais usada na criação).
// O filtro considera apenas combinação de objeto + modalidade.
const itensFiltro = const itensFiltro =
selectedItems.length > 0 selectedItems.length > 0
? selectedItems.map((item) => ({ ? selectedItems.map((item) => ({
objetoId: item.objeto._id, objetoId: item.objeto._id
modalidade: item.modalidade
})) }))
: undefined; : undefined;
@@ -292,11 +224,6 @@
async function handleSubmit(e: Event) { async function handleSubmit(e: Event) {
e.preventDefault(); e.preventDefault();
if (hasMixedModalidades) {
error =
'Não é possível criar o pedido com itens de modalidades diferentes. Ajuste os itens antes de continuar.';
return;
}
creating = true; creating = true;
error = null; error = null;
try { try {
@@ -312,9 +239,7 @@
objetoId: item.objeto._id, objetoId: item.objeto._id,
valorEstimado: item.objeto.valorEstimado, valorEstimado: item.objeto.valorEstimado,
quantidade: item.quantidade, quantidade: item.quantidade,
modalidade: item.modalidade, acaoId: item.acaoId
acaoId: item.acaoId,
ataId: item.ataId
}) })
) )
); );
@@ -417,20 +342,6 @@
<div class="flex-1 space-y-2"> <div class="flex-1 space-y-2">
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<p class="font-semibold text-gray-900">{item.objeto.nome}</p> <p class="font-semibold text-gray-900">{item.objeto.nome}</p>
<span
class={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold ${getModalidadeBadgeClasses(
item.modalidade
)}`}
>
{formatModalidade(item.modalidade)}
</span>
{#if item.ataNumero}
<span
class="inline-flex items-center rounded-full bg-green-100 px-2.5 py-0.5 text-xs font-medium text-green-800"
>
Ata {item.ataNumero}
</span>
{/if}
{#if item.acaoId} {#if item.acaoId}
<span <span
class="inline-flex items-center rounded-full bg-indigo-100 px-2.5 py-0.5 text-xs font-medium text-indigo-800" class="inline-flex items-center rounded-full bg-indigo-100 px-2.5 py-0.5 text-xs font-medium text-indigo-800"
@@ -480,15 +391,6 @@
</div> </div>
<!-- Warnings Section --> <!-- Warnings Section -->
{#if hasMixedModalidades}
<div class="mb-3 rounded-lg border border-red-400 bg-red-50 px-4 py-3 text-sm text-red-800">
<p class="font-semibold">Modalidades diferentes detectadas</p>
<p>
Não é possível criar o pedido com itens de modalidades diferentes. Ajuste os itens para
usar uma única modalidade.
</p>
</div>
{/if}
{#if warning} {#if warning}
<div <div
@@ -508,22 +410,13 @@
<p class="mb-3 font-semibold text-yellow-900">Pedidos similares encontrados:</p> <p class="mb-3 font-semibold text-yellow-900">Pedidos similares encontrados:</p>
<ul class="space-y-2"> <ul class="space-y-2">
{#each existingPedidos as pedido (pedido._id)} {#each existingPedidos as pedido (pedido._id)}
{@const first = getFirstMatchingSelectedItem(pedido)}
<li class="flex flex-col rounded-lg bg-white px-4 py-3 shadow-sm"> <li class="flex flex-col rounded-lg bg-white px-4 py-3 shadow-sm">
<div class="flex items-center justify-between gap-3"> <div class="flex items-center justify-between gap-3">
<div class="space-y-1"> <div class="space-y-1">
<p class="text-sm font-medium text-gray-900"> <p class="text-sm font-medium text-gray-900">
Pedido {pedido.numeroSei || 'sem número SEI'}{formatStatus(pedido.status)} Pedido {pedido.numeroSei || 'sem número SEI'}{formatStatus(pedido.status)}
</p> </p>
{#if first}
<span
class={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold ${getModalidadeBadgeClasses(
first.modalidade
)}`}
>
Modalidade: {formatModalidade(first.modalidade)}
</span>
{/if}
{#if getMatchingInfo(pedido)} {#if getMatchingInfo(pedido)}
<p class="mt-1 text-xs text-blue-700"> <p class="mt-1 text-xs text-blue-700">
{getMatchingInfo(pedido)} {getMatchingInfo(pedido)}
@@ -553,7 +446,7 @@
</a> </a>
<button <button
type="submit" type="submit"
disabled={creating || selectedItems.length === 0 || hasMixedModalidades} disabled={creating || selectedItems.length === 0}
class="rounded-lg bg-blue-600 px-6 py-2.5 font-semibold text-white shadow-md transition hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50" class="rounded-lg bg-blue-600 px-6 py-2.5 font-semibold text-white shadow-md transition hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
> >
{creating ? 'Criando...' : 'Criar Pedido'} {creating ? 'Criando...' : 'Criar Pedido'}
@@ -600,62 +493,6 @@
/> />
</div> </div>
<div>
<label class="mb-2 block text-sm font-medium text-gray-700" for="modalidade">
Modalidade
</label>
<select
id="modalidade"
class="w-full rounded-lg border border-gray-300 px-4 py-2.5 transition focus:border-blue-500 focus:ring-2 focus:ring-blue-200 focus:outline-none"
bind:value={itemConfig.modalidade}
>
<option value="consumo">Consumo</option>
<option value="dispensa">Dispensa</option>
<option value="inexgibilidade">Inexigibilidade</option>
<option value="adesao">Adesão</option>
</select>
</div>
{#if availableAtas.length > 0}
<div class="rounded-lg border-2 border-green-200 bg-green-50 p-4">
<div class="mb-2 flex items-center gap-2">
<span class="rounded-full bg-green-600 px-2 py-0.5 text-xs font-bold text-white">
{availableAtas.length}
{availableAtas.length === 1 ? 'Ata' : 'Atas'}
</span>
<span class="text-sm font-semibold text-green-900">disponível para este objeto</span
>
</div>
<label class="mb-2 block text-sm font-medium text-gray-700" for="itemAta">
Selecionar Ata (Opcional)
</label>
<select
id="itemAta"
class="w-full rounded-lg border border-green-300 bg-white px-4 py-2.5 transition focus:border-green-500 focus:ring-2 focus:ring-green-200 focus:outline-none"
bind:value={itemConfig.ataId}
>
<option value="">Nenhuma</option>
{#each availableAtas as ata (ata._id)}
{@const reason =
ata.lockReason === 'nao_configurada'
? 'não configurada'
: ata.lockReason === 'limite_atingido'
? 'limite atingido'
: ata.lockReason === 'vigencia_expirada'
? `vigência encerrada em ${
ata.dataFimEfetiva || ata.dataFim
? formatarDataBR((ata.dataFimEfetiva || ata.dataFim) as string)
: '-'
}`
: null}
<option value={ata._id} disabled={ata.isLocked}>
Ata {ata.numero} (SEI: {ata.numeroSei}){reason ? ` (${reason})` : ''}
</option>
{/each}
</select>
</div>
{/if}
<div> <div>
<label class="mb-2 block text-sm font-medium text-gray-700" for="itemAcao"> <label class="mb-2 block text-sm font-medium text-gray-700" for="itemAcao">
Ação (Opcional) Ação (Opcional)
@@ -723,7 +560,7 @@
<div class="rounded-lg bg-gray-50 p-4"> <div class="rounded-lg bg-gray-50 p-4">
<h4 class="mb-2 font-semibold text-gray-800">Pedido</h4> <h4 class="mb-2 font-semibold text-gray-800">Pedido</h4>
<p class="text-gray-700"><strong>Quantidade:</strong> {detailsItem.quantidade}</p> <p class="text-gray-700"><strong>Quantidade:</strong> {detailsItem.quantidade}</p>
<p class="text-gray-700"><strong>Modalidade:</strong> {detailsItem.modalidade}</p>
{#if detailsItem.acaoId} {#if detailsItem.acaoId}
<p class="text-gray-700"> <p class="text-gray-700">
<strong>Ação:</strong> <strong>Ação:</strong>
@@ -731,32 +568,6 @@
</p> </p>
{/if} {/if}
</div> </div>
{#if detailsItem.ata}
<div class="rounded-lg border border-green-100 bg-green-50 p-4">
<h4 class="mb-2 font-semibold text-green-900">Ata de Registro de Preços</h4>
<p class="text-green-800"><strong>Número:</strong> {detailsItem.ata.numero}</p>
<p class="text-green-800">
<strong>Processo SEI:</strong>
{detailsItem.ata.numeroSei}
</p>
{#if detailsItem.ata.dataInicio}
<p class="text-green-800">
<strong>Vigência:</strong>
{formatarDataBR(detailsItem.ata.dataInicio)} até {detailsItem.ata
.dataFimEfetiva || detailsItem.ata.dataFim
? formatarDataBR(
(detailsItem.ata.dataFimEfetiva || detailsItem.ata.dataFim) as string
)
: 'Indefinido'}
</p>
{/if}
</div>
{:else}
<div class="rounded-lg bg-gray-50 p-4">
<p class="text-sm text-gray-500 italic">Nenhuma Ata vinculada a este item.</p>
</div>
{/if}
</div> </div>
<div class="mt-6 flex justify-end"> <div class="mt-6 flex justify-end">

View File

@@ -159,12 +159,11 @@ async function getUsuarioAutenticado(ctx: QueryCtx | MutationCtx) {
return user; return user;
} }
// Garante que todos os itens de um pedido utilizem a mesma // Garante que todos os itens de um pedido utilizem a mesma ata (quando houver).
// combinação de modalidade e ata (quando houver). // Nota: Modalidade não é mais validada aqui, pois é definida apenas pelo Setor de Compras.
async function ensurePedidoModalidadeAtaConsistency( async function ensurePedidoAtaConsistency(
ctx: MutationCtx, ctx: MutationCtx,
pedidoId: Id<'pedidos'>, pedidoId: Id<'pedidos'>,
modalidade: Doc<'objetoItems'>['modalidade'],
ataId: Id<'atas'> | undefined, ataId: Id<'atas'> | undefined,
ignoreItemId?: Id<'objetoItems'> ignoreItemId?: Id<'objetoItems'>
) { ) {
@@ -185,12 +184,11 @@ async function ensurePedidoModalidadeAtaConsistency(
const normalizedItemAtaId = (('ataId' in item ? item.ataId : undefined) ?? const normalizedItemAtaId = (('ataId' in item ? item.ataId : undefined) ??
null) as Id<'atas'> | null; null) as Id<'atas'> | null;
const modalidadeMismatch = !!item.modalidade && !!modalidade && item.modalidade !== modalidade;
const ataMismatch = normalizedItemAtaId !== normalizedNewAtaId; const ataMismatch = normalizedItemAtaId !== normalizedNewAtaId;
if (modalidadeMismatch || ataMismatch) { if (ataMismatch) {
throw new Error( 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.' 'Todos os itens do pedido devem usar a mesma ata (quando houver). Ajuste os itens existentes ou crie um novo pedido para a nova ata.'
); );
} }
} }
@@ -580,16 +578,11 @@ export const getHistory = query({
export const checkExisting = query({ export const checkExisting = query({
args: { args: {
numeroSei: v.optional(v.string()), numeroSei: v.optional(v.string()),
// Modalidade removida do filtro - agora busca apenas por objetoId
itensFiltro: v.optional( itensFiltro: v.optional(
v.array( v.array(
v.object({ v.object({
objetoId: v.id('objetos'), objetoId: v.id('objetos')
modalidade: v.union(
v.literal('dispensa'),
v.literal('inexgibilidade'),
v.literal('adesao'),
v.literal('consumo')
)
}) })
) )
) )
@@ -608,7 +601,6 @@ export const checkExisting = query({
v.literal('cancelado'), v.literal('cancelado'),
v.literal('concluido') v.literal('concluido')
), ),
// acaoId removed
criadoPor: v.id('usuarios'), criadoPor: v.id('usuarios'),
aceitoPor: v.optional(v.id('funcionarios')), aceitoPor: v.optional(v.id('funcionarios')),
descricaoAjuste: v.optional(v.string()), descricaoAjuste: v.optional(v.string()),
@@ -618,12 +610,6 @@ export const checkExisting = query({
v.array( v.array(
v.object({ v.object({
objetoId: v.id('objetos'), objetoId: v.id('objetos'),
modalidade: v.union(
v.literal('dispensa'),
v.literal('inexgibilidade'),
v.literal('adesao'),
v.literal('consumo')
),
quantidade: v.number() quantidade: v.number()
}) })
) )
@@ -654,7 +640,7 @@ export const checkExisting = query({
return true; return true;
}); });
// 3) Filtro por itens (objetoId + modalidade), se informado, e coleta de matchingItems // 3) Filtro por itens (apenas objetoId), se informado, e coleta de matchingItems
const resultados = []; const resultados = [];
const itensFiltro = args.itensFiltro ?? []; const itensFiltro = args.itensFiltro ?? [];
@@ -663,31 +649,21 @@ export const checkExisting = query({
let include = true; let include = true;
let matchingItems: { let matchingItems: {
objetoId: Id<'objetos'>; objetoId: Id<'objetos'>;
modalidade: NonNullable<Doc<'objetoItems'>['modalidade']>;
quantidade: number; quantidade: number;
}[] = []; }[] = [];
// Se houver filtro de itens, verificamos se o pedido tem ALGUM dos itens (objetoId + modalidade) // Se houver filtro de itens, verificamos se o pedido tem ALGUM dos itens (apenas objetoId)
if (itensFiltro.length > 0) { if (itensFiltro.length > 0) {
const items = await ctx.db const items = await ctx.db
.query('objetoItems') .query('objetoItems')
.withIndex('by_pedidoId', (q) => q.eq('pedidoId', pedido._id)) .withIndex('by_pedidoId', (q) => q.eq('pedidoId', pedido._id))
.collect(); .collect();
const matching = items.filter((i) => const matching = items.filter((i) => itensFiltro.some((f) => f.objetoId === i.objetoId));
itensFiltro.some(
(f) => f.objetoId === i.objetoId && f.modalidade === (i.modalidade ?? 'consumo')
)
);
if (matching.length > 0) { if (matching.length > 0) {
matchingItems = matching.map((i) => ({ matchingItems = matching.map((i) => ({
objetoId: i.objetoId, objetoId: i.objetoId,
modalidade: (i.modalidade ?? 'consumo') as
| 'dispensa'
| 'inexgibilidade'
| 'adesao'
| 'consumo',
quantidade: i.quantidade quantidade: i.quantidade
})); }));
} else { } else {
@@ -1442,9 +1418,27 @@ export const addItem = mutation({
const modalidade = const modalidade =
args.modalidade ?? userProductItems.find((i) => !!i.modalidade)?.modalidade ?? undefined; args.modalidade ?? userProductItems.find((i) => !!i.modalidade)?.modalidade ?? undefined;
// Regra global: todos os itens do pedido devem ter a mesma // Regra global: todos os itens do pedido devem ter a mesma ata (quando houver).
// modalidade e a mesma ata (quando houver). await ensurePedidoAtaConsistency(ctx, args.pedidoId, args.ataId);
await ensurePedidoModalidadeAtaConsistency(ctx, args.pedidoId, modalidade, args.ataId);
// Bloqueia ataId se não for Compras em análise
if (args.ataId) {
const config = await ctx.db.query('config').first();
let isInComprasSector = false;
if (config?.comprasSetorId) {
const funcionarioSetores = await ctx.db
.query('funcionarioSetores')
.withIndex('by_funcionarioId', (q) => q.eq('funcionarioId', user.funcionarioId!))
.filter((q) => q.eq(q.field('setorId'), config.comprasSetorId))
.first();
isInComprasSector = !!funcionarioSetores;
}
if (!(pedido.status === 'em_analise' && isInComprasSector)) {
throw new Error(
'Apenas funcionários do Setor de Compras podem vincular uma Ata, e somente quando o pedido está em análise.'
);
}
}
// --- CHECK ANALYSIS MODE --- // --- CHECK ANALYSIS MODE ---
// Em pedidos em análise, a inclusão de itens deve passar por fluxo de aprovação. // Em pedidos em análise, a inclusão de itens deve passar por fluxo de aprovação.
@@ -1846,9 +1840,35 @@ export const updateItem = mutation({
const pedido = await ctx.db.get(item.pedidoId); const pedido = await ctx.db.get(item.pedidoId);
if (!pedido) throw new Error('Pedido não encontrado.'); if (!pedido) throw new Error('Pedido não encontrado.');
// Apenas quem adicionou o item pode editá-lo // Apenas quem adicionou o item pode editá-lo (outras propriedades)
const isOwner = item.adicionadoPor === user.funcionarioId; const isOwner = item.adicionadoPor === user.funcionarioId;
if (!isOwner) {
// Verificar permissão para editar modalidade e ata
// Somente Setor de Compras pode editar modalidade e ata, e apenas quando em análise
const config = await ctx.db.query('config').first();
let isInComprasSector = false;
if (config?.comprasSetorId) {
const funcionarioSetores = await ctx.db
.query('funcionarioSetores')
.withIndex('by_funcionarioId', (q) => q.eq('funcionarioId', user.funcionarioId!))
.filter((q) => q.eq(q.field('setorId'), config.comprasSetorId))
.first();
isInComprasSector = !!funcionarioSetores;
}
const canEditComprasFields = pedido.status === 'em_analise' && isInComprasSector;
// Se está tentando editar a modalidade ou a ata, verificar permissão
const isChangingModalidade = item.modalidade !== args.modalidade;
const isChangingAta = (item.ataId ?? null) !== (args.ataId ?? null);
if ((isChangingModalidade || isChangingAta) && !canEditComprasFields) {
throw new Error(
'Apenas funcionários do Setor de Compras podem editar a modalidade ou a ata, e somente quando o pedido está em análise.'
);
}
// Para outras propriedades, apenas o dono do item pode editar
if (!isOwner && !canEditComprasFields) {
throw new Error('Apenas quem adicionou este item pode editá-lo.'); throw new Error('Apenas quem adicionou este item pode editá-lo.');
} }
@@ -1860,14 +1880,8 @@ export const updateItem = mutation({
}; };
// Regra global: ao alterar um item, garantir que os demais itens do pedido // Regra global: ao alterar um item, garantir que os demais itens do pedido
// continuem (ou passem a estar) com a mesma modalidade e ata. // continuem (ou passem a estar) com a mesma ata.
await ensurePedidoModalidadeAtaConsistency( await ensurePedidoAtaConsistency(ctx, item.pedidoId, args.ataId, args.itemId);
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 // 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') { if (pedido.status === 'em_analise' || pedido.status === 'aguardando_aceite') {
@@ -1954,6 +1968,8 @@ export const getPermissions = query({
canCancel: false, canCancel: false,
canCompleteAdjustments: false, canCompleteAdjustments: false,
canManageRequests: false, canManageRequests: false,
canEditModalidade: false,
canEditAta: false,
currentFuncionarioId: user?.funcionarioId ?? null currentFuncionarioId: user?.funcionarioId ?? null
}; };
} }
@@ -2004,6 +2020,9 @@ export const getPermissions = query({
isCreator && isCreator &&
hasOnlyCreatorItems, hasOnlyCreatorItems,
canManageRequests: pedido.status === 'em_analise' && isInComprasSector, canManageRequests: pedido.status === 'em_analise' && isInComprasSector,
// Somente Setor de Compras pode editar modalidade e ata, e apenas quando o pedido está em análise
canEditModalidade: pedido.status === 'em_analise' && isInComprasSector,
canEditAta: pedido.status === 'em_analise' && isInComprasSector,
currentFuncionarioId: user.funcionarioId currentFuncionarioId: user.funcionarioId
}; };
} }
@@ -2524,14 +2543,8 @@ export const approveItemRequest = mutation({
// We trust the request data structure matches addItem args // We trust the request data structure matches addItem args
const newItem = data; const newItem = data;
// Regra global: todos os itens do pedido devem ter a mesma // Regra global: todos os itens do pedido devem ter a mesma ata (quando houver).
// modalidade e ata (quando houver). await ensurePedidoAtaConsistency(ctx, request.pedidoId, newItem.ataId);
await ensurePedidoModalidadeAtaConsistency(
ctx,
request.pedidoId,
newItem.modalidade,
newItem.ataId
);
// Note: We MUST use the original requester's ID (request.solicitadoPor) as addedBy? // Note: We MUST use the original requester's ID (request.solicitadoPor) as addedBy?
// Or should we attribute it to the requester? YES. // Or should we attribute it to the requester? YES.
// BUT `addItem` logic usually checks if `existingItem.adicionadoPor === user`. // BUT `addItem` logic usually checks if `existingItem.adicionadoPor === user`.
@@ -2625,13 +2638,7 @@ export const approveItemRequest = mutation({
const item = await ctx.db.get(itemId); const item = await ctx.db.get(itemId);
if (item) { if (item) {
// Regra global também se aplica na alteração de detalhes aprovada // Regra global também se aplica na alteração de detalhes aprovada
await ensurePedidoModalidadeAtaConsistency( await ensurePedidoAtaConsistency(ctx, item.pedidoId, para.ataId, itemId);
ctx,
item.pedidoId,
para.modalidade,
para.ataId,
itemId
);
const oldAtaId = ('ataId' in item ? item.ataId : undefined) ?? undefined; const oldAtaId = ('ataId' in item ? item.ataId : undefined) ?? undefined;
const newAtaId = para.ataId; const newAtaId = para.ataId;