586 lines
17 KiB
Svelte
586 lines
17 KiB
Svelte
<script lang="ts">
|
|
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, Info } from 'lucide-svelte';
|
|
import { goto } from '$app/navigation';
|
|
import { resolve } from '$app/paths';
|
|
|
|
const client = useConvexClient();
|
|
|
|
const acoesQuery = useQuery(api.acoes.list, {});
|
|
let acoes = $derived(acoesQuery.data || []);
|
|
|
|
let searchQuery = $state('');
|
|
const searchResultsQuery = useQuery(api.objetos.search, () => ({
|
|
query: searchQuery
|
|
}));
|
|
let searchResults = $derived(searchResultsQuery.data);
|
|
|
|
let formData = $state({
|
|
numeroSei: ''
|
|
});
|
|
let creating = $state(false);
|
|
let error = $state<string | null>(null);
|
|
let warning = $state<string | null>(null);
|
|
|
|
// Item selection state
|
|
// Nota: modalidade é opcional aqui pois será definida pelo Setor de Compras posteriormente
|
|
type SelectedItem = {
|
|
objeto: Doc<'objetos'>;
|
|
quantidade: number;
|
|
acaoId?: Id<'acoes'>;
|
|
};
|
|
|
|
let selectedItems = $state<SelectedItem[]>([]);
|
|
let selectedObjetoIds = $derived(selectedItems.map((i) => i.objeto._id));
|
|
|
|
// Item configuration modal
|
|
let showItemModal = $state(false);
|
|
let itemConfig = $state<{
|
|
objeto: Doc<'objetos'> | null;
|
|
quantidade: number;
|
|
acaoId: string; // using string to handle empty select
|
|
}>({
|
|
objeto: null,
|
|
quantidade: 1,
|
|
acaoId: ''
|
|
});
|
|
|
|
let showDetailsModal = $state(false);
|
|
let detailsItem = $state<SelectedItem | null>(null);
|
|
|
|
function openDetails(item: SelectedItem) {
|
|
detailsItem = item;
|
|
showDetailsModal = true;
|
|
}
|
|
|
|
function closeDetails() {
|
|
showDetailsModal = false;
|
|
detailsItem = null;
|
|
}
|
|
|
|
async function openItemModal(objeto: Doc<'objetos'>) {
|
|
itemConfig = {
|
|
objeto,
|
|
quantidade: 1,
|
|
acaoId: ''
|
|
};
|
|
showItemModal = true;
|
|
searchQuery = ''; // Clear search
|
|
}
|
|
|
|
function closeItemModal() {
|
|
showItemModal = false;
|
|
itemConfig.objeto = null;
|
|
}
|
|
|
|
function confirmAddItem() {
|
|
if (!itemConfig.objeto) return;
|
|
|
|
selectedItems = [
|
|
...selectedItems,
|
|
{
|
|
objeto: itemConfig.objeto,
|
|
quantidade: itemConfig.quantidade,
|
|
acaoId: itemConfig.acaoId ? (itemConfig.acaoId as Id<'acoes'>) : undefined
|
|
}
|
|
];
|
|
checkExisting();
|
|
closeItemModal();
|
|
}
|
|
|
|
function removeItem(index: number) {
|
|
selectedItems = selectedItems.filter((_, i) => i !== index);
|
|
checkExisting();
|
|
}
|
|
|
|
// Existing orders check
|
|
let existingPedidos = $state<
|
|
{
|
|
_id: Id<'pedidos'>;
|
|
numeroSei?: string;
|
|
status: string;
|
|
criadoEm: number;
|
|
matchingItems?: {
|
|
objetoId: Id<'objetos'>;
|
|
quantidade: number;
|
|
}[];
|
|
}[]
|
|
>([]);
|
|
let checking = $state(false);
|
|
|
|
function formatStatus(status: string) {
|
|
switch (status) {
|
|
case 'em_rascunho':
|
|
return 'Rascunho';
|
|
case 'aguardando_aceite':
|
|
return 'Aguardando Aceite';
|
|
case 'em_analise':
|
|
return 'Em Análise';
|
|
case 'precisa_ajustes':
|
|
return 'Precisa de Ajustes';
|
|
case 'concluido':
|
|
return 'Concluído';
|
|
case 'cancelado':
|
|
return 'Cancelado';
|
|
default:
|
|
return status;
|
|
}
|
|
}
|
|
|
|
function getAcaoNome(acaoId: Id<'acoes'> | undefined) {
|
|
if (!acaoId) return '-';
|
|
const acao = acoes.find((a) => a._id === acaoId);
|
|
return acao ? acao.nome : '-';
|
|
}
|
|
|
|
function getMatchingInfo(pedido: (typeof existingPedidos)[0]) {
|
|
if (!pedido.matchingItems || pedido.matchingItems.length === 0) return null;
|
|
|
|
const matches = pedido.matchingItems.filter((item) =>
|
|
selectedObjetoIds.includes(item.objetoId)
|
|
);
|
|
|
|
if (matches.length === 0) return null;
|
|
|
|
const details = matches
|
|
.map((match) => {
|
|
// 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(', ');
|
|
|
|
return `Contém: ${details}`;
|
|
}
|
|
|
|
function getFirstMatchingSelectedItem(pedido: (typeof existingPedidos)[0]) {
|
|
if (!pedido.matchingItems || pedido.matchingItems.length === 0) return null;
|
|
|
|
for (const match of pedido.matchingItems) {
|
|
const item = selectedItems.find((p) => p.objeto._id === match.objetoId);
|
|
if (item) {
|
|
return item;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function buildPedidoHref(pedido: (typeof existingPedidos)[0]): `/pedidos/${string}` {
|
|
const matchedItem = getFirstMatchingSelectedItem(pedido);
|
|
|
|
if (!matchedItem) {
|
|
return `/pedidos/${pedido._id}` as `/pedidos/${string}`;
|
|
}
|
|
|
|
const params = new URLSearchParams();
|
|
params.set('obj', matchedItem.objeto._id);
|
|
params.set('qtd', String(matchedItem.quantidade));
|
|
|
|
if (matchedItem.acaoId) {
|
|
params.set('acao', matchedItem.acaoId);
|
|
}
|
|
|
|
return `/pedidos/${pedido._id}?${params.toString()}` as `/pedidos/${string}`;
|
|
}
|
|
|
|
async function checkExisting() {
|
|
warning = null;
|
|
existingPedidos = [];
|
|
|
|
const hasFilters = formData.numeroSei || selectedItems.length > 0;
|
|
if (!hasFilters) return;
|
|
|
|
checking = true;
|
|
try {
|
|
// Importante: O filtro considera apenas objetoId (modalidade não é mais usada na criação).
|
|
const itensFiltro =
|
|
selectedItems.length > 0
|
|
? selectedItems.map((item) => ({
|
|
objetoId: item.objeto._id
|
|
}))
|
|
: undefined;
|
|
|
|
const result = await client.query(api.pedidos.checkExisting, {
|
|
numeroSei: formData.numeroSei || undefined,
|
|
itensFiltro
|
|
});
|
|
|
|
existingPedidos = result;
|
|
|
|
if (result.length > 0) {
|
|
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.';
|
|
}
|
|
} catch (e) {
|
|
console.error(e);
|
|
} finally {
|
|
checking = false;
|
|
}
|
|
}
|
|
|
|
async function handleSubmit(e: Event) {
|
|
e.preventDefault();
|
|
creating = true;
|
|
error = null;
|
|
try {
|
|
const pedidoId = await client.mutation(api.pedidos.create, {
|
|
numeroSei: formData.numeroSei || undefined
|
|
});
|
|
|
|
if (selectedItems.length > 0) {
|
|
await Promise.all(
|
|
selectedItems.map((item) =>
|
|
client.mutation(api.pedidos.addItem, {
|
|
pedidoId,
|
|
objetoId: item.objeto._id,
|
|
valorEstimado: item.objeto.valorEstimado,
|
|
quantidade: item.quantidade,
|
|
acaoId: item.acaoId
|
|
})
|
|
)
|
|
);
|
|
}
|
|
|
|
goto(resolve(`/pedidos/${pedidoId}`));
|
|
} catch (e) {
|
|
error = (e as Error).message;
|
|
} finally {
|
|
creating = false;
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<div class="container mx-auto max-w-4xl p-6">
|
|
<h1 class="mb-6 text-3xl font-bold">Novo Pedido</h1>
|
|
|
|
<div class="space-y-6">
|
|
{#if error}
|
|
<div class="rounded-lg border border-red-400 bg-red-50 px-4 py-3 text-red-700">
|
|
<p class="font-semibold">Erro</p>
|
|
<p class="text-sm">{error}</p>
|
|
</div>
|
|
{/if}
|
|
|
|
<form onsubmit={handleSubmit} class="space-y-6">
|
|
<!-- Section 1: Basic Information -->
|
|
<div class="rounded-lg bg-white p-6 shadow-md">
|
|
<h2 class="mb-4 text-lg font-semibold text-gray-800">Informações Básicas</h2>
|
|
<div>
|
|
<label class="mb-2 block text-sm font-medium text-gray-700" for="numeroSei">
|
|
Número SEI (Opcional)
|
|
</label>
|
|
<input
|
|
class="focus:shadow-outline w-full appearance-none rounded-lg border border-gray-300 px-4 py-2.5 leading-tight text-gray-700 shadow-sm transition focus:border-blue-500 focus:ring-2 focus:ring-blue-200 focus:outline-none"
|
|
id="numeroSei"
|
|
type="text"
|
|
bind:value={formData.numeroSei}
|
|
placeholder="Ex: 12345.000000/2023-00"
|
|
onblur={checkExisting}
|
|
/>
|
|
<p class="mt-1.5 text-xs text-gray-500">
|
|
Você pode adicionar o número SEI posteriormente.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Section 2: Add Objects -->
|
|
<div class="rounded-lg bg-white p-6 shadow-md">
|
|
<h2 class="mb-4 text-lg font-semibold text-gray-800">Adicionar Objetos ao Pedido</h2>
|
|
|
|
<div class="relative mb-4">
|
|
<label class="mb-2 block text-sm font-medium text-gray-700" for="search-objetos">
|
|
Buscar Objetos
|
|
</label>
|
|
<input
|
|
id="search-objetos"
|
|
type="text"
|
|
placeholder="Digite o nome do objeto..."
|
|
class="focus:shadow-outline w-full appearance-none rounded-lg border border-gray-300 px-4 py-2.5 leading-tight text-gray-700 shadow-sm transition focus:border-blue-500 focus:ring-2 focus:ring-blue-200 focus:outline-none"
|
|
bind:value={searchQuery}
|
|
/>
|
|
{#if searchQuery.length > 0 && searchResults}
|
|
<div
|
|
class="absolute z-10 mt-2 w-full rounded-lg border border-gray-200 bg-white shadow-xl"
|
|
>
|
|
{#if searchResults.length === 0}
|
|
<div class="p-4 text-sm text-gray-500">Nenhum objeto encontrado.</div>
|
|
{:else}
|
|
<ul class="max-h-64 overflow-y-auto">
|
|
{#each searchResults as objeto (objeto._id)}
|
|
<li>
|
|
<button
|
|
type="button"
|
|
class="flex w-full items-center justify-between px-4 py-3 text-left transition hover:bg-blue-50"
|
|
onclick={() => openItemModal(objeto)}
|
|
>
|
|
<span class="font-medium text-gray-800">{objeto.nome}</span>
|
|
<Plus size={16} class="text-blue-600" />
|
|
</button>
|
|
</li>
|
|
{/each}
|
|
</ul>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
{#if selectedItems.length > 0}
|
|
<div class="mt-6">
|
|
<h3 class="mb-3 text-sm font-semibold text-gray-700">
|
|
Itens Selecionados ({selectedItems.length})
|
|
</h3>
|
|
<div class="space-y-3">
|
|
{#each selectedItems as item, index (index)}
|
|
<div
|
|
class="rounded-xl border border-gray-200 bg-gray-50 p-4 transition hover:shadow-md"
|
|
>
|
|
<div class="flex items-start justify-between gap-4">
|
|
<div class="flex-1 space-y-2">
|
|
<div class="flex flex-wrap items-center gap-2">
|
|
<p class="font-semibold text-gray-900">{item.objeto.nome}</p>
|
|
{#if item.acaoId}
|
|
<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}
|
|
</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 class="flex items-center gap-2">
|
|
<button
|
|
type="button"
|
|
class="rounded-lg p-2 text-blue-600 transition hover:bg-blue-50"
|
|
onclick={() => openDetails(item)}
|
|
aria-label="Ver detalhes"
|
|
>
|
|
<Info size={18} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="rounded-lg p-2 text-red-600 transition hover:bg-red-50"
|
|
onclick={() => removeItem(index)}
|
|
aria-label="Remover item"
|
|
>
|
|
<Trash2 size={18} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
{:else}
|
|
<div class="mt-4 rounded-lg border-2 border-dashed border-gray-300 p-8 text-center">
|
|
<p class="text-sm text-gray-500">
|
|
Nenhum item adicionado. Use a busca acima para adicionar objetos ao pedido.
|
|
</p>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Warnings Section -->
|
|
|
|
{#if warning}
|
|
<div
|
|
class="rounded-lg border border-yellow-400 bg-yellow-50 px-4 py-3 text-sm text-yellow-800"
|
|
>
|
|
<p class="font-semibold">Aviso</p>
|
|
<p>{warning}</p>
|
|
</div>
|
|
{/if}
|
|
|
|
{#if checking}
|
|
<p class="text-sm text-gray-500">Verificando pedidos existentes...</p>
|
|
{/if}
|
|
|
|
{#if existingPedidos.length > 0}
|
|
<div class="rounded-lg border border-yellow-300 bg-yellow-50 p-4">
|
|
<p class="mb-3 font-semibold text-yellow-900">Pedidos similares encontrados:</p>
|
|
<ul class="space-y-2">
|
|
{#each existingPedidos as pedido (pedido._id)}
|
|
<li class="flex flex-col rounded-lg bg-white px-4 py-3 shadow-sm">
|
|
<div class="flex items-center justify-between gap-3">
|
|
<div class="space-y-1">
|
|
<p class="text-sm font-medium text-gray-900">
|
|
Pedido {pedido.numeroSei || 'sem número SEI'} — {formatStatus(pedido.status)}
|
|
</p>
|
|
|
|
{#if getMatchingInfo(pedido)}
|
|
<p class="mt-1 text-xs text-blue-700">
|
|
{getMatchingInfo(pedido)}
|
|
</p>
|
|
{/if}
|
|
</div>
|
|
<a
|
|
href={resolve(buildPedidoHref(pedido))}
|
|
class="text-sm font-medium text-blue-600 hover:text-blue-800"
|
|
>
|
|
Abrir
|
|
</a>
|
|
</div>
|
|
</li>
|
|
{/each}
|
|
</ul>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Action Buttons -->
|
|
<div class="flex items-center justify-end gap-3 border-t pt-6">
|
|
<a
|
|
href={resolve('/pedidos')}
|
|
class="rounded-lg bg-gray-200 px-6 py-2.5 font-semibold text-gray-800 transition hover:bg-gray-300"
|
|
>
|
|
Cancelar
|
|
</a>
|
|
<button
|
|
type="submit"
|
|
disabled={creating || selectedItems.length === 0}
|
|
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'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
|
|
<!-- Item Configuration Modal -->
|
|
{#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 p-4"
|
|
>
|
|
<div class="relative w-full max-w-lg rounded-xl bg-white p-6 shadow-2xl">
|
|
<button
|
|
onclick={closeItemModal}
|
|
class="absolute top-4 right-4 rounded-lg p-1 text-gray-400 transition hover:bg-gray-100 hover:text-gray-600"
|
|
aria-label="Fechar"
|
|
>
|
|
<X size={24} />
|
|
</button>
|
|
|
|
<h3 class="mb-4 text-xl font-bold text-gray-900">Configurar Item</h3>
|
|
|
|
<div class="mb-6 rounded-lg bg-blue-50 p-4">
|
|
<p class="font-semibold text-gray-900">{itemConfig.objeto.nome}</p>
|
|
<p class="text-sm text-gray-600">Unidade: {itemConfig.objeto.unidade}</p>
|
|
<p class="mt-1 text-xs text-gray-500">
|
|
Valor estimado: {itemConfig.objeto.valorEstimado}
|
|
</p>
|
|
</div>
|
|
|
|
<div class="space-y-4">
|
|
<div>
|
|
<label class="mb-2 block text-sm font-medium text-gray-700" for="quantidade">
|
|
Quantidade
|
|
</label>
|
|
<input
|
|
id="quantidade"
|
|
type="number"
|
|
min="1"
|
|
class="w-full rounded-lg border border-gray-300 px-4 py-2.5 transition focus:border-blue-500 focus:ring-2 focus:ring-blue-200 focus:outline-none"
|
|
bind:value={itemConfig.quantidade}
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label class="mb-2 block text-sm font-medium text-gray-700" for="itemAcao">
|
|
Ação (Opcional)
|
|
</label>
|
|
<select
|
|
id="itemAcao"
|
|
class="w-full rounded-lg border border-gray-300 px-4 py-2.5 transition focus:border-blue-500 focus:ring-2 focus:ring-blue-200 focus:outline-none"
|
|
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>
|
|
|
|
<div class="mt-6 flex justify-end gap-3">
|
|
<button
|
|
type="button"
|
|
onclick={closeItemModal}
|
|
class="rounded-lg bg-gray-200 px-5 py-2.5 font-semibold text-gray-800 transition hover:bg-gray-300"
|
|
>
|
|
Cancelar
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onclick={confirmAddItem}
|
|
class="rounded-lg bg-blue-600 px-5 py-2.5 font-semibold text-white shadow-md transition hover:bg-blue-700"
|
|
>
|
|
Adicionar Item
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Details Modal -->
|
|
{#if showDetailsModal && detailsItem}
|
|
<div
|
|
class="fixed inset-0 z-50 flex h-full w-full items-center justify-center overflow-y-auto bg-black/40 p-4"
|
|
>
|
|
<div class="relative w-full max-w-lg rounded-xl bg-white p-6 shadow-2xl">
|
|
<button
|
|
onclick={closeDetails}
|
|
class="absolute top-4 right-4 rounded-lg p-1 text-gray-400 transition hover:bg-gray-100 hover:text-gray-600"
|
|
aria-label="Fechar"
|
|
>
|
|
<X size={24} />
|
|
</button>
|
|
|
|
<h3 class="mb-4 text-xl font-bold text-gray-900">Detalhes do Item</h3>
|
|
|
|
<div class="space-y-4">
|
|
<div class="rounded-lg bg-gray-50 p-4">
|
|
<h4 class="mb-2 font-semibold text-gray-800">Objeto</h4>
|
|
<p class="text-gray-700"><strong>Nome:</strong> {detailsItem.objeto.nome}</p>
|
|
<p class="text-gray-700"><strong>Unidade:</strong> {detailsItem.objeto.unidade}</p>
|
|
<p class="text-gray-700">
|
|
<strong>Valor Estimado:</strong>
|
|
{detailsItem.objeto.valorEstimado}
|
|
</p>
|
|
</div>
|
|
|
|
<div class="rounded-lg bg-gray-50 p-4">
|
|
<h4 class="mb-2 font-semibold text-gray-800">Pedido</h4>
|
|
<p class="text-gray-700"><strong>Quantidade:</strong> {detailsItem.quantidade}</p>
|
|
|
|
{#if detailsItem.acaoId}
|
|
<p class="text-gray-700">
|
|
<strong>Ação:</strong>
|
|
{getAcaoNome(detailsItem.acaoId)}
|
|
</p>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mt-6 flex justify-end">
|
|
<button
|
|
type="button"
|
|
onclick={closeDetails}
|
|
class="rounded-lg bg-blue-600 px-5 py-2.5 font-semibold text-white shadow-md transition hover:bg-blue-700"
|
|
>
|
|
Fechar
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|