✨ feat(pickers): поиск в выборе ответственного и передаче владения

Выборы работали списком без поиска: при большой команде найти человека в них
было нельзя, а справочник коллег отдавался целиком.

- /conversations/directory/ отдаёт ограниченную выдачу, ищет по имени и почте
  и сообщает hasMoreEmployees;
- выбор ответственного берёт справочник сам (сквозной проп employees убран из
  ChatPage и карточки контакта) и показывает строку поиска, когда коллег
  больше, чем помещается в выдачу;
- передача владения ищет кандидатов по имени, почте и должности; строка поиска
  появляется, только если администраторов больше страницы;
- в маленькой команде оба выбора выглядят как прежде — строка поиска не
  появляется.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
AndreyandClaude Opus 5 committed 2026-09-09 01:39:11 +03:00
1 parent 776bc89514
commit ed78617722
13 files changed
+196 -27

No files matched your search

+1 -2
View File
@@ -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
+3 -3
View File
@@ -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
@@ -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,
}
)
@@ -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())
@@ -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<EmployeeGroupRef & { color?: string }>;
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({
</div>
<div className="sales-context-body">
{rightTab === "client" && (
<ClientContext dialog={dialog} detail={detail} groups={groups} employees={employees} applyConversation={applyConversation} startCall={startCall} viewerId={viewerId} />
<ClientContext dialog={dialog} detail={detail} groups={groups} applyConversation={applyConversation} startCall={startCall} viewerId={viewerId} />
)}
{rightTab === "history" && <HistoryContext detail={detail} />}
</div>
@@ -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<EmployeeGroupRef & { color?: string }>;
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({
<label className="ctx-label">Ответственный</label>
<Dropdown
disabled={busy || employees.length === 0}
disabled={busy || (directory.employees.length === 0 && !directory.query)}
trigger={["click"]}
overlayClassName="app-dropdown ctx-menu"
menu={{
items: [
// Строка поиска появляется, когда коллег больше, чем помещается
// в выдачу справочника: маленькой команде она не нужна.
...(directory.hasMore || directory.query
? [{
key: "search",
type: "group" as const,
label: (
<SearchInput
className="ctx-menu-search"
placeholder="Имя или почта"
value={directory.query}
onChange={directory.setQuery}
/>
),
}]
: []),
{ 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) => ({
...directory.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))}><SmallAvatar name={employee.name} avatarUrl={employee.avatarUrl} /><span>{employee.name}{viewerId === employee.id ? " · вы" : ""}</span>{assignee?.id === employee.id && <Icon name="check" size={15} />}</button>,
})),
@@ -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<ChatDirectory>("/api/v1/conversations/directory/");
export const fetchChatDirectory = (query = "") =>
api<ChatDirectory>(`/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}/`, {
@@ -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<ChatDirectoryEmployee[]>([]);
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 };
}
@@ -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<Employee | null>(null);
const [candidates, setCandidates] = useState<Employee[]>([]);
const [targetId, setTargetId] = useState<number | null>(null);
// Поиск по кандидатам появляется, только если администраторов больше, чем
// вернула страница: у обычной команды выбор остаётся простым списком.
const [query, setQuery] = useState("");
const [hasMore, setHasMore] = useState(false);
const settledQuery = useDebounced(query);
const [previousOwnerRole, setPreviousOwnerRole] = useState<Extract<Role, "ADMIN" | "EMPLOYEE">>("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 }) {
</header>
<div className="employee-transfer-body">
{(hasMore || query) && (
<SearchInput
className="employee-transfer-search"
placeholder="Имя, почта или должность"
value={query}
onChange={setQuery}
/>
)}
{candidates.length === 0 ? (
<p className="employee-create-note">Активных администраторов нет. Чтобы передать владение сотруднику, сначала сделайте его администратором.</p>
) : (
@@ -68,9 +68,12 @@ export function fetchEmployees(
}
/** Кандидаты на владение (кадр E9) — только действующие администраторы.
* Их единицы, поэтому берётся одна страница; роль отбирает сервер. */
export function fetchOwnershipCandidates(): Promise<PagedPayload<Employee>> {
return api<PagedPayload<Employee>>("/api/v1/employees/?role=ADMIN&pageSize=100");
* Роль и поиск отбирает сервер; если админов больше страницы, у выбора
* появляется строка поиска. */
export function fetchOwnershipCandidates(query = ""): Promise<PagedPayload<Employee>> {
const params = new URLSearchParams({ role: "ADMIN", pageSize: "100" });
if (query.trim()) params.set("q", query.trim());
return api<PagedPayload<Employee>>(`/api/v1/employees/?${params.toString()}`);
}
export function fetchOwner(): Promise<PagedPayload<Employee>> {
@@ -667,3 +667,10 @@
padding-left: 20px;
}
}
/* Поиск по кандидатам передачи владения (кадр E9): показывается, только если
администраторов больше, чем вернула страница. */
.employee-transfer-body .employee-transfer-search {
width: 100%;
margin-bottom: 12px;
}
@@ -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;
}
@@ -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<EmployeeGroupRef & { color?: string }>;
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({
</div>
{detail && applyConversation && (
<DialogControls detail={detail} groups={groups} employees={employees} applyConversation={applyConversation} viewerId={viewerId} />
<DialogControls detail={detail} groups={groups} applyConversation={applyConversation} viewerId={viewerId} />
)}
</div>
);