✨ feat(chat)!: единый чат и оболочка сотрудника по дизайн-базлайну v2

Sales- и support-диалоги объединены в один экран «Чат» (маршрут /chat,
старые адреса — алиасы). Контекст-панель сама выбирает представление по
identity диалога. У сотрудника нет верхней панели и разделов — сайдбар
это дерево «Все диалоги · Группы · Агенты», управляющее охватом списка;
у менеджера — плоские шесть пунктов макета и группа «Ещё» для служебных
разделов. Роуты уведомлений и карточек обзора на бэкенде — chat.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
AndreyandClaude Fable 5 committed 2026-09-05 01:17:10 +03:00
1 parent 981d149a38
commit 445e3c83c2
26 files changed
+471 -245

No files matched your search

@@ -63,7 +63,7 @@ def command_center_overview(context: TenantContext, period: str) -> dict:
{
"code": str(group.id),
"name": group.name,
"route": "salesDialogs",
"route": "chat",
"employees": group.member_count,
"aiAgents": agents_by_group.get(group.id, 0),
"dialogs": _dialog_block(group_open, now),
@@ -75,7 +75,7 @@ def command_center_overview(context: TenantContext, period: str) -> dict:
{
"code": "none",
"name": "Без группы",
"route": "salesDialogs",
"route": "chat",
"employees": 0,
"aiAgents": AIAgent.objects.filter(
channel__organization_id=organization_id,
@@ -31,7 +31,7 @@
"level": "WARNING",
"title": "Диалог ожидает оператора",
"body": "Сергей Орлов ждёт ответа в канале продаж Вектора.",
"targetRoute": "conversations",
"targetRoute": "chat",
"targetId": "vektor-queue",
"audience": "OPERATORS",
"sourceId": "vektor-queue",
@@ -42,7 +42,7 @@
"level": "INFO",
"title": "Новое сообщение в диалоге",
"body": "Наталья Кузнецова написала в продажи Вектора.",
"targetRoute": "conversations",
"targetRoute": "chat",
"targetId": "vektor-lead-active",
"audience": "OPERATORS",
"sourceId": "vektor-lead-active"
@@ -17,8 +17,8 @@ from hub_platform.notifications.selectors import unread_for
# Реестр типов: дефолтный уровень и маршрут диплинка. Новый тип события —
# одна запись здесь + вызов notify(...) из доменного сервиса.
TYPE_META: dict[str, dict] = {
NotificationType.DIALOG_WAITING: {"level": NotificationLevel.WARNING, "route": "salesDialogs"},
NotificationType.DIALOG_NEW_MESSAGE: {"level": NotificationLevel.INFO, "route": "salesDialogs"},
NotificationType.DIALOG_WAITING: {"level": NotificationLevel.WARNING, "route": "chat"},
NotificationType.DIALOG_NEW_MESSAGE: {"level": NotificationLevel.INFO, "route": "chat"},
NotificationType.RELEASE_PUBLISHED: {"level": NotificationLevel.SUCCESS, "route": "agents"},
NotificationType.INTEGRATION_ERROR: {"level": NotificationLevel.CRITICAL, "route": "integrations"},
}
+1 -1
View File
@@ -163,7 +163,7 @@ export function App() {
) : !canAccess(user, navigation.route) ? (
<PermissionScreen onReturn={() => navigate(defaultRoute(user), null, true)} />
) : (
<Shell route={navigation.route} setRoute={(nextRoute) => navigate(nextRoute)} selectedEmployeeId={navigation.selectedEmployeeId} selectedProductCode={navigation.selectedProductCode} selectedAgentId={navigation.selectedAgentId} selectedKnowledgeId={navigation.selectedKnowledgeId} selectedConversationId={navigation.selectedConversationId} selectedClientId={navigation.selectedClientId} openClientRoute={(clientId) => navigate("salesClientDetail", clientId)} selectedChannelId={navigation.selectedChannelId} openChannelRoute={(channelId) => navigate("agentDetail", channelId)} selectedSupportPortalId={navigation.selectedSupportPortalId} openSupportPortalRoute={(portalId) => navigate("supportPortalDetail", portalId)} openEmployeeRoute={(employeeId) => navigate("employeeDetail", employeeId)} openAgentCreateRoute={() => navigate("agents")} openAgentRoute={(agentId) => navigate("agentDetail", agentId)} openKnowledgeRoute={(knowledgeId) => navigate("aiKnowledgeDetail", knowledgeId)} openConversationRoute={(conversationId) => navigate("salesDialogs", conversationId)} user={user} data={data} reload={loadData} onUserUpdated={refreshIdentity} onLogout={logout} />
<Shell route={navigation.route} setRoute={(nextRoute) => navigate(nextRoute)} selectedEmployeeId={navigation.selectedEmployeeId} selectedProductCode={navigation.selectedProductCode} selectedAgentId={navigation.selectedAgentId} selectedKnowledgeId={navigation.selectedKnowledgeId} selectedConversationId={navigation.selectedConversationId} selectedClientId={navigation.selectedClientId} openClientRoute={(clientId) => navigate("salesClientDetail", clientId)} selectedChannelId={navigation.selectedChannelId} openChannelRoute={(channelId) => navigate("agentDetail", channelId)} selectedSupportPortalId={navigation.selectedSupportPortalId} openSupportPortalRoute={(portalId) => navigate("supportPortalDetail", portalId)} openEmployeeRoute={(employeeId) => navigate("employeeDetail", employeeId)} openAgentCreateRoute={() => navigate("agents")} openAgentRoute={(agentId) => navigate("agentDetail", agentId)} openKnowledgeRoute={(knowledgeId) => navigate("aiKnowledgeDetail", knowledgeId)} openConversationRoute={(conversationId) => navigate("chat", conversationId)} user={user} data={data} reload={loadData} onUserUpdated={refreshIdentity} onLogout={logout} />
)}
</ConfigProvider>
);
+2 -3
View File
@@ -43,15 +43,14 @@ describe("role-based navigation (SPEC-HUB-0031 §3)", () => {
it("limits employee to the chat plus self-service", () => {
const user = userWith("EMPLOYEE");
expect(isManager(user)).toBe(false);
expect(canAccess(user, "salesDialogs")).toBe(true);
expect(canAccess(user, "supportDialogs")).toBe(true);
expect(canAccess(user, "chat")).toBe(true);
expect(canAccess(user, "profile")).toBe(true);
expect(canAccess(user, "settings")).toBe(true);
expect(canAccess(user, "employees")).toBe(false);
expect(canAccess(user, "agents")).toBe(false);
expect(canAccess(user, "aiKnowledge")).toBe(false);
expect(canAccess(user, "salesClients")).toBe(false);
expect(defaultRoute(user)).toBe("salesDialogs");
expect(defaultRoute(user)).toBe("chat");
});
it("reads capabilities straight from the session payload", () => {
+2 -3
View File
@@ -5,8 +5,7 @@ import type { RouteKey, SessionUser } from "../types";
// фронтенд лишь скрывает недоступное.
const EMPLOYEE_ROUTES: ReadonlySet<RouteKey> = new Set<RouteKey>([
"salesDialogs",
"supportDialogs",
"chat",
"profile",
"settings",
]);
@@ -27,5 +26,5 @@ export function canAccess(user: SessionUser, route: RouteKey): boolean {
export function defaultRoute(user: SessionUser): RouteKey {
if (isManager(user)) return "command";
return "salesDialogs";
return "chat";
}
@@ -0,0 +1,107 @@
import { useState, type ReactNode } from "react";
import { ConversationWorkspace, type DialogScope } from "../conversations/ConversationWorkspace";
import { DialogControls } from "../conversations/DialogControls";
import type { ApiConversation, ConversationCounters } from "../conversations/model";
import type { ConversationListItem } from "../conversations/types";
import { ClientContext } from "../sales/dialogs/context/ClientContext";
import { HistoryContext } from "../sales/dialogs/context/HistoryContext";
import { OperatorCards } from "../support/context/OperatorCards";
import { SupportHistory } from "../support/context/SupportHistory";
import type { EmployeeGroup, SessionUser } from "../../types";
// Единый «Чат» (дизайн-базлайн v2 §8.1): один экран для всех диалогов
// организации. Контекст-панель сама выбирает представление по источнику
// identity диалога: sales-контакт или verified support-снапшот.
type ChatRightTab = "client" | "history";
export function ChatPage({
initialConversationId,
user,
groups = [],
employees = [],
scope,
setScope,
counters,
showScopeSwitcher,
}: {
initialConversationId?: number | null;
user: SessionUser;
groups?: EmployeeGroup[];
employees?: Array<{ id: number; name: string }>;
scope: DialogScope;
setScope: (scope: DialogScope) => void;
counters: ConversationCounters | null;
showScopeSwitcher: boolean;
}) {
const [rightTab, setRightTab] = useState<ChatRightTab>("client");
return (
<ConversationWorkspace
isOwner={user.role === "OWNER"}
initialConversationId={initialConversationId}
scope={scope}
setScope={setScope}
counters={counters}
showScopeSwitcher={showScopeSwitcher}
renderContextPanel={({ dialog, detail, applyConversation }) => (
<ChatContextPanel
rightTab={rightTab}
setRightTab={setRightTab}
dialog={dialog}
detail={detail}
groups={groups}
employees={employees}
applyConversation={applyConversation}
/>
)}
/>
);
}
function ChatContextPanel({
rightTab,
setRightTab,
dialog,
detail,
groups,
employees,
applyConversation,
}: {
rightTab: ChatRightTab;
setRightTab: (tab: ChatRightTab) => void;
dialog: ConversationListItem | null;
detail: ApiConversation | null;
groups: EmployeeGroup[];
employees: Array<{ id: number; name: string }>;
applyConversation: (updated: ApiConversation) => void;
}) {
const isSupport = Boolean(detail?.supportIdentitySnapshot);
return (
<section className="sales-context">
<div className="sales-context-tabs">
<RightTabButton active={rightTab === "client"} onClick={() => setRightTab("client")}>Контакт</RightTabButton>
<RightTabButton active={rightTab === "history"} onClick={() => setRightTab("history")}>История</RightTabButton>
</div>
<div className="sales-context-body">
{rightTab === "client" && (
isSupport ? (
<>
{detail && (
<DialogControls detail={detail} groups={groups} employees={employees} applyConversation={applyConversation} />
)}
<OperatorCards detail={detail} />
</>
) : (
<ClientContext dialog={dialog} detail={detail} groups={groups} employees={employees} applyConversation={applyConversation} />
)
)}
{rightTab === "history" && (isSupport ? <SupportHistory detail={detail} /> : <HistoryContext detail={detail} />)}
</div>
</section>
);
}
function RightTabButton({ active, onClick, children }: { active: boolean; onClick: () => void; children: ReactNode }) {
return <button className={active ? "active" : ""} onClick={onClick}>{children}</button>;
}
@@ -0,0 +1,30 @@
import { useEffect, useState } from "react";
import type { DialogScope } from "../conversations/ConversationWorkspace";
import { fetchConversationCounters, type ConversationCounters } from "../conversations/model";
// Охват чата (дерево «Все диалоги · Группы · Агенты») живёт на уровне Shell:
// у сотрудника им управляет сайдбар, у менеджера — поповер в заголовке списка.
export function useChatScope(enabled: boolean) {
const [scope, setScope] = useState<DialogScope>({ kind: "all" });
const [counters, setCounters] = useState<ConversationCounters | null>(null);
useEffect(() => {
if (!enabled) return;
let cancelled = false;
const load = () =>
fetchConversationCounters()
.then((payload) => {
if (!cancelled) setCounters(payload);
})
.catch(() => undefined);
void load();
const timer = setInterval(load, 30000);
return () => {
cancelled = true;
clearInterval(timer);
};
}, [enabled]);
return { scope, setScope, counters };
}
@@ -9,7 +9,6 @@ import {
closeConversation,
controlModeOf,
fetchConversation,
fetchConversationCounters,
fetchConversations,
markConversationAsSpam,
releaseConversation,
@@ -46,16 +45,20 @@ import { useIncomingMessageSound } from "./useIncomingMessageSound";
// Общий workspace диалогов (SPEC-HUB-0010 §8.2). Видимость inbox решает
// backend по группам (ADR-HUB-0043); страница параметризуется заголовком,
// placeholder поиска и правой панелью через render-prop.
export function ConversationWorkspace({ isOwner = false, listTitle, searchPlaceholder, renderContextPanel, initialConversationId }: {
export function ConversationWorkspace({ isOwner = false, listTitle, searchPlaceholder, renderContextPanel, initialConversationId, scope, setScope, counters, showScopeSwitcher = true }: {
isOwner?: boolean;
listTitle?: string;
searchPlaceholder?: string;
renderContextPanel: (ctx: { dialog: ConversationListItem | null; detail: ApiConversation | null; applyConversation: (updated: ApiConversation) => void }) => ReactNode;
initialConversationId?: number | null;
// Охват (дерево фильтров) живёт в Shell: у сотрудника им управляет сайдбар,
// у менеджера — поповер в заголовке списка.
scope: DialogScope;
setScope: (scope: DialogScope) => void;
counters: ConversationCounters | null;
showScopeSwitcher?: boolean;
}) {
const [listTab, setListTab] = useState<ListTab>("all");
const [scope, setScope] = useState<DialogScope>({ kind: "all" });
const [counters, setCounters] = useState<ConversationCounters | null>(null);
const [search, setSearch] = useState("");
const [conversations, setConversations] = useState<ApiConversation[]>([]);
const [listLoaded, setListLoaded] = useState(false);
@@ -99,23 +102,6 @@ export function ConversationWorkspace({ isOwner = false, listTitle, searchPlaceh
return () => clearInterval(timer);
}, [loadList]);
// Счётчики дерева охвата (группы/агенты) обновляются реже списка.
useEffect(() => {
let cancelled = false;
const load = () =>
fetchConversationCounters()
.then((payload) => {
if (!cancelled) setCounters(payload);
})
.catch(() => undefined);
void load();
const timer = setInterval(load, 30000);
return () => {
cancelled = true;
clearInterval(timer);
};
}, []);
useEffect(() => {
if (initialConversationId != null) setSelectedId(initialConversationId);
}, [initialConversationId]);
@@ -187,6 +173,7 @@ export function ConversationWorkspace({ isOwner = false, listTitle, searchPlaceh
scope={scope}
counters={counters}
setScope={setScope}
showScopeSwitcher={showScopeSwitcher}
title={listTitle}
searchPlaceholder={searchPlaceholder}
dialogs={dialogs}
@@ -10,12 +10,13 @@ import type { ConversationCounters } from "./model";
import type { ConversationListItem, ListTab } from "./types";
import { SearchInput } from "../../shared/ui-controls";
export function DialogList({ title = "Диалоги", searchPlaceholder = "Поиск по клиенту, продукту…", scope, counters, setScope, dialogs, filtered, listTab, selectedId, search, errorText, setSearch, setListTab, setSelectedId }: {
export function DialogList({ title = "Диалоги", searchPlaceholder = "Поиск по клиенту, продукту…", scope, counters, setScope, showScopeSwitcher = true, dialogs, filtered, listTab, selectedId, search, errorText, setSearch, setListTab, setSelectedId }: {
title?: string;
searchPlaceholder?: string;
scope: DialogScope;
counters: ConversationCounters | null;
setScope: (scope: DialogScope) => void;
showScopeSwitcher?: boolean;
dialogs: ConversationListItem[];
filtered: ConversationListItem[];
listTab: ListTab;
@@ -31,7 +32,9 @@ export function DialogList({ title = "Диалоги", searchPlaceholder = "По
<section className="sales-dialog-list">
<div className="sales-dialog-list-head">
<div>
<ScopeSwitcher scope={scope} counters={counters} setScope={setScope} fallbackTitle={title} />
{showScopeSwitcher
? <ScopeSwitcher scope={scope} counters={counters} setScope={setScope} fallbackTitle={title} />
: <h2>{scope.kind === "all" ? title : scopeLabel(scope)}</h2>}
<span>{dialogs.length} всего</span>
</div>
<SearchInput className="sales-dialog-search" placeholder={searchPlaceholder} value={search} onChange={setSearch} />
@@ -1,19 +0,0 @@
import { useState } from "react";
import { ConversationWorkspace } from "../../features/conversations/ConversationWorkspace";
import type { EmployeeGroup, SessionUser } from "../../types";
import { SalesContextPanel } from "./dialogs/SalesContextPanel";
import type { SalesRightTab } from "./dialogs/context/SalesContextTabs";
export function SalesDialogsPage({ initialConversationId, user, groups = [], employees = [] }: { initialConversationId?: number | null; user: SessionUser; groups?: EmployeeGroup[]; employees?: Array<{ id: number; name: string }> }) {
const [rightTab, setRightTab] = useState<SalesRightTab>("client");
return (
<ConversationWorkspace
isOwner={user.role === "OWNER"}
initialConversationId={initialConversationId}
renderContextPanel={({ dialog, detail, applyConversation }) => (
<SalesContextPanel rightTab={rightTab} setRightTab={setRightTab} dialog={dialog} detail={detail} groups={groups} employees={employees} applyConversation={applyConversation} />
)}
/>
);
}
@@ -1,18 +0,0 @@
import { ClientContext } from "./context/ClientContext";
import { HistoryContext } from "./context/HistoryContext";
import { SalesContextTabs, type SalesRightTab } from "./context/SalesContextTabs";
import type { ApiConversation } from "../../conversations/model";
import type { ConversationListItem } from "../../conversations/types";
import type { EmployeeGroupRef } from "../../../types";
export function SalesContextPanel({ rightTab, setRightTab, dialog, detail, groups = [], employees = [], applyConversation }: { rightTab: SalesRightTab; setRightTab: (tab: SalesRightTab) => void; dialog: ConversationListItem | null; detail: ApiConversation | null; groups?: EmployeeGroupRef[]; employees?: Array<{ id: number; name: string }>; applyConversation?: (updated: ApiConversation) => void }) {
return (
<section className="sales-context">
<SalesContextTabs rightTab={rightTab} setRightTab={setRightTab} />
<div className="sales-context-body">
{rightTab === "client" && <ClientContext dialog={dialog} detail={detail} groups={groups} employees={employees} applyConversation={applyConversation} />}
{rightTab === "history" && <HistoryContext detail={detail} />}
</div>
</section>
);
}
@@ -1,16 +0,0 @@
import type { ReactNode } from "react";
export type SalesRightTab = "client" | "history";
export function SalesContextTabs({ rightTab, setRightTab }: { rightTab: SalesRightTab; setRightTab: (tab: SalesRightTab) => void }) {
return (
<div className="sales-context-tabs">
<RightTabButton active={rightTab === "client"} onClick={() => setRightTab("client")}>Контакт</RightTabButton>
<RightTabButton active={rightTab === "history"} onClick={() => setRightTab("history")}>История</RightTabButton>
</div>
);
}
function RightTabButton({ active, onClick, children }: { active: boolean; onClick: () => void; children: ReactNode }) {
return <button className={active ? "active" : ""} onClick={onClick}>{children}</button>;
}
@@ -1,20 +0,0 @@
import { useState } from "react";
import { ConversationWorkspace } from "../conversations/ConversationWorkspace";
import type { EmployeeGroup, SessionUser } from "../../types";
import { SupportContextPanel, type SupportRightTab } from "./context/SupportContextPanel";
export function SupportDialogsPage({ initialConversationId, user, groups = [], employees = [] }: { initialConversationId?: number | null; user: SessionUser; groups?: EmployeeGroup[]; employees?: Array<{ id: number; name: string }> }) {
const [rightTab, setRightTab] = useState<SupportRightTab>("client");
return (
<ConversationWorkspace
isOwner={user.role === "OWNER"}
listTitle="Обращения"
searchPlaceholder="Поиск по клиенту, продукту…"
initialConversationId={initialConversationId}
renderContextPanel={({ detail, applyConversation }) => (
<SupportContextPanel rightTab={rightTab} setRightTab={setRightTab} detail={detail} groups={groups} employees={employees} applyConversation={applyConversation} />
)}
/>
);
}
@@ -1,35 +0,0 @@
import type { ReactNode } from "react";
import type { ApiConversation } from "../../conversations/model";
import { DialogControls } from "../../conversations/DialogControls";
import { OperatorCards } from "./OperatorCards";
import { SupportHistory } from "./SupportHistory";
import type { EmployeeGroupRef } from "../../../types";
export type SupportRightTab = "client" | "history";
export function SupportContextPanel({ rightTab, setRightTab, detail, groups = [], employees = [], applyConversation }: { rightTab: SupportRightTab; setRightTab: (tab: SupportRightTab) => void; detail: ApiConversation | null; groups?: EmployeeGroupRef[]; employees?: Array<{ id: number; name: string }>; applyConversation?: (updated: ApiConversation) => void }) {
return (
<section className="sales-context">
<div className="sales-context-tabs">
<RightTabButton active={rightTab === "client"} onClick={() => setRightTab("client")}>Контакт</RightTabButton>
<RightTabButton active={rightTab === "history"} onClick={() => setRightTab("history")}>История</RightTabButton>
</div>
<div className="sales-context-body">
{rightTab === "client" && (
<>
{detail && applyConversation && (
<DialogControls detail={detail} groups={groups} employees={employees} applyConversation={applyConversation} />
)}
<OperatorCards detail={detail} />
</>
)}
{rightTab === "history" && <SupportHistory detail={detail} />}
</div>
</section>
);
}
function RightTabButton({ active, onClick, children }: { active: boolean; onClick: () => void; children: ReactNode }) {
return <button className={active ? "active" : ""} onClick={onClick}>{children}</button>;
}
+20 -11
View File
@@ -5,6 +5,8 @@ import type { AppData, Employee, 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 { useChatScope } from "../features/chat/useChatScope";
import { isManager } from "../auth/access";
import { Sidebar } from "./Sidebar";
import { ShellRouteContent } from "./ShellRouteContent";
import { TopBar } from "./TopBar";
@@ -17,6 +19,10 @@ export function Shell({ route, setRoute, selectedEmployeeId, selectedProductCode
const [notifOpen, setNotifOpen] = useState(false);
const [waitingCount, setWaitingCount] = useState(0);
const prevUnread = useRef<number | null>(null);
const manager = isManager(user);
// Охват чата живёт здесь: сотрудницкий сайдбар и страница чата делят одно
// состояние (дизайн-базлайн v2 §4.1).
const chatScope = useChatScope(true);
const loadWaitingCount = useCallback(async () => {
try {
@@ -58,10 +64,14 @@ export function Shell({ route, setRoute, selectedEmployeeId, selectedProductCode
await markRead([notification.id]).catch(() => undefined);
void loadNotifications();
}
if (notification.targetRoute === "salesDialogs" && notification.targetId) {
// «salesDialogs»/«conversations» — легаси-маршруты старых уведомлений в БД.
const targetRoute = notification.targetRoute === "salesDialogs" || notification.targetRoute === "conversations"
? "chat"
: notification.targetRoute;
if (targetRoute === "chat" && notification.targetId) {
openConversationRoute(Number(notification.targetId));
} else if (notification.targetRoute) {
setRoute(notification.targetRoute as RouteKey);
} else if (targetRoute) {
setRoute(targetRoute as RouteKey);
}
}
@@ -76,11 +86,9 @@ export function Shell({ route, setRoute, selectedEmployeeId, selectedProductCode
function openEmployee(employee: Employee) {
openEmployeeRoute(employee.id);
}
const isSalesWorkspace = route === "salesClientDetail" || route === "salesClients" || route === "salesDialogs";
const isSupportWorkspace = route === "supportOverview" || route === "supportDialogs" || route === "supportPortals" || route === "supportPortalDetail";
const isSalesDialogs = route === "salesDialogs";
const isSupportDialogs = route === "supportDialogs";
const isDialogsWorkspace = isSalesDialogs || isSupportDialogs;
const isSalesWorkspace = route === "salesClientDetail" || route === "salesClients" || route === "chat";
const isSupportWorkspace = route === "supportOverview" || route === "supportPortals" || route === "supportPortalDetail";
const isDialogsWorkspace = route === "chat";
const isSalesClients = route === "salesClients";
const isSalesClientDetail = route === "salesClientDetail";
const isAiFullWidth = false;
@@ -88,12 +96,13 @@ export function Shell({ route, setRoute, selectedEmployeeId, selectedProductCode
const isKnowledgeEditor = route === "aiKnowledgeCreate" || route === "aiKnowledgeDetail";
return (
<div className="hub-shell">
<Sidebar route={route} user={user} setRoute={setRoute} onLogout={onLogout} waitingCount={waitingCount} />
<Sidebar route={route} user={user} setRoute={setRoute} onLogout={onLogout} waitingCount={waitingCount} chatScope={chatScope.scope} setChatScope={chatScope.setScope} chatCounters={chatScope.counters} />
<div className="hub-main">
<TopBar route={route} user={user} currentEmployee={currentEmployee} currentAgentName={agentName} currentChannelName={channelName} setRoute={setRoute} unreadCount={unreadCount} onOpenNotifications={() => { setNotifOpen(true); void loadNotifications(); }} />
{/* У сотрудника верхней панели нет (дизайн-базлайн v2 §4.1). */}
{manager && <TopBar route={route} user={user} currentEmployee={currentEmployee} currentAgentName={agentName} currentChannelName={channelName} 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" : ""} ${isAiFullWidth ? "ai-fullwidth-page" : ""} ${isKnowledgeLibrary ? "ai-knowledge-library-page" : ""} ${isKnowledgeEditor ? "ai-knowledge-editor-page" : ""}`}>
<ShellRouteContent route={route} data={data} currentEmployee={currentEmployee} selectedProductCode={selectedProductCode} selectedAgentId={selectedAgentId} selectedKnowledgeId={selectedKnowledgeId} selectedConversationId={selectedConversationId} selectedClientId={selectedClientId} openClient={openClientRoute} selectedChannelId={selectedChannelId} openChannel={openChannelRoute} selectedSupportPortalId={selectedSupportPortalId} openSupportPortal={openSupportPortalRoute} openConversation={openConversationRoute} openEmployee={openEmployee} openAgentCreate={openAgentCreateRoute} openAgent={openAgentRoute} openKnowledge={openKnowledgeRoute} onAgentLoaded={setAgentName} onChannelLoaded={setChannelName} reload={reload} setRoute={setRoute} user={user} onUserUpdated={onUserUpdated} onLogout={onLogout} />
<ShellRouteContent chatScope={chatScope.scope} setChatScope={chatScope.setScope} chatCounters={chatScope.counters} chatScopeSwitcher={manager} route={route} data={data} currentEmployee={currentEmployee} selectedProductCode={selectedProductCode} selectedAgentId={selectedAgentId} selectedKnowledgeId={selectedKnowledgeId} selectedConversationId={selectedConversationId} selectedClientId={selectedClientId} openClient={openClientRoute} selectedChannelId={selectedChannelId} openChannel={openChannelRoute} selectedSupportPortalId={selectedSupportPortalId} openSupportPortal={openSupportPortalRoute} openConversation={openConversationRoute} openEmployee={openEmployee} openAgentCreate={openAgentCreateRoute} openAgent={openAgentRoute} openKnowledge={openKnowledgeRoute} onAgentLoaded={setAgentName} onChannelLoaded={setChannelName} reload={reload} setRoute={setRoute} user={user} onUserUpdated={onUserUpdated} onLogout={onLogout} />
</div>
</main>
</div>
@@ -11,10 +11,11 @@ import { EmployeeDetailPage, EmployeesPage } from "../features/employees/Employe
import { ProfilePage } from "../features/profile/ProfilePage";
import { SettingsPage } from "../features/settings/SettingsPage";
import { AdministrationPage } from "../features/administration/AdministrationPage";
import { ChatPage } from "../features/chat/ChatPage";
import type { DialogScope } from "../features/conversations/ConversationWorkspace";
import type { ConversationCounters } from "../features/conversations/model";
import { SalesClientDetailPage } from "../features/sales/client-detail/SalesClientDetailPage";
import { SalesClientsPage } from "../features/sales/SalesClientsPage";
import { SalesDialogsPage } from "../features/sales/SalesDialogsPage";
import { SupportDialogsPage } from "../features/support/SupportDialogsPage";
import { SupportOverviewPage } from "../features/support/SupportOverviewPage";
import { LoadingState } from "../shared/ui";
import type { AppData, Employee, RouteKey, SessionUser } from "../types";
@@ -27,7 +28,7 @@ const SupportPortalDetailPage = lazy(() => import("../features/support-portals/S
(module) => ({ default: module.SupportPortalDetailPage }),
));
export function ShellRouteContent({ route, data, currentEmployee, selectedProductCode, selectedAgentId, selectedKnowledgeId, selectedConversationId, selectedClientId, openClient, selectedChannelId, openChannel, selectedSupportPortalId, openSupportPortal, openConversation, openEmployee, openAgentCreate, openAgent, openKnowledge, onAgentLoaded, onChannelLoaded, reload, setRoute, user, onUserUpdated, onLogout }: { route: RouteKey; data: AppData; currentEmployee: Employee | null; selectedProductCode: string | null; selectedAgentId: number | null; selectedKnowledgeId: number | null; selectedConversationId: number | null; selectedClientId: number | null; openClient: (clientId: number) => void; selectedChannelId: number | null; openChannel: (channelId: number) => void; selectedSupportPortalId: number | null; openSupportPortal: (portalId: number) => void; openConversation: (conversationId: number) => void; openEmployee: (employee: Employee) => void; openAgentCreate: (productCode: string | null) => void; openAgent: (agentId: number) => void; openKnowledge: (knowledgeId: number) => void; onAgentLoaded: (name: string | null) => void; onChannelLoaded: (name: string | null) => void; reload: () => void; setRoute: (route: RouteKey) => void; user: SessionUser; onUserUpdated: (user: SessionUser) => void; onLogout: () => void }) {
export function ShellRouteContent({ chatScope, setChatScope, chatCounters, chatScopeSwitcher, route, data, currentEmployee, selectedProductCode, selectedAgentId, selectedKnowledgeId, selectedConversationId, selectedClientId, openClient, selectedChannelId, openChannel, selectedSupportPortalId, openSupportPortal, openConversation, openEmployee, openAgentCreate, openAgent, openKnowledge, onAgentLoaded, onChannelLoaded, reload, setRoute, user, onUserUpdated, onLogout }: { chatScope: DialogScope; setChatScope: (scope: DialogScope) => void; chatCounters: ConversationCounters | null; chatScopeSwitcher: boolean; route: RouteKey; data: AppData; currentEmployee: Employee | null; selectedProductCode: string | null; selectedAgentId: number | null; selectedKnowledgeId: number | null; selectedConversationId: number | null; selectedClientId: number | null; openClient: (clientId: number) => void; selectedChannelId: number | null; openChannel: (channelId: number) => void; selectedSupportPortalId: number | null; openSupportPortal: (portalId: number) => void; openConversation: (conversationId: number) => void; openEmployee: (employee: Employee) => void; openAgentCreate: (productCode: string | null) => void; openAgent: (agentId: number) => void; openKnowledge: (knowledgeId: number) => void; onAgentLoaded: (name: string | null) => void; onChannelLoaded: (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} />}
@@ -47,8 +48,18 @@ export function ShellRouteContent({ route, data, currentEmployee, selectedProduc
{route === "salesClients" && <SalesClientsPage openClient={openClient} />}
{route === "salesClientDetail" && <SalesClientDetailPage contactId={selectedClientId} openConversation={openConversation} />}
{route === "supportOverview" && <SupportOverviewPage />}
{route === "supportDialogs" && <SupportDialogsPage initialConversationId={selectedConversationId} user={user} groups={data.groups} employees={dialogAssignees(data)} />}
{route === "salesDialogs" && <SalesDialogsPage initialConversationId={selectedConversationId} user={user} groups={data.groups} employees={dialogAssignees(data)} />}
{route === "chat" && (
<ChatPage
initialConversationId={selectedConversationId}
user={user}
groups={data.groups}
employees={dialogAssignees(data)}
scope={chatScope}
setScope={setChatScope}
counters={chatCounters}
showScopeSwitcher={chatScopeSwitcher}
/>
)}
{route === "agents" && <AgentsPage agents={data.agents} groups={data.groups} reload={reload} openAgent={openAgent} />}
{route === "agentDetail" && (
<AgentDetailPage
+147 -66
View File
@@ -1,82 +1,75 @@
import type { RouteKey, SessionUser } from "../types";
import { Icon, LogoIcon } from "../shared/icons";
import { canAccess, defaultRoute, isManager } from "../auth/access";
import { defaultRoute, isManager } from "../auth/access";
import type { DialogScope } from "../features/conversations/ConversationWorkspace";
import type { ConversationCounters } from "../features/conversations/model";
import { LaunchChecklist } from "./LaunchChecklist";
import { SidebarNavSection, type SidebarNavSectionItem } from "./SidebarNavSection";
import { SidebarUserMenu } from "./SidebarUserMenu";
// Сайдбар по дизайн-базлайну v2: у менеджера — плоские шесть пунктов (A1) и
// блок «Запуск»; у сотрудника разделов нет (§3) — сайдбар и есть фильтр списка
// диалогов: дерево «Все диалоги · Группы · Агенты» + профиль внизу.
type SidebarLinkProps = {
activeRoutes?: RouteKey[];
disabled?: boolean;
badge?: string;
icon: Parameters<typeof Icon>[0]["name"];
label: string;
route: RouteKey;
routeKey?: RouteKey;
routeKey: RouteKey;
setRoute: (route: RouteKey) => void;
};
function SidebarLink({ activeRoutes, disabled, icon, label, route, routeKey, setRoute }: SidebarLinkProps) {
function SidebarLink({ activeRoutes, badge, icon, label, route, routeKey, setRoute }: SidebarLinkProps) {
const active = activeRoutes?.includes(route) ?? routeKey === route;
return (
<button
className={`hub-nav-item ${active ? "is-active" : ""}`}
disabled={disabled}
type="button"
onClick={() => routeKey && setRoute(routeKey)}
onClick={() => setRoute(routeKey)}
>
{active && <span className="active-bar" />}
<Icon name={icon} />
{label}
{badge && <b className="hub-nav-badge">{badge}</b>}
</button>
);
}
const SALES_ITEMS: SidebarNavSectionItem[] = [
{ activeRoutes: ["salesDialogs"], key: "salesDialogs", label: "Диалоги" },
{ activeRoutes: ["salesClients", "salesClientDetail"], key: "salesClients", label: "Контакты" },
];
const SUPPORT_ITEMS: SidebarNavSectionItem[] = [
{ activeRoutes: ["supportOverview"], key: "supportOverview", label: "Обзор" },
{ activeRoutes: ["supportDialogs"], key: "supportDialogs", label: "Диалоги" },
{ activeRoutes: ["supportPortals", "supportPortalDetail"], key: "supportPortals", label: "Порталы" },
];
const AI_ITEMS: SidebarNavSectionItem[] = [
const SERVICE_ITEMS: SidebarNavSectionItem[] = [
{ activeRoutes: ["command"], key: "command", label: "Обзор" },
{ activeRoutes: ["supportOverview"], key: "supportOverview", label: "Обзор поддержки" },
{ activeRoutes: ["aiKnowledge", "aiKnowledgeCreate", "aiKnowledgeDetail"], key: "aiKnowledge", label: "Знания" },
{ activeRoutes: ["aiUsage"], disabled: true, key: "aiUsage", label: "Использование AI" },
{ activeRoutes: ["aiUsage"], key: "aiUsage", label: "Использование AI" },
{ activeRoutes: ["integrations"], key: "integrations", label: "Интеграции" },
{ activeRoutes: ["administrationOrganization"], key: "administrationOrganization", label: "Организация" },
{ activeRoutes: ["administrationAudit"], key: "administrationAudit", label: "Аудит" },
];
const ADMINISTRATION_ITEMS: SidebarNavSectionItem[] = [
{
activeRoutes: ["administrationOrganization"],
key: "administrationOrganization",
label: "Организация",
},
{
activeRoutes: ["administrationAudit"],
key: "administrationAudit",
label: "Аудит",
},
];
function visibleItems(user: SessionUser, items: SidebarNavSectionItem[]) {
return items.filter((item) => canAccess(user, item.key));
}
export function Sidebar({ route, user, setRoute, onLogout, waitingCount = 0 }: { route: RouteKey; user: SessionUser; setRoute: (route: RouteKey) => void; onLogout: () => void; waitingCount?: number }) {
export function Sidebar({
route,
user,
setRoute,
onLogout,
waitingCount = 0,
chatScope,
setChatScope,
chatCounters,
}: {
route: RouteKey;
user: SessionUser;
setRoute: (route: RouteKey) => void;
onLogout: () => void;
waitingCount?: number;
chatScope: DialogScope;
setChatScope: (scope: DialogScope) => void;
chatCounters: ConversationCounters | null;
}) {
const manager = isManager(user);
const sectionStorageKey = (section: string) => (
`chatbolls.sidebar.${user.organizationPublicId}.${section}.expanded`
);
const salesItems = visibleItems(user, SALES_ITEMS).map((item) => (
item.key === "salesDialogs" && waitingCount > 0
? { ...item, badge: String(waitingCount) }
: item
));
const supportItems = visibleItems(user, SUPPORT_ITEMS);
const aiItems = visibleItems(user, AI_ITEMS);
const administrationItems = visibleItems(user, ADMINISTRATION_ITEMS);
const isManagerUser = isManager(user);
return (
<aside className="hub-sidebar">
@@ -86,30 +79,118 @@ export function Sidebar({ route, user, setRoute, onLogout, waitingCount = 0 }: {
? <img src={user.organizationLogoUrl} alt="" />
: <LogoIcon />}
</div>
<div><strong>Chatbolls</strong><span>Управление компанией</span></div>
<div><strong>{user.organizationName || "Chatbolls"}</strong><span>Chatbolls</span></div>
</button>
<nav className="hub-nav">
{canAccess(user, "command") && <SidebarLink icon="grid" label="Обзор" route={route} routeKey="command" setRoute={setRoute} />}
<div className="hub-nav-group">КОМПАНИЯ</div>
<SidebarNavSection icon="shop" items={salesItems} label="Клиенты" route={route} setRoute={setRoute} storageKey={sectionStorageKey("sales")} />
<SidebarNavSection icon="wrench" items={supportItems} label="Поддержка" route={route} setRoute={setRoute} storageKey={sectionStorageKey("support")} />
{canAccess(user, "employees") && <SidebarLink activeRoutes={["employees", "employeeDetail"]} icon="team" label="Сотрудники" route={route} routeKey="employees" setRoute={setRoute} />}
{canAccess(user, "agents") && <SidebarLink activeRoutes={["agents", "agentDetail"]} icon="robot" label="Агенты" route={route} routeKey="agents" setRoute={setRoute} />}
<div className="hub-nav-group">ПЛАТФОРМА</div>
<SidebarNavSection icon="robot" items={aiItems} label="AI" route={route} setRoute={setRoute} storageKey={sectionStorageKey("ai")} />
{canAccess(user, "integrations") && <SidebarLink icon="plug" label="Интеграции" route={route} routeKey="integrations" setRoute={setRoute} />}
<div className="hub-nav-divider" />
<SidebarNavSection
icon="settings"
items={administrationItems}
label="Администрирование"
{manager ? (
<nav className="hub-nav">
<SidebarLink icon="message" label="Чат" badge={waitingCount > 0 ? String(waitingCount) : undefined} route={route} routeKey="chat" setRoute={setRoute} />
<SidebarLink activeRoutes={["salesClients", "salesClientDetail"]} icon="user" label="Контакты" route={route} routeKey="salesClients" setRoute={setRoute} />
<SidebarLink activeRoutes={["agents", "agentDetail"]} icon="robot" label="Агенты" route={route} routeKey="agents" setRoute={setRoute} />
<SidebarLink activeRoutes={["employees", "employeeDetail"]} icon="team" label="Сотрудники" route={route} routeKey="employees" setRoute={setRoute} />
<SidebarLink activeRoutes={["supportPortals", "supportPortalDetail"]} icon="columns" label="Доска" route={route} routeKey="supportPortals" setRoute={setRoute} />
<SidebarLink icon="settings" label="Настройки" route={route} routeKey="settings" setRoute={setRoute} />
<div className="hub-nav-divider" />
{/* Служебные разделы до их переезда в «Настройки» (SPEC-HUB-0031 §8.6). */}
<SidebarNavSection
icon="wrench"
items={SERVICE_ITEMS}
label="Ещё"
route={route}
setRoute={setRoute}
storageKey={sectionStorageKey("service")}
/>
</nav>
) : (
<ChatScopeTree
route={route}
setRoute={setRoute}
storageKey={sectionStorageKey("administration")}
scope={chatScope}
counters={chatCounters}
setScope={(scope) => {
setChatScope(scope);
setRoute("chat");
}}
/>
</nav>
{isManagerUser && <LaunchChecklist user={user} setRoute={setRoute} />}
)}
{manager && <LaunchChecklist user={user} setRoute={setRoute} />}
<SidebarUserMenu user={user} route={route} setRoute={setRoute} onLogout={onLogout} />
</aside>
);
}
// Дерево «Диалоги» сотрудника (кадры A-H): пункты — это охват списка чата.
function ChatScopeTree({
route,
scope,
counters,
setScope,
}: {
route: RouteKey;
scope: DialogScope;
counters: ConversationCounters | null;
setScope: (scope: DialogScope) => void;
}) {
const inChat = route === "chat";
const isActive = (candidate: DialogScope) =>
inChat
&& scope.kind === candidate.kind
&& (scope.kind !== "group" || candidate.kind !== "group" || scope.id === candidate.id)
&& (scope.kind !== "agent" || candidate.kind !== "agent" || scope.id === candidate.id);
return (
<nav className="hub-nav chat-scope-tree">
<div className="chat-scope-head"><Icon name="message" size={17} /><span>Диалоги</span></div>
<button
className={`chat-scope-item is-top ${isActive({ kind: "all" }) ? "is-active" : ""}`}
type="button"
onClick={() => setScope({ kind: "all" })}
>
<Icon name="inbox" size={15} />
<span>Все диалоги</span>
{counters && <small>{counters.all}</small>}
</button>
{counters && counters.groups.length > 0 && (
<>
<div className="chat-scope-section"><Icon name="team" size={15} /><span>Группы</span></div>
{counters.groups.map((group) => (
<button
className={`chat-scope-item is-nested ${isActive({ kind: "group", id: group.id, label: group.name }) ? "is-active" : ""}`}
key={group.id}
type="button"
onClick={() => setScope({ kind: "group", id: group.id, label: group.name })}
>
<i className="chat-scope-dot" />
<span>{group.name}</span>
<small>{group.count}</small>
</button>
))}
<button
className={`chat-scope-item is-nested ${isActive({ kind: "ungrouped" }) ? "is-active" : ""}`}
type="button"
onClick={() => setScope({ kind: "ungrouped" })}
>
<i className="chat-scope-dot is-muted" />
<span>Без группы</span>
{counters && <small>{counters.ungrouped}</small>}
</button>
</>
)}
{counters && counters.agents.length > 0 && (
<>
<div className="chat-scope-section"><Icon name="robot" size={15} /><span>Агенты</span></div>
{counters.agents.map((agent) => (
<button
className={`chat-scope-item is-nested ${isActive({ kind: "agent", id: agent.id, label: agent.name }) ? "is-active" : ""}`}
key={agent.id}
type="button"
onClick={() => setScope({ kind: "agent", id: agent.id, label: agent.name })}
>
<span className="chat-scope-agent"><Icon name="robot" size={11} /></span>
<span>{agent.name}</span>
<small>{agent.count}</small>
</button>
))}
</>
)}
</nav>
);
}
+3 -5
View File
@@ -6,8 +6,7 @@ export function TopBar({ route, user, currentEmployee, currentAgentName, current
const isCommand = route === "command";
const isAiDetail = route === "aiKnowledgeCreate" || route === "aiKnowledgeDetail";
const isAi = route.startsWith("ai") && !isAiDetail;
const isSalesWorkspace = route === "salesClientDetail" || route === "salesClients" || route === "salesDialogs";
const isSupportWorkspace = route === "supportOverview" || route === "supportDialogs" || route === "supportPortals" || route === "supportPortalDetail";
const isSupportWorkspace = route === "supportOverview" || route === "supportPortals" || route === "supportPortalDetail";
return (
<header className="hub-topbar">
<div className="breadcrumbs">
@@ -15,14 +14,13 @@ export function TopBar({ route, user, currentEmployee, currentAgentName, current
<i>/</i>
{route === "employeeDetail" && <><button type="button" onClick={() => setRoute("employees")}>Сотрудники</button><i>/</i><strong>{currentEmployee?.fullName || currentEmployee?.email || routes[route]}</strong></>}
{route === "agentDetail" && <><button type="button" onClick={() => setRoute("agents")}>Агенты</button><i>/</i><strong>{currentAgentName || routes[route]}</strong></>}
{isSalesWorkspace && route !== "salesClientDetail" && <><button type="button" onClick={() => setRoute("salesDialogs")}>Клиенты</button><i>/</i><strong>{routes[route]}</strong></>}
{route === "salesClientDetail" && <><button type="button" onClick={() => setRoute("salesDialogs")}>Клиенты</button><i>/</i><button type="button" onClick={() => setRoute("salesClients")}>Контакты</button><i>/</i><strong>{routes[route]}</strong></>}
{route === "salesClientDetail" && <><button type="button" onClick={() => setRoute("salesClients")}>Контакты</button><i>/</i><strong>{routes[route]}</strong></>}
{isSupportWorkspace && route !== "supportPortalDetail" && <><button type="button" onClick={() => setRoute("supportOverview")}>Поддержка</button><i>/</i><strong>{routes[route]}</strong></>}
{route === "supportPortalDetail" && <><button type="button" onClick={() => setRoute("supportOverview")}>Поддержка</button><i>/</i><button type="button" onClick={() => setRoute("supportPortals")}>Порталы</button><i>/</i><strong>{routes[route]}</strong></>}
{isAi && <><button type="button" onClick={() => setRoute("aiKnowledge")}>AI</button><i>/</i><strong>{routes[route]}</strong></>}
{route === "aiKnowledgeDetail" && <><button type="button" onClick={() => setRoute("aiKnowledge")}>AI</button><i>/</i><button type="button" onClick={() => setRoute("aiKnowledge")}>Знания</button><i>/</i><strong>{currentAgentName || routes[route]}</strong></>}
{route === "aiKnowledgeCreate" && <><button type="button" onClick={() => setRoute("aiKnowledge")}>AI</button><i>/</i><button type="button" onClick={() => setRoute("aiKnowledge")}>Знания</button><i>/</i><strong>{routes[route]}</strong></>}
{route !== "employeeDetail" && route !== "agentDetail" && route !== "supportPortalDetail" && !isSalesWorkspace && !isSupportWorkspace && !isAi && !isAiDetail && <strong>{routes[route]}</strong>}
{route !== "employeeDetail" && route !== "agentDetail" && route !== "supportPortalDetail" && route !== "salesClientDetail" && !isSupportWorkspace && !isAi && !isAiDetail && <strong>{routes[route]}</strong>}
</div>
<div className="topbar-actions">
<button className="icon-button" aria-label="Уведомления" onClick={onOpenNotifications}><Icon name="bell" size={18} />{unreadCount > 0 && <b>{unreadCount > 99 ? "99+" : unreadCount}</b>}</button>
+100
View File
@@ -203,6 +203,106 @@
background: var(--n-9);
}
/* Дерево «Диалоги» сотрудника (дизайн-базлайн v2 §3): сайдбар = фильтр чата. */
.chat-scope-head {
display: flex;
align-items: center;
gap: 9px;
height: 34px;
padding: 0 8px;
color: var(--n-1);
font-size: 13.5px;
font-weight: 700;
}
.chat-scope-section {
display: flex;
align-items: center;
gap: 8px;
height: 30px;
margin: 6px 0 0 16px;
padding: 0 10px 0 12px;
color: var(--n-4);
font-size: 12.5px;
font-weight: 600;
}
.chat-scope-item {
display: flex;
align-items: center;
gap: 9px;
height: 32px;
padding: 0 10px 0 12px;
border: 0;
border-radius: 8px;
color: var(--n-2);
background: transparent;
font-size: 13px;
font-weight: 500;
text-align: left;
}
.chat-scope-item.is-top {
height: 34px;
margin-left: 16px;
}
.chat-scope-item.is-nested {
margin-left: 34px;
}
.chat-scope-item:hover {
background: var(--n-9);
}
.chat-scope-item.is-active {
color: var(--primary-text);
background: var(--primary-bg);
font-weight: 600;
}
.chat-scope-item > span:not(.chat-scope-agent) {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chat-scope-item small {
color: var(--n-5);
font-size: 11.5px;
font-weight: 600;
}
.chat-scope-item.is-active small {
color: var(--primary-text);
}
.chat-scope-dot {
flex: none;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--primary);
}
.chat-scope-dot.is-muted {
background: var(--n-5);
}
.chat-scope-agent {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border-radius: 5px;
color: var(--ai);
background: color-mix(in srgb, var(--ai) 14%, var(--surface-card));
}
.profile-link {
margin: 0;
padding: 8px 10px;
+14 -4
View File
@@ -54,14 +54,24 @@ describe("ai knowledge routes", () => {
});
describe("support routes", () => {
it("parses support overview and dialogs URLs", () => {
it("parses the support overview URL", () => {
expect(routeFromPath("/departments/support")).toEqual({ route: "supportOverview", ...empty });
expect(routeFromPath("/departments/support/dialogs")).toEqual({ route: "supportDialogs", ...empty });
});
it("creates support overview and dialogs URLs", () => {
it("creates the support overview URL", () => {
expect(pathFromRoute("supportOverview")).toBe("/departments/support");
expect(pathFromRoute("supportDialogs")).toBe("/departments/support/dialogs");
});
});
describe("chat route", () => {
it("parses /chat and the legacy dialog URLs", () => {
expect(routeFromPath("/chat")).toEqual({ route: "chat", ...empty });
expect(routeFromPath("/departments/sales/dialogs")).toEqual({ route: "chat", ...empty });
expect(routeFromPath("/departments/support/dialogs")).toEqual({ route: "chat", ...empty });
});
it("creates the chat URL", () => {
expect(pathFromRoute("chat")).toBe("/chat");
});
});
+5 -5
View File
@@ -20,15 +20,16 @@ export function routeFromPath(pathname: string, search = ""): RouteState {
const base = { employeeId: null, productCode: null, agentId: null, knowledgeId: null, clientId: null, channelId: null, supportPortalId: null };
const state = { organizationPublicId, ...base };
if (path === "/" || path === "/command") return { route: "command", ...state };
if (path === "/departments/sales") return { route: "salesDialogs", ...state };
// Устаревшие адреса разделённых чатов ведут в единый «Чат».
if (path === "/chat" || path === "/departments/sales" || path === "/departments/sales/dialogs" || path === "/departments/support/dialogs") {
return { route: "chat", ...state };
}
if (path === "/departments/support") return { route: "supportOverview", ...state };
if (path === "/departments/support/dialogs") return { route: "supportDialogs", ...state };
if (path === "/departments/sales/clients") return { route: "salesClients", ...state };
if (path.startsWith("/departments/sales/clients/")) {
const id = Number(path.split("/")[4]);
return Number.isInteger(id) && id > 0 ? { ...state, route: "salesClientDetail", clientId: id } : { route: "salesClients", ...state };
}
if (path === "/departments/sales/dialogs") return { route: "salesDialogs", ...state };
if (path === "/employees") return { route: "employees", ...state };
if (path.startsWith("/employees/")) {
const id = Number(path.split("/")[2]);
@@ -76,10 +77,9 @@ export function pathFromRoute(route: RouteKey, entityId: number | null = null, p
const prefix = organizationPublicId ? `/organizations/${organizationPublicId}` : "";
if (route === "command") return `${prefix}/`;
if (route === "supportOverview") return `${prefix}/departments/support`;
if (route === "supportDialogs") return `${prefix}/departments/support/dialogs`;
if (route === "salesClients") return `${prefix}/departments/sales/clients`;
if (route === "salesClientDetail") return entityId ? `${prefix}/departments/sales/clients/${entityId}` : `${prefix}/departments/sales/clients`;
if (route === "salesDialogs") return `${prefix}/departments/sales/dialogs`;
if (route === "chat") return `${prefix}/chat`;
if (route === "employees") return `${prefix}/employees`;
if (route === "employeeDetail") return entityId ? `${prefix}/employees/${entityId}` : `${prefix}/employees`;
if (route === "supportPortals") return `${prefix}/departments/support/portals`;
+1 -2
View File
@@ -10,9 +10,8 @@ export const routes: Record<RouteKey, string> = {
settings: "Настройки",
salesClientDetail: "Контакт",
salesClients: "Контакты",
salesDialogs: "Диалоги",
chat: "Чат",
supportOverview: "Обзор",
supportDialogs: "Диалоги",
supportPortals: "Порталы",
supportPortalDetail: "Портал поддержки",
agents: "Агенты",
+2 -1
View File
@@ -35,12 +35,13 @@ export function MaxLogo({ size = 24 }: { size?: number }) {
);
}
export function Icon({ name, size = 17 }: { name: "grid" | "building" | "team" | "box" | "robot" | "plug" | "settings" | "bell" | "chevron" | "plus" | "arrow" | "paperclip" | "send" | "phone" | "video" | "lock" | "mail" | "eye" | "eyeOff" | "logout" | "user" | "refresh" | "transfer" | "warning" | "bolt" | "shop" | "search" | "more" | "external" | "key" | "pause" | "play" | "percent" | "save" | "check" | "copy" | "clock" | "message" | "cart" | "columns" | "download" | "list" | "split" | "gitBranch" | "route" | "expand" | "edit" | "trash" | "wrench" | "xCircle" | "folder" | "grip"; size?: number }) {
export function Icon({ name, size = 17 }: { name: "grid" | "building" | "team" | "box" | "robot" | "plug" | "settings" | "bell" | "chevron" | "plus" | "arrow" | "paperclip" | "send" | "phone" | "video" | "lock" | "mail" | "eye" | "eyeOff" | "logout" | "user" | "refresh" | "transfer" | "warning" | "bolt" | "shop" | "search" | "more" | "external" | "key" | "pause" | "play" | "percent" | "save" | "check" | "copy" | "clock" | "message" | "inbox" | "cart" | "columns" | "download" | "list" | "split" | "gitBranch" | "route" | "expand" | "edit" | "trash" | "wrench" | "xCircle" | "folder" | "grip"; size?: number }) {
const common = { width: size, height: size, fill: "none", stroke: "currentColor", strokeWidth: 1.8, strokeLinecap: "round", strokeLinejoin: "round" } as const;
const paths: Record<typeof name, ReactNode> = {
grid: <><rect x="3" y="3" width="7" height="9" rx="1.4" /><rect x="14" y="3" width="7" height="5" rx="1.4" /><rect x="14" y="12" width="7" height="9" rx="1.4" /><rect x="3" y="16" width="7" height="5" rx="1.4" /></>,
building: <><path d="M3 21h18" /><path d="M5 21V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16" /><path d="M9 8h1.5M13.5 8H15M9 12h1.5M13.5 12H15M9 16h1.5M13.5 16H15" /></>,
team: <><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" /><circle cx="9" cy="7" r="4" /><path d="M22 21v-2a4 4 0 0 0-3-3.87" /><path d="M16 3.13a4 4 0 0 1 0 7.75" /></>,
inbox: <><polyline points="22 12 16 12 14 15 10 15 8 12 2 12" /><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" /></>,
box: <><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z" /><path d="m3.3 7 8.7 5 8.7-5" /><path d="M12 22V12" /></>,
// Tabler ti-robot. Глаза — точки: рисуются нулевыми сегментами v.01
// и держатся на strokeLinecap="round" из common.
+1 -1
View File
@@ -115,7 +115,7 @@ export type Product = {
updatedAt: string;
};
export type RouteKey = "administrationOrganization" | "administrationAudit" | "command" | "employeeDetail" | "employees" | "profile" | "settings" | "salesClientDetail" | "salesClients" | "salesDialogs" | "supportOverview" | "supportDialogs" | "supportPortals" | "supportPortalDetail" | "agents" | "agentDetail" | "aiKnowledge" | "aiKnowledgeCreate" | "aiKnowledgeDetail" | "aiUsage" | "integrations";
export type RouteKey = "administrationOrganization" | "administrationAudit" | "command" | "employeeDetail" | "employees" | "profile" | "settings" | "salesClientDetail" | "salesClients" | "chat" | "supportOverview" | "supportPortals" | "supportPortalDetail" | "agents" | "agentDetail" | "aiKnowledge" | "aiKnowledgeCreate" | "aiKnowledgeDetail" | "aiUsage" | "integrations";
export type AppData = {
employees: Employee[];
+1 -1
View File
@@ -49,7 +49,7 @@ export function useRouteNavigation(
};
applyRouteState(nextState);
setSelectedConversationId(
nextRoute === "salesDialogs" || nextRoute === "supportDialogs" ? entityId : null,
nextRoute === "chat" ? entityId : null,
);
const nextPath = pathFromRoute(
nextRoute,