feat: implement filtering and document management features in dashboard components, enhancing user experience with improved query capabilities and UI for managing documents in pedidos and compras
This commit is contained in:
@@ -8,7 +8,21 @@
|
||||
const client = useConvexClient();
|
||||
|
||||
// Reactive queries
|
||||
const atasQuery = useQuery(api.atas.list, {});
|
||||
// Filtros (listagem)
|
||||
let filtroPeriodoInicio = $state('');
|
||||
let filtroPeriodoFim = $state('');
|
||||
let filtroNumero = $state('');
|
||||
let filtroNumeroSei = $state('');
|
||||
|
||||
const atasTotalQuery = useQuery(api.atas.list, {});
|
||||
let atasTotal = $derived(atasTotalQuery.data || []);
|
||||
|
||||
const atasQuery = useQuery(api.atas.list, () => ({
|
||||
periodoInicio: filtroPeriodoInicio || undefined,
|
||||
periodoFim: filtroPeriodoFim || undefined,
|
||||
numero: filtroNumero.trim() || undefined,
|
||||
numeroSei: filtroNumeroSei.trim() || undefined
|
||||
}));
|
||||
let atas = $derived(atasQuery.data || []);
|
||||
let loadingAtas = $derived(atasQuery.isLoading);
|
||||
let errorAtas = $derived(atasQuery.error?.message || null);
|
||||
@@ -188,6 +202,13 @@
|
||||
attachmentFiles = Array.from(input.files);
|
||||
}
|
||||
}
|
||||
|
||||
function limparFiltros() {
|
||||
filtroPeriodoInicio = '';
|
||||
filtroPeriodoFim = '';
|
||||
filtroNumero = '';
|
||||
filtroNumeroSei = '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<main class="container mx-auto flex max-w-7xl flex-col px-4 py-4">
|
||||
@@ -220,6 +241,71 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-100/90 border-base-300 mb-6 border shadow-xl backdrop-blur-sm">
|
||||
<div class="card-body">
|
||||
<div class="grid gap-4 sm:grid-cols-[1fr_auto] sm:items-end">
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-4">
|
||||
<div class="form-control w-full">
|
||||
<label class="label" for="filtro_numero">
|
||||
<span class="label-text font-semibold">Número</span>
|
||||
</label>
|
||||
<input
|
||||
id="filtro_numero"
|
||||
class="input input-bordered focus:input-primary w-full"
|
||||
type="text"
|
||||
placeholder="Ex.: 12/2025"
|
||||
bind:value={filtroNumero}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control w-full">
|
||||
<label class="label" for="filtro_numeroSei">
|
||||
<span class="label-text font-semibold">Número SEI</span>
|
||||
</label>
|
||||
<input
|
||||
id="filtro_numeroSei"
|
||||
class="input input-bordered focus:input-primary w-full"
|
||||
type="text"
|
||||
placeholder="Ex.: 12345.000000/2025-00"
|
||||
bind:value={filtroNumeroSei}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control w-full">
|
||||
<label class="label" for="filtro_inicio">
|
||||
<span class="label-text font-semibold">Período (início)</span>
|
||||
</label>
|
||||
<input
|
||||
id="filtro_inicio"
|
||||
class="input input-bordered focus:input-primary w-full"
|
||||
type="date"
|
||||
bind:value={filtroPeriodoInicio}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control w-full">
|
||||
<label class="label" for="filtro_fim">
|
||||
<span class="label-text font-semibold">Período (fim)</span>
|
||||
</label>
|
||||
<input
|
||||
id="filtro_fim"
|
||||
class="input input-bordered focus:input-primary w-full"
|
||||
type="date"
|
||||
bind:value={filtroPeriodoFim}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-3 sm:min-w-max sm:justify-end">
|
||||
<div class="text-base-content/70 text-sm">
|
||||
{atas.length} de {atasTotal.length}
|
||||
</div>
|
||||
<button type="button" class="btn btn-ghost" onclick={limparFiltros}>Limpar</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if loadingAtas}
|
||||
<div class="flex items-center justify-center py-10">
|
||||
<span class="loading loading-spinner loading-lg"></span>
|
||||
@@ -258,8 +344,13 @@
|
||||
<tr>
|
||||
<td colspan="5" class="py-12 text-center">
|
||||
<div class="text-base-content/60 flex flex-col items-center gap-2">
|
||||
<p class="text-lg font-semibold">Nenhuma ata cadastrada</p>
|
||||
<p class="text-sm">Clique em “Nova Ata” para cadastrar.</p>
|
||||
{#if atasTotal.length === 0}
|
||||
<p class="text-lg font-semibold">Nenhuma ata cadastrada</p>
|
||||
<p class="text-sm">Clique em “Nova Ata” para cadastrar.</p>
|
||||
{:else}
|
||||
<p class="text-lg font-semibold">Nenhum resultado encontrado</p>
|
||||
<p class="text-sm">Ajuste ou limpe os filtros para ver resultados.</p>
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -9,7 +9,19 @@
|
||||
const client = useConvexClient();
|
||||
|
||||
// Reactive queries
|
||||
const objetosQuery = useQuery(api.objetos.list, {});
|
||||
// Filtros (listagem)
|
||||
let filtroNome = $state('');
|
||||
let filtroTipo = $state<'todos' | 'material' | 'servico'>('todos');
|
||||
let filtroCodigos = $state('');
|
||||
|
||||
const objetosTotalQuery = useQuery(api.objetos.list, {});
|
||||
let objetosTotal = $derived(objetosTotalQuery.data || []);
|
||||
|
||||
const objetosQuery = useQuery(api.objetos.list, () => ({
|
||||
nome: filtroNome.trim() || undefined,
|
||||
tipo: filtroTipo === 'todos' ? undefined : filtroTipo,
|
||||
codigos: filtroCodigos.trim() || undefined
|
||||
}));
|
||||
let objetos = $derived(objetosQuery.data || []);
|
||||
let loading = $derived(objetosQuery.isLoading);
|
||||
let error = $derived(objetosQuery.error?.message || null);
|
||||
@@ -115,6 +127,12 @@
|
||||
formData.atas = [...formData.atas, ataId];
|
||||
}
|
||||
}
|
||||
|
||||
function limparFiltros() {
|
||||
filtroNome = '';
|
||||
filtroTipo = 'todos';
|
||||
filtroCodigos = '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<main class="container mx-auto flex max-w-7xl flex-col px-4 py-4">
|
||||
@@ -147,6 +165,62 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-100/90 border-base-300 mb-6 border shadow-xl backdrop-blur-sm">
|
||||
<div class="card-body">
|
||||
<div class="grid gap-4 sm:grid-cols-[1fr_auto] sm:items-end">
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<div class="form-control w-full">
|
||||
<label class="label" for="filtro_nome">
|
||||
<span class="label-text font-semibold">Nome</span>
|
||||
</label>
|
||||
<input
|
||||
id="filtro_nome"
|
||||
class="input input-bordered focus:input-primary w-full"
|
||||
type="text"
|
||||
placeholder="Digite para filtrar..."
|
||||
bind:value={filtroNome}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control w-full">
|
||||
<label class="label" for="filtro_tipo">
|
||||
<span class="label-text font-semibold">Tipo</span>
|
||||
</label>
|
||||
<select
|
||||
id="filtro_tipo"
|
||||
class="select select-bordered focus:select-primary w-full"
|
||||
bind:value={filtroTipo}
|
||||
>
|
||||
<option value="todos">Todos</option>
|
||||
<option value="material">Material</option>
|
||||
<option value="servico">Serviço</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-control w-full">
|
||||
<label class="label" for="filtro_codigos">
|
||||
<span class="label-text font-semibold">Códigos</span>
|
||||
</label>
|
||||
<input
|
||||
id="filtro_codigos"
|
||||
class="input input-bordered focus:input-primary w-full"
|
||||
type="text"
|
||||
placeholder="Efisco / Catmat / Catserv"
|
||||
bind:value={filtroCodigos}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-3 sm:min-w-max sm:justify-end">
|
||||
<div class="text-base-content/70 text-sm">
|
||||
{objetos.length} de {objetosTotal.length}
|
||||
</div>
|
||||
<button type="button" class="btn btn-ghost" onclick={limparFiltros}>Limpar</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="flex items-center justify-center py-10">
|
||||
<span class="loading loading-spinner loading-lg"></span>
|
||||
@@ -185,8 +259,13 @@
|
||||
<tr>
|
||||
<td colspan="5" class="py-12 text-center">
|
||||
<div class="text-base-content/60 flex flex-col items-center gap-2">
|
||||
<p class="text-lg font-semibold">Nenhum objeto cadastrado</p>
|
||||
<p class="text-sm">Clique em “Novo Objeto” para cadastrar.</p>
|
||||
{#if objetosTotal.length === 0}
|
||||
<p class="text-lg font-semibold">Nenhum objeto cadastrado</p>
|
||||
<p class="text-sm">Clique em “Novo Objeto” para cadastrar.</p>
|
||||
{:else}
|
||||
<p class="text-lg font-semibold">Nenhum resultado encontrado</p>
|
||||
<p class="text-sm">Ajuste ou limpe os filtros para ver resultados.</p>
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
Edit,
|
||||
Eye,
|
||||
Plus,
|
||||
Upload,
|
||||
Save,
|
||||
Send,
|
||||
Trash2,
|
||||
@@ -34,6 +35,9 @@
|
||||
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);
|
||||
@@ -43,11 +47,19 @@
|
||||
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;
|
||||
@@ -125,7 +137,8 @@
|
||||
objetosQuery.isLoading ||
|
||||
acoesQuery.isLoading ||
|
||||
permissionsQuery.isLoading ||
|
||||
requestsQuery.isLoading
|
||||
requestsQuery.isLoading ||
|
||||
pedidoDocumentosQuery.isLoading
|
||||
);
|
||||
|
||||
let error = $derived(
|
||||
@@ -137,6 +150,213 @@
|
||||
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<any[]>([]);
|
||||
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 newItem = $state({
|
||||
@@ -1001,6 +1221,142 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Documentos do Pedido -->
|
||||
<div class="mb-6 overflow-hidden rounded-lg bg-white shadow-md">
|
||||
<div class="flex items-center justify-between border-b border-gray-200 px-6 py-4">
|
||||
<h2 class="text-lg font-semibold">Documentos do Pedido</h2>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showAddPedidoDocumento = !showAddPedidoDocumento)}
|
||||
class="flex items-center gap-1 text-sm font-medium text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
<Plus size={16} /> Adicionar documento
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if showAddPedidoDocumento}
|
||||
<div class="border-b border-gray-200 bg-gray-50 px-6 py-4">
|
||||
<div class="grid grid-cols-1 gap-3 md:grid-cols-3">
|
||||
<div class="md:col-span-2">
|
||||
<label class="mb-1 block text-xs font-medium text-gray-500" for="pedido-doc-desc"
|
||||
>Descrição</label
|
||||
>
|
||||
<input
|
||||
id="pedido-doc-desc"
|
||||
type="text"
|
||||
bind:value={pedidoDocumentoDescricao}
|
||||
class="w-full rounded-md border-gray-300 text-sm shadow-sm"
|
||||
placeholder="Ex: Cotação, Parecer, Termo de referência..."
|
||||
disabled={salvandoPedidoDocumento}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-500" for="pedido-doc-file"
|
||||
>Arquivo</label
|
||||
>
|
||||
<input
|
||||
id="pedido-doc-file"
|
||||
type="file"
|
||||
accept=".pdf,.jpg,.jpeg,.png"
|
||||
class="w-full text-sm"
|
||||
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="rounded bg-gray-200 px-3 py-2 text-sm text-gray-700 hover:bg-gray-300"
|
||||
disabled={salvandoPedidoDocumento}
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleAddPedidoDocumento}
|
||||
disabled={salvandoPedidoDocumento}
|
||||
class="flex items-center gap-2 rounded bg-blue-600 px-3 py-2 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{#if salvandoPedidoDocumento}
|
||||
<span class="loading loading-spinner loading-xs"></span>
|
||||
{:else}
|
||||
<Upload size={16} />
|
||||
{/if}
|
||||
Anexar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="p-6">
|
||||
{#if pedidoDocumentos.length === 0}
|
||||
<p class="text-sm text-gray-500">Nenhum documento anexado ao pedido.</p>
|
||||
{:else}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-left text-sm text-gray-600">
|
||||
<thead class="bg-gray-50 text-xs font-medium text-gray-500 uppercase">
|
||||
<tr>
|
||||
<th class="px-4 py-2">Descrição</th>
|
||||
<th class="px-4 py-2">Arquivo</th>
|
||||
<th class="px-4 py-2">Tamanho</th>
|
||||
<th class="px-4 py-2">Enviado por</th>
|
||||
<th class="px-4 py-2">Data</th>
|
||||
<th class="px-4 py-2 text-right">Ações</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200">
|
||||
{#each pedidoDocumentos as doc (doc._id)}
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-4 py-2">{doc.descricao}</td>
|
||||
<td class="px-4 py-2">
|
||||
<span class="font-medium text-gray-900">{doc.nome}</span>
|
||||
{#if doc.origemSolicitacaoId}
|
||||
<span class="ml-2 text-xs text-gray-400"> (origem: solicitação)</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="px-4 py-2">{formatBytes(doc.tamanho)}</td>
|
||||
<td class="px-4 py-2">{doc.criadoPorNome ?? 'Desconhecido'}</td>
|
||||
<td class="px-4 py-2">
|
||||
{new Date(doc.criadoEm).toLocaleString('pt-BR')}
|
||||
</td>
|
||||
<td class="px-4 py-2 text-right">
|
||||
<button
|
||||
type="button"
|
||||
class="mr-2 rounded bg-indigo-100 p-1 text-indigo-700 hover:bg-indigo-200"
|
||||
title="Ver documento"
|
||||
onclick={() => handleOpenPedidoDocumento(doc.url)}
|
||||
disabled={!doc.url}
|
||||
>
|
||||
<Eye size={16} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded bg-red-100 p-1 text-red-700 hover:bg-red-200"
|
||||
title="Remover documento"
|
||||
onclick={() => handleRemovePedidoDocumento(doc._id)}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Requests Section -->
|
||||
{#if requests.length > 0}
|
||||
<div class="mb-6 overflow-hidden rounded-lg border-l-4 border-yellow-400 bg-white shadow-md">
|
||||
@@ -1064,24 +1420,53 @@
|
||||
{/if}
|
||||
</td>
|
||||
<td class="px-4 py-2 text-right">
|
||||
{#if permissions?.canManageRequests}
|
||||
<div class="flex justify-end gap-2">
|
||||
{#if req.tipo === 'adicao'}
|
||||
{@const canAddDoc =
|
||||
!!currentFuncionarioId && req.solicitadoPor === currentFuncionarioId}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => openSolicitacaoDocs(req)}
|
||||
class="rounded bg-blue-100 p-1 text-blue-700 hover:bg-blue-200"
|
||||
title={canAddDoc
|
||||
? 'Adicionar documento'
|
||||
: 'Apenas quem criou a solicitação pode adicionar documentos'}
|
||||
disabled={!canAddDoc}
|
||||
>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
onclick={() => handleApproveRequest(req._id)}
|
||||
class="mr-2 rounded bg-green-100 p-1 text-green-700 hover:bg-green-200"
|
||||
title="Aprovar"
|
||||
type="button"
|
||||
onclick={() => openSolicitacaoDocs(req)}
|
||||
class="rounded bg-indigo-100 p-1 text-indigo-700 hover:bg-indigo-200"
|
||||
title="Ver documentos"
|
||||
>
|
||||
<CheckCircle size={16} />
|
||||
<Eye size={16} />
|
||||
</button>
|
||||
<button
|
||||
onclick={() => handleRejectRequest(req._id)}
|
||||
class="rounded bg-red-100 p-1 text-red-700 hover:bg-red-200"
|
||||
title="Rejeitar"
|
||||
>
|
||||
<XCircle size={16} />
|
||||
</button>
|
||||
{:else}
|
||||
<span class="text-xs text-gray-400">Aguardando Análise</span>
|
||||
{/if}
|
||||
|
||||
{#if permissions?.canManageRequests}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => handleApproveRequest(req._id)}
|
||||
class="rounded bg-green-100 p-1 text-green-700 hover:bg-green-200"
|
||||
title="Aprovar"
|
||||
>
|
||||
<CheckCircle size={16} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => handleRejectRequest(req._id)}
|
||||
class="rounded bg-red-100 p-1 text-red-700 hover:bg-red-200"
|
||||
title="Rejeitar"
|
||||
>
|
||||
<XCircle size={16} />
|
||||
</button>
|
||||
{:else}
|
||||
<span class="ml-2 self-center text-xs text-gray-400">Aguardando Análise</span>
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
@@ -1380,7 +1765,7 @@
|
||||
setEditingField(
|
||||
item._id,
|
||||
'modalidade',
|
||||
e.currentTarget.value as Modalidade
|
||||
coerceModalidade(e.currentTarget.value)
|
||||
);
|
||||
void persistItemChanges(item);
|
||||
}}
|
||||
@@ -1698,6 +2083,147 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showSolicitacaoDocsModal && solicitacaoDocsRequestId}
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex h-full w-full items-center justify-center overflow-y-auto bg-black/40 p-4"
|
||||
>
|
||||
<div class="relative w-full max-w-3xl rounded-lg bg-white p-6 shadow-xl">
|
||||
<button
|
||||
type="button"
|
||||
onclick={closeSolicitacaoDocs}
|
||||
class="absolute top-4 right-4 text-gray-400 hover:text-gray-600"
|
||||
aria-label="Fechar"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
|
||||
<h3 class="mb-2 text-lg font-semibold text-gray-900">Documentos da solicitação</h3>
|
||||
<p class="mb-4 text-xs text-gray-500">
|
||||
Solicitação: {solicitacaoDocsRequestId.slice(-6)} — tipo: {solicitacaoDocsTipo}
|
||||
</p>
|
||||
|
||||
{#if canAddDocsToSelectedRequest}
|
||||
<div class="mb-4 rounded-lg border border-blue-100 bg-blue-50 p-4">
|
||||
<div class="grid grid-cols-1 gap-3 md:grid-cols-3">
|
||||
<div class="md:col-span-2">
|
||||
<label class="mb-1 block text-xs font-medium text-gray-600" for="req-doc-desc"
|
||||
>Descrição</label
|
||||
>
|
||||
<input
|
||||
id="req-doc-desc"
|
||||
type="text"
|
||||
bind:value={solicitacaoDocumentoDescricao}
|
||||
class="w-full rounded-md border-gray-300 text-sm shadow-sm"
|
||||
placeholder="Ex: justificativa, anexo do item, planilha..."
|
||||
disabled={salvandoSolicitacaoDocumento}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-600" for="req-doc-file"
|
||||
>Arquivo</label
|
||||
>
|
||||
<input
|
||||
id="req-doc-file"
|
||||
type="file"
|
||||
accept=".pdf,.jpg,.jpeg,.png"
|
||||
class="w-full text-sm"
|
||||
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="flex items-center gap-2 rounded bg-blue-600 px-3 py-2 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{#if salvandoSolicitacaoDocumento}
|
||||
<span class="loading loading-spinner loading-xs"></span>
|
||||
{:else}
|
||||
<Upload size={16} />
|
||||
{/if}
|
||||
Anexar documento
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if carregandoSolicitacaoDocs}
|
||||
<p class="text-sm text-gray-500">Carregando documentos...</p>
|
||||
{:else if solicitacaoDocs.length === 0}
|
||||
<p class="text-sm text-gray-500">Nenhum documento anexado à solicitação.</p>
|
||||
{:else}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-left text-sm text-gray-600">
|
||||
<thead class="bg-gray-50 text-xs font-medium text-gray-500 uppercase">
|
||||
<tr>
|
||||
<th class="px-4 py-2">Descrição</th>
|
||||
<th class="px-4 py-2">Arquivo</th>
|
||||
<th class="px-4 py-2">Tamanho</th>
|
||||
<th class="px-4 py-2">Enviado por</th>
|
||||
<th class="px-4 py-2">Data</th>
|
||||
<th class="px-4 py-2 text-right">Ações</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200">
|
||||
{#each solicitacaoDocs as doc (doc._id)}
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-4 py-2">{doc.descricao}</td>
|
||||
<td class="px-4 py-2">
|
||||
<span class="font-medium text-gray-900">{doc.nome}</span>
|
||||
</td>
|
||||
<td class="px-4 py-2">{formatBytes(doc.tamanho)}</td>
|
||||
<td class="px-4 py-2">{doc.criadoPorNome ?? 'Desconhecido'}</td>
|
||||
<td class="px-4 py-2">
|
||||
{new Date(doc.criadoEm).toLocaleString('pt-BR')}
|
||||
</td>
|
||||
<td class="px-4 py-2 text-right">
|
||||
<button
|
||||
type="button"
|
||||
class="mr-2 rounded bg-indigo-100 p-1 text-indigo-700 hover:bg-indigo-200"
|
||||
title="Ver documento"
|
||||
onclick={() => handleOpenSolicitacaoDocumento(doc.url)}
|
||||
disabled={!doc.url}
|
||||
>
|
||||
<Eye size={16} />
|
||||
</button>
|
||||
{#if canAddDocsToSelectedRequest}
|
||||
<button
|
||||
type="button"
|
||||
class="rounded bg-red-100 p-1 text-red-700 hover:bg-red-200"
|
||||
title="Remover documento"
|
||||
onclick={() => handleRemoveSolicitacaoDocumento(doc._id)}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mt-5 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onclick={closeSolicitacaoDocs}
|
||||
class="rounded bg-gray-200 px-4 py-2 text-sm text-gray-800 hover:bg-gray-300"
|
||||
>
|
||||
Fechar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showRequestAdjustmentsModal}
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex h-full w-full items-center justify-center overflow-y-auto bg-black/40"
|
||||
|
||||
Reference in New Issue
Block a user