Compare commits

...

3 Commits

11 changed files with 1265 additions and 578 deletions

View File

@@ -2,6 +2,8 @@
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import logo from '$lib/assets/logo_governo_PE.png'; import logo from '$lib/assets/logo_governo_PE.png';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import { onMount } from 'svelte';
import { aplicarTemaDaisyUI } from '$lib/utils/temas';
type HeaderProps = { type HeaderProps = {
left?: Snippet; left?: Snippet;
@@ -9,6 +11,43 @@
}; };
const { left, right }: HeaderProps = $props(); const { left, right }: HeaderProps = $props();
let themeSelectEl: HTMLSelectElement | null = null;
function safeGetThemeLS(): string | null {
try {
const t = localStorage.getItem('theme');
return t && t.trim() ? t : null;
} catch {
return null;
}
}
onMount(() => {
const persisted = safeGetThemeLS();
if (persisted) {
// Sincroniza UI + HTML com o valor persistido (evita select ficar "aqua" indevido)
if (themeSelectEl && themeSelectEl.value !== persisted) {
themeSelectEl.value = persisted;
}
aplicarTemaDaisyUI(persisted);
}
});
function onThemeChange(e: Event) {
const nextValue = (e.currentTarget as HTMLSelectElement | null)?.value ?? null;
// Se o theme-change não atualizar (caso comum após login/logout),
// garantimos aqui a persistência + aplicação imediata.
if (nextValue) {
try {
localStorage.setItem('theme', nextValue);
} catch {
// ignore
}
aplicarTemaDaisyUI(nextValue);
}
}
</script> </script>
<header <header
@@ -36,9 +75,11 @@
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<select <select
bind:this={themeSelectEl}
class="select select-sm bg-base-100 border-base-300 w-40" class="select select-sm bg-base-100 border-base-300 w-40"
aria-label="Selecionar tema" aria-label="Selecionar tema"
data-choose-theme data-choose-theme
onchange={onThemeChange}
> >
<option value="aqua">Aqua</option> <option value="aqua">Aqua</option>
<option value="sgse-blue">Azul</option> <option value="sgse-blue">Azul</option>

View File

@@ -1,6 +1,8 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte';
import { useQuery, useConvexClient } from 'convex-svelte'; import { useQuery, useConvexClient } from 'convex-svelte';
import { api } from '@sgse-app/backend/convex/_generated/api'; import { api } from '@sgse-app/backend/convex/_generated/api';
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
import { notificacoesCount } from '$lib/stores/chatStore'; import { notificacoesCount } from '$lib/stores/chatStore';
import { formatDistanceToNow } from 'date-fns'; import { formatDistanceToNow } from 'date-fns';
import { ptBR } from 'date-fns/locale'; import { ptBR } from 'date-fns/locale';
@@ -18,9 +20,10 @@
const currentUser = useQuery(api.auth.getCurrentUser, {}); const currentUser = useQuery(api.auth.getCurrentUser, {});
let modalOpen = $state(false); let modalOpen = $state(false);
let usuarioId = $derived((currentUser?.data?._id as Id<'usuarios'> | undefined) ?? null);
let notificacoesFerias = $state< let notificacoesFerias = $state<
Array<{ Array<{
_id: string; _id: Id<'notificacoesFerias'>;
mensagem: string; mensagem: string;
tipo: string; tipo: string;
_creationTime: number; _creationTime: number;
@@ -28,7 +31,7 @@
>([]); >([]);
let notificacoesAusencias = $state< let notificacoesAusencias = $state<
Array<{ Array<{
_id: string; _id: Id<'notificacoesAusencias'>;
mensagem: string; mensagem: string;
tipo: string; tipo: string;
_creationTime: number; _creationTime: number;
@@ -47,43 +50,40 @@
// Separar notificações lidas e não lidas // Separar notificações lidas e não lidas
let notificacoesNaoLidas = $derived(todasNotificacoes.filter((n) => !n.lida)); let notificacoesNaoLidas = $derived(todasNotificacoes.filter((n) => !n.lida));
let notificacoesLidas = $derived(todasNotificacoes.filter((n) => n.lida)); let notificacoesLidas = $derived(todasNotificacoes.filter((n) => n.lida));
let totalCount = $derived(count + (notificacoesFerias?.length || 0));
// Atualizar contador no store // Atualizar contador no store
$effect(() => { $effect(() => {
const totalNotificacoes = const totalNotificacoes =
count + (notificacoesFerias?.length || 0) + (notificacoesAusencias?.length || 0); count + (notificacoesFerias?.length || 0) + (notificacoesAusencias?.length || 0);
notificacoesCount.set(totalNotificacoes); $notificacoesCount = totalNotificacoes;
}); });
// Buscar notificações de férias // Buscar notificações de férias
async function buscarNotificacoesFerias() { async function buscarNotificacoesFerias(id: Id<'usuarios'> | null) {
try { try {
const usuarioId = currentUser?.data?._id; if (!id) return;
if (usuarioId) {
const notifsFerias = await client.query(api.ferias.obterNotificacoesNaoLidas, { const notifsFerias = await client.query(api.ferias.obterNotificacoesNaoLidas, {
usuarioId usuarioId: id
}); });
notificacoesFerias = notifsFerias || []; notificacoesFerias = notifsFerias || [];
}
} catch (e) { } catch (e) {
console.error('Erro ao buscar notificações de férias:', e); console.error('Erro ao buscar notificações de férias:', e);
} }
} }
// Buscar notificações de ausências // Buscar notificações de ausências
async function buscarNotificacoesAusencias() { async function buscarNotificacoesAusencias(id: Id<'usuarios'> | null) {
try { try {
const usuarioId = currentUser?.data?._id; if (!id) return;
if (usuarioId) {
try { try {
const notifsAusencias = await client.query(api.ausencias.obterNotificacoesNaoLidas, { const notifsAusencias = await client.query(api.ausencias.obterNotificacoesNaoLidas, {
usuarioId usuarioId: id
}); });
notificacoesAusencias = notifsAusencias || []; notificacoesAusencias = notifsAusencias || [];
} catch (queryError: unknown) { } catch (queryError: unknown) {
// Silenciar erros de timeout e função não encontrada // Silenciar erros de timeout e função não encontrada
const errorMessage = const errorMessage = queryError instanceof Error ? queryError.message : String(queryError);
queryError instanceof Error ? queryError.message : String(queryError);
const isTimeout = errorMessage.includes('timed out') || errorMessage.includes('timeout'); const isTimeout = errorMessage.includes('timed out') || errorMessage.includes('timeout');
const isFunctionNotFound = errorMessage.includes('Could not find public function'); const isFunctionNotFound = errorMessage.includes('Could not find public function');
@@ -92,7 +92,6 @@
} }
notificacoesAusencias = []; notificacoesAusencias = [];
} }
}
} catch (e) { } catch (e) {
// Erro geral - silenciar se for sobre função não encontrada ou timeout // Erro geral - silenciar se for sobre função não encontrada ou timeout
const errorMessage = e instanceof Error ? e.message : String(e); const errorMessage = e instanceof Error ? e.message : String(e);
@@ -106,13 +105,15 @@
} }
// Atualizar notificações periodicamente // Atualizar notificações periodicamente
$effect(() => { onMount(() => {
buscarNotificacoesFerias(); void buscarNotificacoesFerias(usuarioId);
buscarNotificacoesAusencias(); void buscarNotificacoesAusencias(usuarioId);
const interval = setInterval(() => { const interval = setInterval(() => {
buscarNotificacoesFerias(); void buscarNotificacoesFerias(usuarioId);
buscarNotificacoesAusencias(); void buscarNotificacoesAusencias(usuarioId);
}, 30000); // A cada 30s }, 30000); // A cada 30s
return () => clearInterval(interval); return () => clearInterval(interval);
}); });
@@ -127,30 +128,12 @@
} }
} }
async function handleMarcarTodasLidas() {
await client.mutation(api.chat.marcarTodasNotificacoesLidas, {});
// Marcar todas as notificações de férias como lidas
for (const notif of notificacoesFerias) {
await client.mutation(api.ferias.marcarComoLida, {
notificacaoId: notif._id
});
}
// Marcar todas as notificações de ausências como lidas
for (const notif of notificacoesAusencias) {
await client.mutation(api.ausencias.marcarComoLida, {
notificacaoId: notif._id
});
}
await buscarNotificacoesFerias();
await buscarNotificacoesAusencias();
}
async function handleLimparTodasNotificacoes() { async function handleLimparTodasNotificacoes() {
limpandoNotificacoes = true; limpandoNotificacoes = true;
try { try {
await client.mutation(api.chat.limparTodasNotificacoes, {}); await client.mutation(api.chat.limparTodasNotificacoes, {});
await buscarNotificacoesFerias(); await buscarNotificacoesFerias(usuarioId);
await buscarNotificacoesAusencias(); await buscarNotificacoesAusencias(usuarioId);
} catch (error) { } catch (error) {
console.error('Erro ao limpar notificações:', error); console.error('Erro ao limpar notificações:', error);
} finally { } finally {
@@ -162,8 +145,8 @@
limpandoNotificacoes = true; limpandoNotificacoes = true;
try { try {
await client.mutation(api.chat.limparNotificacoesNaoLidas, {}); await client.mutation(api.chat.limparNotificacoesNaoLidas, {});
await buscarNotificacoesFerias(); await buscarNotificacoesFerias(usuarioId);
await buscarNotificacoesAusencias(); await buscarNotificacoesAusencias(usuarioId);
} catch (error) { } catch (error) {
console.error('Erro ao limpar notificações não lidas:', error); console.error('Erro ao limpar notificações não lidas:', error);
} finally { } finally {
@@ -173,24 +156,24 @@
async function handleClickNotificacao(notificacaoId: string) { async function handleClickNotificacao(notificacaoId: string) {
await client.mutation(api.chat.marcarNotificacaoLida, { await client.mutation(api.chat.marcarNotificacaoLida, {
notificacaoId: notificacaoId as any notificacaoId: notificacaoId as Id<'notificacoes'>
}); });
} }
async function handleClickNotificacaoFerias(notificacaoId: string) { async function handleClickNotificacaoFerias(notificacaoId: Id<'notificacoesFerias'>) {
await client.mutation(api.ferias.marcarComoLida, { await client.mutation(api.ferias.marcarComoLida, {
notificacaoId: notificacaoId notificacaoId: notificacaoId
}); });
await buscarNotificacoesFerias(); await buscarNotificacoesFerias(usuarioId);
// Redirecionar para a página de férias // Redirecionar para a página de férias
window.location.href = '/recursos-humanos/ferias'; window.location.href = '/recursos-humanos/ferias';
} }
async function handleClickNotificacaoAusencias(notificacaoId: string) { async function handleClickNotificacaoAusencias(notificacaoId: Id<'notificacoesAusencias'>) {
await client.mutation(api.ausencias.marcarComoLida, { await client.mutation(api.ausencias.marcarComoLida, {
notificacaoId: notificacaoId notificacaoId: notificacaoId
}); });
await buscarNotificacoesAusencias(); await buscarNotificacoesAusencias(usuarioId);
// Redirecionar para a página de perfil na aba de ausências // Redirecionar para a página de perfil na aba de ausências
window.location.href = '/perfil?aba=minhas-ausencias'; window.location.href = '/perfil?aba=minhas-ausencias';
} }
@@ -204,19 +187,19 @@
} }
// Fechar popup ao clicar fora ou pressionar Escape // Fechar popup ao clicar fora ou pressionar Escape
$effect(() => { onMount(() => {
if (!modalOpen) return;
function handleClickOutside(event: MouseEvent) { function handleClickOutside(event: MouseEvent) {
if (!modalOpen) return;
const target = event.target as HTMLElement; const target = event.target as HTMLElement;
if (!target.closest('.notification-popup') && !target.closest('.notification-bell')) { if (!target.closest('.notification-popup') && !target.closest('.notification-bell')) {
modalOpen = false; closeModal();
} }
} }
function handleEscape(event: KeyboardEvent) { function handleEscape(event: KeyboardEvent) {
if (!modalOpen) return;
if (event.key === 'Escape') { if (event.key === 'Escape') {
modalOpen = false; closeModal();
} }
} }
@@ -230,56 +213,32 @@
</script> </script>
<div class="notification-bell relative"> <div class="notification-bell relative">
<!-- Botão de Notificação ULTRA MODERNO (igual ao perfil) --> <!-- Botão de Notificação (padrão do tema) -->
<button <div class="indicator">
type="button" {#if totalCount > 0}
tabindex="0" <span class="indicator-item badge badge-error badge-sm">
class="group relative flex h-14 w-14 items-center justify-center overflow-hidden rounded-2xl transition-all duration-300 hover:scale-105"
style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); box-shadow: 0 8px 24px -4px rgba(102, 126, 234, 0.4);"
onclick={openModal}
aria-label="Notificações"
>
<!-- Efeito de brilho no hover -->
<div
class="absolute inset-0 bg-linear-to-br from-white/0 to-white/20 opacity-0 transition-opacity duration-300 group-hover:opacity-100"
></div>
<!-- Anel de pulso sutil -->
<div
class="absolute inset-0 rounded-2xl"
style="animation: pulse-ring-subtle 3s cubic-bezier(0.4, 0, 0.6, 1) infinite;"
></div>
<!-- Glow effect quando tem notificações -->
{#if count && count > 0}
<div class="bg-error/30 absolute inset-0 animate-pulse rounded-2xl blur-lg"></div>
{/if}
<!-- Ícone do sino PREENCHIDO moderno -->
<Bell
class="relative z-10 h-7 w-7 text-white transition-all duration-300 group-hover:scale-110"
style="filter: drop-shadow(0 2px 8px rgba(0,0,0,0.3)); animation: {count && count > 0
? 'bell-ring 2s ease-in-out infinite'
: 'none'};"
fill="currentColor"
/>
<!-- Badge premium MODERNO com gradiente -->
{#if count + (notificacoesFerias?.length || 0) > 0}
{@const totalCount = count + (notificacoesFerias?.length || 0)}
<span
class="absolute -top-1 -right-1 z-20 flex h-6 w-6 items-center justify-center rounded-full text-[10px] font-black text-white shadow-xl ring-2 ring-white"
style="background: linear-gradient(135deg, #ff416c, #ff4b2b); box-shadow: 0 8px 24px -4px rgba(255, 65, 108, 0.6), 0 4px 12px -2px rgba(255, 75, 43, 0.4); animation: badge-bounce 2s ease-in-out infinite;"
>
{totalCount > 9 ? '9+' : totalCount} {totalCount > 9 ? '9+' : totalCount}
</span> </span>
{/if} {/if}
<button
type="button"
tabindex="0"
class="btn ring-base-200 hover:ring-primary/50 size-10 p-0 ring-2 ring-offset-2 transition-all"
onclick={openModal}
aria-label="Notificações"
aria-expanded={modalOpen}
>
<Bell
class="size-6 transition-colors {totalCount > 0 ? 'text-primary' : 'text-base-content/70'}"
style="animation: {totalCount > 0 ? 'bell-ring 2s ease-in-out infinite' : 'none'};"
/>
</button> </button>
</div>
<!-- Popup Flutuante de Notificações --> <!-- Popup Flutuante de Notificações -->
{#if modalOpen} {#if modalOpen}
<div <div
class="notification-popup bg-base-100 border-base-300 fixed top-24 right-4 z-[100] flex max-h-[calc(100vh-7rem)] w-[calc(100vw-2rem)] max-w-2xl flex-col overflow-hidden rounded-2xl border shadow-2xl backdrop-blur-sm" class="notification-popup bg-base-100 border-base-300 fixed top-24 right-4 z-100 flex max-h-[calc(100vh-7rem)] w-[calc(100vw-2rem)] max-w-2xl flex-col overflow-hidden rounded-2xl border shadow-2xl backdrop-blur-sm"
style="animation: slideDown 0.2s ease-out;" style="animation: slideDown 0.2s ease-out;"
> >
<!-- Header --> <!-- Header -->
@@ -310,7 +269,7 @@
Limpar todas Limpar todas
</button> </button>
{/if} {/if}
<button type="button" class="btn btn-sm btn-circle btn-ghost" onclick={closeModal}> <button type="button" class="btn btn-sm btn-circle" onclick={closeModal}>
<X class="h-5 w-5" /> <X class="h-5 w-5" />
</button> </button>
</div> </div>
@@ -439,17 +398,17 @@
<!-- Notificações de Férias --> <!-- Notificações de Férias -->
{#if notificacoesFerias.length > 0} {#if notificacoesFerias.length > 0}
<div class="mb-4"> <div class="mb-4">
<h4 class="mb-2 px-2 text-sm font-semibold text-purple-600">Férias</h4> <h4 class="text-secondary mb-2 px-2 text-sm font-semibold">Férias</h4>
{#each notificacoesFerias as notificacao (notificacao._id)} {#each notificacoesFerias as notificacao (notificacao._id)}
<button <button
type="button" type="button"
class="hover:bg-base-200 mb-2 w-full rounded-lg border-l-4 border-purple-600 px-4 py-3 text-left transition-colors" class="hover:bg-base-200 border-secondary mb-2 w-full rounded-lg border-l-4 px-4 py-3 text-left transition-colors"
onclick={() => handleClickNotificacaoFerias(notificacao._id)} onclick={() => handleClickNotificacaoFerias(notificacao._id)}
> >
<div class="flex items-start gap-3"> <div class="flex items-start gap-3">
<!-- Ícone --> <!-- Ícone -->
<div class="mt-1 shrink-0"> <div class="mt-1 shrink-0">
<Calendar class="h-5 w-5 text-purple-600" strokeWidth={2} /> <Calendar class="text-secondary h-5 w-5" strokeWidth={2} />
</div> </div>
<!-- Conteúdo --> <!-- Conteúdo -->
@@ -464,7 +423,7 @@
<!-- Badge --> <!-- Badge -->
<div class="shrink-0"> <div class="shrink-0">
<div class="badge badge-primary badge-xs"></div> <div class="badge badge-secondary badge-xs"></div>
</div> </div>
</div> </div>
</button> </button>
@@ -475,17 +434,17 @@
<!-- Notificações de Ausências --> <!-- Notificações de Ausências -->
{#if notificacoesAusencias.length > 0} {#if notificacoesAusencias.length > 0}
<div class="mb-4"> <div class="mb-4">
<h4 class="mb-2 px-2 text-sm font-semibold text-orange-600">Ausências</h4> <h4 class="text-warning mb-2 px-2 text-sm font-semibold">Ausências</h4>
{#each notificacoesAusencias as notificacao (notificacao._id)} {#each notificacoesAusencias as notificacao (notificacao._id)}
<button <button
type="button" type="button"
class="hover:bg-base-200 mb-2 w-full rounded-lg border-l-4 border-orange-600 px-4 py-3 text-left transition-colors" class="hover:bg-base-200 border-warning mb-2 w-full rounded-lg border-l-4 px-4 py-3 text-left transition-colors"
onclick={() => handleClickNotificacaoAusencias(notificacao._id)} onclick={() => handleClickNotificacaoAusencias(notificacao._id)}
> >
<div class="flex items-start gap-3"> <div class="flex items-start gap-3">
<!-- Ícone --> <!-- Ícone -->
<div class="mt-1 shrink-0"> <div class="mt-1 shrink-0">
<Clock class="h-5 w-5 text-orange-600" strokeWidth={2} /> <Clock class="text-warning h-5 w-5" strokeWidth={2} />
</div> </div>
<!-- Conteúdo --> <!-- Conteúdo -->
@@ -539,28 +498,6 @@
</div> </div>
<style> <style>
@keyframes badge-bounce {
0%,
100% {
transform: scale(1);
}
50% {
transform: scale(1.1);
}
}
@keyframes pulse-ring-subtle {
0%,
100% {
opacity: 0.1;
transform: scale(1);
}
50% {
opacity: 0.3;
transform: scale(1.05);
}
}
@keyframes bell-ring { @keyframes bell-ring {
0%, 0%,
100% { 100% {

View File

@@ -40,9 +40,6 @@
if (result.error) { if (result.error) {
console.error('Sign out error:', result.error); console.error('Sign out error:', result.error);
} }
// Resetar tema para padrão ao fazer logout
const { aplicarTemaPadrao } = await import('$lib/utils/temas');
aplicarTemaPadrao();
goto(resolve('/home')); goto(resolve('/home'));
} }
</script> </script>

View File

@@ -0,0 +1,128 @@
<script lang="ts">
import type { HTMLButtonAttributes } from 'svelte/elements';
import type { Snippet } from 'svelte';
import ShineEffect from '$lib/components/ShineEffect.svelte';
type Size = 'sm' | 'md' | 'lg' | 'icon';
type Variant = 'primary' | 'secondary' | 'ghost' | 'outline' | 'danger' | 'link';
type Classes = Partial<{
button: string;
content: string;
spinner: string;
}>;
interface Props {
type?: HTMLButtonAttributes['type'];
disabled?: boolean;
loading?: boolean;
loadingText?: string;
size?: Size;
variant?: Variant;
fullWidth?: boolean;
shine?: boolean;
left?: Snippet;
right?: Snippet;
children?: Snippet;
classes?: Classes;
class?: string;
}
let {
type = 'button',
disabled = false,
loading = false,
loadingText,
size = 'md',
variant = 'primary',
fullWidth = false,
shine = false,
left,
right,
children,
classes,
class: className = ''
}: Props = $props();
const isDisabled = $derived(disabled || loading);
const sizeClass = $derived(
size === 'sm'
? 'px-3 py-2 text-sm'
: size === 'lg'
? 'px-5 py-4 text-base'
: size === 'icon'
? 'p-2'
: 'px-4 py-3.5 text-sm'
);
const base =
'relative inline-flex items-center justify-center gap-2 rounded-xl font-bold transition-all duration-300 overflow-hidden';
const variantClass = $derived(
variant === 'primary'
? 'bg-primary hover:bg-primary-focus hover:shadow-primary/25 text-primary-content shadow-lg'
: variant === 'secondary'
? 'bg-base-200 hover:bg-base-300 text-base-content'
: variant === 'ghost'
? 'bg-transparent hover:bg-base-200/60 text-base-content'
: variant === 'outline'
? 'border-base-content/20 hover:bg-base-200/40 text-base-content border'
: variant === 'danger'
? 'bg-error hover:bg-error/90 text-error-content shadow-lg'
: 'bg-transparent hover:underline text-primary px-0 py-0 rounded-none'
);
const widthClass = $derived(fullWidth ? 'w-full' : '');
const disabledClass = 'disabled:cursor-not-allowed disabled:opacity-50';
const buttonClass = $derived(
[base, sizeClass, variantClass, widthClass, disabledClass, classes?.button, className]
.filter(Boolean)
.join(' ')
);
</script>
<button {type} class={buttonClass} disabled={isDisabled}>
<div
class={['relative z-10 flex items-center justify-center gap-2', classes?.content].filter(
Boolean
)}
>
{#if loading}
<span
class={[
'border-primary-content/30 border-t-primary-content h-5 w-5 animate-spin rounded-full border-2',
classes?.spinner
].filter(Boolean)}
></span>
{#if loadingText}
<span>{loadingText}</span>
{/if}
{:else}
{#if left}
<span class="[&_svg]:h-4 [&_svg]:w-4">
{@render left()}
</span>
{/if}
{@render children?.()}
{#if right}
<span class="[&_svg]:h-4 [&_svg]:w-4">
{@render right()}
</span>
{/if}
{/if}
</div>
{#if shine}
<ShineEffect />
{/if}
</button>

View File

@@ -0,0 +1,174 @@
<script lang="ts">
import { Field } from '@ark-ui/svelte/field';
import type { Snippet } from 'svelte';
import type { HTMLInputAttributes } from 'svelte/elements';
type Size = 'sm' | 'md' | 'lg';
type Variant = 'filled' | 'outline';
type Classes = Partial<{
root: string;
labelRow: string;
label: string;
control: string;
input: string;
helperText: string;
errorText: string;
}>;
interface Props {
id: string;
label: string;
value?: string;
type?: HTMLInputAttributes['type'];
name?: string;
placeholder?: string;
autocomplete?: HTMLInputAttributes['autocomplete'];
disabled?: boolean;
readonly?: boolean;
required?: boolean;
error?: string | null;
helperText?: string | null;
size?: Size;
variant?: Variant;
fullWidth?: boolean;
left?: Snippet;
right?: Snippet;
inputProps?: Omit<
HTMLInputAttributes,
| 'id'
| 'type'
| 'name'
| 'placeholder'
| 'autocomplete'
| 'disabled'
| 'required'
| 'readOnly'
| 'value'
>;
classes?: Classes;
class?: string;
}
let {
id,
label,
value = $bindable(''),
type = 'text',
name,
placeholder = '',
autocomplete,
disabled = false,
readonly = false,
required = false,
error = null,
helperText = null,
size = 'md',
variant = 'filled',
fullWidth = true,
left,
right,
inputProps,
classes,
class: className = ''
}: Props = $props();
const invalid = $derived(!!error);
const hasLeft = $derived(!!left);
const hasRight = $derived(!!right);
const paddingY = $derived(size === 'sm' ? 'py-2.5' : size === 'lg' ? 'py-3.5' : 'py-3');
const paddingX = 'px-4';
const paddingLeft = $derived(hasLeft ? 'pl-11' : '');
const paddingRight = $derived(hasRight ? 'pr-11' : '');
const baseInput =
'border-base-content/10 bg-base-200/25 text-base-content placeholder-base-content/40 focus:border-primary/50 focus:bg-base-200/35 focus:ring-primary/20 w-full rounded-xl border transition-all duration-300 focus:ring-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-60';
const variantClass = $derived(variant === 'outline' ? 'bg-transparent' : 'bg-base-200/25');
const inputClass = $derived(
[
baseInput,
variantClass,
fullWidth ? 'w-full' : '',
paddingX,
paddingY,
paddingLeft,
paddingRight,
classes?.input
]
.filter(Boolean)
.join(' ')
);
</script>
<Field.Root {invalid} {required} {disabled} class={['space-y-2', classes?.root, className]}>
<div class={['flex items-center justify-between gap-3', classes?.labelRow].filter(Boolean)}>
<Field.Label
for={id}
class={[
'text-base-content/60 text-xs font-semibold tracking-wider uppercase',
classes?.label
].filter(Boolean)}
>
{label}
</Field.Label>
{@render right?.()}
</div>
<div class={['group relative', classes?.control].filter(Boolean)}>
{#if left}
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-4">
<div class="text-base-content/50 [&_svg]:h-4 [&_svg]:w-4">
{@render left()}
</div>
</div>
{/if}
<Field.Input
{id}
{type}
{name}
{placeholder}
{disabled}
{readonly}
{autocomplete}
{required}
bind:value
{...inputProps}
class={inputClass}
/>
{#if right}
<div class="absolute inset-y-0 right-0 flex items-center pr-4">
<div class="text-base-content/70 [&_svg]:h-4 [&_svg]:w-4">
{@render right()}
</div>
</div>
{/if}
</div>
{#if helperText && !error}
<Field.HelperText class={['text-base-content/50 text-sm', classes?.helperText].filter(Boolean)}>
{helperText}
</Field.HelperText>
{/if}
{#if error}
<Field.ErrorText class={['text-error text-sm font-medium', classes?.errorText].filter(Boolean)}>
{error}
</Field.ErrorText>
{/if}
</Field.Root>

View File

@@ -0,0 +1,287 @@
<script lang="ts">
import { Field } from '@ark-ui/svelte/field';
import { Portal } from '@ark-ui/svelte/portal';
import { Select, createListCollection } from '@ark-ui/svelte/select';
import type { Snippet } from 'svelte';
import { Check, ChevronDown, X } from 'lucide-svelte';
type Size = 'sm' | 'md' | 'lg';
type Variant = 'filled' | 'outline';
export interface SelectItem {
label: string;
value: string;
disabled?: boolean;
group?: string;
}
type Classes = Partial<{
root: string;
labelRow: string;
label: string;
control: string;
trigger: string;
content: string;
item: string;
helperText: string;
errorText: string;
}>;
interface Props {
id: string;
label: string;
items: SelectItem[];
value?: string[];
name?: string;
placeholder?: string;
disabled?: boolean;
readOnly?: boolean;
required?: boolean;
error?: string | null;
helperText?: string | null;
size?: Size;
variant?: Variant;
fullWidth?: boolean;
multiple?: boolean;
clearable?: boolean;
maxSelected?: number;
positioning?: Select.RootProps<SelectItem>['positioning'];
/** Slot ao lado do label (ex.: link, ação, etc.) */
labelRight?: Snippet;
/** Slot antes do texto no trigger (ex.: ícone) */
triggerLeft?: Snippet;
classes?: Classes;
class?: string;
}
let {
id,
label,
items,
value = $bindable<string[]>([]),
name,
placeholder = 'Selecione...',
disabled = false,
readOnly = false,
required = false,
error = null,
helperText = null,
size = 'md',
variant = 'filled',
fullWidth = true,
multiple = false,
clearable = true,
maxSelected,
positioning,
labelRight,
triggerLeft,
classes,
class: className = ''
}: Props = $props();
const invalid = $derived(!!error);
const hasGroups = $derived(items.some((i) => i.group));
const canClear = $derived(clearable && value.length > 0 && !disabled && !readOnly);
const collection = $derived(
createListCollection<SelectItem>({
items
})
);
const groups = $derived.by(() => {
if (!hasGroups) return [];
const record: Record<string, SelectItem[]> = {};
for (const item of items) {
const key = item.group ?? 'Opções';
const arr = record[key] ?? [];
arr.push(item);
record[key] = arr;
}
return Object.entries(record);
});
const paddingY = $derived(size === 'sm' ? 'py-2.5' : size === 'lg' ? 'py-3.5' : 'py-3');
const paddingX = 'px-4';
const baseTrigger =
'border-base-content/10 bg-base-200/25 text-base-content focus:border-primary/50 focus:bg-base-200/35 focus:ring-primary/20 w-full rounded-xl border transition-all duration-300 focus:ring-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-60';
const variantClass = $derived(variant === 'outline' ? 'bg-transparent' : 'bg-base-200/25');
const triggerClass = $derived(
[
baseTrigger,
variantClass,
fullWidth ? 'w-full' : '',
'flex items-center justify-between gap-3 text-left',
paddingX,
paddingY,
classes?.trigger
]
.filter(Boolean)
.join(' ')
);
const contentClass = $derived(
[
'bg-base-100 border-base-200 w-[var(--reference-width)] overflow-hidden rounded-xl border shadow-lg',
'max-h-72',
'z-50',
classes?.content
]
.filter(Boolean)
.join(' ')
);
function handleValueChange(details: { value: string[] }) {
let next = details.value;
if (
typeof maxSelected === 'number' &&
maxSelected >= 0 &&
multiple &&
next.length > maxSelected
) {
next = next.slice(0, maxSelected);
}
value = next;
}
</script>
<Field.Root {invalid} {required} {disabled} class={['space-y-2', classes?.root, className]}>
<div class={['flex items-center justify-between gap-3', classes?.labelRow].filter(Boolean)}>
<Field.Label
for={id}
class={[
'text-base-content/60 text-xs font-semibold tracking-wider uppercase',
classes?.label
].filter(Boolean)}
>
{label}
</Field.Label>
{@render labelRight?.()}
</div>
<Select.Root
{collection}
{disabled}
{required}
{readOnly}
{invalid}
{multiple}
{positioning}
{value}
onValueChange={handleValueChange}
>
<Select.Control class={classes?.control}>
<Select.Trigger {id} class={triggerClass}>
<div class="flex min-w-0 flex-1 items-center gap-2">
{#if triggerLeft}
<span class="text-base-content/60 shrink-0 [&_svg]:h-4 [&_svg]:w-4">
{@render triggerLeft()}
</span>
{/if}
<Select.ValueText
{placeholder}
class="text-base-content/90 data-placeholder-shown:text-base-content/40 truncate"
/>
</div>
<div class="flex shrink-0 items-center gap-2">
{#if canClear}
<Select.ClearTrigger
aria-label="Limpar seleção"
class="text-base-content/50 hover:text-base-content/80 inline-flex items-center justify-center rounded-md p-1 transition-colors"
>
<X class="h-4 w-4" />
</Select.ClearTrigger>
{/if}
<Select.Indicator class="text-base-content/60 [&_svg]:h-4 [&_svg]:w-4">
<ChevronDown />
</Select.Indicator>
</div>
</Select.Trigger>
</Select.Control>
<Portal>
<Select.Positioner>
<Select.Content class={contentClass}>
<div class="p-1">
{#if hasGroups}
{#each groups as [groupLabel, groupItems] (groupLabel)}
<Select.ItemGroup class="mb-1 last:mb-0">
<Select.ItemGroupLabel
class="text-base-content/50 px-3 py-2 text-xs font-semibold tracking-wider uppercase"
>
{groupLabel}
</Select.ItemGroupLabel>
{#each groupItems as item (item.value)}
<Select.Item
{item}
class={[
'text-base-content relative flex cursor-pointer items-center justify-between gap-3 rounded-lg px-3 py-2 text-sm',
'data-highlighted:bg-base-200 data-highlighted:text-base-content',
'data-[state=checked]:bg-primary/10',
'data-disabled:cursor-not-allowed data-disabled:opacity-50',
classes?.item
].filter(Boolean)}
>
<Select.ItemText class="min-w-0 flex-1 truncate">{item.label}</Select.ItemText
>
<Select.ItemIndicator class="text-primary shrink-0">
<Check class="h-4 w-4" />
</Select.ItemIndicator>
</Select.Item>
{/each}
</Select.ItemGroup>
{/each}
{:else}
{#each items as item (item.value)}
<Select.Item
{item}
class={[
'text-base-content relative flex cursor-pointer items-center justify-between gap-3 rounded-lg px-3 py-2 text-sm',
'data-highlighted:bg-base-200 data-highlighted:text-base-content',
'data-[state=checked]:bg-primary/10',
'data-disabled:cursor-not-allowed data-disabled:opacity-50',
classes?.item
].filter(Boolean)}
>
<Select.ItemText class="min-w-0 flex-1 truncate">{item.label}</Select.ItemText>
<Select.ItemIndicator class="text-primary shrink-0">
<Check class="h-4 w-4" />
</Select.ItemIndicator>
</Select.Item>
{/each}
{/if}
</div>
</Select.Content>
</Select.Positioner>
</Portal>
<Select.HiddenSelect {name} />
</Select.Root>
{#if helperText && !error}
<Field.HelperText class={['text-base-content/50 text-sm', classes?.helperText].filter(Boolean)}>
{helperText}
</Field.HelperText>
{/if}
{#if error}
<Field.ErrorText class={['text-error text-sm font-medium', classes?.errorText].filter(Boolean)}>
{error}
</Field.ErrorText>
{/if}
</Field.Root>

View File

@@ -144,6 +144,52 @@ export function obterNomeDaisyUI(id: TemaId | string | null | undefined): string
return temaParaDaisyUI[tema.id] || 'aqua'; return temaParaDaisyUI[tema.id] || 'aqua';
} }
/**
* Lê o tema persistido pelo `theme-change` (chave "theme") no localStorage.
* Retorna o nome do tema do DaisyUI (ex.: "dark", "light", "aqua", "sgse-blue").
*/
export function obterTemaPersistidoNoLocalStorage(chave: string = 'theme'): string | null {
if (typeof window === 'undefined') return null;
try {
const tema = window.localStorage.getItem(chave);
return tema && tema.trim() ? tema : null;
} catch {
return null;
}
}
/**
* Aplica diretamente um tema do DaisyUI no `<html data-theme="...">`.
* (Não altera o localStorage)
*/
export function aplicarTemaDaisyUI(tema: string): void {
if (typeof document === 'undefined') return;
const htmlElement = document.documentElement;
if (!htmlElement) return;
// Normaliza qualquer estado anterior
htmlElement.removeAttribute('data-theme');
// Evita que `body[data-theme]` sobrescreva o tema do `<html>`
if (document.body) document.body.removeAttribute('data-theme');
htmlElement.setAttribute('data-theme', tema);
// Forçar reflow para garantir que o CSS seja aplicado
void htmlElement.offsetHeight;
}
/**
* Garante que o tema do `<html>` reflita SEMPRE o valor persistido no localStorage.
* Se não houver tema persistido, aplica o tema padrão.
*/
export function aplicarTemaDoLocalStorage(): void {
const temaPersistido = obterTemaPersistidoNoLocalStorage('theme');
if (temaPersistido) {
aplicarTemaDaisyUI(temaPersistido);
return;
}
aplicarTemaPadrao();
}
/** /**
* Aplicar tema ao documento HTML * Aplicar tema ao documento HTML
* NÃO salva no localStorage - apenas no banco de dados do usuário * NÃO salva no localStorage - apenas no banco de dados do usuário

View File

@@ -1,10 +1,8 @@
<script lang="ts"> <script lang="ts">
import { Building2, FileText, Package, ShoppingCart } from 'lucide-svelte'; import { Building2, FileText, Package, ShoppingCart } from 'lucide-svelte';
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import ProtectedRoute from '$lib/components/ProtectedRoute.svelte';
</script> </script>
<ProtectedRoute>
<main class="container mx-auto px-4 py-4"> <main class="container mx-auto px-4 py-4">
<div class="breadcrumbs mb-4 text-sm"> <div class="breadcrumbs mb-4 text-sm">
<ul> <ul>
@@ -95,4 +93,3 @@
</a> </a>
</div> </div>
</main> </main>
</ProtectedRoute>

View File

@@ -2,7 +2,8 @@
import { api } from '@sgse-app/backend/convex/_generated/api'; import { api } from '@sgse-app/backend/convex/_generated/api';
import type { Doc, Id } from '@sgse-app/backend/convex/_generated/dataModel'; import type { Doc, Id } from '@sgse-app/backend/convex/_generated/dataModel';
import { useConvexClient, useQuery } from 'convex-svelte'; 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';
const client = useConvexClient(); const client = useConvexClient();
@@ -189,140 +190,173 @@
} }
</script> </script>
<div class="container mx-auto p-6"> <main class="container mx-auto flex max-w-7xl flex-col px-4 py-4">
<div class="mb-6 flex items-center justify-between"> <div class="breadcrumbs mb-4 text-sm">
<h1 class="text-2xl font-bold">Atas de Registro de Preços</h1> <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 <button
class="btn btn-primary gap-2 shadow-md transition-all hover:shadow-lg"
onclick={() => openModal()} 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} /> <Plus class="h-5 w-5" strokeWidth={2} />
Nova Ata Nova Ata
</button> </button>
</div> </div>
</div>
{#if loadingAtas} {#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} {:else if errorAtas}
<p class="text-red-600">{errorAtas}</p> <div class="alert alert-error">
<span>{errorAtas}</span>
</div>
{:else} {:else}
<div class="overflow-hidden rounded-lg bg-white shadow-md"> <div class="card bg-base-100/90 border-base-300 border shadow-xl backdrop-blur-sm">
<table class="min-w-full divide-y divide-gray-200"> <div class="card-body p-0">
<thead class="bg-gray-50"> <div class="overflow-x-auto">
<table class="table-zebra table w-full">
<thead class="from-base-300 to-base-200 sticky top-0 z-10 bg-linear-to-r shadow-md">
<tr> <tr>
<th <th class="text-base-content border-base-400 border-b font-bold whitespace-nowrap"
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
>Número</th >Número</th
> >
<th <th class="text-base-content border-base-400 border-b font-bold whitespace-nowrap"
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
>SEI</th >SEI</th
> >
<th <th class="text-base-content border-base-400 border-b font-bold whitespace-nowrap"
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
>Empresa</th >Empresa</th
> >
<th <th class="text-base-content border-base-400 border-b font-bold whitespace-nowrap"
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
>Vigência</th >Vigência</th
> >
<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 >Ações</th
> >
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-gray-200 bg-white"> <tbody>
{#each atas as ata (ata._id)} {#if atas.length === 0}
<tr> <tr>
<td class="px-6 py-4 font-medium whitespace-nowrap">{ata.numero}</td> <td colspan="5" class="py-12 text-center">
<td class="px-6 py-4 whitespace-nowrap">{ata.numeroSei}</td> <div class="text-base-content/60 flex flex-col items-center gap-2">
<p class="text-lg font-semibold">Nenhuma ata cadastrada</p>
<p class="text-sm">Clique em “Nova Ata” para cadastrar.</p>
</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 <td
class="max-w-md truncate px-6 py-4 whitespace-nowrap" class="max-w-md truncate whitespace-nowrap"
title={getEmpresaNome(ata.empresaId)} title={getEmpresaNome(ata.empresaId)}
> >
{getEmpresaNome(ata.empresaId)} {getEmpresaNome(ata.empresaId)}
</td> </td>
<td class="px-6 py-4 text-sm whitespace-nowrap text-gray-500"> <td class="text-base-content/70 whitespace-nowrap">
{ata.dataInicio || '-'} a {ata.dataFim || '-'} {ata.dataInicio || '-'} a {ata.dataFim || '-'}
</td> </td>
<td class="px-6 py-4 text-right text-sm font-medium whitespace-nowrap"> <td class="text-right whitespace-nowrap">
<div class="flex items-center justify-end gap-1">
<button <button
type="button"
class="btn btn-ghost btn-sm"
aria-label="Editar ata"
onclick={() => openModal(ata)} onclick={() => openModal(ata)}
class="mr-4 text-indigo-600 hover:text-indigo-900"
> >
<Pencil size={18} /> <Pencil size={18} />
</button> </button>
<button <button
type="button"
class="btn btn-ghost btn-sm text-error"
aria-label="Excluir ata"
onclick={() => handleDelete(ata._id)} onclick={() => handleDelete(ata._id)}
class="text-red-600 hover:text-red-900"
> >
<Trash2 size={18} /> <Trash2 size={18} />
</button> </button>
</div>
</td> </td>
</tr> </tr>
{/each} {/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} {/if}
</tbody> </tbody>
</table> </table>
</div> </div>
</div>
</div>
{/if} {/if}
{#if showModal} {#if showModal}
<div <div class="modal modal-open">
class="fixed inset-0 z-50 flex h-full w-full items-center justify-center overflow-y-auto bg-black/40" <div class="modal-box max-w-4xl">
>
<div class="relative my-8 w-full max-w-2xl rounded-lg bg-white p-8 shadow-xl">
<button <button
type="button"
class="btn btn-sm btn-circle absolute top-2 right-2"
onclick={closeModal} 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> </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 class="grid grid-cols-1 gap-6 md:grid-cols-2">
<div> <div class="space-y-4">
<div class="mb-4"> <div class="form-control w-full">
<label class="mb-2 block text-sm font-bold text-gray-700" for="numero"> <label class="label" for="numero">
Número da Ata <span class="label-text font-semibold">Número da Ata</span>
</label> </label>
<input <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" id="numero"
class="input input-bordered focus:input-primary w-full"
type="text" type="text"
bind:value={formData.numero} bind:value={formData.numero}
required required
/> />
</div> </div>
<div class="mb-4"> <div class="form-control w-full">
<label class="mb-2 block text-sm font-bold text-gray-700" for="numeroSei"> <label class="label" for="numeroSei">
Número SEI <span class="label-text font-semibold">Número SEI</span>
</label> </label>
<input <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" id="numeroSei"
class="input input-bordered focus:input-primary w-full"
type="text" type="text"
bind:value={formData.numeroSei} bind:value={formData.numeroSei}
required required
/> />
</div> </div>
<div class="mb-4"> <div class="form-control w-full">
<label class="mb-2 block text-sm font-bold text-gray-700" for="empresa"> <label class="label" for="empresa">
Empresa <span class="label-text font-semibold">Empresa</span>
</label> </label>
<select <select
class="focus:shadow-outline w-full rounded border px-3 py-2 leading-tight text-gray-700 shadow focus:outline-none"
id="empresa" id="empresa"
class="select select-bordered focus:select-primary w-full"
bind:value={formData.empresaId} bind:value={formData.empresaId}
required required
> >
@@ -333,25 +367,25 @@
</select> </select>
</div> </div>
<div class="mb-4 grid grid-cols-2 gap-4"> <div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div> <div class="form-control w-full">
<label class="mb-2 block text-sm font-bold text-gray-700" for="dataInicio"> <label class="label" for="dataInicio">
Data Início <span class="label-text font-semibold">Data Início</span>
</label> </label>
<input <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" id="dataInicio"
class="input input-bordered focus:input-primary w-full"
type="date" type="date"
bind:value={formData.dataInicio} bind:value={formData.dataInicio}
/> />
</div> </div>
<div> <div class="form-control w-full">
<label class="mb-2 block text-sm font-bold text-gray-700" for="dataFim"> <label class="label" for="dataFim">
Data Fim <span class="label-text font-semibold">Data Fim</span>
</label> </label>
<input <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" id="dataFim"
class="input input-bordered focus:input-primary w-full"
type="date" type="date"
bind:value={formData.dataFim} bind:value={formData.dataFim}
/> />
@@ -359,81 +393,89 @@
</div> </div>
</div> </div>
<div class="flex flex-col"> <div class="space-y-4">
<label class="mb-2 block text-sm font-bold text-gray-700" for="objetos"> <div class="flex items-center justify-between">
Objetos Vinculados ({selectedObjetos.length}) <div class="font-semibold">Objetos Vinculados</div>
</label> <span class="badge badge-outline">{selectedObjetos.length}</span>
</div>
<div class="relative mb-2"> <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"> <div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<Search size={16} class="text-gray-400" /> <Search size={16} class="text-base-content/40" />
</div> </div>
<input <input
id="buscar_objeto"
type="text" type="text"
placeholder="Buscar objetos..." placeholder="Digite para filtrar..."
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" class="input input-bordered focus:input-primary w-full pl-10"
bind:value={searchObjeto} bind:value={searchObjeto}
/> />
</div> </div>
</div>
<div <div class="border-base-300 max-h-52 overflow-y-auto rounded-lg border p-2">
class="mb-4 flex-1 overflow-y-auto rounded border bg-gray-50 p-2"
style="max-height: 200px;"
>
{#if filteredObjetos.length === 0} {#if filteredObjetos.length === 0}
<p class="py-4 text-center text-sm text-gray-500">Nenhum objeto encontrado.</p> <p class="text-base-content/60 px-2 py-3 text-center text-sm">
Nenhum objeto encontrado.
</p>
{:else} {:else}
<div class="space-y-1">
{#each filteredObjetos as objeto (objeto._id)} {#each filteredObjetos as objeto (objeto._id)}
<button {@const isSelected = selectedObjetos.includes(objeto._id)}
type="button" <label
class="flex w-full items-center justify-between rounded px-3 py-2 text-left text-sm hover:bg-gray-200 {selectedObjetos.includes( class="hover:bg-base-200/50 flex cursor-pointer items-center gap-3 rounded-md px-2 py-2 {isSelected
objeto._id ? 'bg-primary/5'
)
? 'bg-blue-50 text-blue-700'
: ''}" : ''}"
onclick={() => toggleObjeto(objeto._id)}
> >
<span class="truncate">{objeto.nome}</span> <input
{#if selectedObjetos.includes(objeto._id)} type="checkbox"
<Check size={16} class="text-blue-600" /> 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} {/if}
</button> </label>
{/each} {/each}
</div>
{/if} {/if}
</div> </div>
<div class="border-t pt-4"> <div class="border-base-300 border-t pt-4">
<label class="mb-2 block text-sm font-bold text-gray-700" for="anexos"> <div class="font-semibold">Anexos</div>
Anexos <div class="mt-2 space-y-2">
</label>
<input <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" id="anexos"
type="file" type="file"
multiple multiple
class="file-input file-input-bordered w-full"
onchange={handleAttachmentsSelect} onchange={handleAttachmentsSelect}
/> />
{#if attachments.length > 0} {#if attachments.length > 0}
<div class="mt-2 max-h-40 space-y-2 overflow-y-auto">
{#each attachments as doc (doc._id)}
<div <div
class="flex items-center justify-between rounded bg-gray-100 p-2 text-sm" 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 <a
href={doc.url} href={doc.url}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
class="max-w-[150px] truncate text-blue-600 hover:underline" class="link link-primary max-w-[260px] truncate"
> >
{doc.nome} {doc.nome}
</a> </a>
<button <button
type="button" type="button"
class="btn btn-ghost btn-xs text-error"
onclick={() => handleDeleteAttachment(doc._id)} onclick={() => handleDeleteAttachment(doc._id)}
class="text-red-500 hover:text-red-700" aria-label="Excluir anexo"
> >
<X size={16} /> <X size={16} />
</button> </button>
@@ -444,25 +486,23 @@
</div> </div>
</div> </div>
</div> </div>
</div>
<div class="mt-6 flex items-center justify-end border-t pt-4"> <div class="modal-action">
<button <button type="button" class="btn" onclick={closeModal} disabled={saving || uploading}>
type="button"
onclick={closeModal}
class="mr-2 rounded bg-gray-300 px-4 py-2 font-bold text-gray-800 hover:bg-gray-400"
>
Cancelar Cancelar
</button> </button>
<button <button type="submit" class="btn btn-primary" disabled={saving || uploading}>
type="submit" {#if saving || uploading}
disabled={saving || uploading} <span class="loading loading-spinner loading-sm"></span>
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" {/if}
>
{saving || uploading ? 'Salvando...' : 'Salvar'} {saving || uploading ? 'Salvando...' : 'Salvar'}
</button> </button>
</div> </div>
</form> </form>
</div> </div>
<button type="button" class="modal-backdrop" onclick={closeModal} aria-label="Fechar modal"
></button>
</div> </div>
{/if} {/if}
</div> </main>

View File

@@ -2,8 +2,9 @@
import { api } from '@sgse-app/backend/convex/_generated/api'; import { api } from '@sgse-app/backend/convex/_generated/api';
import type { Doc, Id } from '@sgse-app/backend/convex/_generated/dataModel'; import type { Doc, Id } from '@sgse-app/backend/convex/_generated/dataModel';
import { useConvexClient, useQuery } from 'convex-svelte'; 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 { maskCurrencyBRL } from '$lib/utils/masks';
import { resolve } from '$app/paths';
const client = useConvexClient(); const client = useConvexClient();
@@ -116,56 +117,86 @@
} }
</script> </script>
<div class="container mx-auto p-6"> <main class="container mx-auto flex max-w-7xl flex-col px-4 py-4">
<div class="mb-6 flex items-center justify-between"> <div class="breadcrumbs mb-4 text-sm">
<h1 class="text-2xl font-bold">Objetos</h1> <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 <button
class="btn btn-primary gap-2 shadow-md transition-all hover:shadow-lg"
onclick={() => openModal()} 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} /> <Plus class="h-5 w-5" strokeWidth={2} />
Novo Objeto Novo Objeto
</button> </button>
</div> </div>
</div>
{#if loading} {#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} {:else if error}
<p class="text-red-600">{error}</p> <div class="alert alert-error">
<span>{error}</span>
</div>
{:else} {:else}
<div class="overflow-hidden rounded-lg bg-white shadow-md"> <div class="card bg-base-100/90 border-base-300 border shadow-xl backdrop-blur-sm">
<table class="min-w-full divide-y divide-gray-200"> <div class="card-body p-0">
<thead class="bg-gray-50"> <div class="overflow-x-auto">
<table class="table-zebra table w-full">
<thead class="from-base-300 to-base-200 sticky top-0 z-10 bg-linear-to-r shadow-md">
<tr> <tr>
<th <th class="text-base-content border-base-400 border-b font-bold whitespace-nowrap"
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
>Nome</th >Nome</th
> >
<th <th class="text-base-content border-base-400 border-b font-bold whitespace-nowrap"
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
>Tipo</th >Tipo</th
> >
<th <th class="text-base-content border-base-400 border-b font-bold whitespace-nowrap"
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
>Unidade</th >Unidade</th
> >
<th <th class="text-base-content border-base-400 border-b font-bold whitespace-nowrap"
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
>Valor Estimado</th >Valor Estimado</th
> >
<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 >Ações</th
> >
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-gray-200 bg-white"> <tbody>
{#each objetos as objeto (objeto._id)} {#if objetos.length === 0}
<tr> <tr>
<td class="px-6 py-4 whitespace-nowrap"> <td colspan="5" class="py-12 text-center">
<div class="text-base-content/60 flex flex-col items-center gap-2">
<p class="text-lg font-semibold">Nenhum objeto cadastrado</p>
<p class="text-sm">Clique em “Novo Objeto” para cadastrar.</p>
</div>
</td>
</tr>
{:else}
{#each objetos as objeto (objeto._id)}
<tr class="hover:bg-base-200/50 transition-colors">
<td>
<div class="flex flex-col"> <div class="flex flex-col">
<span class="font-medium">{objeto.nome}</span> <span class="font-medium">{objeto.nome}</span>
<span class="text-xs text-gray-500"> <span class="text-base-content/60 text-xs">
Efisco: {objeto.codigoEfisco} Efisco: {objeto.codigoEfisco}
{#if objeto.codigoCatmat} {#if objeto.codigoCatmat}
| Catmat: {objeto.codigoCatmat}{/if} | Catmat: {objeto.codigoCatmat}{/if}
@@ -174,92 +205,99 @@
</span> </span>
</div> </div>
</td> </td>
<td class="px-6 py-4 whitespace-nowrap"> <td class="whitespace-nowrap">
<span <span
class="inline-flex rounded-full px-2 text-xs leading-5 font-semibold class="badge badge-sm {objeto.tipo === 'servico'
{objeto.tipo === 'servico' ? 'badge-success'
? 'bg-green-100 text-green-800' : 'badge-info'}"
: 'bg-blue-100 text-blue-800'}"
> >
{objeto.tipo === 'material' ? 'Material' : 'Serviço'} {objeto.tipo === 'material' ? 'Material' : 'Serviço'}
</span> </span>
</td> </td>
<td class="px-6 py-4 whitespace-nowrap">{objeto.unidade}</td> <td class="whitespace-nowrap">{objeto.unidade}</td>
<td class="px-6 py-4 whitespace-nowrap"> <td class="whitespace-nowrap">
{maskCurrencyBRL(objeto.valorEstimado) || 'R$ 0,00'} {maskCurrencyBRL(objeto.valorEstimado) || 'R$ 0,00'}
</td> </td>
<td class="px-6 py-4 text-right text-sm font-medium whitespace-nowrap"> <td class="text-right whitespace-nowrap">
<div class="flex items-center justify-end gap-1">
<button <button
type="button"
class="btn btn-ghost btn-sm"
aria-label="Editar objeto"
onclick={() => openModal(objeto)} onclick={() => openModal(objeto)}
class="mr-4 text-indigo-600 hover:text-indigo-900"
> >
<Pencil size={18} /> <Pencil size={18} />
</button> </button>
<button <button
type="button"
class="btn btn-ghost btn-sm text-error"
aria-label="Excluir objeto"
onclick={() => handleDelete(objeto._id)} onclick={() => handleDelete(objeto._id)}
class="text-red-600 hover:text-red-900"
> >
<Trash2 size={18} /> <Trash2 size={18} />
</button> </button>
</div>
</td> </td>
</tr> </tr>
{/each} {/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} {/if}
</tbody> </tbody>
</table> </table>
</div> </div>
</div>
</div>
{/if} {/if}
{#if showModal} {#if showModal}
<div <div class="modal modal-open">
class="fixed inset-0 z-50 flex h-full w-full items-center justify-center overflow-y-auto bg-black/40" <div class="modal-box max-w-2xl">
>
<div class="relative w-full max-w-md rounded-lg bg-white p-8 shadow-xl">
<button <button
type="button"
class="btn btn-sm btn-circle absolute top-2 right-2"
onclick={closeModal} 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> </button>
<h2 class="mb-6 text-xl font-bold">{editingId ? 'Editar' : 'Novo'} Objeto</h2>
<form onsubmit={handleSubmit}> <h3 class="text-lg font-bold">{editingId ? 'Editar' : 'Novo'} Objeto</h3>
<div class="mb-4">
<label class="mb-2 block text-sm font-bold text-gray-700" for="nome"> Nome </label> <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 <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" id="nome"
class="input input-bordered focus:input-primary w-full"
type="text" type="text"
bind:value={formData.nome} bind:value={formData.nome}
required required
/> />
</div> </div>
<div class="mb-4 grid grid-cols-2 gap-4"> <div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div> <div class="form-control w-full">
<label class="mb-2 block text-sm font-bold text-gray-700" for="tipo"> Tipo </label> <label class="label" for="tipo">
<span class="label-text font-semibold">Tipo</span>
</label>
<select <select
class="focus:shadow-outline w-full rounded border px-3 py-2 leading-tight text-gray-700 shadow focus:outline-none"
id="tipo" id="tipo"
class="select select-bordered focus:select-primary w-full"
bind:value={formData.tipo} bind:value={formData.tipo}
> >
<option value="material">Material</option> <option value="material">Material</option>
<option value="servico">Serviço</option> <option value="servico">Serviço</option>
</select> </select>
</div> </div>
<div>
<label class="mb-2 block text-sm font-bold text-gray-700" for="unidade"> <div class="form-control w-full">
Unidade <label class="label" for="unidade">
<span class="label-text font-semibold">Unidade</span>
</label> </label>
<input <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" id="unidade"
class="input input-bordered focus:input-primary w-full"
type="text" type="text"
bind:value={formData.unidade} bind:value={formData.unidade}
required required
@@ -267,13 +305,13 @@
</div> </div>
</div> </div>
<div class="mb-4"> <div class="form-control w-full">
<label class="mb-2 block text-sm font-bold text-gray-700" for="codigoEfisco"> <label class="label" for="codigoEfisco">
Código Efisco <span class="label-text font-semibold">Código Efisco</span>
</label> </label>
<input <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" id="codigoEfisco"
class="input input-bordered focus:input-primary w-full"
type="text" type="text"
bind:value={formData.codigoEfisco} bind:value={formData.codigoEfisco}
required required
@@ -281,88 +319,86 @@
</div> </div>
{#if formData.tipo === 'material'} {#if formData.tipo === 'material'}
<div class="mb-4"> <div class="form-control w-full">
<label class="mb-2 block text-sm font-bold text-gray-700" for="codigoCatmat"> <label class="label" for="codigoCatmat">
Código Catmat <span class="label-text font-semibold">Código Catmat</span>
</label> </label>
<input <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" id="codigoCatmat"
class="input input-bordered focus:input-primary w-full"
type="text" type="text"
bind:value={formData.codigoCatmat} bind:value={formData.codigoCatmat}
/> />
</div> </div>
{:else} {:else}
<div class="mb-4"> <div class="form-control w-full">
<label class="mb-2 block text-sm font-bold text-gray-700" for="codigoCatserv"> <label class="label" for="codigoCatserv">
Código Catserv <span class="label-text font-semibold">Código Catserv</span>
</label> </label>
<input <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" id="codigoCatserv"
class="input input-bordered focus:input-primary w-full"
type="text" type="text"
bind:value={formData.codigoCatserv} bind:value={formData.codigoCatserv}
/> />
</div> </div>
{/if} {/if}
<div class="mb-6"> <div class="form-control w-full">
<label class="mb-2 block text-sm font-bold text-gray-700" for="valor"> <label class="label" for="valor">
Valor Estimado <span class="label-text font-semibold">Valor Estimado</span>
</label> </label>
<input <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" id="valor"
class="input input-bordered focus:input-primary w-full"
type="text" type="text"
placeholder="R$ 0,00"
bind:value={formData.valorEstimado} bind:value={formData.valorEstimado}
oninput={(e) => (formData.valorEstimado = maskCurrencyBRL(e.currentTarget.value))} oninput={(e) => (formData.valorEstimado = maskCurrencyBRL(e.currentTarget.value))}
placeholder="R$ 0,00"
/> />
</div> </div>
<div class="mb-6"> <div class="form-control w-full">
<label class="mb-2 block text-sm font-bold text-gray-700" for="atas"> <label class="label" for="atas">
Vincular Atas <span class="label-text font-semibold">Vincular Atas</span>
</label> </label>
<div class="max-h-40 overflow-y-auto rounded border p-2"> <div class="border-base-300 max-h-48 overflow-y-auto rounded-lg border p-2">
{#if atas.length === 0}
<p class="text-base-content/60 px-2 py-3 text-sm">Nenhuma ata disponível.</p>
{:else}
{#each atas as ata (ata._id)} {#each atas as ata (ata._id)}
<div class="mb-2 flex items-center"> <label
class="hover:bg-base-200/50 flex cursor-pointer items-center gap-3 rounded-md px-2 py-2"
>
<input <input
type="checkbox" type="checkbox"
id={`ata-${ata._id}`} class="checkbox checkbox-primary checkbox-sm"
checked={formData.atas.includes(ata._id)} checked={formData.atas.includes(ata._id)}
onchange={() => toggleAtaSelection(ata._id)} onchange={() => toggleAtaSelection(ata._id)}
class="mr-2 h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" aria-label="Vincular ata {ata.numero}"
/> />
<label for={`ata-${ata._id}`} class="text-sm text-gray-700"> <span class="text-sm">{ata.numero} ({ata.numeroSei})</span>
{ata.numero} ({ata.numeroSei})
</label> </label>
</div>
{/each} {/each}
{#if atas.length === 0}
<p class="text-sm text-gray-500">Nenhuma ata disponível.</p>
{/if} {/if}
</div> </div>
</div> </div>
<div class="flex items-center justify-end"> <div class="modal-action">
<button <button type="button" class="btn" onclick={closeModal} disabled={saving}>
type="button"
onclick={closeModal}
class="mr-2 rounded bg-gray-300 px-4 py-2 font-bold text-gray-800 hover:bg-gray-400"
>
Cancelar Cancelar
</button> </button>
<button <button type="submit" class="btn btn-primary" disabled={saving}>
type="submit" {#if saving}
disabled={saving} <span class="loading loading-spinner loading-sm"></span>
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" {/if}
>
{saving ? 'Salvando...' : 'Salvar'} {saving ? 'Salvando...' : 'Salvar'}
</button> </button>
</div> </div>
</form> </form>
</div> </div>
<button type="button" class="modal-backdrop" onclick={closeModal} aria-label="Fechar modal"
></button>
</div> </div>
{/if} {/if}
</div> </main>

View File

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