Merge remote-tracking branch 'origin/master' into ajustes_final_etapa1
This commit is contained in:
4
packages/backend/convex/_generated/api.d.ts
vendored
4
packages/backend/convex/_generated/api.d.ts
vendored
@@ -52,6 +52,7 @@ import type * as monitoramento from "../monitoramento.js";
|
||||
import type * as objetos from "../objetos.js";
|
||||
import type * as pedidos from "../pedidos.js";
|
||||
import type * as permissoesAcoes from "../permissoesAcoes.js";
|
||||
import type * as planejamentos from "../planejamentos.js";
|
||||
import type * as pontos from "../pontos.js";
|
||||
import type * as preferenciasNotificacao from "../preferenciasNotificacao.js";
|
||||
import type * as pushNotifications from "../pushNotifications.js";
|
||||
@@ -77,6 +78,7 @@ import type * as tables_lgpdTables from "../tables/lgpdTables.js";
|
||||
import type * as tables_licencas from "../tables/licencas.js";
|
||||
import type * as tables_objetos from "../tables/objetos.js";
|
||||
import type * as tables_pedidos from "../tables/pedidos.js";
|
||||
import type * as tables_planejamentos from "../tables/planejamentos.js";
|
||||
import type * as tables_ponto from "../tables/ponto.js";
|
||||
import type * as tables_security from "../tables/security.js";
|
||||
import type * as tables_setores from "../tables/setores.js";
|
||||
@@ -144,6 +146,7 @@ declare const fullApi: ApiFromModules<{
|
||||
objetos: typeof objetos;
|
||||
pedidos: typeof pedidos;
|
||||
permissoesAcoes: typeof permissoesAcoes;
|
||||
planejamentos: typeof planejamentos;
|
||||
pontos: typeof pontos;
|
||||
preferenciasNotificacao: typeof preferenciasNotificacao;
|
||||
pushNotifications: typeof pushNotifications;
|
||||
@@ -169,6 +172,7 @@ declare const fullApi: ApiFromModules<{
|
||||
"tables/licencas": typeof tables_licencas;
|
||||
"tables/objetos": typeof tables_objetos;
|
||||
"tables/pedidos": typeof tables_pedidos;
|
||||
"tables/planejamentos": typeof tables_planejamentos;
|
||||
"tables/ponto": typeof tables_ponto;
|
||||
"tables/security": typeof tables_security;
|
||||
"tables/setores": typeof tables_setores;
|
||||
|
||||
@@ -4,14 +4,67 @@ import type { Id } from './_generated/dataModel';
|
||||
import { getCurrentUserFunction } from './auth';
|
||||
import { internal } from './_generated/api';
|
||||
|
||||
function normalizeLimitePercentual(value: number | undefined): number {
|
||||
const fallback = 50;
|
||||
if (value === undefined) return fallback;
|
||||
if (!Number.isFinite(value)) return fallback;
|
||||
if (value < 0) return 0;
|
||||
if (value > 100) return 100;
|
||||
return value;
|
||||
}
|
||||
|
||||
function assertQuantidadeTotalValida(value: number) {
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
throw new Error('Quantidade do produto na ata deve ser maior que zero.');
|
||||
}
|
||||
}
|
||||
|
||||
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 ataFimEfetivo = (() => {
|
||||
const a = ata.dataFim;
|
||||
const b = (ata as { dataProrrogacao?: string }).dataProrrogacao;
|
||||
if (!a && !b) return '9999-12-31';
|
||||
if (!a) return b!;
|
||||
if (!b) return a;
|
||||
return a >= b ? a : b;
|
||||
})();
|
||||
|
||||
const periodoOk =
|
||||
(!periodoInicio && !periodoFim) ||
|
||||
(periodoInicio &&
|
||||
periodoFim &&
|
||||
ataInicio <= periodoFim &&
|
||||
ataFimEfetivo >= periodoInicio) ||
|
||||
(periodoInicio && !periodoFim && ataFimEfetivo >= periodoInicio) ||
|
||||
(!periodoInicio && periodoFim && ataInicio <= periodoFim);
|
||||
|
||||
return numeroOk && seiOk && periodoOk;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -43,6 +96,34 @@ export const getObjetos = query({
|
||||
}
|
||||
});
|
||||
|
||||
export const getObjetosConfig = query({
|
||||
args: { id: v.id('atas') },
|
||||
returns: v.array(
|
||||
v.object({
|
||||
objetoId: v.id('objetos'),
|
||||
quantidadeTotal: v.union(v.number(), v.null()),
|
||||
limitePercentual: v.union(v.number(), v.null())
|
||||
})
|
||||
),
|
||||
handler: async (ctx, args) => {
|
||||
await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, {
|
||||
recurso: 'atas',
|
||||
acao: 'ver'
|
||||
});
|
||||
|
||||
const links = await ctx.db
|
||||
.query('atasObjetos')
|
||||
.withIndex('by_ataId', (q) => q.eq('ataId', args.id))
|
||||
.collect();
|
||||
|
||||
return links.map((l) => ({
|
||||
objetoId: l.objetoId,
|
||||
quantidadeTotal: l.quantidadeTotal ?? null,
|
||||
limitePercentual: l.limitePercentual ?? null
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
export const listByObjetoIds = query({
|
||||
args: {
|
||||
objetoIds: v.array(v.id('objetos'))
|
||||
@@ -78,9 +159,16 @@ export const create = mutation({
|
||||
numero: v.string(),
|
||||
dataInicio: v.optional(v.string()),
|
||||
dataFim: v.optional(v.string()),
|
||||
dataProrrogacao: v.optional(v.string()),
|
||||
empresaId: v.id('empresas'),
|
||||
numeroSei: v.string(),
|
||||
objetosIds: v.array(v.id('objetos'))
|
||||
objetos: v.array(
|
||||
v.object({
|
||||
objetoId: v.id('objetos'),
|
||||
quantidadeTotal: v.number(),
|
||||
limitePercentual: v.optional(v.number())
|
||||
})
|
||||
)
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, {
|
||||
@@ -97,16 +185,23 @@ export const create = mutation({
|
||||
empresaId: args.empresaId,
|
||||
dataInicio: args.dataInicio,
|
||||
dataFim: args.dataFim,
|
||||
dataProrrogacao: args.dataProrrogacao,
|
||||
criadoPor: user._id,
|
||||
criadoEm: Date.now(),
|
||||
atualizadoEm: Date.now()
|
||||
});
|
||||
|
||||
// Vincular objetos
|
||||
for (const objetoId of args.objetosIds) {
|
||||
for (const cfg of args.objetos) {
|
||||
assertQuantidadeTotalValida(cfg.quantidadeTotal);
|
||||
const limitePercentual = normalizeLimitePercentual(cfg.limitePercentual);
|
||||
|
||||
await ctx.db.insert('atasObjetos', {
|
||||
ataId,
|
||||
objetoId
|
||||
objetoId: cfg.objetoId,
|
||||
quantidadeTotal: cfg.quantidadeTotal,
|
||||
limitePercentual,
|
||||
quantidadeUsada: 0
|
||||
});
|
||||
}
|
||||
|
||||
@@ -122,7 +217,14 @@ export const update = mutation({
|
||||
empresaId: v.id('empresas'),
|
||||
dataInicio: v.optional(v.string()),
|
||||
dataFim: v.optional(v.string()),
|
||||
objetosIds: v.array(v.id('objetos'))
|
||||
dataProrrogacao: v.optional(v.string()),
|
||||
objetos: v.array(
|
||||
v.object({
|
||||
objetoId: v.id('objetos'),
|
||||
quantidadeTotal: v.number(),
|
||||
limitePercentual: v.optional(v.number())
|
||||
})
|
||||
)
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, {
|
||||
@@ -139,26 +241,76 @@ export const update = mutation({
|
||||
empresaId: args.empresaId,
|
||||
dataInicio: args.dataInicio,
|
||||
dataFim: args.dataFim,
|
||||
dataProrrogacao: args.dataProrrogacao,
|
||||
atualizadoEm: Date.now()
|
||||
});
|
||||
|
||||
// Atualizar objetos vinculados
|
||||
// Primeiro remove todos os vínculos existentes
|
||||
const existingLinks = await ctx.db
|
||||
.query('atasObjetos')
|
||||
.withIndex('by_ataId', (q) => q.eq('ataId', args.id))
|
||||
.collect();
|
||||
|
||||
const existingByObjeto = new Map<Id<'objetos'>, (typeof existingLinks)[number]>();
|
||||
for (const link of existingLinks) {
|
||||
await ctx.db.delete(link._id);
|
||||
existingByObjeto.set(link.objetoId, link);
|
||||
}
|
||||
|
||||
// Adiciona os novos vínculos
|
||||
for (const objetoId of args.objetosIds) {
|
||||
await ctx.db.insert('atasObjetos', {
|
||||
ataId: args.id,
|
||||
objetoId
|
||||
});
|
||||
const desiredObjetoIds = new Set<Id<'objetos'>>(args.objetos.map((o) => o.objetoId));
|
||||
|
||||
// Upsert dos vínculos desejados (preserva quantidadeUsada quando já existe)
|
||||
for (const cfg of args.objetos) {
|
||||
assertQuantidadeTotalValida(cfg.quantidadeTotal);
|
||||
const limitePercentual = normalizeLimitePercentual(cfg.limitePercentual);
|
||||
|
||||
const existing = existingByObjeto.get(cfg.objetoId);
|
||||
if (existing) {
|
||||
await ctx.db.patch(existing._id, {
|
||||
quantidadeTotal: cfg.quantidadeTotal,
|
||||
limitePercentual
|
||||
});
|
||||
} else {
|
||||
await ctx.db.insert('atasObjetos', {
|
||||
ataId: args.id,
|
||||
objetoId: cfg.objetoId,
|
||||
quantidadeTotal: cfg.quantidadeTotal,
|
||||
limitePercentual,
|
||||
quantidadeUsada: 0
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Remoção de vínculos não selecionados (somente se não houver uso em pedidos não-cancelados)
|
||||
for (const link of existingLinks) {
|
||||
if (desiredObjetoIds.has(link.objetoId)) continue;
|
||||
|
||||
const items = await ctx.db
|
||||
.query('objetoItems')
|
||||
.withIndex('by_ataId_and_objetoId', (q) =>
|
||||
q.eq('ataId', args.id).eq('objetoId', link.objetoId)
|
||||
)
|
||||
.collect();
|
||||
|
||||
// Se existe qualquer item em pedido não cancelado, bloquear remoção do vínculo
|
||||
let inUse = false;
|
||||
const seenPedidos = new Set<Id<'pedidos'>>();
|
||||
for (const item of items) {
|
||||
if (seenPedidos.has(item.pedidoId)) continue;
|
||||
seenPedidos.add(item.pedidoId);
|
||||
const pedido = await ctx.db.get(item.pedidoId);
|
||||
if (pedido && pedido.status !== 'cancelado') {
|
||||
inUse = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (inUse) {
|
||||
throw new Error(
|
||||
'Não é possível remover este objeto da ata porque já existe uso em pedidos não cancelados.'
|
||||
);
|
||||
}
|
||||
|
||||
await ctx.db.delete(link._id);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -8,8 +8,6 @@ import { type MutationCtx, type QueryCtx, type ActionCtx, query } from './_gener
|
||||
// Usar SITE_URL se disponível, caso contrário usar CONVEX_SITE_URL ou um valor padrão
|
||||
const siteUrl = process.env.SITE_URL || process.env.CONVEX_SITE_URL || 'http://localhost:5173';
|
||||
|
||||
console.log('siteUrl:', siteUrl);
|
||||
|
||||
// The component client has methods needed for integrating Convex with Better Auth,
|
||||
// as well as helper methods for general use.
|
||||
export const authComponent = createClient<DataModel>(components.betterAuth);
|
||||
|
||||
@@ -5,15 +5,37 @@ import { getCurrentUserFunction } from './auth';
|
||||
import type { Id } from './_generated/dataModel';
|
||||
|
||||
export const list = query({
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
args: {
|
||||
query: v.optional(v.string())
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, {
|
||||
recurso: 'empresas',
|
||||
acao: 'listar'
|
||||
});
|
||||
|
||||
const empresas = await ctx.db.query('empresas').collect();
|
||||
return empresas;
|
||||
const term = args.query?.trim();
|
||||
if (!term) return empresas;
|
||||
|
||||
const termLower = term.toLowerCase();
|
||||
const termDigits = term.replace(/\D/g, '');
|
||||
|
||||
return empresas.filter((empresa) => {
|
||||
const razao = (empresa.razao_social || '').toLowerCase();
|
||||
const fantasia = (empresa.nome_fantasia || '').toLowerCase();
|
||||
|
||||
const cnpjRaw = empresa.cnpj || '';
|
||||
const cnpjLower = cnpjRaw.toLowerCase();
|
||||
const cnpjDigits = cnpjRaw.replace(/\D/g, '');
|
||||
|
||||
const matchNome = razao.includes(termLower) || fantasia.includes(termLower);
|
||||
const matchCnpj = termDigits
|
||||
? cnpjDigits.includes(termDigits)
|
||||
: cnpjLower.includes(termLower);
|
||||
|
||||
return matchNome || matchCnpj;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,11 +1,184 @@
|
||||
import { v } from 'convex/values';
|
||||
import { mutation, query } from './_generated/server';
|
||||
import type { Id } from './_generated/dataModel';
|
||||
import type { QueryCtx } from './_generated/server';
|
||||
import { getCurrentUserFunction } from './auth';
|
||||
import { addMonthsClampedYMD, getTodayYMD, isWithinRangeYMD, maxYMD } from './utils/datas';
|
||||
|
||||
const ataComLimiteValidator = v.object({
|
||||
_id: v.id('atas'),
|
||||
numero: v.string(),
|
||||
numeroSei: v.string(),
|
||||
dataInicio: v.union(v.string(), v.null()),
|
||||
dataFim: v.union(v.string(), v.null()),
|
||||
dataProrrogacao: v.union(v.string(), v.null()),
|
||||
dataFimEfetiva: v.union(v.string(), v.null()),
|
||||
quantidadeTotal: v.union(v.number(), v.null()),
|
||||
limitePercentual: v.number(),
|
||||
quantidadeUsada: v.number(),
|
||||
limitePermitido: v.number(),
|
||||
restante: v.number(),
|
||||
isLocked: v.boolean(),
|
||||
isVencidaRecentemente: v.boolean(),
|
||||
lockReason: v.union(
|
||||
v.literal('vigencia_expirada'),
|
||||
v.literal('limite_atingido'),
|
||||
v.literal('nao_configurada'),
|
||||
v.null()
|
||||
)
|
||||
});
|
||||
|
||||
async function computeAtasComLimiteForObjeto(
|
||||
ctx: QueryCtx,
|
||||
objetoId: Id<'objetos'>,
|
||||
includeAtaIds?: Id<'atas'>[]
|
||||
) {
|
||||
const today = getTodayYMD();
|
||||
const threeMonthsAgo = addMonthsClampedYMD(today, -3);
|
||||
const includeSet = new Set<Id<'atas'>>(includeAtaIds ?? []);
|
||||
|
||||
const links = await ctx.db
|
||||
.query('atasObjetos')
|
||||
.withIndex('by_objetoId', (q) => q.eq('objetoId', objetoId))
|
||||
.collect();
|
||||
|
||||
const results: Array<{
|
||||
_id: Id<'atas'>;
|
||||
numero: string;
|
||||
numeroSei: string;
|
||||
dataInicio: string | null;
|
||||
dataFim: string | null;
|
||||
dataProrrogacao: string | null;
|
||||
dataFimEfetiva: string | null;
|
||||
quantidadeTotal: number | null;
|
||||
limitePercentual: number;
|
||||
quantidadeUsada: number;
|
||||
limitePermitido: number;
|
||||
restante: number;
|
||||
isLocked: boolean;
|
||||
isVencidaRecentemente: boolean;
|
||||
lockReason: 'vigencia_expirada' | 'limite_atingido' | 'nao_configurada' | null;
|
||||
}> = [];
|
||||
|
||||
for (const link of links) {
|
||||
const ata = await ctx.db.get(link.ataId);
|
||||
if (!ata) continue;
|
||||
|
||||
const dataProrrogacao = (ata as { dataProrrogacao?: string }).dataProrrogacao ?? null;
|
||||
const dataFimEfetiva = maxYMD(ata.dataFim ?? null, dataProrrogacao);
|
||||
|
||||
const vigenteHoje = isWithinRangeYMD(today, ata.dataInicio ?? null, dataFimEfetiva);
|
||||
const isVencidaRecentemente =
|
||||
!!dataFimEfetiva && dataFimEfetiva < today && dataFimEfetiva >= threeMonthsAgo;
|
||||
|
||||
// Para seleção em pedidos: manter somente vigentes e vencidas recentemente,
|
||||
// mas permitir incluir atas específicas (ex.: já selecionadas em pedido antigo).
|
||||
const shouldForceInclude = includeSet.has(ata._id);
|
||||
if (!shouldForceInclude && !vigenteHoje && !isVencidaRecentemente) {
|
||||
continue;
|
||||
}
|
||||
const isForaDaVigencia = !vigenteHoje;
|
||||
|
||||
let quantidadeUsada = link.quantidadeUsada ?? 0;
|
||||
if (link.quantidadeUsada === undefined) {
|
||||
const items = await ctx.db
|
||||
.query('objetoItems')
|
||||
.withIndex('by_ataId_and_objetoId', (q) =>
|
||||
q.eq('ataId', link.ataId).eq('objetoId', link.objetoId)
|
||||
)
|
||||
.collect();
|
||||
|
||||
const sumByPedidoId = new Map<Id<'pedidos'>, number>();
|
||||
for (const item of items) {
|
||||
const prev = sumByPedidoId.get(item.pedidoId) ?? 0;
|
||||
sumByPedidoId.set(item.pedidoId, prev + item.quantidade);
|
||||
}
|
||||
|
||||
let total = 0;
|
||||
for (const [pedidoId, sum] of sumByPedidoId.entries()) {
|
||||
const pedido = await ctx.db.get(pedidoId);
|
||||
if (pedido && pedido.status !== 'cancelado') {
|
||||
total += sum;
|
||||
}
|
||||
}
|
||||
quantidadeUsada = total;
|
||||
}
|
||||
|
||||
const quantidadeTotal = link.quantidadeTotal ?? null;
|
||||
const limitePercentualRaw = link.limitePercentual ?? 50;
|
||||
const limitePercentual = Number.isFinite(limitePercentualRaw)
|
||||
? Math.min(100, Math.max(0, limitePercentualRaw))
|
||||
: 50;
|
||||
|
||||
const limitePermitido =
|
||||
quantidadeTotal && quantidadeTotal > 0
|
||||
? Math.floor(quantidadeTotal * (limitePercentual / 100))
|
||||
: 0;
|
||||
|
||||
const restante = Math.max(0, limitePermitido - quantidadeUsada);
|
||||
|
||||
const lockReason: 'nao_configurada' | 'limite_atingido' | 'vigencia_expirada' | null =
|
||||
!quantidadeTotal || quantidadeTotal <= 0
|
||||
? 'nao_configurada'
|
||||
: quantidadeUsada >= limitePermitido
|
||||
? 'limite_atingido'
|
||||
: isForaDaVigencia
|
||||
? 'vigencia_expirada'
|
||||
: null;
|
||||
|
||||
const isLocked = lockReason !== null;
|
||||
|
||||
results.push({
|
||||
_id: ata._id,
|
||||
numero: ata.numero,
|
||||
numeroSei: ata.numeroSei,
|
||||
dataInicio: ata.dataInicio ?? null,
|
||||
dataFim: ata.dataFim ?? null,
|
||||
dataProrrogacao,
|
||||
dataFimEfetiva,
|
||||
quantidadeTotal,
|
||||
limitePercentual,
|
||||
quantidadeUsada,
|
||||
limitePermitido,
|
||||
restante,
|
||||
isLocked,
|
||||
isVencidaRecentemente,
|
||||
lockReason
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
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;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -120,6 +293,43 @@ export const getAtas = query({
|
||||
}
|
||||
});
|
||||
|
||||
export const getAtasComLimite = query({
|
||||
args: {
|
||||
objetoId: v.id('objetos'),
|
||||
// Permite incluir atas específicas (ex.: já selecionadas em um pedido antigo),
|
||||
// mesmo que não estejam vigentes ou não tenham vencido nos últimos 3 meses.
|
||||
includeAtaIds: v.optional(v.array(v.id('atas')))
|
||||
},
|
||||
returns: v.array(ataComLimiteValidator),
|
||||
handler: async (ctx, args) => {
|
||||
return await computeAtasComLimiteForObjeto(ctx, args.objetoId, args.includeAtaIds);
|
||||
}
|
||||
});
|
||||
|
||||
export const getAtasComLimiteBatch = query({
|
||||
args: {
|
||||
objetoIds: v.array(v.id('objetos')),
|
||||
includeAtaIds: v.optional(v.array(v.id('atas')))
|
||||
},
|
||||
returns: v.array(
|
||||
v.object({
|
||||
objetoId: v.id('objetos'),
|
||||
atas: v.array(ataComLimiteValidator)
|
||||
})
|
||||
),
|
||||
handler: async (ctx, args) => {
|
||||
if (args.objetoIds.length === 0) return [];
|
||||
const include = args.includeAtaIds ?? [];
|
||||
|
||||
const out = [];
|
||||
for (const objetoId of args.objetoIds) {
|
||||
const atas = await computeAtasComLimiteForObjeto(ctx, objetoId, include);
|
||||
out.push({ objetoId, atas });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
});
|
||||
|
||||
export const remove = mutation({
|
||||
args: {
|
||||
id: v.id('objetos')
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
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;
|
||||
}
|
||||
});
|
||||
@@ -14,6 +14,7 @@ import { funcionariosTables } from './tables/funcionarios';
|
||||
import { licencasTables } from './tables/licencas';
|
||||
import { objetosTables } from './tables/objetos';
|
||||
import { pedidosTables } from './tables/pedidos';
|
||||
import { planejamentosTables } from './tables/planejamentos';
|
||||
import { pontoTables } from './tables/ponto';
|
||||
import { securityTables } from './tables/security';
|
||||
import { setoresTables } from './tables/setores';
|
||||
@@ -42,6 +43,7 @@ export default defineSchema({
|
||||
...securityTables,
|
||||
...pontoTables,
|
||||
...pedidosTables,
|
||||
...planejamentosTables,
|
||||
...objetosTables,
|
||||
...atasTables,
|
||||
...lgpdTables
|
||||
|
||||
@@ -6,6 +6,7 @@ export const atasTables = {
|
||||
numero: v.string(),
|
||||
dataInicio: v.optional(v.string()),
|
||||
dataFim: v.optional(v.string()),
|
||||
dataProrrogacao: v.optional(v.string()),
|
||||
empresaId: v.id('empresas'),
|
||||
numeroSei: v.string(),
|
||||
criadoPor: v.id('usuarios'),
|
||||
@@ -18,10 +19,16 @@ export const atasTables = {
|
||||
|
||||
atasObjetos: defineTable({
|
||||
ataId: v.id('atas'),
|
||||
objetoId: v.id('objetos')
|
||||
objetoId: v.id('objetos'),
|
||||
// Configuração de limite de uso por (ataId, objetoId)
|
||||
quantidadeTotal: v.optional(v.number()),
|
||||
limitePercentual: v.optional(v.number()), // padrão lógico: 50
|
||||
// Controle transacional para evitar corrida; se ausente, pode ser inicializado via rebuild.
|
||||
quantidadeUsada: v.optional(v.number())
|
||||
})
|
||||
.index('by_ataId', ['ataId'])
|
||||
.index('by_objetoId', ['objetoId']),
|
||||
.index('by_objetoId', ['objetoId'])
|
||||
.index('by_ataId_and_objetoId', ['ataId', 'objetoId']),
|
||||
|
||||
atasDocumentos: defineTable({
|
||||
ataId: v.id('atas'),
|
||||
|
||||
@@ -4,6 +4,7 @@ import { v } from 'convex/values';
|
||||
export const pedidosTables = {
|
||||
pedidos: defineTable({
|
||||
numeroSei: v.optional(v.string()),
|
||||
numeroDfd: v.optional(v.string()),
|
||||
status: v.union(
|
||||
v.literal('em_rascunho'),
|
||||
v.literal('aguardando_aceite'),
|
||||
@@ -16,23 +17,31 @@ export const pedidosTables = {
|
||||
criadoPor: v.id('usuarios'),
|
||||
aceitoPor: v.optional(v.id('funcionarios')),
|
||||
descricaoAjuste: v.optional(v.string()), // Required when status is 'precisa_ajustes'
|
||||
concluidoEm: v.optional(v.number()),
|
||||
criadoEm: v.number(),
|
||||
atualizadoEm: v.number()
|
||||
})
|
||||
.index('by_numeroSei', ['numeroSei'])
|
||||
.index('by_numeroDfd', ['numeroDfd'])
|
||||
.index('by_status', ['status'])
|
||||
.index('by_criadoPor', ['criadoPor']),
|
||||
.index('by_criadoPor', ['criadoPor'])
|
||||
.index('by_aceitoPor', ['aceitoPor'])
|
||||
.index('by_criadoEm', ['criadoEm'])
|
||||
.index('by_concluidoEm', ['concluidoEm']),
|
||||
|
||||
objetoItems: defineTable({
|
||||
pedidoId: v.id('pedidos'),
|
||||
objetoId: v.id('objetos'), // was produtoId
|
||||
ataId: v.optional(v.id('atas')),
|
||||
acaoId: v.optional(v.id('acoes')), // Moved from pedidos
|
||||
modalidade: v.union(
|
||||
v.literal('dispensa'),
|
||||
v.literal('inexgibilidade'),
|
||||
v.literal('adesao'),
|
||||
v.literal('consumo')
|
||||
// Opcional: permite criar itens sem definir modalidade upfront (ex: geração via planejamento)
|
||||
modalidade: v.optional(
|
||||
v.union(
|
||||
v.literal('dispensa'),
|
||||
v.literal('inexgibilidade'),
|
||||
v.literal('adesao'),
|
||||
v.literal('consumo')
|
||||
)
|
||||
),
|
||||
valorEstimado: v.string(),
|
||||
valorReal: v.optional(v.string()),
|
||||
@@ -42,6 +51,7 @@ export const pedidosTables = {
|
||||
})
|
||||
.index('by_pedidoId', ['pedidoId'])
|
||||
.index('by_objetoId', ['objetoId'])
|
||||
.index('by_ataId_and_objetoId', ['ataId', 'objetoId'])
|
||||
.index('by_adicionadoPor', ['adicionadoPor'])
|
||||
.index('by_acaoId', ['acaoId']),
|
||||
|
||||
@@ -70,5 +80,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'])
|
||||
};
|
||||
|
||||
45
packages/backend/convex/tables/planejamentos.ts
Normal file
45
packages/backend/convex/tables/planejamentos.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { defineTable } from 'convex/server';
|
||||
import { v } from 'convex/values';
|
||||
|
||||
export const planejamentosTables = {
|
||||
planejamentosPedidos: defineTable({
|
||||
titulo: v.string(),
|
||||
descricao: v.string(),
|
||||
// Armazenar como yyyy-MM-dd para facilitar input type="date" no frontend.
|
||||
data: v.string(),
|
||||
responsavelId: v.id('funcionarios'),
|
||||
acaoId: v.optional(v.id('acoes')),
|
||||
status: v.union(v.literal('rascunho'), v.literal('gerado'), v.literal('cancelado')),
|
||||
criadoPor: v.id('usuarios'),
|
||||
criadoEm: v.number(),
|
||||
atualizadoEm: v.number()
|
||||
})
|
||||
.index('by_responsavelId', ['responsavelId'])
|
||||
.index('by_status', ['status'])
|
||||
.index('by_criadoEm', ['criadoEm']),
|
||||
|
||||
planejamentoItens: defineTable({
|
||||
planejamentoId: v.id('planejamentosPedidos'),
|
||||
// Opcional no cadastro; obrigatório para gerar pedidos.
|
||||
numeroDfd: v.optional(v.string()),
|
||||
objetoId: v.id('objetos'),
|
||||
quantidade: v.number(),
|
||||
valorEstimado: v.string(),
|
||||
// Preenchido após a geração (itens foram materializados no pedido).
|
||||
pedidoId: v.optional(v.id('pedidos')),
|
||||
criadoEm: v.number(),
|
||||
atualizadoEm: v.number()
|
||||
})
|
||||
.index('by_planejamentoId', ['planejamentoId'])
|
||||
.index('by_planejamentoId_and_numeroDfd', ['planejamentoId', 'numeroDfd']),
|
||||
|
||||
planejamentoPedidosLinks: defineTable({
|
||||
planejamentoId: v.id('planejamentosPedidos'),
|
||||
numeroDfd: v.string(),
|
||||
pedidoId: v.id('pedidos'),
|
||||
criadoEm: v.number()
|
||||
})
|
||||
.index('by_planejamentoId', ['planejamentoId'])
|
||||
.index('by_pedidoId', ['pedidoId'])
|
||||
.index('by_planejamentoId_and_numeroDfd', ['planejamentoId', 'numeroDfd'])
|
||||
};
|
||||
@@ -7,10 +7,10 @@
|
||||
* Converte uma string de data no formato YYYY-MM-DD para um objeto Date local
|
||||
* No ambiente Convex, as datas são tratadas como UTC, então precisamos garantir
|
||||
* que a data seja interpretada corretamente.
|
||||
*
|
||||
*
|
||||
* @param dateString - String no formato YYYY-MM-DD
|
||||
* @returns Date objeto representando a data
|
||||
*
|
||||
*
|
||||
* @example
|
||||
* parseLocalDate('2024-01-15') // Retorna Date para 15/01/2024
|
||||
*/
|
||||
@@ -42,13 +42,13 @@ export function parseLocalDate(dateString: string): Date {
|
||||
|
||||
/**
|
||||
* Formata uma data para o formato brasileiro (DD/MM/YYYY)
|
||||
*
|
||||
*
|
||||
* @param date - Date objeto ou string no formato YYYY-MM-DD
|
||||
* @returns String formatada no formato DD/MM/YYYY
|
||||
*/
|
||||
export function formatarDataBR(date: Date | string): string {
|
||||
let dateObj: Date;
|
||||
|
||||
|
||||
if (typeof date === 'string') {
|
||||
dateObj = parseLocalDate(date);
|
||||
} else {
|
||||
@@ -63,3 +63,69 @@ export function formatarDataBR(date: Date | string): string {
|
||||
return `${day}/${month}/${year}`;
|
||||
}
|
||||
|
||||
function pad2(n: number): string {
|
||||
return String(n).padStart(2, '0');
|
||||
}
|
||||
|
||||
/**
|
||||
* Retorna a data atual (UTC) no formato YYYY-MM-DD.
|
||||
* Útil para comparações lexicográficas com strings YYYY-MM-DD persistidas no banco.
|
||||
*/
|
||||
export function getTodayYMD(): string {
|
||||
const now = new Date();
|
||||
const y = now.getUTCFullYear();
|
||||
const m = now.getUTCMonth() + 1;
|
||||
const d = now.getUTCDate();
|
||||
return `${y}-${pad2(m)}-${pad2(d)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Soma meses (calendário) a uma data YYYY-MM-DD, mantendo o dia quando possível,
|
||||
* e fazendo clamp para o último dia do mês quando necessário.
|
||||
*
|
||||
* Ex.: 2025-03-31 + (-1) mês => 2025-02-28 (ou 29 em ano bissexto)
|
||||
*/
|
||||
export function addMonthsClampedYMD(dateString: string, deltaMonths: number): string {
|
||||
const base = parseLocalDate(dateString); // UTC midnight
|
||||
const year = base.getUTCFullYear();
|
||||
const monthIndex = base.getUTCMonth(); // 0..11
|
||||
const day = base.getUTCDate();
|
||||
|
||||
const totalMonths = monthIndex + deltaMonths;
|
||||
const newYear = year + Math.floor(totalMonths / 12);
|
||||
let newMonthIndex = totalMonths % 12;
|
||||
if (newMonthIndex < 0) {
|
||||
newMonthIndex += 12;
|
||||
}
|
||||
|
||||
// Último dia do mês alvo
|
||||
const lastDay = new Date(Date.UTC(newYear, newMonthIndex + 1, 0)).getUTCDate();
|
||||
const newDay = Math.min(day, lastDay);
|
||||
|
||||
return `${newYear}-${pad2(newMonthIndex + 1)}-${pad2(newDay)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retorna o maior (mais recente) entre duas datas YYYY-MM-DD (lexicograficamente).
|
||||
* Se uma delas for null/undefined, retorna a outra.
|
||||
*/
|
||||
export function maxYMD(a?: string | null, b?: string | null): string | null {
|
||||
if (!a && !b) return null;
|
||||
if (!a) return b ?? null;
|
||||
if (!b) return a;
|
||||
return a >= b ? a : b;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checa se `date` está dentro do intervalo [inicio..fim], onde
|
||||
* `inicio` e `fim` são YYYY-MM-DD (ou null para aberto).
|
||||
*/
|
||||
export function isWithinRangeYMD(
|
||||
date: string,
|
||||
inicio?: string | null,
|
||||
fim?: string | null
|
||||
): boolean {
|
||||
const start = inicio ?? '0000-01-01';
|
||||
const end = fim ?? '9999-12-31';
|
||||
return start <= date && date <= end;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user