Merge remote-tracking branch 'origin/master' into ajustes_final_etapa1

This commit is contained in:
2025-12-18 14:58:03 -03:00
44 changed files with 7748 additions and 1741 deletions

View File

@@ -1,21 +1,17 @@
import { createConvexHttpClient } from '@mmailaender/convex-better-auth-svelte/sveltekit';
import { api } from '@sgse-app/backend/convex/_generated/api';
import { error, redirect } from '@sveltejs/kit';
import type { FunctionReference } from 'convex/server';
import { redirect } from '@sveltejs/kit';
export const load = async ({ locals, url }) => {
if (!locals.token) {
throw redirect(302, '/login?redirect=' + url.pathname);
}
try {
const client = createConvexHttpClient({ token: locals.token });
const currentUser = await client.query(api.auth.getCurrentUser as FunctionReference<'query'>);
if (!currentUser) {
throw redirect(302, '/login?redirect=' + url.pathname);
}
return { currentUser };
} catch {
return error(500, 'Ops! Ocorreu um erro, tente novamente mais tarde.');
const client = createConvexHttpClient({ token: locals.token });
const currentUser = await client.query(api.auth.getCurrentUser);
if (!currentUser) {
throw redirect(302, '/login?redirect=' + url.pathname);
}
return { currentUser };
};

View File

@@ -1,98 +1,95 @@
<script lang="ts">
import { Building2, FileText, Package, ShoppingCart } from 'lucide-svelte';
import { resolve } from '$app/paths';
import ProtectedRoute from '$lib/components/ProtectedRoute.svelte';
</script>
<ProtectedRoute>
<main class="container mx-auto 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>Compras</li>
</ul>
</div>
<main class="container mx-auto 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>Compras</li>
</ul>
</div>
<div class="mb-6">
<div class="mb-2 flex items-center gap-4">
<div class="rounded-xl bg-cyan-500/20 p-3">
<ShoppingCart class="h-8 w-8 text-cyan-600" strokeWidth={2} />
</div>
<div>
<h1 class="text-primary text-3xl font-bold">Compras</h1>
<p class="text-base-content/70">Gestão de compras e aquisições</p>
</div>
<div class="mb-6">
<div class="mb-2 flex items-center gap-4">
<div class="rounded-xl bg-cyan-500/20 p-3">
<ShoppingCart class="h-8 w-8 text-cyan-600" strokeWidth={2} />
</div>
<div>
<h1 class="text-primary text-3xl font-bold">Compras</h1>
<p class="text-base-content/70">Gestão de compras e aquisições</p>
</div>
</div>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
<a
href={resolve('/compras/objetos')}
class="card bg-base-100 border-base-200 hover:border-primary border shadow-md transition-shadow hover:shadow-lg"
>
<div class="card-body">
<div class="mb-2 flex items-center gap-3">
<div class="bg-primary/10 rounded-lg p-2">
<Package class="text-primary h-6 w-6" strokeWidth={2} />
</div>
<h4 class="font-semibold">Objetos</h4>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
<a
href={resolve('/compras/objetos')}
class="card bg-base-100 border-base-200 hover:border-primary border shadow-md transition-shadow hover:shadow-lg"
>
<div class="card-body">
<div class="mb-2 flex items-center gap-3">
<div class="bg-primary/10 rounded-lg p-2">
<Package class="text-primary h-6 w-6" strokeWidth={2} />
</div>
<p class="text-base-content/70 text-sm">
Cadastro, listagem e edição de objetos e serviços disponíveis para compra.
</p>
<h4 class="font-semibold">Objetos</h4>
</div>
</a>
<p class="text-base-content/70 text-sm">
Cadastro, listagem e edição de objetos e serviços disponíveis para compra.
</p>
</div>
</a>
<a
href={resolve('/compras/atas')}
class="card bg-base-100 border-base-200 hover:border-accent border shadow-md transition-shadow hover:shadow-lg"
>
<div class="card-body">
<div class="mb-2 flex items-center gap-3">
<div class="bg-accent/10 rounded-lg p-2">
<FileText class="text-accent h-6 w-6" strokeWidth={2} />
</div>
<h4 class="font-semibold">Atas de Registro</h4>
<a
href={resolve('/compras/atas')}
class="card bg-base-100 border-base-200 hover:border-accent border shadow-md transition-shadow hover:shadow-lg"
>
<div class="card-body">
<div class="mb-2 flex items-center gap-3">
<div class="bg-accent/10 rounded-lg p-2">
<FileText class="text-accent h-6 w-6" strokeWidth={2} />
</div>
<p class="text-base-content/70 text-sm">
Gerencie Atas de Registro de Preços e seus vínculos com objetos.
</p>
<h4 class="font-semibold">Atas de Registro</h4>
</div>
</a>
<p class="text-base-content/70 text-sm">
Gerencie Atas de Registro de Preços e seus vínculos com objetos.
</p>
</div>
</a>
<a
href={resolve('/licitacoes/empresas')}
class="card bg-base-100 border-base-200 hover:border-info border shadow-md transition-shadow hover:shadow-lg"
>
<div class="card-body">
<div class="mb-2 flex items-center gap-3">
<div class="bg-info/10 rounded-lg p-2">
<Building2 class="text-info h-6 w-6" strokeWidth={2} />
</div>
<h4 class="font-semibold">Empresas</h4>
<a
href={resolve('/licitacoes/empresas')}
class="card bg-base-100 border-base-200 hover:border-info border shadow-md transition-shadow hover:shadow-lg"
>
<div class="card-body">
<div class="mb-2 flex items-center gap-3">
<div class="bg-info/10 rounded-lg p-2">
<Building2 class="text-info h-6 w-6" strokeWidth={2} />
</div>
<p class="text-base-content/70 text-sm">
Cadastro e gestão de empresas fornecedoras e seus contatos.
</p>
<h4 class="font-semibold">Empresas</h4>
</div>
</a>
<p class="text-base-content/70 text-sm">
Cadastro e gestão de empresas fornecedoras e seus contatos.
</p>
</div>
</a>
<a
href={resolve('/pedidos')}
class="card bg-base-100 border-base-200 hover:border-secondary border shadow-md transition-shadow hover:shadow-lg"
>
<div class="card-body">
<div class="mb-2 flex items-center gap-3">
<div class="bg-secondary/10 rounded-lg p-2">
<FileText class="text-secondary h-6 w-6" strokeWidth={2} />
</div>
<h4 class="font-semibold">Pedidos</h4>
<a
href={resolve('/pedidos')}
class="card bg-base-100 border-base-200 hover:border-secondary border shadow-md transition-shadow hover:shadow-lg"
>
<div class="card-body">
<div class="mb-2 flex items-center gap-3">
<div class="bg-secondary/10 rounded-lg p-2">
<FileText class="text-secondary h-6 w-6" strokeWidth={2} />
</div>
<p class="text-base-content/70 text-sm">
Gerencie pedidos de compra, acompanhe status e histórico de aquisições.
</p>
<h4 class="font-semibold">Pedidos</h4>
</div>
</a>
</div>
</main>
</ProtectedRoute>
<p class="text-base-content/70 text-sm">
Gerencie pedidos de compra, acompanhe status e histórico de aquisições.
</p>
</div>
</a>
</div>
</main>

View File

@@ -2,12 +2,28 @@
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 } from 'lucide-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
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);
@@ -26,9 +42,15 @@
numeroSei: '',
empresaId: '' as Id<'empresas'> | '',
dataInicio: '',
dataFim: ''
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);
@@ -52,12 +74,22 @@
numeroSei: ata.numeroSei,
empresaId: ata.empresaId,
dataInicio: ata.dataInicio || '',
dataFim: ata.dataFim || ''
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 {
@@ -67,9 +99,11 @@
numeroSei: '',
empresaId: '',
dataInicio: '',
dataFim: ''
dataFim: '',
dataProrrogacao: ''
};
selectedObjetos = [];
objetosConfig = {};
attachments = [];
}
attachmentFiles = [];
@@ -82,11 +116,22 @@
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 };
}
}
@@ -109,13 +154,38 @@
}
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,
objetosIds: selectedObjetos
dataProrrogacao: formData.dataProrrogacao || undefined,
objetos
};
let ataId: Id<'atas'>;
@@ -187,142 +257,258 @@
attachmentFiles = Array.from(input.files);
}
}
function limparFiltros() {
filtroPeriodoInicio = '';
filtroPeriodoFim = '';
filtroNumero = '';
filtroNumeroSei = '';
}
</script>
<div class="container mx-auto p-6">
<div class="mb-6 flex items-center justify-between">
<h1 class="text-2xl font-bold">Atas de Registro de Preços</h1>
<button
onclick={() => openModal()}
class="flex items-center gap-2 rounded-md bg-blue-600 px-4 py-2 text-white hover:bg-blue-700"
>
<Plus size={20} />
Nova Ata
</button>
<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}
<p>Carregando...</p>
<div class="flex items-center justify-center py-10">
<span class="loading loading-spinner loading-lg"></span>
</div>
{:else if errorAtas}
<p class="text-red-600">{errorAtas}</p>
<div class="alert alert-error">
<span>{errorAtas}</span>
</div>
{:else}
<div class="overflow-hidden rounded-lg bg-white shadow-md">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
>Número</th
>
<th
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
>SEI</th
>
<th
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
>Empresa</th
>
<th
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
>Vigência</th
>
<th
class="px-6 py-3 text-right text-xs font-medium tracking-wider text-gray-500 uppercase"
>Ações</th
>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
{#each atas as ata (ata._id)}
<tr>
<td class="px-6 py-4 font-medium whitespace-nowrap">{ata.numero}</td>
<td class="px-6 py-4 whitespace-nowrap">{ata.numeroSei}</td>
<td
class="max-w-md truncate px-6 py-4 whitespace-nowrap"
title={getEmpresaNome(ata.empresaId)}
>
{getEmpresaNome(ata.empresaId)}
</td>
<td class="px-6 py-4 text-sm whitespace-nowrap text-gray-500">
{ata.dataInicio || '-'} a {ata.dataFim || '-'}
</td>
<td class="px-6 py-4 text-right text-sm font-medium whitespace-nowrap">
<button
onclick={() => openModal(ata)}
class="mr-4 text-indigo-600 hover:text-indigo-900"
<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
>
<Pencil size={18} />
</button>
<button
onclick={() => handleDelete(ata._id)}
class="text-red-600 hover:text-red-900"
<th class="text-base-content border-base-400 border-b font-bold whitespace-nowrap"
>SEI</th
>
<Trash2 size={18} />
</button>
</td>
</tr>
{/each}
{#if atas.length === 0}
<tr>
<td colspan="5" class="px-6 py-4 text-center text-gray-500"
>Nenhuma ata cadastrada.</td
>
</tr>
{/if}
</tbody>
</table>
<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="fixed inset-0 z-50 flex h-full w-full items-center justify-center overflow-y-auto bg-black/40"
>
<div class="relative my-8 w-full max-w-2xl rounded-lg bg-white p-8 shadow-xl">
<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}
class="absolute top-4 right-4 text-gray-400 hover:text-gray-600"
aria-label="Fechar modal"
>
<X size={24} />
<X class="h-5 w-5" />
</button>
<h2 class="mb-6 text-xl font-bold">{editingId ? 'Editar' : 'Nova'} Ata</h2>
<form onsubmit={handleSubmit}>
<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>
<div class="mb-4">
<label class="mb-2 block text-sm font-bold text-gray-700" for="numero">
Número da Ata
<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
class="focus:shadow-outline w-full appearance-none rounded border px-3 py-2 leading-tight text-gray-700 shadow focus:outline-none"
id="numero"
class="input input-bordered focus:input-primary w-full"
type="text"
bind:value={formData.numero}
required
/>
</div>
<div class="mb-4">
<label class="mb-2 block text-sm font-bold text-gray-700" for="numeroSei">
Número SEI
<div class="form-control w-full">
<label class="label" for="numeroSei">
<span class="label-text font-semibold">Número SEI</span>
</label>
<input
class="focus:shadow-outline w-full appearance-none rounded border px-3 py-2 leading-tight text-gray-700 shadow focus:outline-none"
id="numeroSei"
class="input input-bordered focus:input-primary w-full"
type="text"
bind:value={formData.numeroSei}
required
/>
</div>
<div class="mb-4">
<label class="mb-2 block text-sm font-bold text-gray-700" for="empresa">
Empresa
<div class="form-control w-full">
<label class="label" for="empresa">
<span class="label-text font-semibold">Empresa</span>
</label>
<select
class="focus:shadow-outline w-full rounded border px-3 py-2 leading-tight text-gray-700 shadow focus:outline-none"
id="empresa"
class="select select-bordered focus:select-primary w-full"
bind:value={formData.empresaId}
required
>
@@ -333,136 +519,201 @@
</select>
</div>
<div class="mb-4 grid grid-cols-2 gap-4">
<div>
<label class="mb-2 block text-sm font-bold text-gray-700" for="dataInicio">
Data Início
<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
class="focus:shadow-outline w-full appearance-none rounded border px-3 py-2 leading-tight text-gray-700 shadow focus:outline-none"
id="dataInicio"
class="input input-bordered focus:input-primary w-full"
type="date"
bind:value={formData.dataInicio}
/>
</div>
<div>
<label class="mb-2 block text-sm font-bold text-gray-700" for="dataFim">
Data Fim
<div class="form-control w-full">
<label class="label" for="dataFim">
<span class="label-text font-semibold">Data Fim</span>
</label>
<input
class="focus:shadow-outline w-full appearance-none rounded border px-3 py-2 leading-tight text-gray-700 shadow focus:outline-none"
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="flex flex-col">
<label class="mb-2 block text-sm font-bold text-gray-700" for="objetos">
Objetos Vinculados ({selectedObjetos.length})
</label>
<div class="relative mb-2">
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<Search size={16} class="text-gray-400" />
</div>
<input
type="text"
placeholder="Buscar objetos..."
class="focus:shadow-outline w-full rounded border py-2 pr-3 pl-10 text-sm leading-tight text-gray-700 shadow focus:outline-none"
bind:value={searchObjeto}
/>
<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="mb-4 flex-1 overflow-y-auto rounded border bg-gray-50 p-2"
style="max-height: 200px;"
>
{#if filteredObjetos.length === 0}
<p class="py-4 text-center text-sm text-gray-500">Nenhum objeto encontrado.</p>
{:else}
<div class="space-y-1">
{#each filteredObjetos as objeto (objeto._id)}
<button
type="button"
class="flex w-full items-center justify-between rounded px-3 py-2 text-left text-sm hover:bg-gray-200 {selectedObjetos.includes(
objeto._id
)
? 'bg-blue-50 text-blue-700'
: ''}"
onclick={() => toggleObjeto(objeto._id)}
>
<span class="truncate">{objeto.nome}</span>
{#if selectedObjetos.includes(objeto._id)}
<Check size={16} class="text-blue-600" />
{/if}
</button>
{/each}
<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>
<div class="border-t pt-4">
<label class="mb-2 block text-sm font-bold text-gray-700" for="anexos">
Anexos
</label>
<input
class="focus:shadow-outline mb-2 w-full appearance-none rounded border px-3 py-2 leading-tight text-gray-700 shadow focus:outline-none"
id="anexos"
type="file"
multiple
onchange={handleAttachmentsSelect}
/>
{#if attachments.length > 0}
<div class="mt-2 max-h-40 space-y-2 overflow-y-auto">
{#each attachments as doc (doc._id)}
<div
class="flex items-center justify-between rounded bg-gray-100 p-2 text-sm"
>
<a
href={doc.url}
target="_blank"
rel="noopener noreferrer"
class="max-w-[150px] truncate text-blue-600 hover:underline"
>
{doc.nome}
</a>
<button
type="button"
onclick={() => handleDeleteAttachment(doc._id)}
class="text-red-500 hover:text-red-700"
>
<X size={16} />
</button>
{#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>
{/if}
</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="mt-6 flex items-center justify-end border-t pt-4">
<button
type="button"
onclick={closeModal}
class="mr-2 rounded bg-gray-300 px-4 py-2 font-bold text-gray-800 hover:bg-gray-400"
>
<div class="modal-action">
<button type="button" class="btn" onclick={closeModal} disabled={saving || uploading}>
Cancelar
</button>
<button
type="submit"
disabled={saving || uploading}
class="focus:shadow-outline rounded bg-blue-600 px-4 py-2 font-bold text-white hover:bg-blue-700 focus:outline-none disabled:opacity-50"
>
<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}
</div>
</main>

View File

@@ -2,13 +2,26 @@
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 } from 'lucide-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, {});
// 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);
@@ -114,152 +127,256 @@
formData.atas = [...formData.atas, ataId];
}
}
function limparFiltros() {
filtroNome = '';
filtroTipo = 'todos';
filtroCodigos = '';
}
</script>
<div class="container mx-auto p-6">
<div class="mb-6 flex items-center justify-between">
<h1 class="text-2xl font-bold">Objetos</h1>
<button
onclick={() => openModal()}
class="flex items-center gap-2 rounded-md bg-blue-600 px-4 py-2 text-white hover:bg-blue-700"
>
<Plus size={20} />
Novo Objeto
</button>
<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>
<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}
<p>Carregando...</p>
<div class="flex items-center justify-center py-10">
<span class="loading loading-spinner loading-lg"></span>
</div>
{:else if error}
<p class="text-red-600">{error}</p>
<div class="alert alert-error">
<span>{error}</span>
</div>
{:else}
<div class="overflow-hidden rounded-lg bg-white shadow-md">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
>Nome</th
>
<th
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
>Tipo</th
>
<th
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
>Unidade</th
>
<th
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
>Valor Estimado</th
>
<th
class="px-6 py-3 text-right text-xs font-medium tracking-wider text-gray-500 uppercase"
>Ações</th
>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
{#each objetos as objeto (objeto._id)}
<tr>
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex flex-col">
<span class="font-medium">{objeto.nome}</span>
<span class="text-xs text-gray-500">
Efisco: {objeto.codigoEfisco}
{#if objeto.codigoCatmat}
| Catmat: {objeto.codigoCatmat}{/if}
{#if objeto.codigoCatserv}
| Catserv: {objeto.codigoCatserv}{/if}
</span>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span
class="inline-flex rounded-full px-2 text-xs leading-5 font-semibold
{objeto.tipo === 'servico'
? 'bg-green-100 text-green-800'
: 'bg-blue-100 text-blue-800'}"
<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"
>Nome</th
>
{objeto.tipo === 'material' ? 'Material' : 'Serviço'}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap">{objeto.unidade}</td>
<td class="px-6 py-4 whitespace-nowrap">
{maskCurrencyBRL(objeto.valorEstimado) || 'R$ 0,00'}
</td>
<td class="px-6 py-4 text-right text-sm font-medium whitespace-nowrap">
<button
onclick={() => openModal(objeto)}
class="mr-4 text-indigo-600 hover:text-indigo-900"
<th class="text-base-content border-base-400 border-b font-bold whitespace-nowrap"
>Tipo</th
>
<Pencil size={18} />
</button>
<button
onclick={() => handleDelete(objeto._id)}
class="text-red-600 hover:text-red-900"
<th class="text-base-content border-base-400 border-b font-bold whitespace-nowrap"
>Unidade</th
>
<Trash2 size={18} />
</button>
</td>
</tr>
{/each}
{#if objetos.length === 0}
<tr>
<td colspan="5" class="px-6 py-4 text-center text-gray-500"
>Nenhum objeto cadastrado.</td
>
</tr>
{/if}
</tbody>
</table>
<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">
{#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>
{: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="fixed inset-0 z-50 flex h-full w-full items-center justify-center overflow-y-auto bg-black/40"
>
<div class="relative w-full max-w-md rounded-lg bg-white p-8 shadow-xl">
<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}
class="absolute top-4 right-4 text-gray-400 hover:text-gray-600"
aria-label="Fechar modal"
>
<X size={24} />
<X class="h-5 w-5" />
</button>
<h2 class="mb-6 text-xl font-bold">{editingId ? 'Editar' : 'Novo'} Objeto</h2>
<form onsubmit={handleSubmit}>
<div class="mb-4">
<label class="mb-2 block text-sm font-bold text-gray-700" for="nome"> Nome </label>
<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
class="focus:shadow-outline w-full appearance-none rounded border px-3 py-2 leading-tight text-gray-700 shadow focus:outline-none"
id="nome"
class="input input-bordered focus:input-primary w-full"
type="text"
bind:value={formData.nome}
required
/>
</div>
<div class="mb-4 grid grid-cols-2 gap-4">
<div>
<label class="mb-2 block text-sm font-bold text-gray-700" for="tipo"> Tipo </label>
<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
class="focus:shadow-outline w-full rounded border px-3 py-2 leading-tight text-gray-700 shadow focus:outline-none"
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>
<label class="mb-2 block text-sm font-bold text-gray-700" for="unidade">
Unidade
<div class="form-control w-full">
<label class="label" for="unidade">
<span class="label-text font-semibold">Unidade</span>
</label>
<input
class="focus:shadow-outline w-full appearance-none rounded border px-3 py-2 leading-tight text-gray-700 shadow focus:outline-none"
id="unidade"
class="input input-bordered focus:input-primary w-full"
type="text"
bind:value={formData.unidade}
required
@@ -267,13 +384,13 @@
</div>
</div>
<div class="mb-4">
<label class="mb-2 block text-sm font-bold text-gray-700" for="codigoEfisco">
Código Efisco
<div class="form-control w-full">
<label class="label" for="codigoEfisco">
<span class="label-text font-semibold">Código Efisco</span>
</label>
<input
class="focus:shadow-outline w-full appearance-none rounded border px-3 py-2 leading-tight text-gray-700 shadow focus:outline-none"
id="codigoEfisco"
class="input input-bordered focus:input-primary w-full"
type="text"
bind:value={formData.codigoEfisco}
required
@@ -281,88 +398,86 @@
</div>
{#if formData.tipo === 'material'}
<div class="mb-4">
<label class="mb-2 block text-sm font-bold text-gray-700" for="codigoCatmat">
Código Catmat
<div class="form-control w-full">
<label class="label" for="codigoCatmat">
<span class="label-text font-semibold">Código Catmat</span>
</label>
<input
class="focus:shadow-outline w-full appearance-none rounded border px-3 py-2 leading-tight text-gray-700 shadow focus:outline-none"
id="codigoCatmat"
class="input input-bordered focus:input-primary w-full"
type="text"
bind:value={formData.codigoCatmat}
/>
</div>
{:else}
<div class="mb-4">
<label class="mb-2 block text-sm font-bold text-gray-700" for="codigoCatserv">
Código Catserv
<div class="form-control w-full">
<label class="label" for="codigoCatserv">
<span class="label-text font-semibold">Código Catserv</span>
</label>
<input
class="focus:shadow-outline w-full appearance-none rounded border px-3 py-2 leading-tight text-gray-700 shadow focus:outline-none"
id="codigoCatserv"
class="input input-bordered focus:input-primary w-full"
type="text"
bind:value={formData.codigoCatserv}
/>
</div>
{/if}
<div class="mb-6">
<label class="mb-2 block text-sm font-bold text-gray-700" for="valor">
Valor Estimado
<div class="form-control w-full">
<label class="label" for="valor">
<span class="label-text font-semibold">Valor Estimado</span>
</label>
<input
class="focus:shadow-outline w-full appearance-none rounded border px-3 py-2 leading-tight text-gray-700 shadow focus:outline-none"
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))}
placeholder="R$ 0,00"
/>
</div>
<div class="mb-6">
<label class="mb-2 block text-sm font-bold text-gray-700" for="atas">
Vincular Atas
<div class="form-control w-full">
<label class="label" for="atas">
<span class="label-text font-semibold">Vincular Atas</span>
</label>
<div class="max-h-40 overflow-y-auto rounded border p-2">
{#each atas as ata (ata._id)}
<div class="mb-2 flex items-center">
<input
type="checkbox"
id={`ata-${ata._id}`}
checked={formData.atas.includes(ata._id)}
onchange={() => toggleAtaSelection(ata._id)}
class="mr-2 h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<label for={`ata-${ata._id}`} class="text-sm text-gray-700">
{ata.numero} ({ata.numeroSei})
</label>
</div>
{/each}
<div class="border-base-300 max-h-48 overflow-y-auto rounded-lg border p-2">
{#if atas.length === 0}
<p class="text-sm text-gray-500">Nenhuma ata disponível.</p>
<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="flex items-center justify-end">
<button
type="button"
onclick={closeModal}
class="mr-2 rounded bg-gray-300 px-4 py-2 font-bold text-gray-800 hover:bg-gray-400"
>
<div class="modal-action">
<button type="button" class="btn" onclick={closeModal} disabled={saving}>
Cancelar
</button>
<button
type="submit"
disabled={saving}
class="focus:shadow-outline rounded bg-blue-600 px-4 py-2 font-bold text-white hover:bg-blue-700 focus:outline-none disabled:opacity-50"
>
<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}
</div>
</main>

View File

@@ -7,7 +7,16 @@
import { maskCEP, maskCNPJ, maskPhone, maskUF, onlyDigits } from '$lib/utils/masks';
const client = useConvexClient();
const empresasQuery = useQuery(api.empresas.list, {});
let filtroEmpresa = $state('');
const empresasTotalQuery = useQuery(api.empresas.list, {});
const empresasQuery = useQuery(api.empresas.list, () => ({
query: filtroEmpresa.trim() || undefined
}));
function limparFiltroEmpresa() {
filtroEmpresa = '';
}
let modalAberto = $state(false);
@@ -425,6 +434,32 @@
</button>
</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="form-control w-full">
<label class="label" for="filtro_empresa">
<span class="label-text font-semibold">Buscar empresa</span>
</label>
<input
id="filtro_empresa"
class="input input-bordered focus:input-primary w-full"
type="text"
placeholder="Nome fantasia, razão social ou CNPJ"
bind:value={filtroEmpresa}
/>
</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">
{empresasQuery.data?.length ?? 0} de {empresasTotalQuery.data?.length ?? 0}
</div>
<button type="button" class="btn btn-ghost" onclick={limparFiltroEmpresa}>Limpar</button>
</div>
</div>
</div>
</div>
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
{#if empresasQuery.isLoading}
@@ -437,11 +472,21 @@
</div>
{:else if empresasQuery.data && empresasQuery.data.length === 0}
<div class="py-10 text-center">
<p class="text-base-content/70 mb-4">Nenhuma empresa cadastrada ainda.</p>
<button class="btn btn-primary gap-2" type="button" onclick={abrirNovaEmpresa}>
<Plus class="h-4 w-4" strokeWidth={2} />
Cadastrar primeira empresa
</button>
{#if (empresasTotalQuery.data?.length ?? 0) === 0}
<p class="text-base-content/70 mb-4">Nenhuma empresa cadastrada ainda.</p>
<button class="btn btn-primary gap-2" type="button" onclick={abrirNovaEmpresa}>
<Plus class="h-4 w-4" strokeWidth={2} />
Cadastrar primeira empresa
</button>
{:else}
<p class="text-base-content/70 mb-2">Nenhum resultado encontrado.</p>
<p class="text-base-content/60 mb-4 text-sm">
Ajuste ou limpe o filtro para ver empresas.
</p>
<button type="button" class="btn btn-ghost" onclick={limparFiltroEmpresa}>
Limpar filtro
</button>
{/if}
</div>
{:else if empresasQuery.data}
<div class="overflow-x-auto">

View File

@@ -1,12 +1,124 @@
<script lang="ts">
import { api } from '@sgse-app/backend/convex/_generated/api';
import { useQuery } from 'convex-svelte';
import { Eye, Plus } from 'lucide-svelte';
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
import { exportarRelatorioPedidosXLSX } from '$lib/utils/pedidos/relatorioPedidosExcel';
import { gerarRelatorioPedidosPDF } from '$lib/utils/pedidos/relatorioPedidosPDF';
import Breadcrumbs from '$lib/components/layout/Breadcrumbs.svelte';
import PageHeader from '$lib/components/layout/PageHeader.svelte';
import PageShell from '$lib/components/layout/PageShell.svelte';
import GlassCard from '$lib/components/ui/GlassCard.svelte';
import TableCard from '$lib/components/ui/TableCard.svelte';
import { useConvexClient, useQuery } from 'convex-svelte';
import { endOfDay, startOfDay } from 'date-fns';
import { Eye, FileText, Plus } from 'lucide-svelte';
import { resolve } from '$app/paths';
const client = useConvexClient();
const statusOptions = [
{ value: 'em_rascunho', label: 'Rascunho' },
{ value: 'aguardando_aceite', label: 'Aguardando Aceite' },
{ value: 'em_analise', label: 'Em Análise' },
{ value: 'precisa_ajustes', label: 'Precisa de Ajustes' },
{ value: 'concluido', label: 'Concluído' },
{ value: 'cancelado', label: 'Cancelado' }
] as const;
type PedidoStatus = (typeof statusOptions)[number]['value'];
// Filtros (cumulativos / backend)
let filtroNumeroSei = $state('');
let filtroCriadoPor = $state<Id<'usuarios'> | ''>('');
let filtroAceitoPor = $state<Id<'funcionarios'> | ''>('');
let filtroInicio = $state(''); // yyyy-MM-dd
let filtroFim = $state(''); // yyyy-MM-dd
let statusSelected = $state<Record<PedidoStatus, boolean>>({
em_rascunho: false,
aguardando_aceite: false,
em_analise: false,
precisa_ajustes: false,
concluido: false,
cancelado: false
});
function getSelectedStatuses(): PedidoStatus[] | undefined {
const selected = (Object.entries(statusSelected) as Array<[PedidoStatus, boolean]>)
.filter(([, v]) => v)
.map(([k]) => k);
return selected.length > 0 ? selected : undefined;
}
function getPeriodoInicio(): number | undefined {
if (!filtroInicio) return undefined;
return startOfDay(new Date(`${filtroInicio}T00:00:00`)).getTime();
}
function getPeriodoFim(): number | undefined {
if (!filtroFim) return undefined;
return endOfDay(new Date(`${filtroFim}T23:59:59`)).getTime();
}
function limparFiltros() {
filtroNumeroSei = '';
filtroCriadoPor = '';
filtroAceitoPor = '';
filtroInicio = '';
filtroFim = '';
(Object.keys(statusSelected) as PedidoStatus[]).forEach((k) => (statusSelected[k] = false));
}
const usuariosQuery = useQuery(api.usuarios.listar, { ativo: true });
const funcionariosQuery = useQuery(api.funcionarios.getAll, {});
const filtroArgs = () => ({
statuses: getSelectedStatuses(),
numeroSei: filtroNumeroSei.trim() || undefined,
criadoPor: filtroCriadoPor ? filtroCriadoPor : undefined,
aceitoPor: filtroAceitoPor ? filtroAceitoPor : undefined,
periodoInicio: getPeriodoInicio(),
periodoFim: getPeriodoFim()
});
let generatingPDF = $state(false);
let generatingXLSX = $state(false);
async function gerarPDF() {
if (!filtroInicio && !filtroFim) {
alert('Informe um período (início e/ou fim) para gerar o relatório.');
return;
}
try {
generatingPDF = true;
const relatorio = await client.query(api.pedidos.gerarRelatorio, filtroArgs());
gerarRelatorioPedidosPDF(relatorio);
} catch (e) {
console.error('Erro ao gerar relatório PDF:', e);
alert('Erro ao gerar relatório PDF. Tente novamente.');
} finally {
generatingPDF = false;
}
}
async function exportarXLSX() {
if (!filtroInicio && !filtroFim) {
alert('Informe um período (início e/ou fim) para exportar o relatório.');
return;
}
try {
generatingXLSX = true;
const relatorio = await client.query(api.pedidos.gerarRelatorio, filtroArgs());
await exportarRelatorioPedidosXLSX(relatorio);
} catch (e) {
console.error('Erro ao exportar relatório XLSX:', e);
alert('Erro ao exportar relatório Excel. Tente novamente.');
} finally {
generatingXLSX = false;
}
}
// Reactive queries
const pedidosQuery = useQuery(api.pedidos.list, {});
const myItemsQuery = useQuery(api.pedidos.listByItemCreator, {});
const pedidosQuery = useQuery(api.pedidos.list, filtroArgs);
const myItemsQuery = useQuery(api.pedidos.listByItemCreator, filtroArgs);
const acoesQuery = useQuery(api.acoes.list, {});
let activeTab = $state<'all' | 'my_items'>('all');
@@ -42,22 +154,22 @@
}
}
function getStatusColor(status: string) {
function getStatusBadgeClass(status: string) {
switch (status) {
case 'em_rascunho':
return 'bg-gray-100 text-gray-800';
return 'badge-ghost';
case 'aguardando_aceite':
return 'bg-yellow-100 text-yellow-800';
return 'badge-warning';
case 'em_analise':
return 'bg-blue-100 text-blue-800';
return 'badge-info';
case 'precisa_ajustes':
return 'bg-orange-100 text-orange-800';
return 'badge-secondary';
case 'concluido':
return 'bg-green-100 text-green-800';
return 'badge-success';
case 'cancelado':
return 'bg-red-100 text-red-800';
return 'badge-error';
default:
return 'bg-gray-100 text-gray-800';
return 'badge-ghost';
}
}
@@ -66,17 +178,150 @@
}
</script>
<div class="container mx-auto p-6">
<div class="mb-6 flex items-center justify-between">
<h1 class="text-2xl font-bold">Pedidos</h1>
<a
href={resolve('/pedidos/novo')}
class="flex items-center gap-2 rounded-md bg-blue-600 px-4 py-2 text-white hover:bg-blue-700"
>
<Plus size={20} />
Novo Pedido
</a>
</div>
<PageShell>
<Breadcrumbs items={[{ label: 'Dashboard', href: resolve('/') }, { label: 'Pedidos' }]} />
<PageHeader title="Pedidos" subtitle="Cadastro, acompanhamento e relatórios de pedidos">
{#snippet icon()}
<FileText strokeWidth={2} />
{/snippet}
{#snippet actions()}
<button
type="button"
class="btn btn-outline"
onclick={gerarPDF}
disabled={generatingPDF || generatingXLSX}
title="Gera relatório completo (PDF) no padrão do sistema"
>
{#if generatingPDF}
<span class="loading loading-spinner loading-sm"></span>
{/if}
{generatingPDF ? 'Gerando PDF...' : 'Relatório (PDF)'}
</button>
<button
type="button"
class="btn btn-outline"
onclick={exportarXLSX}
disabled={generatingPDF || generatingXLSX}
title="Exporta relatório completo em Excel (XLSX)"
>
{#if generatingXLSX}
<span class="loading loading-spinner loading-sm"></span>
{/if}
{generatingXLSX ? 'Exportando...' : 'Excel (XLSX)'}
</button>
<a
href={resolve('/pedidos/novo')}
class="btn btn-primary gap-2 shadow-md transition-all hover:shadow-lg"
>
<Plus class="h-5 w-5" strokeWidth={2} />
Novo Pedido
</a>
{/snippet}
</PageHeader>
<GlassCard class="mb-6">
<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 lg:grid-cols-5">
<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="Digite para filtrar..."
bind:value={filtroNumeroSei}
/>
</div>
<div class="form-control w-full">
<label class="label" for="filtro_criadoPor">
<span class="label-text font-semibold">Criado por</span>
</label>
<select
id="filtro_criadoPor"
class="select select-bordered focus:select-primary w-full"
bind:value={filtroCriadoPor}
>
<option value="">Todos</option>
{#each usuariosQuery.data || [] as u (u._id)}
<option value={u._id}>{u.nome}</option>
{/each}
</select>
</div>
<div class="form-control w-full">
<label class="label" for="filtro_aceitoPor">
<span class="label-text font-semibold">Aceito por</span>
</label>
<select
id="filtro_aceitoPor"
class="select select-bordered focus:select-primary w-full"
bind:value={filtroAceitoPor}
>
<option value="">Todos</option>
{#each funcionariosQuery.data || [] as f (f._id)}
<option value={f._id}>{f.nome}</option>
{/each}
</select>
</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={filtroInicio}
/>
</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={filtroFim}
/>
</div>
<div class="form-control w-full md:col-span-3 lg:col-span-5">
<div class="label">
<span class="label-text font-semibold">Status</span>
</div>
<div class="flex flex-wrap gap-3">
{#each statusOptions as s (s.value)}
<label class="label cursor-pointer justify-start gap-2 py-1">
<input
type="checkbox"
class="checkbox checkbox-primary checkbox-sm"
checked={statusSelected[s.value]}
onchange={(e) =>
(statusSelected[s.value] = (e.currentTarget as HTMLInputElement).checked)}
/>
<span class="label-text">{s.label}</span>
</label>
{/each}
</div>
</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">{pedidos.length} resultado(s)</div>
<button type="button" class="btn btn-ghost" onclick={limparFiltros}>Limpar</button>
</div>
</div>
</GlassCard>
<div role="tablist" class="tabs tabs-bordered mb-6">
<button
@@ -96,87 +341,82 @@
</div>
{#if loading}
<div class="flex flex-col gap-4">
{#each Array(3) as _, i (i)}
<div class="skeleton h-16 w-full rounded-lg"></div>
{/each}
<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="overflow-hidden rounded-lg bg-white shadow-md">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<TableCard>
<table class="table-zebra table w-full">
<thead>
<tr>
<th
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
<th class="text-base-content border-base-400 border-b font-bold whitespace-nowrap"
>Número SEI</th
>
<th
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
<th class="text-base-content border-base-400 border-b font-bold whitespace-nowrap"
>Status</th
>
<th
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
>Criado Por</th
<th class="text-base-content border-base-400 border-b font-bold whitespace-nowrap"
>Criado por</th
>
<th class="text-base-content border-base-400 border-b font-bold whitespace-nowrap"
>Data de criação</th
>
<th
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
>Data de Criação</th
>
<th
class="px-6 py-3 text-right text-xs font-medium tracking-wider text-gray-500 uppercase"
class="text-base-content border-base-400 border-b text-right font-bold whitespace-nowrap"
>Ações</th
>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
{#each pedidos as pedido (pedido._id)}
<tr class="hover:bg-gray-50">
<td class="px-6 py-4 font-medium whitespace-nowrap">
{#if pedido.numeroSei}
{pedido.numeroSei}
{:else}
<span class="text-amber-600">Sem número SEI</span>
{/if}
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span
class="inline-flex rounded-full px-2 py-1 text-xs font-semibold {getStatusColor(
pedido.status
)}"
>
{formatStatus(pedido.status)}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-gray-500">
{pedido.criadoPorNome || 'Desconhecido'}
</td>
<td class="px-6 py-4 whitespace-nowrap text-gray-500">
{formatDate(pedido.criadoEm)}
</td>
<td class="px-6 py-4 text-right text-sm font-medium whitespace-nowrap">
<a
href={resolve(`/pedidos/${pedido._id}`)}
class="inline-flex items-center gap-1 text-indigo-600 hover:text-indigo-900"
>
<Eye size={18} />
Visualizar
</a>
</td>
</tr>
{/each}
<tbody>
{#if pedidos.length === 0}
<tr>
<td colspan="5" class="px-6 py-4 text-center text-gray-500"
>Nenhum pedido encontrado.</td
>
<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 pedido encontrado</p>
<p class="text-sm">Ajuste ou limpe os filtros para ver resultados.</p>
</div>
</td>
</tr>
{:else}
{#each pedidos as pedido (pedido._id)}
<tr class="hover:bg-base-200/50 transition-colors">
<td class="font-medium whitespace-nowrap">
{#if pedido.numeroSei}
{pedido.numeroSei}
{:else}
<span class="text-warning">Sem número SEI</span>
{/if}
</td>
<td class="whitespace-nowrap">
<span class="badge badge-sm {getStatusBadgeClass(pedido.status)}">
{formatStatus(pedido.status)}
</span>
</td>
<td class="text-base-content/70 whitespace-nowrap">
{pedido.criadoPorNome || 'Desconhecido'}
</td>
<td class="text-base-content/70 whitespace-nowrap">
{formatDate(pedido.criadoEm)}
</td>
<td class="text-right whitespace-nowrap">
<a
href={resolve(`/pedidos/${pedido._id}`)}
class="btn btn-ghost btn-sm gap-2"
aria-label="Visualizar pedido"
>
<Eye class="h-4 w-4" />
Visualizar
</a>
</td>
</tr>
{/each}
{/if}
</tbody>
</table>
</div>
</TableCard>
{/if}
</div>
</PageShell>

File diff suppressed because it is too large Load Diff

View File

@@ -4,6 +4,12 @@
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
import { CheckCircle, Clock, FileText, User } from 'lucide-svelte';
import { toast } from 'svelte-sonner';
import { resolve } from '$app/paths';
import Breadcrumbs from '$lib/components/layout/Breadcrumbs.svelte';
import PageHeader from '$lib/components/layout/PageHeader.svelte';
import PageShell from '$lib/components/layout/PageShell.svelte';
import GlassCard from '$lib/components/ui/GlassCard.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte';
const client = useConvexClient();
const ordersQuery = useQuery(api.pedidos.listForAcceptance, {});
@@ -27,42 +33,44 @@
}
</script>
<div class="space-y-6">
<div class="flex items-center justify-between">
<div>
<h1 class="text-primary text-2xl font-bold tracking-tight">Pedidos para Aceite</h1>
<p class="text-base-content/70 mt-1">
Lista de pedidos aguardando análise do setor de compras.
</p>
</div>
</div>
<PageShell>
<Breadcrumbs
items={[
{ label: 'Dashboard', href: resolve('/') },
{ label: 'Pedidos', href: resolve('/pedidos') },
{ label: 'Aceite' }
]}
/>
{#if ordersQuery.isLoading}
<div class="flex flex-col gap-4">
{#each Array(3) as _, i (i)}
<div class="skeleton h-24 w-full rounded-lg"></div>
{/each}
</div>
<PageHeader
title="Pedidos para Aceite"
subtitle="Lista de pedidos aguardando análise do setor de compras"
>
{#snippet icon()}
<Clock strokeWidth={2} />
{/snippet}
</PageHeader>
<div class="space-y-6">
{#if ordersQuery.isLoading}
<div class="flex items-center justify-center py-10">
<span class="loading loading-spinner loading-lg"></span>
</div>
{:else if ordersQuery.error}
<div class="alert alert-error">
<span>Erro ao carregar pedidos: {ordersQuery.error.message}</span>
</div>
{:else if !ordersQuery.data || ordersQuery.data.length === 0}
<div
class="bg-base-100 flex flex-col items-center justify-center rounded-lg border py-12 text-center shadow-sm"
>
<div class="bg-base-200 mb-4 rounded-full p-4">
<CheckCircle class="text-base-content/30 h-8 w-8" />
</div>
<h3 class="text-lg font-medium">Tudo em dia!</h3>
<p class="text-base-content/60 mt-1 max-w-sm">Não há pedidos aguardando aceite no momento.</p>
</div>
<EmptyState title="Tudo em dia!" description="Não há pedidos aguardando aceite no momento.">
{#snippet icon()}
<CheckCircle />
{/snippet}
</EmptyState>
{:else}
<div class="grid gap-4">
{#each ordersQuery.data as pedido (pedido._id)}
<div
class="bg-base-100 border-base-200 hover:border-primary/30 group relative overflow-hidden rounded-xl border p-6 shadow-sm transition-all hover:shadow-md"
>
<GlassCard class="border-base-300 hover:border-primary/30 transition-all hover:shadow-md">
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div class="space-y-1">
<div class="flex items-center gap-2">
@@ -90,7 +98,7 @@
</div>
<div class="flex items-center gap-2">
<a href="/pedidos/{pedido._id}" class="btn btn-ghost btn-sm">
<a href={resolve(`/pedidos/${pedido._id}`)} class="btn btn-ghost btn-sm">
<FileText class="mr-2 h-4 w-4" />
Ver Detalhes
</a>
@@ -108,8 +116,9 @@
</button>
</div>
</div>
</div>
</GlassCard>
{/each}
</div>
{/if}
</div>
</div>
</PageShell>

View File

@@ -2,82 +2,91 @@
import { useQuery } from 'convex-svelte';
import { api } from '@sgse-app/backend/convex/_generated/api';
import { ClipboardList, Clock, FileText, User, Search } from 'lucide-svelte';
import { resolve } from '$app/paths';
import Breadcrumbs from '$lib/components/layout/Breadcrumbs.svelte';
import PageHeader from '$lib/components/layout/PageHeader.svelte';
import PageShell from '$lib/components/layout/PageShell.svelte';
import GlassCard from '$lib/components/ui/GlassCard.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte';
const ordersQuery = useQuery(api.pedidos.listMyAnalysis, {});
</script>
<div class="space-y-6">
<div class="flex items-center justify-between">
<div>
<h1 class="text-primary text-2xl font-bold tracking-tight">Minhas Análises</h1>
<p class="text-base-content/70 mt-1">Pedidos que você aceitou e está analisando.</p>
</div>
</div>
<PageShell>
<Breadcrumbs
items={[
{ label: 'Dashboard', href: resolve('/') },
{ label: 'Pedidos', href: resolve('/pedidos') },
{ label: 'Minhas análises' }
]}
/>
{#if ordersQuery.isLoading}
<div class="flex flex-col gap-4">
{#each Array(3) as _, i (i)}
<div class="skeleton h-24 w-full rounded-lg"></div>
{/each}
</div>
{:else if ordersQuery.error}
<div class="alert alert-error">
<span>Erro ao carregar análises: {ordersQuery.error.message}</span>
</div>
{:else if !ordersQuery.data || ordersQuery.data.length === 0}
<div
class="bg-base-100 flex flex-col items-center justify-center rounded-lg border py-12 text-center shadow-sm"
>
<div class="bg-base-200 mb-4 rounded-full p-4">
<ClipboardList class="text-base-content/30 h-8 w-8" />
<PageHeader title="Minhas Análises" subtitle="Pedidos que você aceitou e está analisando">
{#snippet icon()}
<Search strokeWidth={2} />
{/snippet}
</PageHeader>
<div class="space-y-6">
{#if ordersQuery.isLoading}
<div class="flex items-center justify-center py-10">
<span class="loading loading-spinner loading-lg"></span>
</div>
<h3 class="text-lg font-medium">Nenhuma análise em andamento</h3>
<p class="text-base-content/60 mt-1 max-w-sm">
Você não possui pedidos sob sua responsabilidade no momento. Vá para "Pedidos para Aceite"
para pegar novos pedidos.
</p>
</div>
{:else}
<div class="grid gap-4">
{#each ordersQuery.data as pedido (pedido._id)}
<div
class="bg-base-100 border-base-200 hover:border-primary/30 group relative overflow-hidden rounded-xl border p-6 shadow-sm transition-all hover:shadow-md"
>
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div class="space-y-1">
<div class="flex items-center gap-2">
<span class="badge badge-info gap-1 font-medium">
<Search class="h-3 w-3" />
Em Análise
</span>
<span class="text-base-content/40 text-xs">
#{pedido._id.slice(-6)}
</span>
</div>
<h3 class="text-lg font-bold">
{pedido.numeroSei ? `SEI: ${pedido.numeroSei}` : 'Sem número SEI'}
</h3>
<div class="text-base-content/70 flex flex-wrap items-center gap-x-4 gap-y-1 text-sm">
<div class="flex items-center gap-1">
<User class="h-3.5 w-3.5" />
<span>Criado por: {pedido.criadoPorNome}</span>
{:else if ordersQuery.error}
<div class="alert alert-error">
<span>Erro ao carregar análises: {ordersQuery.error.message}</span>
</div>
{:else if !ordersQuery.data || ordersQuery.data.length === 0}
<EmptyState
title="Nenhuma análise em andamento"
description="Você não possui pedidos sob sua responsabilidade no momento. Vá para &quot;Pedidos para Aceite&quot; para pegar novos pedidos."
>
{#snippet icon()}
<ClipboardList />
{/snippet}
</EmptyState>
{:else}
<div class="grid gap-4">
{#each ordersQuery.data as pedido (pedido._id)}
<GlassCard class="border-base-300 hover:border-primary/30 transition-all hover:shadow-md">
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div class="space-y-1">
<div class="flex items-center gap-2">
<span class="badge badge-info gap-1 font-medium">
<Search class="h-3 w-3" />
Em Análise
</span>
<span class="text-base-content/40 text-xs">
#{pedido._id.slice(-6)}
</span>
</div>
<div class="flex items-center gap-1">
<Clock class="h-3.5 w-3.5" />
<span>Aceito em: {new Date(pedido.atualizadoEm).toLocaleDateString()}</span>
<h3 class="text-lg font-bold">
{pedido.numeroSei ? `SEI: ${pedido.numeroSei}` : 'Sem número SEI'}
</h3>
<div
class="text-base-content/70 flex flex-wrap items-center gap-x-4 gap-y-1 text-sm"
>
<div class="flex items-center gap-1">
<User class="h-3.5 w-3.5" />
<span>Criado por: {pedido.criadoPorNome}</span>
</div>
<div class="flex items-center gap-1">
<Clock class="h-3.5 w-3.5" />
<span>Aceito em: {new Date(pedido.atualizadoEm).toLocaleDateString()}</span>
</div>
</div>
</div>
</div>
<div class="flex items-center gap-2">
<a href="/pedidos/{pedido._id}" class="btn btn-primary btn-sm">
<FileText class="mr-2 h-4 w-4" />
Continuar Análise
</a>
<div class="flex items-center gap-2">
<a href={resolve(`/pedidos/${pedido._id}`)} class="btn btn-primary btn-sm">
<FileText class="mr-2 h-4 w-4" />
Continuar Análise
</a>
</div>
</div>
</div>
</div>
{/each}
</div>
{/if}
</div>
</GlassCard>
{/each}
</div>
{/if}
</div>
</PageShell>

View File

@@ -2,7 +2,12 @@
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 { Plus, Trash2, X, Info } from 'lucide-svelte';
import Breadcrumbs from '$lib/components/layout/Breadcrumbs.svelte';
import PageHeader from '$lib/components/layout/PageHeader.svelte';
import PageShell from '$lib/components/layout/PageShell.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte';
import GlassCard from '$lib/components/ui/GlassCard.svelte';
import { Info, Plus, Trash2, X } from 'lucide-svelte';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
@@ -25,39 +30,28 @@
let warning = $state<string | null>(null);
// Item selection state
// Nota: modalidade é opcional aqui pois será definida pelo Setor de Compras posteriormente
type SelectedItem = {
objeto: Doc<'objetos'>;
quantidade: number;
modalidade: 'dispensa' | 'inexgibilidade' | 'adesao' | 'consumo';
acaoId?: Id<'acoes'>;
ataId?: Id<'atas'>;
ataNumero?: string; // For display
ata?: Doc<'atas'>; // Full ata object for details
};
let selectedItems = $state<SelectedItem[]>([]);
let selectedObjetoIds = $derived(selectedItems.map((i) => i.objeto._id));
let hasMixedModalidades = $derived(new Set(selectedItems.map((i) => i.modalidade)).size > 1);
// Item configuration modal
let showItemModal = $state(false);
let itemConfig = $state<{
objeto: Doc<'objetos'> | null;
quantidade: number;
modalidade: 'dispensa' | 'inexgibilidade' | 'adesao' | 'consumo';
acaoId: string; // using string to handle empty select
ataId: string; // using string to handle empty select
}>({
objeto: null,
quantidade: 1,
modalidade: 'consumo',
acaoId: '',
ataId: ''
acaoId: ''
});
let availableAtas = $state<Doc<'atas'>[]>([]);
// Item Details Modal
let showDetailsModal = $state(false);
let detailsItem = $state<SelectedItem | null>(null);
@@ -72,16 +66,10 @@
}
async function openItemModal(objeto: Doc<'objetos'>) {
// Fetch linked Atas for this object
const linkedAtas = await client.query(api.objetos.getAtas, { objetoId: objeto._id });
availableAtas = linkedAtas;
itemConfig = {
objeto,
quantidade: 1,
modalidade: 'consumo',
acaoId: '',
ataId: ''
acaoId: ''
};
showItemModal = true;
searchQuery = ''; // Clear search
@@ -90,24 +78,17 @@
function closeItemModal() {
showItemModal = false;
itemConfig.objeto = null;
availableAtas = [];
}
function confirmAddItem() {
if (!itemConfig.objeto) return;
const selectedAta = availableAtas.find((a) => a._id === itemConfig.ataId);
selectedItems = [
...selectedItems,
{
objeto: itemConfig.objeto,
quantidade: itemConfig.quantidade,
modalidade: itemConfig.modalidade,
acaoId: itemConfig.acaoId ? (itemConfig.acaoId as Id<'acoes'>) : undefined,
ataId: itemConfig.ataId ? (itemConfig.ataId as Id<'atas'>) : undefined,
ataNumero: selectedAta?.numero,
ata: selectedAta
acaoId: itemConfig.acaoId ? (itemConfig.acaoId as Id<'acoes'>) : undefined
}
];
checkExisting();
@@ -128,7 +109,6 @@
criadoEm: number;
matchingItems?: {
objetoId: Id<'objetos'>;
modalidade: SelectedItem['modalidade'];
quantidade: number;
}[];
}[]
@@ -154,36 +134,6 @@
}
}
function formatModalidade(modalidade: SelectedItem['modalidade']) {
switch (modalidade) {
case 'consumo':
return 'Consumo';
case 'dispensa':
return 'Dispensa';
case 'inexgibilidade':
return 'Inexigibilidade';
case 'adesao':
return 'Adesão';
default:
return modalidade;
}
}
function getModalidadeBadgeClasses(modalidade: SelectedItem['modalidade']) {
switch (modalidade) {
case 'consumo':
return 'bg-blue-100 text-blue-800';
case 'dispensa':
return 'bg-yellow-100 text-yellow-800';
case 'inexgibilidade':
return 'bg-purple-100 text-purple-800';
case 'adesao':
return 'bg-green-100 text-green-800';
default:
return 'bg-gray-100 text-gray-800';
}
}
function getAcaoNome(acaoId: Id<'acoes'> | undefined) {
if (!acaoId) return '-';
const acao = acoes.find((a) => a._id === acaoId);
@@ -203,8 +153,7 @@
.map((match) => {
// Find name from selected items (might be multiple with same object, just pick one name)
const item = selectedItems.find((p) => p.objeto._id === match.objetoId);
const modalidadeLabel = formatModalidade(match.modalidade);
return `${item?.objeto.nome} (${modalidadeLabel}): ${match.quantidade} un.`;
return `${item?.objeto.nome}: ${match.quantidade} un.`;
})
.join(', ');
@@ -215,9 +164,7 @@
if (!pedido.matchingItems || pedido.matchingItems.length === 0) return null;
for (const match of pedido.matchingItems) {
const item = selectedItems.find(
(p) => p.objeto._id === match.objetoId && p.modalidade === match.modalidade
);
const item = selectedItems.find((p) => p.objeto._id === match.objetoId);
if (item) {
return item;
}
@@ -226,27 +173,22 @@
return null;
}
function buildPedidoHref(pedido: (typeof existingPedidos)[0]) {
function buildPedidoHref(pedido: (typeof existingPedidos)[0]): `/pedidos/${string}` {
const matchedItem = getFirstMatchingSelectedItem(pedido);
if (!matchedItem) {
return resolve(`/pedidos/${pedido._id}`);
return `/pedidos/${pedido._id}` as `/pedidos/${string}`;
}
const params = new URLSearchParams();
params.set('obj', matchedItem.objeto._id);
params.set('qtd', String(matchedItem.quantidade));
params.set('mod', matchedItem.modalidade);
if (matchedItem.acaoId) {
params.set('acao', matchedItem.acaoId);
}
if (matchedItem.ataId) {
params.set('ata', matchedItem.ataId);
}
return resolve(`/pedidos/${pedido._id}?${params.toString()}`);
return `/pedidos/${pedido._id}?${params.toString()}` as `/pedidos/${string}`;
}
async function checkExisting() {
@@ -258,13 +200,11 @@
checking = true;
try {
// Importante: ação (acaoId) NÃO entra no filtro de similaridade.
// O filtro considera apenas combinação de objeto + modalidade.
// Importante: O filtro considera apenas objetoId (modalidade não é mais usada na criação).
const itensFiltro =
selectedItems.length > 0
? selectedItems.map((item) => ({
objetoId: item.objeto._id,
modalidade: item.modalidade
objetoId: item.objeto._id
}))
: undefined;
@@ -289,11 +229,6 @@
async function handleSubmit(e: Event) {
e.preventDefault();
if (hasMixedModalidades) {
error =
'Não é possível criar o pedido com itens de modalidades diferentes. Ajuste os itens antes de continuar.';
return;
}
creating = true;
error = null;
try {
@@ -309,9 +244,7 @@
objetoId: item.objeto._id,
valorEstimado: item.objeto.valorEstimado,
quantidade: item.quantidade,
modalidade: item.modalidade,
acaoId: item.acaoId,
ataId: item.ataId
acaoId: item.acaoId
})
)
);
@@ -326,71 +259,81 @@
}
</script>
<div class="container mx-auto max-w-4xl p-6">
<h1 class="mb-6 text-3xl font-bold">Novo Pedido</h1>
<PageShell class="max-w-4xl">
<Breadcrumbs
items={[
{ label: 'Dashboard', href: resolve('/') },
{ label: 'Pedidos', href: resolve('/pedidos') },
{ label: 'Novo' }
]}
/>
<PageHeader title="Novo Pedido" subtitle="Crie um pedido e adicione objetos.">
{#snippet icon()}
<Plus class="h-6 w-6" />
{/snippet}
</PageHeader>
<div class="space-y-6">
{#if error}
<div class="rounded-lg border border-red-400 bg-red-50 px-4 py-3 text-red-700">
<p class="font-semibold">Erro</p>
<p class="text-sm">{error}</p>
<div class="alert alert-error">
<span>{error}</span>
</div>
{/if}
<form onsubmit={handleSubmit} class="space-y-6">
<!-- Section 1: Basic Information -->
<div class="rounded-lg bg-white p-6 shadow-md">
<h2 class="mb-4 text-lg font-semibold text-gray-800">Informações Básicas</h2>
<div>
<label class="mb-2 block text-sm font-medium text-gray-700" for="numeroSei">
Número SEI (Opcional)
<GlassCard>
<h2 class="text-lg font-semibold">Informações Básicas</h2>
<div class="mt-4">
<label class="label py-0" for="numeroSei">
<span class="label-text font-semibold">Número SEI (Opcional)</span>
</label>
<input
class="focus:shadow-outline w-full appearance-none rounded-lg border border-gray-300 px-4 py-2.5 leading-tight text-gray-700 shadow-sm transition focus:border-blue-500 focus:ring-2 focus:ring-blue-200 focus:outline-none"
id="numeroSei"
type="text"
bind:value={formData.numeroSei}
placeholder="Ex: 12345.000000/2023-00"
onblur={checkExisting}
class="input input-bordered focus:input-primary w-full"
/>
<p class="mt-1.5 text-xs text-gray-500">
<p class="text-base-content/60 mt-2 text-xs">
Você pode adicionar o número SEI posteriormente.
</p>
</div>
</div>
</GlassCard>
<!-- Section 2: Add Objects -->
<div class="rounded-lg bg-white p-6 shadow-md">
<h2 class="mb-4 text-lg font-semibold text-gray-800">Adicionar Objetos ao Pedido</h2>
<GlassCard>
<h2 class="text-lg font-semibold">Adicionar Objetos ao Pedido</h2>
<div class="relative mb-4">
<label class="mb-2 block text-sm font-medium text-gray-700" for="search-objetos">
Buscar Objetos
<div class="relative mt-4">
<label class="label py-0" for="search-objetos">
<span class="label-text font-semibold">Buscar Objetos</span>
</label>
<input
id="search-objetos"
type="text"
placeholder="Digite o nome do objeto..."
class="focus:shadow-outline w-full appearance-none rounded-lg border border-gray-300 px-4 py-2.5 leading-tight text-gray-700 shadow-sm transition focus:border-blue-500 focus:ring-2 focus:ring-blue-200 focus:outline-none"
class="input input-bordered focus:input-primary w-full"
bind:value={searchQuery}
/>
{#if searchQuery.length > 0 && searchResults}
<div
class="absolute z-10 mt-2 w-full rounded-lg border border-gray-200 bg-white shadow-xl"
class="border-base-300 bg-base-100 rounded-box absolute z-20 mt-2 w-full overflow-hidden border shadow"
>
{#if searchResults.length === 0}
<div class="p-4 text-sm text-gray-500">Nenhum objeto encontrado.</div>
<div class="text-base-content/60 p-4 text-sm">Nenhum objeto encontrado.</div>
{:else}
<ul class="max-h-64 overflow-y-auto">
<ul class="menu max-h-64 overflow-y-auto p-2">
{#each searchResults as objeto (objeto._id)}
<li>
<button
type="button"
class="flex w-full items-center justify-between px-4 py-3 text-left transition hover:bg-blue-50"
class="flex items-center justify-between"
onclick={() => openItemModal(objeto)}
>
<span class="font-medium text-gray-800">{objeto.nome}</span>
<Plus size={16} class="text-blue-600" />
<span class="font-medium">{objeto.nome}</span>
<Plus class="h-4 w-4" />
</button>
</li>
{/each}
@@ -400,256 +343,161 @@
{/if}
</div>
{#if selectedItems.length > 0}
<div class="mt-6">
<h3 class="mb-3 text-sm font-semibold text-gray-700">
<div class="mt-6">
{#if selectedItems.length > 0}
<h3 class="text-base-content/70 mb-3 text-sm font-semibold">
Itens Selecionados ({selectedItems.length})
</h3>
<div class="space-y-3">
<div class="grid gap-3">
{#each selectedItems as item, index (index)}
<div
class="rounded-xl border border-gray-200 bg-gray-50 p-4 transition hover:shadow-md"
>
<GlassCard class="border-base-300" bodyClass="p-4">
<div class="flex items-start justify-between gap-4">
<div class="flex-1 space-y-2">
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<p class="font-semibold text-gray-900">{item.objeto.nome}</p>
<span
class={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold ${getModalidadeBadgeClasses(
item.modalidade
)}`}
>
{formatModalidade(item.modalidade)}
</span>
{#if item.ataNumero}
<span
class="inline-flex items-center rounded-full bg-green-100 px-2.5 py-0.5 text-xs font-medium text-green-800"
>
Ata {item.ataNumero}
</span>
{/if}
<p class="font-semibold">{item.objeto.nome}</p>
{#if item.acaoId}
<span
class="inline-flex items-center rounded-full bg-indigo-100 px-2.5 py-0.5 text-xs font-medium text-indigo-800"
<span class="badge badge-info badge-sm"
>Ação: {getAcaoNome(item.acaoId)}</span
>
Ação: {getAcaoNome(item.acaoId)}
</span>
{/if}
</div>
<div class="flex flex-wrap gap-x-4 gap-y-1 text-sm text-gray-600">
<span>
<strong>Qtd:</strong>
{item.quantidade}
{item.objeto.unidade}
</span>
<div class="text-base-content/70 mt-1 text-sm">
<span class="font-semibold">Qtd:</span>
{item.quantidade}
{item.objeto.unidade}
</div>
</div>
<div class="flex items-center gap-2">
<div class="flex items-center gap-1">
<button
type="button"
class="rounded-lg p-2 text-blue-600 transition hover:bg-blue-50"
class="btn btn-ghost btn-sm btn-square"
onclick={() => openDetails(item)}
aria-label="Ver detalhes"
title="Ver detalhes"
>
<Info size={18} />
<Info class="h-4 w-4" />
</button>
<button
type="button"
class="rounded-lg p-2 text-red-600 transition hover:bg-red-50"
class="btn btn-ghost btn-sm btn-square text-error"
onclick={() => removeItem(index)}
aria-label="Remover item"
title="Remover item"
>
<Trash2 size={18} />
<Trash2 class="h-4 w-4" />
</button>
</div>
</div>
</div>
</GlassCard>
{/each}
</div>
</div>
{:else}
<div class="mt-4 rounded-lg border-2 border-dashed border-gray-300 p-8 text-center">
<p class="text-sm text-gray-500">
Nenhum item adicionado. Use a busca acima para adicionar objetos ao pedido.
</p>
</div>
{/if}
</div>
<!-- Warnings Section -->
{#if hasMixedModalidades}
<div class="mb-3 rounded-lg border border-red-400 bg-red-50 px-4 py-3 text-sm text-red-800">
<p class="font-semibold">Modalidades diferentes detectadas</p>
<p>
Não é possível criar o pedido com itens de modalidades diferentes. Ajuste os itens para
usar uma única modalidade.
</p>
{:else}
<EmptyState
title="Nenhum item adicionado"
description="Use a busca acima para adicionar objetos ao pedido."
>
{#snippet icon()}
<Plus />
{/snippet}
</EmptyState>
{/if}
</div>
{/if}
</GlassCard>
{#if warning}
<div
class="rounded-lg border border-yellow-400 bg-yellow-50 px-4 py-3 text-sm text-yellow-800"
>
<p class="font-semibold">Aviso</p>
<p>{warning}</p>
<div class={`alert ${existingPedidos.length > 0 ? 'alert-warning' : 'alert-info'}`}>
<span>{warning}</span>
</div>
{/if}
{#if checking}
<p class="text-sm text-gray-500">Verificando pedidos existentes...</p>
{/if}
{#if existingPedidos.length > 0}
<div class="rounded-lg border border-yellow-300 bg-yellow-50 p-4">
<p class="mb-3 font-semibold text-yellow-900">Pedidos similares encontrados:</p>
<ul class="space-y-2">
{#each existingPedidos as pedido (pedido._id)}
<li class="flex flex-col rounded-lg bg-white px-4 py-3 shadow-sm">
<div class="flex items-center justify-between gap-3">
<div class="space-y-1">
<p class="text-sm font-medium text-gray-900">
Pedido {pedido.numeroSei || 'sem número SEI'}{formatStatus(pedido.status)}
</p>
{#if getFirstMatchingSelectedItem(pedido)}
{#key pedido._id}
{#if getFirstMatchingSelectedItem(pedido)}
<span
class={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold ${getModalidadeBadgeClasses(
getFirstMatchingSelectedItem(pedido).modalidade
)}`}
>
Modalidade:{' '}
{formatModalidade(getFirstMatchingSelectedItem(pedido).modalidade)}
</span>
{/if}
{/key}
{/if}
{#if getMatchingInfo(pedido)}
<p class="mt-1 text-xs text-blue-700">
{getMatchingInfo(pedido)}
</p>
{/if}
</div>
<a
href={buildPedidoHref(pedido)}
class="text-sm font-medium text-blue-600 hover:text-blue-800"
>
Abrir
</a>
</div>
</li>
{/each}
</ul>
<div class="text-base-content/60 flex items-center gap-2 text-sm">
<span class="loading loading-spinner loading-sm"></span>
Verificando pedidos existentes...
</div>
{/if}
<!-- Action Buttons -->
<div class="flex items-center justify-end gap-3 border-t pt-6">
<a
href={resolve('/pedidos')}
class="rounded-lg bg-gray-200 px-6 py-2.5 font-semibold text-gray-800 transition hover:bg-gray-300"
>
Cancelar
</a>
{#if existingPedidos.length > 0}
<GlassCard>
<h2 class="text-lg font-semibold">Pedidos similares encontrados</h2>
<div class="mt-4 space-y-2">
{#each existingPedidos as pedido (pedido._id)}
<div
class="border-base-300 bg-base-100 flex items-start justify-between gap-3 rounded-lg border p-4"
>
<div class="min-w-0">
<p class="font-medium">
Pedido {pedido.numeroSei || 'sem número SEI'}{formatStatus(pedido.status)}
</p>
{#if getMatchingInfo(pedido)}
<p class="text-info mt-1 text-xs">{getMatchingInfo(pedido)}</p>
{/if}
</div>
<a href={resolve(buildPedidoHref(pedido))} class="btn btn-ghost btn-sm">Abrir</a>
</div>
{/each}
</div>
</GlassCard>
{/if}
<div class="flex items-center justify-end gap-3">
<a href={resolve('/pedidos')} class="btn">Cancelar</a>
<button
type="submit"
disabled={creating || selectedItems.length === 0 || hasMixedModalidades}
class="rounded-lg bg-blue-600 px-6 py-2.5 font-semibold text-white shadow-md transition hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
disabled={creating || selectedItems.length === 0}
class="btn btn-primary"
>
{creating ? 'Criando...' : 'Criar Pedido'}
{#if creating}
<span class="loading loading-spinner loading-sm"></span>
{/if}
Criar Pedido
</button>
</div>
</form>
</div>
<!-- Item Configuration Modal -->
{#if showItemModal && itemConfig.objeto}
<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-lg rounded-xl bg-white p-6 shadow-2xl">
<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={closeItemModal}
class="absolute top-4 right-4 rounded-lg p-1 text-gray-400 transition hover:bg-gray-100 hover:text-gray-600"
aria-label="Fechar"
aria-label="Fechar modal"
>
<X size={24} />
<X class="h-5 w-5" />
</button>
<h3 class="mb-4 text-xl font-bold text-gray-900">Configurar Item</h3>
<h3 class="text-lg font-bold">Configurar Item</h3>
<div class="mb-6 rounded-lg bg-blue-50 p-4">
<p class="font-semibold text-gray-900">{itemConfig.objeto.nome}</p>
<p class="text-sm text-gray-600">Unidade: {itemConfig.objeto.unidade}</p>
<p class="mt-1 text-xs text-gray-500">
<div class="border-base-300 bg-base-200/30 mt-4 rounded-lg border p-4">
<p class="font-semibold">{itemConfig.objeto.nome}</p>
<p class="text-base-content/70 text-sm">Unidade: {itemConfig.objeto.unidade}</p>
<p class="text-base-content/60 mt-1 text-xs">
Valor estimado: {itemConfig.objeto.valorEstimado}
</p>
</div>
<div class="space-y-4">
<div class="mt-4 grid gap-4">
<div>
<label class="mb-2 block text-sm font-medium text-gray-700" for="quantidade">
Quantidade
<label class="label py-0" for="quantidade">
<span class="label-text font-semibold">Quantidade</span>
</label>
<input
id="quantidade"
type="number"
min="1"
class="w-full rounded-lg border border-gray-300 px-4 py-2.5 transition focus:border-blue-500 focus:ring-2 focus:ring-blue-200 focus:outline-none"
class="input input-bordered focus:input-primary w-full"
bind:value={itemConfig.quantidade}
/>
</div>
<div>
<label class="mb-2 block text-sm font-medium text-gray-700" for="modalidade">
Modalidade
</label>
<select
id="modalidade"
class="w-full rounded-lg border border-gray-300 px-4 py-2.5 transition focus:border-blue-500 focus:ring-2 focus:ring-blue-200 focus:outline-none"
bind:value={itemConfig.modalidade}
>
<option value="consumo">Consumo</option>
<option value="dispensa">Dispensa</option>
<option value="inexgibilidade">Inexigibilidade</option>
<option value="adesao">Adesão</option>
</select>
</div>
{#if availableAtas.length > 0}
<div class="rounded-lg border-2 border-green-200 bg-green-50 p-4">
<div class="mb-2 flex items-center gap-2">
<span class="rounded-full bg-green-600 px-2 py-0.5 text-xs font-bold text-white">
{availableAtas.length}
{availableAtas.length === 1 ? 'Ata' : 'Atas'}
</span>
<span class="text-sm font-semibold text-green-900">disponível para este objeto</span
>
</div>
<label class="mb-2 block text-sm font-medium text-gray-700" for="itemAta">
Selecionar Ata (Opcional)
</label>
<select
id="itemAta"
class="w-full rounded-lg border border-green-300 bg-white px-4 py-2.5 transition focus:border-green-500 focus:ring-2 focus:ring-green-200 focus:outline-none"
bind:value={itemConfig.ataId}
>
<option value="">Nenhuma</option>
{#each availableAtas as ata (ata._id)}
<option value={ata._id}>Ata {ata.numero} (SEI: {ata.numeroSei})</option>
{/each}
</select>
</div>
{/if}
<div>
<label class="mb-2 block text-sm font-medium text-gray-700" for="itemAcao">
Ação (Opcional)
<label class="label py-0" for="itemAcao">
<span class="label-text font-semibold">Ação (Opcional)</span>
</label>
<select
id="itemAcao"
class="w-full rounded-lg border border-gray-300 px-4 py-2.5 transition focus:border-blue-500 focus:ring-2 focus:ring-blue-200 focus:outline-none"
class="select select-bordered focus:select-primary w-full"
bind:value={itemConfig.acaoId}
>
<option value="">Selecione uma ação...</option>
@@ -660,97 +508,75 @@
</div>
</div>
<div class="mt-6 flex justify-end gap-3">
<button
type="button"
onclick={closeItemModal}
class="rounded-lg bg-gray-200 px-5 py-2.5 font-semibold text-gray-800 transition hover:bg-gray-300"
<div class="modal-action">
<button type="button" class="btn" onclick={closeItemModal}>Cancelar</button>
<button type="button" class="btn btn-primary" onclick={confirmAddItem}
>Adicionar Item</button
>
Cancelar
</button>
<button
type="button"
onclick={confirmAddItem}
class="rounded-lg bg-blue-600 px-5 py-2.5 font-semibold text-white shadow-md transition hover:bg-blue-700"
>
Adicionar Item
</button>
</div>
</div>
<button
type="button"
class="modal-backdrop"
onclick={closeItemModal}
aria-label="Fechar modal"
></button>
</div>
{/if}
<!-- Details Modal -->
{#if showDetailsModal && detailsItem}
<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-lg rounded-xl bg-white p-6 shadow-2xl">
<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}
class="absolute top-4 right-4 rounded-lg p-1 text-gray-400 transition hover:bg-gray-100 hover:text-gray-600"
aria-label="Fechar"
aria-label="Fechar modal"
>
<X size={24} />
<X class="h-5 w-5" />
</button>
<h3 class="mb-4 text-xl font-bold text-gray-900">Detalhes do Item</h3>
<h3 class="text-lg font-bold">Detalhes do Item</h3>
<div class="space-y-4">
<div class="rounded-lg bg-gray-50 p-4">
<h4 class="mb-2 font-semibold text-gray-800">Objeto</h4>
<p class="text-gray-700"><strong>Nome:</strong> {detailsItem.objeto.nome}</p>
<p class="text-gray-700"><strong>Unidade:</strong> {detailsItem.objeto.unidade}</p>
<p class="text-gray-700">
<div class="mt-4 space-y-4">
<div class="border-base-300 bg-base-200/30 rounded-lg border p-4">
<h4 class="font-semibold">Objeto</h4>
<p class="text-base-content/70 mt-2 text-sm">
<strong>Nome:</strong>
{detailsItem.objeto.nome}
</p>
<p class="text-base-content/70 text-sm">
<strong>Unidade:</strong>
{detailsItem.objeto.unidade}
</p>
<p class="text-base-content/70 text-sm">
<strong>Valor Estimado:</strong>
{detailsItem.objeto.valorEstimado}
</p>
</div>
<div class="rounded-lg bg-gray-50 p-4">
<h4 class="mb-2 font-semibold text-gray-800">Pedido</h4>
<p class="text-gray-700"><strong>Quantidade:</strong> {detailsItem.quantidade}</p>
<p class="text-gray-700"><strong>Modalidade:</strong> {detailsItem.modalidade}</p>
<div class="border-base-300 bg-base-200/30 rounded-lg border p-4">
<h4 class="font-semibold">Pedido</h4>
<p class="text-base-content/70 mt-2 text-sm">
<strong>Quantidade:</strong>
{detailsItem.quantidade}
</p>
{#if detailsItem.acaoId}
<p class="text-gray-700">
<p class="text-base-content/70 text-sm">
<strong>Ação:</strong>
{getAcaoNome(detailsItem.acaoId)}
</p>
{/if}
</div>
{#if detailsItem.ata}
<div class="rounded-lg border border-green-100 bg-green-50 p-4">
<h4 class="mb-2 font-semibold text-green-900">Ata de Registro de Preços</h4>
<p class="text-green-800"><strong>Número:</strong> {detailsItem.ata.numero}</p>
<p class="text-green-800">
<strong>Processo SEI:</strong>
{detailsItem.ata.numeroSei}
</p>
{#if detailsItem.ata.dataInicio}
<p class="text-green-800">
<strong>Vigência:</strong>
{detailsItem.ata.dataInicio} até {detailsItem.ata.dataFim || 'Indefinido'}
</p>
{/if}
</div>
{:else}
<div class="rounded-lg bg-gray-50 p-4">
<p class="text-sm text-gray-500 italic">Nenhuma Ata vinculada a este item.</p>
</div>
{/if}
</div>
<div class="mt-6 flex justify-end">
<button
type="button"
onclick={closeDetails}
class="rounded-lg bg-blue-600 px-5 py-2.5 font-semibold text-white shadow-md transition hover:bg-blue-700"
>
Fechar
</button>
<div class="modal-action">
<button type="button" class="btn" onclick={closeDetails}>Fechar</button>
</div>
</div>
<button type="button" class="modal-backdrop" onclick={closeDetails} aria-label="Fechar modal"
></button>
</div>
{/if}
</div>
</PageShell>

View File

@@ -0,0 +1,3 @@
export const load = async ({ parent }) => {
await parent();
};

View File

@@ -0,0 +1,310 @@
<script lang="ts">
import { api } from '@sgse-app/backend/convex/_generated/api';
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
import Breadcrumbs from '$lib/components/layout/Breadcrumbs.svelte';
import PageHeader from '$lib/components/layout/PageHeader.svelte';
import PageShell from '$lib/components/layout/PageShell.svelte';
import TableCard from '$lib/components/ui/TableCard.svelte';
import { useConvexClient, useQuery } from 'convex-svelte';
import { ClipboardList, Eye, Plus, X } from 'lucide-svelte';
import { resolve } from '$app/paths';
import { goto } from '$app/navigation';
import { toast } from 'svelte-sonner';
const client = useConvexClient();
const planejamentosQuery = useQuery(api.planejamentos.list, {});
const funcionariosQuery = useQuery(api.funcionarios.getAll, {});
const acoesQuery = useQuery(api.acoes.list, {});
let planejamentos = $derived(planejamentosQuery.data || []);
function formatStatus(status: string) {
switch (status) {
case 'rascunho':
return 'Rascunho';
case 'gerado':
return 'Gerado';
case 'cancelado':
return 'Cancelado';
default:
return status;
}
}
function getStatusBadgeClass(status: string) {
switch (status) {
case 'rascunho':
return 'badge-ghost';
case 'gerado':
return 'badge-success';
case 'cancelado':
return 'badge-error';
default:
return 'badge-ghost';
}
}
function formatDateYMD(ymd: string) {
// ymd: yyyy-MM-dd
const [y, m, d] = ymd.split('-');
if (!y || !m || !d) return ymd;
return `${d}/${m}/${y}`;
}
// Create modal
let showCreate = $state(false);
let creating = $state(false);
let form = $state({
titulo: '',
descricao: '',
data: '',
responsavelId: '' as string,
acaoId: '' as string
});
function openCreate() {
form = { titulo: '', descricao: '', data: '', responsavelId: '', acaoId: '' };
showCreate = true;
}
function closeCreate() {
showCreate = false;
}
async function handleCreate() {
if (!form.titulo.trim()) return toast.error('Informe um título.');
if (!form.descricao.trim()) return toast.error('Informe uma descrição.');
if (!form.data.trim()) return toast.error('Informe uma data.');
if (!form.responsavelId) return toast.error('Selecione um responsável.');
creating = true;
try {
const id = await client.mutation(api.planejamentos.create, {
titulo: form.titulo,
descricao: form.descricao,
data: form.data,
responsavelId: form.responsavelId as Id<'funcionarios'>,
acaoId: form.acaoId ? (form.acaoId as Id<'acoes'>) : undefined
});
toast.success('Planejamento criado.');
showCreate = false;
await goto(resolve(`/pedidos/planejamento/${id}`));
} catch (e) {
toast.error((e as Error).message);
} finally {
creating = false;
}
}
</script>
<PageShell>
<Breadcrumbs
items={[
{ label: 'Dashboard', href: resolve('/') },
{ label: 'Pedidos', href: resolve('/pedidos') },
{ label: 'Planejamento' }
]}
/>
<PageHeader
title="Planejamento de Pedidos"
subtitle="Crie e acompanhe planejamentos antes de gerar pedidos"
>
{#snippet icon()}
<ClipboardList strokeWidth={2} />
{/snippet}
{#snippet actions()}
<button
type="button"
class="btn btn-primary gap-2 shadow-md transition-all hover:shadow-lg"
onclick={openCreate}
>
<Plus class="h-5 w-5" strokeWidth={2} />
Novo planejamento
</button>
{/snippet}
</PageHeader>
{#if planejamentosQuery.isLoading}
<div class="flex items-center justify-center py-10">
<span class="loading loading-spinner loading-lg"></span>
</div>
{:else if planejamentosQuery.error}
<div class="alert alert-error">
<span>{planejamentosQuery.error.message}</span>
</div>
{:else}
<TableCard>
<table class="table-zebra table w-full">
<thead>
<tr>
<th class="text-base-content border-base-400 border-b font-bold whitespace-nowrap"
>Título</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 font-bold whitespace-nowrap"
>Responsável</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"
>Status</th
>
<th
class="text-base-content border-base-400 border-b text-right font-bold whitespace-nowrap"
>Ações</th
>
</tr>
</thead>
<tbody>
{#if planejamentos.length === 0}
<tr>
<td colspan="6" 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 planejamento encontrado</p>
<p class="text-sm">Clique em “Novo planejamento” para criar o primeiro.</p>
</div>
</td>
</tr>
{:else}
{#each planejamentos as p (p._id)}
<tr class="hover:bg-base-200/50 transition-colors">
<td class="font-medium whitespace-nowrap">{p.titulo}</td>
<td class="text-base-content/70 whitespace-nowrap">{formatDateYMD(p.data)}</td>
<td class="text-base-content/70 whitespace-nowrap">{p.responsavelNome}</td>
<td class="text-base-content/70 whitespace-nowrap">{p.acaoNome || '-'}</td>
<td class="whitespace-nowrap">
<span class="badge badge-sm {getStatusBadgeClass(p.status)}">
{formatStatus(p.status)}
</span>
</td>
<td class="text-right whitespace-nowrap">
<a
href={resolve(`/pedidos/planejamento/${p._id}`)}
class="btn btn-ghost btn-sm gap-2"
aria-label="Abrir planejamento"
>
<Eye class="h-4 w-4" />
Abrir
</a>
</td>
</tr>
{/each}
{/if}
</tbody>
</table>
</TableCard>
{/if}
{#if showCreate}
<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={closeCreate}
aria-label="Fechar modal"
disabled={creating}
>
<X class="h-5 w-5" />
</button>
<h3 class="text-lg font-bold">Novo planejamento</h3>
<div class="mt-6 grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control w-full md:col-span-2">
<label class="label" for="titulo">
<span class="label-text font-semibold">Título</span>
</label>
<input
id="titulo"
type="text"
class="input input-bordered focus:input-primary w-full"
bind:value={form.titulo}
disabled={creating}
/>
</div>
<div class="form-control w-full md:col-span-2">
<label class="label" for="descricao">
<span class="label-text font-semibold">Descrição</span>
</label>
<textarea
id="descricao"
class="textarea textarea-bordered focus:textarea-primary w-full"
rows="4"
bind:value={form.descricao}
disabled={creating}
></textarea>
</div>
<div class="form-control w-full">
<label class="label" for="data">
<span class="label-text font-semibold">Data</span>
</label>
<input
id="data"
type="date"
class="input input-bordered focus:input-primary w-full"
bind:value={form.data}
disabled={creating}
/>
</div>
<div class="form-control w-full">
<label class="label" for="responsavel">
<span class="label-text font-semibold">Responsável</span>
</label>
<select
id="responsavel"
class="select select-bordered focus:select-primary w-full"
bind:value={form.responsavelId}
disabled={creating || funcionariosQuery.isLoading}
>
<option value="">Selecione...</option>
{#each funcionariosQuery.data || [] as f (f._id)}
<option value={f._id}>{f.nome}</option>
{/each}
</select>
</div>
<div class="form-control w-full md:col-span-2">
<label class="label" for="acao">
<span class="label-text font-semibold">Ação (opcional)</span>
</label>
<select
id="acao"
class="select select-bordered focus:select-primary w-full"
bind:value={form.acaoId}
disabled={creating || acoesQuery.isLoading}
>
<option value="">Nenhuma</option>
{#each acoesQuery.data || [] as a (a._id)}
<option value={a._id}>{a.nome}</option>
{/each}
</select>
</div>
</div>
<div class="modal-action">
<button type="button" class="btn" onclick={closeCreate} disabled={creating}
>Cancelar</button
>
<button type="button" class="btn btn-primary" onclick={handleCreate} disabled={creating}>
{#if creating}
<span class="loading loading-spinner loading-sm"></span>
{/if}
{creating ? 'Criando...' : 'Criar'}
</button>
</div>
</div>
<button type="button" class="modal-backdrop" onclick={closeCreate} aria-label="Fechar modal"
></button>
</div>
{/if}
</PageShell>

View File

@@ -0,0 +1,3 @@
export const load = async ({ parent }) => {
await parent();
};

View File

@@ -0,0 +1,900 @@
<script lang="ts">
import { api } from '@sgse-app/backend/convex/_generated/api';
import type { Doc, Id } from '@sgse-app/backend/convex/_generated/dataModel';
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 { useConvexClient, useQuery } from 'convex-svelte';
import { page } from '$app/state';
import { resolve } from '$app/paths';
import { toast } from 'svelte-sonner';
import { Plus, Trash2, X, Save, Edit } from 'lucide-svelte';
const client = useConvexClient();
const planejamentoId = $derived(page.params.id as Id<'planejamentosPedidos'>);
const planejamentoQuery = $derived.by(() =>
useQuery(api.planejamentos.get, { id: planejamentoId })
);
const itemsQuery = $derived.by(() => useQuery(api.planejamentos.listItems, { planejamentoId }));
const pedidosQuery = $derived.by(() =>
useQuery(api.planejamentos.listPedidos, { planejamentoId })
);
const funcionariosQuery = useQuery(api.funcionarios.getAll, {});
const acoesQuery = useQuery(api.acoes.list, {});
let planejamento = $derived(planejamentoQuery.data);
let items = $derived(itemsQuery.data || []);
let pedidosLinks = $derived(pedidosQuery.data || []);
const isRascunho = $derived(planejamento?.status === 'rascunho');
function formatStatus(status: string) {
switch (status) {
case 'rascunho':
return 'Rascunho';
case 'gerado':
return 'Gerado';
case 'cancelado':
return 'Cancelado';
default:
return status;
}
}
function getStatusBadgeClass(status: string) {
switch (status) {
case 'rascunho':
return 'badge-ghost';
case 'gerado':
return 'badge-success';
case 'cancelado':
return 'badge-error';
default:
return 'badge-ghost';
}
}
function formatPedidoStatus(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;
}
}
function getPedidoBadgeClass(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';
}
}
// --- Header editing ---
let editingHeader = $state(false);
let headerForm = $state({
titulo: '',
descricao: '',
data: '',
responsavelId: '' as string,
acaoId: '' as string
});
let savingHeader = $state(false);
function syncHeaderFormFromPlanejamento() {
if (!planejamento) return;
headerForm = {
titulo: planejamento.titulo,
descricao: planejamento.descricao,
data: planejamento.data,
responsavelId: planejamento.responsavelId as unknown as string,
acaoId: planejamento.acaoId ? (planejamento.acaoId as unknown as string) : ''
};
}
function startEditHeader() {
if (!isRascunho) return;
syncHeaderFormFromPlanejamento();
editingHeader = true;
}
function cancelEditHeader() {
editingHeader = false;
syncHeaderFormFromPlanejamento();
}
async function saveHeader() {
if (!planejamento) return;
if (!headerForm.titulo.trim()) return toast.error('Informe um título.');
if (!headerForm.descricao.trim()) return toast.error('Informe uma descrição.');
if (!headerForm.data.trim()) return toast.error('Informe uma data.');
if (!headerForm.responsavelId) return toast.error('Selecione um responsável.');
savingHeader = true;
try {
await client.mutation(api.planejamentos.update, {
id: planejamentoId,
titulo: headerForm.titulo,
descricao: headerForm.descricao,
data: headerForm.data,
responsavelId: headerForm.responsavelId as Id<'funcionarios'>,
acaoId: headerForm.acaoId ? (headerForm.acaoId as Id<'acoes'>) : null
});
toast.success('Planejamento atualizado.');
editingHeader = false;
} catch (e) {
toast.error((e as Error).message);
} finally {
savingHeader = false;
}
}
// --- Add item (search + modal) ---
let searchQuery = $state('');
const searchResultsQuery = useQuery(api.objetos.search, () => ({ query: searchQuery }));
let searchResults = $derived(searchResultsQuery.data || []);
type SelectedObjeto = Doc<'objetos'>;
let showAddItemModal = $state(false);
let addItemConfig = $state<{
objeto: SelectedObjeto | null;
quantidade: number;
valorEstimado: string;
numeroDfd: string;
}>({
objeto: null,
quantidade: 1,
valorEstimado: '',
numeroDfd: ''
});
let addingItem = $state(false);
function openAddItemModal(objeto: SelectedObjeto) {
addItemConfig = {
objeto,
quantidade: 1,
valorEstimado: objeto.valorEstimado,
numeroDfd: ''
};
showAddItemModal = true;
searchQuery = '';
}
function closeAddItemModal() {
showAddItemModal = false;
addItemConfig.objeto = null;
}
async function confirmAddItem() {
if (!addItemConfig.objeto) return;
if (!isRascunho)
return toast.error('Não é possível adicionar itens em um planejamento não rascunho.');
if (!Number.isFinite(addItemConfig.quantidade) || addItemConfig.quantidade <= 0) {
return toast.error('Quantidade inválida.');
}
if (!addItemConfig.valorEstimado.trim()) return toast.error('Informe o valor estimado.');
addingItem = true;
try {
await client.mutation(api.planejamentos.addItem, {
planejamentoId,
objetoId: addItemConfig.objeto._id,
quantidade: addItemConfig.quantidade,
valorEstimado: addItemConfig.valorEstimado,
numeroDfd: addItemConfig.numeroDfd.trim() || undefined
});
toast.success('Item adicionado.');
closeAddItemModal();
} catch (e) {
toast.error((e as Error).message);
} finally {
addingItem = false;
}
}
// --- Inline item edits ---
async function updateItemField(
itemId: Id<'planejamentoItens'>,
patch: { numeroDfd?: string | null; quantidade?: number; valorEstimado?: string }
) {
if (!isRascunho) return;
try {
await client.mutation(api.planejamentos.updateItem, { itemId, ...patch });
} catch (e) {
toast.error((e as Error).message);
}
}
async function removeItem(itemId: Id<'planejamentoItens'>) {
if (!isRascunho) return;
try {
await client.mutation(api.planejamentos.removeItem, { itemId });
toast.success('Item removido.');
} catch (e) {
toast.error((e as Error).message);
}
}
// --- Grouping ---
let pedidosByDfd = $derived.by(() => {
const map: Record<string, (typeof pedidosLinks)[number]> = {};
for (const p of pedidosLinks) map[p.numeroDfd] = p;
return map;
});
let grouped = $derived.by(() => {
const groups: Record<string, typeof items> = {};
const semDfd: typeof items = [];
for (const it of items) {
const dfd = (it.numeroDfd || '').trim();
if (!dfd) {
semDfd.push(it);
continue;
}
if (!groups[dfd]) groups[dfd] = [];
groups[dfd].push(it);
}
const out: Array<{ key: string; label: string; items: typeof items; isSemDfd: boolean }> = [];
if (semDfd.length > 0)
out.push({ key: '__sem_dfd__', label: 'Sem DFD', items: semDfd, isSemDfd: true });
const keys = Object.keys(groups).sort((a, b) => a.localeCompare(b));
for (const k of keys) {
out.push({ key: k, label: `DFD ${k}`, items: groups[k], isSemDfd: false });
}
return out;
});
let itensSemDfdCount = $derived(grouped.find((g) => g.isSemDfd)?.items.length ?? 0);
let dfdsParaGerar = $derived.by(() => grouped.filter((g) => !g.isSemDfd).map((g) => g.key));
// --- Gerar pedidos modal ---
let showGerarModal = $state(false);
let seiByDfd = $state<Record<string, string>>({});
let gerando = $state(false);
const canGerar = $derived(
isRascunho && items.length > 0 && itensSemDfdCount === 0 && dfdsParaGerar.length > 0
);
function openGerarModal() {
if (!canGerar) {
if (items.length === 0) toast.error('Adicione itens antes de gerar pedidos.');
else if (itensSemDfdCount > 0) toast.error('Atribua um DFD a todos os itens antes de gerar.');
return;
}
const initial: Record<string, string> = {};
for (const dfd of dfdsParaGerar) initial[dfd] = '';
seiByDfd = initial;
showGerarModal = true;
}
function closeGerarModal() {
showGerarModal = false;
seiByDfd = {};
}
async function confirmarGeracao() {
if (!canGerar) return;
for (const dfd of dfdsParaGerar) {
if (!seiByDfd[dfd]?.trim()) {
return toast.error(`Informe o número SEI para o DFD ${dfd}.`);
}
}
gerando = true;
try {
await client.mutation(api.planejamentos.gerarPedidosPorDfd, {
planejamentoId,
dfds: dfdsParaGerar.map((dfd) => ({ numeroDfd: dfd, numeroSei: seiByDfd[dfd] }))
});
toast.success('Pedidos gerados.');
closeGerarModal();
} catch (e) {
toast.error((e as Error).message);
} finally {
gerando = false;
}
}
</script>
<PageShell>
<Breadcrumbs
items={[
{ label: 'Dashboard', href: resolve('/') },
{ label: 'Pedidos', href: resolve('/pedidos') },
{ label: 'Planejamento', href: resolve('/pedidos/planejamento') },
{ label: planejamento?.titulo ?? 'Detalhes' }
]}
/>
{#if planejamentoQuery.isLoading}
<div class="flex items-center justify-center py-10">
<span class="loading loading-spinner loading-lg"></span>
</div>
{:else if planejamentoQuery.error}
<div class="alert alert-error">
<span>{planejamentoQuery.error.message}</span>
</div>
{:else if planejamento}
<GlassCard class="mb-6">
<div class="flex items-start justify-between gap-4">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-3">
<h1 class="text-primary truncate text-2xl font-bold">{planejamento.titulo}</h1>
<span class="badge badge-sm {getStatusBadgeClass(planejamento.status)}">
{formatStatus(planejamento.status)}
</span>
</div>
<p class="text-base-content/70 mt-2 text-sm whitespace-pre-wrap">
{planejamento.descricao}
</p>
<div class="mt-4 grid gap-3 md:grid-cols-3">
<div class="bg-base-200/50 rounded-lg p-3">
<div class="text-base-content/60 text-xs font-semibold">Data</div>
<div class="text-base-content text-sm">{planejamento.data}</div>
</div>
<div class="bg-base-200/50 rounded-lg p-3">
<div class="text-base-content/60 text-xs font-semibold">Responsável</div>
<div class="text-base-content text-sm">{planejamento.responsavelNome}</div>
</div>
<div class="bg-base-200/50 rounded-lg p-3">
<div class="text-base-content/60 text-xs font-semibold">Ação</div>
<div class="text-base-content text-sm">{planejamento.acaoNome || '-'}</div>
</div>
</div>
</div>
<div class="flex shrink-0 flex-col items-end gap-2">
{#if isRascunho}
{#if editingHeader}
<div class="flex gap-2">
<button
type="button"
class="btn btn-primary btn-sm gap-2"
onclick={saveHeader}
disabled={savingHeader}
>
{#if savingHeader}
<span class="loading loading-spinner loading-sm"></span>
{:else}
<Save class="h-4 w-4" />
{/if}
Salvar
</button>
<button
type="button"
class="btn btn-ghost btn-sm"
onclick={cancelEditHeader}
disabled={savingHeader}
>
Cancelar
</button>
</div>
{:else}
<button type="button" class="btn btn-ghost btn-sm gap-2" onclick={startEditHeader}>
<Edit class="h-4 w-4" />
Editar
</button>
{/if}
{/if}
</div>
</div>
{#if editingHeader}
<div class="border-base-300 bg-base-100 mt-5 rounded-lg border p-4">
<div class="grid gap-4 md:grid-cols-2">
<div class="md:col-span-2">
<label class="label" for="eh_titulo">
<span class="label-text font-semibold">Título</span>
</label>
<input
id="eh_titulo"
type="text"
class="input input-bordered focus:input-primary w-full"
bind:value={headerForm.titulo}
disabled={savingHeader}
/>
</div>
<div class="md:col-span-2">
<label class="label" for="eh_descricao">
<span class="label-text font-semibold">Descrição</span>
</label>
<textarea
id="eh_descricao"
rows="4"
class="textarea textarea-bordered focus:textarea-primary w-full"
bind:value={headerForm.descricao}
disabled={savingHeader}
></textarea>
</div>
<div>
<label class="label" for="eh_data">
<span class="label-text font-semibold">Data</span>
</label>
<input
id="eh_data"
type="date"
class="input input-bordered focus:input-primary w-full"
bind:value={headerForm.data}
disabled={savingHeader}
/>
</div>
<div>
<label class="label" for="eh_resp">
<span class="label-text font-semibold">Responsável</span>
</label>
<select
id="eh_resp"
class="select select-bordered focus:select-primary w-full"
bind:value={headerForm.responsavelId}
disabled={savingHeader || funcionariosQuery.isLoading}
>
<option value="">Selecione...</option>
{#each funcionariosQuery.data || [] as f (f._id)}
<option value={f._id}>{f.nome}</option>
{/each}
</select>
</div>
<div class="md:col-span-2">
<label class="label" for="eh_acao">
<span class="label-text font-semibold">Ação (opcional)</span>
</label>
<select
id="eh_acao"
class="select select-bordered focus:select-primary w-full"
bind:value={headerForm.acaoId}
disabled={savingHeader || acoesQuery.isLoading}
>
<option value="">Nenhuma</option>
{#each acoesQuery.data || [] as a (a._id)}
<option value={a._id}>{a.nome}</option>
{/each}
</select>
</div>
</div>
</div>
{/if}
</GlassCard>
<!-- Itens -->
<GlassCard class="mb-6" bodyClass="p-0">
<div class="border-base-300 flex items-center justify-between border-b px-6 py-4">
<div>
<h2 class="text-lg font-semibold">Itens</h2>
{#if itensSemDfdCount > 0}
<p class="text-warning mt-1 text-xs">
{itensSemDfdCount} item(ns) sem DFD. Para gerar pedidos, todos os itens precisam ter DFD.
</p>
{/if}
</div>
<div class="flex items-center gap-2">
{#if isRascunho}
<button
type="button"
onclick={openGerarModal}
disabled={!canGerar}
class="btn btn-primary btn-sm"
>
Gerar pedidos
</button>
{/if}
{#if isRascunho}
<div class="relative">
<input
type="text"
class="input input-bordered focus:input-primary input-sm w-64"
placeholder="Buscar objetos..."
bind:value={searchQuery}
/>
{#if searchQuery.length > 0}
<div
class="border-base-300 bg-base-100 absolute z-10 mt-2 w-full rounded-lg border shadow-xl"
>
{#if searchResultsQuery.isLoading}
<div class="text-base-content/60 p-3 text-sm">Buscando...</div>
{:else if searchResults.length === 0}
<div class="text-base-content/60 p-3 text-sm">Nenhum objeto encontrado.</div>
{:else}
<ul class="max-h-64 overflow-y-auto">
{#each searchResults as o (o._id)}
<li>
<button
type="button"
class="hover:bg-base-200/50 flex w-full items-center justify-between px-4 py-3 text-left transition-colors"
onclick={() => openAddItemModal(o)}
>
<span class="font-medium">{o.nome}</span>
<Plus class="text-primary h-4 w-4" />
</button>
</li>
{/each}
</ul>
{/if}
</div>
{/if}
</div>
{/if}
</div>
</div>
<div class="p-6">
{#if itemsQuery.isLoading}
<p class="text-base-content/60 text-sm">Carregando itens...</p>
{:else if items.length === 0}
<p class="text-base-content/60 text-sm">Nenhum item adicionado.</p>
{:else}
<div class="space-y-4">
{#each grouped as group (group.key)}
{@const dfd = group.isSemDfd ? null : group.key}
{@const pedidoLink = dfd ? pedidosByDfd[dfd] : null}
<GlassCard bodyClass="p-0">
<div
class="border-base-300 bg-base-200/50 flex items-center justify-between border-b px-6 py-4"
>
<div class="flex flex-col gap-1">
<div class="font-semibold">{group.label}</div>
{#if pedidoLink?.pedido}
<a
href={resolve(`/pedidos/${pedidoLink.pedido._id}`)}
class="text-primary text-xs hover:underline"
>
Pedido: {pedidoLink.pedido.numeroSei || pedidoLink.pedido._id} — {formatPedidoStatus(
pedidoLink.pedido.status
)}
</a>
{/if}
</div>
<div class="text-base-content/60 text-xs">{group.items.length} item(ns)</div>
</div>
<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"
>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"
>DFD</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 it (it._id)}
<tr class="hover:bg-base-200/50 transition-colors">
<td class="whitespace-nowrap">
{it.objetoNome}
{#if it.objetoUnidade}
<span class="text-base-content/60 ml-2 text-xs">
({it.objetoUnidade})
</span>
{/if}
</td>
<td class="whitespace-nowrap">
{#if isRascunho}
<input
type="number"
min="1"
class="input input-bordered input-sm w-24"
value={it.quantidade}
onchange={(e) =>
updateItemField(it._id, {
quantidade: parseInt(e.currentTarget.value, 10) || 1
})}
/>
{:else}
{it.quantidade}
{/if}
</td>
<td class="whitespace-nowrap">
{#if isRascunho}
<input
type="text"
class="input input-bordered input-sm w-40"
value={it.valorEstimado}
onblur={(e) =>
updateItemField(it._id, { valorEstimado: e.currentTarget.value })}
/>
{:else}
{it.valorEstimado}
{/if}
</td>
<td class="whitespace-nowrap">
{#if isRascunho}
<input
type="text"
class="input input-bordered input-sm w-32"
value={it.numeroDfd || ''}
placeholder="(opcional)"
onblur={(e) => {
const v = e.currentTarget.value.trim();
updateItemField(it._id, { numeroDfd: v ? v : null });
}}
/>
{:else}
{it.numeroDfd || '-'}
{/if}
</td>
<td class="text-right whitespace-nowrap">
{#if isRascunho}
<button
type="button"
class="btn btn-ghost btn-sm text-error"
onclick={() => removeItem(it._id)}
title="Remover item"
>
<Trash2 class="h-4 w-4" />
</button>
{:else}
<span class="text-base-content/50 text-xs">—</span>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</GlassCard>
{/each}
</div>
{/if}
</div>
</GlassCard>
<!-- Pedidos -->
<GlassCard bodyClass="p-0">
<div class="border-base-300 border-b px-6 py-4">
<h2 class="text-lg font-semibold">Pedidos gerados</h2>
</div>
<div class="p-6">
{#if pedidosQuery.isLoading}
<p class="text-base-content/60 text-sm">Carregando pedidos...</p>
{:else if pedidosLinks.length === 0}
<p class="text-base-content/60 text-sm">Nenhum pedido gerado ainda.</p>
{:else}
<div class="space-y-4">
{#each pedidosLinks as row (row._id)}
<div class="border-base-300 rounded-lg border p-4">
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="space-y-1">
<div class="text-sm font-semibold">DFD: {row.numeroDfd}</div>
{#if row.pedido}
<a
class="text-primary text-sm hover:underline"
href={resolve(`/pedidos/${row.pedido._id}`)}
>
Pedido: {row.pedido.numeroSei || row.pedido._id}
</a>
{/if}
</div>
{#if row.pedido}
<span class="badge badge-sm {getPedidoBadgeClass(row.pedido.status)}">
{formatPedidoStatus(row.pedido.status)}
</span>
{/if}
</div>
{#if row.lastHistory && row.lastHistory.length > 0}
<div class="mt-3">
<div class="text-base-content/60 mb-2 text-xs font-semibold">Últimas ações</div>
<ul class="text-base-content/70 space-y-1 text-xs">
{#each row.lastHistory as h (h._id)}
<li class="flex items-center justify-between gap-2">
<span class="truncate">{h.usuarioNome}: {h.acao}</span>
<span class="text-base-content/50 shrink-0"
>{new Date(h.data).toLocaleString('pt-BR')}</span
>
</li>
{/each}
</ul>
</div>
{/if}
</div>
{/each}
</div>
{/if}
</div>
</GlassCard>
<!-- Add item modal -->
{#if showAddItemModal && addItemConfig.objeto}
<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={closeAddItemModal}
aria-label="Fechar modal"
disabled={addingItem}
>
<X class="h-5 w-5" />
</button>
<h3 class="text-lg font-bold">Adicionar item</h3>
<div class="bg-base-200/50 mt-4 rounded-lg p-4">
<p class="font-semibold">{addItemConfig.objeto.nome}</p>
<p class="text-base-content/70 text-sm">Unidade: {addItemConfig.objeto.unidade}</p>
</div>
<div class="mt-6 space-y-4">
<div class="form-control w-full">
<label class="label" for="ai_qtd">
<span class="label-text font-semibold">Quantidade</span>
</label>
<input
id="ai_qtd"
type="number"
min="1"
class="input input-bordered focus:input-primary w-full"
bind:value={addItemConfig.quantidade}
disabled={addingItem}
/>
</div>
<div class="form-control w-full">
<label class="label" for="ai_valor">
<span class="label-text font-semibold">Valor estimado</span>
</label>
<input
id="ai_valor"
type="text"
class="input input-bordered focus:input-primary w-full"
bind:value={addItemConfig.valorEstimado}
disabled={addingItem}
/>
</div>
<div class="form-control w-full">
<label class="label" for="ai_dfd">
<span class="label-text font-semibold">Número DFD (opcional)</span>
</label>
<input
id="ai_dfd"
type="text"
class="input input-bordered focus:input-primary w-full"
bind:value={addItemConfig.numeroDfd}
disabled={addingItem}
placeholder="Ex: 123/2025"
/>
</div>
</div>
<div class="modal-action">
<button type="button" class="btn" onclick={closeAddItemModal} disabled={addingItem}>
Cancelar
</button>
<button
type="button"
class="btn btn-primary"
onclick={confirmAddItem}
disabled={addingItem}
>
{#if addingItem}
<span class="loading loading-spinner loading-sm"></span>
{/if}
{addingItem ? 'Adicionando...' : 'Adicionar'}
</button>
</div>
</div>
<button
type="button"
class="modal-backdrop"
onclick={closeAddItemModal}
aria-label="Fechar modal"
></button>
</div>
{/if}
<!-- Gerar pedidos modal -->
{#if showGerarModal}
<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={closeGerarModal}
aria-label="Fechar modal"
disabled={gerando}
>
<X class="h-5 w-5" />
</button>
<h3 class="text-lg font-bold">Gerar pedidos</h3>
<p class="text-base-content/70 mt-2 text-sm">
Será criado <strong>1 pedido por DFD</strong>. Informe o número SEI de cada pedido.
</p>
<div class="mt-6 space-y-3">
{#each dfdsParaGerar as dfd (dfd)}
<div class="border-base-300 grid gap-3 rounded-lg border p-4 md:grid-cols-3">
<div class="md:col-span-1">
<div class="text-base-content/60 text-xs font-semibold">DFD</div>
<div class="text-sm font-semibold">{dfd}</div>
</div>
<div class="md:col-span-2">
<label class="label py-0" for={`sei_${dfd}`}>
<span class="label-text font-semibold">Número SEI</span>
</label>
<input
id={`sei_${dfd}`}
type="text"
class="input input-bordered focus:input-primary w-full"
bind:value={seiByDfd[dfd]}
disabled={gerando}
placeholder="Ex: 12345.000000/2025-00"
/>
</div>
</div>
{/each}
</div>
<div class="modal-action">
<button type="button" class="btn" onclick={closeGerarModal} disabled={gerando}>
Cancelar
</button>
<button
type="button"
class="btn btn-primary"
onclick={confirmarGeracao}
disabled={gerando}
>
{#if gerando}
<span class="loading loading-spinner loading-sm"></span>
{/if}
{gerando ? 'Gerando...' : 'Confirmar e gerar'}
</button>
</div>
</div>
<button
type="button"
class="modal-backdrop"
onclick={closeGerarModal}
aria-label="Fechar modal"
></button>
</div>
{/if}
{/if}
</PageShell>

View File

@@ -14,7 +14,7 @@
Users,
Inbox,
Search,
AlertTriangle,
TriangleAlert,
User
} from 'lucide-svelte';
import type { FunctionReturnType } from 'convex/server';
@@ -30,6 +30,7 @@
const list = $derived(funcionariosQuery.data ?? []);
let funcionarioToDelete = $derived<Funcionario | null>(null);
let deleting = $state(false);
let filtro = $state('');
let notice: { kind: 'success' | 'error'; text: string } | null = $state(null);
@@ -48,6 +49,7 @@
if (!funcionarioToDelete) return;
try {
deleting = true;
await client.mutation(api.funcionarios.remove, { id: funcionarioToDelete._id });
closeDeleteModal();
notice = {
@@ -56,6 +58,8 @@
};
} catch {
notice = { kind: 'error', text: 'Erro ao excluir cadastro. Tente novamente.' };
} finally {
deleting = false;
}
}
@@ -243,12 +247,12 @@
<dialog id="delete_modal_func_excluir" class="modal">
<div class="modal-box max-w-md">
<h3 class="text-error mb-4 flex items-center gap-2 text-2xl font-bold">
<AlertTriangle class="h-7 w-7" strokeWidth={2} />
<TriangleAlert class="h-7 w-7" strokeWidth={2} />
Confirmar Exclusão
</h3>
<div class="alert alert-warning mb-4">
<AlertTriangle class="h-6 w-6 shrink-0 stroke-current" strokeWidth={2} />
<TriangleAlert class="h-6 w-6 shrink-0 stroke-current" strokeWidth={2} />
<div>
<span class="font-bold">Atenção!</span>
<p class="text-sm">Esta ação não pode ser desfeita!</p>
@@ -280,16 +284,16 @@
{/if}
<div class="modal-action justify-between">
<button class="btn gap-2" onclick={closeDeleteModal} disabled={funcionarioToDelete !== null}>
<button class="btn gap-2" onclick={closeDeleteModal} disabled={deleting}>
<X class="h-5 w-5" strokeWidth={2} />
Cancelar
</button>
<button
class="btn btn-error gap-2"
onclick={confirmDelete}
disabled={funcionarioToDelete !== null}
disabled={deleting || funcionarioToDelete === null}
>
{#if funcionarioToDelete}
{#if deleting}
<span class="loading loading-spinner loading-sm"></span>
Excluindo...
{:else}