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:
@@ -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()],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
34
packages/backend/convex/_generated/api.d.ts
vendored
34
packages/backend/convex/_generated/api.d.ts
vendored
@@ -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">
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
|||||||
16
packages/backend/convex/_generated/server.d.ts
vendored
16
packages/backend/convex/_generated/server.d.ts
vendored
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user