diff options
| author | zwlucas <lucas.fariamo08@gmail.com> | 2026-07-25 21:23:29 +0000 |
|---|---|---|
| committer | zwlucas <lucas.fariamo08@gmail.com> | 2026-07-25 21:23:29 +0000 |
| commit | ef55d9bb24430610b60c1b77821307cd72c08c55 (patch) | |
| tree | e6935a810b34370b0a4c821e55f6397aac50c832 /web/src/pages/TicketDetailPage.tsx | |
| download | helpdesk-master.tar.gz helpdesk-master.zip | |
Signed-off-by: zwlucas <lucas.fariamo08@gmail.com>
Diffstat (limited to 'web/src/pages/TicketDetailPage.tsx')
| -rw-r--r-- | web/src/pages/TicketDetailPage.tsx | 366 |
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> + ); +} |