From ef55d9bb24430610b60c1b77821307cd72c08c55 Mon Sep 17 00:00:00 2001 From: zwlucas Date: Sat, 25 Jul 2026 18:23:29 -0300 Subject: feat: upload of the project Signed-off-by: zwlucas --- web/src/pages/TicketDetailPage.tsx | 366 +++++++++++++++++++++++++++++++++++++ 1 file changed, 366 insertions(+) create mode 100644 web/src/pages/TicketDetailPage.tsx (limited to 'web/src/pages/TicketDetailPage.tsx') 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 = { + open: ['in_progress', 'closed'], + in_progress: ['waiting', 'resolved', 'closed'], + waiting: ['in_progress', 'resolved', 'closed'], + resolved: ['closed', 'open'], + closed: [], +}; + +const STATUS_LABELS: Record = { + 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 ( +
+
+

Carregando chamado...

+
+ ); + } + + if (ticketQuery.isError || !ticketQuery.data) { + return ( +
+

Chamado não encontrado

+ +
+ ); + } + + const ticket = ticketQuery.data; + const comments = commentsQuery.data ?? []; + const nextStatuses = NEXT_STATUSES[ticket.status]; + + return ( +
+ + {/* ── Header do ticket ─────────────────────────────────────────────── */} +
+
+ + #{ticket.id} +
+ +

{ticket.title}

+ +
+ + + + Aberto em {formatDateTime(ticket.createdAt)} + +
+
+ + {/* ── Layout de 2 colunas ──────────────────────────────────────────── */} +
+ + {/* ── Coluna principal ─────────────────────────────────────────── */} +
+ + {/* Descrição */} +
+

Descrição

+

{ticket.description}

+
+ + {/* Timeline de comentários */} +
+

+ Histórico ({comments.length}) +

+ + {commentsQuery.isLoading && ( +
+
+
+ )} + + {comments.length === 0 && !commentsQuery.isLoading && ( +

Nenhuma interação ainda.

+ )} + +
+ {comments.map((comment) => ( +
+
+
+ {comment.isInternal ? '🔒' : '💬'} +
+
+ + {comment.authorName ?? `Usuário #${comment.authorId}`} + + {comment.isInternal && ( + Nota Interna + )} +
+ +
+

{comment.content}

+
+ ))} +
+
+ + {/* Formulário de resposta */} + {ticket.status !== 'closed' && ticket.status !== 'resolved' && !(user?.role === 'requester' && settingsQuery.data && !settingsQuery.data.allowRequesterChat) && ( +
+

Adicionar Resposta

+ + <> +