feat: Add 'atas' (minutes/records) management feature, and implement various improvements across UI, backend logic, and authentication.

This commit is contained in:
2025-12-02 16:37:48 -03:00
parent 05e7f1181d
commit 4bd9e21748
265 changed files with 29156 additions and 26460 deletions

View File

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