diff --git a/apps/internal-ui/src/features/chat/ChatPage.tsx b/apps/internal-ui/src/features/chat/ChatPage.tsx index 731ee0f..c36efe5 100644 --- a/apps/internal-ui/src/features/chat/ChatPage.tsx +++ b/apps/internal-ui/src/features/chat/ChatPage.tsx @@ -1,5 +1,7 @@ import { useState, type ReactNode } from "react"; +import { ChatMobileHeader } from "../../layout/ChatMobileHeader"; + import { ConversationWorkspace, type DialogScope } from "../conversations/ConversationWorkspace"; import { DialogControls } from "../conversations/DialogControls"; import type { ApiConversation, ConversationCounters } from "../conversations/model"; @@ -8,7 +10,7 @@ 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"; +import type { EmployeeGroup, RouteKey, SessionUser } from "../../types"; // Единый «Чат» (дизайн-базлайн v2 §8.1): один экран для всех диалогов // организации. Контекст-панель сама выбирает представление по источнику @@ -25,6 +27,8 @@ export function ChatPage({ setScope, counters, showScopeSwitcher, + setRoute, + onLogout, }: { initialConversationId?: number | null; user: SessionUser; @@ -34,6 +38,8 @@ export function ChatPage({ setScope: (scope: DialogScope) => void; counters: ConversationCounters | null; showScopeSwitcher: boolean; + setRoute: (route: RouteKey) => void; + onLogout: () => void; }) { const [rightTab, setRightTab] = useState("client"); return ( @@ -44,6 +50,7 @@ export function ChatPage({ setScope={setScope} counters={counters} showScopeSwitcher={showScopeSwitcher} + mobileHeader={} renderContextPanel={({ dialog, detail, applyConversation }) => ( void; onCall: (kind: "AUDIO" | "VIDEO") => void; onClose: () => void; onSpam: () => Promise; onReturnQueue: () => void; onArchive: () => Promise; onToggleContext?: () => void }) { +export function ConversationThread({ controlMode, dialog, detail, isOwner = false, onClaim, onCall, onClose, onSpam, onReturnQueue, onArchive, onToggleContext, onMobileBack }: { controlMode: ControlMode; dialog: ConversationListItem | null; detail: ApiConversation | null; isOwner?: boolean; onClaim: () => void; onCall: (kind: "AUDIO" | "VIDEO") => void; onClose: () => void; onSpam: () => Promise; onReturnQueue: () => void; onArchive: () => Promise; onToggleContext?: () => void; onMobileBack?: () => void }) { const timelineRef = useRef(null); const messages = detail?.messages ?? []; const lastMessageId = messages.length ? messages[messages.length - 1].id : 0; @@ -34,6 +34,8 @@ export function ConversationThread({ controlMode, dialog, detail, isOwner = fals return ( <>
+ {/* Кадр M2: на мобильном лента — отдельный экран, назад к списку. */} + {onMobileBack && }
diff --git a/apps/internal-ui/src/features/conversations/ConversationWorkspace.tsx b/apps/internal-ui/src/features/conversations/ConversationWorkspace.tsx index fbb59a4..0b30d95 100644 --- a/apps/internal-ui/src/features/conversations/ConversationWorkspace.tsx +++ b/apps/internal-ui/src/features/conversations/ConversationWorkspace.tsx @@ -45,11 +45,12 @@ 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, scope, setScope, counters, showScopeSwitcher = true }: { +export function ConversationWorkspace({ isOwner = false, listTitle, searchPlaceholder, renderContextPanel, mobileHeader, 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; + mobileHeader?: ReactNode; initialConversationId?: number | null; // Охват (дерево фильтров) живёт в Shell: у сотрудника им управляет сайдбар, // у менеджера — поповер в заголовке списка. @@ -61,6 +62,8 @@ export function ConversationWorkspace({ isOwner = false, listTitle, searchPlaceh const [listTab, setListTab] = useState("all"); // Кадр S2: на ≤1024px контекст-панель — выдвижная поверх ленты. const [ctxOpen, setCtxOpen] = useState(false); + // Кадры M1/M2: на ≤768px список и лента — отдельные экраны. + const [mobileDialogOpen, setMobileDialogOpen] = useState(false); const [search, setSearch] = useState(""); const [conversations, setConversations] = useState([]); const [listLoaded, setListLoaded] = useState(false); @@ -174,7 +177,7 @@ export function ConversationWorkspace({ isOwner = false, listTitle, searchPlaceh }; return ( -
+
{ + setSelectedId(id); + setMobileDialogOpen(true); + }} + mobileHeader={mobileHeader} />
- void callController.start(kind)} onClose={onClose} onSpam={onSpam} onReturnQueue={onReturnQueue} onArchive={onArchive} onToggleContext={() => setCtxOpen((open) => !open)} /> + void callController.start(kind)} onClose={onClose} onSpam={onSpam} onReturnQueue={onReturnQueue} onArchive={onArchive} onToggleContext={() => setCtxOpen((open) => !open)} onMobileBack={() => setMobileDialogOpen(false)} /> {(detailError || actionError) &&
{detailError || actionError}
} void; showScopeSwitcher?: boolean; + mobileHeader?: ReactNode; dialogs: ConversationListItem[]; filtered: ConversationListItem[]; listTab: ListTab; @@ -30,6 +31,7 @@ export function DialogList({ title = "Диалоги", searchPlaceholder = "По const waitCount = dialogs.filter((dialog) => dialog.mode === "wait").length; return (
+ {mobileHeader}
{showScopeSwitcher @@ -46,6 +48,8 @@ export function DialogList({ title = "Диалоги", searchPlaceholder = "По
{errorText &&
{errorText}
} + {/* Кадр S1: пустой список без призыва к действию. */} + {!errorText && dialogs.length === 0 &&
Диалоги появятся, когда клиенты напишут вашему агенту
} {filtered.map((dialog) => )}
diff --git a/apps/internal-ui/src/features/sales/dialogs/layout.css b/apps/internal-ui/src/features/sales/dialogs/layout.css index 526a6e7..d39ab69 100644 --- a/apps/internal-ui/src/features/sales/dialogs/layout.css +++ b/apps/internal-ui/src/features/sales/dialogs/layout.css @@ -52,3 +52,94 @@ display: flex; } } + +/* Кадры M1/M2 (≤768px): список и лента — отдельные экраны; сайдбар скрыт, + шапка бренда живёт в списке. */ +@media (max-width: 768px) { + .hub-shell.is-chat-route .hub-sidebar { + display: none; + } + + .sales-dialogs .sales-dialog-list { + width: 100%; + border-right: 0; + } + + .sales-dialogs .sales-conversation { + display: none; + } + + .sales-dialogs.is-mobile-dialog .sales-dialog-list { + display: none; + } + + .sales-dialogs.is-mobile-dialog .sales-conversation { + display: flex; + } + + .sales-dialogs .sales-context { + width: min(320px, 100%); + } + + .sales-conversation-head .mobile-back { + display: flex; + } +} + +.sales-conversation-head .mobile-back { + display: none; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + flex: none; + border: 0; + border-radius: 8px; + color: var(--n-3); + background: transparent; +} + +.sales-conversation-head .mobile-back svg { + transform: rotate(180deg); +} + +/* Мобильная шапка списка (кадр M1). */ +.chat-mobile-header { + display: none; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 10px 14px 4px; +} + +.chat-mobile-brand { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; + border: 0; + padding: 0; + background: transparent; + color: var(--n-1); + font-size: 14.5px; + font-weight: 700; +} + +.chat-mobile-brand strong { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.chat-mobile-avatar { + border: 0; + padding: 0; + flex: none; + background: transparent; +} + +@media (max-width: 768px) { + .chat-mobile-header { + display: flex; + } +} diff --git a/apps/internal-ui/src/layout/ChatMobileHeader.tsx b/apps/internal-ui/src/layout/ChatMobileHeader.tsx new file mode 100644 index 0000000..8f3d8f8 --- /dev/null +++ b/apps/internal-ui/src/layout/ChatMobileHeader.tsx @@ -0,0 +1,33 @@ +import { Dropdown } from "antd"; +import { useState } from "react"; + +import type { RouteKey, SessionUser } from "../types"; +import { Icon, LogoIcon } from "../shared/icons"; +import { Avatar } from "../shared/ui"; +import { defaultRoute } from "../auth/access"; + +// Мобильная шапка экрана списка (дизайн-базлайн v2, кадр M1): логотип + +// организация слева, аватар с меню профиля справа. Видна только на ≤768px — +// сайдбар на мобильном чате скрыт. +export function ChatMobileHeader({ user, setRoute, onLogout }: { user: SessionUser; setRoute: (route: RouteKey) => void; onLogout: () => void }) { + const [open, setOpen] = useState(false); + const menuItems = [ + { key: "profile", label: }, + { key: "settings", label: }, + { type: "divider" as const }, + { key: "logout", label: }, + ]; + return ( +
+ + + + +
+ ); +} diff --git a/apps/internal-ui/src/layout/Shell.tsx b/apps/internal-ui/src/layout/Shell.tsx index bed23a0..f9fcd83 100644 --- a/apps/internal-ui/src/layout/Shell.tsx +++ b/apps/internal-ui/src/layout/Shell.tsx @@ -95,7 +95,7 @@ export function Shell({ route, setRoute, selectedEmployeeId, selectedProductCode const isKnowledgeLibrary = route === "aiKnowledge"; const isKnowledgeEditor = route === "aiKnowledgeCreate" || route === "aiKnowledgeDetail"; return ( -
+
{/* У сотрудника верхней панели нет (дизайн-базлайн v2 §4.1). */} diff --git a/apps/internal-ui/src/layout/ShellRouteContent.tsx b/apps/internal-ui/src/layout/ShellRouteContent.tsx index be362f6..c20d769 100644 --- a/apps/internal-ui/src/layout/ShellRouteContent.tsx +++ b/apps/internal-ui/src/layout/ShellRouteContent.tsx @@ -58,6 +58,8 @@ export function ShellRouteContent({ chatScope, setChatScope, chatCounters, chatS setScope={setChatScope} counters={chatCounters} showScopeSwitcher={chatScopeSwitcher} + setRoute={setRoute} + onLogout={onLogout} /> )} {route === "agents" && }