mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 09:14:58 +03:00
✨ feat(chat)!: единый чат и оболочка сотрудника по дизайн-базлайну v2
Sales- и support-диалоги объединены в один экран «Чат» (маршрут /chat, старые адреса — алиасы). Контекст-панель сама выбирает представление по identity диалога. У сотрудника нет верхней панели и разделов — сайдбар это дерево «Все диалоги · Группы · Агенты», управляющее охватом списка; у менеджера — плоские шесть пунктов макета и группа «Ещё» для служебных разделов. Роуты уведомлений и карточек обзора на бэкенде — chat. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
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"},
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -10,9 +10,8 @@ export const routes: Record<RouteKey, string> = {
|
||||
settings: "Настройки",
|
||||
salesClientDetail: "Контакт",
|
||||
salesClients: "Контакты",
|
||||
salesDialogs: "Диалоги",
|
||||
chat: "Чат",
|
||||
supportOverview: "Обзор",
|
||||
supportDialogs: "Диалоги",
|
||||
supportPortals: "Порталы",
|
||||
supportPortalDetail: "Портал поддержки",
|
||||
agents: "Агенты",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -49,7 +49,7 @@ export function useRouteNavigation(
|
||||
};
|
||||
applyRouteState(nextState);
|
||||
setSelectedConversationId(
|
||||
nextRoute === "salesDialogs" || nextRoute === "supportDialogs" ? entityId : null,
|
||||
nextRoute === "chat" ? entityId : null,
|
||||
);
|
||||
const nextPath = pathFromRoute(
|
||||
nextRoute,
|
||||
|
||||
Reference in new issue
Block a user