feat: Add 'atas' (minutes/records) management feature, and implement various improvements across UI, backend logic, and authentication.

This commit is contained in:
2025-12-02 16:37:48 -03:00
parent 05e7f1181d
commit 4bd9e21748
265 changed files with 29156 additions and 26460 deletions

View File

@@ -1,60 +1,99 @@
<script lang="ts">
import { useQuery, useConvexClient } from 'convex-svelte';
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 { Plus, Trash2, X } from 'lucide-svelte';
import { goto } from '$app/navigation';
import type { Id, Doc } from '@sgse-app/backend/convex/_generated/dataModel';
import { resolve } from '$app/paths';
const client = useConvexClient();
const acoesQuery = useQuery(api.acoes.list, {});
const acoes = $derived(acoesQuery.data || []);
const loading = $derived(acoesQuery.isLoading);
let searchQuery = $state('');
const searchResultsQuery = useQuery(api.produtos.search, () => ({ query: searchQuery }));
const searchResultsQuery = useQuery(api.objetos.search, () => ({
query: searchQuery
}));
const searchResults = $derived(searchResultsQuery.data);
let formData = $state({
numeroSei: '',
acaoId: '' as Id<'acoes'> | ''
const formData = $state({
numeroSei: ''
});
let creating = $state(false);
let error = $state<string | null>(null);
let warning = $state<string | null>(null);
// Updated to store quantity
let selectedProdutos = $state<{ produto: Doc<'produtos'>; quantidade: number }[]>([]);
let selectedProdutoIds = $derived(selectedProdutos.map((p) => p.produto._id));
// Item selection state
type SelectedItem = {
objeto: Doc<'objetos'>;
quantidade: number;
modalidade: 'dispensa' | 'inexgibilidade' | 'adesao' | 'consumo';
acaoId?: Id<'acoes'>;
};
function addProduto(produto: Doc<'produtos'>) {
if (!selectedProdutos.find((p) => p.produto._id === produto._id)) {
// Default quantity 1
selectedProdutos = [...selectedProdutos, { produto, quantidade: 1 }];
checkExisting();
}
searchQuery = '';
let selectedItems = $state<SelectedItem[]>([]);
const selectedObjetoIds = $derived(selectedItems.map((i) => i.objeto._id));
// Item configuration modal
let showItemModal = $state(false);
let itemConfig = $state<{
objeto: Doc<'objetos'> | null;
quantidade: number;
modalidade: 'dispensa' | 'inexgibilidade' | 'adesao' | 'consumo';
acaoId: string; // using string to handle empty select
}>({
objeto: null,
quantidade: 1,
modalidade: 'consumo',
acaoId: ''
});
function openItemModal(objeto: Doc<'objetos'>) {
itemConfig = {
objeto,
quantidade: 1,
modalidade: 'consumo',
acaoId: ''
};
showItemModal = true;
searchQuery = ''; // Clear search
}
function removeProduto(produtoId: Id<'produtos'>) {
selectedProdutos = selectedProdutos.filter((p) => p.produto._id !== produtoId);
function closeItemModal() {
showItemModal = false;
itemConfig.objeto = null;
}
function confirmAddItem() {
if (!itemConfig.objeto) return;
selectedItems = [
...selectedItems,
{
objeto: itemConfig.objeto,
quantidade: itemConfig.quantidade,
modalidade: itemConfig.modalidade,
acaoId: itemConfig.acaoId ? (itemConfig.acaoId as Id<'acoes'>) : undefined
}
];
checkExisting();
closeItemModal();
}
function removeItem(index: number) {
selectedItems = selectedItems.filter((_, i) => i !== index);
checkExisting();
}
// Updated type for existingPedidos to include matchingItems
// Existing orders check
let existingPedidos = $state<
{
_id: Id<'pedidos'>;
numeroSei?: string;
status:
| 'em_rascunho'
| 'aguardando_aceite'
| 'em_analise'
| 'precisa_ajustes'
| 'cancelado'
| 'concluido';
acaoId?: Id<'acoes'>;
status: string;
criadoEm: number;
matchingItems?: { produtoId: Id<'produtos'>; quantidade: number }[];
matchingItems?: { objetoId: Id<'objetos'>; quantidade: number }[];
}[]
>([]);
let checking = $state(false);
@@ -84,22 +123,20 @@
return acao ? acao.nome : '-';
}
// Helper to get matching product info for display
function getMatchingInfo(pedido: (typeof existingPedidos)[0]) {
if (!pedido.matchingItems || pedido.matchingItems.length === 0) return null;
// Find which of the selected products match this order
const matches = pedido.matchingItems.filter((item) =>
selectedProdutoIds.includes(item.produtoId)
selectedObjetoIds.includes(item.objetoId)
);
if (matches.length === 0) return null;
// Create a summary string
const details = matches
.map((match) => {
const prod = selectedProdutos.find((p) => p.produto._id === match.produtoId);
return `${prod?.produto.nome}: ${match.quantidade} un.`;
// 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);
return `${item?.objeto.nome}: ${match.quantidade} un.`;
})
.join(', ');
@@ -110,21 +147,22 @@
warning = null;
existingPedidos = [];
const hasFilters = formData.acaoId || formData.numeroSei || selectedProdutoIds.length > 0;
const hasFilters = formData.numeroSei || selectedObjetoIds.length > 0;
if (!hasFilters) return;
checking = true;
try {
// Note: checkExisting query might need update to handle item-level acaoId if we want to filter by it.
// Currently we only filter by numeroSei and objetoIds.
const result = await client.query(api.pedidos.checkExisting, {
acaoId: formData.acaoId ? (formData.acaoId as Id<'acoes'>) : undefined,
numeroSei: formData.numeroSei || undefined,
produtoIds: selectedProdutoIds.length ? (selectedProdutoIds as Id<'produtos'>[]) : undefined
objetoIds: selectedObjetoIds.length ? (selectedObjetoIds as Id<'objetos'>[]) : undefined
});
existingPedidos = result;
if (result.length > 0) {
warning = `Atenção: encontramos ${result.length} pedido(s) em andamento que batem com os filtros informados. Você pode abrir um deles para adicionar itens.`;
warning = `Atenção: encontramos ${result.length} pedido(s) em andamento que batem com os filtros informados.`;
} else {
warning = 'Nenhum pedido em andamento encontrado com esses filtros.';
}
@@ -141,18 +179,19 @@
error = null;
try {
const pedidoId = await client.mutation(api.pedidos.create, {
numeroSei: formData.numeroSei || undefined,
acaoId: formData.acaoId ? (formData.acaoId as Id<'acoes'>) : undefined
numeroSei: formData.numeroSei || undefined
});
if (selectedProdutos.length > 0) {
if (selectedItems.length > 0) {
await Promise.all(
selectedProdutos.map((item) =>
selectedItems.map((item) =>
client.mutation(api.pedidos.addItem, {
pedidoId,
produtoId: item.produto._id,
valorEstimado: item.produto.valorEstimado,
quantidade: item.quantidade // Pass quantity
objetoId: item.objeto._id,
valorEstimado: item.objeto.valorEstimado,
quantidade: item.quantidade,
modalidade: item.modalidade,
acaoId: item.acaoId
})
)
);
@@ -167,7 +206,7 @@
}
</script>
<div class="container mx-auto max-w-2xl p-6">
<div class="container mx-auto max-w-3xl p-6">
<h1 class="mb-6 text-2xl font-bold">Novo Pedido</h1>
<div class="rounded-lg bg-white p-6 shadow-md">
@@ -178,7 +217,7 @@
{/if}
<form onsubmit={handleSubmit}>
<div class="mb-4">
<div class="mb-6">
<label class="mb-2 block text-sm font-bold text-gray-700" for="numeroSei">
Número SEI (Opcional)
</label>
@@ -190,102 +229,77 @@
placeholder="Ex: 12345.000000/2023-00"
onblur={checkExisting}
/>
<p class="mt-1 text-xs text-gray-500">
Você pode adicionar o número SEI posteriormente, se necessário.
</p>
</div>
<div class="mb-4">
<label class="mb-2 block text-sm font-bold text-gray-700" for="acao">
Ação (Opcional)
</label>
{#if loading}
<p class="text-sm text-gray-500">Carregando ações...</p>
{:else}
<select
class="focus:shadow-outline w-full rounded border px-3 py-2 leading-tight text-gray-700 shadow focus:outline-none"
id="acao"
bind:value={formData.acaoId}
onchange={checkExisting}
>
<option value="">Selecione uma ação...</option>
{#each acoes as acao (acao._id)}
<option value={acao._id}>{acao.nome} ({acao.tipo})</option>
{/each}
</select>
{/if}
<p class="mt-1 text-xs text-gray-500">Você pode adicionar o número SEI posteriormente.</p>
</div>
<div class="mb-6">
<label class="mb-2 block text-sm font-bold text-gray-700" for="produtos">
Produtos (Opcional)
Adicionar Objetos
</label>
<div class="mb-2">
<div class="relative mb-2">
<input
type="text"
placeholder="Buscar produtos..."
placeholder="Buscar objetos..."
class="focus:shadow-outline w-full appearance-none rounded border px-3 py-2 leading-tight text-gray-700 shadow focus:outline-none"
bind:value={searchQuery}
/>
{#if searchQuery.length > 0 && searchResults}
<div class="absolute z-10 mt-1 w-full rounded border bg-white shadow-lg">
{#if searchResults.length === 0}
<div class="p-2 text-sm text-gray-500">Nenhum objeto encontrado.</div>
{:else}
<ul class="max-h-60 overflow-y-auto">
{#each searchResults as objeto (objeto._id)}
<li>
<button
type="button"
class="flex w-full items-center justify-between px-4 py-2 text-left hover:bg-gray-100"
onclick={() => openItemModal(objeto)}
>
<span>{objeto.nome}</span>
<Plus size={16} class="text-blue-600" />
</button>
</li>
{/each}
</ul>
{/if}
</div>
{/if}
</div>
{#if searchQuery.length > 0}
<div class="mb-4 rounded border bg-gray-50 p-2">
{#if searchResults === undefined}
<p class="text-sm text-gray-500">Carregando...</p>
{:else if searchResults.length === 0}
<p class="text-sm text-gray-500">Nenhum produto encontrado.</p>
{:else}
<ul class="space-y-1">
{#each searchResults as produto (produto._id)}
<li>
<button
type="button"
class="flex w-full items-center justify-between rounded px-2 py-1 text-left hover:bg-gray-200"
onclick={() => addProduto(produto)}
>
<span>{produto.nome}</span>
<span class="text-xs text-gray-500">Adicionar</span>
</button>
</li>
{/each}
</ul>
{/if}
</div>
{/if}
{#if selectedProdutos.length > 0}
<div class="mt-2">
<p class="mb-2 text-sm font-semibold text-gray-700">Produtos Selecionados:</p>
<ul class="space-y-2">
{#each selectedProdutos as item (item.produto._id)}
<li
class="flex items-center justify-between rounded bg-blue-50 px-3 py-2 text-sm text-blue-900"
>
<span class="flex-1">{item.produto.nome}</span>
<div class="flex items-center space-x-2">
<label class="flex items-center space-x-1 text-xs text-gray-600">
<span>Qtd:</span>
<input
type="number"
min="1"
class="w-16 rounded border px-2 py-1 text-sm"
bind:value={item.quantidade}
/>
</label>
{#if selectedItems.length > 0}
<div class="mt-4">
<h3 class="mb-2 text-sm font-semibold text-gray-700">Itens Selecionados:</h3>
<div class="space-y-3">
{#each selectedItems as item, index (index)}
<div class="rounded-md border bg-gray-50 p-3">
<div class="flex items-start justify-between">
<div>
<div class="font-medium">{item.objeto.nome}</div>
<div class="text-xs text-gray-500">
Qtd: {item.quantidade} | Unid: {item.objeto.unidade}
</div>
<div class="mt-1 text-xs">
<span class="font-semibold text-gray-600">Modalidade:</span>
{item.modalidade}
{#if item.acaoId}
<span class="ml-2 font-semibold text-gray-600">Ação:</span>
{getAcaoNome(item.acaoId)}
{/if}
</div>
</div>
<button
type="button"
class="text-red-600 hover:text-red-800"
onclick={() => removeProduto(item.produto._id)}
onclick={() => removeItem(index)}
>
Remover
<Trash2 size={18} />
</button>
</div>
</li>
</div>
{/each}
</ul>
</div>
</div>
{/if}
</div>
@@ -304,9 +318,7 @@
{#if existingPedidos.length > 0}
<div class="mb-6 rounded border border-yellow-300 bg-yellow-50 p-4">
<p class="mb-2 text-sm text-yellow-800">
Os pedidos abaixo estão em rascunho/análise. Você pode abri-los para adicionar itens.
</p>
<p class="mb-2 text-sm text-yellow-800">Pedidos similares encontrados:</p>
<ul class="space-y-2">
{#each existingPedidos as pedido (pedido._id)}
<li class="flex flex-col rounded bg-white px-3 py-2 shadow-sm">
@@ -315,18 +327,14 @@
<div class="text-sm font-medium">
Pedido {pedido.numeroSei || 'sem número SEI'}{formatStatus(pedido.status)}
</div>
<div class="text-xs text-gray-500">
Ação: {getAcaoNome(pedido.acaoId)}
</div>
</div>
<a
href={resolve(`/pedidos/${pedido._id}`)}
class="text-sm font-medium text-blue-600 hover:text-blue-800"
>
Abrir pedido
Abrir
</a>
</div>
{#if getMatchingInfo(pedido)}
<div class="mt-1 text-xs font-semibold text-blue-700">
{getMatchingInfo(pedido)}
@@ -338,7 +346,7 @@
</div>
{/if}
<div class="flex items-center justify-end">
<div class="flex items-center justify-end border-t pt-4">
<a
href={resolve('/pedidos')}
class="mr-2 rounded bg-gray-300 px-4 py-2 font-bold text-gray-800 hover:bg-gray-400"
@@ -347,7 +355,7 @@
</a>
<button
type="submit"
disabled={creating || loading}
disabled={creating || selectedItems.length === 0}
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"
>
{creating ? 'Criando...' : 'Criar Pedido'}
@@ -355,4 +363,78 @@
</div>
</form>
</div>
{#if showItemModal && itemConfig.objeto}
<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={closeItemModal}
class="absolute top-4 right-4 text-gray-400 hover:text-gray-600"
>
<X size={24} />
</button>
<h3 class="mb-4 text-lg font-bold">Configurar Item</h3>
<div class="mb-4">
<p class="font-medium">{itemConfig.objeto.nome}</p>
<p class="text-sm text-gray-500">Unidade: {itemConfig.objeto.unidade}</p>
</div>
<div class="mb-4">
<label class="mb-1 block text-sm font-bold text-gray-700" for="quantidade">
Quantidade
</label>
<input
id="quantidade"
type="number"
min="1"
class="w-full rounded border px-3 py-2"
bind:value={itemConfig.quantidade}
/>
</div>
<div class="mb-4">
<label class="mb-1 block text-sm font-bold text-gray-700" for="modalidade">
Modalidade
</label>
<select
id="modalidade"
class="w-full rounded border px-3 py-2"
bind:value={itemConfig.modalidade}
>
<option value="consumo">Consumo</option>
<option value="dispensa">Dispensa</option>
<option value="inexgibilidade">Inexigibilidade</option>
<option value="adesao">Adesão</option>
</select>
</div>
<div class="mb-6">
<label class="mb-1 block text-sm font-bold text-gray-700" for="itemAcao">
Ação (Opcional)
</label>
<select
id="itemAcao"
class="w-full rounded border px-3 py-2"
bind:value={itemConfig.acaoId}
>
<option value="">Selecione uma ação...</option>
{#each acoes as acao (acao._id)}
<option value={acao._id}>{acao.nome}</option>
{/each}
</select>
</div>
<div class="flex justify-end">
<button
onclick={confirmAddItem}
class="rounded bg-blue-600 px-4 py-2 font-bold text-white hover:bg-blue-700"
>
Adicionar
</button>
</div>
</div>
</div>
{/if}
</div>