Merge branch 'feat-central-chamados' into feat-cibersecurity

This commit is contained in:
2025-11-17 10:19:10 -03:00
11 changed files with 1574 additions and 188 deletions

186
RELATORIO_TESTES.md Normal file
View File

@@ -0,0 +1,186 @@
# Relatório de Testes - Sistema de Central de Chamados
**Data:** 16 de novembro de 2025
**Testador:** Sistema Automatizado
**Página Testada:** `/ti/central-chamados`
## Resumo Executivo
Foram realizados testes completos na página de Central de Chamados do sistema SGSE. A maioria das funcionalidades está funcionando corretamente, mas foram identificados alguns problemas que precisam ser corrigidos.
## Testes Realizados
### ✅ Testes Bem-Sucedidos
1. **Login no Sistema**
- Status: ✅ PASSOU
- Usuário logado: Deyvison (dfw@poli.br)
2. **Visualização de SLAs Configurados**
- Status: ✅ PASSOU
- Tabela de SLAs exibe 7 SLAs ativos corretamente
- Resumo mostra: 4 Baixa, 2 Média, 1 Alta/Crítica
- Detalhes completos (tempos, prioridades) são exibidos corretamente
3. **Cards de Prioridade**
- Status: ✅ PASSOU
- Cards mostram corretamente "Configurado" ou "Não configurado"
- Botão "Configurar" funciona corretamente
- Detalhes dos SLAs configurados são exibidos nos cards
4. **Criação de SLA**
- Status: ✅ PASSOU
- SLA criado com sucesso para prioridade "Alta"
- Formulário preenche corretamente quando clica em "Configurar"
- Tabela atualiza automaticamente após criação
- Card de prioridade atualiza para "Configurado"
5. **Edição de SLA**
- Status: ✅ PASSOU
- Botão "Editar" abre formulário com dados corretos
- Atualização funciona corretamente
6. **Lista de Chamados**
- Status: ✅ PASSOU
- 4 chamados sendo exibidos corretamente
- Filtros funcionando (status, responsável, setor)
- Detalhes do chamado são exibidos ao selecionar
7. **Atribuição de Responsável**
- Status: ✅ PASSOU
- Dropdown mostra 2 usuários TI: Deyvison e Suporte_TI
- Formulário está funcional
8. **Prorrogação de Prazo**
- Status: ✅ PASSOU
- Dropdown de tickets carrega corretamente (4 tickets)
- Formulário permite selecionar tipo de prazo e horas
- Botão habilita quando todos os campos estão preenchidos
### ⚠️ Problemas Identificados
#### 1. Templates de Email - Listagem Após Criação
- **Status:** ⚠️ PROBLEMA
- **Descrição:** Templates são criados com sucesso (mensagem "Templates padrão criados com sucesso" aparece), mas não são listados na interface após criação
- **Ação Realizada:** Botão "Criar templates padrão" foi clicado e retornou sucesso
- **Comportamento Esperado:** Templates deveriam aparecer em uma lista após criação
- **Comportamento Atual:** Seção continua mostrando "Nenhum template encontrado"
- **Severidade:** MÉDIA
- **Impacto:** Usuários não conseguem visualizar/editar templates de email após criação
- **Possível Causa:** Query de templates pode não estar sendo atualizada após criação, ou filtro pode estar excluindo templates de chamados
#### 2. Warning no Console - Token de Autenticação
- **Status:** ⚠️ AVISO (Não crítico)
- **Descrição:** `⚠️ [useConvexWithAuth] Token não disponível` aparece no console durante carregamento inicial
- **Severidade:** BAIXA
- **Impacto:** Não afeta funcionalidade (autenticação funciona corretamente após carregamento)
- **Observação:** Parece ser um problema de timing durante inicialização da página
#### 3. Warning no Console - Formato de Query
- **Status:** ⚠️ AVISO (Não crítico)
- **Descrição:** `🔍 [usuariosTI] Formato inesperado: object {data: undefined, isLoading: undefined, error: undefined, isStale: undefined}` aparece no console
- **Severidade:** BAIXA
- **Impacto:** Não afeta funcionalidade (usuários são carregados corretamente - 2 usuários TI encontrados)
- **Observação:** Indica possível inconsistência no formato de retorno da query durante carregamento inicial
## Detalhes dos Testes
### Teste de Criação de SLA
- **Prioridade Testada:** Alta
- **Valores Inseridos:**
- Nome: "SLA - Alta - Teste"
- Tempo de Resposta: 2h
- Tempo de Conclusão: 8h
- Auto-encerramento: 24h
- Alerta: 2h antes
- **Resultado:** ✅ SLA criado e exibido na tabela e no card
### Teste de Edição de SLA
- **SLA Editado:** Prioridade Baixa
- **Alterações:**
- Nome: "SLA Baixa - Editado em Teste"
- Tempo de Resposta: 6h
- **Resultado:** ✅ Atualização bem-sucedida
### Teste de Prorrogação
- **Ticket Selecionado:** SGSE-202511-3750
- **Prazo:** Conclusão
- **Horas Adicionais:** 24h
- **Motivo:** "Teste de prorrogação de prazo - necessário mais tempo para análise"
- **Resultado:** ✅ Formulário preenchido corretamente, botão habilitado
## Lista de Erros Encontrados
### Erros Críticos
- **Nenhum erro crítico encontrado**
### Erros de Funcionalidade
1. **Templates de Email não aparecem após criação**
- Localização: Seção "Templates de Email - Chamados"
- Ação necessária: Verificar query de templates e atualização reativa após criação
### Avisos (Warnings)
1. **Token de autenticação não disponível durante carregamento inicial**
- Localização: Console do navegador
- Ação necessária: Melhorar timing de inicialização de autenticação
2. **Formato inesperado de query durante carregamento**
- Localização: Console do navegador (usuariosTI)
- Ação necessária: Verificar formato de retorno de useQuery do convex-svelte
## Recomendações
### Prioridade ALTA
1. **Corrigir listagem de templates de email após criação**
- Verificar se a query `templatesChamados` está sendo atualizada após criação
- Verificar se o filtro de templates está correto (deve incluir templates de chamados)
- Adicionar refresh automático após criação de templates
### Prioridade MÉDIA
2. **Investigar e corrigir warnings no console**
- Melhorar timing de autenticação para evitar warning inicial
- Padronizar formato de retorno de queries do convex-svelte
### Prioridade BAIXA
3. **Melhorar logs de debug**
- Reduzir verbosidade de logs informativos
- Manter apenas logs de erro e warnings importantes
## Conclusão
O sistema está **funcionalmente operacional**, com a maioria das funcionalidades testadas funcionando corretamente:
**Funcionalidades Testadas e Funcionando:**
- Login e autenticação
- Visualização de SLAs (tabela e cards)
- Criação de SLAs
- Edição de SLAs
- Lista de chamados
- Atribuição de responsável
- Prorrogação de prazo (formulário funcional)
- Criação de templates (backend funciona, frontend não atualiza)
⚠️ **Problemas Identificados:**
- Templates não aparecem na lista após criação (problema de atualização reativa)
- Warnings no console (não afetam funcionalidade)
**Status Geral:****OPERACIONAL COM PEQUENOS AJUSTES NECESSÁRIOS**
**Próximos Passos:**
1. Corrigir atualização reativa de templates após criação
2. Investigar e resolver warnings do console (opcional, não crítico)

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

@@ -1,28 +1,23 @@
<script lang="ts">
import type { Doc, Id } from "@sgse-app/backend/convex/_generated/dataModel";
import type { Doc } from "@sgse-app/backend/convex/_generated/dataModel";
import { createEventDispatcher } from "svelte";
type SlaConfig = Doc<"slaConfigs">;
interface FormValues {
titulo: string;
descricao: string;
tipo: Doc<"tickets">["tipo"];
prioridade: Doc<"tickets">["prioridade"];
categoria: string;
slaConfigId?: Id<"slaConfigs">;
canalOrigem?: string;
anexos: File[];
}
interface Props {
slaConfigs?: Array<SlaConfig>;
loading?: boolean;
}
const dispatch = createEventDispatcher<{ submit: { values: FormValues } }>();
const props = $props<Props>();
const slaConfigs = $derived<Array<SlaConfig>>(props.slaConfigs ?? []);
const loading = $derived(props.loading ?? false);
let titulo = $state("");
@@ -30,7 +25,6 @@ const loading = $derived(props.loading ?? false);
let tipo = $state<Doc<"tickets">["tipo"]>("chamado");
let prioridade = $state<Doc<"tickets">["prioridade"]>("media");
let categoria = $state("");
let slaConfigId = $state<Id<"slaConfigs"> | "">("");
let canalOrigem = $state("Portal SGSE");
let anexos = $state<Array<File>>([]);
let errors = $state<Record<string, string>>({});
@@ -67,10 +61,6 @@ let slaConfigId = $state<Id<"slaConfigs"> | "">("");
event.preventDefault();
if (!validate()) return;
const slaSelecionada =
(slaConfigId && slaConfigId !== "" ? (slaConfigId as Id<"slaConfigs">) : slaConfigs[0]?._id) ??
undefined;
dispatch("submit", {
values: {
titulo: titulo.trim(),
@@ -78,7 +68,6 @@ let slaConfigId = $state<Id<"slaConfigs"> | "">("");
tipo,
prioridade,
categoria: categoria.trim(),
slaConfigId: slaSelecionada,
canalOrigem,
anexos,
},
@@ -150,22 +139,6 @@ let slaConfigId = $state<Id<"slaConfigs"> | "">("");
<span class="text-error mt-1 text-sm">{errors.categoria}</span>
{/if}
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-semibold">Configuração de SLA</span>
</label>
<select class="select select-bordered w-full" bind:value={slaConfigId}>
{#each slaConfigs as sla (sla._id)}
<option value={sla._id}>
{sla.nome} • Resp. {sla.tempoRespostaHoras}h • Conc.
{sla.tempoConclusaoHoras}h
</option>
{:else}
<option value="">Padrão (24h)</option>
{/each}
</select>
</div>
</section>
<section class="form-control">

View File

@@ -30,15 +30,25 @@ export function useConvexWithAuth() {
const clientWithAuth = client as ConvexClientWithAuth;
// Configurar token se disponível
if (clientWithAuth && typeof clientWithAuth.setAuth === "function" && token) {
if (clientWithAuth && token) {
try {
clientWithAuth.setAuth(token);
if (import.meta.env.DEV) {
console.log("✅ [useConvexWithAuth] Token configurado:", token.substring(0, 20) + "...");
// Tentar setAuth se disponível
if (typeof clientWithAuth.setAuth === "function") {
clientWithAuth.setAuth(token);
if (import.meta.env.DEV) {
console.log("✅ [useConvexWithAuth] Token configurado via setAuth:", token.substring(0, 20) + "...");
}
} else {
// Se setAuth não estiver disponível, o token deve ser passado via createSvelteAuthClient
if (import.meta.env.DEV) {
console.log(" [useConvexWithAuth] Token disponível, autenticação gerenciada por createSvelteAuthClient");
}
}
} catch (e) {
console.warn("⚠️ [useConvexWithAuth] Erro ao configurar token:", e);
}
} else if (!token && import.meta.env.DEV) {
console.warn("⚠️ [useConvexWithAuth] Token não disponível");
}
return client;

View File

@@ -7,14 +7,12 @@
import TicketTimeline from "$lib/components/chamados/TicketTimeline.svelte";
import { chamadosStore } from "$lib/stores/chamados";
import { resolve } from "$app/paths";
import { useConvexWithAuth } from "$lib/hooks/useConvexWithAuth";
type Ticket = Doc<"tickets">;
type SlaConfig = Doc<"slaConfigs">;
const client = useConvexClient();
let slaConfigs = $state<Array<SlaConfig>>([]);
let carregandoSla = $state(true);
let submitLoading = $state(false);
let resetSignal = $state(0);
let feedback = $state<{ tipo: "success" | "error"; mensagem: string; numero?: string } | null>(
@@ -41,23 +39,11 @@
},
]);
onMount(() => {
carregarSlaConfigs();
$effect(() => {
// Garante que o cliente Convex use o token do usuário logado
useConvexWithAuth();
});
async function carregarSlaConfigs() {
try {
carregandoSla = true;
const lista = await client.query(api.chamados.listarSlaConfigs, {});
slaConfigs = lista ?? [];
} catch (error) {
console.error("Erro ao carregar SLA:", error);
slaConfigs = [];
} finally {
carregandoSla = false;
}
}
async function uploadArquivo(file: File) {
const uploadUrl = await client.mutation(api.chamados.generateUploadUrl, {});
@@ -98,7 +84,6 @@
tipo: values.tipo,
categoria: values.categoria,
prioridade: values.prioridade,
slaConfigId: values.slaConfigId,
canalOrigem: values.canalOrigem,
anexos,
});
@@ -186,53 +171,15 @@
</p>
<div class="mt-6">
{#if resetSignal % 2 === 0}
<TicketForm {slaConfigs} loading={submitLoading} on:submit={handleSubmit} />
<TicketForm loading={submitLoading} on:submit={handleSubmit} />
{:else}
<TicketForm {slaConfigs} loading={submitLoading} on:submit={handleSubmit} />
<TicketForm loading={submitLoading} on:submit={handleSubmit} />
{/if}
</div>
</div>
</div>
<aside class="space-y-6">
<div class="rounded-3xl border border-base-200 bg-base-100/90 p-6 shadow-lg">
<h3 class="font-semibold text-base-content">Configurações de SLA</h3>
{#if carregandoSla}
<div class="flex items-center justify-center py-6">
<span class="loading loading-spinner loading-md"></span>
</div>
{:else if slaConfigs.length === 0}
<p class="text-sm text-base-content/60">
Nenhuma configuração customizada cadastrada. Os prazos padrão serão aplicados (Resposta:
4h, Conclusão: 24h).
</p>
{:else}
<div class="mt-4 space-y-4">
{#each slaConfigs as sla (sla._id)}
<div class="rounded-2xl border border-primary/20 bg-primary/5 p-4">
<p class="text-sm font-semibold text-primary">{sla.nome}</p>
<p class="text-xs text-base-content/60">{sla.descricao}</p>
<div class="mt-3 grid grid-cols-2 gap-2 text-xs">
<div class="rounded-xl bg-base-100/90 p-2 text-center">
<p class="font-semibold">{sla.tempoRespostaHoras}h</p>
<p class="text-base-content/60">Resposta</p>
</div>
<div class="rounded-xl bg-base-100/90 p-2 text-center">
<p class="font-semibold">{sla.tempoConclusaoHoras}h</p>
<p class="text-base-content/60">Conclusão</p>
</div>
</div>
{#if sla.alertaAntecedenciaHoras}
<p class="text-[11px] text-base-content/50 mt-2">
Alerta {sla.alertaAntecedenciaHoras}h antes do prazo.
</p>
{/if}
</div>
{/each}
</div>
{/if}
</div>
<div class="rounded-3xl border border-base-200 bg-base-100/90 p-6 shadow-lg">
<h3 class="font-semibold text-base-content">Como funciona a timeline</h3>
<p class="text-sm text-base-content/60 mb-4">

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import { useConvexClient, useQuery } from 'convex-svelte';
import { api } from '@sgse-app/backend/convex/_generated/api';
import { resolve } from '$app/paths';
import AprovarFerias from '$lib/components/AprovarFerias.svelte';
import WizardSolicitacaoFerias from '$lib/components/ferias/WizardSolicitacaoFerias.svelte';
import WizardSolicitacaoAusencia from '$lib/components/ausencias/WizardSolicitacaoAusencia.svelte';
@@ -603,6 +604,29 @@ const meusTimesGestor = $derived(timesSubordinados);
Minhas Ausências
</button>
<a
role="tab"
href={resolve('/perfil/chamados')}
class="tab tab-lg font-semibold transition-all duration-300 hover:bg-base-100"
aria-label="Meus Chamados"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="mr-2 h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M3 7h18M3 12h12M3 17h18"
/>
</svg>
Meus Chamados
</a>
{#if ehGestor}
<button
type="button"

View File

@@ -14,6 +14,7 @@ import { chamadosStore } from "$lib/stores/chamados";
prazoRestante,
} from "$lib/utils/chamados";
import { resolve } from "$app/paths";
import { useConvexWithAuth } from "$lib/hooks/useConvexWithAuth";
type Ticket = Doc<"tickets">;
@@ -45,6 +46,10 @@ const ticketsFiltrados = $derived(
onMount(() => {
carregarChamados();
});
$effect(() => {
// Configura o token de autenticação no cliente Convex
useConvexWithAuth();
});
async function carregarChamados() {
try {

View File

@@ -100,14 +100,13 @@ function montarTimeline(base: number, prazos: ReturnType<typeof calcularPrazos>)
return timeline;
}
async function selecionarSlaConfig(ctx: Parameters<typeof getCurrentUserFunction>[0], slaConfigId?: Id<"slaConfigs">) {
if (slaConfigId) {
return await ctx.db.get(slaConfigId);
}
async function selecionarSlaConfig(
ctx: Parameters<typeof getCurrentUserFunction>[0],
prioridade: "baixa" | "media" | "alta" | "critica"
): Promise<Doc<"slaConfigs"> | null> {
return await ctx.db
.query("slaConfigs")
.withIndex("by_ativo", (q) => q.eq("ativo", true))
.withIndex("by_prioridade", (q) => q.eq("prioridade", prioridade).eq("ativo", true))
.first();
}
@@ -122,6 +121,7 @@ async function registrarNotificacoes(
) {
const { ticket, titulo, mensagem, usuarioEvento } = params;
// Notificar solicitante
if (ticket.solicitanteEmail) {
await ctx.runMutation(api.email.enfileirarEmail, {
destinatario: ticket.solicitanteEmail,
@@ -142,6 +142,31 @@ async function registrarNotificacoes(
lida: false,
criadaEm: Date.now(),
});
// Notificar responsável (se houver)
if (ticket.responsavelId && ticket.responsavelId !== ticket.solicitanteId) {
const responsavel = await ctx.db.get(ticket.responsavelId);
if (responsavel?.email) {
await ctx.runMutation(api.email.enfileirarEmail, {
destinatario: responsavel.email,
destinatarioId: ticket.responsavelId,
assunto: `${titulo} - Chamado ${ticket.numero}`,
corpo: `${mensagem}\n\n---\nCentral de Chamados SGSE`,
enviadoPor: usuarioEvento,
});
}
await ctx.db.insert("notificacoes", {
usuarioId: ticket.responsavelId,
tipo: "nova_mensagem",
...(ticket.conversaId ? { conversaId: ticket.conversaId } : {}),
remetenteId: usuarioEvento,
titulo,
descricao: mensagem.length > 120 ? `${mensagem.slice(0, 117)}...` : mensagem,
lida: false,
criadaEm: Date.now(),
});
}
}
async function registrarInteracao(
@@ -185,7 +210,6 @@ export const abrirChamado = mutation({
categoria: v.optional(v.string()),
prioridade: prioridadeValidator,
anexos: v.optional(v.array(arquivoValidator)),
slaConfigId: v.optional(v.id("slaConfigs")),
canalOrigem: v.optional(v.string()),
},
returns: v.object({
@@ -195,7 +219,7 @@ export const abrirChamado = mutation({
handler: async (ctx, args) => {
const usuario = await assertAuth(ctx);
const agora = Date.now();
const sla = await selecionarSlaConfig(ctx, args.slaConfigId);
const sla = await selecionarSlaConfig(ctx, args.prioridade);
const prazos = calcularPrazos(agora, sla);
const timeline = montarTimeline(agora, prazos);
@@ -304,8 +328,23 @@ export const listarChamadosTI = query({
return true;
});
filtrados.sort((a, b) => b.atualizadoEm - a.atualizadoEm);
return args.limite ? filtrados.slice(0, args.limite) : filtrados;
// Enriquecer tickets com nome do responsável
const ticketsEnriquecidos = await Promise.all(
filtrados.map(async (ticket) => {
let responsavelNome: string | undefined = undefined;
if (ticket.responsavelId) {
const responsavel = await ctx.db.get(ticket.responsavelId);
responsavelNome = responsavel?.nome;
}
return {
...ticket,
responsavelNome,
};
})
);
ticketsEnriquecidos.sort((a, b) => b.atualizadoEm - a.atualizadoEm);
return args.limite ? ticketsEnriquecidos.slice(0, args.limite) : ticketsEnriquecidos;
},
});
@@ -481,12 +520,108 @@ 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")),
nome: v.string(),
descricao: v.optional(v.string()),
setores: v.optional(v.array(v.string())),
prioridade: prioridadeValidator,
tempoRespostaHoras: v.number(),
tempoConclusaoHoras: v.number(),
tempoEncerramentoHoras: v.optional(v.number()),
@@ -501,7 +636,7 @@ export const salvarSlaConfig = mutation({
await ctx.db.patch(args.slaId, {
nome: args.nome,
descricao: args.descricao,
setores: args.setores,
prioridade: args.prioridade,
tempoRespostaHoras: args.tempoRespostaHoras,
tempoConclusaoHoras: args.tempoConclusaoHoras,
tempoEncerramentoHoras: args.tempoEncerramentoHoras,
@@ -516,7 +651,7 @@ export const salvarSlaConfig = mutation({
return await ctx.db.insert("slaConfigs", {
nome: args.nome,
descricao: args.descricao,
setores: args.setores,
prioridade: args.prioridade,
tempoRespostaHoras: args.tempoRespostaHoras,
tempoConclusaoHoras: args.tempoConclusaoHoras,
tempoEncerramentoHoras: args.tempoEncerramentoHoras,
@@ -530,6 +665,102 @@ export const salvarSlaConfig = mutation({
},
});
export const excluirSlaConfig = mutation({
args: {
slaId: v.id("slaConfigs"),
},
handler: async (ctx, args) => {
await assertAuth(ctx);
const sla = await ctx.db.get(args.slaId);
if (!sla) {
throw new Error("Configuração de SLA não encontrada");
}
await ctx.db.delete(args.slaId);
return { sucesso: true };
},
});
export const prorrogarChamado = mutation({
args: {
ticketId: v.id("tickets"),
horasAdicionais: v.number(),
prazo: v.union(v.literal("resposta"), v.literal("conclusao")),
motivo: v.string(),
},
handler: async (ctx, args) => {
const usuario = await assertAuth(ctx);
const ticket = await ctx.db.get(args.ticketId);
if (!ticket) {
throw new Error("Chamado não encontrado");
}
const agora = Date.now();
const horasMs = args.horasAdicionais * 60 * 60 * 1000;
let novoPrazoResposta = ticket.prazoResposta;
let novoPrazoConclusao = ticket.prazoConclusao;
let prazoExtendido: number;
if (args.prazo === "resposta") {
prazoExtendido = (ticket.prazoResposta || agora) + horasMs;
novoPrazoResposta = prazoExtendido;
// Se o prazo de conclusão é antes do novo prazo de resposta, ajuste-o também
if (ticket.prazoConclusao && ticket.prazoConclusao < prazoExtendido) {
novoPrazoConclusao = prazoExtendido + (ticket.prazoConclusao - (ticket.prazoResposta || agora));
}
} else {
prazoExtendido = (ticket.prazoConclusao || agora) + horasMs;
novoPrazoConclusao = prazoExtendido;
}
// Atualizar timeline
const timelineAtualizada = ticket.timeline?.map((etapa) => {
if (args.prazo === "resposta" && etapa.etapa === "resposta_inicial") {
return {
...etapa,
prazo: prazoExtendido,
status: prazoExtendido > agora ? "pendente" : etapa.status,
};
}
if (args.prazo === "conclusao" && etapa.etapa === "conclusao") {
return {
...etapa,
prazo: prazoExtendido,
status: prazoExtendido > agora ? "pendente" : etapa.status,
};
}
return etapa;
}) || ticket.timeline;
await ctx.db.patch(ticket._id, {
prazoResposta: novoPrazoResposta,
prazoConclusao: novoPrazoConclusao,
timeline: timelineAtualizada,
atualizadoEm: agora,
ultimaInteracaoEm: agora,
});
await registrarInteracao(ctx, {
ticketId: ticket._id,
autorId: usuario._id,
origem: "ti",
tipo: "status",
conteudo: `Prazo ${args.prazo === "resposta" ? "de resposta" : "de conclusão"} prorrogado em ${args.horasAdicionais}h. Motivo: ${args.motivo}`,
});
const ticketAtualizado = await ctx.db.get(ticket._id);
if (ticketAtualizado) {
await registrarNotificacoes(ctx, {
ticket: ticketAtualizado,
titulo: `Prazo prorrogado - Chamado ${ticketAtualizado.numero}`,
mensagem: `O prazo ${args.prazo === "resposta" ? "de resposta" : "de conclusão"} foi prorrogado em ${args.horasAdicionais} horas. Motivo: ${args.motivo}`,
usuarioEvento: usuario._id,
});
}
return { sucesso: true };
},
});
export const emitirAlertaPrazo = mutation({
args: {
ticketId: v.id("tickets"),

View File

@@ -1027,7 +1027,14 @@ export default defineSchema({
slaConfigs: defineTable({
nome: v.string(),
descricao: v.optional(v.string()),
setores: v.optional(v.array(v.string())),
prioridade: v.optional(
v.union(
v.literal("baixa"),
v.literal("media"),
v.literal("alta"),
v.literal("critica")
)
),
tempoRespostaHoras: v.number(),
tempoConclusaoHoras: v.number(),
tempoEncerramentoHoras: v.optional(v.number()),
@@ -1039,6 +1046,7 @@ export default defineSchema({
atualizadoEm: v.number(),
})
.index("by_ativo", ["ativo"])
.index("by_prioridade", ["prioridade", "ativo"])
.index("by_nome", ["nome"]),
ticketAssignments: defineTable({

View File

@@ -287,6 +287,115 @@ export const criarTemplatesPadrao = mutation({
+ "</div></body></html>",
variaveis: ["remetente", "mensagem", "conversaId", "urlSistema"],
},
{
codigo: "chamado_registrado",
nome: "Chamado Registrado",
titulo: "Chamado {{numeroTicket}} registrado",
corpo: "<html><body style='font-family: Arial, sans-serif; line-height: 1.6; color: #333;'>"
+ "<div style='max-width: 600px; margin: 0 auto; padding: 20px;'>"
+ "<h2 style='color: #2563EB;'>Chamado registrado com sucesso!</h2>"
+ "<p>Olá <strong>{{solicitante}}</strong>,</p>"
+ "<p>Recebemos sua solicitação e iniciaremos o atendimento em breve.</p>"
+ "<div style='background-color: #EFF6FF; border-left: 4px solid #2563EB; padding: 15px; border-radius: 8px; margin: 20px 0;'>"
+ "<p style='margin: 0;'><strong>Ticket:</strong> {{numeroTicket}}</p>"
+ "<p style='margin: 5px 0 0 0;'><strong>Prioridade:</strong> {{prioridade}}</p>"
+ "<p style='margin: 5px 0 0 0;'><strong>Categoria:</strong> {{categoria}}</p>"
+ "</div>"
+ "<p style='margin-top: 30px;'>"
+ "<a href='{{urlSistema}}/perfil/chamados' "
+ "style='background-color: #2563EB; color: white; padding: 12px 24px; "
+ "text-decoration: none; border-radius: 6px; display: inline-block;'>"
+ "Acompanhar chamado"
+ "</a>"
+ "</p>"
+ "<p style='color: #6B7280; font-size: 12px; margin-top: 30px;'>"
+ "Central de Chamados SGSE"
+ "</p>"
+ "</div></body></html>",
variaveis: ["solicitante", "numeroTicket", "prioridade", "categoria", "urlSistema"],
},
{
codigo: "chamado_atualizado",
nome: "Atualização no Chamado",
titulo: "Atualização no chamado {{numeroTicket}}",
corpo: "<html><body style='font-family: Arial, sans-serif; line-height: 1.6; color: #333;'>"
+ "<div style='max-width: 600px; margin: 0 auto; padding: 20px;'>"
+ "<h2 style='color: #2563EB;'>Nova atualização no seu chamado</h2>"
+ "<p>Olá <strong>{{solicitante}}</strong>,</p>"
+ "<p>Há uma nova atualização no seu chamado:</p>"
+ "<div style='background-color: #EFF6FF; border-left: 4px solid #2563EB; padding: 15px; border-radius: 8px; margin: 20px 0;'>"
+ "<p style='margin: 0;'><strong>Ticket:</strong> {{numeroTicket}}</p>"
+ "<p style='margin: 5px 0 0 0;'><strong>Mensagem:</strong></p>"
+ "<p style='margin: 10px 0 0 0;'>{{mensagem}}</p>"
+ "</div>"
+ "<p style='margin-top: 30px;'>"
+ "<a href='{{urlSistema}}/perfil/chamados' "
+ "style='background-color: #2563EB; color: white; padding: 12px 24px; "
+ "text-decoration: none; border-radius: 6px; display: inline-block;'>"
+ "Ver detalhes"
+ "</a>"
+ "</p>"
+ "<p style='color: #6B7280; font-size: 12px; margin-top: 30px;'>"
+ "Central de Chamados SGSE"
+ "</p>"
+ "</div></body></html>",
variaveis: ["solicitante", "numeroTicket", "mensagem", "urlSistema"],
},
{
codigo: "chamado_atribuido",
nome: "Chamado Atribuído",
titulo: "Chamado {{numeroTicket}} atribuído",
corpo: "<html><body style='font-family: Arial, sans-serif; line-height: 1.6; color: #333;'>"
+ "<div style='max-width: 600px; margin: 0 auto; padding: 20px;'>"
+ "<h2 style='color: #059669;'>Chamado atribuído</h2>"
+ "<p>Olá <strong>{{responsavel}}</strong>,</p>"
+ "<p>Um novo chamado foi atribuído para você:</p>"
+ "<div style='background-color: #ECFDF5; border-left: 4px solid #059669; padding: 15px; border-radius: 8px; margin: 20px 0;'>"
+ "<p style='margin: 0;'><strong>Ticket:</strong> {{numeroTicket}}</p>"
+ "<p style='margin: 5px 0 0 0;'><strong>Solicitante:</strong> {{solicitante}}</p>"
+ "<p style='margin: 5px 0 0 0;'><strong>Prioridade:</strong> {{prioridade}}</p>"
+ "<p style='margin: 5px 0 0 0;'><strong>Descrição:</strong> {{descricao}}</p>"
+ "</div>"
+ "<p style='margin-top: 30px;'>"
+ "<a href='{{urlSistema}}/ti/central-chamados' "
+ "style='background-color: #059669; color: white; padding: 12px 24px; "
+ "text-decoration: none; border-radius: 6px; display: inline-block;'>"
+ "Acessar chamado"
+ "</a>"
+ "</p>"
+ "<p style='color: #6B7280; font-size: 12px; margin-top: 30px;'>"
+ "Central de Chamados SGSE"
+ "</p>"
+ "</div></body></html>",
variaveis: ["responsavel", "numeroTicket", "solicitante", "prioridade", "descricao", "urlSistema"],
},
{
codigo: "chamado_alerta_prazo",
nome: "Alerta de Prazo do Chamado",
titulo: "⚠️ Alerta de prazo - Chamado {{numeroTicket}}",
corpo: "<html><body style='font-family: Arial, sans-serif; line-height: 1.6; color: #333;'>"
+ "<div style='max-width: 600px; margin: 0 auto; padding: 20px;'>"
+ "<h2 style='color: #DC2626;'>⚠️ Alerta de prazo</h2>"
+ "<p>Olá <strong>{{destinatario}}</strong>,</p>"
+ "<p>O chamado abaixo está próximo do prazo de {{tipoPrazo}}:</p>"
+ "<div style='background-color: #FEF2F2; border-left: 4px solid #DC2626; padding: 15px; border-radius: 8px; margin: 20px 0;'>"
+ "<p style='margin: 0;'><strong>Ticket:</strong> {{numeroTicket}}</p>"
+ "<p style='margin: 5px 0 0 0;'><strong>Prazo de {{tipoPrazo}}:</strong> {{prazo}}</p>"
+ "<p style='margin: 5px 0 0 0;'><strong>Status:</strong> {{status}}</p>"
+ "</div>"
+ "<p style='margin-top: 30px;'>"
+ "<a href='{{urlSistema}}{{rotaAcesso}}' "
+ "style='background-color: #DC2626; color: white; padding: 12px 24px; "
+ "text-decoration: none; border-radius: 6px; display: inline-block;'>"
+ "Ver chamado"
+ "</a>"
+ "</p>"
+ "<p style='color: #6B7280; font-size: 12px; margin-top: 30px;'>"
+ "Central de Chamados SGSE"
+ "</p>"
+ "</div></body></html>",
variaveis: ["destinatario", "numeroTicket", "tipoPrazo", "prazo", "status", "urlSistema", "rotaAcesso"],
},
];
for (const template of templatesPadrao) {