💄 fix(ui): чат — адаптив S2/M1/M2, кадры S1 и G по дизайн-базлайну v2

- снят глобальный min-width 1024 (медиазапросы ≤768 не срабатывали); ограничение
  осталось только у страниц вне чата
- M1: мобильная шапка ☰ · «Все диалоги ▾» + «Организация · N диалогов» · аватар 32;
  ☰ открывает дерево «Диалоги» выезжающим сайдбаром (состояние поднято в Shell);
  строки 12/16, аватар 42, шрифты по макету
- M2: шапка ленты «назад · аватар 36 · имя/статус · контакт · ⋯», компактный
  композер (поле + кнопка-иконка 38 r10, плейсхолдер «Сообщение…»)
- S2: затемнение ленты при открытой панели, крестик в шапке панели, кнопка
  «Контакт» подсвечена только при открытой панели
- S1: пустой список с иконкой в круге 44; без выбранного диалога — только
  «Выберите диалог», без панели и композера
- бренд сайдбара: логотип + переключатель организации (153×32, шеврон 14);
  дерево показывает «Группы»/«Агенты» и без элементов
- G: меню полей блока «Диалог» — 283px, заголовок «Перенести в группу», галочка
  у выбранного, подпись «Диалог без группы видят все сотрудники»; все меню
  приложения по макету: 220px, пункты 33 r7, разделитель n-9, тень 0 6 20
- textarea/select наследуют шрифт (композер был моноширинным)
- backend: GET /conversations/directory/ — все группы и активные коллеги для
  оператора (сотруднику менеджерские списки закрыты, а переносить/назначать он
  может); чат грузит справочник для всех ролей

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
AndreyandClaude Fable 5.1 committed 2026-09-05 19:14:32 +03:00
1 parent 5dcc98d594
commit 54f19f783e
22 files changed
+619 -95

No files matched your search

@@ -25,6 +25,8 @@ from hub_platform.conversations.models import (
from hub_platform.conversations.selectors import apply_conversation_visibility
from hub_platform.conversations.serializers import conversation_payload
from hub_platform.conversations.view_base import ConversationViewBase
from hub_platform.identity.group_models import EmployeeGroup
from hub_platform.identity.models import OrganizationMembership
from hub_platform.identity.policy import can_administer_access
@@ -205,6 +207,34 @@ class ConversationCountersView(ConversationViewBase):
)
class ConversationDirectoryView(APIView):
"""Справочник блока «Диалог» для оператора (дизайн-базлайн v2, кадр G):
все группы организации — для переноса, активные коллеги — для назначения.
Доступен любому, кто видит чат: менеджерские списки сотрудников и групп
сотруднику закрыты, а переносить и назначать он может."""
permission_classes = [HasCapability]
required_capabilities = {"GET": "conversations.view"}
def get(self, request: Request) -> Response:
organization_id = request.tenant_context.organization_id
groups = EmployeeGroup.objects.filter(organization_id=organization_id).order_by("name")
members = (
OrganizationMembership.objects.select_related("user")
.filter(organization_id=organization_id, blocked_at__isnull=True, user__is_active=True)
.order_by("user__full_name", "user__email")
)
return Response(
{
"groups": [{"id": group.id, "name": group.name} for group in groups],
"employees": [
{"id": member.user_id, "name": member.user.full_name or member.user.email}
for member in members
],
}
)
class LabelListView(APIView):
"""Словарь меток организации. Создание — лёгкое действие оператора
(«+ Добавить» в карточке), правка/удаление — администратора."""
@@ -1,6 +1,7 @@
import json
from django.test import TestCase
from django.utils import timezone
from hub_platform.testing import TenantAPIClient as APIClient
from hub_platform.channels.models import Channel
@@ -205,6 +206,25 @@ class ListFilterTests(ChatExtrasTestCase):
class CountersTests(ChatExtrasTestCase):
def test_directory_lists_all_groups_and_colleagues_for_employee(self) -> None:
support = EmployeeGroup.objects.create(
organization=self.organization, name="Поддержка"
)
blocked = self._member("blocked@chat.test", EmployeeRole.EMPLOYEE)
blocked.blocked_at = timezone.now()
blocked.save(update_fields=["blocked_at"])
payload = self.client.get("/api/v1/conversations/directory/").json()
# Сотрудник видит все группы — иначе не перенести диалог (кадр G).
self.assertEqual(
[group["name"] for group in payload["groups"]], ["Операторы", "Поддержка"]
)
self.assertEqual(payload["groups"][1]["id"], support.id)
names = {employee["id"]: employee["name"] for employee in payload["employees"]}
self.assertIn(self.owner.user_id, names)
self.assertIn(self.employee.user_id, names)
self.assertNotIn(blocked.user_id, names)
def test_counters_reflect_visibility(self) -> None:
support = EmployeeGroup.objects.create(
organization=self.organization, name="Поддержка"
@@ -22,6 +22,7 @@ urlpatterns = [
path("<int:conversation_id>/labels/", chat_extras_views.ConversationLabelsView.as_view(), name="conversation-labels"),
path("<int:conversation_id>/archive/", chat_extras_views.ConversationArchiveView.as_view(), name="conversation-archive"),
path("counters/", chat_extras_views.ConversationCountersView.as_view(), name="conversation-counters"),
path("directory/", chat_extras_views.ConversationDirectoryView.as_view(), name="conversation-directory"),
path("labels/", chat_extras_views.LabelListView.as_view(), name="conversation-label-list"),
path("labels/<int:label_id>/", chat_extras_views.LabelDetailView.as_view(), name="conversation-label-detail"),
path("templates/", chat_extras_views.ReplyTemplateListView.as_view(), name="reply-template-list"),
+21 -12
View File
@@ -1,17 +1,18 @@
import { useState, type ReactNode } from "react";
import { useEffect, useState, type ReactNode } from "react";
import { ChatMobileHeader } from "../../layout/ChatMobileHeader";
import { Hint } from "../../shared/Hint";
import { Icon } from "../../shared/icons";
import { ConversationWorkspace, type DialogScope } from "../conversations/ConversationWorkspace";
import { DialogControls } from "../conversations/DialogControls";
import type { ApiConversation, ConversationCounters } from "../conversations/model";
import { fetchChatDirectory, type ApiConversation, type ChatDirectory, type 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, RouteKey, SessionUser } from "../../types";
import type { EmployeeGroupRef, RouteKey, SessionUser } from "../../types";
// Единый «Чат» (дизайн-базлайн v2 §8.1): один экран для всех диалогов
// организации. Контекст-панель сама выбирает представление по источнику
@@ -22,27 +23,31 @@ type ChatRightTab = "client" | "history";
export function ChatPage({
initialConversationId,
user,
groups = [],
employees = [],
scope,
setScope,
counters,
showScopeSwitcher,
setRoute,
onLogout,
onOpenMenu,
}: {
initialConversationId?: number | null;
user: SessionUser;
groups?: EmployeeGroup[];
employees?: Array<{ id: number; name: string }>;
scope: DialogScope;
setScope: (scope: DialogScope) => void;
counters: ConversationCounters | null;
showScopeSwitcher: boolean;
setRoute: (route: RouteKey) => void;
onLogout: () => void;
onOpenMenu: () => void;
}) {
const [rightTab, setRightTab] = useState<ChatRightTab>("client");
const [directory, setDirectory] = useState<ChatDirectory>({ groups: [], employees: [] });
useEffect(() => {
let cancelled = false;
fetchChatDirectory().then((payload) => { if (!cancelled) setDirectory(payload); }).catch(() => undefined);
return () => { cancelled = true; };
}, [user.organizationPublicId]);
return (
<ConversationWorkspace
isOwner={user.role === "OWNER"}
@@ -51,21 +56,22 @@ export function ChatPage({
setScope={setScope}
counters={counters}
showScopeSwitcher={showScopeSwitcher}
mobileHeader={<ChatMobileHeader user={user} setRoute={setRoute} onLogout={onLogout} />}
mobileHeader={({ total }) => <ChatMobileHeader user={user} scope={scope} total={total} setRoute={setRoute} onLogout={onLogout} onOpenMenu={onOpenMenu} />}
hint={showScopeSwitcher
? <Hint id="chat-visibility">Вы видите все диалоги организации. Сотрудники видят только диалоги своих групп, без группы и те, где они ответственные.</Hint>
: undefined}
viewerId={user.id}
renderContextPanel={({ dialog, detail, applyConversation, startCall }) => (
renderContextPanel={({ dialog, detail, applyConversation, startCall, closeContext }) => (
<ChatContextPanel
rightTab={rightTab}
setRightTab={setRightTab}
dialog={dialog}
detail={detail}
groups={groups}
employees={employees}
groups={directory.groups}
employees={directory.employees}
applyConversation={applyConversation}
startCall={startCall}
closeContext={closeContext}
viewerId={user.id}
/>
)}
@@ -82,16 +88,18 @@ function ChatContextPanel({
employees,
applyConversation,
startCall,
closeContext,
viewerId,
}: {
rightTab: ChatRightTab;
setRightTab: (tab: ChatRightTab) => void;
dialog: ConversationListItem | null;
detail: ApiConversation | null;
groups: EmployeeGroup[];
groups: EmployeeGroupRef[];
employees: Array<{ id: number; name: string }>;
applyConversation: (updated: ApiConversation) => void;
startCall: ((kind: "AUDIO" | "VIDEO") => void) | null;
closeContext: () => void;
viewerId: number;
}) {
const isSupport = Boolean(detail?.supportIdentitySnapshot);
@@ -100,6 +108,7 @@ function ChatContextPanel({
<div className="sales-context-tabs">
<RightTabButton active={rightTab === "client"} onClick={() => setRightTab("client")}>Контакт</RightTabButton>
<RightTabButton active={rightTab === "history"} onClick={() => setRightTab("history")}>История</RightTabButton>
<button className="ctx-close" type="button" aria-label="Закрыть панель" onClick={closeContext}><Icon name="xCircle" size={16} /></button>
</div>
<div className="sales-context-body">
{rightTab === "client" && (
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { Icon } from "../../shared/icons";
import { useMediaQuery } from "../../shared/useMediaQuery";
import { fetchReplyTemplates, sendOperatorMessage, sendVoiceMessage, type ReplyTemplateRef } from "./model";
import { formatDuration } from "./VoiceMessage";
import { useVoiceRecorder } from "./useVoiceRecorder";
@@ -8,6 +9,7 @@ import type { ChannelKey, ControlMode } from "./types";
export function Composer({ mode, loaded, assignedOperatorName, conversationId, channel, onClaim, onRelease, onReturnQueue, onClose, onSent }: { mode: ControlMode; loaded: boolean; assignedOperatorName?: string; conversationId: number | null; channel?: ChannelKey; onClaim: () => void; onRelease: () => void; onReturnQueue: () => void; onClose: () => void; onSent: () => void }) {
const [text, setText] = useState("");
const compact = useMediaQuery("(max-width: 768px)");
const [sending, setSending] = useState(false);
const [sendError, setSendError] = useState("");
const [templates, setTemplates] = useState<ReplyTemplateRef[]>([]);
@@ -137,7 +139,7 @@ export function Composer({ mode, loaded, assignedOperatorName, conversationId, c
<textarea
ref={textareaRef}
rows={1}
placeholder="Введите сообщение… Shift+Enter — перенос строки, «/» — шаблон ответа"
placeholder={compact ? "Сообщение…" : "Введите сообщение… Shift+Enter — перенос строки, «/» — шаблон ответа"}
value={text}
onChange={(event) => setText(event.target.value)}
onKeyDown={(event) => {
@@ -162,7 +164,7 @@ export function Composer({ mode, loaded, assignedOperatorName, conversationId, c
</button>
)}
<span className="composer-spacer" />
<button className="composer-send" type="button" onClick={() => void send()} disabled={sending}>Отправить<kbd>⏎</kbd></button>
<button className="composer-send" type="button" onClick={() => void send()} disabled={sending}><span>Отправить</span><kbd>⏎</kbd><Icon name="send" size={17} /></button>
</div>
</div>
</div>
@@ -49,9 +49,9 @@ export function ConversationWorkspace({ isOwner = false, viewerId = null, listTi
isOwner?: boolean;
listTitle?: string;
searchPlaceholder?: string;
renderContextPanel: (ctx: { dialog: ConversationListItem | null; detail: ApiConversation | null; applyConversation: (updated: ApiConversation) => void; startCall: ((kind: "AUDIO" | "VIDEO") => void) | null }) => ReactNode;
renderContextPanel: (ctx: { dialog: ConversationListItem | null; detail: ApiConversation | null; applyConversation: (updated: ApiConversation) => void; startCall: ((kind: "AUDIO" | "VIDEO") => void) | null; closeContext: () => void }) => ReactNode;
viewerId?: number | null;
mobileHeader?: ReactNode;
mobileHeader?: (info: { total: number }) => ReactNode;
hint?: ReactNode;
initialConversationId?: number | null;
// Охват (дерево фильтров) живёт в Shell: у сотрудника им управляет сайдбар,
@@ -251,7 +251,12 @@ export function ConversationWorkspace({ isOwner = false, viewerId = null, listTi
hint={hint}
searchRef={searchRef}
/>
{!selectedDialog && (
<section className="sales-conversation"><div className="sales-conversation-empty">Выберите диалог</div></section>
)}
{selectedDialog && (
<section className="sales-conversation">
{ctxOpen && <button className="ctx-backdrop" type="button" aria-label="Закрыть панель" onClick={() => setCtxOpen(false)} />}
<ConversationThread controlMode={controlMode} dialog={selectedDialog} detail={detail} isOwner={isOwner} onExpandList={listCollapsed ? () => setListCollapsed(false) : undefined} viewerId={viewerId} onClaim={onClaim} onRelease={onRelease} 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
@@ -279,12 +284,15 @@ export function ConversationWorkspace({ isOwner = false, viewerId = null, listTi
onSent={() => selectedId != null && loadDetail(selectedId)}
/>
</section>
{renderContextPanel({
)}
{selectedDialog && renderContextPanel({
dialog: selectedDialog,
detail,
applyConversation: applyUpdated,
// Звонки — в карточке контакта (решение 5); почта звонков не поддерживает.
startCall: detail?.lifecycle === "OPEN" && selectedDialog && selectedDialog.channel !== "EMAIL" ? (kind) => void callController.start(kind) : null,
// Кадр S2: выдвижная панель закрывается крестиком в её шапке.
closeContext: () => setCtxOpen(false),
})}
</div>
);
@@ -129,13 +129,13 @@ export function DialogControls({
<Dropdown
disabled={busy || employees.length === 0}
trigger={["click"]}
overlayClassName="app-dropdown is-wide"
overlayClassName="app-dropdown ctx-menu"
menu={{
items: [
{ key: "none", label: <button type="button" className={assignee ? "" : "is-checked"} onClick={() => void run(() => setConversationAssignee(detail.id, null))}><span className="ctx-avatar-empty" />Не назначен</button> },
{ key: "none", label: <button type="button" className={assignee ? "" : "is-checked"} onClick={() => void run(() => setConversationAssignee(detail.id, null))}><span className="ctx-avatar-empty" /><span>Не назначен</span>{!assignee && <Icon name="check" size={15} />}</button> },
...employees.map((employee) => ({
key: employee.id,
label: <button type="button" className={assignee?.id === employee.id ? "is-checked" : ""} onClick={() => void run(() => setConversationAssignee(detail.id, employee.id))}><span className="ctx-avatar-small">{initials(employee.name)}</span>{employee.name}{viewerId === employee.id ? " · вы" : ""}</button>,
label: <button type="button" className={assignee?.id === employee.id ? "is-checked" : ""} onClick={() => void run(() => setConversationAssignee(detail.id, employee.id))}><span className="ctx-avatar-small">{initials(employee.name)}</span><span>{employee.name}{viewerId === employee.id ? " · вы" : ""}</span>{assignee?.id === employee.id && <Icon name="check" size={15} />}</button>,
})),
],
}}
@@ -151,14 +151,17 @@ export function DialogControls({
<Dropdown
disabled={busy || groups.length === 0}
trigger={["click"]}
overlayClassName="app-dropdown is-wide"
overlayClassName="app-dropdown ctx-menu"
menu={{
// Кадр G: заголовок «Перенести в группу», отмеченный пункт с галочкой, подпись внизу.
items: [
{ key: "none", label: <button type="button" className={detail.group ? "" : "is-checked"} onClick={() => void run(() => setConversationGroup(detail.id, null))}><i className="ctx-dot is-muted" />Без группы<small>видят все сотрудники</small></button> },
{ key: "title", type: "group" as const, label: "Перенести в группу" },
{ key: "none", label: <button type="button" className={detail.group ? "" : "is-checked"} onClick={() => void run(() => setConversationGroup(detail.id, null))}><i className="ctx-dot is-muted" /><span>Без группы</span>{!detail.group && <Icon name="check" size={15} />}</button> },
...groups.map((group) => ({
key: group.id,
label: <button type="button" className={detail.group?.id === group.id ? "is-checked" : ""} onClick={() => void run(() => setConversationGroup(detail.id, group.id))}><i className="ctx-dot" style={{ background: groupColorOf(group.id) }} />{group.name}</button>,
label: <button type="button" className={detail.group?.id === group.id ? "is-checked" : ""} onClick={() => void run(() => setConversationGroup(detail.id, group.id))}><i className="ctx-dot" style={{ background: groupColorOf(group.id) }} /><span>{group.name}</span>{detail.group?.id === group.id && <Icon name="check" size={15} />}</button>,
})),
{ key: "note", type: "group" as const, className: "ctx-menu-note", label: "Диалог без группы видят все сотрудники." },
],
}}
>
@@ -173,11 +176,11 @@ export function DialogControls({
<Dropdown
disabled={busy}
trigger={["click"]}
overlayClassName="app-dropdown is-wide"
overlayClassName="app-dropdown ctx-menu"
menu={{
items: PRIORITY_OPTIONS.map(([value, label]) => ({
key: value,
label: <button type="button" className={detail.priority === value ? "is-checked" : ""} onClick={() => void run(() => setConversationPriority(detail.id, value))}><PriorityBars priority={value} placeholder />{label}</button>,
label: <button type="button" className={detail.priority === value ? "is-checked" : ""} onClick={() => void run(() => setConversationPriority(detail.id, value))}><PriorityBars priority={value} placeholder /><span>{label}</span>{detail.priority === value && <Icon name="check" size={15} />}</button>,
})),
}}
>
@@ -22,7 +22,7 @@ export function DialogList({ title = "Диалоги", searchPlaceholder = "По
counters: ConversationCounters | null;
setScope: (scope: DialogScope) => void;
showScopeSwitcher?: boolean;
mobileHeader?: ReactNode;
mobileHeader?: (info: { total: number }) => ReactNode;
hint?: ReactNode;
searchRef?: RefObject<HTMLInputElement | null>;
dialogs: ConversationListItem[];
@@ -38,7 +38,7 @@ export function DialogList({ title = "Диалоги", searchPlaceholder = "По
const waitCount = dialogs.filter((dialog) => dialog.mode === "wait").length;
return (
<section className="sales-dialog-list">
{mobileHeader}
{mobileHeader?.({ total: dialogs.length })}
<div className="sales-dialog-list-head">
<div>
{showScopeSwitcher
@@ -72,7 +72,9 @@ export function DialogList({ title = "Диалоги", searchPlaceholder = "По
<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>}
{!errorText && dialogs.length === 0 && (
<div className="sales-dialog-list-empty"><span><Icon name="message" size={20} /></span><p>Диалоги появятся, когда клиенты напишут вашему агенту</p></div>
)}
{filtered.map((dialog) => <DialogListItem dialog={dialog} active={dialog.id === selectedId} setSelectedId={setSelectedId} key={dialog.id} />)}
</div>
</section>
@@ -252,6 +252,15 @@ export const fetchWaitingCount = () => api<{ waiting: number }>("/api/v1/convers
// --- Дизайн-базлайн v2: карточка «Диалог», метки, шаблоны, счётчики ---
// Справочник блока «Диалог» (кадр G): все группы для переноса и коллеги для
// назначения — доступен и сотруднику, у которого нет менеджерских списков.
export type ChatDirectory = {
groups: Array<{ id: number; name: string }>;
employees: Array<{ id: number; name: string }>;
};
export const fetchChatDirectory = () => api<ChatDirectory>("/api/v1/conversations/directory/");
const conversationAction = (id: number, suffix: string, body: object) =>
api<{ conversation: ApiConversation }>(`/api/v1/conversations/${id}/${suffix}/`, {
method: "POST",
@@ -121,6 +121,11 @@
opacity: 0.75;
}
/* Иконка отправки — только в мобильном композере (кадр M2). */
.composer-send svg {
display: none;
}
/* Кадры D, E: композер заменён карточкой-сообщением. */
.composer-locked {
max-width: 760px;
@@ -33,6 +33,29 @@
font-weight: 600;
}
/* Крестик выдвижной панели (кадр S2) — только на ≤1024px. */
.sales-context-tabs button.ctx-close {
display: none;
flex: none;
width: 36px;
align-items: center;
justify-content: center;
padding: 0;
margin-bottom: 0;
color: var(--n-4);
border-bottom: 0;
}
.sales-context-tabs button.ctx-close:hover {
color: var(--n-2);
}
@media (max-width: 1024px) {
.sales-context-tabs button.ctx-close {
display: flex;
}
}
.sales-context-body {
flex: 1;
min-height: 0;
@@ -1133,6 +1156,54 @@
.app-dropdown .ant-dropdown-menu-item button.is-checked {
color: var(--primary-text);
background: var(--primary-bg);
font-weight: 600;
}
/* Меню полей блока «Диалог» (кадр G): 283px, пункты 33px r7, заголовок капсом,
галочка у выбранного, подпись под списком. */
.app-dropdown.ctx-menu .ant-dropdown-menu {
width: 283px;
}
.app-dropdown.ctx-menu .ant-dropdown-menu-item button > span:not(.ctx-avatar-small):not(.ctx-avatar-empty):not(.sales-priority-bars) {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.app-dropdown.ctx-menu .ant-dropdown-menu-item button > svg {
flex: none;
}
.app-dropdown.ctx-menu .ant-dropdown-menu-item-group-title {
padding: 6px 10px 4px;
color: var(--n-5);
font-size: 11px;
font-weight: 600;
line-height: 15px;
letter-spacing: 0.02em;
text-transform: uppercase;
}
.app-dropdown.ctx-menu .ant-dropdown-menu-item-group-list {
margin: 0;
}
.app-dropdown.ctx-menu .ctx-menu-note {
margin-top: 4px;
border-top: 1px solid var(--n-9);
}
.app-dropdown.ctx-menu .ctx-menu-note .ant-dropdown-menu-item-group-title {
padding: 8px 10px 6px;
color: var(--n-4);
font-size: 11.5px;
font-weight: 400;
line-height: 17px;
letter-spacing: 0;
text-transform: none;
}
@@ -232,6 +232,16 @@
font-weight: 500;
}
/* Кадр S1: ничего не выбрано. */
.sales-conversation-empty {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
color: var(--n-5);
font-size: 13px;
}
.sales-wait-note {
margin: -8px 0 8px;
color: var(--n-5);
@@ -32,11 +32,29 @@
.sales-conversation-actions .ctx-toggle {
display: inline-flex;
}
.sales-dialogs.is-ctx-open .sales-conversation-actions .ctx-toggle {
color: var(--primary-text);
background: var(--primary-bg);
border-color: var(--primary-border);
}
/* Кадр S2: при открытой панели лента затемняется, клик по ней закрывает панель. */
.sales-dialogs .ctx-backdrop {
display: none;
position: absolute;
inset: 0;
z-index: 3;
border: 0;
padding: 0;
background: rgba(0, 0, 0, 0.18);
}
.sales-dialogs.is-ctx-open .ctx-backdrop {
display: block;
}
.sales-dialogs .sales-context {
display: none;
position: absolute;
@@ -56,10 +74,188 @@
/* Кадры M1/M2 (≤768px): список и лента — отдельные экраны; сайдбар скрыт,
шапка бренда живёт в списке. */
@media (max-width: 768px) {
/* Сайдбар на мобильном чате — выезжающее меню (☰ в шапке списка): сама
колонка схлопнута, раскрытое тело сайдбара и подложка — fixed. */
.hub-shell.is-chat-route .hub-sidebar {
width: 0;
border-right: 0;
overflow: visible;
}
.hub-shell.is-chat-route .hub-sidebar .hub-rail {
display: none;
}
.hub-shell.is-chat-route .hub-sidebar.is-rail-expanded .hub-sidebar-body {
width: 280px;
}
.sales-dialogs .sales-dialog-list-head > div {
display: none;
}
.sales-dialogs .sales-dialog-list-head {
padding: 0 16px 10px;
}
.sales-dialogs .sales-dialog-search {
height: 36px;
border-radius: 9px;
}
.sales-dialogs .sales-dialog-search kbd {
display: none;
}
.sales-dialogs .sales-dialog-search input {
font-size: 13.5px;
}
.sales-dialogs .sales-dialog-tabs button {
padding: 10px 9px 9px;
}
.sales-dialogs .sales-dialog-row {
gap: 11px;
padding: 12px 16px;
}
.sales-dialogs .sales-dialog-avatar {
width: 42px;
height: 42px;
font-size: 14px;
}
.sales-dialogs .sales-dialog-row-main {
gap: 3px;
}
.sales-dialogs .sales-dialog-row-title strong {
font-size: 14.5px;
}
.sales-dialogs .sales-dialog-row-title em,
.sales-dialogs .sales-dialog-row-agent small {
font-size: 11.5px;
}
.sales-dialogs .sales-dialog-row-preview small {
font-size: 13px;
font-weight: 500;
}
/* Кадр M2: шапка ленты — назад · аватар 36 · имя/статус · контакт · ⋯. */
.sales-dialogs .sales-conversation-head {
gap: 10px;
padding: 10px 12px 10px 8px;
}
.sales-dialogs .sales-conversation-person {
flex: 1;
min-width: 0;
gap: 10px;
}
.sales-dialogs .sales-conversation-avatar {
width: 36px;
height: 36px;
font-size: 13px;
}
.sales-dialogs .sales-conversation-avatar-wrap i {
display: none;
}
.sales-dialogs .sales-conversation-person strong {
font-size: 14.5px;
}
.sales-dialogs .sales-conversation-person p {
font-size: 11.5px;
}
.sales-dialogs .sales-conversation-person p > span:not(.is-status),
.sales-dialogs .sales-conversation-person p > i {
display: none;
}
.sales-dialogs .sales-conversation-actions {
gap: 10px;
}
.sales-dialogs .sales-conversation-actions .sales-claim-button,
.sales-dialogs .sales-conversation-actions .sales-secondary-action {
display: none;
}
.sales-dialogs .sales-conversation-actions .ctx-toggle,
.sales-dialogs .sales-conversation-actions .sales-more-button {
width: 36px;
height: 36px;
border-radius: 8px;
color: var(--n-3);
background: var(--surface-card);
border: 1px solid var(--n-8);
}
.sales-dialogs.is-ctx-open .sales-conversation-actions .ctx-toggle {
color: var(--primary-text);
background: var(--primary-bg);
border-color: var(--primary-border);
}
.sales-dialogs .sales-timeline {
padding: 16px 14px 8px;
}
/* Кадр M2: компактный композер — поле и круглая кнопка отправки в одной строке. */
.sales-dialogs .sales-composer {
padding: 8px 12px 14px;
}
.sales-dialogs .composer-warning {
margin: 0 6px 6px;
}
.sales-dialogs .composer-box {
display: flex;
align-items: flex-end;
gap: 8px;
padding: 6px 6px 6px 14px;
border-radius: 14px;
}
.sales-dialogs .composer-box textarea {
flex: 1;
min-height: 35px;
padding: 8px 0;
font-size: 14px;
}
.sales-dialogs .composer-toolbar {
padding: 0;
gap: 0;
}
.sales-dialogs .composer-tool,
.sales-dialogs .composer-spacer,
.sales-dialogs .composer-send > span,
.sales-dialogs .composer-send kbd {
display: none;
}
.sales-dialogs .composer-send {
width: 38px;
height: 38px;
padding: 0;
justify-content: center;
border-radius: 10px;
}
.sales-dialogs .composer-send svg {
display: block;
}
.sales-dialogs .sales-dialog-list {
width: 100%;
border-right: 0;
@@ -81,7 +277,10 @@
width: min(320px, 100%);
}
.sales-conversation-head .mobile-back {
}
@media (max-width: 768px) {
.sales-dialogs .sales-conversation-head .mobile-back {
display: flex;
}
}
@@ -89,6 +288,7 @@
.sales-conversation-head .mobile-back {
display: none;
align-items: center;
margin-right: -2px;
justify-content: center;
width: 36px;
height: 36px;
@@ -103,34 +303,74 @@
transform: rotate(180deg);
}
/* Мобильная шапка списка (кадр M1). */
/* Мобильная шапка списка (кадр M1): ☰ · заголовок охвата с подписью · аватар. */
.chat-mobile-header {
display: none;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 10px 14px 4px;
height: 56px;
padding: 12px 12px 8px 8px;
}
.chat-mobile-brand {
.chat-mobile-menu {
width: 36px;
height: 36px;
display: flex;
align-items: center;
gap: 10px;
justify-content: center;
flex: none;
border: 0;
border-radius: 8px;
padding: 0;
color: var(--n-3);
background: transparent;
}
.chat-mobile-menu:hover {
background: var(--n-9);
}
.chat-mobile-title {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 1px;
border: 0;
padding: 0;
background: transparent;
text-align: left;
}
.chat-mobile-title strong {
max-width: 100%;
display: flex;
align-items: center;
gap: 5px;
color: var(--n-1);
font-size: 14.5px;
font-weight: 700;
}
.chat-mobile-brand strong {
.chat-mobile-title strong span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chat-mobile-title strong svg {
flex: none;
color: var(--n-4);
}
.chat-mobile-title small {
color: var(--n-4);
font-size: 11px;
font-weight: 400;
white-space: nowrap;
}
.chat-mobile-avatar {
border: 0;
padding: 0;
@@ -138,6 +378,12 @@
background: transparent;
}
.chat-mobile-avatar .avatar {
width: 32px;
height: 32px;
font-size: 11px;
}
@media (max-width: 768px) {
.chat-mobile-header {
display: flex;
@@ -116,6 +116,7 @@
margin-bottom: -1px;
font-size: 12.5px;
font-weight: 500;
line-height: 14px;
white-space: nowrap;
}
@@ -146,6 +147,32 @@
}
/* Строка диалога (решение 4). Активная — фон n9, без акцентной полосы. */
/* Кадр S1: пустой список без призыва к действию. */
.sales-dialog-list-empty {
padding: 64px 28px;
text-align: center;
}
.sales-dialog-list-empty span {
width: 44px;
height: 44px;
display: inline-flex;
align-items: center;
justify-content: center;
margin-bottom: 12px;
color: var(--n-5);
background: var(--n-10);
border-radius: 50%;
}
.sales-dialog-list-empty p {
margin: 0;
color: var(--n-3);
font-size: 13.5px;
font-weight: 600;
line-height: 1.45;
}
.sales-dialog-row {
position: relative;
width: 100%;
@@ -2,14 +2,23 @@ import { Dropdown } from "antd";
import { useState } from "react";
import type { RouteKey, SessionUser } from "../types";
import { Icon, LogoIcon } from "../shared/icons";
import { Icon } from "../shared/icons";
import { Avatar } from "../shared/ui";
import { defaultRoute } from "../auth/access";
import { scopeLabel, type DialogScope } from "../features/conversations/ConversationWorkspace";
// Мобильная шапка экрана списка (дизайн-базлайн v2, кадр M1): логотип +
// организация слева, аватар с меню профиля справа. Видна только на ≤768px —
// сайдбар на мобильном чате скрыт.
export function ChatMobileHeader({ user, setRoute, onLogout }: { user: SessionUser; setRoute: (route: RouteKey) => void; onLogout: () => void }) {
// Мобильная шапка экрана списка (дизайн-базлайн v2, кадр M1): ☰ и заголовок
// охвата открывают дерево «Диалоги» выезжающим меню; справа — аватар с меню
// профиля. Видна только на ≤768px.
function pluralDialogs(count: number): string {
const mod10 = count % 10;
const mod100 = count % 100;
if (mod10 === 1 && mod100 !== 11) return `${count} диалог`;
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 >= 20)) return `${count} диалога`;
return `${count} диалогов`;
}
export function ChatMobileHeader({ user, scope, total, setRoute, onLogout, onOpenMenu }: { user: SessionUser; scope: DialogScope; total: number; setRoute: (route: RouteKey) => void; onLogout: () => void; onOpenMenu: () => void }) {
const [open, setOpen] = useState(false);
const menuItems = [
{ key: "profile", label: <button type="button" onClick={() => setRoute("profile")}><Icon name="user" size={15} />Профиль</button> },
@@ -17,13 +26,13 @@ export function ChatMobileHeader({ user, setRoute, onLogout }: { user: SessionUs
{ type: "divider" as const },
{ key: "logout", label: <button type="button" className="danger" onClick={onLogout}><Icon name="logout" size={15} />Выйти</button> },
];
const title = scope.kind === "all" ? "Все диалоги" : scopeLabel(scope);
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 className="chat-mobile-menu" type="button" aria-label="Меню" onClick={onOpenMenu}><Icon name="list" size={20} /></button>
<button className="chat-mobile-title" type="button" onClick={onOpenMenu}>
<strong><span>{title}</span><Icon name="chevron" size={14} /></strong>
<small>{user.organizationName || "Chatbolls"} · {pluralDialogs(total)}</small>
</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>
+4 -2
View File
@@ -18,6 +18,8 @@ export function Shell({ route, setRoute, selectedEmployeeId, selectedProductCode
const [unreadCount, setUnreadCount] = useState(0);
const [notifOpen, setNotifOpen] = useState(false);
const [waitingCount, setWaitingCount] = useState(0);
// Кадры S2/M1: рейка или ☰ раскрывают сайдбар поверх контента.
const [sidebarExpanded, setSidebarExpanded] = useState(false);
const prevUnread = useRef<number | null>(null);
const manager = isManager(user);
// Охват чата живёт здесь: сотрудницкий сайдбар и страница чата делят одно
@@ -96,7 +98,7 @@ export function Shell({ route, setRoute, selectedEmployeeId, selectedProductCode
const isKnowledgeEditor = route === "aiKnowledgeCreate" || route === "aiKnowledgeDetail";
return (
<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} unreadCount={unreadCount} onOpenNotifications={() => { setNotifOpen(true); void loadNotifications(); }} />
<Sidebar route={route} user={user} setRoute={setRoute} onLogout={onLogout} waitingCount={waitingCount} chatScope={chatScope.scope} setChatScope={chatScope.setScope} chatCounters={chatScope.counters} unreadCount={unreadCount} onOpenNotifications={() => { setNotifOpen(true); void loadNotifications(); }} expanded={sidebarExpanded} setExpanded={setSidebarExpanded} />
<div className="hub-main">
{/* У сотрудника верхней панели нет (дизайн-базлайн v2 §4.1). */}
{/* Дизайн-базлайн v2 (A1/A2): верхней панели в чате нет и у менеджера;
@@ -104,7 +106,7 @@ export function Shell({ route, setRoute, selectedEmployeeId, selectedProductCode
{manager && !isDialogsWorkspace && <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 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} />
<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} onOpenSidebar={() => setSidebarExpanded(true)} />
</div>
</main>
</div>
@@ -25,7 +25,7 @@ const SupportPortalDetailPage = lazy(() => import("../features/support-portals/S
(module) => ({ default: module.SupportPortalDetailPage }),
));
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 }) {
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, onOpenSidebar }: { 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; onOpenSidebar: () => void }) {
return (
<>
{route === "employees" && <EmployeesPage groups={data.groups} employees={data.employees} reload={reload} openEmployee={openEmployee} setRoute={setRoute} user={user} />}
@@ -46,14 +46,13 @@ export function ShellRouteContent({ chatScope, setChatScope, chatCounters, chatS
<ChatPage
initialConversationId={selectedConversationId}
user={user}
groups={data.groups}
employees={dialogAssignees(data)}
scope={chatScope}
setScope={setChatScope}
counters={chatCounters}
showScopeSwitcher={chatScopeSwitcher}
setRoute={setRoute}
onLogout={onLogout}
onOpenMenu={onOpenSidebar}
/>
)}
{route === "agents" && <AgentsPage agents={data.agents} groups={data.groups} reload={reload} openAgent={openAgent} />}
@@ -80,9 +79,3 @@ export function ShellRouteContent({ chatScope, setChatScope, chatCounters, chatS
}
// Кандидаты в «Ответственные»: id сотрудника в employee-API — это id пользователя.
function dialogAssignees(data: AppData): Array<{ id: number; name: string }> {
return data.employees.map((employee) => ({
id: employee.id,
name: employee.fullName || employee.email,
}));
}
+37 -19
View File
@@ -1,4 +1,4 @@
import { useState } from "react";
import { Dropdown } from "antd";
import type { RouteKey, SessionUser } from "../types";
import { Icon, LogoIcon } from "../shared/icons";
@@ -55,6 +55,8 @@ export function Sidebar({
chatCounters,
unreadCount = 0,
onOpenNotifications,
expanded: railExpanded,
setExpanded: setRailExpanded,
}: {
route: RouteKey;
user: SessionUser;
@@ -66,11 +68,13 @@ export function Sidebar({
chatCounters: ConversationCounters | null;
unreadCount?: number;
onOpenNotifications?: () => void;
expanded: boolean;
setExpanded: (expanded: boolean) => void;
}) {
const manager = isManager(user);
// Кадр S2: на ≤1024px сайдбар сжимается в рейку 60px; «развернуть»
// раскрывает полный сайдбар поверх контента.
const [railExpanded, setRailExpanded] = useState(false);
// раскрывает полный сайдбар поверх контента (состояние — в Shell, его же
// открывает ☰ мобильной шапки чата, кадр M1).
const sectionStorageKey = (section: string) => (
`chatbolls.sidebar.${user.organizationPublicId}.${section}.expanded`
);
@@ -105,14 +109,26 @@ export function Sidebar({
</div>
{railExpanded && <button className="hub-rail-backdrop" aria-label="Свернуть меню" type="button" onClick={() => setRailExpanded(false)} />}
<div className="hub-sidebar-body" onClick={() => setRailExpanded(false)}>
<button className="hub-brand" type="button" onClick={() => setRoute(defaultRoute(user))}>
<div className={`hub-brand-mark ${user.organizationLogoUrl ? "has-logo" : ""}`}>
<div className="hub-brand">
<button className={`hub-brand-mark ${user.organizationLogoUrl ? "has-logo" : ""}`} type="button" aria-label="На главную" onClick={() => setRoute(defaultRoute(user))}>
{user.organizationLogoUrl
? <img src={user.organizationLogoUrl} alt="" />
: <LogoIcon />}
</div>
<strong>{user.organizationName || "Chatbolls"}</strong>
</button>
</button>
{/* Переключатель организации (дизайн-базлайн v2, A1): пока у пользователя
одна организация — в списке она одна, отмечена. */}
<Dropdown
trigger={["click"]}
placement="bottomLeft"
overlayClassName="app-dropdown is-wide"
menu={{ items: [{ key: "current", label: <button type="button" className="is-checked"><Icon name="building" size={15} />{user.organizationName || "Chatbolls"}</button> }] }}
>
<button className="hub-brand-switch" type="button">
<span>{user.organizationName || "Chatbolls"}</span>
<Icon name="chevron" size={14} />
</button>
</Dropdown>
</div>
{manager ? (
<nav className="hub-nav">
<SidebarLink icon="message" label="Чат" badge={waitingCount > 0 ? String(waitingCount) : undefined} route={route} routeKey="chat" setRoute={setRoute} />
@@ -181,7 +197,7 @@ function ChatScopeTree({
<span>Все диалоги</span>
{counters && <small>{counters.all}</small>}
</button>
{counters && counters.groups.length > 0 && (
{counters && (
<>
<div className="chat-scope-section"><Icon name="team" size={15} /><span>Группы</span></div>
{counters.groups.map((group) => (
@@ -196,18 +212,20 @@ function ChatScopeTree({
<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.groups.length > 0 && (
<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>
<small>{counters.ungrouped}</small>
</button>
)}
</>
)}
{counters && counters.agents.length > 0 && (
{counters && (
<>
<div className="chat-scope-section"><Icon name="robot" size={15} /><span>Агенты</span></div>
{counters.agents.map((agent) => (
+45 -14
View File
@@ -5,6 +5,12 @@
background: var(--n-11);
}
/* Страницы вне чата не адаптированы под мобильный — держат десктопную ширину.
Чат (кадры M1/M2) адаптивен и ограничение снимает. */
.hub-shell:not(.is-chat-route) {
min-width: 1024px;
}
/* Сайдбар 220px (дизайн-базлайн v2, решение 1): фон surface2, без верхней панели. */
.hub-sidebar {
width: 220px;
@@ -124,15 +130,48 @@
flex: none;
gap: 10px;
padding: 14px 14px 10px;
}
.hub-brand-mark {
border: 0;
padding: 0;
}
/* Переключатель организации (A1): имя + шеврон, 32px. */
.hub-brand-switch {
flex: 1;
min-width: 0;
height: 32px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
padding: 0 6px 0 8px;
border: 0;
border-radius: 8px;
background: transparent;
border: none;
color: var(--n-1);
font-size: 14px;
font-weight: 700;
text-align: left;
}
.hub-brand:hover {
.hub-brand-switch:hover {
background: var(--n-9);
}
.hub-brand-switch span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.hub-brand-switch svg {
flex: none;
color: var(--n-4);
}
.hub-brand-mark {
width: 28px;
height: 28px;
@@ -159,18 +198,6 @@
object-fit: contain;
}
.hub-brand strong {
display: block;
min-width: 0;
overflow: hidden;
color: var(--n-1);
font-size: 14px;
font-weight: 700;
line-height: 1.35;
text-overflow: ellipsis;
white-space: nowrap;
}
.profile-link strong {
display: block;
overflow: hidden;
@@ -342,6 +369,10 @@
}
/* Дерево «Диалоги» сотрудника (дизайн-базлайн v2 §3): сайдбар = фильтр чата. */
.chat-scope-tree {
padding-bottom: 12px;
}
.chat-scope-head {
display: flex;
align-items: center;
+13 -3
View File
@@ -5,11 +5,20 @@
больше нет — портал antd сам закрывается по клику вне и по Esc и не
обрезается overflow-контейнерами. */
/* Геометрия по дизайн-базлайну v2 (кадр C, меню ⋯): 220px, отступ 5,
скругление 10, пункты 33px r7, разделитель n-9. */
.app-dropdown .ant-dropdown-menu {
width: 200px;
width: 220px;
padding: 5px;
background: var(--surface-card);
border: 1px solid var(--n-8);
border-radius: 10px;
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.12);
}
.app-dropdown .ant-dropdown-menu .ant-dropdown-menu-item-divider {
margin: 4px 8px;
background: var(--n-9);
}
.app-dropdown.is-wide .ant-dropdown-menu {
@@ -26,7 +35,7 @@
.app-dropdown .ant-dropdown-menu-item button {
width: 100%;
min-height: 32px;
min-height: 33px;
display: flex;
align-items: center;
gap: 9px;
@@ -34,9 +43,10 @@
color: var(--n-2);
background: transparent;
border: 0;
border-radius: 6px;
border-radius: 7px;
font-size: 13px;
font-weight: 500;
line-height: 17px;
text-align: left;
cursor: pointer;
white-space: nowrap;
@@ -0,0 +1,17 @@
import { useEffect, useState } from "react";
// Реактивный matchMedia — для разметки, которую CSS переключить не может
// (текст плейсхолдера, набор кнопок на мобильном кадре M2).
export function useMediaQuery(query: string): boolean {
const get = () => (typeof window !== "undefined" && window.matchMedia ? window.matchMedia(query).matches : false);
const [matches, setMatches] = useState(get);
useEffect(() => {
if (typeof window === "undefined" || !window.matchMedia) return;
const media = window.matchMedia(query);
const onChange = () => setMatches(media.matches);
onChange();
media.addEventListener("change", onChange);
return () => media.removeEventListener("change", onChange);
}, [query]);
return matches;
}
+3 -2
View File
@@ -112,7 +112,6 @@
html,
body,
#root {
min-width: 1024px;
min-height: 100%;
margin: 0;
}
@@ -125,7 +124,9 @@ body {
}
button,
input {
input,
textarea,
select {
font: inherit;
}