feat: Implement initial pedido (order) management, product catalog, and TI configuration features.
This commit is contained in:
358
apps/web/src/routes/(dashboard)/pedidos/novo/+page.svelte
Normal file
358
apps/web/src/routes/(dashboard)/pedidos/novo/+page.svelte
Normal file
@@ -0,0 +1,358 @@
|
||||
<script lang="ts">
|
||||
import { useQuery, useConvexClient } from 'convex-svelte';
|
||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||
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 searchResults = $derived(searchResultsQuery.data);
|
||||
|
||||
let formData = $state({
|
||||
numeroSei: '',
|
||||
acaoId: '' as Id<'acoes'> | ''
|
||||
});
|
||||
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));
|
||||
|
||||
function addProduto(produto: Doc<'produtos'>) {
|
||||
if (!selectedProdutos.find((p) => p.produto._id === produto._id)) {
|
||||
// Default quantity 1
|
||||
selectedProdutos = [...selectedProdutos, { produto, quantidade: 1 }];
|
||||
checkExisting();
|
||||
}
|
||||
searchQuery = '';
|
||||
}
|
||||
|
||||
function removeProduto(produtoId: Id<'produtos'>) {
|
||||
selectedProdutos = selectedProdutos.filter((p) => p.produto._id !== produtoId);
|
||||
checkExisting();
|
||||
}
|
||||
|
||||
// Updated type for existingPedidos to include matchingItems
|
||||
let existingPedidos = $state<
|
||||
{
|
||||
_id: Id<'pedidos'>;
|
||||
numeroSei?: string;
|
||||
status:
|
||||
| 'em_rascunho'
|
||||
| 'aguardando_aceite'
|
||||
| 'em_analise'
|
||||
| 'precisa_ajustes'
|
||||
| 'cancelado'
|
||||
| 'concluido';
|
||||
acaoId?: Id<'acoes'>;
|
||||
criadoEm: number;
|
||||
matchingItems?: { produtoId: Id<'produtos'>; 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 : '-';
|
||||
}
|
||||
|
||||
// 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)
|
||||
);
|
||||
|
||||
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.`;
|
||||
})
|
||||
.join(', ');
|
||||
|
||||
return `Contém: ${details}`;
|
||||
}
|
||||
|
||||
async function checkExisting() {
|
||||
warning = null;
|
||||
existingPedidos = [];
|
||||
|
||||
const hasFilters = formData.acaoId || formData.numeroSei || selectedProdutoIds.length > 0;
|
||||
if (!hasFilters) return;
|
||||
|
||||
checking = true;
|
||||
try {
|
||||
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
|
||||
});
|
||||
|
||||
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.`;
|
||||
} 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,
|
||||
acaoId: formData.acaoId ? (formData.acaoId as Id<'acoes'>) : undefined
|
||||
});
|
||||
|
||||
if (selectedProdutos.length > 0) {
|
||||
await Promise.all(
|
||||
selectedProdutos.map((item) =>
|
||||
client.mutation(api.pedidos.addItem, {
|
||||
pedidoId,
|
||||
produtoId: item.produto._id,
|
||||
valorEstimado: item.produto.valorEstimado,
|
||||
quantidade: item.quantidade // Pass quantity
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
goto(resolve(`/pedidos/${pedidoId}`));
|
||||
} catch (e) {
|
||||
error = (e as Error).message;
|
||||
} finally {
|
||||
creating = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="container mx-auto max-w-2xl p-6">
|
||||
<h1 class="mb-6 text-2xl font-bold">Novo Pedido</h1>
|
||||
|
||||
<div class="rounded-lg bg-white p-6 shadow-md">
|
||||
{#if error}
|
||||
<div class="mb-4 rounded border border-red-400 bg-red-100 px-4 py-3 text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form onsubmit={handleSubmit}>
|
||||
<div class="mb-4">
|
||||
<label class="mb-2 block text-sm font-bold text-gray-700" for="numeroSei">
|
||||
Número SEI (Opcional)
|
||||
</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="numeroSei"
|
||||
type="text"
|
||||
bind:value={formData.numeroSei}
|
||||
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}
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label class="mb-2 block text-sm font-bold text-gray-700" for="produtos">
|
||||
Produtos (Opcional)
|
||||
</label>
|
||||
|
||||
<div class="mb-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar produtos..."
|
||||
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}
|
||||
/>
|
||||
</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>
|
||||
<button
|
||||
type="button"
|
||||
class="text-red-600 hover:text-red-800"
|
||||
onclick={() => removeProduto(item.produto._id)}
|
||||
>
|
||||
Remover
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if warning}
|
||||
<div
|
||||
class="mb-4 rounded border border-yellow-400 bg-yellow-100 px-4 py-3 text-sm text-yellow-800"
|
||||
>
|
||||
{warning}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if checking}
|
||||
<p class="mb-4 text-sm text-gray-500">Verificando pedidos existentes...</p>
|
||||
{/if}
|
||||
|
||||
{#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>
|
||||
<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">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<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
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{#if getMatchingInfo(pedido)}
|
||||
<div class="mt-1 text-xs font-semibold text-blue-700">
|
||||
{getMatchingInfo(pedido)}
|
||||
</div>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex items-center justify-end">
|
||||
<a
|
||||
href={resolve('/pedidos')}
|
||||
class="mr-2 rounded bg-gray-300 px-4 py-2 font-bold text-gray-800 hover:bg-gray-400"
|
||||
>
|
||||
Cancelar
|
||||
</a>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={creating || loading}
|
||||
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'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user