refactor: enhance authentication and access control in ProtectedRoute component

- Updated the ProtectedRoute component to improve access control logic, including a timeout mechanism for handling authentication checks.
- Refactored the checkAccess function to streamline user access verification based on roles and authentication status.
- Added comments for clarity on the authentication flow and the use of the convexClient plugin in the auth.ts file.
- Improved the overall structure and readability of the code in auth.ts and ProtectedRoute.svelte.
This commit is contained in:
2025-11-17 16:27:15 -03:00
parent d4e70b5e52
commit 2c3d231d20
7 changed files with 1399 additions and 970 deletions

View File

@@ -8,6 +8,11 @@
import { createAuthClient } from "better-auth/svelte"; import { createAuthClient } from "better-auth/svelte";
import { convexClient } from "@convex-dev/better-auth/client/plugins"; import { convexClient } from "@convex-dev/better-auth/client/plugins";
// O baseURL deve apontar para o frontend (SvelteKit), não para o Convex diretamente
// O Better Auth usa as rotas HTTP do Convex que são acessadas via proxy do SvelteKit
// ou diretamente se configurado. Com o plugin convexClient, o token é gerenciado automaticamente.
export const authClient = createAuthClient({ export const authClient = createAuthClient({
// baseURL padrão é window.location.origin, que é o correto para SvelteKit
// O Better Auth será acessado via rotas HTTP do Convex registradas em http.ts
plugins: [convexClient()], plugins: [convexClient()],
}); });

View File

@@ -20,26 +20,32 @@
let isChecking = $state(true); let isChecking = $state(true);
let hasAccess = $state(false); let hasAccess = $state(false);
let timeoutId: ReturnType<typeof setTimeout> | null = null;
const currentUser = useQuery(api.auth.getCurrentUser, {}); const currentUser = useQuery(api.auth.getCurrentUser, {});
onMount(() => { // Usar $effect para reagir às mudanças na query
$effect(() => {
checkAccess(); checkAccess();
}); });
function checkAccess() { function checkAccess() {
isChecking = true; // Limpar timeout anterior se existir
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
}
// Aguardar um pouco para o authStore carregar do localStorage // Se a query ainda está carregando (undefined), aguardar
setTimeout(() => { if (currentUser === undefined) {
// Verificar autenticação isChecking = true;
if (requireAuth && !currentUser?.data) { hasAccess = false;
const currentPath = window.location.pathname;
window.location.href = `${redirectTo}?error=auth_required&redirect=${encodeURIComponent(currentPath)}`;
return; return;
} }
// Se a query retornou dados, verificar autenticação
if (currentUser?.data) {
// Verificar roles // Verificar roles
if (allowedRoles.length > 0 && currentUser?.data) { if (allowedRoles.length > 0) {
const hasRole = allowedRoles.includes(currentUser.data.role?.nome ?? ''); const hasRole = allowedRoles.includes(currentUser.data.role?.nome ?? '');
if (!hasRole) { if (!hasRole) {
const currentPath = window.location.pathname; const currentPath = window.location.pathname;
@@ -49,19 +55,40 @@
} }
// Verificar nível // Verificar nível
if ( if (currentUser.data.role?.nivel && currentUser.data.role.nivel > maxLevel) {
currentUser?.data &&
currentUser.data.role?.nivel &&
currentUser.data.role.nivel > maxLevel
) {
const currentPath = window.location.pathname; const currentPath = window.location.pathname;
window.location.href = `${redirectTo}?error=access_denied&route=${encodeURIComponent(currentPath)}`; window.location.href = `${redirectTo}?error=access_denied&route=${encodeURIComponent(currentPath)}`;
return; return;
} }
// Se chegou aqui, permitir acesso
hasAccess = true; hasAccess = true;
isChecking = false; isChecking = false;
}, 100); return;
}
// Se não tem dados e requer autenticação, aguardar um pouco antes de redirecionar
// (pode estar carregando ainda)
if (requireAuth && !currentUser?.data) {
isChecking = true;
hasAccess = false;
// Aguardar 3 segundos antes de redirecionar (dar tempo para a query carregar)
timeoutId = setTimeout(() => {
// Verificar novamente antes de redirecionar
if (!currentUser?.data) {
const currentPath = window.location.pathname;
window.location.href = `${redirectTo}?error=auth_required&redirect=${encodeURIComponent(currentPath)}`;
}
}, 3000);
return;
}
// Se não requer autenticação, permitir acesso
if (!requireAuth) {
hasAccess = true;
isChecking = false;
}
} }
</script> </script>

View File

@@ -3,7 +3,8 @@
import { api } from "@sgse-app/backend/convex/_generated/api"; import { api } from "@sgse-app/backend/convex/_generated/api";
import { onMount } from "svelte"; import { onMount } from "svelte";
import { page } from "$app/stores"; import { page } from "$app/stores";
import { goto } from "$app/navigation"; import { goto, replaceState } from "$app/navigation";
import { afterNavigate } from "$app/navigation";
import { resolve } from "$app/paths"; import { resolve } from "$app/paths";
import { UserPlus, Mail } from "lucide-svelte"; import { UserPlus, Mail } from "lucide-svelte";
import { useAuth } from "@mmailaender/convex-better-auth-svelte/svelte"; import { useAuth } from "@mmailaender/convex-better-auth-svelte/svelte";
@@ -44,28 +45,35 @@
// Forçar atualização das queries de monitoramento a cada 1 segundo // Forçar atualização das queries de monitoramento a cada 1 segundo
let refreshKey = $state(0); let refreshKey = $state(0);
onMount(() => { // Limpar URL após navegação estar completa
mounted = true; afterNavigate(({ to }) => {
if (to?.url.searchParams.has("error")) {
// Verificar se há mensagem de erro na URL const error = to.url.searchParams.get("error");
const urlParams = new URLSearchParams(window.location.search); const route = to.url.searchParams.get("route") || to.url.searchParams.get("redirect") || "";
const error = urlParams.get("error");
const route = urlParams.get("route") || urlParams.get("redirect") || "";
if (error) { if (error) {
alertType = error as any; alertType = error as any;
redirectRoute = route; redirectRoute = route;
showAlert = true; showAlert = true;
// Limpar URL // Limpar URL usando SvelteKit (após router estar inicializado)
const newUrl = window.location.pathname; try {
window.history.replaceState({}, "", newUrl); replaceState(to.url.pathname, {});
} catch (e) {
// Se ainda não estiver pronto, usar goto com replaceState
goto(to.url.pathname, { replaceState: true, noScroll: true });
}
// Auto-fechar após 10 segundos // Auto-fechar após 10 segundos
setTimeout(() => { setTimeout(() => {
showAlert = false; showAlert = false;
}, 10000); }, 10000);
} }
}
});
onMount(() => {
mounted = true;
// Atualizar relógio e forçar refresh das queries a cada segundo // Atualizar relógio e forçar refresh das queries a cada segundo
const interval = setInterval(() => { const interval = setInterval(() => {

View File

@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { useConvexClient, useQuery } from 'convex-svelte'; import { useConvexClient, useQuery } from 'convex-svelte';
import { api } from '@sgse-app/backend/convex/_generated/api'; import { api } from '@sgse-app/backend/convex/_generated/api';
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import AprovarFerias from '$lib/components/AprovarFerias.svelte'; import AprovarFerias from '$lib/components/AprovarFerias.svelte';
import WizardSolicitacaoFerias from '$lib/components/ferias/WizardSolicitacaoFerias.svelte'; import WizardSolicitacaoFerias from '$lib/components/ferias/WizardSolicitacaoFerias.svelte';
import WizardSolicitacaoAusencia from '$lib/components/ausencias/WizardSolicitacaoAusencia.svelte'; import WizardSolicitacaoAusencia from '$lib/components/ausencias/WizardSolicitacaoAusencia.svelte';
@@ -12,8 +12,21 @@ import { resolve } from '$app/paths';
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel'; import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
import type { FunctionReturnType } from 'convex/server'; import type { FunctionReturnType } from 'convex/server';
import { X, Calendar } from 'lucide-svelte'; import { X, Calendar } from 'lucide-svelte';
import TicketCard from '$lib/components/chamados/TicketCard.svelte';
import TicketTimeline from '$lib/components/chamados/TicketTimeline.svelte';
import { chamadosStore } from '$lib/stores/chamados';
import {
formatarData,
getStatusBadge as getStatusBadgeChamado,
getStatusDescription,
getStatusLabel,
prazoRestante
} from '$lib/utils/chamados';
import { useConvexWithAuth } from '$lib/hooks/useConvexWithAuth';
import type { Doc } from '@sgse-app/backend/convex/_generated/dataModel';
const client = useConvexClient(); const client = useConvexClient();
// @ts-expect-error - Convex types issue with getCurrentUser
const currentUser = useQuery(api.auth.getCurrentUser, {}); const currentUser = useQuery(api.auth.getCurrentUser, {});
type FuncionarioAtual = FunctionReturnType<typeof api.funcionarios.getCurrent>; type FuncionarioAtual = FunctionReturnType<typeof api.funcionarios.getCurrent>;
@@ -26,7 +39,12 @@ import { resolve } from '$app/paths';
let minhasAusenciasEstaveis = $state<MinhasAusencias>([]); let minhasAusenciasEstaveis = $state<MinhasAusencias>([]);
let abaAtiva = $state< let abaAtiva = $state<
'meu-perfil' | 'minhas-ferias' | 'minhas-ausencias' | 'aprovar-ferias' | 'aprovar-ausencias' | 'meu-perfil'
| 'meus-chamados'
| 'minhas-ferias'
| 'minhas-ausencias'
| 'aprovar-ferias'
| 'aprovar-ausencias'
>('meu-perfil'); >('meu-perfil');
let periodoSelecionado = $state<Id<'ferias'> | null>(null); let periodoSelecionado = $state<Id<'ferias'> | null>(null);
@@ -49,6 +67,17 @@ import { resolve } from '$app/paths';
// Estados para Aprovar Ausências (Gestores) // Estados para Aprovar Ausências (Gestores)
let solicitacaoAusenciaAprovar = $state<Id<'solicitacoesAusencias'> | null>(null); let solicitacaoAusenciaAprovar = $state<Id<'solicitacoesAusencias'> | null>(null);
// Estados para Meus Chamados
type Ticket = Doc<'tickets'>;
const detalhesStore = chamadosStore.detalhes;
let carregandoDetalheChamado = $state(false);
let filtroStatusChamados = $state<'todos' | Ticket['status']>('todos');
let filtroTipoChamados = $state<'todos' | Ticket['tipo']>('todos');
let selectedTicketId = $state<Id<'tickets'> | null>(null);
let mensagemChamado = $state('');
let erroMensagemChamado = $state<string | null>(null);
let sucessoMensagemChamado = $state<string | null>(null);
// Galeria de avatares (30 avatares profissionais 3D realistas) // Galeria de avatares (30 avatares profissionais 3D realistas)
const avatarGallery = generateAvatarGallery(30); const avatarGallery = generateAvatarGallery(30);
@@ -151,6 +180,123 @@ import { resolve } from '$app/paths';
const ehGestor = $derived((timesSubordinados || []).length > 0 || rolePermiteAprovacao); const ehGestor = $derived((timesSubordinados || []).length > 0 || rolePermiteAprovacao);
// Query reativa para Meus Chamados (igual às outras abas)
const chamadosQuery = $derived(
currentUser?.data?._id ? useQuery(api.chamados.listarChamadosUsuario, {}) : null
);
// Estados estáveis para Meus Chamados (igual às outras abas)
let chamadosEstaveis = $state<Array<Doc<'tickets'>>>([]);
// Sincronizar query com estado estável (igual às outras abas)
$effect(() => {
if (chamadosQuery && Array.isArray(chamadosQuery.data)) {
chamadosEstaveis = chamadosQuery.data;
// Sincronizar com a store para compatibilidade
chamadosStore.setTickets(chamadosQuery.data);
// Selecionar primeiro chamado automaticamente se não houver seleção
if (!selectedTicketId && chamadosQuery.data.length > 0) {
selectedTicketId = chamadosQuery.data[0]._id;
}
} else if (!currentUser?.data?._id) {
chamadosEstaveis = [];
chamadosStore.setTickets([]);
}
});
// Deriveds para Meus Chamados
const listaChamados = $derived(chamadosEstaveis);
const carregandoListaChamados = $derived(
chamadosQuery === undefined || chamadosQuery === null || chamadosQuery.data === undefined
);
const detalheAtualChamado = $derived(
selectedTicketId ? ($detalhesStore[selectedTicketId] ?? null) : null
);
const ticketsFiltrados = $derived(
listaChamados.filter((ticket) => {
if (filtroStatusChamados !== 'todos' && ticket.status !== filtroStatusChamados) return false;
if (filtroTipoChamados !== 'todos' && ticket.tipo !== filtroTipoChamados) return false;
return true;
})
);
// Funções para Meus Chamados
async function recarregarChamados() {
// A query é reativa e atualiza automaticamente
// Esta função pode ser usada para forçar atualização se necessário
if (chamadosQuery?.data) {
chamadosEstaveis = chamadosQuery.data;
chamadosStore.setTickets(chamadosQuery.data);
}
}
async function selecionarChamado(ticketId: Id<'tickets'>) {
selectedTicketId = ticketId;
if (!$detalhesStore[ticketId]) {
try {
carregandoDetalheChamado = true;
const detalhe = await client.query(api.chamados.obterChamado, { ticketId });
if (detalhe) {
chamadosStore.setDetalhe(ticketId, detalhe);
}
} catch (error) {
console.error('Erro ao carregar detalhe:', error);
} finally {
carregandoDetalheChamado = false;
}
}
}
async function enviarMensagemChamado() {
if (!selectedTicketId || !mensagemChamado.trim()) {
erroMensagemChamado = 'Informe uma mensagem para atualizar o chamado.';
return;
}
try {
erroMensagemChamado = null;
sucessoMensagemChamado = null;
await client.mutation(api.chamados.registrarAtualizacao, {
ticketId: selectedTicketId,
conteudo: mensagemChamado.trim(),
visibilidade: 'publico'
});
mensagemChamado = '';
sucessoMensagemChamado = 'Atualização registrada com sucesso.';
await selecionarChamado(selectedTicketId);
// A query é reativa e atualiza automaticamente, mas podemos forçar atualização
await recarregarChamados();
} catch (error) {
const mensagemErro =
error instanceof Error ? error.message : 'Erro ao enviar atualização. Tente novamente.';
erroMensagemChamado = mensagemErro;
}
}
function statusAlertasChamado(ticket: Ticket) {
const alertas: Array<{ label: string; tipo: 'success' | 'warning' | 'error' }> = [];
if (ticket.prazoResposta) {
const diff = ticket.prazoResposta - Date.now();
if (diff < 0) alertas.push({ label: 'Prazo de resposta vencido', tipo: 'error' });
else if (diff <= 4 * 60 * 60 * 1000)
alertas.push({ label: 'Resposta vence em breve', tipo: 'warning' });
}
if (ticket.prazoConclusao) {
const diff = ticket.prazoConclusao - Date.now();
if (diff < 0) alertas.push({ label: 'Prazo de conclusão vencido', tipo: 'error' });
else if (diff <= 24 * 60 * 60 * 1000)
alertas.push({ label: 'Conclusão vence em breve', tipo: 'warning' });
}
return alertas;
}
// Garantir autenticação quando a aba for ativada
$effect(() => {
if (abaAtiva === 'meus-chamados') {
useConvexWithAuth();
}
});
// Filtrar minhas solicitações // Filtrar minhas solicitações
const solicitacoesFiltradas = $derived( const solicitacoesFiltradas = $derived(
minhasSolicitacoes.filter((s) => { minhasSolicitacoes.filter((s) => {
@@ -378,7 +524,6 @@ import { resolve } from '$app/paths';
onmouseenter={() => (mostrarBotaoCamera = true)} onmouseenter={() => (mostrarBotaoCamera = true)}
onmouseleave={() => (mostrarBotaoCamera = false)} onmouseleave={() => (mostrarBotaoCamera = false)}
> >
s
<button <button
type="button" type="button"
class="avatar cursor-pointer border-0 bg-transparent p-0" class="avatar cursor-pointer border-0 bg-transparent p-0"
@@ -560,10 +705,11 @@ import { resolve } from '$app/paths';
Meu Perfil Meu Perfil
</button> </button>
<a <button
type="button"
role="tab" role="tab"
href={resolve('/perfil/chamados')} class={`tab tab-lg font-semibold transition-all duration-300 ${abaAtiva === 'meus-chamados' ? 'tab-active scale-105 bg-linear-to-r from-purple-600 to-blue-600 text-white shadow-lg' : 'hover:bg-base-100'}`}
class="tab tab-lg font-semibold transition-all duration-300 hover:bg-base-100" onclick={() => (abaAtiva = 'meus-chamados')}
aria-label="Meus Chamados" aria-label="Meus Chamados"
> >
<svg <svg
@@ -574,14 +720,10 @@ import { resolve } from '$app/paths';
stroke="currentColor" stroke="currentColor"
stroke-width="2" stroke-width="2"
> >
<path <path stroke-linecap="round" stroke-linejoin="round" d="M3 7h18M3 12h12M3 17h18" />
stroke-linecap="round"
stroke-linejoin="round"
d="M3 7h18M3 12h12M3 17h18"
/>
</svg> </svg>
Meus Chamados Meus Chamados
</a> </button>
{#if ehGestor} {#if ehGestor}
<button <button
@@ -654,8 +796,9 @@ import { resolve } from '$app/paths';
Aprovar Férias Aprovar Férias
{#if (solicitacoesSubordinados || []).filter((s) => s.status === 'aguardando_aprovacao').length > 0} {#if (solicitacoesSubordinados || []).filter((s) => s.status === 'aguardando_aprovacao').length > 0}
<span class="badge badge-error badge-sm ml-2 animate-pulse"> <span class="badge badge-error badge-sm ml-2 animate-pulse">
{(solicitacoesSubordinados || []).filter((s) => s.status === 'aguardando_aprovacao') {(solicitacoesSubordinados || []).filter(
.length} (s) => s.status === 'aguardando_aprovacao'
).length}
</span> </span>
{/if} {/if}
</button> </button>
@@ -689,6 +832,7 @@ import { resolve } from '$app/paths';
{/if} {/if}
</button> </button>
{/if} {/if}
{/if}
</div> </div>
<!-- Conteúdo das Abas --> <!-- Conteúdo das Abas -->
@@ -1188,6 +1332,239 @@ import { resolve } from '$app/paths';
</div> </div>
{/if} {/if}
</div> </div>
{:else if abaAtiva === 'meus-chamados'}
<!-- Meus Chamados -->
<div class="space-y-6">
<section class="border-base-200 bg-base-100/90 rounded-3xl border p-8 shadow-xl">
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<p class="text-primary text-xs tracking-[0.25em] uppercase">Meu Perfil</p>
<h1 class="text-base-content text-3xl font-black">Meus Chamados</h1>
<p class="text-base-content/70 mt-2 text-sm">
Acompanhe o status, interaja com a equipe de TI e visualize a timeline de SLA em
tempo real.
</p>
</div>
<div class="flex flex-wrap gap-3">
<a href={resolve('/abrir-chamado')} class="btn btn-primary">Abrir novo chamado</a>
<button class="btn btn-ghost" type="button" onclick={recarregarChamados}>
Atualizar
</button>
</div>
</div>
</section>
<div class="grid gap-6 lg:grid-cols-[340px,1fr]">
<aside class="border-base-200 bg-base-100/80 rounded-3xl border p-4 shadow-lg">
<div class="flex items-center justify-between">
<h2 class="text-base-content text-lg font-semibold">Meus tickets</h2>
{#if carregandoListaChamados}
<span class="loading loading-spinner loading-xs"></span>
{/if}
</div>
<div class="mt-4 space-y-3">
<select
class="select select-sm select-bordered w-full"
bind:value={filtroStatusChamados}
>
<option value="todos">Todos os status</option>
<option value="aberto">Aberto</option>
<option value="em_andamento">Em andamento</option>
<option value="aguardando_usuario">Aguardando usuário</option>
<option value="resolvido">Resolvido</option>
<option value="encerrado">Encerrado</option>
<option value="cancelado">Cancelado</option>
</select>
<select
class="select select-sm select-bordered w-full"
bind:value={filtroTipoChamados}
>
<option value="todos">Todos os tipos</option>
<option value="chamado">Chamados técnicos</option>
<option value="reclamacao">Reclamações</option>
<option value="elogio">Elogios</option>
<option value="sugestao">Sugestões</option>
</select>
</div>
<div
class="mt-6 space-y-3 overflow-y-auto pr-1"
style="max-height: calc(100vh - 260px);"
>
{#if ticketsFiltrados.length === 0}
<div class="alert alert-info">
<span>Nenhum chamado encontrado.</span>
</div>
{:else}
{#each ticketsFiltrados as ticket (ticket._id)}
<TicketCard
{ticket}
selected={ticket._id === selectedTicketId}
on:select={({ detail }) => selecionarChamado(detail.ticketId)}
/>
{/each}
{/if}
</div>
</aside>
<section class="border-base-200 bg-base-100/90 rounded-3xl border p-6 shadow-xl">
{#if !selectedTicketId || !detalheAtualChamado}
<div class="text-base-content/60 flex min-h-[400px] items-center justify-center">
{#if carregandoDetalheChamado}
<span class="loading loading-spinner loading-lg"></span>
{:else}
<p>Selecione um chamado para visualizar os detalhes.</p>
{/if}
</div>
{:else}
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div>
<p class="text-base-content/60 text-xs uppercase">
Ticket {detalheAtualChamado.ticket.numero}
</p>
<h2 class="text-base-content text-2xl font-bold">
{detalheAtualChamado.ticket.titulo}
</h2>
<p class="text-base-content/70 mt-1 text-sm">
{detalheAtualChamado.ticket.descricao}
</p>
</div>
<span class={getStatusBadgeChamado(detalheAtualChamado.ticket.status)}>
{getStatusLabel(detalheAtualChamado.ticket.status)}
</span>
</div>
<div class="text-base-content/70 mt-4 flex flex-wrap gap-3 text-sm">
<span class="badge badge-outline badge-sm">
Tipo: {detalheAtualChamado.ticket.tipo.charAt(0).toUpperCase() +
detalheAtualChamado.ticket.tipo.slice(1)}
</span>
<span class="badge badge-outline badge-sm">
Prioridade: {detalheAtualChamado.ticket.prioridade}
</span>
<span class="badge badge-outline badge-sm">
Última interação: {formatarData(detalheAtualChamado.ticket.ultimaInteracaoEm)}
</span>
</div>
{#if statusAlertasChamado(detalheAtualChamado.ticket).length > 0}
<div class="mt-4 space-y-2">
{#each statusAlertasChamado(detalheAtualChamado.ticket) as alerta (alerta.label)}
<div
class={`alert ${
alerta.tipo === 'error'
? 'alert-error'
: alerta.tipo === 'warning'
? 'alert-warning'
: 'alert-success'
}`}
>
<span>{alerta.label}</span>
</div>
{/each}
</div>
{/if}
<div class="mt-6 grid gap-6 lg:grid-cols-2">
<div class="border-base-200 bg-base-100/80 rounded-2xl border p-4">
<h3 class="text-base-content font-semibold">Timeline e SLA</h3>
<p class="text-base-content/60 text-xs">
Etapas monitoradas com indicadores de prazo.
</p>
<div class="mt-4">
<TicketTimeline timeline={detalheAtualChamado.ticket.timeline ?? []} />
</div>
</div>
<div class="border-base-200 bg-base-100/80 rounded-2xl border p-4">
<h3 class="text-base-content font-semibold">Responsabilidade</h3>
<p class="text-base-content/60 text-sm">
{detalheAtualChamado.ticket.responsavelId
? `Responsável: ${detalheAtualChamado.ticket.setorResponsavel ?? 'Equipe TI'}`
: 'Aguardando atribuição'}
</p>
<div class="text-base-content/70 mt-4 space-y-2 text-sm">
<p>
Prazo resposta: {prazoRestante(detalheAtualChamado.ticket.prazoResposta) ??
'--'}
</p>
<p>
Prazo conclusão: {prazoRestante(
detalheAtualChamado.ticket.prazoConclusao
) ?? '--'}
</p>
<p>
Prazo encerramento: {prazoRestante(
detalheAtualChamado.ticket.prazoEncerramento
) ?? '--'}
</p>
</div>
<p class="text-base-content/50 mt-2 text-xs">
{getStatusDescription(detalheAtualChamado.ticket.status)}
</p>
</div>
</div>
<div class="mt-8 grid gap-6 lg:grid-cols-2">
<div class="border-base-200 bg-base-100/70 rounded-2xl border p-4">
<h3 class="text-base-content font-semibold">Interações</h3>
<div class="mt-4 max-h-[360px] space-y-3 overflow-y-auto pr-2">
{#if detalheAtualChamado.interactions.length === 0}
<p class="text-base-content/60 text-sm">
Nenhuma interação registrada ainda.
</p>
{:else}
{#each detalheAtualChamado.interactions as interacao (interacao._id)}
<div class="border-base-200 bg-base-100/90 rounded-2xl border p-3">
<div
class="text-base-content/60 flex items-center justify-between text-xs"
>
<span
>{interacao.origem === 'usuario' ? 'Você' : interacao.origem}</span
>
<span>{formatarData(interacao.criadoEm)}</span>
</div>
<p class="text-base-content mt-2 text-sm whitespace-pre-wrap">
{interacao.conteudo}
</p>
{#if interacao.statusNovo && interacao.statusNovo !== interacao.statusAnterior}
<span class="badge badge-xs badge-outline mt-2">
Status: {getStatusLabel(interacao.statusNovo)}
</span>
{/if}
</div>
{/each}
{/if}
</div>
</div>
<div class="border-base-200 bg-base-100/70 rounded-2xl border p-4">
<h3 class="text-base-content font-semibold">Enviar atualização</h3>
<textarea
class="textarea textarea-bordered mt-3 min-h-[140px] w-full"
placeholder="Compartilhe informações adicionais, aprovações ou anexos enviados por outros canais."
bind:value={mensagemChamado}
></textarea>
{#if erroMensagemChamado}
<p class="text-error mt-2 text-sm">{erroMensagemChamado}</p>
{/if}
{#if sucessoMensagemChamado}
<p class="text-success mt-2 text-sm">{sucessoMensagemChamado}</p>
{/if}
<button
type="button"
class="btn btn-primary mt-3 w-full"
onclick={enviarMensagemChamado}
>
Enviar
</button>
</div>
</div>
{/if}
</section>
</div>
</div>
{:else if abaAtiva === 'minhas-ferias'} {:else if abaAtiva === 'minhas-ferias'}
<!-- Minhas Férias --> <!-- Minhas Férias -->
<div class="space-y-6"> <div class="space-y-6">
@@ -2332,7 +2709,14 @@ import { resolve } from '$app/paths';
> >
<div <div
class="modal-box flex max-h-[90vh] max-w-4xl flex-col p-0" class="modal-box flex max-h-[90vh] max-w-4xl flex-col p-0"
role="presentation"
tabindex="-1"
onclick={(e) => e.stopPropagation()} onclick={(e) => e.stopPropagation()}
onkeydown={(e) => {
if (e.key === 'Escape') {
mostrarWizardAusencia = false;
}
}}
> >
<!-- Header --> <!-- Header -->
<div class="border-base-300 flex items-center justify-between border-b px-6 py-4"> <div class="border-base-300 flex items-center justify-between border-b px-6 py-4">

View File

@@ -15,9 +15,9 @@ import type * as actions_smtp from "../actions/smtp.js";
import type * as actions_utils_nodeCrypto from "../actions/utils/nodeCrypto.js"; import type * as actions_utils_nodeCrypto from "../actions/utils/nodeCrypto.js";
import type * as atestadosLicencas from "../atestadosLicencas.js"; import type * as atestadosLicencas from "../atestadosLicencas.js";
import type * as ausencias from "../ausencias.js"; import type * as ausencias from "../ausencias.js";
import type * as auth from "../auth.js";
import type * as auth_utils from "../auth/utils.js"; import type * as auth_utils from "../auth/utils.js";
import type * as chamados from "../chamados.js"; import type * as chamados from "../chamados.js";
import type * as auth from "../auth.js";
import type * as chat from "../chat.js"; import type * as chat from "../chat.js";
import type * as configuracaoEmail from "../configuracaoEmail.js"; import type * as configuracaoEmail from "../configuracaoEmail.js";
import type * as crons from "../crons.js"; import type * as crons from "../crons.js";
@@ -55,14 +55,6 @@ import type {
FunctionReference, FunctionReference,
} from "convex/server"; } from "convex/server";
/**
* A utility for referencing Convex functions in your app's API.
*
* Usage:
* ```js
* const myFunctionReference = api.myModule.myFunction;
* ```
*/
declare const fullApi: ApiFromModules<{ declare const fullApi: ApiFromModules<{
"actions/email": typeof actions_email; "actions/email": typeof actions_email;
"actions/linkPreview": typeof actions_linkPreview; "actions/linkPreview": typeof actions_linkPreview;
@@ -71,9 +63,9 @@ declare const fullApi: ApiFromModules<{
"actions/utils/nodeCrypto": typeof actions_utils_nodeCrypto; "actions/utils/nodeCrypto": typeof actions_utils_nodeCrypto;
atestadosLicencas: typeof atestadosLicencas; atestadosLicencas: typeof atestadosLicencas;
ausencias: typeof ausencias; ausencias: typeof ausencias;
auth: typeof auth;
"auth/utils": typeof auth_utils; "auth/utils": typeof auth_utils;
chamados: typeof chamados; chamados: typeof chamados;
auth: typeof auth;
chat: typeof chat; chat: typeof chat;
configuracaoEmail: typeof configuracaoEmail; configuracaoEmail: typeof configuracaoEmail;
crons: typeof crons; crons: typeof crons;
@@ -105,14 +97,30 @@ declare const fullApi: ApiFromModules<{
"utils/getClientIP": typeof utils_getClientIP; "utils/getClientIP": typeof utils_getClientIP;
verificarMatriculas: typeof verificarMatriculas; verificarMatriculas: typeof verificarMatriculas;
}>; }>;
declare const fullApiWithMounts: typeof fullApi;
/**
* A utility for referencing Convex functions in your app's public API.
*
* Usage:
* ```js
* const myFunctionReference = api.myModule.myFunction;
* ```
*/
export declare const api: FilterApi< export declare const api: FilterApi<
typeof fullApiWithMounts, typeof fullApi,
FunctionReference<any, "public"> FunctionReference<any, "public">
>; >;
/**
* A utility for referencing Convex functions in your app's internal API.
*
* Usage:
* ```js
* const myFunctionReference = internal.myModule.myFunction;
* ```
*/
export declare const internal: FilterApi< export declare const internal: FilterApi<
typeof fullApiWithMounts, typeof fullApi,
FunctionReference<any, "internal"> FunctionReference<any, "internal">
>; >;

View File

@@ -10,7 +10,6 @@
import { import {
ActionBuilder, ActionBuilder,
AnyComponents,
HttpActionBuilder, HttpActionBuilder,
MutationBuilder, MutationBuilder,
QueryBuilder, QueryBuilder,
@@ -19,15 +18,9 @@ import {
GenericQueryCtx, GenericQueryCtx,
GenericDatabaseReader, GenericDatabaseReader,
GenericDatabaseWriter, GenericDatabaseWriter,
FunctionReference,
} from "convex/server"; } from "convex/server";
import type { DataModel } from "./dataModel.js"; import type { DataModel } from "./dataModel.js";
type GenericCtx =
| GenericActionCtx<DataModel>
| GenericMutationCtx<DataModel>
| GenericQueryCtx<DataModel>;
/** /**
* Define a query in this Convex app's public API. * Define a query in this Convex app's public API.
* *
@@ -92,11 +85,12 @@ export declare const internalAction: ActionBuilder<DataModel, "internal">;
/** /**
* Define an HTTP action. * Define an HTTP action.
* *
* This function will be used to respond to HTTP requests received by a Convex * The wrapped function will be used to respond to HTTP requests received
* deployment if the requests matches the path and method where this action * by a Convex deployment if the requests matches the path and method where
* is routed. Be sure to route your action in `convex/http.js`. * this action is routed. Be sure to route your httpAction in `convex/http.js`.
* *
* @param func - The function. It receives an {@link ActionCtx} as its first argument. * @param func - The function. It receives an {@link ActionCtx} as its first argument
* and a Fetch API `Request` object as its second.
* @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up. * @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up.
*/ */
export declare const httpAction: HttpActionBuilder; export declare const httpAction: HttpActionBuilder;

View File

@@ -16,7 +16,6 @@ import {
internalActionGeneric, internalActionGeneric,
internalMutationGeneric, internalMutationGeneric,
internalQueryGeneric, internalQueryGeneric,
componentsGeneric,
} from "convex/server"; } from "convex/server";
/** /**
@@ -81,10 +80,14 @@ export const action = actionGeneric;
export const internalAction = internalActionGeneric; export const internalAction = internalActionGeneric;
/** /**
* Define a Convex HTTP action. * Define an HTTP action.
* *
* @param func - The function. It receives an {@link ActionCtx} as its first argument, and a `Request` object * The wrapped function will be used to respond to HTTP requests received
* as its second. * by a Convex deployment if the requests matches the path and method where
* @returns The wrapped endpoint function. Route a URL path to this function in `convex/http.js`. * this action is routed. Be sure to route your httpAction in `convex/http.js`.
*
* @param func - The function. It receives an {@link ActionCtx} as its first argument
* and a Fetch API `Request` object as its second.
* @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up.
*/ */
export const httpAction = httpActionGeneric; export const httpAction = httpActionGeneric;