diff --git a/client/src/pages/TicketDetail.tsx b/client/src/pages/TicketDetail.tsx index e5db7e2..4268806 100644 --- a/client/src/pages/TicketDetail.tsx +++ b/client/src/pages/TicketDetail.tsx @@ -6,6 +6,7 @@ import remarkGfm from 'remark-gfm' import { Pencil, Trash2, Send, X, Check, MessageSquare, ClipboardList, FileText, + ArrowLeft, } from 'lucide-react' import api from '../api/client' import Layout from '../components/Layout' @@ -25,16 +26,24 @@ const STATUS_OPTIONS: { value: TicketStatus; label: string }[] = [ { value: 'CLOSED', label: 'Closed' }, ] +const SEVERITY_OPTIONS = [ + { value: 1, label: 'SEV 1 — Critical' }, + { value: 2, label: 'SEV 2 — High' }, + { value: 3, label: 'SEV 3 — Medium' }, + { value: 4, label: 'SEV 4 — Low' }, + { value: 5, label: 'SEV 5 — Minimal' }, +] + const AUDIT_LABELS: Record = { - CREATED: 'Ticket created', - STATUS_CHANGED: 'Status changed', - ASSIGNEE_CHANGED: 'Assignee changed', - SEVERITY_CHANGED: 'Severity changed', - REROUTED: 'Rerouted', - TITLE_CHANGED: 'Title updated', - OVERVIEW_CHANGED: 'Overview updated', - COMMENT_ADDED: 'Comment added', - COMMENT_DELETED: 'Comment deleted', + CREATED: 'created this ticket', + STATUS_CHANGED: 'changed status', + ASSIGNEE_CHANGED: 'changed assignee', + SEVERITY_CHANGED: 'changed severity', + REROUTED: 'rerouted ticket', + TITLE_CHANGED: 'updated title', + OVERVIEW_CHANGED: 'updated overview', + COMMENT_ADDED: 'added a comment', + COMMENT_DELETED: 'deleted a comment', } const AUDIT_COLORS: Record = { @@ -49,6 +58,18 @@ const AUDIT_COLORS: Record = { COMMENT_DELETED: 'bg-red-400', } +const selectClass = + 'w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white' + +function SidebarField({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+

{label}

+ {children} +
+ ) +} + export default function TicketDetail() { const { id } = useParams<{ id: string }>() const navigate = useNavigate() @@ -60,19 +81,13 @@ export default function TicketDetail() { const [loading, setLoading] = useState(true) const [tab, setTab] = useState('overview') const [editing, setEditing] = useState(false) + const [reroutingCTI, setReroutingCTI] = useState(false) const [commentBody, setCommentBody] = useState('') const [submittingComment, setSubmittingComment] = useState(false) const [preview, setPreview] = useState(false) - const [editForm, setEditForm] = useState({ - title: '', - overview: '', - severity: 3, - assigneeId: '', - categoryId: '', - typeId: '', - itemId: '', - }) + const [editForm, setEditForm] = useState({ title: '', overview: '' }) + const [pendingCTI, setPendingCTI] = useState({ categoryId: '', typeId: '', itemId: '' }) useEffect(() => { Promise.all([ @@ -84,60 +99,44 @@ export default function TicketDetail() { }).finally(() => setLoading(false)) }, [id]) - const fetchAudit = () => { - if (!ticket) return - api.get(`/tickets/${id}/audit`).then((r) => setAuditLogs(r.data)) - } - useEffect(() => { - if (tab === 'audit') fetchAudit() - }, [tab, ticket]) + if (tab === 'audit' && ticket) { + api.get(`/tickets/${id}/audit`).then((r) => setAuditLogs(r.data)) + } + }, [tab, ticket, id]) + + const patch = async (payload: Record) => { + if (!ticket) return + const res = await api.patch(`/tickets/${ticket.displayId}`, payload) + setTicket(res.data) + return res.data + } const startEdit = () => { if (!ticket) return - setEditForm({ - title: ticket.title, - overview: ticket.overview, - severity: ticket.severity, - assigneeId: ticket.assigneeId ?? '', - categoryId: ticket.categoryId, - typeId: ticket.typeId, - itemId: ticket.itemId, - }) + setEditForm({ title: ticket.title, overview: ticket.overview }) setEditing(true) + setTab('overview') } const saveEdit = async () => { - if (!ticket) return - const res = await api.patch(`/tickets/${ticket.displayId}`, { - title: editForm.title, - overview: editForm.overview, - severity: editForm.severity, - categoryId: editForm.categoryId, - typeId: editForm.typeId, - itemId: editForm.itemId, - assigneeId: editForm.assigneeId || null, - }) - setTicket(res.data) + await patch({ title: editForm.title, overview: editForm.overview }) setEditing(false) } - const updateStatus = async (status: TicketStatus) => { + const startReroute = () => { if (!ticket) return - const res = await api.patch(`/tickets/${ticket.displayId}`, { status }) - setTicket(res.data) + setPendingCTI({ categoryId: ticket.categoryId, typeId: ticket.typeId, itemId: ticket.itemId }) + setReroutingCTI(true) } - const updateAssignee = async (assigneeId: string) => { - if (!ticket) return - const res = await api.patch(`/tickets/${ticket.displayId}`, { - assigneeId: assigneeId || null, - }) - setTicket(res.data) + const saveReroute = async () => { + await patch(pendingCTI) + setReroutingCTI(false) } const deleteTicket = async () => { - if (!ticket || !confirm('Delete this ticket?')) return + if (!ticket || !confirm('Delete this ticket? This cannot be undone.')) return await api.delete(`/tickets/${ticket.displayId}`) navigate('/') } @@ -164,374 +163,423 @@ export default function TicketDetail() { setTicket((t) => t ? { ...t, comments: t.comments?.filter((c) => c.id !== commentId) } : t) } - const inputClass = - 'w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500' - if (loading) { - return
Loading...
+ return ( + +
+ Loading... +
+
+ ) } if (!ticket) { - return
Ticket not found
+ return ( + +
+ Ticket not found +
+
+ ) } const commentCount = ticket.comments?.length ?? 0 + const agentUsers = users.filter((u) => u.role !== 'SERVICE') return ( -
- {/* Ticket header */} -
-
- {/* ID + actions row */} -
-
- - {ticket.displayId} - - - -
-
- {!editing ? ( - - ) : ( - <> - - - - )} - {authUser?.role === 'ADMIN' && ( - - )} -
+ {/* Back link */} + + +
+ {/* ── Main content ── */} +
+ {/* Title card */} +
+
+ + {ticket.displayId} + + + + + {ticket.category.name} › {ticket.type.name} › {ticket.item.name} +
- {/* Title */} {editing ? ( setEditForm((f) => ({ ...f, title: e.target.value }))} - className={`${inputClass} text-lg font-semibold mb-3`} + className="w-full text-2xl font-bold text-gray-900 border-0 border-b-2 border-blue-500 focus:outline-none pb-1 bg-transparent" + autoFocus /> ) : ( -

{ticket.title}

- )} - - {/* CTI breadcrumb / edit */} - {editing ? ( -
- - setEditForm((f) => ({ ...f, ...cti }))} - /> -
- ) : ( -

- {ticket.category.name} › {ticket.type.name} › {ticket.item.name} -

- )} - - {/* Status + Assignee quick controls (when not editing) */} - {!editing && ( -
-
- - -
-
- - -
-
- )} - - {/* Severity + Assignee edit controls */} - {editing && ( -
-
- - -
-
- - -
-
+

{ticket.title}

)}
- {/* Meta footer */} -
- - Opened by {ticket.createdBy.displayName} - - {format(new Date(ticket.createdAt), 'MMM d, yyyy HH:mm')} - {ticket.resolvedAt && ( - Resolved {format(new Date(ticket.resolvedAt), 'MMM d, yyyy')} - )} - - Updated {formatDistanceToNow(new Date(ticket.updatedAt), { addSuffix: true })} - -
-
- - {/* Tab bar */} -
- {( - [ - { key: 'overview', icon: FileText, label: 'Overview' }, - { key: 'comments', icon: MessageSquare, label: `Comments${commentCount > 0 ? ` (${commentCount})` : ''}` }, - { key: 'audit', icon: ClipboardList, label: 'Audit Log' }, - ] as const - ).map(({ key, icon: Icon, label }) => ( - - ))} -
- - {/* ── Overview tab ── */} - {tab === 'overview' && ( -
- {editing ? ( -