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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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'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
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'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'll see the new
|
||||
secret once.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={confirmRotate}>Rotate</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user