aboutsummaryrefslogtreecommitdiff
path: root/web/src/pages/TicketDetailPage.tsx
diff options
context:
space:
mode:
authorzwlucas <lucas.fariamo08@gmail.com>2026-07-25 21:23:29 +0000
committerzwlucas <lucas.fariamo08@gmail.com>2026-07-25 21:23:29 +0000
commitef55d9bb24430610b60c1b77821307cd72c08c55 (patch)
treee6935a810b34370b0a4c821e55f6397aac50c832 /web/src/pages/TicketDetailPage.tsx
downloadhelpdesk-ef55d9bb24430610b60c1b77821307cd72c08c55.tar.gz
helpdesk-ef55d9bb24430610b60c1b77821307cd72c08c55.zip
feat: upload of the projectHEADmaster
Signed-off-by: zwlucas <lucas.fariamo08@gmail.com>
Diffstat (limited to 'web/src/pages/TicketDetailPage.tsx')
-rw-r--r--web/src/pages/TicketDetailPage.tsx366
1 files changed, 366 insertions, 0 deletions
diff --git a/web/src/pages/TicketDetailPage.tsx b/web/src/pages/TicketDetailPage.tsx
new file mode 100644
index 0000000..dbf3066
--- /dev/null
+++ b/web/src/pages/TicketDetailPage.tsx
@@ -0,0 +1,366 @@
+/**
+ * TicketDetailPage.tsx
+ *
+ * Página de detalhe de um chamado — visão do Agente.
+ * Exibe informações completas, histórico de comentários e ações.
+ */
+
+import { useState } from 'react';
+import { useParams, useNavigate } from 'react-router-dom';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { api, type TicketStatus } from '@/lib/api';
+import { useAuth } from '@/context/AuthContext';
+import { useToast } from '@/context/ToastContext';
+import { StatusBadge } from '@/components/tickets/StatusBadge';
+import { PriorityBadge } from '@/components/tickets/PriorityBadge';
+import styles from './TicketDetailPage.module.css';
+
+// ─── Helpers ──────────────────────────────────────────────────────────────────
+
+function formatDateTime(iso: string) {
+ return new Intl.DateTimeFormat('pt-BR', {
+ dateStyle: 'short',
+ timeStyle: 'short',
+ }).format(new Date(iso));
+}
+
+const NEXT_STATUSES: Record<TicketStatus, TicketStatus[]> = {
+ open: ['in_progress', 'closed'],
+ in_progress: ['waiting', 'resolved', 'closed'],
+ waiting: ['in_progress', 'resolved', 'closed'],
+ resolved: ['closed', 'open'],
+ closed: [],
+};
+
+const STATUS_LABELS: Record<TicketStatus, string> = {
+ open: 'Aberto',
+ in_progress: 'Em Andamento',
+ waiting: 'Aguardando',
+ resolved: 'Resolvido',
+ closed: 'Fechado',
+};
+
+// ─── Componente ───────────────────────────────────────────────────────────────
+
+export default function TicketDetailPage() {
+ const { id } = useParams<{ id: string }>();
+ const navigate = useNavigate();
+ const queryClient = useQueryClient();
+ const { user } = useAuth();
+ const { addToast } = useToast();
+ const ticketId = Number(id);
+
+ // ── Queries ────────────────────────────────────────────────────────────────
+ const ticketQuery = useQuery({
+ queryKey: ['tickets', ticketId],
+ queryFn: () => api.tickets.get(ticketId),
+ enabled: Boolean(ticketId),
+ });
+
+ const commentsQuery = useQuery({
+ queryKey: ['tickets', ticketId, 'comments'],
+ queryFn: () => api.comments.list(ticketId),
+ enabled: Boolean(ticketId),
+ });
+
+ const settingsQuery = useQuery({
+ queryKey: ['settings'],
+ queryFn: () => api.settings.get(),
+ });
+
+ // ── State do formulário de comentário ─────────────────────────────────────
+ const [commentText, setCommentText] = useState('');
+ const [isInternal, setIsInternal] = useState(false);
+
+ // ── Mutations ──────────────────────────────────────────────────────────────
+ const addCommentMutation = useMutation({
+ mutationFn: () =>
+ api.comments.add(ticketId, {
+ content: commentText.trim(),
+ isInternal,
+ authorId: user?.id ?? 0,
+ }),
+ onSuccess: () => {
+ setCommentText('');
+ setIsInternal(false);
+ void queryClient.invalidateQueries({ queryKey: ['tickets', ticketId, 'comments'] });
+ },
+ });
+
+ const updateStatusMutation = useMutation({
+ mutationFn: (status: TicketStatus) =>
+ api.tickets.updateStatus(ticketId, status),
+ onSuccess: () => {
+ void queryClient.invalidateQueries({ queryKey: ['tickets', ticketId] });
+ void queryClient.invalidateQueries({ queryKey: ['tickets'] });
+ addToast('Status atualizado', 'success');
+ },
+ });
+
+ const assignTicketMutation = useMutation({
+ mutationFn: () => api.tickets.assign(ticketId, user!.id),
+ onSuccess: () => {
+ void queryClient.invalidateQueries({ queryKey: ['tickets', ticketId] });
+ void queryClient.invalidateQueries({ queryKey: ['tickets'] });
+ addToast('Você assumiu o chamado', 'success');
+ },
+ });
+
+ const deleteTicketMutation = useMutation({
+ mutationFn: () => api.tickets.delete(ticketId),
+ onSuccess: () => {
+ void queryClient.invalidateQueries({ queryKey: ['tickets'] });
+ addToast('Chamado excluído com sucesso.', 'success');
+ navigate('/dashboard');
+ },
+ onError: (err: any) => {
+ addToast(`Erro ao excluir chamado: ${err.message}`, 'error');
+ }
+ });
+
+ // ── Loading / Error states ─────────────────────────────────────────────────
+ if (ticketQuery.isLoading) {
+ return (
+ <div className={styles.loadingWrapper}>
+ <div className="spinner" style={{ width: 28, height: 28, borderWidth: 3 }} />
+ <p>Carregando chamado...</p>
+ </div>
+ );
+ }
+
+ if (ticketQuery.isError || !ticketQuery.data) {
+ return (
+ <div className={styles.errorWrapper} role="alert">
+ <p className={styles.errorTitle}>Chamado não encontrado</p>
+ <button className="btn btn--ghost" onClick={() => navigate('/dashboard')}>
+ ← Voltar ao Dashboard
+ </button>
+ </div>
+ );
+ }
+
+ const ticket = ticketQuery.data;
+ const comments = commentsQuery.data ?? [];
+ const nextStatuses = NEXT_STATUSES[ticket.status];
+
+ return (
+ <div className={styles.page}>
+
+ {/* ── Header do ticket ─────────────────────────────────────────────── */}
+ <div className={styles.ticketHeader}>
+ <div className={styles.headerTop}>
+ <button className="btn btn--ghost" onClick={() => navigate('/dashboard')}>
+ ← Voltar
+ </button>
+ <span className={styles.ticketId}>#{ticket.id}</span>
+ </div>
+
+ <h2 className={styles.ticketTitle}>{ticket.title}</h2>
+
+ <div className={styles.headerMeta}>
+ <StatusBadge status={ticket.status} />
+ <PriorityBadge priority={ticket.priority} />
+ <span className={styles.metaDate}>
+ Aberto em {formatDateTime(ticket.createdAt)}
+ </span>
+ </div>
+ </div>
+
+ {/* ── Layout de 2 colunas ──────────────────────────────────────────── */}
+ <div className={styles.layout}>
+
+ {/* ── Coluna principal ─────────────────────────────────────────── */}
+ <div className={styles.mainCol}>
+
+ {/* Descrição */}
+ <section className="card" aria-labelledby="desc-heading">
+ <h3 id="desc-heading" className={styles.sectionTitle}>Descrição</h3>
+ <p className={styles.description}>{ticket.description}</p>
+ </section>
+
+ {/* Timeline de comentários */}
+ <section aria-labelledby="comments-heading">
+ <h3 id="comments-heading" className={styles.sectionTitle}>
+ Histórico ({comments.length})
+ </h3>
+
+ {commentsQuery.isLoading && (
+ <div className={styles.commentsLoading}>
+ <div className="spinner" />
+ </div>
+ )}
+
+ {comments.length === 0 && !commentsQuery.isLoading && (
+ <p className={styles.noComments}>Nenhuma interação ainda.</p>
+ )}
+
+ <div className={styles.timeline}>
+ {comments.map((comment) => (
+ <article
+ key={comment.id}
+ className={`${styles.comment} ${comment.isInternal ? styles.commentInternal : ''}`}
+ >
+ <div className={styles.commentHeader}>
+ <div className={styles.commentAvatar}>
+ {comment.isInternal ? '🔒' : '💬'}
+ </div>
+ <div>
+ <span className={styles.commentAuthor}>
+ {comment.authorName ?? `Usuário #${comment.authorId}`}
+ </span>
+ {comment.isInternal && (
+ <span className={styles.internalTag}>Nota Interna</span>
+ )}
+ </div>
+ <time className={styles.commentDate} dateTime={comment.createdAt}>
+ {formatDateTime(comment.createdAt)}
+ </time>
+ </div>
+ <p className={styles.commentContent}>{comment.content}</p>
+ </article>
+ ))}
+ </div>
+ </section>
+
+ {/* Formulário de resposta */}
+ {ticket.status !== 'closed' && ticket.status !== 'resolved' && !(user?.role === 'requester' && settingsQuery.data && !settingsQuery.data.allowRequesterChat) && (
+ <section className={`card ${styles.replySection}`} aria-labelledby="reply-heading">
+ <h3 id="reply-heading" className={styles.sectionTitle}>Adicionar Resposta</h3>
+
+ <>
+ <textarea
+ className={`input ${styles.replyTextarea}`}
+ placeholder={
+ isInternal
+ ? 'Nota interna (visível apenas para agentes)...'
+ : 'Resposta pública para o solicitante...'
+ }
+ value={commentText}
+ onChange={(e) => setCommentText(e.target.value)}
+ rows={4}
+ disabled={addCommentMutation.isPending}
+ aria-label="Texto do comentário"
+ />
+
+ <div className={styles.replyActions}>
+ {user?.role !== 'requester' && (
+ <label className={styles.internalToggle}>
+ <input
+ type="checkbox"
+ checked={isInternal}
+ onChange={(e) => setIsInternal(e.target.checked)}
+ disabled={addCommentMutation.isPending}
+ />
+ 🔒 Nota interna
+ </label>
+ )}
+
+ <button
+ className="btn btn--primary"
+ onClick={() => addCommentMutation.mutate()}
+ disabled={
+ addCommentMutation.isPending ||
+ commentText.trim().length === 0
+ }
+ aria-busy={addCommentMutation.isPending}
+ style={{ marginLeft: user?.role === 'requester' ? 'auto' : undefined }}
+ >
+ {addCommentMutation.isPending ? 'Enviando...' : 'Enviar'}
+ </button>
+ </div>
+ </>
+ </section>
+ )}
+ </div>
+
+ {/* ── Sidebar de ações ─────────────────────────────────────────── */}
+ <aside className={styles.sidebar}>
+
+ {/* Mudar status (somente se estiver atribuído a alguém e o usuário for admin ou agent) */}
+ {nextStatuses.length > 0 && (user?.role === 'admin' || user?.role === 'agent') && ticket.assigneeId && (
+ <div className="card">
+ <h3 className={styles.sectionTitle}>Mudar Status</h3>
+ <div className={styles.statusActions}>
+ {nextStatuses.map((s) => (
+ <button
+ key={s}
+ className={`btn btn--ghost ${styles.statusBtn}`}
+ onClick={() => updateStatusMutation.mutate(s)}
+ disabled={updateStatusMutation.isPending}
+ >
+ {STATUS_LABELS[s]}
+ </button>
+ ))}
+ </div>
+ </div>
+ )}
+
+ {/* Ações do Agente */}
+ {(user?.role === 'admin' || user?.role === 'agent') && !ticket.assigneeId && (
+ <div className="card" style={{ marginTop: '16px' }}>
+ <button
+ className="btn btn--primary"
+ style={{ width: '100%', justifyContent: 'center' }}
+ disabled={assignTicketMutation.isPending}
+ onClick={() => assignTicketMutation.mutate()}
+ >
+ {assignTicketMutation.isPending ? 'Assumindo...' : 'Assumir Chamado'}
+ </button>
+ </div>
+ )}
+
+ {/* Informações */}
+ <div className="card">
+ <h3 className={styles.sectionTitle}>Informações</h3>
+ <dl className={styles.infoList}>
+ <div className={styles.infoRow}>
+ <dt>Criador</dt>
+ <dd>{ticket.creatorName ?? `Usuário #${ticket.creatorId}`}</dd>
+ </div>
+ <div className={styles.infoRow}>
+ <dt>Agente</dt>
+ <dd>{ticket.assigneeId ? (ticket.assigneeName ?? `Agente #${ticket.assigneeId}`) : '—'}</dd>
+ </div>
+ <div className={styles.infoRow}>
+ <dt>Categoria</dt>
+ <dd>{ticket.categoryName ?? (ticket.categoryId ? `Categoria #${ticket.categoryId}` : '—')}</dd>
+ </div>
+ <div className={styles.infoRow}>
+ <dt>Criado em</dt>
+ <dd>{formatDateTime(ticket.createdAt)}</dd>
+ </div>
+ <div className={styles.infoRow}>
+ <dt>Atualizado</dt>
+ <dd>{formatDateTime(ticket.updatedAt)}</dd>
+ </div>
+ {ticket.resolvedAt && (
+ <div className={styles.infoRow}>
+ <dt>Resolvido em</dt>
+ <dd>{formatDateTime(ticket.resolvedAt)}</dd>
+ </div>
+ )}
+ </dl>
+ </div>
+
+ {(user?.role === 'admin' || user?.role === 'agent') && (
+ <div className="card" style={{ marginTop: '16px', borderColor: '#fca5a5' }}>
+ <h3 className={styles.sectionTitle} style={{ color: '#991b1b' }}>Zona de Perigo</h3>
+ <button
+ className="btn btn--danger"
+ style={{ width: '100%', justifyContent: 'center' }}
+ disabled={deleteTicketMutation.isPending}
+ onClick={() => {
+ if (confirm('Tem certeza que deseja excluir permanentemente este chamado?')) {
+ deleteTicketMutation.mutate();
+ }
+ }}
+ >
+ {deleteTicketMutation.isPending ? 'Excluindo...' : 'Excluir Chamado'}
+ </button>
+ </div>
+ )}
+ </aside>
+ </div>
+ </div>
+ );
+}