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:
183
apps/web/src/lib/components/chamados/SlaChart.svelte
Normal file
183
apps/web/src/lib/components/chamados/SlaChart.svelte
Normal 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user