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 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<K extends keyof EditingItem>(
|
||||
itemId: Id<'objetoItems'>,
|
||||
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">
|
||||
Adicionado por: {group.name}
|
||||
</div>
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
{#if pedido.status === 'em_rascunho' || pedido.status === 'precisa_ajustes'}
|
||||
<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">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
{#if (pedido.status === 'em_rascunho' || pedido.status === 'precisa_ajustes') && currentFuncionarioId && group.items[0]?.adicionadoPor === currentFuncionarioId}
|
||||
<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"
|
||||
checked={isItemSelected(item._id)}
|
||||
onchange={() => 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}`}
|
||||
/>
|
||||
</td>
|
||||
</th>
|
||||
{/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>
|
||||
<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>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</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') && 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}
|
||||
|
||||
{#if items.length === 0}
|
||||
|
||||
Reference in New Issue
Block a user