Compare commits
13 Commits
feat-style
...
ajustes_ge
| Author | SHA1 | Date | |
|---|---|---|---|
| b76c308aab | |||
| 67b2091d96 | |||
| 1b1d2fb97e | |||
| 16ede85bc2 | |||
|
|
a951f61676 | ||
|
|
98d12d40ef | ||
| 457e89e386 | |||
|
|
10454b38ea | ||
| 6936a59c21 | |||
|
|
813d614648 | ||
| 196ef90643 | |||
|
|
1a56f2ab64 | ||
|
|
52e6805c09 |
@@ -2,8 +2,6 @@
|
||||
import { resolve } from '$app/paths';
|
||||
import logo from '$lib/assets/logo_governo_PE.png';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { aplicarTemaDaisyUI } from '$lib/utils/temas';
|
||||
|
||||
type HeaderProps = {
|
||||
left?: Snippet;
|
||||
@@ -11,43 +9,6 @@
|
||||
};
|
||||
|
||||
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>
|
||||
|
||||
<header
|
||||
@@ -75,11 +36,9 @@
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<select
|
||||
bind:this={themeSelectEl}
|
||||
class="select select-sm bg-base-100 border-base-300 w-40"
|
||||
aria-label="Selecionar tema"
|
||||
data-choose-theme
|
||||
onchange={onThemeChange}
|
||||
>
|
||||
<option value="aqua">Aqua</option>
|
||||
<option value="sgse-blue">Azul</option>
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { useQuery, useConvexClient } from 'convex-svelte';
|
||||
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 { formatDistanceToNow } from 'date-fns';
|
||||
import { ptBR } from 'date-fns/locale';
|
||||
@@ -20,10 +18,9 @@
|
||||
const currentUser = useQuery(api.auth.getCurrentUser, {});
|
||||
|
||||
let modalOpen = $state(false);
|
||||
let usuarioId = $derived((currentUser?.data?._id as Id<'usuarios'> | undefined) ?? null);
|
||||
let notificacoesFerias = $state<
|
||||
Array<{
|
||||
_id: Id<'notificacoesFerias'>;
|
||||
_id: string;
|
||||
mensagem: string;
|
||||
tipo: string;
|
||||
_creationTime: number;
|
||||
@@ -31,7 +28,7 @@
|
||||
>([]);
|
||||
let notificacoesAusencias = $state<
|
||||
Array<{
|
||||
_id: Id<'notificacoesAusencias'>;
|
||||
_id: string;
|
||||
mensagem: string;
|
||||
tipo: string;
|
||||
_creationTime: number;
|
||||
@@ -50,40 +47,43 @@
|
||||
// Separar notificações lidas e não lidas
|
||||
let notificacoesNaoLidas = $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
|
||||
$effect(() => {
|
||||
const totalNotificacoes =
|
||||
count + (notificacoesFerias?.length || 0) + (notificacoesAusencias?.length || 0);
|
||||
$notificacoesCount = totalNotificacoes;
|
||||
notificacoesCount.set(totalNotificacoes);
|
||||
});
|
||||
|
||||
// Buscar notificações de férias
|
||||
async function buscarNotificacoesFerias(id: Id<'usuarios'> | null) {
|
||||
async function buscarNotificacoesFerias() {
|
||||
try {
|
||||
if (!id) return;
|
||||
const usuarioId = currentUser?.data?._id;
|
||||
if (usuarioId) {
|
||||
const notifsFerias = await client.query(api.ferias.obterNotificacoesNaoLidas, {
|
||||
usuarioId: id
|
||||
usuarioId
|
||||
});
|
||||
notificacoesFerias = notifsFerias || [];
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Erro ao buscar notificações de férias:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Buscar notificações de ausências
|
||||
async function buscarNotificacoesAusencias(id: Id<'usuarios'> | null) {
|
||||
async function buscarNotificacoesAusencias() {
|
||||
try {
|
||||
if (!id) return;
|
||||
const usuarioId = currentUser?.data?._id;
|
||||
if (usuarioId) {
|
||||
try {
|
||||
const notifsAusencias = await client.query(api.ausencias.obterNotificacoesNaoLidas, {
|
||||
usuarioId: id
|
||||
usuarioId
|
||||
});
|
||||
notificacoesAusencias = notifsAusencias || [];
|
||||
} catch (queryError: unknown) {
|
||||
// Silenciar erros de timeout e função não encontrada
|
||||
const errorMessage = queryError instanceof Error ? queryError.message : String(queryError);
|
||||
const errorMessage =
|
||||
queryError instanceof Error ? queryError.message : String(queryError);
|
||||
const isTimeout = errorMessage.includes('timed out') || errorMessage.includes('timeout');
|
||||
const isFunctionNotFound = errorMessage.includes('Could not find public function');
|
||||
|
||||
@@ -92,6 +92,7 @@
|
||||
}
|
||||
notificacoesAusencias = [];
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Erro geral - silenciar se for sobre função não encontrada ou timeout
|
||||
const errorMessage = e instanceof Error ? e.message : String(e);
|
||||
@@ -105,15 +106,13 @@
|
||||
}
|
||||
|
||||
// Atualizar notificações periodicamente
|
||||
onMount(() => {
|
||||
void buscarNotificacoesFerias(usuarioId);
|
||||
void buscarNotificacoesAusencias(usuarioId);
|
||||
|
||||
$effect(() => {
|
||||
buscarNotificacoesFerias();
|
||||
buscarNotificacoesAusencias();
|
||||
const interval = setInterval(() => {
|
||||
void buscarNotificacoesFerias(usuarioId);
|
||||
void buscarNotificacoesAusencias(usuarioId);
|
||||
buscarNotificacoesFerias();
|
||||
buscarNotificacoesAusencias();
|
||||
}, 30000); // A cada 30s
|
||||
|
||||
return () => clearInterval(interval);
|
||||
});
|
||||
|
||||
@@ -128,12 +127,30 @@
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
limpandoNotificacoes = true;
|
||||
try {
|
||||
await client.mutation(api.chat.limparTodasNotificacoes, {});
|
||||
await buscarNotificacoesFerias(usuarioId);
|
||||
await buscarNotificacoesAusencias(usuarioId);
|
||||
await buscarNotificacoesFerias();
|
||||
await buscarNotificacoesAusencias();
|
||||
} catch (error) {
|
||||
console.error('Erro ao limpar notificações:', error);
|
||||
} finally {
|
||||
@@ -145,8 +162,8 @@
|
||||
limpandoNotificacoes = true;
|
||||
try {
|
||||
await client.mutation(api.chat.limparNotificacoesNaoLidas, {});
|
||||
await buscarNotificacoesFerias(usuarioId);
|
||||
await buscarNotificacoesAusencias(usuarioId);
|
||||
await buscarNotificacoesFerias();
|
||||
await buscarNotificacoesAusencias();
|
||||
} catch (error) {
|
||||
console.error('Erro ao limpar notificações não lidas:', error);
|
||||
} finally {
|
||||
@@ -156,24 +173,24 @@
|
||||
|
||||
async function handleClickNotificacao(notificacaoId: string) {
|
||||
await client.mutation(api.chat.marcarNotificacaoLida, {
|
||||
notificacaoId: notificacaoId as Id<'notificacoes'>
|
||||
notificacaoId: notificacaoId as any
|
||||
});
|
||||
}
|
||||
|
||||
async function handleClickNotificacaoFerias(notificacaoId: Id<'notificacoesFerias'>) {
|
||||
async function handleClickNotificacaoFerias(notificacaoId: string) {
|
||||
await client.mutation(api.ferias.marcarComoLida, {
|
||||
notificacaoId: notificacaoId
|
||||
});
|
||||
await buscarNotificacoesFerias(usuarioId);
|
||||
await buscarNotificacoesFerias();
|
||||
// Redirecionar para a página de férias
|
||||
window.location.href = '/recursos-humanos/ferias';
|
||||
}
|
||||
|
||||
async function handleClickNotificacaoAusencias(notificacaoId: Id<'notificacoesAusencias'>) {
|
||||
async function handleClickNotificacaoAusencias(notificacaoId: string) {
|
||||
await client.mutation(api.ausencias.marcarComoLida, {
|
||||
notificacaoId: notificacaoId
|
||||
});
|
||||
await buscarNotificacoesAusencias(usuarioId);
|
||||
await buscarNotificacoesAusencias();
|
||||
// Redirecionar para a página de perfil na aba de ausências
|
||||
window.location.href = '/perfil?aba=minhas-ausencias';
|
||||
}
|
||||
@@ -187,19 +204,19 @@
|
||||
}
|
||||
|
||||
// Fechar popup ao clicar fora ou pressionar Escape
|
||||
onMount(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
$effect(() => {
|
||||
if (!modalOpen) return;
|
||||
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
const target = event.target as HTMLElement;
|
||||
if (!target.closest('.notification-popup') && !target.closest('.notification-bell')) {
|
||||
closeModal();
|
||||
modalOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleEscape(event: KeyboardEvent) {
|
||||
if (!modalOpen) return;
|
||||
if (event.key === 'Escape') {
|
||||
closeModal();
|
||||
modalOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -213,32 +230,56 @@
|
||||
</script>
|
||||
|
||||
<div class="notification-bell relative">
|
||||
<!-- Botão de Notificação (padrão do tema) -->
|
||||
<div class="indicator">
|
||||
{#if totalCount > 0}
|
||||
<span class="indicator-item badge badge-error badge-sm">
|
||||
{totalCount > 9 ? '9+' : totalCount}
|
||||
</span>
|
||||
{/if}
|
||||
<!-- Botão de Notificação ULTRA MODERNO (igual ao perfil) -->
|
||||
<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"
|
||||
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"
|
||||
aria-expanded={modalOpen}
|
||||
>
|
||||
<!-- 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="size-6 transition-colors {totalCount > 0 ? 'text-primary' : 'text-base-content/70'}"
|
||||
style="animation: {totalCount > 0 ? 'bell-ring 2s ease-in-out infinite' : 'none'};"
|
||||
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}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Popup Flutuante de Notificações -->
|
||||
{#if modalOpen}
|
||||
<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;"
|
||||
>
|
||||
<!-- Header -->
|
||||
@@ -269,7 +310,7 @@
|
||||
Limpar todas
|
||||
</button>
|
||||
{/if}
|
||||
<button type="button" class="btn btn-sm btn-circle" onclick={closeModal}>
|
||||
<button type="button" class="btn btn-sm btn-circle btn-ghost" onclick={closeModal}>
|
||||
<X class="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -398,17 +439,17 @@
|
||||
<!-- Notificações de Férias -->
|
||||
{#if notificacoesFerias.length > 0}
|
||||
<div class="mb-4">
|
||||
<h4 class="text-secondary mb-2 px-2 text-sm font-semibold">Férias</h4>
|
||||
<h4 class="mb-2 px-2 text-sm font-semibold text-purple-600">Férias</h4>
|
||||
{#each notificacoesFerias as notificacao (notificacao._id)}
|
||||
<button
|
||||
type="button"
|
||||
class="hover:bg-base-200 border-secondary mb-2 w-full rounded-lg border-l-4 px-4 py-3 text-left transition-colors"
|
||||
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"
|
||||
onclick={() => handleClickNotificacaoFerias(notificacao._id)}
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<!-- Ícone -->
|
||||
<div class="mt-1 shrink-0">
|
||||
<Calendar class="text-secondary h-5 w-5" strokeWidth={2} />
|
||||
<Calendar class="h-5 w-5 text-purple-600" strokeWidth={2} />
|
||||
</div>
|
||||
|
||||
<!-- Conteúdo -->
|
||||
@@ -423,7 +464,7 @@
|
||||
|
||||
<!-- Badge -->
|
||||
<div class="shrink-0">
|
||||
<div class="badge badge-secondary badge-xs"></div>
|
||||
<div class="badge badge-primary badge-xs"></div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
@@ -434,17 +475,17 @@
|
||||
<!-- Notificações de Ausências -->
|
||||
{#if notificacoesAusencias.length > 0}
|
||||
<div class="mb-4">
|
||||
<h4 class="text-warning mb-2 px-2 text-sm font-semibold">Ausências</h4>
|
||||
<h4 class="mb-2 px-2 text-sm font-semibold text-orange-600">Ausências</h4>
|
||||
{#each notificacoesAusencias as notificacao (notificacao._id)}
|
||||
<button
|
||||
type="button"
|
||||
class="hover:bg-base-200 border-warning mb-2 w-full rounded-lg border-l-4 px-4 py-3 text-left transition-colors"
|
||||
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"
|
||||
onclick={() => handleClickNotificacaoAusencias(notificacao._id)}
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<!-- Ícone -->
|
||||
<div class="mt-1 shrink-0">
|
||||
<Clock class="text-warning h-5 w-5" strokeWidth={2} />
|
||||
<Clock class="h-5 w-5 text-orange-600" strokeWidth={2} />
|
||||
</div>
|
||||
|
||||
<!-- Conteúdo -->
|
||||
@@ -498,6 +539,28 @@
|
||||
</div>
|
||||
|
||||
<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 {
|
||||
0%,
|
||||
100% {
|
||||
|
||||
@@ -40,6 +40,9 @@
|
||||
if (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'));
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -9,12 +9,21 @@
|
||||
|
||||
let tempoAtual = $state<Date>(new Date());
|
||||
let sincronizado = $state(false);
|
||||
let sincronizando = $state(false);
|
||||
let usandoServidorExterno = $state(false);
|
||||
let offsetSegundos = $state(0);
|
||||
let erro = $state<string | null>(null);
|
||||
let intervalId: ReturnType<typeof setInterval> | null = null;
|
||||
let intervaloSincronizacao: ReturnType<typeof setInterval> | null = null;
|
||||
let sincronizacaoEmAndamento = $state(false); // Flag para evitar múltiplas sincronizações simultâneas
|
||||
|
||||
async function atualizarTempo() {
|
||||
// Evitar múltiplas sincronizações simultâneas
|
||||
if (sincronizacaoEmAndamento) {
|
||||
return;
|
||||
}
|
||||
sincronizacaoEmAndamento = true;
|
||||
sincronizando = true;
|
||||
try {
|
||||
const config = await client.query(api.configuracaoRelogio.obterConfiguracao, {});
|
||||
// Usar gmtOffset da configuração, sem valor padrão, pois 0 é um valor válido
|
||||
@@ -25,7 +34,12 @@
|
||||
|
||||
if (config.usarServidorExterno) {
|
||||
try {
|
||||
const resultado = await client.action(api.configuracaoRelogio.sincronizarTempo, {});
|
||||
// Adicionar timeout de 10 segundos para sincronização
|
||||
const sincronizacaoPromise = client.action(api.configuracaoRelogio.sincronizarTempo, {});
|
||||
const timeoutPromise = new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Timeout na sincronização (10s)')), 10000)
|
||||
);
|
||||
const resultado = await Promise.race([sincronizacaoPromise, timeoutPromise]);
|
||||
if (resultado.sucesso && resultado.timestamp) {
|
||||
timestampBase = resultado.timestamp;
|
||||
sincronizado = true;
|
||||
@@ -43,7 +57,11 @@
|
||||
usandoServidorExterno = false;
|
||||
erro = 'Usando relógio do PC (falha na sincronização)';
|
||||
} else {
|
||||
throw error;
|
||||
// Mesmo sem fallback configurado, usar PC como última opção
|
||||
timestampBase = obterTempoPC();
|
||||
sincronizado = false;
|
||||
usandoServidorExterno = false;
|
||||
erro = 'Usando relógio do PC (servidor indisponível)';
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -71,6 +89,9 @@
|
||||
tempoAtual = new Date(obterTempoPC());
|
||||
sincronizado = false;
|
||||
erro = 'Erro ao obter tempo do servidor';
|
||||
} finally {
|
||||
sincronizando = false;
|
||||
sincronizacaoEmAndamento = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,17 +102,34 @@
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
await atualizarTempo();
|
||||
// Sincronizar a cada 30 segundos
|
||||
setInterval(atualizarTempo, 30000);
|
||||
// Inicializar com relógio do PC imediatamente para não bloquear a interface
|
||||
tempoAtual = new Date(obterTempoPC());
|
||||
sincronizado = false;
|
||||
erro = 'Usando relógio do PC';
|
||||
// Atualizar display a cada segundo
|
||||
intervalId = setInterval(atualizarRelogio, 1000);
|
||||
// Sincronizar em background (não bloquear) após um pequeno delay para garantir que a UI está renderizada
|
||||
setTimeout(() => {
|
||||
atualizarTempo().catch((error) => {
|
||||
console.error('Erro ao sincronizar tempo em background:', error);
|
||||
});
|
||||
}, 100);
|
||||
// Sincronizar a cada 30 segundos
|
||||
intervaloSincronizacao = setInterval(() => {
|
||||
atualizarTempo().catch((error) => {
|
||||
console.error('Erro ao sincronizar tempo periódico:', error);
|
||||
});
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId);
|
||||
}
|
||||
if (intervaloSincronizacao) {
|
||||
clearInterval(intervaloSincronizacao);
|
||||
}
|
||||
sincronizacaoEmAndamento = false;
|
||||
});
|
||||
|
||||
const horaFormatada = $derived.by(() => {
|
||||
@@ -131,13 +169,18 @@
|
||||
|
||||
<!-- Status de Sincronização -->
|
||||
<div
|
||||
class="flex items-center gap-2 rounded-full px-4 py-2 {sincronizado
|
||||
class="flex items-center gap-2 rounded-full px-4 py-2 {sincronizando
|
||||
? 'bg-info/20 text-info border-info/30 border animate-pulse'
|
||||
: sincronizado
|
||||
? 'bg-success/20 text-success border-success/30 border'
|
||||
: erro
|
||||
? 'bg-warning/20 text-warning border-warning/30 border'
|
||||
: 'bg-base-300/50 text-base-content/60 border-base-300 border'}"
|
||||
>
|
||||
{#if sincronizado}
|
||||
{#if sincronizando}
|
||||
<span class="loading loading-spinner loading-sm text-info"></span>
|
||||
<span class="text-sm font-semibold">Sincronizando com servidor...</span>
|
||||
{:else if sincronizado}
|
||||
<CheckCircle2 class="h-4 w-4" strokeWidth={2.5} />
|
||||
<span class="text-sm font-semibold">
|
||||
{#if usandoServidorExterno}
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
<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>
|
||||
@@ -1,174 +0,0 @@
|
||||
<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>
|
||||
@@ -1,287 +0,0 @@
|
||||
<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>
|
||||
@@ -444,3 +444,9 @@ export function adicionarRodape(doc: jsPDF): void {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -144,52 +144,6 @@ export function obterNomeDaisyUI(id: TemaId | string | null | undefined): string
|
||||
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
|
||||
* NÃO salva no localStorage - apenas no banco de dados do usuário
|
||||
|
||||
@@ -82,4 +82,10 @@
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<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>
|
||||
@@ -93,3 +95,4 @@
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
</ProtectedRoute>
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||
import type { Doc, Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||
import { useConvexClient, useQuery } from 'convex-svelte';
|
||||
import { Pencil, Plus, Trash2, X, Search, Check, FileText } from 'lucide-svelte';
|
||||
import { resolve } from '$app/paths';
|
||||
import { Pencil, Plus, Trash2, X, Search, Check } from 'lucide-svelte';
|
||||
|
||||
const client = useConvexClient();
|
||||
|
||||
@@ -190,173 +189,140 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<main class="container mx-auto flex max-w-7xl flex-col px-4 py-4">
|
||||
<div class="breadcrumbs mb-4 text-sm">
|
||||
<ul>
|
||||
<li><a href={resolve('/')} class="text-primary hover:underline">Dashboard</a></li>
|
||||
<li><a href={resolve('/compras')} class="text-primary hover:underline">Compras</a></li>
|
||||
<li>Atas</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<div class="flex flex-col items-start justify-between gap-4 sm:flex-row sm:items-center">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="bg-accent/10 rounded-xl p-3">
|
||||
<FileText class="text-accent h-8 w-8" strokeWidth={2} />
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-primary text-3xl font-bold">Atas de Registro de Preços</h1>
|
||||
<p class="text-base-content/70">Gerencie atas, vigência, empresa e anexos</p>
|
||||
</div>
|
||||
</div>
|
||||
<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
|
||||
class="btn btn-primary gap-2 shadow-md transition-all hover:shadow-lg"
|
||||
onclick={() => openModal()}
|
||||
class="flex items-center gap-2 rounded-md bg-blue-600 px-4 py-2 text-white hover:bg-blue-700"
|
||||
>
|
||||
<Plus class="h-5 w-5" strokeWidth={2} />
|
||||
<Plus size={20} />
|
||||
Nova Ata
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if loadingAtas}
|
||||
<div class="flex items-center justify-center py-10">
|
||||
<span class="loading loading-spinner loading-lg"></span>
|
||||
</div>
|
||||
<p>Carregando...</p>
|
||||
{:else if errorAtas}
|
||||
<div class="alert alert-error">
|
||||
<span>{errorAtas}</span>
|
||||
</div>
|
||||
<p class="text-red-600">{errorAtas}</p>
|
||||
{:else}
|
||||
<div class="card bg-base-100/90 border-base-300 border shadow-xl backdrop-blur-sm">
|
||||
<div class="card-body p-0">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table-zebra table w-full">
|
||||
<thead class="from-base-300 to-base-200 sticky top-0 z-10 bg-linear-to-r shadow-md">
|
||||
<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="text-base-content border-base-400 border-b font-bold whitespace-nowrap"
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
|
||||
>Número</th
|
||||
>
|
||||
<th class="text-base-content border-base-400 border-b font-bold whitespace-nowrap"
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
|
||||
>SEI</th
|
||||
>
|
||||
<th class="text-base-content border-base-400 border-b font-bold whitespace-nowrap"
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
|
||||
>Empresa</th
|
||||
>
|
||||
<th class="text-base-content border-base-400 border-b font-bold whitespace-nowrap"
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
|
||||
>Vigência</th
|
||||
>
|
||||
<th
|
||||
class="text-base-content border-base-400 border-b text-right font-bold whitespace-nowrap"
|
||||
class="px-6 py-3 text-right text-xs font-medium tracking-wider text-gray-500 uppercase"
|
||||
>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">
|
||||
<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}
|
||||
<tbody class="divide-y divide-gray-200 bg-white">
|
||||
{#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>
|
||||
<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 whitespace-nowrap"
|
||||
class="max-w-md truncate px-6 py-4 whitespace-nowrap"
|
||||
title={getEmpresaNome(ata.empresaId)}
|
||||
>
|
||||
{getEmpresaNome(ata.empresaId)}
|
||||
</td>
|
||||
<td class="text-base-content/70 whitespace-nowrap">
|
||||
<td class="px-6 py-4 text-sm whitespace-nowrap text-gray-500">
|
||||
{ata.dataInicio || '-'} a {ata.dataFim || '-'}
|
||||
</td>
|
||||
<td class="text-right whitespace-nowrap">
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<td class="px-6 py-4 text-right text-sm font-medium whitespace-nowrap">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-sm"
|
||||
aria-label="Editar ata"
|
||||
onclick={() => openModal(ata)}
|
||||
class="mr-4 text-indigo-600 hover:text-indigo-900"
|
||||
>
|
||||
<Pencil size={18} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-sm text-error"
|
||||
aria-label="Excluir ata"
|
||||
onclick={() => handleDelete(ata._id)}
|
||||
class="text-red-600 hover:text-red-900"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showModal}
|
||||
<div class="modal modal-open">
|
||||
<div class="modal-box max-w-4xl">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-circle absolute top-2 right-2"
|
||||
onclick={closeModal}
|
||||
aria-label="Fechar modal"
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex h-full w-full items-center justify-center overflow-y-auto bg-black/40"
|
||||
>
|
||||
<X class="h-5 w-5" />
|
||||
<div class="relative my-8 w-full max-w-2xl rounded-lg bg-white p-8 shadow-xl">
|
||||
<button
|
||||
onclick={closeModal}
|
||||
class="absolute top-4 right-4 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<X size={24} />
|
||||
</button>
|
||||
<h2 class="mb-6 text-xl font-bold">{editingId ? 'Editar' : 'Nova'} Ata</h2>
|
||||
|
||||
<h3 class="text-lg font-bold">{editingId ? 'Editar' : 'Nova'} Ata</h3>
|
||||
|
||||
<form class="mt-6 space-y-6" onsubmit={handleSubmit}>
|
||||
<form onsubmit={handleSubmit}>
|
||||
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<div class="space-y-4">
|
||||
<div class="form-control w-full">
|
||||
<label class="label" for="numero">
|
||||
<span class="label-text font-semibold">Número da Ata</span>
|
||||
<div>
|
||||
<div class="mb-4">
|
||||
<label class="mb-2 block text-sm font-bold text-gray-700" for="numero">
|
||||
Número da Ata
|
||||
</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="form-control w-full">
|
||||
<label class="label" for="numeroSei">
|
||||
<span class="label-text font-semibold">Número SEI</span>
|
||||
<div class="mb-4">
|
||||
<label class="mb-2 block text-sm font-bold text-gray-700" for="numeroSei">
|
||||
Número SEI
|
||||
</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="form-control w-full">
|
||||
<label class="label" for="empresa">
|
||||
<span class="label-text font-semibold">Empresa</span>
|
||||
<div class="mb-4">
|
||||
<label class="mb-2 block text-sm font-bold text-gray-700" for="empresa">
|
||||
Empresa
|
||||
</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
|
||||
>
|
||||
@@ -367,25 +333,25 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div class="form-control w-full">
|
||||
<label class="label" for="dataInicio">
|
||||
<span class="label-text font-semibold">Data Início</span>
|
||||
<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
|
||||
</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 class="form-control w-full">
|
||||
<label class="label" for="dataFim">
|
||||
<span class="label-text font-semibold">Data Fim</span>
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-bold text-gray-700" for="dataFim">
|
||||
Data Fim
|
||||
</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}
|
||||
/>
|
||||
@@ -393,89 +359,81 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="font-semibold">Objetos Vinculados</div>
|
||||
<span class="badge badge-outline">{selectedObjetos.length}</span>
|
||||
</div>
|
||||
|
||||
<div class="form-control w-full">
|
||||
<label class="label" for="buscar_objeto">
|
||||
<span class="label-text font-semibold">Buscar objetos</span>
|
||||
<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">
|
||||
|
||||
<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-base-content/40" />
|
||||
<Search size={16} class="text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
id="buscar_objeto"
|
||||
type="text"
|
||||
placeholder="Digite para filtrar..."
|
||||
class="input input-bordered focus:input-primary w-full pl-10"
|
||||
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>
|
||||
</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'
|
||||
: ''}"
|
||||
<div
|
||||
class="mb-4 flex-1 overflow-y-auto rounded border bg-gray-50 p-2"
|
||||
style="max-height: 200px;"
|
||||
>
|
||||
<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 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}
|
||||
</label>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="border-base-300 border-t pt-4">
|
||||
<div class="font-semibold">Anexos</div>
|
||||
<div class="mt-2 space-y-2">
|
||||
<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
|
||||
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"
|
||||
>
|
||||
<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 gap-2 text-sm">
|
||||
<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="link link-primary max-w-[260px] truncate"
|
||||
class="max-w-[150px] truncate text-blue-600 hover:underline"
|
||||
>
|
||||
{doc.nome}
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs text-error"
|
||||
onclick={() => handleDeleteAttachment(doc._id)}
|
||||
aria-label="Excluir anexo"
|
||||
class="text-red-500 hover:text-red-700"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
@@ -486,23 +444,25 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn" onclick={closeModal} disabled={saving || uploading}>
|
||||
<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"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary" disabled={saving || uploading}>
|
||||
{#if saving || uploading}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
{/if}
|
||||
<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"
|
||||
>
|
||||
{saving || uploading ? 'Salvando...' : 'Salvar'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<button type="button" class="modal-backdrop" onclick={closeModal} aria-label="Fechar modal"
|
||||
></button>
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -2,9 +2,8 @@
|
||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||
import type { Doc, Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||
import { useConvexClient, useQuery } from 'convex-svelte';
|
||||
import { Pencil, Plus, Trash2, X, Package } from 'lucide-svelte';
|
||||
import { Pencil, Plus, Trash2, X } from 'lucide-svelte';
|
||||
import { maskCurrencyBRL } from '$lib/utils/masks';
|
||||
import { resolve } from '$app/paths';
|
||||
|
||||
const client = useConvexClient();
|
||||
|
||||
@@ -117,86 +116,56 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<main class="container mx-auto flex max-w-7xl flex-col px-4 py-4">
|
||||
<div class="breadcrumbs mb-4 text-sm">
|
||||
<ul>
|
||||
<li><a href={resolve('/')} class="text-primary hover:underline">Dashboard</a></li>
|
||||
<li><a href={resolve('/compras')} class="text-primary hover:underline">Compras</a></li>
|
||||
<li>Objetos</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<div class="flex flex-col items-start justify-between gap-4 sm:flex-row sm:items-center">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="bg-primary/10 rounded-xl p-3">
|
||||
<Package class="text-primary h-8 w-8" strokeWidth={2} />
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-primary text-3xl font-bold">Objetos</h1>
|
||||
<p class="text-base-content/70">Cadastro e gestão de objetos e serviços</p>
|
||||
</div>
|
||||
</div>
|
||||
<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
|
||||
class="btn btn-primary gap-2 shadow-md transition-all hover:shadow-lg"
|
||||
onclick={() => openModal()}
|
||||
class="flex items-center gap-2 rounded-md bg-blue-600 px-4 py-2 text-white hover:bg-blue-700"
|
||||
>
|
||||
<Plus class="h-5 w-5" strokeWidth={2} />
|
||||
<Plus size={20} />
|
||||
Novo Objeto
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="flex items-center justify-center py-10">
|
||||
<span class="loading loading-spinner loading-lg"></span>
|
||||
</div>
|
||||
<p>Carregando...</p>
|
||||
{:else if error}
|
||||
<div class="alert alert-error">
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
<p class="text-red-600">{error}</p>
|
||||
{:else}
|
||||
<div class="card bg-base-100/90 border-base-300 border shadow-xl backdrop-blur-sm">
|
||||
<div class="card-body p-0">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table-zebra table w-full">
|
||||
<thead class="from-base-300 to-base-200 sticky top-0 z-10 bg-linear-to-r shadow-md">
|
||||
<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="text-base-content border-base-400 border-b font-bold whitespace-nowrap"
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
|
||||
>Nome</th
|
||||
>
|
||||
<th class="text-base-content border-base-400 border-b font-bold whitespace-nowrap"
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
|
||||
>Tipo</th
|
||||
>
|
||||
<th class="text-base-content border-base-400 border-b font-bold whitespace-nowrap"
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
|
||||
>Unidade</th
|
||||
>
|
||||
<th class="text-base-content border-base-400 border-b font-bold whitespace-nowrap"
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
|
||||
>Valor Estimado</th
|
||||
>
|
||||
<th
|
||||
class="text-base-content border-base-400 border-b text-right font-bold whitespace-nowrap"
|
||||
class="px-6 py-3 text-right text-xs font-medium tracking-wider text-gray-500 uppercase"
|
||||
>Ações</th
|
||||
>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#if objetos.length === 0}
|
||||
<tr>
|
||||
<td colspan="5" class="py-12 text-center">
|
||||
<div class="text-base-content/60 flex flex-col items-center gap-2">
|
||||
<p class="text-lg font-semibold">Nenhum objeto cadastrado</p>
|
||||
<p class="text-sm">Clique em “Novo Objeto” para cadastrar.</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{:else}
|
||||
<tbody class="divide-y divide-gray-200 bg-white">
|
||||
{#each objetos as objeto (objeto._id)}
|
||||
<tr class="hover:bg-base-200/50 transition-colors">
|
||||
<td>
|
||||
<tr>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="flex flex-col">
|
||||
<span class="font-medium">{objeto.nome}</span>
|
||||
<span class="text-base-content/60 text-xs">
|
||||
<span class="text-xs text-gray-500">
|
||||
Efisco: {objeto.codigoEfisco}
|
||||
{#if objeto.codigoCatmat}
|
||||
| Catmat: {objeto.codigoCatmat}{/if}
|
||||
@@ -205,99 +174,92 @@
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="whitespace-nowrap">
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span
|
||||
class="badge badge-sm {objeto.tipo === 'servico'
|
||||
? 'badge-success'
|
||||
: 'badge-info'}"
|
||||
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'}"
|
||||
>
|
||||
{objeto.tipo === 'material' ? 'Material' : 'Serviço'}
|
||||
</span>
|
||||
</td>
|
||||
<td class="whitespace-nowrap">{objeto.unidade}</td>
|
||||
<td class="whitespace-nowrap">
|
||||
<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="text-right whitespace-nowrap">
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<td class="px-6 py-4 text-right text-sm font-medium whitespace-nowrap">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-sm"
|
||||
aria-label="Editar objeto"
|
||||
onclick={() => openModal(objeto)}
|
||||
class="mr-4 text-indigo-600 hover:text-indigo-900"
|
||||
>
|
||||
<Pencil size={18} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-sm text-error"
|
||||
aria-label="Excluir objeto"
|
||||
onclick={() => handleDelete(objeto._id)}
|
||||
class="text-red-600 hover:text-red-900"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showModal}
|
||||
<div class="modal modal-open">
|
||||
<div class="modal-box max-w-2xl">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-circle absolute top-2 right-2"
|
||||
onclick={closeModal}
|
||||
aria-label="Fechar modal"
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex h-full w-full items-center justify-center overflow-y-auto bg-black/40"
|
||||
>
|
||||
<X class="h-5 w-5" />
|
||||
<div class="relative w-full max-w-md rounded-lg bg-white p-8 shadow-xl">
|
||||
<button
|
||||
onclick={closeModal}
|
||||
class="absolute top-4 right-4 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<X size={24} />
|
||||
</button>
|
||||
<h2 class="mb-6 text-xl font-bold">{editingId ? 'Editar' : 'Novo'} Objeto</h2>
|
||||
|
||||
<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>
|
||||
<form onsubmit={handleSubmit}>
|
||||
<div class="mb-4">
|
||||
<label class="mb-2 block text-sm font-bold text-gray-700" for="nome"> Nome </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="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>
|
||||
<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>
|
||||
<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 class="form-control w-full">
|
||||
<label class="label" for="unidade">
|
||||
<span class="label-text font-semibold">Unidade</span>
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-bold text-gray-700" for="unidade">
|
||||
Unidade
|
||||
</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
|
||||
@@ -305,13 +267,13 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-control w-full">
|
||||
<label class="label" for="codigoEfisco">
|
||||
<span class="label-text font-semibold">Código Efisco</span>
|
||||
<div class="mb-4">
|
||||
<label class="mb-2 block text-sm font-bold text-gray-700" for="codigoEfisco">
|
||||
Código Efisco
|
||||
</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
|
||||
@@ -319,86 +281,88 @@
|
||||
</div>
|
||||
|
||||
{#if formData.tipo === 'material'}
|
||||
<div class="form-control w-full">
|
||||
<label class="label" for="codigoCatmat">
|
||||
<span class="label-text font-semibold">Código Catmat</span>
|
||||
<div class="mb-4">
|
||||
<label class="mb-2 block text-sm font-bold text-gray-700" for="codigoCatmat">
|
||||
Código Catmat
|
||||
</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="form-control w-full">
|
||||
<label class="label" for="codigoCatserv">
|
||||
<span class="label-text font-semibold">Código Catserv</span>
|
||||
<div class="mb-4">
|
||||
<label class="mb-2 block text-sm font-bold text-gray-700" for="codigoCatserv">
|
||||
Código Catserv
|
||||
</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="form-control w-full">
|
||||
<label class="label" for="valor">
|
||||
<span class="label-text font-semibold">Valor Estimado</span>
|
||||
<div class="mb-6">
|
||||
<label class="mb-2 block text-sm font-bold text-gray-700" for="valor">
|
||||
Valor Estimado
|
||||
</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="form-control w-full">
|
||||
<label class="label" for="atas">
|
||||
<span class="label-text font-semibold">Vincular Atas</span>
|
||||
<div class="mb-6">
|
||||
<label class="mb-2 block text-sm font-bold text-gray-700" for="atas">
|
||||
Vincular Atas
|
||||
</label>
|
||||
<div class="border-base-300 max-h-48 overflow-y-auto rounded-lg border p-2">
|
||||
{#if atas.length === 0}
|
||||
<p class="text-base-content/60 px-2 py-3 text-sm">Nenhuma ata disponível.</p>
|
||||
{:else}
|
||||
<div class="max-h-40 overflow-y-auto rounded border p-2">
|
||||
{#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"
|
||||
>
|
||||
<div class="mb-2 flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-primary checkbox-sm"
|
||||
id={`ata-${ata._id}`}
|
||||
checked={formData.atas.includes(ata._id)}
|
||||
onchange={() => toggleAtaSelection(ata._id)}
|
||||
aria-label="Vincular ata {ata.numero}"
|
||||
class="mr-2 h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span class="text-sm">{ata.numero} ({ata.numeroSei})</span>
|
||||
<label for={`ata-${ata._id}`} class="text-sm text-gray-700">
|
||||
{ata.numero} ({ata.numeroSei})
|
||||
</label>
|
||||
</div>
|
||||
{/each}
|
||||
{#if atas.length === 0}
|
||||
<p class="text-sm text-gray-500">Nenhuma ata disponível.</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn" onclick={closeModal} disabled={saving}>
|
||||
<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"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary" disabled={saving}>
|
||||
{#if saving}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
{/if}
|
||||
<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"
|
||||
>
|
||||
{saving ? 'Salvando...' : 'Salvar'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<button type="button" class="modal-backdrop" onclick={closeModal} aria-label="Fechar modal"
|
||||
></button>
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
Users,
|
||||
Inbox,
|
||||
Search,
|
||||
TriangleAlert,
|
||||
AlertTriangle,
|
||||
User
|
||||
} from 'lucide-svelte';
|
||||
import type { FunctionReturnType } from 'convex/server';
|
||||
@@ -30,7 +30,6 @@
|
||||
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);
|
||||
@@ -49,7 +48,6 @@
|
||||
if (!funcionarioToDelete) return;
|
||||
|
||||
try {
|
||||
deleting = true;
|
||||
await client.mutation(api.funcionarios.remove, { id: funcionarioToDelete._id });
|
||||
closeDeleteModal();
|
||||
notice = {
|
||||
@@ -58,8 +56,6 @@
|
||||
};
|
||||
} catch {
|
||||
notice = { kind: 'error', text: 'Erro ao excluir cadastro. Tente novamente.' };
|
||||
} finally {
|
||||
deleting = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -247,12 +243,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">
|
||||
<TriangleAlert class="h-7 w-7" strokeWidth={2} />
|
||||
<AlertTriangle class="h-7 w-7" strokeWidth={2} />
|
||||
Confirmar Exclusão
|
||||
</h3>
|
||||
|
||||
<div class="alert alert-warning mb-4">
|
||||
<TriangleAlert class="h-6 w-6 shrink-0 stroke-current" strokeWidth={2} />
|
||||
<AlertTriangle 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>
|
||||
@@ -284,16 +280,16 @@
|
||||
{/if}
|
||||
|
||||
<div class="modal-action justify-between">
|
||||
<button class="btn gap-2" onclick={closeDeleteModal} disabled={deleting}>
|
||||
<button class="btn gap-2" onclick={closeDeleteModal} disabled={funcionarioToDelete !== null}>
|
||||
<X class="h-5 w-5" strokeWidth={2} />
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-error gap-2"
|
||||
onclick={confirmDelete}
|
||||
disabled={deleting || funcionarioToDelete === null}
|
||||
disabled={funcionarioToDelete !== null}
|
||||
>
|
||||
{#if deleting}
|
||||
{#if funcionarioToDelete}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
Excluindo...
|
||||
{:else}
|
||||
|
||||
@@ -82,4 +82,10 @@
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
116
packages/backend/convex/_generated/api.d.ts
vendored
116
packages/backend/convex/_generated/api.d.ts
vendored
@@ -352,6 +352,10 @@ export declare const components: {
|
||||
lastRequest?: null | number;
|
||||
};
|
||||
model: "rateLimit";
|
||||
}
|
||||
| {
|
||||
data: { count: number; key: string; lastRequest: number };
|
||||
model: "ratelimit";
|
||||
};
|
||||
onCreateHandle?: string;
|
||||
select?: Array<string>;
|
||||
@@ -729,6 +733,32 @@ export declare const components: {
|
||||
| Array<number>
|
||||
| null;
|
||||
}>;
|
||||
}
|
||||
| {
|
||||
model: "ratelimit";
|
||||
where?: Array<{
|
||||
connector?: "AND" | "OR";
|
||||
field: "key" | "count" | "lastRequest" | "_id";
|
||||
operator?:
|
||||
| "lt"
|
||||
| "lte"
|
||||
| "gt"
|
||||
| "gte"
|
||||
| "eq"
|
||||
| "in"
|
||||
| "not_in"
|
||||
| "ne"
|
||||
| "contains"
|
||||
| "starts_with"
|
||||
| "ends_with";
|
||||
value:
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| Array<string>
|
||||
| Array<number>
|
||||
| null;
|
||||
}>;
|
||||
};
|
||||
onDeleteHandle?: string;
|
||||
paginationOpts: {
|
||||
@@ -1113,6 +1143,32 @@ export declare const components: {
|
||||
| Array<number>
|
||||
| null;
|
||||
}>;
|
||||
}
|
||||
| {
|
||||
model: "ratelimit";
|
||||
where?: Array<{
|
||||
connector?: "AND" | "OR";
|
||||
field: "key" | "count" | "lastRequest" | "_id";
|
||||
operator?:
|
||||
| "lt"
|
||||
| "lte"
|
||||
| "gt"
|
||||
| "gte"
|
||||
| "eq"
|
||||
| "in"
|
||||
| "not_in"
|
||||
| "ne"
|
||||
| "contains"
|
||||
| "starts_with"
|
||||
| "ends_with";
|
||||
value:
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| Array<string>
|
||||
| Array<number>
|
||||
| null;
|
||||
}>;
|
||||
};
|
||||
onDeleteHandle?: string;
|
||||
},
|
||||
@@ -1134,7 +1190,8 @@ export declare const components: {
|
||||
| "oauthAccessToken"
|
||||
| "oauthConsent"
|
||||
| "jwks"
|
||||
| "rateLimit";
|
||||
| "rateLimit"
|
||||
| "ratelimit";
|
||||
offset?: number;
|
||||
paginationOpts: {
|
||||
cursor: string | null;
|
||||
@@ -1186,7 +1243,8 @@ export declare const components: {
|
||||
| "oauthAccessToken"
|
||||
| "oauthConsent"
|
||||
| "jwks"
|
||||
| "rateLimit";
|
||||
| "rateLimit"
|
||||
| "ratelimit";
|
||||
select?: Array<string>;
|
||||
where?: Array<{
|
||||
connector?: "AND" | "OR";
|
||||
@@ -1695,6 +1753,33 @@ export declare const components: {
|
||||
| Array<number>
|
||||
| null;
|
||||
}>;
|
||||
}
|
||||
| {
|
||||
model: "ratelimit";
|
||||
update: { count?: number; key?: string; lastRequest?: number };
|
||||
where?: Array<{
|
||||
connector?: "AND" | "OR";
|
||||
field: "key" | "count" | "lastRequest" | "_id";
|
||||
operator?:
|
||||
| "lt"
|
||||
| "lte"
|
||||
| "gt"
|
||||
| "gte"
|
||||
| "eq"
|
||||
| "in"
|
||||
| "not_in"
|
||||
| "ne"
|
||||
| "contains"
|
||||
| "starts_with"
|
||||
| "ends_with";
|
||||
value:
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| Array<string>
|
||||
| Array<number>
|
||||
| null;
|
||||
}>;
|
||||
};
|
||||
onUpdateHandle?: string;
|
||||
paginationOpts: {
|
||||
@@ -2183,6 +2268,33 @@ export declare const components: {
|
||||
| Array<number>
|
||||
| null;
|
||||
}>;
|
||||
}
|
||||
| {
|
||||
model: "ratelimit";
|
||||
update: { count?: number; key?: string; lastRequest?: number };
|
||||
where?: Array<{
|
||||
connector?: "AND" | "OR";
|
||||
field: "key" | "count" | "lastRequest" | "_id";
|
||||
operator?:
|
||||
| "lt"
|
||||
| "lte"
|
||||
| "gt"
|
||||
| "gte"
|
||||
| "eq"
|
||||
| "in"
|
||||
| "not_in"
|
||||
| "ne"
|
||||
| "contains"
|
||||
| "starts_with"
|
||||
| "ends_with";
|
||||
value:
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| Array<string>
|
||||
| Array<number>
|
||||
| null;
|
||||
}>;
|
||||
};
|
||||
onUpdateHandle?: string;
|
||||
},
|
||||
|
||||
@@ -38,7 +38,7 @@ export type Doc<TableName extends TableNames> = DocumentByName<
|
||||
* Convex documents are uniquely identified by their `Id`, which is accessible
|
||||
* on the `_id` field. To learn more, see [Document IDs](https://docs.convex.dev/using/document-ids).
|
||||
*
|
||||
* Documents can be loaded using `db.get(tableName, id)` in query and mutation functions.
|
||||
* Documents can be loaded using `db.get(id)` in query and mutation functions.
|
||||
*
|
||||
* IDs are just strings at runtime, but this type can be used to distinguish them from other
|
||||
* strings when type checking.
|
||||
|
||||
@@ -1848,7 +1848,14 @@ async function atualizarBancoHoras(
|
||||
|
||||
// Atualizar banco de horas mensal
|
||||
const mes = data.substring(0, 7); // YYYY-MM
|
||||
await calcularBancoHorasMensal(ctx, funcionarioId, mes);
|
||||
|
||||
// Verificar se estamos editando um mês passado
|
||||
const hoje = new Date();
|
||||
const mesAtual = `${hoje.getFullYear()}-${String(hoje.getMonth() + 1).padStart(2, '0')}`;
|
||||
const estaEditandoMesPassado = mes < mesAtual;
|
||||
|
||||
// Se estamos editando um mês passado, recalcular em cascata para atualizar meses seguintes
|
||||
await calcularBancoHorasMensal(ctx, funcionarioId, mes, estaEditandoMesPassado);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1979,14 +1986,74 @@ export const obterBancoHorasFuncionario = query({
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Recalcula meses seguintes em cascata quando um mês anterior é atualizado
|
||||
* Isso garante que os saldos iniciais dos meses seguintes sejam atualizados corretamente
|
||||
*/
|
||||
async function recalcularMesesSeguintes(
|
||||
ctx: MutationCtx,
|
||||
funcionarioId: Id<'funcionarios'>,
|
||||
mesAtualizado: string // YYYY-MM do mês que foi atualizado
|
||||
): Promise<void> {
|
||||
const hoje = new Date();
|
||||
const mesAtual = `${hoje.getFullYear()}-${String(hoje.getMonth() + 1).padStart(2, '0')}`;
|
||||
|
||||
// Se o mês atualizado já é o mês atual ou futuro, não precisa recalcular nada
|
||||
if (mesAtualizado >= mesAtual) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Recalcular todos os meses do mês seguinte ao atualizado até o mês atual
|
||||
// Calcular primeiro mês a recalcular (mês seguinte ao atualizado)
|
||||
const [anoAtualizado, mesNumAtualizado] = mesAtualizado.split('-').map(Number);
|
||||
let anoIter = anoAtualizado;
|
||||
let mesNumIter = mesNumAtualizado + 1;
|
||||
if (mesNumIter > 12) {
|
||||
mesNumIter = 1;
|
||||
anoIter += 1;
|
||||
}
|
||||
|
||||
// Continuar enquanto o mês iterado for menor ou igual ao mês atual
|
||||
while (true) {
|
||||
const mesIterStr = `${anoIter}-${String(mesNumIter).padStart(2, '0')}`;
|
||||
|
||||
// Se passou do mês atual, parar
|
||||
if (mesIterStr > mesAtual) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Verificar se existe registro mensal para este mês
|
||||
const bancoMensalExistente = await ctx.db
|
||||
.query('bancoHorasMensal')
|
||||
.withIndex('by_funcionario_mes', (q) =>
|
||||
q.eq('funcionarioId', funcionarioId).eq('mes', mesIterStr)
|
||||
)
|
||||
.first();
|
||||
|
||||
// Se existe registro, recalcular (o saldo inicial mudou porque o mês anterior mudou)
|
||||
if (bancoMensalExistente) {
|
||||
await calcularBancoHorasMensal(ctx, funcionarioId, mesIterStr, false); // false = não recalcular cascata novamente
|
||||
}
|
||||
|
||||
// Avançar para o próximo mês
|
||||
mesNumIter += 1;
|
||||
if (mesNumIter > 12) {
|
||||
mesNumIter = 1;
|
||||
anoIter += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcula e atualiza banco de horas mensal para um funcionário
|
||||
* Esta função deve ser chamada após atualizações no banco de horas diário
|
||||
* @param recalcularCascata - Se true, recalcula automaticamente os meses seguintes (padrão: true)
|
||||
*/
|
||||
async function calcularBancoHorasMensal(
|
||||
ctx: MutationCtx,
|
||||
funcionarioId: Id<'funcionarios'>,
|
||||
mes: string // YYYY-MM
|
||||
mes: string, // YYYY-MM
|
||||
recalcularCascata: boolean = true // Por padrão, recalcula em cascata
|
||||
): Promise<void> {
|
||||
// Buscar todos os bancoHoras do mês
|
||||
const dataInicio = `${mes}-01`;
|
||||
@@ -2106,6 +2173,11 @@ async function calcularBancoHorasMensal(
|
||||
atualizadoEm: agora
|
||||
});
|
||||
}
|
||||
|
||||
// Recalcular meses seguintes em cascata se solicitado
|
||||
if (recalcularCascata) {
|
||||
await recalcularMesesSeguintes(ctx, funcionarioId, mes);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2611,7 +2683,14 @@ export const ajustarBancoHoras = mutation({
|
||||
|
||||
// Recalcular banco de horas mensal após ajuste
|
||||
const mes = hoje.substring(0, 7); // YYYY-MM
|
||||
await calcularBancoHorasMensal(ctx, args.funcionarioId, mes);
|
||||
|
||||
// Verificar se estamos ajustando um mês passado
|
||||
const hojeDate = new Date();
|
||||
const mesAtual = `${hojeDate.getFullYear()}-${String(hojeDate.getMonth() + 1).padStart(2, '0')}`;
|
||||
const estaAjustandoMesPassado = mes < mesAtual;
|
||||
|
||||
// Se estamos ajustando um mês passado, recalcular em cascata para atualizar meses seguintes
|
||||
await calcularBancoHorasMensal(ctx, args.funcionarioId, mes, estaAjustandoMesPassado);
|
||||
|
||||
// Criar registro de homologação (mantido para compatibilidade)
|
||||
const homologacaoId = await ctx.db.insert('homologacoesPonto', {
|
||||
@@ -3872,7 +3951,14 @@ export const criarAjusteBancoHoras = mutation({
|
||||
|
||||
// Recalcular banco de horas mensal
|
||||
const mes = args.dataAplicacao.substring(0, 7);
|
||||
await calcularBancoHorasMensal(ctx, args.funcionarioId, mes);
|
||||
|
||||
// Verificar se estamos aplicando ajuste em um mês passado
|
||||
const hoje = new Date();
|
||||
const mesAtual = `${hoje.getFullYear()}-${String(hoje.getMonth() + 1).padStart(2, '0')}`;
|
||||
const estaAplicandoEmMesPassado = mes < mesAtual;
|
||||
|
||||
// Se estamos aplicando em um mês passado, recalcular em cascata para atualizar meses seguintes
|
||||
await calcularBancoHorasMensal(ctx, args.funcionarioId, mes, estaAplicandoEmMesPassado);
|
||||
|
||||
return { ajusteId, success: true };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user