feat: Implement batch item removal and pedido splitting for pedidos, and add document management for atas.

This commit is contained in:
2025-12-03 23:37:26 -03:00
parent fb78866a0e
commit 7746dce25a
6 changed files with 796 additions and 100 deletions

View File

@@ -37,6 +37,14 @@
objetos.filter((obj) => obj.nome.toLowerCase().includes(searchObjeto.toLowerCase())) objetos.filter((obj) => obj.nome.toLowerCase().includes(searchObjeto.toLowerCase()))
); );
// Document state
let mainPdfFile: File | null = $state(null);
let attachmentFiles: File[] = $state([]);
let attachments = $state<Array<{ _id: Id<'atasDocumentos'>; nome: string; url: string | null }>>(
[]
);
let uploading = $state(false);
async function openModal(ata?: Doc<'atas'>) { async function openModal(ata?: Doc<'atas'>) {
if (ata) { if (ata) {
editingId = ata._id; editingId = ata._id;
@@ -50,6 +58,9 @@
// Fetch linked objects // Fetch linked objects
const linkedObjetos = await client.query(api.atas.getObjetos, { id: ata._id }); const linkedObjetos = await client.query(api.atas.getObjetos, { id: ata._id });
selectedObjetos = linkedObjetos.map((o) => o._id); selectedObjetos = linkedObjetos.map((o) => o._id);
// Fetch attachments
attachments = await client.query(api.atas.getDocumentos, { ataId: ata._id });
} else { } else {
editingId = null; editingId = null;
formData = { formData = {
@@ -60,7 +71,10 @@
dataFim: '' dataFim: ''
}; };
selectedObjetos = []; selectedObjetos = [];
attachments = [];
} }
mainPdfFile = null;
attachmentFiles = [];
searchObjeto = ''; searchObjeto = '';
showModal = true; showModal = true;
} }
@@ -78,6 +92,17 @@
} }
} }
async function uploadFile(file: File) {
const uploadUrl = await client.mutation(api.atas.generateUploadUrl, {});
const result = await fetch(uploadUrl, {
method: 'POST',
headers: { 'Content-Type': file.type },
body: file
});
const { storageId } = await result.json();
return storageId;
}
async function handleSubmit(e: Event) { async function handleSubmit(e: Event) {
e.preventDefault(); e.preventDefault();
if (!formData.empresaId) { if (!formData.empresaId) {
@@ -86,28 +111,53 @@
} }
saving = true; saving = true;
try { try {
let pdfStorageId = undefined;
if (mainPdfFile) {
pdfStorageId = await uploadFile(mainPdfFile);
}
const payload = { const payload = {
numero: formData.numero, numero: formData.numero,
numeroSei: formData.numeroSei, numeroSei: formData.numeroSei,
empresaId: formData.empresaId as Id<'empresas'>, empresaId: formData.empresaId as Id<'empresas'>,
dataInicio: formData.dataInicio || undefined, dataInicio: formData.dataInicio || undefined,
dataFim: formData.dataFim || undefined, dataFim: formData.dataFim || undefined,
objetosIds: selectedObjetos objetosIds: selectedObjetos,
pdf: pdfStorageId
}; };
let ataId: Id<'atas'>;
if (editingId) { if (editingId) {
await client.mutation(api.atas.update, { await client.mutation(api.atas.update, {
id: editingId as Id<'atas'>, id: editingId as Id<'atas'>,
...payload ...payload
}); });
ataId = editingId as Id<'atas'>;
} else { } else {
await client.mutation(api.atas.create, payload); ataId = await client.mutation(api.atas.create, payload);
} }
// Upload attachments
if (attachmentFiles.length > 0) {
uploading = true;
for (const file of attachmentFiles) {
const storageId = await uploadFile(file);
await client.mutation(api.atas.saveDocumento, {
ataId,
nome: file.name,
storageId,
tipo: file.type,
tamanho: file.size
});
}
}
closeModal(); closeModal();
} catch (e) { } catch (e) {
alert('Erro ao salvar: ' + (e as Error).message); alert('Erro ao salvar: ' + (e as Error).message);
} finally { } finally {
saving = false; saving = false;
uploading = false;
} }
} }
@@ -120,9 +170,38 @@
} }
} }
async function handleDeleteAttachment(docId: Id<'atasDocumentos'>) {
if (!confirm('Tem certeza que deseja excluir este anexo?')) return;
try {
await client.mutation(api.atas.removeDocumento, { id: docId });
// Refresh attachments list
if (editingId) {
attachments = await client.query(api.atas.getDocumentos, {
ataId: editingId as Id<'atas'>
});
}
} catch (e) {
alert('Erro ao excluir anexo: ' + (e as Error).message);
}
}
function getEmpresaNome(id: Id<'empresas'>) { function getEmpresaNome(id: Id<'empresas'>) {
return empresas.find((e) => e._id === id)?.razao_social || 'Empresa não encontrada'; return empresas.find((e) => e._id === id)?.razao_social || 'Empresa não encontrada';
} }
function handleFileSelect(e: Event) {
const input = e.target as HTMLInputElement;
if (input.files && input.files.length > 0) {
mainPdfFile = input.files[0];
}
}
function handleAttachmentsSelect(e: Event) {
const input = e.target as HTMLInputElement;
if (input.files && input.files.length > 0) {
attachmentFiles = Array.from(input.files);
}
}
</script> </script>
<div class="container mx-auto p-6"> <div class="container mx-auto p-6">
@@ -173,7 +252,12 @@
<tr> <tr>
<td class="px-6 py-4 font-medium whitespace-nowrap">{ata.numero}</td> <td class="px-6 py-4 font-medium whitespace-nowrap">{ata.numero}</td>
<td class="px-6 py-4 whitespace-nowrap">{ata.numeroSei}</td> <td class="px-6 py-4 whitespace-nowrap">{ata.numeroSei}</td>
<td class="px-6 py-4 whitespace-nowrap">{getEmpresaNome(ata.empresaId)}</td> <td
class="max-w-md truncate px-6 py-4 whitespace-nowrap"
title={getEmpresaNome(ata.empresaId)}
>
{getEmpresaNome(ata.empresaId)}
</td>
<td class="px-6 py-4 text-sm whitespace-nowrap text-gray-500"> <td class="px-6 py-4 text-sm whitespace-nowrap text-gray-500">
{ata.dataInicio || '-'} a {ata.dataFim || '-'} {ata.dataInicio || '-'} a {ata.dataFim || '-'}
</td> </td>
@@ -209,7 +293,7 @@
<div <div
class="fixed inset-0 z-50 flex h-full w-full items-center justify-center overflow-y-auto bg-black/40" 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-2xl rounded-lg bg-white p-8 shadow-xl"> <div class="relative my-8 w-full max-w-2xl rounded-lg bg-white p-8 shadow-xl">
<button <button
onclick={closeModal} onclick={closeModal}
class="absolute top-4 right-4 text-gray-400 hover:text-gray-600" class="absolute top-4 right-4 text-gray-400 hover:text-gray-600"
@@ -288,6 +372,19 @@
/> />
</div> </div>
</div> </div>
<div class="mb-4">
<label class="mb-2 block text-sm font-bold text-gray-700" for="pdf">
PDF da Ata {editingId ? '(Deixe em branco para manter o atual)' : ''}
</label>
<input
class="focus:shadow-outline w-full appearance-none rounded border px-3 py-2 leading-tight text-gray-700 shadow focus:outline-none"
id="pdf"
type="file"
accept=".pdf"
onchange={handleFileSelect}
/>
</div>
</div> </div>
<div class="flex flex-col"> <div class="flex flex-col">
@@ -308,8 +405,8 @@
</div> </div>
<div <div
class="flex-1 overflow-y-auto rounded border bg-gray-50 p-2" class="mb-4 flex-1 overflow-y-auto rounded border bg-gray-50 p-2"
style="max-height: 300px;" style="max-height: 200px;"
> >
{#if filteredObjetos.length === 0} {#if filteredObjetos.length === 0}
<p class="py-4 text-center text-sm text-gray-500">Nenhum objeto encontrado.</p> <p class="py-4 text-center text-sm text-gray-500">Nenhum objeto encontrado.</p>
@@ -334,9 +431,45 @@
</div> </div>
{/if} {/if}
</div> </div>
<p class="mt-1 text-xs text-gray-500">
Selecione os objetos que fazem parte desta Ata. <div class="border-t pt-4">
</p> <label class="mb-2 block text-sm font-bold text-gray-700" for="anexos">
Anexos
</label>
<input
class="focus:shadow-outline mb-2 w-full appearance-none rounded border px-3 py-2 leading-tight text-gray-700 shadow focus:outline-none"
id="anexos"
type="file"
multiple
onchange={handleAttachmentsSelect}
/>
{#if attachments.length > 0}
<div class="mt-2 max-h-40 space-y-2 overflow-y-auto">
{#each attachments as doc (doc._id)}
<div
class="flex items-center justify-between rounded bg-gray-100 p-2 text-sm"
>
<a
href={doc.url}
target="_blank"
rel="noopener noreferrer"
class="max-w-[150px] truncate text-blue-600 hover:underline"
>
{doc.nome}
</a>
<button
type="button"
onclick={() => handleDeleteAttachment(doc._id)}
class="text-red-500 hover:text-red-700"
>
<X size={16} />
</button>
</div>
{/each}
</div>
{/if}
</div>
</div> </div>
</div> </div>
@@ -350,10 +483,10 @@
</button> </button>
<button <button
type="submit" type="submit"
disabled={saving} disabled={saving || uploading}
class="focus:shadow-outline rounded bg-blue-600 px-4 py-2 font-bold text-white hover:bg-blue-700 focus:outline-none disabled:opacity-50" class="focus:shadow-outline rounded bg-blue-600 px-4 py-2 font-bold text-white hover:bg-blue-700 focus:outline-none disabled:opacity-50"
> >
{saving ? 'Salvando...' : 'Salvar'} {saving || uploading ? 'Salvando...' : 'Salvar'}
</button> </button>
</div> </div>
</form> </form>

View File

@@ -2,6 +2,7 @@
import { api } from '@sgse-app/backend/convex/_generated/api'; import { api } from '@sgse-app/backend/convex/_generated/api';
import type { Doc, 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 { useConvexClient, useQuery } from 'convex-svelte';
import { SvelteSet } from 'svelte/reactivity';
import { import {
AlertTriangle, AlertTriangle,
CheckCircle, CheckCircle,
@@ -15,18 +16,20 @@
X, X,
XCircle XCircle
} from 'lucide-svelte'; } 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'; 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(); const client = useConvexClient();
// Reactive queries // Reactive queries
const pedidoQuery = useQuery(api.pedidos.get, { id: pedidoId }); const pedidoQuery = $derived.by(() => useQuery(api.pedidos.get, { id: pedidoId }));
const itemsQuery = useQuery(api.pedidos.getItems, { pedidoId }); const itemsQuery = $derived.by(() => useQuery(api.pedidos.getItems, { pedidoId }));
const historyQuery = useQuery(api.pedidos.getHistory, { pedidoId }); const historyQuery = $derived.by(() => useQuery(api.pedidos.getHistory, { pedidoId }));
const objetosQuery = useQuery(api.objetos.list, {}); const objetosQuery = $derived.by(() => useQuery(api.objetos.list, {}));
const acoesQuery = useQuery(api.acoes.list, {}); const acoesQuery = $derived.by(() => useQuery(api.acoes.list, {}));
// Derived state // Derived state
let pedido = $derived(pedidoQuery.data); let pedido = $derived(pedidoQuery.data);
@@ -35,11 +38,7 @@
let objetos = $derived(objetosQuery.data || []); let objetos = $derived(objetosQuery.data || []);
let acoes = $derived(acoesQuery.data || []); let acoes = $derived(acoesQuery.data || []);
type Modalidade = type Modalidade = 'dispensa' | 'inexgibilidade' | 'adesao' | 'consumo';
| 'dispensa'
| 'inexgibilidade'
| 'adesao'
| 'consumo';
type EditingItem = { type EditingItem = {
valorEstimado: string; valorEstimado: string;
@@ -62,6 +61,28 @@
let editingItems = $state<Record<string, EditingItem>>({}); 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 // Garante que, para todos os itens existentes, as atas do respectivo objeto
// sejam carregadas independentemente do formulário de criação. // sejam carregadas independentemente do formulário de criação.
$effect(() => { $effect(() => {
@@ -145,7 +166,7 @@
if (hasAppliedPrefill) return; if (hasAppliedPrefill) return;
if (objetosQuery.isLoading || acoesQuery.isLoading) return; if (objetosQuery.isLoading || acoesQuery.isLoading) return;
const url = $page.url; const url = page.url;
const obj = url.searchParams.get('obj'); const obj = url.searchParams.get('obj');
const qtdStr = url.searchParams.get('qtd'); const qtdStr = url.searchParams.get('qtd');
const mod = url.searchParams.get('mod') as Modalidade | null; const mod = url.searchParams.get('mod') as Modalidade | null;
@@ -496,6 +517,61 @@
return `${entry.usuarioNome} - ${entry.acao}`; 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> </script>
<div class="container mx-auto p-6"> <div class="container mx-auto p-6">
@@ -729,6 +805,47 @@
{/if} {/if}
<div class="flex flex-col"> <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)} {#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"> <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}
@@ -736,9 +853,31 @@
<table class="min-w-full divide-y divide-gray-200"> <table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50"> <thead class="bg-gray-50">
<tr> <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 <th
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase" class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
>Objeto</th >
Objeto</th
> >
<th <th
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase" class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
@@ -772,7 +911,18 @@
</thead> </thead>
<tbody class="divide-y divide-gray-200 bg-white"> <tbody class="divide-y divide-gray-200 bg-white">
{#each group.items as item (item._id)} {#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">{getObjetoName(item.objetoId)}</td>
<td class="px-6 py-4 whitespace-nowrap"> <td class="px-6 py-4 whitespace-nowrap">
{#if pedido.status === 'em_rascunho' || pedido.status === 'precisa_ajustes'} {#if pedido.status === 'em_rascunho' || pedido.status === 'precisa_ajustes'}
@@ -864,16 +1014,14 @@
<option value={ata._id}>Ata {ata.numero} (SEI: {ata.numeroSei})</option> <option value={ata._id}>Ata {ata.numero} (SEI: {ata.numeroSei})</option>
{/each} {/each}
</select> </select>
{:else if item.ataId}
{#each getAtasForObjeto(item.objetoId) as ata (ata._id)}
{#if ata._id === item.ataId}
Ata {ata.numero}
{/if}
{/each}
{:else} {: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} {/if}
</td> </td>
<td class="px-6 py-4 text-right font-medium whitespace-nowrap"> <td class="px-6 py-4 text-right font-medium whitespace-nowrap">
@@ -971,9 +1119,9 @@
</div> </div>
<div> <div>
<div class="block text-xs font-bold text-gray-500 uppercase" <div class="block text-xs font-bold text-gray-500 uppercase">
>Valor Estimado (Unitário)</div Valor Estimado (Unitário)
> </div>
<p class="text-gray-900"> <p class="text-gray-900">
{maskCurrencyBRL(selectedObjeto.valorEstimado || '') || 'R$ 0,00'} {maskCurrencyBRL(selectedObjeto.valorEstimado || '') || 'R$ 0,00'}
</p> </p>
@@ -1026,4 +1174,112 @@
</div> </div>
</div> </div>
{/if} {/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> </div>

View File

@@ -37,6 +37,9 @@
let selectedItems = $state<SelectedItem[]>([]); let selectedItems = $state<SelectedItem[]>([]);
let selectedObjetoIds = $derived(selectedItems.map((i) => i.objeto._id)); let selectedObjetoIds = $derived(selectedItems.map((i) => i.objeto._id));
let hasMixedModalidades = $derived(
new Set(selectedItems.map((i) => i.modalidade)).size > 1
);
// Item configuration modal // Item configuration modal
let showItemModal = $state(false); let showItemModal = $state(false);
@@ -153,6 +156,36 @@
} }
} }
function formatModalidade(modalidade: SelectedItem['modalidade']) {
switch (modalidade) {
case 'consumo':
return 'Consumo';
case 'dispensa':
return 'Dispensa';
case 'inexgibilidade':
return 'Inexigibilidade';
case 'adesao':
return 'Adesão';
default:
return modalidade;
}
}
function getModalidadeBadgeClasses(modalidade: SelectedItem['modalidade']) {
switch (modalidade) {
case 'consumo':
return 'bg-blue-100 text-blue-800';
case 'dispensa':
return 'bg-yellow-100 text-yellow-800';
case 'inexgibilidade':
return 'bg-purple-100 text-purple-800';
case 'adesao':
return 'bg-green-100 text-green-800';
default:
return 'bg-gray-100 text-gray-800';
}
}
function getAcaoNome(acaoId: Id<'acoes'> | undefined) { function getAcaoNome(acaoId: Id<'acoes'> | undefined) {
if (!acaoId) return '-'; if (!acaoId) return '-';
const acao = acoes.find((a) => a._id === acaoId); const acao = acoes.find((a) => a._id === acaoId);
@@ -172,7 +205,8 @@
.map((match) => { .map((match) => {
// Find name from selected items (might be multiple with same object, just pick one name) // Find name from selected items (might be multiple with same object, just pick one name)
const item = selectedItems.find((p) => p.objeto._id === match.objetoId); const item = selectedItems.find((p) => p.objeto._id === match.objetoId);
return `${item?.objeto.nome}: ${match.quantidade} un.`; const modalidadeLabel = formatModalidade(match.modalidade);
return `${item?.objeto.nome} (${modalidadeLabel}): ${match.quantidade} un.`;
}) })
.join(', '); .join(', ');
@@ -226,6 +260,8 @@
checking = true; checking = true;
try { try {
// Importante: ação (acaoId) NÃO entra no filtro de similaridade.
// O filtro considera apenas combinação de objeto + modalidade.
const itensFiltro = const itensFiltro =
selectedItems.length > 0 selectedItems.length > 0
? selectedItems.map((item) => ({ ? selectedItems.map((item) => ({
@@ -255,6 +291,11 @@
async function handleSubmit(e: Event) { async function handleSubmit(e: Event) {
e.preventDefault(); e.preventDefault();
if (hasMixedModalidades) {
error =
'Não é possível criar o pedido com itens de modalidades diferentes. Ajuste os itens antes de continuar.';
return;
}
creating = true; creating = true;
error = null; error = null;
try { try {
@@ -369,32 +410,44 @@
<div class="space-y-3"> <div class="space-y-3">
{#each selectedItems as item, index (index)} {#each selectedItems as item, index (index)}
<div <div
class="rounded-lg border border-gray-200 bg-gray-50 p-4 transition hover:shadow-md" class="rounded-xl border border-gray-200 bg-gray-50 p-4 transition hover:shadow-md"
> >
<div class="flex items-start justify-between"> <div class="flex items-start justify-between gap-4">
<div class="flex-1"> <div class="flex-1 space-y-2">
<div class="flex items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<p class="font-semibold text-gray-900">{item.objeto.nome}</p> <p class="font-semibold text-gray-900">{item.objeto.nome}</p>
<span
class={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold ${getModalidadeBadgeClasses(
item.modalidade
)}`}
>
{formatModalidade(item.modalidade)}
</span>
{#if item.ataNumero} {#if item.ataNumero}
<span <span
class="rounded-full bg-green-100 px-2.5 py-0.5 text-xs font-medium text-green-800" class="inline-flex items-center rounded-full bg-green-100 px-2.5 py-0.5 text-xs font-medium text-green-800"
> >
Ata {item.ataNumero} Ata {item.ataNumero}
</span> </span>
{/if} {/if}
</div>
<div class="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-sm text-gray-600">
<span><strong>Qtd:</strong> {item.quantidade} {item.objeto.unidade}</span>
<span><strong>Modalidade:</strong> {item.modalidade}</span>
{#if item.acaoId} {#if item.acaoId}
<span><strong>Ação:</strong> {getAcaoNome(item.acaoId)}</span> <span
class="inline-flex items-center rounded-full bg-indigo-100 px-2.5 py-0.5 text-xs font-medium text-indigo-800"
>
Ação: {getAcaoNome(item.acaoId)}
</span>
{/if} {/if}
</div> </div>
<div class="flex flex-wrap gap-x-4 gap-y-1 text-sm text-gray-600">
<span>
<strong>Qtd:</strong> {item.quantidade} {item.objeto.unidade}
</span>
</div>
</div> </div>
<div class="ml-4 flex items-center gap-2"> <div class="flex items-center gap-2">
<button <button
type="button" type="button"
class="rounded p-2 text-blue-600 transition hover:bg-blue-50" class="rounded-lg p-2 text-blue-600 transition hover:bg-blue-50"
onclick={() => openDetails(item)} onclick={() => openDetails(item)}
aria-label="Ver detalhes" aria-label="Ver detalhes"
> >
@@ -402,7 +455,7 @@
</button> </button>
<button <button
type="button" type="button"
class="rounded p-2 text-red-600 transition hover:bg-red-50" class="rounded-lg p-2 text-red-600 transition hover:bg-red-50"
onclick={() => removeItem(index)} onclick={() => removeItem(index)}
aria-label="Remover item" aria-label="Remover item"
> >
@@ -424,6 +477,18 @@
</div> </div>
<!-- Warnings Section --> <!-- Warnings Section -->
{#if hasMixedModalidades}
<div
class="mb-3 rounded-lg border border-red-400 bg-red-50 px-4 py-3 text-sm text-red-800"
>
<p class="font-semibold">Modalidades diferentes detectadas</p>
<p>
Não é possível criar o pedido com itens de modalidades diferentes. Ajuste os itens para
usar uma única modalidade.
</p>
</div>
{/if}
{#if warning} {#if warning}
<div <div
class="rounded-lg border border-yellow-400 bg-yellow-50 px-4 py-3 text-sm text-yellow-800" class="rounded-lg border border-yellow-400 bg-yellow-50 px-4 py-3 text-sm text-yellow-800"
@@ -443,11 +508,25 @@
<ul class="space-y-2"> <ul class="space-y-2">
{#each existingPedidos as pedido (pedido._id)} {#each existingPedidos as pedido (pedido._id)}
<li class="flex flex-col rounded-lg bg-white px-4 py-3 shadow-sm"> <li class="flex flex-col rounded-lg bg-white px-4 py-3 shadow-sm">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between gap-3">
<div> <div class="space-y-1">
<p class="text-sm font-medium text-gray-900"> <p class="text-sm font-medium text-gray-900">
Pedido {pedido.numeroSei || 'sem número SEI'}{formatStatus(pedido.status)} Pedido {pedido.numeroSei || 'sem número SEI'}{formatStatus(pedido.status)}
</p> </p>
{#if getFirstMatchingSelectedItem(pedido)}
{#key pedido._id}
{#if getFirstMatchingSelectedItem(pedido)}
<span
class={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold ${getModalidadeBadgeClasses(
getFirstMatchingSelectedItem(pedido).modalidade
)}`}
>
Modalidade:{' '}
{formatModalidade(getFirstMatchingSelectedItem(pedido).modalidade)}
</span>
{/if}
{/key}
{/if}
{#if getMatchingInfo(pedido)} {#if getMatchingInfo(pedido)}
<p class="mt-1 text-xs text-blue-700"> <p class="mt-1 text-xs text-blue-700">
{getMatchingInfo(pedido)} {getMatchingInfo(pedido)}
@@ -477,7 +556,7 @@
</a> </a>
<button <button
type="submit" type="submit"
disabled={creating || selectedItems.length === 0} disabled={creating || selectedItems.length === 0 || hasMixedModalidades}
class="rounded-lg bg-blue-600 px-6 py-2.5 font-semibold text-white shadow-md transition hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50" class="rounded-lg bg-blue-600 px-6 py-2.5 font-semibold text-white shadow-md transition hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
> >
{creating ? 'Criando...' : 'Criar Pedido'} {creating ? 'Criando...' : 'Criar Pedido'}

View File

@@ -47,9 +47,7 @@ export const listByObjetoIds = query({
links.push(...partial); links.push(...partial);
} }
const ataIds = Array.from( const ataIds = Array.from(new Set(links.map((l) => l.ataId as Id<'atas'>)));
new Set(links.map((l) => l.ataId as Id<'atas'>))
);
if (ataIds.length === 0) return []; if (ataIds.length === 0) return [];
@@ -64,9 +62,9 @@ export const create = mutation({
dataInicio: v.optional(v.string()), dataInicio: v.optional(v.string()),
dataFim: v.optional(v.string()), dataFim: v.optional(v.string()),
empresaId: v.id('empresas'), empresaId: v.id('empresas'),
pdf: v.optional(v.string()), pdf: v.optional(v.id('_storage')),
numeroSei: v.string(), numeroSei: v.string(),
objetosIds: v.optional(v.array(v.id('objetos'))) objetosIds: v.array(v.id('objetos'))
}, },
handler: async (ctx, args) => { handler: async (ctx, args) => {
const user = await getCurrentUserFunction(ctx); const user = await getCurrentUserFunction(ctx);
@@ -74,23 +72,22 @@ export const create = mutation({
const ataId = await ctx.db.insert('atas', { const ataId = await ctx.db.insert('atas', {
numero: args.numero, numero: args.numero,
numeroSei: args.numeroSei,
empresaId: args.empresaId,
dataInicio: args.dataInicio, dataInicio: args.dataInicio,
dataFim: args.dataFim, dataFim: args.dataFim,
empresaId: args.empresaId,
pdf: args.pdf, pdf: args.pdf,
numeroSei: args.numeroSei,
criadoPor: user._id, criadoPor: user._id,
criadoEm: Date.now(), criadoEm: Date.now(),
atualizadoEm: Date.now() atualizadoEm: Date.now()
}); });
if (args.objetosIds) { // Vincular objetos
for (const objetoId of args.objetosIds) { for (const objetoId of args.objetosIds) {
await ctx.db.insert('atasObjetos', { await ctx.db.insert('atasObjetos', {
ataId, ataId,
objetoId objetoId
}); });
}
} }
return ataId; return ataId;
@@ -101,12 +98,12 @@ export const update = mutation({
args: { args: {
id: v.id('atas'), id: v.id('atas'),
numero: v.string(), numero: v.string(),
numeroSei: v.string(),
empresaId: v.id('empresas'),
dataInicio: v.optional(v.string()), dataInicio: v.optional(v.string()),
dataFim: v.optional(v.string()), dataFim: v.optional(v.string()),
empresaId: v.id('empresas'), pdf: v.optional(v.id('_storage')),
pdf: v.optional(v.string()), objetosIds: v.array(v.id('objetos'))
numeroSei: v.string(),
objetosIds: v.optional(v.array(v.id('objetos')))
}, },
handler: async (ctx, args) => { handler: async (ctx, args) => {
const user = await getCurrentUserFunction(ctx); const user = await getCurrentUserFunction(ctx);
@@ -114,45 +111,42 @@ export const update = mutation({
await ctx.db.patch(args.id, { await ctx.db.patch(args.id, {
numero: args.numero, numero: args.numero,
numeroSei: args.numeroSei,
empresaId: args.empresaId,
dataInicio: args.dataInicio, dataInicio: args.dataInicio,
dataFim: args.dataFim, dataFim: args.dataFim,
empresaId: args.empresaId,
pdf: args.pdf, pdf: args.pdf,
numeroSei: args.numeroSei,
atualizadoEm: Date.now() atualizadoEm: Date.now()
}); });
if (args.objetosIds !== undefined) { // Atualizar objetos vinculados
// Remove existing links // Primeiro remove todos os vínculos existentes
const existingLinks = await ctx.db const existingLinks = await ctx.db
.query('atasObjetos') .query('atasObjetos')
.withIndex('by_ataId', (q) => q.eq('ataId', args.id)) .withIndex('by_ataId', (q) => q.eq('ataId', args.id))
.collect(); .collect();
for (const link of existingLinks) { for (const link of existingLinks) {
await ctx.db.delete(link._id); await ctx.db.delete(link._id);
} }
// Add new links // Adiciona os novos vínculos
for (const objetoId of args.objetosIds) { for (const objetoId of args.objetosIds) {
await ctx.db.insert('atasObjetos', { await ctx.db.insert('atasObjetos', {
ataId: args.id, ataId: args.id,
objetoId objetoId
}); });
}
} }
} }
}); });
export const remove = mutation({ export const remove = mutation({
args: { args: { id: v.id('atas') },
id: v.id('atas')
},
handler: async (ctx, args) => { handler: async (ctx, args) => {
const user = await getCurrentUserFunction(ctx); const user = await getCurrentUserFunction(ctx);
if (!user) throw new Error('Unauthorized'); if (!user) throw new Error('Unauthorized');
// Remove linked objects // Remover vínculos com objetos
const links = await ctx.db const links = await ctx.db
.query('atasObjetos') .query('atasObjetos')
.withIndex('by_ataId', (q) => q.eq('ataId', args.id)) .withIndex('by_ataId', (q) => q.eq('ataId', args.id))
@@ -162,6 +156,79 @@ export const remove = mutation({
await ctx.db.delete(link._id); await ctx.db.delete(link._id);
} }
// Remover documentos vinculados
const docs = await ctx.db
.query('atasDocumentos')
.withIndex('by_ataId', (q) => q.eq('ataId', args.id))
.collect();
for (const doc of docs) {
await ctx.storage.delete(doc.storageId);
await ctx.db.delete(doc._id);
}
await ctx.db.delete(args.id); await ctx.db.delete(args.id);
} }
}); });
export const generateUploadUrl = mutation({
args: {},
handler: async (ctx) => {
return await ctx.storage.generateUploadUrl();
}
});
export const saveDocumento = mutation({
args: {
ataId: v.id('atas'),
nome: v.string(),
storageId: v.id('_storage'),
tipo: v.string(),
tamanho: v.number()
},
handler: async (ctx, args) => {
const user = await getCurrentUserFunction(ctx);
if (!user) throw new Error('Unauthorized');
return await ctx.db.insert('atasDocumentos', {
ataId: args.ataId,
nome: args.nome,
storageId: args.storageId,
tipo: args.tipo,
tamanho: args.tamanho,
criadoPor: user._id,
criadoEm: Date.now()
});
}
});
export const removeDocumento = mutation({
args: { id: v.id('atasDocumentos') },
handler: async (ctx, args) => {
const user = await getCurrentUserFunction(ctx);
if (!user) throw new Error('Unauthorized');
const doc = await ctx.db.get(args.id);
if (!doc) throw new Error('Documento não encontrado');
await ctx.storage.delete(doc.storageId);
await ctx.db.delete(args.id);
}
});
export const getDocumentos = query({
args: { ataId: v.id('atas') },
handler: async (ctx, args) => {
const docs = await ctx.db
.query('atasDocumentos')
.withIndex('by_ataId', (q) => q.eq('ataId', args.ataId))
.collect();
return await Promise.all(
docs.map(async (doc) => ({
...doc,
url: await ctx.storage.getUrl(doc.storageId)
}))
);
}
});

View File

@@ -238,9 +238,7 @@ export const checkExisting = query({
.collect(); .collect();
const matching = items.filter((i) => const matching = items.filter((i) =>
itensFiltro.some( itensFiltro.some((f) => f.objetoId === i.objetoId && f.modalidade === i.modalidade)
(f) => f.objetoId === i.objetoId && f.modalidade === i.modalidade
)
); );
if (matching.length > 0) { if (matching.length > 0) {
@@ -513,6 +511,159 @@ export const removeItem = mutation({
} }
}); });
export const removeItemsBatch = mutation({
args: {
itemIds: v.array(v.id('objetoItems'))
},
returns: v.null(),
handler: async (ctx, args) => {
const user = await getUsuarioAutenticado(ctx);
if (!user.funcionarioId) {
throw new Error('Usuário não vinculado a um funcionário.');
}
if (args.itemIds.length === 0) {
return null;
}
const firstItem = await ctx.db.get(args.itemIds[0]);
if (!firstItem) {
throw new Error('Item não encontrado.');
}
const pedidoId = firstItem.pedidoId;
const pedido = await ctx.db.get(pedidoId);
if (!pedido) {
throw new Error('Pedido não encontrado.');
}
if (pedido.status !== 'em_rascunho' && pedido.status !== 'precisa_ajustes') {
throw new Error(
'Só é possível remover itens em pedidos em rascunho ou que precisam de ajustes.'
);
}
for (const itemId of args.itemIds) {
const item = await ctx.db.get(itemId);
if (!item) continue;
if (item.pedidoId !== pedidoId) {
throw new Error('Todos os itens devem pertencer ao mesmo pedido.');
}
if (item.adicionadoPor !== user.funcionarioId) {
throw new Error('Você só pode remover itens que você adicionou.');
}
await ctx.db.delete(itemId);
await ctx.db.insert('historicoPedidos', {
pedidoId,
usuarioId: user._id,
acao: 'remocao_item',
detalhes: JSON.stringify({
objetoId: item.objetoId,
valor: item.valorEstimado
}),
data: Date.now()
});
}
await ctx.db.patch(pedidoId, { atualizadoEm: Date.now() });
return null;
}
});
export const splitPedido = mutation({
args: {
pedidoId: v.id('pedidos'),
itemIds: v.array(v.id('objetoItems')),
numeroSei: v.optional(v.string())
},
returns: v.id('pedidos'),
handler: async (ctx, args) => {
const user = await getUsuarioAutenticado(ctx);
if (!user.funcionarioId) {
throw new Error('Usuário não vinculado a um funcionário.');
}
if (args.itemIds.length === 0) {
throw new Error('Selecione ao menos um item para dividir o pedido.');
}
const pedidoOriginal = await ctx.db.get(args.pedidoId);
if (!pedidoOriginal) {
throw new Error('Pedido não encontrado.');
}
if (pedidoOriginal.status !== 'em_rascunho' && pedidoOriginal.status !== 'precisa_ajustes') {
throw new Error('Só é possível dividir pedidos em rascunho ou que precisam de ajustes.');
}
const itens = [];
for (const itemId of args.itemIds) {
const item = await ctx.db.get(itemId);
if (!item) {
continue;
}
if (item.pedidoId !== args.pedidoId) {
throw new Error('Todos os itens devem pertencer ao mesmo pedido.');
}
if (item.adicionadoPor !== user.funcionarioId) {
throw new Error('Você só pode mover itens que você adicionou.');
}
itens.push(item);
}
if (itens.length === 0) {
throw new Error('Nenhum dos itens selecionados pôde ser usado para divisão.');
}
const novoPedidoId = await ctx.db.insert('pedidos', {
numeroSei: args.numeroSei,
status: 'em_rascunho',
criadoPor: user._id,
criadoEm: Date.now(),
atualizadoEm: Date.now()
});
for (const item of itens) {
await ctx.db.patch(item._id, {
pedidoId: novoPedidoId
});
}
await ctx.db.insert('historicoPedidos', {
pedidoId: args.pedidoId,
usuarioId: user._id,
acao: 'divisao_pedido_origem',
detalhes: JSON.stringify({
itensMovidos: itens.map((i) => i._id),
novoPedidoId
}),
data: Date.now()
});
await ctx.db.insert('historicoPedidos', {
pedidoId: novoPedidoId,
usuarioId: user._id,
acao: 'divisao_pedido_destino',
detalhes: JSON.stringify({
pedidoOriginalId: args.pedidoId,
itensRecebidos: itens.map((i) => i._id)
}),
data: Date.now()
});
await ctx.db.patch(args.pedidoId, { atualizadoEm: Date.now() });
return novoPedidoId;
}
});
export const updateItem = mutation({ export const updateItem = mutation({
args: { args: {
itemId: v.id('objetoItems'), itemId: v.id('objetoItems'),

View File

@@ -7,7 +7,7 @@ export const atasTables = {
dataInicio: v.optional(v.string()), dataInicio: v.optional(v.string()),
dataFim: v.optional(v.string()), dataFim: v.optional(v.string()),
empresaId: v.id('empresas'), empresaId: v.id('empresas'),
pdf: v.optional(v.string()), // storage ID pdf: v.optional(v.id('_storage')),
numeroSei: v.string(), numeroSei: v.string(),
criadoPor: v.id('usuarios'), criadoPor: v.id('usuarios'),
criadoEm: v.number(), criadoEm: v.number(),
@@ -22,5 +22,15 @@ export const atasTables = {
objetoId: v.id('objetos') objetoId: v.id('objetos')
}) })
.index('by_ataId', ['ataId']) .index('by_ataId', ['ataId'])
.index('by_objetoId', ['objetoId']) .index('by_objetoId', ['objetoId']),
atasDocumentos: defineTable({
ataId: v.id('atas'),
nome: v.string(),
storageId: v.id('_storage'),
tipo: v.string(), // MIME type
tamanho: v.number(), // bytes
criadoPor: v.id('usuarios'),
criadoEm: v.number()
}).index('by_ataId', ['ataId'])
}; };