import { notification as antToast } from "antd"; import { useCallback, useEffect, useRef, useState } from "react"; import type { AppData, Employee, RouteKey, SessionUser } from "../types"; import type { SettingsSectionKey } from "../features/settings/sections"; import type { PortalSettingsSectionKey } from "../features/support-portals/sections"; import { NotificationDrawer } from "../features/notifications/NotificationDrawer"; import { fetchNotifications, markAllRead, markRead, type AppNotification } from "../features/notifications/model"; import { fetchWaitingCount } from "../features/conversations/model"; import { useChatScope } from "../features/chat/useChatScope"; import { isManager } from "../auth/access"; import { DemoInstallBanner } from "../features/settings/DemoInstallBanner"; import { Sidebar } from "./Sidebar"; import { ShellRouteContent } from "./ShellRouteContent"; export function Shell({ route, setRoute, settingsSection, openSettingsRoute, selectedEmployeeId, selectedProductCode, selectedAgentId, selectedKnowledgeId, selectedConversationId, selectedClientId, selectedChannelId, selectedSupportPortalId, portalSettingsSection, openChannelRoute, openSupportPortalRoute, openPortalSettingsRoute, openEmployeeRoute, openAgentCreateRoute, openAgentRoute, openKnowledgeRoute, openConversationRoute, openClientRoute, user, data, reload, onUserUpdated, onLogout }: { route: RouteKey; setRoute: (route: RouteKey) => void; settingsSection: SettingsSectionKey | null; openSettingsRoute: (section: SettingsSectionKey | null) => void; selectedEmployeeId: number | null; selectedProductCode: string | null; selectedAgentId: number | null; selectedKnowledgeId: number | null; selectedConversationId: number | null; selectedClientId: number | null; selectedChannelId: number | null; selectedSupportPortalId: number | null; portalSettingsSection: PortalSettingsSectionKey | null; openEmployeeRoute: (employeeId: number) => void; openAgentCreateRoute: (productCode: string | null) => void; openAgentRoute: (agentId: number) => void; openKnowledgeRoute: (knowledgeId: number) => void; openConversationRoute: (conversationId: number) => void; openClientRoute: (clientId: number) => void; openChannelRoute: (channelId: number) => void; openSupportPortalRoute: (portalId: number) => void; openPortalSettingsRoute: (portalId: number, section?: PortalSettingsSectionKey) => void; user: SessionUser; data: AppData; reload: () => void; onUserUpdated: (user: SessionUser) => void; onLogout: () => void }) { const [notifications, setNotifications] = useState([]); const [unreadCount, setUnreadCount] = useState(0); const [notifOpen, setNotifOpen] = useState(false); const [waitingCount, setWaitingCount] = useState(0); // Кадры S2/M1: рейка или ☰ раскрывают сайдбар поверх контента. const [sidebarExpanded, setSidebarExpanded] = useState(false); const prevUnread = useRef(null); const manager = isManager(user); // Охват чата живёт здесь: сотрудницкий сайдбар и страница чата делят одно // состояние (дизайн-базлайн v2 §4.1). const chatScope = useChatScope(true); const loadWaitingCount = useCallback(async () => { try { setWaitingCount(await fetchWaitingCount()); } catch { /* ignore */ } }, []); 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 */ } }, []); useEffect(() => { const tick = () => { void loadNotifications(); void loadWaitingCount(); }; tick(); const timer = setInterval(tick, 15000); return () => clearInterval(timer); }, [loadNotifications, loadWaitingCount]); async function onNotificationClick(notification: AppNotification) { setNotifOpen(false); if (notification.unread) { await markRead([notification.id]).catch(() => undefined); void loadNotifications(); } // «salesDialogs»/«conversations» — легаси-маршруты старых уведомлений в БД. const targetRoute = notification.targetRoute === "salesDialogs" || notification.targetRoute === "conversations" ? "chat" : notification.targetRoute; if (targetRoute === "chat" && notification.targetId) { openConversationRoute(Number(notification.targetId)); } else if (targetRoute) { setRoute(targetRoute as RouteKey); } } async function onMarkAll() { await markAllRead().catch(() => undefined); void loadNotifications(); } const currentEmployee = route === "employeeDetail" ? data.employees.find((employee) => employee.id === selectedEmployeeId) ?? null : data.employees[0] ?? null; function openEmployee(employee: Employee) { openEmployeeRoute(employee.id); } const isSalesWorkspace = route === "chat"; const isSupportWorkspace = route === "supportPortals" || route === "supportPortalDetail" || route === "supportPortalSettings"; const isDialogsWorkspace = route === "chat"; const isAiFullWidth = false; const isKnowledgeLibrary = route === "aiKnowledge"; const isKnowledgeEditor = route === "aiKnowledgeCreate" || route === "aiKnowledgeDetail"; // «Настройки» занимают всю область как чат: своё субменю 240px и своя лента. const isSettings = route === "settings"; // «Профиль» — своя лента на --surface-feed и мобильные подэкраны (кадр M). const isProfile = route === "profile"; // «Контакты» — своя лента на --surface-feed (кадры K1–K5). const isContacts = route === "salesClients" || route === "salesClientDetail"; // «Агенты» — своя лента на --surface-feed (кадры G1–G5, S1). const isAgents = route === "agents" || route === "agentDetail"; // «Сотрудники» — своя лента на --surface-feed (кадры E1–E4). const isEmployees = route === "employees" || route === "employeeDetail"; // «Порталы» — своя лента на --surface-feed и полноэкранные сплиты (кадры PT1–PT8). const isPortals = route === "supportPortals" || route === "supportPortalDetail" || route === "supportPortalSettings"; return (
{ setNotifOpen(true); void loadNotifications(); }} expanded={sidebarExpanded} setExpanded={setSidebarExpanded} />
{manager && } {/* Верхней панели нет ни у одной роли (дизайн-базлайн v2): заголовок и «назад» живут в самой странице, уведомления — в меню профиля сайдбара. */}
undefined} onChannelLoaded={() => undefined} reload={reload} setRoute={setRoute} user={user} onUserUpdated={onUserUpdated} onLogout={onLogout} onOpenSidebar={() => setSidebarExpanded(true)} />
setNotifOpen(false)} onItemClick={onNotificationClick} onMarkAll={onMarkAll} />
); }