feat: Enforce consistency in order items' modalidade and ata across mutations and frontend validation in pedidos management.
This commit is contained in:
@@ -44,6 +44,8 @@
|
|||||||
let permissions = $derived(permissionsQuery.data);
|
let permissions = $derived(permissionsQuery.data);
|
||||||
let requests = $derived(requestsQuery.data || []);
|
let requests = $derived(requestsQuery.data || []);
|
||||||
|
|
||||||
|
let currentFuncionarioId = $derived(permissions?.currentFuncionarioId ?? null);
|
||||||
|
|
||||||
type Modalidade = 'dispensa' | 'inexgibilidade' | 'adesao' | 'consumo';
|
type Modalidade = 'dispensa' | 'inexgibilidade' | 'adesao' | 'consumo';
|
||||||
|
|
||||||
type EditingItem = {
|
type EditingItem = {
|
||||||
@@ -251,6 +253,35 @@
|
|||||||
|
|
||||||
async function handleAddItem() {
|
async function handleAddItem() {
|
||||||
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
|
||||||
|
// 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;
|
addingItem = true;
|
||||||
try {
|
try {
|
||||||
await client.mutation(api.pedidos.addItem, {
|
await client.mutation(api.pedidos.addItem, {
|
||||||
@@ -277,7 +308,27 @@
|
|||||||
toast.success('Item adicionado com sucesso!');
|
toast.success('Item adicionado com sucesso!');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} 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 {
|
} finally {
|
||||||
addingItem = false;
|
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<K extends keyof EditingItem>(
|
function setEditingField<K extends keyof EditingItem>(
|
||||||
itemId: Id<'objetoItems'>,
|
itemId: Id<'objetoItems'>,
|
||||||
field: K,
|
field: K,
|
||||||
@@ -1181,207 +1247,209 @@
|
|||||||
<div class="border-b border-gray-200 bg-gray-100 px-6 py-2 font-medium text-gray-700">
|
<div class="border-b border-gray-200 bg-gray-100 px-6 py-2 font-medium text-gray-700">
|
||||||
Adicionado por: {group.name}
|
Adicionado por: {group.name}
|
||||||
</div>
|
</div>
|
||||||
<table class="min-w-full divide-y divide-gray-200">
|
<div class="overflow-x-auto">
|
||||||
<thead class="bg-gray-50">
|
<table class="min-w-full divide-y divide-gray-200">
|
||||||
<tr>
|
<thead class="bg-gray-50">
|
||||||
{#if pedido.status === 'em_rascunho' || pedido.status === 'precisa_ajustes'}
|
<tr>
|
||||||
<th
|
{#if (pedido.status === 'em_rascunho' || pedido.status === 'precisa_ajustes') && currentFuncionarioId && group.items[0]?.adicionadoPor === currentFuncionarioId}
|
||||||
class="px-4 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
|
<th
|
||||||
>
|
class="px-4 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
|
||||||
<input
|
>
|
||||||
type="checkbox"
|
|
||||||
class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
|
||||||
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}`}
|
|
||||||
/>
|
|
||||||
</th>
|
|
||||||
{/if}
|
|
||||||
<th
|
|
||||||
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
|
|
||||||
>
|
|
||||||
Objeto</th
|
|
||||||
>
|
|
||||||
<th
|
|
||||||
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
|
|
||||||
>Qtd</th
|
|
||||||
>
|
|
||||||
<th
|
|
||||||
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
|
|
||||||
>Valor Est.</th
|
|
||||||
>
|
|
||||||
<th
|
|
||||||
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
|
|
||||||
>Modalidade</th
|
|
||||||
>
|
|
||||||
<th
|
|
||||||
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
|
|
||||||
>Ação</th
|
|
||||||
>
|
|
||||||
<th
|
|
||||||
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
|
|
||||||
>Ata</th
|
|
||||||
>
|
|
||||||
<th
|
|
||||||
class="px-6 py-3 text-right text-xs font-medium tracking-wider text-gray-500 uppercase"
|
|
||||||
>Total</th
|
|
||||||
>
|
|
||||||
<th
|
|
||||||
class="px-6 py-3 text-right text-xs font-medium tracking-wider text-gray-500 uppercase"
|
|
||||||
>Ações</th
|
|
||||||
>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody class="divide-y divide-gray-200 bg-white">
|
|
||||||
{#each group.items as item (item._id)}
|
|
||||||
<tr class:selected={isItemSelected(item._id)}>
|
|
||||||
{#if pedido.status === 'em_rascunho' || pedido.status === 'precisa_ajustes'}
|
|
||||||
<td class="px-4 py-4 whitespace-nowrap">
|
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||||
checked={isItemSelected(item._id)}
|
onchange={(e) => {
|
||||||
onchange={() => toggleItemSelection(item._id)}
|
const checked = e.currentTarget.checked;
|
||||||
aria-label={`Selecionar item ${getObjetoName(item.objetoId)}`}
|
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}`}
|
||||||
/>
|
/>
|
||||||
</td>
|
</th>
|
||||||
{/if}
|
{/if}
|
||||||
<td class="px-6 py-4 whitespace-nowrap">{getObjetoName(item.objetoId)}</td>
|
<th
|
||||||
<td class="px-6 py-4 whitespace-nowrap">
|
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
|
||||||
{#if pedido.status === 'em_rascunho' || pedido.status === 'precisa_ajustes' || pedido.status === 'em_analise' || pedido.status === 'aguardando_aceite'}
|
>
|
||||||
<input
|
Objeto</th
|
||||||
type="number"
|
>
|
||||||
min="1"
|
<th
|
||||||
value={item.quantidade}
|
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
|
||||||
onchange={(e) =>
|
>Qtd</th
|
||||||
handleUpdateQuantity(item._id, parseInt(e.currentTarget.value) || 1)}
|
>
|
||||||
class="w-20 rounded border px-2 py-1 text-sm"
|
<th
|
||||||
/>
|
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
|
||||||
{:else}
|
>Valor Est.</th
|
||||||
{item.quantidade}
|
>
|
||||||
{/if}
|
<th
|
||||||
</td>
|
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
|
||||||
<td class="px-6 py-4 whitespace-nowrap">
|
>Modalidade</th
|
||||||
{#if pedido.status === 'em_rascunho' || pedido.status === 'precisa_ajustes' || pedido.status === 'em_analise' || pedido.status === 'aguardando_aceite'}
|
>
|
||||||
<input
|
<th
|
||||||
type="text"
|
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
|
||||||
class="w-28 rounded border px-2 py-1 text-sm"
|
>Ação</th
|
||||||
value={ensureEditingItem(item).valorEstimado}
|
>
|
||||||
oninput={(e) =>
|
<th
|
||||||
setEditingField(
|
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
|
||||||
item._id,
|
>Ata</th
|
||||||
'valorEstimado',
|
>
|
||||||
maskCurrencyBRL(e.currentTarget.value)
|
<th
|
||||||
)}
|
class="px-6 py-3 text-right text-xs font-medium tracking-wider text-gray-500 uppercase"
|
||||||
onblur={() => persistItemChanges(item)}
|
>Total</th
|
||||||
placeholder="R$ 0,00"
|
>
|
||||||
/>
|
<th
|
||||||
{:else}
|
class="px-6 py-3 text-right text-xs font-medium tracking-wider text-gray-500 uppercase"
|
||||||
{maskCurrencyBRL(item.valorEstimado) || 'R$ 0,00'}
|
>Ações</th
|
||||||
{/if}
|
>
|
||||||
</td>
|
|
||||||
<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'}
|
|
||||||
<select
|
|
||||||
class="rounded border px-2 py-1 text-xs"
|
|
||||||
value={ensureEditingItem(item).modalidade}
|
|
||||||
onchange={(e) => {
|
|
||||||
setEditingField(
|
|
||||||
item._id,
|
|
||||||
'modalidade',
|
|
||||||
e.currentTarget.value as Modalidade
|
|
||||||
);
|
|
||||||
void persistItemChanges(item);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<option value="consumo">Consumo</option>
|
|
||||||
<option value="dispensa">Dispensa</option>
|
|
||||||
<option value="inexgibilidade">Inexigibilidade</option>
|
|
||||||
<option value="adesao">Adesão</option>
|
|
||||||
</select>
|
|
||||||
{:else}
|
|
||||||
{item.modalidade}
|
|
||||||
{/if}
|
|
||||||
</td>
|
|
||||||
<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'}
|
|
||||||
<select
|
|
||||||
class="rounded border px-2 py-1 text-xs"
|
|
||||||
value={ensureEditingItem(item).acaoId}
|
|
||||||
onchange={(e) => {
|
|
||||||
setEditingField(item._id, 'acaoId', e.currentTarget.value);
|
|
||||||
void persistItemChanges(item);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<option value="">Nenhuma</option>
|
|
||||||
{#each acoes as a (a._id)}
|
|
||||||
<option value={a._id}>{a.nome}</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
{:else}
|
|
||||||
{getAcaoName(item.acaoId)}
|
|
||||||
{/if}
|
|
||||||
</td>
|
|
||||||
<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'}
|
|
||||||
<select
|
|
||||||
class="rounded border px-2 py-1 text-xs"
|
|
||||||
value={ensureEditingItem(item).ataId}
|
|
||||||
onchange={(e) => {
|
|
||||||
setEditingField(item._id, 'ataId', e.currentTarget.value);
|
|
||||||
void persistItemChanges(item);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<option value="">Nenhuma</option>
|
|
||||||
{#each getAtasForObjeto(item.objetoId) as ata (ata._id)}
|
|
||||||
<option value={ata._id}>Ata {ata.numero} (SEI: {ata.numeroSei})</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
{:else if item.ataId}
|
|
||||||
{#each getAtasForObjeto(item.objetoId) as ata (ata._id)}
|
|
||||||
{#if ata._id === item.ataId}
|
|
||||||
Ata {ata.numero}
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
{:else}
|
|
||||||
-
|
|
||||||
{/if}
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-4 text-right font-medium whitespace-nowrap">
|
|
||||||
R$ {calculateItemTotal(item.valorEstimado, item.quantidade)
|
|
||||||
.toFixed(2)
|
|
||||||
.replace('.', ',')}
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-4 text-right text-sm font-medium whitespace-nowrap">
|
|
||||||
<button
|
|
||||||
onclick={() => openDetails(item.objetoId)}
|
|
||||||
class="mr-3 text-indigo-600 hover:text-indigo-900"
|
|
||||||
title="Ver Detalhes"
|
|
||||||
>
|
|
||||||
<Eye size={18} />
|
|
||||||
</button>
|
|
||||||
{#if pedido.status === 'em_rascunho' || pedido.status === 'precisa_ajustes' || pedido.status === 'em_analise' || pedido.status === 'aguardando_aceite'}
|
|
||||||
<button
|
|
||||||
onclick={() => handleRemoveItem(item._id)}
|
|
||||||
class="text-red-600 hover:text-red-900"
|
|
||||||
title="Remover Item"
|
|
||||||
>
|
|
||||||
<Trash2 size={18} />
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
{/each}
|
</thead>
|
||||||
</tbody>
|
<tbody class="divide-y divide-gray-200 bg-white">
|
||||||
</table>
|
{#each group.items as item (item._id)}
|
||||||
|
<tr class:selected={isItemSelected(item._id)}>
|
||||||
|
{#if (pedido.status === 'em_rascunho' || pedido.status === 'precisa_ajustes') && currentFuncionarioId && item.adicionadoPor === currentFuncionarioId}
|
||||||
|
<td class="px-4 py-4 whitespace-nowrap">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||||
|
checked={isItemSelected(item._id)}
|
||||||
|
onchange={() => toggleItemSelection(item._id)}
|
||||||
|
aria-label={`Selecionar item ${getObjetoName(item.objetoId)}`}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
{/if}
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">{getObjetoName(item.objetoId)}</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
{#if pedido.status === 'em_rascunho' || pedido.status === 'precisa_ajustes' || pedido.status === 'em_analise' || pedido.status === 'aguardando_aceite'}
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
value={item.quantidade}
|
||||||
|
onchange={(e) =>
|
||||||
|
handleUpdateQuantity(item._id, parseInt(e.currentTarget.value) || 1)}
|
||||||
|
class="w-20 rounded border px-2 py-1 text-sm"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
{item.quantidade}
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
{#if pedido.status === 'em_rascunho' || pedido.status === 'precisa_ajustes' || pedido.status === 'em_analise' || pedido.status === 'aguardando_aceite'}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="w-28 rounded border px-2 py-1 text-sm"
|
||||||
|
value={ensureEditingItem(item).valorEstimado}
|
||||||
|
oninput={(e) =>
|
||||||
|
setEditingField(
|
||||||
|
item._id,
|
||||||
|
'valorEstimado',
|
||||||
|
maskCurrencyBRL(e.currentTarget.value)
|
||||||
|
)}
|
||||||
|
onblur={() => persistItemChanges(item)}
|
||||||
|
placeholder="R$ 0,00"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
{maskCurrencyBRL(item.valorEstimado) || 'R$ 0,00'}
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<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'}
|
||||||
|
<select
|
||||||
|
class="rounded border px-2 py-1 text-xs"
|
||||||
|
value={ensureEditingItem(item).modalidade}
|
||||||
|
onchange={(e) => {
|
||||||
|
setEditingField(
|
||||||
|
item._id,
|
||||||
|
'modalidade',
|
||||||
|
e.currentTarget.value as Modalidade
|
||||||
|
);
|
||||||
|
void persistItemChanges(item);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="consumo">Consumo</option>
|
||||||
|
<option value="dispensa">Dispensa</option>
|
||||||
|
<option value="inexgibilidade">Inexigibilidade</option>
|
||||||
|
<option value="adesao">Adesão</option>
|
||||||
|
</select>
|
||||||
|
{:else}
|
||||||
|
{item.modalidade}
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<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'}
|
||||||
|
<select
|
||||||
|
class="rounded border px-2 py-1 text-xs"
|
||||||
|
value={ensureEditingItem(item).acaoId}
|
||||||
|
onchange={(e) => {
|
||||||
|
setEditingField(item._id, 'acaoId', e.currentTarget.value);
|
||||||
|
void persistItemChanges(item);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">Nenhuma</option>
|
||||||
|
{#each acoes as a (a._id)}
|
||||||
|
<option value={a._id}>{a.nome}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
{:else}
|
||||||
|
{getAcaoName(item.acaoId)}
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<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'}
|
||||||
|
<select
|
||||||
|
class="rounded border px-2 py-1 text-xs"
|
||||||
|
value={ensureEditingItem(item).ataId}
|
||||||
|
onchange={(e) => {
|
||||||
|
setEditingField(item._id, 'ataId', e.currentTarget.value);
|
||||||
|
void persistItemChanges(item);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">Nenhuma</option>
|
||||||
|
{#each getAtasForObjeto(item.objetoId) as ata (ata._id)}
|
||||||
|
<option value={ata._id}>Ata {ata.numero} (SEI: {ata.numeroSei})</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
{:else if item.ataId}
|
||||||
|
{#each getAtasForObjeto(item.objetoId) as ata (ata._id)}
|
||||||
|
{#if ata._id === item.ataId}
|
||||||
|
Ata {ata.numero}
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
{:else}
|
||||||
|
-
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 text-right font-medium whitespace-nowrap">
|
||||||
|
R$ {calculateItemTotal(item.valorEstimado, item.quantidade)
|
||||||
|
.toFixed(2)
|
||||||
|
.replace('.', ',')}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 text-right text-sm font-medium whitespace-nowrap">
|
||||||
|
<button
|
||||||
|
onclick={() => openDetails(item.objetoId)}
|
||||||
|
class="mr-3 text-indigo-600 hover:text-indigo-900"
|
||||||
|
title="Ver Detalhes"
|
||||||
|
>
|
||||||
|
<Eye size={18} />
|
||||||
|
</button>
|
||||||
|
{#if pedido.status === 'em_rascunho' || pedido.status === 'precisa_ajustes' || pedido.status === 'em_analise' || pedido.status === 'aguardando_aceite'}
|
||||||
|
<button
|
||||||
|
onclick={() => handleRemoveItem(item._id)}
|
||||||
|
class="text-red-600 hover:text-red-900"
|
||||||
|
title="Remover Item"
|
||||||
|
>
|
||||||
|
<Trash2 size={18} />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
{#if items.length === 0}
|
{#if items.length === 0}
|
||||||
|
|||||||
@@ -13,6 +13,41 @@ async function getUsuarioAutenticado(ctx: QueryCtx | MutationCtx) {
|
|||||||
return user;
|
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 ==========
|
// ========== QUERIES ==========
|
||||||
|
|
||||||
export const list = query({
|
export const list = query({
|
||||||
@@ -619,6 +654,15 @@ export const addItem = mutation({
|
|||||||
const pedido = await ctx.db.get(args.pedidoId);
|
const pedido = await ctx.db.get(args.pedidoId);
|
||||||
if (!pedido) throw new Error('Pedido não encontrado.');
|
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 ---
|
// --- CHECK ANALYSIS / ACCEPTANCE MODE ---
|
||||||
if (pedido.status === 'em_analise' || pedido.status === 'aguardando_aceite') {
|
if (pedido.status === 'em_analise' || pedido.status === 'aguardando_aceite') {
|
||||||
await ctx.db.insert('solicitacoesItens', {
|
await ctx.db.insert('solicitacoesItens', {
|
||||||
@@ -651,7 +695,7 @@ export const addItem = mutation({
|
|||||||
|
|
||||||
if (conflict) {
|
if (conflict) {
|
||||||
throw new Error(
|
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
|
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
|
// 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') {
|
||||||
await ctx.db.insert('solicitacoesItens', {
|
await ctx.db.insert('solicitacoesItens', {
|
||||||
@@ -1074,7 +1128,10 @@ export const getPermissions = query({
|
|||||||
canStartAnalysis: false,
|
canStartAnalysis: false,
|
||||||
canConclude: false,
|
canConclude: false,
|
||||||
canRequestAdjustments: false,
|
canRequestAdjustments: false,
|
||||||
canCancel: false
|
canCancel: false,
|
||||||
|
canCompleteAdjustments: false,
|
||||||
|
canManageRequests: false,
|
||||||
|
currentFuncionarioId: user?.funcionarioId ?? null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1099,6 +1156,14 @@ export const getPermissions = query({
|
|||||||
.first();
|
.first();
|
||||||
const hasAddedItems = !!userItem;
|
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;
|
const isCreator = pedido.criadoPor === user._id;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -1111,8 +1176,13 @@ export const getPermissions = query({
|
|||||||
isInComprasSector &&
|
isInComprasSector &&
|
||||||
pedido.aceitoPor === user.funcionarioId,
|
pedido.aceitoPor === user.funcionarioId,
|
||||||
canCompleteAdjustments: pedido.status === 'precisa_ajustes' && hasAddedItems,
|
canCompleteAdjustments: pedido.status === 'precisa_ajustes' && hasAddedItems,
|
||||||
canCancel: pedido.status !== 'cancelado' && pedido.status !== 'concluido' && isCreator,
|
canCancel:
|
||||||
canManageRequests: pedido.status === 'em_analise' && isInComprasSector
|
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.');
|
throw new Error('Pedido já finalizado.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Anyone involved (creator or compras) can cancel? Or just creator?
|
// Regra: apenas o criador pode cancelar o pedido
|
||||||
// Logic: If it's creator OR Compras.
|
|
||||||
const isCreator = pedido.criadoPor === user._id;
|
const isCreator = pedido.criadoPor === user._id;
|
||||||
let isCompras = false;
|
if (!isCreator) {
|
||||||
|
throw new Error('Apenas quem criou este pedido pode cancelá-lo.');
|
||||||
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 && !isCompras) {
|
if (!user.funcionarioId) {
|
||||||
throw new Error('Permissão negada para cancelar este pedido.');
|
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;
|
const oldStatus = pedido.status;
|
||||||
@@ -1597,6 +1672,15 @@ export const approveItemRequest = mutation({
|
|||||||
// Reuse addItem logic (simplified: insert or increment)
|
// Reuse addItem logic (simplified: insert or increment)
|
||||||
// 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
|
||||||
|
// 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?
|
// 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`.
|
||||||
@@ -1675,6 +1759,15 @@ 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
|
||||||
|
await ensurePedidoModalidadeAtaConsistency(
|
||||||
|
ctx,
|
||||||
|
item.pedidoId,
|
||||||
|
para.modalidade,
|
||||||
|
para.ataId,
|
||||||
|
itemId
|
||||||
|
);
|
||||||
|
|
||||||
await ctx.db.patch(itemId, {
|
await ctx.db.patch(itemId, {
|
||||||
valorEstimado: para.valorEstimado,
|
valorEstimado: para.valorEstimado,
|
||||||
modalidade: para.modalidade,
|
modalidade: para.modalidade,
|
||||||
|
|||||||
Reference in New Issue
Block a user