720 lines
22 KiB
Svelte
720 lines
22 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 { useConvexClient, useQuery } from 'convex-svelte';
|
|
import { Pencil, Plus, Trash2, X, Search, Check, FileText } from 'lucide-svelte';
|
|
import { resolve } from '$app/paths';
|
|
import { formatarDataBR } from '$lib/utils/datas';
|
|
|
|
const client = useConvexClient();
|
|
|
|
// Reactive queries
|
|
// 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);
|
|
|
|
const empresasQuery = useQuery(api.empresas.list, {});
|
|
let empresas = $derived(empresasQuery.data || []);
|
|
|
|
const objetosQuery = useQuery(api.objetos.list, {});
|
|
let objetos = $derived(objetosQuery.data || []);
|
|
|
|
// Modal state
|
|
let showModal = $state(false);
|
|
let editingId: string | null = $state(null);
|
|
let formData = $state({
|
|
numero: '',
|
|
numeroSei: '',
|
|
empresaId: '' as Id<'empresas'> | '',
|
|
dataInicio: '',
|
|
dataFim: '',
|
|
dataProrrogacao: ''
|
|
});
|
|
let selectedObjetos = $state<Id<'objetos'>[]>([]);
|
|
type ObjetoAtaConfig = {
|
|
quantidadeTotal: number | undefined;
|
|
limitePercentual: number | undefined;
|
|
};
|
|
let objetosConfig = $state<Record<string, ObjetoAtaConfig>>({});
|
|
let searchObjeto = $state('');
|
|
let saving = $state(false);
|
|
|
|
// Derived state for filtered objects
|
|
let filteredObjetos = $derived(
|
|
objetos.filter((obj) => obj.nome.toLowerCase().includes(searchObjeto.toLowerCase()))
|
|
);
|
|
|
|
// Document state
|
|
let attachmentFiles: File[] = $state([]);
|
|
let attachments = $state<Array<{ _id: Id<'atasDocumentos'>; nome: string; url: string | null }>>(
|
|
[]
|
|
);
|
|
let uploading = $state(false);
|
|
|
|
async function openModal(ata?: Doc<'atas'>) {
|
|
if (ata) {
|
|
editingId = ata._id;
|
|
formData = {
|
|
numero: ata.numero,
|
|
numeroSei: ata.numeroSei,
|
|
empresaId: ata.empresaId,
|
|
dataInicio: ata.dataInicio || '',
|
|
dataFim: ata.dataFim || '',
|
|
dataProrrogacao: ata.dataProrrogacao || ''
|
|
};
|
|
// Fetch linked objects
|
|
const linkedObjetos = await client.query(api.atas.getObjetos, { id: ata._id });
|
|
selectedObjetos = linkedObjetos.map((o) => o._id);
|
|
|
|
const linkedConfigs = await client.query(api.atas.getObjetosConfig, { id: ata._id });
|
|
objetosConfig = {};
|
|
for (const cfg of linkedConfigs) {
|
|
objetosConfig[String(cfg.objetoId)] = {
|
|
quantidadeTotal: cfg.quantidadeTotal ?? undefined,
|
|
limitePercentual: cfg.limitePercentual ?? 50
|
|
};
|
|
}
|
|
|
|
// Fetch attachments
|
|
attachments = await client.query(api.atas.getDocumentos, { ataId: ata._id });
|
|
} else {
|
|
editingId = null;
|
|
formData = {
|
|
numero: '',
|
|
numeroSei: '',
|
|
empresaId: '',
|
|
dataInicio: '',
|
|
dataFim: '',
|
|
dataProrrogacao: ''
|
|
};
|
|
selectedObjetos = [];
|
|
objetosConfig = {};
|
|
attachments = [];
|
|
}
|
|
attachmentFiles = [];
|
|
searchObjeto = '';
|
|
showModal = true;
|
|
}
|
|
|
|
function closeModal() {
|
|
showModal = false;
|
|
editingId = null;
|
|
}
|
|
|
|
function getObjetoConfig(id: Id<'objetos'>): ObjetoAtaConfig {
|
|
const key = String(id);
|
|
if (!objetosConfig[key]) {
|
|
objetosConfig[key] = { quantidadeTotal: 1, limitePercentual: 50 };
|
|
}
|
|
return objetosConfig[key];
|
|
}
|
|
|
|
function toggleObjeto(id: Id<'objetos'>) {
|
|
const key = String(id);
|
|
if (selectedObjetos.includes(id)) {
|
|
selectedObjetos = selectedObjetos.filter((oid) => oid !== id);
|
|
delete objetosConfig[key];
|
|
} else {
|
|
selectedObjetos = [...selectedObjetos, id];
|
|
objetosConfig[key] = { quantidadeTotal: 1, limitePercentual: 50 };
|
|
}
|
|
}
|
|
|
|
async function uploadFile(file: File) {
|
|
const uploadUrl = await client.mutation(api.atas.generateUploadUrl, {});
|
|
const result = await fetch(uploadUrl, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': file.type },
|
|
body: file
|
|
});
|
|
const { storageId } = await result.json();
|
|
return storageId;
|
|
}
|
|
|
|
async function handleSubmit(e: Event) {
|
|
e.preventDefault();
|
|
if (!formData.empresaId) {
|
|
alert('Selecione uma empresa.');
|
|
return;
|
|
}
|
|
saving = true;
|
|
try {
|
|
const objetos = selectedObjetos.map((objetoId) => {
|
|
const cfg = objetosConfig[String(objetoId)];
|
|
if (
|
|
!cfg ||
|
|
cfg.quantidadeTotal === undefined ||
|
|
!Number.isFinite(cfg.quantidadeTotal) ||
|
|
cfg.quantidadeTotal <= 0
|
|
) {
|
|
throw new Error(
|
|
'Informe a quantidade (maior que zero) para todos os objetos vinculados.'
|
|
);
|
|
}
|
|
const limitePercentual =
|
|
cfg.limitePercentual === undefined || !Number.isFinite(cfg.limitePercentual)
|
|
? 50
|
|
: cfg.limitePercentual;
|
|
|
|
return {
|
|
objetoId,
|
|
quantidadeTotal: cfg.quantidadeTotal,
|
|
limitePercentual
|
|
};
|
|
});
|
|
|
|
const payload = {
|
|
numero: formData.numero,
|
|
numeroSei: formData.numeroSei,
|
|
empresaId: formData.empresaId as Id<'empresas'>,
|
|
dataInicio: formData.dataInicio || undefined,
|
|
dataFim: formData.dataFim || undefined,
|
|
dataProrrogacao: formData.dataProrrogacao || undefined,
|
|
objetos
|
|
};
|
|
|
|
let ataId: Id<'atas'>;
|
|
if (editingId) {
|
|
await client.mutation(api.atas.update, {
|
|
id: editingId as Id<'atas'>,
|
|
...payload
|
|
});
|
|
ataId = editingId as Id<'atas'>;
|
|
} else {
|
|
ataId = await client.mutation(api.atas.create, payload);
|
|
}
|
|
|
|
// Upload attachments
|
|
if (attachmentFiles.length > 0) {
|
|
uploading = true;
|
|
for (const file of attachmentFiles) {
|
|
const storageId = await uploadFile(file);
|
|
await client.mutation(api.atas.saveDocumento, {
|
|
ataId,
|
|
nome: file.name,
|
|
storageId,
|
|
tipo: file.type,
|
|
tamanho: file.size
|
|
});
|
|
}
|
|
}
|
|
|
|
closeModal();
|
|
} catch (e) {
|
|
alert('Erro ao salvar: ' + (e as Error).message);
|
|
} finally {
|
|
saving = false;
|
|
uploading = false;
|
|
}
|
|
}
|
|
|
|
async function handleDelete(id: Id<'atas'>) {
|
|
if (!confirm('Tem certeza que deseja excluir esta ata?')) return;
|
|
try {
|
|
await client.mutation(api.atas.remove, { id });
|
|
} catch (e) {
|
|
alert('Erro ao excluir: ' + (e as Error).message);
|
|
}
|
|
}
|
|
|
|
async function handleDeleteAttachment(docId: Id<'atasDocumentos'>) {
|
|
if (!confirm('Tem certeza que deseja excluir este anexo?')) return;
|
|
try {
|
|
await client.mutation(api.atas.removeDocumento, { id: docId });
|
|
// Refresh attachments list
|
|
if (editingId) {
|
|
attachments = await client.query(api.atas.getDocumentos, {
|
|
ataId: editingId as Id<'atas'>
|
|
});
|
|
}
|
|
} catch (e) {
|
|
alert('Erro ao excluir anexo: ' + (e as Error).message);
|
|
}
|
|
}
|
|
|
|
function getEmpresaNome(id: Id<'empresas'>) {
|
|
return empresas.find((e) => e._id === id)?.razao_social || 'Empresa não encontrada';
|
|
}
|
|
|
|
function handleAttachmentsSelect(e: Event) {
|
|
const input = e.target as HTMLInputElement;
|
|
if (input.files && input.files.length > 0) {
|
|
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">
|
|
<div class="breadcrumbs mb-4 text-sm">
|
|
<ul>
|
|
<li><a href={resolve('/')} class="text-primary hover:underline">Dashboard</a></li>
|
|
<li><a href={resolve('/compras')} class="text-primary hover:underline">Compras</a></li>
|
|
<li>Atas</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<div class="mb-6">
|
|
<div class="flex flex-col items-start justify-between gap-4 sm:flex-row sm:items-center">
|
|
<div class="flex items-center gap-4">
|
|
<div class="bg-accent/10 rounded-xl p-3">
|
|
<FileText class="text-accent h-8 w-8" strokeWidth={2} />
|
|
</div>
|
|
<div>
|
|
<h1 class="text-primary text-3xl font-bold">Atas de Registro de Preços</h1>
|
|
<p class="text-base-content/70">Gerencie atas, vigência, empresa e anexos</p>
|
|
</div>
|
|
</div>
|
|
<button
|
|
class="btn btn-primary gap-2 shadow-md transition-all hover:shadow-lg"
|
|
onclick={() => openModal()}
|
|
>
|
|
<Plus class="h-5 w-5" strokeWidth={2} />
|
|
Nova Ata
|
|
</button>
|
|
</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>
|
|
</div>
|
|
{:else if errorAtas}
|
|
<div class="alert alert-error">
|
|
<span>{errorAtas}</span>
|
|
</div>
|
|
{:else}
|
|
<div class="card bg-base-100/90 border-base-300 border shadow-xl backdrop-blur-sm">
|
|
<div class="card-body p-0">
|
|
<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"
|
|
>Número</th
|
|
>
|
|
<th class="text-base-content border-base-400 border-b font-bold whitespace-nowrap"
|
|
>SEI</th
|
|
>
|
|
<th class="text-base-content border-base-400 border-b font-bold whitespace-nowrap"
|
|
>Empresa</th
|
|
>
|
|
<th class="text-base-content border-base-400 border-b font-bold whitespace-nowrap"
|
|
>Vigência</th
|
|
>
|
|
<th
|
|
class="text-base-content border-base-400 border-b text-right font-bold whitespace-nowrap"
|
|
>Ações</th
|
|
>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{#if atas.length === 0}
|
|
<tr>
|
|
<td colspan="5" class="py-12 text-center">
|
|
<div class="text-base-content/60 flex flex-col items-center gap-2">
|
|
{#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>
|
|
{:else}
|
|
{#each atas as ata (ata._id)}
|
|
<tr class="hover:bg-base-200/50 transition-colors">
|
|
<td class="font-medium whitespace-nowrap">{ata.numero}</td>
|
|
<td class="whitespace-nowrap">{ata.numeroSei}</td>
|
|
<td
|
|
class="max-w-md truncate whitespace-nowrap"
|
|
title={getEmpresaNome(ata.empresaId)}
|
|
>
|
|
{getEmpresaNome(ata.empresaId)}
|
|
</td>
|
|
<td class="text-base-content/70 whitespace-nowrap">
|
|
{ata.dataInicio ? formatarDataBR(ata.dataInicio) : '-'} a
|
|
{ata.dataFim ? formatarDataBR(ata.dataFim) : '-'}
|
|
{#if ata.dataProrrogacao}
|
|
<span class="text-base-content/50">
|
|
(prorrogação: {formatarDataBR(ata.dataProrrogacao)})</span
|
|
>
|
|
{/if}
|
|
</td>
|
|
<td class="text-right whitespace-nowrap">
|
|
<div class="flex items-center justify-end gap-1">
|
|
<button
|
|
type="button"
|
|
class="btn btn-ghost btn-sm"
|
|
aria-label="Editar ata"
|
|
onclick={() => openModal(ata)}
|
|
>
|
|
<Pencil size={18} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="btn btn-ghost btn-sm text-error"
|
|
aria-label="Excluir ata"
|
|
onclick={() => handleDelete(ata._id)}
|
|
>
|
|
<Trash2 size={18} />
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
{/each}
|
|
{/if}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
{#if showModal}
|
|
<div class="modal modal-open">
|
|
<div class="modal-box max-w-4xl">
|
|
<button
|
|
type="button"
|
|
class="btn btn-sm btn-circle absolute top-2 right-2"
|
|
onclick={closeModal}
|
|
aria-label="Fechar modal"
|
|
>
|
|
<X class="h-5 w-5" />
|
|
</button>
|
|
|
|
<h3 class="text-lg font-bold">{editingId ? 'Editar' : 'Nova'} Ata</h3>
|
|
|
|
<form class="mt-6 space-y-6" onsubmit={handleSubmit}>
|
|
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
|
|
<div class="space-y-4">
|
|
<div class="form-control w-full">
|
|
<label class="label" for="numero">
|
|
<span class="label-text font-semibold">Número da Ata</span>
|
|
</label>
|
|
<input
|
|
id="numero"
|
|
class="input input-bordered focus:input-primary w-full"
|
|
type="text"
|
|
bind:value={formData.numero}
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<div class="form-control w-full">
|
|
<label class="label" for="numeroSei">
|
|
<span class="label-text font-semibold">Número SEI</span>
|
|
</label>
|
|
<input
|
|
id="numeroSei"
|
|
class="input input-bordered focus:input-primary w-full"
|
|
type="text"
|
|
bind:value={formData.numeroSei}
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<div class="form-control w-full">
|
|
<label class="label" for="empresa">
|
|
<span class="label-text font-semibold">Empresa</span>
|
|
</label>
|
|
<select
|
|
id="empresa"
|
|
class="select select-bordered focus:select-primary w-full"
|
|
bind:value={formData.empresaId}
|
|
required
|
|
>
|
|
<option value="">Selecione uma empresa...</option>
|
|
{#each empresas as empresa (empresa._id)}
|
|
<option value={empresa._id}>{empresa.razao_social}</option>
|
|
{/each}
|
|
</select>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
|
|
<div class="form-control w-full">
|
|
<label class="label" for="dataInicio">
|
|
<span class="label-text font-semibold">Data Início</span>
|
|
</label>
|
|
<input
|
|
id="dataInicio"
|
|
class="input input-bordered focus:input-primary w-full"
|
|
type="date"
|
|
bind:value={formData.dataInicio}
|
|
/>
|
|
</div>
|
|
<div class="form-control w-full">
|
|
<label class="label" for="dataFim">
|
|
<span class="label-text font-semibold">Data Fim</span>
|
|
</label>
|
|
<input
|
|
id="dataFim"
|
|
class="input input-bordered focus:input-primary w-full"
|
|
type="date"
|
|
bind:value={formData.dataFim}
|
|
/>
|
|
</div>
|
|
<div class="form-control w-full">
|
|
<label class="label" for="dataProrrogacao">
|
|
<span class="label-text font-semibold">Data Prorrogação</span>
|
|
</label>
|
|
<input
|
|
id="dataProrrogacao"
|
|
class="input input-bordered focus:input-primary w-full"
|
|
type="date"
|
|
bind:value={formData.dataProrrogacao}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="space-y-4">
|
|
<div class="flex items-center justify-between">
|
|
<div class="font-semibold">Objetos Vinculados</div>
|
|
<span class="badge badge-outline">{selectedObjetos.length}</span>
|
|
</div>
|
|
|
|
<div class="form-control w-full">
|
|
<label class="label" for="buscar_objeto">
|
|
<span class="label-text font-semibold">Buscar objetos</span>
|
|
</label>
|
|
<div class="relative">
|
|
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
|
<Search size={16} class="text-base-content/40" />
|
|
</div>
|
|
<input
|
|
id="buscar_objeto"
|
|
type="text"
|
|
placeholder="Digite para filtrar..."
|
|
class="input input-bordered focus:input-primary w-full pl-10"
|
|
bind:value={searchObjeto}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="border-base-300 max-h-52 overflow-y-auto rounded-lg border p-2">
|
|
{#if filteredObjetos.length === 0}
|
|
<p class="text-base-content/60 px-2 py-3 text-center text-sm">
|
|
Nenhum objeto encontrado.
|
|
</p>
|
|
{:else}
|
|
{#each filteredObjetos as objeto (objeto._id)}
|
|
{@const isSelected = selectedObjetos.includes(objeto._id)}
|
|
<label
|
|
class="hover:bg-base-200/50 flex cursor-pointer items-center gap-3 rounded-md px-2 py-2 {isSelected
|
|
? 'bg-primary/5'
|
|
: ''}"
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
class="checkbox checkbox-primary checkbox-sm"
|
|
checked={isSelected}
|
|
onchange={() => toggleObjeto(objeto._id)}
|
|
aria-label="Vincular objeto {objeto.nome}"
|
|
/>
|
|
<span class="flex-1 truncate text-sm">{objeto.nome}</span>
|
|
{#if isSelected}
|
|
<Check size={16} class="text-primary" />
|
|
{/if}
|
|
</label>
|
|
{/each}
|
|
{/if}
|
|
</div>
|
|
|
|
{#if selectedObjetos.length > 0}
|
|
<div class="border-base-300 border-t pt-4">
|
|
<div class="font-semibold">Configuração por objeto</div>
|
|
<p class="text-base-content/60 mt-1 text-xs">
|
|
Defina a quantidade total do objeto na ata e o limite de uso em % (padrão 50%).
|
|
</p>
|
|
<div class="mt-3 max-h-52 space-y-3 overflow-y-auto">
|
|
{#each selectedObjetos as objetoId (objetoId)}
|
|
{@const obj = objetos.find((o) => o._id === objetoId)}
|
|
{@const cfg = getObjetoConfig(objetoId)}
|
|
<div class="border-base-300 rounded-lg border p-3">
|
|
<div class="text-sm font-semibold">{obj?.nome || 'Objeto'}</div>
|
|
<div class="mt-3 grid grid-cols-1 gap-3 md:grid-cols-2">
|
|
<div class="form-control w-full">
|
|
<label class="label" for={`qtd_${objetoId}`}>
|
|
<span class="label-text text-xs font-semibold">Quantidade na ata</span
|
|
>
|
|
</label>
|
|
<input
|
|
id={`qtd_${objetoId}`}
|
|
class="input input-bordered input-sm focus:input-primary w-full"
|
|
type="number"
|
|
min="1"
|
|
required
|
|
bind:value={cfg.quantidadeTotal}
|
|
/>
|
|
</div>
|
|
<div class="form-control w-full">
|
|
<label class="label" for={`limite_${objetoId}`}>
|
|
<span class="label-text text-xs font-semibold">Limite (%)</span>
|
|
</label>
|
|
<input
|
|
id={`limite_${objetoId}`}
|
|
class="input input-bordered input-sm focus:input-primary w-full"
|
|
type="number"
|
|
min="0"
|
|
max="100"
|
|
placeholder="50"
|
|
bind:value={cfg.limitePercentual}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<div class="border-base-300 border-t pt-4">
|
|
<div class="font-semibold">Anexos</div>
|
|
<div class="mt-2 space-y-2">
|
|
<input
|
|
id="anexos"
|
|
type="file"
|
|
multiple
|
|
class="file-input file-input-bordered w-full"
|
|
onchange={handleAttachmentsSelect}
|
|
/>
|
|
|
|
{#if attachments.length > 0}
|
|
<div
|
|
class="border-base-300 max-h-40 space-y-2 overflow-y-auto rounded-lg border p-2"
|
|
>
|
|
{#each attachments as doc (doc._id)}
|
|
<div class="flex items-center justify-between gap-2 text-sm">
|
|
<a
|
|
href={doc.url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
class="link link-primary max-w-[260px] truncate"
|
|
>
|
|
{doc.nome}
|
|
</a>
|
|
<button
|
|
type="button"
|
|
class="btn btn-ghost btn-xs text-error"
|
|
onclick={() => handleDeleteAttachment(doc._id)}
|
|
aria-label="Excluir anexo"
|
|
>
|
|
<X size={16} />
|
|
</button>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="modal-action">
|
|
<button type="button" class="btn" onclick={closeModal} disabled={saving || uploading}>
|
|
Cancelar
|
|
</button>
|
|
<button type="submit" class="btn btn-primary" disabled={saving || uploading}>
|
|
{#if saving || uploading}
|
|
<span class="loading loading-spinner loading-sm"></span>
|
|
{/if}
|
|
{saving || uploading ? 'Salvando...' : 'Salvar'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
<button type="button" class="modal-backdrop" onclick={closeModal} aria-label="Fechar modal"
|
|
></button>
|
|
</div>
|
|
{/if}
|
|
</main>
|