Feat cibersecurity #27
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";
|
} from "$lib/utils/chamados";
|
||||||
import { useConvexWithAuth } from "$lib/hooks/useConvexWithAuth";
|
import { useConvexWithAuth } from "$lib/hooks/useConvexWithAuth";
|
||||||
import { authStore } from "$lib/stores/auth.svelte";
|
import { authStore } from "$lib/stores/auth.svelte";
|
||||||
|
import SlaChart from "$lib/components/chamados/SlaChart.svelte";
|
||||||
|
|
||||||
type Ticket = Doc<"tickets">;
|
type Ticket = Doc<"tickets">;
|
||||||
type Usuario = Doc<"usuarios">;
|
type Usuario = Doc<"usuarios">;
|
||||||
@@ -30,6 +31,8 @@
|
|||||||
const usuariosQuery = useQuery(api.usuarios.listar, {});
|
const usuariosQuery = useQuery(api.usuarios.listar, {});
|
||||||
const slaConfigsQuery = useQuery(api.chamados.listarSlaConfigs, {});
|
const slaConfigsQuery = useQuery(api.chamados.listarSlaConfigs, {});
|
||||||
const templatesQuery = useQuery(api.templatesMensagens.listarTemplates, {});
|
const templatesQuery = useQuery(api.templatesMensagens.listarTemplates, {});
|
||||||
|
const estatisticasQuery = useQuery(api.chamados.obterEstatisticasChamados, {});
|
||||||
|
const dadosSlaGraficoQuery = useQuery(api.chamados.obterDadosSlaGrafico, {});
|
||||||
|
|
||||||
// Extrair dados dos templates
|
// Extrair dados dos templates
|
||||||
const templates = $derived.by(() => {
|
const templates = $derived.by(() => {
|
||||||
@@ -102,8 +105,6 @@
|
|||||||
let prorrogacaoFeedback = $state<string | null>(null);
|
let prorrogacaoFeedback = $state<string | null>(null);
|
||||||
let criandoTemplates = $state(false);
|
let criandoTemplates = $state(false);
|
||||||
let templatesFeedback = $state<string | null>(null);
|
let templatesFeedback = $state<string | null>(null);
|
||||||
let migrandoSLAs = $state(false);
|
|
||||||
let migracaoFeedback = $state<string | null>(null);
|
|
||||||
|
|
||||||
let carregamentoToken = 0;
|
let carregamentoToken = 0;
|
||||||
|
|
||||||
@@ -237,7 +238,25 @@
|
|||||||
return usuariosFiltrados;
|
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 total = tickets.length;
|
||||||
const abertos = tickets.filter((t) => t.status === "aberto").length;
|
const abertos = tickets.filter((t) => t.status === "aberto").length;
|
||||||
const emAndamento = tickets.filter((t) => t.status === "em_andamento").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)
|
// Debug: ver templates carregados (remover em produção)
|
||||||
// $effect(() => {
|
// $effect(() => {
|
||||||
@@ -468,22 +470,96 @@
|
|||||||
<section class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
<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">
|
<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-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>
|
||||||
<div class="rounded-2xl border border-info/20 bg-info/5 p-4">
|
<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-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>
|
||||||
<div class="rounded-2xl border border-warning/20 bg-warning/5 p-4">
|
<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-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>
|
||||||
<div class="rounded-2xl border border-error/20 bg-error/5 p-4">
|
<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-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>
|
</div>
|
||||||
</section>
|
</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">
|
<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 class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||||
<div>
|
<div>
|
||||||
@@ -633,72 +709,73 @@
|
|||||||
Atribuir responsável
|
Atribuir responsável
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="rounded-3xl border border-base-200 bg-base-100/80 p-6 shadow-lg">
|
<!-- Seção Prorrogar prazo dentro de Atribuir responsável -->
|
||||||
<h3 class="text-lg font-semibold text-base-content">Prorrogar prazo</h3>
|
<div class="mt-6 border-t border-base-300 pt-6">
|
||||||
<p class="text-xs text-base-content/60 mt-1">Recurso exclusivo para a equipe de TI</p>
|
<h3 class="text-lg font-semibold text-base-content mb-1">Prorrogar prazo</h3>
|
||||||
<div class="mt-4 space-y-3">
|
<p class="text-xs text-base-content/60 mb-4">Recurso exclusivo para a equipe de TI</p>
|
||||||
<div class="form-control">
|
<div class="space-y-3">
|
||||||
<label class="label">
|
<div class="form-control">
|
||||||
<span class="label-text font-semibold">Ticket *</span>
|
<label class="label">
|
||||||
</label>
|
<span class="label-text font-semibold">Ticket *</span>
|
||||||
<select class="select select-bordered w-full" bind:value={prorrogacaoForm.ticketId}>
|
</label>
|
||||||
<option value="">Selecione o ticket</option>
|
<select class="select select-bordered w-full" bind:value={prorrogacaoForm.ticketId}>
|
||||||
{#if tickets.length === 0}
|
<option value="">Selecione o ticket</option>
|
||||||
<option disabled>Carregando tickets...</option>
|
{#if tickets.length === 0}
|
||||||
{:else}
|
<option disabled>Carregando tickets...</option>
|
||||||
{#each tickets as ticket (ticket._id)}
|
{:else}
|
||||||
<option value={ticket._id}>{ticket.numero} - {ticket.titulo}</option>
|
{#each tickets as ticket (ticket._id)}
|
||||||
{/each}
|
<option value={ticket._id}>{ticket.numero} - {ticket.titulo}</option>
|
||||||
{/if}
|
{/each}
|
||||||
</select>
|
{/if}
|
||||||
</div>
|
</select>
|
||||||
<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>
|
</div>
|
||||||
{/if}
|
<div class="form-control">
|
||||||
<button
|
<label class="label">
|
||||||
class="btn btn-warning w-full"
|
<span class="label-text font-semibold">Prazo a prorrogar *</span>
|
||||||
type="button"
|
</label>
|
||||||
onclick={prorrogarChamado}
|
<select class="select select-bordered w-full" bind:value={prorrogacaoForm.prazo}>
|
||||||
disabled={!prorrogacaoForm.ticketId}
|
<option value="resposta">Prazo de resposta</option>
|
||||||
>
|
<option value="conclusao">Prazo de conclusão</option>
|
||||||
Prorrogar prazo
|
</select>
|
||||||
</button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -865,34 +942,9 @@
|
|||||||
|
|
||||||
<!-- Seção: Configuração de SLA por Prioridade -->
|
<!-- Seção: Configuração de SLA por Prioridade -->
|
||||||
<section class="rounded-3xl border border-base-200 bg-base-100/80 p-6 shadow-xl">
|
<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>
|
||||||
<div>
|
<h3 class="text-lg font-semibold text-base-content">Configuração de SLA por Prioridade</h3>
|
||||||
<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>
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
<!-- Cards rápidos de prioridade -->
|
<!-- Cards rápidos de prioridade -->
|
||||||
@@ -1042,88 +1094,5 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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>
|
</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({
|
export const salvarSlaConfig = mutation({
|
||||||
args: {
|
args: {
|
||||||
slaId: v.optional(v.id("slaConfigs")),
|
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