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:
+163
-129
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user