mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 09:14:58 +03:00
💄 feat(ui): мобильный чат — список ↔ диалог (кадры M1/M2, S1)
На ≤768px сайдбар в чате скрыт: экран списка с шапкой бренда (логотип, организация, меню профиля), лента открывается выбором диалога, назад — стрелкой в шапке. Контекст — по кнопке в шапке (drawer S2 на всю ширину). Пустой список — текст кадра S1. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
12107e02aa
commit
09e9819fdd
8 files changed
+154
-8
No files matched your search
@@ -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<ChatRightTab>("client");
|
||||
return (
|
||||
@@ -44,6 +50,7 @@ export function ChatPage({
|
||||
setScope={setScope}
|
||||
counters={counters}
|
||||
showScopeSwitcher={showScopeSwitcher}
|
||||
mobileHeader={<ChatMobileHeader user={user} setRoute={setRoute} onLogout={onLogout} />}
|
||||
renderContextPanel={({ dialog, detail, applyConversation }) => (
|
||||
<ChatContextPanel
|
||||
rightTab={rightTab}
|
||||
|
||||
@@ -15,7 +15,7 @@ function fmtTime(value: string): string {
|
||||
return new Date(value).toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
|
||||
export function ConversationThread({ controlMode, dialog, detail, isOwner = false, onClaim, onCall, onClose, onSpam, onReturnQueue, onArchive, onToggleContext }: { controlMode: ControlMode; dialog: ConversationListItem | null; detail: ApiConversation | null; isOwner?: boolean; onClaim: () => void; onCall: (kind: "AUDIO" | "VIDEO") => void; onClose: () => void; onSpam: () => Promise<boolean>; onReturnQueue: () => void; onArchive: () => Promise<boolean>; 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<boolean>; onReturnQueue: () => void; onArchive: () => Promise<boolean>; onToggleContext?: () => void; onMobileBack?: () => void }) {
|
||||
const timelineRef = useRef<HTMLDivElement>(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 (
|
||||
<>
|
||||
<div className="sales-conversation-head">
|
||||
{/* Кадр M2: на мобильном лента — отдельный экран, назад к списку. */}
|
||||
{onMobileBack && <button className="mobile-back" type="button" aria-label="К списку диалогов" onClick={onMobileBack}><Icon name="arrow" size={19} /></button>}
|
||||
<div className="sales-conversation-person">
|
||||
<ContactAvatar avatarUrl={dialog.avatarUrl} initials={dialog.initials} background={dialog.avatarBg} className="sales-conversation-avatar" />
|
||||
<div>
|
||||
|
||||
@@ -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<ListTab>("all");
|
||||
// Кадр S2: на ≤1024px контекст-панель — выдвижная поверх ленты.
|
||||
const [ctxOpen, setCtxOpen] = useState(false);
|
||||
// Кадры M1/M2: на ≤768px список и лента — отдельные экраны.
|
||||
const [mobileDialogOpen, setMobileDialogOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const [conversations, setConversations] = useState<ApiConversation[]>([]);
|
||||
const [listLoaded, setListLoaded] = useState(false);
|
||||
@@ -174,7 +177,7 @@ export function ConversationWorkspace({ isOwner = false, listTitle, searchPlaceh
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`sales-dialogs ${ctxOpen ? "is-ctx-open" : ""}`}>
|
||||
<div className={`sales-dialogs ${ctxOpen ? "is-ctx-open" : ""} ${mobileDialogOpen ? "is-mobile-dialog" : ""}`}>
|
||||
<DialogList
|
||||
scope={scope}
|
||||
counters={counters}
|
||||
@@ -190,10 +193,14 @@ export function ConversationWorkspace({ isOwner = false, listTitle, searchPlaceh
|
||||
errorText={listError}
|
||||
setSearch={setSearch}
|
||||
setListTab={setListTab}
|
||||
setSelectedId={setSelectedId}
|
||||
setSelectedId={(id) => {
|
||||
setSelectedId(id);
|
||||
setMobileDialogOpen(true);
|
||||
}}
|
||||
mobileHeader={mobileHeader}
|
||||
/>
|
||||
<section className="sales-conversation">
|
||||
<ConversationThread controlMode={controlMode} dialog={selectedDialog} detail={detail} isOwner={isOwner} onClaim={onClaim} onCall={(kind) => void callController.start(kind)} onClose={onClose} onSpam={onSpam} onReturnQueue={onReturnQueue} onArchive={onArchive} onToggleContext={() => setCtxOpen((open) => !open)} />
|
||||
<ConversationThread controlMode={controlMode} dialog={selectedDialog} detail={detail} isOwner={isOwner} onClaim={onClaim} onCall={(kind) => void callController.start(kind)} onClose={onClose} onSpam={onSpam} onReturnQueue={onReturnQueue} onArchive={onArchive} onToggleContext={() => setCtxOpen((open) => !open)} onMobileBack={() => setMobileDialogOpen(false)} />
|
||||
{(detailError || actionError) && <div className="sales-conversation-error">{detailError || actionError}</div>}
|
||||
<CallOverlay
|
||||
open={callController.open}
|
||||
|
||||
@@ -10,13 +10,14 @@ 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, showScopeSwitcher = true, dialogs, filtered, listTab, selectedId, search, errorText, setSearch, setListTab, setSelectedId }: {
|
||||
export function DialogList({ title = "Диалоги", searchPlaceholder = "Поиск по клиенту, продукту…", scope, counters, setScope, showScopeSwitcher = true, mobileHeader, dialogs, filtered, listTab, selectedId, search, errorText, setSearch, setListTab, setSelectedId }: {
|
||||
title?: string;
|
||||
searchPlaceholder?: string;
|
||||
scope: DialogScope;
|
||||
counters: ConversationCounters | null;
|
||||
setScope: (scope: DialogScope) => 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 (
|
||||
<section className="sales-dialog-list">
|
||||
{mobileHeader}
|
||||
<div className="sales-dialog-list-head">
|
||||
<div>
|
||||
{showScopeSwitcher
|
||||
@@ -46,6 +48,8 @@ export function DialogList({ title = "Диалоги", searchPlaceholder = "По
|
||||
</div>
|
||||
<div className="sales-dialog-list-body">
|
||||
{errorText && <div className="sales-wait-note sales-load-error">{errorText}</div>}
|
||||
{/* Кадр S1: пустой список без призыва к действию. */}
|
||||
{!errorText && dialogs.length === 0 && <div className="sales-wait-note">Диалоги появятся, когда клиенты напишут вашему агенту</div>}
|
||||
{filtered.map((dialog) => <DialogListItem dialog={dialog} active={dialog.id === selectedId} setSelectedId={setSelectedId} key={dialog.id} />)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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: <button type="button" onClick={() => setRoute("profile")}><Icon name="user" size={15} />Профиль</button> },
|
||||
{ key: "settings", label: <button type="button" onClick={() => setRoute("settings")}><Icon name="settings" size={15} />Настройки</button> },
|
||||
{ type: "divider" as const },
|
||||
{ key: "logout", label: <button type="button" className="danger" onClick={onLogout}><Icon name="logout" size={15} />Выйти</button> },
|
||||
];
|
||||
return (
|
||||
<div className="chat-mobile-header">
|
||||
<button className="chat-mobile-brand" type="button" onClick={() => setRoute(defaultRoute(user))}>
|
||||
<span className={`hub-brand-mark ${user.organizationLogoUrl ? "has-logo" : ""}`}>
|
||||
{user.organizationLogoUrl ? <img src={user.organizationLogoUrl} alt="" /> : <LogoIcon />}
|
||||
</span>
|
||||
<strong>{user.organizationName || "Chatbolls"}</strong>
|
||||
</button>
|
||||
<Dropdown menu={{ items: menuItems }} open={open} onOpenChange={setOpen} trigger={["click"]} placement="bottomRight" overlayClassName="app-dropdown is-wide">
|
||||
<button className="chat-mobile-avatar" type="button" aria-label="Меню пользователя"><Avatar user={user} /></button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -95,7 +95,7 @@ export function Shell({ route, setRoute, selectedEmployeeId, selectedProductCode
|
||||
const isKnowledgeLibrary = route === "aiKnowledge";
|
||||
const isKnowledgeEditor = route === "aiKnowledgeCreate" || route === "aiKnowledgeDetail";
|
||||
return (
|
||||
<div className="hub-shell">
|
||||
<div className={`hub-shell ${isDialogsWorkspace ? "is-chat-route" : ""}`}>
|
||||
<Sidebar route={route} user={user} setRoute={setRoute} onLogout={onLogout} waitingCount={waitingCount} chatScope={chatScope.scope} setChatScope={chatScope.setScope} chatCounters={chatScope.counters} />
|
||||
<div className="hub-main">
|
||||
{/* У сотрудника верхней панели нет (дизайн-базлайн v2 §4.1). */}
|
||||
|
||||
@@ -58,6 +58,8 @@ export function ShellRouteContent({ chatScope, setChatScope, chatCounters, chatS
|
||||
setScope={setChatScope}
|
||||
counters={chatCounters}
|
||||
showScopeSwitcher={chatScopeSwitcher}
|
||||
setRoute={setRoute}
|
||||
onLogout={onLogout}
|
||||
/>
|
||||
)}
|
||||
{route === "agents" && <AgentsPage agents={data.agents} groups={data.groups} reload={reload} openAgent={openAgent} />}
|
||||
|
||||
Reference in new issue
Block a user