feat: Enhance pedidos management with detailed item linking, object search, and improved UI for item configuration and details

This commit is contained in:
2025-12-03 10:22:22 -03:00
parent 4d29501849
commit d86d7d8dbb
5 changed files with 923 additions and 219 deletions

View File

@@ -1,12 +1,13 @@
<script lang="ts">
import { api } from '@sgse-app/backend/convex/_generated/api';
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
import type { Doc, Id } from '@sgse-app/backend/convex/_generated/dataModel';
import { useConvexClient, useQuery } from 'convex-svelte';
import {
AlertTriangle,
CheckCircle,
Clock,
Edit,
Eye,
Plus,
Save,
Send,
@@ -34,6 +35,43 @@
let objetos = $derived(objetosQuery.data || []);
let acoes = $derived(acoesQuery.data || []);
type Modalidade =
| 'dispensa'
| 'inexgibilidade'
| 'adesao'
| 'consumo';
type EditingItem = {
valorEstimado: string;
modalidade: Modalidade;
acaoId: string;
ataId: string;
};
type PedidoItemForEdit = {
_id: Id<'objetoItems'>;
valorEstimado: string;
modalidade: Modalidade;
acaoId?: Id<'acoes'>;
ataId?: Id<'atas'>;
objetoId: Id<'objetos'>;
};
// Atas por objeto (carregadas sob demanda)
let atasPorObjeto = $state<Record<string, Array<Doc<'atas'>>>>({});
let editingItems = $state<Record<string, EditingItem>>({});
// Garante que, para todos os itens existentes, as atas do respectivo objeto
// sejam carregadas independentemente do formulário de criação.
$effect(() => {
for (const item of items as unknown as PedidoItemForEdit[]) {
if (!atasPorObjeto[item.objetoId]) {
void loadAtasForObjeto(item.objetoId);
}
}
});
// Group items by user
let groupedItems = $derived.by(() => {
const groups: Record<string, { name: string; items: typeof items }> = {};
@@ -73,8 +111,9 @@
objetoId: '' as string,
valorEstimado: '',
quantidade: 1,
modalidade: 'consumo' as 'dispensa' | 'inexgibilidade' | 'adesao' | 'consumo',
acaoId: '' as string
modalidade: 'consumo' as Modalidade,
acaoId: '' as string,
ataId: '' as string
});
let addingItem = $state(false);
@@ -83,6 +122,23 @@
let seiValue = $state('');
let updatingSei = $state(false);
// Item Details Modal State
let showDetailsModal = $state(false);
let selectedObjeto = $state<Doc<'objetos'> | null>(null);
function openDetails(objetoId: string) {
const obj = objetos.find((o) => o._id === objetoId);
if (obj) {
selectedObjeto = obj;
showDetailsModal = true;
}
}
function closeDetails() {
showDetailsModal = false;
selectedObjeto = null;
}
async function handleAddItem() {
if (!newItem.objetoId || !newItem.valorEstimado) return;
addingItem = true;
@@ -93,14 +149,16 @@
valorEstimado: newItem.valorEstimado,
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
});
newItem = {
objetoId: '',
valorEstimado: '',
quantidade: 1,
modalidade: 'consumo',
acaoId: ''
acaoId: '',
ataId: ''
};
showAddItem = false;
} catch (e) {
@@ -163,6 +221,25 @@
return acoes.find((a) => a._id === id)?.nome || '-';
}
async function loadAtasForObjeto(objetoId: string) {
if (atasPorObjeto[objetoId]) return;
try {
const linkedAtas = await client.query(api.objetos.getAtas, {
objetoId: objetoId as Id<'objetos'>
});
atasPorObjeto = {
...atasPorObjeto,
[objetoId]: linkedAtas
};
} catch (e) {
console.error('Erro ao carregar atas para objeto', objetoId, e);
}
}
function getAtasForObjeto(objetoId: string): Array<Doc<'atas'>> {
return atasPorObjeto[objetoId] || [];
}
function handleObjetoChange(id: string) {
newItem.objetoId = id;
const objeto = objetos.find((o) => o._id === id);
@@ -171,6 +248,11 @@
} else {
newItem.valorEstimado = '';
}
newItem.ataId = '';
if (id) {
void loadAtasForObjeto(id);
}
}
function parseMoneyToNumber(value: string): number {
@@ -187,6 +269,54 @@
items.reduce((sum, item) => sum + calculateItemTotal(item.valorEstimado, item.quantidade), 0)
);
function ensureEditingItem(item: PedidoItemForEdit): EditingItem {
const existing = editingItems[item._id];
if (existing) return existing;
return {
valorEstimado: maskCurrencyBRL(item.valorEstimado || ''),
modalidade: item.modalidade,
acaoId: item.acaoId ?? '',
ataId: item.ataId ?? ''
};
}
function setEditingField<K extends keyof EditingItem>(
itemId: Id<'objetoItems'>,
field: K,
value: EditingItem[K]
) {
const current = editingItems[itemId] ?? {
valorEstimado: '',
modalidade: 'consumo',
acaoId: '',
ataId: ''
};
editingItems = {
...editingItems,
[itemId]: {
...current,
[field]: value
}
};
}
async function persistItemChanges(item: PedidoItemForEdit) {
const current = ensureEditingItem(item);
try {
await client.mutation(api.pedidos.updateItem, {
itemId: item._id,
valorEstimado: current.valorEstimado,
modalidade: current.modalidade,
acaoId: current.acaoId ? (current.acaoId as Id<'acoes'>) : undefined,
ataId: current.ataId ? (current.ataId as Id<'atas'>) : undefined
});
} catch (e) {
alert('Erro ao atualizar item: ' + (e as Error).message);
}
}
function formatStatus(status: string) {
switch (status) {
case 'em_rascunho':
@@ -268,6 +398,8 @@
return '🔄';
case 'atualizacao_sei':
return '📋';
case 'edicao_item':
return '✏️';
default:
return '•';
}
@@ -308,6 +440,11 @@
return `${entry.usuarioNome} alterou o status para "${formatStatus(detalhes.novoStatus)}"`;
case 'atualizacao_sei':
return `${entry.usuarioNome} atualizou o número SEI para "${detalhes.numeroSei}"`;
case 'edicao_item': {
const objeto = objetos.find((o) => o._id === detalhes.objetoId);
const nomeObjeto = objeto?.nome || 'Objeto desconhecido';
return `${entry.usuarioNome} editou o item ${nomeObjeto}`;
}
default:
return `${entry.usuarioNome} realizou: ${entry.acao}`;
@@ -497,6 +634,23 @@
<option value="adesao">Adesão</option>
</select>
</div>
{#if newItem.objetoId}
<div>
<label for="ata-select" class="mb-1 block text-xs font-medium text-gray-500"
>Ata (Opcional)</label
>
<select
id="ata-select"
bind:value={newItem.ataId}
class="w-full rounded-md border-gray-300 text-sm shadow-sm"
>
<option value="">Nenhuma</option>
{#each getAtasForObjeto(newItem.objetoId) as ata (ata._id)}
<option value={ata._id}>Ata {ata.numero} (SEI: {ata.numeroSei})</option>
{/each}
</select>
</div>
{/if}
<div>
<label for="acao-select" class="mb-1 block text-xs font-medium text-gray-500"
>Ação (Opcional)</label
@@ -559,6 +713,10 @@
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
@@ -588,13 +746,92 @@
{/if}
</td>
<td class="px-6 py-4 whitespace-nowrap">
{maskCurrencyBRL(item.valorEstimado) || 'R$ 0,00'}
{#if pedido.status === 'em_rascunho' || pedido.status === 'precisa_ajustes'}
<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">
{item.modalidade}
{#if pedido.status === 'em_rascunho' || pedido.status === 'precisa_ajustes'}
<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">
{getAcaoName(item.acaoId)}
{#if pedido.status === 'em_rascunho' || pedido.status === 'precisa_ajustes'}
<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'}
<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}
{/if}
</td>
<td class="px-6 py-4 text-right font-medium whitespace-nowrap">
R$ {calculateItemTotal(item.valorEstimado, item.quantidade)
@@ -602,12 +839,20 @@
.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'}
<button
onclick={() => handleRemoveItem(item._id)}
class="text-red-600 hover:text-red-900"
title="Remover Item"
>
<Trash2 size={16} />
<Trash2 size={18} />
</button>
{/if}
</td>
@@ -629,6 +874,81 @@
</div>
</div>
{#if showDetailsModal && selectedObjeto}
<div
class="fixed inset-0 z-50 flex h-full w-full items-center justify-center overflow-y-auto bg-black/40"
>
<div class="relative w-full max-w-lg rounded-lg bg-white p-8 shadow-xl">
<button
onclick={closeDetails}
class="absolute top-4 right-4 text-gray-400 hover:text-gray-600"
>
<X size={24} />
</button>
<h2 class="mb-6 text-xl font-bold">Detalhes do Objeto</h2>
<div class="space-y-4">
<div>
<div class="block text-xs font-bold text-gray-500 uppercase">Nome</div>
<p class="text-gray-900">{selectedObjeto.nome}</p>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<div class="block text-xs font-bold text-gray-500 uppercase">Tipo</div>
<span
class="inline-flex rounded-full px-2 text-xs leading-5 font-semibold
{selectedObjeto.tipo === 'servico'
? 'bg-green-100 text-green-800'
: 'bg-blue-100 text-blue-800'}"
>
{selectedObjeto.tipo === 'material' ? 'Material' : 'Serviço'}
</span>
</div>
<div>
<div class="block text-xs font-bold text-gray-500 uppercase">Unidade</div>
<p class="text-gray-900">{selectedObjeto.unidade}</p>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<div class="block text-xs font-bold text-gray-500 uppercase">Código Efisco</div>
<p class="text-gray-900">{selectedObjeto.codigoEfisco}</p>
</div>
<div>
{#if selectedObjeto.tipo === 'material'}
<div class="block text-xs font-bold text-gray-500 uppercase">Código Catmat</div>
<p class="text-gray-900">{selectedObjeto.codigoCatmat || '-'}</p>
{:else}
<div class="block text-xs font-bold text-gray-500 uppercase">Código Catserv</div>
<p class="text-gray-900">{selectedObjeto.codigoCatserv || '-'}</p>
{/if}
</div>
</div>
<div>
<div class="block text-xs font-bold text-gray-500 uppercase"
>Valor Estimado (Unitário)</div
>
<p class="text-gray-900">
{maskCurrencyBRL(selectedObjeto.valorEstimado || '') || 'R$ 0,00'}
</p>
</div>
</div>
<div class="mt-6 flex justify-end">
<button
onclick={closeDetails}
class="rounded bg-gray-200 px-4 py-2 font-medium text-gray-700 hover:bg-gray-300"
>
Fechar
</button>
</div>
</div>
</div>
{/if}
<!-- Histórico -->
<div class="rounded-lg bg-white p-6 shadow">
<h2 class="mb-4 text-lg font-semibold text-gray-900">Histórico</h2>
@@ -640,7 +960,7 @@
<div
class="flex items-start gap-3 rounded-lg border border-gray-200 p-3 hover:bg-gray-50"
>
<div class="flex-shrink-0 text-2xl">
<div class="shrink-0 text-2xl">
{getHistoryIcon(entry.acao)}
</div>
<div class="min-w-0 flex-1">