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:
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user