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:
+170
-285
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user