Merge remote-tracking branch 'origin/master' into ajustes_gerais

This commit is contained in:
2025-12-02 14:03:30 -03:00
46 changed files with 5178 additions and 2605 deletions

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { ShoppingCart, ShoppingBag, Plus } from "lucide-svelte";
import { ShoppingCart, Package, FileText } from "lucide-svelte";
import { resolve } from "$app/paths";
import ProtectedRoute from "$lib/components/ProtectedRoute.svelte";
</script>
@@ -25,22 +25,40 @@
</div>
</div>
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<div class="flex flex-col items-center justify-center py-12 text-center">
<div class="mb-6">
<ShoppingBag class="h-24 w-24 text-base-content/20" strokeWidth={1.5} />
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<a
href={resolve('/compras/produtos')}
class="card bg-base-100 shadow-md hover:shadow-lg transition-shadow border border-base-200 hover:border-primary"
>
<div class="card-body">
<div class="flex items-center gap-3 mb-2">
<div class="p-2 bg-primary/10 rounded-lg">
<Package class="h-6 w-6 text-primary" strokeWidth={2} />
</div>
<h4 class="font-semibold">Produtos</h4>
</div>
<h2 class="text-2xl font-bold mb-2">Módulo em Desenvolvimento</h2>
<p class="text-base-content/70 max-w-md mb-6">
O módulo de Compras está sendo desenvolvido e em breve estará disponível com funcionalidades completas para gestão de compras e aquisições.
<p class="text-sm text-base-content/70">
Cadastro, listagem e edição de produtos e serviços disponíveis para compra.
</p>
<div class="badge badge-warning badge-lg gap-2">
<Plus class="h-4 w-4" strokeWidth={2} />
Em Desenvolvimento
</div>
</div>
</div>
</a>
<a
href={resolve('/pedidos')}
class="card bg-base-100 shadow-md hover:shadow-lg transition-shadow border border-base-200 hover:border-secondary"
>
<div class="card-body">
<div class="flex items-center gap-3 mb-2">
<div class="p-2 bg-secondary/10 rounded-lg">
<FileText class="h-6 w-6 text-secondary" strokeWidth={2} />
</div>
<h4 class="font-semibold">Pedidos</h4>
</div>
<p class="text-sm text-base-content/70">
Gerencie pedidos de compra, acompanhe status e histórico de aquisições.
</p>
</div>
</a>
</div>
</main>
</ProtectedRoute>

View File

@@ -0,0 +1,157 @@
<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 { 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':
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 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 formatDate(timestamp: number) {
return new Date(timestamp).toLocaleString('pt-BR');
}
</script>
<div class="container mx-auto p-6">
<div class="mb-6 flex items-center justify-between">
<h1 class="text-2xl font-bold">Pedidos</h1>
<a
href={resolve('/pedidos/novo')}
class="flex items-center gap-2 rounded-md bg-blue-600 px-4 py-2 text-white hover:bg-blue-700"
>
<Plus size={20} />
Novo Pedido
</a>
</div>
{#if loading}
<p>Carregando...</p>
{:else if error}
<p class="text-red-600">{error}</p>
{:else}
<div class="overflow-hidden rounded-lg bg-white shadow-md">
<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"
>Número SEI</th
>
<th
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
>
<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 pedidos as pedido (pedido._id)}
<tr class="hover:bg-gray-50">
<td class="px-6 py-4 font-medium whitespace-nowrap">
{#if pedido.numeroSei}
{pedido.numeroSei}
{:else}
<span class="text-amber-600">Sem número SEI</span>
{/if}
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span
class="inline-flex rounded-full px-2 py-1 text-xs font-semibold {getStatusColor(
pedido.status
)}"
>
{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>
<td class="px-6 py-4 text-right text-sm font-medium whitespace-nowrap">
<a
href={resolve(`/pedidos/${pedido._id}`)}
class="inline-flex items-center gap-1 text-indigo-600 hover:text-indigo-900"
>
<Eye size={18} />
Visualizar
</a>
</td>
</tr>
{/each}
{#if pedidos.length === 0}
<tr>
<td colspan="5" class="px-6 py-4 text-center text-gray-500"
>Nenhum pedido cadastrado.</td
>
</tr>
{/if}
</tbody>
</table>
</div>
{/if}
</div>

View File

@@ -0,0 +1,609 @@
<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 {
Plus,
Trash2,
Send,
CheckCircle,
AlertTriangle,
XCircle,
Clock,
Edit,
Save,
X
} from 'lucide-svelte';
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 produtosQuery = useQuery(api.produtos.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 acao = $derived.by(() => {
if (pedido && pedido.acaoId && acoesQuery.data) {
return acoesQuery.data.find((a) => a._id === pedido.acaoId);
}
return null;
});
const loading = $derived(
pedidoQuery.isLoading ||
itemsQuery.isLoading ||
historyQuery.isLoading ||
produtosQuery.isLoading ||
acoesQuery.isLoading
);
const error = $derived(
pedidoQuery.error?.message ||
itemsQuery.error?.message ||
historyQuery.error?.message ||
produtosQuery.error?.message ||
acoesQuery.error?.message ||
null
);
// Add Item State
let showAddItem = $state(false);
let newItem = $state({
produtoId: '' as string,
valorEstimado: '',
quantidade: 1
});
let addingItem = $state(false);
// Edit SEI State
let editingSei = $state(false);
let seiValue = $state('');
let updatingSei = $state(false);
async function handleAddItem() {
if (!newItem.produtoId || !newItem.valorEstimado) return;
addingItem = true;
try {
await client.mutation(api.pedidos.addItem, {
pedidoId,
produtoId: newItem.produtoId as Id<'produtos'>,
valorEstimado: newItem.valorEstimado,
quantidade: newItem.quantidade
});
newItem = { produtoId: '', valorEstimado: '', quantidade: 1 };
showAddItem = false;
} catch (e) {
alert('Erro ao adicionar item: ' + (e as Error).message);
} finally {
addingItem = false;
}
}
async function handleUpdateQuantity(itemId: Id<'pedidoItems'>, 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<'pedidoItems'>) {
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 getProductName(id: string) {
return produtos.find((p) => p._id === id)?.nome || 'Produto desconhecido';
}
function handleProductChange(id: string) {
newItem.produtoId = id;
const produto = produtos.find((p) => p._id === id);
if (produto) {
newItem.valorEstimado = maskCurrencyBRL(produto.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;
}
const 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 produto = produtos.find((p) => p._id === detalhes.produtoId);
const nomeProduto = produto?.nome || 'Produto desconhecido';
const quantidade = detalhes.quantidade || 1;
return `${entry.usuarioNome} adicionou ${quantidade}x ${nomeProduto} (${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}`;
}
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}`;
}
case 'alteracao_status':
return `${entry.usuarioNome} alterou o status para "${formatStatus(detalhes.novoStatus)}"`;
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 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.
</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'}
<!-- 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"
>
<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="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
>
<select
id="produto-select"
bind:value={newItem.produtoId}
onchange={(e) => handleProductChange(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}
</select>
</div>
<div class="w-32">
<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 class="w-40">
<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 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"
>
Adicionar
</button>
<button
onclick={() => (showAddItem = false)}
class="rounded bg-gray-200 px-3 py-2 text-sm text-gray-700 hover:bg-gray-300"
>
Cancelar
</button>
</div>
</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>
<!-- 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>

View 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>

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { Trophy, Award, Building2, Workflow } from "lucide-svelte";
import { Trophy, Award, Building2, Workflow, Target } from "lucide-svelte";
import { resolve } from "$app/paths";
import ProtectedRoute from "$lib/components/ProtectedRoute.svelte";
</script>
@@ -43,6 +43,23 @@
</div>
</a>
<a
href={resolve('/programas-esportivos/acoes')}
class="card bg-base-100 shadow-md hover:shadow-lg transition-shadow border border-base-200 hover:border-accent"
>
<div class="card-body">
<div class="flex items-center gap-3 mb-2">
<div class="p-2 bg-accent/10 rounded-lg">
<Target class="h-6 w-6 text-accent" strokeWidth={2} />
</div>
<h4 class="font-semibold">Ações</h4>
</div>
<p class="text-sm text-base-content/70">
Gerencie ações, projetos e leis relacionadas aos programas esportivos.
</p>
</div>
</a>
<div class="card bg-base-100 shadow-md opacity-70">
<div class="card-body">
<div class="flex items-center gap-3 mb-2">

View File

@@ -0,0 +1,210 @@
<script lang="ts">
import { useQuery, useConvexClient } from 'convex-svelte';
import { api } from '@sgse-app/backend/convex/_generated/api';
import type { Id, Doc } from '@sgse-app/backend/convex/_generated/dataModel';
import { Plus, Pencil, Trash2, X } from 'lucide-svelte';
const client = useConvexClient();
// Reactive query
const acoesQuery = useQuery(api.acoes.list, {});
const acoes = $derived(acoesQuery.data || []);
const loading = $derived(acoesQuery.isLoading);
const error = $derived(acoesQuery.error?.message || null);
// Modal state
let showModal = $state(false);
let editingId: string | null = $state(null);
let formData = $state({
nome: '',
tipo: 'projeto' as 'projeto' | 'lei'
});
let saving = $state(false);
function openModal(acao?: Doc<'acoes'>) {
if (acao) {
editingId = acao._id;
formData = {
nome: acao.nome,
tipo: acao.tipo
};
} else {
editingId = null;
formData = {
nome: '',
tipo: 'projeto'
};
}
showModal = true;
}
function closeModal() {
showModal = false;
editingId = null;
}
async function handleSubmit(e: Event) {
e.preventDefault();
saving = true;
try {
if (editingId) {
await client.mutation(api.acoes.update, {
id: editingId as Id<'acoes'>,
...formData
});
} else {
await client.mutation(api.acoes.create, formData);
}
closeModal();
} catch (e) {
alert('Erro ao salvar: ' + (e as Error).message);
} finally {
saving = false;
}
}
async function handleDelete(id: Id<'acoes'>) {
if (!confirm('Tem certeza que deseja excluir esta ação?')) return;
try {
await client.mutation(api.acoes.remove, { id });
} catch (e) {
alert('Erro ao excluir: ' + (e as Error).message);
}
}
</script>
<div class="container mx-auto p-6">
<div class="mb-6 flex items-center justify-between">
<h1 class="text-2xl font-bold">Ações</h1>
<button
onclick={() => openModal()}
class="flex items-center gap-2 rounded-md bg-blue-600 px-4 py-2 text-white hover:bg-blue-700"
>
<Plus size={20} />
Nova Ação
</button>
</div>
{#if loading}
<p>Carregando...</p>
{:else if error}
<p class="text-red-600">{error}</p>
{:else}
<div class="overflow-hidden rounded-lg bg-white shadow-md">
<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"
>Nome</th
>
<th
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
>Tipo</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 acoes as acao (acao._id)}
<tr>
<td class="px-6 py-4 whitespace-nowrap">{acao.nome}</td>
<td class="px-6 py-4 whitespace-nowrap">
<span
class="inline-flex rounded-full px-2 text-xs leading-5 font-semibold
{acao.tipo === 'projeto'
? 'bg-purple-100 text-purple-800'
: 'bg-orange-100 text-orange-800'}"
>
{acao.tipo === 'projeto' ? 'Projeto' : 'Lei'}
</span>
</td>
<td class="px-6 py-4 text-right text-sm font-medium whitespace-nowrap">
<button
onclick={() => openModal(acao)}
class="mr-4 text-indigo-600 hover:text-indigo-900"
>
<Pencil size={18} />
</button>
<button
onclick={() => handleDelete(acao._id)}
class="text-red-600 hover:text-red-900"
>
<Trash2 size={18} />
</button>
</td>
</tr>
{/each}
{#if acoes.length === 0}
<tr>
<td colspan="3" class="px-6 py-4 text-center text-gray-500"
>Nenhuma ação cadastrada.</td
>
</tr>
{/if}
</tbody>
</table>
</div>
{/if}
{#if showModal}
<div
class="bg-opacity-50 fixed inset-0 z-50 flex h-full w-full items-center justify-center overflow-y-auto bg-gray-600"
>
<div class="relative w-full max-w-md rounded-lg bg-white p-8 shadow-xl">
<button
onclick={closeModal}
class="absolute top-4 right-4 text-gray-400 hover:text-gray-600"
>
<X size={24} />
</button>
<h2 class="mb-6 text-xl font-bold">{editingId ? 'Editar' : 'Novo'} Ação</h2>
<form onsubmit={handleSubmit}>
<div class="mb-4">
<label class="mb-2 block text-sm font-bold text-gray-700" for="nome"> Nome </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="nome"
type="text"
bind:value={formData.nome}
required
/>
</div>
<div class="mb-6">
<label class="mb-2 block text-sm font-bold text-gray-700" for="tipo"> Tipo </label>
<select
class="focus:shadow-outline w-full rounded border px-3 py-2 leading-tight text-gray-700 shadow focus:outline-none"
id="tipo"
bind:value={formData.tipo}
>
<option value="projeto">Projeto</option>
<option value="lei">Lei</option>
</select>
</div>
<div class="flex items-center justify-end">
<button
type="button"
onclick={closeModal}
class="mr-2 rounded bg-gray-300 px-4 py-2 font-bold text-gray-800 hover:bg-gray-400"
>
Cancelar
</button>
<button
type="submit"
disabled={saving}
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"
>
{saving ? 'Salvando...' : 'Salvar'}
</button>
</div>
</form>
</div>
</div>
{/if}
</div>

View File

@@ -380,6 +380,15 @@
palette: 'accent',
icon: 'building'
},
{
title: 'Configurações Gerais',
description:
'Configure opções gerais do sistema, incluindo setor de compras e outras configurações administrativas.',
ctaLabel: 'Configurar',
href: '/(dashboard)/ti/configuracoes',
palette: 'secondary',
icon: 'control'
},
{
title: 'Documentação',
description:

View File

@@ -0,0 +1,94 @@
<script lang="ts">
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';
const client = useConvexClient();
// Reactive queries
const setoresQuery = useQuery(api.setores.list, {});
const configQuery = useQuery(api.config.getComprasSetor, {});
const setores = $derived(setoresQuery.data || []);
const config = $derived(configQuery.data);
const loading = $derived(setoresQuery.isLoading || configQuery.isLoading);
// Initialize selected setor from config - using boxed $state to avoid reactivity warning
let selectedSetorId = $state('');
// Update selectedSetorId when config changes
$effect(() => {
if (config?.comprasSetorId && !selectedSetorId) {
selectedSetorId = config.comprasSetorId;
}
});
let saving = $state(false);
let error: string | null = $state(null);
let success: string | null = $state(null);
async function saveConfig() {
saving = true;
error = null;
success = null;
try {
await client.mutation(api.config.updateComprasSetor, {
setorId: selectedSetorId as Id<'setores'>
});
success = 'Configuração salva com sucesso!';
} catch (e) {
error = (e as Error).message;
} finally {
saving = false;
}
}
</script>
<div class="container mx-auto p-6">
<h1 class="mb-6 text-2xl font-bold">Configurações Gerais</h1>
{#if loading}
<p>Carregando...</p>
{:else}
<div class="max-w-md rounded-lg bg-white p-6 shadow-md">
<h2 class="mb-4 text-xl font-semibold">Setor de Compras</h2>
<p class="mb-4 text-sm text-gray-600">
Selecione o setor responsável por receber e aprovar pedidos de compra.
</p>
{#if error}
<div class="mb-4 rounded border border-red-400 bg-red-100 px-4 py-3 text-red-700">
{error}
</div>
{/if}
{#if success}
<div class="mb-4 rounded border border-green-400 bg-green-100 px-4 py-3 text-green-700">
{success}
</div>
{/if}
<div class="mb-4">
<label for="setor" class="mb-1 block text-sm font-medium text-gray-700"> Setor </label>
<select
id="setor"
bind:value={selectedSetorId}
class="w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
>
<option value="">Selecione um setor...</option>
{#each setores as setor (setor._id)}
<option value={setor._id}>{setor.nome} ({setor.sigla})</option>
{/each}
</select>
</div>
<button
onclick={saveConfig}
disabled={saving || !selectedSetorId}
class="w-full rounded-md bg-blue-600 px-4 py-2 text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{saving ? 'Salvando...' : 'Salvar Configuração'}
</button>
</div>
{/if}
</div>