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

667 lines
19 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script lang="ts">
import { api } from '@sgse-app/backend/convex/_generated/api';
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
import { useConvexClient, useQuery } from 'convex-svelte';
import {
AlertTriangle,
CheckCircle,
Clock,
Edit,
Plus,
Save,
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();
// Reactive queries
const pedidoQuery = useQuery(api.pedidos.get, { id: pedidoId });
const itemsQuery = useQuery(api.pedidos.getItems, { pedidoId });
const historyQuery = useQuery(api.pedidos.getHistory, { pedidoId });
const objetosQuery = useQuery(api.objetos.list, {});
const acoesQuery = useQuery(api.acoes.list, {});
// Derived state
let pedido = $derived(pedidoQuery.data);
let items = $derived(itemsQuery.data || []);
let history = $derived(historyQuery.data || []);
let objetos = $derived(objetosQuery.data || []);
let acoes = $derived(acoesQuery.data || []);
// Group items by user
let 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 Object.values(groups);
});
let loading = $derived(
pedidoQuery.isLoading ||
itemsQuery.isLoading ||
historyQuery.isLoading ||
objetosQuery.isLoading ||
acoesQuery.isLoading
);
let error = $derived(
pedidoQuery.error?.message ||
itemsQuery.error?.message ||
historyQuery.error?.message ||
objetosQuery.error?.message ||
acoesQuery.error?.message ||
null
);
// Add Item State
let showAddItem = $state(false);
let newItem = $state({
objetoId: '' as string,
valorEstimado: '',
quantidade: 1,
modalidade: 'consumo' as 'dispensa' | 'inexgibilidade' | 'adesao' | 'consumo',
acaoId: '' as string
});
let addingItem = $state(false);
// Edit SEI State
let editingSei = $state(false);
let seiValue = $state('');
let updatingSei = $state(false);
async function handleAddItem() {
if (!newItem.objetoId || !newItem.valorEstimado) return;
addingItem = true;
try {
await client.mutation(api.pedidos.addItem, {
pedidoId,
objetoId: newItem.objetoId as Id<'objetos'>,
valorEstimado: newItem.valorEstimado,
quantidade: newItem.quantidade,
modalidade: newItem.modalidade,
acaoId: newItem.acaoId ? (newItem.acaoId as Id<'acoes'>) : undefined
});
newItem = {
objetoId: '',
valorEstimado: '',
quantidade: 1,
modalidade: 'consumo',
acaoId: ''
};
showAddItem = false;
} catch (e) {
alert('Erro ao adicionar item: ' + (e as Error).message);
} finally {
addingItem = false;
}
}
async function handleUpdateQuantity(itemId: Id<'objetoItems'>, novaQuantidade: number) {
if (novaQuantidade < 1) {
alert('Quantidade deve ser pelo menos 1.');
return;
}
try {
await client.mutation(api.pedidos.updateItemQuantity, {
itemId,
novaQuantidade
});
} catch (e) {
alert('Erro ao atualizar quantidade: ' + (e as Error).message);
}
}
async function handleRemoveItem(itemId: Id<'objetoItems'>) {
if (!confirm('Remover este item?')) return;
try {
await client.mutation(api.pedidos.removeItem, { itemId });
} catch (e) {
alert('Erro ao remover item: ' + (e as Error).message);
}
}
async function updateStatus(
novoStatus:
| 'cancelado'
| 'concluido'
| 'em_rascunho'
| 'aguardando_aceite'
| 'em_analise'
| 'precisa_ajustes'
) {
if (!confirm(`Confirmar alteração de status para: ${novoStatus}?`)) return;
try {
await client.mutation(api.pedidos.updateStatus, {
pedidoId,
novoStatus
});
} catch (e) {
alert('Erro ao atualizar status: ' + (e as Error).message);
}
}
function getObjetoName(id: string) {
return objetos.find((o) => o._id === id)?.nome || 'Objeto desconhecido';
}
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 = '';
}
}
function parseMoneyToNumber(value: string): number {
const cleanValue = value.replace(/[^0-9,]/g, '').replace(',', '.');
return parseFloat(cleanValue) || 0;
}
function calculateItemTotal(valorEstimado: string, quantidade: number): number {
const unitValue = parseMoneyToNumber(valorEstimado);
return unitValue * quantidade;
}
let totalGeral = $derived(
items.reduce((sum, item) => sum + calculateItemTotal(item.valorEstimado, item.quantidade), 0)
);
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;
}
}
async function handleUpdateSei() {
if (!seiValue.trim()) {
alert('O número SEI não pode estar vazio.');
return;
}
updatingSei = true;
try {
await client.mutation(api.pedidos.updateSeiNumber, {
pedidoId,
numeroSei: seiValue.trim()
});
editingSei = false;
} catch (e) {
alert('Erro ao atualizar número SEI: ' + (e as Error).message);
} finally {
updatingSei = false;
}
}
function startEditingSei() {
seiValue = pedido?.numeroSei || '';
editingSei = true;
}
function cancelEditingSei() {
editingSei = false;
seiValue = '';
}
function getStatusColor(status: string) {
switch (status) {
case 'em_rascunho':
return 'bg-gray-100 text-gray-800';
case 'aguardando_aceite':
return 'bg-yellow-100 text-yellow-800';
case 'em_analise':
return 'bg-blue-100 text-blue-800';
case 'precisa_ajustes':
return 'bg-orange-100 text-orange-800';
case 'concluido':
return 'bg-green-100 text-green-800';
case 'cancelado':
return 'bg-red-100 text-red-800';
default:
return 'bg-gray-100 text-gray-800';
}
}
function getHistoryIcon(acao: string) {
switch (acao) {
case 'criacao_pedido':
return '📝';
case 'adicao_item':
return '';
case 'remocao_item':
return '🗑️';
case 'alteracao_quantidade':
return '🔢';
case 'alteracao_status':
return '🔄';
case 'atualizacao_sei':
return '📋';
default:
return '•';
}
}
function formatHistoryEntry(entry: {
acao: string;
detalhes: string | undefined;
usuarioNome: string;
}): string {
try {
const detalhes = entry.detalhes ? JSON.parse(entry.detalhes) : {};
switch (entry.acao) {
case 'criacao_pedido':
return `${entry.usuarioNome} criou o pedido ${detalhes.numeroSei || ''}`;
case 'adicao_item': {
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 ${nomeObjeto} (${detalhes.valor})`;
}
case 'remocao_item': {
const objeto = objetos.find((o) => o._id === detalhes.objetoId);
const nomeObjeto = objeto?.nome || 'Objeto desconhecido';
return `${entry.usuarioNome} removeu ${nomeObjeto}`;
}
case 'alteracao_quantidade': {
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:
return `${entry.usuarioNome} realizou: ${entry.acao}`;
}
} catch {
return `${entry.usuarioNome} - ${entry.acao}`;
}
}
</script>
<div class="container mx-auto p-6">
{#if loading}
<p>Carregando...</p>
{:else if error}
<p class="text-red-600">{error}</p>
{:else if pedido}
<div class="mb-6 flex items-start justify-between">
<div>
<h1 class="flex items-center gap-3 text-2xl font-bold">
{#if editingSei}
<div class="flex items-center gap-2">
<input
type="text"
bind:value={seiValue}
class="rounded border px-2 py-1 text-sm"
placeholder="Número SEI"
disabled={updatingSei}
/>
<button
onclick={handleUpdateSei}
disabled={updatingSei}
class="rounded bg-green-600 p-1 text-white hover:bg-green-700 disabled:opacity-50"
title="Salvar"
>
<Save size={16} />
</button>
<button
onclick={cancelEditingSei}
disabled={updatingSei}
class="rounded bg-gray-400 p-1 text-white hover:bg-gray-500 disabled:opacity-50"
title="Cancelar"
>
<X size={16} />
</button>
</div>
{:else}
<div class="flex items-center gap-2">
<span>Pedido {pedido.numeroSei || 'sem número SEI'}</span>
{#if pedido.status === 'em_rascunho' || pedido.status === 'precisa_ajustes'}
<button
onclick={startEditingSei}
class="rounded bg-blue-100 p-1 text-blue-600 hover:bg-blue-200"
title="Editar número SEI"
>
<Edit size={16} />
</button>
{/if}
</div>
{/if}
<span class="rounded-full px-3 py-1 text-sm font-medium {getStatusColor(pedido.status)}">
{formatStatus(pedido.status)}
</span>
</h1>
{#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.
</p>
{/if}
</div>
<div class="flex gap-2">
{#if pedido.status === 'em_rascunho' || pedido.status === 'precisa_ajustes'}
<button
onclick={() => updateStatus('aguardando_aceite')}
class="flex items-center gap-2 rounded bg-blue-600 px-4 py-2 text-white hover:bg-blue-700"
>
<Send size={18} /> Enviar para Aceite
</button>
{/if}
{#if pedido.status === 'aguardando_aceite'}
<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"
>
<Clock size={18} /> Iniciar Análise
</button>
{/if}
{#if pedido.status === 'em_analise'}
<button
onclick={() => updateStatus('concluido')}
class="flex items-center gap-2 rounded bg-green-600 px-4 py-2 text-white hover:bg-green-700"
>
<CheckCircle size={18} /> Concluir
</button>
<button
onclick={() => updateStatus('precisa_ajustes')}
class="flex items-center gap-2 rounded bg-orange-500 px-4 py-2 text-white hover:bg-orange-600"
>
<AlertTriangle size={18} /> Solicitar Ajustes
</button>
{/if}
{#if pedido.status !== 'cancelado' && pedido.status !== 'concluido'}
<button
onclick={() => updateStatus('cancelado')}
class="flex items-center gap-2 rounded bg-red-100 px-4 py-2 text-red-700 hover:bg-red-200"
>
<XCircle size={18} /> Cancelar
</button>
{/if}
</div>
</div>
<!-- Items Section -->
<div class="mb-6 overflow-hidden rounded-lg bg-white shadow-md">
<div class="flex items-center justify-between border-b border-gray-200 px-6 py-4">
<h2 class="text-lg font-semibold">Itens do Pedido</h2>
{#if pedido.status === 'em_rascunho' || pedido.status === 'precisa_ajustes'}
<button
onclick={() => (showAddItem = true)}
class="flex items-center gap-1 text-sm font-medium text-blue-600 hover:text-blue-800"
>
<Plus size={16} /> Adicionar Item
</button>
{/if}
</div>
{#if showAddItem}
<div class="border-b border-gray-200 bg-gray-50 px-6 py-4">
<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="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 objetos as o (o._id)}
<option value={o._id}>{o.nome} ({o.unidade})</option>
{/each}
</select>
</div>
<div>
<label for="quantidade-input" class="mb-1 block text-xs font-medium text-gray-500"
>Quantidade</label
>
<input
id="quantidade-input"
type="number"
min="1"
bind:value={newItem.quantidade}
class="w-full rounded-md border-gray-300 text-sm shadow-sm"
placeholder="1"
/>
</div>
<div>
<label for="valor-input" class="mb-1 block text-xs font-medium text-gray-500"
>Valor Estimado</label
>
<input
id="valor-input"
type="text"
bind:value={newItem.valorEstimado}
oninput={(e) => (newItem.valorEstimado = maskCurrencyBRL(e.currentTarget.value))}
class="w-full rounded-md border-gray-300 text-sm shadow-sm"
placeholder="R$ 0,00"
/>
</div>
<div>
<label for="modalidade-select" class="mb-1 block text-xs font-medium text-gray-500"
>Modalidade</label
>
<select
id="modalidade-select"
bind:value={newItem.modalidade}
class="w-full rounded-md border-gray-300 text-sm shadow-sm"
>
<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}
<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 -->
<div class="rounded-lg bg-white p-6 shadow">
<h2 class="mb-4 text-lg font-semibold text-gray-900">Histórico</h2>
<div class="space-y-3">
{#if history.length === 0}
<p class="text-sm text-gray-500">Nenhum histórico disponível.</p>
{:else}
{#each history as entry (entry._id)}
<div
class="flex items-start gap-3 rounded-lg border border-gray-200 p-3 hover:bg-gray-50"
>
<div class="flex-shrink-0 text-2xl">
{getHistoryIcon(entry.acao)}
</div>
<div class="min-w-0 flex-1">
<p class="text-sm text-gray-900">
{formatHistoryEntry(entry)}
</p>
<p class="mt-1 text-xs text-gray-500">
{new Date(entry.data).toLocaleString('pt-BR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
})}
</p>
</div>
</div>
{/each}
{/if}
</div>
</div>
{/if}
</div>