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

@@ -0,0 +1,321 @@
<script lang="ts">
import { onMount } from "svelte";
import { useConvexClient } from "convex-svelte";
import { api } from "@sgse-app/backend/convex/_generated/api";
import type { Doc, Id } from "@sgse-app/backend/convex/_generated/dataModel";
import TicketCard from "$lib/components/chamados/TicketCard.svelte";
import TicketTimeline from "$lib/components/chamados/TicketTimeline.svelte";
import { chamadosStore } from "$lib/stores/chamados";
import {
formatarData,
getStatusBadge,
getStatusDescription,
getStatusLabel,
prazoRestante,
} from "$lib/utils/chamados";
import { resolve } from "$app/paths";
type Ticket = Doc<"tickets">;
const client = useConvexClient();
const ticketsStore = chamadosStore.tickets;
const detalhesStore = chamadosStore.detalhes;
let carregandoLista = $state(true);
let carregandoDetalhe = $state(false);
let filtroStatus = $state<"todos" | Ticket["status"]>("todos");
let filtroTipo = $state<"todos" | Ticket["tipo"]>("todos");
let selectedTicketId = $state<Id<"tickets"> | null>(null);
let mensagem = $state("");
let erroMensagem = $state<string | null>(null);
let sucessoMensagem = $state<string | null>(null);
const listaChamados = $derived($ticketsStore);
const detalheAtual = $derived(
selectedTicketId ? ($detalhesStore[selectedTicketId] ?? null) : null
);
const ticketsFiltrados = $derived(
listaChamados.filter((ticket) => {
if (filtroStatus !== "todos" && ticket.status !== filtroStatus) return false;
if (filtroTipo !== "todos" && ticket.tipo !== filtroTipo) return false;
return true;
})
);
onMount(() => {
carregarChamados();
});
async function carregarChamados() {
try {
carregandoLista = true;
const data = await client.query(api.chamados.listarChamadosUsuario, {});
chamadosStore.setTickets(data ?? []);
if (!selectedTicketId && data && data.length > 0) {
selecionarChamado(data[0]._id);
}
} catch (error) {
console.error("Erro ao carregar chamados:", error);
} finally {
carregandoLista = false;
}
}
async function selecionarChamado(ticketId: Id<"tickets">) {
selectedTicketId = ticketId;
if (!$detalhesStore[ticketId]) {
try {
carregandoDetalhe = true;
const detalhe = await client.query(api.chamados.obterChamado, { ticketId });
if (detalhe) {
chamadosStore.setDetalhe(ticketId, detalhe);
}
} catch (error) {
console.error("Erro ao carregar detalhe:", error);
} finally {
carregandoDetalhe = false;
}
}
}
async function enviarMensagem() {
if (!selectedTicketId || !mensagem.trim()) {
erroMensagem = "Informe uma mensagem para atualizar o chamado.";
return;
}
try {
erroMensagem = null;
sucessoMensagem = null;
await client.mutation(api.chamados.registrarAtualizacao, {
ticketId: selectedTicketId,
conteudo: mensagem.trim(),
visibilidade: "publico",
});
mensagem = "";
sucessoMensagem = "Atualização registrada com sucesso.";
await selecionarChamado(selectedTicketId);
await carregarChamados();
} catch (error) {
const mensagemErro =
error instanceof Error ? error.message : "Erro ao enviar atualização. Tente novamente.";
erroMensagem = mensagemErro;
}
}
function statusAlertas(ticket: Ticket) {
const alertas: Array<{ label: string; tipo: "success" | "warning" | "error" }> = [];
if (ticket.prazoResposta) {
const diff = ticket.prazoResposta - Date.now();
if (diff < 0) alertas.push({ label: "Prazo de resposta vencido", tipo: "error" });
else if (diff <= 4 * 60 * 60 * 1000)
alertas.push({ label: "Resposta vence em breve", tipo: "warning" });
}
if (ticket.prazoConclusao) {
const diff = ticket.prazoConclusao - Date.now();
if (diff < 0) alertas.push({ label: "Prazo de conclusão vencido", tipo: "error" });
else if (diff <= 24 * 60 * 60 * 1000)
alertas.push({ label: "Conclusão vence em breve", tipo: "warning" });
}
return alertas;
}
</script>
<main class="mx-auto w-full max-w-7xl space-y-8 px-4 py-8">
<section
class="rounded-3xl border border-base-200 bg-base-100/90 p-8 shadow-xl"
>
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<p class="text-xs uppercase tracking-[0.25em] text-primary">Meu Perfil</p>
<h1 class="text-3xl font-black text-base-content">Meus Chamados</h1>
<p class="text-base-content/70 mt-2 text-sm">
Acompanhe o status, interaja com a equipe de TI e visualize a timeline de SLA em tempo real.
</p>
</div>
<div class="flex flex-wrap gap-3">
<a href={resolve("/abrir-chamado")} class="btn btn-primary">Abrir novo chamado</a>
<button class="btn btn-ghost" type="button" onclick={carregarChamados}>
Atualizar
</button>
</div>
</div>
</section>
<div class="grid gap-6 lg:grid-cols-[340px,1fr]">
<aside class="rounded-3xl border border-base-200 bg-base-100/80 p-4 shadow-lg">
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold text-base-content">Meus tickets</h2>
{#if carregandoLista}
<span class="loading loading-spinner loading-xs"></span>
{/if}
</div>
<div class="mt-4 space-y-3">
<select class="select select-sm select-bordered w-full" 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 w-full" bind:value={filtroTipo}>
<option value="todos">Todos os tipos</option>
<option value="chamado">Chamados técnicos</option>
<option value="reclamacao">Reclamações</option>
<option value="elogio">Elogios</option>
<option value="sugestao">Sugestões</option>
</select>
</div>
<div class="mt-6 space-y-3 overflow-y-auto pr-1" style="max-height: calc(100vh - 260px);">
{#if ticketsFiltrados.length === 0}
<div class="alert alert-info">
<span>Nenhum chamado encontrado.</span>
</div>
{:else}
{#each ticketsFiltrados as ticket (ticket._id)}
<TicketCard
{ticket}
selected={ticket._id === selectedTicketId}
on:select={({ detail }) => selecionarChamado(detail.ticketId)}
/>
{/each}
{/if}
</div>
</aside>
<section class="rounded-3xl border border-base-200 bg-base-100/90 p-6 shadow-xl">
{#if !selectedTicketId || !detalheAtual}
<div class="flex min-h-[400px] items-center justify-center text-base-content/60">
{#if carregandoDetalhe}
<span class="loading loading-spinner loading-lg"></span>
{:else}
<p>Selecione um chamado para visualizar os detalhes.</p>
{/if}
</div>
{:else}
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div>
<p class="text-xs uppercase text-base-content/60">Ticket {detalheAtual.ticket.numero}</p>
<h2 class="text-2xl font-bold text-base-content">{detalheAtual.ticket.titulo}</h2>
<p class="text-base-content/70 mt-1 text-sm">{detalheAtual.ticket.descricao}</p>
</div>
<span class={getStatusBadge(detalheAtual.ticket.status)}>
{getStatusLabel(detalheAtual.ticket.status)}
</span>
</div>
<div class="mt-4 flex flex-wrap gap-3 text-sm text-base-content/70">
<span class="badge badge-outline badge-sm">
Tipo: {detalheAtual.ticket.tipo.charAt(0).toUpperCase() + detalheAtual.ticket.tipo.slice(1)}
</span>
<span class="badge badge-outline badge-sm">
Prioridade: {detalheAtual.ticket.prioridade}
</span>
<span class="badge badge-outline badge-sm">
Última interação: {formatarData(detalheAtual.ticket.ultimaInteracaoEm)}
</span>
</div>
{#if statusAlertas(detalheAtual.ticket).length > 0}
<div class="mt-4 space-y-2">
{#each statusAlertas(detalheAtual.ticket) as alerta (alerta.label)}
<div
class={`alert ${
alerta.tipo === "error"
? "alert-error"
: alerta.tipo === "warning"
? "alert-warning"
: "alert-success"
}`}
>
<span>{alerta.label}</span>
</div>
{/each}
</div>
{/if}
<div class="mt-6 grid gap-6 lg:grid-cols-2">
<div class="rounded-2xl border border-base-200 bg-base-100/80 p-4">
<h3 class="font-semibold text-base-content">Timeline e SLA</h3>
<p class="text-xs text-base-content/60">
Etapas monitoradas com indicadores de prazo.
</p>
<div class="mt-4">
<TicketTimeline timeline={detalheAtual.ticket.timeline ?? []} />
</div>
</div>
<div class="rounded-2xl border border-base-200 bg-base-100/80 p-4">
<h3 class="font-semibold text-base-content">Responsabilidade</h3>
<p class="text-sm text-base-content/60">
{detalheAtual.ticket.responsavelId
? `Responsável: ${detalheAtual.ticket.setorResponsavel ?? "Equipe TI"}`
: "Aguardando atribuição"}
</p>
<div class="mt-4 space-y-2 text-sm text-base-content/70">
<p>Prazo resposta: {prazoRestante(detalheAtual.ticket.prazoResposta) ?? "--"}</p>
<p>Prazo conclusão: {prazoRestante(detalheAtual.ticket.prazoConclusao) ?? "--"}</p>
<p>Prazo encerramento: {prazoRestante(detalheAtual.ticket.prazoEncerramento) ?? "--"}</p>
</div>
<p class="text-xs text-base-content/50 mt-2">
{getStatusDescription(detalheAtual.ticket.status)}
</p>
</div>
</div>
<div class="mt-8 grid gap-6 lg:grid-cols-2">
<div class="rounded-2xl border border-base-200 bg-base-100/70 p-4">
<h3 class="font-semibold text-base-content">Interações</h3>
<div class="mt-4 space-y-3 max-h-[360px] overflow-y-auto pr-2">
{#if detalheAtual.interactions.length === 0}
<p class="text-sm text-base-content/60">
Nenhuma interação registrada ainda.
</p>
{:else}
{#each detalheAtual.interactions as interacao (interacao._id)}
<div class="rounded-2xl border border-base-200 bg-base-100/90 p-3">
<div class="flex items-center justify-between text-xs text-base-content/60">
<span>{interacao.origem === "usuario" ? "Você" : interacao.origem}</span>
<span>{formatarData(interacao.criadoEm)}</span>
</div>
<p class="text-sm text-base-content mt-2 whitespace-pre-wrap">
{interacao.conteudo}
</p>
{#if interacao.statusNovo && interacao.statusNovo !== interacao.statusAnterior}
<span class="badge badge-xs badge-outline mt-2">
Status: {getStatusLabel(interacao.statusNovo)}
</span>
{/if}
</div>
{/each}
{/if}
</div>
</div>
<div class="rounded-2xl border border-base-200 bg-base-100/70 p-4">
<h3 class="font-semibold text-base-content">Enviar atualização</h3>
<textarea
class="textarea textarea-bordered mt-3 min-h-[140px] w-full"
placeholder="Compartilhe informações adicionais, aprovações ou anexos enviados por outros canais."
bind:value={mensagem}
></textarea>
{#if erroMensagem}
<p class="text-error mt-2 text-sm">{erroMensagem}</p>
{/if}
{#if sucessoMensagem}
<p class="text-success mt-2 text-sm">{sucessoMensagem}</p>
{/if}
<button type="button" class="btn btn-primary mt-3 w-full" onclick={enviarMensagem}>
Enviar
</button>
</div>
</div>
{/if}
</section>
</div>
</main>