refactor: update menu and routing for ticket management

- Replaced references to "Solicitar Acesso" with "Abrir Chamado" across the application for consistency in terminology.
- Updated routing logic to reflect the new ticket management flow, ensuring that the dashboard and sidebar components point to the correct paths.
- Removed the obsolete "Solicitar Acesso" page, streamlining the user experience and reducing unnecessary navigation options.
- Enhanced backend schema to support new ticket functionalities, including ticket creation and management.
This commit is contained in:
2025-11-14 22:50:03 -03:00
parent 9b3b095c01
commit 118051ad56
17 changed files with 2353 additions and 358 deletions

View File

@@ -198,6 +198,19 @@
palette: "primary",
icon: "control",
},
{
title: "Central de Chamados",
description:
"Monitore tickets, configure SLA, atribua responsáveis e acompanhe alertas de prazos.",
ctaLabel: "Abrir Central",
href: "/ti/central-chamados",
palette: "info",
icon: "support",
highlightBadges: [
{ label: "SLA", variant: "solid" },
{ label: "Alertas", variant: "outline" },
],
},
{
title: "Suporte Técnico",
description:

View File

@@ -0,0 +1,388 @@
<script lang="ts">
import { onMount } from "svelte";
import { useConvexClient, useQuery } from "convex-svelte";
import { api } from "@sgse-app/backend/convex/_generated/api";
import type { Doc, Id } from "@sgse-app/backend/convex/_generated/dataModel";
import TicketTimeline from "$lib/components/chamados/TicketTimeline.svelte";
import {
formatarData,
getStatusBadge,
getStatusLabel,
prazoRestante,
} from "$lib/utils/chamados";
type Ticket = Doc<"tickets">;
type Usuario = Doc<"usuarios">;
type SlaConfig = Doc<"slaConfigs">;
const client = useConvexClient();
const usuariosQuery = useQuery(api.usuarios.listar, {});
const slaConfigsQuery = useQuery(api.chamados.listarSlaConfigs, {});
let carregandoChamados = $state(true);
let tickets = $state<Array<Ticket>>([]);
let filtroStatus = $state<"todos" | Ticket["status"]>("todos");
let filtroResponsavel = $state<"todos" | Id<"usuarios">>("todos");
let filtroSetor = $state<string>("todos");
let ticketSelecionado = $state<Id<"tickets"> | null>(null);
let detalheSelecionado = $state<Ticket | null>(null);
let assignResponsavel = $state<Id<"usuarios"> | "">("");
let assignMotivo = $state("");
let assignFeedback = $state<string | null>(null);
let slaForm = $state<{
slaId?: Id<"slaConfigs">;
nome: string;
descricao: string;
tempoRespostaHoras: number;
tempoConclusaoHoras: number;
tempoEncerramentoHoras?: number | null;
alertaAntecedenciaHoras: number;
ativo: boolean;
}>({
nome: "",
descricao: "",
tempoRespostaHoras: 4,
tempoConclusaoHoras: 24,
tempoEncerramentoHoras: 72,
alertaAntecedenciaHoras: 2,
ativo: true,
});
let slaFeedback = $state<string | null>(null);
let carregamentoToken = 0;
$effect(() => {
const filtros = {
status: filtroStatus === "todos" ? undefined : filtroStatus,
responsavelId: filtroResponsavel === "todos" ? undefined : filtroResponsavel,
setor: filtroSetor === "todos" ? undefined : filtroSetor,
};
carregarChamados(filtros);
});
onMount(() => {
if (slaConfigsQuery?.data && slaConfigsQuery.data.length > 0) {
selecionarSla(slaConfigsQuery.data[0]);
}
});
async function carregarChamados(filtros: {
status?: Ticket["status"];
responsavelId?: Id<"usuarios">;
setor?: string;
}) {
try {
carregandoChamados = true;
const token = ++carregamentoToken;
const data = await client.query(api.chamados.listarChamadosTI, {
status: filtros.status,
responsavelId: filtros.responsavelId,
setor: filtros.setor,
});
if (token !== carregamentoToken) return;
tickets = data ?? [];
if (!ticketSelecionado && tickets.length > 0) {
selecionarTicket(tickets[0]._id);
}
} catch (error) {
console.error("Erro ao carregar chamados:", error);
} finally {
carregandoChamados = false;
}
}
function selecionarTicket(ticketId: Id<"tickets">) {
ticketSelecionado = ticketId;
detalheSelecionado = tickets.find((t) => t._id === ticketId) ?? null;
}
const usuariosTI = $derived(
(usuariosQuery?.data || []).filter((usuario: Usuario) => usuario.setor === "TI")
);
const estatisticas = $derived(() => {
const total = tickets.length;
const abertos = tickets.filter((t) => t.status === "aberto").length;
const emAndamento = tickets.filter((t) => t.status === "em_andamento").length;
const vencidos = tickets.filter(
(t) => (t.prazoConclusao && t.prazoConclusao < Date.now()) || t.status === "cancelado"
).length;
return { total, abertos, emAndamento, vencidos };
});
function selecionarSla(sla: SlaConfig) {
slaForm = {
slaId: sla._id,
nome: sla.nome,
descricao: sla.descricao ?? "",
tempoRespostaHoras: sla.tempoRespostaHoras,
tempoConclusaoHoras: sla.tempoConclusaoHoras,
tempoEncerramentoHoras: sla.tempoEncerramentoHoras ?? undefined,
alertaAntecedenciaHoras: sla.alertaAntecedenciaHoras,
ativo: sla.ativo,
};
}
async function salvarSlaConfig() {
try {
slaFeedback = null;
await client.mutation(api.chamados.salvarSlaConfig, {
...slaForm,
tempoEncerramentoHoras: slaForm.tempoEncerramentoHoras,
});
slaFeedback = "Configuração salva com sucesso.";
} catch (error) {
slaFeedback =
error instanceof Error ? error.message : "Erro ao salvar configuração de SLA.";
}
}
async function atribuirResponsavel() {
if (!ticketSelecionado || !assignResponsavel) {
assignFeedback = "Escolha um ticket e um responsável.";
return;
}
try {
assignFeedback = null;
await client.mutation(api.chamados.atribuirResponsavel, {
ticketId: ticketSelecionado,
responsavelId: assignResponsavel as Id<"usuarios">,
motivo: assignMotivo || undefined,
});
assignFeedback = "Responsável atribuído.";
assignMotivo = "";
await carregarChamados({
status: filtroStatus === "todos" ? undefined : filtroStatus,
responsavelId: filtroResponsavel === "todos" ? undefined : filtroResponsavel,
setor: filtroSetor === "todos" ? undefined : filtroSetor,
});
if (ticketSelecionado) {
selecionarTicket(ticketSelecionado);
}
} catch (error) {
assignFeedback = error instanceof Error ? error.message : "Erro ao atribuir responsável.";
}
}
</script>
<main class="mx-auto w-full max-w-7xl space-y-8 px-4 py-8">
<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>
</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>
</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>
</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>
</div>
</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>
<h2 class="text-xl font-semibold text-base-content">Painel de chamados</h2>
<p class="text-sm text-base-content/60">
Filtros por status, responsável e setor.
</p>
</div>
<div class="flex flex-wrap gap-2">
<select class="select select-sm select-bordered" bind:value={filtroStatus}>
<option value="todos">Todos os status</option>
<option value="aberto">Aberto</option>
<option value="em_andamento">Em andamento</option>
<option value="aguardando_usuario">Aguardando usuário</option>
<option value="resolvido">Resolvido</option>
<option value="encerrado">Encerrado</option>
<option value="cancelado">Cancelado</option>
</select>
<select class="select select-sm select-bordered" bind:value={filtroResponsavel}>
<option value="todos">Todos os responsáveis</option>
{#each usuariosTI as usuario (usuario._id)}
<option value={usuario._id}>{usuario.nome}</option>
{/each}
</select>
<select class="select select-sm select-bordered" bind:value={filtroSetor}>
<option value="todos">Todos os setores</option>
<option value="TI">TI</option>
<option value="Infraestrutura">Infraestrutura</option>
<option value="Sistemas">Sistemas</option>
</select>
</div>
</div>
<div class="mt-6 overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>Ticket</th>
<th>Tipo</th>
<th>Status</th>
<th>Responsável</th>
<th>Prioridade</th>
<th>Prazo</th>
</tr>
</thead>
<tbody>
{#if carregandoChamados}
<tr>
<td colspan="6">
<div class="flex items-center justify-center py-8">
<span class="loading loading-spinner loading-md"></span>
</div>
</td>
</tr>
{:else if tickets.length === 0}
<tr>
<td colspan="6" class="text-center text-sm text-base-content/60">
Nenhum chamado encontrado.
</td>
</tr>
{:else}
{#each tickets as ticket (ticket._id)}
<tr
class={ticketSelecionado === ticket._id ? "bg-primary/5" : ""}
onclick={() => selecionarTicket(ticket._id)}
>
<td>
<div class="font-semibold">{ticket.numero}</div>
<div class="text-xs text-base-content/60">{ticket.solicitanteNome}</div>
</td>
<td class="text-sm capitalize">{ticket.tipo}</td>
<td>
<span class={getStatusBadge(ticket.status)}>{getStatusLabel(ticket.status)}</span>
</td>
<td class="text-sm">{ticket.setorResponsavel ?? "—"}</td>
<td class="text-sm capitalize">{ticket.prioridade}</td>
<td class="text-xs text-base-content/70">
{ticket.prazoConclusao ? prazoRestante(ticket.prazoConclusao) : "--"}
</td>
</tr>
{/each}
{/if}
</tbody>
</table>
</div>
</section>
<section class="grid gap-6 lg:grid-cols-2">
<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">Detalhes do chamado</h3>
{#if !detalheSelecionado}
<p class="text-sm text-base-content/60">Selecione um chamado na tabela.</p>
{:else}
<div class="mt-4 space-y-3">
<div class="flex items-center justify-between">
<div>
<p class="text-xs text-base-content/60">Solicitante</p>
<p class="font-semibold text-base-content">{detalheSelecionado.solicitanteNome}</p>
</div>
<span class={getStatusBadge(detalheSelecionado.status)}>
{getStatusLabel(detalheSelecionado.status)}
</span>
</div>
<p class="text-sm text-base-content/70">{detalheSelecionado.descricao}</p>
<div class="grid grid-cols-2 gap-3 text-sm text-base-content/70">
<div>
<p class="text-xs text-base-content/50">Prazo resposta</p>
<p>{prazoRestante(detalheSelecionado.prazoResposta) ?? "--"}</p>
</div>
<div>
<p class="text-xs text-base-content/50">Prazo conclusão</p>
<p>{prazoRestante(detalheSelecionado.prazoConclusao) ?? "--"}</p>
</div>
</div>
<div class="mt-4">
<TicketTimeline timeline={detalheSelecionado.timeline ?? []} />
</div>
</div>
{/if}
</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">Atribuir responsável</h3>
<div class="mt-4 space-y-3">
<select class="select select-bordered w-full" bind:value={assignResponsavel}>
<option value="">Selecione o responsável</option>
{#each usuariosTI as usuario (usuario._id)}
<option value={usuario._id}>{usuario.nome}</option>
{/each}
</select>
<textarea
class="textarea textarea-bordered w-full"
rows="3"
placeholder="Motivo/observação"
bind:value={assignMotivo}
></textarea>
{#if assignFeedback}
<p class="text-sm text-base-content/70">{assignFeedback}</p>
{/if}
<button class="btn btn-primary w-full" type="button" onclick={atribuirResponsavel}>
Salvar
</button>
</div>
</div>
</section>
<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</h3>
<p class="text-sm text-base-content/60">Defina tempos de resposta, conclusão e alertas.</p>
</div>
<div class="flex gap-2">
{#if slaConfigsQuery?.data}
{#each slaConfigsQuery.data as sla (sla._id)}
<button class="btn btn-sm" type="button" onclick={() => selecionarSla(sla)}>
{sla.nome}
</button>
{/each}
{/if}
</div>
</div>
<div class="mt-6 grid gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label"><span class="label-text font-semibold">Nome</span></label>
<input class="input input-bordered w-full" bind:value={slaForm.nome} />
</div>
<div class="form-control">
<label class="label"><span class="label-text font-semibold">Descrição</span></label>
<input class="input input-bordered w-full" bind:value={slaForm.descricao} />
</div>
<div class="form-control">
<label class="label"><span class="label-text font-semibold">Tempo de resposta (h)</span></label>
<input type="number" min="1" class="input input-bordered w-full" bind:value={slaForm.tempoRespostaHoras} />
</div>
<div class="form-control">
<label class="label"><span class="label-text font-semibold">Tempo de conclusão (h)</span></label>
<input type="number" min="1" class="input input-bordered w-full" bind:value={slaForm.tempoConclusaoHoras} />
</div>
<div class="form-control">
<label class="label"><span class="label-text font-semibold">Auto-encerramento (h)</span></label>
<input type="number" min="1" class="input input-bordered w-full" bind:value={slaForm.tempoEncerramentoHoras} />
</div>
<div class="form-control">
<label class="label"><span class="label-text font-semibold">Alerta antes do vencimento (h)</span></label>
<input type="number" min="1" class="input input-bordered w-full" bind:value={slaForm.alertaAntecedenciaHoras} />
</div>
<label class="label cursor-pointer gap-3">
<span class="label-text font-semibold">Ativo</span>
<input type="checkbox" class="toggle toggle-primary" bind:checked={slaForm.ativo} />
</label>
</div>
{#if slaFeedback}
<p class="text-sm text-base-content/70 mt-3">{slaFeedback}</p>
{/if}
<button class="btn btn-primary mt-4" type="button" onclick={salvarSlaConfig}>
Salvar configuração
</button>
</section>
</main>