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