feat: implement filtering and document management features in dashboard components, enhancing user experience with improved query capabilities and UI for managing documents in pedidos and compras

This commit is contained in:
2025-12-15 14:29:30 -03:00
parent f3288b9639
commit a5ad843b3e
7 changed files with 1135 additions and 34 deletions

View File

@@ -8,7 +8,21 @@
const client = useConvexClient();
// Reactive queries
const atasQuery = useQuery(api.atas.list, {});
// Filtros (listagem)
let filtroPeriodoInicio = $state('');
let filtroPeriodoFim = $state('');
let filtroNumero = $state('');
let filtroNumeroSei = $state('');
const atasTotalQuery = useQuery(api.atas.list, {});
let atasTotal = $derived(atasTotalQuery.data || []);
const atasQuery = useQuery(api.atas.list, () => ({
periodoInicio: filtroPeriodoInicio || undefined,
periodoFim: filtroPeriodoFim || undefined,
numero: filtroNumero.trim() || undefined,
numeroSei: filtroNumeroSei.trim() || undefined
}));
let atas = $derived(atasQuery.data || []);
let loadingAtas = $derived(atasQuery.isLoading);
let errorAtas = $derived(atasQuery.error?.message || null);
@@ -188,6 +202,13 @@
attachmentFiles = Array.from(input.files);
}
}
function limparFiltros() {
filtroPeriodoInicio = '';
filtroPeriodoFim = '';
filtroNumero = '';
filtroNumeroSei = '';
}
</script>
<main class="container mx-auto flex max-w-7xl flex-col px-4 py-4">
@@ -220,6 +241,71 @@
</div>
</div>
<div class="card bg-base-100/90 border-base-300 mb-6 border shadow-xl backdrop-blur-sm">
<div class="card-body">
<div class="grid gap-4 sm:grid-cols-[1fr_auto] sm:items-end">
<div class="grid grid-cols-1 gap-4 md:grid-cols-4">
<div class="form-control w-full">
<label class="label" for="filtro_numero">
<span class="label-text font-semibold">Número</span>
</label>
<input
id="filtro_numero"
class="input input-bordered focus:input-primary w-full"
type="text"
placeholder="Ex.: 12/2025"
bind:value={filtroNumero}
/>
</div>
<div class="form-control w-full">
<label class="label" for="filtro_numeroSei">
<span class="label-text font-semibold">Número SEI</span>
</label>
<input
id="filtro_numeroSei"
class="input input-bordered focus:input-primary w-full"
type="text"
placeholder="Ex.: 12345.000000/2025-00"
bind:value={filtroNumeroSei}
/>
</div>
<div class="form-control w-full">
<label class="label" for="filtro_inicio">
<span class="label-text font-semibold">Período (início)</span>
</label>
<input
id="filtro_inicio"
class="input input-bordered focus:input-primary w-full"
type="date"
bind:value={filtroPeriodoInicio}
/>
</div>
<div class="form-control w-full">
<label class="label" for="filtro_fim">
<span class="label-text font-semibold">Período (fim)</span>
</label>
<input
id="filtro_fim"
class="input input-bordered focus:input-primary w-full"
type="date"
bind:value={filtroPeriodoFim}
/>
</div>
</div>
<div class="flex items-center justify-between gap-3 sm:min-w-max sm:justify-end">
<div class="text-base-content/70 text-sm">
{atas.length} de {atasTotal.length}
</div>
<button type="button" class="btn btn-ghost" onclick={limparFiltros}>Limpar</button>
</div>
</div>
</div>
</div>
{#if loadingAtas}
<div class="flex items-center justify-center py-10">
<span class="loading loading-spinner loading-lg"></span>
@@ -258,8 +344,13 @@
<tr>
<td colspan="5" class="py-12 text-center">
<div class="text-base-content/60 flex flex-col items-center gap-2">
<p class="text-lg font-semibold">Nenhuma ata cadastrada</p>
<p class="text-sm">Clique em “Nova Ata” para cadastrar.</p>
{#if atasTotal.length === 0}
<p class="text-lg font-semibold">Nenhuma ata cadastrada</p>
<p class="text-sm">Clique em “Nova Ata” para cadastrar.</p>
{:else}
<p class="text-lg font-semibold">Nenhum resultado encontrado</p>
<p class="text-sm">Ajuste ou limpe os filtros para ver resultados.</p>
{/if}
</div>
</td>
</tr>

View File

@@ -9,7 +9,19 @@
const client = useConvexClient();
// Reactive queries
const objetosQuery = useQuery(api.objetos.list, {});
// Filtros (listagem)
let filtroNome = $state('');
let filtroTipo = $state<'todos' | 'material' | 'servico'>('todos');
let filtroCodigos = $state('');
const objetosTotalQuery = useQuery(api.objetos.list, {});
let objetosTotal = $derived(objetosTotalQuery.data || []);
const objetosQuery = useQuery(api.objetos.list, () => ({
nome: filtroNome.trim() || undefined,
tipo: filtroTipo === 'todos' ? undefined : filtroTipo,
codigos: filtroCodigos.trim() || undefined
}));
let objetos = $derived(objetosQuery.data || []);
let loading = $derived(objetosQuery.isLoading);
let error = $derived(objetosQuery.error?.message || null);
@@ -115,6 +127,12 @@
formData.atas = [...formData.atas, ataId];
}
}
function limparFiltros() {
filtroNome = '';
filtroTipo = 'todos';
filtroCodigos = '';
}
</script>
<main class="container mx-auto flex max-w-7xl flex-col px-4 py-4">
@@ -147,6 +165,62 @@
</div>
</div>
<div class="card bg-base-100/90 border-base-300 mb-6 border shadow-xl backdrop-blur-sm">
<div class="card-body">
<div class="grid gap-4 sm:grid-cols-[1fr_auto] sm:items-end">
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
<div class="form-control w-full">
<label class="label" for="filtro_nome">
<span class="label-text font-semibold">Nome</span>
</label>
<input
id="filtro_nome"
class="input input-bordered focus:input-primary w-full"
type="text"
placeholder="Digite para filtrar..."
bind:value={filtroNome}
/>
</div>
<div class="form-control w-full">
<label class="label" for="filtro_tipo">
<span class="label-text font-semibold">Tipo</span>
</label>
<select
id="filtro_tipo"
class="select select-bordered focus:select-primary w-full"
bind:value={filtroTipo}
>
<option value="todos">Todos</option>
<option value="material">Material</option>
<option value="servico">Serviço</option>
</select>
</div>
<div class="form-control w-full">
<label class="label" for="filtro_codigos">
<span class="label-text font-semibold">Códigos</span>
</label>
<input
id="filtro_codigos"
class="input input-bordered focus:input-primary w-full"
type="text"
placeholder="Efisco / Catmat / Catserv"
bind:value={filtroCodigos}
/>
</div>
</div>
<div class="flex items-center justify-between gap-3 sm:min-w-max sm:justify-end">
<div class="text-base-content/70 text-sm">
{objetos.length} de {objetosTotal.length}
</div>
<button type="button" class="btn btn-ghost" onclick={limparFiltros}>Limpar</button>
</div>
</div>
</div>
</div>
{#if loading}
<div class="flex items-center justify-center py-10">
<span class="loading loading-spinner loading-lg"></span>
@@ -185,8 +259,13 @@
<tr>
<td colspan="5" class="py-12 text-center">
<div class="text-base-content/60 flex flex-col items-center gap-2">
<p class="text-lg font-semibold">Nenhum objeto cadastrado</p>
<p class="text-sm">Clique em “Novo Objeto” para cadastrar.</p>
{#if objetosTotal.length === 0}
<p class="text-lg font-semibold">Nenhum objeto cadastrado</p>
<p class="text-sm">Clique em “Novo Objeto” para cadastrar.</p>
{:else}
<p class="text-lg font-semibold">Nenhum resultado encontrado</p>
<p class="text-sm">Ajuste ou limpe os filtros para ver resultados.</p>
{/if}
</div>
</td>
</tr>

View File

@@ -12,6 +12,7 @@
Edit,
Eye,
Plus,
Upload,
Save,
Send,
Trash2,
@@ -34,6 +35,9 @@
const acoesQuery = $derived.by(() => useQuery(api.acoes.list, {}));
const permissionsQuery = $derived.by(() => useQuery(api.pedidos.getPermissions, { pedidoId }));
const requestsQuery = $derived.by(() => useQuery(api.pedidos.getItemRequests, { pedidoId }));
const pedidoDocumentosQuery = $derived.by(() =>
useQuery(api.pedidos.listPedidoDocumentos, { pedidoId })
);
// Derived state
let pedido = $derived(pedidoQuery.data);
@@ -43,11 +47,19 @@
let acoes = $derived(acoesQuery.data || []);
let permissions = $derived(permissionsQuery.data);
let requests = $derived(requestsQuery.data || []);
let pedidoDocumentos = $derived(pedidoDocumentosQuery.data || []);
let currentFuncionarioId = $derived(permissions?.currentFuncionarioId ?? null);
type Modalidade = 'dispensa' | 'inexgibilidade' | 'adesao' | 'consumo';
function coerceModalidade(value: string): Modalidade {
if (value === 'dispensa' || value === 'inexgibilidade' || value === 'adesao' || value === 'consumo') {
return value;
}
return 'consumo';
}
type EditingItem = {
valorEstimado: string;
modalidade: Modalidade;
@@ -125,7 +137,8 @@
objetosQuery.isLoading ||
acoesQuery.isLoading ||
permissionsQuery.isLoading ||
requestsQuery.isLoading
requestsQuery.isLoading ||
pedidoDocumentosQuery.isLoading
);
let error = $derived(
@@ -137,6 +150,213 @@
null
);
// Documentos do Pedido
let showAddPedidoDocumento = $state(false);
let pedidoDocumentoDescricao = $state('');
let pedidoDocumentoFile = $state<File | null>(null);
let salvandoPedidoDocumento = $state(false);
function formatBytes(bytes: number) {
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB'];
let value = bytes;
let idx = 0;
while (value >= 1024 && idx < units.length - 1) {
value /= 1024;
idx += 1;
}
return `${value.toFixed(idx === 0 ? 0 : 1)} ${units[idx]}`;
}
async function uploadToStorage(uploadUrl: string, file: File): Promise<Id<'_storage'>> {
const result = await fetch(uploadUrl, {
method: 'POST',
headers: { 'Content-Type': file.type },
body: file
});
const json = (await result.json()) as { storageId: Id<'_storage'> };
return json.storageId;
}
async function handleAddPedidoDocumento() {
if (!pedido) return;
if (!pedidoDocumentoFile) {
toast.error('Selecione um arquivo.');
return;
}
if (!pedidoDocumentoDescricao.trim()) {
toast.error('Informe uma descrição.');
return;
}
salvandoPedidoDocumento = true;
try {
const uploadUrl = await client.mutation(api.pedidos.generatePedidoUploadUrl, { pedidoId });
const storageId = await uploadToStorage(uploadUrl, pedidoDocumentoFile);
await client.mutation(api.pedidos.addPedidoDocumento, {
pedidoId,
descricao: pedidoDocumentoDescricao.trim(),
nome: pedidoDocumentoFile.name,
storageId,
tipo: pedidoDocumentoFile.type,
tamanho: pedidoDocumentoFile.size
});
pedidoDocumentoDescricao = '';
pedidoDocumentoFile = null;
showAddPedidoDocumento = false;
toast.success('Documento anexado ao pedido.');
} catch (e) {
toast.error('Erro ao anexar documento: ' + (e as Error).message);
} finally {
salvandoPedidoDocumento = false;
}
}
function handleOpenPedidoDocumento(url: string | null) {
if (!url) return;
window.open(url, '_blank');
}
function handleRemovePedidoDocumento(id: Id<'pedidoDocumentos'>) {
openConfirm(
'Remover documento',
'Tem certeza que deseja remover este documento do pedido?',
async () => {
await client.mutation(api.pedidos.removePedidoDocumento, { id });
toast.success('Documento removido.');
},
{ isDestructive: true, confirmText: 'Remover' }
);
}
// Documentos por Solicitação
let showSolicitacaoDocsModal = $state(false);
let solicitacaoDocsRequestId = $state<Id<'solicitacoesItens'> | null>(null);
let solicitacaoDocsSolicitadoPor = $state<Id<'funcionarios'> | null>(null);
let solicitacaoDocsTipo = $state<string | null>(null);
let solicitacaoDocs = $state<any[]>([]);
let carregandoSolicitacaoDocs = $state(false);
let solicitacaoDocumentoDescricao = $state('');
let solicitacaoDocumentoFile = $state<File | null>(null);
let salvandoSolicitacaoDocumento = $state(false);
const canAddDocsToSelectedRequest = $derived(
showSolicitacaoDocsModal &&
solicitacaoDocsTipo === 'adicao' &&
currentFuncionarioId !== null &&
solicitacaoDocsSolicitadoPor !== null &&
currentFuncionarioId === solicitacaoDocsSolicitadoPor
);
async function openSolicitacaoDocs(req: {
_id: Id<'solicitacoesItens'>;
solicitadoPor: Id<'funcionarios'>;
tipo: string;
}) {
solicitacaoDocsRequestId = req._id;
solicitacaoDocsSolicitadoPor = req.solicitadoPor;
solicitacaoDocsTipo = req.tipo;
solicitacaoDocumentoDescricao = '';
solicitacaoDocumentoFile = null;
showSolicitacaoDocsModal = true;
carregandoSolicitacaoDocs = true;
try {
solicitacaoDocs = await client.query(api.pedidos.listSolicitacaoDocumentos, {
requestId: req._id
});
} catch (e) {
toast.error('Erro ao carregar documentos da solicitação: ' + (e as Error).message);
solicitacaoDocs = [];
} finally {
carregandoSolicitacaoDocs = false;
}
}
function closeSolicitacaoDocs() {
showSolicitacaoDocsModal = false;
solicitacaoDocsRequestId = null;
solicitacaoDocsSolicitadoPor = null;
solicitacaoDocsTipo = null;
solicitacaoDocs = [];
solicitacaoDocumentoDescricao = '';
solicitacaoDocumentoFile = null;
}
function handleOpenSolicitacaoDocumento(url: string | null) {
if (!url) return;
window.open(url, '_blank');
}
async function refreshSolicitacaoDocs() {
if (!solicitacaoDocsRequestId) return;
carregandoSolicitacaoDocs = true;
try {
solicitacaoDocs = await client.query(api.pedidos.listSolicitacaoDocumentos, {
requestId: solicitacaoDocsRequestId
});
} finally {
carregandoSolicitacaoDocs = false;
}
}
async function handleAddSolicitacaoDocumento() {
if (!solicitacaoDocsRequestId) return;
if (!canAddDocsToSelectedRequest) {
toast.error('Apenas quem criou a solicitação pode anexar documentos.');
return;
}
if (!solicitacaoDocumentoFile) {
toast.error('Selecione um arquivo.');
return;
}
if (!solicitacaoDocumentoDescricao.trim()) {
toast.error('Informe uma descrição.');
return;
}
salvandoSolicitacaoDocumento = true;
try {
const uploadUrl = await client.mutation(api.pedidos.generateSolicitacaoUploadUrl, {
requestId: solicitacaoDocsRequestId
});
const storageId = await uploadToStorage(uploadUrl, solicitacaoDocumentoFile);
await client.mutation(api.pedidos.addSolicitacaoDocumento, {
requestId: solicitacaoDocsRequestId,
descricao: solicitacaoDocumentoDescricao.trim(),
nome: solicitacaoDocumentoFile.name,
storageId,
tipo: solicitacaoDocumentoFile.type,
tamanho: solicitacaoDocumentoFile.size
});
solicitacaoDocumentoDescricao = '';
solicitacaoDocumentoFile = null;
toast.success('Documento anexado à solicitação.');
await refreshSolicitacaoDocs();
} catch (e) {
toast.error('Erro ao anexar documento: ' + (e as Error).message);
} finally {
salvandoSolicitacaoDocumento = false;
}
}
function handleRemoveSolicitacaoDocumento(id: Id<'solicitacoesItensDocumentos'>) {
openConfirm(
'Remover documento',
'Tem certeza que deseja remover este documento da solicitação?',
async () => {
await client.mutation(api.pedidos.removeSolicitacaoDocumento, { id });
toast.success('Documento removido.');
await refreshSolicitacaoDocs();
},
{ isDestructive: true, confirmText: 'Remover' }
);
}
// Add Item State
let showAddItem = $state(false);
let newItem = $state({
@@ -1001,6 +1221,142 @@
</div>
</div>
<!-- Documentos do Pedido -->
<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">Documentos do Pedido</h2>
<button
type="button"
onclick={() => (showAddPedidoDocumento = !showAddPedidoDocumento)}
class="flex items-center gap-1 text-sm font-medium text-blue-600 hover:text-blue-800"
>
<Plus size={16} /> Adicionar documento
</button>
</div>
{#if showAddPedidoDocumento}
<div class="border-b border-gray-200 bg-gray-50 px-6 py-4">
<div class="grid grid-cols-1 gap-3 md:grid-cols-3">
<div class="md:col-span-2">
<label class="mb-1 block text-xs font-medium text-gray-500" for="pedido-doc-desc"
>Descrição</label
>
<input
id="pedido-doc-desc"
type="text"
bind:value={pedidoDocumentoDescricao}
class="w-full rounded-md border-gray-300 text-sm shadow-sm"
placeholder="Ex: Cotação, Parecer, Termo de referência..."
disabled={salvandoPedidoDocumento}
/>
</div>
<div>
<label class="mb-1 block text-xs font-medium text-gray-500" for="pedido-doc-file"
>Arquivo</label
>
<input
id="pedido-doc-file"
type="file"
accept=".pdf,.jpg,.jpeg,.png"
class="w-full text-sm"
disabled={salvandoPedidoDocumento}
onchange={(e) => {
const f = e.currentTarget.files?.[0] ?? null;
pedidoDocumentoFile = f;
}}
/>
</div>
</div>
<div class="mt-4 flex justify-end gap-2">
<button
type="button"
onclick={() => {
showAddPedidoDocumento = false;
pedidoDocumentoDescricao = '';
pedidoDocumentoFile = null;
}}
class="rounded bg-gray-200 px-3 py-2 text-sm text-gray-700 hover:bg-gray-300"
disabled={salvandoPedidoDocumento}
>
Cancelar
</button>
<button
type="button"
onclick={handleAddPedidoDocumento}
disabled={salvandoPedidoDocumento}
class="flex items-center gap-2 rounded bg-blue-600 px-3 py-2 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
>
{#if salvandoPedidoDocumento}
<span class="loading loading-spinner loading-xs"></span>
{:else}
<Upload size={16} />
{/if}
Anexar
</button>
</div>
</div>
{/if}
<div class="p-6">
{#if pedidoDocumentos.length === 0}
<p class="text-sm text-gray-500">Nenhum documento anexado ao pedido.</p>
{:else}
<div class="overflow-x-auto">
<table class="w-full text-left text-sm text-gray-600">
<thead class="bg-gray-50 text-xs font-medium text-gray-500 uppercase">
<tr>
<th class="px-4 py-2">Descrição</th>
<th class="px-4 py-2">Arquivo</th>
<th class="px-4 py-2">Tamanho</th>
<th class="px-4 py-2">Enviado por</th>
<th class="px-4 py-2">Data</th>
<th class="px-4 py-2 text-right">Ações</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
{#each pedidoDocumentos as doc (doc._id)}
<tr class="hover:bg-gray-50">
<td class="px-4 py-2">{doc.descricao}</td>
<td class="px-4 py-2">
<span class="font-medium text-gray-900">{doc.nome}</span>
{#if doc.origemSolicitacaoId}
<span class="ml-2 text-xs text-gray-400"> (origem: solicitação)</span>
{/if}
</td>
<td class="px-4 py-2">{formatBytes(doc.tamanho)}</td>
<td class="px-4 py-2">{doc.criadoPorNome ?? 'Desconhecido'}</td>
<td class="px-4 py-2">
{new Date(doc.criadoEm).toLocaleString('pt-BR')}
</td>
<td class="px-4 py-2 text-right">
<button
type="button"
class="mr-2 rounded bg-indigo-100 p-1 text-indigo-700 hover:bg-indigo-200"
title="Ver documento"
onclick={() => handleOpenPedidoDocumento(doc.url)}
disabled={!doc.url}
>
<Eye size={16} />
</button>
<button
type="button"
class="rounded bg-red-100 p-1 text-red-700 hover:bg-red-200"
title="Remover documento"
onclick={() => handleRemovePedidoDocumento(doc._id)}
>
<Trash2 size={16} />
</button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
</div>
<!-- Requests Section -->
{#if requests.length > 0}
<div class="mb-6 overflow-hidden rounded-lg border-l-4 border-yellow-400 bg-white shadow-md">
@@ -1064,24 +1420,53 @@
{/if}
</td>
<td class="px-4 py-2 text-right">
{#if permissions?.canManageRequests}
<div class="flex justify-end gap-2">
{#if req.tipo === 'adicao'}
{@const canAddDoc =
!!currentFuncionarioId && req.solicitadoPor === currentFuncionarioId}
<button
type="button"
onclick={() => openSolicitacaoDocs(req)}
class="rounded bg-blue-100 p-1 text-blue-700 hover:bg-blue-200"
title={canAddDoc
? 'Adicionar documento'
: 'Apenas quem criou a solicitação pode adicionar documentos'}
disabled={!canAddDoc}
>
<Plus size={16} />
</button>
{/if}
<button
onclick={() => handleApproveRequest(req._id)}
class="mr-2 rounded bg-green-100 p-1 text-green-700 hover:bg-green-200"
title="Aprovar"
type="button"
onclick={() => openSolicitacaoDocs(req)}
class="rounded bg-indigo-100 p-1 text-indigo-700 hover:bg-indigo-200"
title="Ver documentos"
>
<CheckCircle size={16} />
<Eye size={16} />
</button>
<button
onclick={() => handleRejectRequest(req._id)}
class="rounded bg-red-100 p-1 text-red-700 hover:bg-red-200"
title="Rejeitar"
>
<XCircle size={16} />
</button>
{:else}
<span class="text-xs text-gray-400">Aguardando Análise</span>
{/if}
{#if permissions?.canManageRequests}
<button
type="button"
onclick={() => handleApproveRequest(req._id)}
class="rounded bg-green-100 p-1 text-green-700 hover:bg-green-200"
title="Aprovar"
>
<CheckCircle size={16} />
</button>
<button
type="button"
onclick={() => handleRejectRequest(req._id)}
class="rounded bg-red-100 p-1 text-red-700 hover:bg-red-200"
title="Rejeitar"
>
<XCircle size={16} />
</button>
{:else}
<span class="ml-2 self-center text-xs text-gray-400">Aguardando Análise</span>
{/if}
</div>
</td>
</tr>
{/each}
@@ -1380,7 +1765,7 @@
setEditingField(
item._id,
'modalidade',
e.currentTarget.value as Modalidade
coerceModalidade(e.currentTarget.value)
);
void persistItemChanges(item);
}}
@@ -1698,6 +2083,147 @@
</div>
{/if}
{#if showSolicitacaoDocsModal && solicitacaoDocsRequestId}
<div
class="fixed inset-0 z-50 flex h-full w-full items-center justify-center overflow-y-auto bg-black/40 p-4"
>
<div class="relative w-full max-w-3xl rounded-lg bg-white p-6 shadow-xl">
<button
type="button"
onclick={closeSolicitacaoDocs}
class="absolute top-4 right-4 text-gray-400 hover:text-gray-600"
aria-label="Fechar"
>
<X size={20} />
</button>
<h3 class="mb-2 text-lg font-semibold text-gray-900">Documentos da solicitação</h3>
<p class="mb-4 text-xs text-gray-500">
Solicitação: {solicitacaoDocsRequestId.slice(-6)} tipo: {solicitacaoDocsTipo}
</p>
{#if canAddDocsToSelectedRequest}
<div class="mb-4 rounded-lg border border-blue-100 bg-blue-50 p-4">
<div class="grid grid-cols-1 gap-3 md:grid-cols-3">
<div class="md:col-span-2">
<label class="mb-1 block text-xs font-medium text-gray-600" for="req-doc-desc"
>Descrição</label
>
<input
id="req-doc-desc"
type="text"
bind:value={solicitacaoDocumentoDescricao}
class="w-full rounded-md border-gray-300 text-sm shadow-sm"
placeholder="Ex: justificativa, anexo do item, planilha..."
disabled={salvandoSolicitacaoDocumento}
/>
</div>
<div>
<label class="mb-1 block text-xs font-medium text-gray-600" for="req-doc-file"
>Arquivo</label
>
<input
id="req-doc-file"
type="file"
accept=".pdf,.jpg,.jpeg,.png"
class="w-full text-sm"
disabled={salvandoSolicitacaoDocumento}
onchange={(e) => {
const f = e.currentTarget.files?.[0] ?? null;
solicitacaoDocumentoFile = f;
}}
/>
</div>
</div>
<div class="mt-3 flex justify-end">
<button
type="button"
onclick={handleAddSolicitacaoDocumento}
disabled={salvandoSolicitacaoDocumento}
class="flex items-center gap-2 rounded bg-blue-600 px-3 py-2 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
>
{#if salvandoSolicitacaoDocumento}
<span class="loading loading-spinner loading-xs"></span>
{:else}
<Upload size={16} />
{/if}
Anexar documento
</button>
</div>
</div>
{/if}
{#if carregandoSolicitacaoDocs}
<p class="text-sm text-gray-500">Carregando documentos...</p>
{:else if solicitacaoDocs.length === 0}
<p class="text-sm text-gray-500">Nenhum documento anexado à solicitação.</p>
{:else}
<div class="overflow-x-auto">
<table class="w-full text-left text-sm text-gray-600">
<thead class="bg-gray-50 text-xs font-medium text-gray-500 uppercase">
<tr>
<th class="px-4 py-2">Descrição</th>
<th class="px-4 py-2">Arquivo</th>
<th class="px-4 py-2">Tamanho</th>
<th class="px-4 py-2">Enviado por</th>
<th class="px-4 py-2">Data</th>
<th class="px-4 py-2 text-right">Ações</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
{#each solicitacaoDocs as doc (doc._id)}
<tr class="hover:bg-gray-50">
<td class="px-4 py-2">{doc.descricao}</td>
<td class="px-4 py-2">
<span class="font-medium text-gray-900">{doc.nome}</span>
</td>
<td class="px-4 py-2">{formatBytes(doc.tamanho)}</td>
<td class="px-4 py-2">{doc.criadoPorNome ?? 'Desconhecido'}</td>
<td class="px-4 py-2">
{new Date(doc.criadoEm).toLocaleString('pt-BR')}
</td>
<td class="px-4 py-2 text-right">
<button
type="button"
class="mr-2 rounded bg-indigo-100 p-1 text-indigo-700 hover:bg-indigo-200"
title="Ver documento"
onclick={() => handleOpenSolicitacaoDocumento(doc.url)}
disabled={!doc.url}
>
<Eye size={16} />
</button>
{#if canAddDocsToSelectedRequest}
<button
type="button"
class="rounded bg-red-100 p-1 text-red-700 hover:bg-red-200"
title="Remover documento"
onclick={() => handleRemoveSolicitacaoDocumento(doc._id)}
>
<Trash2 size={16} />
</button>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
<div class="mt-5 flex justify-end">
<button
type="button"
onclick={closeSolicitacaoDocs}
class="rounded bg-gray-200 px-4 py-2 text-sm text-gray-800 hover:bg-gray-300"
>
Fechar
</button>
</div>
</div>
</div>
{/if}
{#if showRequestAdjustmentsModal}
<div
class="fixed inset-0 z-50 flex h-full w-full items-center justify-center overflow-y-auto bg-black/40"

View File

@@ -5,13 +5,41 @@ import { getCurrentUserFunction } from './auth';
import { internal } from './_generated/api';
export const list = query({
args: {},
handler: async (ctx) => {
args: {
periodoInicio: v.optional(v.string()),
periodoFim: v.optional(v.string()),
numero: v.optional(v.string()),
numeroSei: v.optional(v.string())
},
handler: async (ctx, args) => {
await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, {
recurso: 'atas',
acao: 'listar'
});
return await ctx.db.query('atas').collect();
const numero = args.numero?.trim().toLowerCase();
const numeroSei = args.numeroSei?.trim().toLowerCase();
const periodoInicio = args.periodoInicio || undefined;
const periodoFim = args.periodoFim || undefined;
const atas = await ctx.db.query('atas').collect();
return atas.filter((ata) => {
const numeroOk = !numero || (ata.numero || '').toLowerCase().includes(numero);
const seiOk = !numeroSei || (ata.numeroSei || '').toLowerCase().includes(numeroSei);
// Filtro por intervalo (range): retorna atas cuja vigência intersecta o período informado.
// Considera datas como strings "YYYY-MM-DD" (lexicograficamente comparáveis).
const ataInicio = ata.dataInicio ?? '0000-01-01';
const ataFim = ata.dataFim ?? '9999-12-31';
const periodoOk =
(!periodoInicio && !periodoFim) ||
(periodoInicio && periodoFim && ataInicio <= periodoFim && ataFim >= periodoInicio) ||
(periodoInicio && !periodoFim && ataFim >= periodoInicio) ||
(!periodoInicio && periodoFim && ataInicio <= periodoFim);
return numeroOk && seiOk && periodoOk;
});
}
});

View File

@@ -3,9 +3,34 @@ import { mutation, query } from './_generated/server';
import { getCurrentUserFunction } from './auth';
export const list = query({
args: {},
handler: async (ctx) => {
return await ctx.db.query('objetos').collect();
args: {
nome: v.optional(v.string()),
tipo: v.optional(v.union(v.literal('material'), v.literal('servico'))),
codigos: v.optional(v.string())
},
handler: async (ctx, args) => {
const nome = args.nome?.trim();
const codigos = args.codigos?.trim().toLowerCase();
const base =
nome && nome.length > 0
? await ctx.db
.query('objetos')
.withSearchIndex('search_nome', (q) => q.search('nome', nome))
.collect()
: await ctx.db.query('objetos').collect();
return base.filter((objeto) => {
const tipoOk = !args.tipo || objeto.tipo === args.tipo;
const codigosOk =
!codigos ||
(objeto.codigoEfisco || '').toLowerCase().includes(codigos) ||
(objeto.codigoCatmat || '').toLowerCase().includes(codigos) ||
(objeto.codigoCatserv || '').toLowerCase().includes(codigos);
return tipoOk && codigosOk;
});
}
});

View File

@@ -47,6 +47,61 @@ async function ensurePedidoModalidadeAtaConsistency(
}
}
async function isFuncionarioInComprasSector(
ctx: QueryCtx | MutationCtx,
funcionarioId: Id<'funcionarios'>
) {
const config = await ctx.db.query('config').first();
if (!config || !config.comprasSetorId) return false;
const isInSector = await ctx.db
.query('funcionarioSetores')
.withIndex('by_funcionarioId', (q) => q.eq('funcionarioId', funcionarioId))
.filter((q) => q.eq(q.field('setorId'), config.comprasSetorId))
.first();
return !!isInSector;
}
async function isUsuarioEnvolvidoNoPedido(
ctx: QueryCtx | MutationCtx,
pedidoId: Id<'pedidos'>,
user: Awaited<ReturnType<typeof getUsuarioAutenticado>>
) {
const pedido = await ctx.db.get(pedidoId);
if (!pedido) return false;
// Criador do pedido (por usuarioId)
if (pedido.criadoPor === user._id) return true;
// Envolvimento por itens (requer funcionarioId)
if (!user.funcionarioId) return false;
const hasItem = await ctx.db
.query('objetoItems')
.withIndex('by_pedidoId', (q) => q.eq('pedidoId', pedidoId))
.filter((q) => q.eq(q.field('adicionadoPor'), user.funcionarioId))
.first();
return !!hasItem;
}
async function assertPodeGerenciarDocumentosDoPedido(
ctx: QueryCtx | MutationCtx,
pedidoId: Id<'pedidos'>,
user: Awaited<ReturnType<typeof getUsuarioAutenticado>>
) {
const isEnvolvido = await isUsuarioEnvolvidoNoPedido(ctx, pedidoId, user);
if (isEnvolvido) return;
if (user.funcionarioId) {
const isCompras = await isFuncionarioInComprasSector(ctx, user.funcionarioId);
if (isCompras) return;
}
throw new Error('Acesso negado.');
}
// ========== QUERIES ==========
export const list = query({
@@ -1652,6 +1707,8 @@ export const approveItemRequest = mutation({
returns: v.null(),
handler: async (ctx, args) => {
const user = await getUsuarioAutenticado(ctx);
const funcionarioId = user.funcionarioId;
if (!funcionarioId) throw new Error('Usuário sem funcionário vinculado.');
const request = await ctx.db.get(args.requestId);
if (!request) throw new Error('Solicitação não encontrada.');
@@ -1666,12 +1723,18 @@ export const approveItemRequest = mutation({
const isInSector = await ctx.db
.query('funcionarioSetores')
.withIndex('by_funcionarioId', (q) => q.eq('funcionarioId', user.funcionarioId!))
.withIndex('by_funcionarioId', (q) => q.eq('funcionarioId', funcionarioId))
.filter((q) => q.eq(q.field('setorId'), config.comprasSetorId))
.first();
if (!isInSector) throw new Error('Acesso negado.');
// Documentos anexados à solicitação (se houver) devem migrar para o pedido ao aprovar.
const solicitacaoDocs = await ctx.db
.query('solicitacoesItensDocumentos')
.withIndex('by_requestId', (q) => q.eq('requestId', request._id))
.collect();
// Apply the change
const data = JSON.parse(request.dados);
@@ -1790,15 +1853,37 @@ export const approveItemRequest = mutation({
// I'll delete it to keep table clean as requested.
await ctx.db.delete(request._id);
// Migrar docs: cria em pedidoDocumentos e remove os registros da solicitacao
const documentosMigrados: Array<{ nome: string; descricao: string }> = [];
if (solicitacaoDocs.length > 0) {
for (const doc of solicitacaoDocs) {
await ctx.db.insert('pedidoDocumentos', {
pedidoId: request.pedidoId,
descricao: doc.descricao,
nome: doc.nome,
storageId: doc.storageId,
tipo: doc.tipo,
tamanho: doc.tamanho,
criadoPor: doc.criadoPor,
criadoEm: doc.criadoEm,
origemSolicitacaoId: request._id
});
documentosMigrados.push({ nome: doc.nome, descricao: doc.descricao });
await ctx.db.delete(doc._id);
}
}
// History
await ctx.db.insert('historicoPedidos', {
pedidoId: request.pedidoId,
usuarioId: user._id,
acao: 'aprovacao_solicitacao',
detalhes: JSON.stringify({
requestId: request._id,
tipo: request.tipo,
solicitadoPor: request.solicitadoPor,
dados: data
dados: data,
documentosMigrados
}),
data: Date.now()
});
@@ -1814,6 +1899,8 @@ export const rejectItemRequest = mutation({
returns: v.null(),
handler: async (ctx, args) => {
const user = await getUsuarioAutenticado(ctx);
const funcionarioId = user.funcionarioId;
if (!funcionarioId) throw new Error('Usuário sem funcionário vinculado.');
const request = await ctx.db.get(args.requestId);
if (!request) throw new Error('Solicitação não encontrada.');
@@ -1822,12 +1909,25 @@ export const rejectItemRequest = mutation({
const isInSector = await ctx.db
.query('funcionarioSetores')
.withIndex('by_funcionarioId', (q) => q.eq('funcionarioId', user.funcionarioId!))
.withIndex('by_funcionarioId', (q) => q.eq('funcionarioId', funcionarioId))
.filter((q) => q.eq(q.field('setorId'), config.comprasSetorId))
.first();
if (!isInSector) throw new Error('Acesso negado.');
// Remover documentos anexados à solicitação (para não deixar órfãos no storage)
const solicitacaoDocs = await ctx.db
.query('solicitacoesItensDocumentos')
.withIndex('by_requestId', (q) => q.eq('requestId', request._id))
.collect();
const documentosRemovidos: Array<{ nome: string; descricao: string }> = [];
for (const doc of solicitacaoDocs) {
documentosRemovidos.push({ nome: doc.nome, descricao: doc.descricao });
await ctx.storage.delete(doc.storageId);
await ctx.db.delete(doc._id);
}
// Delete request
await ctx.db.delete(args.requestId);
@@ -1837,10 +1937,230 @@ export const rejectItemRequest = mutation({
usuarioId: user._id,
acao: 'rejeicao_solicitacao',
detalhes: JSON.stringify({
requestId: request._id,
tipo: request.tipo,
solicitadoPor: request.solicitadoPor
solicitadoPor: request.solicitadoPor,
documentosRemovidos
}),
data: Date.now()
});
}
});
// ========== DOCUMENTOS (PEDIDO / SOLICITAÇÃO) ==========
export const generatePedidoUploadUrl = mutation({
args: { pedidoId: v.id('pedidos') },
returns: v.string(),
handler: async (ctx, args) => {
const user = await getUsuarioAutenticado(ctx);
await assertPodeGerenciarDocumentosDoPedido(ctx, args.pedidoId, user);
return await ctx.storage.generateUploadUrl();
}
});
export const addPedidoDocumento = mutation({
args: {
pedidoId: v.id('pedidos'),
descricao: v.string(),
nome: v.string(),
storageId: v.id('_storage'),
tipo: v.string(),
tamanho: v.number(),
origemSolicitacaoId: v.optional(v.id('solicitacoesItens'))
},
returns: v.id('pedidoDocumentos'),
handler: async (ctx, args) => {
const user = await getUsuarioAutenticado(ctx);
await assertPodeGerenciarDocumentosDoPedido(ctx, args.pedidoId, user);
if (!user.funcionarioId) {
throw new Error('Usuário sem funcionário vinculado.');
}
// Garantir que o pedido existe
const pedido = await ctx.db.get(args.pedidoId);
if (!pedido) throw new Error('Pedido não encontrado.');
return await ctx.db.insert('pedidoDocumentos', {
pedidoId: args.pedidoId,
descricao: args.descricao,
nome: args.nome,
storageId: args.storageId,
tipo: args.tipo,
tamanho: args.tamanho,
criadoPor: user.funcionarioId,
criadoEm: Date.now(),
origemSolicitacaoId: args.origemSolicitacaoId
});
}
});
export const listPedidoDocumentos = query({
args: { pedidoId: v.id('pedidos') },
handler: async (ctx, args) => {
const user = await getUsuarioAutenticado(ctx);
await assertPodeGerenciarDocumentosDoPedido(ctx, args.pedidoId, user);
const docs = await ctx.db
.query('pedidoDocumentos')
.withIndex('by_pedidoId', (q) => q.eq('pedidoId', args.pedidoId))
.order('desc')
.collect();
return await Promise.all(
docs.map(async (doc) => {
const url = await ctx.storage.getUrl(doc.storageId);
const func = await ctx.db.get(doc.criadoPor);
return {
...doc,
criadoPorNome: func?.nome ?? 'Desconhecido',
url
};
})
);
}
});
export const removePedidoDocumento = mutation({
args: { id: v.id('pedidoDocumentos') },
returns: v.null(),
handler: async (ctx, args) => {
const user = await getUsuarioAutenticado(ctx);
const doc = await ctx.db.get(args.id);
if (!doc) throw new Error('Documento não encontrado.');
// Pode remover se for o autor OU se for envolvido/compras
if (!user.funcionarioId || doc.criadoPor !== user.funcionarioId) {
await assertPodeGerenciarDocumentosDoPedido(ctx, doc.pedidoId, user);
}
await ctx.storage.delete(doc.storageId);
await ctx.db.delete(doc._id);
return null;
}
});
export const generateSolicitacaoUploadUrl = mutation({
args: { requestId: v.id('solicitacoesItens') },
returns: v.string(),
handler: async (ctx, args) => {
const user = await getUsuarioAutenticado(ctx);
if (!user.funcionarioId) throw new Error('Usuário sem funcionário vinculado.');
const request = await ctx.db.get(args.requestId);
if (!request) throw new Error('Solicitação não encontrada.');
if (request.tipo !== 'adicao') {
throw new Error('Apenas solicitações de adição permitem anexar documentos.');
}
if (request.status !== 'pendente') {
throw new Error('Não é possível anexar documentos em uma solicitação não pendente.');
}
if (request.solicitadoPor !== user.funcionarioId) {
throw new Error('Apenas quem criou a solicitação pode anexar documentos.');
}
return await ctx.storage.generateUploadUrl();
}
});
export const addSolicitacaoDocumento = mutation({
args: {
requestId: v.id('solicitacoesItens'),
descricao: v.string(),
nome: v.string(),
storageId: v.id('_storage'),
tipo: v.string(),
tamanho: v.number()
},
returns: v.id('solicitacoesItensDocumentos'),
handler: async (ctx, args) => {
const user = await getUsuarioAutenticado(ctx);
if (!user.funcionarioId) throw new Error('Usuário sem funcionário vinculado.');
const request = await ctx.db.get(args.requestId);
if (!request) throw new Error('Solicitação não encontrada.');
if (request.tipo !== 'adicao') {
throw new Error('Apenas solicitações de adição permitem anexar documentos.');
}
if (request.status !== 'pendente') {
throw new Error('Não é possível anexar documentos em uma solicitação não pendente.');
}
if (request.solicitadoPor !== user.funcionarioId) {
throw new Error('Apenas quem criou a solicitação pode anexar documentos.');
}
return await ctx.db.insert('solicitacoesItensDocumentos', {
requestId: args.requestId,
pedidoId: request.pedidoId,
descricao: args.descricao,
nome: args.nome,
storageId: args.storageId,
tipo: args.tipo,
tamanho: args.tamanho,
criadoPor: user.funcionarioId,
criadoEm: Date.now()
});
}
});
export const listSolicitacaoDocumentos = query({
args: { requestId: v.id('solicitacoesItens') },
handler: async (ctx, args) => {
const user = await getUsuarioAutenticado(ctx);
const request = await ctx.db.get(args.requestId);
if (!request) return [];
await assertPodeGerenciarDocumentosDoPedido(ctx, request.pedidoId, user);
const docs = await ctx.db
.query('solicitacoesItensDocumentos')
.withIndex('by_requestId', (q) => q.eq('requestId', args.requestId))
.order('desc')
.collect();
return await Promise.all(
docs.map(async (doc) => {
const url = await ctx.storage.getUrl(doc.storageId);
const func = await ctx.db.get(doc.criadoPor);
return {
...doc,
criadoPorNome: func?.nome ?? 'Desconhecido',
url
};
})
);
}
});
export const removeSolicitacaoDocumento = mutation({
args: { id: v.id('solicitacoesItensDocumentos') },
returns: v.null(),
handler: async (ctx, args) => {
const user = await getUsuarioAutenticado(ctx);
if (!user.funcionarioId) throw new Error('Usuário sem funcionário vinculado.');
const doc = await ctx.db.get(args.id);
if (!doc) throw new Error('Documento não encontrado.');
const request = await ctx.db.get(doc.requestId);
if (!request) throw new Error('Solicitação não encontrada.');
if (request.tipo !== 'adicao') {
throw new Error('Apenas solicitações de adição permitem documentos.');
}
if (request.status !== 'pendente') {
throw new Error('Não é possível remover documentos de uma solicitação não pendente.');
}
if (doc.criadoPor !== user.funcionarioId || request.solicitadoPor !== user.funcionarioId) {
throw new Error('Apenas quem criou a solicitação pode remover documentos.');
}
await ctx.storage.delete(doc.storageId);
await ctx.db.delete(doc._id);
return null;
}
});

View File

@@ -70,5 +70,37 @@ export const pedidosTables = {
})
.index('by_pedidoId', ['pedidoId'])
.index('by_usuarioId', ['usuarioId'])
.index('by_data', ['data'])
.index('by_data', ['data']),
// Documentos anexados diretamente ao pedido (ilimitado)
pedidoDocumentos: defineTable({
pedidoId: v.id('pedidos'),
descricao: v.string(),
nome: v.string(),
storageId: v.id('_storage'),
tipo: v.string(), // MIME type
tamanho: v.number(), // bytes
criadoPor: v.id('funcionarios'),
criadoEm: v.number(),
origemSolicitacaoId: v.optional(v.id('solicitacoesItens'))
})
.index('by_pedidoId', ['pedidoId'])
.index('by_criadoPor', ['criadoPor'])
.index('by_origemSolicitacaoId', ['origemSolicitacaoId']),
// Documentos anexados a uma solicitação (somente solicitante; pode ter mais de um)
solicitacoesItensDocumentos: defineTable({
requestId: v.id('solicitacoesItens'),
pedidoId: v.id('pedidos'),
descricao: v.string(),
nome: v.string(),
storageId: v.id('_storage'),
tipo: v.string(), // MIME type
tamanho: v.number(), // bytes
criadoPor: v.id('funcionarios'),
criadoEm: v.number()
})
.index('by_requestId', ['requestId'])
.index('by_pedidoId', ['pedidoId'])
.index('by_criadoPor', ['criadoPor'])
};