1096 lines
28 KiB
TypeScript
1096 lines
28 KiB
TypeScript
import { v } from 'convex/values';
|
|
import { api, internal } from './_generated/api';
|
|
import type { Doc, Id } from './_generated/dataModel';
|
|
import type { MutationCtx, QueryCtx } from './_generated/server';
|
|
import { internalMutation, mutation, query } from './_generated/server';
|
|
import { getCurrentUserFunction } from './auth';
|
|
|
|
// ========== HELPERS ==========
|
|
|
|
async function getUsuarioAutenticado(ctx: QueryCtx | MutationCtx) {
|
|
const user = await getCurrentUserFunction(ctx);
|
|
if (!user) throw new Error('Unauthorized');
|
|
return user;
|
|
}
|
|
|
|
// ========== QUERIES ==========
|
|
|
|
export const list = query({
|
|
args: {},
|
|
returns: v.array(
|
|
v.object({
|
|
_id: v.id('pedidos'),
|
|
_creationTime: v.number(),
|
|
numeroSei: v.optional(v.string()),
|
|
status: v.union(
|
|
v.literal('em_rascunho'),
|
|
v.literal('aguardando_aceite'),
|
|
v.literal('em_analise'),
|
|
v.literal('precisa_ajustes'),
|
|
v.literal('cancelado'),
|
|
v.literal('concluido')
|
|
),
|
|
// acaoId removed from return
|
|
criadoPor: v.id('usuarios'),
|
|
criadoEm: v.number(),
|
|
atualizadoEm: v.number()
|
|
})
|
|
),
|
|
handler: async (ctx) => {
|
|
return await ctx.db.query('pedidos').collect();
|
|
}
|
|
});
|
|
|
|
export const get = query({
|
|
args: { id: v.id('pedidos') },
|
|
returns: v.union(
|
|
v.object({
|
|
_id: v.id('pedidos'),
|
|
_creationTime: v.number(),
|
|
numeroSei: v.optional(v.string()),
|
|
status: v.union(
|
|
v.literal('em_rascunho'),
|
|
v.literal('aguardando_aceite'),
|
|
v.literal('em_analise'),
|
|
v.literal('precisa_ajustes'),
|
|
v.literal('cancelado'),
|
|
v.literal('concluido')
|
|
),
|
|
acaoId: v.optional(v.id('acoes')),
|
|
criadoPor: v.id('usuarios'),
|
|
criadoEm: v.number(),
|
|
atualizadoEm: v.number()
|
|
}),
|
|
v.null()
|
|
),
|
|
handler: async (ctx, args) => {
|
|
return await ctx.db.get(args.id);
|
|
}
|
|
});
|
|
|
|
export const getItems = query({
|
|
args: { pedidoId: v.id('pedidos') },
|
|
returns: v.array(
|
|
v.object({
|
|
_id: v.id('objetoItems'),
|
|
_creationTime: v.number(),
|
|
pedidoId: v.id('pedidos'),
|
|
objetoId: v.id('objetos'),
|
|
ataId: v.optional(v.id('atas')),
|
|
acaoId: v.optional(v.id('acoes')),
|
|
modalidade: v.union(
|
|
v.literal('dispensa'),
|
|
v.literal('inexgibilidade'),
|
|
v.literal('adesao'),
|
|
v.literal('consumo')
|
|
),
|
|
valorEstimado: v.string(),
|
|
valorReal: v.optional(v.string()),
|
|
quantidade: v.number(),
|
|
adicionadoPor: v.id('funcionarios'),
|
|
adicionadoPorNome: v.string(),
|
|
criadoEm: v.number()
|
|
})
|
|
),
|
|
handler: async (ctx, args) => {
|
|
const items = await ctx.db
|
|
.query('objetoItems')
|
|
.withIndex('by_pedidoId', (q) => q.eq('pedidoId', args.pedidoId))
|
|
.collect();
|
|
|
|
// Get employee names
|
|
const itemsWithNames = await Promise.all(
|
|
items.map(async (item) => {
|
|
const funcionario = await ctx.db.get(item.adicionadoPor);
|
|
return {
|
|
...item,
|
|
adicionadoPorNome: funcionario?.nome || 'Desconhecido'
|
|
};
|
|
})
|
|
);
|
|
|
|
return itemsWithNames;
|
|
}
|
|
});
|
|
|
|
export const getHistory = query({
|
|
args: { pedidoId: v.id('pedidos') },
|
|
handler: async (ctx, args) => {
|
|
const history = await ctx.db
|
|
.query('historicoPedidos')
|
|
.withIndex('by_pedidoId', (q) => q.eq('pedidoId', args.pedidoId))
|
|
.order('desc')
|
|
.collect();
|
|
|
|
// Get user names
|
|
const historyWithNames = await Promise.all(
|
|
history.map(async (entry) => {
|
|
const usuario = await ctx.db.get(entry.usuarioId);
|
|
return {
|
|
_id: entry._id,
|
|
_creationTime: entry._creationTime,
|
|
pedidoId: entry.pedidoId,
|
|
usuarioId: entry.usuarioId,
|
|
usuarioNome: usuario?.nome || 'Desconhecido',
|
|
acao: entry.acao,
|
|
detalhes: entry.detalhes,
|
|
data: entry.data
|
|
};
|
|
})
|
|
);
|
|
|
|
return historyWithNames;
|
|
}
|
|
});
|
|
|
|
export const checkExisting = query({
|
|
args: {
|
|
numeroSei: v.optional(v.string()),
|
|
itensFiltro: v.optional(
|
|
v.array(
|
|
v.object({
|
|
objetoId: v.id('objetos'),
|
|
modalidade: v.union(
|
|
v.literal('dispensa'),
|
|
v.literal('inexgibilidade'),
|
|
v.literal('adesao'),
|
|
v.literal('consumo')
|
|
)
|
|
})
|
|
)
|
|
)
|
|
},
|
|
returns: v.array(
|
|
v.object({
|
|
_id: v.id('pedidos'),
|
|
_creationTime: v.number(),
|
|
numeroSei: v.optional(v.string()),
|
|
status: v.union(
|
|
v.literal('em_rascunho'),
|
|
v.literal('aguardando_aceite'),
|
|
v.literal('em_analise'),
|
|
v.literal('precisa_ajustes'),
|
|
v.literal('cancelado'),
|
|
v.literal('concluido')
|
|
),
|
|
// acaoId removed
|
|
criadoPor: v.id('usuarios'),
|
|
criadoEm: v.number(),
|
|
atualizadoEm: v.number(),
|
|
matchingItems: v.optional(
|
|
v.array(
|
|
v.object({
|
|
objetoId: v.id('objetos'),
|
|
modalidade: v.union(
|
|
v.literal('dispensa'),
|
|
v.literal('inexgibilidade'),
|
|
v.literal('adesao'),
|
|
v.literal('consumo')
|
|
),
|
|
quantidade: v.number()
|
|
})
|
|
)
|
|
)
|
|
})
|
|
),
|
|
handler: async (ctx, args) => {
|
|
const user = await getCurrentUserFunction(ctx);
|
|
if (!user) return [];
|
|
|
|
const openStatuses: Array<
|
|
'em_rascunho' | 'aguardando_aceite' | 'em_analise' | 'precisa_ajustes'
|
|
> = ['em_rascunho', 'aguardando_aceite', 'em_analise', 'precisa_ajustes'];
|
|
|
|
// 1) Buscar todos os pedidos "abertos" usando o índice by_status
|
|
let pedidosAbertos: Doc<'pedidos'>[] = [];
|
|
for (const status of openStatuses) {
|
|
const partial = await ctx.db
|
|
.query('pedidos')
|
|
.withIndex('by_status', (q) => q.eq('status', status))
|
|
.collect();
|
|
pedidosAbertos = pedidosAbertos.concat(partial);
|
|
}
|
|
|
|
// 2) Filtros opcionais: numeroSei
|
|
pedidosAbertos = pedidosAbertos.filter((p) => {
|
|
if (args.numeroSei && p.numeroSei !== args.numeroSei) return false;
|
|
return true;
|
|
});
|
|
|
|
// 3) Filtro por itens (objetoId + modalidade), se informado, e coleta de matchingItems
|
|
const resultados = [];
|
|
|
|
const itensFiltro = args.itensFiltro ?? [];
|
|
|
|
for (const pedido of pedidosAbertos) {
|
|
let include = true;
|
|
let matchingItems: {
|
|
objetoId: Id<'objetos'>;
|
|
modalidade: Doc<'objetoItems'>['modalidade'];
|
|
quantidade: number;
|
|
}[] = [];
|
|
|
|
// Se houver filtro de itens, verificamos se o pedido tem ALGUM dos itens (objetoId + modalidade)
|
|
if (itensFiltro.length > 0) {
|
|
const items = await ctx.db
|
|
.query('objetoItems')
|
|
.withIndex('by_pedidoId', (q) => q.eq('pedidoId', pedido._id))
|
|
.collect();
|
|
|
|
const matching = items.filter((i) =>
|
|
itensFiltro.some((f) => f.objetoId === i.objetoId && f.modalidade === i.modalidade)
|
|
);
|
|
|
|
if (matching.length > 0) {
|
|
matchingItems = matching.map((i) => ({
|
|
objetoId: i.objetoId,
|
|
modalidade: i.modalidade,
|
|
quantidade: i.quantidade
|
|
}));
|
|
} else {
|
|
include = false;
|
|
}
|
|
}
|
|
|
|
if (include) {
|
|
resultados.push({
|
|
_id: pedido._id,
|
|
_creationTime: pedido._creationTime,
|
|
numeroSei: pedido.numeroSei,
|
|
status: pedido.status,
|
|
criadoPor: pedido.criadoPor,
|
|
criadoEm: pedido.criadoEm,
|
|
atualizadoEm: pedido.atualizadoEm,
|
|
matchingItems: matchingItems.length > 0 ? matchingItems : undefined
|
|
});
|
|
}
|
|
}
|
|
|
|
return resultados;
|
|
}
|
|
});
|
|
|
|
export const listForAcceptance = query({
|
|
args: {},
|
|
returns: v.array(
|
|
v.object({
|
|
_id: v.id('pedidos'),
|
|
_creationTime: v.number(),
|
|
numeroSei: v.optional(v.string()),
|
|
status: v.string(),
|
|
criadoPor: v.id('usuarios'),
|
|
criadoPorNome: v.string(),
|
|
criadoEm: v.number(),
|
|
atualizadoEm: v.number()
|
|
})
|
|
),
|
|
handler: async (ctx) => {
|
|
const user = await getUsuarioAutenticado(ctx);
|
|
|
|
// Security Check: Must be in Compras Sector
|
|
const config = await ctx.db.query('config').first();
|
|
if (!config || !config.comprasSetorId) return [];
|
|
|
|
if (!user.funcionarioId) return [];
|
|
|
|
const isInSector = await ctx.db
|
|
.query('funcionarioSetores')
|
|
.withIndex('by_funcionarioId', (q) => q.eq('funcionarioId', user.funcionarioId!))
|
|
.filter((q) => q.eq(q.field('setorId'), config.comprasSetorId))
|
|
.first();
|
|
|
|
if (!isInSector) return [];
|
|
|
|
// Fetch orders waiting for acceptance
|
|
const orders = await ctx.db
|
|
.query('pedidos')
|
|
.withIndex('by_status', (q) => q.eq('status', 'aguardando_aceite'))
|
|
.collect();
|
|
|
|
// Enrich with creator name
|
|
return await Promise.all(
|
|
orders.map(async (o) => {
|
|
const creator = await ctx.db.get(o.criadoPor);
|
|
return {
|
|
_id: o._id,
|
|
_creationTime: o._creationTime,
|
|
numeroSei: o.numeroSei,
|
|
status: o.status,
|
|
criadoPor: o.criadoPor,
|
|
criadoPorNome: creator?.nome || 'Desconhecido',
|
|
criadoEm: o.criadoEm,
|
|
atualizadoEm: o.atualizadoEm
|
|
};
|
|
})
|
|
);
|
|
}
|
|
});
|
|
|
|
export const listMyAnalysis = query({
|
|
args: {},
|
|
returns: v.array(
|
|
v.object({
|
|
_id: v.id('pedidos'),
|
|
_creationTime: v.number(),
|
|
numeroSei: v.optional(v.string()),
|
|
status: v.string(),
|
|
criadoPor: v.id('usuarios'),
|
|
criadoPorNome: v.string(),
|
|
aceitoPor: v.optional(v.id('funcionarios')),
|
|
criadoEm: v.number(),
|
|
atualizadoEm: v.number()
|
|
})
|
|
),
|
|
handler: async (ctx) => {
|
|
const user = await getUsuarioAutenticado(ctx);
|
|
|
|
// Security Check: Must be in Compras Sector
|
|
const config = await ctx.db.query('config').first();
|
|
if (!config || !config.comprasSetorId) return [];
|
|
|
|
if (!user.funcionarioId) return [];
|
|
|
|
const isInSector = await ctx.db
|
|
.query('funcionarioSetores')
|
|
.withIndex('by_funcionarioId', (q) => q.eq('funcionarioId', user.funcionarioId!))
|
|
.filter((q) => q.eq(q.field('setorId'), config.comprasSetorId))
|
|
.first();
|
|
|
|
if (!isInSector) return [];
|
|
|
|
// Fetch orders accepted by this user
|
|
// We don't have an index on aceitoPor yet, but we can filter or add index.
|
|
// Ideally we should add an index, but for now let's filter since volume might be low per user.
|
|
// Wait, we can't filter efficiently without index if table is huge.
|
|
// Let's assume we should add index or filter in memory if small.
|
|
// Given the schema change was just adding the field, let's filter.
|
|
// Actually, let's add the index in the schema update if possible, but I already did that step.
|
|
// I'll filter for now.
|
|
|
|
const orders = await ctx.db
|
|
.query('pedidos')
|
|
.filter((q) => q.eq(q.field('aceitoPor'), user.funcionarioId))
|
|
.collect();
|
|
|
|
return await Promise.all(
|
|
orders.map(async (o) => {
|
|
const creator = await ctx.db.get(o.criadoPor);
|
|
return {
|
|
_id: o._id,
|
|
_creationTime: o._creationTime,
|
|
numeroSei: o.numeroSei,
|
|
status: o.status,
|
|
criadoPor: o.criadoPor,
|
|
criadoPorNome: creator?.nome || 'Desconhecido',
|
|
aceitoPor: o.aceitoPor,
|
|
criadoEm: o.criadoEm,
|
|
atualizadoEm: o.atualizadoEm
|
|
};
|
|
})
|
|
);
|
|
}
|
|
});
|
|
|
|
export const listByItemCreator = query({
|
|
args: {},
|
|
returns: v.array(
|
|
v.object({
|
|
_id: v.id('pedidos'),
|
|
_creationTime: v.number(),
|
|
numeroSei: v.optional(v.string()),
|
|
status: v.string(),
|
|
criadoPor: v.id('usuarios'),
|
|
criadoPorNome: v.string(),
|
|
criadoEm: v.number(),
|
|
atualizadoEm: v.number()
|
|
})
|
|
),
|
|
handler: async (ctx) => {
|
|
const user = await getUsuarioAutenticado(ctx);
|
|
if (!user.funcionarioId) return [];
|
|
|
|
// Find all items added by this user
|
|
const myItems = await ctx.db
|
|
.query('objetoItems')
|
|
.withIndex('by_adicionadoPor', (q) => q.eq('adicionadoPor', user.funcionarioId!))
|
|
.collect();
|
|
|
|
// Get unique pedidoIds
|
|
const pedidoIds = [...new Set(myItems.map((i) => i.pedidoId))];
|
|
|
|
// Fetch orders
|
|
const orders = await Promise.all(pedidoIds.map((id) => ctx.db.get(id)));
|
|
|
|
// Filter out nulls and enrich
|
|
const validOrders = orders.filter((o) => o !== null);
|
|
|
|
return await Promise.all(
|
|
validOrders.map(async (o) => {
|
|
const creator = await ctx.db.get(o!.criadoPor);
|
|
return {
|
|
_id: o!._id,
|
|
_creationTime: o!._creationTime,
|
|
numeroSei: o!.numeroSei,
|
|
status: o!.status,
|
|
criadoPor: o!.criadoPor,
|
|
criadoPorNome: creator?.nome || 'Desconhecido',
|
|
criadoEm: o!.criadoEm,
|
|
atualizadoEm: o!.atualizadoEm
|
|
};
|
|
})
|
|
);
|
|
}
|
|
});
|
|
|
|
export const acceptOrder = mutation({
|
|
args: {
|
|
pedidoId: v.id('pedidos')
|
|
},
|
|
returns: v.null(),
|
|
handler: async (ctx, args) => {
|
|
const user = await getUsuarioAutenticado(ctx);
|
|
|
|
// Security Check: Must be in Compras Sector
|
|
const config = await ctx.db.query('config').first();
|
|
if (!config || !config.comprasSetorId) {
|
|
throw new Error('Setor de compras não configurado.');
|
|
}
|
|
|
|
if (!user.funcionarioId) {
|
|
throw new Error('Usuário sem funcionário vinculado.');
|
|
}
|
|
|
|
const isInSector = await ctx.db
|
|
.query('funcionarioSetores')
|
|
.withIndex('by_funcionarioId', (q) => q.eq('funcionarioId', user.funcionarioId!))
|
|
.filter((q) => q.eq(q.field('setorId'), config.comprasSetorId))
|
|
.first();
|
|
|
|
if (!isInSector) {
|
|
throw new Error('Você não tem permissão para aceitar pedidos (Setor inválido).');
|
|
}
|
|
|
|
const pedido = await ctx.db.get(args.pedidoId);
|
|
if (!pedido) throw new Error('Pedido não encontrado.');
|
|
|
|
if (pedido.status !== 'aguardando_aceite') {
|
|
throw new Error('Este pedido não está aguardando aceite.');
|
|
}
|
|
|
|
if (pedido.aceitoPor) {
|
|
throw new Error('Este pedido já foi aceito por outro funcionário.');
|
|
}
|
|
|
|
await ctx.db.patch(args.pedidoId, {
|
|
status: 'em_analise',
|
|
aceitoPor: user.funcionarioId,
|
|
atualizadoEm: Date.now()
|
|
});
|
|
|
|
await ctx.db.insert('historicoPedidos', {
|
|
pedidoId: args.pedidoId,
|
|
usuarioId: user._id,
|
|
acao: 'aceite_pedido',
|
|
detalhes: JSON.stringify({ aceitoPor: user.funcionarioId }),
|
|
data: Date.now()
|
|
});
|
|
}
|
|
});
|
|
|
|
// ========== MUTATIONS ==========
|
|
|
|
export const create = mutation({
|
|
args: {
|
|
numeroSei: v.optional(v.string())
|
|
// acaoId removed
|
|
},
|
|
returns: v.id('pedidos'),
|
|
handler: async (ctx, args) => {
|
|
const user = await getUsuarioAutenticado(ctx);
|
|
|
|
// 1. Check Config
|
|
const config = await ctx.db.query('config').first();
|
|
if (!config || !config.comprasSetorId) {
|
|
throw new Error('Setor de Compras não configurado. Contate o administrador.');
|
|
}
|
|
|
|
// 2. Check Existing (Double check) - Removed acaoId check here as it's now per item
|
|
|
|
// 3. Create Order
|
|
const pedidoId = await ctx.db.insert('pedidos', {
|
|
numeroSei: args.numeroSei,
|
|
status: 'em_rascunho',
|
|
criadoPor: user._id,
|
|
criadoEm: Date.now(),
|
|
atualizadoEm: Date.now()
|
|
});
|
|
|
|
// 4. Create History
|
|
await ctx.db.insert('historicoPedidos', {
|
|
pedidoId,
|
|
usuarioId: user._id,
|
|
acao: 'criacao',
|
|
detalhes: JSON.stringify({ numeroSei: args.numeroSei }),
|
|
data: Date.now()
|
|
});
|
|
|
|
return pedidoId;
|
|
}
|
|
});
|
|
|
|
export const updateSeiNumber = mutation({
|
|
args: {
|
|
pedidoId: v.id('pedidos'),
|
|
numeroSei: v.string()
|
|
},
|
|
returns: v.null(),
|
|
handler: async (ctx, args) => {
|
|
const user = await getUsuarioAutenticado(ctx);
|
|
const pedido = await ctx.db.get(args.pedidoId);
|
|
if (!pedido) throw new Error('Pedido not found');
|
|
|
|
// Check if SEI number is already taken by another order
|
|
const existing = await ctx.db
|
|
.query('pedidos')
|
|
.filter((q) =>
|
|
q.and(q.eq(q.field('numeroSei'), args.numeroSei), q.neq(q.field('_id'), args.pedidoId))
|
|
)
|
|
.first();
|
|
|
|
if (existing) {
|
|
throw new Error('Este número SEI já está em uso por outro pedido.');
|
|
}
|
|
|
|
const oldSei = pedido.numeroSei;
|
|
|
|
await ctx.db.patch(args.pedidoId, {
|
|
numeroSei: args.numeroSei,
|
|
atualizadoEm: Date.now()
|
|
});
|
|
|
|
await ctx.db.insert('historicoPedidos', {
|
|
pedidoId: args.pedidoId,
|
|
usuarioId: user._id,
|
|
acao: 'atualizacao_sei',
|
|
detalhes: JSON.stringify({ de: oldSei, para: args.numeroSei }),
|
|
data: Date.now()
|
|
});
|
|
}
|
|
});
|
|
|
|
export const addItem = mutation({
|
|
args: {
|
|
pedidoId: v.id('pedidos'),
|
|
objetoId: v.id('objetos'),
|
|
ataId: v.optional(v.id('atas')),
|
|
acaoId: v.optional(v.id('acoes')),
|
|
modalidade: v.union(
|
|
v.literal('dispensa'),
|
|
v.literal('inexgibilidade'),
|
|
v.literal('adesao'),
|
|
v.literal('consumo')
|
|
),
|
|
valorEstimado: v.string(),
|
|
quantidade: v.number()
|
|
},
|
|
returns: v.null(),
|
|
handler: async (ctx, args) => {
|
|
const user = await getUsuarioAutenticado(ctx);
|
|
|
|
// Ensure user has a funcionarioId linked
|
|
if (!user.funcionarioId) {
|
|
throw new Error('Usuário não vinculado a um funcionário.');
|
|
}
|
|
|
|
// Check if item already exists with same parameters (user, object, action, modalidade)
|
|
const existingItem = await ctx.db
|
|
.query('objetoItems')
|
|
.withIndex('by_pedidoId', (q) => q.eq('pedidoId', args.pedidoId))
|
|
.filter((q) =>
|
|
q.and(
|
|
q.eq(q.field('objetoId'), args.objetoId),
|
|
q.eq(q.field('adicionadoPor'), user.funcionarioId),
|
|
q.eq(q.field('acaoId'), args.acaoId),
|
|
q.eq(q.field('ataId'), args.ataId),
|
|
q.eq(q.field('modalidade'), args.modalidade)
|
|
)
|
|
)
|
|
.first();
|
|
|
|
if (existingItem) {
|
|
// Increment quantity
|
|
const novaQuantidade = existingItem.quantidade + args.quantidade;
|
|
await ctx.db.patch(existingItem._id, { quantidade: novaQuantidade });
|
|
|
|
await ctx.db.insert('historicoPedidos', {
|
|
pedidoId: args.pedidoId,
|
|
usuarioId: user._id,
|
|
acao: 'adicao_item_incremento',
|
|
detalhes: JSON.stringify({
|
|
objetoId: args.objetoId,
|
|
quantidadeAdicionada: args.quantidade,
|
|
novaQuantidade
|
|
}),
|
|
data: Date.now()
|
|
});
|
|
} else {
|
|
// Insert new item
|
|
await ctx.db.insert('objetoItems', {
|
|
pedidoId: args.pedidoId,
|
|
objetoId: args.objetoId,
|
|
ataId: args.ataId,
|
|
acaoId: args.acaoId,
|
|
modalidade: args.modalidade,
|
|
valorEstimado: args.valorEstimado,
|
|
quantidade: args.quantidade,
|
|
adicionadoPor: user.funcionarioId,
|
|
criadoEm: Date.now()
|
|
});
|
|
|
|
await ctx.db.insert('historicoPedidos', {
|
|
pedidoId: args.pedidoId,
|
|
usuarioId: user._id,
|
|
acao: 'adicao_item',
|
|
detalhes: JSON.stringify({
|
|
objetoId: args.objetoId,
|
|
valor: args.valorEstimado,
|
|
quantidade: args.quantidade,
|
|
acaoId: args.acaoId,
|
|
ataId: args.ataId,
|
|
modalidade: args.modalidade
|
|
}),
|
|
data: Date.now()
|
|
});
|
|
}
|
|
|
|
await ctx.db.patch(args.pedidoId, { atualizadoEm: Date.now() });
|
|
}
|
|
});
|
|
|
|
export const updateItemQuantity = mutation({
|
|
args: {
|
|
itemId: v.id('objetoItems'),
|
|
novaQuantidade: v.number()
|
|
},
|
|
returns: v.null(),
|
|
handler: async (ctx, args) => {
|
|
const user = await getUsuarioAutenticado(ctx);
|
|
|
|
if (!user.funcionarioId) {
|
|
throw new Error('Usuário não vinculado a um funcionário.');
|
|
}
|
|
|
|
const item = await ctx.db.get(args.itemId);
|
|
if (!item) throw new Error('Item não encontrado.');
|
|
|
|
const quantidadeAnterior = item.quantidade;
|
|
|
|
// Check permission: only item owner can change quantity
|
|
const isOwner = item.adicionadoPor === user.funcionarioId;
|
|
|
|
if (!isOwner) {
|
|
throw new Error('Apenas quem adicionou este item pode alterar a quantidade.');
|
|
}
|
|
|
|
// Update quantity
|
|
await ctx.db.patch(args.itemId, { quantidade: args.novaQuantidade });
|
|
await ctx.db.patch(item.pedidoId, { atualizadoEm: Date.now() });
|
|
|
|
// Create history entry
|
|
await ctx.db.insert('historicoPedidos', {
|
|
pedidoId: item.pedidoId,
|
|
usuarioId: user._id,
|
|
acao: 'alteracao_quantidade',
|
|
detalhes: JSON.stringify({
|
|
objetoId: item.objetoId,
|
|
quantidadeAnterior,
|
|
novaQuantidade: args.novaQuantidade
|
|
}),
|
|
data: Date.now()
|
|
});
|
|
}
|
|
});
|
|
|
|
export const removeItem = mutation({
|
|
args: {
|
|
itemId: v.id('objetoItems')
|
|
},
|
|
returns: v.null(),
|
|
handler: async (ctx, args) => {
|
|
const user = await getUsuarioAutenticado(ctx);
|
|
|
|
const item = await ctx.db.get(args.itemId);
|
|
if (!item) throw new Error('Item not found');
|
|
|
|
await ctx.db.delete(args.itemId);
|
|
await ctx.db.patch(item.pedidoId, { atualizadoEm: Date.now() });
|
|
|
|
await ctx.db.insert('historicoPedidos', {
|
|
pedidoId: item.pedidoId,
|
|
usuarioId: user._id,
|
|
acao: 'remocao_item',
|
|
detalhes: JSON.stringify({
|
|
objetoId: item.objetoId,
|
|
valor: item.valorEstimado
|
|
}),
|
|
data: Date.now()
|
|
});
|
|
}
|
|
});
|
|
|
|
export const removeItemsBatch = mutation({
|
|
args: {
|
|
itemIds: v.array(v.id('objetoItems'))
|
|
},
|
|
returns: v.null(),
|
|
handler: async (ctx, args) => {
|
|
const user = await getUsuarioAutenticado(ctx);
|
|
|
|
if (!user.funcionarioId) {
|
|
throw new Error('Usuário não vinculado a um funcionário.');
|
|
}
|
|
|
|
if (args.itemIds.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const firstItem = await ctx.db.get(args.itemIds[0]);
|
|
if (!firstItem) {
|
|
throw new Error('Item não encontrado.');
|
|
}
|
|
|
|
const pedidoId = firstItem.pedidoId;
|
|
const pedido = await ctx.db.get(pedidoId);
|
|
if (!pedido) {
|
|
throw new Error('Pedido não encontrado.');
|
|
}
|
|
|
|
if (pedido.status !== 'em_rascunho' && pedido.status !== 'precisa_ajustes') {
|
|
throw new Error(
|
|
'Só é possível remover itens em pedidos em rascunho ou que precisam de ajustes.'
|
|
);
|
|
}
|
|
|
|
for (const itemId of args.itemIds) {
|
|
const item = await ctx.db.get(itemId);
|
|
if (!item) continue;
|
|
|
|
if (item.pedidoId !== pedidoId) {
|
|
throw new Error('Todos os itens devem pertencer ao mesmo pedido.');
|
|
}
|
|
|
|
if (item.adicionadoPor !== user.funcionarioId) {
|
|
throw new Error('Você só pode remover itens que você adicionou.');
|
|
}
|
|
|
|
await ctx.db.delete(itemId);
|
|
|
|
await ctx.db.insert('historicoPedidos', {
|
|
pedidoId,
|
|
usuarioId: user._id,
|
|
acao: 'remocao_item',
|
|
detalhes: JSON.stringify({
|
|
objetoId: item.objetoId,
|
|
valor: item.valorEstimado
|
|
}),
|
|
data: Date.now()
|
|
});
|
|
}
|
|
|
|
await ctx.db.patch(pedidoId, { atualizadoEm: Date.now() });
|
|
|
|
return null;
|
|
}
|
|
});
|
|
|
|
export const splitPedido = mutation({
|
|
args: {
|
|
pedidoId: v.id('pedidos'),
|
|
itemIds: v.array(v.id('objetoItems')),
|
|
numeroSei: v.optional(v.string())
|
|
},
|
|
returns: v.id('pedidos'),
|
|
handler: async (ctx, args) => {
|
|
const user = await getUsuarioAutenticado(ctx);
|
|
|
|
if (!user.funcionarioId) {
|
|
throw new Error('Usuário não vinculado a um funcionário.');
|
|
}
|
|
|
|
if (args.itemIds.length === 0) {
|
|
throw new Error('Selecione ao menos um item para dividir o pedido.');
|
|
}
|
|
|
|
const pedidoOriginal = await ctx.db.get(args.pedidoId);
|
|
if (!pedidoOriginal) {
|
|
throw new Error('Pedido não encontrado.');
|
|
}
|
|
|
|
if (pedidoOriginal.status !== 'em_rascunho' && pedidoOriginal.status !== 'precisa_ajustes') {
|
|
throw new Error('Só é possível dividir pedidos em rascunho ou que precisam de ajustes.');
|
|
}
|
|
|
|
const itens = [];
|
|
for (const itemId of args.itemIds) {
|
|
const item = await ctx.db.get(itemId);
|
|
if (!item) {
|
|
continue;
|
|
}
|
|
if (item.pedidoId !== args.pedidoId) {
|
|
throw new Error('Todos os itens devem pertencer ao mesmo pedido.');
|
|
}
|
|
if (item.adicionadoPor !== user.funcionarioId) {
|
|
throw new Error('Você só pode mover itens que você adicionou.');
|
|
}
|
|
itens.push(item);
|
|
}
|
|
|
|
if (itens.length === 0) {
|
|
throw new Error('Nenhum dos itens selecionados pôde ser usado para divisão.');
|
|
}
|
|
|
|
const novoPedidoId = await ctx.db.insert('pedidos', {
|
|
numeroSei: args.numeroSei,
|
|
status: 'em_rascunho',
|
|
criadoPor: user._id,
|
|
criadoEm: Date.now(),
|
|
atualizadoEm: Date.now()
|
|
});
|
|
|
|
for (const item of itens) {
|
|
await ctx.db.patch(item._id, {
|
|
pedidoId: novoPedidoId
|
|
});
|
|
}
|
|
|
|
await ctx.db.insert('historicoPedidos', {
|
|
pedidoId: args.pedidoId,
|
|
usuarioId: user._id,
|
|
acao: 'divisao_pedido_origem',
|
|
detalhes: JSON.stringify({
|
|
itensMovidos: itens.map((i) => i._id),
|
|
novoPedidoId
|
|
}),
|
|
data: Date.now()
|
|
});
|
|
|
|
await ctx.db.insert('historicoPedidos', {
|
|
pedidoId: novoPedidoId,
|
|
usuarioId: user._id,
|
|
acao: 'divisao_pedido_destino',
|
|
detalhes: JSON.stringify({
|
|
pedidoOriginalId: args.pedidoId,
|
|
itensRecebidos: itens.map((i) => i._id)
|
|
}),
|
|
data: Date.now()
|
|
});
|
|
|
|
await ctx.db.patch(args.pedidoId, { atualizadoEm: Date.now() });
|
|
|
|
return novoPedidoId;
|
|
}
|
|
});
|
|
|
|
export const updateItem = mutation({
|
|
args: {
|
|
itemId: v.id('objetoItems'),
|
|
valorEstimado: v.string(),
|
|
modalidade: v.union(
|
|
v.literal('dispensa'),
|
|
v.literal('inexgibilidade'),
|
|
v.literal('adesao'),
|
|
v.literal('consumo')
|
|
),
|
|
acaoId: v.optional(v.id('acoes')),
|
|
ataId: v.optional(v.id('atas'))
|
|
},
|
|
returns: v.null(),
|
|
handler: async (ctx, args) => {
|
|
const user = await getUsuarioAutenticado(ctx);
|
|
|
|
if (!user.funcionarioId) {
|
|
throw new Error('Usuário não vinculado a um funcionário.');
|
|
}
|
|
|
|
const item = await ctx.db.get(args.itemId);
|
|
if (!item) throw new Error('Item não encontrado.');
|
|
|
|
// Apenas quem adicionou o item pode editá-lo
|
|
const isOwner = item.adicionadoPor === user.funcionarioId;
|
|
if (!isOwner) {
|
|
throw new Error('Apenas quem adicionou este item pode editá-lo.');
|
|
}
|
|
|
|
const oldValues = {
|
|
valorEstimado: item.valorEstimado,
|
|
modalidade: item.modalidade,
|
|
acaoId: 'acaoId' in item ? item.acaoId : undefined,
|
|
ataId: 'ataId' in item ? item.ataId : undefined
|
|
};
|
|
|
|
await ctx.db.patch(args.itemId, {
|
|
valorEstimado: args.valorEstimado,
|
|
modalidade: args.modalidade,
|
|
acaoId: args.acaoId,
|
|
ataId: args.ataId
|
|
});
|
|
|
|
await ctx.db.patch(item.pedidoId, { atualizadoEm: Date.now() });
|
|
|
|
await ctx.db.insert('historicoPedidos', {
|
|
pedidoId: item.pedidoId,
|
|
usuarioId: user._id,
|
|
acao: 'edicao_item',
|
|
detalhes: JSON.stringify({
|
|
objetoId: item.objetoId,
|
|
de: oldValues,
|
|
para: {
|
|
valorEstimado: args.valorEstimado,
|
|
modalidade: args.modalidade,
|
|
acaoId: args.acaoId,
|
|
ataId: args.ataId
|
|
}
|
|
}),
|
|
data: Date.now()
|
|
});
|
|
}
|
|
});
|
|
|
|
export const updateStatus = mutation({
|
|
args: {
|
|
pedidoId: v.id('pedidos'),
|
|
novoStatus: v.union(
|
|
v.literal('em_rascunho'),
|
|
v.literal('aguardando_aceite'),
|
|
v.literal('em_analise'),
|
|
v.literal('precisa_ajustes'),
|
|
v.literal('cancelado'),
|
|
v.literal('concluido')
|
|
)
|
|
},
|
|
returns: v.null(),
|
|
handler: async (ctx, args) => {
|
|
const user = await getUsuarioAutenticado(ctx);
|
|
const pedido = await ctx.db.get(args.pedidoId);
|
|
if (!pedido) throw new Error('Pedido not found');
|
|
|
|
const oldStatus = pedido.status;
|
|
|
|
await ctx.db.patch(args.pedidoId, {
|
|
status: args.novoStatus,
|
|
atualizadoEm: Date.now()
|
|
});
|
|
|
|
await ctx.db.insert('historicoPedidos', {
|
|
pedidoId: args.pedidoId,
|
|
usuarioId: user._id,
|
|
acao: 'alteracao_status',
|
|
detalhes: JSON.stringify({ de: oldStatus, para: args.novoStatus }),
|
|
data: Date.now()
|
|
});
|
|
|
|
// Trigger Notifications
|
|
await ctx.scheduler.runAfter(0, internal.pedidos.notifyStatusChange, {
|
|
pedidoId: args.pedidoId,
|
|
oldStatus,
|
|
newStatus: args.novoStatus,
|
|
actorId: user._id
|
|
});
|
|
}
|
|
});
|
|
|
|
// ========== INTERNAL (NOTIFICATIONS) ==========
|
|
|
|
export const notifyStatusChange = internalMutation({
|
|
args: {
|
|
pedidoId: v.id('pedidos'),
|
|
oldStatus: v.string(),
|
|
newStatus: v.string(),
|
|
actorId: v.id('usuarios')
|
|
},
|
|
returns: v.null(),
|
|
handler: async (ctx, args) => {
|
|
const pedido = await ctx.db.get(args.pedidoId);
|
|
if (!pedido) return;
|
|
|
|
const actor = await ctx.db.get(args.actorId);
|
|
const actorName = actor ? actor.nome : 'Alguém';
|
|
|
|
const recipients = new Set<string>(); // Set of User IDs
|
|
|
|
// 1. If status is "aguardando_aceite", notify Purchasing Sector
|
|
if (args.newStatus === 'aguardando_aceite') {
|
|
const config = await ctx.db.query('config').first();
|
|
if (config && config.comprasSetorId) {
|
|
// Find all employees in this sector
|
|
const funcionarioSetores = await ctx.db
|
|
.query('funcionarioSetores')
|
|
.withIndex('by_setorId', (q) => q.eq('setorId', config.comprasSetorId!))
|
|
.collect();
|
|
|
|
const funcionarioIds = funcionarioSetores.map((fs) => fs.funcionarioId);
|
|
|
|
// Find users linked to these employees
|
|
for (const fId of funcionarioIds) {
|
|
const user = await ctx.db
|
|
.query('usuarios')
|
|
.withIndex('by_funcionarioId', (q) => q.eq('funcionarioId', fId))
|
|
.first();
|
|
if (user) recipients.add(user._id);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 2. Notify "Involved" users (Creator + Item Adders)
|
|
// Always notify creator (unless they are the actor)
|
|
if (pedido.criadoPor !== args.actorId) {
|
|
recipients.add(pedido.criadoPor);
|
|
}
|
|
|
|
// Notify item adders
|
|
const items = await ctx.db
|
|
.query('objetoItems')
|
|
.withIndex('by_pedidoId', (q) => q.eq('pedidoId', args.pedidoId))
|
|
.collect();
|
|
|
|
for (const item of items) {
|
|
const user = await ctx.db
|
|
.query('usuarios')
|
|
.withIndex('by_funcionarioId', (q) => q.eq('funcionarioId', item.adicionadoPor))
|
|
.first();
|
|
if (user && user._id !== args.actorId) {
|
|
recipients.add(user._id);
|
|
}
|
|
}
|
|
|
|
// Send Notifications
|
|
for (const recipientId of recipients) {
|
|
const recipientIdTyped = recipientId as Id<'usuarios'>;
|
|
|
|
// 1. In-App Notification
|
|
await ctx.db.insert('notificacoes', {
|
|
usuarioId: recipientIdTyped,
|
|
tipo: 'alerta_seguranca', // Using alerta_seguranca as the closest match for system notifications
|
|
titulo: `Pedido ${pedido.numeroSei || 'sem número SEI'} atualizado`,
|
|
descricao: `Status alterado de "${args.oldStatus}" para "${args.newStatus}" por ${actorName}.`,
|
|
lida: false,
|
|
criadaEm: Date.now(),
|
|
remetenteId: args.actorId
|
|
});
|
|
|
|
// 2. Email Notification (Async)
|
|
const recipientUser = await ctx.db.get(recipientIdTyped);
|
|
if (recipientUser && recipientUser.email) {
|
|
// Using enfileirarEmail directly
|
|
await ctx.scheduler.runAfter(0, api.email.enfileirarEmail, {
|
|
destinatario: recipientUser.email,
|
|
destinatarioId: recipientIdTyped,
|
|
assunto: `Atualização no Pedido ${pedido.numeroSei || 'sem número SEI'}`,
|
|
corpo: `O pedido ${pedido.numeroSei || 'sem número SEI'} teve seu status alterado de "${args.oldStatus}" para "${args.newStatus}" por ${actorName}.`,
|
|
enviadoPor: args.actorId
|
|
});
|
|
}
|
|
}
|
|
}
|
|
});
|