Compare commits

..

3 Commits

33 changed files with 3108 additions and 1663 deletions

View File

@@ -1,6 +1,6 @@
---
trigger: glob
globs: **/*.svelte.ts,**/*.svelte
globs: *.svelte.ts,*.svelte
---
# Convex + Svelte Best Practices

View File

@@ -1,7 +1,7 @@
---
trigger: glob
description: Regras de tipagem para queries e mutations do Convex
globs: **/*.svelte.ts,**/*.svelte
globs: *.svelte.ts,*.svelte
---
# Regras de Tipagem do Convex

View File

@@ -1,5 +1,6 @@
---
trigger: always_on
trigger: glob
globs: *.svelte.ts,*.svelte
---
You are able to use the Svelte MCP server, where you have access to comprehensive Svelte 5 and SvelteKit documentation. Here's how to use the available tools effectively:

View File

@@ -237,6 +237,19 @@
}
]
},
{
label: 'Configurações',
icon: 'Settings',
link: '/configuracoes',
permission: { recurso: 'pedidos', acao: 'listar' },
submenus: [
{
label: 'Fluxo de Pedidos',
link: '/configuracoes/fluxo-pedidos',
permission: { recurso: 'pedidos', acao: 'listar' }
}
]
},
{
label: 'Painel de TI',
icon: 'Settings',

View File

@@ -9,7 +9,6 @@
import NewConversationModal from './NewConversationModal.svelte';
import { Search, Plus, MessageSquare, Users, UsersRound } from 'lucide-svelte';
import type { Id, Doc } from '@sgse-app/backend/convex/_generated/dataModel';
import { obterCoresDoTema } from '$lib/utils/temas';
const client = useConvexClient();
@@ -24,57 +23,6 @@
let searchQuery = $state('');
let activeTab = $state<'usuarios' | 'conversas'>('usuarios');
// Obter cores do tema atual (reativo)
let coresTema = $state(obterCoresDoTema());
// Atualizar cores quando o tema mudar
$effect(() => {
if (typeof window === 'undefined') return;
const atualizarCores = () => {
coresTema = obterCoresDoTema();
};
atualizarCores();
window.addEventListener('themechange', atualizarCores);
const observer = new MutationObserver(atualizarCores);
const htmlElement = document.documentElement;
observer.observe(htmlElement, {
attributes: true,
attributeFilter: ['data-theme']
});
return () => {
window.removeEventListener('themechange', atualizarCores);
observer.disconnect();
};
});
// Função para obter rgba da cor primária
function obterPrimariaRgba(alpha: number = 1) {
const primary = coresTema.primary;
if (primary.startsWith('rgba')) {
const match = primary.match(/rgba?\(([^)]+)\)/);
if (match) {
const values = match[1].split(',');
return `rgba(${values[0]}, ${values[1]}, ${values[2]}, ${alpha})`;
}
}
if (primary.startsWith('#')) {
const hex = primary.replace('#', '');
const r = parseInt(hex.substring(0, 2), 16);
const g = parseInt(hex.substring(2, 4), 16);
const b = parseInt(hex.substring(4, 6), 16);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
if (primary.startsWith('hsl')) {
return primary.replace(/\)$/, `, ${alpha})`).replace('hsl', 'hsla');
}
return `rgba(102, 126, 234, ${alpha})`;
}
// Debug: monitorar carregamento de dados
$effect(() => {
@@ -315,7 +263,7 @@
<!-- Ícone de mensagem -->
<div
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl transition-all duration-300 hover:scale-110"
style="background: linear-gradient(135deg, {obterPrimariaRgba(0.1)} 0%, {obterPrimariaRgba(0.1)} 100%); border: 1px solid {obterPrimariaRgba(0.2)};"
style="background: linear-gradient(135deg, rgba(102, 126, 234, 0.1) 0%, rgba(118, 75, 162, 0.1) 100%); border: 1px solid rgba(102, 126, 234, 0.2);"
>
<MessageSquare class="text-primary h-5 w-5" strokeWidth={2} />
</div>

View File

@@ -17,7 +17,6 @@
import ChatList from './ChatList.svelte';
import ChatWindow from './ChatWindow.svelte';
import { MessageSquare, Minus, Maximize2, X, Bell } from 'lucide-svelte';
import { obterCoresDoTema, obterTemaPersistidoNoLocalStorage } from '$lib/utils/temas';
const count = useQuery(api.chat.contarNotificacoesNaoLidas, {});
@@ -956,80 +955,6 @@
window.removeEventListener('touchend', handleTouchEnd);
};
});
// Obter cores do tema atual (reativo)
let coresTema = $state(obterCoresDoTema());
// Atualizar cores quando o tema mudar
$effect(() => {
if (typeof window === 'undefined') return;
const atualizarCores = () => {
coresTema = obterCoresDoTema();
};
// Atualizar cores inicialmente
atualizarCores();
// Escutar mudanças de tema
window.addEventListener('themechange', atualizarCores);
// Observar mudanças no atributo data-theme do HTML
const observer = new MutationObserver(atualizarCores);
const htmlElement = document.documentElement;
observer.observe(htmlElement, {
attributes: true,
attributeFilter: ['data-theme']
});
return () => {
window.removeEventListener('themechange', atualizarCores);
observer.disconnect();
};
});
// Função para obter gradiente do tema
function obterGradienteTema() {
const primary = coresTema.primary;
// Criar variações da cor primária para o gradiente
return `linear-gradient(135deg, ${primary} 0%, ${primary}dd 50%, ${primary}bb 100%)`;
}
// Função para obter rgba da cor primária
function obterPrimariaRgba(alpha: number = 1) {
const primary = coresTema.primary.trim();
// Se já for rgba, extrair os valores
if (primary.startsWith('rgba')) {
const match = primary.match(/rgba?\(([^)]+)\)/);
if (match) {
const values = match[1].split(',').map(v => v.trim());
if (values.length >= 3) {
return `rgba(${values[0]}, ${values[1]}, ${values[2]}, ${alpha})`;
}
}
}
// Se for hex, converter
if (primary.startsWith('#')) {
const hex = primary.replace('#', '');
if (hex.length === 6) {
const r = parseInt(hex.substring(0, 2), 16);
const g = parseInt(hex.substring(2, 4), 16);
const b = parseInt(hex.substring(4, 6), 16);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
}
// Se for hsl, converter para hsla
if (primary.startsWith('hsl')) {
const match = primary.match(/hsl\(([^)]+)\)/);
if (match) {
return `hsla(${match[1]}, ${alpha})`;
}
// Fallback: tentar adicionar alpha
return primary.replace(/\)$/, `, ${alpha})`).replace('hsl', 'hsla');
}
// Fallback padrão
return `rgba(102, 126, 234, ${alpha})`;
}
</script>
<!-- Botão flutuante MODERNO E ARRASTÁVEL -->
@@ -1050,10 +975,10 @@
bottom: {bottomPos};
right: {rightPos};
position: fixed !important;
background: {obterGradienteTema()};
background: linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%);
box-shadow:
0 20px 60px -10px {obterPrimariaRgba(0.5)},
0 10px 30px -5px {obterPrimariaRgba(0.4)},
0 20px 60px -10px rgba(102, 126, 234, 0.5),
0 10px 30px -5px rgba(118, 75, 162, 0.4),
0 0 0 1px rgba(255, 255, 255, 0.1) inset;
border-radius: 50%;
cursor: {isDragging ? 'grabbing' : 'grab'};
@@ -1133,17 +1058,17 @@
strokeWidth={2}
/>
<!-- Badge ULTRA PREMIUM com gradiente e brilho usando cores do tema -->
<!-- Badge ULTRA PREMIUM com gradiente e brilho -->
{#if count?.data && count.data > 0}
<span
class="absolute -top-1 -right-1 z-20 flex h-8 w-8 items-center justify-center rounded-full text-xs font-black text-white"
style="
background: {coresTema.error ? `linear-gradient(135deg, ${coresTema.error}, ${coresTema.error}dd)` : 'linear-gradient(135deg, #ff416c, #ff4b2b)'};
background: linear-gradient(135deg, #ff416c, #ff4b2b);
box-shadow:
0 8px 24px -4px {coresTema.error ? obterPrimariaRgba(0.6).replace(coresTema.primary, coresTema.error) : 'rgba(255, 65, 108, 0.6)'},
0 4px 12px -2px {coresTema.error ? obterPrimariaRgba(0.4).replace(coresTema.primary, coresTema.error) : 'rgba(255, 75, 43, 0.4)'},
0 8px 24px -4px rgba(255, 65, 108, 0.6),
0 4px 12px -2px rgba(255, 75, 43, 0.4),
0 0 0 3px rgba(255, 255, 255, 0.3),
0 0 0 5px {coresTema.error ? obterPrimariaRgba(0.2).replace(coresTema.primary, coresTema.error) : 'rgba(255, 65, 108, 0.2)'};
0 0 0 5px rgba(255, 65, 108, 0.2);
animation: badge-bounce 2s ease-in-out infinite;
"
>
@@ -1196,8 +1121,8 @@
<div
class="relative flex items-center justify-between overflow-hidden px-6 py-5 text-white"
style="
background: {obterGradienteTema()};
box-shadow: 0 8px 32px -4px {obterPrimariaRgba(0.3)};
background: linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%);
box-shadow: 0 8px 32px -4px rgba(102, 126, 234, 0.3);
cursor: {isDragging ? 'grabbing' : 'grab'};
"
onmousedown={handleMouseDown}
@@ -1314,77 +1239,77 @@
role="button"
tabindex="0"
aria-label="Redimensionar janela pela borda superior"
class="absolute top-0 right-0 left-0 z-50 h-2 cursor-ns-resize transition-colors"
style="--hover-bg: {obterPrimariaRgba(0.2)}; border-radius: 24px 24px 0 0;"
class="hover:bg-primary/20 absolute top-0 right-0 left-0 z-50 h-2 cursor-ns-resize transition-colors"
onmousedown={(e) => handleResizeStart(e, 'n')}
onkeydown={(e) => e.key === 'Enter' && handleResizeStart(e, 'n')}
style="border-radius: 24px 24px 0 0;"
></div>
<!-- Bottom -->
<div
role="button"
tabindex="0"
aria-label="Redimensionar janela pela borda inferior"
class="absolute right-0 bottom-0 left-0 z-50 h-2 cursor-ns-resize transition-colors"
style="--hover-bg: {obterPrimariaRgba(0.2)}; border-radius: 0 0 24px 24px;"
class="hover:bg-primary/20 absolute right-0 bottom-0 left-0 z-50 h-2 cursor-ns-resize transition-colors"
onmousedown={(e) => handleResizeStart(e, 's')}
onkeydown={(e) => e.key === 'Enter' && handleResizeStart(e, 's')}
style="border-radius: 0 0 24px 24px;"
></div>
<!-- Left -->
<div
role="button"
tabindex="0"
aria-label="Redimensionar janela pela borda esquerda"
class="absolute top-0 bottom-0 left-0 z-50 w-2 cursor-ew-resize transition-colors"
style="--hover-bg: {obterPrimariaRgba(0.2)}; border-radius: 24px 0 0 24px;"
class="hover:bg-primary/20 absolute top-0 bottom-0 left-0 z-50 w-2 cursor-ew-resize transition-colors"
onmousedown={(e) => handleResizeStart(e, 'w')}
onkeydown={(e) => e.key === 'Enter' && handleResizeStart(e, 'w')}
style="border-radius: 24px 0 0 24px;"
></div>
<!-- Right -->
<div
role="button"
tabindex="0"
aria-label="Redimensionar janela pela borda direita"
class="absolute top-0 right-0 bottom-0 z-50 w-2 cursor-ew-resize transition-colors"
style="--hover-bg: {obterPrimariaRgba(0.2)}; border-radius: 0 24px 24px 0;"
class="hover:bg-primary/20 absolute top-0 right-0 bottom-0 z-50 w-2 cursor-ew-resize transition-colors"
onmousedown={(e) => handleResizeStart(e, 'e')}
onkeydown={(e) => e.key === 'Enter' && handleResizeStart(e, 'e')}
style="border-radius: 0 24px 24px 0;"
></div>
<!-- Corners -->
<div
role="button"
tabindex="0"
aria-label="Redimensionar janela pelo canto superior esquerdo"
class="absolute top-0 left-0 z-50 h-4 w-4 cursor-nwse-resize transition-colors"
style="--hover-bg: {obterPrimariaRgba(0.2)}; border-radius: 24px 0 0 0;"
class="hover:bg-primary/20 absolute top-0 left-0 z-50 h-4 w-4 cursor-nwse-resize transition-colors"
onmousedown={(e) => handleResizeStart(e, 'nw')}
onkeydown={(e) => e.key === 'Enter' && handleResizeStart(e, 'nw')}
style="border-radius: 24px 0 0 0;"
></div>
<div
role="button"
tabindex="0"
aria-label="Redimensionar janela pelo canto superior direito"
class="absolute top-0 right-0 z-50 h-4 w-4 cursor-nesw-resize transition-colors"
style="--hover-bg: {obterPrimariaRgba(0.2)}; border-radius: 0 24px 0 0;"
class="hover:bg-primary/20 absolute top-0 right-0 z-50 h-4 w-4 cursor-nesw-resize transition-colors"
onmousedown={(e) => handleResizeStart(e, 'ne')}
onkeydown={(e) => e.key === 'Enter' && handleResizeStart(e, 'ne')}
style="border-radius: 0 24px 0 0;"
></div>
<div
role="button"
tabindex="0"
aria-label="Redimensionar janela pelo canto inferior esquerdo"
class="absolute bottom-0 left-0 z-50 h-4 w-4 cursor-nesw-resize transition-colors"
style="--hover-bg: {obterPrimariaRgba(0.2)}; border-radius: 0 0 0 24px;"
class="hover:bg-primary/20 absolute bottom-0 left-0 z-50 h-4 w-4 cursor-nesw-resize transition-colors"
onmousedown={(e) => handleResizeStart(e, 'sw')}
onkeydown={(e) => e.key === 'Enter' && handleResizeStart(e, 'sw')}
style="border-radius: 0 0 0 24px;"
></div>
<div
role="button"
tabindex="0"
aria-label="Redimensionar janela pelo canto inferior direito"
class="absolute right-0 bottom-0 z-50 h-4 w-4 cursor-nwse-resize transition-colors"
style="--hover-bg: {obterPrimariaRgba(0.2)}; border-radius: 0 0 24px 0;"
class="hover:bg-primary/20 absolute right-0 bottom-0 z-50 h-4 w-4 cursor-nwse-resize transition-colors"
onmousedown={(e) => handleResizeStart(e, 'se')}
onkeydown={(e) => e.key === 'Enter' && handleResizeStart(e, 'se')}
style="border-radius: 0 0 24px 0;"
></div>
</div>
</div>
@@ -1399,8 +1324,8 @@
role="button"
tabindex="0"
aria-label="Abrir conversa: Nova mensagem de {notificationMsg.remetente}"
class="bg-base-100 fixed top-4 right-4 z-1000 max-w-sm cursor-pointer rounded-lg border p-4 shadow-2xl"
style="border-color: {obterPrimariaRgba(0.2)}; box-shadow: 0 10px 40px -10px rgba(0,0,0,0.3); animation: slideInRight 0.3s ease-out;"
class="bg-base-100 border-primary/20 fixed top-4 right-4 z-1000 max-w-sm cursor-pointer rounded-lg border p-4 shadow-2xl"
style="box-shadow: 0 10px 40px -10px rgba(0,0,0,0.3); animation: slideInRight 0.3s ease-out;"
onclick={() => {
const conversaIdToOpen = notificationMsg?.conversaId;
showGlobalNotificationPopup = false;
@@ -1431,8 +1356,8 @@
}}
>
<div class="flex items-start gap-3">
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full" style="background-color: {obterPrimariaRgba(0.2)}">
<Bell class="h-5 w-5" style="color: {coresTema.primary}" strokeWidth={2} />
<div class="bg-primary/20 flex h-10 w-10 shrink-0 items-center justify-center rounded-full">
<Bell class="text-primary h-5 w-5" strokeWidth={2} />
</div>
<div class="min-w-0 flex-1">
<p class="text-base-content mb-1 text-sm font-semibold">
@@ -1441,7 +1366,7 @@
<p class="text-base-content/70 line-clamp-2 text-xs">
{notificationMsg.conteudo}
</p>
<p class="mt-1 text-xs" style="color: {coresTema.primary}">Clique para abrir</p>
<p class="text-primary mt-1 text-xs">Clique para abrir</p>
</div>
<button
type="button"
@@ -1489,23 +1414,18 @@
}
}
/* Ondas de pulso para o botão flutuante - cores dinâmicas */
/* Ondas de pulso para o botão flutuante */
@keyframes pulse-ring {
0% {
box-shadow: 0 0 0 0 var(--pulse-color, rgba(102, 126, 234, 0.5));
box-shadow: 0 0 0 0 rgba(102, 126, 234, 0.5);
}
50% {
box-shadow: 0 0 0 15px transparent;
box-shadow: 0 0 0 15px rgba(102, 126, 234, 0);
}
100% {
box-shadow: 0 0 0 0 var(--pulse-color, rgba(102, 126, 234, 0.5));
box-shadow: 0 0 0 0 rgba(102, 126, 234, 0);
}
}
/* Estilos para handles de redimensionamento com hover dinâmico */
[style*="--hover-bg"]:hover {
background-color: var(--hover-bg) !important;
}
/* Rotação para anel de brilho - suavizada */
@keyframes rotate {

View File

@@ -30,7 +30,6 @@
//import { getAvatarUrl } from '$lib/utils/avatarGenerator';
import { voltarParaLista } from '$lib/stores/chatStore';
import { useConvexClient, useQuery } from 'convex-svelte';
import { obterCoresDoTema } from '$lib/utils/temas';
//import { Bell, X, ArrowLeft, LogOut, MoreVertical, Users, Clock, XCircle } from 'lucide-svelte';
@@ -261,84 +260,23 @@
let souAnfitriao = $derived(
chamadaAtual && meuPerfil?.data ? chamadaAtual.criadoPor === meuPerfil.data._id : false
);
// Obter cores do tema atual (reativo)
let coresTema = $state(obterCoresDoTema());
// Atualizar cores quando o tema mudar
$effect(() => {
if (typeof window === 'undefined') return;
const atualizarCores = () => {
coresTema = obterCoresDoTema();
};
atualizarCores();
window.addEventListener('themechange', atualizarCores);
const observer = new MutationObserver(atualizarCores);
const htmlElement = document.documentElement;
observer.observe(htmlElement, {
attributes: true,
attributeFilter: ['data-theme']
});
return () => {
window.removeEventListener('themechange', atualizarCores);
observer.disconnect();
};
});
// Função para obter rgba da cor primária
function obterPrimariaRgba(alpha: number = 1) {
const primary = coresTema.primary.trim();
if (primary.startsWith('rgba')) {
const match = primary.match(/rgba?\(([^)]+)\)/);
if (match) {
const values = match[1].split(',').map(v => v.trim());
if (values.length >= 3) {
return `rgba(${values[0]}, ${values[1]}, ${values[2]}, ${alpha})`;
}
}
}
if (primary.startsWith('#')) {
const hex = primary.replace('#', '');
if (hex.length === 6) {
const r = parseInt(hex.substring(0, 2), 16);
const g = parseInt(hex.substring(2, 4), 16);
const b = parseInt(hex.substring(4, 6), 16);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
}
if (primary.startsWith('hsl')) {
const match = primary.match(/hsl\(([^)]+)\)/);
if (match) {
return `hsla(${match[1]}, ${alpha})`;
}
return primary.replace(/\)$/, `, ${alpha})`).replace('hsl', 'hsla');
}
return `rgba(102, 126, 234, ${alpha})`;
}
</script>
<div class="flex h-full flex-col" onclick={() => (showAdminMenu = false)}>
<!-- Header -->
<div
class="border-base-300 flex items-center gap-3 border-b px-4 py-3"
style="background-color: {coresTema.base200}; border-color: {coresTema.base300};"
class="border-base-300 bg-base-200 flex items-center gap-3 border-b px-4 py-3"
onclick={(e) => e.stopPropagation()}
>
<!-- Botão Voltar -->
<button
type="button"
class="btn btn-sm btn-circle transition-all duration-200 hover:scale-110"
style="--hover-bg: {obterPrimariaRgba(0.2)}"
class="btn btn-sm btn-circle hover:bg-primary/20 transition-all duration-200 hover:scale-110"
onclick={voltarParaLista}
aria-label="Voltar"
title="Voltar para lista de conversas"
>
<ArrowLeft class="h-6 w-6" style="color: {coresTema.primary}" strokeWidth={2.5} />
<ArrowLeft class="text-primary h-6 w-6" strokeWidth={2.5} />
</button>
<!-- Avatar e Info -->
@@ -351,7 +289,7 @@
userId={conversa()?.outroUsuario?._id}
/>
{:else}
<div class="flex h-10 w-10 items-center justify-center rounded-full text-xl" style="background-color: {obterPrimariaRgba(0.2)}">
<div class="bg-primary/20 flex h-10 w-10 items-center justify-center rounded-full text-xl">
{getAvatarConversa()}
</div>
{/if}
@@ -442,8 +380,7 @@
</div>
{#if conversa()?.tipo === 'sala_reuniao' && isAdmin?.data}
<span
class="ml-1 text-[10px] font-semibold whitespace-nowrap"
style="color: {coresTema.primary}"
class="text-primary ml-1 text-[10px] font-semibold whitespace-nowrap"
title="Você é administrador desta sala">• Admin</span
>
{/if}
@@ -790,8 +727,10 @@
{#each searchResults as resultado, index (resultado._id)}
<button
type="button"
class="hover:bg-base-300 flex w-full items-start gap-3 px-4 py-3 text-left transition-colors"
style={index === selectedSearchResult ? `background-color: ${obterPrimariaRgba(0.1)}` : ''}
class="hover:bg-base-300 flex w-full items-start gap-3 px-4 py-3 text-left transition-colors {index ===
selectedSearchResult
? 'bg-primary/10'
: ''}"
onclick={() => {
window.dispatchEvent(
new CustomEvent('scrollToMessage', {
@@ -806,8 +745,7 @@
aria-label="Mensagem de {resultado.remetente?.nome || 'Usuário'}"
>
<div
class="flex h-8 w-8 shrink-0 items-center justify-center overflow-hidden rounded-full"
style="background-color: {obterPrimariaRgba(0.2)}"
class="bg-primary/20 flex h-8 w-8 shrink-0 items-center justify-center overflow-hidden rounded-full"
>
{#if resultado.remetente?.fotoPerfilUrl}
<img
@@ -902,7 +840,7 @@
<div class="modal-box max-w-md" onclick={(e) => e.stopPropagation()}>
<div class="border-base-300 flex items-center justify-between border-b px-6 py-4">
<h2 class="flex items-center gap-2 text-xl font-semibold">
<Bell class="h-5 w-5" style="color: {coresTema.primary}" />
<Bell class="text-primary h-5 w-5" />
Enviar Notificação
</h2>
<button
@@ -993,10 +931,3 @@
details={errorInstructions || errorDetails}
onClose={fecharErrorModal}
/>
<style>
/* Estilos para hover dinâmico com cores do tema */
[style*="--hover-bg"]:hover {
background-color: var(--hover-bg) !important;
}
</style>

View File

@@ -13,7 +13,6 @@
type EncryptedMessage
} from '$lib/utils/e2eEncryption';
import { obterChaveCriptografia, armazenarChaveCriptografia } from '$lib/stores/chatStore';
import { obterCoresDoTema } from '$lib/utils/temas';
interface Props {
conversaId: Id<'conversas'>;
@@ -89,63 +88,6 @@
let mentionStartPos = $state(0);
let selectedMentionIndex = $state(0); // Índice do participante selecionado no dropdown
let mensagemMuitoLonga = $state(false);
// Obter cores do tema atual (reativo)
let coresTema = $state(obterCoresDoTema());
// Atualizar cores quando o tema mudar
$effect(() => {
if (typeof window === 'undefined') return;
const atualizarCores = () => {
coresTema = obterCoresDoTema();
};
atualizarCores();
window.addEventListener('themechange', atualizarCores);
const observer = new MutationObserver(atualizarCores);
const htmlElement = document.documentElement;
observer.observe(htmlElement, {
attributes: true,
attributeFilter: ['data-theme']
});
return () => {
window.removeEventListener('themechange', atualizarCores);
observer.disconnect();
};
});
// Função para obter rgba da cor primária
function obterPrimariaRgba(alpha: number = 1) {
const primary = coresTema.primary;
if (primary.startsWith('rgba')) {
const match = primary.match(/rgba?\(([^)]+)\)/);
if (match) {
const values = match[1].split(',');
return `rgba(${values[0]}, ${values[1]}, ${values[2]}, ${alpha})`;
}
}
if (primary.startsWith('#')) {
const hex = primary.replace('#', '');
const r = parseInt(hex.substring(0, 2), 16);
const g = parseInt(hex.substring(2, 4), 16);
const b = parseInt(hex.substring(4, 6), 16);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
if (primary.startsWith('hsl')) {
return primary.replace(/\)$/, `, ${alpha})`).replace('hsl', 'hsla');
}
return `rgba(102, 126, 234, ${alpha})`;
}
// Função para obter gradiente do tema
function obterGradienteTema() {
const primary = coresTema.primary;
return `linear-gradient(135deg, ${primary} 0%, ${primary}dd 100%)`;
}
// Emojis mais usados
const emojis = [
@@ -728,7 +670,7 @@
<div class="relative shrink-0">
<label
class="group relative flex h-10 w-10 shrink-0 cursor-pointer items-center justify-center overflow-hidden rounded-xl transition-all duration-300"
style="background: {obterPrimariaRgba(0.1)}; border: 1px solid {obterPrimariaRgba(0.2)};"
style="background: rgba(102, 126, 234, 0.1); border: 1px solid rgba(102, 126, 234, 0.2);"
title="Anexar arquivo"
aria-label="Anexar arquivo"
>
@@ -896,7 +838,7 @@
<button
type="button"
class="group relative flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden rounded-xl transition-all duration-300 disabled:cursor-not-allowed disabled:opacity-50"
style="background: {obterGradienteTema()}; box-shadow: 0 8px 24px -4px {obterPrimariaRgba(0.4)};"
style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); box-shadow: 0 8px 24px -4px rgba(102, 126, 234, 0.4);"
onclick={handleEnviar}
disabled={!mensagem.trim() || enviando || uploadingFile}
aria-label="Enviar mensagem"

View File

@@ -5,7 +5,6 @@
import { format } from 'date-fns';
import { ptBR } from 'date-fns/locale';
import { Clock, Trash2, X } from 'lucide-svelte';
import { obterCoresDoTema } from '$lib/utils/temas';
interface Props {
conversaId: Id<'conversas'>;
@@ -23,63 +22,6 @@
let data = $state('');
let hora = $state('');
let loading = $state(false);
// Obter cores do tema atual (reativo)
let coresTema = $state(obterCoresDoTema());
// Atualizar cores quando o tema mudar
$effect(() => {
if (typeof window === 'undefined') return;
const atualizarCores = () => {
coresTema = obterCoresDoTema();
};
atualizarCores();
window.addEventListener('themechange', atualizarCores);
const observer = new MutationObserver(atualizarCores);
const htmlElement = document.documentElement;
observer.observe(htmlElement, {
attributes: true,
attributeFilter: ['data-theme']
});
return () => {
window.removeEventListener('themechange', atualizarCores);
observer.disconnect();
};
});
// Função para obter rgba da cor primária
function obterPrimariaRgba(alpha: number = 1) {
const primary = coresTema.primary;
if (primary.startsWith('rgba')) {
const match = primary.match(/rgba?\(([^)]+)\)/);
if (match) {
const values = match[1].split(',');
return `rgba(${values[0]}, ${values[1]}, ${values[2]}, ${alpha})`;
}
}
if (primary.startsWith('#')) {
const hex = primary.replace('#', '');
const r = parseInt(hex.substring(0, 2), 16);
const g = parseInt(hex.substring(2, 4), 16);
const b = parseInt(hex.substring(4, 6), 16);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
if (primary.startsWith('hsl')) {
return primary.replace(/\)$/, `, ${alpha})`).replace('hsl', 'hsla');
}
return `rgba(102, 126, 234, ${alpha})`;
}
// Função para obter gradiente do tema
function obterGradienteTema() {
const primary = coresTema.primary;
return `linear-gradient(135deg, ${primary} 0%, ${primary}dd 100%)`;
}
// Rastrear mudanças nas mensagens agendadas
$effect(() => {
@@ -244,7 +186,7 @@
<button
type="button"
class="group relative overflow-hidden rounded-xl px-6 py-3 font-bold text-white transition-all duration-300 disabled:cursor-not-allowed disabled:opacity-50"
style="background: {obterGradienteTema()}; box-shadow: 0 8px 24px -4px {obterPrimariaRgba(0.4)};"
style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); box-shadow: 0 8px 24px -4px rgba(102, 126, 234, 0.4);"
onclick={handleAgendar}
disabled={loading || !mensagem.trim() || !data || !hora}
>

View File

@@ -0,0 +1,270 @@
<script lang="ts">
import { api } from '@sgse-app/backend/convex/_generated/api';
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
import { useQuery } from 'convex-svelte';
import { Check, Circle, Clock } from 'lucide-svelte';
interface Props {
pedidoId: Id<'pedidos'>;
}
let { pedidoId }: Props = $props();
const timelineQuery = $derived.by(() => useQuery(api.pedidoFlow.getPedidoTimeline, { pedidoId }));
const timeline = $derived(timelineQuery.data);
const loading = $derived(timelineQuery.isLoading);
// Filtrar etapas que devem aparecer no timeline
const passadoVisivel = $derived(timeline?.passado.filter((item) => item.incluirNoTimeline) ?? []);
const futuroVisivel = $derived(timeline?.futuro.filter((item) => item.incluirNoTimeline) ?? []);
function formatDateShort(timestamp: number): string {
return new Intl.DateTimeFormat('pt-BR', {
day: '2-digit',
month: 'short'
}).format(new Date(timestamp));
}
</script>
{#if loading}
<div class="timeline-loading">
<div class="loading-spinner"></div>
<span>Carregando timeline...</span>
</div>
{:else if timeline}
<div class="timeline-container">
<div class="timeline">
<!-- Etapas passadas -->
{#each passadoVisivel as item, index (item.etapaId + '-' + item.inicioData)}
{@const isAtual = item.atual}
{@const isConcluida = !item.atual}
<div class="timeline-item" class:atual={isAtual} class:concluida={isConcluida}>
<div class="timeline-marker">
{#if isConcluida}
<div class="marker-icon concluida">
<Check size={14} strokeWidth={3} />
</div>
{:else}
<div class="marker-icon atual">
<Circle size={14} fill="currentColor" />
</div>
{/if}
</div>
<div class="timeline-content">
<div class="etapa-nome">{item.etapaNome}</div>
<div class="etapa-data">{formatDateShort(item.inicioData)}</div>
{#if item.funcionarioNome}
<div class="etapa-funcionario">{item.funcionarioNome}</div>
{/if}
</div>
</div>
{#if index < passadoVisivel.length - 1 || futuroVisivel.length > 0}
<div class="timeline-connector" class:concluida={isConcluida}></div>
{/if}
{/each}
<!-- Etapas futuras (previsão) -->
{#each futuroVisivel as item, index (item.etapaId + '-futuro-' + index)}
<div class="timeline-item futuro">
<div class="timeline-marker">
<div class="marker-icon futuro">
<Clock size={14} />
</div>
</div>
<div class="timeline-content">
<div class="etapa-nome">{item.etapaNome}</div>
<div class="etapa-data previsao">
<span class="previsao-label">Prev.</span>
{formatDateShort(item.dataPrevisao)}
</div>
</div>
</div>
{#if index < futuroVisivel.length - 1}
<div class="timeline-connector futuro"></div>
{/if}
{/each}
</div>
</div>
{:else}
<div class="timeline-empty">
<p>Nenhuma etapa registrada ainda.</p>
</div>
{/if}
<style>
.timeline-container {
width: 100%;
overflow-x: auto;
padding: 1rem 0;
}
.timeline {
display: flex;
align-items: flex-start;
gap: 0;
min-width: max-content;
padding: 0 1rem;
}
.timeline-item {
display: flex;
flex-direction: column;
align-items: center;
min-width: 120px;
max-width: 150px;
}
.timeline-marker {
position: relative;
z-index: 1;
}
.marker-icon {
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
}
.marker-icon.concluida {
background: linear-gradient(135deg, #10b981, #059669);
color: white;
box-shadow: 0 2px 8px rgba(16, 185, 129, 0.4);
}
.marker-icon.atual {
background: linear-gradient(135deg, #3b82f6, #2563eb);
color: white;
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.4);
animation: pulse 2s infinite;
}
.marker-icon.futuro {
background: rgba(148, 163, 184, 0.2);
color: #94a3b8;
border: 2px dashed #cbd5e1;
}
@keyframes pulse {
0%,
100% {
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.4);
}
50% {
box-shadow: 0 2px 16px rgba(59, 130, 246, 0.6);
}
}
.timeline-content {
margin-top: 0.75rem;
text-align: center;
}
.etapa-nome {
font-weight: 600;
font-size: 0.875rem;
color: var(--color-text-primary, #1e293b);
line-height: 1.3;
word-wrap: break-word;
}
.timeline-item.futuro .etapa-nome {
color: #94a3b8;
}
.etapa-data {
font-size: 0.75rem;
color: #64748b;
margin-top: 0.25rem;
}
.etapa-data.previsao {
font-style: italic;
}
.previsao-label {
font-size: 0.625rem;
text-transform: uppercase;
color: #94a3b8;
display: block;
}
.etapa-funcionario {
font-size: 0.6875rem;
color: #3b82f6;
margin-top: 0.25rem;
font-weight: 500;
}
.timeline-connector {
flex-shrink: 0;
width: 40px;
height: 2px;
background: #e2e8f0;
margin-top: 15px;
position: relative;
}
.timeline-connector.concluida {
background: linear-gradient(90deg, #10b981, #10b981);
}
.timeline-connector.futuro {
background: repeating-linear-gradient(
90deg,
#cbd5e1 0px,
#cbd5e1 4px,
transparent 4px,
transparent 8px
);
}
.timeline-loading {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 2rem;
color: #64748b;
}
.loading-spinner {
width: 20px;
height: 20px;
border: 2px solid #e2e8f0;
border-top-color: #3b82f6;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.timeline-empty {
text-align: center;
padding: 2rem;
color: #94a3b8;
}
/* Responsividade */
@media (max-width: 640px) {
.timeline-item {
min-width: 100px;
max-width: 120px;
}
.timeline-connector {
width: 24px;
}
.etapa-nome {
font-size: 0.8125rem;
}
}
</style>

View File

@@ -35,9 +35,6 @@
const client = useConvexClient();
// Estado de sincronização do relógio
let sincronizacaoConcluida = $state(false);
// Chave de refresh para forçar atualização das queries após registro
let refreshKey = $state(0);
@@ -63,12 +60,17 @@
funcionarioId && dataHoje ? { funcionarioId, data: dataHoje } : 'skip'
);
// Queries de ponto - usando useQuery com parâmetros derivados reativos
const registrosHojeQuery = useQuery(api.pontos.listarRegistrosDia, registrosHojeParams);
const registrosHojeQuery = $derived.by(() =>
useQuery(api.pontos.listarRegistrosDia, registrosHojeParams)
);
const historicoSaldoQuery = useQuery(api.pontos.obterHistoricoESaldoDia, historicoSaldoParams);
const historicoSaldoQuery = $derived.by(() =>
useQuery(api.pontos.obterHistoricoESaldoDia, historicoSaldoParams)
);
const dispensaQuery = useQuery(api.pontos.verificarDispensaAtiva, dispensaParams);
const dispensaQuery = $derived.by(() =>
useQuery(api.pontos.verificarDispensaAtiva, dispensaParams)
);
// Query para obter status atual do funcionário (férias/licença)
const funcionarioStatusQuery = useQuery(
@@ -353,9 +355,6 @@
justificativa = ''; // Limpar justificativa após registro
mostrandoModalConfirmacao = false;
// Aguardar um pouco para garantir que o backend processou o registro
await new Promise((resolve) => setTimeout(resolve, 800));
// Forçar atualização das queries para mostrar o novo registro
refreshKey++;
@@ -363,13 +362,11 @@
console.log('[RegistroPonto] Registro bem-sucedido, refreshKey incrementado:', refreshKey);
}
// Aguardar mais um pouco e forçar outra atualização para garantir sincronização completa
setTimeout(() => {
refreshKey++;
if (import.meta.env.DEV) {
console.log('[RegistroPonto] Segunda atualização, refreshKey incrementado:', refreshKey);
}
}, 1500);
// Aguardar um pouco para garantir que o backend processou o registro
await new Promise((resolve) => setTimeout(resolve, 500));
// Forçar mais uma atualização após o delay para garantir sincronização
refreshKey++;
// Mostrar comprovante após 1 segundo
setTimeout(() => {
@@ -503,27 +500,25 @@
timestampBase = Date.now();
}
// Aplicar GMT offset ao timestamp (o horário já vem corrigido do servidor)
// Apenas aplicar o offset configurado, sem ajustes adicionais de timezone
// Aplicar GMT offset ao timestamp
// Quando GMT é 0, usar timestamp UTC puro e deixar toLocaleTimeString() fazer a conversão automática
// Quando GMT ≠ 0, aplicar offset configurado ao timestamp
let timestamp: number;
if (gmtOffset !== 0) {
// Aplicar offset configurado
timestamp = timestampBase + gmtOffset * 60 * 60 * 1000;
} else {
// Quando GMT = 0, usar timestamp base diretamente (já vem corrigido)
// Quando GMT = 0, manter timestamp UTC puro
// O toLocaleTimeString() converterá automaticamente para o timezone local do navegador
timestamp = timestampBase;
}
// Usar métodos UTC diretamente para evitar conversão automática do navegador
// O timestamp já está ajustado, então formatamos como UTC para manter o valor correto
const dataObj = new Date(timestamp);
const dia = String(dataObj.getUTCDate()).padStart(2, '0');
const mes = String(dataObj.getUTCMonth() + 1).padStart(2, '0');
const ano = dataObj.getUTCFullYear();
const data = `${dia}/${mes}/${ano}`;
const horaStr = String(dataObj.getUTCHours()).padStart(2, '0');
const minutoStr = String(dataObj.getUTCMinutes()).padStart(2, '0');
const segundoStr = String(dataObj.getUTCSeconds()).padStart(2, '0');
const hora = `${horaStr}:${minutoStr}:${segundoStr}`;
const data = dataObj.toLocaleDateString('pt-BR');
const hora = dataObj.toLocaleTimeString('pt-BR', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
dataHoraAtual = { data, hora };
} catch (error) {
console.warn('Erro ao obter tempo do servidor, usando tempo local:', error);
@@ -871,8 +866,7 @@
!estaDispensado &&
!emFerias &&
!emLicenca &&
temFuncionarioAssociado &&
sincronizacaoConcluida // Só permitir registro após sincronização concluída
temFuncionarioAssociado
);
});
@@ -1137,40 +1131,24 @@
id="relogio-sincronizado-ref"
class="card from-primary/10 to-primary/5 border-primary/20 w-full max-w-sm rounded-2xl border-2 bg-linear-to-br p-5 shadow-lg"
>
<RelogioSincronizado bind:sincronizacaoConcluida />
<RelogioSincronizado />
</div>
</div>
<!-- Mensagem de Aguarde Sincronização -->
{#if !sincronizacaoConcluida}
<div class="alert alert-info mb-5 rounded-xl shadow-lg">
<span class="loading loading-spinner loading-sm text-info"></span>
<div>
<h3 class="font-bold">Aguarde a sincronização</h3>
<div class="text-sm">
O sistema está sincronizando o horário com o servidor. O botão de registro será habilitado
após a conclusão da sincronização.
</div>
</div>
</div>
{/if}
<!-- Botão de Registro -->
<button
class="btn btn-primary mb-5 w-full gap-2 rounded-xl font-semibold shadow-lg transition-all duration-300 hover:shadow-xl"
onclick={iniciarRegistroComFoto}
disabled={!podeRegistrar}
title={!sincronizacaoConcluida
? 'Aguarde a sincronização do horário com o servidor'
: !temFuncionarioAssociado
? 'Você não possui funcionário associado à sua conta'
: estaDispensado
? 'Você está dispensado de registrar ponto no momento'
: emFerias
? 'Você está em férias. Durante o período de férias não é permitido registrar ponto.'
: emLicenca
? 'Você está em licença. Durante o período de licença não é permitido registrar ponto.'
: ''}
title={!temFuncionarioAssociado
? 'Você não possui funcionário associado à sua conta'
: estaDispensado
? 'Você está dispensado de registrar ponto no momento'
: emFerias
? 'Você está em férias. Durante o período de férias não é permitido registrar ponto.'
: emLicenca
? 'Você está em licença. Durante o período de licença não é permitido registrar ponto.'
: ''}
>
{#if registrando}
<span class="loading loading-spinner loading-sm"></span>
@@ -1179,9 +1157,6 @@
{:else}
Registrando...
{/if}
{:else if !sincronizacaoConcluida}
<span class="loading loading-spinner loading-sm"></span>
Aguardando Sincronização
{:else if !temFuncionarioAssociado}
<XCircle class="h-5 w-5" />
Funcionário Não Associado

View File

@@ -7,9 +7,6 @@
const client = useConvexClient();
// Expor estados para o componente pai usando $props() do Svelte 5
let { sincronizacaoConcluida = $bindable(false) }: { sincronizacaoConcluida: boolean } = $props();
let tempoAtual = $state<Date>(new Date());
let sincronizado = $state(false);
let sincronizando = $state(false);
@@ -19,7 +16,6 @@
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
let sincronizacaoInicialConcluida = $state(false); // Flag para indicar que a primeira sincronização foi concluída
async function atualizarTempo() {
// Evitar múltiplas sincronizações simultâneas
@@ -96,11 +92,6 @@
} finally {
sincronizando = false;
sincronizacaoEmAndamento = false;
// Marcar sincronização inicial como concluída após a primeira tentativa
if (!sincronizacaoInicialConcluida) {
sincronizacaoInicialConcluida = true;
sincronizacaoConcluida = true;
}
}
}
@@ -115,7 +106,6 @@
tempoAtual = new Date(obterTempoPC());
sincronizado = false;
erro = 'Usando servidor interno';
sincronizacaoConcluida = false; // Garantir que começa como false
// 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

View File

@@ -216,14 +216,14 @@ function gerarTabelaRegistrosPDF(
}
};
// Função auxiliar para obter símbolo do tipo de dia
const obterSimboloTipoDia = (dia: DiaFichaPonto): string => {
if (dia.atestado) return 'AT';
if (dia.ausencia) return 'AUS';
if (dia.licenca) return 'LIC';
if (dia.tipoDia === 'abonado') return 'ABO';
if (dia.tipoDia === 'nao_computado') return 'NC';
if (dia.inconsistencias.length > 0) return 'INC';
// Função auxiliar para obter ícone do tipo de dia
const obterIconeTipoDia = (dia: DiaFichaPonto): string => {
if (dia.atestado) return '🏥';
if (dia.ausencia) return '🚫';
if (dia.licenca) return '📋';
if (dia.tipoDia === 'abonado') return '';
if (dia.tipoDia === 'nao_computado') return '';
if (dia.inconsistencias.length > 0) return '';
return '';
};
@@ -258,10 +258,8 @@ function gerarTabelaRegistrosPDF(
// Coluna Data (apenas na primeira linha)
if (i === 0) {
const simbolo = obterSimboloTipoDia(dia);
const dataComSimbolo = simbolo ? `${dataFormatada} [${simbolo}]` : dataFormatada;
linha.push({
content: dataComSimbolo,
content: `${dataFormatada} ${obterIconeTipoDia(dia)}`,
styles: {
fillColor: obterCorFundoTipoDia(dia.tipoDia),
fontStyle: 'bold'
@@ -541,48 +539,20 @@ function gerarSecaoAjustesPDF(doc: jsPDF, yPosition: number, dias: DiaFichaPonto
doc.setTextColor(0, 0, 0);
yPosition += 10;
// Função auxiliar para formatar período do ajuste
const formatarPeriodoAjuste = (ajuste: (typeof todosAjustes)[number]): string => {
if (
ajuste.dataInicio &&
ajuste.horaInicio !== undefined &&
ajuste.minutoInicio !== undefined &&
ajuste.dataFim &&
ajuste.horaFim !== undefined &&
ajuste.minutoFim !== undefined
) {
const inicioStr = `${formatarDataDDMMAAAA(ajuste.dataInicio)} ${formatarHoraPonto(
ajuste.horaInicio,
ajuste.minutoInicio
)}`;
const fimStr = `${formatarDataDDMMAAAA(ajuste.dataFim)} ${formatarHoraPonto(
ajuste.horaFim,
ajuste.minutoFim
)}`;
return `${inicioStr} a ${fimStr}`;
}
// Fallback para ajustes antigos sem período
return formatarDataDDMMAAAA(ajuste.data);
};
const ajustesData = todosAjustes.map((ajuste) => [
formatarDataDDMMAAAA(ajuste.data),
ajuste.tipo === 'abonar' ? 'Abonar' : ajuste.tipo === 'descontar' ? 'Descontar' : 'Compensar',
formatarMinutos(ajuste.valorMinutos),
formatarPeriodoAjuste(ajuste),
ajuste.motivoDescricao || '-'
]);
autoTable(doc, {
startY: yPosition,
head: [['Data Aplicação', 'Tipo', 'Valor', 'Período', 'Motivo']],
head: [['Data', 'Tipo', 'Valor', 'Motivo']],
body: ajustesData,
theme: 'grid',
headStyles: { fillColor: [41, 128, 185], fontStyle: 'bold' },
styles: { fontSize: 9 },
columnStyles: {
3: { cellWidth: 'auto', minCellWidth: 60 } // Coluna de período com largura maior
}
styles: { fontSize: 9 }
});
type JsPDFWithAutoTable = jsPDF & {

View File

@@ -598,13 +598,7 @@ export async function processarDadosFichaPonto(
tipo: a.tipo,
valorMinutos: a.valorMinutos,
motivoDescricao: a.motivoDescricao,
gestorId: a.gestorId,
dataInicio: a.dataInicio,
horaInicio: a.horaInicio,
minutoInicio: a.minutoInicio,
dataFim: a.dataFim,
horaFim: a.horaFim,
minutoFim: a.minutoFim
gestorId: a.gestorId
})),
inconsistencias: inconsistenciasDia.map((i) => ({
_id: i._id,

View File

@@ -60,13 +60,6 @@ export interface DiaFichaPonto {
valorMinutos: number;
motivoDescricao?: string;
gestorId?: Id<'usuarios'>;
// Período do ajuste
dataInicio?: string; // YYYY-MM-DD
horaInicio?: number; // 0-23
minutoInicio?: number; // 0-59
dataFim?: string; // YYYY-MM-DD
horaFim?: number; // 0-23
minutoFim?: number; // 0-59
}>;
inconsistencias: Array<{
_id: Id<'inconsistenciasBancoHoras'>;

View File

@@ -1,14 +1,20 @@
<script lang="ts">
import { api } from '@sgse-app/backend/convex/_generated/api';
import { useQuery } from 'convex-svelte';
import { onMount } from 'svelte';
import { afterNavigate, goto, replaceState } from '$app/navigation';
import { resolve } from '$app/paths';
import { UserPlus, Mail, Users, Clock, Calendar, BadgeCheck, Package } from 'lucide-svelte';
import { api } from '@sgse-app/backend/convex/_generated/api';
import { useQuery } from 'convex-svelte';
import { UserPlus, Mail, Clock, Award, TrendingUp, Zap, Users, Database } from 'lucide-svelte';
import ProtectedRoute from '$lib/components/ProtectedRoute.svelte';
// Queries para dados do dashboard
const statsQuery = useQuery(api.dashboard.getStats, {});
const activityQuery = useQuery(api.dashboard.getRecentActivity, {});
// Queries para monitoramento em tempo real
const statusSistemaQuery = useQuery(api.monitoramento.getStatusSistema, {});
const atividadeBDQuery = useQuery(api.monitoramento.getAtividadeBancoDados, {});
const distribuicaoQuery = useQuery(api.monitoramento.getDistribuicaoRequisicoes, {});
// Estado para animações
let currentTime = $state(new Date());
@@ -30,7 +36,6 @@
// Se for erro de autenticação, abrir modal de login automaticamente
if (error === 'auth_required') {
const redirectTo = route || to.url.pathname;
// eslint-disable-next-line svelte/no-navigation-without-resolve
goto(`${resolve('/login')}?redirect=${encodeURIComponent(redirectTo)}`, {
replaceState: true,
noScroll: true
@@ -65,7 +70,6 @@
if (error === 'auth_required') {
const redirectTo = route || window.location.pathname;
// eslint-disable-next-line svelte/no-navigation-without-resolve
goto(`${resolve('/login')}?redirect=${encodeURIComponent(redirectTo)}`, {
replaceState: true,
noScroll: true
@@ -115,6 +119,17 @@
}
}
// Função para formatar números
function formatNumber(num: number): string {
return new Intl.NumberFormat('pt-BR').format(num);
}
// Função para calcular porcentagem
function calcPercentage(value: number, total: number): number {
if (total === 0) return 0;
return Math.round((value / total) * 100);
}
// Obter saudação baseada na hora
function getSaudacao(): string {
const hora = currentTime.getHours();
@@ -122,43 +137,6 @@
if (hora < 18) return 'Boa tarde';
return 'Boa noite';
}
// Função para animar contador numérico
function animateCounter(element: HTMLElement, target: number, duration: number = 2000): void {
const start = 0;
const increment = target / (duration / 16);
let current = start;
const timer = setInterval(() => {
current += increment;
if (current >= target) {
element.textContent = target.toLocaleString('pt-BR');
clearInterval(timer);
} else {
element.textContent = Math.floor(current).toLocaleString('pt-BR');
}
}, 16);
}
// Action para animar contador
function animateCounterAction(node: HTMLElement, value: number) {
if (value > 0 && value !== undefined) {
// Pequeno delay para garantir que o elemento está renderizado
setTimeout(() => {
animateCounter(node, value);
}, 100);
}
return {
update(newValue: number) {
if (newValue > 0 && newValue !== undefined && newValue !== value) {
node.textContent = '0';
setTimeout(() => {
animateCounter(node, newValue);
}, 100);
}
}
};
}
</script>
<ProtectedRoute>
@@ -198,23 +176,17 @@
</div>
{/if}
<!-- Hero Section com Boas-vindas -->
<div
class="fade-in from-primary/20 to-secondary/20 mb-8 rounded-2xl bg-linear-to-r p-8 shadow-xl"
>
<!-- Cabeçalho com Boas-vindas -->
<div class="from-primary/20 to-secondary/20 mb-6 rounded-2xl bg-linear-to-r p-8 shadow-lg">
<div class="flex flex-col items-start justify-between gap-4 md:flex-row md:items-center">
<div class="flex-1">
<h1 class="text-primary mb-3 text-5xl font-bold">
<div>
<h1 class="text-primary mb-2 text-4xl font-bold">
{getSaudacao()}! 👋
</h1>
<p class="text-base-content/80 mb-3 text-2xl font-semibold">Bem-vindo ao SGSE</p>
<p
class="from-primary to-secondary mb-4 bg-gradient-to-r bg-clip-text text-3xl font-bold text-transparent"
>
Simplificando a Gestão Pública
<p class="text-base-content/80 text-xl">
Bem-vindo ao SGSE - Sistema de Gerenciamento de Secretaria
</p>
<p class="text-base-content/70 text-lg">Sistema de Gerenciamento de Secretaria</p>
<p class="text-base-content/60 mt-3 text-sm">
<p class="text-base-content/60 mt-2 text-sm">
{currentTime.toLocaleDateString('pt-BR', {
weekday: 'long',
year: 'numeric',
@@ -225,259 +197,518 @@
{currentTime.toLocaleTimeString('pt-BR')}
</p>
</div>
<div class="flex flex-col gap-3">
<div class="badge badge-primary badge-lg animate-pulse">Sistema Online</div>
<div class="flex gap-2">
<div class="badge badge-primary badge-lg">Sistema Online</div>
<div class="badge badge-success badge-lg">Atualizado</div>
<div class="badge badge-info badge-lg">Disponível 24h</div>
</div>
</div>
</div>
<!-- Seção de Estatísticas -->
<!-- Cards de Estatísticas Principais -->
{#if statsQuery.isLoading}
<div class="flex items-center justify-center py-12">
<span class="loading loading-spinner loading-lg text-primary"></span>
</div>
{:else if statsQuery.data}
<div class="mb-8 grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4">
<!-- Card Usuários Cadastrados -->
<div class="mb-6 grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4">
<!-- Total de Funcionários -->
<div
class="stats-card card transform bg-linear-to-br from-blue-500/20 to-blue-600/30 shadow-xl transition-all duration-300 hover:scale-105 hover:shadow-2xl"
class="card transform bg-linear-to-br from-blue-500/10 to-blue-600/20 shadow-xl transition-all duration-300 hover:-translate-y-1 hover:shadow-2xl"
>
<div class="card-body">
<div class="flex items-center justify-between">
<div class="flex-1">
<p class="text-base-content/70 mb-2 text-sm font-semibold uppercase">
Usuários Cadastrados
</p>
<h2 class="text-primary text-4xl font-bold">
{#if statsQuery.data}
<span use:animateCounterAction={statsQuery.data.totalUsuarios}>0</span>
{:else}
0
{/if}
<div>
<p class="text-base-content/70 text-sm font-semibold">Total de Funcionários</p>
<h2 class="text-primary mt-2 text-4xl font-bold">
{formatNumber(statsQuery.data.totalFuncionarios)}
</h2>
<p class="text-base-content/60 mt-2 text-xs">no sistema</p>
<p class="text-base-content/60 mt-1 text-xs">
{statsQuery.data.funcionariosAtivos} ativos
</p>
</div>
<div class="bg-primary/20 rounded-full p-4">
<Users class="text-primary h-8 w-8" strokeWidth={2} />
<div
class="radial-progress text-primary"
style="--value:{calcPercentage(
statsQuery.data.funcionariosAtivos,
statsQuery.data.totalFuncionarios
)}; --size:4rem;"
>
<span class="text-xs font-bold"
>{calcPercentage(
statsQuery.data.funcionariosAtivos,
statsQuery.data.totalFuncionarios
)}%</span
>
</div>
</div>
</div>
</div>
<!-- Card Funcionários Ativos -->
<!-- Solicitações Pendentes -->
<div
class="stats-card card transform bg-linear-to-br from-green-500/20 to-green-600/30 shadow-xl transition-all duration-300 hover:scale-105 hover:shadow-2xl"
class="card transform bg-linear-to-br from-yellow-500/10 to-yellow-600/20 shadow-xl transition-all duration-300 hover:-translate-y-1 hover:shadow-2xl"
>
<div class="card-body">
<div class="flex items-center justify-between">
<div class="flex-1">
<p class="text-base-content/70 mb-2 text-sm font-semibold uppercase">
Funcionários Ativos
</p>
<h2 class="text-success text-4xl font-bold">
{#if statsQuery.data}
<span use:animateCounterAction={statsQuery.data.funcionariosAtivos}>0</span>
{:else}
0
{/if}
</h2>
<p class="text-base-content/60 mt-2 text-xs">em atividade</p>
<div>
<p class="text-base-content/70 text-sm font-semibold">Solicitações Pendentes</p>
<h2 class="text-warning mt-2 text-4xl font-bold">4</h2>
<p class="text-base-content/60 mt-1 text-xs">de 5 total</p>
</div>
<div class="bg-success/20 rounded-full p-4">
<Users class="text-success h-8 w-8" strokeWidth={2} />
</div>
</div>
</div>
</div>
<!-- Card Cadastros Realizados -->
<div
class="stats-card card transform bg-linear-to-br from-purple-500/20 to-purple-600/30 shadow-xl transition-all duration-300 hover:scale-105 hover:shadow-2xl"
>
<div class="card-body">
<div class="flex items-center justify-between">
<div class="flex-1">
<p class="text-base-content/70 mb-2 text-sm font-semibold uppercase">
Cadastros Realizados
</p>
<h2 class="text-secondary text-4xl font-bold">
{#if statsQuery.data}
<span use:animateCounterAction={statsQuery.data.totalCadastros}>0</span>
{:else}
0
{/if}
</h2>
<p class="text-base-content/60 mt-2 text-xs">total de registros</p>
</div>
<div class="bg-secondary/20 rounded-full p-4">
<BadgeCheck class="text-secondary h-8 w-8" strokeWidth={2} />
</div>
</div>
</div>
</div>
<!-- Card Disponibilidade 24h -->
<div
class="stats-card card transform bg-linear-to-br from-orange-500/20 to-orange-600/30 shadow-xl transition-all duration-300 hover:scale-105 hover:shadow-2xl"
>
<div class="card-body">
<div class="flex items-center justify-between">
<div class="flex-1">
<p class="text-base-content/70 mb-2 text-sm font-semibold uppercase">
Disponibilidade
</p>
<h2 class="text-warning text-4xl font-bold">24h</h2>
<p class="text-base-content/60 mt-2 text-xs">funcionando continuamente</p>
</div>
<div class="bg-warning/20 animate-pulse rounded-full p-4">
<div class="bg-warning/20 rounded-full p-4">
<Clock class="text-warning h-8 w-8" strokeWidth={2} />
</div>
</div>
</div>
</div>
<!-- Símbolos Cadastrados -->
<div
class="card transform bg-linear-to-br from-green-500/10 to-green-600/20 shadow-xl transition-all duration-300 hover:-translate-y-1 hover:shadow-2xl"
>
<div class="card-body">
<div class="flex items-center justify-between">
<div>
<p class="text-base-content/70 text-sm font-semibold">Símbolos Cadastrados</p>
<h2 class="text-success mt-2 text-4xl font-bold">
{formatNumber(statsQuery.data.totalSimbolos)}
</h2>
<p class="text-base-content/60 mt-1 text-xs">
{statsQuery.data.cargoComissionado} CC / {statsQuery.data.funcaoGratificada} FG
</p>
</div>
<div class="bg-success/20 rounded-full p-4">
<Award class="text-success h-8 w-8" strokeWidth={2} />
</div>
</div>
</div>
</div>
<!-- Atividade 24h -->
{#if activityQuery.data}
<div
class="card transform bg-linear-to-br from-purple-500/10 to-purple-600/20 shadow-xl transition-all duration-300 hover:-translate-y-1 hover:shadow-2xl"
>
<div class="card-body">
<div class="flex items-center justify-between">
<div>
<p class="text-base-content/70 text-sm font-semibold">Atividade (24h)</p>
<p class="text-base-content/60 mt-1 text-xs">
{activityQuery.data.funcionariosCadastrados24h} cadastros
</p>
</div>
<div class="bg-secondary/20 rounded-full p-4">
<TrendingUp class="text-secondary h-8 w-8" strokeWidth={2} />
</div>
</div>
</div>
</div>
{/if}
</div>
<!-- Monitoramento em Tempo Real -->
{#if statusSistemaQuery?.data}
{@const status = statusSistemaQuery.data}
{@const atividade = atividadeBDQuery?.data || {
historico: Array.from({ length: 30 }, () => ({ entradas: 0, saidas: 0 }))
}}
{@const distribuicao = distribuicaoQuery?.data || {
queries: 0,
mutations: 0,
leituras: 0,
escritas: 0
}}
{@const maxAtividade =
atividade.historico && atividade.historico.length > 0
? Math.max(
1,
...atividade.historico.map((p) => Math.max(p.entradas || 0, p.saidas || 0))
)
: 1}
<div class="mb-6">
<div class="mb-4 flex items-center gap-3">
<div class="bg-error/10 animate-pulse rounded-lg p-2">
<Zap class="text-error h-6 w-6" strokeWidth={2} />
</div>
<div>
<h2 class="text-base-content text-2xl font-bold">Monitoramento em Tempo Real</h2>
<p class="text-base-content/60 text-sm">
Atualizado a cada segundo • {new Date(status.ultimaAtualizacao).toLocaleTimeString(
'pt-BR'
)}
</p>
</div>
<div class="badge badge-error badge-lg ml-auto gap-2">
<span
class="bg-error absolute inline-flex h-3 w-3 animate-ping rounded-full opacity-75"
></span>
<span class="bg-error relative inline-flex h-3 w-3 rounded-full"></span>
LIVE
</div>
</div>
<!-- Cards de Status do Sistema -->
<div class="mb-6 grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
<!-- Usuários Online -->
<div
class="card from-primary/10 to-primary/5 border-primary/20 border-2 bg-linear-to-br shadow-lg"
>
<div class="card-body p-4">
<div class="flex items-center justify-between">
<div>
<p class="text-base-content/70 text-xs font-semibold uppercase">
Usuários Online
</p>
<h3 class="text-primary mt-1 text-3xl font-bold">
{status.usuariosOnline}
</h3>
<p class="text-base-content/60 mt-1 text-xs">sessões ativas</p>
</div>
<div class="bg-primary/20 rounded-full p-3">
<Users class="text-primary h-6 w-6" strokeWidth={2} />
</div>
</div>
</div>
</div>
<!-- Total de Registros -->
<div
class="card from-success/10 to-success/5 border-success/20 border-2 bg-linear-to-br shadow-lg"
>
<div class="card-body p-4">
<div class="flex items-center justify-between">
<div>
<p class="text-base-content/70 text-xs font-semibold uppercase">
Total Registros
</p>
<h3 class="text-success mt-1 text-3xl font-bold">
{status.totalRegistros.toLocaleString('pt-BR')}
</h3>
<p class="text-base-content/60 mt-1 text-xs">no banco de dados</p>
</div>
<div class="bg-success/20 rounded-full p-3">
<Database class="text-success h-6 w-6" strokeWidth={2} />
</div>
</div>
</div>
</div>
<!-- Tempo Médio de Resposta -->
<div
class="card from-info/10 to-info/5 border-info/20 border-2 bg-linear-to-br shadow-lg"
>
<div class="card-body p-4">
<div class="flex items-center justify-between">
<div>
<p class="text-base-content/70 text-xs font-semibold uppercase">
Tempo Resposta
</p>
<h3 class="text-info mt-1 text-3xl font-bold">
{status.tempoMedioResposta}ms
</h3>
<p class="text-base-content/60 mt-1 text-xs">média atual</p>
</div>
<div class="bg-info/20 rounded-full p-3">
<svg
xmlns="http://www.w3.org/2000/svg"
class="text-info h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 10V3L4 14h7v7l9-11h-7z"
/>
</svg>
</div>
</div>
</div>
</div>
<!-- Uso de Sistema -->
<div
class="card from-warning/10 to-warning/5 border-warning/20 border-2 bg-linear-to-br shadow-lg"
>
<div class="card-body p-4">
<div>
<p class="text-base-content/70 mb-2 text-xs font-semibold uppercase">
Uso do Sistema
</p>
<div class="space-y-2">
<div>
<div class="mb-1 flex justify-between text-xs">
<span class="text-base-content/70">CPU</span>
<span class="text-warning font-bold">{status.cpuUsada}%</span>
</div>
<progress
class="progress progress-warning w-full"
value={status.cpuUsada}
max="100"
></progress>
</div>
<div>
<div class="mb-1 flex justify-between text-xs">
<span class="text-base-content/70">Memória</span>
<span class="text-warning font-bold">{status.memoriaUsada}%</span>
</div>
<progress
class="progress progress-warning w-full"
value={status.memoriaUsada}
max="100"
></progress>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Gráfico de Atividade do Banco de Dados em Tempo Real -->
<div class="card bg-base-100 mb-6 shadow-xl">
<div class="card-body">
<div class="mb-4 flex items-center justify-between">
<div>
<h3 class="text-base-content text-xl font-bold">Atividade do Banco de Dados</h3>
<p class="text-base-content/60 text-sm">
Entradas e saídas em tempo real (último minuto)
</p>
</div>
<div class="badge badge-success gap-2">
<span class="loading loading-spinner loading-xs"></span>
Atualizando
</div>
</div>
<div class="relative h-64">
<!-- Eixo Y -->
<div
class="absolute top-0 bottom-8 left-0 flex w-10 flex-col justify-between pr-2 text-right"
>
{#each [10, 8, 6, 4, 2, 0] as val (val)}
<span class="text-base-content/60 text-xs">{val}</span>
{/each}
</div>
<!-- Grid e Barras -->
<div class="absolute top-0 right-4 bottom-8 left-12">
<!-- Grid horizontal -->
{#each [0, 1, 2, 3, 4, 5] as i (i)}
<div
class="border-base-content/10 absolute right-0 left-0 border-t"
style="top: {(i / 5) * 100}%;"
></div>
{/each}
<!-- Barras de atividade -->
<div class="flex h-full items-end justify-around gap-1">
{#each atividade.historico || [] as ponto, idx (idx)}
{@const entradas = ponto?.entradas || 0}
{@const saidas = ponto?.saidas || 0}
<div class="group relative flex h-full flex-1 items-end gap-0.5">
<!-- Entradas (verde) -->
<div
class="from-success to-success/70 flex-1 rounded-t bg-linear-to-t transition-all duration-300 hover:scale-110"
style="height: {(entradas / maxAtividade) * 100}%; min-height: 2px;"
title="Entradas: {entradas}"
></div>
<!-- Saídas (vermelho) -->
<div
class="from-error to-error/70 flex-1 rounded-t bg-linear-to-t transition-all duration-300 hover:scale-110"
style="height: {(saidas / maxAtividade) * 100}%; min-height: 2px;"
title="Saídas: {saidas}"
></div>
<!-- Tooltip no hover -->
<div
class="bg-base-300 text-base-content absolute bottom-full left-1/2 z-10 mb-2 -translate-x-1/2 rounded px-2 py-1 text-xs whitespace-nowrap opacity-0 shadow-lg transition-opacity group-hover:opacity-100"
>
<div>{entradas} entradas</div>
<div>{saidas} saídas</div>
</div>
</div>
{/each}
</div>
</div>
<!-- Linha do eixo X -->
<div
class="border-base-content/30 absolute right-4 bottom-8 left-12 border-t-2"
></div>
<!-- Labels do eixo X -->
<div
class="text-base-content/60 absolute right-4 bottom-0 left-12 flex justify-between text-xs"
>
<span>-60s</span>
<span>-30s</span>
<span>agora</span>
</div>
</div>
<!-- Legenda -->
<div class="border-base-300 mt-4 flex justify-center gap-6 border-t pt-4">
<div class="flex items-center gap-2">
<div class="from-success to-success/70 h-4 w-4 rounded bg-linear-to-t"></div>
<span class="text-base-content/70 text-sm">Entradas no BD</span>
</div>
<div class="flex items-center gap-2">
<div class="from-error to-error/70 h-4 w-4 rounded bg-linear-to-t"></div>
<span class="text-base-content/70 text-sm">Saídas do BD</span>
</div>
</div>
</div>
</div>
<!-- Distribuição de Requisições -->
<div class="mb-6 grid grid-cols-1 gap-6 md:grid-cols-2">
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h3 class="text-base-content mb-4 text-lg font-bold">Tipos de Operações</h3>
<div class="space-y-3">
<div>
<div class="mb-1 flex justify-between text-sm">
<span>Queries (Leituras)</span>
<span class="text-primary font-bold">{distribuicao?.queries ?? 0}</span>
</div>
<progress
class="progress progress-primary w-full"
value={distribuicao?.queries ?? 0}
max={Math.max(
(distribuicao?.queries ?? 0) + (distribuicao?.mutations ?? 0),
1
)}
></progress>
</div>
<div>
<div class="mb-1 flex justify-between text-sm">
<span>Mutations (Escritas)</span>
<span class="text-secondary font-bold">{distribuicao?.mutations ?? 0}</span>
</div>
<progress
class="progress progress-secondary w-full"
value={distribuicao?.mutations ?? 0}
max={Math.max(
(distribuicao?.queries ?? 0) + (distribuicao?.mutations ?? 0),
1
)}
></progress>
</div>
</div>
</div>
</div>
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h3 class="text-base-content mb-4 text-lg font-bold">Operações no Banco</h3>
<div class="space-y-3">
<div>
<div class="mb-1 flex justify-between text-sm">
<span>Leituras</span>
<span class="text-info font-bold">{distribuicao?.leituras ?? 0}</span>
</div>
<progress
class="progress progress-info w-full"
value={distribuicao?.leituras ?? 0}
max={Math.max(
(distribuicao?.leituras ?? 0) + (distribuicao?.escritas ?? 0),
1
)}
></progress>
</div>
<div>
<div class="mb-1 flex justify-between text-sm">
<span>Escritas</span>
<span class="text-warning font-bold">{distribuicao?.escritas ?? 0}</span>
</div>
<progress
class="progress progress-warning w-full"
value={distribuicao?.escritas ?? 0}
max={Math.max(
(distribuicao?.leituras ?? 0) + (distribuicao?.escritas ?? 0),
1
)}
></progress>
</div>
</div>
</div>
</div>
</div>
</div>
{/if}
<!-- Cards de Status -->
<div class="grid grid-cols-1 gap-6 md:grid-cols-3">
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h3 class="card-title text-lg">Status do Sistema</h3>
<div class="mt-4 space-y-2">
<div class="flex items-center justify-between">
<span class="text-sm">Banco de Dados</span>
<span class="badge badge-success">Online</span>
</div>
<div class="flex items-center justify-between">
<span class="text-sm">API</span>
<span class="badge badge-success">Operacional</span>
</div>
<div class="flex items-center justify-between">
<span class="text-sm">Backup</span>
<span class="badge badge-success">Atualizado</span>
</div>
</div>
</div>
</div>
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h3 class="card-title text-lg">Acesso Rápido</h3>
<div class="mt-4 space-y-2">
<a
href={resolve('/recursos-humanos/funcionarios/cadastro')}
class="btn btn-sm btn-primary w-full"
>
Novo Funcionário
</a>
<a
href={resolve('/recursos-humanos/simbolos/cadastro')}
class="btn btn-sm btn-primary w-full"
>
Novo Símbolo
</a>
<a href={resolve('/ti/painel-administrativo')} class="btn btn-sm btn-primary w-full">
Painel Admin
</a>
</div>
</div>
</div>
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h3 class="card-title text-lg">Informações</h3>
<div class="mt-4 space-y-2 text-sm">
<p class="text-base-content/70">
<strong>Versão:</strong> 1.0.0
</p>
<p class="text-base-content/70">
<strong>Última Atualização:</strong>
{new Date().toLocaleDateString('pt-BR')}
</p>
<p class="text-base-content/70">
<strong>Suporte:</strong> TI SGSE
</p>
</div>
</div>
</div>
</div>
{:else}
<!-- Mensagem de erro ou estado vazio -->
<div class="alert alert-warning">
<span>Não foi possível carregar os dados do dashboard.</span>
</div>
{/if}
<!-- Seção Sobre o SGSE -->
<div
class="fade-in-delay from-base-200 to-base-300 mb-8 rounded-2xl bg-linear-to-br p-8 shadow-xl"
>
<div class="mx-auto max-w-4xl text-center">
<h2 class="text-base-content mb-4 text-3xl font-bold">Sobre o SGSE</h2>
<p
class="from-primary to-secondary mb-6 bg-gradient-to-r bg-clip-text text-2xl font-bold text-transparent"
>
Simplificando a Gestão Pública
</p>
<p class="text-base-content/80 mb-4 text-lg leading-relaxed">
O Sistema de Gerenciamento de Secretaria (SGSE) é uma solução completa e moderna
desenvolvida para otimizar e simplificar os processos administrativos da gestão pública.
Com tecnologia de ponta e interface intuitiva, oferecemos rapidez, comodidade e
disponibilidade 24 horas por dia para atender às necessidades dos nossos usuários.
</p>
<p class="text-base-content/70 text-base leading-relaxed">
Nossa plataforma integra todas as funcionalidades essenciais em um único ambiente,
permitindo gestão eficiente de funcionários, controle de ponto, férias, licenças, símbolos
e muito mais. Trabalhamos continuamente para garantir que você tenha acesso rápido e
seguro a todas as informações e ferramentas necessárias para uma gestão pública de
excelência.
</p>
</div>
</div>
<!-- Grid de Funcionalidades Principais -->
<div class="fade-in-delay-2 mb-8">
<h2 class="text-base-content mb-6 text-center text-3xl font-bold">
Principais Funcionalidades
</h2>
<div class="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
<!-- Gestão de Funcionários -->
<a
href={resolve('/recursos-humanos/funcionarios')}
class="feature-card group card transform bg-linear-to-br from-blue-500/10 to-blue-600/20 shadow-lg transition-all duration-300 hover:-translate-y-2 hover:shadow-2xl"
>
<div class="card-body">
<div class="mb-4 flex items-center justify-between">
<div
class="rounded-xl bg-blue-500/20 p-4 transition-colors duration-300 group-hover:bg-blue-500/30"
>
<Users class="h-10 w-10 text-blue-600" strokeWidth={2} />
</div>
</div>
<h3 class="text-base-content mb-2 text-xl font-bold">Gestão de Funcionários</h3>
<p class="text-base-content/70 text-sm">
Gerencie o cadastro completo de funcionários, informações pessoais, documentos e muito
mais.
</p>
</div>
</a>
<!-- Controle de Ponto -->
<a
href={resolve('/recursos-humanos/registro-pontos')}
class="feature-card group card transform bg-linear-to-br from-cyan-500/10 to-cyan-600/20 shadow-lg transition-all duration-300 hover:-translate-y-2 hover:shadow-2xl"
>
<div class="card-body">
<div class="mb-4 flex items-center justify-between">
<div
class="rounded-xl bg-cyan-500/20 p-4 transition-colors duration-300 group-hover:bg-cyan-500/30"
>
<Clock class="h-10 w-10 text-cyan-600" strokeWidth={2} />
</div>
</div>
<h3 class="text-base-content mb-2 text-xl font-bold">Controle de Ponto</h3>
<p class="text-base-content/70 text-sm">
Registre e gerencie pontos de funcionários, banco de horas e homologações de forma
eficiente.
</p>
</div>
</a>
<!-- Gestão de Férias e Licenças -->
<a
href={resolve('/recursos-humanos/ferias')}
class="feature-card group card transform bg-linear-to-br from-purple-500/10 to-purple-600/20 shadow-lg transition-all duration-300 hover:-translate-y-2 hover:shadow-2xl"
>
<div class="card-body">
<div class="mb-4 flex items-center justify-between">
<div
class="rounded-xl bg-purple-500/20 p-4 transition-colors duration-300 group-hover:bg-purple-500/30"
>
<Calendar class="h-10 w-10 text-purple-600" strokeWidth={2} />
</div>
</div>
<h3 class="text-base-content mb-2 text-xl font-bold">Gestão de Férias e Licenças</h3>
<p class="text-base-content/70 text-sm">
Controle períodos de férias, atestados médicos e licenças de forma organizada e
simplificada.
</p>
</div>
</a>
<!-- Gestão de Símbolos -->
<a
href={resolve('/recursos-humanos/simbolos')}
class="feature-card group card transform bg-linear-to-br from-green-500/10 to-green-600/20 shadow-lg transition-all duration-300 hover:-translate-y-2 hover:shadow-2xl"
>
<div class="card-body">
<div class="mb-4 flex items-center justify-between">
<div
class="rounded-xl bg-green-500/20 p-4 transition-colors duration-300 group-hover:bg-green-500/30"
>
<BadgeCheck class="h-10 w-10 text-green-600" strokeWidth={2} />
</div>
</div>
<h3 class="text-base-content mb-2 text-xl font-bold">Gestão de Símbolos</h3>
<p class="text-base-content/70 text-sm">
Gerencie cargos comissionados e funções gratificadas com facilidade e organização.
</p>
</div>
</a>
<!-- Almoxarifado -->
<a
href={resolve('/almoxarifado')}
class="feature-card group card transform bg-linear-to-br from-amber-500/10 to-amber-600/20 shadow-lg transition-all duration-300 hover:-translate-y-2 hover:shadow-2xl"
>
<div class="card-body">
<div class="mb-4 flex items-center justify-between">
<div
class="rounded-xl bg-amber-500/20 p-4 transition-colors duration-300 group-hover:bg-amber-500/30"
>
<Package class="h-10 w-10 text-amber-600" strokeWidth={2} />
</div>
</div>
<h3 class="text-base-content mb-2 text-xl font-bold">Almoxarifado</h3>
<p class="text-base-content/70 text-sm">
Controle de estoque, materiais, movimentações e requisições de forma integrada e
eficiente.
</p>
</div>
</a>
</div>
</div>
</main>
</ProtectedRoute>
@@ -493,37 +724,7 @@
}
}
.fade-in {
animation: fadeIn 0.6s ease-out;
}
.fade-in-delay {
animation: fadeIn 0.8s ease-out 0.2s both;
}
.fade-in-delay-2 {
animation: fadeIn 1s ease-out 0.4s both;
}
.stats-card {
animation: fadeIn 0.6s ease-out;
}
.feature-card {
animation: fadeIn 0.8s ease-out;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.animate-pulse {
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
.card {
animation: fadeIn 0.5s ease-out;
}
</style>

View File

@@ -0,0 +1,819 @@
<script lang="ts">
import { api } from '@sgse-app/backend/convex/_generated/api';
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
import { useConvexClient, useQuery } from 'convex-svelte';
import { toast } from 'svelte-sonner';
import Breadcrumbs from '$lib/components/layout/Breadcrumbs.svelte';
import PageShell from '$lib/components/layout/PageShell.svelte';
import ConfirmationModal from '$lib/components/ConfirmationModal.svelte';
import {
Plus,
Edit,
Trash2,
ArrowRight,
GripVertical,
Clock,
Building2,
Star,
X,
Workflow,
Layers
} from 'lucide-svelte';
const client = useConvexClient();
// Queries
const etapasQuery = $derived.by(() => useQuery(api.pedidoFlow.listEtapas, {}));
const transicoesQuery = $derived.by(() => useQuery(api.pedidoFlow.listTransicoes, {}));
const setoresQuery = $derived.by(() => useQuery(api.setores.list, {}));
const etapas = $derived(etapasQuery.data ?? []);
const transicoes = $derived(transicoesQuery.data ?? []);
const setores = $derived(setoresQuery.data ?? []);
const loading = $derived(etapasQuery.isLoading || transicoesQuery.isLoading);
// Estado do formulário de etapa
let showEtapaModal = $state(false);
let editingEtapaId = $state<Id<'pedidoFluxoEtapa'> | null>(null);
let etapaForm = $state({
nome: '',
codigo: '',
descricao: '',
setorId: '' as string,
tempoEstimadoDias: '' as string,
incluirNoTimeline: true
});
let savingEtapa = $state(false);
// Estado do formulário de transição
let showTransicaoModal = $state(false);
let transicaoForm = $state({
etapaOrigemId: '' as string,
etapaDestinoId: '' as string
});
let savingTransicao = $state(false);
// Confirmation Modal
let confirmModal = $state({
open: false,
title: '',
message: '',
confirmText: 'Confirmar',
cancelText: 'Cancelar',
isDestructive: false,
onConfirm: async () => {}
});
function openConfirm(
title: string,
message: string,
onConfirm: () => Promise<void> | void,
options: {
confirmText?: string;
cancelText?: string;
isDestructive?: boolean;
} = {}
) {
confirmModal.title = title;
confirmModal.message = message;
confirmModal.confirmText = options.confirmText || 'Confirmar';
confirmModal.cancelText = options.cancelText || 'Cancelar';
confirmModal.isDestructive = options.isDestructive || false;
confirmModal.onConfirm = async () => {
try {
await onConfirm();
} catch (e) {
toast.error('Erro: ' + (e as Error).message);
}
};
confirmModal.open = true;
}
// Funções de Etapa
function openNewEtapa() {
editingEtapaId = null;
etapaForm = {
nome: '',
codigo: '',
descricao: '',
setorId: '',
tempoEstimadoDias: '',
incluirNoTimeline: true
};
showEtapaModal = true;
}
function openEditEtapa(etapa: (typeof etapas)[number]) {
editingEtapaId = etapa._id;
etapaForm = {
nome: etapa.nome,
codigo: etapa.codigo,
descricao: etapa.descricao ?? '',
setorId: etapa.setorId ?? '',
tempoEstimadoDias: etapa.tempoEstimadoDias?.toString() ?? '',
incluirNoTimeline: etapa.incluirNoTimeline
};
showEtapaModal = true;
}
function closeEtapaModal() {
showEtapaModal = false;
editingEtapaId = null;
}
async function handleSaveEtapa() {
if (!etapaForm.nome.trim() || !etapaForm.codigo.trim()) {
toast.error('Nome e código são obrigatórios');
return;
}
savingEtapa = true;
try {
const tempoEstimado = etapaForm.tempoEstimadoDias
? parseInt(etapaForm.tempoEstimadoDias, 10)
: undefined;
if (editingEtapaId) {
await client.mutation(api.pedidoFlow.updateEtapa, {
id: editingEtapaId,
nome: etapaForm.nome.trim(),
codigo: etapaForm.codigo.trim(),
descricao: etapaForm.descricao.trim() || undefined,
setorId: etapaForm.setorId ? (etapaForm.setorId as Id<'setores'>) : undefined,
tempoEstimadoDias: tempoEstimado,
incluirNoTimeline: etapaForm.incluirNoTimeline
});
toast.success('Etapa atualizada com sucesso!');
} else {
await client.mutation(api.pedidoFlow.createEtapa, {
nome: etapaForm.nome.trim(),
codigo: etapaForm.codigo.trim(),
descricao: etapaForm.descricao.trim() || undefined,
setorId: etapaForm.setorId ? (etapaForm.setorId as Id<'setores'>) : undefined,
tempoEstimadoDias: tempoEstimado,
incluirNoTimeline: etapaForm.incluirNoTimeline
});
toast.success('Etapa criada com sucesso!');
}
closeEtapaModal();
} catch (e) {
toast.error('Erro ao salvar etapa: ' + (e as Error).message);
} finally {
savingEtapa = false;
}
}
function handleDeleteEtapa(etapa: (typeof etapas)[number]) {
openConfirm(
'Excluir Etapa',
`Tem certeza que deseja excluir a etapa "${etapa.nome}"?`,
async () => {
await client.mutation(api.pedidoFlow.deleteEtapa, { id: etapa._id });
toast.success('Etapa excluída com sucesso!');
},
{ isDestructive: true, confirmText: 'Excluir' }
);
}
// Funções de Transição
function openNewTransicao() {
transicaoForm = {
etapaOrigemId: '',
etapaDestinoId: ''
};
showTransicaoModal = true;
}
function closeTransicaoModal() {
showTransicaoModal = false;
}
async function handleSaveTransicao() {
if (!transicaoForm.etapaOrigemId || !transicaoForm.etapaDestinoId) {
toast.error('Selecione as etapas de origem e destino');
return;
}
savingTransicao = true;
try {
await client.mutation(api.pedidoFlow.createTransicao, {
etapaOrigemId: transicaoForm.etapaOrigemId as Id<'pedidoFluxoEtapa'>,
etapaDestinoId: transicaoForm.etapaDestinoId as Id<'pedidoFluxoEtapa'>
});
toast.success('Transição criada com sucesso!');
closeTransicaoModal();
} catch (e) {
toast.error('Erro ao criar transição: ' + (e as Error).message);
} finally {
savingTransicao = false;
}
}
function handleDeleteTransicao(transicao: (typeof transicoes)[number]) {
openConfirm(
'Excluir Transição',
`Tem certeza que deseja excluir a transição "${transicao.etapaOrigemNome}" → "${transicao.etapaDestinoNome}"?`,
async () => {
await client.mutation(api.pedidoFlow.deleteTransicao, { id: transicao._id });
toast.success('Transição excluída com sucesso!');
},
{ isDestructive: true, confirmText: 'Excluir' }
);
}
async function handleSetPadrao(transicaoId: Id<'pedidoFluxoTransicao'>) {
try {
await client.mutation(api.pedidoFlow.setTransicaoPadrao, { id: transicaoId });
toast.success('Transição definida como padrão!');
} catch (e) {
toast.error('Erro: ' + (e as Error).message);
}
}
// Gerar código a partir do nome
function generateCodigo(nome: string): string {
return nome
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9\s]/g, '')
.replace(/\s+/g, '_')
.substring(0, 30);
}
function handleNomeChange() {
if (!editingEtapaId) {
etapaForm.codigo = generateCodigo(etapaForm.nome);
}
}
</script>
<PageShell>
<Breadcrumbs
items={[
{ label: 'Configurações', href: '/configuracoes' },
{ label: 'Fluxo de Pedidos', href: '/configuracoes/fluxo-pedidos' }
]}
/>
<div class="mb-8 flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
<div>
<h1 class="text-base-content text-3xl font-extrabold tracking-tight">Fluxo de Pedidos</h1>
<p class="text-base-content/60 text-lg">
Gerencie as etapas e as transições automáticas do sistema
</p>
</div>
<div class="flex gap-2">
<button class="btn btn-primary shadow-md" onclick={openNewEtapa}>
<Plus size={20} />
Nova Etapa
</button>
</div>
</div>
{#if loading}
<div class="flex flex-col items-center justify-center gap-4 py-24">
<span class="loading loading-infinity loading-lg text-primary"></span>
<span class="text-base-content/60 animate-pulse font-medium">Sincronizando fluxo...</span>
</div>
{:else}
<!-- Stats Section -->
<div class="stats bg-base-100 border-base-200 mb-8 w-full overflow-hidden border shadow-lg">
<div class="stat">
<div class="stat-figure text-primary">
<Layers size={32} />
</div>
<div class="stat-title text-xs font-semibold tracking-wider uppercase opacity-60">
Etapas Ativas
</div>
<div class="stat-value text-primary">{etapas.length}</div>
<div class="stat-desc font-medium">Status do workflow</div>
</div>
<div class="stat">
<div class="stat-figure text-secondary">
<Workflow size={32} />
</div>
<div class="stat-title text-xs font-semibold tracking-wider uppercase opacity-60">
Transições
</div>
<div class="stat-value text-secondary">{transicoes.length}</div>
<div class="stat-desc font-medium">Caminhos possíveis</div>
</div>
<div class="stat">
<div class="stat-figure text-accent">
<Star size={32} />
</div>
<div class="stat-title text-xs font-semibold tracking-wider uppercase opacity-60">
Transições Padrão
</div>
<div class="stat-value text-accent">{transicoes.filter((t) => t.isPadrao).length}</div>
<div class="stat-desc font-medium">Avanços automáticos</div>
</div>
</div>
<div class="grid gap-8 lg:grid-cols-2">
<!-- Seção de Etapas -->
<div class="flex flex-col gap-4">
<div class="border-base-200 flex items-center gap-2 border-b pb-2">
<Layers size={20} class="text-primary" />
<h2 class="text-xl font-bold">Etapas Disponíveis</h2>
</div>
{#if etapas.length === 0}
<div class="card bg-base-100 border-base-300 border border-dashed">
<div class="card-body items-center py-12 text-center">
<div class="bg-base-200 mb-4 rounded-full p-4">
<Clock size={40} class="text-base-content/40" />
</div>
<h3 class="text-lg font-bold">Sem etapas</h3>
<p class="text-base-content/60 mb-4 text-sm">
Nenhuma etapa configurada para o fluxo de pedidos.
</p>
<button class="btn btn-primary btn-sm btn-outline px-8" onclick={openNewEtapa}>
Começar Agora
</button>
</div>
</div>
{:else}
<div class="grid gap-3">
{#each etapas as etapa (etapa._id)}
<div
class={[
'group card bg-base-100 border shadow-sm transition-all duration-300 hover:shadow-md',
etapa.incluirNoTimeline
? 'border-base-200 hover:border-primary/30'
: 'border-base-200 opacity-80'
]}
>
<div class="card-body flex-row items-center gap-4 p-4">
<div
class="text-base-content/20 group-hover:text-primary/40 flex-none cursor-grab transition-colors"
>
<GripVertical size={20} />
</div>
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<h3 class="text-base leading-tight font-bold">{etapa.nome}</h3>
{#if !etapa.incluirNoTimeline}
<div class="badge badge-sm badge-ghost border-base-300 bg-base-200/50">
Oculto
</div>
{/if}
</div>
<div class="mt-1 flex flex-wrap items-center gap-x-4 gap-y-1">
<span
class="bg-base-200 text-base-content/70 rounded px-2 py-0.5 font-mono text-[10px]"
>
{etapa.codigo}
</span>
{#if etapa.tempoEstimadoDias}
<div class="text-secondary flex items-center gap-1 text-xs font-medium">
<Clock size={12} />
{etapa.tempoEstimadoDias}d
</div>
{/if}
{#if etapa.setorNome}
<div class="text-accent flex items-center gap-1 text-xs font-medium">
<Building2 size={12} />
{etapa.setorNome}
</div>
{/if}
</div>
</div>
<div class="flex flex-none items-center gap-1">
<button
class="btn btn-ghost btn-sm hover:bg-primary/10 hover:text-primary transition-colors"
onclick={() => openEditEtapa(etapa)}
title="Editar etapa"
>
<Edit size={16} />
</button>
<button
class="btn btn-error btn-sm text-base-content/30 hover:bg-error/10 hover:text-error transition-colors"
onclick={() => handleDeleteEtapa(etapa)}
title="Excluir etapa"
>
<Trash2 size={16} />
</button>
</div>
</div>
</div>
{/each}
</div>
{/if}
</div>
<!-- Seção de Transições -->
<div class="flex flex-col gap-4">
<div class="border-base-200 flex items-center justify-between border-b pb-2">
<div class="flex items-center gap-2">
<Workflow size={20} class="text-secondary" />
<h2 class="text-xl font-bold">Lógica de Transição</h2>
</div>
<button
class="btn btn-secondary btn-xs btn-outline rounded-full"
onclick={openNewTransicao}
disabled={etapas.length < 2}
>
Nova Lógica
</button>
</div>
{#if transicoes.length === 0}
<div class="card bg-base-100 border-base-300 border border-dashed">
<div class="card-body items-center py-12 text-center">
<div class="bg-base-200 text-base-content/40 mb-4 rounded-full p-4">
<ArrowRight size={40} />
</div>
<h3 class="text-lg font-bold">Nenhuma conexão</h3>
<p class="text-base-content/60 mb-4 px-8 text-sm">
Define os caminhos que um pedido pode tomar entre as etapas.
</p>
{#if etapas.length >= 2}
<button
class="btn btn-secondary btn-sm btn-outline px-8"
onclick={openNewTransicao}
>
Configurar Conexão
</button>
{:else}
<div class="badge badge-warning py-3 text-xs">Adicione etapas primeiro</div>
{/if}
</div>
</div>
{:else}
<div class="grid gap-3">
{#each transicoes as transicao (transicao._id)}
<div
class={[
'group card bg-base-100 border shadow-sm transition-all duration-300 hover:shadow-md',
transicao.isPadrao
? 'border-accent/40 bg-accent/5'
: 'border-base-200 hover:border-secondary/30'
]}
>
<div class="card-body flex-row items-center justify-between gap-4 p-4">
<div class="flex min-w-0 flex-1 items-center gap-3">
<div class="flex flex-col">
<span
class="text-base-content/40 mb-0.5 text-[10px] font-black tracking-widest uppercase"
>Origem</span
>
<span class="truncate text-sm font-bold md:text-base"
>{transicao.etapaOrigemNome}</span
>
</div>
<div
class="text-base-content/30 group-hover:text-secondary/50 flex flex-col items-center px-1 transition-colors"
>
<ArrowRight size={20} />
</div>
<div class="flex flex-col">
<span
class="text-base-content/40 mb-0.5 text-end text-[10px] font-black tracking-widest uppercase"
>Destino</span
>
<span class="truncate text-end text-sm font-bold md:text-base"
>{transicao.etapaDestinoNome}</span
>
</div>
</div>
<div class="flex items-center gap-2">
{#if transicao.isPadrao}
<div
class="badge badge-accent badge-sm animate-appearance gap-1 px-3 py-3 font-black shadow-sm"
>
<Star size={12} fill="currentColor" />
PADRÃO
</div>
{:else}
<button
class="btn btn-ghost btn-sm text-base-content/30 hover:text-accent hover:bg-accent/10 opacity-0 transition-all group-hover:opacity-100"
onclick={() => handleSetPadrao(transicao._id)}
title="Marcar como avanço padrão"
>
<Star size={16} />
</button>
{/if}
<div class="divider divider-horizontal mx-0 h-6 opacity-30"></div>
<button
class="btn btn-sm btn-error text-base-content/30 hover:text-error hover:bg-error/10 transition-all"
onclick={() => handleDeleteTransicao(transicao)}
title="Excluir transição"
>
<Trash2 size={16} />
</button>
</div>
</div>
</div>
{/each}
</div>
{/if}
</div>
</div>
{/if}
</PageShell>
<!-- Modals -->
<!-- Modal de Etapa -->
{#if showEtapaModal}
<div class="modal modal-open" role="dialog" aria-modal="true">
<button class="modal-backdrop" onclick={closeEtapaModal} aria-label="Fechar modal"></button>
<div class="modal-box border-base-300 max-w-lg overflow-hidden border p-0 shadow-2xl">
<!-- Header -->
<div class="bg-base-200 border-base-300 flex items-center justify-between border-b px-6 py-4">
<div class="flex items-center gap-2">
<div class="bg-primary/10 text-primary rounded-lg p-2">
<Layers size={20} />
</div>
<h3 class="text-lg font-black tracking-tight uppercase">
{editingEtapaId ? 'Editar Etapa' : 'Nova Etapa'}
</h3>
</div>
<button class="btn btn-circle btn-sm" onclick={closeEtapaModal}>
<X size={20} />
</button>
</div>
<div class="space-y-5 p-6">
<div class="form-control w-full">
<label class="label pt-0" for="etapa-nome">
<span
class="label-text text-base-content/60 text-[10px] font-bold tracking-widest uppercase"
>Identificação da Etapa</span
>
</label>
<div class="join w-full">
<input
id="etapa-nome"
type="text"
placeholder="Ex: Aguardando Aceite"
class="input input-bordered join-item focus:input-primary w-full font-medium transition-all"
bind:value={etapaForm.nome}
oninput={handleNomeChange}
/>
<div
class="join-item bg-base-200 text-base-content/40 flex items-center border-y border-r px-4"
>
<Layers size={18} />
</div>
</div>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control w-full">
<label class="label pt-0" for="etapa-codigo">
<span
class="label-text text-base-content/60 text-[10px] font-bold tracking-widest uppercase"
>Código Referência</span
>
</label>
<input
id="etapa-codigo"
type="text"
placeholder="Ex: aguardando_aceite"
class="input input-bordered focus:input-primary w-full font-mono text-xs"
bind:value={etapaForm.codigo}
/>
</div>
<div class="form-control w-full">
<label class="label pt-0" for="etapa-tempo">
<span
class="label-text text-base-content/60 text-[10px] font-bold tracking-widest uppercase"
>Tempo Estimado (dias)</span
>
</label>
<div
class="input input-bordered focus-within:outline-primary group flex items-center gap-2 overflow-hidden pr-0 pl-4"
>
<Clock
size={16}
class="text-base-content/30 group-focus-within:text-primary transition-colors"
/>
<input
id="etapa-tempo"
type="number"
min="0"
class="ml-1 h-full w-full font-medium focus:outline-none"
placeholder="0"
bind:value={etapaForm.tempoEstimadoDias}
/>
</div>
</div>
</div>
<div class="form-control w-full">
<label class="label pt-0" for="etapa-setor">
<span
class="label-text text-base-content/60 text-[10px] font-bold tracking-widest uppercase"
>Setor Responsável</span
>
</label>
<select
id="etapa-setor"
class="select select-bordered focus:select-primary w-full font-medium"
bind:value={etapaForm.setorId}
>
<option value="">Nenhum</option>
{#each setores as setor (setor._id)}
<option value={setor._id}>{setor.nome}</option>
{/each}
</select>
</div>
<div class="form-control w-full">
<label class="label pt-0" for="etapa-descricao">
<span
class="label-text text-base-content/60 text-[10px] font-bold tracking-widest uppercase"
>Observações Internas</span
>
</label>
<textarea
id="etapa-descricao"
placeholder="Detalhes sobre esta etapa..."
class="textarea textarea-bordered focus:textarea-primary min-h-[80px] leading-tight font-medium"
bind:value={etapaForm.descricao}
></textarea>
</div>
<div class="bg-base-200/50 border-base-200 rounded-xl border p-4">
<label class="label cursor-pointer justify-start gap-4">
<input
type="checkbox"
class="toggle toggle-primary toggle-sm"
bind:checked={etapaForm.incluirNoTimeline}
/>
<div class="flex flex-col">
<span class="label-text text-sm font-bold">Exibir no Timeline do Pedido</span>
<span class="text-base-content/50 text-[10px] font-black uppercase"
>Histórico Visível</span
>
</div>
</label>
</div>
</div>
<div class="bg-base-200 border-base-300 flex justify-between gap-3 border-t px-6 py-4">
<button class="btn btn-ghost hover:bg-base-300 shadow-sm" onclick={closeEtapaModal}>
Fechar
</button>
<button
class="btn btn-primary px-10 shadow-lg"
onclick={handleSaveEtapa}
disabled={savingEtapa}
>
{#if savingEtapa}
<span class="loading loading-spinner loading-xs"></span>
Gravando
{:else}
Confirmar
{/if}
</button>
</div>
</div>
</div>
{/if}
<!-- Modal de Transição -->
{#if showTransicaoModal}
<div class="modal modal-open" role="dialog" aria-modal="true">
<button class="modal-backdrop" onclick={closeTransicaoModal} aria-label="Fechar modal"></button>
<div class="modal-box border-base-300 max-w-md overflow-hidden border p-0 shadow-2xl">
<div class="bg-base-200 border-base-300 flex items-center justify-between border-b px-6 py-4">
<div class="flex items-center gap-2">
<div class="bg-secondary/10 text-secondary rounded-lg p-2">
<Workflow size={20} />
</div>
<h3 class="text-lg font-black tracking-tight uppercase">Nova Transição</h3>
</div>
<button class="btn btn-circle btn-sm" onclick={closeTransicaoModal}>
<X size={20} />
</button>
</div>
<div class="space-y-8 p-8 pb-4">
<div class="relative">
<div class="form-control">
<label class="label pt-0" for="transicao-origem">
<span
class="label-text text-base-content/50 text-[10px] font-black tracking-widest uppercase"
>Fluxo de Saída</span
>
</label>
<select
id="transicao-origem"
class="select select-bordered select-lg focus:select-secondary w-full font-bold shadow-sm"
bind:value={transicaoForm.etapaOrigemId}
>
<option value="">Selecione origem...</option>
{#each etapas as etapa (etapa._id)}
<option value={etapa._id}>{etapa.nome}</option>
{/each}
</select>
</div>
<div class="absolute -bottom-6 left-1/2 z-10 -translate-x-1/2">
<div
class="bg-secondary text-secondary-content border-base-100 ring-secondary/20 scale-110 rounded-full border-2 p-2 shadow-lg ring-4"
>
<ArrowRight size={20} class="rotate-90 md:rotate-0" />
</div>
</div>
</div>
<div class="form-control">
<label class="label pt-4" for="transicao-destino">
<span
class="label-text text-base-content/50 w-full text-end text-[10px] font-black tracking-widest uppercase"
>Etapa de Chegada</span
>
</label>
<select
id="transicao-destino"
class="select select-bordered select-lg focus:select-secondary w-full font-bold shadow-sm"
bind:value={transicaoForm.etapaDestinoId}
>
<option value="">Selecione destino...</option>
{#each etapas as etapa (etapa._id)}
<option value={etapa._id} disabled={etapa._id === transicaoForm.etapaOrigemId}>
{etapa.nome}
</option>
{/each}
</select>
</div>
<div class="alert alert-info border-info/20 mt-4 flex items-start gap-3 rounded-xl">
<div class="bg-info text-info-content mt-0.5 rounded-full p-1">
<Star size={10} fill="currentColor" />
</div>
<div class="text-[11px] leading-snug font-medium">
A primeira conexão criada para cada etapa será o <span class="font-bold underline"
>caminho preferencial</span
> (padrão) do sistema.
</div>
</div>
</div>
<div class="bg-base-200 mt-4 flex flex-col gap-2 px-6 py-6">
<button
class="btn btn-secondary btn-block shadow-lg"
onclick={handleSaveTransicao}
disabled={savingTransicao}
>
{#if savingTransicao}
<span class="loading loading-spinner loading-sm"></span>
Processando...
{:else}
Criar Transição
{/if}
</button>
<button class="btn btn-block btn-sm" onclick={closeTransicaoModal}> Descartar </button>
</div>
</div>
</div>
{/if}
<ConfirmationModal
bind:open={confirmModal.open}
title={confirmModal.title}
message={confirmModal.message}
confirmText={confirmModal.confirmText}
cancelText={confirmModal.cancelText}
isDestructive={confirmModal.isDestructive}
onConfirm={confirmModal.onConfirm}
onClose={() => (confirmModal.open = false)}
/>
<style>
/* Animating custom elements */
.animate-appearance {
animation: appear 0.4s cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes appear {
from {
opacity: 0;
transform: scale(0.9);
}
to {
opacity: 1;
transform: scale(1);
}
}
/* Force cursor style for modal backdrop */
:global(.modal-backdrop) {
cursor: default !important;
}
</style>

View File

@@ -8,6 +8,7 @@
import ConfirmationModal from '$lib/components/ConfirmationModal.svelte';
import Breadcrumbs from '$lib/components/layout/Breadcrumbs.svelte';
import PageShell from '$lib/components/layout/PageShell.svelte';
import PedidoTimeline from '$lib/components/pedidos/PedidoTimeline.svelte';
import GlassCard from '$lib/components/ui/GlassCard.svelte';
import {
AlertTriangle,
@@ -1355,6 +1356,14 @@
</div></GlassCard
>
<!-- Timeline do Pedido -->
<GlassCard class="mb-6">
<div class="mb-4 flex items-center justify-between">
<h3 class="text-lg font-semibold text-gray-900">Timeline do Pedido</h3>
</div>
<PedidoTimeline {pedidoId} />
</GlassCard>
<!-- Documentos do Pedido -->
<GlassCard class="mb-6" bodyClass="p-0">
<div class="border-base-300 flex items-center justify-between border-b px-6 py-4">

View File

@@ -6,7 +6,6 @@
import FuncionarioMatriculaAutocomplete from '$lib/components/FuncionarioMatriculaAutocomplete.svelte';
import FileUpload from '$lib/components/FileUpload.svelte';
import ErrorModal from '$lib/components/ErrorModal.svelte';
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
import CalendarioAfastamentos from '$lib/components/CalendarioAfastamentos.svelte';
import AreaChart from '$lib/components/ti/charts/AreaChart.svelte';
import UserAvatar from '$lib/components/chat/UserAvatar.svelte';
@@ -133,14 +132,6 @@
titulo: ''
});
// Modal de exclusão
let exclusaoModal = $state({
aberto: false,
tipo: null as 'atestado' | 'licenca' | null,
id: null as string | null,
nome: ''
});
// Licenças maternidade para prorrogação (derivar dos dados já carregados)
let licencasMaternidade = $derived.by(() => {
const dados = dadosQuery?.data;
@@ -660,42 +651,26 @@
}
});
// Abrir modal de exclusão
function abrirModalExclusao(tipo: 'atestado' | 'licenca', id: string, nome: string) {
exclusaoModal = {
aberto: true,
tipo,
id,
nome
};
}
// Confirmar exclusão
async function confirmarExclusao() {
if (!exclusaoModal.tipo || !exclusaoModal.id) return;
// Excluir registro
async function excluirRegistro(tipo: 'atestado' | 'licenca', id: string) {
if (!confirm(`Tem certeza que deseja excluir este ${tipo}?`)) return;
try {
if (exclusaoModal.tipo === 'atestado') {
if (tipo === 'atestado') {
await client.mutation(api.atestadosLicencas.excluirAtestado, {
id: exclusaoModal.id as Id<'atestados'>
id: id as Id<'atestados'>
});
} else {
await client.mutation(api.atestadosLicencas.excluirLicenca, {
id: exclusaoModal.id as Id<'licencas'>
id: id as Id<'licencas'>
});
}
toast.success('Registro excluído com sucesso!');
exclusaoModal.aberto = false;
} catch (error: unknown) {
toast.error(getErrorMessage(error, 'Erro ao excluir registro'));
}
}
// Cancelar exclusão
function cancelarExclusao() {
exclusaoModal.aberto = false;
}
// Filtrar registros
let registrosFiltrados = $derived.by(() => {
const dados = dadosQuery?.data;
@@ -1702,12 +1677,7 @@
{/if}
<button
class="btn btn-xs btn-error"
onclick={() =>
abrirModalExclusao(
'atestado',
atestado._id,
`${atestado.tipo === 'atestado_medico' ? 'Atestado Médico' : 'Declaração'} - ${atestado.funcionario?.nome || 'Funcionário'}`
)}
onclick={() => excluirRegistro('atestado', atestado._id)}
>
Excluir
</button>
@@ -1769,12 +1739,7 @@
{/if}
<button
class="btn btn-xs btn-error"
onclick={() =>
abrirModalExclusao(
'licenca',
licenca._id,
`${licenca.tipo === 'maternidade' ? 'Licença Maternidade' : 'Licença Paternidade'} - ${licenca.funcionario?.nome || 'Funcionário'}`
)}
onclick={() => excluirRegistro('licenca', licenca._id)}
>
Excluir
</button>
@@ -2473,17 +2438,6 @@
}}
/>
<!-- Modal de Confirmação de Exclusão -->
<ConfirmModal
bind:open={exclusaoModal.aberto}
title="Confirmar Exclusão"
message="Tem certeza que deseja excluir este registro? Esta ação não pode ser desfeita e todos os dados relacionados serão removidos permanentemente."
confirmText="Excluir"
cancelText="Cancelar"
onConfirm={confirmarExclusao}
onCancel={cancelarExclusao}
/>
<!-- Modal de Documento -->
{#if documentoModal.aberto}
<!-- svelte-ignore a11y_click_events_have_key_events -->

View File

@@ -13,7 +13,6 @@
let modoCriacao = $state(false);
let mostrandoModalExcluir = $state(false);
let dispensaParaExcluir = $state<Id<'dispensasRegistro'> | null>(null);
let filtroStatus = $state<'todas' | 'ativas' | 'expiradas'>('todas');
// Formulário
let dataInicio = $state(new Date().toISOString().split('T')[0]!);
@@ -26,33 +25,22 @@
// Computed para converter time string para hora/minuto
let horaInicio = $derived.by(() => {
const [hora, minuto] = horaInicioTime.split(':').map(Number);
return { hora: isNaN(hora) ? 8 : hora, minuto: isNaN(minuto) ? 0 : minuto };
return { hora: hora || 8, minuto: minuto || 0 };
});
let horaFim = $derived.by(() => {
const [hora, minuto] = horaFimTime.split(':').map(Number);
return { hora: isNaN(hora) ? 18 : hora, minuto: isNaN(minuto) ? 0 : minuto };
return { hora: hora || 18, minuto: minuto || 0 };
});
// Queries
const subordinadosQuery = useQuery(api.times.listarSubordinadosDoGestorAtual, {});
const dispensasQuery = useQuery(api.pontos.listarDispensas, {});
const dispensasQuery = useQuery(api.pontos.listarDispensas, {
apenasAtivas: true // Mostrar apenas dispensas ativas
});
let subordinados = $derived(subordinadosQuery?.data || []);
let todasDispensas = $derived(dispensasQuery?.data || []);
// Filtrar dispensas baseado no filtro selecionado
let dispensas = $derived.by(() => {
if (filtroStatus === 'todas') {
return todasDispensas;
} else if (filtroStatus === 'ativas') {
// Ativas: não expiradas (inclui isentos que já começaram)
return todasDispensas.filter((d) => !d.expirada);
} else {
// Expiradas: apenas dispensas não isentas que expiraram
return todasDispensas.filter((d) => d.expirada && !d.isento);
}
});
let dispensas = $derived(dispensasQuery?.data || []);
// Lista de funcionários do time
let funcionarios = $derived.by(() => {
@@ -112,9 +100,10 @@
return;
}
// Validar datas (comparar strings diretamente para evitar problemas de timezone)
// Formato YYYY-MM-DD permite comparação lexicográfica
if (dataFim < dataInicio) {
const dataInicioObj = new Date(dataInicio);
const dataFimObj = new Date(dataFim);
if (dataFimObj < dataInicioObj) {
toast.error('Data fim deve ser maior ou igual à data início');
return;
}
@@ -174,10 +163,7 @@
}
function formatarDataHora(data: string, hora: number, minuto: number): string {
// Converter YYYY-MM-DD para DD/MM/YYYY sem problemas de timezone
const [ano, mes, dia] = data.split('-');
const dataFormatada = `${dia}/${mes}/${ano}`;
return `${dataFormatada} ${hora.toString().padStart(2, '0')}:${minuto.toString().padStart(2, '0')}`;
return `${new Date(data).toLocaleDateString('pt-BR')} ${hora.toString().padStart(2, '0')}:${minuto.toString().padStart(2, '0')}`;
}
</script>
@@ -325,42 +311,11 @@
<!-- Lista de Dispensas -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<div class="mb-4 flex items-center justify-between">
<h2 class="card-title">Dispensas</h2>
<!-- Filtro de Status -->
<div class="join">
<button
class="btn join-item btn-sm {filtroStatus === 'todas' ? 'btn-active' : ''}"
onclick={() => (filtroStatus = 'todas')}
>
Todas
</button>
<button
class="btn join-item btn-sm {filtroStatus === 'ativas' ? 'btn-active' : ''}"
onclick={() => (filtroStatus = 'ativas')}
>
Ativas
</button>
<button
class="btn join-item btn-sm {filtroStatus === 'expiradas' ? 'btn-active' : ''}"
onclick={() => (filtroStatus = 'expiradas')}
>
Expiradas
</button>
</div>
</div>
<h2 class="card-title mb-4">Dispensas Ativas</h2>
{#if dispensas.length === 0}
<div class="alert alert-info">
<span>
{#if filtroStatus === 'todas'}
Nenhuma dispensa encontrada
{:else if filtroStatus === 'ativas'}
Nenhuma dispensa ativa encontrada
{:else}
Nenhuma dispensa expirada encontrada
{/if}
</span>
<span>Nenhuma dispensa ativa encontrada</span>
</div>
{:else}
<div class="overflow-x-auto">
@@ -416,7 +371,7 @@
{#if dispensa.isento}
<span class="badge badge-warning">Isento (sem expiração)</span>
{:else if dispensa.expirada}
<span class="badge badge-error">Não ativo</span>
<span class="badge badge-error">Expirada</span>
{:else}
<span class="badge badge-success">Ativa</span>
{/if}

View File

@@ -264,10 +264,6 @@
return;
}
// Converter hora formato HH:mm para hora e minuto
const { hora: horaInicio, minuto: minutoInicio } = timeParaHoraMinuto(horaInicioAjuste);
const { hora: horaFim, minuto: minutoFim } = timeParaHoraMinuto(horaFimAjuste);
try {
await client.mutation(api.pontos.ajustarBancoHoras, {
funcionarioId: funcionarioSelecionado,
@@ -275,13 +271,6 @@
periodoDias: dias,
periodoHoras: horas,
periodoMinutos: minutos,
dataAplicacao: dataInicioAjuste, // Data escolhida pelo usuário
dataInicio: dataInicioAjuste,
horaInicio,
minutoInicio,
dataFim: dataFimAjuste,
horaFim,
minutoFim,
motivoId: motivoId || undefined,
motivoTipo: motivoTipo || undefined,
motivoDescricao: motivoDescricao || undefined,
@@ -921,11 +910,7 @@
{#each homologacoes as homologacao (homologacao._id)}
<tr>
<td>
{#if homologacao.dataAplicacaoAjuste}
{new Date(homologacao.dataAplicacaoAjuste + 'T00:00:00').toLocaleDateString('pt-BR')}
{:else}
{new Date(homologacao.criadoEm).toLocaleDateString('pt-BR')}
{/if}
{new Date(homologacao.criadoEm).toLocaleDateString('pt-BR')}
</td>
{#if !funcionarioSelecionado}
<td>
@@ -973,16 +958,9 @@
</span>
</div>
{:else if homologacao.ajusteMinutos}
<div class="text-sm space-y-1">
<div>
{homologacao.periodoDias || 0}d {homologacao.periodoHoras || 0}h
{homologacao.periodoMinutos || 0}min
</div>
{#if homologacao.periodoAjuste?.dataInicio && homologacao.periodoAjuste?.horaInicio !== undefined && homologacao.periodoAjuste?.minutoInicio !== undefined && homologacao.periodoAjuste?.dataFim && homologacao.periodoAjuste?.horaFim !== undefined && homologacao.periodoAjuste?.minutoFim !== undefined}
<div class="text-xs text-base-content/60">
{new Date(homologacao.periodoAjuste.dataInicio + 'T00:00:00').toLocaleDateString('pt-BR')} {formatarHoraPonto(homologacao.periodoAjuste.horaInicio, homologacao.periodoAjuste.minutoInicio)} a {new Date(homologacao.periodoAjuste.dataFim + 'T00:00:00').toLocaleDateString('pt-BR')} {formatarHoraPonto(homologacao.periodoAjuste.horaFim, homologacao.periodoAjuste.minutoFim)}
</div>
{/if}
<div class="text-sm">
{homologacao.periodoDias || 0}d {homologacao.periodoHoras || 0}h
{homologacao.periodoMinutos || 0}min
</div>
{/if}
</td>
@@ -1048,18 +1026,13 @@
<div class="grid grid-cols-2 gap-4 text-sm">
<div>
<span class="font-medium">Data:&nbsp;</span>
{#if homologacaoSelecionada.dataAplicacaoAjuste}
{new Date(homologacaoSelecionada.dataAplicacaoAjuste + 'T00:00:00').toLocaleDateString('pt-BR')}
<span class="text-base-content/60 text-xs"> (Aplicado em)</span>
{:else}
{new Date(homologacaoSelecionada.criadoEm).toLocaleDateString('pt-BR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
})}
{/if}
{new Date(homologacaoSelecionada.criadoEm).toLocaleDateString('pt-BR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
})}
</div>
<div>
<span class="font-medium">Funcionário:</span>
@@ -1151,16 +1124,6 @@
{homologacaoSelecionada.periodoHoras || 0}h
{homologacaoSelecionada.periodoMinutos || 0}min
</div>
{#if homologacaoSelecionada.periodoAjuste?.dataInicio && homologacaoSelecionada.periodoAjuste?.horaInicio !== undefined && homologacaoSelecionada.periodoAjuste?.minutoInicio !== undefined && homologacaoSelecionada.periodoAjuste?.dataFim && homologacaoSelecionada.periodoAjuste?.horaFim !== undefined && homologacaoSelecionada.periodoAjuste?.minutoFim !== undefined}
<div>
<span class="font-medium">Data/Hora Início:&nbsp;</span>
{new Date(homologacaoSelecionada.periodoAjuste.dataInicio + 'T00:00:00').toLocaleDateString('pt-BR')} {formatarHoraPonto(homologacaoSelecionada.periodoAjuste.horaInicio, homologacaoSelecionada.periodoAjuste.minutoInicio)}
</div>
<div>
<span class="font-medium">Data/Hora Fim:&nbsp;</span>
{new Date(homologacaoSelecionada.periodoAjuste.dataFim + 'T00:00:00').toLocaleDateString('pt-BR')} {formatarHoraPonto(homologacaoSelecionada.periodoAjuste.horaFim, homologacaoSelecionada.periodoAjuste.minutoFim)}
</div>
{/if}
{#if homologacaoSelecionada.ajusteMinutos}
<div>
<span class="font-medium">Ajuste Total:&nbsp;</span>

View File

@@ -37,6 +37,7 @@ import type * as contratos from "../contratos.js";
import type * as crons from "../crons.js";
import type * as cursos from "../cursos.js";
import type * as dashboard from "../dashboard.js";
import type * as debug from "../debug.js";
import type * as documentos from "../documentos.js";
import type * as email from "../email.js";
import type * as empresas from "../empresas.js";
@@ -55,6 +56,7 @@ import type * as logsLogin from "../logsLogin.js";
import type * as menu from "../menu.js";
import type * as monitoramento from "../monitoramento.js";
import type * as objetos from "../objetos.js";
import type * as pedidoFlow from "../pedidoFlow.js";
import type * as pedidos from "../pedidos.js";
import type * as permissoesAcoes from "../permissoesAcoes.js";
import type * as planejamentos from "../planejamentos.js";
@@ -137,6 +139,7 @@ declare const fullApi: ApiFromModules<{
crons: typeof crons;
cursos: typeof cursos;
dashboard: typeof dashboard;
debug: typeof debug;
documentos: typeof documentos;
email: typeof email;
empresas: typeof empresas;
@@ -155,6 +158,7 @@ declare const fullApi: ApiFromModules<{
menu: typeof menu;
monitoramento: typeof monitoramento;
objetos: typeof objetos;
pedidoFlow: typeof pedidoFlow;
pedidos: typeof pedidos;
permissoesAcoes: typeof permissoesAcoes;
planejamentos: typeof planejamentos;

View File

@@ -182,7 +182,7 @@ export const listarTodos = query({
fotoPerfilUrl,
criadoPorNome: criadoPor?.nome || 'Sistema',
dias: calcularDias(a.dataInicio, a.dataFim),
status: new Date() > new Date(a.dataFim) ? 'finalizado' : 'ativo'
status: new Date(a.dataFim) >= new Date() ? 'ativo' : 'finalizado'
};
} catch (error) {
console.error('Erro ao buscar detalhes do atestado:', error);
@@ -192,7 +192,7 @@ export const listarTodos = query({
fotoPerfilUrl: null,
criadoPorNome: 'Sistema',
dias: calcularDias(a.dataInicio, a.dataFim),
status: new Date() > new Date(a.dataFim) ? 'finalizado' : 'ativo'
status: new Date(a.dataFim) >= new Date() ? 'ativo' : 'finalizado'
};
}
})
@@ -226,7 +226,7 @@ export const listarTodos = query({
criadoPorNome: criadoPor?.nome || 'Sistema',
licencaOriginal,
dias: calcularDias(l.dataInicio, l.dataFim),
status: new Date() > new Date(l.dataFim) ? 'finalizado' : 'ativo'
status: new Date(l.dataFim) >= new Date() ? 'ativo' : 'finalizado'
};
} catch (error) {
console.error('Erro ao buscar detalhes da licença:', error);
@@ -237,7 +237,7 @@ export const listarTodos = query({
criadoPorNome: 'Sistema',
licencaOriginal: null,
dias: calcularDias(l.dataInicio, l.dataFim),
status: new Date() > new Date(l.dataFim) ? 'finalizado' : 'ativo'
status: new Date(l.dataFim) >= new Date() ? 'ativo' : 'finalizado'
};
}
})
@@ -1254,33 +1254,6 @@ export const excluirAtestado = mutation({
const funcionarioId = atestado.funcionarioId;
const dataInicio = atestado.dataInicio; // Data início do atestado
const dataFim = atestado.dataFim; // Data fim do atestado
const atestadoId = args.id.toString(); // ID do atestado para remover ajustes
const documentoId = atestado.documentoId; // ID do documento para remover do storage
// Remover logs de atividades relacionados ao atestado
try {
const logs = await ctx.db
.query('logsAtividades')
.withIndex('by_recurso_id', (q) =>
q.eq('recurso', 'atestados').eq('recursoId', atestadoId)
)
.collect();
for (const log of logs) {
await ctx.db.delete(log._id);
}
} catch (error) {
console.error('[excluirAtestado] Erro ao remover logs de atividades:', error);
}
// Remover documento do storage se existir
if (documentoId) {
try {
await ctx.storage.delete(documentoId);
} catch (error) {
console.error('[excluirAtestado] Erro ao remover documento do storage:', error);
// Não falhar a exclusão se o documento não existir mais
}
}
// Excluir o registro do banco de dados
await ctx.db.delete(args.id);
@@ -1294,19 +1267,6 @@ export const excluirAtestado = mutation({
args.id
);
// Remover ajustes automáticos relacionados ao atestado excluído
try {
await ctx.runMutation(internal.pontos.removerAjustesAutomaticosInternal, {
funcionarioId,
motivoTipo: 'atestado',
motivoId: atestadoId,
dataInicio,
dataFim
});
} catch (error) {
console.error('[excluirAtestado] Erro ao remover ajustes automáticos:', error);
}
// Recalcular banco de horas APENAS para o período específico do atestado excluído
// Isso garante que os dias do atestado sejam removidos corretamente dos registros de ponto
await recalcularBancoHorasPeriodo(ctx, funcionarioId, dataInicio, dataFim);
@@ -1345,33 +1305,6 @@ export const excluirLicenca = mutation({
const funcionarioId = licenca.funcionarioId;
const dataInicio = licenca.dataInicio; // Data início da licença
const dataFim = licenca.dataFim; // Data fim da licença
const licencaId = args.id.toString(); // ID da licença para remover logs
const documentoId = licenca.documentoId; // ID do documento para remover do storage
// Remover logs de atividades relacionados à licença
try {
const logs = await ctx.db
.query('logsAtividades')
.withIndex('by_recurso_id', (q) =>
q.eq('recurso', 'licencas').eq('recursoId', licencaId)
)
.collect();
for (const log of logs) {
await ctx.db.delete(log._id);
}
} catch (error) {
console.error('[excluirLicenca] Erro ao remover logs de atividades:', error);
}
// Remover documento do storage se existir
if (documentoId) {
try {
await ctx.storage.delete(documentoId);
} catch (error) {
console.error('[excluirLicenca] Erro ao remover documento do storage:', error);
// Não falhar a exclusão se o documento não existir mais
}
}
// Excluir o registro do banco de dados
await ctx.db.delete(args.id);
@@ -1385,19 +1318,6 @@ export const excluirLicenca = mutation({
args.id
);
// Remover ajustes automáticos relacionados à licença excluída
try {
await ctx.runMutation(internal.pontos.removerAjustesAutomaticosInternal, {
funcionarioId,
motivoTipo: 'licenca',
motivoId: licencaId,
dataInicio,
dataFim
});
} catch (error) {
console.error('[excluirLicenca] Erro ao remover ajustes automáticos:', error);
}
// Recalcular banco de horas APENAS para o período específico da licença excluída
// Isso garante que os dias da licença sejam removidos corretamente dos registros de ponto
await recalcularBancoHorasPeriodo(ctx, funcionarioId, dataInicio, dataFim);

View File

@@ -941,13 +941,10 @@ export const excluirSolicitacao = mutation({
throw new Error('Solicitação não encontrada');
}
// IMPORTANTE: Salvar o período exato da ausência ANTES de excluir
// para recalcular o banco de horas apenas para esse período específico
const funcionarioId = solicitacao.funcionarioId;
const dataInicio = solicitacao.dataInicio;
const dataFim = solicitacao.dataFim;
const statusOriginal = solicitacao.status;
const ausenciaId = args.solicitacaoId.toString(); // ID da ausência para remover ajustes
// Apenas solicitações ainda não processadas podem ser excluídas
if (solicitacao.status !== 'aguardando_aprovacao') {
throw new Error('Apenas solicitações pendentes podem ser excluídas');
}
// Verificar se o usuário é o criador original da solicitação
const usuario = await ctx.db.get(args.usuarioId);
@@ -966,34 +963,7 @@ export const excluirSolicitacao = mutation({
throw new Error('Você não tem permissão para excluir esta solicitação');
}
// Permitir exclusão de ausências aprovadas (não apenas pendentes)
// Se estiver aprovada, o gestor pode excluir para corrigir erros
if (statusOriginal === 'aprovado' && !usuarioEhGestor) {
throw new Error('Apenas o gestor pode excluir ausências aprovadas');
}
// Excluir o registro do banco de dados
await ctx.db.delete(args.solicitacaoId);
// Remover ajustes automáticos relacionados à ausência excluída (apenas se estava aprovada)
if (statusOriginal === 'aprovado') {
try {
await ctx.runMutation(internal.pontos.removerAjustesAutomaticosInternal, {
funcionarioId,
motivoTipo: 'ausencia',
motivoId: ausenciaId,
dataInicio,
dataFim
});
} catch (error) {
console.error('[excluirSolicitacao] Erro ao remover ajustes automáticos:', error);
}
// Recalcular banco de horas APENAS para o período específico da ausência excluída
// Isso garante que os dias da ausência sejam removidos corretamente dos registros de ponto
await recalcularBancoHorasPeriodo(ctx, funcionarioId, dataInicio, dataFim);
}
return null;
}
});

View File

@@ -7,12 +7,10 @@ export const getStats = query({
returns: v.object({
totalFuncionarios: v.number(),
totalSimbolos: v.number(),
totalUsuarios: v.number(),
funcionariosAtivos: v.number(),
funcionariosDesligados: v.number(),
cargoComissionado: v.number(),
funcaoGratificada: v.number(),
totalCadastros: v.number()
funcaoGratificada: v.number()
}),
handler: async (ctx) => {
// Contar funcionários
@@ -38,22 +36,41 @@ export const getStats = query({
const simbolos = await ctx.db.query('simbolos').collect();
const totalSimbolos = simbolos.length;
// Contar usuários cadastrados
const usuarios = await ctx.db.query('usuarios').collect();
const totalUsuarios = usuarios.length;
// Calcular total de cadastros (funcionários + símbolos + usuários)
const totalCadastros = totalFuncionarios + totalSimbolos + totalUsuarios;
return {
totalFuncionarios,
totalSimbolos,
totalUsuarios,
funcionariosAtivos,
funcionariosDesligados,
cargoComissionado,
funcaoGratificada,
totalCadastros
funcaoGratificada
};
}
});
// Obter atividades recentes (últimas 24 horas)
export const getRecentActivity = query({
args: {},
returns: v.object({
funcionariosCadastrados24h: v.number(),
simbolosCadastrados24h: v.number()
}),
handler: async (ctx) => {
const now = Date.now();
const last24h = now - 24 * 60 * 60 * 1000;
// Funcionários cadastrados nas últimas 24h
const funcionarios = await ctx.db.query('funcionarios').collect();
const funcionariosCadastrados24h = funcionarios.filter(
(f) => f._creationTime >= last24h
).length;
// Símbolos cadastrados nas últimas 24h
const simbolos = await ctx.db.query('simbolos').collect();
const simbolosCadastrados24h = simbolos.filter((s) => s._creationTime >= last24h).length;
return {
funcionariosCadastrados24h,
simbolosCadastrados24h
};
}
});

View File

@@ -0,0 +1,23 @@
import { query } from './_generated/server';
import { v } from 'convex/values';
export const inspectOrder = query({
args: { pedidoId: v.id('pedidos') },
handler: async (ctx, args) => {
const pedido = await ctx.db.get(args.pedidoId);
const historicoEtapas = await ctx.db
.query('pedidoEtapasHistorico')
.withIndex('by_pedidoId', (q) => q.eq('pedidoId', args.pedidoId))
.collect();
const etapas = await ctx.db.query('pedidoFluxoEtapa').collect();
const transicoes = await ctx.db.query('pedidoFluxoTransicao').collect();
return {
pedido,
historicoEtapas,
etapasConfiguradas: etapas,
transicoesConfiguradas: transicoes
};
}
});

View File

@@ -1,43 +1,12 @@
import { v } from 'convex/values';
import { mutation, query, internalMutation } from './_generated/server';
import { internal } from './_generated/api';
import type { MutationCtx } from './_generated/server';
import { Id, Doc } from './_generated/dataModel';
import { verificarLicencaAtiva } from './atestadosLicencas';
import { getCurrentUserFunction } from './auth';
import { formatarDataBR } from './utils/datas';
import { api } from './_generated/api';
// Helper: Recalcular banco de horas em um período
async function recalcularBancoHorasPeriodo(
ctx: MutationCtx,
funcionarioId: Id<'funcionarios'>,
dataInicio: string,
dataFim: string
): Promise<void> {
// Gerar todas as datas do período
const dataInicioObj = new Date(dataInicio);
const dataFimObj = new Date(dataFim);
const datas: string[] = [];
const dataAtual = new Date(dataInicioObj);
while (dataAtual <= dataFimObj) {
const ano = dataAtual.getFullYear();
const mes = String(dataAtual.getMonth() + 1).padStart(2, '0');
const dia = String(dataAtual.getDate()).padStart(2, '0');
datas.push(`${ano}-${mes}-${dia}`);
dataAtual.setDate(dataAtual.getDate() + 1);
}
// Recalcular para cada data usando a mutation interna (agendar para execução assíncrona)
for (let i = 0; i < datas.length; i++) {
await ctx.scheduler.runAfter(i * 100, internal.pontos.recalcularBancoHorasData, {
funcionarioId,
data: datas[i]!
});
}
}
// Validador para períodos
const periodoValidator = v.object({
dataInicio: v.string(),
@@ -852,13 +821,8 @@ export const atualizarStatus = mutation({
throw new Error('Período de férias não encontrado');
}
// Buscar usuário que está alterando o status para incluir na mensagem quando for Cancelado_RH
let acao = `Status alterado para ${args.novoStatus}`;
if (args.novoStatus === 'Cancelado_RH') {
const usuarioRH = await ctx.db.get(args.usuarioId);
const nomeUsuario = usuarioRH?.nome || 'Usuário Desconhecido';
acao = `Status alterado para Cancelado_RH por ${nomeUsuario}`;
}
// Atualizar status e histórico
const acao = `Status alterado para ${args.novoStatus}`;
const updateData: {
status: typeof args.novoStatus;
@@ -909,26 +873,6 @@ export const atualizarStatus = mutation({
);
}
// Se o status foi alterado para Cancelado_RH, recalcular banco de horas
// para garantir que os dias de férias sejam removidos do registro de ponto
if (args.novoStatus === 'Cancelado_RH') {
// IMPORTANTE: Recalcular banco de horas para o período das férias canceladas
// Isso garante que os dias de férias sejam removidos corretamente dos registros de ponto
try {
await recalcularBancoHorasPeriodo(
ctx,
registro.funcionarioId,
registro.dataInicio,
registro.dataFim
);
} catch (error) {
console.error(
'[ferias.atualizarStatus] Erro ao recalcular banco de horas após cancelamento:',
error
);
}
}
// Se o status foi alterado para Cancelado_RH, notificar o funcionário
if (args.novoStatus === 'Cancelado_RH') {
const funcionario = await ctx.db.get(registro.funcionarioId);

View File

@@ -847,3 +847,224 @@ export const obterHistoricoAlertas = query({
}
});
/**
* Status consolidado do sistema para o dashboard
*/
export const getStatusSistema = query({
args: {},
returns: v.object({
usuariosOnline: v.number(),
totalRegistros: v.number(),
tempoMedioResposta: v.number(),
cpuUsada: v.number(),
memoriaUsada: v.number(),
ultimaAtualizacao: v.number()
}),
handler: async (ctx) => {
try {
// Última métrica, se existir
const ultimaMetrica = (await ctx.db.query('systemMetrics').order('desc').first()) ?? null;
// Usuários online: usar métrica se disponível, senão derivar de usuários
let usuariosOnline = 0;
if (ultimaMetrica?.usuariosOnline !== undefined) {
usuariosOnline = ultimaMetrica.usuariosOnline;
} else {
const usuarios = await ctx.db.query('usuarios').collect();
usuariosOnline = usuarios.filter((u) => u.statusPresenca === 'online').length;
}
// Total de registros (estimativa baseada em tabelas principais)
const [usuarios, funcionarios, simbolos, alertas, metricas] = await Promise.all([
ctx.db.query('usuarios').collect(),
ctx.db.query('funcionarios').collect(),
ctx.db.query('simbolos').collect(),
ctx.db.query('alertConfigurations').collect(),
ctx.db.query('systemMetrics').take(100) // não precisa contar tudo
]);
const totalRegistros =
usuarios.length + funcionarios.length + simbolos.length + alertas.length + metricas.length;
// Métricas de performance com fallbacks seguros
const tempoMedioResposta = ultimaMetrica?.tempoRespostaMedio ?? 0;
const cpuUsada = Math.max(
0,
Math.min(100, Math.round((ultimaMetrica?.cpuUsage ?? 0) * 100) / 100)
);
const memoriaUsada = Math.max(
0,
Math.min(100, Math.round((ultimaMetrica?.memoryUsage ?? 0) * 100) / 100)
);
const ultimaAtualizacao = ultimaMetrica?.timestamp ?? Date.now();
return {
usuariosOnline,
totalRegistros,
tempoMedioResposta,
cpuUsada,
memoriaUsada,
ultimaAtualizacao
};
} catch (error) {
console.error('Erro em getStatusSistema:', error);
// Retornar valores padrão em caso de erro
return {
usuariosOnline: 0,
totalRegistros: 0,
tempoMedioResposta: 0,
cpuUsada: 0,
memoriaUsada: 0,
ultimaAtualizacao: Date.now()
};
}
}
});
/**
* Atividade do banco no último minuto (agregada em buckets)
* Usa logsAtividades e systemMetrics para calcular atividade real.
*/
export const getAtividadeBancoDados = query({
args: {},
returns: v.object({
historico: v.array(
v.object({
entradas: v.number(),
saidas: v.number()
})
)
}),
handler: async (ctx) => {
try {
const agora = Date.now();
const haUmMinuto = agora - 60 * 1000;
// Buscar atividades reais do sistema
const atividadesRecentes = await ctx.db
.query('logsAtividades')
.withIndex('by_timestamp', (q) => q.gte('timestamp', haUmMinuto))
.order('asc')
.collect();
// Buscar métricas também (para mensagens se houver)
const metricasRecentes = await ctx.db
.query('systemMetrics')
.withIndex('by_timestamp', (q) => q.gte('timestamp', haUmMinuto))
.order('asc')
.collect();
// Bucketizar em 30 pontos (~2s cada) para visualização
const numBuckets = 30;
const bucketSizeMs = Math.ceil(60_000 / numBuckets);
const historico: Array<{ entradas: number; saidas: number }> = [];
for (let i = 0; i < numBuckets; i++) {
const inicio = haUmMinuto + i * bucketSizeMs;
const fim = inicio + bucketSizeMs;
// Contar atividades de criação/inserção (entradas)
const atividadesBucket = atividadesRecentes.filter(
(a) => a.timestamp >= inicio && a.timestamp < fim
);
const entradasAtividades = atividadesBucket.filter(
(a) => a.acao === 'criar' || a.acao === 'inserir' || a.acao === 'cadastrar'
).length;
// Contar atividades de exclusão/remoção (saídas)
const saidasAtividades = atividadesBucket.filter(
(a) => a.acao === 'excluir' || a.acao === 'remover' || a.acao === 'deletar'
).length;
// Usar mensagensPorMinuto como adicional se disponível
const bucketMetricas = metricasRecentes.filter(
(m) => m.timestamp >= inicio && m.timestamp < fim
);
const somaMensagens =
bucketMetricas.reduce((acc, m) => acc + (m.mensagensPorMinuto ?? 0), 0) || 0;
// Combinar atividades reais com métricas de mensagens
const entradas = Math.max(0, Math.round(entradasAtividades + somaMensagens * 0.3));
const saidas = Math.max(0, Math.round(saidasAtividades + somaMensagens * 0.2));
historico.push({ entradas, saidas });
}
return { historico };
} catch (error) {
console.error('Erro em getAtividadeBancoDados:', error);
// Retornar histórico vazio em caso de erro
return { historico: Array(30).fill({ entradas: 0, saidas: 0 }) };
}
}
});
/**
* Distribuição de operações (calculada a partir de logsAtividades e métricas)
*/
export const getDistribuicaoRequisicoes = query({
args: {},
returns: v.object({
queries: v.number(),
mutations: v.number(),
leituras: v.number(),
escritas: v.number()
}),
handler: async (ctx) => {
try {
const umaHoraAtras = Date.now() - 60 * 60 * 1000;
// Buscar atividades reais do sistema
const atividades = await ctx.db
.query('logsAtividades')
.withIndex('by_timestamp', (q) => q.gte('timestamp', umaHoraAtras))
.collect();
// Buscar métricas também
const metricas = await ctx.db
.query('systemMetrics')
.withIndex('by_timestamp', (q) => q.gte('timestamp', umaHoraAtras))
.order('desc')
.take(100);
// Contar operações de leitura (consultas, visualizações)
const leituras = atividades.filter(
(a) =>
a.acao === 'consultar' ||
a.acao === 'visualizar' ||
a.acao === 'listar' ||
a.acao === 'buscar'
).length;
// Contar operações de escrita (criar, editar, excluir)
const escritas = atividades.filter(
(a) =>
a.acao === 'criar' ||
a.acao === 'editar' ||
a.acao === 'excluir' ||
a.acao === 'inserir' ||
a.acao === 'atualizar' ||
a.acao === 'deletar' ||
a.acao === 'cadastrar' ||
a.acao === 'remover'
).length;
// Adicionar estimativa baseada em mensagens se disponível
const totalMensagens = Math.max(
0,
Math.round(metricas.reduce((acc, m) => acc + (m.mensagensPorMinuto ?? 0), 0))
);
// Queries são leituras + parte das mensagens (como consultas de chat)
const queries = leituras + Math.round(totalMensagens * 0.5);
// Mutations são escritas + parte das mensagens (como envio de mensagens)
const mutations = escritas + Math.round(totalMensagens * 0.3);
return { queries, mutations, leituras, escritas };
} catch (error) {
console.error('Erro em getDistribuicaoRequisicoes:', error);
// Retornar valores padrão em caso de erro
return { queries: 0, mutations: 0, leituras: 0, escritas: 0 };
}
}
});

View File

@@ -0,0 +1,859 @@
import { v } from 'convex/values';
import type { Id } from './_generated/dataModel';
import type { MutationCtx, QueryCtx } from './_generated/server';
import { mutation, query } from './_generated/server';
// ========== HELPERS ==========
async function getUsuarioAutenticado(ctx: QueryCtx | MutationCtx) {
const identity = await ctx.auth.getUserIdentity();
if (!identity) {
throw new Error('Usuário não autenticado');
}
const usuario = await ctx.db
.query('usuarios')
.filter((q) => q.eq(q.field('email'), identity.email))
.first();
if (!usuario) {
throw new Error('Usuário não encontrado');
}
return usuario;
}
export async function iniciarFluxoPedidoInternal(
ctx: MutationCtx,
pedidoId: Id<'pedidos'>,
usuarioId: Id<'usuarios'>
) {
const pedido = await ctx.db.get(pedidoId);
if (!pedido) {
throw new Error('Pedido não encontrado');
}
// Verificar se já tem histórico
const historicoExistente = await ctx.db
.query('pedidoEtapasHistorico')
.withIndex('by_pedidoId', (q) => q.eq('pedidoId', pedidoId))
.first();
if (historicoExistente) {
return historicoExistente._id;
}
// Buscar primeira etapa do fluxo
const primeiraEtapa = await ctx.db.query('pedidoFluxoEtapa').withIndex('by_ordem').first();
if (!primeiraEtapa) {
console.warn('Nenhuma etapa configurada no fluxo. Timeline ficará vazia.');
return null;
}
const now = Date.now();
// Criar primeiro registro
const historicoId = await ctx.db.insert('pedidoEtapasHistorico', {
pedidoId,
etapaId: primeiraEtapa._id,
inicioData: now,
atual: true
});
// Registrar no histórico
await ctx.db.insert('historicoPedidos', {
pedidoId,
usuarioId,
acao: 'inicio_fluxo',
detalhes: JSON.stringify({
etapa: primeiraEtapa.codigo,
etapaNome: primeiraEtapa.nome
}),
data: now
});
return historicoId;
}
async function getEtapaAtualDoPedido(ctx: QueryCtx | MutationCtx, pedidoId: Id<'pedidos'>) {
const etapaAtual = await ctx.db
.query('pedidoEtapasHistorico')
.withIndex('by_pedidoId_atual', (q) => q.eq('pedidoId', pedidoId).eq('atual', true))
.first();
return etapaAtual;
}
// ========== ETAPAS - QUERIES ==========
export const listEtapas = query({
args: {},
returns: v.array(
v.object({
_id: v.id('pedidoFluxoEtapa'),
_creationTime: v.number(),
nome: v.string(),
codigo: v.string(),
descricao: v.optional(v.string()),
setorId: v.optional(v.id('setores')),
setorNome: v.optional(v.string()),
tempoEstimadoDias: v.optional(v.number()),
incluirNoTimeline: v.boolean(),
ordem: v.number(),
criadoEm: v.number(),
atualizadoEm: v.number()
})
),
handler: async (ctx) => {
const etapas = await ctx.db.query('pedidoFluxoEtapa').withIndex('by_ordem').collect();
const result = await Promise.all(
etapas.map(async (etapa) => {
let setorNome: string | undefined;
if (etapa.setorId) {
const setor = await ctx.db.get(etapa.setorId);
setorNome = setor?.nome;
}
return {
...etapa,
setorNome
};
})
);
return result;
}
});
export const getEtapa = query({
args: { id: v.id('pedidoFluxoEtapa') },
returns: v.union(
v.object({
_id: v.id('pedidoFluxoEtapa'),
_creationTime: v.number(),
nome: v.string(),
codigo: v.string(),
descricao: v.optional(v.string()),
setorId: v.optional(v.id('setores')),
tempoEstimadoDias: v.optional(v.number()),
incluirNoTimeline: v.boolean(),
ordem: v.number(),
criadoEm: v.number(),
atualizadoEm: v.number()
}),
v.null()
),
handler: async (ctx, args) => {
return await ctx.db.get(args.id);
}
});
export const getEtapaByCodigo = query({
args: { codigo: v.string() },
returns: v.union(
v.object({
_id: v.id('pedidoFluxoEtapa'),
_creationTime: v.number(),
nome: v.string(),
codigo: v.string(),
descricao: v.optional(v.string()),
setorId: v.optional(v.id('setores')),
tempoEstimadoDias: v.optional(v.number()),
incluirNoTimeline: v.boolean(),
ordem: v.number(),
criadoEm: v.number(),
atualizadoEm: v.number()
}),
v.null()
),
handler: async (ctx, args) => {
return await ctx.db
.query('pedidoFluxoEtapa')
.withIndex('by_codigo', (q) => q.eq('codigo', args.codigo))
.first();
}
});
// ========== ETAPAS - MUTATIONS ==========
export const createEtapa = mutation({
args: {
nome: v.string(),
codigo: v.string(),
descricao: v.optional(v.string()),
setorId: v.optional(v.id('setores')),
tempoEstimadoDias: v.optional(v.number()),
incluirNoTimeline: v.boolean()
},
returns: v.id('pedidoFluxoEtapa'),
handler: async (ctx, args) => {
await getUsuarioAutenticado(ctx);
// Verificar se já existe etapa com este código
const existente = await ctx.db
.query('pedidoFluxoEtapa')
.withIndex('by_codigo', (q) => q.eq('codigo', args.codigo))
.first();
if (existente) {
throw new Error(`Já existe uma etapa com o código "${args.codigo}"`);
}
// Obter a maior ordem atual
const todasEtapas = await ctx.db.query('pedidoFluxoEtapa').collect();
const maiorOrdem = todasEtapas.reduce((max, e) => Math.max(max, e.ordem), 0);
const now = Date.now();
return await ctx.db.insert('pedidoFluxoEtapa', {
nome: args.nome,
codigo: args.codigo,
descricao: args.descricao,
setorId: args.setorId,
tempoEstimadoDias: args.tempoEstimadoDias,
incluirNoTimeline: args.incluirNoTimeline,
ordem: maiorOrdem + 1,
criadoEm: now,
atualizadoEm: now
});
}
});
export const updateEtapa = mutation({
args: {
id: v.id('pedidoFluxoEtapa'),
nome: v.optional(v.string()),
codigo: v.optional(v.string()),
descricao: v.optional(v.string()),
setorId: v.optional(v.id('setores')),
tempoEstimadoDias: v.optional(v.number()),
incluirNoTimeline: v.optional(v.boolean()),
ordem: v.optional(v.number())
},
returns: v.null(),
handler: async (ctx, args) => {
await getUsuarioAutenticado(ctx);
const etapa = await ctx.db.get(args.id);
if (!etapa) {
throw new Error('Etapa não encontrada');
}
const codigo = args.codigo;
// Se estiver mudando o código, verificar duplicidade
if (codigo && codigo !== etapa.codigo) {
const existente = await ctx.db
.query('pedidoFluxoEtapa')
.withIndex('by_codigo', (q) => q.eq('codigo', codigo))
.first();
if (existente) {
throw new Error(`Já existe uma etapa com o código "${args.codigo}"`);
}
}
await ctx.db.patch(args.id, {
...(args.nome !== undefined && { nome: args.nome }),
...(args.codigo !== undefined && { codigo: args.codigo }),
...(args.descricao !== undefined && { descricao: args.descricao }),
...(args.setorId !== undefined && { setorId: args.setorId }),
...(args.tempoEstimadoDias !== undefined && { tempoEstimadoDias: args.tempoEstimadoDias }),
...(args.incluirNoTimeline !== undefined && { incluirNoTimeline: args.incluirNoTimeline }),
...(args.ordem !== undefined && { ordem: args.ordem }),
atualizadoEm: Date.now()
});
return null;
}
});
export const deleteEtapa = mutation({
args: { id: v.id('pedidoFluxoEtapa') },
returns: v.null(),
handler: async (ctx, args) => {
await getUsuarioAutenticado(ctx);
const etapa = await ctx.db.get(args.id);
if (!etapa) {
throw new Error('Etapa não encontrada');
}
// Verificar se há transições usando esta etapa
const transicoesOrigem = await ctx.db
.query('pedidoFluxoTransicao')
.withIndex('by_etapaOrigemId', (q) => q.eq('etapaOrigemId', args.id))
.collect();
if (transicoesOrigem.length > 0) {
throw new Error('Não é possível excluir: esta etapa possui transições de saída');
}
// Verificar se há transições de destino
const transicoesDestino = await ctx.db
.query('pedidoFluxoTransicao')
.filter((q) => q.eq(q.field('etapaDestinoId'), args.id))
.collect();
if (transicoesDestino.length > 0) {
throw new Error('Não é possível excluir: esta etapa é destino de outras transições');
}
// Verificar se há histórico usando esta etapa
const historico = await ctx.db
.query('pedidoEtapasHistorico')
.withIndex('by_etapaId', (q) => q.eq('etapaId', args.id))
.first();
if (historico) {
throw new Error('Não é possível excluir: esta etapa já foi utilizada em pedidos');
}
await ctx.db.delete(args.id);
return null;
}
});
export const reordenarEtapas = mutation({
args: {
ordens: v.array(
v.object({
id: v.id('pedidoFluxoEtapa'),
ordem: v.number()
})
)
},
returns: v.null(),
handler: async (ctx, args) => {
await getUsuarioAutenticado(ctx);
for (const item of args.ordens) {
await ctx.db.patch(item.id, { ordem: item.ordem, atualizadoEm: Date.now() });
}
return null;
}
});
// ========== TRANSIÇÕES - QUERIES ==========
export const listTransicoes = query({
args: {},
returns: v.array(
v.object({
_id: v.id('pedidoFluxoTransicao'),
_creationTime: v.number(),
etapaOrigemId: v.id('pedidoFluxoEtapa'),
etapaOrigemNome: v.string(),
etapaDestinoId: v.id('pedidoFluxoEtapa'),
etapaDestinoNome: v.string(),
isPadrao: v.boolean(),
criadoEm: v.number()
})
),
handler: async (ctx) => {
const transicoes = await ctx.db.query('pedidoFluxoTransicao').collect();
const result = await Promise.all(
transicoes.map(async (t) => {
const origem = await ctx.db.get(t.etapaOrigemId);
const destino = await ctx.db.get(t.etapaDestinoId);
return {
...t,
etapaOrigemNome: origem?.nome ?? 'Desconhecido',
etapaDestinoNome: destino?.nome ?? 'Desconhecido'
};
})
);
return result;
}
});
export const getProximasEtapas = query({
args: { pedidoId: v.id('pedidos') },
returns: v.array(
v.object({
_id: v.id('pedidoFluxoEtapa'),
nome: v.string(),
codigo: v.string(),
isPadrao: v.boolean()
})
),
handler: async (ctx, args) => {
const etapaAtualHistorico = await getEtapaAtualDoPedido(ctx, args.pedidoId);
if (!etapaAtualHistorico) {
// Pedido não tem etapa, retornar a primeira etapa do fluxo
const primeiraEtapa = await ctx.db.query('pedidoFluxoEtapa').withIndex('by_ordem').first();
if (!primeiraEtapa) return [];
return [
{
_id: primeiraEtapa._id,
nome: primeiraEtapa.nome,
codigo: primeiraEtapa.codigo,
isPadrao: true
}
];
}
// Buscar transições a partir da etapa atual
const transicoes = await ctx.db
.query('pedidoFluxoTransicao')
.withIndex('by_etapaOrigemId', (q) => q.eq('etapaOrigemId', etapaAtualHistorico.etapaId))
.collect();
const result = await Promise.all(
transicoes.map(async (t) => {
const etapa = await ctx.db.get(t.etapaDestinoId);
if (!etapa) return null;
return {
_id: etapa._id,
nome: etapa.nome,
codigo: etapa.codigo,
isPadrao: t.isPadrao
};
})
);
return result.filter((r): r is NonNullable<typeof r> => r !== null);
}
});
// ========== TRANSIÇÕES - MUTATIONS ==========
export const createTransicao = mutation({
args: {
etapaOrigemId: v.id('pedidoFluxoEtapa'),
etapaDestinoId: v.id('pedidoFluxoEtapa')
},
returns: v.id('pedidoFluxoTransicao'),
handler: async (ctx, args) => {
await getUsuarioAutenticado(ctx);
if (args.etapaOrigemId === args.etapaDestinoId) {
throw new Error('A etapa de origem e destino não podem ser iguais');
}
// Verificar se etapas existem
const origem = await ctx.db.get(args.etapaOrigemId);
const destino = await ctx.db.get(args.etapaDestinoId);
if (!origem) throw new Error('Etapa de origem não encontrada');
if (!destino) throw new Error('Etapa de destino não encontrada');
// Verificar se já existe esta transição
const existente = await ctx.db
.query('pedidoFluxoTransicao')
.withIndex('by_etapaOrigemId', (q) => q.eq('etapaOrigemId', args.etapaOrigemId))
.filter((q) => q.eq(q.field('etapaDestinoId'), args.etapaDestinoId))
.first();
if (existente) {
throw new Error('Esta transição já existe');
}
// Verificar se já existe alguma transição de saída para esta origem
const transicoesExistentes = await ctx.db
.query('pedidoFluxoTransicao')
.withIndex('by_etapaOrigemId', (q) => q.eq('etapaOrigemId', args.etapaOrigemId))
.collect();
// Se for a primeira transição, ela é a padrão
const isPadrao = transicoesExistentes.length === 0;
return await ctx.db.insert('pedidoFluxoTransicao', {
etapaOrigemId: args.etapaOrigemId,
etapaDestinoId: args.etapaDestinoId,
isPadrao,
criadoEm: Date.now()
});
}
});
export const deleteTransicao = mutation({
args: { id: v.id('pedidoFluxoTransicao') },
returns: v.null(),
handler: async (ctx, args) => {
await getUsuarioAutenticado(ctx);
const transicao = await ctx.db.get(args.id);
if (!transicao) {
throw new Error('Transição não encontrada');
}
await ctx.db.delete(args.id);
// Se era a transição padrão, definir outra como padrão
if (transicao.isPadrao) {
const outraTransicao = await ctx.db
.query('pedidoFluxoTransicao')
.withIndex('by_etapaOrigemId', (q) => q.eq('etapaOrigemId', transicao.etapaOrigemId))
.first();
if (outraTransicao) {
await ctx.db.patch(outraTransicao._id, { isPadrao: true });
}
}
return null;
}
});
export const setTransicaoPadrao = mutation({
args: { id: v.id('pedidoFluxoTransicao') },
returns: v.null(),
handler: async (ctx, args) => {
await getUsuarioAutenticado(ctx);
const transicao = await ctx.db.get(args.id);
if (!transicao) {
throw new Error('Transição não encontrada');
}
// Remover isPadrao de todas as transições da mesma origem
const todasTransicoes = await ctx.db
.query('pedidoFluxoTransicao')
.withIndex('by_etapaOrigemId', (q) => q.eq('etapaOrigemId', transicao.etapaOrigemId))
.collect();
for (const t of todasTransicoes) {
if (t._id !== args.id && t.isPadrao) {
await ctx.db.patch(t._id, { isPadrao: false });
}
}
// Definir esta como padrão
await ctx.db.patch(args.id, { isPadrao: true });
return null;
}
});
// ========== HISTÓRICO E TIMELINE ==========
export const getEtapasHistorico = query({
args: { pedidoId: v.id('pedidos') },
returns: v.array(
v.object({
_id: v.id('pedidoEtapasHistorico'),
_creationTime: v.number(),
pedidoId: v.id('pedidos'),
etapaId: v.id('pedidoFluxoEtapa'),
etapaNome: v.string(),
etapaCodigo: v.string(),
inicioData: v.number(),
fimData: v.optional(v.number()),
funcionarioId: v.optional(v.id('funcionarios')),
funcionarioNome: v.optional(v.string()),
atual: v.boolean()
})
),
handler: async (ctx, args) => {
const historico = await ctx.db
.query('pedidoEtapasHistorico')
.withIndex('by_pedidoId', (q) => q.eq('pedidoId', args.pedidoId))
.collect();
// Ordenar por inicioData
historico.sort((a, b) => a.inicioData - b.inicioData);
const result = await Promise.all(
historico.map(async (h) => {
const etapa = await ctx.db.get(h.etapaId);
let funcionarioNome: string | undefined;
if (h.funcionarioId) {
const funcionario = await ctx.db.get(h.funcionarioId);
funcionarioNome = funcionario?.nome;
}
return {
...h,
etapaNome: etapa?.nome ?? 'Desconhecido',
etapaCodigo: etapa?.codigo ?? 'desconhecido',
funcionarioNome
};
})
);
return result;
}
});
export const getPedidoTimeline = query({
args: { pedidoId: v.id('pedidos') },
returns: v.object({
passado: v.array(
v.object({
etapaId: v.id('pedidoFluxoEtapa'),
etapaNome: v.string(),
etapaCodigo: v.string(),
inicioData: v.number(),
fimData: v.optional(v.number()),
funcionarioNome: v.optional(v.string()),
atual: v.boolean(),
incluirNoTimeline: v.boolean()
})
),
futuro: v.array(
v.object({
etapaId: v.id('pedidoFluxoEtapa'),
etapaNome: v.string(),
etapaCodigo: v.string(),
dataPrevisao: v.number(),
incluirNoTimeline: v.boolean()
})
)
}),
handler: async (ctx, args) => {
// Buscar histórico passado
const historico = await ctx.db
.query('pedidoEtapasHistorico')
.withIndex('by_pedidoId', (q) => q.eq('pedidoId', args.pedidoId))
.collect();
historico.sort((a, b) => a.inicioData - b.inicioData);
const passado = await Promise.all(
historico.map(async (h) => {
const etapa = await ctx.db.get(h.etapaId);
let funcionarioNome: string | undefined;
if (h.funcionarioId) {
const funcionario = await ctx.db.get(h.funcionarioId);
funcionarioNome = funcionario?.nome;
}
return {
etapaId: h.etapaId,
etapaNome: etapa?.nome ?? 'Desconhecido',
etapaCodigo: etapa?.codigo ?? 'desconhecido',
inicioData: h.inicioData,
fimData: h.fimData,
funcionarioNome,
atual: h.atual,
incluirNoTimeline: etapa?.incluirNoTimeline ?? true
};
})
);
// Calcular previsão futura usando transições padrão
const futuro: {
etapaId: Id<'pedidoFluxoEtapa'>;
etapaNome: string;
etapaCodigo: string;
dataPrevisao: number;
incluirNoTimeline: boolean;
}[] = [];
const etapaAtualHistorico = historico.find((h) => h.atual);
if (etapaAtualHistorico) {
let etapaAtualId = etapaAtualHistorico.etapaId;
let dataPrevisao = Date.now();
// Calcular tempo decorrido na etapa atual
const etapaAtual = await ctx.db.get(etapaAtualId);
if (etapaAtual?.tempoEstimadoDias) {
dataPrevisao += etapaAtual.tempoEstimadoDias * 24 * 60 * 60 * 1000;
}
// Seguir transições padrão até não ter mais
const visitadas = new Set<string>();
let iteracoes = 0;
const MAX_ITERACOES = 20; // Evitar loop infinito
while (iteracoes < MAX_ITERACOES) {
iteracoes++;
if (visitadas.has(etapaAtualId)) break;
visitadas.add(etapaAtualId);
// Buscar transição padrão
const transicaoPadrao = await ctx.db
.query('pedidoFluxoTransicao')
.withIndex('by_etapaOrigemId_isPadrao', (q) =>
q.eq('etapaOrigemId', etapaAtualId).eq('isPadrao', true)
)
.first();
if (!transicaoPadrao) break;
const proximaEtapa = await ctx.db.get(transicaoPadrao.etapaDestinoId);
if (!proximaEtapa) break;
futuro.push({
etapaId: proximaEtapa._id,
etapaNome: proximaEtapa.nome,
etapaCodigo: proximaEtapa.codigo,
dataPrevisao,
incluirNoTimeline: proximaEtapa.incluirNoTimeline
});
// Atualizar para próxima iteração
if (proximaEtapa.tempoEstimadoDias) {
dataPrevisao += proximaEtapa.tempoEstimadoDias * 24 * 60 * 60 * 1000;
}
etapaAtualId = proximaEtapa._id;
}
}
return { passado, futuro };
}
});
// ========== MUDANÇA DE ETAPA ==========
export const mudarEtapa = mutation({
args: {
pedidoId: v.id('pedidos'),
novaEtapaId: v.id('pedidoFluxoEtapa'),
funcionarioId: v.optional(v.id('funcionarios'))
},
returns: v.id('pedidoEtapasHistorico'),
handler: async (ctx, args) => {
const usuario = await getUsuarioAutenticado(ctx);
const pedido = await ctx.db.get(args.pedidoId);
if (!pedido) {
throw new Error('Pedido não encontrado');
}
const novaEtapa = await ctx.db.get(args.novaEtapaId);
if (!novaEtapa) {
throw new Error('Etapa não encontrada');
}
const now = Date.now();
// Buscar etapa atual
const etapaAtualHistorico = await getEtapaAtualDoPedido(ctx, args.pedidoId);
if (etapaAtualHistorico) {
// Validar se a transição é permitida
const transicaoPermitida = await ctx.db
.query('pedidoFluxoTransicao')
.withIndex('by_etapaOrigemId', (q) => q.eq('etapaOrigemId', etapaAtualHistorico.etapaId))
.filter((q) => q.eq(q.field('etapaDestinoId'), args.novaEtapaId))
.first();
if (!transicaoPermitida) {
throw new Error('Transição não permitida para esta etapa');
}
// Finalizar etapa atual
await ctx.db.patch(etapaAtualHistorico._id, {
atual: false,
fimData: now
});
}
// Criar novo registro de etapa
const novoHistoricoId = await ctx.db.insert('pedidoEtapasHistorico', {
pedidoId: args.pedidoId,
etapaId: args.novaEtapaId,
inicioData: now,
funcionarioId: args.funcionarioId,
atual: true
});
// Registrar no histórico de pedidos
await ctx.db.insert('historicoPedidos', {
pedidoId: args.pedidoId,
usuarioId: usuario._id,
acao: 'alteracao_etapa',
detalhes: JSON.stringify({
novaEtapa: novaEtapa.codigo,
novaEtapaNome: novaEtapa.nome,
funcionarioId: args.funcionarioId
}),
data: now
});
return novoHistoricoId;
}
});
export const iniciarFluxoPedido = mutation({
args: {
pedidoId: v.id('pedidos')
},
returns: v.union(v.id('pedidoEtapasHistorico'), v.null()),
handler: async (ctx, args) => {
const usuario = await getUsuarioAutenticado(ctx);
return await iniciarFluxoPedidoInternal(ctx, args.pedidoId, usuario._id);
}
});
export const atribuirFuncionario = mutation({
args: {
pedidoId: v.id('pedidos'),
funcionarioId: v.id('funcionarios')
},
returns: v.null(),
handler: async (ctx, args) => {
await getUsuarioAutenticado(ctx);
const etapaAtual = await getEtapaAtualDoPedido(ctx, args.pedidoId);
if (!etapaAtual) {
throw new Error('Pedido não possui etapa atual');
}
const funcionario = await ctx.db.get(args.funcionarioId);
if (!funcionario) {
throw new Error('Funcionário não encontrado');
}
await ctx.db.patch(etapaAtual._id, {
funcionarioId: args.funcionarioId
});
return null;
}
});
// ========== QUERY PARA OBTER ETAPA ATUAL ==========
export const getEtapaAtual = query({
args: { pedidoId: v.id('pedidos') },
returns: v.union(
v.object({
_id: v.id('pedidoEtapasHistorico'),
etapaId: v.id('pedidoFluxoEtapa'),
etapaNome: v.string(),
etapaCodigo: v.string(),
setorId: v.optional(v.id('setores')),
setorNome: v.optional(v.string()),
inicioData: v.number(),
funcionarioId: v.optional(v.id('funcionarios')),
funcionarioNome: v.optional(v.string())
}),
v.null()
),
handler: async (ctx, args) => {
const etapaAtualHistorico = await getEtapaAtualDoPedido(ctx, args.pedidoId);
if (!etapaAtualHistorico) return null;
const etapa = await ctx.db.get(etapaAtualHistorico.etapaId);
if (!etapa) return null;
let setorNome: string | undefined;
if (etapa.setorId) {
const setor = await ctx.db.get(etapa.setorId);
setorNome = setor?.nome;
}
let funcionarioNome: string | undefined;
if (etapaAtualHistorico.funcionarioId) {
const funcionario = await ctx.db.get(etapaAtualHistorico.funcionarioId);
funcionarioNome = funcionario?.nome;
}
return {
_id: etapaAtualHistorico._id,
etapaId: etapa._id,
etapaNome: etapa.nome,
etapaCodigo: etapa.codigo,
setorId: etapa.setorId,
setorNome,
inicioData: etapaAtualHistorico.inicioData,
funcionarioId: etapaAtualHistorico.funcionarioId,
funcionarioNome
};
}
});

View File

@@ -4,6 +4,7 @@ import type { Doc, Id } from './_generated/dataModel';
import type { MutationCtx, QueryCtx } from './_generated/server';
import { internalMutation, mutation, query } from './_generated/server';
import { getCurrentUserFunction } from './auth';
import { iniciarFluxoPedidoInternal } from './pedidoFlow';
import { getTodayYMD, isWithinRangeYMD, maxYMD } from './utils/datas';
// ========== HELPERS ==========
@@ -1292,6 +1293,9 @@ export const create = mutation({
data: Date.now()
});
// 5. Iniciar Fluxo se houver etapas
await iniciarFluxoPedidoInternal(ctx, pedidoId, user._id);
return pedidoId;
}
});
@@ -2079,6 +2083,9 @@ export const enviarParaAceite = mutation({
data: Date.now()
});
// Garantir que o fluxo foi iniciado
await iniciarFluxoPedidoInternal(ctx, args.pedidoId, user._id);
await ctx.scheduler.runAfter(0, internal.pedidos.notifyStatusChange, {
pedidoId: args.pedidoId,
oldStatus,

View File

@@ -636,59 +636,45 @@ export const registrarPonto = mutation({
.filter((q) => q.eq(q.field('ativo'), true))
.collect();
// Helper para criar timestamp UTC a partir de data (YYYY-MM-DD), hora e minuto em GMT-3
// A hora informada está em GMT-3, então precisamos adicionar 3 horas para obter UTC
const offsetGMT3ParaUTC = 3 * 60 * 60 * 1000; // 3 horas em milissegundos
function criarTimestampUTCDeGMT3(data: string, hora: number, minuto: number): number {
const [ano, mes, dia] = data.split('-').map(Number);
return Date.UTC(ano, mes - 1, dia, hora, minuto, 0, 0) + offsetGMT3ParaUTC;
}
// Helper para criar timestamp UTC a partir de data (YYYY-MM-DD), hora e minuto que já estão em UTC
function criarTimestampUTC(data: string, horaUTC: number, minutoUTC: number): number {
const [ano, mes, dia] = data.split('-').map(Number);
return Date.UTC(ano, mes - 1, dia, horaUTC, minutoUTC, 0, 0);
}
// Obter timestamp atual em UTC
const agoraUTC = new Date();
const agoraTimestampUTC = agoraUTC.getTime();
// Timestamp da consulta (registro sendo feito) em UTC
// hora/minuto já estão em UTC (extraídos com getUTCHours/getUTCMinutes)
const timestampConsultaUTC = criarTimestampUTC(data, hora, minuto);
const dataConsulta = new Date(data);
for (const dispensa of dispensas) {
// Se for isento, sempre está dispensado
if (dispensa.isento) {
throw new Error('Registro dispensado pelo gestor: Isento de registro (caso excepcional)');
}
// Calcular timestamps de início e fim da dispensa em UTC
const timestampInicioUTC = criarTimestampUTCDeGMT3(
dispensa.dataInicio,
dispensa.horaInicio,
dispensa.minutoInicio
);
const timestampFimUTC = criarTimestampUTCDeGMT3(
dispensa.dataFim,
dispensa.horaFim,
dispensa.minutoFim
);
// Verificar se está no período
const dataInicio = new Date(dispensa.dataInicio);
const dataFim = new Date(dispensa.dataFim);
// Desativar dispensa expirada ANTES de verificar bloqueio (após o fim)
// Verificar se AGORA já passou do horário de fim da dispensa
if (agoraTimestampUTC > timestampFimUTC) {
if (dataConsulta >= dataInicio && dataConsulta <= dataFim) {
// Verificar hora e minuto se necessário
const timestampConsulta = new Date(
`${data}T${hora.toString().padStart(2, '0')}:${minuto.toString().padStart(2, '0')}:00`
).getTime();
const timestampInicio = new Date(
`${dispensa.dataInicio}T${dispensa.horaInicio.toString().padStart(2, '0')}:${dispensa.minutoInicio.toString().padStart(2, '0')}:00`
).getTime();
const timestampFim = new Date(
`${dispensa.dataFim}T${dispensa.horaFim.toString().padStart(2, '0')}:${dispensa.minutoFim.toString().padStart(2, '0')}:00`
).getTime();
if (timestampConsulta >= timestampInicio && timestampConsulta <= timestampFim) {
throw new Error(`Registro dispensado pelo gestor: ${dispensa.motivo}`);
}
}
// Verificar se expirou (desativar na mutation de registro)
const agora = new Date();
const dataFimTimestamp = new Date(
`${dispensa.dataFim}T${dispensa.horaFim.toString().padStart(2, '0')}:${dispensa.minutoFim.toString().padStart(2, '0')}:00`
).getTime();
if (agora.getTime() > dataFimTimestamp && !dispensa.isento) {
// Desativar dispensa expirada (mutation pode fazer isso)
await ctx.db.patch(dispensa._id, {
ativo: false
});
continue; // Pular verificação de bloqueio se já expirou
}
// Verificar se AGORA está dentro do período da dispensa (não o horário do registro)
// Se o momento atual está dentro do período, bloqueia qualquer tentativa de registro
if (agoraTimestampUTC >= timestampInicioUTC && agoraTimestampUTC <= timestampFimUTC) {
throw new Error(`Registro dispensado pelo gestor: ${dispensa.motivo}`);
}
}
@@ -1414,45 +1400,78 @@ function calcularHorasTrabalhadas(
return minutosA - minutosB;
});
let totalMinutos = 0;
let entradaPendente: { hora: number; minuto: number } | null = null;
// Procurar registros principais
const entrada = registrosOrdenados.find((r) => r.tipo === 'entrada');
const saidaAlmoco = registrosOrdenados.find((r) => r.tipo === 'saida_almoco');
const retornoAlmoco = registrosOrdenados.find((r) => r.tipo === 'retorno_almoco');
const saida = registrosOrdenados.find((r) => r.tipo === 'saida');
// Processar registros sequencialmente para capturar todos os períodos de trabalho
// Isso permite calcular múltiplas entradas/saídas no mesmo dia
for (const registro of registrosOrdenados) {
const minutosRegistro = registro.hora * 60 + registro.minuto;
// Caso 1: Tem entrada e saída completas
if (entrada && saida) {
const minutosEntrada = entrada.hora * 60 + entrada.minuto;
const minutosSaida = saida.hora * 60 + saida.minuto;
if (registro.tipo === 'entrada') {
// Se já havia uma entrada pendente sem saída, ignorar a anterior (inconsistência)
// e usar a nova entrada
entradaPendente = { hora: registro.hora, minuto: registro.minuto };
} else if (registro.tipo === 'saida_almoco') {
// Se há entrada pendente, calcular período da manhã
if (entradaPendente) {
const minutosEntrada = entradaPendente.hora * 60 + entradaPendente.minuto;
if (minutosRegistro > minutosEntrada) {
totalMinutos += minutosRegistro - minutosEntrada;
}
// Limpar entrada pendente após saída almoço (aguardar retorno)
entradaPendente = null;
// Caso 1.1: Tem intervalo de almoço completo (saída almoço + retorno almoço)
if (saidaAlmoco && retornoAlmoco) {
const minutosSaidaAlmoco = saidaAlmoco.hora * 60 + saidaAlmoco.minuto;
const minutosRetornoAlmoco = retornoAlmoco.hora * 60 + retornoAlmoco.minuto;
// Validar ordem lógica
if (
minutosSaidaAlmoco > minutosEntrada &&
minutosRetornoAlmoco > minutosSaidaAlmoco &&
minutosSaida > minutosRetornoAlmoco
) {
const horasManha = minutosSaidaAlmoco - minutosEntrada;
const horasTarde = minutosSaida - minutosRetornoAlmoco;
return horasManha + horasTarde;
}
} else if (registro.tipo === 'retorno_almoco') {
// Marcar como nova entrada para período da tarde
entradaPendente = { hora: registro.hora, minuto: registro.minuto };
} else if (registro.tipo === 'saida') {
// Se há entrada pendente (pode ser entrada inicial ou retorno almoço), calcular período
if (entradaPendente) {
const minutosEntrada = entradaPendente.hora * 60 + entradaPendente.minuto;
if (minutosRegistro > minutosEntrada) {
totalMinutos += minutosRegistro - minutosEntrada;
}
// Limpar entrada pendente após saída
entradaPendente = null;
}
// Caso 1.2: Tem apenas saída almoço (sem retorno) - considerar apenas manhã
if (saidaAlmoco && !retornoAlmoco) {
const minutosSaidaAlmoco = saidaAlmoco.hora * 60 + saidaAlmoco.minuto;
if (minutosSaidaAlmoco > minutosEntrada) {
return minutosSaidaAlmoco - minutosEntrada;
}
}
// Caso 1.3: Tem apenas retorno almoço (sem saída) - considerar apenas tarde
if (!saidaAlmoco && retornoAlmoco) {
const minutosRetornoAlmoco = retornoAlmoco.hora * 60 + retornoAlmoco.minuto;
if (minutosSaida > minutosRetornoAlmoco) {
return minutosSaida - minutosRetornoAlmoco;
}
}
// Caso 1.4: Sem intervalo de almoço registrado - calcular direto
if (!saidaAlmoco && !retornoAlmoco) {
if (minutosSaida > minutosEntrada) {
return minutosSaida - minutosEntrada;
}
}
}
return totalMinutos;
// Caso 2: Tem apenas entrada (sem saída) - retornar 0 (dia incompleto)
if (entrada && !saida) {
// Se tiver saída almoço, considerar até a saída almoço
if (saidaAlmoco) {
const minutosEntrada = entrada.hora * 60 + entrada.minuto;
const minutosSaidaAlmoco = saidaAlmoco.hora * 60 + saidaAlmoco.minuto;
if (minutosSaidaAlmoco > minutosEntrada) {
return minutosSaidaAlmoco - minutosEntrada;
}
}
return 0;
}
// Caso 3: Tem apenas saída (sem entrada) - inconsistência, retornar 0
if (saida && !entrada) {
return 0;
}
// Caso 4: Não tem entrada nem saída - retornar 0
return 0;
}
/**
@@ -1549,54 +1568,6 @@ async function verificarAusenciaAprovada(
return { temAusencia: false };
}
/**
* Remove ajustes automáticos relacionados a um registro excluído
* Busca e remove ajustes que referenciam o motivoId fornecido
*/
async function removerAjustesAutomaticos(
ctx: MutationCtx,
funcionarioId: Id<'funcionarios'>,
motivoTipo: 'atestado' | 'licenca' | 'ausencia',
motivoId: string,
dataInicio: string,
dataFim: string
): Promise<void> {
// Gerar todas as datas do período
const dataInicioObj = new Date(dataInicio);
const dataFimObj = new Date(dataFim);
const datas: string[] = [];
const dataAtual = new Date(dataInicioObj);
while (dataAtual <= dataFimObj) {
const ano = dataAtual.getFullYear();
const mes = String(dataAtual.getMonth() + 1).padStart(2, '0');
const dia = String(dataAtual.getDate()).padStart(2, '0');
datas.push(`${ano}-${mes}-${dia}`);
dataAtual.setDate(dataAtual.getDate() + 1);
}
// Buscar todos os ajustes automáticos relacionados ao motivoId no período
for (const data of datas) {
const ajustes = await ctx.db
.query('ajustesBancoHoras')
.filter((q) =>
q.and(
q.eq(q.field('funcionarioId'), funcionarioId),
q.eq(q.field('dataAplicacao'), data),
q.eq(q.field('motivoTipo'), motivoTipo),
q.eq(q.field('motivoId'), motivoId),
q.eq(q.field('aplicado'), true)
)
)
.collect();
// Remover cada ajuste encontrado
for (const ajuste of ajustes) {
await ctx.db.delete(ajuste._id);
}
}
}
/**
* Verifica ajustes manuais aplicados no dia
*/
@@ -1755,13 +1726,10 @@ async function atualizarBancoHoras(
const ajustesIds: Array<Id<'ajustesBancoHoras'>> = [];
// Aplicar ajustes automáticos se houver atestado, licença ou ausência
// IMPORTANTE: Verificar se o registro ainda existe antes de criar ajuste
if (atestadoInfo.temAtestado && atestadoInfo.atestadoId) {
// Verificar se o atestado ainda existe no banco
const atestado = await ctx.db.get(atestadoInfo.atestadoId as Id<'atestados'>);
if (atestado) {
tipoDia = 'atestado';
motivoAbono = atestadoInfo.motivo;
if (atestadoInfo.temAtestado) {
tipoDia = 'atestado';
motivoAbono = atestadoInfo.motivo;
if (atestadoInfo.atestadoId) {
const ajusteId = await aplicarAjusteAutomatico(
ctx,
funcionarioId,
@@ -1773,12 +1741,10 @@ async function atualizarBancoHoras(
);
ajustesIds.push(ajusteId);
}
} else if (licencaInfo.temLicenca && licencaInfo.licencaId) {
// Verificar se a licença ainda existe no banco
const licenca = await ctx.db.get(licencaInfo.licencaId as Id<'licencas'>);
if (licenca) {
tipoDia = 'licenca';
motivoAbono = licencaInfo.motivo;
} else if (licencaInfo.temLicenca) {
tipoDia = 'licenca';
motivoAbono = licencaInfo.motivo;
if (licencaInfo.licencaId) {
const ajusteId = await aplicarAjusteAutomatico(
ctx,
funcionarioId,
@@ -1790,12 +1756,10 @@ async function atualizarBancoHoras(
);
ajustesIds.push(ajusteId);
}
} else if (ausenciaInfo.temAusencia && ausenciaInfo.ausenciaId) {
// Verificar se a ausência ainda existe no banco e está aprovada
const ausencia = await ctx.db.get(ausenciaInfo.ausenciaId as Id<'solicitacoesAusencias'>);
if (ausencia && ausencia.status === 'aprovado') {
tipoDia = 'ausencia';
motivoAbono = ausenciaInfo.motivo;
} else if (ausenciaInfo.temAusencia) {
tipoDia = 'ausencia';
motivoAbono = ausenciaInfo.motivo;
if (ausenciaInfo.ausenciaId) {
const ajusteId = await aplicarAjusteAutomatico(
ctx,
funcionarioId,
@@ -2618,14 +2582,6 @@ export const ajustarBancoHoras = mutation({
periodoDias: v.number(),
periodoHoras: v.number(),
periodoMinutos: v.number(),
dataAplicacao: v.string(), // YYYY-MM-DD - Data em que o ajuste deve ser aplicado
// Período do ajuste (data/hora início e fim)
dataInicio: v.optional(v.string()), // YYYY-MM-DD
horaInicio: v.optional(v.number()), // 0-23
minutoInicio: v.optional(v.number()), // 0-59
dataFim: v.optional(v.string()), // YYYY-MM-DD
horaFim: v.optional(v.number()), // 0-23
minutoFim: v.optional(v.number()), // 0-59
motivoId: v.optional(v.string()),
motivoTipo: v.optional(v.string()),
motivoDescricao: v.optional(v.string()),
@@ -2663,8 +2619,8 @@ export const ajustarBancoHoras = mutation({
ajusteFinal = -ajusteMinutos;
}
// Usar a data de aplicação fornecida pelo usuário
const dataAplicacao = args.dataAplicacao;
// Buscar banco de horas mais recente ou criar um registro de ajuste
const hoje = new Date().toISOString().split('T')[0]!;
// Criar registro de ajuste na nova tabela
const ajusteId = await ctx.db.insert('ajustesBancoHoras', {
@@ -2674,13 +2630,7 @@ export const ajustarBancoHoras = mutation({
motivoId: args.motivoId,
motivoDescricao: args.motivoDescricao || `Ajuste ${args.tipoAjuste}`,
valorMinutos: ajusteFinal,
dataAplicacao: dataAplicacao,
dataInicio: args.dataInicio,
horaInicio: args.horaInicio,
minutoInicio: args.minutoInicio,
dataFim: args.dataFim,
horaFim: args.horaFim,
minutoFim: args.minutoFim,
dataAplicacao: hoje,
gestorId: usuario._id,
observacoes: args.observacoes,
aplicado: false,
@@ -2690,7 +2640,7 @@ export const ajustarBancoHoras = mutation({
const bancoHorasAtual = await ctx.db
.query('bancoHoras')
.withIndex('by_funcionario_data', (q) =>
q.eq('funcionarioId', args.funcionarioId).eq('data', dataAplicacao)
q.eq('funcionarioId', args.funcionarioId).eq('data', hoje)
)
.first();
@@ -2722,7 +2672,7 @@ export const ajustarBancoHoras = mutation({
await ctx.db.insert('bancoHoras', {
funcionarioId: args.funcionarioId,
data: dataAplicacao,
data: hoje,
cargaHorariaDiaria,
horasTrabalhadas: 0,
saldoMinutos: ajusteFinal,
@@ -2741,7 +2691,7 @@ export const ajustarBancoHoras = mutation({
});
// Recalcular banco de horas mensal após ajuste
const mes = dataAplicacao.substring(0, 7); // YYYY-MM
const mes = hoje.substring(0, 7); // YYYY-MM
// Verificar se estamos ajustando um mês passado
const hojeDate = new Date();
@@ -2752,7 +2702,6 @@ export const ajustarBancoHoras = mutation({
await calcularBancoHorasMensal(ctx, args.funcionarioId, mes, estaAjustandoMesPassado);
// Criar registro de homologação (mantido para compatibilidade)
// Armazenar o ajusteId para facilitar a busca posterior
const homologacaoId = await ctx.db.insert('homologacoesPonto', {
funcionarioId: args.funcionarioId,
gestorId: usuario._id,
@@ -2768,9 +2717,6 @@ export const ajustarBancoHoras = mutation({
criadoEm: Date.now()
});
// Armazenar ajusteId na homologação usando um campo customizado ou buscar depois
// Por enquanto, vamos buscar o ajuste no listarHomologacoes usando os critérios
return { success: true, homologacaoId, ajusteId, ajusteMinutos: ajusteFinal };
}
});
@@ -2838,62 +2784,6 @@ export const listarHomologacoes = query({
}
}
// Buscar dataAplicacao e período do ajuste se for um ajuste de banco de horas
let dataAplicacaoAjuste: string | null = null;
let periodoAjuste: {
dataInicio?: string;
horaInicio?: number;
minutoInicio?: number;
dataFim?: string;
horaFim?: number;
minutoFim?: number;
} | null = null;
if (h.tipoAjuste && h.ajusteMinutos !== undefined) {
// Buscar ajustes criados próximo ao tempo da homologação (dentro de 5 minutos antes)
// O ajuste é criado logo antes da homologação em ajustarBancoHoras
const tempoLimite = h.criadoEm - 5 * 60 * 1000; // 5 minutos antes
// Buscar todos os ajustes do funcionário com os mesmos critérios
// e criados próximo ao tempo da homologação
const ajustes = await ctx.db
.query('ajustesBancoHoras')
.withIndex('by_funcionario', (q) => q.eq('funcionarioId', h.funcionarioId))
.filter((q) =>
q.and(
q.eq(q.field('motivoTipo'), 'manual'),
q.eq(q.field('tipo'), h.tipoAjuste),
q.eq(q.field('valorMinutos'), h.ajusteMinutos),
q.eq(q.field('gestorId'), h.gestorId),
q.gte(q.field('criadoEm'), tempoLimite),
q.lte(q.field('criadoEm'), h.criadoEm)
)
)
.collect();
// Se encontrou ajuste(s), encontrar o mais próximo em tempo à homologação
if (ajustes.length > 0) {
// Encontrar o ajuste com timestamp mais próximo ao da homologação
let ajusteMaisProximo = ajustes[0]!;
let menorDiferenca = Math.abs(ajustes[0]!.criadoEm - h.criadoEm);
for (const ajusteCandidato of ajustes) {
const diferenca = Math.abs(ajusteCandidato.criadoEm - h.criadoEm);
if (diferenca < menorDiferenca) {
menorDiferenca = diferenca;
ajusteMaisProximo = ajusteCandidato;
}
}
dataAplicacaoAjuste = ajusteMaisProximo.dataAplicacao;
periodoAjuste = {
dataInicio: ajusteMaisProximo.dataInicio,
horaInicio: ajusteMaisProximo.horaInicio,
minutoInicio: ajusteMaisProximo.minutoInicio,
dataFim: ajusteMaisProximo.dataFim,
horaFim: ajusteMaisProximo.horaFim,
minutoFim: ajusteMaisProximo.minutoFim
};
}
}
return {
...h,
funcionario: funcionario
@@ -2913,9 +2803,7 @@ export const listarHomologacoes = query({
data: registro.data,
tipo: registro.tipo
}
: null,
dataAplicacaoAjuste,
periodoAjuste
: null
};
})
);
@@ -2957,157 +2845,14 @@ export const excluirHomologacao = mutation({
throw new Error('Você não tem permissão para excluir esta homologação');
}
// Se a homologação estiver vinculada a um registro, restaurar valores originais
// Se a homologação estiver vinculada a um registro, remover a referência
if (homologacao.registroId) {
const registro = await ctx.db.get(homologacao.registroId);
if (registro && registro.homologacaoId === args.homologacaoId) {
// Restaurar valores originais se existirem
const patchData: {
homologacaoId: undefined;
editadoPorGestor: boolean;
hora?: number;
minuto?: number;
} = {
await ctx.db.patch(homologacao.registroId, {
homologacaoId: undefined,
editadoPorGestor: false
};
// Se a homologação tem valores anteriores, restaurar
if (homologacao.horaAnterior !== undefined && homologacao.minutoAnterior !== undefined) {
patchData.hora = homologacao.horaAnterior;
patchData.minuto = homologacao.minutoAnterior;
}
await ctx.db.patch(homologacao.registroId, patchData);
// Recalcular banco de horas após restaurar valores
const config = await ctx.db
.query('configuracaoPonto')
.withIndex('by_ativo', (q) => q.eq('ativo', true))
.first();
if (config) {
await atualizarBancoHoras(ctx, registro.funcionarioId, registro.data, config);
}
}
}
// Se for um ajuste de banco de horas, remover completamente do banco de dados
if (homologacao.tipoAjuste && homologacao.ajusteMinutos !== undefined) {
// Buscar ajustes criados próximo ao tempo da homologação (dentro de 5 minutos antes)
// O ajuste é criado logo antes da homologação em ajustarBancoHoras
const tempoLimite = homologacao.criadoEm - 5 * 60 * 1000; // 5 minutos antes
// Buscar todos os ajustes do funcionário com os mesmos critérios
// e criados próximo ao tempo da homologação
const ajustes = await ctx.db
.query('ajustesBancoHoras')
.withIndex('by_funcionario', (q) => q.eq('funcionarioId', homologacao.funcionarioId))
.filter((q) =>
q.and(
q.eq(q.field('motivoTipo'), 'manual'),
q.eq(q.field('tipo'), homologacao.tipoAjuste),
q.eq(q.field('valorMinutos'), homologacao.ajusteMinutos),
q.eq(q.field('gestorId'), homologacao.gestorId),
q.gte(q.field('criadoEm'), tempoLimite),
q.lte(q.field('criadoEm'), homologacao.criadoEm)
)
)
.collect();
// Se encontrou ajuste(s), encontrar o mais próximo em tempo à homologação
if (ajustes.length > 0) {
// Encontrar o ajuste com timestamp mais próximo ao da homologação
// (o ajuste geralmente é criado um pouco antes da homologação)
let ajusteMaisProximo = ajustes[0]!;
let menorDiferenca = Math.abs(ajustes[0]!.criadoEm - homologacao.criadoEm);
for (const ajusteCandidato of ajustes) {
const diferenca = Math.abs(ajusteCandidato.criadoEm - homologacao.criadoEm);
if (diferenca < menorDiferenca) {
menorDiferenca = diferenca;
ajusteMaisProximo = ajusteCandidato;
}
}
const ajuste = ajusteMaisProximo;
// Buscar o banco de horas do dia onde o ajuste foi aplicado
const bancoHoras = await ctx.db
.query('bancoHoras')
.withIndex('by_funcionario_data', (q) =>
q.eq('funcionarioId', homologacao.funcionarioId).eq('data', ajuste.dataAplicacao)
)
.first();
if (bancoHoras) {
// Remover o ajuste do array ajustesIds
const novosAjustesIds = (bancoHoras.ajustesIds || []).filter(
(id) => id !== ajuste._id
);
// Reverter o ajuste do saldo (subtrair o valor que foi adicionado)
const novoSaldoMinutos = bancoHoras.saldoMinutos - ajuste.valorMinutos;
// Verificar se ainda há outros ajustes ou se precisa resetar tipoDia
let novoTipoDia = bancoHoras.tipoDia;
if (novosAjustesIds.length > 0) {
// Se ainda há outros ajustes, verificar qual tipoDia deve ser mantido
const outrosAjustes = await Promise.all(
novosAjustesIds.map((id) => ctx.db.get(id))
);
const temAjusteAbonar = outrosAjustes.some((a) => a?.tipo === 'abonar');
const temAjusteDescontar = outrosAjustes.some((a) => a?.tipo === 'descontar');
// Se há ajuste de abonar, manter ou definir como 'abonado'
if (temAjusteAbonar) {
novoTipoDia = 'abonado';
} else if (temAjusteDescontar) {
// Se há ajuste de descontar, manter ou definir como 'descontado'
novoTipoDia = 'descontado';
} else {
// Se não há ajustes que determinem tipoDia, resetar
novoTipoDia = undefined;
}
} else {
// Se não há mais ajustes, verificar se deve resetar tipoDia
// Se o tipoDia estava relacionado ao ajuste removido, resetar
if (
(bancoHoras.tipoDia === 'abonado' && ajuste.tipo === 'abonar') ||
(bancoHoras.tipoDia === 'descontado' && ajuste.tipo === 'descontar')
) {
novoTipoDia = undefined;
}
}
// Atualizar banco de horas
await ctx.db.patch(bancoHoras._id, {
saldoMinutos: novoSaldoMinutos,
ajustesIds: novosAjustesIds.length > 0 ? novosAjustesIds : undefined,
tipoDia: novoTipoDia
});
// Recalcular banco de horas do dia específico para garantir consistência
const config = await ctx.db
.query('configuracaoPonto')
.withIndex('by_ativo', (q) => q.eq('ativo', true))
.first();
if (config) {
// Recalcular banco de horas do dia para atualizar valores baseados nos registros
await atualizarBancoHoras(ctx, homologacao.funcionarioId, ajuste.dataAplicacao, config);
}
// Recalcular banco de horas mensal após remover ajuste
const mes = ajuste.dataAplicacao.substring(0, 7); // YYYY-MM
const hojeDate = new Date();
const mesAtual = `${hojeDate.getFullYear()}-${String(hojeDate.getMonth() + 1).padStart(2, '0')}`;
const estaRemovendoMesPassado = mes < mesAtual;
// Recalcular em cascata se for mês passado
await calcularBancoHorasMensal(ctx, homologacao.funcionarioId, mes, estaRemovendoMesPassado);
}
// Excluir o registro de ajuste do banco de dados
await ctx.db.delete(ajuste._id);
});
}
}
@@ -3184,9 +2929,11 @@ export const criarDispensaRegistro = mutation({
throw new Error('Você não tem permissão para criar dispensa para este funcionário');
}
// Validar datas (comparar strings diretamente para evitar problemas de timezone)
// Formato YYYY-MM-DD permite comparação lexicográfica
if (args.dataFim < args.dataInicio) {
// Validar datas
const dataInicioObj = new Date(args.dataInicio);
const dataFimObj = new Date(args.dataFim);
if (dataFimObj < dataInicioObj) {
throw new Error('Data fim deve ser maior ou igual à data início');
}
@@ -3239,8 +2986,10 @@ export const removerDispensaRegistro = mutation({
throw new Error('Você não tem permissão para remover esta dispensa');
}
// Deletar dispensa do banco de dados
await ctx.db.delete(args.dispensaId);
// Desativar dispensa
await ctx.db.patch(args.dispensaId, {
ativo: false
});
return { success: true };
}
@@ -3321,49 +3070,14 @@ export const listarDispensas = query({
}
}
// Verificar se está ativa ou expirada (considerando data, hora e minuto em GMT-3)
// Verificar se expirou (se não for isento)
let expirada = false;
// GMT-3 está 3 horas ATRÁS do UTC
// Offset: +3 horas para converter GMT-3 para UTC
const offsetGMT3ParaUTC = 3 * 60 * 60 * 1000; // 3 horas em milissegundos
// Obter data/hora atual em UTC
const agoraUTC = new Date();
const agoraTimestampUTC = agoraUTC.getTime();
// Helper para criar timestamp UTC a partir de data (YYYY-MM-DD), hora e minuto em GMT-3
// A hora informada está em GMT-3, então precisamos adicionar 3 horas para obter UTC
// Exemplo: 08:00 GMT-3 = 11:00 UTC
function criarTimestampUTCDeGMT3(data: string, hora: number, minuto: number): number {
const [ano, mes, dia] = data.split('-').map(Number);
// Date.UTC cria timestamp UTC
// Se a hora está em GMT-3, adicionamos 3 horas para obter o equivalente UTC
return Date.UTC(ano, mes - 1, dia, hora, minuto, 0, 0) + offsetGMT3ParaUTC;
}
if (!d.isento) {
// Para dispensas não isentas, verificar se está dentro do período
const dataInicioTimestamp = criarTimestampUTCDeGMT3(
d.dataInicio,
d.horaInicio,
d.minutoInicio
);
const dataFimTimestamp = criarTimestampUTCDeGMT3(d.dataFim, d.horaFim, d.minutoFim);
// Está expirada se estiver antes do início OU depois do fim
// Está ativa se: dataInicioTimestamp <= agoraTimestampUTC <= dataFimTimestamp
expirada =
agoraTimestampUTC < dataInicioTimestamp || agoraTimestampUTC > dataFimTimestamp;
} else {
// Se for isento, verificar apenas se já passou do início
const dataInicioTimestamp = criarTimestampUTCDeGMT3(
d.dataInicio,
d.horaInicio,
d.minutoInicio
);
// Se ainda não começou, está expirada (não ativa ainda)
expirada = agoraTimestampUTC < dataInicioTimestamp;
const agora = new Date();
const dataFimTimestamp = new Date(
`${d.dataFim}T${d.horaFim.toString().padStart(2, '0')}:${d.minutoFim.toString().padStart(2, '0')}:00`
).getTime();
expirada = agora.getTime() > dataFimTimestamp;
}
return {
@@ -3588,16 +3302,7 @@ export const verificarDispensaAtiva = query({
.filter((q) => q.eq(q.field('ativo'), true))
.collect();
// Helper para criar timestamp UTC a partir de data (YYYY-MM-DD), hora e minuto em GMT-3
const offsetGMT3ParaUTC = 3 * 60 * 60 * 1000; // 3 horas em milissegundos
function criarTimestampUTCDeGMT3(data: string, hora: number, minuto: number): number {
const [ano, mes, dia] = data.split('-').map(Number);
return Date.UTC(ano, mes - 1, dia, hora, minuto, 0, 0) + offsetGMT3ParaUTC;
}
// Obter timestamp atual em UTC
const agoraUTC = new Date();
const agoraTimestampUTC = agoraUTC.getTime();
const dataConsulta = new Date(args.data);
for (const dispensa of dispensas) {
// Se for isento, sempre está dispensado
@@ -3609,39 +3314,33 @@ export const verificarDispensaAtiva = query({
};
}
// Calcular timestamps de início e fim da dispensa em UTC
const timestampInicioUTC = criarTimestampUTCDeGMT3(
dispensa.dataInicio,
dispensa.horaInicio,
dispensa.minutoInicio
);
const timestampFimUTC = criarTimestampUTCDeGMT3(
dispensa.dataFim,
dispensa.horaFim,
dispensa.minutoFim
);
// Verificar se está no período
const dataInicio = new Date(dispensa.dataInicio);
const dataFim = new Date(dispensa.dataFim);
// Verificar se AGORA já passou do horário de fim da dispensa
// Se já expirou, não está mais dispensado
if (agoraTimestampUTC > timestampFimUTC) {
// Dispensa expirada, continuar para próxima
continue;
}
// Se a data está dentro do período
if (dataConsulta >= dataInicio && dataConsulta <= dataFim) {
// Se hora e minuto foram fornecidos, verificar também
if (args.hora !== undefined && args.minuto !== undefined) {
const timestampConsulta = new Date(
`${args.data}T${args.hora.toString().padStart(2, '0')}:${args.minuto.toString().padStart(2, '0')}:00`
).getTime();
const timestampInicio = new Date(
`${dispensa.dataInicio}T${dispensa.horaInicio.toString().padStart(2, '0')}:${dispensa.minutoInicio.toString().padStart(2, '0')}:00`
).getTime();
const timestampFim = new Date(
`${dispensa.dataFim}T${dispensa.horaFim.toString().padStart(2, '0')}:${dispensa.minutoFim.toString().padStart(2, '0')}:00`
).getTime();
// Se hora e minuto foram fornecidos, verificar timestamp completo
if (args.hora !== undefined && args.minuto !== undefined) {
const timestampConsultaUTC = criarTimestampUTCDeGMT3(args.data, args.hora, args.minuto);
if (timestampConsultaUTC >= timestampInicioUTC && timestampConsultaUTC <= timestampFimUTC) {
return {
dispensado: true,
dispensa,
motivo: dispensa.motivo
};
}
} else {
// Se apenas data foi fornecida, verificar se AGORA está dentro do período
// (não apenas a data, mas também o horário)
if (agoraTimestampUTC >= timestampInicioUTC && agoraTimestampUTC <= timestampFimUTC) {
if (timestampConsulta >= timestampInicio && timestampConsulta <= timestampFim) {
return {
dispensado: true,
dispensa,
motivo: dispensa.motivo
};
}
} else {
// Apenas verificar data
return {
dispensado: true,
dispensa,
@@ -3777,13 +3476,7 @@ export const obterBancoHorasCompleto = query({
tipo: a.tipo,
valorMinutos: a.valorMinutos,
motivoDescricao: a.motivoDescricao,
motivoTipo: a.motivoTipo,
dataInicio: a.dataInicio,
horaInicio: a.horaInicio,
minutoInicio: a.minutoInicio,
dataFim: a.dataFim,
horaFim: a.horaFim,
minutoFim: a.minutoFim
motivoTipo: a.motivoTipo
})),
inconsistencias: inconsistenciasFiltradas.map((i) => ({
_id: i._id,
@@ -3820,12 +3513,6 @@ export const listarAjustesBancoHoras = query({
),
motivoDescricao: v.optional(v.string()),
dataAplicacao: v.string(),
dataInicio: v.optional(v.string()),
horaInicio: v.optional(v.number()),
minutoInicio: v.optional(v.number()),
dataFim: v.optional(v.string()),
horaFim: v.optional(v.number()),
minutoFim: v.optional(v.number()),
aplicado: v.boolean(),
gestor: v.union(
v.object({
@@ -3880,12 +3567,6 @@ export const listarAjustesBancoHoras = query({
motivoTipo: ajuste.motivoTipo,
motivoDescricao: ajuste.motivoDescricao,
dataAplicacao: ajuste.dataAplicacao,
dataInicio: ajuste.dataInicio,
horaInicio: ajuste.horaInicio,
minutoInicio: ajuste.minutoInicio,
dataFim: ajuste.dataFim,
horaFim: ajuste.horaFim,
minutoFim: ajuste.minutoFim,
aplicado: ajuste.aplicado,
gestor: gestor ? { nome: gestor.nome } : null
};
@@ -4590,31 +4271,6 @@ export const recalcularBancoHoras = mutation({
/**
* Mutation interna para recalcular banco de horas de uma data específica
*/
/**
* Internal mutation para remover ajustes automáticos relacionados a um registro excluído
*/
export const removerAjustesAutomaticosInternal = internalMutation({
args: {
funcionarioId: v.id('funcionarios'),
motivoTipo: v.union(v.literal('atestado'), v.literal('licenca'), v.literal('ausencia')),
motivoId: v.string(),
dataInicio: v.string(),
dataFim: v.string()
},
returns: v.null(),
handler: async (ctx, args) => {
await removerAjustesAutomaticos(
ctx,
args.funcionarioId,
args.motivoTipo,
args.motivoId,
args.dataInicio,
args.dataFim
);
return null;
}
});
export const recalcularBancoHorasData = internalMutation({
args: {
funcionarioId: v.id('funcionarios'),

View File

@@ -112,5 +112,46 @@ export const pedidosTables = {
})
.index('by_requestId', ['requestId'])
.index('by_pedidoId', ['pedidoId'])
.index('by_criadoPor', ['criadoPor'])
.index('by_criadoPor', ['criadoPor']),
// ========== FLUXO DE PEDIDOS ==========
// Configuração das etapas do fluxo de pedidos (dinâmico)
pedidoFluxoEtapa: defineTable({
nome: v.string(), // Nome da etapa (ex: "Rascunho", "Aguardando Aceite")
codigo: v.string(), // Código único (ex: "rascunho", "aguardando_aceite")
descricao: v.optional(v.string()),
setorId: v.optional(v.id('setores')), // Setor responsável por esta etapa
tempoEstimadoDias: v.optional(v.number()), // Tempo estimado em dias
incluirNoTimeline: v.boolean(), // Se false, não aparece no timeline (ex: rascunho)
ordem: v.number(), // Ordem de exibição
criadoEm: v.number(),
atualizadoEm: v.number()
})
.index('by_codigo', ['codigo'])
.index('by_ordem', ['ordem']),
// Transições possíveis entre etapas
pedidoFluxoTransicao: defineTable({
etapaOrigemId: v.id('pedidoFluxoEtapa'),
etapaDestinoId: v.id('pedidoFluxoEtapa'),
isPadrao: v.boolean(), // Se é a transição padrão quando há múltiplas opções
criadoEm: v.number()
})
.index('by_etapaOrigemId', ['etapaOrigemId'])
.index('by_etapaOrigemId_isPadrao', ['etapaOrigemId', 'isPadrao']),
// Histórico de etapas do pedido
pedidoEtapasHistorico: defineTable({
pedidoId: v.id('pedidos'),
etapaId: v.id('pedidoFluxoEtapa'),
inicioData: v.number(),
fimData: v.optional(v.number()),
funcionarioId: v.optional(v.id('funcionarios')),
atual: v.boolean()
})
.index('by_pedidoId', ['pedidoId'])
.index('by_pedidoId_atual', ['pedidoId', 'atual'])
.index('by_etapaId', ['etapaId'])
.index('by_funcionarioId', ['funcionarioId'])
};

View File

@@ -368,13 +368,6 @@ export const pontoTables = {
valorMinutos: v.number(), // Valor em minutos (positivo para abonar, negativo para descontar)
// Data de aplicação
dataAplicacao: v.string(), // YYYY-MM-DD
// Período do ajuste (data/hora início e fim)
dataInicio: v.optional(v.string()), // YYYY-MM-DD
horaInicio: v.optional(v.number()), // 0-23
minutoInicio: v.optional(v.number()), // 0-59
dataFim: v.optional(v.string()), // YYYY-MM-DD
horaFim: v.optional(v.number()), // 0-23
minutoFim: v.optional(v.number()), // 0-59
// Gestor responsável (null se automático)
gestorId: v.optional(v.id('usuarios')),
// Observações