From 69467239ed9ac2beaf1f98d3d1693543eab3cc42 Mon Sep 17 00:00:00 2001 From: Oleg Date: Wed, 5 Aug 2026 12:50:54 +0000 Subject: [PATCH] Header redesign, sound/toast notifications, and agent scoping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four requested improvements: 1. Logo + wordmark now link to /dashboard. 2. Header reordered: nav links (Заявки/Статистика) centered, right side now reads notifications -> theme -> settings -> account name -> logout, matching the requested order. 3. New TicketToasts component: in-page toast + a short Web Audio chime for new tickets and customer messages, shown regardless of tab focus. Complements the existing DesktopNotifications, which only fires the native OS notification while the tab is backgrounded. Also added a 30s polling refresh on the dashboard as a plain safety net under the existing SSE push, in case an event is dropped by whatever's in front of this app on the network. 4. Agents now see only tickets assigned to them plus unassigned ones (not the whole board) — listTickets() takes an optional visibility filter, applied in both the dashboard page and the /api/tickets search route, and mirrored client-side so a live SSE update can't leak a just-reassigned-away ticket onto their board. Settings gear and the Статистика nav link are now hidden entirely for agents rather than just showing fewer items inside. Also closed a real gap this surfaced: /stats had no server-side role check at all (only the nav link was hidden), so any agent could load it directly — added the same session.user.role !== "admin" guard the other admin-only settings pages already have. Verified end-to-end with a temporary Playwright pass (installed and fully removed afterward): logo click navigation, header icon order, agent view missing the gear/Статистика, direct-URL access to /stats and /settings/accounts still redirecting non-admins, and ticket visibility correctly disappearing from an agent's board the moment it's reassigned to someone else. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_012o9j9RezxbZVKQMrB7oRLY --- src/app/(admin)/dashboard/page.tsx | 10 +- src/app/(admin)/dashboard/ticket-board.tsx | 28 +++++- src/app/(admin)/desktop-notifications.tsx | 5 +- src/app/(admin)/layout.tsx | 22 +++-- src/app/(admin)/nav-links.tsx | 9 +- src/app/(admin)/stats/page.tsx | 7 ++ src/app/(admin)/ticket-toasts.tsx | 108 +++++++++++++++++++++ src/app/api/tickets/route.ts | 2 +- src/lib/events/bus.ts | 2 +- src/lib/tickets/service.ts | 19 +++- src/lib/tickets/visibility.ts | 10 ++ 11 files changed, 201 insertions(+), 21 deletions(-) create mode 100644 src/app/(admin)/ticket-toasts.tsx create mode 100644 src/lib/tickets/visibility.ts diff --git a/src/app/(admin)/dashboard/page.tsx b/src/app/(admin)/dashboard/page.tsx index 45c3f22..40ad72d 100644 --- a/src/app/(admin)/dashboard/page.tsx +++ b/src/app/(admin)/dashboard/page.tsx @@ -1,7 +1,13 @@ +import { redirect } from "next/navigation"; +import { getCurrentSession } from "@/lib/auth/session"; import { listTickets } from "@/lib/tickets/service"; import { TicketBoard } from "./ticket-board"; export default async function DashboardPage() { - const tickets = await listTickets(); - return ; + const session = await getCurrentSession(); + if (!session) redirect("/login"); + + const currentUser = { id: session.user.id, role: session.user.role }; + const tickets = await listTickets(undefined, currentUser); + return ; } diff --git a/src/app/(admin)/dashboard/ticket-board.tsx b/src/app/(admin)/dashboard/ticket-board.tsx index bc78686..1f3de5b 100644 --- a/src/app/(admin)/dashboard/ticket-board.tsx +++ b/src/app/(admin)/dashboard/ticket-board.tsx @@ -7,6 +7,7 @@ import { Send, Globe, UserRound, Mail, MessageSquare, Plus, Inbox, Search, X } f import { useTicketEvents } from "@/lib/events/use-ticket-events"; import { formatRelativeTime } from "@/lib/format"; import type { TicketDTO, TicketStatus } from "@/lib/tickets/types"; +import { isTicketVisibleTo } from "@/lib/tickets/visibility"; import { NewTicketModal } from "./new-ticket-modal"; import { Avatar } from "@/components/avatar"; import { TAG_COLOR_CLASSES } from "@/lib/tags/colors"; @@ -42,7 +43,13 @@ const PRIORITY_COLOR: Record = { urgent: "var(--danger)", }; -export function TicketBoard({ initialTickets }: { initialTickets: TicketDTO[] }) { +export function TicketBoard({ + initialTickets, + currentUser, +}: { + initialTickets: TicketDTO[]; + currentUser: { id: string; role: "admin" | "agent" }; +}) { const [tickets, setTickets] = useState(initialTickets); const [modalOpen, setModalOpen] = useState(false); const [activeTagId, setActiveTagId] = useState(null); @@ -52,13 +59,30 @@ export function TicketBoard({ initialTickets }: { initialTickets: TicketDTO[] }) useTicketEvents("/api/events", (event) => { if (event.type === "ticket.created" || event.type === "ticket.updated") { + const visible = isTicketVisibleTo(event.ticket, currentUser); setTickets((prev) => { const withoutIt = prev.filter((t) => t.id !== event.ticket.id); - return [event.ticket, ...withoutIt]; + // Drop it if a live update (e.g. reassignment) just made it invisible + // to this agent — otherwise it'd linger on their board until reload. + return visible ? [event.ticket, ...withoutIt] : withoutIt; }); } }); + // Server push (SSE) already keeps this current, but that depends on the + // event stream actually reaching the browser through whatever reverse + // proxy sits in front — a periodic refetch is a plain safety net so the + // board is never more than 30s stale even if that path is broken. + useEffect(() => { + const interval = setInterval(() => { + fetch("/api/tickets") + .then((res) => res.json()) + .then((data) => setTickets(data.tickets ?? [])) + .catch(() => {}); + }, 30_000); + return () => clearInterval(interval); + }, []); + // Search hits the server (message bodies aren't loaded client-side), // debounced. An empty query needs no fetch — baseTickets below just falls // back to the live SSE-backed list, so there's nothing to reset here. diff --git a/src/app/(admin)/desktop-notifications.tsx b/src/app/(admin)/desktop-notifications.tsx index 0c05c0f..7c7f2be 100644 --- a/src/app/(admin)/desktop-notifications.tsx +++ b/src/app/(admin)/desktop-notifications.tsx @@ -2,6 +2,7 @@ import { useRouter } from "next/navigation"; import { useTicketEvents } from "@/lib/events/use-ticket-events"; +import { isTicketVisibleTo } from "@/lib/tickets/visibility"; /** * No visible UI — just a permanently-mounted subscriber (in the admin @@ -10,7 +11,7 @@ import { useTicketEvents } from "@/lib/events/use-ticket-events"; * response," while the tab is in the background. Agent-authored messages * are deliberately skipped — that's the agent's own action, not a signal. */ -export function DesktopNotifications() { +export function DesktopNotifications({ currentUser }: { currentUser: { id: string; role: "admin" | "agent" } }) { const router = useRouter(); useTicketEvents("/api/events", (event) => { @@ -18,12 +19,14 @@ export function DesktopNotifications() { if (Notification.permission !== "granted" || !document.hidden) return; if (event.type === "ticket.created") { + if (!isTicketVisibleTo(event.ticket, currentUser)) return; const notification = new Notification("Новая заявка", { body: event.ticket.subject }); notification.onclick = () => { window.focus(); router.push(`/tickets/${event.ticket.id}`); }; } else if (event.type === "message.created" && event.message.authorType === "customer") { + if (!isTicketVisibleTo({ assigneeId: event.assigneeId }, currentUser)) return; const notification = new Notification(`Сообщение от ${event.message.authorName}`, { body: event.message.body.slice(0, 120), }); diff --git a/src/app/(admin)/layout.tsx b/src/app/(admin)/layout.tsx index fd73e13..e06ad7a 100644 --- a/src/app/(admin)/layout.tsx +++ b/src/app/(admin)/layout.tsx @@ -1,4 +1,5 @@ import { redirect } from "next/navigation"; +import Link from "next/link"; import { Ticket } from "lucide-react"; import { getCurrentSession } from "@/lib/auth/session"; import { LogoutButton } from "./logout-button"; @@ -7,6 +8,7 @@ import { ThemeToggle } from "./theme-toggle"; import { SettingsMenu } from "./settings-menu"; import { NotificationToggle } from "./notification-toggle"; import { DesktopNotifications } from "./desktop-notifications"; +import { TicketToasts } from "./ticket-toasts"; export default async function AdminLayout({ children }: { children: React.ReactNode }) { const session = await getCurrentSession(); @@ -14,30 +16,34 @@ export default async function AdminLayout({ children }: { children: React.ReactN redirect("/login"); } + const isAdmin = session.user.role === "admin"; + const currentUser = { id: session.user.id, role: session.user.role }; + return (
-
+
top-tickets + +
+
-
- {session.user.name} +
- + {isAdmin && } + {session.user.name}
-
- -
{children}
- + +
); } diff --git a/src/app/(admin)/nav-links.tsx b/src/app/(admin)/nav-links.tsx index 0cce590..9448482 100644 --- a/src/app/(admin)/nav-links.tsx +++ b/src/app/(admin)/nav-links.tsx @@ -5,16 +5,17 @@ import { usePathname } from "next/navigation"; import { LayoutDashboard, BarChart3 } from "lucide-react"; const links = [ - { href: "/dashboard", label: "Заявки", icon: LayoutDashboard }, - { href: "/stats", label: "Статистика", icon: BarChart3 }, + { href: "/dashboard", label: "Заявки", icon: LayoutDashboard, adminOnly: false }, + { href: "/stats", label: "Статистика", icon: BarChart3, adminOnly: true }, ]; -export function NavLinks() { +export function NavLinks({ isAdmin }: { isAdmin: boolean }) { const pathname = usePathname(); + const visibleLinks = links.filter((link) => !link.adminOnly || isAdmin); return (