Files
sgse-app/apps/web/src/routes/(dashboard)/pedidos/[id]/+page.svelte

2469 lines
75 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script lang="ts">
import { api } from '@sgse-app/backend/convex/_generated/api';
import type { Doc, Id } from '@sgse-app/backend/convex/_generated/dataModel';
import type { FunctionReturnType } from 'convex/server';
import { useConvexClient, useQuery } from 'convex-svelte';
import { SvelteSet } from 'svelte/reactivity';
import { toast } from 'svelte-sonner';
import ConfirmationModal from '$lib/components/ConfirmationModal.svelte';
import Breadcrumbs from '$lib/components/layout/Breadcrumbs.svelte';
import PageShell from '$lib/components/layout/PageShell.svelte';
import GlassCard from '$lib/components/ui/GlassCard.svelte';
import {
AlertTriangle,
CheckCircle,
Clock,
Edit,
Eye,
Plus,
Upload,
Save,
Send,
Trash2,
X,
XCircle
} from 'lucide-svelte';
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';
const pedidoId = $derived(page.params.id as Id<'pedidos'>);
const client = useConvexClient();
// Reactive queries
const pedidoQuery = $derived.by(() => useQuery(api.pedidos.get, { id: pedidoId }));
const itemsQuery = $derived.by(() => useQuery(api.pedidos.getItems, { pedidoId }));
const historyQuery = $derived.by(() => useQuery(api.pedidos.getHistory, { pedidoId }));
const objetosQuery = $derived.by(() => useQuery(api.objetos.list, {}));
const acoesQuery = $derived.by(() => useQuery(api.acoes.list, {}));
const permissionsQuery = $derived.by(() => useQuery(api.pedidos.getPermissions, { pedidoId }));
const requestsQuery = $derived.by(() => useQuery(api.pedidos.getItemRequests, { pedidoId }));
const pedidoDocumentosQuery = $derived.by(() =>
useQuery(api.pedidos.listPedidoDocumentos, { pedidoId })
);
// Derived state
let pedido = $derived(pedidoQuery.data);
let items = $derived(itemsQuery.data || []);
let history = $derived(historyQuery.data || []);
let objetos = $derived(objetosQuery.data || []);
let acoes = $derived(acoesQuery.data || []);
let permissions = $derived(permissionsQuery.data);
let requests = $derived(requestsQuery.data || []);
let pedidoDocumentos = $derived(pedidoDocumentosQuery.data || []);
let currentFuncionarioId = $derived(permissions?.currentFuncionarioId ?? null);
type Modalidade = 'dispensa' | 'inexgibilidade' | 'adesao' | 'consumo';
function coerceModalidade(value: string): Modalidade {
if (
value === 'dispensa' ||
value === 'inexgibilidade' ||
value === 'adesao' ||
value === 'consumo'
) {
return value;
}
return 'consumo';
}
type EditingItem = {
valorEstimado: string;
modalidade: Modalidade;
acaoId: string;
ataId: string;
};
type PedidoItemForEdit = {
_id: Id<'objetoItems'>;
valorEstimado: string;
modalidade: Modalidade;
acaoId?: Id<'acoes'>;
ataId?: Id<'atas'>;
objetoId: Id<'objetos'>;
};
// Atas por objeto (carregadas sob demanda)
type AtasComLimite = FunctionReturnType<typeof api.objetos.getAtasComLimite>;
let atasPorObjetoExtra = $state<Record<string, AtasComLimite>>({});
let editingItems = $state<Record<string, EditingItem>>({});
// Seleção de itens para ações em lote
let selectedItemIds = new SvelteSet<Id<'objetoItems'>>();
function isItemSelected(itemId: Id<'objetoItems'>) {
return selectedItemIds.has(itemId);
}
function toggleItemSelection(itemId: Id<'objetoItems'>) {
if (selectedItemIds.has(itemId)) {
selectedItemIds.delete(itemId);
} else {
selectedItemIds.add(itemId);
}
}
function clearSelection() {
selectedItemIds.clear();
}
let selectedCount = $derived(selectedItemIds.size);
let hasSelection = $derived(selectedCount > 0);
// Pela regra do backend, um pedido só pode ter uma ata (quando houver).
// Usamos isso para “forçar incluir” a ata atual do pedido na listagem do objeto,
// mesmo que esteja fora da janela (ex.: vencida há mais de 3 meses).
let pedidoAtaId = $derived.by(() => {
const withAta = (items as unknown as PedidoItemForEdit[]).find((i) => i.ataId);
return (withAta?.ataId ?? null) as Id<'atas'> | null;
});
// Carrega atas (para itens existentes) via query batch, evitando efeitos que mutam estado.
const atasBatchQuery = $derived.by(() =>
useQuery(api.objetos.getAtasComLimiteBatch, () => {
const ids: Id<'objetos'>[] = [];
const seen: Record<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
let groupedItems = $derived.by(() => {
const groups: Record<string, { name: string; items: typeof items }> = {};
for (const item of items) {
const userId = item.adicionadoPor;
if (!groups[userId]) {
groups[userId] = {
name: item.adicionadoPorNome,
items: []
};
}
groups[userId].items.push(item);
}
return Object.values(groups);
});
let loading = $derived(
pedidoQuery.isLoading ||
itemsQuery.isLoading ||
historyQuery.isLoading ||
objetosQuery.isLoading ||
acoesQuery.isLoading ||
permissionsQuery.isLoading ||
requestsQuery.isLoading ||
pedidoDocumentosQuery.isLoading
);
let error = $derived(
pedidoQuery.error?.message ||
itemsQuery.error?.message ||
historyQuery.error?.message ||
objetosQuery.error?.message ||
acoesQuery.error?.message ||
null
);
// Documentos do Pedido
let showAddPedidoDocumento = $state(false);
let pedidoDocumentoDescricao = $state('');
let pedidoDocumentoFile = $state<File | null>(null);
let salvandoPedidoDocumento = $state(false);
function formatBytes(bytes: number) {
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB'];
let value = bytes;
let idx = 0;
while (value >= 1024 && idx < units.length - 1) {
value /= 1024;
idx += 1;
}
return `${value.toFixed(idx === 0 ? 0 : 1)} ${units[idx]}`;
}
async function uploadToStorage(uploadUrl: string, file: File): Promise<Id<'_storage'>> {
const result = await fetch(uploadUrl, {
method: 'POST',
headers: { 'Content-Type': file.type },
body: file
});
const json = (await result.json()) as { storageId: Id<'_storage'> };
return json.storageId;
}
async function handleAddPedidoDocumento() {
if (!pedido) return;
if (!pedidoDocumentoFile) {
toast.error('Selecione um arquivo.');
return;
}
if (!pedidoDocumentoDescricao.trim()) {
toast.error('Informe uma descrição.');
return;
}
salvandoPedidoDocumento = true;
try {
const uploadUrl = await client.mutation(api.pedidos.generatePedidoUploadUrl, { pedidoId });
const storageId = await uploadToStorage(uploadUrl, pedidoDocumentoFile);
await client.mutation(api.pedidos.addPedidoDocumento, {
pedidoId,
descricao: pedidoDocumentoDescricao.trim(),
nome: pedidoDocumentoFile.name,
storageId,
tipo: pedidoDocumentoFile.type,
tamanho: pedidoDocumentoFile.size
});
pedidoDocumentoDescricao = '';
pedidoDocumentoFile = null;
showAddPedidoDocumento = false;
toast.success('Documento anexado ao pedido.');
} catch (e) {
toast.error('Erro ao anexar documento: ' + (e as Error).message);
} finally {
salvandoPedidoDocumento = false;
}
}
function handleOpenPedidoDocumento(url: string | null) {
if (!url) return;
window.open(url, '_blank');
}
function handleRemovePedidoDocumento(id: Id<'pedidoDocumentos'>) {
openConfirm(
'Remover documento',
'Tem certeza que deseja remover este documento do pedido?',
async () => {
await client.mutation(api.pedidos.removePedidoDocumento, { id });
toast.success('Documento removido.');
},
{ isDestructive: true, confirmText: 'Remover' }
);
}
// Documentos por Solicitação
let showSolicitacaoDocsModal = $state(false);
let solicitacaoDocsRequestId = $state<Id<'solicitacoesItens'> | null>(null);
let solicitacaoDocsSolicitadoPor = $state<Id<'funcionarios'> | null>(null);
let solicitacaoDocsTipo = $state<string | null>(null);
let solicitacaoDocs = $state<FunctionReturnType<typeof api.pedidos.listSolicitacaoDocumentos>>(
[]
);
let carregandoSolicitacaoDocs = $state(false);
let solicitacaoDocumentoDescricao = $state('');
let solicitacaoDocumentoFile = $state<File | null>(null);
let salvandoSolicitacaoDocumento = $state(false);
const canAddDocsToSelectedRequest = $derived(
showSolicitacaoDocsModal &&
solicitacaoDocsTipo === 'adicao' &&
currentFuncionarioId !== null &&
solicitacaoDocsSolicitadoPor !== null &&
currentFuncionarioId === solicitacaoDocsSolicitadoPor
);
async function openSolicitacaoDocs(req: {
_id: Id<'solicitacoesItens'>;
solicitadoPor: Id<'funcionarios'>;
tipo: string;
}) {
solicitacaoDocsRequestId = req._id;
solicitacaoDocsSolicitadoPor = req.solicitadoPor;
solicitacaoDocsTipo = req.tipo;
solicitacaoDocumentoDescricao = '';
solicitacaoDocumentoFile = null;
showSolicitacaoDocsModal = true;
carregandoSolicitacaoDocs = true;
try {
solicitacaoDocs = await client.query(api.pedidos.listSolicitacaoDocumentos, {
requestId: req._id
});
} catch (e) {
toast.error('Erro ao carregar documentos da solicitação: ' + (e as Error).message);
solicitacaoDocs = [];
} finally {
carregandoSolicitacaoDocs = false;
}
}
function closeSolicitacaoDocs() {
showSolicitacaoDocsModal = false;
solicitacaoDocsRequestId = null;
solicitacaoDocsSolicitadoPor = null;
solicitacaoDocsTipo = null;
solicitacaoDocs = [];
solicitacaoDocumentoDescricao = '';
solicitacaoDocumentoFile = null;
}
function handleOpenSolicitacaoDocumento(url: string | null) {
if (!url) return;
window.open(url, '_blank');
}
async function refreshSolicitacaoDocs() {
if (!solicitacaoDocsRequestId) return;
carregandoSolicitacaoDocs = true;
try {
solicitacaoDocs = await client.query(api.pedidos.listSolicitacaoDocumentos, {
requestId: solicitacaoDocsRequestId
});
} finally {
carregandoSolicitacaoDocs = false;
}
}
async function handleAddSolicitacaoDocumento() {
if (!solicitacaoDocsRequestId) return;
if (!canAddDocsToSelectedRequest) {
toast.error('Apenas quem criou a solicitação pode anexar documentos.');
return;
}
if (!solicitacaoDocumentoFile) {
toast.error('Selecione um arquivo.');
return;
}
if (!solicitacaoDocumentoDescricao.trim()) {
toast.error('Informe uma descrição.');
return;
}
salvandoSolicitacaoDocumento = true;
try {
const uploadUrl = await client.mutation(api.pedidos.generateSolicitacaoUploadUrl, {
requestId: solicitacaoDocsRequestId
});
const storageId = await uploadToStorage(uploadUrl, solicitacaoDocumentoFile);
await client.mutation(api.pedidos.addSolicitacaoDocumento, {
requestId: solicitacaoDocsRequestId,
descricao: solicitacaoDocumentoDescricao.trim(),
nome: solicitacaoDocumentoFile.name,
storageId,
tipo: solicitacaoDocumentoFile.type,
tamanho: solicitacaoDocumentoFile.size
});
solicitacaoDocumentoDescricao = '';
solicitacaoDocumentoFile = null;
toast.success('Documento anexado à solicitação.');
await refreshSolicitacaoDocs();
} catch (e) {
toast.error('Erro ao anexar documento: ' + (e as Error).message);
} finally {
salvandoSolicitacaoDocumento = false;
}
}
function handleRemoveSolicitacaoDocumento(id: Id<'solicitacoesItensDocumentos'>) {
openConfirm(
'Remover documento',
'Tem certeza que deseja remover este documento da solicitação?',
async () => {
await client.mutation(api.pedidos.removeSolicitacaoDocumento, { id });
toast.success('Documento removido.');
await refreshSolicitacaoDocs();
},
{ isDestructive: true, confirmText: 'Remover' }
);
}
// Add Item State
let showAddItem = $state(false);
let hasAppliedAddItemPrefill = $state(false);
let newItem = $state({
objetoId: '' as string,
valorEstimado: '',
quantidade: 1,
modalidade: 'consumo' as Modalidade,
acaoId: '' as string,
ataId: '' as string
});
let addingItem = $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);
let seiValue = $state('');
let updatingSei = $state(false);
// Edit DFD State
let editingDfd = $state(false);
let dfdValue = $state('');
let updatingDfd = $state(false);
// Item Details Modal State
let showDetailsModal = $state(false);
let selectedObjeto = $state<Doc<'objetos'> | null>(null);
// Confirmation Modal State
let confirmModal = $state({
open: false,
title: '',
message: '',
confirmText: 'Confirmar',
cancelText: 'Cancelar',
isDestructive: false,
onConfirm: async () => {}
});
function openConfirm(
title: string,
message: string,
onConfirm: () => Promise<void> | void,
options: {
confirmText?: string;
cancelText?: string;
isDestructive?: boolean;
} = {}
) {
confirmModal.title = title;
confirmModal.message = message;
confirmModal.confirmText = options.confirmText || 'Confirmar';
confirmModal.cancelText = options.cancelText || 'Cancelar';
confirmModal.isDestructive = options.isDestructive || false;
confirmModal.onConfirm = async () => {
try {
await onConfirm();
} catch (e) {
console.error('Error in confirmation action:', e);
toast.error('Erro ao executar ação: ' + (e as Error).message);
}
};
confirmModal.open = true;
}
function openDetails(objetoId: string) {
const obj = objetos.find((o) => o._id === objetoId);
if (obj) {
selectedObjeto = obj;
showDetailsModal = true;
}
}
function closeDetails() {
showDetailsModal = false;
selectedObjeto = null;
}
async function handleAddItem() {
if (!pedido || !newItem.objetoId || !newItem.valorEstimado) return;
// Validação no front: garantir que todos os itens existentes do pedido
// utilizem a mesma ata (quando houver).
if (items.length > 0) {
const referenceItem = items[0];
const referenceAtaId = (('ataId' in referenceItem ? referenceItem.ataId : undefined) ??
null) as string | null;
const newAtaId = newItem.ataId || null;
const sameAta = referenceAtaId === newAtaId;
if (!sameAta) {
const refAtaLabel =
referenceAtaId === null ? 'sem Ata vinculada' : 'com uma Ata específica';
toast.error(
`Não é possível adicionar este item com esta ata.\n\n` +
`Este pedido já está vinculado a: ${refAtaLabel}.\n` +
`Todos os itens do pedido devem usar a mesma ata (quando houver).`
);
return;
}
}
addingItem = true;
try {
await client.mutation(api.pedidos.addItem, {
pedidoId,
objetoId: newItem.objetoId as Id<'objetos'>,
valorEstimado: newItem.valorEstimado,
quantidade: newItem.quantidade,
acaoId: newItem.acaoId ? (newItem.acaoId as Id<'acoes'>) : undefined,
ataId: newItem.ataId ? (newItem.ataId as Id<'atas'>) : undefined
});
newItem = {
objetoId: '',
valorEstimado: '',
quantidade: 1,
modalidade: 'consumo',
acaoId: '',
ataId: ''
};
showAddItem = false;
if (pedido.status === 'em_analise' || pedido.status === 'aguardando_aceite') {
toast.success('Solicitação de adição enviada para análise.');
} else {
toast.success('Item adicionado com sucesso!');
}
} catch (e) {
const message = (e as Error).message || String(e);
if (
message.includes('Todos os itens do pedido devem usar a mesma modalidade e a mesma ata')
) {
toast.error(
'Não é possível adicionar este item, pois o pedido já possui uma combinação diferente de modalidade e ata. Ajuste os itens existentes ou crie um novo pedido para a nova combinação.'
);
} else if (
message.includes(
'Este pedido já possui este produto com outra combinação de modalidade e/ou ata'
)
) {
toast.error(
'Você já adicionou este produto neste pedido com outra combinação de modalidade e/ ou ata. Ajuste o item existente ou crie um novo pedido para a nova combinação.'
);
} else {
toast.error('Erro ao adicionar item: ' + message);
}
} finally {
addingItem = false;
}
}
async function handleUpdateQuantity(itemId: Id<'objetoItems'>, novaQuantidade: number) {
if (!pedido) return;
if (novaQuantidade < 1) {
toast.error('Quantidade deve ser pelo menos 1.');
return;
}
try {
await client.mutation(api.pedidos.updateItemQuantity, {
itemId,
novaQuantidade
});
if (pedido.status === 'em_analise' || pedido.status === 'aguardando_aceite') {
toast.success('Solicitação de alteração de quantidade enviada para análise.');
} else {
toast.success('Quantidade atualizada com sucesso!');
}
} catch (e) {
toast.error('Erro ao atualizar quantidade: ' + (e as Error).message);
}
}
function handleRemoveItem(itemId: Id<'objetoItems'>) {
if (!pedido) return;
openConfirm(
'Remover Item',
'Tem certeza que deseja remover este item do pedido?',
async () => {
await client.mutation(api.pedidos.removeItem, { itemId });
if (pedido.status === 'em_analise' || pedido.status === 'aguardando_aceite') {
toast.success('Solicitação de remoção enviada para análise.');
} else {
toast.success('Item removido com sucesso!');
}
},
{ isDestructive: true, confirmText: 'Remover' }
);
}
function handleEnviarParaAceite() {
openConfirm(
'Enviar para Aceite',
'Tem certeza que deseja enviar este pedido para aceite? Ele ficará aguardando análise do setor responsável.',
async () => {
await client.mutation(api.pedidos.enviarParaAceite, { pedidoId });
toast.success('Pedido enviado para aceite com sucesso!');
},
{ confirmText: 'Enviar' }
);
}
function handleIniciarAnalise() {
openConfirm(
'Iniciar Análise',
'Tem certeza que deseja iniciar a análise deste pedido? O status será alterado para "Em Análise".',
async () => {
await client.mutation(api.pedidos.iniciarAnalise, { pedidoId });
toast.success('Análise iniciada com sucesso!');
},
{ confirmText: 'Iniciar Análise' }
);
}
function handleConcluir() {
openConfirm(
'Concluir Pedido',
'Tem certeza que deseja concluir este pedido? Esta ação não pode ser desfeita.',
async () => {
await client.mutation(api.pedidos.concluirPedido, { pedidoId });
toast.success('Pedido concluído com sucesso!');
},
{ confirmText: 'Concluir Pedido' }
);
}
function handleCancelar() {
openConfirm(
'Cancelar Pedido',
'Tem certeza que deseja cancelar este pedido? Esta ação não pode ser desfeita e o pedido ficará inutilizável.',
async () => {
await client.mutation(api.pedidos.cancelarPedido, { pedidoId });
toast.success('Pedido cancelado com sucesso!');
},
{ isDestructive: true, confirmText: 'Cancelar Pedido' }
);
}
function getObjetoName(id: string) {
return objetos.find((o) => o._id === id)?.nome || 'Objeto desconhecido';
}
function getAcaoName(id: string | undefined) {
if (!id) return '-';
return acoes.find((a) => a._id === id)?.nome || '-';
}
async function loadAtasForObjeto(objetoId: string) {
if (atasPorObjetoExtra[objetoId]) return;
try {
const linkedAtas = await client.query(api.objetos.getAtasComLimite, {
objetoId: objetoId as Id<'objetos'>,
includeAtaIds: pedidoAtaId ? [pedidoAtaId] : undefined
});
atasPorObjetoExtra[objetoId] = linkedAtas;
} catch (e) {
console.error('Erro ao carregar atas para objeto', objetoId, e);
}
}
function getAtasForObjeto(objetoId: string): AtasComLimite {
return atasPorObjetoExtra[objetoId] || atasPorObjetoFromBatch[objetoId] || [];
}
function handleObjetoChange(id: string) {
newItem.objetoId = id;
const objeto = objetos.find((o) => o._id === id);
if (objeto) {
newItem.valorEstimado = maskCurrencyBRL(objeto.valorEstimado || '');
} else {
newItem.valorEstimado = '';
}
newItem.ataId = '';
if (id) {
void loadAtasForObjeto(id);
}
}
function parseMoneyToNumber(value: string): number {
const cleanValue = value.replace(/[^0-9,]/g, '').replace(',', '.');
return parseFloat(cleanValue) || 0;
}
function calculateItemTotal(valorEstimado: string, quantidade: number): number {
const unitValue = parseMoneyToNumber(valorEstimado);
return unitValue * quantidade;
}
let totalGeral = $derived(
items.reduce((sum, item) => sum + calculateItemTotal(item.valorEstimado, item.quantidade), 0)
);
function ensureEditingItem(item: PedidoItemForEdit): EditingItem {
const existing = editingItems[item._id];
if (existing) return existing;
return {
valorEstimado: maskCurrencyBRL(item.valorEstimado || ''),
modalidade: coerceModalidade((item.modalidade as string | undefined) ?? 'consumo'),
acaoId: item.acaoId ?? '',
ataId: item.ataId ?? ''
};
}
function formatModalidade(modalidade: Modalidade) {
switch (modalidade) {
case 'consumo':
return 'Consumo';
case 'dispensa':
return 'Dispensa';
case 'inexgibilidade':
return 'Inexigibilidade';
case 'adesao':
return 'Adesão';
default:
return modalidade;
}
}
function setEditingField<K extends keyof EditingItem>(
itemId: Id<'objetoItems'>,
field: K,
value: EditingItem[K]
) {
const current = editingItems[itemId] ?? {
valorEstimado: '',
modalidade: 'consumo',
acaoId: '',
ataId: ''
};
editingItems = {
...editingItems,
[itemId]: {
...current,
[field]: value
}
};
}
async function persistItemChanges(item: PedidoItemForEdit) {
const current = ensureEditingItem(item);
try {
await client.mutation(api.pedidos.updateItem, {
itemId: item._id,
valorEstimado: current.valorEstimado,
modalidade: current.modalidade,
acaoId: current.acaoId ? (current.acaoId as Id<'acoes'>) : undefined,
ataId: current.ataId ? (current.ataId as Id<'atas'>) : undefined
});
delete editingItems[item._id]; // Clear editing state
if (pedido?.status === 'em_analise' || pedido?.status === 'aguardando_aceite') {
toast.success('Solicitação de alteração enviada para análise.');
} else {
toast.success('Item atualizado com sucesso!');
}
} catch (e) {
toast.error('Erro ao atualizar item: ' + (e as Error).message);
}
}
function formatStatus(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;
}
}
async function handleUpdateSei() {
if (!seiValue.trim()) {
toast.error('O número SEI não pode estar vazio.');
return;
}
updatingSei = true;
try {
await client.mutation(api.pedidos.updateSeiNumber, {
pedidoId,
numeroSei: seiValue
});
editingSei = false;
toast.success('Número SEI atualizado com sucesso!');
} catch (e) {
toast.error('Erro ao atualizar número SEI: ' + (e as Error).message);
} finally {
updatingSei = false;
}
}
function startEditingSei() {
seiValue = pedido?.numeroSei || '';
editingSei = true;
}
function cancelEditingSei() {
editingSei = false;
seiValue = '';
}
async function handleUpdateDfd() {
if (!dfdValue.trim()) {
toast.error('O número DFD não pode estar vazio.');
return;
}
updatingDfd = true;
try {
await client.mutation(api.pedidos.updateDfdNumber, {
pedidoId,
numeroDfd: dfdValue
});
editingDfd = false;
toast.success('Número DFD atualizado com sucesso!');
} catch (e) {
toast.error('Erro ao atualizar número DFD: ' + (e as Error).message);
} finally {
updatingDfd = false;
}
}
function startEditingDfd() {
dfdValue = pedido?.numeroDfd || '';
editingDfd = true;
}
function cancelEditingDfd() {
editingDfd = false;
dfdValue = '';
}
function getStatusBadgeClass(status: string) {
switch (status) {
case 'em_rascunho':
return 'badge-ghost';
case 'aguardando_aceite':
return 'badge-warning';
case 'em_analise':
return 'badge-info';
case 'precisa_ajustes':
return 'badge-secondary';
case 'concluido':
return 'badge-success';
case 'cancelado':
return 'badge-error';
default:
return 'badge-ghost';
}
}
function getHistoryIcon(acao: string) {
switch (acao) {
case 'criacao_pedido':
return '📝';
case 'adicao_item':
return '';
case 'remocao_item':
return '🗑️';
case 'alteracao_quantidade':
return '🔢';
case 'alteracao_status':
return '🔄';
case 'atualizacao_sei':
return '📋';
case 'atualizacao_dfd':
return '📋';
case 'edicao_item':
return '✏️';
case 'solicitacao_ajuste':
return '⚠️';
case 'conclusao_ajustes':
return '✅';
default:
return '•';
}
}
function formatHistoryEntry(entry: {
acao: string;
detalhes: string | undefined;
usuarioNome: string;
}): string {
try {
const detalhes = entry.detalhes ? JSON.parse(entry.detalhes) : {};
switch (entry.acao) {
case 'criacao_pedido':
return `${entry.usuarioNome} criou o pedido ${detalhes.numeroSei || ''}`;
case 'adicao_item': {
const objeto = objetos.find((o) => o._id === detalhes.objetoId);
const nomeObjeto = objeto?.nome || 'Objeto desconhecido';
const quantidade = detalhes.quantidade || 1;
return `${entry.usuarioNome} adicionou ${quantidade}x ${nomeObjeto} (${detalhes.valor})`;
}
case 'remocao_item': {
const objeto = objetos.find((o) => o._id === detalhes.objetoId);
const nomeObjeto = objeto?.nome || 'Objeto desconhecido';
return `${entry.usuarioNome} removeu ${nomeObjeto}`;
}
case 'alteracao_quantidade': {
const objeto = objetos.find((o) => o._id === detalhes.objetoId);
const nomeObjeto = objeto?.nome || 'Objeto desconhecido';
return `${entry.usuarioNome} alterou a quantidade de ${nomeObjeto} de ${detalhes.quantidadeAnterior} para ${detalhes.novaQuantidade}`;
}
case 'alteracao_status':
return `${entry.usuarioNome} alterou o status para "${formatStatus(detalhes.novoStatus)}"`;
case 'atualizacao_sei':
return `${entry.usuarioNome} atualizou o número SEI para "${detalhes.para}"`;
case 'atualizacao_dfd':
return `${entry.usuarioNome} atualizou o número DFD para "${detalhes.para}"`;
case 'edicao_item': {
const objeto = objetos.find((o) => o._id === detalhes.objetoId);
const nomeObjeto = objeto?.nome || 'Objeto desconhecido';
return `${entry.usuarioNome} editou o item ${nomeObjeto}`;
}
case 'solicitacao_ajuste': {
const descricao = detalhes.descricao || '';
return descricao
? `${entry.usuarioNome} solicitou ajustes: ${descricao}`
: `${entry.usuarioNome} solicitou ajustes neste pedido.`;
}
case 'conclusao_ajustes': {
const descricaoResolvida = detalhes.descricaoResolvida || '';
return descricaoResolvida
? `${entry.usuarioNome} concluiu os ajustes. Descrição original: ${descricaoResolvida}`
: `${entry.usuarioNome} concluiu os ajustes deste pedido.`;
}
default:
return `${entry.usuarioNome} realizou: ${entry.acao}`;
}
} catch {
return `${entry.usuarioNome} - ${entry.acao}`;
}
}
function handleRemoveSelectedItems() {
if (!hasSelection) return;
openConfirm(
'Remover Itens Selecionados',
selectedCount === 1
? 'Tem certeza que deseja remover o item selecionado deste pedido?'
: `Tem certeza que deseja remover os ${selectedCount} itens selecionados deste pedido?`,
async () => {
await client.mutation(api.pedidos.removeItemsBatch, {
itemIds: Array.from(selectedItemIds) as Id<'objetoItems'>[]
});
clearSelection();
toast.success('Itens removidos com sucesso!');
},
{ isDestructive: true, confirmText: 'Remover Selecionados' }
);
}
let showSplitResultModal = $state(false);
let novoPedidoIdParaNavegar = $state<Id<'pedidos'> | null>(null);
let quantidadeItensMovidos = $state(0);
// Split Confirmation Modal State
let showSplitConfirmationModal = $state(false);
let newPedidoSei = $state('');
function handleSplitPedidoFromSelection() {
if (!hasSelection) return;
newPedidoSei = '';
showSplitConfirmationModal = true;
}
async function confirmSplitPedido() {
try {
const itemIds = Array.from(selectedItemIds) as Id<'objetoItems'>[];
const novoPedidoId = await client.mutation(api.pedidos.splitPedido, {
pedidoId,
itemIds,
numeroSei: newPedidoSei.trim() || undefined
});
novoPedidoIdParaNavegar = novoPedidoId;
quantidadeItensMovidos = itemIds.length;
showSplitConfirmationModal = false;
showSplitResultModal = true;
clearSelection();
toast.success('Pedido dividido com sucesso!');
} catch (e) {
toast.error('Erro ao dividir pedido: ' + (e as Error).message);
}
}
// Adjustment Modal State
let showRequestAdjustmentsModal = $state(false);
let adjustmentDescription = $state('');
function openRequestAdjustmentsModal() {
adjustmentDescription = '';
showRequestAdjustmentsModal = true;
}
async function confirmRequestAdjustments() {
if (!adjustmentDescription.trim()) {
toast.error('Por favor, informe a descrição dos ajustes necessários.');
return;
}
try {
await client.mutation(api.pedidos.solicitarAjustes, {
pedidoId,
descricao: adjustmentDescription
});
showRequestAdjustmentsModal = false;
toast.success('Solicitação de ajustes enviada com sucesso!');
} catch (e) {
toast.error('Erro ao solicitar ajustes: ' + (e as Error).message);
}
}
function handleConcluirAjustes() {
openConfirm(
'Concluir Ajustes',
'Tem certeza que deseja concluir os ajustes e retornar o pedido para análise?',
async () => {
await client.mutation(api.pedidos.concluirAjustes, { pedidoId });
toast.success('Ajustes concluídos com sucesso!');
},
{ confirmText: 'Concluir Ajustes' }
);
}
function handleApproveRequest(requestId: Id<'solicitacoesItens'>) {
openConfirm(
'Aprovar Solicitação',
'Tem certeza que deseja aprovar esta solicitação?',
async () => {
await client.mutation(api.pedidos.approveItemRequest, { requestId });
toast.success('Solicitação aprovada com sucesso!');
},
{ confirmText: 'Aprovar' }
);
}
function handleRejectRequest(requestId: Id<'solicitacoesItens'>) {
openConfirm(
'Rejeitar Solicitação',
'Tem certeza que deseja rejeitar esta solicitação?',
async () => {
await client.mutation(api.pedidos.rejectItemRequest, { requestId });
toast.success('Solicitação rejeitada com sucesso!');
},
{ isDestructive: true, confirmText: 'Rejeitar' }
);
}
function describeChangedDetails(data: unknown): string {
if (!data || typeof data !== 'object') {
return 'Alteração de detalhes do item';
}
const { de, para } = data as {
de?: {
valorEstimado?: string;
modalidade?: Modalidade;
acaoId?: string | null;
ataId?: string | null;
};
para?: {
valorEstimado?: string;
modalidade?: Modalidade;
acaoId?: string | null;
ataId?: string | null;
};
};
if (!de || !para) {
return 'Alteração de detalhes do item';
}
const changed: string[] = [];
if (de.valorEstimado !== para.valorEstimado) {
changed.push('valor estimado');
}
if (de.modalidade !== para.modalidade) {
changed.push('modalidade');
}
if ((de.acaoId ?? null) !== (para.acaoId ?? null)) {
changed.push('ação');
}
if ((de.ataId ?? null) !== (para.ataId ?? null)) {
changed.push('ata');
}
if (changed.length === 0) {
return 'Alteração de detalhes do item';
}
if (changed.length === 1) {
return `Alteração de ${changed[0]}`;
}
const last = changed.pop()!;
const prefix = changed.join(', ');
return `Alteração de ${prefix} e ${last}`;
}
function parseRequestData(json: string) {
try {
return JSON.parse(json);
} catch {
return {};
}
}
</script>
<PageShell>
<Breadcrumbs
items={[
{ label: 'Dashboard', href: resolve('/') },
{ label: 'Pedidos', href: resolve('/pedidos') },
{ label: pedido?.numeroSei ? `SEI: ${pedido.numeroSei}` : `Pedido ${pedidoId.slice(-6)}` }
]}
/>
{#if loading}
<div class="flex items-center justify-center py-10">
<span class="loading loading-spinner loading-lg"></span>
</div>
{:else if error}
<div class="alert alert-error">
<span>{error}</span>
</div>
{:else if pedido}
<GlassCard class="mb-6">
<div class="flex flex-col items-start justify-between gap-4 sm:flex-row sm:items-center">
<div class="min-w-0">
<div class="flex flex-col gap-3">
{#if editingSei}
<div class="flex items-center gap-2">
<input
type="text"
bind:value={seiValue}
class="input input-bordered input-sm w-64"
placeholder="Número SEI"
disabled={updatingSei}
/>
<button
onclick={handleUpdateSei}
disabled={updatingSei}
class="btn btn-primary btn-sm btn-square"
title="Salvar"
>
{#if updatingSei}
<span class="loading loading-spinner loading-sm"></span>
{:else}
<Save class="h-4 w-4" />
{/if}
</button>
<button
onclick={cancelEditingSei}
disabled={updatingSei}
class="btn btn-ghost btn-sm btn-square"
title="Cancelar"
>
<X class="h-4 w-4" />
</button>
</div>
{:else}
<div class="flex items-center gap-2">
<h1 class="text-primary text-2xl font-bold">
SEI: {pedido.numeroSei || 'sem número SEI'}
</h1>
{#if pedido.status === 'em_rascunho' || pedido.status === 'precisa_ajustes'}
<button
onclick={startEditingSei}
class="btn btn-ghost btn-sm btn-square"
title="Editar número SEI"
>
<Edit class="h-4 w-4" />
</button>
{/if}
</div>
{/if}
{#if editingDfd}
<div class="flex items-center gap-2">
<input
type="text"
bind:value={dfdValue}
class="input input-bordered input-sm w-64"
placeholder="Número DFD"
disabled={updatingDfd}
/>
<button
onclick={handleUpdateDfd}
disabled={updatingDfd}
class="btn btn-primary btn-sm btn-square"
title="Salvar"
>
{#if updatingDfd}
<span class="loading loading-spinner loading-sm"></span>
{:else}
<Save class="h-4 w-4" />
{/if}
</button>
<button
onclick={cancelEditingDfd}
disabled={updatingDfd}
class="btn btn-ghost btn-sm btn-square"
title="Cancelar"
>
<X class="h-4 w-4" />
</button>
</div>
{:else}
<div class="flex items-center gap-2">
<span class="text-base-content/70">DFD: {pedido.numeroDfd || 'sem número DFD'}</span
>
{#if pedido.status === 'em_rascunho' || pedido.status === 'precisa_ajustes'}
<button
onclick={startEditingDfd}
class="btn btn-ghost btn-sm btn-square"
title="Editar número DFD"
>
<Edit class="h-4 w-4" />
</button>
{/if}
</div>
{/if}
</div>
<span class="badge badge-sm {getStatusBadgeClass(pedido.status)}">
{formatStatus(pedido.status)}
</span>
{#if !pedido.numeroSei}
<div class="alert alert-warning mt-3">
<span class="text-sm">
⚠️ Este pedido não possui número SEI. Um número SEI deve ser informado antes de
enviar para aceite.
</span>
</div>
{/if}
{#if !pedido.numeroDfd}
<div class="alert alert-warning mt-3">
<span class="text-sm">
⚠️ Este pedido não possui número DFD. Um número DFD deve ser informado antes de
enviar para aceite.
</span>
</div>
{/if}
{#if pedido.status === 'precisa_ajustes' && pedido.descricaoAjuste}
<div class="alert alert-warning mt-3">
<span class="flex items-start gap-2 text-sm">
<AlertTriangle class="mt-0.5 h-4 w-4" />
<span class="whitespace-pre-wrap">
<strong>Ajustes solicitados:</strong>
{pedido.descricaoAjuste}
</span>
</span>
</div>
{/if}
</div>
<div class="flex flex-wrap items-center gap-2">
{#if permissions?.canSendToAcceptance}
<button
onclick={handleEnviarParaAceite}
disabled={!pedido.numeroSei || !pedido.numeroDfd}
class="btn btn-primary gap-2"
>
<Send class="h-4 w-4" /> Enviar para Aceite
</button>
{/if}
{#if permissions?.canStartAnalysis}
<button onclick={handleIniciarAnalise} class="btn btn-primary gap-2">
<Clock class="h-4 w-4" /> Iniciar Análise
</button>
{/if}
{#if permissions?.canConclude}
<button onclick={handleConcluir} class="btn btn-success gap-2">
<CheckCircle class="h-4 w-4" /> Concluir
</button>
{/if}
{#if permissions?.canRequestAdjustments}
<button onclick={openRequestAdjustmentsModal} class="btn btn-warning gap-2">
<AlertTriangle class="h-4 w-4" /> Solicitar Ajustes
</button>
{/if}
{#if permissions?.canCompleteAdjustments}
<button onclick={handleConcluirAjustes} class="btn btn-success gap-2">
<CheckCircle class="h-4 w-4" /> Concluir Ajustes
</button>
{/if}
{#if permissions?.canCancel}
<button onclick={handleCancelar} class="btn btn-outline btn-error gap-2">
<XCircle class="h-4 w-4" /> Cancelar
</button>
{/if}
</div>
</div></GlassCard
>
<!-- Documentos do Pedido -->
<GlassCard class="mb-6" bodyClass="p-0">
<div class="border-base-300 flex items-center justify-between border-b px-6 py-4">
<h2 class="text-lg font-semibold">Documentos do Pedido</h2>
<button
type="button"
onclick={() => (showAddPedidoDocumento = !showAddPedidoDocumento)}
class="btn btn-ghost btn-sm gap-2"
>
<Plus class="h-4 w-4" /> Adicionar documento
</button>
</div>
{#if showAddPedidoDocumento}
<div class="border-base-300 bg-base-200/30 border-b px-6 py-4">
<div class="grid grid-cols-1 gap-3 md:grid-cols-3">
<div class="md:col-span-2">
<label class="label py-0" for="pedido-doc-desc">
<span class="label-text font-semibold">Descrição</span>
</label>
<input
id="pedido-doc-desc"
type="text"
bind:value={pedidoDocumentoDescricao}
class="input input-bordered focus:input-primary input-sm w-full"
placeholder="Ex: Cotação, Parecer, Termo de referência..."
disabled={salvandoPedidoDocumento}
/>
</div>
<div>
<label class="label py-0" for="pedido-doc-file">
<span class="label-text font-semibold">Arquivo</span>
</label>
<input
id="pedido-doc-file"
type="file"
accept=".pdf,.jpg,.jpeg,.png"
class="file-input file-input-bordered file-input-sm w-full"
disabled={salvandoPedidoDocumento}
onchange={(e) => {
const f = e.currentTarget.files?.[0] ?? null;
pedidoDocumentoFile = f;
}}
/>
</div>
</div>
<div class="mt-4 flex justify-end gap-2">
<button
type="button"
onclick={() => {
showAddPedidoDocumento = false;
pedidoDocumentoDescricao = '';
pedidoDocumentoFile = null;
}}
class="btn btn-ghost btn-sm"
disabled={salvandoPedidoDocumento}
>
Cancelar
</button>
<button
type="button"
onclick={handleAddPedidoDocumento}
disabled={salvandoPedidoDocumento}
class="btn btn-primary btn-sm gap-2"
>
{#if salvandoPedidoDocumento}
<span class="loading loading-spinner loading-sm"></span>
{:else}
<Upload class="h-4 w-4" />
{/if}
Anexar
</button>
</div>
</div>
{/if}
<div class="p-6">
{#if pedidoDocumentos.length === 0}
<p class="text-base-content/60 text-sm">Nenhum documento anexado ao pedido.</p>
{:else}
<div class="overflow-x-auto">
<table class="table-zebra table w-full">
<thead>
<tr>
<th class="text-base-content border-base-400 border-b font-bold whitespace-nowrap"
>Descrição</th
>
<th class="text-base-content border-base-400 border-b font-bold whitespace-nowrap"
>Arquivo</th
>
<th class="text-base-content border-base-400 border-b font-bold whitespace-nowrap"
>Tamanho</th
>
<th class="text-base-content border-base-400 border-b font-bold whitespace-nowrap"
>Enviado por</th
>
<th class="text-base-content border-base-400 border-b font-bold whitespace-nowrap"
>Data</th
>
<th
class="text-base-content border-base-400 border-b text-right font-bold whitespace-nowrap"
>Ações</th
>
</tr>
</thead>
<tbody>
{#each pedidoDocumentos as doc (doc._id)}
<tr class="hover:bg-base-200/50 transition-colors">
<td class="whitespace-nowrap">{doc.descricao}</td>
<td class="whitespace-nowrap">
<span class="font-medium">{doc.nome}</span>
{#if doc.origemSolicitacaoId}
<span class="text-base-content/50 ml-2 text-xs">
(origem: solicitação)</span
>
{/if}
</td>
<td class="whitespace-nowrap">{formatBytes(doc.tamanho)}</td>
<td class="whitespace-nowrap">{doc.criadoPorNome ?? 'Desconhecido'}</td>
<td class="whitespace-nowrap">
{new Date(doc.criadoEm).toLocaleString('pt-BR')}
</td>
<td class="text-right whitespace-nowrap">
<button
type="button"
class="btn btn-ghost btn-sm"
title="Ver documento"
onclick={() => handleOpenPedidoDocumento(doc.url)}
disabled={!doc.url}
>
<Eye class="h-4 w-4" />
</button>
<button
type="button"
class="btn btn-ghost btn-sm text-error"
title="Remover documento"
onclick={() => handleRemovePedidoDocumento(doc._id)}
>
<Trash2 class="h-4 w-4" />
</button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
</GlassCard>
<!-- Requests Section -->
{#if requests.length > 0}
<GlassCard class="border-warning mb-6 border-l-4" bodyClass="p-0">
<div
class="border-base-300 bg-warning/10 flex items-center justify-between border-b px-6 py-4"
>
<h2 class="text-lg font-semibold">Solicitações Pendentes</h2>
</div>
<div class="p-6">
<div class="overflow-x-auto">
<table class="table-zebra table w-full">
<thead>
<tr>
<th class="text-base-content border-base-400 border-b font-bold whitespace-nowrap"
>Tipo</th
>
<th class="text-base-content border-base-400 border-b font-bold whitespace-nowrap"
>Solicitante</th
>
<th class="text-base-content border-base-400 border-b font-bold whitespace-nowrap"
>Detalhes</th
>
<th
class="text-base-content border-base-400 border-b text-right font-bold whitespace-nowrap"
>Ações</th
>
</tr>
</thead>
<tbody>
{#each requests as req (req._id)}
{@const data = parseRequestData(req.dados)}
<tr class="hover:bg-base-200/50 transition-colors">
<td class="whitespace-nowrap">
{#if req.tipo === 'adicao'}
<span class="badge badge-sm badge-success">Adição</span>
{:else if req.tipo === 'alteracao_quantidade'}
<span class="badge badge-sm badge-info">Alteração Qtd</span>
{:else if req.tipo === 'exclusao'}
<span class="badge badge-sm badge-error">Exclusão</span>
{:else if req.tipo === 'alteracao_detalhes'}
<span class="badge badge-sm badge-secondary"
>{describeChangedDetails(data)}</span
>
{/if}
</td>
<td class="whitespace-nowrap">{req.solicitadoPorNome}</td>
<td>
{#if req.tipo === 'adicao'}
{getObjetoName(data.objetoId)} - {data.quantidade}x ({data.modalidade})
{:else if req.tipo === 'alteracao_quantidade'}
{#if data.itemId}
{@const item = items.find((i) => i._id === data.itemId)}
{item ? getObjetoName(item.objetoId) : 'Item desconhecido'} (Nova Qtd: {data.novaQuantidade})
{:else}
Qtd: {data.novaQuantidade}
{/if}
{:else if req.tipo === 'exclusao'}
{#if data.itemId}
{@const item = items.find((i) => i._id === data.itemId)}
Remover: {item ? getObjetoName(item.objetoId) : 'Item desconhecido'}
{:else}
Remover Item
{/if}
{:else if req.tipo === 'alteracao_detalhes'}
{#if data.itemId}
{@const item = items.find((i) => i._id === data.itemId)}
Alterar detalhes de:
{item ? getObjetoName(item.objetoId) : 'Item desconhecido'}
{:else}
Alteração de detalhes do item
{/if}
{/if}
</td>
<td class="text-right whitespace-nowrap">
<div class="flex justify-end gap-2">
{#if req.tipo === 'adicao'}
{@const canAddDoc =
!!currentFuncionarioId && req.solicitadoPor === currentFuncionarioId}
<button
type="button"
onclick={() => openSolicitacaoDocs(req)}
class="btn btn-ghost btn-sm"
title={canAddDoc
? 'Adicionar documento'
: 'Apenas quem criou a solicitação pode adicionar documentos'}
disabled={!canAddDoc}
>
<Plus class="h-4 w-4" />
</button>
{/if}
<button
type="button"
onclick={() => openSolicitacaoDocs(req)}
class="btn btn-ghost btn-sm"
title="Ver documentos"
>
<Eye class="h-4 w-4" />
</button>
{#if permissions?.canManageRequests}
<button
type="button"
onclick={() => handleApproveRequest(req._id)}
class="btn btn-ghost btn-sm"
title="Aprovar"
>
<CheckCircle class="h-4 w-4" />
</button>
<button
type="button"
onclick={() => handleRejectRequest(req._id)}
class="btn btn-ghost btn-sm text-error"
title="Rejeitar"
>
<XCircle class="h-4 w-4" />
</button>
{:else}
<span class="text-base-content/50 ml-2 self-center text-xs"
>Aguardando Análise</span
>
{/if}
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
</GlassCard>
{/if}
<!-- Items Section -->
<GlassCard class="mb-6" bodyClass="p-0">
<div class="border-base-300 flex items-center justify-between border-b px-6 py-4">
<h2 class="text-lg font-semibold">Itens do Pedido</h2>
{#if pedido.status === 'em_rascunho' || pedido.status === 'precisa_ajustes' || pedido.status === 'em_analise' || pedido.status === 'aguardando_aceite'}
<button onclick={() => (showAddItem = true)} class="btn btn-ghost btn-sm gap-2">
<Plus class="h-4 w-4" /> Adicionar Item
</button>
{/if}
</div>
{#if showAddItem}
<div class="border-base-300 bg-base-200/30 border-b px-6 py-4">
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
<div class="col-span-1 md:col-span-2 lg:col-span-1">
<label class="label py-0" for="objeto-select">
<span class="label-text font-semibold">Objeto</span>
</label>
<select
id="objeto-select"
bind:value={newItem.objetoId}
onchange={(e) => handleObjetoChange(e.currentTarget.value)}
class="select select-bordered focus:select-primary select-sm w-full"
>
<option value="">Selecione...</option>
{#each objetos as o (o._id)}
<option value={o._id}>{o.nome} ({o.unidade})</option>
{/each}
</select>
</div>
<div>
<label class="label py-0" for="quantidade-input">
<span class="label-text font-semibold">Quantidade</span>
</label>
<input
id="quantidade-input"
type="number"
min="1"
bind:value={newItem.quantidade}
class="input input-bordered focus:input-primary input-sm w-full"
placeholder="1"
/>
</div>
<div>
<label class="label py-0" for="valor-input">
<span class="label-text font-semibold">Valor Estimado</span>
</label>
<input
id="valor-input"
type="text"
bind:value={newItem.valorEstimado}
oninput={(e) => (newItem.valorEstimado = maskCurrencyBRL(e.currentTarget.value))}
class="input input-bordered focus:input-primary input-sm w-full"
placeholder="R$ 0,00"
/>
</div>
{#if newItem.objetoId && permissions?.canEditAta}
<div>
<label class="label py-0" for="ata-select">
<span class="label-text font-semibold">Ata (Opcional)</span>
</label>
<select
id="ata-select"
bind:value={newItem.ataId}
class="select select-bordered focus:select-primary select-sm w-full"
>
<option value="">Nenhuma</option>
{#each getAtasForObjeto(newItem.objetoId) as ata (ata._id)}
{@const isSelectedAta = String(ata._id) === newItem.ataId}
{@const reason =
ata.lockReason === 'nao_configurada'
? 'não configurada'
: ata.lockReason === 'limite_atingido'
? 'limite atingido'
: ata.lockReason === 'vigencia_expirada'
? `vigência encerrada em ${
ata.dataFimEfetiva || ata.dataFim
? formatarDataBR((ata.dataFimEfetiva || ata.dataFim) as string)
: '-'
}`
: null}
<option value={ata._id} disabled={ata.isLocked && !isSelectedAta}>
Ata {ata.numero} (SEI: {ata.numeroSei}){reason ? ` (${reason})` : ''}
</option>
{/each}
</select>
</div>
{/if}
<div>
<label class="label py-0" for="acao-select">
<span class="label-text font-semibold">Ação (Opcional)</span>
</label>
<select
id="acao-select"
bind:value={newItem.acaoId}
class="select select-bordered focus:select-primary select-sm w-full"
>
<option value="">Selecione...</option>
{#each acoes as a (a._id)}
<option value={a._id}>{a.nome}</option>
{/each}
</select>
</div>
</div>
<div class="mt-4 flex justify-end gap-2">
<button
onclick={() => (showAddItem = false)}
class="btn btn-ghost btn-sm"
disabled={addingItem}
>
Cancelar
</button>
<button
onclick={handleAddItem}
disabled={addingItem}
class="btn btn-primary btn-sm gap-2"
>
{#if addingItem}
<span class="loading loading-spinner loading-sm"></span>
{/if}
Adicionar
</button>
</div>
</div>
{/if}
<div class="flex flex-col">
{#if hasSelection}
<div
class="alert alert-info border-base-300 flex items-center justify-between rounded-none border-b px-6 py-3 text-sm"
>
<div class="flex items-center gap-2">
<span class="badge badge-primary badge-sm">
{selectedCount}
</span>
<span
>{selectedCount === 1
? '1 item selecionado'
: `${selectedCount} itens selecionados`}</span
>
</div>
<div class="flex flex-wrap items-center gap-2">
<button
type="button"
class="btn btn-primary btn-sm"
onclick={() => handleSplitPedidoFromSelection()}
>
Criar novo pedido com selecionados
</button>
<button
type="button"
class="btn btn-outline btn-error btn-sm"
onclick={() => handleRemoveSelectedItems()}
>
Excluir selecionados
</button>
<button type="button" class="btn btn-ghost btn-sm" onclick={clearSelection}>
Limpar seleção
</button>
</div>
</div>
{/if}
{#each groupedItems as group (group.name)}
<div class="border-base-300 bg-base-200/50 border-b px-6 py-2 font-semibold">
Adicionado por: {group.name}
</div>
<div class="overflow-x-auto">
<table class="table-zebra table w-full">
<thead>
<tr>
{#if (pedido.status === 'em_rascunho' || pedido.status === 'precisa_ajustes') && currentFuncionarioId && group.items[0]?.adicionadoPor === currentFuncionarioId}
<th
class="text-base-content border-base-400 border-b font-bold whitespace-nowrap"
>
<input
type="checkbox"
class="checkbox checkbox-primary checkbox-sm"
onchange={(e) => {
const checked = e.currentTarget.checked;
for (const groupItem of group.items) {
if (checked) {
selectedItemIds.add(groupItem._id);
} else {
selectedItemIds.delete(groupItem._id);
}
}
}}
aria-label={`Selecionar todos os itens de ${group.name}`}
/>
</th>
{/if}
<th
class="text-base-content border-base-400 border-b font-bold whitespace-nowrap"
>
Objeto</th
>
<th class="text-base-content border-base-400 border-b font-bold whitespace-nowrap"
>Qtd</th
>
<th class="text-base-content border-base-400 border-b font-bold whitespace-nowrap"
>Valor Est.</th
>
<th class="text-base-content border-base-400 border-b font-bold whitespace-nowrap"
>Modalidade</th
>
<th class="text-base-content border-base-400 border-b font-bold whitespace-nowrap"
>Ação</th
>
<th class="text-base-content border-base-400 border-b font-bold whitespace-nowrap"
>Ata</th
>
<th
class="text-base-content border-base-400 border-b text-right font-bold whitespace-nowrap"
>Total</th
>
<th
class="text-base-content border-base-400 border-b text-right font-bold whitespace-nowrap"
>Ações</th
>
</tr>
</thead>
<tbody>
{#each group.items as item (item._id)}
<tr
class:selected={isItemSelected(item._id)}
class="hover:bg-base-200/50 transition-colors"
>
{#if (pedido.status === 'em_rascunho' || pedido.status === 'precisa_ajustes') && currentFuncionarioId && item.adicionadoPor === currentFuncionarioId}
<td class="whitespace-nowrap">
<input
type="checkbox"
class="checkbox checkbox-primary checkbox-sm"
checked={isItemSelected(item._id)}
onchange={() => toggleItemSelection(item._id)}
aria-label={`Selecionar item ${getObjetoName(item.objetoId)}`}
/>
</td>
{/if}
<td class="whitespace-nowrap">{getObjetoName(item.objetoId)}</td>
<td class="whitespace-nowrap">
{#if pedido.status === 'em_rascunho' || pedido.status === 'precisa_ajustes' || pedido.status === 'em_analise' || pedido.status === 'aguardando_aceite'}
<input
type="number"
min="1"
value={item.quantidade}
onchange={(e) =>
handleUpdateQuantity(item._id, parseInt(e.currentTarget.value) || 1)}
class="input input-bordered input-sm w-20"
/>
{:else}
{item.quantidade}
{/if}
</td>
<td class="whitespace-nowrap">
{#if pedido.status === 'em_rascunho' || pedido.status === 'precisa_ajustes' || pedido.status === 'em_analise' || pedido.status === 'aguardando_aceite'}
<input
type="text"
class="input input-bordered input-sm w-28"
value={ensureEditingItem(item).valorEstimado}
oninput={(e) =>
setEditingField(
item._id,
'valorEstimado',
maskCurrencyBRL(e.currentTarget.value)
)}
onblur={() => persistItemChanges(item)}
placeholder="R$ 0,00"
/>
{:else}
{maskCurrencyBRL(item.valorEstimado) || 'R$ 0,00'}
{/if}
</td>
<td class="text-base-content/70 whitespace-nowrap">
{#if permissions?.canEditModalidade}
<select
class="select select-bordered select-sm"
value={ensureEditingItem(item).modalidade}
onchange={(e) => {
setEditingField(
item._id,
'modalidade',
coerceModalidade(e.currentTarget.value)
);
void persistItemChanges(item);
}}
>
<option value="consumo">Consumo</option>
<option value="dispensa">Dispensa</option>
<option value="inexgibilidade">Inexigibilidade</option>
<option value="adesao">Adesão</option>
</select>
{:else}
{formatModalidade(item.modalidade as Modalidade) || '-'}
{/if}
</td>
<td class="text-base-content/70 whitespace-nowrap">
{#if pedido.status === 'em_rascunho' || pedido.status === 'precisa_ajustes' || pedido.status === 'em_analise' || pedido.status === 'aguardando_aceite'}
<select
class="select select-bordered select-sm"
value={ensureEditingItem(item).acaoId}
onchange={(e) => {
setEditingField(item._id, 'acaoId', e.currentTarget.value);
void persistItemChanges(item);
}}
>
<option value="">Nenhuma</option>
{#each acoes as a (a._id)}
<option value={a._id}>{a.nome}</option>
{/each}
</select>
{:else}
{getAcaoName(item.acaoId)}
{/if}
</td>
<td class="text-base-content/70 whitespace-nowrap">
{#if permissions?.canEditAta}
<select
class="select select-bordered select-sm"
value={ensureEditingItem(item).ataId}
onchange={(e) => {
setEditingField(item._id, 'ataId', e.currentTarget.value);
void persistItemChanges(item);
}}
>
<option value="">Nenhuma</option>
{#each getAtasForObjeto(item.objetoId) as ata (ata._id)}
{@const currentAtaId = ensureEditingItem(item).ataId}
{@const isSelectedAta = String(ata._id) === currentAtaId}
{@const reason =
ata.lockReason === 'nao_configurada'
? 'não configurada'
: ata.lockReason === 'limite_atingido'
? 'limite atingido'
: ata.lockReason === 'vigencia_expirada'
? `vigência encerrada em ${
ata.dataFimEfetiva || ata.dataFim
? formatarDataBR(
(ata.dataFimEfetiva || ata.dataFim) as string
)
: '-'
}`
: null}
<option value={ata._id} disabled={ata.isLocked && !isSelectedAta}>
Ata {ata.numero} (SEI: {ata.numeroSei}){reason ? ` (${reason})` : ''}
</option>
{/each}
</select>
{:else if item.ataId}
{#each getAtasForObjeto(item.objetoId) as ata (ata._id)}
{#if ata._id === item.ataId}
Ata {ata.numero}
{/if}
{/each}
{:else}
-
{/if}
</td>
<td class="text-right font-medium whitespace-nowrap">
R$ {calculateItemTotal(item.valorEstimado, item.quantidade)
.toFixed(2)
.replace('.', ',')}
</td>
<td class="text-right whitespace-nowrap">
<button
onclick={() => openDetails(item.objetoId)}
class="btn btn-ghost btn-sm"
title="Ver Detalhes"
>
<Eye class="h-4 w-4" />
</button>
{#if pedido.status === 'em_rascunho' || pedido.status === 'precisa_ajustes' || pedido.status === 'em_analise' || pedido.status === 'aguardando_aceite'}
<button
onclick={() => handleRemoveItem(item._id)}
class="btn btn-ghost btn-sm text-error"
title="Remover Item"
>
<Trash2 class="h-4 w-4" />
</button>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/each}
{#if items.length === 0}
<div class="text-base-content/60 px-6 py-8 text-center">Nenhum item adicionado.</div>
{:else}
<div class="bg-base-200/30 flex justify-end px-6 py-4">
<div class="text-base font-bold">
Total Geral: R$ {totalGeral.toFixed(2).replace('.', ',')}
</div>
</div>
{/if}
</div>
</GlassCard>
{#if showDetailsModal && selectedObjeto}
<div class="modal modal-open">
<div class="modal-box max-w-lg">
<button
type="button"
class="btn btn-sm btn-circle absolute top-2 right-2"
onclick={closeDetails}
>
<X class="h-5 w-5" />
</button>
<h2 class="text-lg font-bold">Detalhes do Objeto</h2>
<div class="space-y-4">
<div>
<div class="text-base-content/60 text-xs font-semibold tracking-wider uppercase">
Nome
</div>
<p class="font-medium">{selectedObjeto.nome}</p>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<div class="text-base-content/60 text-xs font-semibold tracking-wider uppercase">
Tipo
</div>
<span
class="badge badge-sm {selectedObjeto.tipo === 'servico'
? 'badge-success'
: 'badge-info'}"
>
{selectedObjeto.tipo === 'material' ? 'Material' : 'Serviço'}
</span>
</div>
<div>
<div class="text-base-content/60 text-xs font-semibold tracking-wider uppercase">
Unidade
</div>
<p class="font-medium">{selectedObjeto.unidade}</p>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<div class="text-base-content/60 text-xs font-semibold tracking-wider uppercase">
Código Efisco
</div>
<p class="font-medium">{selectedObjeto.codigoEfisco}</p>
</div>
<div>
{#if selectedObjeto.tipo === 'material'}
<div class="text-base-content/60 text-xs font-semibold tracking-wider uppercase">
Código Catmat
</div>
<p class="font-medium">{selectedObjeto.codigoCatmat || '-'}</p>
{:else}
<div class="text-base-content/60 text-xs font-semibold tracking-wider uppercase">
Código Catserv
</div>
<p class="font-medium">{selectedObjeto.codigoCatserv || '-'}</p>
{/if}
</div>
</div>
<div>
<div class="text-base-content/60 text-xs font-semibold tracking-wider uppercase">
Valor Estimado (Unitário)
</div>
<p class="font-medium">
{maskCurrencyBRL(selectedObjeto.valorEstimado || '') || 'R$ 0,00'}
</p>
</div>
</div>
<div class="modal-action">
<button type="button" onclick={closeDetails} class="btn">Fechar</button>
</div>
</div>
<button
type="button"
class="modal-backdrop"
onclick={closeDetails}
aria-label="Fechar modal"
></button>
</div>
{/if}
<!-- Histórico -->
<GlassCard class="mb-6">
<h2 class="text-lg font-semibold">Histórico</h2>
<div class="space-y-3">
{#if history.length === 0}
<p class="text-base-content/60 text-sm">Nenhum histórico disponível.</p>
{:else}
{#each history as entry (entry._id)}
<div
class="border-base-300 hover:bg-base-200/50 flex items-start gap-3 rounded-lg border p-3 transition-colors"
>
<div class="shrink-0 text-2xl">
{getHistoryIcon(entry.acao)}
</div>
<div class="min-w-0 flex-1">
<p class="text-base-content text-sm">
{formatHistoryEntry(entry)}
</p>
<p class="text-base-content/60 mt-1 text-xs">
{new Date(entry.data).toLocaleString('pt-BR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
})}
</p>
</div>
</div>
{/each}
{/if}
</div>
</GlassCard>
{/if}
{#if showSplitResultModal && novoPedidoIdParaNavegar}
<div class="modal modal-open">
<div class="modal-box max-w-md">
<button
onclick={() => {
showSplitResultModal = false;
}}
class="btn btn-sm btn-circle absolute top-2 right-2"
>
<X class="h-5 w-5" />
</button>
<h2 class="text-lg font-bold">Novo pedido criado</h2>
<p class="text-base-content/70 mt-2 text-sm">
{quantidadeItensMovidos === 1
? '1 item foi movido para um novo pedido em rascunho.'
: `${quantidadeItensMovidos} itens foram movidos para um novo pedido em rascunho.`}
</p>
<p class="text-base-content/60 mt-2 text-xs">
Os itens não foram copiados, e sim movidos deste pedido para o novo.
</p>
<div class="modal-action">
<button
type="button"
class="btn"
onclick={() => {
showSplitResultModal = false;
}}
>
Continuar neste pedido
</button>
<button
type="button"
class="btn btn-primary"
onclick={async () => {
const id = novoPedidoIdParaNavegar;
showSplitResultModal = false;
if (id) {
await goto(resolve(`/pedidos/${id}`));
}
}}
>
Ir para o novo pedido
</button>
</div>
</div>
<button
type="button"
class="modal-backdrop"
onclick={() => (showSplitResultModal = false)}
aria-label="Fechar modal"
></button>
</div>
{/if}
{#if showSplitConfirmationModal}
<div class="modal modal-open">
<div class="modal-box max-w-md">
<button
onclick={() => {
showSplitConfirmationModal = false;
}}
class="btn btn-sm btn-circle absolute top-2 right-2"
>
<X class="h-5 w-5" />
</button>
<h2 class="text-lg font-bold">Criar novo pedido</h2>
<p class="text-base-content/70 mt-2 text-sm">
{selectedCount === 1
? 'Criar um novo pedido movendo o item selecionado para ele?'
: `Criar um novo pedido movendo os ${selectedCount} itens selecionados para ele?`}
</p>
<div class="mt-4">
<label class="label py-0" for="new-sei">
<span class="label-text font-semibold">Número SEI (Opcional)</span>
</label>
<input
id="new-sei"
type="text"
bind:value={newPedidoSei}
class="input input-bordered focus:input-primary input-sm w-full"
placeholder="Ex: 12345.000000/2023-00"
/>
<p class="text-base-content/60 mt-2 text-xs">
Se deixado em branco, o novo pedido será criado sem número SEI.
</p>
</div>
<div class="modal-action">
<button
type="button"
class="btn"
onclick={() => {
showSplitConfirmationModal = false;
}}
>
Cancelar
</button>
<button type="button" class="btn btn-primary" onclick={confirmSplitPedido}>
Confirmar e Criar
</button>
</div>
</div>
<button
type="button"
class="modal-backdrop"
onclick={() => (showSplitConfirmationModal = false)}
aria-label="Fechar modal"
></button>
</div>
{/if}
{#if showSolicitacaoDocsModal && solicitacaoDocsRequestId}
<div class="modal modal-open">
<div class="modal-box max-w-3xl">
<button
type="button"
onclick={closeSolicitacaoDocs}
class="btn btn-sm btn-circle absolute top-2 right-2"
aria-label="Fechar modal"
>
<X class="h-5 w-5" />
</button>
<h3 class="text-lg font-bold">Documentos da solicitação</h3>
<p class="text-base-content/60 mt-2 text-xs">
Solicitação: {solicitacaoDocsRequestId.slice(-6)} tipo: {solicitacaoDocsTipo}
</p>
{#if canAddDocsToSelectedRequest}
<div class="border-base-300 bg-base-200/30 mt-6 mb-4 rounded-lg border p-4">
<div class="grid grid-cols-1 gap-3 md:grid-cols-3">
<div class="md:col-span-2">
<label class="label py-0" for="req-doc-desc">
<span class="label-text font-semibold">Descrição</span>
</label>
<input
id="req-doc-desc"
type="text"
bind:value={solicitacaoDocumentoDescricao}
class="input input-bordered focus:input-primary input-sm w-full"
placeholder="Ex: justificativa, anexo do item, planilha..."
disabled={salvandoSolicitacaoDocumento}
/>
</div>
<div>
<label class="label py-0" for="req-doc-file">
<span class="label-text font-semibold">Arquivo</span>
</label>
<input
id="req-doc-file"
type="file"
accept=".pdf,.jpg,.jpeg,.png"
class="file-input file-input-bordered file-input-sm w-full"
disabled={salvandoSolicitacaoDocumento}
onchange={(e) => {
const f = e.currentTarget.files?.[0] ?? null;
solicitacaoDocumentoFile = f;
}}
/>
</div>
</div>
<div class="mt-3 flex justify-end">
<button
type="button"
onclick={handleAddSolicitacaoDocumento}
disabled={salvandoSolicitacaoDocumento}
class="btn btn-primary btn-sm gap-2"
>
{#if salvandoSolicitacaoDocumento}
<span class="loading loading-spinner loading-sm"></span>
{:else}
<Upload class="h-4 w-4" />
{/if}
Anexar documento
</button>
</div>
</div>
{/if}
{#if carregandoSolicitacaoDocs}
<p class="text-base-content/60 mt-2 text-sm">Carregando documentos...</p>
{:else if solicitacaoDocs.length === 0}
<p class="text-base-content/60 mt-2 text-sm">Nenhum documento anexado à solicitação.</p>
{:else}
<div class="overflow-x-auto">
<table class="table-zebra table w-full">
<thead>
<tr>
<th class="text-base-content border-base-400 border-b font-bold whitespace-nowrap"
>Descrição</th
>
<th class="text-base-content border-base-400 border-b font-bold whitespace-nowrap"
>Arquivo</th
>
<th class="text-base-content border-base-400 border-b font-bold whitespace-nowrap"
>Tamanho</th
>
<th class="text-base-content border-base-400 border-b font-bold whitespace-nowrap"
>Enviado por</th
>
<th class="text-base-content border-base-400 border-b font-bold whitespace-nowrap"
>Data</th
>
<th
class="text-base-content border-base-400 border-b text-right font-bold whitespace-nowrap"
>Ações</th
>
</tr>
</thead>
<tbody>
{#each solicitacaoDocs as doc (doc._id)}
<tr class="hover:bg-base-200/50 transition-colors">
<td class="whitespace-nowrap">{doc.descricao}</td>
<td class="whitespace-nowrap">
<span class="font-medium">{doc.nome}</span>
</td>
<td class="whitespace-nowrap">{formatBytes(doc.tamanho)}</td>
<td class="whitespace-nowrap">{doc.criadoPorNome ?? 'Desconhecido'}</td>
<td class="whitespace-nowrap">
{new Date(doc.criadoEm).toLocaleString('pt-BR')}
</td>
<td class="text-right whitespace-nowrap">
<button
type="button"
class="btn btn-ghost btn-sm"
title="Ver documento"
onclick={() => handleOpenSolicitacaoDocumento(doc.url)}
disabled={!doc.url}
>
<Eye class="h-4 w-4" />
</button>
{#if canAddDocsToSelectedRequest}
<button
type="button"
class="btn btn-ghost btn-sm text-error"
title="Remover documento"
onclick={() => handleRemoveSolicitacaoDocumento(doc._id)}
>
<Trash2 class="h-4 w-4" />
</button>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
<div class="modal-action">
<button type="button" onclick={closeSolicitacaoDocs} class="btn">Fechar</button>
</div>
</div>
<button
type="button"
class="modal-backdrop"
onclick={closeSolicitacaoDocs}
aria-label="Fechar modal"
></button>
</div>
{/if}
{#if showRequestAdjustmentsModal}
<div class="modal modal-open">
<div class="modal-box max-w-md">
<h3 class="text-lg font-bold">Solicitar Ajustes</h3>
<p class="text-base-content/70 mt-2 text-sm">
Descreva os ajustes necessários para este pedido. O status será alterado para "Precisa de
Ajustes".
</p>
<textarea
bind:value={adjustmentDescription}
class="textarea textarea-bordered focus:textarea-primary mt-4 w-full"
rows="5"
placeholder="Ex: O valor do Item 1 está acima do mercado..."
></textarea>
<div class="modal-action">
<button type="button" class="btn" onclick={() => (showRequestAdjustmentsModal = false)}>
Cancelar
</button>
<button type="button" class="btn btn-warning" onclick={confirmRequestAdjustments}>
Confirmar Solicitação
</button>
</div>
</div>
<button
type="button"
class="modal-backdrop"
onclick={() => (showRequestAdjustmentsModal = false)}
aria-label="Fechar modal"
></button>
</div>
{/if}
<ConfirmationModal
bind:open={confirmModal.open}
title={confirmModal.title}
message={confirmModal.message}
confirmText={confirmModal.confirmText}
isDestructive={confirmModal.isDestructive}
onConfirm={confirmModal.onConfirm}
onClose={() => (confirmModal.open = false)}
/>
</PageShell>