405 lines
12 KiB
Svelte
405 lines
12 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, Package } from 'lucide-svelte';
|
|
import { maskCurrencyBRL } from '$lib/utils/masks';
|
|
import { resolve } from '$app/paths';
|
|
|
|
const client = useConvexClient();
|
|
|
|
// Reactive queries
|
|
const objetosQuery = useQuery(api.objetos.list, {});
|
|
let objetos = $derived(objetosQuery.data || []);
|
|
let loading = $derived(objetosQuery.isLoading);
|
|
let error = $derived(objetosQuery.error?.message || null);
|
|
|
|
const atasQuery = useQuery(api.atas.list, {});
|
|
let atas = $derived(atasQuery.data || []);
|
|
|
|
// Modal state
|
|
let showModal = $state(false);
|
|
let editingId: string | null = $state(null);
|
|
let formData = $state({
|
|
nome: '',
|
|
valorEstimado: '',
|
|
tipo: 'material' as 'material' | 'servico',
|
|
codigoEfisco: '',
|
|
codigoCatmat: '',
|
|
codigoCatserv: '',
|
|
unidade: '',
|
|
atas: [] as Id<'atas'>[]
|
|
});
|
|
let saving = $state(false);
|
|
|
|
async function openModal(objeto?: Doc<'objetos'>) {
|
|
if (objeto) {
|
|
editingId = objeto._id;
|
|
// Fetch linked Atas
|
|
const linkedAtas = await client.query(api.objetos.getAtas, { objetoId: objeto._id });
|
|
formData = {
|
|
nome: objeto.nome,
|
|
valorEstimado: maskCurrencyBRL(objeto.valorEstimado || ''),
|
|
tipo: objeto.tipo,
|
|
codigoEfisco: objeto.codigoEfisco || '',
|
|
codigoCatmat: objeto.codigoCatmat || '',
|
|
codigoCatserv: objeto.codigoCatserv || '',
|
|
unidade: objeto.unidade || '',
|
|
atas: linkedAtas.map((a) => a._id)
|
|
};
|
|
} else {
|
|
editingId = null;
|
|
formData = {
|
|
nome: '',
|
|
valorEstimado: '',
|
|
tipo: 'material',
|
|
codigoEfisco: '',
|
|
codigoCatmat: '',
|
|
codigoCatserv: '',
|
|
unidade: '',
|
|
atas: []
|
|
};
|
|
}
|
|
showModal = true;
|
|
}
|
|
|
|
function closeModal() {
|
|
showModal = false;
|
|
editingId = null;
|
|
}
|
|
|
|
async function handleSubmit(e: Event) {
|
|
e.preventDefault();
|
|
saving = true;
|
|
try {
|
|
const payload = {
|
|
nome: formData.nome,
|
|
valorEstimado: formData.valorEstimado,
|
|
tipo: formData.tipo,
|
|
codigoEfisco: formData.codigoEfisco,
|
|
codigoCatmat: formData.codigoCatmat || undefined,
|
|
codigoCatserv: formData.codigoCatserv || undefined,
|
|
unidade: formData.unidade,
|
|
atas: formData.atas
|
|
};
|
|
|
|
if (editingId) {
|
|
await client.mutation(api.objetos.update, {
|
|
id: editingId as Id<'objetos'>,
|
|
...payload
|
|
});
|
|
} else {
|
|
await client.mutation(api.objetos.create, payload);
|
|
}
|
|
closeModal();
|
|
} catch (e) {
|
|
alert('Erro ao salvar: ' + (e as Error).message);
|
|
} finally {
|
|
saving = false;
|
|
}
|
|
}
|
|
|
|
async function handleDelete(id: Id<'objetos'>) {
|
|
if (!confirm('Tem certeza que deseja excluir este objeto?')) return;
|
|
try {
|
|
await client.mutation(api.objetos.remove, { id });
|
|
} catch (e) {
|
|
alert('Erro ao excluir: ' + (e as Error).message);
|
|
}
|
|
}
|
|
|
|
function toggleAtaSelection(ataId: Id<'atas'>) {
|
|
if (formData.atas.includes(ataId)) {
|
|
formData.atas = formData.atas.filter((id) => id !== ataId);
|
|
} else {
|
|
formData.atas = [...formData.atas, ataId];
|
|
}
|
|
}
|
|
</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>Objetos</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-primary/10 rounded-xl p-3">
|
|
<Package class="text-primary h-8 w-8" strokeWidth={2} />
|
|
</div>
|
|
<div>
|
|
<h1 class="text-primary text-3xl font-bold">Objetos</h1>
|
|
<p class="text-base-content/70">Cadastro e gestão de objetos e serviços</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} />
|
|
Novo Objeto
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{#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}
|
|
<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 class="from-base-300 to-base-200 sticky top-0 z-10 bg-linear-to-r shadow-md">
|
|
<tr>
|
|
<th class="text-base-content border-base-400 border-b font-bold whitespace-nowrap"
|
|
>Nome</th
|
|
>
|
|
<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"
|
|
>Unidade</th
|
|
>
|
|
<th class="text-base-content border-base-400 border-b font-bold whitespace-nowrap"
|
|
>Valor Estimado</th
|
|
>
|
|
<th
|
|
class="text-base-content border-base-400 border-b text-right font-bold whitespace-nowrap"
|
|
>Ações</th
|
|
>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{#if objetos.length === 0}
|
|
<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>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
{:else}
|
|
{#each objetos as objeto (objeto._id)}
|
|
<tr class="hover:bg-base-200/50 transition-colors">
|
|
<td>
|
|
<div class="flex flex-col">
|
|
<span class="font-medium">{objeto.nome}</span>
|
|
<span class="text-base-content/60 text-xs">
|
|
Efisco: {objeto.codigoEfisco}
|
|
{#if objeto.codigoCatmat}
|
|
| Catmat: {objeto.codigoCatmat}{/if}
|
|
{#if objeto.codigoCatserv}
|
|
| Catserv: {objeto.codigoCatserv}{/if}
|
|
</span>
|
|
</div>
|
|
</td>
|
|
<td class="whitespace-nowrap">
|
|
<span
|
|
class="badge badge-sm {objeto.tipo === 'servico'
|
|
? 'badge-success'
|
|
: 'badge-info'}"
|
|
>
|
|
{objeto.tipo === 'material' ? 'Material' : 'Serviço'}
|
|
</span>
|
|
</td>
|
|
<td class="whitespace-nowrap">{objeto.unidade}</td>
|
|
<td class="whitespace-nowrap">
|
|
{maskCurrencyBRL(objeto.valorEstimado) || 'R$ 0,00'}
|
|
</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 objeto"
|
|
onclick={() => openModal(objeto)}
|
|
>
|
|
<Pencil size={18} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="btn btn-ghost btn-sm text-error"
|
|
aria-label="Excluir objeto"
|
|
onclick={() => handleDelete(objeto._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-2xl">
|
|
<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' : 'Novo'} Objeto</h3>
|
|
|
|
<form class="mt-6 space-y-4" onsubmit={handleSubmit}>
|
|
<div class="form-control w-full">
|
|
<label class="label" for="nome">
|
|
<span class="label-text font-semibold">Nome</span>
|
|
</label>
|
|
<input
|
|
id="nome"
|
|
class="input input-bordered focus:input-primary w-full"
|
|
type="text"
|
|
bind:value={formData.nome}
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
<div class="form-control w-full">
|
|
<label class="label" for="tipo">
|
|
<span class="label-text font-semibold">Tipo</span>
|
|
</label>
|
|
<select
|
|
id="tipo"
|
|
class="select select-bordered focus:select-primary w-full"
|
|
bind:value={formData.tipo}
|
|
>
|
|
<option value="material">Material</option>
|
|
<option value="servico">Serviço</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="form-control w-full">
|
|
<label class="label" for="unidade">
|
|
<span class="label-text font-semibold">Unidade</span>
|
|
</label>
|
|
<input
|
|
id="unidade"
|
|
class="input input-bordered focus:input-primary w-full"
|
|
type="text"
|
|
bind:value={formData.unidade}
|
|
required
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="form-control w-full">
|
|
<label class="label" for="codigoEfisco">
|
|
<span class="label-text font-semibold">Código Efisco</span>
|
|
</label>
|
|
<input
|
|
id="codigoEfisco"
|
|
class="input input-bordered focus:input-primary w-full"
|
|
type="text"
|
|
bind:value={formData.codigoEfisco}
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
{#if formData.tipo === 'material'}
|
|
<div class="form-control w-full">
|
|
<label class="label" for="codigoCatmat">
|
|
<span class="label-text font-semibold">Código Catmat</span>
|
|
</label>
|
|
<input
|
|
id="codigoCatmat"
|
|
class="input input-bordered focus:input-primary w-full"
|
|
type="text"
|
|
bind:value={formData.codigoCatmat}
|
|
/>
|
|
</div>
|
|
{:else}
|
|
<div class="form-control w-full">
|
|
<label class="label" for="codigoCatserv">
|
|
<span class="label-text font-semibold">Código Catserv</span>
|
|
</label>
|
|
<input
|
|
id="codigoCatserv"
|
|
class="input input-bordered focus:input-primary w-full"
|
|
type="text"
|
|
bind:value={formData.codigoCatserv}
|
|
/>
|
|
</div>
|
|
{/if}
|
|
|
|
<div class="form-control w-full">
|
|
<label class="label" for="valor">
|
|
<span class="label-text font-semibold">Valor Estimado</span>
|
|
</label>
|
|
<input
|
|
id="valor"
|
|
class="input input-bordered focus:input-primary w-full"
|
|
type="text"
|
|
placeholder="R$ 0,00"
|
|
bind:value={formData.valorEstimado}
|
|
oninput={(e) => (formData.valorEstimado = maskCurrencyBRL(e.currentTarget.value))}
|
|
/>
|
|
</div>
|
|
|
|
<div class="form-control w-full">
|
|
<label class="label" for="atas">
|
|
<span class="label-text font-semibold">Vincular Atas</span>
|
|
</label>
|
|
<div class="border-base-300 max-h-48 overflow-y-auto rounded-lg border p-2">
|
|
{#if atas.length === 0}
|
|
<p class="text-base-content/60 px-2 py-3 text-sm">Nenhuma ata disponível.</p>
|
|
{:else}
|
|
{#each atas as ata (ata._id)}
|
|
<label
|
|
class="hover:bg-base-200/50 flex cursor-pointer items-center gap-3 rounded-md px-2 py-2"
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
class="checkbox checkbox-primary checkbox-sm"
|
|
checked={formData.atas.includes(ata._id)}
|
|
onchange={() => toggleAtaSelection(ata._id)}
|
|
aria-label="Vincular ata {ata.numero}"
|
|
/>
|
|
<span class="text-sm">{ata.numero} ({ata.numeroSei})</span>
|
|
</label>
|
|
{/each}
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="modal-action">
|
|
<button type="button" class="btn" onclick={closeModal} disabled={saving}>
|
|
Cancelar
|
|
</button>
|
|
<button type="submit" class="btn btn-primary" disabled={saving}>
|
|
{#if saving}
|
|
<span class="loading loading-spinner loading-sm"></span>
|
|
{/if}
|
|
{saving ? 'Salvando...' : 'Salvar'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
<button type="button" class="modal-backdrop" onclick={closeModal} aria-label="Fechar modal"
|
|
></button>
|
|
</div>
|
|
{/if}
|
|
</main>
|