feat: add SLA statistics and real-time monitoring to central-chamados

- Introduced new queries to fetch SLA statistics and real-time SLA data for better ticket management insights.
- Enhanced the central-chamados route to display SLA performance metrics, including compliance rates and ticket statuses by priority.
- Implemented fallback logic for statistics calculation to ensure data availability even when queries return undefined.
- Refactored the UI to include a dedicated section for SLA performance, improving user experience and data visibility.
This commit is contained in:
2025-11-17 09:33:33 -03:00
parent 5ef6ef8550
commit 55847e2a77
3 changed files with 447 additions and 236 deletions

View File

@@ -11,6 +11,7 @@
} from "$lib/utils/chamados";
import { useConvexWithAuth } from "$lib/hooks/useConvexWithAuth";
import { authStore } from "$lib/stores/auth.svelte";
import SlaChart from "$lib/components/chamados/SlaChart.svelte";
type Ticket = Doc<"tickets">;
type Usuario = Doc<"usuarios">;
@@ -30,6 +31,8 @@
const usuariosQuery = useQuery(api.usuarios.listar, {});
const slaConfigsQuery = useQuery(api.chamados.listarSlaConfigs, {});
const templatesQuery = useQuery(api.templatesMensagens.listarTemplates, {});
const estatisticasQuery = useQuery(api.chamados.obterEstatisticasChamados, {});
const dadosSlaGraficoQuery = useQuery(api.chamados.obterDadosSlaGrafico, {});
// Extrair dados dos templates
const templates = $derived.by(() => {
@@ -102,8 +105,6 @@
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;
@@ -237,7 +238,25 @@
return usuariosFiltrados;
});
const estatisticas = $derived(() => {
const estatisticas = $derived.by(() => {
// Usar query de estatísticas se disponível, senão calcular localmente
if (estatisticasQuery !== undefined && estatisticasQuery !== null) {
// useQuery retorna dados diretamente ou em propriedade data
let dadosEstatisticas: { total: number; abertos: number; emAndamento: number; vencidos: number } | null = null;
if (typeof estatisticasQuery === 'object') {
if ('data' in estatisticasQuery && estatisticasQuery.data) {
dadosEstatisticas = estatisticasQuery.data as { total: number; abertos: number; emAndamento: number; vencidos: number };
} else if ('total' in estatisticasQuery) {
dadosEstatisticas = estatisticasQuery as { total: number; abertos: number; emAndamento: number; vencidos: number };
}
}
if (dadosEstatisticas) {
return dadosEstatisticas;
}
}
// Fallback: calcular com base nos tickets carregados
const total = tickets.length;
const abertos = tickets.filter((t) => t.status === "aberto").length;
const emAndamento = tickets.filter((t) => t.status === "em_andamento").length;
@@ -438,23 +457,6 @@
}
}
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(() => {
@@ -468,22 +470,96 @@
<section class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div class="rounded-2xl border border-primary/20 bg-primary/5 p-4">
<p class="text-sm text-base-content/60">Total de chamados</p>
<p class="text-3xl font-bold text-primary">{estatisticas.total}</p>
<p class="text-3xl font-bold text-primary">{estatisticas.total ?? 0}</p>
</div>
<div class="rounded-2xl border border-info/20 bg-info/5 p-4">
<p class="text-sm text-base-content/60">Abertos</p>
<p class="text-3xl font-bold text-info">{estatisticas.abertos}</p>
<p class="text-3xl font-bold text-info">{estatisticas.abertos ?? 0}</p>
</div>
<div class="rounded-2xl border border-warning/20 bg-warning/5 p-4">
<p class="text-sm text-base-content/60">Em andamento</p>
<p class="text-3xl font-bold text-warning">{estatisticas.emAndamento}</p>
<p class="text-3xl font-bold text-warning">{estatisticas.emAndamento ?? 0}</p>
</div>
<div class="rounded-2xl border border-error/20 bg-error/5 p-4">
<p class="text-sm text-base-content/60">Vencidos/Cancelados</p>
<p class="text-3xl font-bold text-error">{estatisticas.vencidos}</p>
<p class="text-3xl font-bold text-error">{estatisticas.vencidos ?? 0}</p>
</div>
</section>
<!-- Gráfico de SLA em Tempo Real -->
<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">Performance de SLA</h3>
<p class="text-sm text-base-content/60">Monitoramento em tempo real do cumprimento de SLA por prioridade</p>
</div>
{#if dadosSlaGraficoQuery !== undefined && dadosSlaGraficoQuery !== null}
{@const dadosSla = typeof dadosSlaGraficoQuery === 'object' && 'data' in dadosSlaGraficoQuery
? dadosSlaGraficoQuery.data
: (typeof dadosSlaGraficoQuery === 'object' && 'taxaCumprimento' in dadosSlaGraficoQuery
? dadosSlaGraficoQuery
: null)}
{#if dadosSla}
<div class="flex items-center gap-4">
<div class="text-right">
<p class="text-xs text-base-content/60">Taxa de Cumprimento</p>
<p class="text-2xl font-bold {
dadosSla.taxaCumprimento >= 90 ? 'text-success' :
dadosSla.taxaCumprimento >= 70 ? 'text-warning' :
'text-error'
}">
{dadosSla.taxaCumprimento}%
</p>
</div>
<div class="text-right">
<p class="text-xs text-base-content/60">Última atualização</p>
<p class="text-xs text-base-content/40">
{new Date(dadosSla.atualizadoEm).toLocaleTimeString('pt-BR')}
</p>
</div>
</div>
{/if}
{/if}
</div>
{#if dadosSlaGraficoQuery === undefined || dadosSlaGraficoQuery === null}
<div class="flex items-center justify-center py-12">
<span class="loading loading-spinner loading-lg"></span>
</div>
{:else}
{@const dadosSla = typeof dadosSlaGraficoQuery === 'object' && 'data' in dadosSlaGraficoQuery
? dadosSlaGraficoQuery.data
: (typeof dadosSlaGraficoQuery === 'object' && 'taxaCumprimento' in dadosSlaGraficoQuery
? dadosSlaGraficoQuery
: null)}
{#if dadosSla}
<div class="mb-4 grid grid-cols-2 gap-4 md:grid-cols-4">
<div class="rounded-xl border border-success/20 bg-success/5 p-3">
<p class="text-xs text-base-content/60">Dentro do Prazo</p>
<p class="text-xl font-bold text-success">{dadosSla.statusSla.dentroPrazo}</p>
</div>
<div class="rounded-xl border border-warning/20 bg-warning/5 p-3">
<p class="text-xs text-base-content/60">Próximo Vencimento</p>
<p class="text-xl font-bold text-warning">{dadosSla.statusSla.proximoVencimento}</p>
</div>
<div class="rounded-xl border border-error/20 bg-error/5 p-3">
<p class="text-xs text-base-content/60">Vencidos</p>
<p class="text-xl font-bold text-error">{dadosSla.statusSla.vencido}</p>
</div>
<div class="rounded-xl border border-base-300 bg-base-200/50 p-3">
<p class="text-xs text-base-content/60">Sem Prazo</p>
<p class="text-xl font-bold text-base-content">{dadosSla.statusSla.semPrazo}</p>
</div>
</div>
<SlaChart dadosSla={dadosSla} height={400} />
{:else}
<div class="rounded-2xl border border-base-300 bg-base-200/50 p-8 text-center">
<p class="text-sm text-base-content/60">Carregando dados de SLA...</p>
</div>
{/if}
{/if}
</section>
<section class="rounded-3xl border border-base-200 bg-base-100/90 p-6 shadow-xl">
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
@@ -633,72 +709,73 @@
Atribuir responsável
</button>
</div>
</div>
<div class="rounded-3xl border border-base-200 bg-base-100/80 p-6 shadow-lg">
<h3 class="text-lg font-semibold text-base-content">Prorrogar prazo</h3>
<p class="text-xs text-base-content/60 mt-1">Recurso exclusivo para a equipe de TI</p>
<div class="mt-4 space-y-3">
<div class="form-control">
<label class="label">
<span class="label-text font-semibold">Ticket *</span>
</label>
<select class="select select-bordered w-full" bind:value={prorrogacaoForm.ticketId}>
<option value="">Selecione o ticket</option>
{#if tickets.length === 0}
<option disabled>Carregando tickets...</option>
{:else}
{#each tickets as ticket (ticket._id)}
<option value={ticket._id}>{ticket.numero} - {ticket.titulo}</option>
{/each}
{/if}
</select>
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-semibold">Prazo a prorrogar *</span>
</label>
<select class="select select-bordered w-full" bind:value={prorrogacaoForm.prazo}>
<option value="resposta">Prazo de resposta</option>
<option value="conclusao">Prazo de conclusão</option>
</select>
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-semibold">Horas adicionais</span>
</label>
<input
type="number"
min="1"
class="input input-bordered w-full"
bind:value={prorrogacaoForm.horasAdicionais}
placeholder="Ex: 24"
/>
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-semibold">Motivo *</span>
</label>
<textarea
class="textarea textarea-bordered w-full"
rows="3"
placeholder="Descreva o motivo da prorrogação..."
bind:value={prorrogacaoForm.motivo}
></textarea>
</div>
{#if prorrogacaoFeedback}
<div class={`alert ${prorrogacaoFeedback.includes('sucesso') ? 'alert-success' : 'alert-error'} text-sm`}>
{prorrogacaoFeedback}
<!-- Seção Prorrogar prazo dentro de Atribuir responsável -->
<div class="mt-6 border-t border-base-300 pt-6">
<h3 class="text-lg font-semibold text-base-content mb-1">Prorrogar prazo</h3>
<p class="text-xs text-base-content/60 mb-4">Recurso exclusivo para a equipe de TI</p>
<div class="space-y-3">
<div class="form-control">
<label class="label">
<span class="label-text font-semibold">Ticket *</span>
</label>
<select class="select select-bordered w-full" bind:value={prorrogacaoForm.ticketId}>
<option value="">Selecione o ticket</option>
{#if tickets.length === 0}
<option disabled>Carregando tickets...</option>
{:else}
{#each tickets as ticket (ticket._id)}
<option value={ticket._id}>{ticket.numero} - {ticket.titulo}</option>
{/each}
{/if}
</select>
</div>
{/if}
<button
class="btn btn-warning w-full"
type="button"
onclick={prorrogarChamado}
disabled={!prorrogacaoForm.ticketId}
>
Prorrogar prazo
</button>
<div class="form-control">
<label class="label">
<span class="label-text font-semibold">Prazo a prorrogar *</span>
</label>
<select class="select select-bordered w-full" bind:value={prorrogacaoForm.prazo}>
<option value="resposta">Prazo de resposta</option>
<option value="conclusao">Prazo de conclusão</option>
</select>
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-semibold">Horas adicionais</span>
</label>
<input
type="number"
min="1"
class="input input-bordered w-full"
bind:value={prorrogacaoForm.horasAdicionais}
placeholder="Ex: 24"
/>
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-semibold">Motivo *</span>
</label>
<textarea
class="textarea textarea-bordered w-full"
rows="3"
placeholder="Descreva o motivo da prorrogação..."
bind:value={prorrogacaoForm.motivo}
></textarea>
</div>
{#if prorrogacaoFeedback}
<div class={`alert ${prorrogacaoFeedback.includes('sucesso') ? 'alert-success' : 'alert-error'} text-sm`}>
{prorrogacaoFeedback}
</div>
{/if}
<button
class="btn btn-warning w-full"
type="button"
onclick={prorrogarChamado}
disabled={!prorrogacaoForm.ticketId || !prorrogacaoForm.motivo.trim()}
>
Prorrogar prazo
</button>
</div>
</div>
</div>
</section>
@@ -865,34 +942,9 @@
<!-- 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>
<h3 class="text-lg font-semibold text-base-content">Configuração de SLA por Prioridade</h3>
<p class="text-sm text-base-content/60">Configure SLAs separados para cada nível de prioridade</p>
</div>
<div class="flex gap-2">
<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>
<h3 class="text-lg font-semibold text-base-content">Configuração de SLA por Prioridade</h3>
<p class="text-sm text-base-content/60">Configure SLAs separados para cada nível de prioridade</p>
</div>
<!-- Cards rápidos de prioridade -->
@@ -1042,88 +1094,5 @@
</div>
{/if}
<!-- Templates de Email -->
<section class="rounded-3xl border border-base-200 bg-base-100/80 p-6 shadow-xl">
<div>
<h3 class="text-lg font-semibold text-base-content">Templates de Email - Chamados</h3>
<p class="text-sm text-base-content/60">Templates automáticos usados nas notificações de chamados.</p>
</div>
<div class="mt-6 grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{#if carregandoTemplates}
<div class="col-span-full text-center text-sm text-base-content/60">
<span class="loading loading-spinner loading-md"></span>
<p class="mt-2">Carregando templates...</p>
</div>
{:else if templatesChamados.length === 0}
<div class="col-span-full rounded-2xl border border-base-300 bg-base-200/50 p-6 text-center">
<p class="text-sm font-semibold text-base-content/70">Nenhum template encontrado</p>
<p class="mt-2 text-xs text-base-content/50 mb-4">
Os templates de chamados serão criados automaticamente quando o sistema for inicializado.
Clique no botão abaixo para criar os templates padrão agora.
</p>
{#if templatesFeedback}
<div class={`alert ${templatesFeedback.includes('sucesso') ? 'alert-success' : 'alert-error'} mb-4`}>
<span class="text-sm">{templatesFeedback}</span>
</div>
{/if}
<button
class="btn btn-primary"
type="button"
onclick={criarTemplatesPadrao}
disabled={criandoTemplates}
>
{#if criandoTemplates}
<span class="loading loading-spinner loading-sm"></span>
Criando templates...
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 mr-2"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4v16m8-8H4"
/>
</svg>
Criar templates padrão
{/if}
</button>
</div>
{:else}
{#each templatesChamados as template (template._id)}
<div class="rounded-2xl border border-base-200 bg-base-100/50 p-4">
<div class="mb-2 flex items-center justify-between">
<h4 class="font-semibold text-base-content">{template.nome}</h4>
<span class="badge badge-primary badge-sm">Sistema</span>
</div>
<p class="mb-3 text-xs text-base-content/60">{template.titulo}</p>
{#if template.variaveis && template.variaveis.length > 0}
<div class="mb-2">
<p class="text-xs font-semibold text-base-content/60">Variáveis:</p>
<div class="mt-1 flex flex-wrap gap-1">
{#each template.variaveis as variavel}
<span class="badge badge-outline badge-xs">{{variavel}}</span>
{/each}
</div>
</div>
{/if}
<div class="mt-3 text-xs text-base-content/50">
<p>Código: <code class="bg-base-200 px-1 py-0.5 rounded">{template.codigo || "N/A"}</code></p>
</div>
</div>
{/each}
{:else}
<div class="col-span-full text-center text-sm text-base-content/60">
<p>Carregando templates...</p>
</div>
{/if}
</div>
</section>
</main>