feat: enhance pedidos functionality by adding new submenu options for creating and planning orders, improving user navigation and access control in the sidebar; also implement URL-based prefill for adding items, ensuring a smoother user experience when creating pedidos

This commit is contained in:
2025-12-17 21:42:35 -03:00
parent 551a2fed00
commit 69914170bf
12 changed files with 1896 additions and 97 deletions

View File

@@ -108,11 +108,26 @@
link: '/pedidos',
permission: { recurso: 'pedidos', acao: 'listar' },
submenus: [
{
label: 'Novo Pedido',
link: '/pedidos/novo',
permission: { recurso: 'pedidos', acao: 'criar' }
},
{
label: 'Planejamentos',
link: '/pedidos/planejamento',
permission: { recurso: 'pedidos', acao: 'listar' }
},
{
label: 'Meus Pedidos',
link: '/pedidos',
permission: { recurso: 'pedidos', acao: 'listar' },
excludePaths: ['/pedidos/aceite', '/pedidos/minhas-analises']
excludePaths: [
'/pedidos/aceite',
'/pedidos/minhas-analises',
'/pedidos/novo',
'/pedidos/planejamento'
]
},
{
label: 'Pedidos para Aceite',

View File

@@ -20,9 +20,10 @@
X,
XCircle
} from 'lucide-svelte';
import { goto } from '$app/navigation';
import { afterNavigate, goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { page } from '$app/state';
import { onMount } from 'svelte';
import { maskCurrencyBRL } from '$lib/utils/masks';
import { formatarDataBR } from '$lib/utils/datas';
@@ -167,7 +168,6 @@
itemsQuery.isLoading ||
historyQuery.isLoading ||
objetosQuery.isLoading ||
objetosQuery.isLoading ||
acoesQuery.isLoading ||
permissionsQuery.isLoading ||
requestsQuery.isLoading ||
@@ -394,6 +394,7 @@
// Add Item State
let showAddItem = $state(false);
let hasAppliedAddItemPrefill = $state(false);
let newItem = $state({
objetoId: '' as string,
valorEstimado: '',
@@ -404,7 +405,46 @@
});
let addingItem = $state(false);
let hasAppliedPrefill = $state(false);
function applyAddItemPrefillFromUrl() {
if (hasAppliedAddItemPrefill) return;
const obj = page.url.searchParams.get('obj');
if (!obj) return;
const qtdRaw = page.url.searchParams.get('qtd');
const qtd = qtdRaw ? Number.parseInt(qtdRaw, 10) : 1;
const mod = page.url.searchParams.get('mod') ?? '';
const acao = page.url.searchParams.get('acao') ?? '';
const ata = page.url.searchParams.get('ata') ?? '';
showAddItem = true;
newItem.objetoId = obj;
newItem.quantidade = Number.isFinite(qtd) && qtd > 0 ? qtd : 1;
newItem.modalidade = coerceModalidade(mod);
newItem.acaoId = acao;
newItem.ataId = ata;
const objeto = objetos.find((o: Doc<'objetos'>) => o._id === obj);
newItem.valorEstimado = maskCurrencyBRL(objeto?.valorEstimado || '');
void loadAtasForObjeto(obj);
hasAppliedAddItemPrefill = true;
void goto(resolve(`/pedidos/${pedidoId}`), {
replaceState: true,
noScroll: true,
keepFocus: true
});
}
onMount(() => {
applyAddItemPrefillFromUrl();
});
afterNavigate(() => {
applyAddItemPrefillFromUrl();
});
// Edit SEI State
let editingSei = $state(false);
@@ -470,47 +510,6 @@
selectedObjeto = null;
}
$effect(() => {
if (hasAppliedPrefill) return;
if (objetosQuery.isLoading || acoesQuery.isLoading) return;
const url = page.url;
const obj = url.searchParams.get('obj');
const qtdStr = url.searchParams.get('qtd');
const mod = url.searchParams.get('mod') as Modalidade | null;
const acao = url.searchParams.get('acao');
const ata = url.searchParams.get('ata');
if (!obj) return;
const objeto = objetos.find((o) => o._id === obj);
if (!objeto) return;
let quantidade = parseInt(qtdStr || '1', 10);
if (!Number.isFinite(quantidade) || quantidade <= 0) {
quantidade = 1;
}
const modalidade: Modalidade =
mod === 'dispensa' || mod === 'inexgibilidade' || mod === 'adesao' || mod === 'consumo'
? mod
: 'consumo';
showAddItem = true;
newItem = {
objetoId: obj,
valorEstimado: maskCurrencyBRL(objeto.valorEstimado || ''),
quantidade,
modalidade,
acaoId: acao || '',
ataId: ata || ''
};
void loadAtasForObjeto(obj);
hasAppliedPrefill = true;
});
async function handleAddItem() {
if (!pedido || !newItem.objetoId || !newItem.valorEstimado) return;
@@ -519,17 +518,19 @@
if (items.length > 0) {
const referenceItem = items[0];
const referenceModalidade = referenceItem.modalidade as Modalidade;
const referenceModalidade = (referenceItem.modalidade as Modalidade | undefined) ?? undefined;
const referenceAtaId = (('ataId' in referenceItem ? referenceItem.ataId : undefined) ??
null) as string | null;
const newAtaId = newItem.ataId || null;
const sameModalidade = newItem.modalidade === referenceModalidade;
const sameModalidade = !referenceModalidade || newItem.modalidade === referenceModalidade;
const sameAta = referenceAtaId === newAtaId;
if (!sameModalidade || !sameAta) {
const refModalidadeLabel = formatModalidade(referenceModalidade);
const refModalidadeLabel = referenceModalidade
? formatModalidade(referenceModalidade)
: 'Não definida';
const refAtaLabel =
referenceAtaId === null ? 'sem Ata vinculada' : 'com uma Ata específica';
@@ -740,7 +741,7 @@
return {
valorEstimado: maskCurrencyBRL(item.valorEstimado || ''),
modalidade: item.modalidade,
modalidade: coerceModalidade((item.modalidade as string | undefined) ?? 'consumo'),
acaoId: item.acaoId ?? '',
ataId: item.ataId ?? ''
};

View File

@@ -0,0 +1,3 @@
export const load = async ({ parent }) => {
await parent();
};

View File

@@ -0,0 +1,305 @@
<script lang="ts">
import { api } from '@sgse-app/backend/convex/_generated/api';
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
import { useConvexClient, useQuery } from 'convex-svelte';
import { Plus, Eye, X } from 'lucide-svelte';
import { resolve } from '$app/paths';
import { goto } from '$app/navigation';
import { toast } from 'svelte-sonner';
const client = useConvexClient();
const planejamentosQuery = useQuery(api.planejamentos.list, {});
const funcionariosQuery = useQuery(api.funcionarios.getAll, {});
const acoesQuery = useQuery(api.acoes.list, {});
let planejamentos = $derived(planejamentosQuery.data || []);
function formatStatus(status: string) {
switch (status) {
case 'rascunho':
return 'Rascunho';
case 'gerado':
return 'Gerado';
case 'cancelado':
return 'Cancelado';
default:
return status;
}
}
function getStatusColor(status: string) {
switch (status) {
case 'rascunho':
return 'bg-gray-100 text-gray-800';
case 'gerado':
return 'bg-green-100 text-green-800';
case 'cancelado':
return 'bg-red-100 text-red-800';
default:
return 'bg-gray-100 text-gray-800';
}
}
function formatDateYMD(ymd: string) {
// ymd: yyyy-MM-dd
const [y, m, d] = ymd.split('-');
if (!y || !m || !d) return ymd;
return `${d}/${m}/${y}`;
}
// Create modal
let showCreate = $state(false);
let creating = $state(false);
let form = $state({
titulo: '',
descricao: '',
data: '',
responsavelId: '' as string,
acaoId: '' as string
});
function openCreate() {
form = { titulo: '', descricao: '', data: '', responsavelId: '', acaoId: '' };
showCreate = true;
}
function closeCreate() {
showCreate = false;
}
async function handleCreate() {
if (!form.titulo.trim()) return toast.error('Informe um título.');
if (!form.descricao.trim()) return toast.error('Informe uma descrição.');
if (!form.data.trim()) return toast.error('Informe uma data.');
if (!form.responsavelId) return toast.error('Selecione um responsável.');
creating = true;
try {
const id = await client.mutation(api.planejamentos.create, {
titulo: form.titulo,
descricao: form.descricao,
data: form.data,
responsavelId: form.responsavelId as Id<'funcionarios'>,
acaoId: form.acaoId ? (form.acaoId as Id<'acoes'>) : undefined
});
toast.success('Planejamento criado.');
showCreate = false;
await goto(resolve(`/pedidos/planejamento/${id}`));
} catch (e) {
toast.error((e as Error).message);
} finally {
creating = false;
}
}
</script>
<div class="container mx-auto p-6">
<div class="mb-6 flex items-center justify-between">
<h1 class="text-2xl font-bold">Planejamento de Pedidos</h1>
<button
type="button"
class="flex items-center gap-2 rounded-md bg-blue-600 px-4 py-2 text-white hover:bg-blue-700"
onclick={openCreate}
>
<Plus size={20} />
Novo planejamento
</button>
</div>
{#if planejamentosQuery.isLoading}
<div class="flex flex-col gap-4">
{#each Array(3)}
<div class="skeleton h-16 w-full rounded-lg"></div>
{/each}
</div>
{:else if planejamentosQuery.error}
<div class="alert alert-error">
<span>{planejamentosQuery.error.message}</span>
</div>
{:else}
<div class="overflow-hidden rounded-lg bg-white shadow-md">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
>
Título
</th>
<th
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
>
Data
</th>
<th
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
>
Responsável
</th>
<th
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
>
Ação
</th>
<th
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
>
Status
</th>
<th
class="px-6 py-3 text-right text-xs font-medium tracking-wider text-gray-500 uppercase"
>
Ações
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
{#each planejamentos as p (p._id)}
<tr class="hover:bg-gray-50">
<td class="px-6 py-4 font-medium whitespace-nowrap">{p.titulo}</td>
<td class="px-6 py-4 whitespace-nowrap text-gray-600">{formatDateYMD(p.data)}</td>
<td class="px-6 py-4 whitespace-nowrap text-gray-600">{p.responsavelNome}</td>
<td class="px-6 py-4 whitespace-nowrap text-gray-600">{p.acaoNome || '-'}</td>
<td class="px-6 py-4 whitespace-nowrap">
<span
class="inline-flex rounded-full px-2 py-1 text-xs font-semibold {getStatusColor(
p.status
)}"
>
{formatStatus(p.status)}
</span>
</td>
<td class="px-6 py-4 text-right text-sm font-medium whitespace-nowrap">
<a
href={resolve(`/pedidos/planejamento/${p._id}`)}
class="inline-flex items-center gap-1 text-indigo-600 hover:text-indigo-900"
>
<Eye size={18} />
Abrir
</a>
</td>
</tr>
{/each}
{#if planejamentos.length === 0}
<tr>
<td colspan="6" class="px-6 py-4 text-center text-gray-500"
>Nenhum planejamento encontrado.</td
>
</tr>
{/if}
</tbody>
</table>
</div>
{/if}
{#if showCreate}
<div
class="fixed inset-0 z-50 flex h-full w-full items-center justify-center overflow-y-auto bg-black/40 p-4"
>
<div class="relative w-full max-w-2xl rounded-xl bg-white p-6 shadow-2xl">
<button
type="button"
onclick={closeCreate}
class="absolute top-4 right-4 rounded-lg p-1 text-gray-400 transition hover:bg-gray-100 hover:text-gray-600"
aria-label="Fechar"
disabled={creating}
>
<X size={24} />
</button>
<h2 class="mb-4 text-xl font-bold text-gray-900">Novo planejamento</h2>
<div class="grid gap-4 md:grid-cols-2">
<div class="md:col-span-2">
<label class="mb-1 block text-sm font-medium text-gray-700" for="titulo">Título</label>
<input
id="titulo"
type="text"
class="w-full rounded-lg border border-gray-300 px-4 py-2.5 text-gray-700 shadow-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-200 focus:outline-none"
bind:value={form.titulo}
disabled={creating}
/>
</div>
<div class="md:col-span-2">
<label class="mb-1 block text-sm font-medium text-gray-700" for="descricao"
>Descrição</label
>
<textarea
id="descricao"
class="w-full rounded-lg border border-gray-300 px-4 py-2.5 text-gray-700 shadow-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-200 focus:outline-none"
rows="4"
bind:value={form.descricao}
disabled={creating}
></textarea>
</div>
<div>
<label class="mb-1 block text-sm font-medium text-gray-700" for="data">Data</label>
<input
id="data"
type="date"
class="w-full rounded-lg border border-gray-300 px-4 py-2.5 text-gray-700 shadow-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-200 focus:outline-none"
bind:value={form.data}
disabled={creating}
/>
</div>
<div>
<label class="mb-1 block text-sm font-medium text-gray-700" for="responsavel"
>Responsável</label
>
<select
id="responsavel"
class="w-full rounded-lg border border-gray-300 bg-white px-4 py-2.5 text-gray-700 shadow-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-200 focus:outline-none"
bind:value={form.responsavelId}
disabled={creating || funcionariosQuery.isLoading}
>
<option value="">Selecione...</option>
{#each funcionariosQuery.data || [] as f (f._id)}
<option value={f._id}>{f.nome}</option>
{/each}
</select>
</div>
<div class="md:col-span-2">
<label class="mb-1 block text-sm font-medium text-gray-700" for="acao"
>Ação (opcional)</label
>
<select
id="acao"
class="w-full rounded-lg border border-gray-300 bg-white px-4 py-2.5 text-gray-700 shadow-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-200 focus:outline-none"
bind:value={form.acaoId}
disabled={creating || acoesQuery.isLoading}
>
<option value="">Nenhuma</option>
{#each acoesQuery.data || [] as a (a._id)}
<option value={a._id}>{a.nome}</option>
{/each}
</select>
</div>
</div>
<div class="mt-6 flex justify-end gap-2">
<button
type="button"
class="rounded-lg bg-gray-200 px-5 py-2.5 font-semibold text-gray-800 transition hover:bg-gray-300"
onclick={closeCreate}
disabled={creating}
>
Cancelar
</button>
<button
type="button"
class="rounded-lg bg-blue-600 px-5 py-2.5 font-semibold text-white shadow-md transition hover:bg-blue-700 disabled:opacity-50"
onclick={handleCreate}
disabled={creating}
>
{creating ? 'Criando...' : 'Criar'}
</button>
</div>
</div>
</div>
{/if}
</div>

View File

@@ -0,0 +1,3 @@
export const load = async ({ parent }) => {
await parent();
};

View File

@@ -0,0 +1,875 @@
<script lang="ts">
import { api } from '@sgse-app/backend/convex/_generated/api';
import type { Doc, Id } from '@sgse-app/backend/convex/_generated/dataModel';
import { useConvexClient, useQuery } from 'convex-svelte';
import { page } from '$app/state';
import { resolve } from '$app/paths';
import { toast } from 'svelte-sonner';
import { Plus, Trash2, X, Save, Edit } from 'lucide-svelte';
const client = useConvexClient();
const planejamentoId = $derived(page.params.id as Id<'planejamentosPedidos'>);
const planejamentoQuery = $derived.by(() =>
useQuery(api.planejamentos.get, { id: planejamentoId })
);
const itemsQuery = $derived.by(() => useQuery(api.planejamentos.listItems, { planejamentoId }));
const pedidosQuery = $derived.by(() =>
useQuery(api.planejamentos.listPedidos, { planejamentoId })
);
const funcionariosQuery = useQuery(api.funcionarios.getAll, {});
const acoesQuery = useQuery(api.acoes.list, {});
let planejamento = $derived(planejamentoQuery.data);
let items = $derived(itemsQuery.data || []);
let pedidosLinks = $derived(pedidosQuery.data || []);
const isRascunho = $derived(planejamento?.status === 'rascunho');
function formatStatus(status: string) {
switch (status) {
case 'rascunho':
return 'Rascunho';
case 'gerado':
return 'Gerado';
case 'cancelado':
return 'Cancelado';
default:
return status;
}
}
function getStatusColor(status: string) {
switch (status) {
case 'rascunho':
return 'bg-gray-100 text-gray-800';
case 'gerado':
return 'bg-green-100 text-green-800';
case 'cancelado':
return 'bg-red-100 text-red-800';
default:
return 'bg-gray-100 text-gray-800';
}
}
function formatPedidoStatus(status: string) {
switch (status) {
case 'em_rascunho':
return 'Rascunho';
case 'aguardando_aceite':
return 'Aguardando Aceite';
case 'em_analise':
return 'Em Análise';
case 'precisa_ajustes':
return 'Precisa de Ajustes';
case 'concluido':
return 'Concluído';
case 'cancelado':
return 'Cancelado';
default:
return status;
}
}
function getPedidoStatusColor(status: string) {
switch (status) {
case 'em_rascunho':
return 'bg-gray-100 text-gray-800';
case 'aguardando_aceite':
return 'bg-yellow-100 text-yellow-800';
case 'em_analise':
return 'bg-blue-100 text-blue-800';
case 'precisa_ajustes':
return 'bg-orange-100 text-orange-800';
case 'concluido':
return 'bg-green-100 text-green-800';
case 'cancelado':
return 'bg-red-100 text-red-800';
default:
return 'bg-gray-100 text-gray-800';
}
}
// --- Header editing ---
let editingHeader = $state(false);
let headerForm = $state({
titulo: '',
descricao: '',
data: '',
responsavelId: '' as string,
acaoId: '' as string
});
let savingHeader = $state(false);
function syncHeaderFormFromPlanejamento() {
if (!planejamento) return;
headerForm = {
titulo: planejamento.titulo,
descricao: planejamento.descricao,
data: planejamento.data,
responsavelId: planejamento.responsavelId as unknown as string,
acaoId: planejamento.acaoId ? (planejamento.acaoId as unknown as string) : ''
};
}
function startEditHeader() {
if (!isRascunho) return;
syncHeaderFormFromPlanejamento();
editingHeader = true;
}
function cancelEditHeader() {
editingHeader = false;
syncHeaderFormFromPlanejamento();
}
async function saveHeader() {
if (!planejamento) return;
if (!headerForm.titulo.trim()) return toast.error('Informe um título.');
if (!headerForm.descricao.trim()) return toast.error('Informe uma descrição.');
if (!headerForm.data.trim()) return toast.error('Informe uma data.');
if (!headerForm.responsavelId) return toast.error('Selecione um responsável.');
savingHeader = true;
try {
await client.mutation(api.planejamentos.update, {
id: planejamentoId,
titulo: headerForm.titulo,
descricao: headerForm.descricao,
data: headerForm.data,
responsavelId: headerForm.responsavelId as Id<'funcionarios'>,
acaoId: headerForm.acaoId ? (headerForm.acaoId as Id<'acoes'>) : null
});
toast.success('Planejamento atualizado.');
editingHeader = false;
} catch (e) {
toast.error((e as Error).message);
} finally {
savingHeader = false;
}
}
// --- Add item (search + modal) ---
let searchQuery = $state('');
const searchResultsQuery = useQuery(api.objetos.search, () => ({ query: searchQuery }));
let searchResults = $derived(searchResultsQuery.data || []);
type SelectedObjeto = Doc<'objetos'>;
let showAddItemModal = $state(false);
let addItemConfig = $state<{
objeto: SelectedObjeto | null;
quantidade: number;
valorEstimado: string;
numeroDfd: string;
}>({
objeto: null,
quantidade: 1,
valorEstimado: '',
numeroDfd: ''
});
let addingItem = $state(false);
function openAddItemModal(objeto: SelectedObjeto) {
addItemConfig = {
objeto,
quantidade: 1,
valorEstimado: objeto.valorEstimado,
numeroDfd: ''
};
showAddItemModal = true;
searchQuery = '';
}
function closeAddItemModal() {
showAddItemModal = false;
addItemConfig.objeto = null;
}
async function confirmAddItem() {
if (!addItemConfig.objeto) return;
if (!isRascunho)
return toast.error('Não é possível adicionar itens em um planejamento não rascunho.');
if (!Number.isFinite(addItemConfig.quantidade) || addItemConfig.quantidade <= 0) {
return toast.error('Quantidade inválida.');
}
if (!addItemConfig.valorEstimado.trim()) return toast.error('Informe o valor estimado.');
addingItem = true;
try {
await client.mutation(api.planejamentos.addItem, {
planejamentoId,
objetoId: addItemConfig.objeto._id,
quantidade: addItemConfig.quantidade,
valorEstimado: addItemConfig.valorEstimado,
numeroDfd: addItemConfig.numeroDfd.trim() || undefined
});
toast.success('Item adicionado.');
closeAddItemModal();
} catch (e) {
toast.error((e as Error).message);
} finally {
addingItem = false;
}
}
// --- Inline item edits ---
async function updateItemField(
itemId: Id<'planejamentoItens'>,
patch: { numeroDfd?: string | null; quantidade?: number; valorEstimado?: string }
) {
if (!isRascunho) return;
try {
await client.mutation(api.planejamentos.updateItem, { itemId, ...patch });
} catch (e) {
toast.error((e as Error).message);
}
}
async function removeItem(itemId: Id<'planejamentoItens'>) {
if (!isRascunho) return;
try {
await client.mutation(api.planejamentos.removeItem, { itemId });
toast.success('Item removido.');
} catch (e) {
toast.error((e as Error).message);
}
}
// --- Grouping ---
let pedidosByDfd = $derived.by(() => {
const map: Record<string, (typeof pedidosLinks)[number]> = {};
for (const p of pedidosLinks) map[p.numeroDfd] = p;
return map;
});
let grouped = $derived.by(() => {
const groups: Record<string, typeof items> = {};
const semDfd: typeof items = [];
for (const it of items) {
const dfd = (it.numeroDfd || '').trim();
if (!dfd) {
semDfd.push(it);
continue;
}
if (!groups[dfd]) groups[dfd] = [];
groups[dfd].push(it);
}
const out: Array<{ key: string; label: string; items: typeof items; isSemDfd: boolean }> = [];
if (semDfd.length > 0)
out.push({ key: '__sem_dfd__', label: 'Sem DFD', items: semDfd, isSemDfd: true });
const keys = Object.keys(groups).sort((a, b) => a.localeCompare(b));
for (const k of keys) {
out.push({ key: k, label: `DFD ${k}`, items: groups[k], isSemDfd: false });
}
return out;
});
let itensSemDfdCount = $derived(grouped.find((g) => g.isSemDfd)?.items.length ?? 0);
let dfdsParaGerar = $derived.by(() => grouped.filter((g) => !g.isSemDfd).map((g) => g.key));
// --- Gerar pedidos modal ---
let showGerarModal = $state(false);
let seiByDfd = $state<Record<string, string>>({});
let gerando = $state(false);
const canGerar = $derived(
isRascunho && items.length > 0 && itensSemDfdCount === 0 && dfdsParaGerar.length > 0
);
function openGerarModal() {
if (!canGerar) {
if (items.length === 0) toast.error('Adicione itens antes de gerar pedidos.');
else if (itensSemDfdCount > 0) toast.error('Atribua um DFD a todos os itens antes de gerar.');
return;
}
const initial: Record<string, string> = {};
for (const dfd of dfdsParaGerar) initial[dfd] = '';
seiByDfd = initial;
showGerarModal = true;
}
function closeGerarModal() {
showGerarModal = false;
seiByDfd = {};
}
async function confirmarGeracao() {
if (!canGerar) return;
for (const dfd of dfdsParaGerar) {
if (!seiByDfd[dfd]?.trim()) {
return toast.error(`Informe o número SEI para o DFD ${dfd}.`);
}
}
gerando = true;
try {
await client.mutation(api.planejamentos.gerarPedidosPorDfd, {
planejamentoId,
dfds: dfdsParaGerar.map((dfd) => ({ numeroDfd: dfd, numeroSei: seiByDfd[dfd] }))
});
toast.success('Pedidos gerados.');
closeGerarModal();
} catch (e) {
toast.error((e as Error).message);
} finally {
gerando = false;
}
}
</script>
<div class="container mx-auto p-6">
{#if planejamentoQuery.isLoading}
<p>Carregando...</p>
{:else if planejamentoQuery.error}
<p class="text-red-600">{planejamentoQuery.error.message}</p>
{:else if planejamento}
<div class="mb-6 overflow-hidden rounded-lg bg-white p-6 shadow-md">
<div class="flex items-start justify-between gap-4">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-3">
<h1 class="truncate text-2xl font-bold text-gray-900">{planejamento.titulo}</h1>
<span
class="inline-flex rounded-full px-2 py-1 text-xs font-semibold {getStatusColor(
planejamento.status
)}"
>
{formatStatus(planejamento.status)}
</span>
</div>
<p class="mt-2 text-sm whitespace-pre-wrap text-gray-700">{planejamento.descricao}</p>
<div class="mt-4 grid gap-3 md:grid-cols-3">
<div class="rounded-lg bg-gray-50 p-3">
<div class="text-xs font-semibold text-gray-500">Data</div>
<div class="text-sm text-gray-900">{planejamento.data}</div>
</div>
<div class="rounded-lg bg-gray-50 p-3">
<div class="text-xs font-semibold text-gray-500">Responsável</div>
<div class="text-sm text-gray-900">{planejamento.responsavelNome}</div>
</div>
<div class="rounded-lg bg-gray-50 p-3">
<div class="text-xs font-semibold text-gray-500">Ação</div>
<div class="text-sm text-gray-900">{planejamento.acaoNome || '-'}</div>
</div>
</div>
</div>
<div class="flex shrink-0 flex-col items-end gap-2">
{#if isRascunho}
{#if editingHeader}
<div class="flex gap-2">
<button
type="button"
class="flex items-center gap-2 rounded bg-green-600 px-3 py-2 text-sm text-white hover:bg-green-700 disabled:opacity-50"
onclick={saveHeader}
disabled={savingHeader}
>
<Save size={16} />
Salvar
</button>
<button
type="button"
class="rounded bg-gray-200 px-3 py-2 text-sm text-gray-800 hover:bg-gray-300"
onclick={cancelEditHeader}
disabled={savingHeader}
>
Cancelar
</button>
</div>
{:else}
<button
type="button"
class="flex items-center gap-2 rounded bg-gray-100 px-3 py-2 text-sm text-gray-800 hover:bg-gray-200"
onclick={startEditHeader}
>
<Edit size={16} />
Editar
</button>
{/if}
{/if}
</div>
</div>
{#if editingHeader}
<div class="mt-5 rounded-lg border border-gray-200 bg-white p-4">
<div class="grid gap-4 md:grid-cols-2">
<div class="md:col-span-2">
<label class="mb-1 block text-sm font-medium text-gray-700" for="eh_titulo"
>Título</label
>
<input
id="eh_titulo"
type="text"
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm"
bind:value={headerForm.titulo}
disabled={savingHeader}
/>
</div>
<div class="md:col-span-2">
<label class="mb-1 block text-sm font-medium text-gray-700" for="eh_descricao"
>Descrição</label
>
<textarea
id="eh_descricao"
rows="4"
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm"
bind:value={headerForm.descricao}
disabled={savingHeader}
></textarea>
</div>
<div>
<label class="mb-1 block text-sm font-medium text-gray-700" for="eh_data">Data</label>
<input
id="eh_data"
type="date"
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm"
bind:value={headerForm.data}
disabled={savingHeader}
/>
</div>
<div>
<label class="mb-1 block text-sm font-medium text-gray-700" for="eh_resp"
>Responsável</label
>
<select
id="eh_resp"
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm"
bind:value={headerForm.responsavelId}
disabled={savingHeader || funcionariosQuery.isLoading}
>
<option value="">Selecione...</option>
{#each funcionariosQuery.data || [] as f (f._id)}
<option value={f._id}>{f.nome}</option>
{/each}
</select>
</div>
<div class="md:col-span-2">
<label class="mb-1 block text-sm font-medium text-gray-700" for="eh_acao"
>Ação (opcional)</label
>
<select
id="eh_acao"
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm"
bind:value={headerForm.acaoId}
disabled={savingHeader || acoesQuery.isLoading}
>
<option value="">Nenhuma</option>
{#each acoesQuery.data || [] as a (a._id)}
<option value={a._id}>{a.nome}</option>
{/each}
</select>
</div>
</div>
</div>
{/if}
</div>
<!-- Itens -->
<div class="mb-6 overflow-hidden rounded-lg bg-white shadow-md">
<div class="flex items-center justify-between border-b border-gray-200 px-6 py-4">
<div>
<h2 class="text-lg font-semibold">Itens</h2>
{#if itensSemDfdCount > 0}
<p class="mt-1 text-xs text-amber-700">
{itensSemDfdCount} item(ns) sem DFD. Para gerar pedidos, todos os itens precisam ter DFD.
</p>
{/if}
</div>
<div class="flex items-center gap-2">
{#if isRascunho}
<button
type="button"
onclick={openGerarModal}
disabled={!canGerar}
class="rounded bg-indigo-600 px-3 py-2 text-sm text-white hover:bg-indigo-700 disabled:opacity-50"
>
Gerar pedidos
</button>
{/if}
{#if isRascunho}
<div class="relative">
<input
type="text"
class="w-64 rounded-md border border-gray-300 px-3 py-2 text-sm"
placeholder="Buscar objetos..."
bind:value={searchQuery}
/>
{#if searchQuery.length > 0}
<div
class="absolute z-10 mt-2 w-full rounded-lg border border-gray-200 bg-white shadow-xl"
>
{#if searchResultsQuery.isLoading}
<div class="p-3 text-sm text-gray-500">Buscando...</div>
{:else if searchResults.length === 0}
<div class="p-3 text-sm text-gray-500">Nenhum objeto encontrado.</div>
{:else}
<ul class="max-h-64 overflow-y-auto">
{#each searchResults as o (o._id)}
<li>
<button
type="button"
class="flex w-full items-center justify-between px-4 py-3 text-left transition hover:bg-blue-50"
onclick={() => openAddItemModal(o)}
>
<span class="font-medium text-gray-800">{o.nome}</span>
<Plus size={16} class="text-blue-600" />
</button>
</li>
{/each}
</ul>
{/if}
</div>
{/if}
</div>
{/if}
</div>
</div>
<div class="p-6">
{#if itemsQuery.isLoading}
<p class="text-sm text-gray-500">Carregando itens...</p>
{:else if items.length === 0}
<p class="text-sm text-gray-500">Nenhum item adicionado.</p>
{:else}
<div class="space-y-4">
{#each grouped as group (group.key)}
{@const dfd = group.isSemDfd ? null : group.key}
{@const pedidoLink = dfd ? pedidosByDfd[dfd] : null}
<div class="rounded-lg border border-gray-200">
<div class="flex items-center justify-between bg-gray-50 px-4 py-3">
<div class="flex flex-col gap-1">
<div class="font-semibold text-gray-900">{group.label}</div>
{#if pedidoLink?.pedido}
<a
href={resolve(`/pedidos/${pedidoLink.pedido._id}`)}
class="text-xs text-blue-700 hover:underline"
>
Pedido: {pedidoLink.pedido.numeroSei || pedidoLink.pedido._id} — {formatPedidoStatus(
pedidoLink.pedido.status
)}
</a>
{/if}
</div>
<div class="text-xs text-gray-500">{group.items.length} item(ns)</div>
</div>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-white">
<tr>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase"
>Objeto</th
>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase"
>Qtd</th
>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase"
>Valor Est.</th
>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase"
>DFD</th
>
<th class="px-4 py-2 text-right text-xs font-medium text-gray-500 uppercase"
>Ações</th
>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
{#each group.items as it (it._id)}
<tr class="hover:bg-gray-50">
<td class="px-4 py-2 text-sm text-gray-900">
{it.objetoNome}
{#if it.objetoUnidade}
<span class="ml-2 text-xs text-gray-400">({it.objetoUnidade})</span>
{/if}
</td>
<td class="px-4 py-2 text-sm text-gray-700">
{#if isRascunho}
<input
type="number"
min="1"
class="w-24 rounded border px-2 py-1 text-sm"
value={it.quantidade}
onchange={(e) =>
updateItemField(it._id, {
quantidade: parseInt(e.currentTarget.value, 10) || 1
})}
/>
{:else}
{it.quantidade}
{/if}
</td>
<td class="px-4 py-2 text-sm text-gray-700">
{#if isRascunho}
<input
type="text"
class="w-40 rounded border px-2 py-1 text-sm"
value={it.valorEstimado}
onblur={(e) =>
updateItemField(it._id, { valorEstimado: e.currentTarget.value })}
/>
{:else}
{it.valorEstimado}
{/if}
</td>
<td class="px-4 py-2 text-sm text-gray-700">
{#if isRascunho}
<input
type="text"
class="w-32 rounded border px-2 py-1 text-sm"
value={it.numeroDfd || ''}
placeholder="(opcional)"
onblur={(e) => {
const v = e.currentTarget.value.trim();
updateItemField(it._id, { numeroDfd: v ? v : null });
}}
/>
{:else}
{it.numeroDfd || '-'}
{/if}
</td>
<td class="px-4 py-2 text-right">
{#if isRascunho}
<button
type="button"
class="rounded bg-red-100 p-2 text-red-700 hover:bg-red-200"
onclick={() => removeItem(it._id)}
title="Remover item"
>
<Trash2 size={16} />
</button>
{:else}
<span class="text-xs text-gray-400">—</span>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
{/each}
</div>
{/if}
</div>
</div>
<!-- Pedidos -->
<div class="overflow-hidden rounded-lg bg-white shadow-md">
<div class="border-b border-gray-200 px-6 py-4">
<h2 class="text-lg font-semibold">Pedidos gerados</h2>
</div>
<div class="p-6">
{#if pedidosQuery.isLoading}
<p class="text-sm text-gray-500">Carregando pedidos...</p>
{:else if pedidosLinks.length === 0}
<p class="text-sm text-gray-500">Nenhum pedido gerado ainda.</p>
{:else}
<div class="space-y-4">
{#each pedidosLinks as row (row._id)}
<div class="rounded-lg border border-gray-200 p-4">
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="space-y-1">
<div class="text-sm font-semibold text-gray-900">DFD: {row.numeroDfd}</div>
{#if row.pedido}
<a
class="text-sm text-blue-700 hover:underline"
href={resolve(`/pedidos/${row.pedido._id}`)}
>
Pedido: {row.pedido.numeroSei || row.pedido._id}
</a>
{/if}
</div>
{#if row.pedido}
<span
class="inline-flex rounded-full px-2 py-1 text-xs font-semibold {getPedidoStatusColor(
row.pedido.status
)}"
>
{formatPedidoStatus(row.pedido.status)}
</span>
{/if}
</div>
{#if row.lastHistory && row.lastHistory.length > 0}
<div class="mt-3">
<div class="mb-2 text-xs font-semibold text-gray-500">Últimas ações</div>
<ul class="space-y-1 text-xs text-gray-700">
{#each row.lastHistory as h (h._id)}
<li class="flex items-center justify-between gap-2">
<span class="truncate">{h.usuarioNome}: {h.acao}</span>
<span class="shrink-0 text-gray-400"
>{new Date(h.data).toLocaleString('pt-BR')}</span
>
</li>
{/each}
</ul>
</div>
{/if}
</div>
{/each}
</div>
{/if}
</div>
</div>
<!-- Add item modal -->
{#if showAddItemModal && addItemConfig.objeto}
<div
class="fixed inset-0 z-50 flex h-full w-full items-center justify-center overflow-y-auto bg-black/40 p-4"
>
<div class="relative w-full max-w-lg rounded-xl bg-white p-6 shadow-2xl">
<button
type="button"
onclick={closeAddItemModal}
class="absolute top-4 right-4 rounded-lg p-1 text-gray-400 transition hover:bg-gray-100 hover:text-gray-600"
aria-label="Fechar"
disabled={addingItem}
>
<X size={24} />
</button>
<h3 class="mb-4 text-xl font-bold text-gray-900">Adicionar item</h3>
<div class="mb-4 rounded-lg bg-blue-50 p-4">
<p class="font-semibold text-gray-900">{addItemConfig.objeto.nome}</p>
<p class="text-sm text-gray-600">Unidade: {addItemConfig.objeto.unidade}</p>
</div>
<div class="space-y-4">
<div>
<label class="mb-2 block text-sm font-medium text-gray-700" for="ai_qtd"
>Quantidade</label
>
<input
id="ai_qtd"
type="number"
min="1"
class="w-full rounded-lg border border-gray-300 px-4 py-2.5"
bind:value={addItemConfig.quantidade}
disabled={addingItem}
/>
</div>
<div>
<label class="mb-2 block text-sm font-medium text-gray-700" for="ai_valor"
>Valor estimado</label
>
<input
id="ai_valor"
type="text"
class="w-full rounded-lg border border-gray-300 px-4 py-2.5"
bind:value={addItemConfig.valorEstimado}
disabled={addingItem}
/>
</div>
<div>
<label class="mb-2 block text-sm font-medium text-gray-700" for="ai_dfd"
>Número DFD (opcional)</label
>
<input
id="ai_dfd"
type="text"
class="w-full rounded-lg border border-gray-300 px-4 py-2.5"
bind:value={addItemConfig.numeroDfd}
disabled={addingItem}
placeholder="Ex: 123/2025"
/>
</div>
</div>
<div class="mt-6 flex justify-end gap-3">
<button
type="button"
onclick={closeAddItemModal}
class="rounded-lg bg-gray-200 px-5 py-2.5 font-semibold text-gray-800 hover:bg-gray-300"
disabled={addingItem}
>
Cancelar
</button>
<button
type="button"
onclick={confirmAddItem}
class="rounded-lg bg-blue-600 px-5 py-2.5 font-semibold text-white hover:bg-blue-700 disabled:opacity-50"
disabled={addingItem}
>
{addingItem ? 'Adicionando...' : 'Adicionar'}
</button>
</div>
</div>
</div>
{/if}
<!-- Gerar pedidos modal -->
{#if showGerarModal}
<div
class="fixed inset-0 z-50 flex h-full w-full items-center justify-center overflow-y-auto bg-black/40 p-4"
>
<div class="relative w-full max-w-2xl rounded-xl bg-white p-6 shadow-2xl">
<button
type="button"
onclick={closeGerarModal}
class="absolute top-4 right-4 rounded-lg p-1 text-gray-400 transition hover:bg-gray-100 hover:text-gray-600"
aria-label="Fechar"
disabled={gerando}
>
<X size={24} />
</button>
<h3 class="mb-2 text-xl font-bold text-gray-900">Gerar pedidos</h3>
<p class="mb-4 text-sm text-gray-600">
Será criado <strong>1 pedido por DFD</strong>. Informe o número SEI de cada pedido.
</p>
<div class="space-y-3">
{#each dfdsParaGerar as dfd (dfd)}
<div class="grid gap-3 rounded-lg border border-gray-200 p-4 md:grid-cols-3">
<div class="md:col-span-1">
<div class="text-xs font-semibold text-gray-500">DFD</div>
<div class="text-sm font-semibold text-gray-900">{dfd}</div>
</div>
<div class="md:col-span-2">
<label class="mb-1 block text-xs font-semibold text-gray-500" for={`sei_${dfd}`}
>Número SEI</label
>
<input
id={`sei_${dfd}`}
type="text"
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm"
bind:value={seiByDfd[dfd]}
disabled={gerando}
placeholder="Ex: 12345.000000/2025-00"
/>
</div>
</div>
{/each}
</div>
<div class="mt-6 flex justify-end gap-2">
<button
type="button"
onclick={closeGerarModal}
class="rounded-lg bg-gray-200 px-5 py-2.5 font-semibold text-gray-800 hover:bg-gray-300"
disabled={gerando}
>
Cancelar
</button>
<button
type="button"
onclick={confirmarGeracao}
class="rounded-lg bg-indigo-600 px-5 py-2.5 font-semibold text-white hover:bg-indigo-700 disabled:opacity-50"
disabled={gerando}
>
{gerando ? 'Gerando...' : 'Confirmar e gerar'}
</button>
</div>
</div>
</div>
{/if}
{/if}
</div>

View File

@@ -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;

View File

@@ -185,7 +185,10 @@ async function ensurePedidoModalidadeAtaConsistency(
const normalizedItemAtaId = (('ataId' in item ? item.ataId : undefined) ??
null) as Id<'atas'> | null;
if (item.modalidade !== modalidade || normalizedItemAtaId !== normalizedNewAtaId) {
const modalidadeMismatch = !!item.modalidade && !!modalidade && item.modalidade !== modalidade;
const ataMismatch = normalizedItemAtaId !== normalizedNewAtaId;
if (modalidadeMismatch || ataMismatch) {
throw new Error(
'Todos os itens do pedido devem usar a mesma modalidade e a mesma ata (quando houver). Ajuste os itens existentes ou crie um novo pedido para a nova combinação.'
);
@@ -529,6 +532,12 @@ export const getItems = query({
const funcionario = await ctx.db.get(item.adicionadoPor);
return {
...item,
// Se a modalidade ainda não foi definida, expõe como "consumo" para manter compatibilidade com UI existente
modalidade: (item.modalidade ?? 'consumo') as
| 'dispensa'
| 'inexgibilidade'
| 'adesao'
| 'consumo',
adicionadoPorNome: funcionario?.nome || 'Desconhecido'
};
})
@@ -654,7 +663,7 @@ export const checkExisting = query({
let include = true;
let matchingItems: {
objetoId: Id<'objetos'>;
modalidade: Doc<'objetoItems'>['modalidade'];
modalidade: NonNullable<Doc<'objetoItems'>['modalidade']>;
quantidade: number;
}[] = [];
@@ -666,13 +675,19 @@ export const checkExisting = query({
.collect();
const matching = items.filter((i) =>
itensFiltro.some((f) => f.objetoId === i.objetoId && f.modalidade === i.modalidade)
itensFiltro.some(
(f) => f.objetoId === i.objetoId && f.modalidade === (i.modalidade ?? 'consumo')
)
);
if (matching.length > 0) {
matchingItems = matching.map((i) => ({
objetoId: i.objetoId,
modalidade: i.modalidade,
modalidade: (i.modalidade ?? 'consumo') as
| 'dispensa'
| 'inexgibilidade'
| 'adesao'
| 'consumo',
quantidade: i.quantidade
}));
} else {
@@ -959,11 +974,13 @@ export const gerarRelatorio = query({
ataNumero: v.optional(v.string()),
acaoId: v.optional(v.id('acoes')),
acaoNome: v.optional(v.string()),
modalidade: v.union(
modalidade: v.optional(
v.union(
v.literal('dispensa'),
v.literal('inexgibilidade'),
v.literal('adesao'),
v.literal('consumo')
)
),
quantidade: v.number(),
valorEstimado: v.string(),
@@ -1125,7 +1142,11 @@ export const gerarRelatorio = query({
ataNumero: ata?.numero ?? undefined,
acaoId: it.acaoId,
acaoNome: acao?.nome ?? undefined,
modalidade: it.modalidade,
modalidade: (it.modalidade ?? 'consumo') as
| 'dispensa'
| 'inexgibilidade'
| 'adesao'
| 'consumo',
quantidade: it.quantidade,
valorEstimado: it.valorEstimado,
valorReal: it.valorReal,
@@ -1380,11 +1401,14 @@ export const addItem = mutation({
objetoId: v.id('objetos'),
ataId: v.optional(v.id('atas')),
acaoId: v.optional(v.id('acoes')),
modalidade: v.union(
// 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(),
quantidade: v.number()
@@ -1401,30 +1425,6 @@ export const addItem = mutation({
const pedido = await ctx.db.get(args.pedidoId);
if (!pedido) throw new Error('Pedido não encontrado.');
// Regra global: todos os itens do pedido devem ter a mesma
// modalidade e a mesma ata (quando houver).
await ensurePedidoModalidadeAtaConsistency(ctx, args.pedidoId, args.modalidade, args.ataId);
// --- CHECK ANALYSIS MODE ---
// Em pedidos em análise, a inclusão de itens deve passar por fluxo de aprovação.
// Em rascunho ou aguardando aceite, a inclusão é direta, sem necessidade de aprovação.
if (pedido.status === 'em_analise') {
if (args.ataId) {
// Não altera consumo aqui (ainda é só solicitação), mas valida limite/configuração.
await assertAtaObjetoCanConsume(ctx, args.ataId, args.objetoId, args.quantidade);
}
await ctx.db.insert('solicitacoesItens', {
pedidoId: args.pedidoId,
tipo: 'adicao',
dados: JSON.stringify(args),
status: 'pendente',
solicitadoPor: user.funcionarioId,
criadoEm: Date.now()
});
return;
}
// --- CHECK DUPLICATES (Same Product + User, Different Config) ---
// Get all items of this product added by this user in this order
const userProductItems = await ctx.db
.query('objetoItems')
@@ -1437,8 +1437,37 @@ export const addItem = mutation({
)
.collect();
// Se não foi informada, tenta inferir a modalidade a partir de itens já adicionados por este usuário
// (evita conflito de "mesmo produto com outra combinação" quando modalidade estiver vazia).
const modalidade =
args.modalidade ?? userProductItems.find((i) => !!i.modalidade)?.modalidade ?? undefined;
// Regra global: todos os itens do pedido devem ter a mesma
// modalidade e a mesma ata (quando houver).
await ensurePedidoModalidadeAtaConsistency(ctx, args.pedidoId, modalidade, args.ataId);
// --- CHECK ANALYSIS MODE ---
// Em pedidos em análise, a inclusão de itens deve passar por fluxo de aprovação.
// Em rascunho ou aguardando aceite, a inclusão é direta, sem necessidade de aprovação.
if (pedido.status === 'em_analise') {
if (args.ataId) {
// Não altera consumo aqui (ainda é só solicitação), mas valida limite/configuração.
await assertAtaObjetoCanConsume(ctx, args.ataId, args.objetoId, args.quantidade);
}
await ctx.db.insert('solicitacoesItens', {
pedidoId: args.pedidoId,
tipo: 'adicao',
dados: JSON.stringify({ ...args, modalidade }),
status: 'pendente',
solicitadoPor: user.funcionarioId,
criadoEm: Date.now()
});
return;
}
// --- CHECK DUPLICATES (Same Product + User, Different Config) ---
const conflict = userProductItems.find(
(i) => i.modalidade !== args.modalidade || i.ataId !== args.ataId
(i) => i.modalidade !== modalidade || i.ataId !== args.ataId
);
if (conflict) {
@@ -1449,7 +1478,7 @@ export const addItem = mutation({
// Check if item already exists with SAME parameters (exact match) to increment
const existingItem = userProductItems.find(
(i) => i.acaoId === args.acaoId && i.ataId === args.ataId && i.modalidade === args.modalidade
(i) => i.acaoId === args.acaoId && i.ataId === args.ataId && i.modalidade === modalidade
);
if (existingItem) {
@@ -1481,7 +1510,7 @@ export const addItem = mutation({
objetoId: args.objetoId,
ataId: args.ataId,
acaoId: args.acaoId,
modalidade: args.modalidade,
...(modalidade ? { modalidade } : {}),
valorEstimado: args.valorEstimado,
quantidade: args.quantidade,
adicionadoPor: user.funcionarioId,
@@ -1498,7 +1527,7 @@ export const addItem = mutation({
quantidade: args.quantidade,
acaoId: args.acaoId,
ataId: args.ataId,
modalidade: args.modalidade
modalidade: modalidade ?? null
}),
data: Date.now()
});

View 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;
}
});

View File

@@ -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

View File

@@ -34,11 +34,14 @@ export const pedidosTables = {
objetoId: v.id('objetos'), // was produtoId
ataId: v.optional(v.id('atas')),
acaoId: v.optional(v.id('acoes')), // Moved from pedidos
modalidade: v.union(
// 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()),

View 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'])
};