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

@@ -0,0 +1,183 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { Chart, registerables } from 'chart.js';
Chart.register(...registerables);
type Props = {
dadosSla: {
statusSla: {
dentroPrazo: number;
proximoVencimento: number;
vencido: number;
semPrazo: number;
};
porPrioridade: {
baixa: { dentroPrazo: number; proximoVencimento: number; vencido: number; total: number };
media: { dentroPrazo: number; proximoVencimento: number; vencido: number; total: number };
alta: { dentroPrazo: number; proximoVencimento: number; vencido: number; total: number };
critica: { dentroPrazo: number; proximoVencimento: number; vencido: number; total: number };
};
taxaCumprimento: number;
totalComPrazo: number;
atualizadoEm: number;
};
height?: number;
};
let { dadosSla, height = 400 }: Props = $props();
let canvas: HTMLCanvasElement;
let chart: Chart | null = null;
function prepararDados() {
const prioridades = ['Baixa', 'Média', 'Alta', 'Crítica'];
const cores = {
dentroPrazo: 'rgba(34, 197, 94, 0.8)', // verde
proximoVencimento: 'rgba(251, 191, 36, 0.8)', // amarelo
vencido: 'rgba(239, 68, 68, 0.8)', // vermelho
};
return {
labels: prioridades,
datasets: [
{
label: 'Dentro do Prazo',
data: [
dadosSla.porPrioridade.baixa.dentroPrazo,
dadosSla.porPrioridade.media.dentroPrazo,
dadosSla.porPrioridade.alta.dentroPrazo,
dadosSla.porPrioridade.critica.dentroPrazo,
],
backgroundColor: cores.dentroPrazo,
borderColor: 'rgba(34, 197, 94, 1)',
borderWidth: 2,
},
{
label: 'Próximo ao Vencimento',
data: [
dadosSla.porPrioridade.baixa.proximoVencimento,
dadosSla.porPrioridade.media.proximoVencimento,
dadosSla.porPrioridade.alta.proximoVencimento,
dadosSla.porPrioridade.critica.proximoVencimento,
],
backgroundColor: cores.proximoVencimento,
borderColor: 'rgba(251, 191, 36, 1)',
borderWidth: 2,
},
{
label: 'Vencido',
data: [
dadosSla.porPrioridade.baixa.vencido,
dadosSla.porPrioridade.media.vencido,
dadosSla.porPrioridade.alta.vencido,
dadosSla.porPrioridade.critica.vencido,
],
backgroundColor: cores.vencido,
borderColor: 'rgba(239, 68, 68, 1)',
borderWidth: 2,
},
],
};
}
onMount(() => {
if (canvas) {
const ctx = canvas.getContext('2d');
if (ctx) {
const chartData = prepararDados();
chart = new Chart(ctx, {
type: 'bar',
data: chartData,
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: true,
position: 'top',
labels: {
color: '#a6adbb',
font: {
size: 12,
family: "'Inter', sans-serif",
},
usePointStyle: true,
padding: 15,
}
},
tooltip: {
backgroundColor: 'rgba(0, 0, 0, 0.9)',
titleColor: '#fff',
bodyColor: '#fff',
borderColor: '#570df8',
borderWidth: 1,
padding: 12,
callbacks: {
label: function(context) {
const label = context.dataset.label || '';
const value = context.parsed.y;
const prioridade = context.label;
return `${label}: ${value} chamado(s)`;
}
}
}
},
scales: {
x: {
stacked: true,
grid: {
color: 'rgba(255, 255, 255, 0.05)',
},
ticks: {
color: '#a6adbb',
font: {
size: 11,
weight: '500',
}
}
},
y: {
stacked: true,
beginAtZero: true,
grid: {
color: 'rgba(255, 255, 255, 0.05)',
},
ticks: {
color: '#a6adbb',
font: {
size: 11,
},
stepSize: 1,
}
}
},
animation: {
duration: 800,
easing: 'easeInOutQuart'
}
}
});
}
}
});
$effect(() => {
if (chart && dadosSla) {
const chartData = prepararDados();
chart.data = chartData;
chart.update('active');
}
});
onDestroy(() => {
if (chart) {
chart.destroy();
}
});
</script>
<div style="height: {height}px; position: relative;">
<canvas bind:this={canvas}></canvas>
</div>

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,12 +709,12 @@
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">
<!-- 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>
@@ -695,12 +771,13 @@
class="btn btn-warning w-full"
type="button"
onclick={prorrogarChamado}
disabled={!prorrogacaoForm.ticketId}
disabled={!prorrogacaoForm.ticketId || !prorrogacaoForm.motivo.trim()}
>
Prorrogar prazo
</button>
</div>
</div>
</div>
</section>
<!-- Seção: SLAs Existentes - Visualização Detalhada -->
@@ -865,35 +942,10 @@
<!-- 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>
<!-- Cards rápidos de prioridade -->
<div class="mt-6 grid gap-4 md:grid-cols-2 lg:grid-cols-4">
@@ -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>

View File

@@ -520,6 +520,102 @@ export const listarSlaConfigs = query({
},
});
export const obterEstatisticasChamados = query({
args: {},
handler: async (ctx) => {
await assertAuth(ctx);
const todosTickets = await ctx.db.query("tickets").collect();
const total = todosTickets.length;
const abertos = todosTickets.filter((t) => t.status === "aberto").length;
const emAndamento = todosTickets.filter((t) => t.status === "em_andamento").length;
const vencidos = todosTickets.filter(
(t) => (t.prazoConclusao && t.prazoConclusao < Date.now()) || t.status === "cancelado"
).length;
return { total, abertos, emAndamento, vencidos };
},
});
export const obterDadosSlaGrafico = query({
args: {},
handler: async (ctx) => {
await assertAuth(ctx);
const agora = Date.now();
const todosTickets = await ctx.db.query("tickets").collect();
const slaConfigs = await ctx.db.query("slaConfigs").collect();
// Agrupar SLAs por prioridade
const slaPorPrioridade = new Map<string, Doc<"slaConfigs">>();
slaConfigs.filter(s => s.ativo).forEach(sla => {
if (sla.prioridade) {
slaPorPrioridade.set(sla.prioridade, sla);
}
});
// Calcular status de SLA para cada ticket
const statusSla = {
dentroPrazo: 0,
proximoVencimento: 0,
vencido: 0,
semPrazo: 0,
};
const porPrioridade = {
baixa: { dentroPrazo: 0, proximoVencimento: 0, vencido: 0, total: 0 },
media: { dentroPrazo: 0, proximoVencimento: 0, vencido: 0, total: 0 },
alta: { dentroPrazo: 0, proximoVencimento: 0, vencido: 0, total: 0 },
critica: { dentroPrazo: 0, proximoVencimento: 0, vencido: 0, total: 0 },
};
todosTickets.forEach(ticket => {
if (!ticket.prazoConclusao) {
statusSla.semPrazo++;
return;
}
const prazoConclusao = ticket.prazoConclusao;
const horasRestantes = (prazoConclusao - agora) / (1000 * 60 * 60);
const sla = slaPorPrioridade.get(ticket.prioridade);
const alertaHoras = sla?.alertaAntecedenciaHoras ?? 2;
if (prazoConclusao < agora) {
statusSla.vencido++;
if (ticket.prioridade && porPrioridade[ticket.prioridade as keyof typeof porPrioridade]) {
porPrioridade[ticket.prioridade as keyof typeof porPrioridade].vencido++;
porPrioridade[ticket.prioridade as keyof typeof porPrioridade].total++;
}
} else if (horasRestantes <= alertaHoras) {
statusSla.proximoVencimento++;
if (ticket.prioridade && porPrioridade[ticket.prioridade as keyof typeof porPrioridade]) {
porPrioridade[ticket.prioridade as keyof typeof porPrioridade].proximoVencimento++;
porPrioridade[ticket.prioridade as keyof typeof porPrioridade].total++;
}
} else {
statusSla.dentroPrazo++;
if (ticket.prioridade && porPrioridade[ticket.prioridade as keyof typeof porPrioridade]) {
porPrioridade[ticket.prioridade as keyof typeof porPrioridade].dentroPrazo++;
porPrioridade[ticket.prioridade as keyof typeof porPrioridade].total++;
}
}
});
// Calcular taxa de cumprimento
const totalComPrazo = statusSla.dentroPrazo + statusSla.proximoVencimento + statusSla.vencido;
const taxaCumprimento = totalComPrazo > 0
? Math.round((statusSla.dentroPrazo / totalComPrazo) * 100)
: 100;
return {
statusSla,
porPrioridade,
taxaCumprimento,
totalComPrazo,
atualizadoEm: agora,
};
},
});
export const salvarSlaConfig = mutation({
args: {
slaId: v.optional(v.id("slaConfigs")),
@@ -718,40 +814,3 @@ export const generateUploadUrl = mutation({
},
});
/**
* Migração: Adiciona o campo 'prioridade' aos SLAs antigos que não possuem
* Esta mutation corrige documentos criados antes da migração do schema
*/
export const migrarSlaConfigs = mutation({
args: {},
handler: async (ctx) => {
const usuario = await assertAuth(ctx);
// Buscar todos os SLAs
const slaConfigs = await ctx.db.query("slaConfigs").collect();
let migrados = 0;
for (const sla of slaConfigs) {
// Verificar se o documento não tem o campo 'prioridade'
// Usando type assertion para acessar campos não tipados
const slaDoc = sla as any;
if (!slaDoc.prioridade) {
// Adicionar prioridade padrão "media" para SLAs antigos
await ctx.db.patch(sla._id, {
prioridade: "media" as "baixa" | "media" | "alta" | "critica",
atualizadoPor: usuario._id,
atualizadoEm: Date.now(),
});
migrados++;
console.log(`✅ SLA migrado: ${sla.nome} (ID: ${sla._id})`);
}
}
return {
sucesso: true,
migrados,
total: slaConfigs.length,
};
},
});