mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 09:14:58 +03:00
feat(notifications): deep-link to conversation + toast on new event
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 <noreply@anthropic.com>
This commit is contained in:
1 parent
0f7d60d808
commit
6ec999bbdd
4 files changed
+28
-9
No files matched your search
@@ -25,6 +25,7 @@ export function App() {
|
||||
const [selectedProductCode, setSelectedProductCode] = useState<string | null>(initialRoute.productCode);
|
||||
const [selectedAgentId, setSelectedAgentId] = useState<number | null>(initialRoute.agentId);
|
||||
const [selectedReleaseId, setSelectedReleaseId] = useState<number | null>(initialRoute.releaseId);
|
||||
const [selectedConversationId, setSelectedConversationId] = useState<number | null>(null);
|
||||
const [data, setData] = useState<AppData>({ 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) ? (
|
||||
<PermissionScreen onReturn={() => navigate(defaultRoute(user.role), null, true)} />
|
||||
) : (
|
||||
<Shell route={route} setRoute={(nextRoute) => 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} />
|
||||
<Shell route={route} setRoute={(nextRoute) => 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} />
|
||||
)}
|
||||
</ConfigProvider>
|
||||
);
|
||||
|
||||
@@ -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<ListTab>("all");
|
||||
const [rightTab, setRightTab] = useState<RightTab>("client");
|
||||
const [conversations, setConversations] = useState<ApiConversation[]>([]);
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
const [selectedId, setSelectedId] = useState<number | null>(initialConversationId ?? null);
|
||||
const [detail, setDetail] = useState<ApiConversation | null>(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);
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const [notifications, setNotifications] = useState<AppNotification[]>([]);
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
const [notifOpen, setNotifOpen] = useState(false);
|
||||
const prevUnread = useRef<number | null>(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 && <AiSubnav route={route} setRoute={setRoute} />}
|
||||
<main className={`hub-scroll ${isSalesDialogs ? "sales-dialogs-scroll" : ""} ${isAiFullWidth ? "ai-fullwidth-scroll" : ""}`}>
|
||||
<div className={`hub-page ${isSalesWorkspace ? "sales-workspace-page" : ""} ${isSalesDialogs ? "sales-dialogs-page" : ""} ${isSalesClients ? "sales-clients-page" : ""} ${isSalesClientDetail ? "sales-client-detail-page" : ""} ${isSalesOrderDetail ? "sales-order-detail-page" : ""} ${isSalesOrders ? "sales-orders-page" : ""} ${isAiFullWidth ? "ai-fullwidth-page" : ""}`}>
|
||||
<ShellRouteContent route={route} data={data} currentEmployee={currentEmployee} currentProduct={currentProduct} selectedProductCode={selectedProductCode} selectedAgentId={selectedAgentId} selectedReleaseId={selectedReleaseId} openEmployee={openEmployee} openProduct={openProduct} openAgentCreate={openAgentCreateRoute} openAgent={openAgentRoute} openRelease={openReleaseRoute} onAgentLoaded={setAgentName} reload={reload} setRoute={setRoute} user={user} onUserUpdated={onUserUpdated} onLogout={onLogout} />
|
||||
<ShellRouteContent route={route} data={data} currentEmployee={currentEmployee} currentProduct={currentProduct} selectedProductCode={selectedProductCode} selectedAgentId={selectedAgentId} selectedReleaseId={selectedReleaseId} selectedConversationId={selectedConversationId} openEmployee={openEmployee} openProduct={openProduct} openAgentCreate={openAgentCreateRoute} openAgent={openAgentRoute} openRelease={openReleaseRoute} onAgentLoaded={setAgentName} reload={reload} setRoute={setRoute} user={user} onUserUpdated={onUserUpdated} onLogout={onLogout} />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -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" && <CommandCenter data={data} setRoute={setRoute} />}
|
||||
@@ -33,7 +33,7 @@ export function ShellRouteContent({ route, data, currentEmployee, currentProduct
|
||||
{route === "salesClients" && <SalesClientsPage openClient={() => setRoute("salesClientDetail")} />}
|
||||
{route === "salesClientDetail" && <SalesClientDetailPage setRoute={setRoute} />}
|
||||
{route === "salesOverview" && <SalesOverviewPage products={data.products} />}
|
||||
{route === "salesDialogs" && <SalesDialogsPage />}
|
||||
{route === "salesDialogs" && <SalesDialogsPage initialConversationId={selectedConversationId} />}
|
||||
{route === "salesOrderDetail" && <SalesOrderDetailPage setRoute={setRoute} />}
|
||||
{route === "salesOrders" && <SalesOrdersPage setRoute={setRoute} />}
|
||||
{route === "aiAgents" && <AiAgentsPage agents={data.agents} releases={data.releases} reload={reload} setRoute={setRoute} openAgentCreate={openAgentCreate} openAgent={openAgent} openRelease={openRelease} />}
|
||||
|
||||
Reference in new issue
Block a user