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 (