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:
+10
-1
@@ -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 />} />
|
||||
|
||||
@@ -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'] }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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