Files
TicketingSystem/client/src/pages/TicketDetail.tsx
Josh Wright 8c86ad7bb8
All checks were successful
Build & Push / TypeScript Check (client) (push) Successful in 14s
Build & Push / Build Server (push) Successful in 34s
Build & Push / Build Client (push) Successful in 33s
Move edit pencil to title card top-right; hide Actions card for non-admins
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 12:24:53 -04:00

719 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { format, formatDistanceToNow } from 'date-fns'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import {
Pencil, Trash2, Send, X, Check,
MessageSquare, ClipboardList, FileText,
ArrowLeft, ChevronDown, ChevronRight,
} from 'lucide-react'
import api from '../api/client'
import Layout from '../components/Layout'
import Modal from '../components/Modal'
import SeverityBadge from '../components/SeverityBadge'
import StatusBadge from '../components/StatusBadge'
import CTISelect from '../components/CTISelect'
import Avatar from '../components/Avatar'
import { Ticket, TicketStatus, User, Comment, AuditLog } from '../types'
import { useAuth } from '../contexts/AuthContext'
type Tab = 'overview' | 'comments' | 'audit'
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<string, string> = {
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<string, string> = {
CREATED: 'bg-green-500',
STATUS_CHANGED: 'bg-blue-500',
ASSIGNEE_CHANGED: 'bg-purple-500',
SEVERITY_CHANGED: 'bg-orange-500',
REROUTED: 'bg-cyan-500',
TITLE_CHANGED: 'bg-gray-500',
OVERVIEW_CHANGED: 'bg-gray-500',
COMMENT_ADDED: 'bg-gray-500',
COMMENT_DELETED: 'bg-red-500',
}
const COMMENT_ACTIONS = new Set(['COMMENT_ADDED', 'COMMENT_DELETED'])
export default function TicketDetail() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const { user: authUser } = useAuth()
const [ticket, setTicket] = useState<Ticket | null>(null)
const [users, setUsers] = useState<User[]>([])
const [auditLogs, setAuditLogs] = useState<AuditLog[]>([])
const [loading, setLoading] = useState(true)
const [tab, setTab] = useState<Tab>('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 [expandedLogs, setExpandedLogs] = useState<Set<string>>(new Set())
const [editForm, setEditForm] = useState({ title: '', overview: '' })
const [pendingCTI, setPendingCTI] = useState({ categoryId: '', typeId: '', itemId: '' })
const [editingStatus, setEditingStatus] = useState(false)
const [editingSeverity, setEditingSeverity] = useState(false)
const [editingAssignee, setEditingAssignee] = useState(false)
const [expandedDates, setExpandedDates] = useState<Set<string>>(new Set())
const toggleDate = (key: string) =>
setExpandedDates((prev) => {
const next = new Set(prev)
next.has(key) ? next.delete(key) : next.add(key)
return next
})
const isAdmin = authUser?.role === 'ADMIN'
useEffect(() => {
Promise.all([
api.get<Ticket>(`/tickets/${id}`),
api.get<User[]>('/users'),
]).then(([tRes, uRes]) => {
setTicket(tRes.data)
setUsers(uRes.data)
}).finally(() => setLoading(false))
}, [id])
useEffect(() => {
if (tab === 'audit' && ticket) {
api.get<AuditLog[]>(`/tickets/${id}/audit`).then((r) => setAuditLogs(r.data))
}
}, [tab, ticket, id])
const patch = async (payload: Record<string, unknown>) => {
if (!ticket) return
const res = await api.patch<Ticket>(`/tickets/${ticket.displayId}`, payload)
setTicket(res.data)
return res.data
}
const startEdit = () => {
if (!ticket) return
setEditForm({ title: ticket.title, overview: ticket.overview })
setEditing(true)
setTab('overview')
}
const saveEdit = async () => {
await patch({ title: editForm.title, overview: editForm.overview })
setEditing(false)
}
const startReroute = () => {
if (!ticket) return
setPendingCTI({ categoryId: ticket.categoryId, typeId: ticket.typeId, itemId: ticket.itemId })
setReroutingCTI(true)
}
const saveReroute = async () => {
await patch(pendingCTI)
setReroutingCTI(false)
}
const deleteTicket = async () => {
if (!ticket || !confirm('Delete this ticket? This cannot be undone.')) return
await api.delete(`/tickets/${ticket.displayId}`)
navigate('/')
}
const submitComment = async (e: React.FormEvent) => {
e.preventDefault()
if (!ticket || !commentBody.trim()) return
setSubmittingComment(true)
try {
const res = await api.post<Comment>(`/tickets/${ticket.displayId}/comments`, {
body: commentBody.trim(),
})
setTicket((t) => t ? { ...t, comments: [...(t.comments ?? []), res.data] } : t)
setCommentBody('')
setPreview(false)
} finally {
setSubmittingComment(false)
}
}
const deleteComment = async (commentId: string) => {
if (!ticket) return
await api.delete(`/tickets/${ticket.displayId}/comments/${commentId}`)
setTicket((t) => t ? { ...t, comments: t.comments?.filter((c) => c.id !== commentId) } : t)
}
const toggleLog = (logId: string) => {
setExpandedLogs((prev) => {
const next = new Set(prev)
if (next.has(logId)) next.delete(logId)
else next.add(logId)
return next
})
}
if (loading) {
return (
<Layout>
<div className="flex items-center justify-center h-full text-gray-600 text-sm">
Loading...
</div>
</Layout>
)
}
if (!ticket) {
return (
<Layout>
<div className="flex items-center justify-center h-full text-gray-600 text-sm">
Ticket not found
</div>
</Layout>
)
}
const commentCount = ticket.comments?.length ?? 0
const agentUsers = users.filter((u) => u.role !== 'SERVICE')
// Status options: CLOSED only for admins
const statusOptions: { value: TicketStatus; label: string }[] = [
{ value: 'OPEN', label: 'Open' },
{ value: 'IN_PROGRESS', label: 'In Progress' },
{ value: 'RESOLVED', label: 'Resolved' },
...(isAdmin ? [{ value: 'CLOSED' as TicketStatus, label: 'Closed' }] : []),
]
return (
<Layout>
{/* Back link */}
<button
onClick={() => navigate(-1)}
className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-300 mb-4 transition-colors"
>
<ArrowLeft size={14} />
Back
</button>
<div className="flex gap-6 items-start">
{/* ── Main content ── */}
<div className="flex-1 min-w-0">
{/* Title card */}
<div className="bg-gray-900 border border-gray-800 rounded-xl px-6 py-5 mb-3">
<div className="flex items-center gap-2 mb-3 flex-wrap">
<span className="font-mono text-xs font-semibold text-gray-500 bg-gray-800 px-2 py-0.5 rounded">
{ticket.displayId}
</span>
<SeverityBadge severity={ticket.severity} />
<StatusBadge status={ticket.status} />
<span className="text-xs text-gray-500 ml-1">
{ticket.category.name} {ticket.type.name} {ticket.item.name}
</span>
<button
onClick={startEdit}
className="ml-auto text-gray-500 hover:text-gray-300 transition-colors"
title="Edit title & overview"
>
<Pencil size={14} />
</button>
</div>
{editing ? (
<input
type="text"
value={editForm.title}
onChange={(e) => setEditForm((f) => ({ ...f, title: e.target.value }))}
className="w-full text-2xl font-bold text-gray-100 bg-transparent border-0 border-b-2 border-blue-500 focus:outline-none pb-1"
autoFocus
/>
) : (
<h1 className="text-2xl font-bold text-gray-100">{ticket.title}</h1>
)}
</div>
{/* Tabs + content */}
<div className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden">
{/* Tab bar */}
<div className="flex border-b border-gray-800 px-2">
{(
[
{ 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 }) => (
<button
key={key}
onClick={() => setTab(key)}
className={`flex items-center gap-2 px-4 py-3.5 text-sm font-medium border-b-2 -mb-px transition-colors ${
tab === key
? 'border-blue-500 text-blue-400'
: 'border-transparent text-gray-500 hover:text-gray-300'
}`}
>
<Icon size={14} />
{label}
</button>
))}
</div>
{/* ── Overview ── */}
{tab === 'overview' && (
<div className="p-6">
{editing ? (
<div className="space-y-3">
<textarea
value={editForm.overview}
onChange={(e) => setEditForm((f) => ({ ...f, overview: e.target.value }))}
rows={12}
className="w-full bg-gray-800 border border-gray-700 text-gray-100 rounded-lg px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 resize-y font-mono"
/>
<div className="flex justify-end gap-2">
<button
onClick={() => setEditing(false)}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm text-gray-400 border border-gray-700 rounded-lg hover:bg-gray-800 transition-colors"
>
<X size={13} /> Cancel
</button>
<button
onClick={saveEdit}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
<Check size={13} /> Save changes
</button>
</div>
</div>
) : (
<div className="prose text-sm text-gray-300">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{ticket.overview}
</ReactMarkdown>
</div>
)}
</div>
)}
{/* ── Comments ── */}
{tab === 'comments' && (
<div>
{ticket.comments && ticket.comments.length > 0 ? (
<div className="divide-y divide-gray-800">
{ticket.comments.map((comment) => (
<div key={comment.id} className="p-6 group">
<div className="flex items-start gap-3">
<Avatar name={comment.author.displayName} size="md" />
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-gray-200">
{comment.author.displayName}
</span>
<span className="text-xs text-gray-500">
{format(new Date(comment.createdAt), 'MMM d, yyyy · HH:mm')}
</span>
</div>
{(comment.authorId === authUser?.id || isAdmin) && (
<button
onClick={() => deleteComment(comment.id)}
className="opacity-0 group-hover:opacity-100 text-gray-600 hover:text-red-400 transition-all"
>
<Trash2 size={13} />
</button>
)}
</div>
<div className="prose text-sm text-gray-300">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{comment.body}
</ReactMarkdown>
</div>
</div>
</div>
</div>
))}
</div>
) : (
<div className="py-16 text-center text-sm text-gray-600">
No comments yet
</div>
)}
{/* Comment composer */}
<div className="border-t border-gray-800 p-6">
<div className="flex gap-3">
<Avatar name={authUser?.displayName ?? '?'} size="md" />
<div className="flex-1">
<div className="flex gap-4 mb-2 border-b border-gray-800">
{(['Write', 'Preview'] as const).map((label) => (
<button
key={label}
onClick={() => setPreview(label === 'Preview')}
className={`text-xs pb-2 border-b-2 -mb-px transition-colors ${
(label === 'Preview') === preview
? 'border-blue-500 text-blue-400'
: 'border-transparent text-gray-500 hover:text-gray-300'
}`}
>
{label}
</button>
))}
</div>
<form onSubmit={submitComment}>
{preview ? (
<div className="prose text-sm text-gray-300 min-h-[80px] mb-3 px-1">
{commentBody.trim()
? <ReactMarkdown remarkPlugins={[remarkGfm]}>{commentBody}</ReactMarkdown>
: <span className="text-gray-600 italic">Nothing to preview</span>
}
</div>
) : (
<textarea
value={commentBody}
onChange={(e) => setCommentBody(e.target.value)}
placeholder="Leave a comment… Markdown supported"
rows={4}
className="w-full bg-gray-800 border border-gray-700 text-gray-100 placeholder-gray-600 rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none mb-3"
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
e.preventDefault()
submitComment(e as unknown as React.FormEvent)
}
}}
/>
)}
<div className="flex justify-between items-center">
<span className="text-xs text-gray-600">
Markdown supported · Ctrl+Enter to submit
</span>
<button
type="submit"
disabled={submittingComment || !commentBody.trim()}
className="flex items-center gap-2 px-4 py-1.5 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors"
>
<Send size={13} />
Comment
</button>
</div>
</form>
</div>
</div>
</div>
</div>
)}
{/* ── Audit Log ── */}
{tab === 'audit' && (
<div className="p-6">
{auditLogs.length === 0 ? (
<div className="py-10 text-center text-sm text-gray-600">No activity yet</div>
) : (
<div>
{auditLogs.map((log, i) => {
const hasDetail = !!log.detail
const isExpanded = expandedLogs.has(log.id)
const isComment = COMMENT_ACTIONS.has(log.action)
return (
<div key={log.id} className="flex gap-4">
{/* Timeline */}
<div className="flex flex-col items-center w-5 flex-shrink-0">
<div className={`w-2.5 h-2.5 rounded-full mt-1 flex-shrink-0 ${AUDIT_COLORS[log.action] ?? 'bg-gray-500'}`} />
{i < auditLogs.length - 1 && (
<div className="w-px flex-1 bg-gray-800 my-1" />
)}
</div>
{/* Entry */}
<div className="flex-1 pb-4">
<div
className={`flex items-baseline justify-between gap-4 ${hasDetail ? 'cursor-pointer select-none' : ''}`}
onClick={() => hasDetail && toggleLog(log.id)}
>
<p className="text-sm text-gray-300">
<span className="font-medium text-gray-100">{log.user.displayName}</span>
{' '}{AUDIT_LABELS[log.action] ?? log.action.toLowerCase()}
{hasDetail && (
<span className="ml-1 inline-flex items-center text-gray-600">
{isExpanded ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
</span>
)}
</p>
<span
className="text-xs text-gray-600 flex-shrink-0"
title={format(new Date(log.createdAt), 'MMM d, yyyy HH:mm:ss')}
>
{formatDistanceToNow(new Date(log.createdAt), { addSuffix: true })}
</span>
</div>
{hasDetail && isExpanded && (
<div className="mt-2 ml-0 bg-gray-800 border border-gray-700 rounded-lg px-4 py-3">
{isComment ? (
<div className="prose text-sm text-gray-300">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{log.detail!}
</ReactMarkdown>
</div>
) : (
<p className="text-sm text-gray-400">{log.detail}</p>
)}
</div>
)}
</div>
</div>
)
})}
</div>
)}
</div>
)}
</div>
</div>
{/* ── Sidebar ── */}
<div className="w-64 flex-shrink-0 sticky top-0 space-y-3">
{/* Ticket Summary */}
<div className="bg-gray-900 border border-gray-800 rounded-xl divide-y divide-gray-800">
<div className="px-4 py-3">
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide">Ticket Summary</p>
</div>
{/* Status */}
<button
onClick={() => setEditingStatus(true)}
className="w-full px-4 py-3 text-left hover:bg-gray-800/50 transition-colors"
>
<p className="text-xs font-medium text-gray-500 mb-1.5">Status</p>
<StatusBadge status={ticket.status} />
</button>
{/* Severity */}
<button
onClick={() => setEditingSeverity(true)}
className="w-full px-4 py-3 text-left hover:bg-gray-800/50 transition-colors"
>
<p className="text-xs font-medium text-gray-500 mb-1.5">Severity</p>
<SeverityBadge severity={ticket.severity} />
</button>
{/* CTI — one clickable unit */}
<button
onClick={startReroute}
className="w-full px-4 py-3 text-left space-y-3 hover:bg-gray-800/50 transition-colors"
>
<div>
<p className="text-xs font-medium text-gray-500 mb-1">Category</p>
<p className="text-sm text-gray-300">{ticket.category.name}</p>
</div>
<div>
<p className="text-xs font-medium text-gray-500 mb-1">Type</p>
<p className="text-sm text-gray-300">{ticket.type.name}</p>
</div>
<div>
<p className="text-xs font-medium text-gray-500 mb-1">Issue</p>
<p className="text-sm text-gray-300">{ticket.item.name}</p>
</div>
</button>
{/* Dates */}
<div className="px-4 py-3 space-y-2.5">
{[
{ key: 'created', label: 'Created', date: ticket.createdAt },
{ key: 'modified', label: 'Modified', date: ticket.updatedAt },
...(ticket.resolvedAt ? [{ key: 'resolved', label: 'Resolved', date: ticket.resolvedAt }] : []),
].map(({ key, label, date }) => (
<div key={key}>
<p className="text-xs font-medium text-gray-500 mb-1">{label}</p>
<button
onClick={() => toggleDate(key)}
className="text-xs text-gray-300 hover:text-gray-100 transition-colors"
>
{expandedDates.has(key)
? format(new Date(date), 'MMM d, yyyy HH:mm')
: formatDistanceToNow(new Date(date), { addSuffix: true })}
</button>
</div>
))}
</div>
{/* Assignee */}
<button
onClick={() => setEditingAssignee(true)}
className="w-full px-4 py-3 text-left hover:bg-gray-800/50 transition-colors"
>
<p className="text-xs font-medium text-gray-500 mb-1.5">Assignee</p>
{ticket.assignee ? (
<div className="flex items-center gap-1.5">
<Avatar name={ticket.assignee.displayName} size="sm" />
<span className="text-sm text-gray-300">{ticket.assignee.displayName}</span>
</div>
) : (
<p className="text-sm text-gray-500">Unassigned</p>
)}
</button>
{/* Requester */}
<div className="px-4 py-3">
<p className="text-xs font-medium text-gray-500 mb-1.5">Requester</p>
<div className="flex items-center gap-1.5">
<Avatar name={ticket.createdBy.displayName} size="sm" />
<span className="text-sm text-gray-300">{ticket.createdBy.displayName}</span>
</div>
</div>
</div>
{isAdmin && (
<div className="bg-gray-900 border border-gray-800 rounded-xl px-4 py-3">
<button
onClick={deleteTicket}
className="w-full flex items-center justify-center gap-2 py-2 text-sm text-red-400 border border-red-500/30 rounded-lg hover:bg-red-500/10 transition-colors"
>
<Trash2 size={13} />
Delete ticket
</button>
</div>
)}
</div>
</div>
{editingStatus && (
<Modal title="Change Status" onClose={() => setEditingStatus(false)}>
<div className="space-y-2">
{statusOptions.map((s) => (
<button
key={s.value}
onClick={async () => {
await patch({ status: s.value })
setEditingStatus(false)
}}
className={`w-full flex items-center gap-3 px-4 py-3 rounded-lg border transition-colors ${
ticket.status === s.value
? 'border-blue-500/50 bg-blue-500/10'
: 'border-gray-700 hover:bg-gray-800'
}`}
>
<StatusBadge status={s.value} />
{ticket.status === s.value && <Check size={14} className="ml-auto text-blue-400" />}
</button>
))}
{!isAdmin && (
<p className="text-xs text-gray-500 pt-1">Closing a ticket requires admin access</p>
)}
</div>
</Modal>
)}
{editingSeverity && (
<Modal title="Change Severity" onClose={() => setEditingSeverity(false)}>
<div className="space-y-2">
{SEVERITY_OPTIONS.map((s) => (
<button
key={s.value}
onClick={async () => {
await patch({ severity: s.value })
setEditingSeverity(false)
}}
className={`w-full flex items-center gap-3 px-4 py-3 rounded-lg border transition-colors ${
ticket.severity === s.value
? 'border-blue-500/50 bg-blue-500/10'
: 'border-gray-700 hover:bg-gray-800'
}`}
>
<SeverityBadge severity={s.value} />
<span className="text-sm text-gray-400">{s.label.split(' — ')[1]}</span>
{ticket.severity === s.value && <Check size={14} className="ml-auto text-blue-400" />}
</button>
))}
</div>
</Modal>
)}
{editingAssignee && (
<Modal title="Change Assignee" onClose={() => setEditingAssignee(false)}>
<div className="space-y-2">
<button
onClick={async () => {
await patch({ assigneeId: null })
setEditingAssignee(false)
}}
className={`w-full flex items-center gap-3 px-4 py-3 rounded-lg border transition-colors ${
!ticket.assigneeId
? 'border-blue-500/50 bg-blue-500/10'
: 'border-gray-700 hover:bg-gray-800'
}`}
>
<span className="text-sm text-gray-400">Unassigned</span>
{!ticket.assigneeId && <Check size={14} className="ml-auto text-blue-400" />}
</button>
{agentUsers.map((u) => (
<button
key={u.id}
onClick={async () => {
await patch({ assigneeId: u.id })
setEditingAssignee(false)
}}
className={`w-full flex items-center gap-3 px-4 py-3 rounded-lg border transition-colors ${
ticket.assigneeId === u.id
? 'border-blue-500/50 bg-blue-500/10'
: 'border-gray-700 hover:bg-gray-800'
}`}
>
<Avatar name={u.displayName} size="sm" />
<span className="text-sm text-gray-300">{u.displayName}</span>
{ticket.assigneeId === u.id && <Check size={14} className="ml-auto text-blue-400" />}
</button>
))}
</div>
</Modal>
)}
{reroutingCTI && (
<Modal title="Change Routing" onClose={() => setReroutingCTI(false)} size="lg">
<div className="space-y-5">
<p className="text-sm text-gray-400">
Current:{' '}
<span className="text-gray-200">
{ticket.category.name} {ticket.type.name} {ticket.item.name}
</span>
</p>
<CTISelect value={pendingCTI} onChange={setPendingCTI} />
<div className="flex justify-end gap-3 pt-1">
<button
onClick={() => setReroutingCTI(false)}
className="px-4 py-2 text-sm text-gray-400 border border-gray-700 rounded-lg hover:bg-gray-800 transition-colors"
>
Cancel
</button>
<button
onClick={saveReroute}
disabled={!pendingCTI.itemId}
className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors"
>
Save routing
</button>
</div>
</div>
</Modal>
)}
</Layout>
)
}