Phase 3: UI redesign (Gitea-issues aesthetic)

Top-nav Layout replaces side-nav: brand, primary nav, global search
(debounced /search), notifications bell (Popover + unread badge),
user avatar DropdownMenu. Mobile hamburger collapse.

New pages:
- /dashboard: analytics home (open-by-severity, age buckets, queue
  load, median resolution)
- /tickets: Gitea-style list with status tabs, severity/assignee/CTI
  filters, server pagination (25/page), multi-select bulk bar
  (reassign/close/severity), saved views CRUD
- /notifications: full list with mark-all-read
- /settings: profile, notification prefs grid, API key (SERVICE role)
- /admin/webhooks: CRUD + rotate-secret + active toggle,
  reveal-once secret dialog

TicketDetail: inline Popover editing for Status/Severity/Assignee
(replaces modal chain), AlertDialog delete confirmation, comment
draft autosave to localStorage per ticket.

Admin Users: window.confirm swapped for AlertDialog on delete +
rotate API key with toast feedback.

React Query hooks added for paged tickets, bulk actions,
notifications, webhooks, saved views, analytics, notification prefs.
ThemeProvider wired (v1.0 ships dark-only; toggle deferred).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-18 16:20:28 -04:00
parent edf4c5eb3c
commit 4bade22410
14 changed files with 2213 additions and 506 deletions
+163 -129
View File
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { format, formatDistanceToNow } from 'date-fns';
import ReactMarkdown from 'react-markdown';
@@ -16,6 +16,7 @@ import {
ChevronDown,
ChevronRight,
} from 'lucide-react';
import { toast } from 'sonner';
import Layout from '../components/Layout';
import Modal from '../components/Modal';
import SeverityBadge from '../components/SeverityBadge';
@@ -33,6 +34,21 @@ import {
useAddComment,
useDeleteComment,
} from '../api/queries';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
type Tab = 'overview' | 'comments' | 'audit';
@@ -81,15 +97,29 @@ export default function TicketDetail() {
const [commentBody, setCommentBody] = useState('');
const [preview, setPreview] = useState(false);
const [expandedLogs, setExpandedLogs] = useState<Set<string>>(new Set());
const [deleteOpen, setDeleteOpen] = useState(false);
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 [statusOpen, setStatusOpen] = useState(false);
const [severityOpen, setSeverityOpen] = useState(false);
const [assigneeOpen, setAssigneeOpen] = useState(false);
const [expandedDates, setExpandedDates] = useState<Set<string>>(new Set());
const [expandedCommentDates, setExpandedCommentDates] = useState<Set<string>>(new Set());
// Draft autosave
const draftKey = id ? `comment-draft:${id}` : null;
useEffect(() => {
if (!draftKey) return;
const saved = localStorage.getItem(draftKey);
if (saved) setCommentBody(saved);
}, [draftKey]);
useEffect(() => {
if (!draftKey) return;
if (commentBody) localStorage.setItem(draftKey, commentBody);
else localStorage.removeItem(draftKey);
}, [commentBody, draftKey]);
const { data: ticket, isLoading } = useTicket(id);
const { data: users = [] } = useUsers();
const { data: auditLogs = [] } = useTicketAudit(id, tab === 'audit');
@@ -145,10 +175,11 @@ export default function TicketDetail() {
setReroutingCTI(false);
};
const deleteTicket = async () => {
if (!ticket || !confirm('Delete this ticket? This cannot be undone.')) return;
const confirmDeleteTicket = async () => {
if (!ticket) return;
await deleteTicketMutation.mutateAsync(ticket.displayId);
navigate('/');
toast.success('Ticket deleted');
navigate('/tickets');
};
const submitComment = async (e: React.FormEvent) => {
@@ -157,6 +188,7 @@ export default function TicketDetail() {
await addComment.mutateAsync(commentBody.trim());
setCommentBody('');
setPreview(false);
if (draftKey) localStorage.removeItem(draftKey);
};
const handleDeleteComment = async (commentId: string) => {
@@ -206,11 +238,11 @@ export default function TicketDetail() {
<Layout>
{/* Back link */}
<button
onClick={() => navigate(-1)}
onClick={() => navigate('/tickets')}
className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-300 mb-4 transition-colors"
>
<ArrowLeft size={14} />
Back
All tickets
</button>
<div className="flex gap-6 items-start">
@@ -511,22 +543,66 @@ export default function TicketDetail() {
</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>
<Popover open={statusOpen} onOpenChange={setStatusOpen}>
<PopoverTrigger asChild>
<button 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>
</PopoverTrigger>
<PopoverContent align="start" className="w-56 p-1">
{statusOptions.map((s) => (
<button
key={s.value}
onClick={async () => {
await patch({ status: s.value });
setStatusOpen(false);
}}
className="w-full flex items-center gap-2 px-2 py-1.5 rounded-sm text-sm hover:bg-accent"
>
<StatusBadge status={s.value} />
{ticket.status === s.value && (
<Check size={13} className="ml-auto text-primary" />
)}
</button>
))}
{!isAdmin && (
<p className="px-2 pt-1 text-[11px] text-muted-foreground">
Closing requires admin
</p>
)}
</PopoverContent>
</Popover>
{/* 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>
<Popover open={severityOpen} onOpenChange={setSeverityOpen}>
<PopoverTrigger asChild>
<button 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>
</PopoverTrigger>
<PopoverContent align="start" className="w-56 p-1">
{SEVERITY_OPTIONS.map((s) => (
<button
key={s.value}
onClick={async () => {
await patch({ severity: s.value });
setSeverityOpen(false);
}}
className="w-full flex items-center gap-2 px-2 py-1.5 rounded-sm text-sm hover:bg-accent"
>
<SeverityBadge severity={s.value} />
<span className="text-muted-foreground text-xs">
{s.label.split(' — ')[1]}
</span>
{ticket.severity === s.value && (
<Check size={13} className="ml-auto text-primary" />
)}
</button>
))}
</PopoverContent>
</Popover>
{/* CTI — one clickable unit */}
<button
@@ -571,20 +647,51 @@ export default function TicketDetail() {
</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>
<Popover open={assigneeOpen} onOpenChange={setAssigneeOpen}>
<PopoverTrigger asChild>
<button 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>
</PopoverTrigger>
<PopoverContent align="start" className="w-60 p-1">
<button
onClick={async () => {
await patch({ assigneeId: null });
setAssigneeOpen(false);
}}
className="w-full flex items-center gap-2 px-2 py-1.5 rounded-sm text-sm hover:bg-accent"
>
<span className="text-muted-foreground">Unassigned</span>
{!ticket.assigneeId && <Check size={13} className="ml-auto text-primary" />}
</button>
<div className="max-h-64 overflow-auto">
{agentUsers.map((u) => (
<button
key={u.id}
onClick={async () => {
await patch({ assigneeId: u.id });
setAssigneeOpen(false);
}}
className="w-full flex items-center gap-2 px-2 py-1.5 rounded-sm text-sm hover:bg-accent"
>
<Avatar name={u.displayName} size="sm" />
<span>{u.displayName}</span>
{ticket.assigneeId === u.id && (
<Check size={13} className="ml-auto text-primary" />
)}
</button>
))}
</div>
) : (
<p className="text-sm text-gray-500">Unassigned</p>
)}
</button>
</PopoverContent>
</Popover>
{/* Requester */}
<div className="px-4 py-3">
@@ -599,7 +706,7 @@ export default function TicketDetail() {
{isAdmin && (
<div className="bg-gray-900 border border-gray-800 rounded-xl px-4 py-3">
<button
onClick={deleteTicket}
onClick={() => setDeleteOpen(true)}
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} />
@@ -609,100 +716,27 @@ export default function TicketDetail() {
)}
</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'
}`}
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete this ticket?</AlertDialogTitle>
<AlertDialogDescription>
{ticket.displayId} · {ticket.title} will be permanently removed along with its
comments and audit log. This cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={confirmDeleteTicket}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
<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>
)}
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{reroutingCTI && (
<Modal title="Change Routing" onClose={() => setReroutingCTI(false)} size="lg">