feat: Implement batch item removal and pedido splitting for pedidos, and add document management for atas.
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||
import type { Doc, Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||
import { useConvexClient, useQuery } from 'convex-svelte';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
@@ -15,18 +16,20 @@
|
||||
X,
|
||||
XCircle
|
||||
} from 'lucide-svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import { page } from '$app/state';
|
||||
import { maskCurrencyBRL } from '$lib/utils/masks';
|
||||
|
||||
const pedidoId = $page.params.id as Id<'pedidos'>;
|
||||
const pedidoId = $derived(page.params.id as Id<'pedidos'>);
|
||||
const client = useConvexClient();
|
||||
|
||||
// Reactive queries
|
||||
const pedidoQuery = useQuery(api.pedidos.get, { id: pedidoId });
|
||||
const itemsQuery = useQuery(api.pedidos.getItems, { pedidoId });
|
||||
const historyQuery = useQuery(api.pedidos.getHistory, { pedidoId });
|
||||
const objetosQuery = useQuery(api.objetos.list, {});
|
||||
const acoesQuery = useQuery(api.acoes.list, {});
|
||||
const pedidoQuery = $derived.by(() => useQuery(api.pedidos.get, { id: pedidoId }));
|
||||
const itemsQuery = $derived.by(() => useQuery(api.pedidos.getItems, { pedidoId }));
|
||||
const historyQuery = $derived.by(() => useQuery(api.pedidos.getHistory, { pedidoId }));
|
||||
const objetosQuery = $derived.by(() => useQuery(api.objetos.list, {}));
|
||||
const acoesQuery = $derived.by(() => useQuery(api.acoes.list, {}));
|
||||
|
||||
// Derived state
|
||||
let pedido = $derived(pedidoQuery.data);
|
||||
@@ -35,11 +38,7 @@
|
||||
let objetos = $derived(objetosQuery.data || []);
|
||||
let acoes = $derived(acoesQuery.data || []);
|
||||
|
||||
type Modalidade =
|
||||
| 'dispensa'
|
||||
| 'inexgibilidade'
|
||||
| 'adesao'
|
||||
| 'consumo';
|
||||
type Modalidade = 'dispensa' | 'inexgibilidade' | 'adesao' | 'consumo';
|
||||
|
||||
type EditingItem = {
|
||||
valorEstimado: string;
|
||||
@@ -62,6 +61,28 @@
|
||||
|
||||
let editingItems = $state<Record<string, EditingItem>>({});
|
||||
|
||||
// Seleção de itens para ações em lote
|
||||
let selectedItemIds = new SvelteSet<Id<'objetoItems'>>();
|
||||
|
||||
function isItemSelected(itemId: Id<'objetoItems'>) {
|
||||
return selectedItemIds.has(itemId);
|
||||
}
|
||||
|
||||
function toggleItemSelection(itemId: Id<'objetoItems'>) {
|
||||
if (selectedItemIds.has(itemId)) {
|
||||
selectedItemIds.delete(itemId);
|
||||
} else {
|
||||
selectedItemIds.add(itemId);
|
||||
}
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
selectedItemIds.clear();
|
||||
}
|
||||
|
||||
let selectedCount = $derived(selectedItemIds.size);
|
||||
let hasSelection = $derived(selectedCount > 0);
|
||||
|
||||
// Garante que, para todos os itens existentes, as atas do respectivo objeto
|
||||
// sejam carregadas independentemente do formulário de criação.
|
||||
$effect(() => {
|
||||
@@ -145,7 +166,7 @@
|
||||
if (hasAppliedPrefill) return;
|
||||
if (objetosQuery.isLoading || acoesQuery.isLoading) return;
|
||||
|
||||
const url = $page.url;
|
||||
const url = page.url;
|
||||
const obj = url.searchParams.get('obj');
|
||||
const qtdStr = url.searchParams.get('qtd');
|
||||
const mod = url.searchParams.get('mod') as Modalidade | null;
|
||||
@@ -496,6 +517,61 @@
|
||||
return `${entry.usuarioNome} - ${entry.acao}`;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemoveSelectedItems() {
|
||||
if (!hasSelection) return;
|
||||
if (
|
||||
!confirm(
|
||||
selectedCount === 1
|
||||
? 'Remover o item selecionado deste pedido?'
|
||||
: `Remover os ${selectedCount} itens selecionados deste pedido?`
|
||||
)
|
||||
)
|
||||
return;
|
||||
|
||||
try {
|
||||
const itemIds = Array.from(selectedItemIds) as Id<'objetoItems'>[];
|
||||
await client.mutation(api.pedidos.removeItemsBatch, {
|
||||
itemIds
|
||||
});
|
||||
clearSelection();
|
||||
} catch (e) {
|
||||
alert('Erro ao remover itens selecionados: ' + (e as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
let showSplitResultModal = $state(false);
|
||||
let novoPedidoIdParaNavegar = $state<Id<'pedidos'> | null>(null);
|
||||
let quantidadeItensMovidos = $state(0);
|
||||
|
||||
// Split Confirmation Modal State
|
||||
let showSplitConfirmationModal = $state(false);
|
||||
let newPedidoSei = $state('');
|
||||
|
||||
function handleSplitPedidoFromSelection() {
|
||||
if (!hasSelection) return;
|
||||
newPedidoSei = '';
|
||||
showSplitConfirmationModal = true;
|
||||
}
|
||||
|
||||
async function confirmSplitPedido() {
|
||||
try {
|
||||
const itemIds = Array.from(selectedItemIds) as Id<'objetoItems'>[];
|
||||
const novoPedidoId = await client.mutation(api.pedidos.splitPedido, {
|
||||
pedidoId,
|
||||
itemIds,
|
||||
numeroSei: newPedidoSei.trim() || undefined
|
||||
});
|
||||
|
||||
novoPedidoIdParaNavegar = novoPedidoId;
|
||||
quantidadeItensMovidos = itemIds.length;
|
||||
showSplitConfirmationModal = false;
|
||||
showSplitResultModal = true;
|
||||
clearSelection();
|
||||
} catch (e) {
|
||||
alert('Erro ao dividir pedido: ' + (e as Error).message);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="container mx-auto p-6">
|
||||
@@ -729,6 +805,47 @@
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-col">
|
||||
{#if hasSelection}
|
||||
<div
|
||||
class="flex items-center justify-between border-b border-blue-100 bg-blue-50 px-6 py-3 text-sm text-blue-900"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="inline-flex h-6 w-6 items-center justify-center rounded-full bg-blue-600 text-xs font-semibold text-white"
|
||||
>
|
||||
{selectedCount}
|
||||
</span>
|
||||
<span
|
||||
>{selectedCount === 1
|
||||
? '1 item selecionado'
|
||||
: `${selectedCount} itens selecionados`}</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-transparent bg-blue-600 px-3 py-1.5 text-xs font-medium text-white shadow-sm hover:bg-blue-700"
|
||||
onclick={() => handleSplitPedidoFromSelection()}
|
||||
>
|
||||
Criar novo pedido com selecionados
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-red-200 bg-red-50 px-3 py-1.5 text-xs font-medium text-red-700 hover:bg-red-100"
|
||||
onclick={() => handleRemoveSelectedItems()}
|
||||
>
|
||||
Excluir selecionados
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-transparent px-2 py-1 text-xs font-medium text-blue-700 hover:bg-blue-100"
|
||||
onclick={clearSelection}
|
||||
>
|
||||
Limpar seleção
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#each groupedItems as group (group.name)}
|
||||
<div class="border-b border-gray-200 bg-gray-100 px-6 py-2 font-medium text-gray-700">
|
||||
Adicionado por: {group.name}
|
||||
@@ -736,9 +853,31 @@
|
||||
<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
|
||||
>
|
||||
Objeto</th
|
||||
>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
|
||||
@@ -772,7 +911,18 @@
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 bg-white">
|
||||
{#each group.items as item (item._id)}
|
||||
<tr>
|
||||
<tr class:selected={isItemSelected(item._id)}>
|
||||
{#if pedido.status === 'em_rascunho' || pedido.status === 'precisa_ajustes'}
|
||||
<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'}
|
||||
@@ -864,16 +1014,14 @@
|
||||
<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 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">
|
||||
@@ -971,9 +1119,9 @@
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="block text-xs font-bold text-gray-500 uppercase"
|
||||
>Valor Estimado (Unitário)</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>
|
||||
@@ -1026,4 +1174,112 @@
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showSplitResultModal && novoPedidoIdParaNavegar}
|
||||
<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-md rounded-lg bg-white p-6 shadow-xl">
|
||||
<button
|
||||
onclick={() => {
|
||||
showSplitResultModal = false;
|
||||
}}
|
||||
class="absolute top-4 right-4 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
<h2 class="mb-3 text-lg font-semibold text-gray-900">Novo pedido criado</h2>
|
||||
<p class="mb-4 text-sm text-gray-700">
|
||||
{quantidadeItensMovidos === 1
|
||||
? '1 item foi movido para um novo pedido em rascunho.'
|
||||
: `${quantidadeItensMovidos} itens foram movidos para um novo pedido em rascunho.`}
|
||||
</p>
|
||||
<p class="mb-6 text-xs text-gray-500">
|
||||
Os itens não foram copiados, e sim movidos deste pedido para o novo.
|
||||
</p>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded bg-gray-200 px-3 py-1.5 text-xs font-medium text-gray-800 hover:bg-gray-300"
|
||||
onclick={() => {
|
||||
showSplitResultModal = false;
|
||||
}}
|
||||
>
|
||||
Continuar neste pedido
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded bg-blue-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-blue-700"
|
||||
onclick={async () => {
|
||||
const id = novoPedidoIdParaNavegar;
|
||||
showSplitResultModal = false;
|
||||
if (id) {
|
||||
await goto(resolve(`/pedidos/${id}`));
|
||||
}
|
||||
}}
|
||||
>
|
||||
Ir para o novo pedido
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showSplitConfirmationModal}
|
||||
<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-md rounded-lg bg-white p-6 shadow-xl">
|
||||
<button
|
||||
onclick={() => {
|
||||
showSplitConfirmationModal = false;
|
||||
}}
|
||||
class="absolute top-4 right-4 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
<h2 class="mb-3 text-lg font-semibold text-gray-900">Criar novo pedido</h2>
|
||||
<p class="mb-4 text-sm text-gray-700">
|
||||
{selectedCount === 1
|
||||
? 'Criar um novo pedido movendo o item selecionado para ele?'
|
||||
: `Criar um novo pedido movendo os ${selectedCount} itens selecionados para ele?`}
|
||||
</p>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="new-sei" class="mb-1 block text-sm font-medium text-gray-700"
|
||||
>Número SEI (Opcional)</label
|
||||
>
|
||||
<input
|
||||
id="new-sei"
|
||||
type="text"
|
||||
bind:value={newPedidoSei}
|
||||
class="w-full rounded-md border-gray-300 text-sm shadow-sm focus:border-blue-500 focus:ring-blue-500"
|
||||
placeholder="Ex: 12345.000000/2023-00"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500">
|
||||
Se deixado em branco, o novo pedido será criado sem número SEI.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded bg-gray-200 px-3 py-1.5 text-xs font-medium text-gray-800 hover:bg-gray-300"
|
||||
onclick={() => {
|
||||
showSplitConfirmationModal = false;
|
||||
}}
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded bg-blue-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-blue-700"
|
||||
onclick={confirmSplitPedido}
|
||||
>
|
||||
Confirmar e Criar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user