feat: enhance SLA management and authentication handling

- Updated the useConvexWithAuth hook to improve token management and logging for better debugging.
- Integrated automatic authentication handling in the central-chamados route, ensuring seamless user experience.
- Added a new mutation for migrating old SLA configurations to include a priority field, enhancing data consistency.
- Improved the display of SLA configurations in the UI, including detailed views and migration feedback for better user interaction.
- Refactored ticket loading logic to enrich ticket data with responsible user names, improving clarity in ticket management.
This commit is contained in:
2025-11-17 08:44:18 -03:00
parent fb784d6f7e
commit 5ef6ef8550
5 changed files with 613 additions and 70 deletions

View File

@@ -1,5 +1,4 @@
<script lang="ts">
import { onMount } from "svelte";
import { useConvexClient, useQuery } from "convex-svelte";
import { api } from "@sgse-app/backend/convex/_generated/api";
import type { Doc, Id } from "@sgse-app/backend/convex/_generated/dataModel";
@@ -11,6 +10,7 @@
prazoRestante,
} from "$lib/utils/chamados";
import { useConvexWithAuth } from "$lib/hooks/useConvexWithAuth";
import { authStore } from "$lib/stores/auth.svelte";
type Ticket = Doc<"tickets">;
type Usuario = Doc<"usuarios">;
@@ -18,20 +18,26 @@
type Template = Doc<"templatesMensagens">;
const client = useConvexClient();
// createSvelteAuthClient gerencia autenticação automaticamente
// Não precisamos verificar token manualmente, apenas garantir que useConvexWithAuth seja chamado
$effect(() => {
// Sempre chamar useConvexWithAuth para garantir que autenticação está configurada
useConvexWithAuth();
});
// Queries - executar normalmente, o createSvelteAuthClient no layout gerencia autenticação
const usuariosQuery = useQuery(api.usuarios.listar, {});
const slaConfigsQuery = useQuery(api.chamados.listarSlaConfigs, {});
const templatesQuery = useQuery(api.templatesMensagens.listarTemplates, {});
// Extrair dados dos templates
const templates = $derived.by(() => {
if (!templatesQuery || templatesQuery === undefined || templatesQuery === null) {
// useQuery retorna undefined enquanto carrega, depois retorna os dados diretamente
if (templatesQuery === undefined || templatesQuery === null) {
return [];
}
// Se tem propriedade data, usar os dados
if ('data' in templatesQuery && templatesQuery.data !== undefined) {
return Array.isArray(templatesQuery.data) ? templatesQuery.data : [];
}
// Se templatesQuery é diretamente um array (caso não tenha .data)
// useQuery retorna os dados diretamente (não em .data)
if (Array.isArray(templatesQuery)) {
return templatesQuery;
}
@@ -39,22 +45,11 @@
});
const carregandoTemplates = $derived.by(() => {
if (!templatesQuery || templatesQuery === undefined || templatesQuery === null) {
return true;
}
if (typeof templatesQuery === 'object' && Object.keys(templatesQuery).length === 0) {
return true;
}
if (!('data' in templatesQuery)) {
return true;
}
if (templatesQuery.data === undefined) {
return true;
}
return false;
// useQuery retorna undefined enquanto carrega
return templatesQuery === undefined || templatesQuery === null;
});
const templatesChamados = $derived(() => {
const templatesChamados = $derived.by(() => {
return templates.filter((t: Template) => {
if (!t.codigo) return false;
return typeof t.codigo === 'string' && t.codigo.startsWith("chamado_");
@@ -107,20 +102,27 @@
let prorrogacaoFeedback = $state<string | null>(null);
let criandoTemplates = $state(false);
let templatesFeedback = $state<string | null>(null);
let migrandoSLAs = $state(false);
let migracaoFeedback = $state<string | null>(null);
let carregamentoToken = 0;
// Carregar chamados quando filtros mudarem
$effect(() => {
const filtros = {
status: filtroStatus === "todos" ? undefined : filtroStatus,
responsavelId: filtroResponsavel === "todos" ? undefined : filtroResponsavel,
setor: filtroSetor === "todos" ? undefined : filtroSetor,
};
carregarChamados(filtros);
});
onMount(() => {
// Configura token no cliente Convex
useConvexWithAuth();
// Pequeno delay para garantir que autenticação está configurada
const timeoutId = setTimeout(() => {
const filtros = {
status: filtroStatus === "todos" ? undefined : filtroStatus,
responsavelId: filtroResponsavel === "todos" ? undefined : filtroResponsavel,
setor: filtroSetor === "todos" ? undefined : filtroSetor,
};
if (import.meta.env.DEV) {
console.log("🚀 [effect] Carregando chamados com filtros:", filtros);
}
carregarChamados(filtros);
}, 200);
return () => clearTimeout(timeoutId);
});
async function carregarChamados(filtros: {
@@ -131,18 +133,43 @@
try {
carregandoChamados = true;
const token = ++carregamentoToken;
if (import.meta.env.DEV) {
console.log("🔍 [carregarChamados] Executando query com filtros:", filtros);
}
// createSvelteAuthClient gerencia autenticação automaticamente
const data = await client.query(api.chamados.listarChamadosTI, {
status: filtros.status,
responsavelId: filtros.responsavelId,
setor: filtros.setor,
});
if (token !== carregamentoToken) return;
if (token !== carregamentoToken) {
if (import.meta.env.DEV) {
console.log("🔍 [carregarChamados] Query cancelada (nova requisição iniciada)");
}
return;
}
if (import.meta.env.DEV) {
console.log("✅ [carregarChamados] Query executada com sucesso. Chamados retornados:", data?.length ?? 0);
}
tickets = data ?? [];
if (!ticketSelecionado && tickets.length > 0) {
selecionarTicket(tickets[0]._id);
}
} catch (error) {
console.error("Erro ao carregar chamados:", error);
console.error("❌ [carregarChamados] Erro ao carregar chamados:", error);
// Se erro de autenticação, tentar novamente após um pequeno delay
if (error instanceof Error && (error.message.includes("autenticado") || error.message.includes("authentication"))) {
console.warn("⚠️ [carregarChamados] Erro de autenticação detectado, tentando novamente...");
setTimeout(() => {
carregarChamados(filtros);
}, 1000);
}
} finally {
carregandoChamados = false;
}
@@ -153,9 +180,61 @@
detalheSelecionado = tickets.find((t) => t._id === ticketId) ?? null;
}
const usuariosTI = $derived(() => {
if (!usuariosQuery?.data) return [];
return usuariosQuery.data.filter((usuario: Usuario) => usuario.setor === "TI");
const usuariosTI = $derived.by(() => {
// useQuery retorna undefined enquanto carrega, depois retorna os dados diretamente
if (usuariosQuery === undefined || usuariosQuery === null) {
if (import.meta.env.DEV) {
console.log("🔍 [usuariosTI] Query ainda carregando...", usuariosQuery);
}
return [];
}
// Verificar se é um objeto com propriedade data (como em outros lugares do código)
let usuarios: any[] = [];
if (typeof usuariosQuery === 'object' && usuariosQuery !== null) {
if ('data' in usuariosQuery && Array.isArray(usuariosQuery.data)) {
usuarios = usuariosQuery.data;
} else if (Array.isArray(usuariosQuery)) {
usuarios = usuariosQuery;
} else {
if (import.meta.env.DEV) {
console.log("🔍 [usuariosTI] Formato inesperado:", typeof usuariosQuery, usuariosQuery);
}
return [];
}
} else if (Array.isArray(usuariosQuery)) {
usuarios = usuariosQuery;
} else {
if (import.meta.env.DEV) {
console.log("🔍 [usuariosTI] Tipo inesperado:", typeof usuariosQuery, usuariosQuery);
}
return [];
}
if (usuarios.length === 0) {
if (import.meta.env.DEV) {
console.log("🔍 [usuariosTI] Nenhum usuário retornado");
}
return [];
}
const usuariosFiltrados = usuarios.filter((usuario: any) => {
// Verificar se o usuário tem setor "TI" no role (case-insensitive)
const setor = usuario.role?.setor;
const temSetorTI = setor && setor.toUpperCase() === "TI";
if (import.meta.env.DEV && temSetorTI) {
console.log("✅ [usuariosTI] Usuário TI encontrado:", usuario.nome, usuario.role?.setor);
}
// Log para debug: mostrar todos os setores encontrados
if (import.meta.env.DEV && setor) {
console.log("🔍 [usuariosTI] Usuário:", usuario.nome, "Setor:", setor);
}
return temSetorTI;
});
if (import.meta.env.DEV) {
console.log("📊 [usuariosTI] Total de usuários:", usuarios.length, "Usuários TI:", usuariosFiltrados.length);
}
return usuariosFiltrados;
});
const estatisticas = $derived(() => {
@@ -182,11 +261,11 @@
};
}
function novoSla() {
function novoSla(prioridade?: "baixa" | "media" | "alta" | "critica") {
slaForm = {
nome: "",
descricao: "",
prioridade: "media",
prioridade: prioridade || slaForm.prioridade || "media",
tempoRespostaHoras: 4,
tempoConclusaoHoras: 24,
tempoEncerramentoHoras: 72,
@@ -194,6 +273,7 @@
ativo: true,
};
slaFeedback = null;
slaParaExcluir = null;
}
async function salvarSlaConfig() {
@@ -249,8 +329,21 @@
}
}
const slaConfigsPorPrioridade = $derived(() => {
const slaConfigs = slaConfigsQuery?.data || [];
const slaConfigsPorPrioridade = $derived.by(() => {
// useQuery retorna um objeto com propriedade .data
if (slaConfigsQuery === undefined || slaConfigsQuery === null) {
return {
baixa: undefined,
media: undefined,
alta: undefined,
critica: undefined,
};
}
// Verificar se tem propriedade data
const slaConfigs = ('data' in slaConfigsQuery && slaConfigsQuery.data !== undefined)
? (Array.isArray(slaConfigsQuery.data) ? slaConfigsQuery.data : [])
: (Array.isArray(slaConfigsQuery) ? slaConfigsQuery : []);
return {
baixa: slaConfigs.find((s: SlaConfig) => s.prioridade === "baixa" && s.ativo),
media: slaConfigs.find((s: SlaConfig) => s.prioridade === "media" && s.ativo),
@@ -345,12 +438,30 @@
}
}
// Debug: ver templates carregados
$effect(() => {
console.log("templatesQuery:", templatesQuery);
console.log("Templates extraídos:", templates);
console.log("Templates de chamados:", templatesChamados);
});
async function migrarSlaConfigs() {
try {
migrandoSLAs = true;
migracaoFeedback = null;
const resultado = await client.mutation(api.chamados.migrarSlaConfigs, {});
migracaoFeedback = `Migração concluída: ${resultado?.migrados || 0} SLA(s) migrado(s) de ${resultado?.total || 0} total`;
// Recarregar SLAs após migração
await new Promise((resolve) => setTimeout(resolve, 1000));
// Recarregar página para atualizar dados
window.location.reload();
} catch (error) {
console.error("Erro ao migrar SLAs:", error);
migracaoFeedback = error instanceof Error ? error.message : "Erro ao migrar SLAs";
} finally {
migrandoSLAs = false;
}
}
// Debug: ver templates carregados (remover em produção)
// $effect(() => {
// console.log("templatesQuery:", templatesQuery);
// console.log("Templates extraídos:", templates);
// console.log("Templates de chamados:", templatesChamados);
// });
</script>
<main class="mx-auto w-full max-w-7xl space-y-8 px-4 py-8">
@@ -447,7 +558,7 @@
<td>
<span class={getStatusBadge(ticket.status)}>{getStatusLabel(ticket.status)}</span>
</td>
<td class="text-sm">{ticket.setorResponsavel ?? "—"}</td>
<td class="text-sm">{(ticket as any).responsavelNome ?? ticket.setorResponsavel ?? "—"}</td>
<td class="text-sm capitalize">{ticket.prioridade}</td>
<td class="text-xs text-base-content/70">
{ticket.prazoConclusao ? prazoRestante(ticket.prazoConclusao) : "--"}
@@ -592,6 +703,167 @@
</div>
</section>
<!-- Seção: SLAs Existentes - Visualização Detalhada -->
<section class="rounded-3xl border border-base-200 bg-base-100/80 p-6 shadow-xl">
<div class="mb-6 flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<h3 class="text-lg font-semibold text-base-content">SLAs Configurados</h3>
<p class="text-sm text-base-content/60">Visualize todos os SLAs ativos com seus tempos e configurações</p>
</div>
</div>
{#if slaConfigsQuery === undefined || slaConfigsQuery === null || ('data' in slaConfigsQuery && slaConfigsQuery.data === undefined)}
<div class="flex justify-center py-8">
<span class="loading loading-spinner loading-md"></span>
</div>
{:else}
{@const slaConfigs = ('data' in slaConfigsQuery && slaConfigsQuery.data !== undefined)
? (Array.isArray(slaConfigsQuery.data) ? slaConfigsQuery.data : [])
: (Array.isArray(slaConfigsQuery) ? slaConfigsQuery : [])}
{@const slaConfigsAtivos = slaConfigs.filter((s: SlaConfig) => s.ativo)}
{@const slaConfigsPorPrioridadeCount = {
baixa: slaConfigsAtivos.filter((s: SlaConfig) => s.prioridade === 'baixa').length,
media: slaConfigsAtivos.filter((s: SlaConfig) => s.prioridade === 'media').length,
alta: slaConfigsAtivos.filter((s: SlaConfig) => s.prioridade === 'alta').length,
critica: slaConfigsAtivos.filter((s: SlaConfig) => s.prioridade === 'critica').length,
}}
{#if slaConfigsAtivos.length === 0}
<div class="rounded-2xl border border-base-300 bg-base-200/50 p-8 text-center">
<p class="text-sm font-semibold text-base-content/70">Nenhum SLA configurado</p>
<p class="mt-2 text-xs text-base-content/50">Configure SLAs para cada prioridade na seção abaixo</p>
</div>
{:else}
<!-- Resumo de SLAs -->
<div class="mb-4 grid grid-cols-2 gap-3 md:grid-cols-4">
<div class="rounded-xl border border-base-300 bg-base-200/30 p-3 text-center">
<div class="text-2xl font-bold text-base-content">{slaConfigsAtivos.length}</div>
<div class="text-xs text-base-content/60">Total de SLAs</div>
</div>
<div class="rounded-xl border border-base-300 bg-base-200/30 p-3 text-center">
<div class="text-2xl font-bold text-success">{slaConfigsPorPrioridadeCount.baixa}</div>
<div class="text-xs text-base-content/60">Prioridade Baixa</div>
</div>
<div class="rounded-xl border border-base-300 bg-base-200/30 p-3 text-center">
<div class="text-2xl font-bold text-info">{slaConfigsPorPrioridadeCount.media}</div>
<div class="text-xs text-base-content/60">Prioridade Média</div>
</div>
<div class="rounded-xl border border-base-300 bg-base-200/30 p-3 text-center">
<div class="text-2xl font-bold text-warning">{slaConfigsPorPrioridadeCount.alta + slaConfigsPorPrioridadeCount.critica}</div>
<div class="text-xs text-base-content/60">Prioridade Alta/Crítica</div>
</div>
</div>
<!-- Tabela de SLAs -->
<div class="overflow-x-auto rounded-xl border border-base-300">
<table class="table w-full">
<thead class="bg-base-200/50">
<tr>
<th class="font-semibold text-base-content">Nome</th>
<th class="font-semibold text-base-content">Prioridade</th>
<th class="font-semibold text-base-content">Tempo de Resposta</th>
<th class="font-semibold text-base-content">Tempo de Conclusão</th>
<th class="font-semibold text-base-content">Auto-encerramento</th>
<th class="font-semibold text-base-content">Alerta Antecedência</th>
<th class="font-semibold text-base-content">Status</th>
<th class="font-semibold text-base-content text-center">Ações</th>
</tr>
</thead>
<tbody>
{#each slaConfigsAtivos as sla (sla._id)}
<tr class="hover:bg-base-200/30 transition-colors">
<td>
<div class="font-medium text-base-content">{sla.nome}</div>
{#if sla.descricao}
<div class="text-xs text-base-content/60 mt-1 line-clamp-1">{sla.descricao}</div>
{/if}
</td>
<td>
<span class="badge badge-outline capitalize {
sla.prioridade === 'critica' ? 'badge-error' :
sla.prioridade === 'alta' ? 'badge-warning' :
sla.prioridade === 'media' ? 'badge-info' :
'badge-success'
}">
{sla.prioridade}
</span>
</td>
<td>
<div class="flex flex-col">
<span class="font-semibold text-base-content">{sla.tempoRespostaHoras}h</span>
{#if sla.tempoRespostaHoras >= 24}
<span class="text-xs text-base-content/50">
({Math.floor(sla.tempoRespostaHoras / 24)}d {sla.tempoRespostaHoras % 24}h)
</span>
{/if}
</div>
</td>
<td>
<div class="flex flex-col">
<span class="font-semibold text-base-content">{sla.tempoConclusaoHoras}h</span>
{#if sla.tempoConclusaoHoras >= 24}
<span class="text-xs text-base-content/50">
({Math.floor(sla.tempoConclusaoHoras / 24)}d {sla.tempoConclusaoHoras % 24}h)
</span>
{/if}
</div>
</td>
<td>
{#if sla.tempoEncerramentoHoras}
<div class="flex flex-col">
<span class="font-semibold text-base-content">{sla.tempoEncerramentoHoras}h</span>
{#if sla.tempoEncerramentoHoras >= 24}
<span class="text-xs text-base-content/50">
({Math.floor(sla.tempoEncerramentoHoras / 24)}d {sla.tempoEncerramentoHoras % 24}h)
</span>
{/if}
</div>
{:else}
<span class="text-base-content/40 text-sm italic">Não configurado</span>
{/if}
</td>
<td>
<div class="flex items-center gap-1">
<span class="font-semibold text-base-content">{sla.alertaAntecedenciaHoras}h</span>
<span class="text-xs text-base-content/50">antes</span>
</div>
</td>
<td>
<span class="badge badge-success badge-sm gap-1">
<span class="h-2 w-2 rounded-full bg-success"></span>
Ativo
</span>
</td>
<td>
<div class="flex justify-center gap-1">
<button
class="btn btn-xs btn-ghost hover:btn-primary"
type="button"
onclick={() => selecionarSla(sla)}
title="Editar SLA"
>
✏️
</button>
<button
class="btn btn-xs btn-ghost hover:btn-error"
type="button"
onclick={() => (slaParaExcluir = sla._id)}
title="Excluir SLA"
>
🗑️
</button>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
{/if}
</section>
<!-- Seção: Configuração de SLA por Prioridade -->
<section class="rounded-3xl border border-base-200 bg-base-100/80 p-6 shadow-xl">
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
@@ -602,14 +874,32 @@
<button class="btn btn-sm btn-primary" type="button" onclick={novoSla}>
Novo SLA
</button>
<button
class="btn btn-sm btn-warning"
type="button"
onclick={migrarSlaConfigs}
disabled={migrandoSLAs}
>
{#if migrandoSLAs}
<span class="loading loading-spinner loading-sm"></span>
Migrando...
{:else}
🔧 Migrar SLAs Antigos
{/if}
</button>
</div>
{#if migracaoFeedback}
<div class={`alert ${migracaoFeedback.includes('concluída') ? 'alert-success' : 'alert-error'} mt-2`}>
<span class="text-sm">{migracaoFeedback}</span>
</div>
{/if}
</div>
<!-- Lista de SLAs por prioridade -->
<!-- Cards rápidos de prioridade -->
<div class="mt-6 grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{#each ["baixa", "media", "alta", "critica"] as prioridade}
{@const slaAtual = slaConfigsPorPrioridade[prioridade]}
<div class="rounded-2xl border border-base-200 bg-base-100/50 p-4">
<div class="rounded-2xl border border-base-200 bg-base-100/50 p-4 transition-all hover:shadow-md">
<div class="mb-2 flex items-center justify-between">
<h4 class="font-semibold capitalize text-base-content">{prioridade}</h4>
{#if slaAtual}
@@ -620,24 +910,28 @@
</div>
{#if slaAtual}
<div class="space-y-2 text-xs">
<div>
<div class="flex justify-between">
<span class="text-base-content/60">Resposta:</span>
<span class="font-semibold">{slaAtual.tempoRespostaHoras}h</span>
<span class="font-semibold text-base-content">{slaAtual.tempoRespostaHoras}h</span>
</div>
<div>
<div class="flex justify-between">
<span class="text-base-content/60">Conclusão:</span>
<span class="font-semibold">{slaAtual.tempoConclusaoHoras}h</span>
<span class="font-semibold text-base-content">{slaAtual.tempoConclusaoHoras}h</span>
</div>
{#if slaAtual.tempoEncerramentoHoras}
<div>
<div class="flex justify-between">
<span class="text-base-content/60">Auto-encerramento:</span>
<span class="font-semibold">{slaAtual.tempoEncerramentoHoras}h</span>
<span class="font-semibold text-base-content">{slaAtual.tempoEncerramentoHoras}h</span>
</div>
{/if}
<div class="flex justify-between">
<span class="text-base-content/60">Alerta:</span>
<span class="font-semibold text-base-content">{slaAtual.alertaAntecedenciaHoras}h antes</span>
</div>
</div>
<div class="mt-3 flex gap-1">
<button
class="btn btn-xs btn-ghost"
class="btn btn-xs btn-ghost flex-1"
type="button"
onclick={() => selecionarSla(slaAtual)}
>
@@ -656,8 +950,7 @@
class="btn btn-xs btn-primary btn-outline mt-2 w-full"
type="button"
onclick={() => {
slaForm.prioridade = prioridade as "baixa" | "media" | "alta" | "critica";
novoSla();
novoSla(prioridade as "baixa" | "media" | "alta" | "critica");
}}
>
Configurar