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

@@ -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'])
};