⚡ perf(ui): списки отдаются страницами, фильтры считает база

Пагинация в порталах и контактах была нарисованной: сервер отдавал весь набор,
браузер резал его на страницы. У сотрудников и агентов не было и этого. Любой
из четырёх списков рос вместе с организацией и целиком уезжал клиенту.

Серверная часть — страницы и фильтры до среза:
- контакты: подзапросы вместо join-агрегатов (фильтр по каналу больше не
  урезает счётчики диалогов), поиск, каналы, агенты, «с открытым диалогом»
  и порядок — в SQL;
- сотрудники: роль, группа и поиск по имени, почте и должности;
- агенты: группа и поиск;
- порталы: статус и поиск, архивные последними;
- библиотека статей: категория с вложенными, язык, статус и поиск по последней
  редакции.

Клиент:
- один подвал со страницами на всё приложение вместо трёх разных
  (PortalTableFooter, SalesClientsPagination, TablePagination удалены);
- usePagedResource: страница принадлежит набору фильтров, гонки ответов
  отсекаются, сервер решает, какая страница существует;
- useDebounced вынесен в shared — поиск придерживает запрос;
- карточка сотрудника грузится по идентификатору, а передача владения сама
  запрашивает кандидатов: список постраничный, и нужного человека может не
  быть на открытой странице;
- App больше не тянет всех сотрудников на старте.

Выпадающие выборы (ответственный, передача владения, фильтр по агентам)
работают со справочниками; библиотеке знаний нужен серверный контракт со
счётчиками прикреплений — до него потолок в 100 карточек оставлен явным.

Контракт списков и лент записан в SPEC-CHATBALLS-0031 §8.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
AndreyandClaude Opus 5 committed 2026-09-09 00:05:02 +03:00
1 parent 0636ef5fa9
commit e6a6bf77b6
56 files changed
+1399 -673

No files matched your search

+11 -4
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
from django.core.exceptions import ValidationError
from django.db.models import Q
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
@@ -17,6 +18,7 @@ from chatballs.ai.agent_card import (
update_agent_card,
)
from chatballs.ai.provider.base import ProviderError
from chatballs.api.pagination import page_payload, paginate
from chatballs.api.permissions import HasCapability
from chatballs.channels import services as channel_services
from chatballs.channels.models import Channel
@@ -74,13 +76,18 @@ class AgentCardListView(APIView):
knowledge_total_for_organization,
)
query = request.query_params.get("q", "").strip()
if query:
cards = cards.filter(Q(name__icontains=query) | Q(code__icontains=query))
total = knowledge_total_for_organization(request.tenant_context.organization_id)
items = []
for channel in cards:
page = paginate(cards, request.query_params)
def payload(channel):
# Страховка для каналов, созданных в обход мастера.
ensure_channel_agent(channel)
items.append(agent_card_payload(channel, knowledge_total=total))
return Response({"items": items})
return agent_card_payload(channel, knowledge_total=total)
return Response(page_payload(page, payload))
def post(self, request: Request) -> Response:
data = request.data if isinstance(request.data, dict) else {}
@@ -0,0 +1,54 @@
"""Список агентов отдаётся страницами, поиск и группа — параметры запроса."""
from django.test import TestCase
from chatballs.channels.models import Channel
from chatballs.identity.bootstrap import bootstrap_owner
from chatballs.identity.group_models import EmployeeGroup
from chatballs.identity.models import Organization
from chatballs.testing import TenantAPIClient as APIClient
class AgentListPaginationTests(TestCase):
def setUp(self) -> None:
bootstrap_owner(email="owner@example.com", password="temporary-password")
self.organization = Organization.objects.get(slug="demo")
self.group = EmployeeGroup.objects.create(
organization=self.organization, name="Вечерняя смена"
)
self.channels = [
Channel.objects.create(
organization=self.organization,
code=f"agent-{index:02d}",
name=f"Агент {index:02d}",
group=self.group if index < 4 else None,
)
for index in range(25)
]
self.client = APIClient()
self.client.login(username="owner@example.com", password="temporary-password")
def _page(self, query: str = "") -> dict:
response = self.client.get(f"/api/v1/agents/{query}")
self.assertEqual(response.status_code, 200)
return response.json()
def test_first_page_is_bounded(self) -> None:
page = self._page()
total = Channel.objects.filter(organization=self.organization).count()
self.assertEqual(len(page["items"]), 20)
self.assertEqual(page["total"], total)
def test_pages_cover_the_set_without_repeats(self) -> None:
total = self._page()["total"]
seen: list[int] = []
for number in range(1, -(-total // 20) + 1):
seen += [item["id"] for item in self._page(f"?page={number}")["items"]]
self.assertEqual(len(set(seen)), total)
def test_group_filter_is_applied_before_the_page(self) -> None:
self.assertEqual(self._page(f"?group={self.group.id}")["total"], 4)
def test_search_is_applied_before_the_page(self) -> None:
self.assertEqual(self._page("?q=Агент 07")["total"], 1)
self.assertEqual(self._page("?q=agent-07")["total"], 1)
+128 -60
View File
@@ -6,14 +6,24 @@ A "client" is a Contact. Commerce data was removed with the sales domain
from __future__ import annotations
from django.db.models import Prefetch, Q
from django.db.models import (
Count,
IntegerField,
OuterRef,
Prefetch,
Q,
QuerySet,
Subquery,
Value,
)
from django.db.models.functions import Coalesce
from chatballs.conversations.models import (
ConnectionIdentity,
Contact,
ContactMerge,
Conversation,
ControlMode,
Conversation,
LifecycleState,
)
from chatballs.identity.audit_catalog import (
@@ -47,69 +57,127 @@ def _mode(latest: Conversation) -> str:
return "wait"
def clients_overview(organization_id: int) -> list[dict]:
# Провайдер подключения по короткому коду канала из фильтра списка (кадр K1).
PROVIDER_BY_CODE = {code: provider for provider, code in PROVIDER_CODE.items()}
def _client_counts(*, lifecycle: str | None = None) -> Subquery:
"""Число диалогов контакта отдельным подзапросом.
Через join-агрегат считать нельзя: фильтры списка (агент, канал) идут по
той же связи и урезали бы счётчик до отфильтрованных строк.
"""
conversations = (
Conversation.objects.filter(contact_id=OuterRef("pk"))
.order_by()
.values("contact_id")
.annotate(total=Count("id"))
.values("total")
)
if lifecycle:
conversations = (
Conversation.objects.filter(contact_id=OuterRef("pk"), lifecycle=lifecycle)
.order_by()
.values("contact_id")
.annotate(total=Count("id"))
.values("total")
)
return Coalesce(Subquery(conversations, output_field=IntegerField()), Value(0))
def clients_queryset(organization_id: int, params) -> QuerySet[Contact]:
"""Список контактов (кадры K1/K2): фильтры, поиск и порядок — на сервере.
Клиент — контакт, который писал: у него есть хотя бы один диалог.
"""
conversation_qs = Conversation.objects.select_related(
"channel", "connection", "assigned_operator"
).order_by("-last_activity_at")
identity_qs = ConnectionIdentity.objects.select_related("connection")
contacts = Contact.objects.filter(organization_id=organization_id, merged_into__isnull=True).prefetch_related(
Prefetch(
"conversations",
queryset=conversation_qs,
),
Prefetch("identities", queryset=identity_qs),
)
rows: list[dict] = []
for contact in contacts:
conversations = list(contact.conversations.all())
if not conversations:
continue # клиенты — те, кто писал
channels: set[str] = set()
agents: dict[int, dict[str, object]] = {}
open_dialogs = 0
for conversation in conversations:
provider = conversation.connection.provider if conversation.connection_id else None
if provider in PROVIDER_CODE:
channels.add(PROVIDER_CODE[provider])
# Агент = карточка канала обработки: по нему фильтруется список (кадр K1).
agents.setdefault(
conversation.channel_id,
{"id": conversation.channel_id, "code": conversation.channel.code, "name": conversation.channel.name},
)
if conversation.lifecycle == LifecycleState.OPEN:
open_dialogs += 1
latest = conversations[0]
rows.append(
{
"id": contact.id,
"cid": f"CUS-{contact.id}",
"name": contact.name or "Гость",
"phone": contact.phone,
"avatarUrl": contact.avatar_url,
"email": next(
(
identity.external_user_id
for identity in contact.identities.all()
if identity.connection.provider == "EMAIL"
),
"",
),
# Первый непустой @логин среди identity каналов (остальные — в карточке).
"username": next((identity.username for identity in contact.identities.all() if identity.username), ""),
"channels": sorted(channels),
"openDialogs": open_dialogs,
"totalDialogs": len(conversations),
"lastActivityAt": latest.last_activity_at.isoformat(),
"mode": _mode(latest),
# Колонка «Последний диалог» (кадр K1): кто ведёт — агент или сотрудник.
"lastAgentName": latest.channel.name,
"lastAgentCode": latest.channel.code,
"lastAssignee": _actor_name(latest.assigned_operator),
"agents": sorted(agents.values(), key=lambda item: str(item["name"])),
}
contacts = (
Contact.objects.filter(organization_id=organization_id, merged_into__isnull=True)
.annotate(
last_activity=Subquery(
Conversation.objects.filter(contact_id=OuterRef("pk"))
.order_by("-last_activity_at")
.values("last_activity_at")[:1]
),
open_dialogs_count=_client_counts(lifecycle=LifecycleState.OPEN),
total_dialogs_count=_client_counts(),
)
rows.sort(key=lambda row: row["lastActivityAt"], reverse=True)
return rows
.filter(last_activity__isnull=False)
# Связанное подтягивается уже для страницы: prefetch выполняется после
# среза, а не по всей организации.
.prefetch_related(
Prefetch("conversations", queryset=conversation_qs),
Prefetch("identities", queryset=identity_qs),
)
)
query = params.get("q", "").strip()
if query:
contacts = contacts.filter(
Q(name__icontains=query)
| Q(phone__icontains=query)
| Q(identities__username__icontains=query)
| Q(identities__external_user_id__icontains=query)
)
agents = [value for value in params.getlist("agent") if value.isdigit()]
if agents:
contacts = contacts.filter(conversations__channel_id__in=agents)
providers = [
PROVIDER_BY_CODE[code] for code in params.getlist("channel") if code in PROVIDER_BY_CODE
]
if providers:
contacts = contacts.filter(conversations__connection__provider__in=providers)
if params.get("open") == "1":
contacts = contacts.filter(open_dialogs_count__gt=0)
field = "open_dialogs_count" if params.get("sort") == "open" else "last_activity"
ascending = params.get("dir") == "asc"
return contacts.distinct().order_by(f"{'' if ascending else '-'}{field}", "-id")
def client_row(contact: Contact) -> dict:
"""Строка списка контактов. Связанные диалоги и identity приходят из prefetch."""
conversations = list(contact.conversations.all())
channels: set[str] = set()
agents: dict[int, dict[str, object]] = {}
for conversation in conversations:
provider = conversation.connection.provider if conversation.connection_id else None
if provider in PROVIDER_CODE:
channels.add(PROVIDER_CODE[provider])
# Агент = карточка канала обработки: по нему фильтруется список (кадр K1).
agents.setdefault(
conversation.channel_id,
{"id": conversation.channel_id, "code": conversation.channel.code, "name": conversation.channel.name},
)
latest = conversations[0]
return {
"id": contact.id,
"cid": f"CUS-{contact.id}",
"name": contact.name or "Гость",
"phone": contact.phone,
"avatarUrl": contact.avatar_url,
"email": next(
(
identity.external_user_id
for identity in contact.identities.all()
if identity.connection.provider == "EMAIL"
),
"",
),
# Первый непустой @логин среди identity каналов (остальные — в карточке).
"username": next((identity.username for identity in contact.identities.all() if identity.username), ""),
"channels": sorted(channels),
"openDialogs": contact.open_dialogs_count,
"totalDialogs": contact.total_dialogs_count,
"lastActivityAt": latest.last_activity_at.isoformat(),
"mode": _mode(latest),
# Колонка «Последний диалог» (кадр K1): кто ведёт — агент или сотрудник.
"lastAgentName": latest.channel.name,
"lastAgentCode": latest.channel.code,
"lastAssignee": _actor_name(latest.assigned_operator),
"agents": sorted(agents.values(), key=lambda item: str(item["name"])),
}
def _dialog_status(conversation: Conversation) -> str:
@@ -3,7 +3,8 @@ from rest_framework.response import Response
from django.core.exceptions import ValidationError
from chatballs.conversations.clients import client_detail, clients_overview
from chatballs.api.pagination import page_payload, paginate
from chatballs.conversations.clients import client_detail, client_row, clients_queryset
from chatballs.conversations.contacts_merge import merge_contacts, revert_merge
from chatballs.conversations.models import Contact
from chatballs.identity.models import EmployeeRole
@@ -24,7 +25,12 @@ class ClientsView(ConversationViewBase):
required_capability = "customers.view"
def get(self, request: Request) -> Response:
return Response({"items": clients_overview(self._org(request).id)})
"""Страница списка контактов: фильтры, поиск и порядок отрабатывает база."""
page = paginate(
clients_queryset(self._org(request).id, request.query_params),
request.query_params,
)
return Response(page_payload(page, client_row))
class ClientDetailView(ConversationViewBase):
@@ -0,0 +1,115 @@
"""Список контактов: страницы, фильтры и порядок считает база.
Раньше endpoint перебирал все контакты организации со всеми их диалогами, а
браузер резал результат на страницы — при росте базы это неизбежно упиралось
в память и время ответа.
"""
from django.test import TestCase
from chatballs.channels.models import Channel
from chatballs.conversations.models import (
Contact,
Conversation,
LifecycleState,
Message,
MessageAuthor,
)
from chatballs.identity.bootstrap import bootstrap_owner
from chatballs.identity.models import Organization
from chatballs.integrations.models import (
Integration,
IntegrationKind,
IntegrationProvider,
)
from chatballs.testing import TenantAPIClient as APIClient
class ClientsListPaginationTests(TestCase):
def setUp(self) -> None:
bootstrap_owner(email="owner@example.com", password="temporary-password")
self.organization = Organization.objects.get(slug="demo")
self.channel = Channel.objects.create(
organization=self.organization, code="line", name="Линия"
)
self.other_channel = Channel.objects.create(
organization=self.organization, code="shop", name="Магазин"
)
self.telegram = self._connection(IntegrationProvider.TELEGRAM, "bot", self.channel)
self.email = self._connection(IntegrationProvider.EMAIL, "mail", self.other_channel)
self.contacts = [self._client(f"Клиент {index:02d}") for index in range(30)]
# Пятеро с открытым диалогом и почтовым каналом — для фильтров.
self.open_clients = self.contacts[:5]
for contact in self.open_clients:
self._conversation(contact, self.email, self.other_channel, LifecycleState.OPEN)
self.client = APIClient()
self.client.login(username="owner@example.com", password="temporary-password")
def _connection(self, provider: str, name: str, channel: Channel) -> Integration:
return Integration.objects.create(
organization=self.organization,
kind=IntegrationKind.MESSENGER,
provider=provider,
name=name,
channel=channel,
)
def _conversation(self, contact, connection, channel, lifecycle) -> Conversation:
conversation = Conversation.objects.create(
organization=self.organization,
channel=channel,
connection=connection,
contact=contact,
lifecycle=lifecycle,
)
Message.objects.create(
conversation=conversation, author_type=MessageAuthor.CONTACT, text="Здравствуйте"
)
return conversation
def _client(self, name: str) -> Contact:
contact = Contact.objects.create(organization=self.organization, name=name)
self._conversation(contact, self.telegram, self.channel, LifecycleState.CLOSED)
return contact
def _page(self, query: str = "") -> dict:
response = self.client.get(f"/api/v1/conversations/clients/{query}")
self.assertEqual(response.status_code, 200)
return response.json()
def test_first_page_is_bounded(self) -> None:
page = self._page()
self.assertEqual(len(page["items"]), 20)
self.assertEqual(page["total"], 30)
self.assertEqual(page["pageCount"], 2)
def test_contact_without_conversations_is_not_a_client(self) -> None:
Contact.objects.create(organization=self.organization, name="Никогда не писал")
self.assertEqual(self._page()["total"], 30)
def test_open_filter_is_applied_before_the_page(self) -> None:
page = self._page("?open=1")
self.assertEqual(page["total"], len(self.open_clients))
self.assertTrue(all(item["openDialogs"] > 0 for item in page["items"]))
def test_channel_filter_is_applied_before_the_page(self) -> None:
page = self._page("?channel=EMAIL")
self.assertEqual(page["total"], len(self.open_clients))
def test_agent_filter_is_applied_before_the_page(self) -> None:
page = self._page(f"?agent={self.other_channel.id}")
self.assertEqual(page["total"], len(self.open_clients))
def test_dialog_counts_survive_filtering(self) -> None:
# Счётчики считаются подзапросом: фильтр по каналу не должен их урезать.
item = self._page("?channel=EMAIL")["items"][0]
self.assertEqual(item["totalDialogs"], 2)
self.assertEqual(item["openDialogs"], 1)
def test_search_matches_name(self) -> None:
page = self._page("?q=Клиент 07")
self.assertEqual(page["total"], 1)
def test_sort_by_open_dialogs(self) -> None:
page = self._page("?sort=open")
self.assertTrue(page["items"][0]["openDialogs"] >= page["items"][-1]["openDialogs"])
@@ -129,7 +129,9 @@ class ContactsMergeTests(TestCase):
revert_merge(organization=self.organization, merge_id=merge.id, reason="Ещё раз", actor=self.owner)
def test_contact_merged_away_disappears_from_the_list(self) -> None:
from chatballs.conversations.clients import clients_overview
from django.http import QueryDict
from chatballs.conversations.clients import client_row, clients_queryset
merge_contacts(
organization=self.organization,
@@ -138,7 +140,7 @@ class ContactsMergeTests(TestCase):
reason="Совпал телефон",
actor=self.owner,
)
rows = clients_overview(self.organization.id)
rows = [client_row(contact) for contact in clients_queryset(self.organization.id, QueryDict())]
self.assertEqual([row["id"] for row in rows], [self.target.id])
@@ -10,6 +10,8 @@ from unittest import mock
from django.http import QueryDict
from django.test import TestCase
@@ -18,7 +20,7 @@ from chatballs.channels.models import Channel
from chatballs.conversations.models import Contact, Conversation, MessageAuthor
from chatballs.conversations.clients import client_detail, clients_overview
from chatballs.conversations.clients import client_detail, client_row, clients_queryset
from chatballs.conversations.selectors import conversation_messages
@@ -574,7 +576,13 @@ class EmailIngestThreadMetaTests(TestCase):
overview = clients_overview(self.organization.id)
overview = [
client_row(contact)
for contact in clients_queryset(self.organization.id, QueryDict())
]
self.assertEqual(overview[0]["email"], "ivan@example.com")
@@ -0,0 +1,35 @@
"""Выборка сотрудников для списка (кадры E1/E2).
Поиск, роль и группа — параметры запроса: страница приходит из базы уже
отфильтрованной, иначе фильтр видел бы только загруженную страницу.
"""
from __future__ import annotations
from django.db.models import Q, QuerySet
from chatballs.identity.models import OrganizationMembership
ROLE_ANY = "all"
def employees_for(organization_id: int, params) -> QuerySet[OrganizationMembership]:
employees = (
OrganizationMembership.objects.select_related("user")
.prefetch_related("group_links__group")
.filter(organization_id=organization_id)
)
role = params.get("role")
if role and role != ROLE_ANY:
employees = employees.filter(role=role)
group = params.get("group")
if group and group != ROLE_ANY and str(group).isdigit():
employees = employees.filter(group_links__group_id=int(group))
query = params.get("q", "").strip()
if query:
employees = employees.filter(
Q(user__full_name__icontains=query)
| Q(user__email__icontains=query)
| Q(position_title__icontains=query)
)
return employees.distinct().order_by("user__email")
@@ -5,8 +5,10 @@ from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
from chatballs.api.pagination import page_payload, paginate
from chatballs.events.services import DomainEvent, enqueue_event
from chatballs.identity.audit import record_audit_event
from chatballs.identity.employee_selectors import employees_for
from chatballs.identity.employee_support import employee_payload, get_owned_profile
from chatballs.identity.employee_validation import (
ASSIGNABLE_ROLES,
@@ -65,19 +67,11 @@ class EmployeeListView(APIView):
actor = request.tenant_context.membership
if not has_capability_any_scope(actor, "employees.view"):
return Response({"detail": "Not allowed"}, status=403)
employees = (
OrganizationMembership.objects.select_related("user")
.prefetch_related("group_links__group")
.filter(organization=actor.organization)
)
return Response(
{
"items": [
employee_payload(employee, actor)
for employee in employees.order_by("user__email")
]
}
page = paginate(
employees_for(actor.organization_id, request.query_params),
request.query_params,
)
return Response(page_payload(page, lambda employee: employee_payload(employee, actor)))
class EmployeeCreateView(APIView):
@@ -0,0 +1,80 @@
"""Список сотрудников отдаётся страницами, а фильтры считает база."""
from django.test import TestCase
from chatballs.identity.bootstrap import bootstrap_owner
from chatballs.identity.group_models import EmployeeGroup, EmployeeGroupMember
from chatballs.identity.models import (
EmployeeRole,
HumanUser,
Organization,
OrganizationMembership,
)
from chatballs.testing import TenantAPIClient as APIClient
class EmployeeListPaginationTests(TestCase):
def setUp(self) -> None:
bootstrap_owner(email="owner@example.com", password="temporary-password")
self.organization = Organization.objects.get(slug="demo")
self.group = EmployeeGroup.objects.create(
organization=self.organization, name="Ночная смена"
)
for index in range(44):
user = HumanUser.objects.create_user(
email=f"employee{index:02d}@example.com", password="Password-123"
)
membership = OrganizationMembership.objects.create(
user=user,
organization=self.organization,
role=EmployeeRole.ADMIN if index < 5 else EmployeeRole.EMPLOYEE,
position_title="Оператор" if index else "Руководитель поддержки",
)
if index < 3:
EmployeeGroupMember.objects.create(
organization=self.organization, group=self.group, employee=membership
)
self.client = APIClient()
self.client.login(username="owner@example.com", password="temporary-password")
def _page(self, query: str = "") -> dict:
response = self.client.get(f"/api/v1/employees/{query}")
self.assertEqual(response.status_code, 200)
return response.json()
def test_first_page_is_bounded_and_reports_whole_set(self) -> None:
page = self._page()
total = OrganizationMembership.objects.filter(organization=self.organization).count()
self.assertEqual(len(page["items"]), 20)
self.assertEqual(page["total"], total)
self.assertEqual(page["pageCount"], -(-total // 20))
self.assertEqual(page["page"], 1)
def test_pages_cover_the_set_without_repeats(self) -> None:
total = OrganizationMembership.objects.filter(organization=self.organization).count()
seen: list[int] = []
for number in range(1, -(-total // 20) + 1):
seen += [item["id"] for item in self._page(f"?page={number}")["items"]]
self.assertEqual(len(seen), total)
self.assertEqual(len(set(seen)), total)
def test_role_filter_is_applied_before_the_page(self) -> None:
page = self._page("?role=ADMIN")
admins = OrganizationMembership.objects.filter(
organization=self.organization, role=EmployeeRole.ADMIN
).count()
self.assertEqual(page["total"], admins)
self.assertTrue(all(item["role"] == "ADMIN" for item in page["items"]))
def test_group_filter_is_applied_before_the_page(self) -> None:
page = self._page(f"?group={self.group.id}")
self.assertEqual(page["total"], 3)
def test_search_covers_name_email_and_position(self) -> None:
self.assertEqual(self._page("?q=employee07")["total"], 1)
self.assertEqual(self._page("?q=Руководитель")["total"], 1)
def test_search_and_role_narrow_together(self) -> None:
page = self._page("?role=EMPLOYEE&q=employee1")
# employee10..employee19 — десять сотрудников роли EMPLOYEE.
self.assertEqual(page["total"], 10)
@@ -3,6 +3,7 @@ from rest_framework.parsers import FormParser, JSONParser, MultiPartParser
from rest_framework.request import Request
from rest_framework.response import Response
from chatballs.api.pagination import page_payload, paginate
from chatballs.support_portals.api import validation_response
from chatballs.support_portals.content_services import (
add_article_file,
@@ -18,7 +19,13 @@ from chatballs.support_portals.content_services import (
)
from chatballs.support_portals.models import PortalArticle
from chatballs.support_portals.portal_views import PortalBaseView
from chatballs.support_portals.selectors import category_article_counts
# Библиотека статей плотнее списка порталов — своя страница (кадр PT3).
ARTICLES_PAGE_SIZE = 25
from chatballs.support_portals.selectors import (
category_article_counts,
portal_articles_queryset,
)
from chatballs.support_portals.serializers import (
article_file_payload,
article_payload,
@@ -113,10 +120,13 @@ class ArticleListView(PortalBaseView):
portal = self.portal(request, portal_id)
if portal is None:
return Response({"detail": "Портал не найден"}, status=404)
articles = portal.articles.select_related(
"category", "published_revision"
).prefetch_related("revisions", "files", "feedback").all()
return Response({"items": [article_payload(item) for item in articles]})
"""Страница библиотеки: категория, язык, статус и поиск — на сервере."""
page = paginate(
portal_articles_queryset(portal, request.query_params),
request.query_params,
default_size=ARTICLES_PAGE_SIZE,
)
return Response(page_payload(page, article_payload))
def post(self, request: Request, portal_id: int) -> Response:
portal = self.portal(request, portal_id)
@@ -4,6 +4,7 @@ from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
from chatballs.api.pagination import page_payload, paginate
from chatballs.api.permissions import HasCapability
from chatballs.identity.audit import record_audit_event
from chatballs.integrations.models import IntegrationProvider, IntegrationStatus
@@ -23,8 +24,10 @@ from chatballs.support_portals.selectors import (
portal_content_counts,
portal_for_context,
portals_for_context,
portals_page_queryset,
)
from chatballs.support_portals.serializers import portal_payload
from chatballs.support_portals.statuses import PortalStatus
from chatballs.support_portals.themes import (
DEFAULT_PORTAL_THEME,
PortalThemeScheme,
@@ -110,23 +113,31 @@ def _input(request: Request, current: SupportPortal | None = None) -> PortalInpu
class PortalListView(PortalBaseView):
def get(self, request: Request) -> Response:
portals = list(portals_for_context(request.tenant_context))
"""Страница списка порталов: статус, поиск и порядок отрабатывает база."""
page = paginate(
portals_page_queryset(request.tenant_context, request.query_params),
request.query_params,
)
counts = portal_content_counts(request.tenant_context)
# Тарифные лимиты порталов удалены (ADR-CHATBALLS-0042 §2): создание доступно всегда.
active_count = sum(item.status != "ARCHIVED" for item in portals)
active_count = (
portals_for_context(request.tenant_context)
.exclude(status=PortalStatus.ARCHIVED)
.count()
)
return Response(
{
"items": [
portal_payload(
**page_payload(
page,
lambda item: portal_payload(
item,
counts={
"categories": 0,
"articles": 0,
**counts.get(item.id, {}),
},
)
for item in portals
],
),
),
"creation": {
"available": True,
"canCreate": True,
@@ -1,10 +1,22 @@
from django.db.models import Count, Q, QuerySet
from django.db.models import (
Case,
Count,
IntegerField,
OuterRef,
Q,
QuerySet,
Subquery,
Value,
When,
)
from chatballs.support_portals.models import (
PortalArticle,
PortalArticleRevision,
PortalCategory,
SupportPortal,
)
from chatballs.support_portals.statuses import PortalStatus
from chatballs.tenancy.context import TenantContext
@@ -26,6 +38,70 @@ def portal_for_context(context: TenantContext, portal_id: int) -> SupportPortal:
return portals_for_context(context).get(id=portal_id)
def portals_page_queryset(context: TenantContext, params) -> QuerySet[SupportPortal]:
"""Список порталов (кадр PT1): статус и поиск — параметры запроса.
Архивные всегда идут последними: страница отдаёт тот же порядок, который
раньше выстраивал браузер по полному списку.
"""
portals = portals_for_context(context)
statuses = [value for value in params.getlist("status") if value]
if statuses:
portals = portals.filter(status__in=statuses)
query = params.get("q", "").strip()
if query:
portals = portals.filter(
Q(name__icontains=query)
| Q(hosted_domain__icontains=query)
| Q(custom_domain__icontains=query)
)
return portals.annotate(
_archived=Case(
When(status=PortalStatus.ARCHIVED, then=Value(1)),
default=Value(0),
output_field=IntegerField(),
)
).order_by("_archived", "name", "id")
def portal_articles_queryset(portal: SupportPortal, params) -> QuerySet[PortalArticle]:
"""Библиотека статей портала (кадр PT3): категория, язык, статус и поиск.
Поиск идёт по последней редакции — заголовку и краткому описанию, — как и
в редакторе: сравнивать со старыми версиями было бы неожиданно.
"""
latest = PortalArticleRevision.objects.filter(article_id=OuterRef("pk")).order_by("-revision")
articles = (
PortalArticle.objects.filter(portal=portal)
.select_related("category", "published_revision")
.prefetch_related("revisions", "files", "feedback")
.annotate(
latest_title=Subquery(latest.values("title")[:1]),
latest_summary=Subquery(latest.values("summary")[:1]),
)
)
category = params.get("category")
if category and str(category).isdigit():
# Фильтр охватывает поддерево категории — тем же обходом, что и публичный портал.
articles = articles.filter(
category_id__in=descendant_category_ids(portal, int(category))
)
locales = [value for value in params.getlist("locale") if value]
if locales:
articles = articles.filter(locale__in=locales)
statuses = [value for value in params.getlist("status") if value]
if statuses:
articles = articles.filter(status__in=statuses)
query = params.get("q", "").strip()
if query:
articles = articles.filter(
Q(slug__icontains=query)
| Q(latest_title__icontains=query)
| Q(latest_summary__icontains=query)
)
return articles.order_by("-id")
def public_articles(
portal: SupportPortal,
*,
@@ -0,0 +1,118 @@
"""Списки порталов и библиотеки статей отдаются страницами."""
from chatballs.support_portals.models import (
PortalArticle,
PortalArticleRevision,
PortalCategory,
SupportPortal,
)
from chatballs.support_portals.statuses import ArticleStatus, PortalStatus
from chatballs.support_portals.tests.base import SupportPortalTestCase
class PortalListPaginationTests(SupportPortalTestCase):
def setUp(self) -> None:
super().setUp()
self.portals = [
SupportPortal.objects.create(
organization=self.organization,
slug=f"portal-{index:02d}",
hosted_domain=f"portal-{index:02d}.example.test",
name=f"Портал {index:02d}",
)
for index in range(25)
]
self.archived = self.portals[0]
self.archived.status = PortalStatus.ARCHIVED
self.archived.save(update_fields=["status"])
def _page(self, query: str = "") -> dict:
response = self.client.get(f"/api/v1/support/portals/{query}")
self.assertEqual(response.status_code, 200)
return response.json()
def test_first_page_is_bounded(self) -> None:
page = self._page()
self.assertEqual(len(page["items"]), 20)
self.assertEqual(page["total"], 25)
self.assertEqual(page["pageCount"], 2)
def test_archived_portals_go_last(self) -> None:
# Архивный портал не должен попасть на первую страницу впереди действующих.
last = self._page("?page=2")["items"][-1]
self.assertEqual(last["id"], self.archived.id)
def test_status_filter_is_applied_before_the_page(self) -> None:
page = self._page("?status=ARCHIVED")
self.assertEqual(page["total"], 1)
def test_search_is_applied_before_the_page(self) -> None:
self.assertEqual(self._page("?q=Портал 07")["total"], 1)
self.assertEqual(self._page("?q=portal-07.example.test")["total"], 1)
class ArticleLibraryPaginationTests(SupportPortalTestCase):
def setUp(self) -> None:
super().setUp()
self.portal = SupportPortal.objects.create(
organization=self.organization,
slug="library",
hosted_domain="library.example.test",
name="Библиотека",
)
self.root = PortalCategory.objects.create(
organization=self.organization, portal=self.portal, slug="root", name="Раздел"
)
self.child = PortalCategory.objects.create(
organization=self.organization,
portal=self.portal,
parent=self.root,
slug="child",
name="Подраздел",
)
self.other = PortalCategory.objects.create(
organization=self.organization, portal=self.portal, slug="other", name="Другое"
)
for index in range(30):
category = self.child if index < 4 else self.other
article = PortalArticle.objects.create(
organization=self.organization,
portal=self.portal,
category=category,
slug=f"article-{index:02d}",
locale="en" if index < 3 else "ru",
status=ArticleStatus.PUBLISHED if index < 2 else ArticleStatus.DRAFT,
)
PortalArticleRevision.objects.create(
organization=self.organization,
article=article,
revision=1,
title=f"Статья {index:02d}",
summary="Короткое описание",
content="Текст",
)
def _page(self, query: str = "") -> dict:
response = self.client.get(
f"/api/v1/support/portals/{self.portal.id}/articles/{query}"
)
self.assertEqual(response.status_code, 200)
return response.json()
def test_first_page_is_bounded(self) -> None:
page = self._page()
self.assertEqual(len(page["items"]), 25)
self.assertEqual(page["total"], 30)
self.assertEqual(page["pageCount"], 2)
def test_category_filter_covers_the_subtree(self) -> None:
# Выбран родитель — статьи из вложенного раздела тоже попадают.
self.assertEqual(self._page(f"?category={self.root.id}")["total"], 4)
def test_locale_and_status_filters_are_applied_before_the_page(self) -> None:
self.assertEqual(self._page("?locale=en")["total"], 3)
self.assertEqual(self._page("?status=PUBLISHED")["total"], 2)
def test_search_matches_slug_and_latest_revision(self) -> None:
self.assertEqual(self._page("?q=article-07")["total"], 1)
self.assertEqual(self._page("?q=Статья 07")["total"], 1)
+12 -14
View File
@@ -6,6 +6,7 @@ import { buildTheme } from "@chatballs/ui";
import { applyAppearance, DEFAULT_ACCENT, resolvedDark } from "./shared/appearance";
import { api, setActiveOrganization } from "./api/client";
import { fetchAllAgents } from "./features/agents/model";
import { canAccess, defaultRoute, isManager } from "./auth/access";
import { activateOrganization, clearOrganizationPreference } from "./auth/session";
import { AuthChangePassword, AuthLogin, AuthPasswordRecovery, AuthResetPassword, AuthSetup, AuthTotpCode, AuthTotpSetup } from "./features/auth/AuthScreens";
@@ -14,7 +15,7 @@ import { pathFromRoute, routeFromPath } from "./router";
import { ErrorScreen, LoadingScreen, PermissionScreen } from "./shared/ui";
import { useRouteNavigation } from "./useRouteNavigation";
import type { AgentCard } from "./features/agents/model";
import type { AppData, AuthChallenge, AuthenticatedUser, Employee, EmployeeGroup, SessionUser } from "./types";
import type { AppData, AuthChallenge, AuthenticatedUser, EmployeeGroup, SessionUser } from "./types";
export function App() {
const initialRoute = useMemo(() => routeFromPath(window.location.pathname, window.location.search), []);
@@ -36,7 +37,7 @@ export function App() {
const [resetting, setResetting] = useState(() => window.location.pathname === "/reset-password");
// Мастер первого запуска: пока в инстансе нет организации, вместо входа — форма создания.
const [needsSetup, setNeedsSetup] = useState(false);
const [data, setData] = useState<AppData>({ employees: [], groups: [], agents: [] });
const [data, setData] = useState<AppData>({ groups: [], agents: [] });
const [dataError, setDataError] = useState(false);
const navigation = useRouteNavigation(initialRoute, organizationPublicId);
const { navigate } = navigation;
@@ -53,21 +54,18 @@ export function App() {
const loadData = useCallback(async () => {
setDataError(false);
try {
// Списки сотрудников и агентов грузят сами страницы — постранично.
// Здесь остаются только группы: их немного, и они нужны формам и фильтрам
// по всему приложению.
const manager = Boolean(user && isManager(user));
const [employees, groups] = await Promise.all([
manager
? api<{ items: Employee[] }>("/api/v1/employees/")
: Promise.resolve({ items: [] }),
manager
? api<{ items: EmployeeGroup[] }>("/api/v1/company/groups/")
: Promise.resolve({ items: [] }),
]);
const groups = manager
? await api<{ items: EmployeeGroup[] }>("/api/v1/company/groups/")
: { items: [] };
let agents: AgentCard[] = [];
if (user && canAccess(user, "agents")) {
const agentsResponse = await api<{ items: AgentCard[] }>("/api/v1/agents/");
agents = agentsResponse.items;
agents = (await fetchAllAgents()).items;
}
setData({ employees: employees.items, groups: groups.items, agents });
setData({ groups: groups.items, agents });
} catch {
setDataError(true);
}
@@ -130,7 +128,7 @@ export function App() {
clearOrganizationPreference();
setTotpChallenge(null);
navigate("chat", null, true, null);
setData({ employees: [], groups: [], agents: [] });
setData({ groups: [], agents: [] });
}
if (resetting) {
@@ -1,5 +1,6 @@
import { EmptyState, LoadingState, PageHeader } from "../../shared/ui";
import { Button, TablePagination, paginationItems } from "../../shared/ui-controls";
import { Pagination } from "../../shared/Pagination";
import { Button } from "../../shared/ui-controls";
import { pluralRu } from "../../shared/utils";
import { AuditFilters } from "./AuditFilters";
import { AuditTable } from "./AuditTable";
@@ -59,14 +60,12 @@ export function AuditPage() {
: <AuditTable events={payload.items} />}
</div>
{payload.pageCount > 1 && (
<TablePagination
<Pagination
className="audit-pagination"
currentPage={payload.page}
pageSizeLabel={`по ${payload.pageSize} на странице`}
pages={paginationItems(payload.page, payload.pageCount)}
shown={payload.items.length}
total={payload.total}
onPageChange={(page) => setQuery({ page })}
note={`Показано ${payload.items.length} из ${payload.total} · по ${payload.pageSize} на странице`}
page={payload.page}
pageCount={payload.pageCount}
onPage={(page) => setQuery({ page })}
/>
)}
</div>
@@ -1,10 +1,12 @@
import { Dropdown, Modal } from "antd";
import { useState } from "react";
import { useCallback, useState } from "react";
import { ChannelGlyph } from "../../shared/badges";
import { FormField, SelectField } from "../../shared/form-controls";
import { Icon } from "../../shared/icons";
import { Pagination } from "../../shared/Pagination";
import { Button } from "../../shared/ui-controls";
import { usePagedResource } from "../../shared/usePagedResource";
import type { EmployeeGroup } from "../../types";
import { groupColorOf } from "../conversations/model";
import {
@@ -13,6 +15,7 @@ import {
agentTile,
agentTint,
createAgent,
fetchAgentsPage,
setAgentAiActive,
type AgentCard,
} from "./model";
@@ -122,21 +125,24 @@ function AgentRow({ card, openAgent, onToggleAi }: { card: AgentCard; openAgent:
}
export function AgentsPage({
agents,
groups,
reload,
openAgent,
}: {
agents: AgentCard[];
groups: EmployeeGroup[];
reload: () => void;
openAgent: (agentId: number) => void;
}) {
const [creating, setCreating] = useState(false);
// Страницу считает сервер: агентов у крупной организации столько же, сколько
// точек входа, и отдавать их одним списком нельзя.
const load = useCallback(
(page: number) => fetchAgentsPage({ group: "all", query: "" }, page),
[],
);
const agents = usePagedResource(load, null, "Не удалось загрузить агентов");
async function toggleAi(card: AgentCard) {
await setAgentAiActive(card.id, card.aiStatus !== "ACTIVE").catch(() => undefined);
reload();
void agents.reload();
}
return (
@@ -148,7 +154,8 @@ export function AgentsPage({
</div>
<Button variant="primary" className="agents-create" icon="plus" iconSize={15} onClick={() => setCreating(true)}>Создать агента</Button>
</header>
{agents.length === 0 ? (
{agents.errorText && <div className="agents-error">{agents.errorText}</div>}
{agents.total === 0 ? (
<div className="agents-empty">
<span><Icon name="robot" size={24} strokeWidth={1.8} /></span>
<div>
@@ -168,9 +175,17 @@ export function AgentsPage({
<span className="is-right">Открытые</span>
<span />
</div>
{agents.map((card) => (
{agents.items.map((card) => (
<AgentRow card={card} openAgent={openAgent} onToggleAi={(item) => void toggleAi(item)} key={card.id} />
))}
{agents.pageCount > 1 && (
<Pagination
note={`Показано ${agents.items.length} из ${agents.total}`}
page={agents.page}
pageCount={agents.pageCount}
onPage={agents.setPage}
/>
)}
</div>
)}
{creating && (
@@ -179,7 +194,7 @@ export function AgentsPage({
onClose={() => setCreating(false)}
onCreated={(agentId) => {
setCreating(false);
reload();
void agents.reload();
openAgent(agentId);
}}
/>
+20 -2
View File
@@ -2,6 +2,7 @@
// Источник данных — агрегированный API /api/v1/agents/.
// Экранная модель — дизайн-базлайн v2, «Агенты Baseline», кадры G1–G5, S1.
import { api } from "../../api/client";
import type { PagedPayload } from "../../shared/usePagedResource";
import { agentColorOf } from "../conversations/model";
import { channelMap, providerKey } from "../../shared/providers";
import { pluralRu, shortDate } from "../../shared/utils";
@@ -61,8 +62,25 @@ export type AgentPatch = Partial<{
limits: Record<string, unknown>;
}>;
export function fetchAgents(): Promise<{ items: AgentCard[] }> {
return api<{ items: AgentCard[] }>("/api/v1/agents/");
// Страница списка агентов (кадр G1): группа, поиск и страница — на сервере.
export type AgentListQuery = { group: number | "none" | "all"; query: string };
export function fetchAgentsPage(
{ group, query }: AgentListQuery,
page: number,
): Promise<PagedPayload<AgentCard>> {
const params = new URLSearchParams({ page: String(page) });
if (group !== "all") params.set("group", String(group));
if (query.trim()) params.set("q", query.trim());
return api<PagedPayload<AgentCard>>(`/api/v1/agents/?${params.toString()}`);
}
/** Полный набор карточек для экранов, которым нужны связи агентов со знаниями
* (библиотека знаний считает по ним прикрепления). Ограничен одной страницей
* предельного размера: этим экранам нужен серверный контракт со счётчиками,
* пока он не сделан — держим потолок явным, а не молчаливым. */
export function fetchAllAgents(): Promise<{ items: AgentCard[] }> {
return api<PagedPayload<AgentCard>>("/api/v1/agents/?pageSize=100");
}
export function fetchAgent(agentId: number): Promise<{ agent: AgentCard }> {
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useState } from "react";
import { fetchAgents, type AgentCard } from "../agents/model";
import { fetchAllAgents, type AgentCard } from "../agents/model";
export function useAiAgents() {
const [agents, setAgents] = useState<AgentCard[]>([]);
@@ -11,7 +11,7 @@ export function useAiAgents() {
setLoading(true);
setError(false);
try {
const agentsResponse = await fetchAgents();
const agentsResponse = await fetchAllAgents();
setAgents(agentsResponse.items);
} catch {
setError(true);
@@ -42,8 +42,9 @@ export function scopeLabel(scope: DialogScope): string {
}
import type { ConversationListItem, ListSort, ListTab } from "./types";
import { useConversationCall } from "./useConversationCall";
import { useDebounced } from "../../shared/useDebounced";
import { useConversationHistory } from "./useConversationHistory";
import { useConversationList, useDebounced } from "./useConversationList";
import { useConversationList } from "./useConversationList";
import { useDialogKeyboardNav } from "./useDialogKeyboardNav";
import { useIncomingMessageSound } from "./useIncomingMessageSound";
@@ -197,6 +198,7 @@ export function ConversationWorkspace({ isOwner = false, viewerId = null, listTi
total={list.total}
hasMore={list.hasMore}
onLoadMore={list.loadMore}
narrowed={Boolean(settledSearch) || listTab !== "all"}
listTab={listTab}
selectedId={selectedId ?? -1}
search={search}
@@ -12,7 +12,7 @@ import type { ConversationCounters } from "./model";
import type { ConversationListItem, ListSort, ListTab } from "./types";
import { SearchInput } from "../../shared/ui-controls";
export function DialogList({ title = "Диалоги", searchPlaceholder = "Поиск по контакту, сообщению…", viewerId = null, scope, counters, setScope, showScopeSwitcher = true, mobileHeader, hint, dialogs, total, hasMore, onLoadMore, listTab, selectedId, search, errorText, sort, setSort, onCollapse, setSearch, setListTab, setSelectedId }: {
export function DialogList({ title = "Диалоги", searchPlaceholder = "Поиск по контакту, сообщению…", viewerId = null, scope, counters, setScope, showScopeSwitcher = true, mobileHeader, hint, dialogs, total, hasMore, onLoadMore, narrowed, listTab, selectedId, search, errorText, sort, setSort, onCollapse, setSearch, setListTab, setSelectedId }: {
title?: string;
searchPlaceholder?: string;
sort: ListSort;
@@ -31,6 +31,8 @@ export function DialogList({ title = "Диалоги", searchPlaceholder = "По
total: number;
hasMore: boolean;
onLoadMore: () => void;
/** Список сужен поиском или вкладкой — пустота означает «не найдено». */
narrowed: boolean;
listTab: ListTab;
selectedId: number;
search: string;
@@ -85,9 +87,13 @@ export function DialogList({ title = "Диалоги", searchPlaceholder = "По
{hint}
<div className="sales-dialog-list-body" onScroll={onScroll}>
{errorText && <div className="sales-wait-note sales-load-error">{errorText}</div>}
{/* Кадр S1: пустой список без призыва к действию. */}
{/* Кадр S1: пустой список без призыва к действию. Под фильтром и
поиском показывается, что ничего не нашлось, а не что диалогов нет. */}
{!errorText && dialogs.length === 0 && (
<div className="sales-dialog-list-empty"><span><Icon name="message" size={20} /></span><p>Диалоги появятся, когда клиенты напишут вашему агенту</p></div>
<div className="sales-dialog-list-empty">
<span><Icon name={narrowed ? "search" : "message"} size={20} /></span>
<p>{narrowed ? "Диалоги не найдены" : "Диалоги появятся, когда клиенты напишут вашему агенту"}</p>
</div>
)}
{dialogs.map((dialog) => <DialogListItem dialog={dialog} active={dialog.id === selectedId} setSelectedId={setSelectedId} key={dialog.id} />)}
</div>
@@ -11,7 +11,6 @@ import {
// активность) и вклеивает её в уже загруженное, не сбрасывая прокрутку.
const LIST_WINDOW = 30;
const REFRESH_INTERVAL_MS = 4000;
const SEARCH_DEBOUNCE_MS = 300;
export type ConversationListState = {
conversations: ApiConversation[];
@@ -34,15 +33,6 @@ export function mergeHead(
return [...head, ...tail.filter((conversation) => !fresh.has(conversation.id))];
}
export function useDebounced<T>(value: T, delay = SEARCH_DEBOUNCE_MS): T {
const [settled, setSettled] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setSettled(value), delay);
return () => clearTimeout(timer);
}, [delay, value]);
return settled;
}
export function useConversationList(query: ConversationListQuery): ConversationListState {
const [conversations, setConversations] = useState<ApiConversation[]>([]);
const [total, setTotal] = useState(0);
@@ -14,33 +14,34 @@ import { employeeForm, employeeStatusKey, type EmployeeForm } from "./model";
// Карточка сотрудника (дизайн-базлайн v2, кадры E3/E4).
export function EmployeeDetailPage({ groups, employee, employees, reload, setRoute }: {
// Карточка грузится по идентификатору: список сотрудников постраничный, и
// открытый по ссылке человек может быть не на загруженной странице.
export function EmployeeDetailPage({ groups, employeeId, setRoute }: {
groups: EmployeeGroup[];
employee: Employee;
employees: Employee[];
reload: () => void;
employeeId: number;
setRoute: (route: RouteKey) => void;
}) {
const [currentEmployee, setCurrentEmployee] = useState(employee);
const [form, setForm] = useState<EmployeeForm>(() => employeeForm(employee));
const [currentEmployee, setCurrentEmployee] = useState<Employee | null>(null);
const [form, setForm] = useState<EmployeeForm | null>(null);
const [saving, setSaving] = useState(false);
const [busy, setBusy] = useState(false);
const [message, setMessage] = useState("");
const [issued, setIssued] = useState<IssuedPassword | null>(null);
const [transferOpen, setTransferOpen] = useState(false);
const status = employeeStatusKey(currentEmployee);
const refresh = useCallback(async () => {
const payload = await api<{ employee: Employee }>(`/api/v1/employees/${employee.id}/`);
const payload = await api<{ employee: Employee }>(`/api/v1/employees/${employeeId}/`);
setCurrentEmployee(payload.employee);
setForm(employeeForm(payload.employee));
}, [employee.id]);
}, [employeeId]);
useEffect(() => { void refresh().catch(() => undefined); }, [refresh]);
useEffect(() => { setCurrentEmployee(employee); setForm(employeeForm(employee)); setMessage(""); }, [employee]);
useEffect(() => {
setMessage("");
void refresh().catch(() => setMessage("Не удалось загрузить карточку сотрудника"));
}, [refresh]);
function updateForm(field: keyof EmployeeForm, value: string | boolean | number[]) {
setForm((current) => ({ ...current, [field]: value }));
setForm((current) => (current ? { ...current, [field]: value } : current));
setMessage("");
}
@@ -48,9 +49,8 @@ export function EmployeeDetailPage({ groups, employee, employees, reload, setRou
setSaving(true);
setMessage("");
try {
await api(`/api/v1/employees/${currentEmployee.id}/update/`, { method: "POST", body: JSON.stringify(form) });
await api(`/api/v1/employees/${employeeId}/update/`, { method: "POST", body: JSON.stringify(form) });
await refresh();
reload();
} catch (reason) {
setMessage(reason instanceof Error ? reason.message : "Не удалось сохранить изменения");
} finally {
@@ -64,7 +64,6 @@ export function EmployeeDetailPage({ groups, employee, employees, reload, setRou
try {
await action();
await refresh();
reload();
} catch (reason) {
setMessage(reason instanceof Error ? reason.message : "Не удалось выполнить действие");
} finally {
@@ -72,6 +71,14 @@ export function EmployeeDetailPage({ groups, employee, employees, reload, setRou
}
}
if (!currentEmployee || !form) {
return (
<div className="employee-page">
{message && <div className="employees-error"><Icon name="alert" size={16} strokeWidth={1.8} />{message}</div>}
</div>
);
}
const status = employeeStatusKey(currentEmployee);
// Кадр E4: на карточке владельца всегда стоит напоминание о правиле роли —
// сменить её можно только передачей владения.
const isOwnerCard = currentEmployee.role === "OWNER";
@@ -107,7 +114,7 @@ export function EmployeeDetailPage({ groups, employee, employees, reload, setRou
</div>
{issued && <EmployeePasswordDialog issued={issued} onClose={() => setIssued(null)} />}
{transferOpen && <OwnershipTransferModal employees={employees} onClose={() => setTransferOpen(false)} />}
{transferOpen && <OwnershipTransferModal onClose={() => setTransferOpen(false)} />}
</div>
);
}
@@ -1,22 +1,24 @@
import { useState } from "react";
import { useCallback, useMemo, useState } from "react";
import { hasCapability } from "../../auth/access";
import { Icon } from "../../shared/icons";
import { Pagination } from "../../shared/Pagination";
import { Button } from "../../shared/ui-controls";
import { useDebounced } from "../../shared/useDebounced";
import { usePagedResource } from "../../shared/usePagedResource";
import type { Employee, EmployeeGroup, RouteKey, SessionUser } from "../../types";
import { EmployeeCreateDrawer } from "./EmployeeCreateDrawer";
import { EmployeePasswordDialog } from "./EmployeePasswordDialog";
import { EmployeeTable } from "./EmployeeTable";
import { EmployeesFilters } from "./EmployeesFilters";
import { blockEmployee, resetEmployeePassword, terminateEmployeeSessions, type IssuedPassword } from "./api";
import { filterEmployees, type EmployeeRoleFilter } from "./model";
import { blockEmployee, fetchEmployees, resetEmployeePassword, terminateEmployeeSessions, type IssuedPassword } from "./api";
import type { EmployeeRoleFilter } from "./model";
// Список сотрудников (дизайн-базлайн v2, «Сотрудники Baseline», кадры E1/E2).
// Страницу, фильтры и поиск считает сервер: список растёт вместе с компанией.
export function EmployeesPage({ groups, employees, reload, openEmployee, user }: {
export function EmployeesPage({ groups, openEmployee, user }: {
groups: EmployeeGroup[];
employees: Employee[];
reload: () => void;
openEmployee: (employee: Employee) => void;
setRoute: (route: RouteKey) => void;
user: SessionUser;
@@ -29,7 +31,14 @@ export function EmployeesPage({ groups, employees, reload, openEmployee, user }:
const [issued, setIssued] = useState<IssuedPassword | null>(null);
const [error, setError] = useState("");
const canManage = hasCapability(user, "employees.manage");
const filtered = filterEmployees(employees, role, groupId, query);
// Поиск придерживается: запрос уходит, когда человек перестал печатать.
const settledQuery = useDebounced(query);
const filters = useMemo(
() => ({ role, groupId, query: settledQuery }),
[groupId, role, settledQuery],
);
const load = useCallback((page: number) => fetchEmployees(filters, page), [filters]);
const employees = usePagedResource(load, filters, "Не удалось загрузить сотрудников");
function resetFilters() {
setQuery(""); setRole("all"); setGroupId("all"); setMenuId(null);
@@ -39,7 +48,7 @@ export function EmployeesPage({ groups, employees, reload, openEmployee, user }:
setError("");
try {
await action();
reload();
await employees.reload();
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Не удалось выполнить действие");
}
@@ -50,7 +59,7 @@ export function EmployeesPage({ groups, employees, reload, openEmployee, user }:
<header className="employees-header">
<div>
<h2>Сотрудники</h2>
<p>Роли и группы · показано <b>{filtered.length}</b> из {employees.length}</p>
<p>Роли и группы · показано <b>{employees.items.length}</b> из {employees.total}</p>
</div>
{canManage && (
<Button className="employees-create" variant="primary" icon="team" iconSize={16} onClick={() => setCreateOpen(true)}>Добавить сотрудника</Button>
@@ -68,10 +77,10 @@ export function EmployeesPage({ groups, employees, reload, openEmployee, user }:
setRole={(value) => { setRole(value); setMenuId(null); }}
/>
{error && <div className="employees-error"><Icon name="alert" size={16} strokeWidth={1.8} />{error}</div>}
{(error || employees.errorText) && <div className="employees-error"><Icon name="alert" size={16} strokeWidth={1.8} />{error || employees.errorText}</div>}
<EmployeeTable
employees={filtered}
employees={employees.items}
groups={groups}
menuId={menuId}
onBlock={(employee) => void run(() => blockEmployee(employee.id, !employee.isBlocked))}
@@ -79,14 +88,23 @@ export function EmployeesPage({ groups, employees, reload, openEmployee, user }:
onTerminateSessions={(employee) => void run(() => terminateEmployeeSessions(employee.id))}
openEmployee={openEmployee}
setMenuId={setMenuId}
total={employees.length}
total={employees.total}
/>
{employees.pageCount > 1 && (
<Pagination
className="employees-pagination"
note={`Показано ${employees.items.length} из ${employees.total}`}
page={employees.page}
pageCount={employees.pageCount}
onPage={employees.setPage}
/>
)}
{createOpen && (
<EmployeeCreateDrawer
groups={groups}
onClose={() => setCreateOpen(false)}
onCreated={(password) => { setCreateOpen(false); reload(); if (password) setIssued(password); }}
onCreated={(password) => { setCreateOpen(false); void employees.reload(); if (password) setIssued(password); }}
/>
)}
{issued && <EmployeePasswordDialog issued={issued} onClose={() => setIssued(null)} />}
@@ -1,9 +1,10 @@
import { useMemo, useState } from "react";
import { useEffect, useState } from "react";
import { api } from "../../api/client";
import { Icon } from "../../shared/icons";
import { Avatar } from "../../shared/ui";
import type { Employee, Role } from "../../types";
import { fetchOwner, fetchOwnershipCandidates } from "./api";
import { employeeAvatarColor, roleBadge } from "./model";
// Передача владения (дизайн-базлайн v2, кадр E9). Кандидаты — только активные
@@ -14,18 +15,37 @@ const PREVIOUS_ROLES: Array<{ value: Extract<Role, "ADMIN" | "EMPLOYEE">; label:
{ value: "EMPLOYEE", label: "Сотрудник" },
];
export function OwnershipTransferModal({ employees, onClose }: { employees: Employee[]; onClose: () => void }) {
const owner = employees.find((employee) => employee.role === "OWNER") ?? null;
const candidates = useMemo(
() => employees.filter((employee) => employee.role === "ADMIN" && employee.isActive && !employee.isBlocked),
[employees],
);
const [targetId, setTargetId] = useState<number | null>(candidates[0]?.id ?? null);
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 [previousOwnerRole, setPreviousOwnerRole] = useState<Extract<Role, "ADMIN" | "EMPLOYEE">>("ADMIN");
const [confirmed, setConfirmed] = useState(false);
const [sending, setSending] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
let active = true;
void Promise.all([fetchOwner(), fetchOwnershipCandidates()])
.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);
})
.catch(() => {
if (active) setError("Не удалось загрузить кандидатов");
});
return () => {
active = false;
};
}, []);
if (!owner) return null;
const target = candidates.find((employee) => employee.id === targetId) ?? null;
@@ -1,5 +1,7 @@
import { api } from "../../api/client";
import type { PagedPayload } from "../../shared/usePagedResource";
import type { Employee, Role } from "../../types";
import type { EmployeeRoleFilter } from "./model";
// Действия над сотрудником (дизайн-базлайн v2, кадры E2, E3, E5–E8).
// Пароль первичного доступа сервер отдаёт ровно один раз — открытым он нигде
@@ -46,3 +48,31 @@ export async function terminateEmployeeSessions(userId: number): Promise<void> {
export async function blockEmployee(userId: number, block: boolean): Promise<void> {
await api(`/api/v1/employees/${userId}/${block ? "block" : "unblock"}/`, { method: "POST" });
}
// Список сотрудников: страница, роль, группа и поиск считает сервер (кадры E1/E2).
export type EmployeeListQuery = {
role: EmployeeRoleFilter;
groupId: number | "all";
query: string;
};
export function fetchEmployees(
{ role, groupId, query }: EmployeeListQuery,
page: number,
): Promise<PagedPayload<Employee>> {
const params = new URLSearchParams({ page: String(page) });
if (role !== "all") params.set("role", role);
if (groupId !== "all") params.set("group", String(groupId));
if (query.trim()) params.set("q", query.trim());
return api<PagedPayload<Employee>>(`/api/v1/employees/?${params.toString()}`);
}
/** Кандидаты на владение (кадр E9) — только действующие администраторы.
* Их единицы, поэтому берётся одна страница; роль отбирает сервер. */
export function fetchOwnershipCandidates(): Promise<PagedPayload<Employee>> {
return api<PagedPayload<Employee>>("/api/v1/employees/?role=ADMIN&pageSize=100");
}
export function fetchOwner(): Promise<PagedPayload<Employee>> {
return api<PagedPayload<Employee>>("/api/v1/employees/?role=OWNER");
}
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import type { Employee } from "../../types";
import { filterEmployees, groupsLabel, roleAccessLabel } from "./model";
import { groupsLabel, roleAccessLabel } from "./model";
const baseEmployee: Employee = {
id: 1,
@@ -19,13 +19,6 @@ const baseEmployee: Employee = {
};
describe("employee list model", () => {
it("combines role, group and text filters", () => {
const owner = { ...baseEmployee, id: 2, email: "owner@example.test", fullName: "Иван Петров", role: "OWNER" as const, groups: [] };
expect(filterEmployees([baseEmployee, owner], "EMPLOYEE", 10, "оператор")).toEqual([baseEmployee]);
expect(filterEmployees([baseEmployee, owner], "OWNER", "all", "owner@")).toEqual([owner]);
expect(filterEmployees([baseEmployee, owner], "all", 99, "")).toEqual([]);
});
it("labels groups and role-based access", () => {
expect(groupsLabel(baseEmployee)).toBe("Операторы");
expect(groupsLabel({ ...baseEmployee, groups: [] })).toBe("Без группы");
@@ -64,22 +64,6 @@ export function employeeStatusKey(employee: Employee): EmployeeStatus {
return "active";
}
export function filterEmployees(
employees: Employee[],
role: EmployeeRoleFilter,
groupId: number | "all",
query: string,
) {
const q = query.trim().toLowerCase();
return employees.filter((employee) => (
(role === "all" || employee.role === role)
&& (groupId === "all" || employee.groups.some((group) => group.id === groupId))
&& (!q
|| employee.fullName.toLowerCase().includes(q)
|| employee.email.toLowerCase().includes(q)
|| employee.positionTitle.toLowerCase().includes(q))
));
}
export function employeeForm(employee: Employee): EmployeeForm {
return {
@@ -1,28 +1,55 @@
import { useEffect, useState } from "react";
import { EmptyState, LoadingState } from "../../shared/ui";
import { useAiAgents } from "../ai/useAiAgents";
import { SalesClientsEmpty } from "./clients/SalesClientsEmpty";
import { SalesClientsFilters } from "./clients/SalesClientsFilters";
import { SalesClientsHeader } from "./clients/SalesClientsHeader";
import { SalesClientsTable } from "./clients/SalesClientsTable";
import { fetchClientsForExport, fetchClientsTotal } from "./clients/api";
import { exportClientsCsv } from "./clients/exportClientsCsv";
import { useClientsData } from "./clients/useClientsData";
import { toSalesClient, toSalesClientRow } from "./clients/model";
import { useSalesClients } from "./clients/useSalesClients";
// «Контакты» (дизайн-базлайн v2, кадры K1/K2/S1).
// «Контакты» (дизайн-базлайн v2, кадры K1/K2/S1). Страница списка — серверная.
export function SalesClientsPage({ openClient, openIntegrations }: { openClient: (id: number) => void; openIntegrations: () => void }) {
const { clients: data, loading, error } = useClientsData();
const clients = useSalesClients(data);
const clients = useSalesClients();
const { agents } = useAiAgents();
// Счётчик «6 из 128»: знаменатель — весь список без фильтров.
const [totalCount, setTotalCount] = useState<number | null>(null);
useEffect(() => {
let active = true;
void fetchClientsTotal()
.then((total) => { if (active) setTotalCount(total); })
.catch(() => undefined);
return () => { active = false; };
}, []);
if (loading) return <div className="sales-clients-page"><LoadingState /></div>;
if (error) return <div className="sales-clients-page"><EmptyState title="Не удалось загрузить контакты" /></div>;
async function exportCsv() {
const rows = await fetchClientsForExport(clients.request);
exportClientsCsv(rows.map((row) => toSalesClientRow(toSalesClient(row))));
}
if (clients.loading && clients.rows.length === 0 && totalCount === null) {
return <div className="sales-clients-page"><LoadingState /></div>;
}
if (clients.errorText) {
return <div className="sales-clients-page"><EmptyState title="Не удалось загрузить контакты" /></div>;
}
return (
<div className="sales-clients-page">
<SalesClientsHeader shownCount={clients.filteredCount} totalCount={data.length} filtered={clients.filtered} onExport={() => exportClientsCsv(clients.filteredRows)} />
{data.length === 0 ? (
<SalesClientsHeader
shownCount={clients.total}
totalCount={totalCount ?? clients.total}
filtered={clients.filtered}
onExport={() => void exportCsv()}
/>
{(totalCount ?? 0) === 0 && !clients.filtered ? (
<SalesClientsEmpty onOpenIntegrations={openIntegrations} />
) : (
<>
<SalesClientsFilters clients={clients} salesClients={data} />
<SalesClientsFilters clients={clients} agents={agents.map((agent) => ({ id: agent.id, code: agent.code, name: agent.name }))} />
<SalesClientsTable clients={clients} openClient={openClient} />
</>
)}
@@ -1,12 +1,13 @@
import { FilterDropdown, SearchInput } from "../../../shared/ui-controls";
import { agentOptionsOf, channelOptions, type SalesClient } from "./model";
import { channelOptions, type ClientAgentRef } from "./model";
import type { SalesClientsState } from "./useSalesClients";
// Фильтры списка (кадры K1/K2): поиск, «Все каналы», «Все агенты», чип
// «С открытым диалогом»; «Сбросить» появляется только когда фильтр применён.
export function SalesClientsFilters({ clients, salesClients }: { clients: SalesClientsState; salesClients: SalesClient[] }) {
const agents = agentOptionsOf(salesClients);
// Агенты для фильтра приходят из справочника агентов: список контактов теперь
// постраничный, и собрать их по загруженной странице нельзя.
export function SalesClientsFilters({ clients, agents }: { clients: SalesClientsState; agents: ClientAgentRef[] }) {
return (
<div className="sales-clients-filterbar">
<SearchInput
@@ -1,21 +0,0 @@
import { Icon } from "../../../shared/icons";
import { paginationItems } from "../../../shared/ui-controls";
// Страницы в подвале карточки списка (кадр K1).
export function SalesClientsPagination({ page, pageCount, setPage }: { page: number; pageCount: number; setPage: (page: number) => void }) {
return (
<div className="sales-clients-pager">
<button type="button" aria-label="Предыдущая страница" disabled={page === 1} onClick={() => setPage(page - 1)}>
<Icon name="chevronLeft" size={13} strokeWidth={2.2} />
</button>
{paginationItems(page, pageCount).map((item, index) => (
item === "ellipsis"
? <span key={`gap-${index}`}>…</span>
: <button className={item === page ? "is-active" : ""} type="button" key={item} onClick={() => setPage(item)}>{item}</button>
))}
<button className="is-next" type="button" aria-label="Следующая страница" disabled={page === pageCount} onClick={() => setPage(page + 1)}>
<Icon name="chevronRight" size={13} strokeWidth={2.2} />
</button>
</div>
);
}
@@ -1,6 +1,6 @@
import { Icon } from "../../../shared/icons";
import { SalesClientRow } from "./SalesClientRow";
import { SalesClientsPagination } from "./SalesClientsPagination";
import { Pagination } from "../../../shared/Pagination";
import type { SalesClientsState } from "./useSalesClients";
// Таблица контактов (кадр K1): шапка · строки 60px · подвал со страницами.
@@ -9,6 +9,7 @@ import type { SalesClientsState } from "./useSalesClients";
export function SalesClientsTable({ clients, openClient }: { clients: SalesClientsState; openClient: (id: number) => void }) {
const from = (clients.page - 1) * clients.pageSize + 1;
const to = from + clients.rows.length - 1;
const shown = clients.total === 0 ? "Ничего не найдено" : `${from}–${to} из ${clients.total}`;
return (
<div className="sales-clients-card">
<div className="sales-clients-head">
@@ -35,10 +36,13 @@ export function SalesClientsTable({ clients, openClient }: { clients: SalesClien
<span>Измените условия фильтра или сбросьте их.</span>
</div>
)}
<div className="sales-clients-foot">
<small>{clients.filteredCount === 0 ? "Ничего не найдено" : `${from}–${to} из ${clients.filteredCount}`}</small>
{clients.pageCount > 1 && <SalesClientsPagination page={clients.page} pageCount={clients.pageCount} setPage={clients.setPage} />}
</div>
<Pagination
className="sales-clients-foot"
note={shown}
page={clients.page}
pageCount={clients.pageCount}
onPage={clients.setPage}
/>
</div>
);
}
@@ -0,0 +1,59 @@
import { api } from "../../../api/client";
import type { PagedPayload } from "../../../shared/usePagedResource";
import type { ApiClient, ClientChannelCode, ClientSortKey } from "./model";
// Список контактов (кадры K1/K2): страницу, фильтры, поиск и порядок считает
// сервер — контактов у работающей организации столько же, сколько клиентов.
export type ClientsQuery = {
query: string;
agentFilter: number[];
channelFilter: ClientChannelCode[];
openOnly: boolean;
sortKey: ClientSortKey;
sortDir: "asc" | "desc";
};
// Выгрузка CSV идёт теми же фильтрами, но крупными страницами; потолок в
// 10 000 строк защищает браузер от выгрузки всей базы одним файлом.
const EXPORT_PAGE_SIZE = 100;
const EXPORT_PAGE_LIMIT = 100;
function searchParams({ query, agentFilter, channelFilter, openOnly, sortKey, sortDir }: ClientsQuery): URLSearchParams {
const params = new URLSearchParams();
if (query.trim()) params.set("q", query.trim());
for (const agent of agentFilter) params.append("agent", String(agent));
for (const channel of channelFilter) params.append("channel", channel);
if (openOnly) params.set("open", "1");
params.set("sort", sortKey === "open" ? "open" : "last");
params.set("dir", sortDir);
return params;
}
export function fetchClients(query: ClientsQuery, page: number): Promise<PagedPayload<ApiClient>> {
const params = searchParams(query);
params.set("page", String(page));
return api<PagedPayload<ApiClient>>(`/api/v1/conversations/clients/?${params.toString()}`);
}
/** Все строки под текущими фильтрами — для выгрузки в CSV. */
export async function fetchClientsForExport(query: ClientsQuery): Promise<ApiClient[]> {
const rows: ApiClient[] = [];
for (let page = 1; page <= EXPORT_PAGE_LIMIT; page += 1) {
const params = searchParams(query);
params.set("page", String(page));
params.set("pageSize", String(EXPORT_PAGE_SIZE));
const payload = await api<PagedPayload<ApiClient>>(
`/api/v1/conversations/clients/?${params.toString()}`,
);
rows.push(...payload.items);
if (payload.page >= payload.pageCount) break;
}
return rows;
}
/** Сколько контактов всего — для счётчика «6 из 128» в шапке. Берётся одна
* строка: нужен только total. */
export function fetchClientsTotal(): Promise<number> {
return api<PagedPayload<ApiClient>>("/api/v1/conversations/clients/?pageSize=1").then((page) => page.total);
}
@@ -51,11 +51,6 @@ export const channelOptions: Array<{ code: ClientChannelCode; name: string; colo
{ code: "WEB", name: "Web-виджет", color: channelMap.WEB.color },
];
export function agentOptionsOf(clients: SalesClient[]): ClientAgentRef[] {
const seen = new Map<number, ClientAgentRef>();
for (const client of clients) for (const agent of client.agents) seen.set(agent.id, agent);
return [...seen.values()].sort((a, b) => a.name.localeCompare(b.name, "ru"));
}
const statusDot = {
wait: "#faad14",
@@ -366,50 +366,6 @@
font-size: 12px;
}
.sales-clients-pager {
display: flex;
align-items: center;
gap: 4px;
}
.sales-clients-pager button {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 28px;
height: 28px;
padding: 0 6px;
border: 1px solid var(--n-7);
border-radius: 7px;
background: var(--surface-card);
color: var(--n-2);
font-family: inherit;
font-size: 12.5px;
font-weight: 600;
cursor: pointer;
}
.sales-clients-pager button:hover:not(:disabled) {
background: var(--n-9);
}
.sales-clients-pager button:disabled {
color: var(--n-5);
cursor: default;
}
.sales-clients-pager button.is-active {
border-color: var(--primary);
background: var(--primary-bg);
color: var(--primary-text);
}
.sales-clients-pager > span {
color: var(--n-5);
font-size: 12.5px;
}
/* --- Пустые состояния --- */
.sales-clients-empty {
@@ -1,29 +0,0 @@
import { useEffect, useState } from "react";
import { api } from "../../../api/client";
import { toSalesClient, type ApiClient, type SalesClient } from "./model";
export function useClientsData() {
const [clients, setClients] = useState<SalesClient[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
useEffect(() => {
let active = true;
api<{ items: ApiClient[] }>("/api/v1/conversations/clients/")
.then((data) => {
if (active) setClients(data.items.map(toSalesClient));
})
.catch(() => {
if (active) setError(true);
})
.finally(() => {
if (active) setLoading(false);
});
return () => {
active = false;
};
}, []);
return { clients, loading, error };
}
@@ -1,14 +1,18 @@
import { useEffect, useMemo, useState } from "react";
import { useCallback, useMemo, useState } from "react";
import type { ClientChannelCode, ClientDropdown, ClientSortKey, SalesClient } from "./model";
import { toSalesClientRow } from "./model";
import { useDebounced } from "../../../shared/useDebounced";
import { usePagedResource } from "../../../shared/usePagedResource";
import { fetchClients, type ClientsQuery } from "./api";
import type { ClientChannelCode, ClientDropdown, ClientSortKey } from "./model";
import { toSalesClient, toSalesClientRow } from "./model";
export type SalesClientsState = ReturnType<typeof useSalesClients>;
// Фильтры списка контактов (кадры K1/K2): поиск, каналы, агенты и чип
// «С открытым диалогом». Фильтра по продуктам нет (ADR-CHATBALLS-0041).
// Всё это — параметры запроса: страница приходит с сервера уже отобранной.
export function useSalesClients(salesClients: SalesClient[]) {
export function useSalesClients() {
const [query, setQueryState] = useState("");
const [agentFilter, setAgentFilter] = useState<number[]>([]);
const [channelFilter, setChannelFilter] = useState<ClientChannelCode[]>([]);
@@ -17,45 +21,29 @@ export function useSalesClients(salesClients: SalesClient[]) {
const [sortDir, setSortDir] = useState<"asc" | "desc">("asc");
const [menu, setMenu] = useState<string | null>(null);
const [dropdown, setDropdown] = useState<ClientDropdown | null>(null);
const [page, setPage] = useState(1);
const pageSize = 20;
const settledQuery = useDebounced(query);
const filteredRows = useMemo(() => {
const normalizedQuery = query.trim().toLowerCase();
const filtered = salesClients.filter((client) => {
return (!normalizedQuery || client.name.toLowerCase().includes(normalizedQuery) || client.email.toLowerCase().includes(normalizedQuery) || client.phone.includes(normalizedQuery) || client.username.toLowerCase().includes(normalizedQuery))
&& (agentFilter.length === 0 || client.agents.some((agent) => agentFilter.includes(agent.id)))
&& (channelFilter.length === 0 || client.channels.some((channel) => channelFilter.includes(channel)))
&& (!openOnly || client.openDialogs > 0);
});
const key = { last: "last", open: "openDialogs" } satisfies Record<ClientSortKey, keyof typeof salesClients[number]>;
return [...filtered]
.sort((left, right) => sortDir === "asc" ? Number(left[key[sortKey]]) - Number(right[key[sortKey]]) : Number(right[key[sortKey]]) - Number(left[key[sortKey]]))
.map(toSalesClientRow);
}, [salesClients, agentFilter, channelFilter, openOnly, query, sortDir, sortKey]);
const pageCount = Math.max(1, Math.ceil(filteredRows.length / pageSize));
const request: ClientsQuery = useMemo(
() => ({ query: settledQuery, agentFilter, channelFilter, openOnly, sortKey, sortDir }),
[agentFilter, channelFilter, openOnly, settledQuery, sortDir, sortKey],
);
const load = useCallback((page: number) => fetchClients(request, page), [request]);
const page = usePagedResource(load, request, "Не удалось загрузить контакты");
const rows = useMemo(
() => filteredRows.slice((page - 1) * pageSize, page * pageSize),
[filteredRows, page],
() => page.items.map((item) => toSalesClientRow(toSalesClient(item))),
[page.items],
);
useEffect(() => {
setPage((current) => Math.min(current, pageCount));
}, [pageCount]);
function setQuery(value: string) {
setQueryState(value);
setPage(1);
setMenu(null);
}
function toggleAgent(id: number) {
setPage(1);
setAgentFilter((current) => current.includes(id) ? current.filter((item) => item !== id) : [...current, id]);
}
function toggleChannel(code: string) {
setPage(1);
setChannelFilter((current) => current.includes(code as ClientChannelCode) ? current.filter((item) => item !== code) : [...current, code as ClientChannelCode]);
}
@@ -75,21 +63,23 @@ export function useSalesClients(salesClients: SalesClient[]) {
setAgentFilter([]);
setChannelFilter([]);
setOpenOnly(false);
setPage(1);
setMenu(null);
setDropdown(null);
}
return {
rows,
filteredRows,
filteredCount: filteredRows.length,
request,
loading: page.loading,
errorText: page.errorText,
total: page.total,
// Кадр K2: «Сбросить» и счётчик «6 из 128» показываются только при фильтре.
filtered: Boolean(query.trim()) || agentFilter.length > 0 || channelFilter.length > 0 || openOnly,
page,
pageCount,
pageSize,
setPage,
page: page.page,
pageCount: page.pageCount,
pageSize: page.pageSize,
setPage: page.setPage,
reload: page.reload,
query,
agentFilter,
channelFilter,
@@ -105,7 +95,7 @@ export function useSalesClients(salesClients: SalesClient[]) {
toggleDropdown,
setDropdown,
closeDropdown: () => setDropdown(null),
toggleOpenOnly: () => { setOpenOnly((current) => !current); setPage(1); setMenu(null); setDropdown(null); },
toggleOpenOnly: () => { setOpenOnly((current) => !current); setMenu(null); setDropdown(null); },
sortBy,
reset,
};
@@ -1,5 +1,5 @@
import { Dropdown } from "antd";
import { useMemo, useState } from "react";
import { useCallback, useMemo, useState } from "react";
import {
AgentLinkDialog,
@@ -13,9 +13,13 @@ import { Button, FilterDropdown, SearchInput } from "../../shared/ui-controls";
import { pluralRu } from "../../shared/utils";
import { agentLinkOptions } from "../ai/agentOptions";
import { linkPortalArticlesToAgent } from "../ai/knowledge/model";
import { Pagination } from "../../shared/Pagination";
import { useDebounced } from "../../shared/useDebounced";
import { usePagedResource } from "../../shared/usePagedResource";
import { useAiAgents } from "../ai/useAiAgents";
import {
archivePortalArticle,
listPortalArticles,
portalErrorMessage,
ARTICLE_STATUS_LABEL,
type ArticleStatus,
@@ -24,13 +28,11 @@ import {
} from "./model";
import { PortalArticleImportModal } from "./PortalArticleImportModal";
import { PortalCategoryManagement } from "./PortalCategoryManagement";
import { PortalTableFooter } from "./PortalTableFooter";
import { LOCALE_OPTIONS, revisionSummary, updatedAt } from "./portalText";
// Библиотека материалов портала (дизайн-базлайн v2, кадр PT3): дерево разделов
// 260px с вложенностью и счётчиками, тулбар и таблица статей — без вкладок.
const PAGE_SIZE = 25;
const ARTICLE_FORMS: [string, string, string] = ["статья", "статьи", "статей"];
const STATUS_FILTER = [
@@ -70,7 +72,6 @@ function sectionRows(categories: PortalCategory[]): SectionRow[] {
}
export function PortalLibrary({
articles,
canLinkAgents,
canManage,
categories,
@@ -78,7 +79,6 @@ export function PortalLibrary({
reload,
onEditArticle,
}: {
articles: PortalArticle[];
canLinkAgents: boolean;
canManage: boolean;
categories: PortalCategory[];
@@ -92,7 +92,6 @@ export function PortalLibrary({
const [languageOpen, setLanguageOpen] = useState(false);
const [status, setStatus] = useState<string[]>([]);
const [statusOpen, setStatusOpen] = useState(false);
const [page, setPage] = useState(1);
const [managingCategories, setManagingCategories] = useState(false);
const [importOpen, setImportOpen] = useState(false);
const [linking, setLinking] = useState<PortalArticle | null>(null);
@@ -108,32 +107,21 @@ export function PortalLibrary({
.filter((category) => category.parentId === null)
.reduce((sum, category) => sum + category.articleCount, 0);
const filtered = useMemo(() => {
const childIds = new Set<number>();
if (selectedCategory !== undefined) {
const pending = [selectedCategory];
while (pending.length) {
const id = pending.pop()!;
childIds.add(id);
categories.filter((item) => item.parentId === id).forEach((item) => pending.push(item.id));
}
}
const normalized = query.trim().toLocaleLowerCase();
return articles.filter((article) => (
(selectedCategory === undefined || childIds.has(article.category.id))
&& (language.length === 0 || language.includes(article.locale))
&& (status.length === 0 || status.includes(article.status))
&& (!normalized || [
article.slug,
article.latestRevision?.title,
article.latestRevision?.summary,
].some((value) => value?.toLocaleLowerCase().includes(normalized)))
));
}, [articles, categories, language, query, selectedCategory, status]);
const pageCount = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
const currentPage = Math.min(page, pageCount);
const visible = filtered.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE);
// Категория (вместе с вложенными), язык, статус, поиск и страница — запрос к
// серверу: статей в библиотеке может быть сколько угодно.
const settledQuery = useDebounced(query);
const request = useMemo(
() => ({ category: selectedCategory, locale: language, status, search: settledQuery }),
[language, selectedCategory, settledQuery, status],
);
const loadArticles = useCallback(
(page: number) => listPortalArticles(portalId, request, page),
[portalId, request],
);
const articles = usePagedResource(loadArticles, request, "Не удалось загрузить статьи");
const reloadAll = useCallback(async () => {
await Promise.all([reload(), articles.reload()]);
}, [articles, reload]);
async function submitAgentLink(agentId: number, action: AgentLinkAction) {
if (!linking) return;
@@ -156,7 +144,7 @@ export function PortalLibrary({
try {
await archivePortalArticle(portalId, archiving.id);
setArchiving(null);
await reload();
await reloadAll();
} catch (caught) {
setError(portalErrorMessage(caught, "Не удалось изменить статью"));
setArchiving(null);
@@ -177,7 +165,7 @@ export function PortalLibrary({
className={`portal-section-row${selectedCategory === undefined ? " is-active" : ""}`}
style={{ paddingLeft: 9 }}
type="button"
onClick={() => { setSelectedCategory(undefined); setPage(1); }}
onClick={() => { setSelectedCategory(undefined); }}
>
<Icon name="folder" size={15} strokeWidth={1.8} />
<span>Все материалы</span>
@@ -189,7 +177,7 @@ export function PortalLibrary({
key={category.id}
style={{ paddingLeft: 9 + depth * 16 }}
type="button"
onClick={() => { setSelectedCategory(category.id); setPage(1); }}
onClick={() => { setSelectedCategory(category.id); }}
>
<span>{category.name}</span>
<small>{count}</small>
@@ -212,7 +200,7 @@ export function PortalLibrary({
className="portal-library-search"
placeholder="Поиск по статьям"
value={query}
onChange={(value) => { setQuery(value); setPage(1); }}
onChange={setQuery}
/>
<FilterDropdown
caption="Язык:"
@@ -223,7 +211,6 @@ export function PortalLibrary({
selected={language}
onOpenChange={setLanguageOpen}
onSelect={(value) => {
setPage(1);
setLanguage((current) => (current.includes(value)
? current.filter((item) => item !== value)
: [...current, value]));
@@ -238,7 +225,6 @@ export function PortalLibrary({
selected={status}
onOpenChange={setStatusOpen}
onSelect={(value) => {
setPage(1);
setStatus((current) => (current.includes(value)
? current.filter((item) => item !== value)
: [...current, value]));
@@ -268,7 +254,7 @@ export function PortalLibrary({
</tr>
</thead>
<tbody>
{visible.map((article) => {
{articles.items.map((article) => {
const title = article.latestRevision?.title || article.slug;
const { revision, note } = revisionSummary(article);
const helpful = article.feedback?.helpful ?? 0;
@@ -334,11 +320,11 @@ export function PortalLibrary({
</tbody>
</table>
<PortalTableFooter
note={`${pluralRu(articles.length, ARTICLE_FORMS)} · показаны ${visible.length} · агент отвечает только по опубликованным`}
page={currentPage}
pageCount={pageCount}
onPageChange={setPage}
<Pagination
note={`${pluralRu(articles.total, ARTICLE_FORMS)} · показаны ${articles.items.length} · агент отвечает только по опубликованным`}
page={articles.page}
pageCount={articles.pageCount}
onPage={articles.setPage}
/>
</div>
</div>
@@ -367,7 +353,7 @@ export function PortalLibrary({
<PortalArticleImportModal
portalId={portalId}
onClose={() => setImportOpen(false)}
onImported={() => void reload()}
onImported={() => void reloadAll()}
/>
)}
<DecisionDialog
@@ -1,48 +0,0 @@
import type { ReactNode } from "react";
import { Icon } from "../../shared/icons";
/** Подвал таблиц порталов: подпись слева, страницы справа (кадры PT1/PT3).
* Один вид на оба списка раздела — у списка порталов и у библиотеки статей
* подвал в макете одинаковый; стрелки на границах гасятся (кадр PT1). */
export function PortalTableFooter({
note,
page,
pageCount,
onPageChange,
}: {
note: ReactNode;
page: number;
pageCount: number;
onPageChange: (page: number) => void;
}) {
const pages = Array.from({ length: pageCount }, (_, index) => index + 1);
return (
<div className="portal-table-footer">
<span>{note}</span>
<span className="portal-pages">
<button
aria-label="Предыдущая страница"
className="portal-page-step"
disabled={page <= 1}
type="button"
onClick={() => onPageChange(page - 1)}
>
<Icon name="chevronLeft" size={14} strokeWidth={2} />
</button>
{pages.map((item) => (item === page
? <b key={item}>{item}</b>
: <button className="portal-page-number" key={item} type="button" onClick={() => onPageChange(item)}>{item}</button>))}
<button
aria-label="Следующая страница"
className="portal-page-step"
disabled={page >= pageCount}
type="button"
onClick={() => onPageChange(page + 1)}
>
<Icon name="chevronRight" size={14} strokeWidth={2} />
</button>
</span>
</div>
);
}
@@ -7,7 +7,6 @@ import { Button } from "../../shared/ui-controls";
import type { SessionUser } from "../../types";
import {
changePortalStatus,
listPortalArticles,
listPortalCategories,
listSupportPortals,
loadSupportPortal,
@@ -49,7 +48,6 @@ export function SupportPortalDetailPage({
const [portal, setPortal] = useState<SupportPortal | null>(null);
const [address, setAddress] = useState<PortalAddressConfig | null>(null);
const [categories, setCategories] = useState<PortalCategory[]>([]);
const [articles, setArticles] = useState<PortalArticle[]>([]);
const [editing, setEditing] = useState<PortalArticle | null | undefined>(undefined);
const [publishing, setPublishing] = useState<Publishing | null>(null);
const [pendingStatus, setPendingStatus] = useState<PortalStatus | null>(null);
@@ -62,15 +60,14 @@ export function SupportPortalDetailPage({
if (!portalId) return;
setFailed(false);
try {
const [portalPayload, categoryPayload, articlePayload, listPayload] = await Promise.all([
// Статьи грузит сама библиотека — постранично, со своими фильтрами.
const [portalPayload, categoryPayload, listPayload] = await Promise.all([
loadSupportPortal(portalId),
listPortalCategories(portalId),
listPortalArticles(portalId),
listSupportPortals(),
]);
setPortal(portalPayload.portal);
setCategories(categoryPayload.items);
setArticles(articlePayload.items);
setAddress(listPayload.address);
} catch {
setFailed(true);
@@ -164,7 +161,6 @@ export function SupportPortalDetailPage({
{section === null ? (
<PortalLibrary
articles={articles}
canLinkAgents={hasCapability(user, "ai.manage")}
canManage={canManage && portal.status !== "ARCHIVED"}
categories={categories}
@@ -3,8 +3,11 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { hasCapability } from "../../auth/access";
import { Icon } from "../../shared/icons";
import { Pagination } from "../../shared/Pagination";
import { ErrorScreen, LoadingState, StatusPill } from "../../shared/ui";
import { Button, CopyButton, FilterDropdown, SearchInput } from "../../shared/ui-controls";
import { useDebounced } from "../../shared/useDebounced";
import { usePagedResource } from "../../shared/usePagedResource";
import { pluralRu } from "../../shared/utils";
import type { SessionUser } from "../../types";
import {
@@ -23,7 +26,6 @@ import {
updatedAt,
} from "./portalText";
import { PortalCreateDialog } from "./PortalCreateDialog";
import { PortalTableFooter } from "./PortalTableFooter";
import { DecisionDialog } from "../../shared/DecisionDialog";
import "./styles";
@@ -31,8 +33,6 @@ import "./styles";
// ссылкой и копированием, действия строки — в меню ⋯, архивные приглушены и
// уходят в конец списка.
const PAGE_SIZE = 20;
const STATUS_FILTER: Array<{ value: string; label: string }> = [
{ value: "PUBLISHED", label: PORTAL_STATUS_LABEL.PUBLISHED },
{ value: "DRAFT", label: PORTAL_STATUS_LABEL.DRAFT },
@@ -54,50 +54,30 @@ export function SupportPortalsPage({
openPortal: (portalId: number) => void;
openPortalSettings: (portalId: number) => void;
}) {
const [portals, setPortals] = useState<SupportPortal[] | null>(null);
const [address, setAddress] = useState<PortalAddressConfig | null>(null);
const [failed, setFailed] = useState(false);
const [search, setSearch] = useState("");
const [status, setStatus] = useState<string[]>([]);
const [statusOpen, setStatusOpen] = useState(false);
const [page, setPage] = useState(1);
const [creating, setCreating] = useState(false);
const [archiving, setArchiving] = useState<SupportPortal | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const canManage = hasCapability(user, "support.operate");
const load = useCallback(async () => {
// Страницу, фильтр по статусу и поиск считает сервер: порталов может быть
// сколько угодно, и резать список в браузере нельзя.
const settledSearch = useDebounced(search);
const query = useMemo(() => ({ status, search: settledSearch }), [settledSearch, status]);
const loadPage = useCallback(async (page: number) => {
const payload = await listSupportPortals(query, page);
setAddress(payload.address);
setFailed(false);
try {
const payload = await listSupportPortals();
setPortals(payload.items);
setAddress(payload.address);
} catch {
setFailed(true);
}
}, []);
return payload;
}, [query]);
const portals = usePagedResource(loadPage, query, "Не удалось загрузить порталы");
const load = portals.reload;
useEffect(() => {
void load();
}, [load]);
const filtered = useMemo(() => {
const query = search.trim().toLocaleLowerCase();
return (portals ?? [])
.filter((portal) => (
(status.length === 0 || status.includes(portal.status))
&& (!query
|| portal.name.toLocaleLowerCase().includes(query)
|| publicHost(portal).toLocaleLowerCase().includes(query))
))
// Архивные показываются последними (подпись в подвале кадра PT1).
.sort((left, right) => Number(left.status === "ARCHIVED") - Number(right.status === "ARCHIVED"));
}, [portals, search, status]);
const pageCount = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
const currentPage = Math.min(page, pageCount);
const visible = filtered.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE);
async function archive(portal: SupportPortal) {
setBusy(true);
@@ -114,8 +94,8 @@ export function SupportPortalsPage({
}
}
if (failed) return <ErrorScreen retry={() => void load()} />;
if (!portals || !address) return <LoadingState />;
if (failed || portals.errorText) return <ErrorScreen retry={() => void load()} />;
if (!address) return <LoadingState />;
const createButton = canManage ? (
<Button variant="primary" icon="plus" iconSize={16} onClick={() => setCreating(true)}>
@@ -151,7 +131,8 @@ export function SupportPortalsPage({
</>
);
if (portals.length === 0) {
// Пустое состояние — только когда порталов нет вовсе, а не когда их скрыл фильтр.
if (portals.total === 0 && status.length === 0 && !settledSearch.trim()) {
// Кадр PT2: создание доступно всегда, тарифных лимитов нет.
return (
<section className="portals-page is-empty">
@@ -205,7 +186,7 @@ export function SupportPortalsPage({
className="portals-search"
placeholder="Поиск по названию и адресу"
value={search}
onChange={(value) => { setSearch(value); setPage(1); }}
onChange={setSearch}
/>
<FilterDropdown
caption="Статус:"
@@ -217,7 +198,6 @@ export function SupportPortalsPage({
className="portals-status-filter"
onOpenChange={setStatusOpen}
onSelect={(value) => {
setPage(1);
setStatus((current) => (current.includes(value)
? current.filter((item) => item !== value)
: [...current, value]));
@@ -237,7 +217,7 @@ export function SupportPortalsPage({
</tr>
</thead>
<tbody>
{visible.map((portal) => {
{portals.items.map((portal) => {
const host = publicHost(portal);
const ownDomain = Boolean(portal.customDomain && portal.customDomainVerifiedAt);
const archived = portal.status === "ARCHIVED";
@@ -288,11 +268,11 @@ export function SupportPortalsPage({
</tbody>
</table>
<PortalTableFooter
note={`${pluralRu(filtered.length, ["портал", "портала", "порталов"])} · архивные показываются последними`}
page={currentPage}
pageCount={pageCount}
onPageChange={setPage}
<Pagination
note={`${pluralRu(portals.total, ["портал", "портала", "порталов"])} · архивные показываются последними`}
page={portals.page}
pageCount={portals.pageCount}
onPage={portals.setPage}
/>
</div>
{dialogs}
@@ -1,4 +1,5 @@
import { api, apiUpload } from "../../api/client";
import type { PagedPayload } from "../../shared/usePagedResource";
import type { ArticleImportDocument } from "./parseArticleYaml";
import type {
ArticleRevision,
@@ -12,8 +13,18 @@ import type {
SupportPortalList,
} from "./model";
export function listSupportPortals(): Promise<SupportPortalList> {
return api("/api/v1/support/portals/");
// Страница списка порталов (кадр PT1): статус, поиск и номер страницы —
// параметры запроса, фильтровать в браузере нечего.
export type PortalListQuery = { status: string[]; search: string };
export function listSupportPortals(
{ status, search }: PortalListQuery = { status: [], search: "" },
page = 1,
): Promise<SupportPortalList> {
const params = new URLSearchParams({ page: String(page) });
for (const value of status) params.append("status", value);
if (search.trim()) params.set("q", search.trim());
return api(`/api/v1/support/portals/?${params.toString()}`);
}
export function createSupportPortal(input: PortalInput): Promise<{ portal: SupportPortal }> {
@@ -105,8 +116,26 @@ export function verifyPortalCustomDomain(
});
}
export function listPortalArticles(id: number): Promise<{ items: PortalArticle[] }> {
return api(`/api/v1/support/portals/${id}/articles/`);
// Страница библиотеки статей (кадр PT3): категория, язык, статус и поиск —
// тоже параметры запроса.
export type ArticleListQuery = {
category?: number;
locale: string[];
status: string[];
search: string;
};
export function listPortalArticles(
id: number,
{ category, locale, status, search }: ArticleListQuery = { locale: [], status: [], search: "" },
page = 1,
): Promise<PagedPayload<PortalArticle>> {
const params = new URLSearchParams({ page: String(page) });
if (category !== undefined) params.set("category", String(category));
for (const value of locale) params.append("locale", value);
for (const value of status) params.append("status", value);
if (search.trim()) params.set("q", search.trim());
return api(`/api/v1/support/portals/${id}/articles/?${params.toString()}`);
}
export function createPortalArticle(
@@ -1,4 +1,5 @@
import { ApiError } from "../../api/client";
import type { PagedPayload } from "../../shared/usePagedResource";
import type { PortalThemeSchemeSetting } from "../help-center/themes/types";
export type PortalStatus = "DRAFT" | "PUBLISHED" | "ARCHIVED";
@@ -109,9 +110,10 @@ export type PortalAddressConfig = {
port: string | null;
};
export type SupportPortalList = {
items: SupportPortal[];
// Ответ списка порталов: страница плюс блоки, общие для всего раздела.
export type SupportPortalList = PagedPayload<SupportPortal> & {
address: PortalAddressConfig;
creation: { available: boolean; canCreate: boolean; limit: number | null; used: number };
};
export {
@@ -308,71 +308,6 @@
/* --- Подвал таблиц раздела (кадры PT1/PT3) --- */
.portal-table-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 11px 16px;
border-top: 1px solid var(--n-8);
background: var(--n-10);
color: var(--n-4);
font-size: 12.5px;
}
.portal-pages {
display: flex;
align-items: center;
gap: 6px;
}
.portal-pages b {
display: inline-flex;
min-width: 28px;
height: 28px;
align-items: center;
justify-content: center;
border-radius: 7px;
background: var(--primary-bg);
color: var(--primary-text);
font-size: 12.5px;
font-weight: 600;
}
.portal-page-step,
.portal-page-number {
display: inline-flex;
min-width: 28px;
height: 28px;
align-items: center;
justify-content: center;
padding: 0 8px;
border: 1px solid var(--n-7);
border-radius: 7px;
background: var(--surface-card);
color: var(--n-3);
font-family: inherit;
font-size: 12.5px;
cursor: pointer;
}
.portal-page-step { width: 28px; padding: 0; }
.portal-page-step:disabled {
color: var(--n-6);
cursor: default;
}
.portal-page-number:hover,
.portal-page-step:not(:disabled):hover {
background: var(--n-9);
}
/* Меню строки портала — 236px по макету, кнопка ⋯ 28px. */
.app-dropdown.is-portal-menu .ant-dropdown-menu {
width: 236px;
}
.portals-table .row-menu-button,
.portal-articles-table .row-menu-button {
width: 28px;
+1 -4
View File
@@ -83,9 +83,6 @@ export function Shell({ route, setRoute, settingsSection, openSettingsRoute, sel
void loadNotifications();
}
const currentEmployee = route === "employeeDetail"
? data.employees.find((employee) => employee.id === selectedEmployeeId) ?? null
: data.employees[0] ?? null;
function openEmployee(employee: Employee) {
openEmployeeRoute(employee.id);
}
@@ -122,7 +119,7 @@ export function Shell({ route, setRoute, settingsSection, openSettingsRoute, sel
«назад» живут в самой странице, уведомления — в меню профиля сайдбара. */}
<main className={`hub-scroll ${isDialogsWorkspace ? "sales-dialogs-scroll" : ""} ${isAiFullWidth ? "ai-fullwidth-scroll" : ""} ${isSettings ? "settings-scroll" : ""} ${isProfile ? "profile-scroll" : ""} ${isContacts ? "contacts-scroll" : ""} ${isAgents ? "agents-scroll" : ""} ${isEmployees ? "employees-scroll" : ""} ${isAudit ? "audit-scroll" : ""} ${isPortals ? "portals-scroll" : ""} ${isKnowledge || isKnowledgeEditor ? "knowledge-scroll" : ""}`}>
<div key={route} className={`hub-page enter-surface ${isSalesWorkspace || isSupportWorkspace ? "sales-workspace-page" : ""} ${isDialogsWorkspace ? "sales-dialogs-page" : ""} ${isAiFullWidth ? "ai-fullwidth-page" : ""} ${isSettings ? "settings-page" : ""} ${isProfile ? "profile-page-shell" : ""} ${isContacts ? "contacts-page-shell" : ""} ${isAgents ? "agents-page-shell" : ""} ${isEmployees ? "employees-page-shell" : ""} ${isAudit ? "audit-page-shell" : ""} ${isKnowledge ? "knowledge-page-shell" : ""} ${isKnowledgeEditor ? "knowledge-editor-shell" : ""} ${isPortals ? "portals-page-shell" : ""}`}>
<ShellRouteContent settingsSection={settingsSection} openSettings={openSettingsRoute} chatScope={chatScope.scope} setChatScope={chatScope.setScope} chatCounters={chatScope.counters} chatScopeSwitcher={manager} route={route} data={data} currentEmployee={currentEmployee} selectedAgentId={selectedAgentId} selectedKnowledgeId={selectedKnowledgeId} selectedConversationId={selectedConversationId} selectedClientId={selectedClientId} openClient={openClientRoute} selectedChannelId={selectedChannelId} openChannel={openChannelRoute} selectedSupportPortalId={selectedSupportPortalId} portalSettingsSection={portalSettingsSection} openSupportPortal={openSupportPortalRoute} openPortalSettings={openPortalSettingsRoute} openConversation={openConversationRoute} openEmployee={openEmployee} openAgent={openAgentRoute} openKnowledge={openKnowledgeRoute} openKnowledgeEditor={openKnowledgeEditorRoute} onAgentLoaded={() => undefined} onChannelLoaded={() => undefined} reload={reload} setRoute={setRoute} user={user} onUserUpdated={onUserUpdated} onLogout={onLogout} onOpenSidebar={() => setSidebarExpanded(true)} />
<ShellRouteContent settingsSection={settingsSection} openSettings={openSettingsRoute} chatScope={chatScope.scope} setChatScope={chatScope.setScope} chatCounters={chatScope.counters} chatScopeSwitcher={manager} route={route} data={data} selectedEmployeeId={selectedEmployeeId} selectedAgentId={selectedAgentId} selectedKnowledgeId={selectedKnowledgeId} selectedConversationId={selectedConversationId} selectedClientId={selectedClientId} openClient={openClientRoute} selectedChannelId={selectedChannelId} openChannel={openChannelRoute} selectedSupportPortalId={selectedSupportPortalId} portalSettingsSection={portalSettingsSection} openSupportPortal={openSupportPortalRoute} openPortalSettings={openPortalSettingsRoute} openConversation={openConversationRoute} openEmployee={openEmployee} openAgent={openAgentRoute} openKnowledge={openKnowledgeRoute} openKnowledgeEditor={openKnowledgeEditorRoute} onAgentLoaded={() => undefined} onChannelLoaded={() => undefined} reload={reload} setRoute={setRoute} user={user} onUserUpdated={onUserUpdated} onLogout={onLogout} onOpenSidebar={() => setSidebarExpanded(true)} />
</div>
</main>
</div>
@@ -42,12 +42,12 @@ const SupportPortalDetailPage = lazy(() => import("../features/support-portals/S
(module) => ({ default: module.SupportPortalDetailPage }),
));
export function ShellRouteContent({ settingsSection, openSettings, chatScope, setChatScope, chatCounters, chatScopeSwitcher, route, data, currentEmployee, selectedAgentId, selectedKnowledgeId, selectedConversationId, selectedClientId, openClient, selectedChannelId, openChannel, selectedSupportPortalId, portalSettingsSection, openSupportPortal, openPortalSettings, openConversation, openEmployee, openAgent, openKnowledge, openKnowledgeEditor, onAgentLoaded, onChannelLoaded, reload, setRoute, user, onUserUpdated, onLogout, onOpenSidebar }: { settingsSection: SettingsSectionKey | null; openSettings: (section: SettingsSectionKey | null) => void; chatScope: DialogScope; setChatScope: (scope: DialogScope) => void; chatCounters: ConversationCounters | null; chatScopeSwitcher: boolean; route: RouteKey; data: AppData; currentEmployee: Employee | 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; portalSettingsSection: PortalSettingsSectionKey | null; openSupportPortal: (portalId: number) => void; openPortalSettings: (portalId: number, section?: PortalSettingsSectionKey) => void; openConversation: (conversationId: number) => void; openEmployee: (employee: Employee) => void; openAgent: (agentId: number) => void; openKnowledge: (knowledgeId: number) => void; openKnowledgeEditor: (knowledgeId: number | null) => 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 }) {
export function ShellRouteContent({ settingsSection, openSettings, chatScope, setChatScope, chatCounters, chatScopeSwitcher, route, data, selectedEmployeeId, selectedAgentId, selectedKnowledgeId, selectedConversationId, selectedClientId, openClient, selectedChannelId, openChannel, selectedSupportPortalId, portalSettingsSection, openSupportPortal, openPortalSettings, openConversation, openEmployee, openAgent, openKnowledge, openKnowledgeEditor, onAgentLoaded, onChannelLoaded, reload, setRoute, user, onUserUpdated, onLogout, onOpenSidebar }: { settingsSection: SettingsSectionKey | null; openSettings: (section: SettingsSectionKey | null) => void; chatScope: DialogScope; setChatScope: (scope: DialogScope) => void; chatCounters: ConversationCounters | null; chatScopeSwitcher: boolean; route: RouteKey; data: AppData; selectedEmployeeId: number | 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; portalSettingsSection: PortalSettingsSectionKey | null; openSupportPortal: (portalId: number) => void; openPortalSettings: (portalId: number, section?: PortalSettingsSectionKey) => void; openConversation: (conversationId: number) => void; openEmployee: (employee: Employee) => void; openAgent: (agentId: number) => void; openKnowledge: (knowledgeId: number) => void; openKnowledgeEditor: (knowledgeId: number | null) => 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} />}
{route === "employeeDetail" && currentEmployee && <EmployeeDetailPage groups={data.groups} employee={currentEmployee} employees={data.employees} reload={reload} setRoute={setRoute} />}
{route === "employeeDetail" && !currentEmployee && <EmployeesPage groups={data.groups} employees={data.employees} reload={reload} openEmployee={openEmployee} setRoute={setRoute} user={user} />}
{route === "employees" && <EmployeesPage groups={data.groups} openEmployee={openEmployee} setRoute={setRoute} user={user} />}
{route === "employeeDetail" && selectedEmployeeId != null && <EmployeeDetailPage groups={data.groups} employeeId={selectedEmployeeId} setRoute={setRoute} />}
{route === "employeeDetail" && selectedEmployeeId == null && <EmployeesPage groups={data.groups} openEmployee={openEmployee} setRoute={setRoute} user={user} />}
{route === "profile" && <ProfilePage user={user} onUserUpdated={onUserUpdated} reload={reload} onLogout={onLogout} onBack={() => setRoute("chat")} />}
{route === "settings" && <SettingsPage user={user} onUserUpdated={onUserUpdated} reload={reload} groups={data.groups} section={settingsSection} openSection={openSettings} setRoute={setRoute} />}
{route === "administrationAudit" && <AuditPage />}
@@ -66,7 +66,7 @@ export function ShellRouteContent({ settingsSection, openSettings, chatScope, se
onOpenMenu={onOpenSidebar}
/>
)}
{route === "agents" && <AgentsPage agents={data.agents} groups={data.groups} reload={reload} openAgent={openAgent} />}
{route === "agents" && <AgentsPage groups={data.groups} openAgent={openAgent} />}
{route === "agentDetail" && (
<AgentDetailPage
agentId={selectedAgentId}
@@ -0,0 +1,37 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { Pagination } from "./Pagination";
// Единственный подвал со страницами: он же в порталах, статьях, контактах,
// сотрудниках, агентах и журнале аудита.
function render(page: number, pageCount: number, note?: string): string {
return renderToStaticMarkup(
<Pagination note={note} page={page} pageCount={pageCount} onPage={() => undefined} />,
);
}
describe("Pagination", () => {
it("гасит стрелки на границах набора", () => {
const first = render(1, 3);
expect(first).toContain('aria-label="Предыдущая страница" class="pager-step" disabled');
expect(first).not.toContain('aria-label="Следующая страница" class="pager-step" disabled');
const last = render(3, 3);
expect(last).toContain('aria-label="Следующая страница" class="pager-step" disabled');
});
it("отмечает текущую страницу", () => {
expect(render(2, 3)).toContain('aria-current="page"');
});
it("сворачивает длинный набор многоточиями", () => {
const markup = render(5, 20);
expect(markup).toContain("…");
expect(markup).toContain(">20<");
});
it("показывает подпись слева", () => {
expect(render(1, 2, "Показано 20 из 45")).toContain("Показано 20 из 45");
});
});
@@ -0,0 +1,66 @@
import type { ReactNode } from "react";
import { Icon } from "./icons";
import { paginationItems } from "./ui-controls";
import "./pagination.css";
// Единственный подвал со страницами в приложении: списки порталов, статей,
// контактов, сотрудников, агентов и журнал аудита. Страницу считает сервер —
// компонент только рисует номера и сообщает о выборе.
export function Pagination({
page,
pageCount,
onPage,
note,
className = "",
}: {
page: number;
pageCount: number;
onPage: (page: number) => void;
/** Подпись слева: «Показано 20 из 45», подсказка про архив и т.п. */
note?: ReactNode;
className?: string;
}) {
return (
<div className={`pager ${className}`.trim()}>
<span className="pager-note">{note}</span>
<span className="pager-pages">
<button
aria-label="Предыдущая страница"
className="pager-step"
disabled={page <= 1}
type="button"
onClick={() => onPage(page - 1)}
>
<Icon name="chevronLeft" size={14} strokeWidth={2} />
</button>
{paginationItems(page, pageCount).map((item, index) => (
item === "ellipsis"
? <span key={`gap-${index}`}>…</span>
: (
<button
aria-current={item === page ? "page" : undefined}
className={`pager-number ${item === page ? "is-current" : ""}`.trim()}
disabled={item === page}
key={item}
type="button"
onClick={() => onPage(item)}
>
{item}
</button>
)
))}
<button
aria-label="Следующая страница"
className="pager-step"
disabled={page >= pageCount}
type="button"
onClick={() => onPage(page + 1)}
>
<Icon name="chevronRight" size={14} strokeWidth={2} />
</button>
</span>
</div>
);
}
@@ -0,0 +1,71 @@
/* Подвал со страницами — один на всё приложение (порталы, библиотека статей,
контакты, сотрудники, агенты, журнал аудита). Раньше у каждого списка была
своя разметка и свои классы; вид взят из кадров PT1/PT3, где он утверждён. */
.pager {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 11px 16px;
border-top: 1px solid var(--n-8);
background: var(--n-10);
color: var(--n-4);
font-size: 12.5px;
}
.pager-note {
display: inline-flex;
align-items: center;
gap: 8px;
}
.pager-pages {
display: flex;
align-items: center;
gap: 6px;
}
.pager-pages > span {
color: var(--n-5);
}
.pager-step,
.pager-number {
display: inline-flex;
min-width: 28px;
height: 28px;
align-items: center;
justify-content: center;
padding: 0 8px;
border: 1px solid var(--n-7);
border-radius: 7px;
background: var(--surface-card);
color: var(--n-3);
font-family: inherit;
font-size: 12.5px;
cursor: pointer;
}
.pager-step {
width: 28px;
padding: 0;
}
.pager-step:disabled {
color: var(--n-6);
cursor: default;
}
.pager-number:hover,
.pager-step:not(:disabled):hover {
background: var(--n-9);
}
.pager-number.is-current {
border-color: transparent;
background: var(--primary-bg);
color: var(--primary-text);
font-weight: 600;
cursor: default;
}
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { paginationItems } from "../../../shared/ui-controls";
import { paginationItems } from "./ui-controls";
describe("paginationItems", () => {
it("returns every page for a short list", () => {
@@ -316,67 +316,6 @@
text-decoration: none;
}
.ui-table-pagination {
display: flex;
align-items: center;
justify-content: space-between;
}
.ui-table-pagination > div:first-child,
.ui-table-pagination em {
color: var(--n-4);
font-size: 12.5px;
font-style: normal;
font-weight: 400;
}
.ui-table-pagination > div:last-child {
display: flex;
align-items: center;
gap: 6px;
}
.ui-table-pagination button {
min-width: 30px;
height: 30px;
padding: 0 9px;
color: var(--n-3);
background: var(--surface-card);
border: 1px solid var(--n-7);
border-radius: 7px;
font-size: 13px;
}
.ui-table-pagination button:not(:disabled):hover {
color: var(--primary);
border-color: var(--primary);
}
.ui-table-pagination button.active {
color: #ffffff;
background: var(--primary);
border-color: var(--primary);
font-weight: 600;
}
.ui-table-pagination button:disabled {
color: var(--n-5);
cursor: not-allowed;
}
.ui-table-pagination span {
padding: 0 2px;
color: var(--n-5);
font-size: 13px;
}
.ui-table-pagination i {
width: 1px;
height: 20px;
margin: 0 4px;
background: var(--n-8);
}
/* «Назад» над карточкой сущности (кадры K3 «Контакты», G3 «Агенты»). */
.ui-back-link {
@@ -45,16 +45,6 @@ type ToneBadgeProps = {
color: string;
};
type TablePaginationProps = {
className?: string;
currentPage?: number;
onPageChange?: (page: number) => void;
pageSizeLabel: string;
pages: Array<number | "ellipsis">;
shown: number;
total: number;
};
export function Button({ children, className = "", icon, iconSize = 15, type = "button", variant, ...buttonProps }: ButtonProps) {
return (
<button className={`${buttonVariantClass[variant]} ${className}`.trim()} type={type} {...buttonProps}>
@@ -224,19 +214,3 @@ export function paginationItems(page: number, pageCount: number): Array<number |
return result;
}
export function TablePagination({ className = "", currentPage = 1, onPageChange, pageSizeLabel, pages, shown, total }: TablePaginationProps) {
const numericPages = pages.filter((page): page is number => page !== "ellipsis");
const lastPage = Math.max(...numericPages, 1);
return (
<div className={`ui-table-pagination ${className}`.trim()}>
<div>Показано {shown} из {total}</div>
<div>
<button type="button" disabled={!onPageChange || currentPage <= 1} onClick={() => onPageChange?.(currentPage - 1)}>‹</button>
{pages.map((page, index) => page === "ellipsis" ? <span key={`ellipsis-${index}`}>…</span> : <button className={page === currentPage ? "active" : ""} type="button" disabled={!onPageChange} onClick={() => onPageChange?.(page)} key={page}>{page}</button>)}
<button type="button" disabled={!onPageChange || currentPage >= lastPage} onClick={() => onPageChange?.(currentPage + 1)}>›</button>
<i />
<em>{pageSizeLabel}</em>
</div>
</div>
);
}
@@ -0,0 +1,15 @@
import { useEffect, useState } from "react";
// Ввод в поиске придерживается: запрос уходит, когда человек перестал печатать.
// Один хук на все списки — фильтры у них серверные, и каждая буква иначе шла бы
// в базу.
const DEFAULT_DELAY_MS = 300;
export function useDebounced<T>(value: T, delay = DEFAULT_DELAY_MS): T {
const [settled, setSettled] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setSettled(value), delay);
return () => clearTimeout(timer);
}, [delay, value]);
return settled;
}
@@ -0,0 +1,92 @@
import { useCallback, useEffect, useRef, useState } from "react";
// Постраничный список: страницу и фильтры считает сервер, здесь — только
// состояние страницы и защита от гонок. Смена любого фильтра возвращает на
// первую страницу: иначе человек остаётся на седьмой странице набора из двух
// записей.
export type PagedPayload<T> = {
items: T[];
page: number;
pageSize: number;
total: number;
pageCount: number;
};
const EMPTY: PagedPayload<never> = { items: [], page: 1, pageSize: 0, total: 0, pageCount: 1 };
export type PagedResource<T> = {
items: T[];
page: number;
pageCount: number;
pageSize: number;
total: number;
loading: boolean;
errorText: string;
setPage: (page: number) => void;
reload: () => Promise<void>;
};
export function usePagedResource<T>(
load: (page: number) => Promise<PagedPayload<T>>,
filters: unknown,
errorMessage = "Не удалось загрузить список",
): PagedResource<T> {
const [payload, setPayload] = useState<PagedPayload<T>>(EMPTY as PagedPayload<T>);
const [loading, setLoading] = useState(true);
const [errorText, setErrorText] = useState("");
// Загрузчик пересоздаётся на каждый рендер — держим его в ref, чтобы эффект
// перезапускался только на смену страницы и фильтров.
const loadRef = useRef(load);
loadRef.current = load;
// Ответ медленного запроса не должен затирать более свежий.
const generation = useRef(0);
const key = JSON.stringify(filters ?? null);
// Страница принадлежит набору фильтров: при их смене она сбрасывается прямо
// здесь, а не эффектом — иначе первый запрос ушёл бы за старой страницей
// нового набора и тут же был бы перезапрошен.
const [pageState, setPageState] = useState({ key, page: 1 });
const page = pageState.key === key ? pageState.page : 1;
if (pageState.key !== key) setPageState({ key, page: 1 });
const setPage = useCallback(
(next: number) => setPageState((current) => ({ key: current.key, page: next })),
[],
);
const reload = useCallback(async () => {
const current = ++generation.current;
setLoading(true);
try {
const next = await loadRef.current(page);
if (current !== generation.current) return;
setPayload(next);
// Сервер мог отдать другую страницу — например последнюю, если записи
// удалили из-под открытой. Тогда и состояние переходит на неё, иначе
// «дальше» листало бы от несуществующего номера.
setPageState((state) => (state.page === next.page ? state : { key: state.key, page: next.page }));
setErrorText("");
} catch {
if (current === generation.current) setErrorText(errorMessage);
} finally {
if (current === generation.current) setLoading(false);
}
}, [errorMessage, page]);
useEffect(() => {
void reload();
}, [key, reload]);
return {
items: payload.items,
// Сервер возвращает существующую страницу: если записи удалили из-под
// открытой страницы, он отдаст последнюю, и подвал покажет именно её.
page: payload.page,
pageCount: payload.pageCount,
pageSize: payload.pageSize,
total: payload.total,
loading,
errorText,
setPage,
reload,
};
}
-1
View File
@@ -104,7 +104,6 @@ export type EmployeeAuditEvent = {
export type RouteKey = "administrationAudit" | "employeeDetail" | "employees" | "profile" | "settings" | "salesClientDetail" | "salesClients" | "chat" | "supportPortals" | "supportPortalDetail" | "supportPortalSettings" | "agents" | "agentDetail" | "knowledge" | "knowledgeDetail" | "knowledgeCreate" | "knowledgeEdit" | "knowledgeCategories" | "knowledgeImport" | "aiUsage";
export type AppData = {
employees: Employee[];
groups: EmployeeGroup[];
agents: AgentCard[];
};
+12 -2
View File
@@ -162,7 +162,7 @@ async function mockInstance(page: Page) {
}
return route.fulfill({ json: { items: GROUPS } });
});
await page.route("**/api/v1/organizations/*/agents/**", (route) => route.fulfill({ json: { items: [] } }));
await page.route("**/api/v1/organizations/*/agents/**", (route) => route.fulfill({ json: { items: [], page: 1, pageSize: 20, total: 0, pageCount: 1 } }));
await page.route("**/api/v1/organizations/*/notifications/**", (route) => route.fulfill({ json: { items: [] } }));
}
@@ -183,7 +183,17 @@ async function mockEmployees(page: Page) {
},
});
}
return route.fulfill({ json: { items: [OWNER_STAFF, ADMIN_STAFF, STAFF] } });
// Список сотрудников постраничный, роль и поиск отбирает сервер — мок
// повторяет этот контракт, иначе он проверял бы несуществующее поведение.
const params = new URL(route.request().url()).searchParams;
const role = params.get("role");
const query = (params.get("q") ?? "").toLowerCase();
const items = [OWNER_STAFF, ADMIN_STAFF, STAFF]
.filter((employee) => !role || employee.role === role)
.filter((employee) => !query || `${employee.fullName} ${employee.email} ${employee.positionTitle}`.toLowerCase().includes(query));
return route.fulfill({
json: { items, page: 1, pageSize: 20, total: items.length, pageCount: 1 },
});
});
}