feat: Add 'atas' (minutes/records) management feature, and implement various improvements across UI, backend logic, and authentication.
This commit is contained in:
@@ -1,26 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { useQuery } from 'convex-svelte';
|
||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||
import { Plus, Eye } from 'lucide-svelte';
|
||||
import { useQuery } from 'convex-svelte';
|
||||
import { Eye, Plus } from 'lucide-svelte';
|
||||
import { resolve } from '$app/paths';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
// Reactive queries
|
||||
const pedidosQuery = useQuery(api.pedidos.list, {});
|
||||
const acoesQuery = useQuery(api.acoes.list, {});
|
||||
|
||||
const pedidos = $derived(pedidosQuery.data || []);
|
||||
const acoes = $derived(acoesQuery.data || []);
|
||||
const loading = $derived(pedidosQuery.isLoading || acoesQuery.isLoading);
|
||||
const error = $derived(pedidosQuery.error?.message || acoesQuery.error?.message || null);
|
||||
|
||||
function getAcaoNome(acaoId: Id<'acoes'> | undefined) {
|
||||
if (!acaoId) return '-';
|
||||
const acao = acoes.find((a) => a._id === acaoId);
|
||||
return acao ? acao.nome : '-';
|
||||
}
|
||||
|
||||
function formatStatus(status: string) {
|
||||
switch (status) {
|
||||
case 'em_rascunho':
|
||||
@@ -93,10 +84,6 @@
|
||||
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
|
||||
>Status</th
|
||||
>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
|
||||
>Ação</th
|
||||
>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
|
||||
>Data de Criação</th
|
||||
@@ -126,9 +113,6 @@
|
||||
{formatStatus(pedido.status)}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-gray-500">
|
||||
{getAcaoNome(pedido.acaoId)}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-gray-500">
|
||||
{formatDate(pedido.criadoEm)}
|
||||
</td>
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { useQuery, useConvexClient } from 'convex-svelte';
|
||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||
import { maskCurrencyBRL } from '$lib/utils/masks';
|
||||
import { useConvexClient, useQuery } from 'convex-svelte';
|
||||
import {
|
||||
Plus,
|
||||
Trash2,
|
||||
Send,
|
||||
CheckCircle,
|
||||
AlertTriangle,
|
||||
XCircle,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
Edit,
|
||||
Plus,
|
||||
Save,
|
||||
X
|
||||
Send,
|
||||
Trash2,
|
||||
X,
|
||||
XCircle
|
||||
} from 'lucide-svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { maskCurrencyBRL } from '$lib/utils/masks';
|
||||
|
||||
const pedidoId = $page.params.id as Id<'pedidos'>;
|
||||
const client = useConvexClient();
|
||||
@@ -24,27 +24,37 @@
|
||||
const pedidoQuery = useQuery(api.pedidos.get, { id: pedidoId });
|
||||
const itemsQuery = useQuery(api.pedidos.getItems, { pedidoId });
|
||||
const historyQuery = useQuery(api.pedidos.getHistory, { pedidoId });
|
||||
const produtosQuery = useQuery(api.produtos.list, {});
|
||||
const objetosQuery = useQuery(api.objetos.list, {});
|
||||
const acoesQuery = useQuery(api.acoes.list, {});
|
||||
|
||||
// Derived state
|
||||
const pedido = $derived(pedidoQuery.data);
|
||||
const items = $derived(itemsQuery.data || []);
|
||||
const history = $derived(historyQuery.data || []);
|
||||
const produtos = $derived(produtosQuery.data || []);
|
||||
const objetos = $derived(objetosQuery.data || []);
|
||||
const acoes = $derived(acoesQuery.data || []);
|
||||
|
||||
const acao = $derived.by(() => {
|
||||
if (pedido && pedido.acaoId && acoesQuery.data) {
|
||||
return acoesQuery.data.find((a) => a._id === pedido.acaoId);
|
||||
// Group items by user
|
||||
const groupedItems = $derived.by(() => {
|
||||
const groups: Record<string, { name: string; items: typeof items }> = {};
|
||||
for (const item of items) {
|
||||
const userId = item.adicionadoPor;
|
||||
if (!groups[userId]) {
|
||||
groups[userId] = {
|
||||
name: item.adicionadoPorNome,
|
||||
items: []
|
||||
};
|
||||
}
|
||||
groups[userId].items.push(item);
|
||||
}
|
||||
return null;
|
||||
return Object.values(groups);
|
||||
});
|
||||
|
||||
const loading = $derived(
|
||||
pedidoQuery.isLoading ||
|
||||
itemsQuery.isLoading ||
|
||||
historyQuery.isLoading ||
|
||||
produtosQuery.isLoading ||
|
||||
objetosQuery.isLoading ||
|
||||
acoesQuery.isLoading
|
||||
);
|
||||
|
||||
@@ -52,7 +62,7 @@
|
||||
pedidoQuery.error?.message ||
|
||||
itemsQuery.error?.message ||
|
||||
historyQuery.error?.message ||
|
||||
produtosQuery.error?.message ||
|
||||
objetosQuery.error?.message ||
|
||||
acoesQuery.error?.message ||
|
||||
null
|
||||
);
|
||||
@@ -60,9 +70,11 @@
|
||||
// Add Item State
|
||||
let showAddItem = $state(false);
|
||||
let newItem = $state({
|
||||
produtoId: '' as string,
|
||||
objetoId: '' as string,
|
||||
valorEstimado: '',
|
||||
quantidade: 1
|
||||
quantidade: 1,
|
||||
modalidade: 'consumo' as 'dispensa' | 'inexgibilidade' | 'adesao' | 'consumo',
|
||||
acaoId: '' as string
|
||||
});
|
||||
let addingItem = $state(false);
|
||||
|
||||
@@ -72,16 +84,24 @@
|
||||
let updatingSei = $state(false);
|
||||
|
||||
async function handleAddItem() {
|
||||
if (!newItem.produtoId || !newItem.valorEstimado) return;
|
||||
if (!newItem.objetoId || !newItem.valorEstimado) return;
|
||||
addingItem = true;
|
||||
try {
|
||||
await client.mutation(api.pedidos.addItem, {
|
||||
pedidoId,
|
||||
produtoId: newItem.produtoId as Id<'produtos'>,
|
||||
objetoId: newItem.objetoId as Id<'objetos'>,
|
||||
valorEstimado: newItem.valorEstimado,
|
||||
quantidade: newItem.quantidade
|
||||
quantidade: newItem.quantidade,
|
||||
modalidade: newItem.modalidade,
|
||||
acaoId: newItem.acaoId ? (newItem.acaoId as Id<'acoes'>) : undefined
|
||||
});
|
||||
newItem = { produtoId: '', valorEstimado: '', quantidade: 1 };
|
||||
newItem = {
|
||||
objetoId: '',
|
||||
valorEstimado: '',
|
||||
quantidade: 1,
|
||||
modalidade: 'consumo',
|
||||
acaoId: ''
|
||||
};
|
||||
showAddItem = false;
|
||||
} catch (e) {
|
||||
alert('Erro ao adicionar item: ' + (e as Error).message);
|
||||
@@ -90,7 +110,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpdateQuantity(itemId: Id<'pedidoItems'>, novaQuantidade: number) {
|
||||
async function handleUpdateQuantity(itemId: Id<'objetoItems'>, novaQuantidade: number) {
|
||||
if (novaQuantidade < 1) {
|
||||
alert('Quantidade deve ser pelo menos 1.');
|
||||
return;
|
||||
@@ -105,7 +125,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemoveItem(itemId: Id<'pedidoItems'>) {
|
||||
async function handleRemoveItem(itemId: Id<'objetoItems'>) {
|
||||
if (!confirm('Remover este item?')) return;
|
||||
try {
|
||||
await client.mutation(api.pedidos.removeItem, { itemId });
|
||||
@@ -134,15 +154,20 @@
|
||||
}
|
||||
}
|
||||
|
||||
function getProductName(id: string) {
|
||||
return produtos.find((p) => p._id === id)?.nome || 'Produto desconhecido';
|
||||
function getObjetoName(id: string) {
|
||||
return objetos.find((o) => o._id === id)?.nome || 'Objeto desconhecido';
|
||||
}
|
||||
|
||||
function handleProductChange(id: string) {
|
||||
newItem.produtoId = id;
|
||||
const produto = produtos.find((p) => p._id === id);
|
||||
if (produto) {
|
||||
newItem.valorEstimado = maskCurrencyBRL(produto.valorEstimado || '');
|
||||
function getAcaoName(id: string | undefined) {
|
||||
if (!id) return '-';
|
||||
return acoes.find((a) => a._id === id)?.nome || '-';
|
||||
}
|
||||
|
||||
function handleObjetoChange(id: string) {
|
||||
newItem.objetoId = id;
|
||||
const objeto = objetos.find((o) => o._id === id);
|
||||
if (objeto) {
|
||||
newItem.valorEstimado = maskCurrencyBRL(objeto.valorEstimado || '');
|
||||
} else {
|
||||
newItem.valorEstimado = '';
|
||||
}
|
||||
@@ -261,26 +286,27 @@
|
||||
return `${entry.usuarioNome} criou o pedido ${detalhes.numeroSei || ''}`;
|
||||
|
||||
case 'adicao_item': {
|
||||
const produto = produtos.find((p) => p._id === detalhes.produtoId);
|
||||
const nomeProduto = produto?.nome || 'Produto desconhecido';
|
||||
const objeto = objetos.find((o) => o._id === detalhes.objetoId);
|
||||
const nomeObjeto = objeto?.nome || 'Objeto desconhecido';
|
||||
const quantidade = detalhes.quantidade || 1;
|
||||
return `${entry.usuarioNome} adicionou ${quantidade}x ${nomeProduto} (${detalhes.valor})`;
|
||||
return `${entry.usuarioNome} adicionou ${quantidade}x ${nomeObjeto} (${detalhes.valor})`;
|
||||
}
|
||||
|
||||
case 'remocao_item': {
|
||||
const produto = produtos.find((p) => p._id === detalhes.produtoId);
|
||||
const nomeProduto = produto?.nome || 'Produto desconhecido';
|
||||
return `${entry.usuarioNome} removeu ${nomeProduto}`;
|
||||
const objeto = objetos.find((o) => o._id === detalhes.objetoId);
|
||||
const nomeObjeto = objeto?.nome || 'Objeto desconhecido';
|
||||
return `${entry.usuarioNome} removeu ${nomeObjeto}`;
|
||||
}
|
||||
|
||||
case 'alteracao_quantidade': {
|
||||
const produto = produtos.find((p) => p._id === detalhes.produtoId);
|
||||
const nomeProduto = produto?.nome || 'Produto desconhecido';
|
||||
return `${entry.usuarioNome} alterou a quantidade de ${nomeProduto} de ${detalhes.quantidadeAnterior} para ${detalhes.novaQuantidade}`;
|
||||
const objeto = objetos.find((o) => o._id === detalhes.objetoId);
|
||||
const nomeObjeto = objeto?.nome || 'Objeto desconhecido';
|
||||
return `${entry.usuarioNome} alterou a quantidade de ${nomeObjeto} de ${detalhes.quantidadeAnterior} para ${detalhes.novaQuantidade}`;
|
||||
}
|
||||
|
||||
case 'alteracao_status':
|
||||
return `${entry.usuarioNome} alterou o status para "${formatStatus(detalhes.novoStatus)}"`;
|
||||
case 'atualizacao_sei':
|
||||
return `${entry.usuarioNome} atualizou o número SEI para "${detalhes.numeroSei}"`;
|
||||
|
||||
default:
|
||||
@@ -345,9 +371,6 @@
|
||||
{formatStatus(pedido.status)}
|
||||
</span>
|
||||
</h1>
|
||||
{#if acao}
|
||||
<p class="mt-1 text-gray-600">Ação: {acao.nome} ({acao.tipo})</p>
|
||||
{/if}
|
||||
{#if !pedido.numeroSei}
|
||||
<p class="mt-1 text-sm text-amber-600">
|
||||
⚠️ Este pedido não possui número SEI. Adicione um número SEI quando disponível.
|
||||
@@ -366,7 +389,6 @@
|
||||
{/if}
|
||||
|
||||
{#if pedido.status === 'aguardando_aceite'}
|
||||
<!-- Actions for Purchasing Sector (Assuming current user has permission, logic handled in backend/UI visibility) -->
|
||||
<button
|
||||
onclick={() => updateStatus('em_analise')}
|
||||
class="flex items-center gap-2 rounded bg-indigo-600 px-4 py-2 text-white hover:bg-indigo-700"
|
||||
@@ -417,24 +439,24 @@
|
||||
|
||||
{#if showAddItem}
|
||||
<div class="border-b border-gray-200 bg-gray-50 px-6 py-4">
|
||||
<div class="flex items-end gap-4">
|
||||
<div class="flex-1">
|
||||
<label for="produto-select" class="mb-1 block text-xs font-medium text-gray-500"
|
||||
>Produto</label
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
<div class="col-span-1 md:col-span-2 lg:col-span-1">
|
||||
<label for="objeto-select" class="mb-1 block text-xs font-medium text-gray-500"
|
||||
>Objeto</label
|
||||
>
|
||||
<select
|
||||
id="produto-select"
|
||||
bind:value={newItem.produtoId}
|
||||
onchange={(e) => handleProductChange(e.currentTarget.value)}
|
||||
id="objeto-select"
|
||||
bind:value={newItem.objetoId}
|
||||
onchange={(e) => handleObjetoChange(e.currentTarget.value)}
|
||||
class="w-full rounded-md border-gray-300 text-sm shadow-sm"
|
||||
>
|
||||
<option value="">Selecione...</option>
|
||||
{#each produtos as p (p._id)}
|
||||
<option value={p._id}>{p.nome} ({p.tipo})</option>
|
||||
{#each objetos as o (o._id)}
|
||||
<option value={o._id}>{o.nome} ({o.unidade})</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div class="w-32">
|
||||
<div>
|
||||
<label for="quantidade-input" class="mb-1 block text-xs font-medium text-gray-500"
|
||||
>Quantidade</label
|
||||
>
|
||||
@@ -447,7 +469,7 @@
|
||||
placeholder="1"
|
||||
/>
|
||||
</div>
|
||||
<div class="w-40">
|
||||
<div>
|
||||
<label for="valor-input" class="mb-1 block text-xs font-medium text-gray-500"
|
||||
>Valor Estimado</label
|
||||
>
|
||||
@@ -460,116 +482,151 @@
|
||||
placeholder="R$ 0,00"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
onclick={handleAddItem}
|
||||
disabled={addingItem}
|
||||
class="rounded bg-blue-600 px-3 py-2 text-sm text-white hover:bg-blue-700"
|
||||
<div>
|
||||
<label for="modalidade-select" class="mb-1 block text-xs font-medium text-gray-500"
|
||||
>Modalidade</label
|
||||
>
|
||||
Adicionar
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (showAddItem = false)}
|
||||
class="rounded bg-gray-200 px-3 py-2 text-sm text-gray-700 hover:bg-gray-300"
|
||||
<select
|
||||
id="modalidade-select"
|
||||
bind:value={newItem.modalidade}
|
||||
class="w-full rounded-md border-gray-300 text-sm shadow-sm"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<option value="consumo">Consumo</option>
|
||||
<option value="dispensa">Dispensa</option>
|
||||
<option value="inexgibilidade">Inexigibilidade</option>
|
||||
<option value="adesao">Adesão</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="acao-select" class="mb-1 block text-xs font-medium text-gray-500"
|
||||
>Ação (Opcional)</label
|
||||
>
|
||||
<select
|
||||
id="acao-select"
|
||||
bind:value={newItem.acaoId}
|
||||
class="w-full rounded-md border-gray-300 text-sm shadow-sm"
|
||||
>
|
||||
<option value="">Selecione...</option>
|
||||
{#each acoes as a (a._id)}
|
||||
<option value={a._id}>{a.nome}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 flex justify-end gap-2">
|
||||
<button
|
||||
onclick={() => (showAddItem = false)}
|
||||
class="rounded bg-gray-200 px-3 py-2 text-sm text-gray-700 hover:bg-gray-300"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
onclick={handleAddItem}
|
||||
disabled={addingItem}
|
||||
class="rounded bg-blue-600 px-3 py-2 text-sm text-white hover:bg-blue-700"
|
||||
>
|
||||
Adicionar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
|
||||
>Produto</th
|
||||
>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
|
||||
>Quantidade</th
|
||||
>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
|
||||
>Valor Estimado</th
|
||||
>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
|
||||
>Adicionado Por</th
|
||||
>
|
||||
<th
|
||||
class="px-6 py-3 text-right text-xs font-medium tracking-wider text-gray-500 uppercase"
|
||||
>Total</th
|
||||
>
|
||||
<th
|
||||
class="px-6 py-3 text-right text-xs font-medium tracking-wider text-gray-500 uppercase"
|
||||
>Ações</th
|
||||
>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 bg-white">
|
||||
{#each items as item (item._id)}
|
||||
<tr>
|
||||
<td class="px-6 py-4 whitespace-nowrap">{getProductName(item.produtoId)}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
{#if pedido.status === 'em_rascunho' || pedido.status === 'precisa_ajustes'}
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={item.quantidade}
|
||||
onchange={(e) =>
|
||||
handleUpdateQuantity(item._id, parseInt(e.currentTarget.value) || 1)}
|
||||
class="w-20 rounded border px-2 py-1 text-sm"
|
||||
/>
|
||||
{:else}
|
||||
{item.quantidade}
|
||||
{/if}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
{maskCurrencyBRL(item.valorEstimado) || 'R$ 0,00'}
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm whitespace-nowrap text-gray-600">
|
||||
{item.adicionadoPorNome}
|
||||
</td>
|
||||
<td class="px-6 py-4 text-right font-medium whitespace-nowrap">
|
||||
R$ {calculateItemTotal(item.valorEstimado, item.quantidade)
|
||||
.toFixed(2)
|
||||
.replace('.', ',')}
|
||||
</td>
|
||||
<td class="px-6 py-4 text-right text-sm font-medium whitespace-nowrap">
|
||||
{#if pedido.status === 'em_rascunho' || pedido.status === 'precisa_ajustes'}
|
||||
<button
|
||||
onclick={() => handleRemoveItem(item._id)}
|
||||
class="text-red-600 hover:text-red-900"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
{#if items.length === 0}
|
||||
<tr>
|
||||
<td colspan="6" class="px-6 py-4 text-center text-gray-500"
|
||||
>Nenhum item adicionado.</td
|
||||
>
|
||||
</tr>
|
||||
{:else}
|
||||
<tr class="bg-gray-50 font-semibold">
|
||||
<td
|
||||
colspan="5"
|
||||
class="px-6 py-4 text-right text-sm tracking-wider text-gray-700 uppercase"
|
||||
>
|
||||
Total Geral:
|
||||
</td>
|
||||
<td class="px-6 py-4 text-right text-base font-bold text-gray-900">
|
||||
R$ {totalGeral.toFixed(2).replace('.', ',')}
|
||||
</td>
|
||||
</tr>
|
||||
{/if}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="flex flex-col">
|
||||
{#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">
|
||||
Adicionado por: {group.name}
|
||||
</div>
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
|
||||
>Objeto</th
|
||||
>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
|
||||
>Qtd</th
|
||||
>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
|
||||
>Valor Est.</th
|
||||
>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
|
||||
>Modalidade</th
|
||||
>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
|
||||
>Ação</th
|
||||
>
|
||||
<th
|
||||
class="px-6 py-3 text-right text-xs font-medium tracking-wider text-gray-500 uppercase"
|
||||
>Total</th
|
||||
>
|
||||
<th
|
||||
class="px-6 py-3 text-right text-xs font-medium tracking-wider text-gray-500 uppercase"
|
||||
>Ações</th
|
||||
>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 bg-white">
|
||||
{#each group.items as item (item._id)}
|
||||
<tr>
|
||||
<td class="px-6 py-4 whitespace-nowrap">{getObjetoName(item.objetoId)}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
{#if pedido.status === 'em_rascunho' || pedido.status === 'precisa_ajustes'}
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={item.quantidade}
|
||||
onchange={(e) =>
|
||||
handleUpdateQuantity(item._id, parseInt(e.currentTarget.value) || 1)}
|
||||
class="w-20 rounded border px-2 py-1 text-sm"
|
||||
/>
|
||||
{:else}
|
||||
{item.quantidade}
|
||||
{/if}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
{maskCurrencyBRL(item.valorEstimado) || 'R$ 0,00'}
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm whitespace-nowrap text-gray-600">
|
||||
{item.modalidade}
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm whitespace-nowrap text-gray-600">
|
||||
{getAcaoName(item.acaoId)}
|
||||
</td>
|
||||
<td class="px-6 py-4 text-right font-medium whitespace-nowrap">
|
||||
R$ {calculateItemTotal(item.valorEstimado, item.quantidade)
|
||||
.toFixed(2)
|
||||
.replace('.', ',')}
|
||||
</td>
|
||||
<td class="px-6 py-4 text-right text-sm font-medium whitespace-nowrap">
|
||||
{#if pedido.status === 'em_rascunho' || pedido.status === 'precisa_ajustes'}
|
||||
<button
|
||||
onclick={() => handleRemoveItem(item._id)}
|
||||
class="text-red-600 hover:text-red-900"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/each}
|
||||
|
||||
{#if items.length === 0}
|
||||
<div class="px-6 py-4 text-center text-gray-500">Nenhum item adicionado.</div>
|
||||
{:else}
|
||||
<div class="flex justify-end bg-gray-50 px-6 py-4">
|
||||
<div class="text-base font-bold text-gray-900">
|
||||
Total Geral: R$ {totalGeral.toFixed(2).replace('.', ',')}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Histórico -->
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user