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:
@@ -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:
|
||||
|
||||
388
apps/web/src/routes/(dashboard)/ti/central-chamados/+page.svelte
Normal file
388
apps/web/src/routes/(dashboard)/ti/central-chamados/+page.svelte
Normal 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>
|
||||
|
||||
Reference in New Issue
Block a user