From e6a6bf77b6c9bc567044f33d20e242a6788dcf51 Mon Sep 17 00:00:00 2001 From: Andrey Date: Wed, 9 Sep 2026 00:05:02 +0300 Subject: [PATCH] =?UTF-8?q?:zap:=20perf(ui):=20=D1=81=D0=BF=D0=B8=D1=81?= =?UTF-8?q?=D0=BA=D0=B8=20=D0=BE=D1=82=D0=B4=D0=B0=D1=8E=D1=82=D1=81=D1=8F?= =?UTF-8?q?=20=D1=81=D1=82=D1=80=D0=B0=D0=BD=D0=B8=D1=86=D0=B0=D0=BC=D0=B8?= =?UTF-8?q?,=20=D1=84=D0=B8=D0=BB=D1=8C=D1=82=D1=80=D1=8B=20=D1=81=D1=87?= =?UTF-8?q?=D0=B8=D1=82=D0=B0=D0=B5=D1=82=20=D0=B1=D0=B0=D0=B7=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Пагинация в порталах и контактах была нарисованной: сервер отдавал весь набор, браузер резал его на страницы. У сотрудников и агентов не было и этого. Любой из четырёх списков рос вместе с организацией и целиком уезжал клиенту. Серверная часть — страницы и фильтры до среза: - контакты: подзапросы вместо join-агрегатов (фильтр по каналу больше не урезает счётчики диалогов), поиск, каналы, агенты, «с открытым диалогом» и порядок — в SQL; - сотрудники: роль, группа и поиск по имени, почте и должности; - агенты: группа и поиск; - порталы: статус и поиск, архивные последними; - библиотека статей: категория с вложенными, язык, статус и поиск по последней редакции. Клиент: - один подвал со страницами на всё приложение вместо трёх разных (PortalTableFooter, SalesClientsPagination, TablePagination удалены); - usePagedResource: страница принадлежит набору фильтров, гонки ответов отсекаются, сервер решает, какая страница существует; - useDebounced вынесен в shared — поиск придерживает запрос; - карточка сотрудника грузится по идентификатору, а передача владения сама запрашивает кандидатов: список постраничный, и нужного человека может не быть на открытой странице; - App больше не тянет всех сотрудников на старте. Выпадающие выборы (ответственный, передача владения, фильтр по агентам) работают со справочниками; библиотеке знаний нужен серверный контракт со счётчиками прикреплений — до него потолок в 100 карточек оставлен явным. Контракт списков и лент записан в SPEC-CHATBALLS-0031 §8. Co-Authored-By: Claude Opus 5 --- apps/backend/chatballs/ai/agent_card_views.py | 15 +- apps/backend/chatballs/ai/test_agent_list.py | 54 +++++ .../chatballs/conversations/clients.py | 188 ++++++++++++------ .../conversations/reporting_views.py | 10 +- .../conversations/test_clients_list.py | 115 +++++++++++ .../conversations/test_contacts_merge.py | 6 +- .../conversations/test_email_transport.py | 12 +- .../chatballs/identity/employee_selectors.py | 35 ++++ .../chatballs/identity/employee_views.py | 18 +- .../chatballs/identity/test_employee_list.py | 80 ++++++++ .../support_portals/content_views.py | 20 +- .../chatballs/support_portals/portal_views.py | 25 ++- .../chatballs/support_portals/selectors.py | 78 +++++++- .../tests/test_list_pagination.py | 118 +++++++++++ apps/internal-ui/src/App.tsx | 26 ++- .../src/features/administration/AuditPage.tsx | 15 +- .../src/features/agents/AgentsPage.tsx | 33 ++- apps/internal-ui/src/features/agents/model.ts | 22 +- .../src/features/ai/useAiAgents.ts | 4 +- .../conversations/ConversationWorkspace.tsx | 4 +- .../src/features/conversations/DialogList.tsx | 12 +- .../conversations/useConversationList.ts | 10 - .../features/employees/EmployeeDetailPage.tsx | 39 ++-- .../features/employees/EmployeesListPage.tsx | 44 ++-- .../employees/OwnershipTransferModal.tsx | 36 +++- .../internal-ui/src/features/employees/api.ts | 30 +++ .../src/features/employees/model.test.ts | 9 +- .../src/features/employees/model.ts | 16 -- .../src/features/sales/SalesClientsPage.tsx | 45 ++++- .../sales/clients/SalesClientsFilters.tsx | 7 +- .../sales/clients/SalesClientsPagination.tsx | 21 -- .../sales/clients/SalesClientsTable.tsx | 14 +- .../src/features/sales/clients/api.ts | 59 ++++++ .../src/features/sales/clients/model.ts | 5 - .../src/features/sales/clients/styles.css | 44 ---- .../features/sales/clients/useClientsData.ts | 29 --- .../features/sales/clients/useSalesClients.ts | 64 +++--- .../support-portals/PortalLibrary.tsx | 76 +++---- .../support-portals/PortalTableFooter.tsx | 48 ----- .../SupportPortalDetailPage.tsx | 8 +- .../support-portals/SupportPortalsPage.tsx | 70 +++---- .../src/features/support-portals/api.ts | 37 +++- .../src/features/support-portals/model.ts | 6 +- .../features/support-portals/styles-list.css | 65 ------ apps/internal-ui/src/layout/Shell.tsx | 5 +- .../src/layout/ShellRouteContent.tsx | 10 +- .../src/shared/Pagination.test.tsx | 37 ++++ apps/internal-ui/src/shared/Pagination.tsx | 66 ++++++ apps/internal-ui/src/shared/pagination.css | 71 +++++++ .../paginationItems.test.ts} | 2 +- apps/internal-ui/src/shared/ui-controls.css | 61 ------ apps/internal-ui/src/shared/ui-controls.tsx | 26 --- apps/internal-ui/src/shared/useDebounced.ts | 15 ++ .../src/shared/usePagedResource.ts | 92 +++++++++ apps/internal-ui/src/types.ts | 1 - tests/e2e/internal-ui.spec.ts | 14 +- 56 files changed, 1399 insertions(+), 673 deletions(-) create mode 100644 apps/backend/chatballs/ai/test_agent_list.py create mode 100644 apps/backend/chatballs/conversations/test_clients_list.py create mode 100644 apps/backend/chatballs/identity/employee_selectors.py create mode 100644 apps/backend/chatballs/identity/test_employee_list.py create mode 100644 apps/backend/chatballs/support_portals/tests/test_list_pagination.py delete mode 100644 apps/internal-ui/src/features/sales/clients/SalesClientsPagination.tsx create mode 100644 apps/internal-ui/src/features/sales/clients/api.ts delete mode 100644 apps/internal-ui/src/features/sales/clients/useClientsData.ts delete mode 100644 apps/internal-ui/src/features/support-portals/PortalTableFooter.tsx create mode 100644 apps/internal-ui/src/shared/Pagination.test.tsx create mode 100644 apps/internal-ui/src/shared/Pagination.tsx create mode 100644 apps/internal-ui/src/shared/pagination.css rename apps/internal-ui/src/{features/sales/clients/SalesClientsPagination.test.ts => shared/paginationItems.test.ts} (90%) create mode 100644 apps/internal-ui/src/shared/useDebounced.ts create mode 100644 apps/internal-ui/src/shared/usePagedResource.ts diff --git a/apps/backend/chatballs/ai/agent_card_views.py b/apps/backend/chatballs/ai/agent_card_views.py index 6bf1878..a585cc4 100644 --- a/apps/backend/chatballs/ai/agent_card_views.py +++ b/apps/backend/chatballs/ai/agent_card_views.py @@ -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 {} diff --git a/apps/backend/chatballs/ai/test_agent_list.py b/apps/backend/chatballs/ai/test_agent_list.py new file mode 100644 index 0000000..00aa324 --- /dev/null +++ b/apps/backend/chatballs/ai/test_agent_list.py @@ -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) diff --git a/apps/backend/chatballs/conversations/clients.py b/apps/backend/chatballs/conversations/clients.py index d6543d9..fc9cccd 100644 --- a/apps/backend/chatballs/conversations/clients.py +++ b/apps/backend/chatballs/conversations/clients.py @@ -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: diff --git a/apps/backend/chatballs/conversations/reporting_views.py b/apps/backend/chatballs/conversations/reporting_views.py index d8de9d2..ed1c6b7 100644 --- a/apps/backend/chatballs/conversations/reporting_views.py +++ b/apps/backend/chatballs/conversations/reporting_views.py @@ -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): diff --git a/apps/backend/chatballs/conversations/test_clients_list.py b/apps/backend/chatballs/conversations/test_clients_list.py new file mode 100644 index 0000000..97fe5c9 --- /dev/null +++ b/apps/backend/chatballs/conversations/test_clients_list.py @@ -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"]) diff --git a/apps/backend/chatballs/conversations/test_contacts_merge.py b/apps/backend/chatballs/conversations/test_contacts_merge.py index b319553..bd2f817 100644 --- a/apps/backend/chatballs/conversations/test_contacts_merge.py +++ b/apps/backend/chatballs/conversations/test_contacts_merge.py @@ -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]) diff --git a/apps/backend/chatballs/conversations/test_email_transport.py b/apps/backend/chatballs/conversations/test_email_transport.py index f1c6c1e..6613e6e 100644 --- a/apps/backend/chatballs/conversations/test_email_transport.py +++ b/apps/backend/chatballs/conversations/test_email_transport.py @@ -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") diff --git a/apps/backend/chatballs/identity/employee_selectors.py b/apps/backend/chatballs/identity/employee_selectors.py new file mode 100644 index 0000000..11258c6 --- /dev/null +++ b/apps/backend/chatballs/identity/employee_selectors.py @@ -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") diff --git a/apps/backend/chatballs/identity/employee_views.py b/apps/backend/chatballs/identity/employee_views.py index 96dae08..b1f9b65 100644 --- a/apps/backend/chatballs/identity/employee_views.py +++ b/apps/backend/chatballs/identity/employee_views.py @@ -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): diff --git a/apps/backend/chatballs/identity/test_employee_list.py b/apps/backend/chatballs/identity/test_employee_list.py new file mode 100644 index 0000000..cd4c7ca --- /dev/null +++ b/apps/backend/chatballs/identity/test_employee_list.py @@ -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) diff --git a/apps/backend/chatballs/support_portals/content_views.py b/apps/backend/chatballs/support_portals/content_views.py index 40c95be..1a1f4fe 100644 --- a/apps/backend/chatballs/support_portals/content_views.py +++ b/apps/backend/chatballs/support_portals/content_views.py @@ -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) diff --git a/apps/backend/chatballs/support_portals/portal_views.py b/apps/backend/chatballs/support_portals/portal_views.py index 98c0c16..28a134f 100644 --- a/apps/backend/chatballs/support_portals/portal_views.py +++ b/apps/backend/chatballs/support_portals/portal_views.py @@ -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, diff --git a/apps/backend/chatballs/support_portals/selectors.py b/apps/backend/chatballs/support_portals/selectors.py index 889d99b..7e8cbad 100644 --- a/apps/backend/chatballs/support_portals/selectors.py +++ b/apps/backend/chatballs/support_portals/selectors.py @@ -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, *, diff --git a/apps/backend/chatballs/support_portals/tests/test_list_pagination.py b/apps/backend/chatballs/support_portals/tests/test_list_pagination.py new file mode 100644 index 0000000..78ff6e2 --- /dev/null +++ b/apps/backend/chatballs/support_portals/tests/test_list_pagination.py @@ -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) diff --git a/apps/internal-ui/src/App.tsx b/apps/internal-ui/src/App.tsx index 2b11084..9c3a6b7 100644 --- a/apps/internal-ui/src/App.tsx +++ b/apps/internal-ui/src/App.tsx @@ -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({ employees: [], groups: [], agents: [] }); + const [data, setData] = useState({ 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) { diff --git a/apps/internal-ui/src/features/administration/AuditPage.tsx b/apps/internal-ui/src/features/administration/AuditPage.tsx index 74d97f9..4dc4aac 100644 --- a/apps/internal-ui/src/features/administration/AuditPage.tsx +++ b/apps/internal-ui/src/features/administration/AuditPage.tsx @@ -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() { : } {payload.pageCount > 1 && ( - setQuery({ page })} + note={`Показано ${payload.items.length} из ${payload.total} · по ${payload.pageSize} на странице`} + page={payload.page} + pageCount={payload.pageCount} + onPage={(page) => setQuery({ page })} /> )} diff --git a/apps/internal-ui/src/features/agents/AgentsPage.tsx b/apps/internal-ui/src/features/agents/AgentsPage.tsx index 5fa3db7..2e85dbc 100644 --- a/apps/internal-ui/src/features/agents/AgentsPage.tsx +++ b/apps/internal-ui/src/features/agents/AgentsPage.tsx @@ -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({ - {agents.length === 0 ? ( + {agents.errorText &&
{agents.errorText}
} + {agents.total === 0 ? (
@@ -168,9 +175,17 @@ export function AgentsPage({ Открытые
- {agents.map((card) => ( + {agents.items.map((card) => ( void toggleAi(item)} key={card.id} /> ))} + {agents.pageCount > 1 && ( + + )}
)} {creating && ( @@ -179,7 +194,7 @@ export function AgentsPage({ onClose={() => setCreating(false)} onCreated={(agentId) => { setCreating(false); - reload(); + void agents.reload(); openAgent(agentId); }} /> diff --git a/apps/internal-ui/src/features/agents/model.ts b/apps/internal-ui/src/features/agents/model.ts index 2ab7974..02bbe6f 100644 --- a/apps/internal-ui/src/features/agents/model.ts +++ b/apps/internal-ui/src/features/agents/model.ts @@ -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; }>; -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> { + 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>(`/api/v1/agents/?${params.toString()}`); +} + +/** Полный набор карточек для экранов, которым нужны связи агентов со знаниями + * (библиотека знаний считает по ним прикрепления). Ограничен одной страницей + * предельного размера: этим экранам нужен серверный контракт со счётчиками, + * пока он не сделан — держим потолок явным, а не молчаливым. */ +export function fetchAllAgents(): Promise<{ items: AgentCard[] }> { + return api>("/api/v1/agents/?pageSize=100"); } export function fetchAgent(agentId: number): Promise<{ agent: AgentCard }> { diff --git a/apps/internal-ui/src/features/ai/useAiAgents.ts b/apps/internal-ui/src/features/ai/useAiAgents.ts index c55711e..043d2b0 100644 --- a/apps/internal-ui/src/features/ai/useAiAgents.ts +++ b/apps/internal-ui/src/features/ai/useAiAgents.ts @@ -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([]); @@ -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); diff --git a/apps/internal-ui/src/features/conversations/ConversationWorkspace.tsx b/apps/internal-ui/src/features/conversations/ConversationWorkspace.tsx index 27eb0a7..aad2c8d 100644 --- a/apps/internal-ui/src/features/conversations/ConversationWorkspace.tsx +++ b/apps/internal-ui/src/features/conversations/ConversationWorkspace.tsx @@ -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} diff --git a/apps/internal-ui/src/features/conversations/DialogList.tsx b/apps/internal-ui/src/features/conversations/DialogList.tsx index 1271dd8..641b900 100644 --- a/apps/internal-ui/src/features/conversations/DialogList.tsx +++ b/apps/internal-ui/src/features/conversations/DialogList.tsx @@ -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}
{errorText &&
{errorText}
} - {/* Кадр S1: пустой список без призыва к действию. */} + {/* Кадр S1: пустой список без призыва к действию. Под фильтром и + поиском показывается, что ничего не нашлось, а не что диалогов нет. */} {!errorText && dialogs.length === 0 && ( -

Диалоги появятся, когда клиенты напишут вашему агенту

+
+ +

{narrowed ? "Диалоги не найдены" : "Диалоги появятся, когда клиенты напишут вашему агенту"}

+
)} {dialogs.map((dialog) => )}
diff --git a/apps/internal-ui/src/features/conversations/useConversationList.ts b/apps/internal-ui/src/features/conversations/useConversationList.ts index 0fb93e7..6d0dcdd 100644 --- a/apps/internal-ui/src/features/conversations/useConversationList.ts +++ b/apps/internal-ui/src/features/conversations/useConversationList.ts @@ -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(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([]); const [total, setTotal] = useState(0); diff --git a/apps/internal-ui/src/features/employees/EmployeeDetailPage.tsx b/apps/internal-ui/src/features/employees/EmployeeDetailPage.tsx index 2a5a2b7..7582ee3 100644 --- a/apps/internal-ui/src/features/employees/EmployeeDetailPage.tsx +++ b/apps/internal-ui/src/features/employees/EmployeeDetailPage.tsx @@ -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(employee)); + const [currentEmployee, setCurrentEmployee] = useState(null); + const [form, setForm] = useState(null); const [saving, setSaving] = useState(false); const [busy, setBusy] = useState(false); const [message, setMessage] = useState(""); const [issued, setIssued] = useState(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 ( +
+ {message &&
{message}
} +
+ ); + } + const status = employeeStatusKey(currentEmployee); // Кадр E4: на карточке владельца всегда стоит напоминание о правиле роли — // сменить её можно только передачей владения. const isOwnerCard = currentEmployee.role === "OWNER"; @@ -107,7 +114,7 @@ export function EmployeeDetailPage({ groups, employee, employees, reload, setRou {issued && setIssued(null)} />} - {transferOpen && setTransferOpen(false)} />} + {transferOpen && setTransferOpen(false)} />} ); } diff --git a/apps/internal-ui/src/features/employees/EmployeesListPage.tsx b/apps/internal-ui/src/features/employees/EmployeesListPage.tsx index 9e66f10..3d56cdd 100644 --- a/apps/internal-ui/src/features/employees/EmployeesListPage.tsx +++ b/apps/internal-ui/src/features/employees/EmployeesListPage.tsx @@ -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(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 }:

Сотрудники

-

Роли и группы · показано {filtered.length} из {employees.length}

+

Роли и группы · показано {employees.items.length} из {employees.total}

{canManage && ( @@ -68,10 +77,10 @@ export function EmployeesPage({ groups, employees, reload, openEmployee, user }: setRole={(value) => { setRole(value); setMenuId(null); }} /> - {error &&
{error}
} + {(error || employees.errorText) &&
{error || employees.errorText}
} 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 && ( + + )} {createOpen && ( setCreateOpen(false)} - onCreated={(password) => { setCreateOpen(false); reload(); if (password) setIssued(password); }} + onCreated={(password) => { setCreateOpen(false); void employees.reload(); if (password) setIssued(password); }} /> )} {issued && setIssued(null)} />} diff --git a/apps/internal-ui/src/features/employees/OwnershipTransferModal.tsx b/apps/internal-ui/src/features/employees/OwnershipTransferModal.tsx index bebf45f..7041b95 100644 --- a/apps/internal-ui/src/features/employees/OwnershipTransferModal.tsx +++ b/apps/internal-ui/src/features/employees/OwnershipTransferModal.tsx @@ -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; 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(candidates[0]?.id ?? null); +export function OwnershipTransferModal({ onClose }: { onClose: () => void }) { + // Владелец и кандидаты приходят с сервера отдельным запросом: список + // сотрудников теперь постраничный, и нужных людей может не быть на открытой + // странице. Роль отбирает сервер, действующих — фильтр ниже. + const [owner, setOwner] = useState(null); + const [candidates, setCandidates] = useState([]); + const [targetId, setTargetId] = useState(null); const [previousOwnerRole, setPreviousOwnerRole] = useState>("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; diff --git a/apps/internal-ui/src/features/employees/api.ts b/apps/internal-ui/src/features/employees/api.ts index b621321..3b8e20b 100644 --- a/apps/internal-ui/src/features/employees/api.ts +++ b/apps/internal-ui/src/features/employees/api.ts @@ -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 { export async function blockEmployee(userId: number, block: boolean): Promise { 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> { + 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>(`/api/v1/employees/?${params.toString()}`); +} + +/** Кандидаты на владение (кадр E9) — только действующие администраторы. + * Их единицы, поэтому берётся одна страница; роль отбирает сервер. */ +export function fetchOwnershipCandidates(): Promise> { + return api>("/api/v1/employees/?role=ADMIN&pageSize=100"); +} + +export function fetchOwner(): Promise> { + return api>("/api/v1/employees/?role=OWNER"); +} diff --git a/apps/internal-ui/src/features/employees/model.test.ts b/apps/internal-ui/src/features/employees/model.test.ts index daa68b5..1658d89 100644 --- a/apps/internal-ui/src/features/employees/model.test.ts +++ b/apps/internal-ui/src/features/employees/model.test.ts @@ -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("Без группы"); diff --git a/apps/internal-ui/src/features/employees/model.ts b/apps/internal-ui/src/features/employees/model.ts index 1d5ccf5..7817b72 100644 --- a/apps/internal-ui/src/features/employees/model.ts +++ b/apps/internal-ui/src/features/employees/model.ts @@ -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 { diff --git a/apps/internal-ui/src/features/sales/SalesClientsPage.tsx b/apps/internal-ui/src/features/sales/SalesClientsPage.tsx index 6f79db8..2db1d5c 100644 --- a/apps/internal-ui/src/features/sales/SalesClientsPage.tsx +++ b/apps/internal-ui/src/features/sales/SalesClientsPage.tsx @@ -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(null); + useEffect(() => { + let active = true; + void fetchClientsTotal() + .then((total) => { if (active) setTotalCount(total); }) + .catch(() => undefined); + return () => { active = false; }; + }, []); - if (loading) return
; - if (error) return
; + 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
; + } + if (clients.errorText) { + return
; + } return (
- exportClientsCsv(clients.filteredRows)} /> - {data.length === 0 ? ( + void exportCsv()} + /> + {(totalCount ?? 0) === 0 && !clients.filtered ? ( ) : ( <> - + ({ id: agent.id, code: agent.code, name: agent.name }))} /> )} diff --git a/apps/internal-ui/src/features/sales/clients/SalesClientsFilters.tsx b/apps/internal-ui/src/features/sales/clients/SalesClientsFilters.tsx index 246bc5d..a066810 100644 --- a/apps/internal-ui/src/features/sales/clients/SalesClientsFilters.tsx +++ b/apps/internal-ui/src/features/sales/clients/SalesClientsFilters.tsx @@ -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 (
void }) { - return ( -
- - {paginationItems(page, pageCount).map((item, index) => ( - item === "ellipsis" - ? … - : - ))} - -
- ); -} diff --git a/apps/internal-ui/src/features/sales/clients/SalesClientsTable.tsx b/apps/internal-ui/src/features/sales/clients/SalesClientsTable.tsx index f67d08c..7997247 100644 --- a/apps/internal-ui/src/features/sales/clients/SalesClientsTable.tsx +++ b/apps/internal-ui/src/features/sales/clients/SalesClientsTable.tsx @@ -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 (
@@ -35,10 +36,13 @@ export function SalesClientsTable({ clients, openClient }: { clients: SalesClien Измените условия фильтра или сбросьте их.
)} -
- {clients.filteredCount === 0 ? "Ничего не найдено" : `${from}–${to} из ${clients.filteredCount}`} - {clients.pageCount > 1 && } -
+
); } diff --git a/apps/internal-ui/src/features/sales/clients/api.ts b/apps/internal-ui/src/features/sales/clients/api.ts new file mode 100644 index 0000000..75698e5 --- /dev/null +++ b/apps/internal-ui/src/features/sales/clients/api.ts @@ -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> { + const params = searchParams(query); + params.set("page", String(page)); + return api>(`/api/v1/conversations/clients/?${params.toString()}`); +} + +/** Все строки под текущими фильтрами — для выгрузки в CSV. */ +export async function fetchClientsForExport(query: ClientsQuery): Promise { + 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>( + `/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 { + return api>("/api/v1/conversations/clients/?pageSize=1").then((page) => page.total); +} diff --git a/apps/internal-ui/src/features/sales/clients/model.ts b/apps/internal-ui/src/features/sales/clients/model.ts index 5568257..dc9b38b 100644 --- a/apps/internal-ui/src/features/sales/clients/model.ts +++ b/apps/internal-ui/src/features/sales/clients/model.ts @@ -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(); - 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", diff --git a/apps/internal-ui/src/features/sales/clients/styles.css b/apps/internal-ui/src/features/sales/clients/styles.css index 5ae1c27..a9990da 100644 --- a/apps/internal-ui/src/features/sales/clients/styles.css +++ b/apps/internal-ui/src/features/sales/clients/styles.css @@ -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 { diff --git a/apps/internal-ui/src/features/sales/clients/useClientsData.ts b/apps/internal-ui/src/features/sales/clients/useClientsData.ts deleted file mode 100644 index 1418dcf..0000000 --- a/apps/internal-ui/src/features/sales/clients/useClientsData.ts +++ /dev/null @@ -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([]); - 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 }; -} diff --git a/apps/internal-ui/src/features/sales/clients/useSalesClients.ts b/apps/internal-ui/src/features/sales/clients/useSalesClients.ts index e635882..fc1b375 100644 --- a/apps/internal-ui/src/features/sales/clients/useSalesClients.ts +++ b/apps/internal-ui/src/features/sales/clients/useSalesClients.ts @@ -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; // Фильтры списка контактов (кадры K1/K2): поиск, каналы, агенты и чип // «С открытым диалогом». Фильтра по продуктам нет (ADR-CHATBALLS-0041). +// Всё это — параметры запроса: страница приходит с сервера уже отобранной. -export function useSalesClients(salesClients: SalesClient[]) { +export function useSalesClients() { const [query, setQueryState] = useState(""); const [agentFilter, setAgentFilter] = useState([]); const [channelFilter, setChannelFilter] = useState([]); @@ -17,45 +21,29 @@ export function useSalesClients(salesClients: SalesClient[]) { const [sortDir, setSortDir] = useState<"asc" | "desc">("asc"); const [menu, setMenu] = useState(null); const [dropdown, setDropdown] = useState(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; - 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, }; diff --git a/apps/internal-ui/src/features/support-portals/PortalLibrary.tsx b/apps/internal-ui/src/features/support-portals/PortalLibrary.tsx index 8711eba..0ebb45a 100644 --- a/apps/internal-ui/src/features/support-portals/PortalLibrary.tsx +++ b/apps/internal-ui/src/features/support-portals/PortalLibrary.tsx @@ -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([]); const [statusOpen, setStatusOpen] = useState(false); - const [page, setPage] = useState(1); const [managingCategories, setManagingCategories] = useState(false); const [importOpen, setImportOpen] = useState(false); const [linking, setLinking] = useState(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(); - 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); }} > Все материалы @@ -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); }} > {category.name} {count} @@ -212,7 +200,7 @@ export function PortalLibrary({ className="portal-library-search" placeholder="Поиск по статьям" value={query} - onChange={(value) => { setQuery(value); setPage(1); }} + onChange={setQuery} /> { - 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({ - {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({ -
@@ -367,7 +353,7 @@ export function PortalLibrary({ setImportOpen(false)} - onImported={() => void reload()} + onImported={() => void reloadAll()} /> )} void; -}) { - const pages = Array.from({ length: pageCount }, (_, index) => index + 1); - return ( -
- {note} - - - {pages.map((item) => (item === page - ? {item} - : ))} - - -
- ); -} diff --git a/apps/internal-ui/src/features/support-portals/SupportPortalDetailPage.tsx b/apps/internal-ui/src/features/support-portals/SupportPortalDetailPage.tsx index e8b81ad..6c25a12 100644 --- a/apps/internal-ui/src/features/support-portals/SupportPortalDetailPage.tsx +++ b/apps/internal-ui/src/features/support-portals/SupportPortalDetailPage.tsx @@ -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(null); const [address, setAddress] = useState(null); const [categories, setCategories] = useState([]); - const [articles, setArticles] = useState([]); const [editing, setEditing] = useState(undefined); const [publishing, setPublishing] = useState(null); const [pendingStatus, setPendingStatus] = useState(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 ? ( = [ { 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(null); const [address, setAddress] = useState(null); const [failed, setFailed] = useState(false); const [search, setSearch] = useState(""); const [status, setStatus] = useState([]); const [statusOpen, setStatusOpen] = useState(false); - const [page, setPage] = useState(1); const [creating, setCreating] = useState(false); const [archiving, setArchiving] = useState(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 void load()} />; - if (!portals || !address) return ; + if (failed || portals.errorText) return void load()} />; + if (!address) return ; const createButton = canManage ? ( + {paginationItems(page, pageCount).map((item, index) => ( + item === "ellipsis" + ? … + : ( + + ) + ))} + + + + ); +} diff --git a/apps/internal-ui/src/shared/pagination.css b/apps/internal-ui/src/shared/pagination.css new file mode 100644 index 0000000..6cd10b3 --- /dev/null +++ b/apps/internal-ui/src/shared/pagination.css @@ -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; +} diff --git a/apps/internal-ui/src/features/sales/clients/SalesClientsPagination.test.ts b/apps/internal-ui/src/shared/paginationItems.test.ts similarity index 90% rename from apps/internal-ui/src/features/sales/clients/SalesClientsPagination.test.ts rename to apps/internal-ui/src/shared/paginationItems.test.ts index a3c540c..34679ad 100644 --- a/apps/internal-ui/src/features/sales/clients/SalesClientsPagination.test.ts +++ b/apps/internal-ui/src/shared/paginationItems.test.ts @@ -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", () => { diff --git a/apps/internal-ui/src/shared/ui-controls.css b/apps/internal-ui/src/shared/ui-controls.css index dee7793..a85df2c 100644 --- a/apps/internal-ui/src/shared/ui-controls.css +++ b/apps/internal-ui/src/shared/ui-controls.css @@ -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 { diff --git a/apps/internal-ui/src/shared/ui-controls.tsx b/apps/internal-ui/src/shared/ui-controls.tsx index 6b56d6b..ba96f39 100644 --- a/apps/internal-ui/src/shared/ui-controls.tsx +++ b/apps/internal-ui/src/shared/ui-controls.tsx @@ -45,16 +45,6 @@ type ToneBadgeProps = { color: string; }; -type TablePaginationProps = { - className?: string; - currentPage?: number; - onPageChange?: (page: number) => void; - pageSizeLabel: string; - pages: Array; - shown: number; - total: number; -}; - export function Button({ children, className = "", icon, iconSize = 15, type = "button", variant, ...buttonProps }: ButtonProps) { return ( - {pages.map((page, index) => page === "ellipsis" ? … : )} - - - {pageSizeLabel} - - - ); -} diff --git a/apps/internal-ui/src/shared/useDebounced.ts b/apps/internal-ui/src/shared/useDebounced.ts new file mode 100644 index 0000000..fb80e25 --- /dev/null +++ b/apps/internal-ui/src/shared/useDebounced.ts @@ -0,0 +1,15 @@ +import { useEffect, useState } from "react"; + +// Ввод в поиске придерживается: запрос уходит, когда человек перестал печатать. +// Один хук на все списки — фильтры у них серверные, и каждая буква иначе шла бы +// в базу. +const DEFAULT_DELAY_MS = 300; + +export function useDebounced(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; +} diff --git a/apps/internal-ui/src/shared/usePagedResource.ts b/apps/internal-ui/src/shared/usePagedResource.ts new file mode 100644 index 0000000..10a167f --- /dev/null +++ b/apps/internal-ui/src/shared/usePagedResource.ts @@ -0,0 +1,92 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +// Постраничный список: страницу и фильтры считает сервер, здесь — только +// состояние страницы и защита от гонок. Смена любого фильтра возвращает на +// первую страницу: иначе человек остаётся на седьмой странице набора из двух +// записей. + +export type PagedPayload = { + items: T[]; + page: number; + pageSize: number; + total: number; + pageCount: number; +}; + +const EMPTY: PagedPayload = { items: [], page: 1, pageSize: 0, total: 0, pageCount: 1 }; + +export type PagedResource = { + items: T[]; + page: number; + pageCount: number; + pageSize: number; + total: number; + loading: boolean; + errorText: string; + setPage: (page: number) => void; + reload: () => Promise; +}; + +export function usePagedResource( + load: (page: number) => Promise>, + filters: unknown, + errorMessage = "Не удалось загрузить список", +): PagedResource { + const [payload, setPayload] = useState>(EMPTY as PagedPayload); + 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, + }; +} diff --git a/apps/internal-ui/src/types.ts b/apps/internal-ui/src/types.ts index 6367400..ca8a4e3 100644 --- a/apps/internal-ui/src/types.ts +++ b/apps/internal-ui/src/types.ts @@ -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[]; }; diff --git a/tests/e2e/internal-ui.spec.ts b/tests/e2e/internal-ui.spec.ts index 4ce9303..ab2c2b1 100644 --- a/tests/e2e/internal-ui.spec.ts +++ b/tests/e2e/internal-ui.spec.ts @@ -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 }, + }); }); }