From 9072619e267da2061790e30ebd0392584e25dabd Mon Sep 17 00:00:00 2001 From: killer-cf Date: Wed, 17 Dec 2025 10:39:33 -0300 Subject: [PATCH] feat: enhance ata management by adding dataProrrogacao field and updating related logic for effective date handling, improving data integrity and user experience in pedidos --- .../(dashboard)/compras/atas/+page.svelte | 32 ++- .../(dashboard)/pedidos/[id]/+page.svelte | 85 ++++-- .../(dashboard)/pedidos/novo/+page.svelte | 25 +- packages/backend/convex/atas.ts | 20 +- packages/backend/convex/objetos.ts | 249 ++++++++++++------ packages/backend/convex/pedidos.ts | 19 ++ packages/backend/convex/tables/atas.ts | 1 + packages/backend/convex/utils/datas.ts | 74 +++++- 8 files changed, 390 insertions(+), 115 deletions(-) diff --git a/apps/web/src/routes/(dashboard)/compras/atas/+page.svelte b/apps/web/src/routes/(dashboard)/compras/atas/+page.svelte index 999152d..1fd3b9c 100644 --- a/apps/web/src/routes/(dashboard)/compras/atas/+page.svelte +++ b/apps/web/src/routes/(dashboard)/compras/atas/+page.svelte @@ -4,6 +4,7 @@ import { useConvexClient, useQuery } from 'convex-svelte'; import { Pencil, Plus, Trash2, X, Search, Check, FileText } from 'lucide-svelte'; import { resolve } from '$app/paths'; + import { formatarDataBR } from '$lib/utils/datas'; const client = useConvexClient(); @@ -41,7 +42,8 @@ numeroSei: '', empresaId: '' as Id<'empresas'> | '', dataInicio: '', - dataFim: '' + dataFim: '', + dataProrrogacao: '' }); let selectedObjetos = $state[]>([]); type ObjetoAtaConfig = { @@ -72,7 +74,8 @@ numeroSei: ata.numeroSei, empresaId: ata.empresaId, dataInicio: ata.dataInicio || '', - dataFim: ata.dataFim || '' + dataFim: ata.dataFim || '', + dataProrrogacao: ata.dataProrrogacao || '' }; // Fetch linked objects const linkedObjetos = await client.query(api.atas.getObjetos, { id: ata._id }); @@ -96,7 +99,8 @@ numeroSei: '', empresaId: '', dataInicio: '', - dataFim: '' + dataFim: '', + dataProrrogacao: '' }; selectedObjetos = []; objetosConfig = {}; @@ -180,6 +184,7 @@ empresaId: formData.empresaId as Id<'empresas'>, dataInicio: formData.dataInicio || undefined, dataFim: formData.dataFim || undefined, + dataProrrogacao: formData.dataProrrogacao || undefined, objetos }; @@ -416,7 +421,13 @@ {getEmpresaNome(ata.empresaId)} - {ata.dataInicio || '-'} a {ata.dataFim || '-'} + {ata.dataInicio ? formatarDataBR(ata.dataInicio) : '-'} a + {ata.dataFim ? formatarDataBR(ata.dataFim) : '-'} + {#if ata.dataProrrogacao} + + (prorrogação: {formatarDataBR(ata.dataProrrogacao)}) + {/if}
@@ -508,7 +519,7 @@
-
+
+
+ + +
diff --git a/apps/web/src/routes/(dashboard)/pedidos/[id]/+page.svelte b/apps/web/src/routes/(dashboard)/pedidos/[id]/+page.svelte index 325f843..af1cecf 100644 --- a/apps/web/src/routes/(dashboard)/pedidos/[id]/+page.svelte +++ b/apps/web/src/routes/(dashboard)/pedidos/[id]/+page.svelte @@ -24,6 +24,7 @@ import { resolve } from '$app/paths'; import { page } from '$app/state'; import { maskCurrencyBRL } from '$lib/utils/masks'; + import { formatarDataBR } from '$lib/utils/datas'; const pedidoId = $derived(page.params.id as Id<'pedidos'>); const client = useConvexClient(); @@ -84,7 +85,7 @@ // Atas por objeto (carregadas sob demanda) type AtasComLimite = FunctionReturnType; - let atasPorObjeto = $state>({}); + let atasPorObjetoExtra = $state>({}); let editingItems = $state>({}); @@ -110,14 +111,39 @@ let selectedCount = $derived(selectedItemIds.size); let hasSelection = $derived(selectedCount > 0); - // Garante que, para todos os itens existentes, as atas do respectivo objeto - // sejam carregadas independentemente do formulário de criação. - $effect(() => { - for (const item of items as unknown as PedidoItemForEdit[]) { - if (!atasPorObjeto[item.objetoId]) { - void loadAtasForObjeto(item.objetoId); + // Pela regra do backend, um pedido só pode ter uma ata (quando houver). + // Usamos isso para “forçar incluir” a ata atual do pedido na listagem do objeto, + // mesmo que esteja fora da janela (ex.: vencida há mais de 3 meses). + let pedidoAtaId = $derived.by(() => { + const withAta = (items as unknown as PedidoItemForEdit[]).find((i) => i.ataId); + return (withAta?.ataId ?? null) as Id<'atas'> | null; + }); + + // Carrega atas (para itens existentes) via query batch, evitando efeitos que mutam estado. + const atasBatchQuery = $derived.by(() => + useQuery(api.objetos.getAtasComLimiteBatch, () => { + const ids: Id<'objetos'>[] = []; + const seen: Record = {}; + for (const item of items as unknown as PedidoItemForEdit[]) { + const key = String(item.objetoId); + if (seen[key]) continue; + seen[key] = true; + ids.push(item.objetoId); } + return { + objetoIds: ids, + includeAtaIds: pedidoAtaId ? [pedidoAtaId] : undefined + }; + }) + ); + + let atasPorObjetoFromBatch = $derived.by(() => { + const map: Record = {}; + const data = atasBatchQuery.data || []; + for (const row of data) { + map[String(row.objetoId)] = row.atas; } + return map; }); // Group items by user @@ -663,19 +689,20 @@ } async function loadAtasForObjeto(objetoId: string) { - if (atasPorObjeto[objetoId]) return; + if (atasPorObjetoExtra[objetoId]) return; try { const linkedAtas = await client.query(api.objetos.getAtasComLimite, { - objetoId: objetoId as Id<'objetos'> + objetoId: objetoId as Id<'objetos'>, + includeAtaIds: pedidoAtaId ? [pedidoAtaId] : undefined }); - atasPorObjeto[objetoId] = linkedAtas; + atasPorObjetoExtra[objetoId] = linkedAtas; } catch (e) { console.error('Erro ao carregar atas para objeto', objetoId, e); } } function getAtasForObjeto(objetoId: string): AtasComLimite { - return atasPorObjeto[objetoId] || []; + return atasPorObjetoExtra[objetoId] || atasPorObjetoFromBatch[objetoId] || []; } function handleObjetoChange(id: string) { @@ -1664,11 +1691,18 @@ {#each getAtasForObjeto(newItem.objetoId) as ata (ata._id)} {@const isSelectedAta = String(ata._id) === newItem.ataId} - {@const reason = !ata.quantidadeTotal - ? 'não configurada' - : ata.quantidadeUsada >= ata.limitePermitido - ? 'limite atingido' - : null} + {@const reason = + ata.lockReason === 'nao_configurada' + ? 'não configurada' + : ata.lockReason === 'limite_atingido' + ? 'limite atingido' + : ata.lockReason === 'vigencia_expirada' + ? `vigência encerrada em ${ + ata.dataFimEfetiva || ata.dataFim + ? formatarDataBR((ata.dataFimEfetiva || ata.dataFim) as string) + : '-' + }` + : null} @@ -1920,11 +1954,20 @@ {#each getAtasForObjeto(item.objetoId) as ata (ata._id)} {@const currentAtaId = ensureEditingItem(item).ataId} {@const isSelectedAta = String(ata._id) === currentAtaId} - {@const reason = !ata.quantidadeTotal - ? 'não configurada' - : ata.quantidadeUsada >= ata.limitePermitido - ? 'limite atingido' - : null} + {@const reason = + ata.lockReason === 'nao_configurada' + ? 'não configurada' + : ata.lockReason === 'limite_atingido' + ? 'limite atingido' + : ata.lockReason === 'vigencia_expirada' + ? `vigência encerrada em ${ + ata.dataFimEfetiva || ata.dataFim + ? formatarDataBR( + (ata.dataFimEfetiva || ata.dataFim) as string + ) + : '-' + }` + : null} diff --git a/apps/web/src/routes/(dashboard)/pedidos/novo/+page.svelte b/apps/web/src/routes/(dashboard)/pedidos/novo/+page.svelte index 23dc799..a8bfe63 100644 --- a/apps/web/src/routes/(dashboard)/pedidos/novo/+page.svelte +++ b/apps/web/src/routes/(dashboard)/pedidos/novo/+page.svelte @@ -6,6 +6,7 @@ import { Plus, Trash2, X, Info } from 'lucide-svelte'; import { goto } from '$app/navigation'; import { resolve } from '$app/paths'; + import { formatarDataBR } from '$lib/utils/datas'; const client = useConvexClient(); @@ -635,11 +636,18 @@ > {#each availableAtas as ata (ata._id)} - {@const reason = !ata.quantidadeTotal - ? 'não configurada' - : ata.quantidadeUsada >= ata.limitePermitido - ? 'limite atingido' - : null} + {@const reason = + ata.lockReason === 'nao_configurada' + ? 'não configurada' + : ata.lockReason === 'limite_atingido' + ? 'limite atingido' + : ata.lockReason === 'vigencia_expirada' + ? `vigência encerrada em ${ + ata.dataFimEfetiva || ata.dataFim + ? formatarDataBR((ata.dataFimEfetiva || ata.dataFim) as string) + : '-' + }` + : null} @@ -735,7 +743,12 @@ {#if detailsItem.ata.dataInicio}

Vigência: - {detailsItem.ata.dataInicio} até {detailsItem.ata.dataFim || 'Indefinido'} + {formatarDataBR(detailsItem.ata.dataInicio)} até {detailsItem.ata + .dataFimEfetiva || detailsItem.ata.dataFim + ? formatarDataBR( + (detailsItem.ata.dataFimEfetiva || detailsItem.ata.dataFim) as string + ) + : 'Indefinido'}

{/if} diff --git a/packages/backend/convex/atas.ts b/packages/backend/convex/atas.ts index 2bfa112..095b39d 100644 --- a/packages/backend/convex/atas.ts +++ b/packages/backend/convex/atas.ts @@ -45,12 +45,22 @@ export const list = query({ // Filtro por intervalo (range): retorna atas cuja vigência intersecta o período informado. // Considera datas como strings "YYYY-MM-DD" (lexicograficamente comparáveis). const ataInicio = ata.dataInicio ?? '0000-01-01'; - const ataFim = ata.dataFim ?? '9999-12-31'; + const 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 && ataFim >= periodoInicio) || - (periodoInicio && !periodoFim && ataFim >= periodoInicio) || + (periodoInicio && + periodoFim && + ataInicio <= periodoFim && + ataFimEfetivo >= periodoInicio) || + (periodoInicio && !periodoFim && ataFimEfetivo >= periodoInicio) || (!periodoInicio && periodoFim && ataInicio <= periodoFim); return numeroOk && seiOk && periodoOk; @@ -149,6 +159,7 @@ 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(), objetos: v.array( @@ -174,6 +185,7 @@ 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() @@ -205,6 +217,7 @@ export const update = mutation({ empresaId: v.id('empresas'), dataInicio: v.optional(v.string()), dataFim: v.optional(v.string()), + dataProrrogacao: v.optional(v.string()), objetos: v.array( v.object({ objetoId: v.id('objetos'), @@ -228,6 +241,7 @@ export const update = mutation({ empresaId: args.empresaId, dataInicio: args.dataInicio, dataFim: args.dataFim, + dataProrrogacao: args.dataProrrogacao, atualizadoEm: Date.now() }); diff --git a/packages/backend/convex/objetos.ts b/packages/backend/convex/objetos.ts index 87b4594..0ea410c 100644 --- a/packages/backend/convex/objetos.ts +++ b/packages/backend/convex/objetos.ts @@ -1,7 +1,154 @@ 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>(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, 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: { @@ -147,89 +294,39 @@ export const getAtas = query({ }); export const getAtasComLimite = query({ - args: { objetoId: v.id('objetos') }, + 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({ - _id: v.id('atas'), - numero: v.string(), - numeroSei: v.string(), - dataInicio: v.union(v.string(), v.null()), - dataFim: 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() + objetoId: v.id('objetos'), + atas: v.array(ataComLimiteValidator) }) ), handler: async (ctx, args) => { - const links = await ctx.db - .query('atasObjetos') - .withIndex('by_objetoId', (q) => q.eq('objetoId', args.objetoId)) - .collect(); + if (args.objetoIds.length === 0) return []; + const include = args.includeAtaIds ?? []; - const results = []; - for (const link of links) { - const ata = await ctx.db.get(link.ataId); - if (!ata) continue; - - 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, 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 isLocked = - !quantidadeTotal || quantidadeTotal <= 0 || quantidadeUsada >= limitePermitido; - - results.push({ - _id: ata._id, - numero: ata.numero, - numeroSei: ata.numeroSei, - dataInicio: ata.dataInicio ?? null, - dataFim: ata.dataFim ?? null, - quantidadeTotal, - limitePercentual, - quantidadeUsada, - limitePermitido, - restante, - isLocked - }); + const out = []; + for (const objetoId of args.objetoIds) { + const atas = await computeAtasComLimiteForObjeto(ctx, objetoId, include); + out.push({ objetoId, atas }); } - - return results; + return out; } }); diff --git a/packages/backend/convex/pedidos.ts b/packages/backend/convex/pedidos.ts index ce930b9..09f1cfc 100644 --- a/packages/backend/convex/pedidos.ts +++ b/packages/backend/convex/pedidos.ts @@ -4,6 +4,7 @@ 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'; +import { getTodayYMD, isWithinRangeYMD, maxYMD } from './utils/datas'; // ========== HELPERS ========== @@ -309,6 +310,24 @@ async function assertAtaObjetoCanConsume( delta: number ) { if (!Number.isFinite(delta) || delta <= 0) return; + + // Bloqueia consumo se a ata estiver fora da vigência (considerando prorrogação). + const ata = await ctx.db.get(ataId); + if (!ata) { + throw new Error('Ata não encontrada.'); + } + const dataProrrogacao = (ata as { dataProrrogacao?: string }).dataProrrogacao ?? null; + const dataFimEfetiva = maxYMD(ata.dataFim ?? null, dataProrrogacao); + const today = getTodayYMD(); + const vigenteHoje = isWithinRangeYMD(today, ata.dataInicio ?? null, dataFimEfetiva); + if (!vigenteHoje) { + const inicioLabel = ata.dataInicio ?? 'sem início'; + const fimLabel = dataFimEfetiva ?? 'sem fim'; + throw new Error( + `Não é possível consumir esta ata pois ela está fora da vigência. Vigência: ${inicioLabel} até ${fimLabel}.` + ); + } + const info = await getAtaObjetoUsageInfo(ctx, ataId, objetoId, true); if (info.quantidadeUsada + delta > info.limitePermitido) { throw new Error( diff --git a/packages/backend/convex/tables/atas.ts b/packages/backend/convex/tables/atas.ts index 277962f..b1305e8 100644 --- a/packages/backend/convex/tables/atas.ts +++ b/packages/backend/convex/tables/atas.ts @@ -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'), diff --git a/packages/backend/convex/utils/datas.ts b/packages/backend/convex/utils/datas.ts index 62515ea..9b5b9d8 100644 --- a/packages/backend/convex/utils/datas.ts +++ b/packages/backend/convex/utils/datas.ts @@ -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; +}