mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 09:14:58 +03:00
💄 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:
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"),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user