feat: Enhance pedidos management with detailed item linking, object search, and improved UI for item configuration and details
This commit is contained in:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user