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>
);
}
+90
View File
@@ -0,0 +1,90 @@
import { Link } from 'react-router-dom';
import { formatDistanceToNow } from 'date-fns';
import Layout from '../components/Layout';
import { useNotifications, useMarkNotificationsRead } from '../api/queries';
import type { Notification } from '../../../shared/types';
const KIND_LABELS: Record<string, string> = {
ASSIGNED: 'assigned to you',
MENTION: 'mentioned you',
RESOLVED: 'was resolved',
TICKET_CREATED: 'was created',
STATUS_CHANGED: 'status changed',
COMMENT: 'new comment',
};
interface NotifData {
displayId?: string;
title?: string;
byName?: string;
}
function summary(n: Notification): { label: string; href: string } {
const data = (n.data ?? {}) as NotifData;
const action = KIND_LABELS[n.kind] ?? n.kind.toLowerCase();
const label = data.title ? `${data.title}${action}` : `Ticket ${action}`;
const href = data.displayId ? `/${data.displayId}` : '/notifications';
return { label, href };
}
export default function Notifications() {
const { data: notifications = [], isLoading } = useNotifications();
const markRead = useMarkNotificationsRead();
const unread = notifications.filter((n) => !n.readAt);
return (
<Layout
title="Notifications"
action={
unread.length > 0 ? (
<button
onClick={() => markRead.mutate({ all: true })}
className="text-sm px-3 py-1.5 rounded-md border border-input hover:bg-accent"
>
Mark all read
</button>
) : null
}
>
{isLoading ? (
<p className="py-16 text-center text-sm text-muted-foreground">Loading</p>
) : notifications.length === 0 ? (
<p className="py-16 text-center text-sm text-muted-foreground">
You&apos;re all caught up
</p>
) : (
<ul className="rounded-md border border-border divide-y divide-border overflow-hidden">
{notifications.map((n) => {
const { label, href } = summary(n);
const isUnread = !n.readAt;
return (
<li
key={n.id}
className={isUnread ? 'bg-primary/5' : ''}
>
<Link
to={href}
onClick={() => isUnread && markRead.mutate({ ids: [n.id] })}
className="flex items-center gap-3 px-4 py-3 hover:bg-accent/30 transition-colors"
>
<span
className={`w-2 h-2 rounded-full flex-shrink-0 ${
isUnread ? 'bg-primary' : 'bg-transparent'
}`}
/>
<div className="flex-1 min-w-0">
<p className="text-sm text-foreground line-clamp-1">{label}</p>
<p className="text-xs text-muted-foreground">
{formatDistanceToNow(new Date(n.createdAt), { addSuffix: true })}
</p>
</div>
</Link>
</li>
);
})}
</ul>
)}
</Layout>
);
}
+151
View File
@@ -0,0 +1,151 @@
import { useEffect, useState } from 'react';
import { Copy } from 'lucide-react';
import { toast } from 'sonner';
import Layout from '../components/Layout';
import { useAuth } from '../contexts/AuthContext';
import {
useNotificationPrefs,
useUpdateNotificationPrefs,
type NotificationPrefs,
} from '../api/queries';
const CHANNELS = [
{ key: 'assignment', label: 'Ticket assigned to me' },
{ key: 'mention', label: 'I am mentioned in a comment' },
{ key: 'resolved', label: 'A ticket I created is resolved' },
] as const;
export default function Settings() {
const { user } = useAuth();
const prefsQ = useNotificationPrefs();
const updatePrefs = useUpdateNotificationPrefs();
const [local, setLocal] = useState<NotificationPrefs | null>(null);
useEffect(() => {
if (prefsQ.data && !local) setLocal(prefsQ.data);
}, [prefsQ.data, local]);
const handleToggle = (channel: 'email' | 'inApp', key: keyof NotificationPrefs['email']) => {
setLocal((l) =>
l
? {
...l,
[channel]: { ...l[channel], [key]: !l[channel][key] },
}
: l,
);
};
const save = async () => {
if (!local) return;
try {
await updatePrefs.mutateAsync(local);
toast.success('Preferences saved');
} catch (e) {
toast.error((e as Error).message || 'Failed to save');
}
};
const copyKey = async () => {
if (!user?.apiKey) return;
await navigator.clipboard.writeText(user.apiKey);
toast.success('API key copied');
};
return (
<Layout title="Settings">
<div className="space-y-6">
{/* Profile */}
<section className="rounded-md border border-border p-4">
<h2 className="text-sm font-semibold mb-3">Profile</h2>
<div className="grid grid-cols-2 gap-3 text-sm">
<Field label="Display name" value={user?.displayName ?? ''} />
<Field label="Username" value={user?.username ?? ''} mono />
<Field label="Email" value={user?.email ?? ''} />
<Field label="Role" value={user?.role ?? ''} />
</div>
</section>
{/* Notifications */}
<section className="rounded-md border border-border p-4">
<h2 className="text-sm font-semibold mb-3">Notification preferences</h2>
{local ? (
<div className="space-y-3">
<div className="grid grid-cols-[1fr_80px_80px] gap-2 text-xs uppercase text-muted-foreground pb-1 border-b border-border">
<span />
<span className="text-center">Email</span>
<span className="text-center">In app</span>
</div>
{CHANNELS.map(({ key, label }) => (
<div
key={key}
className="grid grid-cols-[1fr_80px_80px] gap-2 items-center text-sm"
>
<span>{label}</span>
<div className="text-center">
<input
type="checkbox"
checked={local.email[key]}
onChange={() => handleToggle('email', key)}
/>
</div>
<div className="text-center">
<input
type="checkbox"
checked={local.inApp[key]}
onChange={() => handleToggle('inApp', key)}
/>
</div>
</div>
))}
<div className="flex justify-end pt-2">
<button
onClick={save}
disabled={updatePrefs.isPending}
className="px-3 py-1.5 bg-primary text-primary-foreground rounded-md text-sm disabled:opacity-50"
>
{updatePrefs.isPending ? 'Saving…' : 'Save preferences'}
</button>
</div>
</div>
) : (
<p className="text-sm text-muted-foreground">Loading</p>
)}
<p className="mt-3 text-xs text-muted-foreground">
Email notifications require the server&apos;s SMTP config. If SMTP is unset, only
in-app delivery happens regardless of these settings.
</p>
</section>
{/* API key (service accounts only) */}
{user?.role === 'SERVICE' && user?.apiKey && (
<section className="rounded-md border border-border p-4">
<h2 className="text-sm font-semibold mb-3">API key</h2>
<div className="flex items-center gap-2 bg-muted rounded-md px-3 py-2 font-mono text-xs break-all">
<span className="flex-1">{user.apiKey}</span>
<button
onClick={copyKey}
className="text-muted-foreground hover:text-foreground"
>
<Copy size={14} />
</button>
</div>
<p className="mt-2 text-xs text-muted-foreground">
Pass as <code>x-api-key</code> header on any server-to-server request.
</p>
</section>
)}
</div>
</Layout>
);
}
function Field({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
return (
<div>
<p className="text-xs text-muted-foreground mb-0.5">{label}</p>
<p className={`text-sm ${mono ? 'font-mono' : ''}`}>{value || '—'}</p>
</div>
);
}
+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">
+605
View File
@@ -0,0 +1,605 @@
import { useEffect, useMemo, useState } from 'react';
import { Link, useSearchParams } from 'react-router-dom';
import { ChevronLeft, ChevronRight, Trash2, Save } from 'lucide-react';
import { formatDistanceToNow } from 'date-fns';
import { toast } from 'sonner';
import Layout from '../components/Layout';
import SeverityBadge from '../components/SeverityBadge';
import StatusBadge from '../components/StatusBadge';
import Avatar from '../components/Avatar';
import CTISelect from '../components/CTISelect';
import { TicketStatus } from '../types';
import { useAuth } from '../contexts/AuthContext';
import {
useTicketsPaged,
useBulkTickets,
useSavedViews,
useCreateSavedView,
useDeleteSavedView,
useUsers,
} from '../api/queries';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
const STATUS_TABS: { value: TicketStatus | ''; label: string }[] = [
{ value: '', label: 'All' },
{ value: 'OPEN', label: 'Open' },
{ value: 'IN_PROGRESS', label: 'In progress' },
{ value: 'RESOLVED', label: 'Resolved' },
{ value: 'CLOSED', label: 'Closed' },
];
const PAGE_SIZE = 25;
const BULK_LIMIT = 500;
function sevColor(severity: number) {
return severity === 1
? 'bg-red-500'
: severity === 2
? 'bg-orange-400'
: severity === 3
? 'bg-yellow-400'
: severity === 4
? 'bg-blue-400'
: 'bg-gray-600';
}
export default function Tickets() {
const [params, setParams] = useSearchParams();
const { user: authUser } = useAuth();
const { data: users = [] } = useUsers();
const status = (params.get('status') ?? '') as TicketStatus | '';
const severity = params.get('severity') ?? '';
const assigneeId = params.get('assigneeId') ?? '';
const categoryId = params.get('categoryId') ?? '';
const typeId = params.get('typeId') ?? '';
const itemId = params.get('itemId') ?? '';
const search = params.get('search') ?? '';
const page = Math.max(1, Number(params.get('page') ?? '1'));
const [searchInput, setSearchInput] = useState(search);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [saveOpen, setSaveOpen] = useState(false);
const [newViewName, setNewViewName] = useState('');
const [confirmBulk, setConfirmBulk] = useState<
| { kind: 'close' | 'setSeverity' | 'reassign'; value?: unknown; label: string }
| null
>(null);
useEffect(() => {
const t = setTimeout(() => {
if (search !== searchInput) {
updateParam('search', searchInput);
updateParam('page', '1');
}
}, 250);
return () => clearTimeout(t);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchInput]);
const updateParam = (key: string, value: string | null) => {
setParams(
(prev) => {
const next = new URLSearchParams(prev);
if (value === null || value === '') next.delete(key);
else next.set(key, value);
if (key !== 'page') next.delete('page'); // reset page on filter change
return next;
},
{ replace: true },
);
};
const queryParams = {
status: status || undefined,
severity: severity ? Number(severity) : undefined,
assigneeId: assigneeId || undefined,
categoryId: categoryId || undefined,
typeId: typeId || undefined,
itemId: itemId || undefined,
search: search || undefined,
page,
pageSize: PAGE_SIZE,
};
const { data, isLoading, isFetching } = useTicketsPaged(queryParams);
const tickets = data?.data ?? [];
const total = data?.total ?? 0;
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const savedViewsQ = useSavedViews();
const createView = useCreateSavedView();
const deleteView = useDeleteSavedView();
const bulk = useBulkTickets();
const allVisible = tickets.length > 0 && tickets.every((t) => selected.has(t.id));
const someVisible = tickets.some((t) => selected.has(t.id));
const toggleAll = () => {
setSelected((prev) => {
const next = new Set(prev);
if (allVisible) tickets.forEach((t) => next.delete(t.id));
else tickets.forEach((t) => next.add(t.id));
return next;
});
};
const toggleOne = (id: string) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const clearSelected = () => setSelected(new Set());
const currentFilters = useMemo(
() => ({
status: status || undefined,
severity: severity || undefined,
assigneeId: assigneeId || undefined,
categoryId: categoryId || undefined,
typeId: typeId || undefined,
itemId: itemId || undefined,
search: search || undefined,
}),
[status, severity, assigneeId, categoryId, typeId, itemId, search],
);
const runBulk = async (payload: { action: string; value?: unknown }) => {
const ids = Array.from(selected).slice(0, BULK_LIMIT);
try {
const res = (await bulk.mutateAsync({ ids, ...payload })) as { updated?: number };
toast.success(`Updated ${res.updated ?? ids.length} ticket${ids.length === 1 ? '' : 's'}`);
clearSelected();
} catch (e) {
toast.error((e as Error).message || 'Bulk action failed');
} finally {
setConfirmBulk(null);
}
};
const activeCount = Object.values(currentFilters).filter(Boolean).length;
const handleSaveView = async () => {
if (!newViewName.trim()) return;
try {
await createView.mutateAsync({
name: newViewName.trim(),
filters: currentFilters,
});
toast.success('Saved view');
setNewViewName('');
setSaveOpen(false);
} catch (e) {
toast.error((e as Error).message || 'Failed to save view');
}
};
const applyView = (filters: Record<string, unknown>) => {
const next = new URLSearchParams();
Object.entries(filters).forEach(([k, v]) => {
if (v !== undefined && v !== null && v !== '') next.set(k, String(v));
});
setParams(next, { replace: true });
setSearchInput(String(filters.search ?? ''));
};
const agentUsers = users.filter((u) => u.role !== 'SERVICE');
return (
<Layout wide>
{/* Status tabs */}
<div className="flex items-center border-b border-border mb-4 -mx-4 px-4 overflow-x-auto">
{STATUS_TABS.map((tab) => (
<button
key={tab.value}
onClick={() => updateParam('status', tab.value || null)}
className={`px-3 py-2 text-sm whitespace-nowrap border-b-2 -mb-px transition-colors ${
status === tab.value
? 'border-primary text-foreground font-medium'
: 'border-transparent text-muted-foreground hover:text-foreground'
}`}
>
{tab.label}
</button>
))}
</div>
{/* Filter bar */}
<div className="flex gap-2 mb-4 flex-wrap items-center">
<input
type="search"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="Search title, overview, ID…"
className="flex-1 min-w-48 max-w-xs px-3 py-1.5 rounded-md border border-input bg-background text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
<select
value={severity}
onChange={(e) => updateParam('severity', e.target.value || null)}
className="px-3 py-1.5 rounded-md border border-input bg-background text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-ring"
>
<option value="">All severities</option>
{[1, 2, 3, 4, 5].map((s) => (
<option key={s} value={s}>
SEV {s}
</option>
))}
</select>
<select
value={assigneeId}
onChange={(e) => updateParam('assigneeId', e.target.value || null)}
className="px-3 py-1.5 rounded-md border border-input bg-background text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-ring"
>
<option value="">All assignees</option>
{authUser && (
<option value={authUser.id}>Me ({authUser.displayName})</option>
)}
{agentUsers
.filter((u) => u.id !== authUser?.id)
.map((u) => (
<option key={u.id} value={u.id}>
{u.displayName}
</option>
))}
</select>
<div className="min-w-56">
<CTISelect
value={{ categoryId, typeId, itemId }}
onChange={(cti) => {
setParams(
(prev) => {
const next = new URLSearchParams(prev);
next.delete('page');
if (cti.categoryId) next.set('categoryId', cti.categoryId);
else next.delete('categoryId');
if (cti.typeId) next.set('typeId', cti.typeId);
else next.delete('typeId');
if (cti.itemId) next.set('itemId', cti.itemId);
else next.delete('itemId');
return next;
},
{ replace: true },
);
}}
/>
</div>
{/* Saved views */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="px-3 py-1.5 rounded-md border border-input bg-background text-sm hover:bg-accent">
Saved views
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64">
<DropdownMenuLabel>Saved views</DropdownMenuLabel>
<DropdownMenuSeparator />
{savedViewsQ.data && savedViewsQ.data.length > 0 ? (
savedViewsQ.data.map((v) => (
<div
key={v.id}
className="flex items-center gap-1 px-2 py-1 hover:bg-accent rounded-sm"
>
<button
onClick={() => applyView(v.filters)}
className="flex-1 text-left text-sm truncate"
>
{v.name}
</button>
<button
onClick={() => deleteView.mutate(v.id)}
aria-label="Delete view"
className="text-muted-foreground hover:text-destructive"
>
<Trash2 size={13} />
</button>
</div>
))
) : (
<p className="px-2 py-1 text-xs text-muted-foreground">No saved views yet</p>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
disabled={activeCount === 0}
onSelect={() => setSaveOpen(true)}
className="gap-2"
>
<Save size={13} />
Save current filters
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<div className="ml-auto text-xs text-muted-foreground">
{isFetching ? 'Loading…' : `${total} result${total === 1 ? '' : 's'}`}
</div>
</div>
{/* Bulk bar */}
{selected.size > 0 && (
<div className="flex items-center gap-3 mb-3 px-3 py-2 rounded-md bg-accent border border-border">
<span className="text-sm font-medium">
{selected.size} selected
</span>
<button
onClick={clearSelected}
className="text-xs text-muted-foreground hover:text-foreground"
>
Clear
</button>
<div className="h-4 w-px bg-border mx-1" />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="px-2 py-1 rounded-md text-sm hover:bg-background">
Reassign
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem
onSelect={() =>
setConfirmBulk({
kind: 'reassign',
value: null,
label: 'Unassign',
})
}
>
Unassigned
</DropdownMenuItem>
{agentUsers.map((u) => (
<DropdownMenuItem
key={u.id}
onSelect={() =>
setConfirmBulk({
kind: 'reassign',
value: u.id,
label: `Assign to ${u.displayName}`,
})
}
>
{u.displayName}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="px-2 py-1 rounded-md text-sm hover:bg-background">
Severity
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{[1, 2, 3, 4, 5].map((s) => (
<DropdownMenuItem
key={s}
onSelect={() =>
setConfirmBulk({
kind: 'setSeverity',
value: s,
label: `Set severity SEV ${s}`,
})
}
>
SEV {s}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<button
onClick={() =>
setConfirmBulk({ kind: 'close', label: 'Close selected tickets' })
}
className="px-2 py-1 rounded-md text-sm hover:bg-background"
>
Close
</button>
</div>
)}
{/* Ticket list */}
{isLoading ? (
<div className="text-center py-16 text-muted-foreground text-sm">Loading</div>
) : tickets.length === 0 ? (
<div className="text-center py-16 text-muted-foreground text-sm">No tickets found</div>
) : (
<div className="rounded-md border border-border overflow-hidden">
<div className="flex items-center gap-3 px-4 py-2 bg-card text-xs text-muted-foreground border-b border-border">
<input
type="checkbox"
checked={allVisible}
aria-label="Select all on page"
ref={(el) => {
if (el) el.indeterminate = !allVisible && someVisible;
}}
onChange={toggleAll}
className="cursor-pointer"
/>
<span>
{tickets.length} of {total}
</span>
</div>
<ul className="divide-y divide-border">
{tickets.map((ticket) => (
<li
key={ticket.id}
className="flex items-center gap-3 px-4 py-3 hover:bg-accent/30 transition-colors"
>
<input
type="checkbox"
checked={selected.has(ticket.id)}
onChange={() => toggleOne(ticket.id)}
aria-label={`Select ${ticket.displayId}`}
className="cursor-pointer flex-shrink-0"
/>
<div
className={`w-1 self-stretch rounded-full flex-shrink-0 ${sevColor(ticket.severity)}`}
/>
<Link
to={`/${ticket.displayId}`}
className="flex-1 min-w-0 flex items-center gap-3 group"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5 flex-wrap">
<span className="text-sm font-medium text-foreground group-hover:text-primary truncate">
{ticket.title}
</span>
<span className="text-xs font-mono text-muted-foreground">
{ticket.displayId}
</span>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground flex-wrap">
<SeverityBadge severity={ticket.severity} />
<StatusBadge status={ticket.status} />
<span>
opened {formatDistanceToNow(new Date(ticket.createdAt), {
addSuffix: true,
})}{' '}
by {ticket.createdBy.displayName}
</span>
<span className="hidden md:inline">
· {ticket.category.name} {ticket.type.name} {ticket.item.name}
</span>
{ticket.assignee && (
<span className="hidden md:inline">· assigned {ticket.assignee.displayName}</span>
)}
<span>· {ticket._count?.comments ?? 0} comments</span>
</div>
</div>
<div className="hidden sm:flex items-center flex-shrink-0">
{ticket.assignee ? (
<Avatar name={ticket.assignee.displayName} size="sm" />
) : null}
</div>
</Link>
</li>
))}
</ul>
</div>
)}
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between mt-4 text-sm">
<div className="text-muted-foreground">
Page {page} of {totalPages}
</div>
<div className="flex items-center gap-1">
<button
disabled={page <= 1}
onClick={() => updateParam('page', String(page - 1))}
className="p-1.5 rounded-md hover:bg-accent disabled:opacity-40 disabled:cursor-not-allowed"
>
<ChevronLeft size={16} />
</button>
<button
disabled={page >= totalPages}
onClick={() => updateParam('page', String(page + 1))}
className="p-1.5 rounded-md hover:bg-accent disabled:opacity-40 disabled:cursor-not-allowed"
>
<ChevronRight size={16} />
</button>
</div>
</div>
)}
{/* Save view dialog */}
<Dialog open={saveOpen} onOpenChange={setSaveOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Save current filters</DialogTitle>
</DialogHeader>
<div className="py-2">
<input
autoFocus
type="text"
value={newViewName}
onChange={(e) => setNewViewName(e.target.value)}
placeholder="View name"
className="w-full px-3 py-2 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
onKeyDown={(e) => {
if (e.key === 'Enter') handleSaveView();
}}
/>
</div>
<DialogFooter>
<button
onClick={() => setSaveOpen(false)}
className="px-3 py-1.5 rounded-md border border-input text-sm hover:bg-accent"
>
Cancel
</button>
<button
onClick={handleSaveView}
disabled={!newViewName.trim() || createView.isPending}
className="px-3 py-1.5 rounded-md bg-primary text-primary-foreground text-sm disabled:opacity-50"
>
Save
</button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Confirm bulk action */}
<AlertDialog open={!!confirmBulk} onOpenChange={(o) => !o && setConfirmBulk(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{confirmBulk?.label}</AlertDialogTitle>
<AlertDialogDescription>
This will affect {selected.size} ticket{selected.size === 1 ? '' : 's'}.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
if (!confirmBulk) return;
if (confirmBulk.kind === 'close') runBulk({ action: 'close' });
else
runBulk({
action: confirmBulk.kind,
value: confirmBulk.value,
});
}}
>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Layout>
);
}
+74 -19
View File
@@ -1,5 +1,6 @@
import { useState } from 'react';
import { Plus, Pencil, Trash2, RefreshCw, Copy, Check } from 'lucide-react';
import { toast } from 'sonner';
import Layout from '../../components/Layout';
import Modal from '../../components/Modal';
import { User, Role } from '../../types';
@@ -10,6 +11,16 @@ import {
useUpdateUser,
useDeleteUser,
} from '../../api/queries';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
interface UserForm {
username: string;
@@ -61,6 +72,8 @@ export default function AdminUsers() {
const [error, setError] = useState('');
const [copiedKey, setCopiedKey] = useState<string | null>(null);
const [newApiKey, setNewApiKey] = useState<string | null>(null);
const [deleting, setDeleting] = useState<User | null>(null);
const [rotating, setRotating] = useState<User | null>(null);
const submitting = createUser.isPending || updateUser.isPending;
@@ -130,25 +143,31 @@ export default function AdminUsers() {
}
};
const handleDelete = async (u: User) => {
if (!confirm(`Delete user "${u.displayName}"?`)) return;
await deleteUser.mutateAsync(u.id);
const confirmDelete = async () => {
if (!deleting) return;
try {
await deleteUser.mutateAsync(deleting.id);
toast.success(`Deleted ${deleting.displayName}`);
} catch (e) {
toast.error((e as Error).message || 'Failed to delete user');
}
setDeleting(null);
};
const handleRegenerateKey = async (u: User) => {
if (
!confirm(
`Regenerate API key for "${u.displayName}"? The old key will stop working immediately.`,
)
)
return;
const updated = await updateUser.mutateAsync({
id: u.id,
data: { regenerateApiKey: true },
});
setNewApiKey(updated.apiKey ?? null);
setSelected(u);
setModal('edit');
const confirmRegenerate = async () => {
if (!rotating) return;
try {
const updated = await updateUser.mutateAsync({
id: rotating.id,
data: { regenerateApiKey: true },
});
setNewApiKey(updated.apiKey ?? null);
setSelected(rotating);
setModal('edit');
} catch (e) {
toast.error((e as Error).message || 'Failed to rotate key');
}
setRotating(null);
};
const copyToClipboard = (key: string) => {
@@ -210,7 +229,7 @@ export default function AdminUsers() {
<div className="flex items-center justify-end gap-2">
{u.role === 'SERVICE' && (
<button
onClick={() => handleRegenerateKey(u)}
onClick={() => setRotating(u)}
className="text-gray-600 hover:text-gray-300 transition-colors"
title="Regenerate API key"
>
@@ -225,7 +244,7 @@ export default function AdminUsers() {
</button>
{u.id !== authUser?.id && (
<button
onClick={() => handleDelete(u)}
onClick={() => setDeleting(u)}
className="text-gray-600 hover:text-red-400 transition-colors"
>
<Trash2 size={14} />
@@ -365,6 +384,42 @@ export default function AdminUsers() {
)}
</Modal>
)}
<AlertDialog open={!!deleting} onOpenChange={(o) => !o && setDeleting(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete {deleting?.displayName}?</AlertDialogTitle>
<AlertDialogDescription>
This user will be permanently removed. Their tickets and comments are preserved.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={confirmDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog open={!!rotating} onOpenChange={(o) => !o && setRotating(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Regenerate API key for {rotating?.displayName}?</AlertDialogTitle>
<AlertDialogDescription>
The old key will stop working immediately. You&apos;ll see the new key once make
sure whatever uses it can be updated.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={confirmRegenerate}>Rotate key</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Layout>
);
}
+304
View File
@@ -0,0 +1,304 @@
import { useState } from 'react';
import { Copy, Plus, RefreshCw, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import Layout from '../../components/Layout';
import { WEBHOOK_EVENTS } from '../../../../shared/schemas/notification';
import type { Webhook } from '../../../../shared/types';
import {
useWebhooks,
useCreateWebhook,
useDeleteWebhook,
useUpdateWebhook,
useRotateWebhookSecret,
} from '../../api/queries';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
const BLANK = {
name: '',
url: '',
events: [...WEBHOOK_EVENTS] as string[],
};
export default function AdminWebhooks() {
const { data: hooks = [] } = useWebhooks();
const create = useCreateWebhook();
const update = useUpdateWebhook();
const del = useDeleteWebhook();
const rotate = useRotateWebhookSecret();
const [addOpen, setAddOpen] = useState(false);
const [form, setForm] = useState(BLANK);
const [revealedSecret, setRevealedSecret] = useState<{ name: string; secret: string } | null>(
null,
);
const [deleting, setDeleting] = useState<Webhook | null>(null);
const [rotating, setRotating] = useState<Webhook | null>(null);
const copy = async (text: string) => {
await navigator.clipboard.writeText(text);
toast.success('Copied to clipboard');
};
const toggleEvent = (event: string) => {
setForm((f) => ({
...f,
events: f.events.includes(event)
? f.events.filter((e) => e !== event)
: [...f.events, event],
}));
};
const submitCreate = async () => {
try {
const hook = await create.mutateAsync(form);
if (hook.secret) setRevealedSecret({ name: hook.name, secret: hook.secret });
setForm(BLANK);
setAddOpen(false);
toast.success('Webhook created');
} catch (e) {
toast.error((e as Error).message || 'Failed to create webhook');
}
};
const confirmDelete = async () => {
if (!deleting) return;
try {
await del.mutateAsync(deleting.id);
toast.success('Webhook deleted');
} catch (e) {
toast.error((e as Error).message || 'Failed to delete');
}
setDeleting(null);
};
const confirmRotate = async () => {
if (!rotating) return;
try {
const hook = await rotate.mutateAsync(rotating.id);
if (hook.secret) setRevealedSecret({ name: hook.name, secret: hook.secret });
} catch (e) {
toast.error((e as Error).message || 'Failed to rotate');
}
setRotating(null);
};
return (
<Layout
title="Webhooks"
action={
<button
onClick={() => setAddOpen(true)}
className="flex items-center gap-1.5 bg-primary text-primary-foreground px-3 py-1.5 rounded-md text-sm"
>
<Plus size={14} />
Add webhook
</button>
}
>
{hooks.length === 0 ? (
<div className="py-16 text-center text-sm text-muted-foreground border border-border rounded-md">
No webhooks configured. Add one to push events to n8n, Slack, or any HTTP receiver.
</div>
) : (
<div className="rounded-md border border-border overflow-hidden">
<table className="w-full text-sm">
<thead className="border-b border-border bg-card text-xs uppercase text-muted-foreground">
<tr>
<th className="text-left px-4 py-2">Name</th>
<th className="text-left px-4 py-2">URL</th>
<th className="text-left px-4 py-2">Events</th>
<th className="text-left px-4 py-2">Active</th>
<th className="px-4 py-2" />
</tr>
</thead>
<tbody className="divide-y divide-border">
{hooks.map((h) => (
<tr key={h.id}>
<td className="px-4 py-2 font-medium">{h.name}</td>
<td className="px-4 py-2 font-mono text-xs text-muted-foreground truncate max-w-xs">
{h.url}
</td>
<td className="px-4 py-2 text-xs text-muted-foreground">
{h.events.length} event{h.events.length === 1 ? '' : 's'}
</td>
<td className="px-4 py-2">
<button
onClick={() => update.mutate({ id: h.id, data: { active: !h.active } })}
className={`text-xs px-2 py-0.5 rounded-full ${
h.active
? 'bg-green-500/20 text-green-400'
: 'bg-muted text-muted-foreground'
}`}
>
{h.active ? 'Active' : 'Disabled'}
</button>
</td>
<td className="px-4 py-2">
<div className="flex items-center justify-end gap-2">
<button
onClick={() => setRotating(h)}
title="Rotate secret"
className="text-muted-foreground hover:text-foreground"
>
<RefreshCw size={14} />
</button>
<button
onClick={() => setDeleting(h)}
title="Delete"
className="text-muted-foreground hover:text-destructive"
>
<Trash2 size={14} />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<Dialog open={addOpen} onOpenChange={setAddOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Add webhook</DialogTitle>
</DialogHeader>
<div className="space-y-3 py-2">
<div>
<label className="block text-xs font-medium text-muted-foreground mb-1">Name</label>
<input
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
className="w-full px-3 py-1.5 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
<div>
<label className="block text-xs font-medium text-muted-foreground mb-1">
URL
</label>
<input
value={form.url}
onChange={(e) => setForm((f) => ({ ...f, url: e.target.value }))}
placeholder="https://n8n.example.com/webhook/abc"
className="w-full px-3 py-1.5 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
<div>
<label className="block text-xs font-medium text-muted-foreground mb-1">
Events
</label>
<div className="grid grid-cols-2 gap-1">
{WEBHOOK_EVENTS.map((ev) => (
<label key={ev} className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={form.events.includes(ev)}
onChange={() => toggleEvent(ev)}
/>
<span className="font-mono text-xs">{ev}</span>
</label>
))}
</div>
</div>
</div>
<DialogFooter>
<button
onClick={() => setAddOpen(false)}
className="px-3 py-1.5 rounded-md border border-input text-sm hover:bg-accent"
>
Cancel
</button>
<button
onClick={submitCreate}
disabled={!form.name || !form.url || form.events.length === 0 || create.isPending}
className="px-3 py-1.5 rounded-md bg-primary text-primary-foreground text-sm disabled:opacity-50"
>
Create
</button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={!!revealedSecret} onOpenChange={(o) => !o && setRevealedSecret(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Secret for {revealedSecret?.name}</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
Copy it now it won&apos;t be shown again. Use it to verify the{' '}
<code className="text-xs">X-Ticketing-Signature</code> HMAC-SHA256 header.
</p>
<div className="flex items-center gap-2 bg-muted rounded-md px-3 py-2 font-mono text-xs break-all">
<span className="flex-1">{revealedSecret?.secret}</span>
<button
onClick={() => revealedSecret && copy(revealedSecret.secret)}
className="text-muted-foreground hover:text-foreground"
>
<Copy size={14} />
</button>
</div>
<DialogFooter>
<button
onClick={() => setRevealedSecret(null)}
className="px-3 py-1.5 rounded-md bg-primary text-primary-foreground text-sm"
>
Done
</button>
</DialogFooter>
</DialogContent>
</Dialog>
<AlertDialog open={!!deleting} onOpenChange={(o) => !o && setDeleting(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete webhook {deleting?.name}?</AlertDialogTitle>
<AlertDialogDescription>
No events will be dispatched to this URL anymore.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={confirmDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog open={!!rotating} onOpenChange={(o) => !o && setRotating(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Rotate secret for {rotating?.name}?</AlertDialogTitle>
<AlertDialogDescription>
The existing secret stops signing new events immediately. You&apos;ll see the new
secret once.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={confirmRotate}>Rotate</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Layout>
);
}