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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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'])
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user