diff --git a/apps/backend/chatballs/ai/selectors.py b/apps/backend/chatballs/ai/selectors.py index 05224d7..88b374d 100644 --- a/apps/backend/chatballs/ai/selectors.py +++ b/apps/backend/chatballs/ai/selectors.py @@ -1,13 +1,12 @@ from collections.abc import Sequence from dataclasses import dataclass -from django.db.models import Count, IntegerField, OuterRef, Prefetch, Q, QuerySet, Subquery, Value +from django.db.models import Count, IntegerField, OuterRef, Q, QuerySet, Subquery, Value from django.db.models.functions import Coalesce from chatballs.ai.agent_knowledge import knowledge_available_to_channel from chatballs.ai.knowledge_policy import readable_knowledge, writable_knowledge from chatballs.ai.models import AIAgent, Knowledge, KnowledgeCategory, KnowledgeFragment -from chatballs.channels.models import Channel from chatballs.identity.models import AuditEvent from chatballs.identity.policy import has_capability_any_scope from chatballs.tenancy.context import TenantContext diff --git a/apps/backend/chatballs/ai/views.py b/apps/backend/chatballs/ai/views.py index 15adaf3..88f7f53 100644 --- a/apps/backend/chatballs/ai/views.py +++ b/apps/backend/chatballs/ai/views.py @@ -21,9 +21,6 @@ from chatballs.ai.knowledge_services import ( ) from chatballs.ai.indexing import reindex_knowledge from chatballs.ai.models import Knowledge - -# Библиотека знаний плотная — своя страница (кадр KB1). -KNOWLEDGE_PAGE_SIZE = 25 from chatballs.ai.selectors import ( apply_knowledge_filters, knowledge_editors, @@ -36,6 +33,9 @@ from chatballs.api.permissions import HasCapability from chatballs.identity.audit import record_audit_event from chatballs.identity.models import AuditEvent +# Библиотека знаний плотная — своя страница (кадр KB1). +KNOWLEDGE_PAGE_SIZE = 25 + _validation_error = validation_error_response diff --git a/apps/backend/chatballs/conversations/chat_extras_views.py b/apps/backend/chatballs/conversations/chat_extras_views.py index c51a781..8d8ab42 100644 --- a/apps/backend/chatballs/conversations/chat_extras_views.py +++ b/apps/backend/chatballs/conversations/chat_extras_views.py @@ -7,7 +7,7 @@ from __future__ import annotations -from django.db.models import Count +from django.db.models import Count, Q from django.utils import timezone from rest_framework.request import Request from rest_framework.response import Response @@ -270,6 +270,11 @@ class ConversationCountersView(ConversationViewBase): ) +# Справочник выбора: ростер организации может быть большим, поэтому список +# коллег ограничен и ищется на сервере — в выборе стоит строка поиска (кадр G). +DIRECTORY_LIMIT = 50 + + class ConversationDirectoryView(APIView): """Справочник блока «Диалог» для оператора (дизайн-базлайн v2, кадр G): все группы организации — для переноса, активные коллеги — для назначения. @@ -287,6 +292,14 @@ class ConversationDirectoryView(APIView): .filter(organization_id=organization_id, blocked_at__isnull=True, user__is_active=True) .order_by("user__full_name", "user__email") ) + query = request.query_params.get("q", "").strip() + if query: + members = members.filter( + Q(user__full_name__icontains=query) | Q(user__email__icontains=query) + ) + # Ответственного можно назначить и вне выдачи — по поиску, поэтому + # оставшихся не прячем молча, а сообщаем признаком hasMore. + rows = list(members[: DIRECTORY_LIMIT + 1]) return Response( { "groups": [{"id": group.id, "name": group.name, "color": group.color} for group in groups], @@ -296,8 +309,9 @@ class ConversationDirectoryView(APIView): "name": member.user.full_name or member.user.email, "avatarUrl": user_avatar_url(member.user, request.tenant_context.organization.public_id), } - for member in members + for member in rows[:DIRECTORY_LIMIT] ], + "hasMoreEmployees": len(rows) > DIRECTORY_LIMIT, } ) diff --git a/apps/backend/chatballs/conversations/test_directory.py b/apps/backend/chatballs/conversations/test_directory.py new file mode 100644 index 0000000..b05d59c --- /dev/null +++ b/apps/backend/chatballs/conversations/test_directory.py @@ -0,0 +1,56 @@ +"""Справочник выбора ответственного: ограниченная выдача и поиск на сервере.""" + +from django.test import TestCase + +from chatballs.conversations.chat_extras_views import DIRECTORY_LIMIT +from chatballs.identity.bootstrap import bootstrap_owner +from chatballs.identity.models import ( + EmployeeRole, + HumanUser, + Organization, + OrganizationMembership, +) +from chatballs.testing import TenantAPIClient as APIClient + + +class ConversationDirectoryTests(TestCase): + def setUp(self) -> None: + bootstrap_owner(email="owner@example.com", password="temporary-password") + self.organization = Organization.objects.get(slug="demo") + for index in range(DIRECTORY_LIMIT + 10): + user = HumanUser.objects.create_user( + email=f"member{index:03d}@example.com", + password="Password-123", + full_name=f"Сотрудник {index:03d}", + ) + OrganizationMembership.objects.create( + user=user, + organization=self.organization, + role=EmployeeRole.EMPLOYEE, + position_title="Оператор", + ) + self.client = APIClient() + self.client.login(username="owner@example.com", password="temporary-password") + + def _directory(self, query: str = "") -> dict: + response = self.client.get(f"/api/v1/conversations/directory/{query}") + self.assertEqual(response.status_code, 200) + return response.json() + + def test_employees_are_bounded_and_report_the_rest(self) -> None: + payload = self._directory() + self.assertEqual(len(payload["employees"]), DIRECTORY_LIMIT) + self.assertTrue(payload["hasMoreEmployees"]) + + def test_search_finds_colleague_outside_the_first_rows(self) -> None: + # Именно ради этого случая в выборе и появляется строка поиска. + payload = self._directory("?q=Сотрудник 057") + self.assertEqual([item["name"] for item in payload["employees"]], ["Сотрудник 057"]) + self.assertFalse(payload["hasMoreEmployees"]) + + def test_search_matches_email(self) -> None: + payload = self._directory("?q=member042@") + self.assertEqual(len(payload["employees"]), 1) + + def test_groups_are_returned_as_before(self) -> None: + self.assertIn("groups", self._directory()) diff --git a/apps/internal-ui/src/features/chat/ChatPage.tsx b/apps/internal-ui/src/features/chat/ChatPage.tsx index ee8115b..0abe9d3 100644 --- a/apps/internal-ui/src/features/chat/ChatPage.tsx +++ b/apps/internal-ui/src/features/chat/ChatPage.tsx @@ -65,7 +65,6 @@ export function ChatPage({ dialog={dialog} detail={detail} groups={directory.groups} - employees={directory.employees} applyConversation={applyConversation} startCall={startCall} closeContext={closeContext} @@ -82,7 +81,6 @@ function ChatContextPanel({ dialog, detail, groups, - employees, applyConversation, startCall, closeContext, @@ -93,7 +91,6 @@ function ChatContextPanel({ dialog: ConversationListItem | null; detail: ApiConversation | null; groups: Array; - employees: Array<{ id: number; name: string; avatarUrl?: string | null }>; applyConversation: (updated: ApiConversation) => void; startCall: ((kind: "AUDIO" | "VIDEO") => void) | null; closeContext: () => void; @@ -108,7 +105,7 @@ function ChatContextPanel({
{rightTab === "client" && ( - + )} {rightTab === "history" && }
diff --git a/apps/internal-ui/src/features/conversations/DialogControls.tsx b/apps/internal-ui/src/features/conversations/DialogControls.tsx index 1756c2f..1fe4554 100644 --- a/apps/internal-ui/src/features/conversations/DialogControls.tsx +++ b/apps/internal-ui/src/features/conversations/DialogControls.tsx @@ -2,7 +2,9 @@ import { Dropdown } from "antd"; import { useEffect, useState } from "react"; import { Icon } from "../../shared/icons"; +import { SearchInput } from "../../shared/ui-controls"; import { PriorityBars } from "./DialogList"; +import { useEmployeeDirectory } from "./useEmployeeDirectory"; import { statusFor } from "./data"; import { agentColorOf, @@ -47,16 +49,15 @@ function initials(name: string): string { export function DialogControls({ detail, groups, - employees, applyConversation, viewerId = null, }: { detail: ApiConversation; groups: Array; - employees: Array<{ id: number; name: string; avatarUrl?: string | null }>; applyConversation: (updated: ApiConversation) => void; viewerId?: number | null; }) { + const directory = useEmployeeDirectory(); const [busy, setBusy] = useState(false); const [errorText, setErrorText] = useState(""); const [collapsed, setCollapsed] = useState(false); @@ -112,7 +113,7 @@ export function DialogControls({ const assigneeLabel = assignee ? `${assignee.name}${viewerId != null && assignee.id === viewerId ? " · вы" : ""}` : "Не назначен"; const status = statusFor(controlModeOf(detail), assignee?.name); const priorityLabel = PRIORITY_OPTIONS.find(([value]) => value === detail.priority)?.[1] ?? "Не задан"; - const canEdit = employees.length > 0 || groups.length > 0; + const canEdit = directory.employees.length > 0 || groups.length > 0; return ( <> @@ -127,13 +128,29 @@ export function DialogControls({ + ), + }] + : []), { key: "none", label: }, - ...employees.map((employee) => ({ + ...directory.employees.map((employee) => ({ key: employee.id, label: , })), diff --git a/apps/internal-ui/src/features/conversations/model.ts b/apps/internal-ui/src/features/conversations/model.ts index 7c99bda..2b27e06 100644 --- a/apps/internal-ui/src/features/conversations/model.ts +++ b/apps/internal-ui/src/features/conversations/model.ts @@ -270,16 +270,22 @@ export const fetchWaitingCount = () => api<{ waiting: number }>("/api/v1/convers // Справочник блока «Диалог» (кадр G): все группы для переноса и коллеги для // назначения — доступен и сотруднику, у которого нет менеджерских списков. +export type ChatDirectoryEmployee = { id: number; name: string; avatarUrl?: string | null }; + export type ChatDirectory = { groups: Array<{ id: number; name: string; color?: string }>; - employees: Array<{ id: number; name: string; avatarUrl?: string | null }>; + // Выдача коллег ограничена, поиск — на сервере: ростер организации может + // быть каким угодно, а выбор ответственного — не список. + employees: ChatDirectoryEmployee[]; + hasMoreEmployees?: boolean; }; // Карточка контакта из диалога (карандаш у имени, дизайн-базлайн v2). export const updateContactCard = (conversationId: number, fields: Partial<{ name: string; description: string; phone: string; company: string; city: string }>) => conversationAction(conversationId, "contact", fields); -export const fetchChatDirectory = () => api("/api/v1/conversations/directory/"); +export const fetchChatDirectory = (query = "") => + api(`/api/v1/conversations/directory/${query.trim() ? `?q=${encodeURIComponent(query.trim())}` : ""}`); const conversationAction = (id: number, suffix: string, body: object) => api<{ conversation: ApiConversation }>(`/api/v1/conversations/${id}/${suffix}/`, { diff --git a/apps/internal-ui/src/features/conversations/useEmployeeDirectory.ts b/apps/internal-ui/src/features/conversations/useEmployeeDirectory.ts new file mode 100644 index 0000000..fc0507a --- /dev/null +++ b/apps/internal-ui/src/features/conversations/useEmployeeDirectory.ts @@ -0,0 +1,47 @@ +import { useEffect, useState } from "react"; + +import { useDebounced } from "../../shared/useDebounced"; +import { fetchChatDirectory, type ChatDirectoryEmployee } from "./model"; + +// Справочник коллег для выбора ответственного (кадр G). Сервер отдаёт +// ограниченную выдачу и ищет сам: в организации может быть сколько угодно +// сотрудников, а выбор — не список. + +export type EmployeeDirectory = { + employees: ChatDirectoryEmployee[]; + query: string; + setQuery: (query: string) => void; + /** За пределами выдачи есть ещё коллеги — выбору нужна строка поиска. */ + hasMore: boolean; + loading: boolean; +}; + +export function useEmployeeDirectory(): EmployeeDirectory { + const [employees, setEmployees] = useState([]); + const [hasMore, setHasMore] = useState(false); + const [query, setQuery] = useState(""); + const [loading, setLoading] = useState(true); + const settledQuery = useDebounced(query.trim()); + + useEffect(() => { + let active = true; + setLoading(true); + fetchChatDirectory(settledQuery) + .then((payload) => { + if (!active) return; + setEmployees(payload.employees); + setHasMore(Boolean(payload.hasMoreEmployees)); + }) + .catch(() => { + if (active) setEmployees([]); + }) + .finally(() => { + if (active) setLoading(false); + }); + return () => { + active = false; + }; + }, [settledQuery]); + + return { employees, query, setQuery, hasMore, loading }; +} diff --git a/apps/internal-ui/src/features/employees/OwnershipTransferModal.tsx b/apps/internal-ui/src/features/employees/OwnershipTransferModal.tsx index 7041b95..ebf6b5d 100644 --- a/apps/internal-ui/src/features/employees/OwnershipTransferModal.tsx +++ b/apps/internal-ui/src/features/employees/OwnershipTransferModal.tsx @@ -3,6 +3,8 @@ import { useEffect, useState } from "react"; import { api } from "../../api/client"; import { Icon } from "../../shared/icons"; import { Avatar } from "../../shared/ui"; +import { SearchInput } from "../../shared/ui-controls"; +import { useDebounced } from "../../shared/useDebounced"; import type { Employee, Role } from "../../types"; import { fetchOwner, fetchOwnershipCandidates } from "./api"; import { employeeAvatarColor, roleBadge } from "./model"; @@ -22,6 +24,11 @@ export function OwnershipTransferModal({ onClose }: { onClose: () => void }) { const [owner, setOwner] = useState(null); const [candidates, setCandidates] = useState([]); const [targetId, setTargetId] = useState(null); + // Поиск по кандидатам появляется, только если администраторов больше, чем + // вернула страница: у обычной команды выбор остаётся простым списком. + const [query, setQuery] = useState(""); + const [hasMore, setHasMore] = useState(false); + const settledQuery = useDebounced(query); const [previousOwnerRole, setPreviousOwnerRole] = useState>("ADMIN"); const [confirmed, setConfirmed] = useState(false); @@ -30,13 +37,16 @@ export function OwnershipTransferModal({ onClose }: { onClose: () => void }) { useEffect(() => { let active = true; - void Promise.all([fetchOwner(), fetchOwnershipCandidates()]) + void Promise.all([fetchOwner(), fetchOwnershipCandidates(settledQuery)]) .then(([owners, admins]) => { if (!active) return; const activeAdmins = admins.items.filter((employee) => employee.isActive && !employee.isBlocked); setOwner(owners.items[0] ?? null); setCandidates(activeAdmins); - setTargetId(activeAdmins[0]?.id ?? null); + setHasMore(admins.total > admins.items.length); + setTargetId((current) => ( + activeAdmins.some((employee) => employee.id === current) ? current : activeAdmins[0]?.id ?? null + )); }) .catch(() => { if (active) setError("Не удалось загрузить кандидатов"); @@ -44,7 +54,7 @@ export function OwnershipTransferModal({ onClose }: { onClose: () => void }) { return () => { active = false; }; - }, []); + }, [settledQuery]); if (!owner) return null; const target = candidates.find((employee) => employee.id === targetId) ?? null; @@ -77,6 +87,14 @@ export function OwnershipTransferModal({ onClose }: { onClose: () => void }) {
+ {(hasMore || query) && ( + + )} {candidates.length === 0 ? (

Активных администраторов нет. Чтобы передать владение сотруднику, сначала сделайте его администратором.

) : ( diff --git a/apps/internal-ui/src/features/employees/api.ts b/apps/internal-ui/src/features/employees/api.ts index 3b8e20b..a6922ea 100644 --- a/apps/internal-ui/src/features/employees/api.ts +++ b/apps/internal-ui/src/features/employees/api.ts @@ -68,9 +68,12 @@ export function fetchEmployees( } /** Кандидаты на владение (кадр E9) — только действующие администраторы. - * Их единицы, поэтому берётся одна страница; роль отбирает сервер. */ -export function fetchOwnershipCandidates(): Promise> { - return api>("/api/v1/employees/?role=ADMIN&pageSize=100"); + * Роль и поиск отбирает сервер; если админов больше страницы, у выбора + * появляется строка поиска. */ +export function fetchOwnershipCandidates(query = ""): Promise> { + const params = new URLSearchParams({ role: "ADMIN", pageSize: "100" }); + if (query.trim()) params.set("q", query.trim()); + return api>(`/api/v1/employees/?${params.toString()}`); } export function fetchOwner(): Promise> { diff --git a/apps/internal-ui/src/features/employees/styles-detail.css b/apps/internal-ui/src/features/employees/styles-detail.css index d0e3215..d651eeb 100644 --- a/apps/internal-ui/src/features/employees/styles-detail.css +++ b/apps/internal-ui/src/features/employees/styles-detail.css @@ -667,3 +667,10 @@ padding-left: 20px; } } + +/* Поиск по кандидатам передачи владения (кадр E9): показывается, только если + администраторов больше, чем вернула страница. */ +.employee-transfer-body .employee-transfer-search { + width: 100%; + margin-bottom: 12px; +} diff --git a/apps/internal-ui/src/features/sales/dialogs/context.css b/apps/internal-ui/src/features/sales/dialogs/context.css index f3b0d96..eed9361 100644 --- a/apps/internal-ui/src/features/sales/dialogs/context.css +++ b/apps/internal-ui/src/features/sales/dialogs/context.css @@ -1360,3 +1360,10 @@ font-size: 12px; line-height: 1.45; } + +/* Поиск в выборе ответственного (кадр G): появляется, когда коллег больше, + чем помещается в выдачу справочника. */ +.app-dropdown.ctx-menu .ctx-menu-search { + width: 100%; + margin: 2px 0 6px; +} diff --git a/apps/internal-ui/src/features/sales/dialogs/context/ClientContext.tsx b/apps/internal-ui/src/features/sales/dialogs/context/ClientContext.tsx index debad1d..322242a 100644 --- a/apps/internal-ui/src/features/sales/dialogs/context/ClientContext.tsx +++ b/apps/internal-ui/src/features/sales/dialogs/context/ClientContext.tsx @@ -18,7 +18,6 @@ export function ClientContext({ dialog, detail, groups = [], - employees = [], applyConversation, startCall, viewerId = null, @@ -26,7 +25,6 @@ export function ClientContext({ dialog: ConversationListItem | null; detail: ApiConversation | null; groups?: Array; - employees?: Array<{ id: number; name: string; avatarUrl?: string | null }>; applyConversation?: (updated: ApiConversation) => void; startCall?: ((kind: "AUDIO" | "VIDEO") => void) | null; viewerId?: number | null; @@ -131,7 +129,7 @@ export function ClientContext({
{detail && applyConversation && ( - + )} );