feat: enhance pedidos functionality by adding new submenu options for creating and planning orders, improving user navigation and access control in the sidebar; also implement URL-based prefill for adding items, ensuring a smoother user experience when creating pedidos
This commit is contained in:
514
packages/backend/convex/planejamentos.ts
Normal file
514
packages/backend/convex/planejamentos.ts
Normal file
@@ -0,0 +1,514 @@
|
||||
import { v } from 'convex/values';
|
||||
import type { Doc, Id } from './_generated/dataModel';
|
||||
import { mutation, query } from './_generated/server';
|
||||
import { getCurrentUserFunction } from './auth';
|
||||
|
||||
async function getUsuarioAutenticado(ctx: Parameters<typeof getCurrentUserFunction>[0]) {
|
||||
const user = await getCurrentUserFunction(ctx);
|
||||
if (!user) throw new Error('Unauthorized');
|
||||
return user;
|
||||
}
|
||||
|
||||
function normalizeOptionalString(value: string | undefined): string | undefined {
|
||||
const trimmed = value?.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
|
||||
// ========== QUERIES ==========
|
||||
|
||||
export const list = query({
|
||||
args: {
|
||||
status: v.optional(v.union(v.literal('rascunho'), v.literal('gerado'), v.literal('cancelado'))),
|
||||
responsavelId: v.optional(v.id('funcionarios'))
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const status = args.status;
|
||||
const responsavelId = args.responsavelId;
|
||||
|
||||
let base: Doc<'planejamentosPedidos'>[] = [];
|
||||
|
||||
if (responsavelId) {
|
||||
base = await ctx.db
|
||||
.query('planejamentosPedidos')
|
||||
.withIndex('by_responsavelId', (q) => q.eq('responsavelId', responsavelId))
|
||||
.collect();
|
||||
} else if (status) {
|
||||
base = await ctx.db
|
||||
.query('planejamentosPedidos')
|
||||
.withIndex('by_status', (q) => q.eq('status', status))
|
||||
.collect();
|
||||
} else {
|
||||
base = await ctx.db.query('planejamentosPedidos').collect();
|
||||
}
|
||||
|
||||
base.sort((a, b) => b.criadoEm - a.criadoEm);
|
||||
|
||||
return await Promise.all(
|
||||
base.map(async (p) => {
|
||||
const [responsavel, acao] = await Promise.all([
|
||||
ctx.db.get(p.responsavelId),
|
||||
p.acaoId ? ctx.db.get(p.acaoId) : Promise.resolve(null)
|
||||
]);
|
||||
return {
|
||||
...p,
|
||||
responsavelNome: responsavel?.nome ?? 'Desconhecido',
|
||||
acaoNome: acao?.nome ?? undefined
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
export const get = query({
|
||||
args: { id: v.id('planejamentosPedidos') },
|
||||
handler: async (ctx, args) => {
|
||||
const p = await ctx.db.get(args.id);
|
||||
if (!p) return null;
|
||||
|
||||
const [responsavel, acao] = await Promise.all([
|
||||
ctx.db.get(p.responsavelId),
|
||||
p.acaoId ? ctx.db.get(p.acaoId) : Promise.resolve(null)
|
||||
]);
|
||||
|
||||
return {
|
||||
...p,
|
||||
responsavelNome: responsavel?.nome ?? 'Desconhecido',
|
||||
acaoNome: acao?.nome ?? undefined
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
export const listItems = query({
|
||||
args: { planejamentoId: v.id('planejamentosPedidos') },
|
||||
handler: async (ctx, args) => {
|
||||
const items = await ctx.db
|
||||
.query('planejamentoItens')
|
||||
.withIndex('by_planejamentoId', (q) => q.eq('planejamentoId', args.planejamentoId))
|
||||
.collect();
|
||||
|
||||
// Ordenação útil: primeiro sem pedido, depois por numeroDfd, depois por criadoEm
|
||||
items.sort((a, b) => {
|
||||
const ap = a.pedidoId ? 1 : 0;
|
||||
const bp = b.pedidoId ? 1 : 0;
|
||||
if (ap !== bp) return ap - bp;
|
||||
const ad = (a.numeroDfd ?? '').localeCompare(b.numeroDfd ?? '');
|
||||
if (ad !== 0) return ad;
|
||||
return a.criadoEm - b.criadoEm;
|
||||
});
|
||||
|
||||
return await Promise.all(
|
||||
items.map(async (it) => {
|
||||
const [objeto, pedido] = await Promise.all([
|
||||
ctx.db.get(it.objetoId),
|
||||
it.pedidoId ? ctx.db.get(it.pedidoId) : Promise.resolve(null)
|
||||
]);
|
||||
|
||||
return {
|
||||
...it,
|
||||
objetoNome: objeto?.nome ?? 'Objeto desconhecido',
|
||||
objetoUnidade: objeto?.unidade ?? '',
|
||||
pedidoNumeroSei: pedido?.numeroSei ?? undefined,
|
||||
pedidoStatus: pedido?.status ?? undefined
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
export const listPedidos = query({
|
||||
args: { planejamentoId: v.id('planejamentosPedidos') },
|
||||
handler: async (ctx, args) => {
|
||||
const links = await ctx.db
|
||||
.query('planejamentoPedidosLinks')
|
||||
.withIndex('by_planejamentoId', (q) => q.eq('planejamentoId', args.planejamentoId))
|
||||
.collect();
|
||||
|
||||
links.sort((a, b) => a.numeroDfd.localeCompare(b.numeroDfd));
|
||||
|
||||
return await Promise.all(
|
||||
links.map(async (link) => {
|
||||
const pedido = await ctx.db.get(link.pedidoId);
|
||||
if (!pedido) {
|
||||
return {
|
||||
...link,
|
||||
pedido: null,
|
||||
lastHistory: []
|
||||
};
|
||||
}
|
||||
|
||||
const history = await ctx.db
|
||||
.query('historicoPedidos')
|
||||
.withIndex('by_pedidoId', (q) => q.eq('pedidoId', link.pedidoId))
|
||||
.order('desc')
|
||||
.take(3);
|
||||
|
||||
const historyWithNames = await Promise.all(
|
||||
history.map(async (h) => {
|
||||
const usuario = await ctx.db.get(h.usuarioId);
|
||||
return {
|
||||
...h,
|
||||
usuarioNome: usuario?.nome ?? 'Desconhecido'
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
...link,
|
||||
pedido: {
|
||||
_id: pedido._id,
|
||||
numeroSei: pedido.numeroSei,
|
||||
numeroDfd: pedido.numeroDfd,
|
||||
status: pedido.status,
|
||||
criadoEm: pedido.criadoEm,
|
||||
atualizadoEm: pedido.atualizadoEm
|
||||
},
|
||||
lastHistory: historyWithNames
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// ========== MUTATIONS ==========
|
||||
|
||||
export const create = mutation({
|
||||
args: {
|
||||
titulo: v.string(),
|
||||
descricao: v.string(),
|
||||
data: v.string(),
|
||||
responsavelId: v.id('funcionarios'),
|
||||
acaoId: v.optional(v.id('acoes'))
|
||||
},
|
||||
returns: v.id('planejamentosPedidos'),
|
||||
handler: async (ctx, args) => {
|
||||
const user = await getUsuarioAutenticado(ctx);
|
||||
const now = Date.now();
|
||||
|
||||
const titulo = args.titulo.trim();
|
||||
const descricao = args.descricao.trim();
|
||||
const data = args.data.trim();
|
||||
|
||||
if (!titulo) throw new Error('Informe um título.');
|
||||
if (!descricao) throw new Error('Informe uma descrição.');
|
||||
if (!data) throw new Error('Informe uma data.');
|
||||
|
||||
return await ctx.db.insert('planejamentosPedidos', {
|
||||
titulo,
|
||||
descricao,
|
||||
data,
|
||||
responsavelId: args.responsavelId,
|
||||
acaoId: args.acaoId,
|
||||
status: 'rascunho',
|
||||
criadoPor: user._id,
|
||||
criadoEm: now,
|
||||
atualizadoEm: now
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export const update = mutation({
|
||||
args: {
|
||||
id: v.id('planejamentosPedidos'),
|
||||
titulo: v.optional(v.string()),
|
||||
descricao: v.optional(v.string()),
|
||||
data: v.optional(v.string()),
|
||||
responsavelId: v.optional(v.id('funcionarios')),
|
||||
acaoId: v.optional(v.union(v.id('acoes'), v.null()))
|
||||
},
|
||||
returns: v.null(),
|
||||
handler: async (ctx, args) => {
|
||||
await getUsuarioAutenticado(ctx);
|
||||
const p = await ctx.db.get(args.id);
|
||||
if (!p) throw new Error('Planejamento não encontrado.');
|
||||
if (p.status !== 'rascunho')
|
||||
throw new Error('Apenas planejamentos em rascunho podem ser editados.');
|
||||
|
||||
const patch: Partial<Doc<'planejamentosPedidos'>> & { acaoId?: Id<'acoes'> | undefined } = {};
|
||||
|
||||
if (args.titulo !== undefined) {
|
||||
const t = args.titulo.trim();
|
||||
if (!t) throw new Error('Título não pode ficar vazio.');
|
||||
patch.titulo = t;
|
||||
}
|
||||
if (args.descricao !== undefined) {
|
||||
const d = args.descricao.trim();
|
||||
if (!d) throw new Error('Descrição não pode ficar vazia.');
|
||||
patch.descricao = d;
|
||||
}
|
||||
if (args.data !== undefined) {
|
||||
const dt = args.data.trim();
|
||||
if (!dt) throw new Error('Data não pode ficar vazia.');
|
||||
patch.data = dt;
|
||||
}
|
||||
if (args.responsavelId !== undefined) {
|
||||
patch.responsavelId = args.responsavelId;
|
||||
}
|
||||
if (args.acaoId !== undefined) {
|
||||
patch.acaoId = args.acaoId === null ? undefined : args.acaoId;
|
||||
}
|
||||
|
||||
patch.atualizadoEm = Date.now();
|
||||
await ctx.db.patch(args.id, patch);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
export const addItem = mutation({
|
||||
args: {
|
||||
planejamentoId: v.id('planejamentosPedidos'),
|
||||
objetoId: v.id('objetos'),
|
||||
quantidade: v.number(),
|
||||
valorEstimado: v.string(),
|
||||
numeroDfd: v.optional(v.string())
|
||||
},
|
||||
returns: v.id('planejamentoItens'),
|
||||
handler: async (ctx, args) => {
|
||||
await getUsuarioAutenticado(ctx);
|
||||
const p = await ctx.db.get(args.planejamentoId);
|
||||
if (!p) throw new Error('Planejamento não encontrado.');
|
||||
if (p.status !== 'rascunho')
|
||||
throw new Error('Apenas planejamentos em rascunho podem ser editados.');
|
||||
|
||||
if (!Number.isFinite(args.quantidade) || args.quantidade <= 0) {
|
||||
throw new Error('Quantidade inválida.');
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const numeroDfd = normalizeOptionalString(args.numeroDfd);
|
||||
const valorEstimado = args.valorEstimado.trim();
|
||||
if (!valorEstimado) throw new Error('Valor estimado inválido.');
|
||||
|
||||
const itemId = await ctx.db.insert('planejamentoItens', {
|
||||
planejamentoId: args.planejamentoId,
|
||||
numeroDfd,
|
||||
objetoId: args.objetoId,
|
||||
quantidade: args.quantidade,
|
||||
valorEstimado,
|
||||
criadoEm: now,
|
||||
atualizadoEm: now
|
||||
});
|
||||
await ctx.db.patch(args.planejamentoId, { atualizadoEm: Date.now() });
|
||||
return itemId;
|
||||
}
|
||||
});
|
||||
|
||||
export const updateItem = mutation({
|
||||
args: {
|
||||
itemId: v.id('planejamentoItens'),
|
||||
numeroDfd: v.optional(v.union(v.string(), v.null())),
|
||||
quantidade: v.optional(v.number()),
|
||||
valorEstimado: v.optional(v.string())
|
||||
},
|
||||
returns: v.null(),
|
||||
handler: async (ctx, args) => {
|
||||
await getUsuarioAutenticado(ctx);
|
||||
const it = await ctx.db.get(args.itemId);
|
||||
if (!it) throw new Error('Item não encontrado.');
|
||||
|
||||
const p = await ctx.db.get(it.planejamentoId);
|
||||
if (!p) throw new Error('Planejamento não encontrado.');
|
||||
if (p.status !== 'rascunho')
|
||||
throw new Error('Apenas planejamentos em rascunho podem ser editados.');
|
||||
|
||||
const patch: Partial<Doc<'planejamentoItens'>> = { atualizadoEm: Date.now() };
|
||||
|
||||
if (args.numeroDfd !== undefined) {
|
||||
patch.numeroDfd =
|
||||
args.numeroDfd === null
|
||||
? undefined
|
||||
: (normalizeOptionalString(args.numeroDfd) ?? undefined);
|
||||
}
|
||||
if (args.quantidade !== undefined) {
|
||||
if (!Number.isFinite(args.quantidade) || args.quantidade <= 0) {
|
||||
throw new Error('Quantidade inválida.');
|
||||
}
|
||||
patch.quantidade = args.quantidade;
|
||||
}
|
||||
if (args.valorEstimado !== undefined) {
|
||||
const vEst = args.valorEstimado.trim();
|
||||
if (!vEst) throw new Error('Valor estimado inválido.');
|
||||
patch.valorEstimado = vEst;
|
||||
}
|
||||
|
||||
await ctx.db.patch(args.itemId, patch);
|
||||
await ctx.db.patch(it.planejamentoId, { atualizadoEm: Date.now() });
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
export const removeItem = mutation({
|
||||
args: { itemId: v.id('planejamentoItens') },
|
||||
returns: v.null(),
|
||||
handler: async (ctx, args) => {
|
||||
await getUsuarioAutenticado(ctx);
|
||||
const it = await ctx.db.get(args.itemId);
|
||||
if (!it) return null;
|
||||
const p = await ctx.db.get(it.planejamentoId);
|
||||
if (!p) throw new Error('Planejamento não encontrado.');
|
||||
if (p.status !== 'rascunho')
|
||||
throw new Error('Apenas planejamentos em rascunho podem ser editados.');
|
||||
await ctx.db.delete(args.itemId);
|
||||
await ctx.db.patch(it.planejamentoId, { atualizadoEm: Date.now() });
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
export const gerarPedidosPorDfd = mutation({
|
||||
args: {
|
||||
planejamentoId: v.id('planejamentosPedidos'),
|
||||
dfds: v.array(
|
||||
v.object({
|
||||
numeroDfd: v.string(),
|
||||
numeroSei: v.string()
|
||||
})
|
||||
)
|
||||
},
|
||||
returns: v.array(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.');
|
||||
}
|
||||
|
||||
const planejamento = await ctx.db.get(args.planejamentoId);
|
||||
if (!planejamento) throw new Error('Planejamento não encontrado.');
|
||||
if (planejamento.status !== 'rascunho') {
|
||||
throw new Error('Este planejamento não está em rascunho.');
|
||||
}
|
||||
|
||||
const items = await ctx.db
|
||||
.query('planejamentoItens')
|
||||
.withIndex('by_planejamentoId', (q) => q.eq('planejamentoId', args.planejamentoId))
|
||||
.collect();
|
||||
|
||||
if (items.length === 0) {
|
||||
throw new Error('Adicione ao menos um item antes de gerar pedidos.');
|
||||
}
|
||||
|
||||
const itensSemDfd = items.filter((i) => !i.numeroDfd || !i.numeroDfd.trim());
|
||||
if (itensSemDfd.length > 0) {
|
||||
throw new Error(
|
||||
`Existem ${itensSemDfd.length} item(ns) sem DFD. Atribua um DFD a todos os itens antes de gerar pedidos.`
|
||||
);
|
||||
}
|
||||
|
||||
const dfdsPayload = args.dfds.map((d) => ({
|
||||
numeroDfd: d.numeroDfd.trim(),
|
||||
numeroSei: d.numeroSei.trim()
|
||||
}));
|
||||
|
||||
if (dfdsPayload.length === 0) {
|
||||
throw new Error('Informe ao menos um DFD para gerar.');
|
||||
}
|
||||
|
||||
for (const d of dfdsPayload) {
|
||||
if (!d.numeroDfd) throw new Error('DFD inválido.');
|
||||
if (!d.numeroSei) throw new Error(`Informe o número SEI para o DFD ${d.numeroDfd}.`);
|
||||
}
|
||||
|
||||
// Validar que todos os DFDs existem nos itens
|
||||
const dfdsFromItems = new Set(items.map((i) => (i.numeroDfd as string).trim()));
|
||||
for (const d of dfdsPayload) {
|
||||
if (!dfdsFromItems.has(d.numeroDfd)) {
|
||||
throw new Error(`DFD ${d.numeroDfd} não existe nos itens do planejamento.`);
|
||||
}
|
||||
}
|
||||
|
||||
// Evitar duplicidade de DFD no payload
|
||||
const payloadSet = new Set<string>();
|
||||
for (const d of dfdsPayload) {
|
||||
if (payloadSet.has(d.numeroDfd)) throw new Error(`DFD duplicado no envio: ${d.numeroDfd}.`);
|
||||
payloadSet.add(d.numeroDfd);
|
||||
}
|
||||
|
||||
// Garantir que será gerado 1 pedido para CADA DFD existente nos itens
|
||||
if (payloadSet.size !== dfdsFromItems.size) {
|
||||
const missing = [...dfdsFromItems].filter((d) => !payloadSet.has(d));
|
||||
throw new Error(`Informe o número SEI para todos os DFDs. Faltando: ${missing.join(', ')}.`);
|
||||
}
|
||||
|
||||
// Não permitir gerar se algum item já tiver sido movido para pedido
|
||||
const jaMovidos = items.filter((i) => i.pedidoId);
|
||||
if (jaMovidos.length > 0) {
|
||||
throw new Error('Este planejamento já possui itens vinculados a pedidos.');
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const pedidoIds: Id<'pedidos'>[] = [];
|
||||
|
||||
for (const dfd of dfdsPayload) {
|
||||
// Criar pedido em rascunho (similar a pedidos.create)
|
||||
const pedidoId = await ctx.db.insert('pedidos', {
|
||||
numeroSei: dfd.numeroSei,
|
||||
numeroDfd: dfd.numeroDfd,
|
||||
status: 'em_rascunho',
|
||||
criadoPor: user._id,
|
||||
criadoEm: now,
|
||||
atualizadoEm: now
|
||||
});
|
||||
|
||||
pedidoIds.push(pedidoId);
|
||||
|
||||
await ctx.db.insert('historicoPedidos', {
|
||||
pedidoId,
|
||||
usuarioId: user._id,
|
||||
acao: 'criacao',
|
||||
detalhes: JSON.stringify({ numeroSei: dfd.numeroSei, numeroDfd: dfd.numeroDfd }),
|
||||
data: now
|
||||
});
|
||||
|
||||
await ctx.db.insert('historicoPedidos', {
|
||||
pedidoId,
|
||||
usuarioId: user._id,
|
||||
acao: 'gerado_de_planejamento',
|
||||
detalhes: JSON.stringify({ planejamentoId: args.planejamentoId, numeroDfd: dfd.numeroDfd }),
|
||||
data: now
|
||||
});
|
||||
|
||||
await ctx.db.insert('planejamentoPedidosLinks', {
|
||||
planejamentoId: args.planejamentoId,
|
||||
numeroDfd: dfd.numeroDfd,
|
||||
pedidoId,
|
||||
criadoEm: now
|
||||
});
|
||||
|
||||
// Mover itens deste DFD para o pedido
|
||||
const itensDfd = items.filter((i) => (i.numeroDfd as string).trim() === dfd.numeroDfd);
|
||||
for (const it of itensDfd) {
|
||||
// Criar item real diretamente no pedido (sem etapa de conversão)
|
||||
await ctx.db.insert('objetoItems', {
|
||||
pedidoId,
|
||||
objetoId: it.objetoId,
|
||||
ataId: undefined,
|
||||
acaoId: undefined,
|
||||
valorEstimado: it.valorEstimado,
|
||||
quantidade: it.quantidade,
|
||||
adicionadoPor: user.funcionarioId,
|
||||
criadoEm: now
|
||||
});
|
||||
|
||||
await ctx.db.insert('historicoPedidos', {
|
||||
pedidoId,
|
||||
usuarioId: user._id,
|
||||
acao: 'adicao_item',
|
||||
detalhes: JSON.stringify({
|
||||
objetoId: it.objetoId,
|
||||
valor: it.valorEstimado,
|
||||
quantidade: it.quantidade,
|
||||
acaoId: null,
|
||||
ataId: null,
|
||||
modalidade: null,
|
||||
origem: { planejamentoId: args.planejamentoId }
|
||||
}),
|
||||
data: now
|
||||
});
|
||||
|
||||
await ctx.db.patch(it._id, { pedidoId, atualizadoEm: Date.now() });
|
||||
}
|
||||
}
|
||||
|
||||
await ctx.db.patch(args.planejamentoId, { status: 'gerado', atualizadoEm: Date.now() });
|
||||
|
||||
return pedidoIds;
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user