Files
sgse-app/apps/web/src/routes/(dashboard)/pedidos/novo/+page.svelte

583 lines
16 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 Breadcrumbs from '$lib/components/layout/Breadcrumbs.svelte';
import PageHeader from '$lib/components/layout/PageHeader.svelte';
import PageShell from '$lib/components/layout/PageShell.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte';
import GlassCard from '$lib/components/ui/GlassCard.svelte';
import { Info, Plus, Trash2, X } 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>
<PageShell class="max-w-4xl">
<Breadcrumbs
items={[
{ label: 'Dashboard', href: resolve('/') },
{ label: 'Pedidos', href: resolve('/pedidos') },
{ label: 'Novo' }
]}
/>
<PageHeader title="Novo Pedido" subtitle="Crie um pedido e adicione objetos.">
{#snippet icon()}
<Plus class="h-6 w-6" />
{/snippet}
</PageHeader>
<div class="space-y-6">
{#if error}
<div class="alert alert-error">
<span>{error}</span>
</div>
{/if}
<form onsubmit={handleSubmit} class="space-y-6">
<GlassCard>
<h2 class="text-lg font-semibold">Informações Básicas</h2>
<div class="mt-4">
<label class="label py-0" for="numeroSei">
<span class="label-text font-semibold">Número SEI (Opcional)</span>
</label>
<input
id="numeroSei"
type="text"
bind:value={formData.numeroSei}
placeholder="Ex: 12345.000000/2023-00"
onblur={checkExisting}
class="input input-bordered focus:input-primary w-full"
/>
<p class="text-base-content/60 mt-2 text-xs">
Você pode adicionar o número SEI posteriormente.
</p>
</div>
</GlassCard>
<GlassCard>
<h2 class="text-lg font-semibold">Adicionar Objetos ao Pedido</h2>
<div class="relative mt-4">
<label class="label py-0" for="search-objetos">
<span class="label-text font-semibold">Buscar Objetos</span>
</label>
<input
id="search-objetos"
type="text"
placeholder="Digite o nome do objeto..."
class="input input-bordered focus:input-primary w-full"
bind:value={searchQuery}
/>
{#if searchQuery.length > 0 && searchResults}
<div
class="border-base-300 bg-base-100 rounded-box absolute z-20 mt-2 w-full overflow-hidden border shadow"
>
{#if searchResults.length === 0}
<div class="text-base-content/60 p-4 text-sm">Nenhum objeto encontrado.</div>
{:else}
<ul class="menu max-h-64 overflow-y-auto p-2">
{#each searchResults as objeto (objeto._id)}
<li>
<button
type="button"
class="flex items-center justify-between"
onclick={() => openItemModal(objeto)}
>
<span class="font-medium">{objeto.nome}</span>
<Plus class="h-4 w-4" />
</button>
</li>
{/each}
</ul>
{/if}
</div>
{/if}
</div>
<div class="mt-6">
{#if selectedItems.length > 0}
<h3 class="text-base-content/70 mb-3 text-sm font-semibold">
Itens Selecionados ({selectedItems.length})
</h3>
<div class="grid gap-3">
{#each selectedItems as item, index (index)}
<GlassCard class="border-base-300" bodyClass="p-4">
<div class="flex items-start justify-between gap-4">
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<p class="font-semibold">{item.objeto.nome}</p>
{#if item.acaoId}
<span class="badge badge-info badge-sm"
>Ação: {getAcaoNome(item.acaoId)}</span
>
{/if}
</div>
<div class="text-base-content/70 mt-1 text-sm">
<span class="font-semibold">Qtd:</span>
{item.quantidade}
{item.objeto.unidade}
</div>
</div>
<div class="flex items-center gap-1">
<button
type="button"
class="btn btn-ghost btn-sm btn-square"
onclick={() => openDetails(item)}
title="Ver detalhes"
>
<Info class="h-4 w-4" />
</button>
<button
type="button"
class="btn btn-ghost btn-sm btn-square text-error"
onclick={() => removeItem(index)}
title="Remover item"
>
<Trash2 class="h-4 w-4" />
</button>
</div>
</div>
</GlassCard>
{/each}
</div>
{:else}
<EmptyState
title="Nenhum item adicionado"
description="Use a busca acima para adicionar objetos ao pedido."
>
{#snippet icon()}
<Plus />
{/snippet}
</EmptyState>
{/if}
</div>
</GlassCard>
{#if warning}
<div class={`alert ${existingPedidos.length > 0 ? 'alert-warning' : 'alert-info'}`}>
<span>{warning}</span>
</div>
{/if}
{#if checking}
<div class="text-base-content/60 flex items-center gap-2 text-sm">
<span class="loading loading-spinner loading-sm"></span>
Verificando pedidos existentes...
</div>
{/if}
{#if existingPedidos.length > 0}
<GlassCard>
<h2 class="text-lg font-semibold">Pedidos similares encontrados</h2>
<div class="mt-4 space-y-2">
{#each existingPedidos as pedido (pedido._id)}
<div
class="border-base-300 bg-base-100 flex items-start justify-between gap-3 rounded-lg border p-4"
>
<div class="min-w-0">
<p class="font-medium">
Pedido {pedido.numeroSei || 'sem número SEI'}{formatStatus(pedido.status)}
</p>
{#if getMatchingInfo(pedido)}
<p class="text-info mt-1 text-xs">{getMatchingInfo(pedido)}</p>
{/if}
</div>
<a href={resolve(buildPedidoHref(pedido))} class="btn btn-ghost btn-sm">Abrir</a>
</div>
{/each}
</div>
</GlassCard>
{/if}
<div class="flex items-center justify-end gap-3">
<a href={resolve('/pedidos')} class="btn">Cancelar</a>
<button
type="submit"
disabled={creating || selectedItems.length === 0}
class="btn btn-primary"
>
{#if creating}
<span class="loading loading-spinner loading-sm"></span>
{/if}
Criar Pedido
</button>
</div>
</form>
</div>
{#if showItemModal && itemConfig.objeto}
<div class="modal modal-open">
<div class="modal-box max-w-lg">
<button
type="button"
class="btn btn-sm btn-circle absolute top-2 right-2"
onclick={closeItemModal}
aria-label="Fechar modal"
>
<X class="h-5 w-5" />
</button>
<h3 class="text-lg font-bold">Configurar Item</h3>
<div class="border-base-300 bg-base-200/30 mt-4 rounded-lg border p-4">
<p class="font-semibold">{itemConfig.objeto.nome}</p>
<p class="text-base-content/70 text-sm">Unidade: {itemConfig.objeto.unidade}</p>
<p class="text-base-content/60 mt-1 text-xs">
Valor estimado: {itemConfig.objeto.valorEstimado}
</p>
</div>
<div class="mt-4 grid gap-4">
<div>
<label class="label py-0" for="quantidade">
<span class="label-text font-semibold">Quantidade</span>
</label>
<input
id="quantidade"
type="number"
min="1"
class="input input-bordered focus:input-primary w-full"
bind:value={itemConfig.quantidade}
/>
</div>
<div>
<label class="label py-0" for="itemAcao">
<span class="label-text font-semibold">Ação (Opcional)</span>
</label>
<select
id="itemAcao"
class="select select-bordered focus:select-primary w-full"
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="modal-action">
<button type="button" class="btn" onclick={closeItemModal}>Cancelar</button>
<button type="button" class="btn btn-primary" onclick={confirmAddItem}
>Adicionar Item</button
>
</div>
</div>
<button
type="button"
class="modal-backdrop"
onclick={closeItemModal}
aria-label="Fechar modal"
></button>
</div>
{/if}
{#if showDetailsModal && detailsItem}
<div class="modal modal-open">
<div class="modal-box max-w-lg">
<button
type="button"
class="btn btn-sm btn-circle absolute top-2 right-2"
onclick={closeDetails}
aria-label="Fechar modal"
>
<X class="h-5 w-5" />
</button>
<h3 class="text-lg font-bold">Detalhes do Item</h3>
<div class="mt-4 space-y-4">
<div class="border-base-300 bg-base-200/30 rounded-lg border p-4">
<h4 class="font-semibold">Objeto</h4>
<p class="text-base-content/70 mt-2 text-sm">
<strong>Nome:</strong>
{detailsItem.objeto.nome}
</p>
<p class="text-base-content/70 text-sm">
<strong>Unidade:</strong>
{detailsItem.objeto.unidade}
</p>
<p class="text-base-content/70 text-sm">
<strong>Valor Estimado:</strong>
{detailsItem.objeto.valorEstimado}
</p>
</div>
<div class="border-base-300 bg-base-200/30 rounded-lg border p-4">
<h4 class="font-semibold">Pedido</h4>
<p class="text-base-content/70 mt-2 text-sm">
<strong>Quantidade:</strong>
{detailsItem.quantidade}
</p>
{#if detailsItem.acaoId}
<p class="text-base-content/70 text-sm">
<strong>Ação:</strong>
{getAcaoNome(detailsItem.acaoId)}
</p>
{/if}
</div>
</div>
<div class="modal-action">
<button type="button" class="btn" onclick={closeDetails}>Fechar</button>
</div>
</div>
<button type="button" class="modal-backdrop" onclick={closeDetails} aria-label="Fechar modal"
></button>
</div>
{/if}
</PageShell>