540 lines
15 KiB
TypeScript
540 lines
15 KiB
TypeScript
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')),
|
|
sourcePlanningId: v.optional(v.id('planejamentosPedidos'))
|
|
},
|
|
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.');
|
|
|
|
const newItemId = await ctx.db.insert('planejamentosPedidos', {
|
|
titulo,
|
|
descricao,
|
|
data,
|
|
responsavelId: args.responsavelId,
|
|
acaoId: args.acaoId,
|
|
status: 'rascunho',
|
|
criadoPor: user._id,
|
|
criadoEm: now,
|
|
atualizadoEm: now
|
|
});
|
|
|
|
const sourcePlanningId = args.sourcePlanningId;
|
|
|
|
if (sourcePlanningId) {
|
|
const sourceItems = await ctx.db
|
|
.query('planejamentoItens')
|
|
.withIndex('by_planejamentoId', (q) => q.eq('planejamentoId', sourcePlanningId))
|
|
.collect();
|
|
|
|
for (const item of sourceItems) {
|
|
await ctx.db.insert('planejamentoItens', {
|
|
planejamentoId: newItemId,
|
|
objetoId: item.objetoId,
|
|
quantidade: item.quantidade,
|
|
valorEstimado: item.valorEstimado,
|
|
numeroDfd: item.numeroDfd,
|
|
// Não copiamos o pedidoId pois é um novo planejamento
|
|
criadoEm: now,
|
|
atualizadoEm: now
|
|
});
|
|
}
|
|
}
|
|
|
|
return newItemId;
|
|
}
|
|
});
|
|
|
|
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;
|
|
}
|
|
});
|