feat: enhance ata management by adding dataProrrogacao field and updating related logic for effective date handling, improving data integrity and user experience in pedidos
This commit is contained in:
@@ -4,6 +4,7 @@
|
|||||||
import { useConvexClient, useQuery } from 'convex-svelte';
|
import { useConvexClient, useQuery } from 'convex-svelte';
|
||||||
import { Pencil, Plus, Trash2, X, Search, Check, FileText } from 'lucide-svelte';
|
import { Pencil, Plus, Trash2, X, Search, Check, FileText } from 'lucide-svelte';
|
||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
|
import { formatarDataBR } from '$lib/utils/datas';
|
||||||
|
|
||||||
const client = useConvexClient();
|
const client = useConvexClient();
|
||||||
|
|
||||||
@@ -41,7 +42,8 @@
|
|||||||
numeroSei: '',
|
numeroSei: '',
|
||||||
empresaId: '' as Id<'empresas'> | '',
|
empresaId: '' as Id<'empresas'> | '',
|
||||||
dataInicio: '',
|
dataInicio: '',
|
||||||
dataFim: ''
|
dataFim: '',
|
||||||
|
dataProrrogacao: ''
|
||||||
});
|
});
|
||||||
let selectedObjetos = $state<Id<'objetos'>[]>([]);
|
let selectedObjetos = $state<Id<'objetos'>[]>([]);
|
||||||
type ObjetoAtaConfig = {
|
type ObjetoAtaConfig = {
|
||||||
@@ -72,7 +74,8 @@
|
|||||||
numeroSei: ata.numeroSei,
|
numeroSei: ata.numeroSei,
|
||||||
empresaId: ata.empresaId,
|
empresaId: ata.empresaId,
|
||||||
dataInicio: ata.dataInicio || '',
|
dataInicio: ata.dataInicio || '',
|
||||||
dataFim: ata.dataFim || ''
|
dataFim: ata.dataFim || '',
|
||||||
|
dataProrrogacao: ata.dataProrrogacao || ''
|
||||||
};
|
};
|
||||||
// Fetch linked objects
|
// Fetch linked objects
|
||||||
const linkedObjetos = await client.query(api.atas.getObjetos, { id: ata._id });
|
const linkedObjetos = await client.query(api.atas.getObjetos, { id: ata._id });
|
||||||
@@ -96,7 +99,8 @@
|
|||||||
numeroSei: '',
|
numeroSei: '',
|
||||||
empresaId: '',
|
empresaId: '',
|
||||||
dataInicio: '',
|
dataInicio: '',
|
||||||
dataFim: ''
|
dataFim: '',
|
||||||
|
dataProrrogacao: ''
|
||||||
};
|
};
|
||||||
selectedObjetos = [];
|
selectedObjetos = [];
|
||||||
objetosConfig = {};
|
objetosConfig = {};
|
||||||
@@ -180,6 +184,7 @@
|
|||||||
empresaId: formData.empresaId as Id<'empresas'>,
|
empresaId: formData.empresaId as Id<'empresas'>,
|
||||||
dataInicio: formData.dataInicio || undefined,
|
dataInicio: formData.dataInicio || undefined,
|
||||||
dataFim: formData.dataFim || undefined,
|
dataFim: formData.dataFim || undefined,
|
||||||
|
dataProrrogacao: formData.dataProrrogacao || undefined,
|
||||||
objetos
|
objetos
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -416,7 +421,13 @@
|
|||||||
{getEmpresaNome(ata.empresaId)}
|
{getEmpresaNome(ata.empresaId)}
|
||||||
</td>
|
</td>
|
||||||
<td class="text-base-content/70 whitespace-nowrap">
|
<td class="text-base-content/70 whitespace-nowrap">
|
||||||
{ata.dataInicio || '-'} a {ata.dataFim || '-'}
|
{ata.dataInicio ? formatarDataBR(ata.dataInicio) : '-'} a
|
||||||
|
{ata.dataFim ? formatarDataBR(ata.dataFim) : '-'}
|
||||||
|
{#if ata.dataProrrogacao}
|
||||||
|
<span class="text-base-content/50">
|
||||||
|
(prorrogação: {formatarDataBR(ata.dataProrrogacao)})</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
</td>
|
</td>
|
||||||
<td class="text-right whitespace-nowrap">
|
<td class="text-right whitespace-nowrap">
|
||||||
<div class="flex items-center justify-end gap-1">
|
<div class="flex items-center justify-end gap-1">
|
||||||
@@ -508,7 +519,7 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||||
<div class="form-control w-full">
|
<div class="form-control w-full">
|
||||||
<label class="label" for="dataInicio">
|
<label class="label" for="dataInicio">
|
||||||
<span class="label-text font-semibold">Data Início</span>
|
<span class="label-text font-semibold">Data Início</span>
|
||||||
@@ -531,6 +542,17 @@
|
|||||||
bind:value={formData.dataFim}
|
bind:value={formData.dataFim}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-control w-full">
|
||||||
|
<label class="label" for="dataProrrogacao">
|
||||||
|
<span class="label-text font-semibold">Data Prorrogação</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="dataProrrogacao"
|
||||||
|
class="input input-bordered focus:input-primary w-full"
|
||||||
|
type="date"
|
||||||
|
bind:value={formData.dataProrrogacao}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { maskCurrencyBRL } from '$lib/utils/masks';
|
import { maskCurrencyBRL } from '$lib/utils/masks';
|
||||||
|
import { formatarDataBR } from '$lib/utils/datas';
|
||||||
|
|
||||||
const pedidoId = $derived(page.params.id as Id<'pedidos'>);
|
const pedidoId = $derived(page.params.id as Id<'pedidos'>);
|
||||||
const client = useConvexClient();
|
const client = useConvexClient();
|
||||||
@@ -84,7 +85,7 @@
|
|||||||
|
|
||||||
// Atas por objeto (carregadas sob demanda)
|
// Atas por objeto (carregadas sob demanda)
|
||||||
type AtasComLimite = FunctionReturnType<typeof api.objetos.getAtasComLimite>;
|
type AtasComLimite = FunctionReturnType<typeof api.objetos.getAtasComLimite>;
|
||||||
let atasPorObjeto = $state<Record<string, AtasComLimite>>({});
|
let atasPorObjetoExtra = $state<Record<string, AtasComLimite>>({});
|
||||||
|
|
||||||
let editingItems = $state<Record<string, EditingItem>>({});
|
let editingItems = $state<Record<string, EditingItem>>({});
|
||||||
|
|
||||||
@@ -110,14 +111,39 @@
|
|||||||
let selectedCount = $derived(selectedItemIds.size);
|
let selectedCount = $derived(selectedItemIds.size);
|
||||||
let hasSelection = $derived(selectedCount > 0);
|
let hasSelection = $derived(selectedCount > 0);
|
||||||
|
|
||||||
// Garante que, para todos os itens existentes, as atas do respectivo objeto
|
// Pela regra do backend, um pedido só pode ter uma ata (quando houver).
|
||||||
// sejam carregadas independentemente do formulário de criação.
|
// Usamos isso para “forçar incluir” a ata atual do pedido na listagem do objeto,
|
||||||
$effect(() => {
|
// mesmo que esteja fora da janela (ex.: vencida há mais de 3 meses).
|
||||||
for (const item of items as unknown as PedidoItemForEdit[]) {
|
let pedidoAtaId = $derived.by(() => {
|
||||||
if (!atasPorObjeto[item.objetoId]) {
|
const withAta = (items as unknown as PedidoItemForEdit[]).find((i) => i.ataId);
|
||||||
void loadAtasForObjeto(item.objetoId);
|
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<string, true> = {};
|
||||||
|
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<string, AtasComLimite> = {};
|
||||||
|
const data = atasBatchQuery.data || [];
|
||||||
|
for (const row of data) {
|
||||||
|
map[String(row.objetoId)] = row.atas;
|
||||||
}
|
}
|
||||||
|
return map;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Group items by user
|
// Group items by user
|
||||||
@@ -663,19 +689,20 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadAtasForObjeto(objetoId: string) {
|
async function loadAtasForObjeto(objetoId: string) {
|
||||||
if (atasPorObjeto[objetoId]) return;
|
if (atasPorObjetoExtra[objetoId]) return;
|
||||||
try {
|
try {
|
||||||
const linkedAtas = await client.query(api.objetos.getAtasComLimite, {
|
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) {
|
} catch (e) {
|
||||||
console.error('Erro ao carregar atas para objeto', objetoId, e);
|
console.error('Erro ao carregar atas para objeto', objetoId, e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAtasForObjeto(objetoId: string): AtasComLimite {
|
function getAtasForObjeto(objetoId: string): AtasComLimite {
|
||||||
return atasPorObjeto[objetoId] || [];
|
return atasPorObjetoExtra[objetoId] || atasPorObjetoFromBatch[objetoId] || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleObjetoChange(id: string) {
|
function handleObjetoChange(id: string) {
|
||||||
@@ -1664,11 +1691,18 @@
|
|||||||
<option value="">Nenhuma</option>
|
<option value="">Nenhuma</option>
|
||||||
{#each getAtasForObjeto(newItem.objetoId) as ata (ata._id)}
|
{#each getAtasForObjeto(newItem.objetoId) as ata (ata._id)}
|
||||||
{@const isSelectedAta = String(ata._id) === newItem.ataId}
|
{@const isSelectedAta = String(ata._id) === newItem.ataId}
|
||||||
{@const reason = !ata.quantidadeTotal
|
{@const reason =
|
||||||
? 'não configurada'
|
ata.lockReason === 'nao_configurada'
|
||||||
: ata.quantidadeUsada >= ata.limitePermitido
|
? 'não configurada'
|
||||||
? 'limite atingido'
|
: ata.lockReason === 'limite_atingido'
|
||||||
: null}
|
? 'limite atingido'
|
||||||
|
: ata.lockReason === 'vigencia_expirada'
|
||||||
|
? `vigência encerrada em ${
|
||||||
|
ata.dataFimEfetiva || ata.dataFim
|
||||||
|
? formatarDataBR((ata.dataFimEfetiva || ata.dataFim) as string)
|
||||||
|
: '-'
|
||||||
|
}`
|
||||||
|
: null}
|
||||||
<option value={ata._id} disabled={ata.isLocked && !isSelectedAta}>
|
<option value={ata._id} disabled={ata.isLocked && !isSelectedAta}>
|
||||||
Ata {ata.numero} (SEI: {ata.numeroSei}){reason ? ` (${reason})` : ''}
|
Ata {ata.numero} (SEI: {ata.numeroSei}){reason ? ` (${reason})` : ''}
|
||||||
</option>
|
</option>
|
||||||
@@ -1920,11 +1954,20 @@
|
|||||||
{#each getAtasForObjeto(item.objetoId) as ata (ata._id)}
|
{#each getAtasForObjeto(item.objetoId) as ata (ata._id)}
|
||||||
{@const currentAtaId = ensureEditingItem(item).ataId}
|
{@const currentAtaId = ensureEditingItem(item).ataId}
|
||||||
{@const isSelectedAta = String(ata._id) === currentAtaId}
|
{@const isSelectedAta = String(ata._id) === currentAtaId}
|
||||||
{@const reason = !ata.quantidadeTotal
|
{@const reason =
|
||||||
? 'não configurada'
|
ata.lockReason === 'nao_configurada'
|
||||||
: ata.quantidadeUsada >= ata.limitePermitido
|
? 'não configurada'
|
||||||
? 'limite atingido'
|
: ata.lockReason === 'limite_atingido'
|
||||||
: null}
|
? 'limite atingido'
|
||||||
|
: ata.lockReason === 'vigencia_expirada'
|
||||||
|
? `vigência encerrada em ${
|
||||||
|
ata.dataFimEfetiva || ata.dataFim
|
||||||
|
? formatarDataBR(
|
||||||
|
(ata.dataFimEfetiva || ata.dataFim) as string
|
||||||
|
)
|
||||||
|
: '-'
|
||||||
|
}`
|
||||||
|
: null}
|
||||||
<option value={ata._id} disabled={ata.isLocked && !isSelectedAta}>
|
<option value={ata._id} disabled={ata.isLocked && !isSelectedAta}>
|
||||||
Ata {ata.numero} (SEI: {ata.numeroSei}){reason ? ` (${reason})` : ''}
|
Ata {ata.numero} (SEI: {ata.numeroSei}){reason ? ` (${reason})` : ''}
|
||||||
</option>
|
</option>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
import { Plus, Trash2, X, Info } from 'lucide-svelte';
|
import { Plus, Trash2, X, Info } from 'lucide-svelte';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
|
import { formatarDataBR } from '$lib/utils/datas';
|
||||||
|
|
||||||
const client = useConvexClient();
|
const client = useConvexClient();
|
||||||
|
|
||||||
@@ -635,11 +636,18 @@
|
|||||||
>
|
>
|
||||||
<option value="">Nenhuma</option>
|
<option value="">Nenhuma</option>
|
||||||
{#each availableAtas as ata (ata._id)}
|
{#each availableAtas as ata (ata._id)}
|
||||||
{@const reason = !ata.quantidadeTotal
|
{@const reason =
|
||||||
? 'não configurada'
|
ata.lockReason === 'nao_configurada'
|
||||||
: ata.quantidadeUsada >= ata.limitePermitido
|
? 'não configurada'
|
||||||
? 'limite atingido'
|
: ata.lockReason === 'limite_atingido'
|
||||||
: null}
|
? 'limite atingido'
|
||||||
|
: ata.lockReason === 'vigencia_expirada'
|
||||||
|
? `vigência encerrada em ${
|
||||||
|
ata.dataFimEfetiva || ata.dataFim
|
||||||
|
? formatarDataBR((ata.dataFimEfetiva || ata.dataFim) as string)
|
||||||
|
: '-'
|
||||||
|
}`
|
||||||
|
: null}
|
||||||
<option value={ata._id} disabled={ata.isLocked}>
|
<option value={ata._id} disabled={ata.isLocked}>
|
||||||
Ata {ata.numero} (SEI: {ata.numeroSei}){reason ? ` (${reason})` : ''}
|
Ata {ata.numero} (SEI: {ata.numeroSei}){reason ? ` (${reason})` : ''}
|
||||||
</option>
|
</option>
|
||||||
@@ -735,7 +743,12 @@
|
|||||||
{#if detailsItem.ata.dataInicio}
|
{#if detailsItem.ata.dataInicio}
|
||||||
<p class="text-green-800">
|
<p class="text-green-800">
|
||||||
<strong>Vigência:</strong>
|
<strong>Vigência:</strong>
|
||||||
{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'}
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -45,12 +45,22 @@ export const list = query({
|
|||||||
// Filtro por intervalo (range): retorna atas cuja vigência intersecta o período informado.
|
// Filtro por intervalo (range): retorna atas cuja vigência intersecta o período informado.
|
||||||
// Considera datas como strings "YYYY-MM-DD" (lexicograficamente comparáveis).
|
// Considera datas como strings "YYYY-MM-DD" (lexicograficamente comparáveis).
|
||||||
const ataInicio = ata.dataInicio ?? '0000-01-01';
|
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 =
|
const periodoOk =
|
||||||
(!periodoInicio && !periodoFim) ||
|
(!periodoInicio && !periodoFim) ||
|
||||||
(periodoInicio && periodoFim && ataInicio <= periodoFim && ataFim >= periodoInicio) ||
|
(periodoInicio &&
|
||||||
(periodoInicio && !periodoFim && ataFim >= periodoInicio) ||
|
periodoFim &&
|
||||||
|
ataInicio <= periodoFim &&
|
||||||
|
ataFimEfetivo >= periodoInicio) ||
|
||||||
|
(periodoInicio && !periodoFim && ataFimEfetivo >= periodoInicio) ||
|
||||||
(!periodoInicio && periodoFim && ataInicio <= periodoFim);
|
(!periodoInicio && periodoFim && ataInicio <= periodoFim);
|
||||||
|
|
||||||
return numeroOk && seiOk && periodoOk;
|
return numeroOk && seiOk && periodoOk;
|
||||||
@@ -149,6 +159,7 @@ export const create = mutation({
|
|||||||
numero: v.string(),
|
numero: v.string(),
|
||||||
dataInicio: v.optional(v.string()),
|
dataInicio: v.optional(v.string()),
|
||||||
dataFim: v.optional(v.string()),
|
dataFim: v.optional(v.string()),
|
||||||
|
dataProrrogacao: v.optional(v.string()),
|
||||||
empresaId: v.id('empresas'),
|
empresaId: v.id('empresas'),
|
||||||
numeroSei: v.string(),
|
numeroSei: v.string(),
|
||||||
objetos: v.array(
|
objetos: v.array(
|
||||||
@@ -174,6 +185,7 @@ export const create = mutation({
|
|||||||
empresaId: args.empresaId,
|
empresaId: args.empresaId,
|
||||||
dataInicio: args.dataInicio,
|
dataInicio: args.dataInicio,
|
||||||
dataFim: args.dataFim,
|
dataFim: args.dataFim,
|
||||||
|
dataProrrogacao: args.dataProrrogacao,
|
||||||
criadoPor: user._id,
|
criadoPor: user._id,
|
||||||
criadoEm: Date.now(),
|
criadoEm: Date.now(),
|
||||||
atualizadoEm: Date.now()
|
atualizadoEm: Date.now()
|
||||||
@@ -205,6 +217,7 @@ export const update = mutation({
|
|||||||
empresaId: v.id('empresas'),
|
empresaId: v.id('empresas'),
|
||||||
dataInicio: v.optional(v.string()),
|
dataInicio: v.optional(v.string()),
|
||||||
dataFim: v.optional(v.string()),
|
dataFim: v.optional(v.string()),
|
||||||
|
dataProrrogacao: v.optional(v.string()),
|
||||||
objetos: v.array(
|
objetos: v.array(
|
||||||
v.object({
|
v.object({
|
||||||
objetoId: v.id('objetos'),
|
objetoId: v.id('objetos'),
|
||||||
@@ -228,6 +241,7 @@ export const update = mutation({
|
|||||||
empresaId: args.empresaId,
|
empresaId: args.empresaId,
|
||||||
dataInicio: args.dataInicio,
|
dataInicio: args.dataInicio,
|
||||||
dataFim: args.dataFim,
|
dataFim: args.dataFim,
|
||||||
|
dataProrrogacao: args.dataProrrogacao,
|
||||||
atualizadoEm: Date.now()
|
atualizadoEm: Date.now()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,154 @@
|
|||||||
import { v } from 'convex/values';
|
import { v } from 'convex/values';
|
||||||
import { mutation, query } from './_generated/server';
|
import { mutation, query } from './_generated/server';
|
||||||
import type { Id } from './_generated/dataModel';
|
import type { Id } from './_generated/dataModel';
|
||||||
|
import type { QueryCtx } from './_generated/server';
|
||||||
import { getCurrentUserFunction } from './auth';
|
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({
|
export const list = query({
|
||||||
args: {
|
args: {
|
||||||
@@ -147,89 +294,39 @@ export const getAtas = query({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const getAtasComLimite = 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(
|
returns: v.array(
|
||||||
v.object({
|
v.object({
|
||||||
_id: v.id('atas'),
|
objetoId: v.id('objetos'),
|
||||||
numero: v.string(),
|
atas: v.array(ataComLimiteValidator)
|
||||||
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()
|
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
const links = await ctx.db
|
if (args.objetoIds.length === 0) return [];
|
||||||
.query('atasObjetos')
|
const include = args.includeAtaIds ?? [];
|
||||||
.withIndex('by_objetoId', (q) => q.eq('objetoId', args.objetoId))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
const results = [];
|
const out = [];
|
||||||
for (const link of links) {
|
for (const objetoId of args.objetoIds) {
|
||||||
const ata = await ctx.db.get(link.ataId);
|
const atas = await computeAtasComLimiteForObjeto(ctx, objetoId, include);
|
||||||
if (!ata) continue;
|
out.push({ objetoId, atas });
|
||||||
|
|
||||||
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 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
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
return out;
|
||||||
return results;
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { Doc, Id } from './_generated/dataModel';
|
|||||||
import type { MutationCtx, QueryCtx } from './_generated/server';
|
import type { MutationCtx, QueryCtx } from './_generated/server';
|
||||||
import { internalMutation, mutation, query } from './_generated/server';
|
import { internalMutation, mutation, query } from './_generated/server';
|
||||||
import { getCurrentUserFunction } from './auth';
|
import { getCurrentUserFunction } from './auth';
|
||||||
|
import { getTodayYMD, isWithinRangeYMD, maxYMD } from './utils/datas';
|
||||||
|
|
||||||
// ========== HELPERS ==========
|
// ========== HELPERS ==========
|
||||||
|
|
||||||
@@ -309,6 +310,24 @@ async function assertAtaObjetoCanConsume(
|
|||||||
delta: number
|
delta: number
|
||||||
) {
|
) {
|
||||||
if (!Number.isFinite(delta) || delta <= 0) return;
|
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);
|
const info = await getAtaObjetoUsageInfo(ctx, ataId, objetoId, true);
|
||||||
if (info.quantidadeUsada + delta > info.limitePermitido) {
|
if (info.quantidadeUsada + delta > info.limitePermitido) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export const atasTables = {
|
|||||||
numero: v.string(),
|
numero: v.string(),
|
||||||
dataInicio: v.optional(v.string()),
|
dataInicio: v.optional(v.string()),
|
||||||
dataFim: v.optional(v.string()),
|
dataFim: v.optional(v.string()),
|
||||||
|
dataProrrogacao: v.optional(v.string()),
|
||||||
empresaId: v.id('empresas'),
|
empresaId: v.id('empresas'),
|
||||||
numeroSei: v.string(),
|
numeroSei: v.string(),
|
||||||
criadoPor: v.id('usuarios'),
|
criadoPor: v.id('usuarios'),
|
||||||
|
|||||||
@@ -7,10 +7,10 @@
|
|||||||
* Converte uma string de data no formato YYYY-MM-DD para um objeto Date local
|
* 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
|
* No ambiente Convex, as datas são tratadas como UTC, então precisamos garantir
|
||||||
* que a data seja interpretada corretamente.
|
* que a data seja interpretada corretamente.
|
||||||
*
|
*
|
||||||
* @param dateString - String no formato YYYY-MM-DD
|
* @param dateString - String no formato YYYY-MM-DD
|
||||||
* @returns Date objeto representando a data
|
* @returns Date objeto representando a data
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* parseLocalDate('2024-01-15') // Retorna Date para 15/01/2024
|
* 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)
|
* Formata uma data para o formato brasileiro (DD/MM/YYYY)
|
||||||
*
|
*
|
||||||
* @param date - Date objeto ou string no formato YYYY-MM-DD
|
* @param date - Date objeto ou string no formato YYYY-MM-DD
|
||||||
* @returns String formatada no formato DD/MM/YYYY
|
* @returns String formatada no formato DD/MM/YYYY
|
||||||
*/
|
*/
|
||||||
export function formatarDataBR(date: Date | string): string {
|
export function formatarDataBR(date: Date | string): string {
|
||||||
let dateObj: Date;
|
let dateObj: Date;
|
||||||
|
|
||||||
if (typeof date === 'string') {
|
if (typeof date === 'string') {
|
||||||
dateObj = parseLocalDate(date);
|
dateObj = parseLocalDate(date);
|
||||||
} else {
|
} else {
|
||||||
@@ -63,3 +63,69 @@ export function formatarDataBR(date: Date | string): string {
|
|||||||
return `${day}/${month}/${year}`;
|
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