Files
chatballs/apps/internal-ui/src/layout/Shell.tsx
T
AndreyandClaude Opus 4.8 4c77a4e5b8 ✨ feat(ui): add the channels section
SPEC-HUB-0027 §10-§11, этап 4. Раздел «Каналы» в группе КОМПАНИЯ после
«Продукты»: список с фильтрами по отделу, карточка канала одной страницей и
мастер создания из трёх шагов. Вёрстка следует принятому design baseline
DG-02 (кадры A-E/S).

Карточка не имеет вкладок. Секции идут сверху вниз: Назначение, Политика,
Подключения, AI-агент, Счётчики. Секция агента — одна строка сводки
(имя · статус · модель) и ссылка наружу на его существующую страницу: полей
агента, знаний и их счётчиков в карточке нет, как и ссылки на саму себя.

Секция «Политика» показывает причину недоступности флага, запрещённого
инвариантом при текущем продукте. Причина выводится на клиенте из продукта и
обязательной идентичности: правила P1-P5 детерминированы, а источником прав
остаётся backend — он отклоняет запрос независимо от того, что показал
интерфейс. Флаг не игнорируется молча при сохранении.

Модалка ChannelEditForm удалена: ссылка на канал в шапке агента теперь ведёт
в карточку канала. Маршруты aiAgents, aiAgentCreate, aiAgentDetail,
aiKnowledge* и aiUsage не изменены и не редиректят — покрыто тестом.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 13:33:40 +03:00

125 lines
8.2 KiB
TypeScript

import { notification as antToast } from "antd";
import { useCallback, useEffect, useRef, useState } from "react";
import type { AppData, Employee, Product, RouteKey, SessionUser } from "../types";
import { NotificationDrawer } from "../features/notifications/NotificationDrawer";
import { fetchNotifications, markAllRead, markRead, type AppNotification } from "../features/notifications/model";
import { fetchWaitingCount } from "../features/conversations/model";
import { Sidebar } from "./Sidebar";
import { SalesSidebar } from "./SalesSidebar";
import { SupportSidebar } from "./SupportSidebar";
import { ShellRouteContent } from "./ShellRouteContent";
import { TopBar } from "./TopBar";
import { canAccess } from "../auth/access";
export function Shell({ route, setRoute, selectedEmployeeId, selectedProductId, selectedProductCode, selectedAgentId, selectedKnowledgeId, selectedConversationId, selectedClientId, selectedOrderId, selectedChannelId, openChannelRoute, openEmployeeRoute, openProductRoute, openAgentCreateRoute, openAgentRoute, openKnowledgeRoute, openConversationRoute, openClientRoute, openOrderRoute, user, data, reload, onUserUpdated, onLogout }: { route: RouteKey; setRoute: (route: RouteKey) => void; selectedEmployeeId: number | null; selectedProductId: number | null; selectedProductCode: string | null; selectedAgentId: number | null; selectedKnowledgeId: number | null; selectedConversationId: number | null; selectedClientId: number | null; selectedOrderId: number | null; selectedChannelId: number | null; openEmployeeRoute: (employeeId: number) => void; openProductRoute: (productId: number) => void; openAgentCreateRoute: (productCode: string | null) => void; openAgentRoute: (agentId: number) => void; openKnowledgeRoute: (knowledgeId: number) => void; openConversationRoute: (conversationId: number) => void; openClientRoute: (clientId: number) => void; openOrderRoute: (orderId: number) => void; openChannelRoute: (channelId: 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 [waitingCount, setWaitingCount] = useState(0);
const prevUnread = useRef<number | null>(null);
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();
}
if (notification.targetRoute === "salesDialogs" && notification.targetId) {
openConversationRoute(Number(notification.targetId));
} else if (notification.targetRoute) {
setRoute(notification.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.find((employee) => employee.email === "a.kotova@edevs.tech") ?? data.employees[0] ?? null;
function openEmployee(employee: Employee) {
openEmployeeRoute(employee.id);
}
const currentProduct = route === "productDetail" ? data.products.find((product) => product.id === selectedProductId) ?? null : null;
function openProduct(product: Product) {
openProductRoute(product.id);
}
const isSalesWorkspace = route === "salesOverview" || route === "salesClientDetail" || route === "salesClients" || route === "salesDialogs" || route === "salesOrderDetail" || route === "salesOrders";
const isSupportWorkspace = route === "supportOverview" || route === "supportDialogs";
const isSalesDialogs = route === "salesDialogs";
const isSupportDialogs = route === "supportDialogs";
const isDialogsWorkspace = isSalesDialogs || isSupportDialogs;
const isSalesClients = route === "salesClients";
const isSalesClientDetail = route === "salesClientDetail";
const isSalesOrderDetail = route === "salesOrderDetail";
const isSalesOrders = route === "salesOrderDetail" || route === "salesOrders";
const isAiFullWidth = route === "aiAgentCreate";
const isKnowledgeLibrary = route === "aiKnowledge";
const isKnowledgeEditor = route === "aiKnowledgeCreate" || route === "aiKnowledgeDetail";
const showSalesSidebar = isSalesWorkspace || (
canAccess(user, "salesDialogs") && !canAccess(user, "command") && !isSupportWorkspace
);
const showSupportSidebar = isSupportWorkspace || (
canAccess(user, "supportDialogs") && !canAccess(user, "command") && !isSalesWorkspace
);
return (
<div className="hub-shell">
{showSupportSidebar ? <SupportSidebar route={route} user={user} setRoute={setRoute} onLogout={onLogout} waitingCount={waitingCount} /> : showSalesSidebar ? <SalesSidebar route={route} user={user} setRoute={setRoute} onLogout={onLogout} waitingCount={waitingCount} /> : <Sidebar route={route} user={user} setRoute={setRoute} onLogout={onLogout} />}
<div className="hub-main">
<TopBar route={route} user={user} currentEmployee={currentEmployee} currentProduct={currentProduct} currentAgentName={agentName} setRoute={setRoute} unreadCount={unreadCount} onOpenNotifications={() => { setNotifOpen(true); void loadNotifications(); }} />
<main className={`hub-scroll ${isDialogsWorkspace ? "sales-dialogs-scroll" : ""} ${isAiFullWidth ? "ai-fullwidth-scroll" : ""}`}>
<div className={`hub-page ${isSalesWorkspace || isSupportWorkspace ? "sales-workspace-page" : ""} ${isDialogsWorkspace ? "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" : ""} ${isKnowledgeLibrary ? "ai-knowledge-library-page" : ""} ${isKnowledgeEditor ? "ai-knowledge-editor-page" : ""}`}>
<ShellRouteContent route={route} data={data} currentEmployee={currentEmployee} currentProduct={currentProduct} selectedProductCode={selectedProductCode} selectedAgentId={selectedAgentId} selectedKnowledgeId={selectedKnowledgeId} selectedConversationId={selectedConversationId} selectedClientId={selectedClientId} openClient={openClientRoute} selectedOrderId={selectedOrderId} openOrder={openOrderRoute} selectedChannelId={selectedChannelId} openChannel={openChannelRoute} openConversation={openConversationRoute} openEmployee={openEmployee} openProduct={openProduct} openAgentCreate={openAgentCreateRoute} openAgent={openAgentRoute} openKnowledge={openKnowledgeRoute} onAgentLoaded={setAgentName} reload={reload} setRoute={setRoute} user={user} onUserUpdated={onUserUpdated} onLogout={onLogout} />
</div>
</main>
</div>
<NotificationDrawer
open={notifOpen}
items={notifications}
unreadCount={unreadCount}
onClose={() => setNotifOpen(false)}
onItemClick={onNotificationClick}
onMarkAll={onMarkAll}
/>
</div>
);
}