mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 09:14:58 +03:00
✨ feat(pickers): поиск в выборе ответственного и передаче владения
Выборы работали списком без поиска: при большой команде найти человека в них было нельзя, а справочник коллег отдавался целиком. - /conversations/directory/ отдаёт ограниченную выдачу, ищет по имени и почте и сообщает hasMoreEmployees; - выбор ответственного берёт справочник сам (сквозной проп employees убран из ChatPage и карточки контакта) и показывает строку поиска, когда коллег больше, чем помещается в выдачу; - передача владения ищет кандидатов по имени, почте и должности; строка поиска появляется, только если администраторов больше страницы; - в маленькой команде оба выбора выглядят как прежде — строка поиска не появляется. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
776bc89514
commit
ed78617722
13 files changed
+196
-27
No files matched your search
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user