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
+10 -1
View File
@@ -4,10 +4,14 @@ import PrivateRoute from './components/PrivateRoute';
import AdminRoute from './components/AdminRoute';
import Login from './pages/Login';
import Dashboard from './pages/Dashboard';
import Tickets from './pages/Tickets';
import MyTickets from './pages/MyTickets';
import TicketDetail from './pages/TicketDetail';
import Notifications from './pages/Notifications';
import Settings from './pages/Settings';
import AdminUsers from './pages/admin/Users';
import AdminCTI from './pages/admin/CTI';
import AdminWebhooks from './pages/admin/Webhooks';
export default function App() {
return (
@@ -16,12 +20,17 @@ export default function App() {
<Routes>
<Route path="/login" element={<Login />} />
<Route element={<PrivateRoute />}>
<Route path="/" element={<Dashboard />} />
<Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/tickets" element={<Tickets />} />
<Route path="/my-tickets" element={<MyTickets />} />
<Route path="/notifications" element={<Notifications />} />
<Route path="/settings" element={<Settings />} />
<Route path="/:id" element={<TicketDetail />} />
<Route element={<AdminRoute />}>
<Route path="/admin/users" element={<AdminUsers />} />
<Route path="/admin/cti" element={<AdminCTI />} />
<Route path="/admin/webhooks" element={<AdminWebhooks />} />
</Route>
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
+195
View File
@@ -1,18 +1,30 @@
import { useQuery, useMutation, useQueryClient, keepPreviousData } from '@tanstack/react-query';
import api from './client';
import { Ticket, Category, CTIType, Item, User, AuditLog, Comment } from '../types';
import type {
Notification,
SavedView,
Webhook,
PaginatedResponse,
} from '../../../shared/types';
// ── Keys ─────────────────────────────────────────────────────────────────────
export const qk = {
tickets: (filters?: Record<string, string | number | undefined>) =>
['tickets', filters ?? {}] as const,
ticketsPaged: (filters?: Record<string, string | number | undefined>) =>
['tickets-paged', filters ?? {}] as const,
ticket: (id: string) => ['ticket', id] as const,
ticketAudit: (id: string) => ['ticket', id, 'audit'] as const,
categories: () => ['cti', 'categories'] as const,
types: (categoryId?: string) => ['cti', 'types', categoryId ?? null] as const,
items: (typeId?: string) => ['cti', 'items', typeId ?? null] as const,
users: () => ['users'] as const,
notifications: (unread?: boolean) => ['notifications', { unread: !!unread }] as const,
webhooks: () => ['webhooks'] as const,
savedViews: () => ['saved-views'] as const,
analytics: (window: number) => ['analytics', window] as const,
};
// ── Tickets ──────────────────────────────────────────────────────────────────
@@ -34,6 +46,34 @@ export function useTickets(params: Record<string, string | number | undefined> =
});
}
export function useTicketsPaged(params: Record<string, string | number | undefined> = {}) {
const clean: Record<string, string | number> = {};
Object.entries(params).forEach(([k, v]) => {
if (v !== undefined && v !== '' && v !== null) clean[k] = v;
});
return useQuery({
queryKey: qk.ticketsPaged(clean),
queryFn: async () => {
const res = await api.get<PaginatedResponse<Ticket>>('/tickets', { params: clean });
return res.data;
},
placeholderData: keepPreviousData,
});
}
export function useBulkTickets() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (data: { ids: string[]; action: string; value?: unknown }) =>
(await api.post('/tickets/bulk', data)).data,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['tickets'] });
qc.invalidateQueries({ queryKey: ['tickets-paged'] });
},
});
}
export function useTicket(id: string | undefined) {
return useQuery({
queryKey: qk.ticket(id ?? ''),
@@ -246,3 +286,158 @@ export function useDeleteUser() {
onSuccess: () => qc.invalidateQueries({ queryKey: qk.users() }),
});
}
// ── Notifications ────────────────────────────────────────────────────────────
export function useNotifications(unread = false) {
return useQuery({
queryKey: qk.notifications(unread),
queryFn: async () =>
(
await api.get<Notification[]>('/notifications', {
params: unread ? { unread: 'true' } : {},
})
).data,
refetchInterval: 60_000,
});
}
export function useMarkNotificationsRead() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (data: { ids?: string[]; all?: boolean }) =>
(await api.post<{ updated: number }>('/notifications/read', data)).data,
onSuccess: () => qc.invalidateQueries({ queryKey: ['notifications'] }),
});
}
export function useUnreadCount() {
return useQuery({
queryKey: ['notifications', 'unread-count'],
queryFn: async () =>
(await api.get<{ count: number }>('/notifications/unread-count')).data.count,
refetchInterval: 60_000,
});
}
// ── Webhooks ─────────────────────────────────────────────────────────────────
export function useWebhooks() {
return useQuery({
queryKey: qk.webhooks(),
queryFn: async () => (await api.get<Webhook[]>('/webhooks')).data,
});
}
export function useCreateWebhook() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (data: { name: string; url: string; events: string[] }) =>
(await api.post<Webhook>('/webhooks', data)).data,
onSuccess: () => qc.invalidateQueries({ queryKey: qk.webhooks() }),
});
}
export function useUpdateWebhook() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ id, data }: { id: string; data: Record<string, unknown> }) =>
(await api.patch<Webhook>(`/webhooks/${id}`, data)).data,
onSuccess: () => qc.invalidateQueries({ queryKey: qk.webhooks() }),
});
}
export function useDeleteWebhook() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (id: string) => {
await api.delete(`/webhooks/${id}`);
},
onSuccess: () => qc.invalidateQueries({ queryKey: qk.webhooks() }),
});
}
export function useRotateWebhookSecret() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (id: string) =>
(await api.post<Webhook>(`/webhooks/${id}/rotate-secret`)).data,
onSuccess: () => qc.invalidateQueries({ queryKey: qk.webhooks() }),
});
}
// ── Saved Views ──────────────────────────────────────────────────────────────
export function useSavedViews() {
return useQuery({
queryKey: qk.savedViews(),
queryFn: async () => (await api.get<SavedView[]>('/saved-views')).data,
staleTime: 60_000,
});
}
export function useCreateSavedView() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (data: { name: string; filters: Record<string, unknown> }) =>
(await api.post<SavedView>('/saved-views', data)).data,
onSuccess: () => qc.invalidateQueries({ queryKey: qk.savedViews() }),
});
}
export function useDeleteSavedView() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (id: string) => {
await api.delete(`/saved-views/${id}`);
},
onSuccess: () => qc.invalidateQueries({ queryKey: qk.savedViews() }),
});
}
// ── Analytics ────────────────────────────────────────────────────────────────
export interface AnalyticsSummary {
windowDays: number;
openBySeverity: { severity: number; count: number }[];
statusCounts: { status: string; count: number }[];
queueByAssignee: { assigneeId: string | null; count: number }[];
ageBuckets: Record<'d1' | 'd7' | 'd14' | 'older', number>;
medianResolutionHours: number | null;
}
export function useAnalytics(windowDays = 30) {
return useQuery({
queryKey: qk.analytics(windowDays),
queryFn: async () =>
(
await api.get<AnalyticsSummary>('/analytics/summary', {
params: { window: windowDays },
})
).data,
staleTime: 60_000,
});
}
// ── Notification prefs ───────────────────────────────────────────────────────
export interface NotificationPrefs {
email: { assignment: boolean; mention: boolean; resolved: boolean };
inApp: { assignment: boolean; mention: boolean; resolved: boolean };
}
export function useNotificationPrefs() {
return useQuery({
queryKey: ['notifications', 'prefs'],
queryFn: async () => (await api.get<NotificationPrefs>('/notifications/prefs')).data,
});
}
export function useUpdateNotificationPrefs() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (prefs: NotificationPrefs) =>
(await api.put<NotificationPrefs>('/notifications/prefs', prefs)).data,
onSuccess: () => qc.invalidateQueries({ queryKey: ['notifications', 'prefs'] }),
});
}
+104
View File
@@ -0,0 +1,104 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Search } from 'lucide-react';
import api from '../api/client';
import type { Ticket } from '../types';
export default function GlobalSearch() {
const navigate = useNavigate();
const [q, setQ] = useState('');
const [open, setOpen] = useState(false);
const [results, setResults] = useState<Ticket[]>([]);
const [loading, setLoading] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const wrapperRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handler = (e: MouseEvent) => {
if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) {
setOpen(false);
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, []);
useEffect(() => {
if (!q.trim()) {
setResults([]);
return;
}
setLoading(true);
const t = setTimeout(async () => {
try {
const res = await api.get<{ tickets: Ticket[] }>('/search', {
params: { q, limit: 8 },
});
setResults(res.data.tickets ?? []);
} catch {
setResults([]);
} finally {
setLoading(false);
}
}, 200);
return () => clearTimeout(t);
}, [q]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!q.trim()) return;
setOpen(false);
navigate(`/tickets?search=${encodeURIComponent(q)}`);
};
return (
<div ref={wrapperRef} className="relative flex-1 max-w-md">
<form onSubmit={handleSubmit}>
<Search
size={14}
className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground pointer-events-none"
/>
<input
ref={inputRef}
type="search"
value={q}
onChange={(e) => setQ(e.target.value)}
onFocus={() => setOpen(true)}
placeholder="Search tickets"
className="w-full pl-9 pr-3 py-1.5 rounded-md text-sm bg-background border border-input text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
/>
</form>
{open && q.trim() && (
<div className="absolute top-full mt-1 left-0 right-0 z-40 bg-popover border rounded-md shadow-lg overflow-hidden">
{loading ? (
<p className="px-3 py-4 text-xs text-muted-foreground text-center">Searching</p>
) : results.length === 0 ? (
<p className="px-3 py-4 text-xs text-muted-foreground text-center">No matches</p>
) : (
<ul className="max-h-80 overflow-auto">
{results.map((t) => (
<li key={t.id}>
<button
type="button"
onClick={() => {
setOpen(false);
setQ('');
navigate(`/${t.displayId}`);
}}
className="w-full text-left px-3 py-2 hover:bg-accent flex flex-col gap-0.5"
>
<span className="text-sm text-foreground line-clamp-1">{t.title}</span>
<span className="text-xs text-muted-foreground font-mono">
{t.displayId} · {t.status}
</span>
</button>
</li>
))}
</ul>
)}
</div>
)}
</div>
);
}
+190 -69
View File
@@ -1,98 +1,219 @@
import { ReactNode, useState } from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import { LayoutDashboard, Users, Settings, LogOut, Plus, Ticket } from 'lucide-react';
import { Link, NavLink, useNavigate } from 'react-router-dom';
import {
Plus,
LogOut,
Settings as SettingsIcon,
Users as UsersIcon,
Webhook,
Menu,
X,
SlidersHorizontal,
} from 'lucide-react';
import { useAuth } from '../contexts/AuthContext';
import NewTicketModal from '../pages/NewTicket';
import NotificationsBell from './NotificationsBell';
import GlobalSearch from './GlobalSearch';
import Avatar from './Avatar';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
interface LayoutProps {
children: ReactNode;
title?: string;
action?: ReactNode;
subheader?: ReactNode;
wide?: boolean;
}
export default function Layout({ children, title, action }: LayoutProps) {
const navBase =
'px-3 py-1.5 rounded-md text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground';
export default function Layout({ children, title, action, subheader, wide }: LayoutProps) {
const { user, logout } = useAuth();
const location = useLocation();
const navigate = useNavigate();
const [showNewTicket, setShowNewTicket] = useState(false);
const [mobileOpen, setMobileOpen] = useState(false);
const canCreateTicket = user?.role !== 'USER';
const navItems = [
{ to: '/', icon: LayoutDashboard, label: 'All Tickets' },
{ to: '/my-tickets', icon: Ticket, label: 'My Tickets' },
...(user?.role === 'ADMIN'
? [
{ to: '/admin/users', icon: Users, label: 'Users' },
{ to: '/admin/cti', icon: Settings, label: 'CTI Config' },
]
: []),
];
const isAdmin = user?.role === 'ADMIN';
const handleLogout = () => {
logout();
navigate('/login');
};
const isActive = (to: string) =>
to === '/' ? location.pathname === '/' : location.pathname.startsWith(to);
const primaryNav = (
<>
<NavLink
to="/dashboard"
className={({ isActive }) =>
`${navBase} ${isActive ? 'bg-accent text-accent-foreground' : 'text-muted-foreground'}`
}
>
Dashboard
</NavLink>
<NavLink
to="/tickets"
className={({ isActive }) =>
`${navBase} ${isActive ? 'bg-accent text-accent-foreground' : 'text-muted-foreground'}`
}
>
Tickets
</NavLink>
<NavLink
to="/my-tickets"
className={({ isActive }) =>
`${navBase} ${isActive ? 'bg-accent text-accent-foreground' : 'text-muted-foreground'}`
}
>
My tickets
</NavLink>
</>
);
return (
<div className="flex h-screen bg-gray-950 overflow-hidden">
{/* Sidebar */}
<aside className="w-60 bg-gray-900 border-r border-gray-800 flex flex-col flex-shrink-0">
<div className="px-5 py-4 border-b border-gray-800">
<h1 className="text-sm font-bold text-white tracking-wide">Ticketing</h1>
<p className="text-xs text-gray-500 mt-0.5">{user?.displayName}</p>
</div>
<nav className="flex-1 p-2 space-y-0.5">
{navItems.map(({ to, icon: Icon, label }) => (
<Link
key={to}
to={to}
className={`flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors ${
isActive(to)
? 'bg-blue-600 text-white'
: 'text-gray-400 hover:bg-gray-800 hover:text-gray-100'
}`}
>
<Icon size={15} />
{label}
</Link>
))}
</nav>
<div className="p-2 border-t border-gray-800 space-y-0.5">
{canCreateTicket && (
<button
onClick={() => setShowNewTicket(true)}
className="flex items-center gap-3 px-3 py-2 rounded-lg text-sm text-gray-400 hover:bg-gray-800 hover:text-gray-100 w-full transition-colors"
>
<Plus size={15} />
New Ticket
</button>
)}
<button
onClick={handleLogout}
className="flex items-center gap-3 px-3 py-2 rounded-lg text-sm text-gray-400 hover:bg-gray-800 hover:text-gray-100 w-full transition-colors"
<div className="min-h-screen bg-background text-foreground flex flex-col">
{/* Top nav */}
<header className="sticky top-0 z-30 flex-shrink-0 border-b border-border bg-card/95 backdrop-blur">
<div className="mx-auto max-w-[1400px] px-4 h-12 flex items-center gap-4">
<Link
to="/dashboard"
className="flex items-center gap-2 font-semibold text-sm whitespace-nowrap"
>
<LogOut size={15} />
Logout
</button>
</div>
</aside>
<span className="w-6 h-6 rounded bg-primary text-primary-foreground flex items-center justify-center text-xs font-bold">
T
</span>
<span className="hidden sm:inline">Ticketing</span>
</Link>
{/* Main */}
<div className="flex-1 flex flex-col overflow-hidden">
{(title || action) && (
<header className="bg-gray-900 border-b border-gray-800 px-6 py-4 flex items-center justify-between flex-shrink-0">
{title && <h2 className="text-base font-semibold text-gray-100">{title}</h2>}
{action && <div>{action}</div>}
</header>
<nav className="hidden md:flex items-center gap-1">{primaryNav}</nav>
<div className="flex-1 hidden md:block">
<GlobalSearch />
</div>
<div className="flex items-center gap-1 ml-auto">
{canCreateTicket && (
<button
onClick={() => setShowNewTicket(true)}
className="hidden sm:flex items-center gap-1.5 bg-primary text-primary-foreground px-3 py-1.5 rounded-md text-sm font-medium hover:opacity-90 transition-opacity"
>
<Plus size={14} />
New
</button>
)}
<NotificationsBell />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
aria-label="User menu"
className="p-1 rounded-md hover:bg-accent transition-colors"
>
<Avatar name={user?.displayName ?? '?'} size="sm" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<div className="px-2 py-2">
<p className="text-sm font-semibold">{user?.displayName}</p>
<p className="text-xs text-muted-foreground">{user?.email}</p>
</div>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link to="/settings" className="flex items-center gap-2 w-full">
<SettingsIcon size={14} />
Settings
</Link>
</DropdownMenuItem>
{isAdmin && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link to="/admin/users" className="flex items-center gap-2 w-full">
<UsersIcon size={14} />
Users
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/admin/cti" className="flex items-center gap-2 w-full">
<SlidersHorizontal size={14} />
CTI config
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/admin/webhooks" className="flex items-center gap-2 w-full">
<Webhook size={14} />
Webhooks
</Link>
</DropdownMenuItem>
</>
)}
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={handleLogout} className="text-destructive">
<LogOut size={14} />
Log out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<button
type="button"
onClick={() => setMobileOpen((v) => !v)}
className="md:hidden p-2 rounded-md text-muted-foreground hover:bg-accent"
aria-label="Menu"
>
{mobileOpen ? <X size={18} /> : <Menu size={18} />}
</button>
</div>
</div>
{mobileOpen && (
<div className="md:hidden border-t border-border bg-card px-4 py-3 space-y-2">
<div className="flex flex-col gap-1">{primaryNav}</div>
<GlobalSearch />
{canCreateTicket && (
<button
onClick={() => {
setShowNewTicket(true);
setMobileOpen(false);
}}
className="w-full flex items-center justify-center gap-1.5 bg-primary text-primary-foreground px-3 py-2 rounded-md text-sm font-medium"
>
<Plus size={14} />
New ticket
</button>
)}
</div>
)}
<main className="flex-1 overflow-auto p-6">{children}</main>
</div>
</header>
{/* Optional sub-header */}
{(title || action || subheader) && (
<div className="border-b border-border bg-card/50">
<div
className={`mx-auto px-4 py-3 ${wide ? 'max-w-[1400px]' : 'max-w-6xl'} flex items-center justify-between gap-3`}
>
<div className="min-w-0 flex-1">
{title && (
<h1 className="text-lg font-semibold text-foreground truncate">{title}</h1>
)}
{subheader}
</div>
{action && <div className="flex items-center gap-2">{action}</div>}
</div>
</div>
)}
<main className={`flex-1 mx-auto w-full px-4 py-6 ${wide ? 'max-w-[1400px]' : 'max-w-6xl'}`}>
{children}
</main>
{showNewTicket && <NewTicketModal onClose={() => setShowNewTicket(false)} />}
</div>
+113
View File
@@ -0,0 +1,113 @@
import { useMemo } from 'react';
import { Link } from 'react-router-dom';
import { Bell } from 'lucide-react';
import { formatDistanceToNow } from 'date-fns';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import {
useNotifications,
useUnreadCount,
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 renderSummary(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 NotificationsBell() {
const { data: unreadCount = 0 } = useUnreadCount();
const { data: notifications = [] } = useNotifications();
const markRead = useMarkNotificationsRead();
const latest = useMemo(() => notifications.slice(0, 8), [notifications]);
return (
<Popover>
<PopoverTrigger asChild>
<button
type="button"
aria-label="Notifications"
className="relative p-2 rounded-md text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
>
<Bell size={18} />
{unreadCount > 0 && (
<span className="absolute top-1 right-1 min-w-[1rem] h-4 px-1 rounded-full bg-destructive text-destructive-foreground text-[10px] font-semibold flex items-center justify-center">
{unreadCount > 99 ? '99+' : unreadCount}
</span>
)}
</button>
</PopoverTrigger>
<PopoverContent align="end" className="w-80 p-0">
<div className="flex items-center justify-between px-3 py-2 border-b">
<span className="text-sm font-semibold">Notifications</span>
{unreadCount > 0 && (
<button
onClick={() => markRead.mutate({ all: true })}
className="text-xs text-primary hover:underline"
>
Mark all read
</button>
)}
</div>
<div className="max-h-96 overflow-auto">
{latest.length === 0 ? (
<p className="px-3 py-6 text-center text-xs text-muted-foreground">
You&apos;re all caught up
</p>
) : (
latest.map((n) => {
const { label, href } = renderSummary(n);
const unread = !n.readAt;
return (
<Link
key={n.id}
to={href}
onClick={() => unread && markRead.mutate({ ids: [n.id] })}
className={`flex flex-col gap-1 px-3 py-2 text-sm border-b last:border-b-0 hover:bg-accent transition-colors ${
unread ? 'bg-primary/5' : ''
}`}
>
<span className="line-clamp-2">{label}</span>
<span className="text-xs text-muted-foreground">
{formatDistanceToNow(new Date(n.createdAt), { addSuffix: true })}
</span>
</Link>
);
})
)}
</div>
<Link
to="/notifications"
className="block px-3 py-2 text-center text-xs text-primary hover:underline border-t"
>
View all notifications
</Link>
</PopoverContent>
</Popover>
);
}
+36
View File
@@ -0,0 +1,36 @@
import { createContext, useContext, useEffect, useState, ReactNode } from 'react';
type Theme = 'light' | 'dark';
interface ThemeContextType {
theme: Theme;
toggle: () => void;
}
const ThemeContext = createContext<ThemeContextType>(null!);
function initial(): Theme {
// v1.0 ships dark-only; light-mode pages migrate in a future pass.
// Theme context is kept so the toggle can be re-enabled later without wiring.
return 'dark';
}
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<Theme>(initial);
useEffect(() => {
const root = document.documentElement;
root.classList.toggle('dark', theme === 'dark');
localStorage.setItem('theme', theme);
}, [theme]);
return (
<ThemeContext.Provider
value={{ theme, toggle: () => setTheme((t) => (t === 'dark' ? 'light' : 'dark')) }}
>
{children}
</ThemeContext.Provider>
);
}
export const useTheme = () => useContext(ThemeContext);
+8 -3
View File
@@ -2,13 +2,18 @@ import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { QueryClientProvider } from '@tanstack/react-query';
import { queryClient } from './api/queryClient';
import { ThemeProvider } from './contexts/ThemeContext';
import { Toaster } from '@/components/ui/sonner';
import './index.css';
import App from './App';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
<ThemeProvider>
<QueryClientProvider client={queryClient}>
<App />
<Toaster />
</QueryClientProvider>
</ThemeProvider>
</StrictMode>,
);
+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>
);
}