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
+170 -285
View File
@@ -1,306 +1,191 @@
import { useState, useEffect } from 'react';
import { useMemo } from 'react';
import { Link } from 'react-router-dom';
import { Search, ChevronRight, X } from 'lucide-react';
import { formatDistanceToNow } from 'date-fns';
import { AlertTriangle, Clock, TrendingUp, Users } from 'lucide-react';
import Layout from '../components/Layout';
import SeverityBadge from '../components/SeverityBadge';
import StatusBadge from '../components/StatusBadge';
import Avatar from '../components/Avatar';
import { TicketStatus, Category, CTIType, Item } from '../types';
import { useTickets, useCategories, useTypes, useItems } from '../api/queries';
import { useAnalytics, useUsers } from '../api/queries';
const STATUSES: { value: TicketStatus | ''; label: string }[] = [
{ value: '', label: 'All Statuses' },
{ value: 'OPEN', label: 'Open' },
{ value: 'IN_PROGRESS', label: 'In Progress' },
{ value: 'RESOLVED', label: 'Resolved' },
{ value: 'CLOSED', label: 'Closed' },
];
const SEV_NAMES: Record<number, string> = {
1: 'SEV 1 — Critical',
2: 'SEV 2 — High',
3: 'SEV 3 — Medium',
4: 'SEV 4 — Low',
5: 'SEV 5 — Minimal',
};
const selectClass =
'bg-gray-800 border border-gray-700 text-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent';
const SEV_COLORS: Record<number, string> = {
1: 'bg-red-500',
2: 'bg-orange-400',
3: 'bg-yellow-400',
4: 'bg-blue-400',
5: 'bg-gray-500',
};
function queueLabel(category: Category | null, type: CTIType | null, item: Item | null): string {
if (item && type && category) return `${category.name} ${type.name} ${item.name}`;
if (type && category) return `${category.name} ${type.name}`;
if (category) return category.name;
return '';
function fmtHours(hours: number | null) {
if (hours == null) return '—';
if (hours < 1) return `${Math.round(hours * 60)} min`;
if (hours < 48) return `${hours.toFixed(1)} h`;
return `${(hours / 24).toFixed(1)} d`;
}
export default function Dashboard() {
const [search, setSearch] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const [status, setStatus] = useState<TicketStatus | ''>('');
const [severity, setSeverity] = useState('');
const { data: a } = useAnalytics(30);
const { data: users = [] } = useUsers();
const [selectedCategory, setSelectedCategory] = useState<Category | null>(null);
const [selectedType, setSelectedType] = useState<CTIType | null>(null);
const [selectedItem, setSelectedItem] = useState<Item | null>(null);
const [showQueueFilter, setShowQueueFilter] = useState(false);
const userById = useMemo(
() => new Map(users.map((u) => [u.id, u.displayName])),
[users],
);
const { data: categories = [] } = useCategories();
const { data: types = [] } = useTypes(selectedCategory?.id);
const { data: items = [] } = useItems(selectedType?.id);
useEffect(() => {
const t = setTimeout(() => setDebouncedSearch(search), 300);
return () => clearTimeout(t);
}, [search]);
const ticketParams: Record<string, string> = {};
if (selectedItem) ticketParams.itemId = selectedItem.id;
else if (selectedType) ticketParams.typeId = selectedType.id;
else if (selectedCategory) ticketParams.categoryId = selectedCategory.id;
if (status) ticketParams.status = status;
if (severity) ticketParams.severity = severity;
if (debouncedSearch) ticketParams.search = debouncedSearch;
const { data: tickets = [], isLoading } = useTickets(ticketParams);
const handleCategorySelect = (cat: Category) => {
setSelectedCategory(cat);
setSelectedType(null);
setSelectedItem(null);
};
const handleTypeSelect = (type: CTIType) => {
setSelectedType(type);
setSelectedItem(null);
};
const handleItemSelect = (item: Item) => {
setSelectedItem(item);
setShowQueueFilter(false);
};
const clearQueue = () => {
setSelectedCategory(null);
setSelectedType(null);
setSelectedItem(null);
};
const activeQueue = queueLabel(selectedCategory, selectedType, selectedItem);
const totalOpen =
a?.openBySeverity.reduce((sum, row) => sum + row.count, 0) ?? 0;
const maxBucket = Math.max(
...Object.values(a?.ageBuckets ?? { d1: 0, d7: 0, d14: 0, older: 0 }),
1,
);
const maxSeverity = Math.max(...(a?.openBySeverity.map((r) => r.count) ?? [1]));
return (
<Layout title="All Tickets">
{/* Filters */}
<div className="flex gap-3 mb-5 flex-wrap items-start">
<div className="relative flex-1 min-w-48 max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500" size={14} />
<input
type="text"
placeholder="Search tickets..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9 pr-4 py-2 bg-gray-800 border border-gray-700 text-gray-100 placeholder-gray-500 rounded-lg w-full text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<select
value={status}
onChange={(e) => setStatus(e.target.value as TicketStatus | '')}
className={selectClass}
>
{STATUSES.map((s) => (
<option key={s.value} value={s.value}>
{s.label}
</option>
))}
</select>
<select
value={severity}
onChange={(e) => setSeverity(e.target.value)}
className={selectClass}
>
<option value="">All Severities</option>
{[1, 2, 3, 4, 5].map((s) => (
<option key={s} value={s}>
SEV {s}
</option>
))}
</select>
{/* Queue picker */}
<div className="relative">
<button
onClick={() => setShowQueueFilter((v) => !v)}
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm border transition-colors ${
activeQueue
? 'bg-blue-600/20 border-blue-500/40 text-blue-400'
: 'bg-gray-800 border-gray-700 text-gray-300 hover:border-gray-600'
}`}
>
{activeQueue ? (
<>
<span className="max-w-48 truncate">{activeQueue}</span>
<span
onClick={(e) => {
e.stopPropagation();
clearQueue();
}}
className="text-blue-400 hover:text-white transition-colors cursor-pointer"
>
<X size={13} />
</span>
</>
) : (
'All Queues'
)}
</button>
{showQueueFilter && (
<div
className="absolute z-20 top-full mt-1 left-0 bg-gray-900 border border-gray-700 rounded-xl shadow-2xl overflow-hidden flex"
style={{ minWidth: '520px' }}
>
{/* Categories */}
<div className="w-44 border-r border-gray-800">
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide px-3 py-2 border-b border-gray-800">
Category
</p>
<div className="overflow-auto max-h-64">
{categories.map((cat) => (
<button
key={cat.id}
onClick={() => handleCategorySelect(cat)}
className={`w-full flex items-center justify-between px-3 py-2 text-sm text-left transition-colors ${
selectedCategory?.id === cat.id
? 'bg-blue-600/20 text-blue-400'
: 'text-gray-300 hover:bg-gray-800'
}`}
>
{cat.name}
<ChevronRight size={12} className="text-gray-600" />
</button>
))}
</div>
</div>
{/* Types */}
<div className="w-44 border-r border-gray-800">
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide px-3 py-2 border-b border-gray-800">
Type
</p>
<div className="overflow-auto max-h-64">
{!selectedCategory ? (
<p className="text-xs text-gray-600 px-3 py-4">Select category</p>
) : types.length === 0 ? (
<p className="text-xs text-gray-600 px-3 py-4">No types</p>
) : (
types.map((type) => (
<button
key={type.id}
onClick={() => handleTypeSelect(type)}
className={`w-full flex items-center justify-between px-3 py-2 text-sm text-left transition-colors ${
selectedType?.id === type.id
? 'bg-blue-600/20 text-blue-400'
: 'text-gray-300 hover:bg-gray-800'
}`}
>
{type.name}
<ChevronRight size={12} className="text-gray-600" />
</button>
))
)}
</div>
</div>
{/* Items */}
<div className="w-44">
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide px-3 py-2 border-b border-gray-800">
Item
</p>
<div className="overflow-auto max-h-64">
{!selectedType ? (
<p className="text-xs text-gray-600 px-3 py-4">Select type</p>
) : items.length === 0 ? (
<p className="text-xs text-gray-600 px-3 py-4">No items</p>
) : (
items.map((item) => (
<button
key={item.id}
onClick={() => handleItemSelect(item)}
className={`w-full px-3 py-2 text-sm text-left transition-colors ${
selectedItem?.id === item.id
? 'bg-blue-600/20 text-blue-400'
: 'text-gray-300 hover:bg-gray-800'
}`}
>
{item.name}
</button>
))
)}
</div>
</div>
</div>
)}
</div>
</div>
{/* Ticket list */}
{isLoading ? (
<div className="text-center py-16 text-gray-600 text-sm">Loading...</div>
) : tickets.length === 0 ? (
<div className="text-center py-16 text-gray-600 text-sm">No tickets found</div>
<Layout title="Dashboard" subheader={<p className="text-xs text-muted-foreground">Last 30 days</p>}>
{!a ? (
<p className="py-16 text-center text-sm text-muted-foreground">Loading analytics</p>
) : (
<div className="space-y-1.5">
{tickets.map((ticket) => (
<Link
key={ticket.id}
to={`/${ticket.displayId}`}
className="flex items-center gap-4 bg-gray-900 border border-gray-800 rounded-lg px-4 py-3 hover:border-blue-500/50 hover:bg-gray-900/80 transition-all group"
>
<div
className={`w-1 self-stretch rounded-full flex-shrink-0 ${
ticket.severity === 1
? 'bg-red-500'
: ticket.severity === 2
? 'bg-orange-400'
: ticket.severity === 3
? 'bg-yellow-400'
: ticket.severity === 4
? 'bg-blue-400'
: 'bg-gray-600'
}`}
/>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card
icon={<AlertTriangle size={14} />}
label="Open tickets"
value={totalOpen.toString()}
/>
<Card
icon={<Clock size={14} />}
label="Aging >7d"
value={((a.ageBuckets.d14 ?? 0) + (a.ageBuckets.older ?? 0)).toString()}
/>
<Card
icon={<TrendingUp size={14} />}
label="Median resolution"
value={fmtHours(a.medianResolutionHours)}
/>
<Card
icon={<Users size={14} />}
label="Assignees loaded"
value={a.queueByAssignee.filter((q) => q.assigneeId).length.toString()}
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5 flex-wrap">
<span className="text-xs font-mono font-medium text-gray-600">
{ticket.displayId}
</span>
<SeverityBadge severity={ticket.severity} />
<StatusBadge status={ticket.status} />
<span className="text-xs text-gray-600">
{ticket.category.name} {ticket.type.name} {ticket.item.name}
</span>
</div>
<p className="text-sm font-medium text-gray-200 truncate group-hover:text-blue-400">
{ticket.title}
</p>
</div>
<div className="flex items-center gap-3 flex-shrink-0">
{ticket.assignee ? (
<div className="flex items-center gap-1.5 text-xs text-gray-500">
<Avatar name={ticket.assignee.displayName} size="sm" />
<span>{ticket.assignee.displayName}</span>
{/* Open by severity */}
<section className="md:col-span-2 rounded-md border border-border p-4">
<h2 className="text-sm font-semibold mb-3">Open by severity</h2>
<div className="space-y-2">
{[1, 2, 3, 4, 5].map((sev) => {
const row = a.openBySeverity.find((r) => r.severity === sev);
const count = row?.count ?? 0;
const pct = maxSeverity > 0 ? (count / maxSeverity) * 100 : 0;
return (
<div key={sev} className="flex items-center gap-3 text-sm">
<span className="w-32 text-xs text-muted-foreground">
{SEV_NAMES[sev]}
</span>
<div className="flex-1 h-5 rounded-sm bg-muted overflow-hidden">
<div
className={`h-full ${SEV_COLORS[sev]}`}
style={{ width: `${pct}%` }}
/>
</div>
<span className="w-10 text-right text-xs font-mono tabular-nums">
{count}
</span>
</div>
) : (
<span className="text-xs text-gray-600">Unassigned</span>
)}
<span className="text-xs text-gray-600">
{ticket._count?.comments ?? 0} comments
</span>
<span className="text-xs text-gray-600">
{formatDistanceToNow(new Date(ticket.createdAt), { addSuffix: true })}
</span>
);
})}
</div>
<div className="mt-3">
<Link
to="/tickets?status=OPEN"
className="text-xs text-primary hover:underline"
>
Go to open tickets
</Link>
</div>
</section>
{/* Age buckets */}
<section className="md:col-span-2 rounded-md border border-border p-4">
<h2 className="text-sm font-semibold mb-3">Age of open tickets</h2>
<div className="space-y-2">
{[
{ key: 'd1', label: '≤ 1 day' },
{ key: 'd7', label: '≤ 7 days' },
{ key: 'd14', label: '≤ 14 days' },
{ key: 'older', label: '> 14 days' },
].map(({ key, label }) => {
const count = a.ageBuckets[key as keyof typeof a.ageBuckets] ?? 0;
const pct = (count / maxBucket) * 100;
return (
<div key={key} className="flex items-center gap-3 text-sm">
<span className="w-24 text-xs text-muted-foreground">{label}</span>
<div className="flex-1 h-5 rounded-sm bg-muted overflow-hidden">
<div
className={`h-full ${key === 'older' ? 'bg-red-500' : 'bg-primary'}`}
style={{ width: `${pct}%` }}
/>
</div>
<span className="w-10 text-right text-xs font-mono tabular-nums">
{count}
</span>
</div>
);
})}
</div>
</section>
{/* Queue by assignee */}
<section className="md:col-span-2 lg:col-span-4 rounded-md border border-border p-4">
<h2 className="text-sm font-semibold mb-3">Queue load by assignee</h2>
{a.queueByAssignee.length === 0 ? (
<p className="text-xs text-muted-foreground">No open tickets right now.</p>
) : (
<div className="grid gap-2 md:grid-cols-2 lg:grid-cols-3">
{a.queueByAssignee
.slice()
.sort((x, y) => y.count - x.count)
.map((row) => {
const name = row.assigneeId
? userById.get(row.assigneeId) ?? 'Unknown'
: 'Unassigned';
return (
<div
key={row.assigneeId ?? 'none'}
className="flex items-center justify-between px-3 py-2 rounded-md bg-muted/40"
>
<span className="text-sm truncate">{name}</span>
<span className="text-xs font-mono tabular-nums">{row.count}</span>
</div>
);
})}
</div>
</Link>
))}
)}
</section>
</div>
)}
</Layout>
);
}
function Card({
icon,
label,
value,
}: {
icon: React.ReactNode;
label: string;
value: string;
}) {
return (
<div className="rounded-md border border-border p-4">
<div className="flex items-center gap-2 text-xs text-muted-foreground mb-1">
{icon}
<span>{label}</span>
</div>
<p className="text-2xl font-semibold tabular-nums">{value}</p>
</div>
);
}