From 6ec999bbddcffdfd813695c6d3b1da1844dd13cc Mon Sep 17 00:00:00 2001 From: Andrey Date: Sun, 28 Jun 2026 14:07:44 +0300 Subject: [PATCH] feat(notifications): deep-link to conversation + toast on new event MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clicking a dialog notification now opens that exact conversation (conversation id threaded through navigate → Shell → dialogs page, which selects it). New unread notifications detected during polling raise a bottom-right toast. Co-Authored-By: Claude Opus 4.8 --- apps/internal-ui/src/App.tsx | 5 ++++- .../src/features/sales/SalesDialogsPage.tsx | 8 ++++++-- apps/internal-ui/src/layout/Shell.tsx | 20 +++++++++++++++---- .../src/layout/ShellRouteContent.tsx | 4 ++-- 4 files changed, 28 insertions(+), 9 deletions(-) diff --git a/apps/internal-ui/src/App.tsx b/apps/internal-ui/src/App.tsx index 64b349a..728b93e 100644 --- a/apps/internal-ui/src/App.tsx +++ b/apps/internal-ui/src/App.tsx @@ -25,6 +25,7 @@ export function App() { const [selectedProductCode, setSelectedProductCode] = useState(initialRoute.productCode); const [selectedAgentId, setSelectedAgentId] = useState(initialRoute.agentId); const [selectedReleaseId, setSelectedReleaseId] = useState(initialRoute.releaseId); + const [selectedConversationId, setSelectedConversationId] = useState(null); const [data, setData] = useState({ employees: [], departments: [], products: [], agents: [], releases: [] }); const [dataError, setDataError] = useState(false); @@ -34,6 +35,7 @@ export function App() { const nextProductCode = nextRoute === "aiAgentCreate" ? productCode : null; const nextAgentId = nextRoute === "aiAgentDetail" ? entityId : null; const nextReleaseId = nextRoute === "aiRelease" ? entityId : null; + const nextConversationId = nextRoute === "salesDialogs" ? entityId : null; const nextPath = pathFromRoute(nextRoute, entityId, nextProductCode); setRoute(nextRoute); setSelectedEmployeeId(nextEmployeeId); @@ -41,6 +43,7 @@ export function App() { setSelectedProductCode(nextProductCode); setSelectedAgentId(nextAgentId); setSelectedReleaseId(nextReleaseId); + setSelectedConversationId(nextConversationId); if (`${window.location.pathname}${window.location.search}` !== nextPath) { const state = { route: nextRoute, employeeId: nextEmployeeId, productId: nextProductId, productCode: nextProductCode, agentId: nextAgentId, releaseId: nextReleaseId }; if (replace) { @@ -145,7 +148,7 @@ export function App() { ) : !canAccess(user.role, route) ? ( navigate(defaultRoute(user.role), null, true)} /> ) : ( - navigate(nextRoute)} selectedEmployeeId={selectedEmployeeId} selectedProductId={selectedProductId} selectedProductCode={selectedProductCode} selectedAgentId={selectedAgentId} selectedReleaseId={selectedReleaseId} openEmployeeRoute={(employeeId) => navigate("employeeDetail", employeeId)} openProductRoute={(productId) => navigate("productDetail", productId)} openAgentCreateRoute={(productCode) => navigate("aiAgentCreate", null, false, productCode)} openAgentRoute={(agentId) => navigate("aiAgentDetail", agentId)} openReleaseRoute={(releaseId) => navigate("aiRelease", releaseId)} user={user} data={data} reload={loadData} onUserUpdated={setUser} onLogout={logout} /> + navigate(nextRoute)} selectedEmployeeId={selectedEmployeeId} selectedProductId={selectedProductId} selectedProductCode={selectedProductCode} selectedAgentId={selectedAgentId} selectedReleaseId={selectedReleaseId} selectedConversationId={selectedConversationId} openEmployeeRoute={(employeeId) => navigate("employeeDetail", employeeId)} openProductRoute={(productId) => navigate("productDetail", productId)} openAgentCreateRoute={(productCode) => navigate("aiAgentCreate", null, false, productCode)} openAgentRoute={(agentId) => navigate("aiAgentDetail", agentId)} openReleaseRoute={(releaseId) => navigate("aiRelease", releaseId)} openConversationRoute={(conversationId) => navigate("salesDialogs", conversationId)} user={user} data={data} reload={loadData} onUserUpdated={setUser} onLogout={logout} /> )} ); diff --git a/apps/internal-ui/src/features/sales/SalesDialogsPage.tsx b/apps/internal-ui/src/features/sales/SalesDialogsPage.tsx index 79d6472..9953702 100644 --- a/apps/internal-ui/src/features/sales/SalesDialogsPage.tsx +++ b/apps/internal-ui/src/features/sales/SalesDialogsPage.tsx @@ -15,11 +15,11 @@ import { } from "./dialogs/model"; import type { ListTab, RightTab } from "./dialogs/types"; -export function SalesDialogsPage() { +export function SalesDialogsPage({ initialConversationId }: { initialConversationId?: number | null }) { const [listTab, setListTab] = useState("all"); const [rightTab, setRightTab] = useState("client"); const [conversations, setConversations] = useState([]); - const [selectedId, setSelectedId] = useState(null); + const [selectedId, setSelectedId] = useState(initialConversationId ?? null); const [detail, setDetail] = useState(null); const loadList = useCallback(async () => { @@ -46,6 +46,10 @@ export function SalesDialogsPage() { return () => clearInterval(timer); }, [loadList]); + useEffect(() => { + if (initialConversationId != null) setSelectedId(initialConversationId); + }, [initialConversationId]); + useEffect(() => { if (selectedId == null) return; void loadDetail(selectedId); diff --git a/apps/internal-ui/src/layout/Shell.tsx b/apps/internal-ui/src/layout/Shell.tsx index 33916be..78fb2a2 100644 --- a/apps/internal-ui/src/layout/Shell.tsx +++ b/apps/internal-ui/src/layout/Shell.tsx @@ -1,4 +1,5 @@ -import { useCallback, useEffect, useState } from "react"; +import { notification as antToast } from "antd"; +import { useCallback, useEffect, useRef, useState } from "react"; import type { AppData, Employee, Product, RouteKey, SessionUser } from "../types"; import { AiSubnav } from "../features/ai/AiSubnav"; @@ -9,17 +10,24 @@ import { SalesSidebar } from "./SalesSidebar"; import { ShellRouteContent } from "./ShellRouteContent"; import { TopBar } from "./TopBar"; -export function Shell({ route, setRoute, selectedEmployeeId, selectedProductId, selectedProductCode, selectedAgentId, selectedReleaseId, openEmployeeRoute, openProductRoute, openAgentCreateRoute, openAgentRoute, openReleaseRoute, user, data, reload, onUserUpdated, onLogout }: { route: RouteKey; setRoute: (route: RouteKey) => void; selectedEmployeeId: number | null; selectedProductId: number | null; selectedProductCode: string | null; selectedAgentId: number | null; selectedReleaseId: number | null; openEmployeeRoute: (employeeId: number) => void; openProductRoute: (productId: number) => void; openAgentCreateRoute: (productCode: string | null) => void; openAgentRoute: (agentId: number) => void; openReleaseRoute: (releaseId: number) => void; user: SessionUser; data: AppData; reload: () => void; onUserUpdated: (user: SessionUser) => void; onLogout: () => void }) { +export function Shell({ route, setRoute, selectedEmployeeId, selectedProductId, selectedProductCode, selectedAgentId, selectedReleaseId, selectedConversationId, openEmployeeRoute, openProductRoute, openAgentCreateRoute, openAgentRoute, openReleaseRoute, openConversationRoute, user, data, reload, onUserUpdated, onLogout }: { route: RouteKey; setRoute: (route: RouteKey) => void; selectedEmployeeId: number | null; selectedProductId: number | null; selectedProductCode: string | null; selectedAgentId: number | null; selectedReleaseId: number | null; selectedConversationId: number | null; openEmployeeRoute: (employeeId: number) => void; openProductRoute: (productId: number) => void; openAgentCreateRoute: (productCode: string | null) => void; openAgentRoute: (agentId: number) => void; openReleaseRoute: (releaseId: number) => void; openConversationRoute: (conversationId: number) => void; user: SessionUser; data: AppData; reload: () => void; onUserUpdated: (user: SessionUser) => void; onLogout: () => void }) { const [agentName, setAgentName] = useState(null); const [notifications, setNotifications] = useState([]); const [unreadCount, setUnreadCount] = useState(0); const [notifOpen, setNotifOpen] = useState(false); + const prevUnread = useRef(null); const loadNotifications = useCallback(async () => { try { const payload = await fetchNotifications(); setNotifications(payload.items); setUnreadCount(payload.unreadCount); + // Тост при появлении новых непрочитанных (после первой загрузки). + if (prevUnread.current !== null && payload.unreadCount > prevUnread.current) { + const latest = payload.items.find((item) => item.unread); + if (latest) antToast.open({ message: latest.title, description: latest.body, placement: "bottomRight" }); + } + prevUnread.current = payload.unreadCount; } catch { /* ignore transient errors */ } @@ -37,7 +45,11 @@ export function Shell({ route, setRoute, selectedEmployeeId, selectedProductId, await markRead([notification.id]).catch(() => undefined); void loadNotifications(); } - if (notification.targetRoute) setRoute(notification.targetRoute as RouteKey); + if (notification.targetRoute === "salesDialogs" && notification.targetId) { + openConversationRoute(Number(notification.targetId)); + } else if (notification.targetRoute) { + setRoute(notification.targetRoute as RouteKey); + } } async function onMarkAll() { @@ -74,7 +86,7 @@ export function Shell({ route, setRoute, selectedEmployeeId, selectedProductId, {isAiSection && }
- +
diff --git a/apps/internal-ui/src/layout/ShellRouteContent.tsx b/apps/internal-ui/src/layout/ShellRouteContent.tsx index 295813b..f103f92 100644 --- a/apps/internal-ui/src/layout/ShellRouteContent.tsx +++ b/apps/internal-ui/src/layout/ShellRouteContent.tsx @@ -18,7 +18,7 @@ import { SalesOrdersPage } from "../features/sales/orders/SalesOrdersPage"; import { SalesOverviewPage } from "../features/sales/SalesOverviewPage"; import type { AppData, Employee, Product, RouteKey, SessionUser } from "../types"; -export function ShellRouteContent({ route, data, currentEmployee, currentProduct, selectedProductCode, selectedAgentId, selectedReleaseId, openEmployee, openProduct, openAgentCreate, openAgent, openRelease, onAgentLoaded, reload, setRoute, user, onUserUpdated, onLogout }: { route: RouteKey; data: AppData; currentEmployee: Employee | null; currentProduct: Product | null; selectedProductCode: string | null; selectedAgentId: number | null; selectedReleaseId: number | null; openEmployee: (employee: Employee) => void; openProduct: (product: Product) => void; openAgentCreate: (productCode: string | null) => void; openAgent: (agentId: number) => void; openRelease: (releaseId: number) => void; onAgentLoaded: (name: string | null) => void; reload: () => void; setRoute: (route: RouteKey) => void; user: SessionUser; onUserUpdated: (user: SessionUser) => void; onLogout: () => void }) { +export function ShellRouteContent({ route, data, currentEmployee, currentProduct, selectedProductCode, selectedAgentId, selectedReleaseId, selectedConversationId, openEmployee, openProduct, openAgentCreate, openAgent, openRelease, onAgentLoaded, reload, setRoute, user, onUserUpdated, onLogout }: { route: RouteKey; data: AppData; currentEmployee: Employee | null; currentProduct: Product | null; selectedProductCode: string | null; selectedAgentId: number | null; selectedReleaseId: number | null; selectedConversationId: number | null; openEmployee: (employee: Employee) => void; openProduct: (product: Product) => void; openAgentCreate: (productCode: string | null) => void; openAgent: (agentId: number) => void; openRelease: (releaseId: number) => void; onAgentLoaded: (name: string | null) => void; reload: () => void; setRoute: (route: RouteKey) => void; user: SessionUser; onUserUpdated: (user: SessionUser) => void; onLogout: () => void }) { return ( <> {route === "command" && } @@ -33,7 +33,7 @@ export function ShellRouteContent({ route, data, currentEmployee, currentProduct {route === "salesClients" && setRoute("salesClientDetail")} />} {route === "salesClientDetail" && } {route === "salesOverview" && } - {route === "salesDialogs" && } + {route === "salesDialogs" && } {route === "salesOrderDetail" && } {route === "salesOrders" && } {route === "aiAgents" && }