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