diff --git a/client/src/App.tsx b/client/src/App.tsx
index 40ad1ef..8dd7f5b 100644
--- a/client/src/App.tsx
+++ b/client/src/App.tsx
@@ -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() {
} />
}>
- } />
+ } />
+ } />
+ } />
} />
+ } />
+ } />
} />
}>
} />
} />
+ } />
} />
diff --git a/client/src/api/queries.ts b/client/src/api/queries.ts
index e237324..cd4a4e6 100644
--- a/client/src/api/queries.ts
+++ b/client/src/api/queries.ts
@@ -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) =>
['tickets', filters ?? {}] as const,
+ ticketsPaged: (filters?: Record) =>
+ ['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 =
});
}
+export function useTicketsPaged(params: Record = {}) {
+ const clean: Record = {};
+ 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>('/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('/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('/webhooks')).data,
+ });
+}
+
+export function useCreateWebhook() {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: async (data: { name: string; url: string; events: string[] }) =>
+ (await api.post('/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 }) =>
+ (await api.patch(`/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(`/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('/saved-views')).data,
+ staleTime: 60_000,
+ });
+}
+
+export function useCreateSavedView() {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: async (data: { name: string; filters: Record }) =>
+ (await api.post('/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('/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('/notifications/prefs')).data,
+ });
+}
+
+export function useUpdateNotificationPrefs() {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: async (prefs: NotificationPrefs) =>
+ (await api.put('/notifications/prefs', prefs)).data,
+ onSuccess: () => qc.invalidateQueries({ queryKey: ['notifications', 'prefs'] }),
+ });
+}
diff --git a/client/src/components/GlobalSearch.tsx b/client/src/components/GlobalSearch.tsx
new file mode 100644
index 0000000..e2c0436
--- /dev/null
+++ b/client/src/components/GlobalSearch.tsx
@@ -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([]);
+ const [loading, setLoading] = useState(false);
+ const inputRef = useRef(null);
+ const wrapperRef = useRef(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 (
+
+
+
+ {open && q.trim() && (
+
+ {loading ? (
+
Searching…
+ ) : results.length === 0 ? (
+
No matches
+ ) : (
+
+ {results.map((t) => (
+ -
+
+
+ ))}
+
+ )}
+
+ )}
+
+ );
+}
diff --git a/client/src/components/Layout.tsx b/client/src/components/Layout.tsx
index 1729d6b..48a616e 100644
--- a/client/src/components/Layout.tsx
+++ b/client/src/components/Layout.tsx
@@ -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 = (
+ <>
+
+ `${navBase} ${isActive ? 'bg-accent text-accent-foreground' : 'text-muted-foreground'}`
+ }
+ >
+ Dashboard
+
+
+ `${navBase} ${isActive ? 'bg-accent text-accent-foreground' : 'text-muted-foreground'}`
+ }
+ >
+ Tickets
+
+
+ `${navBase} ${isActive ? 'bg-accent text-accent-foreground' : 'text-muted-foreground'}`
+ }
+ >
+ My tickets
+
+ >
+ );
return (
-
diff --git a/client/src/components/NotificationsBell.tsx b/client/src/components/NotificationsBell.tsx
new file mode 100644
index 0000000..0a2668f
--- /dev/null
+++ b/client/src/components/NotificationsBell.tsx
@@ -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 = {
+ 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 (
+
+
+
+
+
+
+ Notifications
+ {unreadCount > 0 && (
+
+ )}
+
+
+ {latest.length === 0 ? (
+
+ You're all caught up
+
+ ) : (
+ latest.map((n) => {
+ const { label, href } = renderSummary(n);
+ const unread = !n.readAt;
+ return (
+
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' : ''
+ }`}
+ >
+
{label}
+
+ {formatDistanceToNow(new Date(n.createdAt), { addSuffix: true })}
+
+
+ );
+ })
+ )}
+
+
+ View all notifications
+
+
+
+ );
+}
diff --git a/client/src/contexts/ThemeContext.tsx b/client/src/contexts/ThemeContext.tsx
new file mode 100644
index 0000000..ac64728
--- /dev/null
+++ b/client/src/contexts/ThemeContext.tsx
@@ -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(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(initial);
+
+ useEffect(() => {
+ const root = document.documentElement;
+ root.classList.toggle('dark', theme === 'dark');
+ localStorage.setItem('theme', theme);
+ }, [theme]);
+
+ return (
+ setTheme((t) => (t === 'dark' ? 'light' : 'dark')) }}
+ >
+ {children}
+
+ );
+}
+
+export const useTheme = () => useContext(ThemeContext);
diff --git a/client/src/main.tsx b/client/src/main.tsx
index 11a730b..26d5e29 100644
--- a/client/src/main.tsx
+++ b/client/src/main.tsx
@@ -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(
-
-
-
+
+
+
+
+
+
,
);
diff --git a/client/src/pages/Dashboard.tsx b/client/src/pages/Dashboard.tsx
index 106c117..e033f07 100644
--- a/client/src/pages/Dashboard.tsx
+++ b/client/src/pages/Dashboard.tsx
@@ -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 = {
+ 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 = {
+ 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('');
- const [severity, setSeverity] = useState('');
+ const { data: a } = useAnalytics(30);
+ const { data: users = [] } = useUsers();
- const [selectedCategory, setSelectedCategory] = useState(null);
- const [selectedType, setSelectedType] = useState(null);
- const [selectedItem, setSelectedItem] = useState- (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 = {};
- 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 (
-
- {/* Filters */}
-
-
-
- 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"
- />
-
-
-
-
-
-
- {/* Queue picker */}
-
-
-
- {showQueueFilter && (
-
- {/* Categories */}
-
-
- Category
-
-
- {categories.map((cat) => (
-
- ))}
-
-
-
- {/* Types */}
-
-
- Type
-
-
- {!selectedCategory ? (
-
Select category
- ) : types.length === 0 ? (
-
No types
- ) : (
- types.map((type) => (
-
- ))
- )}
-
-
-
- {/* Items */}
-
-
- Item
-
-
- {!selectedType ? (
-
Select type
- ) : items.length === 0 ? (
-
No items
- ) : (
- items.map((item) => (
-
- ))
- )}
-
-
-
- )}
-
-
-
- {/* Ticket list */}
- {isLoading ? (
- Loading...
- ) : tickets.length === 0 ? (
- No tickets found
+ Last 30 days
}>
+ {!a ? (
+ Loading analytics…
) : (
-
- {tickets.map((ticket) => (
-
-
+
+
}
+ label="Open tickets"
+ value={totalOpen.toString()}
+ />
+
}
+ label="Aging >7d"
+ value={((a.ageBuckets.d14 ?? 0) + (a.ageBuckets.older ?? 0)).toString()}
+ />
+
}
+ label="Median resolution"
+ value={fmtHours(a.medianResolutionHours)}
+ />
+
}
+ label="Assignees loaded"
+ value={a.queueByAssignee.filter((q) => q.assigneeId).length.toString()}
+ />
-
-
-
- {ticket.displayId}
-
-
-
-
- {ticket.category.name} › {ticket.type.name} › {ticket.item.name}
-
-
-
- {ticket.title}
-
-
-
-
- {ticket.assignee ? (
-
-
-
{ticket.assignee.displayName}
+ {/* Open by severity */}
+
+ Open by severity
+
+ {[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 (
+
+
+ {SEV_NAMES[sev]}
+
+
+
+ {count}
+
- ) : (
-
Unassigned
- )}
-
- {ticket._count?.comments ?? 0} comments
-
-
- {formatDistanceToNow(new Date(ticket.createdAt), { addSuffix: true })}
-
+ );
+ })}
+
+
+
+ Go to open tickets →
+
+
+
+
+ {/* Age buckets */}
+
+ Age of open tickets
+
+ {[
+ { 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 (
+
+
{label}
+
+
+ {count}
+
+
+ );
+ })}
+
+
+
+ {/* Queue by assignee */}
+
+ Queue load by assignee
+ {a.queueByAssignee.length === 0 ? (
+ No open tickets right now.
+ ) : (
+
+ {a.queueByAssignee
+ .slice()
+ .sort((x, y) => y.count - x.count)
+ .map((row) => {
+ const name = row.assigneeId
+ ? userById.get(row.assigneeId) ?? 'Unknown'
+ : 'Unassigned';
+ return (
+
+ {name}
+ {row.count}
+
+ );
+ })}
-
- ))}
+ )}
+
)}
);
}
+
+function Card({
+ icon,
+ label,
+ value,
+}: {
+ icon: React.ReactNode;
+ label: string;
+ value: string;
+}) {
+ return (
+
+
+ {icon}
+ {label}
+
+
{value}
+
+ );
+}
diff --git a/client/src/pages/Notifications.tsx b/client/src/pages/Notifications.tsx
new file mode 100644
index 0000000..0ccdf75
--- /dev/null
+++ b/client/src/pages/Notifications.tsx
@@ -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
= {
+ 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 (
+ 0 ? (
+
+ ) : null
+ }
+ >
+ {isLoading ? (
+ Loading…
+ ) : notifications.length === 0 ? (
+
+ You're all caught up
+
+ ) : (
+
+ {notifications.map((n) => {
+ const { label, href } = summary(n);
+ const isUnread = !n.readAt;
+ return (
+ -
+ isUnread && markRead.mutate({ ids: [n.id] })}
+ className="flex items-center gap-3 px-4 py-3 hover:bg-accent/30 transition-colors"
+ >
+
+
+
{label}
+
+ {formatDistanceToNow(new Date(n.createdAt), { addSuffix: true })}
+
+
+
+
+ );
+ })}
+
+ )}
+
+ );
+}
diff --git a/client/src/pages/Settings.tsx b/client/src/pages/Settings.tsx
new file mode 100644
index 0000000..ec52df0
--- /dev/null
+++ b/client/src/pages/Settings.tsx
@@ -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(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 (
+
+
+ {/* Profile */}
+
+
+ {/* Notifications */}
+
+ Notification preferences
+ {local ? (
+
+
+
+ Email
+ In app
+
+ {CHANNELS.map(({ key, label }) => (
+
+
{label}
+
+ handleToggle('email', key)}
+ />
+
+
+ handleToggle('inApp', key)}
+ />
+
+
+ ))}
+
+
+
+
+ ) : (
+ Loading…
+ )}
+
+ Email notifications require the server's SMTP config. If SMTP is unset, only
+ in-app delivery happens regardless of these settings.
+
+
+
+ {/* API key (service accounts only) */}
+ {user?.role === 'SERVICE' && user?.apiKey && (
+
+ API key
+
+ {user.apiKey}
+
+
+
+ Pass as x-api-key header on any server-to-server request.
+
+
+ )}
+
+
+ );
+}
+
+function Field({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
+ return (
+
+
{label}
+
{value || '—'}
+
+ );
+}
diff --git a/client/src/pages/TicketDetail.tsx b/client/src/pages/TicketDetail.tsx
index faa3ae2..83cbe51 100644
--- a/client/src/pages/TicketDetail.tsx
+++ b/client/src/pages/TicketDetail.tsx
@@ -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>(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>(new Set());
const [expandedCommentDates, setExpandedCommentDates] = useState>(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() {
{/* Back link */}
@@ -511,22 +543,66 @@ export default function TicketDetail() {
{/* Status */}
-
+
+
+
+
+
+ {statusOptions.map((s) => (
+
+ ))}
+ {!isAdmin && (
+
+ Closing requires admin
+
+ )}
+
+
{/* Severity */}
-
+
+
+
+
+
+ {SEVERITY_OPTIONS.map((s) => (
+
+ ))}
+
+
{/* CTI — one clickable unit */}