2469 lines
75 KiB
Svelte
2469 lines
75 KiB
Svelte
<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>
|