4bade22410
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>
91 lines
3.0 KiB
TypeScript
91 lines
3.0 KiB
TypeScript
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>
|
|
);
|
|
}
|