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 { 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({
|
||||
// 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()],
|
||||
});
|
||||
|
||||
@@ -20,26 +20,32 @@
|
||||
|
||||
let isChecking = $state(true);
|
||||
let hasAccess = $state(false);
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
const currentUser = useQuery(api.auth.getCurrentUser, {});
|
||||
|
||||
onMount(() => {
|
||||
// Usar $effect para reagir às mudanças na query
|
||||
$effect(() => {
|
||||
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
|
||||
setTimeout(() => {
|
||||
// Verificar autenticação
|
||||
if (requireAuth && !currentUser?.data) {
|
||||
const currentPath = window.location.pathname;
|
||||
window.location.href = `${redirectTo}?error=auth_required&redirect=${encodeURIComponent(currentPath)}`;
|
||||
return;
|
||||
}
|
||||
// Se a query ainda está carregando (undefined), aguardar
|
||||
if (currentUser === undefined) {
|
||||
isChecking = true;
|
||||
hasAccess = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Se a query retornou dados, verificar autenticação
|
||||
if (currentUser?.data) {
|
||||
// Verificar roles
|
||||
if (allowedRoles.length > 0 && currentUser?.data) {
|
||||
if (allowedRoles.length > 0) {
|
||||
const hasRole = allowedRoles.includes(currentUser.data.role?.nome ?? '');
|
||||
if (!hasRole) {
|
||||
const currentPath = window.location.pathname;
|
||||
@@ -49,19 +55,40 @@
|
||||
}
|
||||
|
||||
// Verificar nível
|
||||
if (
|
||||
currentUser?.data &&
|
||||
currentUser.data.role?.nivel &&
|
||||
currentUser.data.role.nivel > maxLevel
|
||||
) {
|
||||
if (currentUser.data.role?.nivel && currentUser.data.role.nivel > maxLevel) {
|
||||
const currentPath = window.location.pathname;
|
||||
window.location.href = `${redirectTo}?error=access_denied&route=${encodeURIComponent(currentPath)}`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Se chegou aqui, permitir acesso
|
||||
hasAccess = true;
|
||||
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>
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { useConvexClient, useQuery } from 'convex-svelte';
|
||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||
import { resolve } from '$app/paths';
|
||||
import { useConvexClient, useQuery } from 'convex-svelte';
|
||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||
import { resolve } from '$app/paths';
|
||||
import AprovarFerias from '$lib/components/AprovarFerias.svelte';
|
||||
import WizardSolicitacaoFerias from '$lib/components/ferias/WizardSolicitacaoFerias.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 { FunctionReturnType } from 'convex/server';
|
||||
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();
|
||||
// @ts-expect-error - Convex types issue with getCurrentUser
|
||||
const currentUser = useQuery(api.auth.getCurrentUser, {});
|
||||
|
||||
type FuncionarioAtual = FunctionReturnType<typeof api.funcionarios.getCurrent>;
|
||||
@@ -26,7 +39,12 @@ import { resolve } from '$app/paths';
|
||||
let minhasAusenciasEstaveis = $state<MinhasAusencias>([]);
|
||||
|
||||
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');
|
||||
|
||||
let periodoSelecionado = $state<Id<'ferias'> | null>(null);
|
||||
@@ -49,6 +67,17 @@ import { resolve } from '$app/paths';
|
||||
// Estados para Aprovar Ausências (Gestores)
|
||||
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)
|
||||
const avatarGallery = generateAvatarGallery(30);
|
||||
|
||||
@@ -151,6 +180,123 @@ import { resolve } from '$app/paths';
|
||||
|
||||
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
|
||||
const solicitacoesFiltradas = $derived(
|
||||
minhasSolicitacoes.filter((s) => {
|
||||
@@ -378,7 +524,6 @@ import { resolve } from '$app/paths';
|
||||
onmouseenter={() => (mostrarBotaoCamera = true)}
|
||||
onmouseleave={() => (mostrarBotaoCamera = false)}
|
||||
>
|
||||
s
|
||||
<button
|
||||
type="button"
|
||||
class="avatar cursor-pointer border-0 bg-transparent p-0"
|
||||
@@ -560,35 +705,12 @@ import { resolve } from '$app/paths';
|
||||
Meu Perfil
|
||||
</button>
|
||||
|
||||
<a
|
||||
role="tab"
|
||||
href={resolve('/perfil/chamados')}
|
||||
class="tab tab-lg font-semibold transition-all duration-300 hover:bg-base-100"
|
||||
aria-label="Meus Chamados"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="mr-2 h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M3 7h18M3 12h12M3 17h18"
|
||||
/>
|
||||
</svg>
|
||||
Meus Chamados
|
||||
</a>
|
||||
|
||||
{#if ehGestor}
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
class={`tab tab-lg font-semibold transition-all duration-300 ${abaAtiva === 'minhas-ferias' ? 'tab-active scale-105 bg-linear-to-r from-purple-600 to-blue-600 text-white shadow-lg' : 'hover:bg-base-100'}`}
|
||||
onclick={() => (abaAtiva = 'minhas-ferias')}
|
||||
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'}`}
|
||||
onclick={() => (abaAtiva = 'meus-chamados')}
|
||||
aria-label="Meus Chamados"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -598,44 +720,17 @@ import { resolve } from '$app/paths';
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 7h18M3 12h12M3 17h18" />
|
||||
</svg>
|
||||
Minhas Férias
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
class={`tab tab-lg font-semibold transition-all duration-300 ${abaAtiva === 'minhas-ausencias' ? 'tab-active scale-105 bg-linear-to-r from-orange-600 to-amber-600 text-white shadow-lg' : 'hover:bg-base-100'}`}
|
||||
onclick={() => (abaAtiva = 'minhas-ausencias')}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="mr-2 h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
Minhas Ausências
|
||||
Meus Chamados
|
||||
</button>
|
||||
|
||||
{#if ehGestor}
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
class={`tab tab-lg font-semibold transition-all duration-300 ${abaAtiva === 'aprovar-ferias' ? 'tab-active scale-105 bg-linear-to-r from-purple-600 to-blue-600 text-white shadow-lg' : 'hover:bg-base-100'}`}
|
||||
onclick={() => (abaAtiva = 'aprovar-ferias')}
|
||||
class={`tab tab-lg font-semibold transition-all duration-300 ${abaAtiva === 'minhas-ferias' ? 'tab-active scale-105 bg-linear-to-r from-purple-600 to-blue-600 text-white shadow-lg' : 'hover:bg-base-100'}`}
|
||||
onclick={() => (abaAtiva = 'minhas-ferias')}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -648,23 +743,17 @@ import { resolve } from '$app/paths';
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
Aprovar Férias
|
||||
{#if (solicitacoesSubordinados || []).filter((s) => s.status === 'aguardando_aprovacao').length > 0}
|
||||
<span class="badge badge-error badge-sm ml-2 animate-pulse">
|
||||
{(solicitacoesSubordinados || []).filter((s) => s.status === 'aguardando_aprovacao')
|
||||
.length}
|
||||
</span>
|
||||
{/if}
|
||||
Minhas Férias
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
class={`tab tab-lg font-semibold transition-all duration-300 ${abaAtiva === 'aprovar-ausencias' ? 'tab-active scale-105 bg-linear-to-r from-orange-600 to-amber-600 text-white shadow-lg' : 'hover:bg-base-100'}`}
|
||||
onclick={() => (abaAtiva = 'aprovar-ausencias')}
|
||||
class={`tab tab-lg font-semibold transition-all duration-300 ${abaAtiva === 'minhas-ausencias' ? 'tab-active scale-105 bg-linear-to-r from-orange-600 to-amber-600 text-white shadow-lg' : 'hover:bg-base-100'}`}
|
||||
onclick={() => (abaAtiva = 'minhas-ausencias')}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -680,14 +769,69 @@ import { resolve } from '$app/paths';
|
||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
Aprovar Ausências
|
||||
{#if (ausenciasSubordinados || []).filter((a) => a.status === 'aguardando_aprovacao').length > 0}
|
||||
<span class="badge badge-error badge-sm ml-2 animate-pulse">
|
||||
{(ausenciasSubordinados || []).filter((a) => a.status === 'aguardando_aprovacao')
|
||||
.length}
|
||||
</span>
|
||||
{/if}
|
||||
Minhas Ausências
|
||||
</button>
|
||||
|
||||
{#if ehGestor}
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
class={`tab tab-lg font-semibold transition-all duration-300 ${abaAtiva === 'aprovar-ferias' ? 'tab-active scale-105 bg-linear-to-r from-purple-600 to-blue-600 text-white shadow-lg' : 'hover:bg-base-100'}`}
|
||||
onclick={() => (abaAtiva = 'aprovar-ferias')}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="mr-2 h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
Aprovar Férias
|
||||
{#if (solicitacoesSubordinados || []).filter((s) => s.status === 'aguardando_aprovacao').length > 0}
|
||||
<span class="badge badge-error badge-sm ml-2 animate-pulse">
|
||||
{(solicitacoesSubordinados || []).filter(
|
||||
(s) => s.status === 'aguardando_aprovacao'
|
||||
).length}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
class={`tab tab-lg font-semibold transition-all duration-300 ${abaAtiva === 'aprovar-ausencias' ? 'tab-active scale-105 bg-linear-to-r from-orange-600 to-amber-600 text-white shadow-lg' : 'hover:bg-base-100'}`}
|
||||
onclick={() => (abaAtiva = 'aprovar-ausencias')}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="mr-2 h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
Aprovar Ausências
|
||||
{#if (ausenciasSubordinados || []).filter((a) => a.status === 'aguardando_aprovacao').length > 0}
|
||||
<span class="badge badge-error badge-sm ml-2 animate-pulse">
|
||||
{(ausenciasSubordinados || []).filter((a) => a.status === 'aguardando_aprovacao')
|
||||
.length}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1188,6 +1332,239 @@ import { resolve } from '$app/paths';
|
||||
</div>
|
||||
{/if}
|
||||
</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'}
|
||||
<!-- Minhas Férias -->
|
||||
<div class="space-y-6">
|
||||
@@ -2332,7 +2709,14 @@ import { resolve } from '$app/paths';
|
||||
>
|
||||
<div
|
||||
class="modal-box flex max-h-[90vh] max-w-4xl flex-col p-0"
|
||||
role="presentation"
|
||||
tabindex="-1"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
mostrarWizardAusencia = false;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="border-base-300 flex items-center justify-between border-b px-6 py-4">
|
||||
|
||||
Reference in New Issue
Block a user