✨ feat(ui): Агенты и Сотрудники по дизайн-базлайну v2, общий поиск и фильтр списков

Агенты (кадры G1–G5, S1): список с порядком активные → на паузе →
отключённые, цвет плитки по id агента, подключения и знания на карточке,
диалог выбора знаний, стоимость и лимиты.

Сотрудники (кадры E1–E10): список, карточка, панель создания в четыре шага,
пароль первичного доступа (показать или письмом), завершение сессий,
передача владения. Бэкенд: employee_password.py, reset-password и
revoke-sessions вместо заглушек, password_changed_at.

Общие контролы: один поиск и один фильтр-селект на всё приложение —
SearchInput с реакцией на фокус и клавишей «/», FilterDropdown на antd
Dropdown вместо нативного select; копии в контактах и списке диалогов
удалены. Ширина списков Сотрудники/Контакты/Агенты ограничена колонкой
1220px как в макете вместо растяжки на всё окно.

Тем же коммитом уходит незакоммиченная работа предыдущих сессий этого дня:
Профиль отдельной страницей, субменю Настроек, интеграции, метаданные
сессий и TOTP, объединение контактов.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
AndreyandClaude Opus 5 committed 2026-09-06 18:57:17 +03:00
1 parent 1a86906829
commit 3672463363
169 files changed
+13683 -3385

No files matched your search

+72 -12
View File
@@ -9,7 +9,7 @@ from __future__ import annotations
from django.core.exceptions import ValidationError
from django.db import transaction
from django.db.models import Count, Q, QuerySet
from django.db.models import Case, Count, IntegerField, Q, QuerySet, Value, When
from django.utils.text import slugify
from chatballs.ai.models import AIAgent, AIAgentStatus
@@ -30,15 +30,27 @@ def agent_cards_for_context(context: TenantContext) -> QuerySet[Channel]:
return (
Channel.objects.filter(organization_id=context.organization_id)
.select_related("group", "ai_agent", "ai_agent__provider_integration")
.prefetch_related("connections", "ai_agent__knowledge_items", "ai_agent__portal_articles")
.prefetch_related(
"connections__web_chat_widget",
"ai_agent__knowledge_items",
"ai_agent__portal_articles__portal",
"ai_agent__portal_articles__published_revision",
)
.annotate(
open_conversations_count=Count(
"conversations",
filter=Q(conversations__lifecycle=LifecycleState.OPEN),
distinct=True,
)
),
# Кадр G1: сверху отвечающие агенты, ниже «Без AI», выключенные — в конце.
status_rank=Case(
When(is_active=False, then=Value(2)),
When(ai_agent__status=AIAgentStatus.ACTIVE, then=Value(0)),
default=Value(1),
output_field=IntegerField(),
),
)
.order_by("name")
.order_by("status_rank", "name")
)
@@ -46,19 +58,57 @@ def agent_card_for_context(*, context: TenantContext, agent_id: int) -> Channel:
return agent_cards_for_context(context).get(id=agent_id)
def _connection_payload(connection) -> dict[str, object]:
"""Подключение в карточке агента.
Подпись строки собирается на фронте из этих полей: у Telegram — имя бота,
у Web — домен сайта и публичный ключ виджета (из него собирается код
вставки), у Email — адрес ящика.
"""
config = connection.config or {}
payload = {
"id": connection.id,
"provider": connection.provider,
"name": connection.name,
"status": connection.status,
"botUsername": config.get("bot_username", ""),
"email": config.get("email", ""),
"allowedOrigins": config.get("allowed_domains", []),
"widgetPublicKey": "",
}
if connection.provider == "WEB":
from chatballs.webchat.widgets import widget_for_integration
widget = widget_for_integration(connection)
payload["widgetPublicKey"] = widget.public_key if widget is not None else ""
return payload
def _connections_payload(channel: Channel) -> list[dict[str, object]]:
return [
{
"id": connection.id,
"provider": connection.provider,
"name": connection.name,
"status": connection.status,
}
_connection_payload(connection)
for connection in sorted(channel.connections.all(), key=lambda item: item.id)
]
def agent_card_payload(channel: Channel) -> dict[str, object]:
def knowledge_total_for_organization(organization_id: int) -> int:
"""Сколько всего материалов можно выбрать агенту — знаний библиотеки и
опубликованных статей порталов («4 из 18» в шапке блока «Знания»).
Библиотека общая для организации (ADR-HUB-0041 §8), поэтому число одно
на всех агентов — список считает его один раз."""
from chatballs.ai.models import Knowledge
from chatballs.support_portals.models import PortalArticle
from chatballs.support_portals.statuses import ArticleStatus
return (
Knowledge.objects.filter(organization_id=organization_id).count()
+ PortalArticle.objects.filter(
organization_id=organization_id, status=ArticleStatus.PUBLISHED
).count()
)
def agent_card_payload(channel: Channel, *, knowledge_total: int | None = None) -> dict[str, object]:
agent: AIAgent = channel.ai_agent
connections = _connections_payload(channel)
open_count = getattr(channel, "open_conversations_count", None)
@@ -72,6 +122,8 @@ def agent_card_payload(channel: Channel) -> dict[str, object]:
"isActive": channel.is_active,
"groupId": channel.group_id,
"groupName": channel.group.name if channel.group_id else None,
# Цвет группы задаётся в настройках — точка у названия (кадры G1/G3).
"groupColor": channel.group.color if channel.group_id else "",
"aiStatus": agent.status,
"model": agent.model,
"providerIntegrationId": agent.provider_integration_id,
@@ -81,9 +133,17 @@ def agent_card_payload(channel: Channel) -> dict[str, object]:
"tone": agent.tone,
"instructions": agent.instructions,
"knowledge": [
{"id": item.id, "title": item.title, "isEnabled": item.is_enabled}
{
"id": item.id,
"title": item.title,
"isEnabled": item.is_enabled,
"updatedAt": item.updated_at.isoformat(),
}
for item in agent.knowledge_items.all()
],
"knowledgeTotal": knowledge_total
if knowledge_total is not None
else knowledge_total_for_organization(channel.organization_id),
"portalArticles": [
agent_portal_article_payload(article)
for article in agent.portal_articles.all()
@@ -69,13 +69,17 @@ class AgentCardListView(APIView):
cards = cards.filter(group_id=int(group))
except ValueError:
return Response({"detail": "group must be an id or none"}, status=400)
from chatballs.ai.agent_card import ensure_channel_agent
from chatballs.ai.agent_card import (
ensure_channel_agent,
knowledge_total_for_organization,
)
total = knowledge_total_for_organization(request.tenant_context.organization_id)
items = []
for channel in cards:
# Страховка для каналов, созданных в обход мастера.
ensure_channel_agent(channel)
items.append(agent_card_payload(channel))
items.append(agent_card_payload(channel, knowledge_total=total))
return Response({"items": items})
def post(self, request: Request) -> Response:
@@ -98,6 +98,64 @@ class AgentCardListDetailTests(AgentCardTestCase):
[item["name"] for item in ungrouped.json()["items"]], ["Без группы"]
)
def test_list_puts_answering_agents_first_and_disabled_last(self) -> None:
"""Кадр G1: сверху агенты с AI, ниже «Без AI», выключенные — в конце."""
from chatballs.ai.models import AIAgent, AIAgentStatus
from chatballs.channels.models import Channel
draft_id = self.create_agent(name="Бета").json()["agent"]["id"]
answering_id = self.create_agent(name="Альфа").json()["agent"]["id"]
AIAgent.objects.filter(channel_id=answering_id).update(status=AIAgentStatus.ACTIVE)
off_id = self.create_agent(name="Ааа выключенный").json()["agent"]["id"]
Channel.objects.filter(id=off_id).update(is_active=False)
listed = self.client.get("/api/v1/agents/").json()["items"]
order = [item["id"] for item in listed]
self.assertEqual(order[0], answering_id)
self.assertEqual(order[-1], off_id)
self.assertLess(order.index(draft_id), order.index(off_id))
def test_card_carries_group_color_and_knowledge_total(self) -> None:
"""Кадры G1/G3: точка группы красится цветом группы, а в шапке блока
«Знания» стоит «N из M» — оба значения приходят с карточкой."""
from chatballs.ai.models import Knowledge, KnowledgeCategory
self.operators.color = "#2aa876"
self.operators.save(update_fields=["color"])
category = KnowledgeCategory.objects.filter(
organization=self.organization
).first() or KnowledgeCategory.objects.create(
organization=self.organization, name="Общие"
)
Knowledge.objects.create(
organization=self.organization, category=category, title="Прайс"
)
card = self.client.get(f"/api/v1/agents/{self.card_id}/").json()["agent"]
self.assertEqual(card["groupColor"], "#2aa876")
self.assertEqual(card["knowledgeTotal"], 1)
self.assertEqual(card["knowledge"], [])
def test_connection_row_carries_fields_for_its_subtitle(self) -> None:
"""Кадры G3/G4: подпись строки подключения и код вставки виджета
собираются из полей подключения."""
integration = Integration.objects.create(
organization=self.organization,
kind=IntegrationKind.MESSENGER,
provider=IntegrationProvider.TELEGRAM,
name="Бот",
channel_id=self.card_id,
config={"bot_username": "atelie_nord_bot"},
)
card = self.client.get(f"/api/v1/agents/{self.card_id}/").json()["agent"]
connection = card["connections"][0]
self.assertEqual(connection["id"], integration.id)
self.assertEqual(connection["botUsername"], "atelie_nord_bot")
self.assertEqual(connection["email"], "")
self.assertEqual(connection["allowedOrigins"], [])
self.assertEqual(connection["widgetPublicKey"], "")
def test_legacy_channel_without_agent_gets_draft_agent_in_list(self) -> None:
channel = Channel.objects.create(
organization=self.organization, code="legacy", name="Legacy"
@@ -123,7 +123,9 @@ class ConversationNoteView(ConversationViewBase):
if len(note) > 4000:
return Response({"detail": "Заметка длиннее 4000 символов"}, status=400)
conversation.note = note
conversation.save(update_fields=["note"])
conversation.note_author = request.user if note else None
conversation.note_updated_at = timezone.now() if note else None
conversation.save(update_fields=["note", "note_author", "note_updated_at"])
return Response(
{
"conversation": conversation_payload(
@@ -11,10 +11,12 @@ from django.db.models import Prefetch, Q
from chatballs.conversations.models import (
ConnectionIdentity,
Contact,
ContactMerge,
Conversation,
ControlMode,
LifecycleState,
)
from chatballs.identity.avatars import user_avatar_url_in
from chatballs.identity.models import AuditEvent
# Короткие коды для UI (совпадают с фронтовыми справочниками).
@@ -35,6 +37,10 @@ AUDIT_LABELS = {
}
def _actor_name(user) -> str:
return (user.full_name or user.email) if user is not None else ""
def _mode(latest: Conversation) -> str:
if latest.lifecycle != LifecycleState.OPEN:
return "closed"
@@ -47,10 +53,10 @@ def _mode(latest: Conversation) -> str:
def clients_overview(organization_id: int) -> list[dict]:
conversation_qs = Conversation.objects.select_related(
"channel", "channel__product", "connection"
"channel", "channel__product", "connection", "assigned_operator"
).order_by("-last_activity_at")
identity_qs = ConnectionIdentity.objects.select_related("connection")
contacts = Contact.objects.filter(organization_id=organization_id).prefetch_related(
contacts = Contact.objects.filter(organization_id=organization_id, merged_into__isnull=True).prefetch_related(
Prefetch(
"conversations",
queryset=conversation_qs,
@@ -64,6 +70,7 @@ def clients_overview(organization_id: int) -> list[dict]:
continue # клиенты — те, кто писал
channels: set[str] = set()
products: dict[str, str] = {}
agents: dict[int, dict[str, object]] = {}
open_dialogs = 0
for conversation in conversations:
provider = conversation.connection.provider if conversation.connection_id else None
@@ -71,6 +78,11 @@ def clients_overview(organization_id: int) -> list[dict]:
channels.add(PROVIDER_CODE[provider])
if conversation.channel.product_id:
products[conversation.channel.product.code] = conversation.channel.product.name
# Агент = карточка канала обработки: по нему фильтруется список (кадр 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]
@@ -97,6 +109,11 @@ def clients_overview(organization_id: int) -> list[dict]:
"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"])),
}
)
rows.sort(key=lambda row: row["lastActivityAt"], reverse=True)
@@ -111,7 +128,7 @@ def client_detail(organization_id: int, contact_id: int) -> dict:
contact = Contact.objects.get(organization_id=organization_id, id=contact_id)
conversation_qs = Conversation.objects.filter(
organization_id=organization_id, contact=contact
).select_related("channel", "channel__product", "connection")
).select_related("channel", "channel__product", "connection", "group", "assigned_operator", "note_author")
conversations = list(conversation_qs.order_by("-last_activity_at"))
if not conversations:
raise Contact.DoesNotExist
@@ -128,14 +145,29 @@ def client_detail(organization_id: int, contact_id: int) -> dict:
products[conversation.channel.product.code] = conversation.channel.product.name
if conversation.lifecycle == LifecycleState.OPEN:
open_dialogs += 1
# Тема диалога — первое сообщение, превью — последнее (кадр K4).
first = conversation.messages.order_by("created_at").first()
last = conversation.messages.order_by("-created_at").first()
title = (last.text.replace("\n", " ")[:80] if last and last.text else conversation.channel.name)
title = (first.text.replace("\n", " ")[:80] if first and first.text else conversation.channel.name)
preview = (last.text.replace("\n", " ")[:120] if last and last.text else "")
dialogs.append(
{
"id": conversation.id,
"title": title,
"preview": preview,
"channelName": conversation.channel.name,
"agentName": conversation.channel.name,
"agentId": conversation.channel_id,
"agentCode": conversation.channel.code,
"groupName": conversation.group.name if conversation.group_id else "",
"groupColor": conversation.group.color if conversation.group_id else "",
"assignee": _actor_name(conversation.assigned_operator),
"assigneeAvatarUrl": user_avatar_url_in(conversation.assigned_operator, organization_id),
"note": conversation.note,
"noteAuthor": _actor_name(conversation.note_author),
"noteUpdatedAt": conversation.note_updated_at.isoformat() if conversation.note_updated_at else None,
"provider": provider,
"mode": _mode(conversation),
"status": _dialog_status(conversation),
"active": conversation.lifecycle == LifecycleState.OPEN,
"lastActivityAt": conversation.last_activity_at.isoformat(),
@@ -151,8 +183,11 @@ def client_detail(organization_id: int, contact_id: int) -> dict:
if identity.connection.provider == "EMAIL"
else identity.display_name or identity.external_user_id
),
"externalUserId": identity.external_user_id,
"username": identity.username,
"createdAt": identity.created_at.isoformat(),
# Подтверждённой считается идентичность, отдавшая телефон (ADR-HUB-0006).
"phoneVerifiedAt": identity.phone_verified_at.isoformat() if identity.phone_verified_at else None,
}
for identity in identity_qs.order_by("created_at")
]
@@ -214,4 +249,57 @@ def client_detail(organization_id: int, contact_id: int) -> dict:
"identities": identities,
"activity": activity[:8],
"audit": audit,
"duplicate": _duplicate_candidate(organization_id, contact),
"merges": _merges(organization_id, contact),
}
def _merges(organization_id: int, contact: Contact) -> list[dict]:
"""Действующие объединения этого контакта — их можно разъединить."""
rows = (
ContactMerge.objects.filter(organization_id=organization_id, target=contact, reverted_at__isnull=True)
.select_related("source", "actor")
.order_by("-created_at")
)
return [
{
"id": row.id,
"sourceId": row.source_id,
"sourceName": row.source.name or "Гость",
"sourceCid": f"CUS-{row.source_id}",
"reason": row.reason,
"actor": _actor_name(row.actor),
"at": row.created_at.isoformat(),
"identities": len(row.moved_identity_ids),
"conversations": len(row.moved_conversation_ids),
}
for row in rows
]
def _duplicate_candidate(organization_id: int, contact: Contact) -> dict | None:
"""Другой контакт с тем же телефоном. Автоматически ничего не объединяем
(ADR-HUB-0006) — это только предложение владельцу."""
if not contact.phone:
return None
other = (
Contact.objects.filter(organization_id=organization_id, phone=contact.phone, merged_into__isnull=True)
.exclude(id=contact.id)
.prefetch_related("identities__connection", "conversations")
.first()
)
if other is None:
return None
identities = list(other.identities.all())
return {
"id": other.id,
"cid": f"CUS-{other.id}",
"name": other.name or "Гость",
"avatarUrl": other.avatar_url,
"dialogs": other.conversations.count(),
"sources": sorted({identity.connection.provider for identity in identities}),
"phone": other.phone,
# Однозначным совпадение считается, только если телефон подтверждён
# подключением хотя бы у одной стороны (ADR-HUB-0006).
"phoneVerified": any(identity.phone_verified_at is not None for identity in identities),
}
@@ -0,0 +1,127 @@
"""Объединение и разъединение контактов (ADR-HUB-0006).
Автоматически идентичности разных подключений не объединяются. Объединение —
ручная операция владельца: требует причины, переносит идентичности и диалоги,
полностью аудируется и разворачивается обратно. Исходный контакт не удаляется,
поэтому разъединение возвращает ровно то, что переехало.
"""
from __future__ import annotations
from django.core.exceptions import ValidationError
from django.db import transaction
from django.utils import timezone
from chatballs.conversations.models import ConnectionIdentity, ContactMerge, Contact, Conversation
from chatballs.identity.audit import record_audit_event
# Поля карточки, которые дозаполняются из исходного контакта, если у целевого
# они пустые. Что именно заполнили — запоминаем, чтобы очистить при разъединении.
CARD_FIELDS = ("name", "phone", "avatar_url", "description", "company", "city")
MIN_REASON_LENGTH = 5
def _clean_reason(reason: str) -> str:
cleaned = (reason or "").strip()
if len(cleaned) < MIN_REASON_LENGTH:
raise ValidationError("Укажите причину объединения — она попадёт в журнал действий")
return cleaned[:2000]
@transaction.atomic
def merge_contacts(*, organization, target_id: int, source_id: int, reason: str, actor, request=None) -> ContactMerge:
"""Перенести идентичности и диалоги source в target."""
cleaned = _clean_reason(reason)
if target_id == source_id:
raise ValidationError("Нельзя объединить контакт с самим собой")
try:
target = Contact.objects.select_for_update().get(organization=organization, id=target_id)
source = Contact.objects.select_for_update().get(organization=organization, id=source_id)
except Contact.DoesNotExist as error:
raise ValidationError("Контакт не найден") from error
if source.merged_into_id is not None or target.merged_into_id is not None:
raise ValidationError("Контакт уже объединён с другим — сначала разъедините")
identity_ids = list(ConnectionIdentity.objects.filter(contact=source).values_list("id", flat=True))
conversation_ids = list(
Conversation.objects.filter(organization=organization, contact=source).values_list("id", flat=True)
)
ConnectionIdentity.objects.filter(id__in=identity_ids).update(contact=target)
Conversation.objects.filter(id__in=conversation_ids).update(contact=target)
filled: list[str] = []
for field in CARD_FIELDS:
if not getattr(target, field) and getattr(source, field):
setattr(target, field, getattr(source, field))
filled.append(field)
if filled:
target.save(update_fields=filled)
source.merged_into = target
source.save(update_fields=["merged_into"])
merge = ContactMerge.objects.create(
organization=organization,
target=target,
source=source,
reason=cleaned,
actor=actor,
moved_identity_ids=identity_ids,
moved_conversation_ids=conversation_ids,
filled_fields=filled,
)
record_audit_event(
action="contacts.merged",
actor=actor,
organization=organization,
object_type="Contact",
object_id=str(target.id),
payload={
"sourceId": source.id,
"reason": cleaned,
"identities": len(identity_ids),
"conversations": len(conversation_ids),
},
request=request,
)
return merge
@transaction.atomic
def revert_merge(*, organization, merge_id: int, reason: str, actor, request=None) -> ContactMerge:
"""Вернуть перенесённые идентичности и диалоги исходному контакту."""
cleaned = _clean_reason(reason)
try:
merge = ContactMerge.objects.select_for_update().get(organization=organization, id=merge_id)
except ContactMerge.DoesNotExist as error:
raise ValidationError("Объединение не найдено") from error
if merge.reverted_at is not None:
raise ValidationError("Это объединение уже разъединено")
source = merge.source
target = merge.target
ConnectionIdentity.objects.filter(id__in=merge.moved_identity_ids).update(contact=source)
Conversation.objects.filter(id__in=merge.moved_conversation_ids).update(contact=source)
if merge.filled_fields:
for field in merge.filled_fields:
setattr(target, field, "")
target.save(update_fields=list(merge.filled_fields))
source.merged_into = None
source.save(update_fields=["merged_into"])
merge.reverted_at = timezone.now()
merge.reverted_by = actor
merge.revert_reason = cleaned
merge.save(update_fields=["reverted_at", "reverted_by", "revert_reason"])
record_audit_event(
action="contacts.unmerged",
actor=actor,
organization=organization,
object_type="Contact",
object_id=str(target.id),
payload={"sourceId": source.id, "reason": cleaned, "mergeId": merge.id},
request=request,
)
return merge
@@ -150,9 +150,14 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None:
identity.username = inbound.username
identity.save(update_fields=["username"])
contact = identity.contact
if is_contact_share and contact.phone != inbound.phone:
contact.phone = inbound.phone
contact.save(update_fields=["phone"])
if is_contact_share and inbound.phone:
if contact.phone != inbound.phone:
contact.phone = inbound.phone
contact.save(update_fields=["phone"])
# Телефон подтвердило именно это подключение (ADR-HUB-0006).
if identity.phone_verified_at is None:
identity.phone_verified_at = timezone.now()
identity.save(update_fields=["phone_verified_at"])
# Аватар обновляем при каждом заходе: провайдер может сменить фото,
# а контакт ещё не шарил телефон (is_contact_share=False).
if inbound.avatar_url and contact.avatar_url != inbound.avatar_url:
@@ -0,0 +1,15 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("conversations", "0015_message_attachment"),
]
operations = [
migrations.AddField(
model_name="connectionidentity",
name="phone_verified_at",
field=models.DateTimeField(blank=True, db_default=None, null=True),
),
]
@@ -0,0 +1,23 @@
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("conversations", "0016_connectionidentity_phone_verified_at"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name="conversation",
name="note_author",
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="+", to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name="conversation",
name="note_updated_at",
field=models.DateTimeField(blank=True, db_default=None, null=True),
),
]
@@ -0,0 +1,38 @@
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("conversations", "0017_conversation_note_author"),
("identity", "0025_humanuser_totp_last_used_at"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name="contact",
name="merged_into",
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="merged_contacts", to="conversations.contact"),
),
migrations.CreateModel(
name="ContactMerge",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("reason", models.TextField()),
("moved_identity_ids", models.JSONField(blank=True, default=list)),
("moved_conversation_ids", models.JSONField(blank=True, default=list)),
("filled_fields", models.JSONField(blank=True, default=list)),
("created_at", models.DateTimeField(auto_now_add=True)),
("reverted_at", models.DateTimeField(blank=True, null=True)),
("revert_reason", models.TextField(blank=True, default="")),
("actor", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="+", to=settings.AUTH_USER_MODEL)),
("organization", models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name="contact_merges", to="identity.organization")),
("reverted_by", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="+", to=settings.AUTH_USER_MODEL)),
("source", models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name="merges_out", to="conversations.contact")),
("target", models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name="merges_in", to="conversations.contact")),
],
options={"ordering": ["-created_at"]},
),
]
@@ -25,12 +25,49 @@ class Contact(models.Model):
description = models.TextField(blank=True, default="")
company = models.CharField(max_length=160, blank=True, default="")
city = models.CharField(max_length=120, blank=True, default="")
# Контакт, в который этот был объединён (ADR-HUB-0006). Строка не удаляется:
# объединение обратимо, поэтому исходный контакт остаётся для разъединения.
merged_into = models.ForeignKey(
"self",
on_delete=models.SET_NULL,
related_name="merged_contacts",
null=True,
blank=True,
)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self) -> str:
return self.name or f"contact:{self.id}"
class ContactMerge(models.Model):
"""Журнал объединения контактов (ADR-HUB-0006).
Хранит, что именно переехало, чтобы объединение можно было развернуть
обратно: перенесённые идентичности и диалоги и поля карточки, которые были
заполнены из исходного контакта.
"""
organization = models.ForeignKey("identity.Organization", on_delete=models.PROTECT, related_name="contact_merges")
target = models.ForeignKey(Contact, on_delete=models.PROTECT, related_name="merges_in")
source = models.ForeignKey(Contact, on_delete=models.PROTECT, related_name="merges_out")
reason = models.TextField()
actor = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="+")
moved_identity_ids = models.JSONField(default=list, blank=True)
moved_conversation_ids = models.JSONField(default=list, blank=True)
filled_fields = models.JSONField(default=list, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
reverted_at = models.DateTimeField(null=True, blank=True)
reverted_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="+")
revert_reason = models.TextField(blank=True, default="")
class Meta:
ordering = ["-created_at"]
def __str__(self) -> str:
return f"merge:{self.source_id}->{self.target_id}"
class ConnectionIdentity(TenantRelationModel):
tenant_relation_fields = ("contact", "connection")
# Устойчивая идентичность контакта внутри конкретного подключения (ADR-HUB-0006).
@@ -40,6 +77,10 @@ class ConnectionIdentity(TenantRelationModel):
display_name = models.CharField(max_length=255, blank=True)
# Публичный логин в мессенджере (@username в TG/MAX); пустой, если не задан.
username = models.CharField(max_length=128, blank=True)
# Когда подключение отдало подтверждённый телефон (кнопка «поделиться
# контактом»). Только такая идентичность считается подтверждённой
# (ADR-HUB-0006) — колонка «Статус» на вкладке «Идентификаторы».
phone_verified_at = models.DateTimeField(null=True, blank=True, db_default=None)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
@@ -142,6 +183,16 @@ class Conversation(models.Model):
)
labels = models.ManyToManyField(ConversationLabel, blank=True, related_name="conversations")
note = models.TextField(blank=True)
# Кто и когда оставил заметку — подпись «Анна Ким · 2 сен» на карточке
# контакта (дизайн-базлайн v2, кадр K3).
note_author = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="+",
)
note_updated_at = models.DateTimeField(null=True, blank=True, db_default=None)
# «Удалить диалог» = архив (решение владельца): скрыт из списков, видят
# только администраторы; данные не удаляются.
archived_at = models.DateTimeField(null=True, blank=True)
@@ -1,8 +1,12 @@
from rest_framework.request import Request
from rest_framework.response import Response
from django.core.exceptions import ValidationError
from chatballs.conversations.clients import client_detail, clients_overview
from chatballs.conversations.contacts_merge import merge_contacts, revert_merge
from chatballs.conversations.models import Contact
from chatballs.identity.models import EmployeeRole
from chatballs.conversations.stats import sales_overview_stats
from chatballs.conversations.view_base import ConversationViewBase
from chatballs.identity.audit import record_audit_event
@@ -63,3 +67,54 @@ class ClientDetailView(ConversationViewBase):
request=request,
)
return Response({"client": client_detail(organization.id, contact_id)})
class ClientMergeView(ConversationViewBase):
"""Объединение контактов и обратное разъединение (ADR-HUB-0006).
Доступно только владельцу, требует причины и полностью аудируется;
предложение объединения на карточке видят и администраторы.
"""
required_capabilities = {"POST": "customers.manage", "DELETE": "customers.manage"}
def _owner_only(self, request: Request) -> Response | None:
membership = request.tenant_context.membership
if membership is None or membership.role != EmployeeRole.OWNER:
return Response({"detail": "Объединять контакты может только владелец"}, status=403)
return None
def post(self, request: Request, contact_id: int) -> Response:
denied = self._owner_only(request)
if denied is not None:
return denied
organization = self._org(request)
try:
merge_contacts(
organization=organization,
target_id=contact_id,
source_id=int(request.data.get("sourceId") or 0),
reason=str(request.data.get("reason", "")),
actor=request.user,
request=request,
)
except ValidationError as error:
return Response({"detail": "; ".join(error.messages)}, status=400)
return Response({"client": client_detail(organization.id, contact_id)})
def delete(self, request: Request, contact_id: int) -> Response:
denied = self._owner_only(request)
if denied is not None:
return denied
organization = self._org(request)
try:
revert_merge(
organization=organization,
merge_id=int(request.data.get("mergeId") or 0),
reason=str(request.data.get("reason", "")),
actor=request.user,
request=request,
)
except ValidationError as error:
return Response({"detail": "; ".join(error.messages)}, status=400)
return Response({"client": client_detail(organization.id, contact_id)})
@@ -150,6 +150,7 @@ def conversation_payload(
payload = {
"id": conversation.id,
"channel": {
"id": channel.id,
"code": channel.code,
"name": channel.name,
"product": {"code": channel.product.code, "name": channel.product.name} if channel.product_id else None,
@@ -0,0 +1,203 @@
"""Объединение и разъединение контактов (ADR-HUB-0006)."""
from django.core.exceptions import ValidationError
from django.test import TestCase
from chatballs.channels.models import Channel
from chatballs.conversations.contacts_merge import merge_contacts, revert_merge
from chatballs.conversations.models import ConnectionIdentity, Contact, ContactMerge, Conversation
from chatballs.identity.models import (
AuditEvent,
EmployeeRole,
HumanUser,
Organization,
OrganizationMembership,
)
from chatballs.integrations.models import Integration
from chatballs.testing import TenantAPIClient as APIClient
class ContactsMergeTests(TestCase):
def setUp(self) -> None:
self.organization = Organization.objects.create(name="Ателье", slug="atelie")
self.owner = HumanUser.objects.create(email="owner@atelie.test", full_name="Елена Кузнецова")
self.channel = Channel.objects.create(organization=self.organization, code="consultant", name="Консультант")
self.connection = Integration.objects.create(
organization=self.organization, kind="MESSENGER", provider="TELEGRAM", name="Бот", channel=self.channel
)
self.target = Contact.objects.create(organization=self.organization, name="Мария Соколова", phone="+79162041187")
self.source = Contact.objects.create(organization=self.organization, name="Гость", city="Москва")
self.identity = ConnectionIdentity.objects.create(
contact=self.source, connection=self.connection, external_user_id="482913001", display_name="Гость"
)
self.conversation = Conversation.objects.create(
organization=self.organization, channel=self.channel, connection=self.connection, contact=self.source
)
def test_merge_moves_identities_and_dialogs_and_is_audited(self) -> None:
merge = merge_contacts(
organization=self.organization,
target_id=self.target.id,
source_id=self.source.id,
reason="Один и тот же человек, совпал телефон",
actor=self.owner,
)
self.identity.refresh_from_db()
self.conversation.refresh_from_db()
self.source.refresh_from_db()
self.target.refresh_from_db()
self.assertEqual(self.identity.contact_id, self.target.id)
self.assertEqual(self.conversation.contact_id, self.target.id)
self.assertEqual(self.source.merged_into_id, self.target.id)
# Пустое поле целевого контакта дозаполнено из исходного.
self.assertEqual(self.target.city, "Москва")
self.assertEqual(merge.filled_fields, ["city"])
self.assertTrue(
AuditEvent.objects.filter(organization=self.organization, action="contacts.merged").exists()
)
def test_merge_requires_reason(self) -> None:
with self.assertRaises(ValidationError):
merge_contacts(
organization=self.organization,
target_id=self.target.id,
source_id=self.source.id,
reason=" ",
actor=self.owner,
)
self.identity.refresh_from_db()
self.assertEqual(self.identity.contact_id, self.source.id)
def test_merge_rejects_already_merged_contact(self) -> None:
merge_contacts(
organization=self.organization,
target_id=self.target.id,
source_id=self.source.id,
reason="Совпал телефон",
actor=self.owner,
)
with self.assertRaises(ValidationError):
merge_contacts(
organization=self.organization,
target_id=self.target.id,
source_id=self.source.id,
reason="Ещё раз",
actor=self.owner,
)
def test_revert_returns_everything_back(self) -> None:
merge = merge_contacts(
organization=self.organization,
target_id=self.target.id,
source_id=self.source.id,
reason="Совпал телефон",
actor=self.owner,
)
revert_merge(
organization=self.organization,
merge_id=merge.id,
reason="Разные люди, ошиблись",
actor=self.owner,
)
self.identity.refresh_from_db()
self.conversation.refresh_from_db()
self.source.refresh_from_db()
self.target.refresh_from_db()
merge.refresh_from_db()
self.assertEqual(self.identity.contact_id, self.source.id)
self.assertEqual(self.conversation.contact_id, self.source.id)
self.assertIsNone(self.source.merged_into_id)
self.assertEqual(self.target.city, "")
self.assertIsNotNone(merge.reverted_at)
self.assertTrue(
AuditEvent.objects.filter(organization=self.organization, action="contacts.unmerged").exists()
)
def test_revert_twice_is_rejected(self) -> None:
merge = merge_contacts(
organization=self.organization,
target_id=self.target.id,
source_id=self.source.id,
reason="Совпал телефон",
actor=self.owner,
)
revert_merge(organization=self.organization, merge_id=merge.id, reason="Ошиблись", actor=self.owner)
with self.assertRaises(ValidationError):
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
merge_contacts(
organization=self.organization,
target_id=self.target.id,
source_id=self.source.id,
reason="Совпал телефон",
actor=self.owner,
)
rows = clients_overview(self.organization.id)
self.assertEqual([row["id"] for row in rows], [self.target.id])
class ContactsMergeApiTests(TestCase):
"""Объединять и разъединять может только владелец (ADR-HUB-0006)."""
def setUp(self) -> None:
self.organization = Organization.objects.create(name="Ателье", slug="atelie-api")
self.owner = self._member("owner@atelie.test", EmployeeRole.OWNER)
self.admin = self._member("admin@atelie.test", EmployeeRole.ADMIN)
self.channel = Channel.objects.create(organization=self.organization, code="line", name="Линия")
self.target = Contact.objects.create(organization=self.organization, name="Мария", phone="+79162041187")
self.source = Contact.objects.create(organization=self.organization, name="Гость")
Conversation.objects.create(organization=self.organization, channel=self.channel, contact=self.target)
Conversation.objects.create(organization=self.organization, channel=self.channel, contact=self.source)
self.owner_client = APIClient()
self.owner_client.force_authenticate(self.owner.user)
self.admin_client = APIClient()
self.admin_client.force_authenticate(self.admin.user)
def _member(self, email: str, role: str) -> OrganizationMembership:
user = HumanUser.objects.create_user(email=email, password="Password-123")
return OrganizationMembership.objects.create(
user=user, organization=self.organization, role=role, position_title="Specialist"
)
def _merge_url(self) -> str:
return f"/api/v1/conversations/clients/{self.target.id}/merge/"
def test_owner_merges_and_unmerges(self) -> None:
merged = self.owner_client.post(
self._merge_url(),
{"sourceId": self.source.id, "reason": "Один человек, совпал телефон"},
format="json",
)
self.assertEqual(merged.status_code, 200)
self.assertEqual(len(merged.json()["client"]["merges"]), 1)
self.assertEqual(merged.json()["client"]["totalDialogs"], 2)
merge_id = merged.json()["client"]["merges"][0]["id"]
reverted = self.owner_client.delete(
self._merge_url(), {"mergeId": merge_id, "reason": "Разные люди"}, format="json"
)
self.assertEqual(reverted.status_code, 200)
self.assertEqual(reverted.json()["client"]["merges"], [])
self.assertEqual(reverted.json()["client"]["totalDialogs"], 1)
def test_admin_cannot_merge(self) -> None:
response = self.admin_client.post(
self._merge_url(),
{"sourceId": self.source.id, "reason": "Один человек, совпал телефон"},
format="json",
)
self.assertEqual(response.status_code, 403)
self.source.refresh_from_db()
self.assertIsNone(self.source.merged_into_id)
def test_reason_is_required_by_the_api(self) -> None:
response = self.owner_client.post(
self._merge_url(), {"sourceId": self.source.id, "reason": ""}, format="json"
)
self.assertEqual(response.status_code, 400)
@@ -7,6 +7,7 @@ urlpatterns = [
path("stats/", reporting_views.ConversationStatsView.as_view(), name="conversation-stats"),
path("clients/", reporting_views.ClientsView.as_view(), name="conversation-clients"),
path("clients/<int:contact_id>/", reporting_views.ClientDetailView.as_view(), name="conversation-client-detail"),
path("clients/<int:contact_id>/merge/", reporting_views.ClientMergeView.as_view(), name="conversation-client-merge"),
path("<int:conversation_id>/", views.ConversationDetailView.as_view(), name="conversation-detail"),
path("<int:conversation_id>/claim/", views.ConversationClaimView.as_view(), name="conversation-claim"),
path("<int:conversation_id>/release/", views.ConversationReleaseView.as_view(), name="conversation-release"),
@@ -34,6 +34,8 @@ AUDIT_ACTION_LABELS = {
"ai.knowledge_created": "Добавлено знание",
"ai.knowledge_updated": "Изменено знание",
"ai.knowledge_deleted": "Удалено знание",
"contacts.merged": "Объединение контактов",
"contacts.unmerged": "Разъединение контактов",
}
AUDIT_RESULT_LABELS = {
@@ -43,11 +45,36 @@ AUDIT_RESULT_LABELS = {
}
ORGANIZATION_CHANGE_ACTIONS = (
"administration.organization_updated",
"administration.logo_updated",
"administration.logo_deleted",
)
def organization_updated_at(organization: Organization) -> str | None:
"""Когда настройки организации сохраняли в последний раз (кадр N1:
«Сохранено 2 сен, 14:12»). Берём из журнала аудита — отдельного поля
в модели нет."""
event = (
AuditEvent.objects.filter(
organization=organization,
action__in=ORGANIZATION_CHANGE_ACTIONS,
result="SUCCESS",
)
.order_by("-created_at")
.values_list("created_at", flat=True)
.first()
)
return event.isoformat() if event else None
def organization_settings_payload(organization: Organization) -> dict[str, object]:
return {
"name": organization.name,
"timezone": organization.timezone,
"currency": organization.currency,
"updatedAt": organization_updated_at(organization),
"logoUrl": (
reverse(
"organization-logo",
@@ -10,6 +10,7 @@ from chatballs.identity.auth.profile import (
ChangeTemporaryPasswordView,
ProfilePasswordView,
ProfileRevokeOtherSessionsView,
ProfileSessionsView,
ProfileTotpDisableView,
ProfileTotpStartView,
ProfileUpdateView,
@@ -29,6 +30,7 @@ __all__ = [
"ProfileTotpStartView",
"ProfileTotpDisableView",
"ProfileRevokeOtherSessionsView",
"ProfileSessionsView",
"ChangeTemporaryPasswordView",
"TotpSetupView",
"TotpConfirmView",
@@ -46,6 +46,8 @@ def _user_payload(user: HumanUser) -> dict[str, object]:
"role": membership.role,
"positionTitle": membership.position_title,
"totpRequired": membership.totp_required,
# «в организации с …» в шапке «Профиля» (кадр P1).
"joinedAt": membership.created_at.isoformat(),
}
membership_payload.update(get_effective_access(membership))
memberships.append(membership_payload)
@@ -56,6 +58,7 @@ def _user_payload(user: HumanUser) -> dict[str, object]:
"fullName": user.full_name,
"mustChangePassword": user.must_change_password,
"totpEnabled": user.totp_enabled,
"totpLastUsedAt": user.totp_last_used_at.isoformat() if user.totp_last_used_at else None,
"deliveryMode": settings.CHATBALLS_DELIVERY_MODE,
"uiTheme": user.ui_theme,
"uiAccent": user.ui_accent,
@@ -1,6 +1,7 @@
from django.contrib.auth.password_validation import validate_password
from django.contrib.auth.tokens import default_token_generator
from django.core.exceptions import ValidationError as DjangoValidationError
from django.utils import timezone
from django.utils.decorators import method_decorator
from django.utils.encoding import force_str
from django.utils.http import urlsafe_base64_decode
@@ -95,7 +96,8 @@ class PasswordResetConfirmView(APIView):
user.set_password(new_password)
user.must_change_password = False
user.save(update_fields=["password", "must_change_password"])
user.password_changed_at = timezone.now()
user.save(update_fields=["password", "must_change_password", "password_changed_at"])
record_audit_event(
action="identity.password_reset_completed",
actor=user,
@@ -2,6 +2,7 @@ from django.contrib.auth import login
from django.contrib.auth.password_validation import validate_password
from django.core.exceptions import ValidationError as DjangoValidationError
from django.http import FileResponse
from django.utils import timezone
from rest_framework.parsers import FormParser, MultiPartParser
from rest_framework.permissions import IsAuthenticated
from rest_framework.request import Request
@@ -10,6 +11,7 @@ from rest_framework.views import APIView
from chatballs.identity.audit import record_audit_event
from chatballs.identity.auth.common import _revoke_other_user_sessions, _user_payload
from chatballs.identity.sessions import list_user_sessions
from chatballs.tenancy.ingress import user_requires_totp
from chatballs.identity.avatars import delete_user_avatar, replace_user_avatar
from chatballs.identity.models import HumanUser
@@ -99,7 +101,8 @@ class ProfilePasswordView(APIView):
return Response({"detail": " ".join(error.messages)}, status=400)
request.user.set_password(new_password)
request.user.save(update_fields=["password"])
request.user.password_changed_at = timezone.now()
request.user.save(update_fields=["password", "password_changed_at"])
login(request, request.user)
revoked = _revoke_other_user_sessions(request)
record_audit_event(
@@ -149,6 +152,16 @@ class ProfileTotpDisableView(APIView):
return Response({"authenticated": True, "user": _user_payload(request.user), "revoked": revoked})
class ProfileSessionsView(APIView):
"""Список активных сессий учётной записи (кадр P1)."""
permission_classes = [IsAuthenticated]
def get(self, request: Request) -> Response:
items = list_user_sessions(request.user.id, request.session.session_key)
return Response({"items": items})
class ProfileRevokeOtherSessionsView(APIView):
permission_classes = [IsAuthenticated]
@@ -179,7 +192,8 @@ class ChangeTemporaryPasswordView(APIView):
request.user.set_password(new_password)
request.user.must_change_password = False
request.user.save(update_fields=["password", "must_change_password"])
request.user.password_changed_at = timezone.now()
request.user.save(update_fields=["password", "must_change_password", "password_changed_at"])
login(request, request.user)
record_audit_event(
action="identity.temporary_password_changed",
@@ -11,6 +11,7 @@ from chatballs.identity.audit import record_audit_event
from chatballs.identity.auth.common import _challenge_payload, _user_payload
from chatballs.identity.auth.totp_utils import TOTP_SESSION_KEY
from chatballs.identity.models import AuditResult
from chatballs.identity.sessions import remember_device
@method_decorator(ensure_csrf_cookie, name="dispatch")
@@ -56,6 +57,7 @@ class LoginView(APIView):
)
login(request, user)
remember_device(request)
record_audit_event(
action="identity.login_succeeded",
actor=user,
+8 -1
View File
@@ -1,6 +1,7 @@
from urllib.parse import quote
from django.contrib.auth import login
from django.utils import timezone
from django.utils.decorators import method_decorator
from django.views.decorators.csrf import csrf_protect
from rest_framework.permissions import AllowAny, IsAuthenticated
@@ -19,6 +20,7 @@ from chatballs.identity.auth.totp_utils import (
_verify_totp,
)
from chatballs.identity.models import AuditResult, HumanUser
from chatballs.identity.sessions import remember_device
class TotpSetupView(APIView):
@@ -60,7 +62,8 @@ class TotpConfirmView(APIView):
return Response({"detail": "Invalid TOTP code"}, status=400)
request.user.totp_enabled = True
request.user.save(update_fields=["totp_enabled"])
request.user.totp_last_used_at = timezone.now()
request.user.save(update_fields=["totp_enabled", "totp_last_used_at"])
record_audit_event(
action="identity.totp_enabled",
actor=request.user,
@@ -103,7 +106,11 @@ class TotpVerifyView(APIView):
return Response({"detail": "Invalid TOTP code"}, status=400)
request.session.pop(TOTP_SESSION_KEY, None)
# Отметка «последний код принят …» в карточке 2FA (кадр P1).
user.totp_last_used_at = timezone.now()
user.save(update_fields=["totp_last_used_at"])
login(request, user)
remember_device(request)
record_audit_event(
action="identity.login_succeeded",
actor=user,
@@ -15,6 +15,7 @@ urlpatterns = [
path("profile/password/", auth.ProfilePasswordView.as_view(), name="auth-profile-password"),
path("profile/totp/start/", auth.ProfileTotpStartView.as_view(), name="auth-profile-totp-start"),
path("profile/totp/disable/", auth.ProfileTotpDisableView.as_view(), name="auth-profile-totp-disable"),
path("profile/sessions/", auth.ProfileSessionsView.as_view(), name="auth-profile-sessions"),
path("profile/sessions/revoke-other/", auth.ProfileRevokeOtherSessionsView.as_view(), name="auth-profile-revoke-other-sessions"),
path("change-temporary-password/", auth.ChangeTemporaryPasswordView.as_view(), name="auth-change-temp-password"),
path("totp/setup/", auth.TotpSetupView.as_view(), name="auth-totp-setup"),
@@ -10,6 +10,13 @@
"description": "Заказ 4471 — комплект штор. Постоянный клиент с августа, предпочитает Telegram.",
"createdDaysAgo": 34
},
{
"key": "maria-duplicate",
"name": "Мария С.",
"phone": "+7 917 254-18-40",
"description": "Дубль: обратилась с сайта, пока не узнали по телефону.",
"createdDaysAgo": 12
},
{
"key": "irina",
"name": "Ирина Левина",
@@ -936,5 +943,13 @@
}
]
}
],
"contactMerges": [
{
"target": "maria",
"source": "maria-duplicate",
"reason": "Один и тот же человек: совпал телефон, подтвердили в диалоге",
"actor": "elena"
}
]
}
@@ -66,6 +66,10 @@ def load(context: TenantContext, refs: DemoRefs) -> None:
organization=organization, title=item["title"], defaults={"text": item["text"]}
)
# Объединение контактов (ADR-HUB-0006): демо показывает и историю слияний.
for item in data.get("contactMerges", []):
_merge_contacts(context, refs, item)
# Диалоги с previousConversation ссылаются на более ранние — создаём в два прохода.
pending = list(data["conversations"])
created_keys: set[str] = set()
@@ -85,6 +89,23 @@ def load(context: TenantContext, refs: DemoRefs) -> None:
)
def _merge_contacts(context: TenantContext, refs: DemoRefs, item: dict) -> None:
from chatballs.conversations.contacts_merge import merge_contacts
from chatballs.conversations.models import ContactMerge
target = refs.contacts[item["target"]]
source = refs.contacts[item["source"]]
if ContactMerge.objects.filter(target=target, source=source).exists():
return
merge_contacts(
organization=refs.organization,
target_id=target.id,
source_id=source.id,
reason=item["reason"],
actor=refs.users.get(item.get("actor")),
)
def _ensure_identity(refs: DemoRefs, item: dict) -> None:
connection = refs.integrations[item["connection"]]
contact = refs.contacts[item["contact"]]
@@ -16,6 +16,7 @@ from django.utils import timezone
from chatballs.events.services import DomainEvent, enqueue_event
from chatballs.identity.audit import record_audit_event
from chatballs.identity.avatars import user_avatar_url_in
from chatballs.identity.demo_models import DemoDataset, DemoDatasetStatus
from chatballs.identity.demo_seed.orchestrator import run_demo_seed
from chatballs.identity.demo_seed.registry import recording, remove_records
@@ -34,7 +35,7 @@ class DemoBusy(ValidationError):
"""Установка или удаление уже идут."""
def showcase_accounts() -> list[dict[str, object]]:
def showcase_accounts(organization_id: int) -> list[dict[str, object]]:
"""Учётки для входа «посмотреть глазами сотрудника»: админ и по одному
сотруднику из разных групп. Пароль общий и намеренно публичный — это демо."""
from chatballs.identity.demo_seed import manifest
@@ -42,9 +43,13 @@ def showcase_accounts() -> list[dict[str, object]]:
data = manifest.load("organization")
by_key = {item["key"]: item for item in data["accounts"]}
groups = {item["key"]: item["name"] for item in data.get("groups", [])}
keys = data.get("showcaseAccounts", [])
emails = [by_key[key]["email"] for key in keys]
users = {user.email: user for user in HumanUser.objects.filter(email__in=emails)}
accounts = []
for key in data.get("showcaseAccounts", []):
for key in keys:
item = by_key[key]
user = users.get(item["email"])
accounts.append(
{
"fullName": item["fullName"],
@@ -53,6 +58,8 @@ def showcase_accounts() -> list[dict[str, object]]:
"role": item["role"],
"positionTitle": item.get("positionTitle", ""),
"groups": [groups[g] for g in item.get("groups", []) if g in groups],
# Фото сотрудника — кадр N7 показывает его в строке учётки.
"avatarUrl": user_avatar_url_in(user, organization_id) if user else None,
}
)
return accounts
@@ -76,7 +83,7 @@ def demo_status(organization: Organization) -> dict[str, object]:
"error": dataset.error,
"startedAt": dataset.started_at.isoformat(),
"finishedAt": dataset.finished_at.isoformat() if dataset.finished_at else None,
"accounts": showcase_accounts() if installed else [],
"accounts": showcase_accounts(organization.id) if installed else [],
}
@@ -0,0 +1,95 @@
"""Пароль первичного доступа сотрудника (дизайн-базлайн v2, кадры E5–E8).
Владелец и администратор задают, как сотрудник получит первый пароль:
* ``mail`` — письмо со ссылкой первого входа на рабочую почту (поведение по
умолчанию, как было раньше);
* ``show`` — сервер генерирует пароль, возвращает его в ответе ровно один раз и
сохраняет только хеш. Это нужно, когда почта организации ещё не работает.
В обоих случаях сотруднику ставится ``must_change_password``: свой пароль он
задаёт при первом входе. Выдача и сброс пишутся в журнал действий.
"""
from __future__ import annotations
import secrets
from django.utils import timezone
from chatballs.identity.audit import record_audit_event
from chatballs.identity.models import HumanUser, OrganizationMembership
from chatballs.identity.sessions import revoke_user_sessions
PASSWORD_MODES = ("mail", "show")
# Без похожих друг на друга символов: пароль диктуют голосом и переписывают.
_ALPHABET = "abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789"
_GROUPS = (3, 4, 4)
def clean_password_mode(value: object) -> str | None:
"""Режим выдачи пароля из тела запроса; None — значение не поддерживается."""
mode = str(value or "mail")
return mode if mode in PASSWORD_MODES else None
def generate_initial_password() -> str:
"""Пароль вида «kv7-Rt94-Xmz2»: группы через дефис читаются вслух."""
return "-".join(
"".join(secrets.choice(_ALPHABET) for _ in range(size)) for size in _GROUPS
)
def issue_initial_password(user: HumanUser) -> str:
"""Поставить сотруднику сгенерированный пароль и вернуть его вызывающему.
В базу уходит только хеш: открытый пароль живёт лишь в этом ответе.
"""
password = generate_initial_password()
user.set_password(password)
user.must_change_password = True
user.password_changed_at = timezone.now()
user.save(update_fields=["password", "must_change_password", "password_changed_at"])
return password
def reset_employee_password(
*, profile: OrganizationMembership, mode: str, actor, request=None
) -> str | None:
"""Сброс пароля сотрудника. Возвращает пароль только в режиме «показать».
Активные сессии завершаются в обоих режимах: старый пароль больше не
действует, и вход по нему из уже открытых окон продолжаться не должен.
"""
from chatballs.events.services import DomainEvent, enqueue_event
from chatballs.identity.event_handlers import INITIAL_ACCESS_REQUESTED
user = profile.user
password = issue_initial_password(user) if mode == "show" else None
if mode == "mail":
user.must_change_password = True
user.set_unusable_password()
user.password_changed_at = timezone.now()
user.save(update_fields=["password", "must_change_password", "password_changed_at"])
revoked = revoke_user_sessions(user.id)
record_audit_event(
action="identity.employee_password_reset",
actor=getattr(actor, "user", actor),
organization=profile.organization,
object_type="HumanUser",
object_id=str(user.id),
payload={"mode": mode, "sessionsRevoked": revoked},
request=request,
)
if mode == "mail" and request is not None:
enqueue_event(
DomainEvent(
aggregate_type="HumanUser",
aggregate_id=str(user.id),
event_type=INITIAL_ACCESS_REQUESTED,
payload={"userId": user.id},
tenant_context=request.tenant_context,
)
)
return password
@@ -5,30 +5,77 @@ from rest_framework.response import Response
from rest_framework.views import APIView
from chatballs.identity.audit import record_audit_event
from chatballs.identity.employee_password import clean_password_mode, reset_employee_password
from chatballs.identity.employee_support import employee_payload, get_owned_profile
from chatballs.identity.employee_validation import deny_employee_action
from chatballs.identity.governance import EmployeeAction, can_manage_employee
from chatballs.identity.sessions import revoke_user_sessions
class EmployeeResetPasswordView(APIView):
"""Сброс пароля сотрудника (дизайн-базлайн v2, кадр E8).
``mode=show`` возвращает сгенерированный пароль ровно один раз — открытым он
нигде не хранится; ``mode=mail`` отправляет письмо со ссылкой первого входа.
"""
permission_classes = [IsAuthenticated]
@transaction.atomic
def post(self, request: Request, user_id: int) -> Response:
actor = request.tenant_context.membership
profile = get_owned_profile(request, user_id)
if profile is None:
return Response({"detail": "Employee not found"}, status=404)
return deny_employee_action(request, profile, EmployeeAction.RESET_PASSWORD)
if not can_manage_employee(actor, profile, EmployeeAction.RESET_PASSWORD):
return deny_employee_action(request, profile, EmployeeAction.RESET_PASSWORD)
mode = clean_password_mode(request.data.get("mode"))
if mode is None:
return Response({"detail": "Unknown password mode"}, status=400)
# Пароль живёт на пользователе, а не на членстве: сбрасывать его из одной
# организации, когда человек работает и в другой, нельзя.
if profile.user.memberships.count() > 1:
return Response(
{
"detail": (
"Сотрудник состоит в нескольких организациях — "
"пароль сбрасывает он сам через восстановление доступа"
)
},
status=409,
)
password = reset_employee_password(
profile=profile, mode=mode, actor=actor, request=request
)
profile.refresh_from_db()
return Response(
{"employee": employee_payload(profile, actor), "password": password}
)
class EmployeeRevokeSessionsView(APIView):
"""Завершение всех сессий сотрудника (кадры E2/E3)."""
permission_classes = [IsAuthenticated]
def post(self, request: Request, user_id: int) -> Response:
actor = request.tenant_context.membership
profile = get_owned_profile(request, user_id)
if profile is None:
return Response({"detail": "Employee not found"}, status=404)
return deny_employee_action(request, profile, EmployeeAction.TERMINATE_SESSIONS)
if not can_manage_employee(actor, profile, EmployeeAction.TERMINATE_SESSIONS):
return deny_employee_action(request, profile, EmployeeAction.TERMINATE_SESSIONS)
revoked = revoke_user_sessions(profile.user_id)
record_audit_event(
action="identity.employee_sessions_terminated",
actor=request.user,
organization=profile.organization,
object_type="HumanUser",
object_id=str(profile.user_id),
payload={"sessionsRevoked": revoked},
request=request,
)
return Response({"employee": employee_payload(profile, actor), "revoked": revoked})
class EmployeeBlockView(APIView):
@@ -29,6 +29,12 @@ def employee_payload(
"isActive": profile.user.is_active,
"isBlocked": profile.is_blocked,
"mustChangePassword": profile.user.must_change_password,
# «Последняя смена» пароля в карточке сотрудника (кадр E3).
"passwordChangedAt": (
profile.user.password_changed_at.isoformat()
if profile.user.password_changed_at
else None
),
"totpRequired": profile.totp_required,
"totpEnabled": profile.user.totp_enabled,
}
@@ -14,6 +14,7 @@ from chatballs.identity.employee_validation import (
deny_employee_action,
resolve_groups,
)
from chatballs.identity.employee_password import clean_password_mode, issue_initial_password
from chatballs.identity.event_handlers import INITIAL_ACCESS_REQUESTED
from chatballs.identity.governance import EmployeeAction, can_create_role, can_manage_employee
from chatballs.identity.group_models import EmployeeGroupMember
@@ -90,6 +91,8 @@ class EmployeeCreateView(APIView):
full_name = str(body.get("fullName", "")).strip()
phone = str(body.get("phone", "")).strip()
provided_password = str(body.get("temporaryPassword", ""))
# Кадры E5/E6: пароль первичного доступа письмом либо показать один раз.
password_mode = clean_password_mode(body.get("passwordMode"))
position_title, position_error = clean_position_title(body.get("positionTitle"))
requested_role = str(body.get("role", EmployeeRole.EMPLOYEE))
@@ -107,6 +110,8 @@ class EmployeeCreateView(APIView):
return Response({"detail": position_error}, status=400)
if provided_password:
return Response({"detail": "Temporary passwords are not supported"}, status=400)
if password_mode is None:
return Response({"detail": "Unknown password mode"}, status=400)
if HumanUser.objects.filter(email=email).exists():
return Response({"detail": "Email is already used"}, status=400)
@@ -131,25 +136,30 @@ class EmployeeCreateView(APIView):
)
if groups:
_set_groups(profile, groups)
password = issue_initial_password(user) if password_mode == "show" else None
record_audit_event(
action="identity.employee_created",
actor=request.user,
organization=actor.organization,
object_type="HumanUser",
object_id=str(user.id),
payload={"role": requested_role},
payload={"role": requested_role, "passwordMode": password_mode},
request=request,
)
enqueue_event(
DomainEvent(
aggregate_type="HumanUser",
aggregate_id=str(user.id),
event_type=INITIAL_ACCESS_REQUESTED,
payload={"userId": user.id},
tenant_context=request.tenant_context,
if password_mode == "mail":
enqueue_event(
DomainEvent(
aggregate_type="HumanUser",
aggregate_id=str(user.id),
event_type=INITIAL_ACCESS_REQUESTED,
payload={"userId": user.id},
tenant_context=request.tenant_context,
)
)
return Response(
{"employee": employee_payload(profile, actor), "password": password},
status=201,
)
return Response({"employee": employee_payload(profile, actor)}, status=201)
class EmployeeDetailView(APIView):
@@ -0,0 +1,21 @@
"""Отметка активности сессии.
Карточка «Активные сессии» в «Профиле» (дизайн-базлайн v2, кадр P1) показывает,
когда сессия была активна. Отдельной таблицы нет: отметка живёт в самой сессии
и обновляется не чаще раза в минуту, чтобы не переписывать её на каждый запрос.
"""
from __future__ import annotations
from chatballs.identity.sessions import touch_session
class SessionActivityMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
user = getattr(request, "user", None)
if user is not None and user.is_authenticated and hasattr(request, "session"):
touch_session(request)
return self.get_response(request)
@@ -0,0 +1,15 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("identity", "0024_employeegroup_color"),
]
operations = [
migrations.AddField(
model_name="humanuser",
name="totp_last_used_at",
field=models.DateTimeField(blank=True, db_default=None, null=True),
),
]
@@ -0,0 +1,15 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("identity", "0025_humanuser_totp_last_used_at"),
]
operations = [
migrations.AddField(
model_name="humanuser",
name="password_changed_at",
field=models.DateTimeField(blank=True, db_default=None, null=True),
),
]
@@ -63,8 +63,13 @@ class HumanUser(AbstractUser):
email = models.EmailField(unique=True)
full_name = models.CharField(max_length=255, blank=True)
must_change_password = models.BooleanField(default=False)
# «Последняя смена пароля» в карточке сотрудника (кадр E3).
password_changed_at = models.DateTimeField(null=True, blank=True, db_default=None)
totp_enabled = models.BooleanField(default=False)
totp_secret = EncryptedCharField(max_length=255, blank=True)
# Когда последний раз принимался код аутентификатора — подпись в карточке
# «Двухфакторная аутентификация» (дизайн-базлайн v2, кадр P1).
totp_last_used_at = models.DateTimeField(null=True, blank=True, db_default=None)
# Внешний вид — глобальная настройка пользователя (не membership):
# тема и акцентный HEX-цвет; пустой акцент — дефолтный синий #1677ff.
ui_theme = models.CharField(max_length=8, choices=UiTheme.choices, default=UiTheme.SYSTEM)
+126 -5
View File
@@ -1,12 +1,133 @@
"""Серверные сессии пользователя: счётчик, список для «Профиля» и отзыв.
Карточка «Активные сессии» (дизайн-базлайн v2, кадр P1) показывает устройство,
адрес и когда сессия была активна. Отдельной таблицы нет: браузер и адрес
пишутся в саму сессию при входе, отметка активности обновляется middleware
не чаще раза в минуту.
"""
from __future__ import annotations
import re
from django.contrib.sessions.models import Session
from django.utils import timezone
SESSION_AGENT_KEY = "device_agent"
SESSION_IP_KEY = "device_ip"
SESSION_STARTED_KEY = "device_started"
SESSION_SEEN_KEY = "device_seen"
# Чаще раза в минуту отметку не обновляем: иначе запись сессии на каждый запрос.
SEEN_THROTTLE_SECONDS = 60
_BROWSERS = (
("YaBrowser", "Яндекс.Браузер"),
("Edg/", "Edge"),
("OPR/", "Opera"),
("Firefox", "Firefox"),
("Chrome", "Chrome"),
("Safari", "Safari"),
)
_PLATFORMS = (
("Android", "Android"),
("iPhone", "iPhone"),
("iPad", "iPad"),
("Macintosh", "macOS"),
("Windows", "Windows"),
("Linux", "Linux"),
)
def describe_agent(user_agent: str) -> str:
"""«Chrome · macOS» из строки User-Agent; неизвестное — «Браузер»."""
browser = next((label for token, label in _BROWSERS if token in user_agent), "")
platform = next((label for token, label in _PLATFORMS if token in user_agent), "")
if browser and platform:
return f"{browser} · {platform}"
return browser or platform or "Браузер"
def device_kind(user_agent: str) -> str:
"""Какой значок ставить в строке сессии: телефон, ноутбук или монитор."""
if any(token in user_agent for token in ("iPhone", "Android", "iPad")):
return "phone"
if "Macintosh" in user_agent:
return "laptop"
return "monitor"
def mask_ip(value: str) -> str:
"""Адрес показываем частично: «91.108.•.•» (кадр P1)."""
if not value:
return ""
if re.fullmatch(r"\d{1,3}(\.\d{1,3}){3}", value):
first, second, *_ = value.split(".")
return f"{first}.{second}.•.•"
head = value.split(":")[0]
return f"{head}:•" if head else ""
def client_ip(request) -> str:
forwarded = request.META.get("HTTP_X_FORWARDED_FOR", "")
if forwarded:
return forwarded.split(",")[0].strip()
return request.META.get("REMOTE_ADDR", "")
def remember_device(request) -> None:
"""Запомнить браузер и адрес в сессии — вызывается сразу после login()."""
now = timezone.now().isoformat()
request.session[SESSION_AGENT_KEY] = request.META.get("HTTP_USER_AGENT", "")[:400]
request.session[SESSION_IP_KEY] = client_ip(request)
request.session[SESSION_STARTED_KEY] = now
request.session[SESSION_SEEN_KEY] = now
def touch_session(request) -> None:
"""Обновить отметку активности, но не чаще SEEN_THROTTLE_SECONDS."""
previous = request.session.get(SESSION_SEEN_KEY)
now = timezone.now()
if previous:
try:
elapsed = (now - timezone.datetime.fromisoformat(previous)).total_seconds()
except (TypeError, ValueError):
elapsed = SEEN_THROTTLE_SECONDS
if elapsed < SEEN_THROTTLE_SECONDS:
return
request.session[SESSION_SEEN_KEY] = now.isoformat()
def _decoded_sessions(user_id: int):
for session in Session.objects.all():
data = session.get_decoded()
if str(data.get("_auth_user_id")) == str(user_id):
yield session, data
def count_user_sessions(user_id: int) -> int:
return sum(
1
for session in Session.objects.all()
if str(session.get_decoded().get("_auth_user_id")) == str(user_id)
)
return sum(1 for _ in _decoded_sessions(user_id))
def list_user_sessions(user_id: int, current_session_key: str | None = None) -> list[dict[str, object]]:
"""Строки карточки «Активные сессии»: устройство, адрес, когда активна."""
items = []
for session, data in _decoded_sessions(user_id):
agent = str(data.get(SESSION_AGENT_KEY, ""))
items.append(
{
"id": session.session_key[:12],
"device": describe_agent(agent),
"kind": device_kind(agent),
"address": mask_ip(str(data.get(SESSION_IP_KEY, ""))),
"startedAt": data.get(SESSION_STARTED_KEY),
"lastSeenAt": data.get(SESSION_SEEN_KEY),
"current": session.session_key == current_session_key,
}
)
# Текущая сессия первой, дальше — по свежести активности.
items.sort(key=lambda item: str(item["lastSeenAt"] or ""), reverse=True)
items.sort(key=lambda item: not item["current"])
return items
def revoke_user_sessions(user_id: int, *, except_session_key: str | None = None) -> int:
@@ -61,6 +61,8 @@ class AdministrationApiTests(TestCase):
"timezone": "Europe/Moscow",
"currency": "RUB",
"logoUrl": None,
# Подпись «Сохранено …» у кнопки (кадр N1): пока правок не было — null.
"updatedAt": None,
},
)
self.assertIn("Europe/Moscow", response.json()["timezones"])
@@ -0,0 +1,126 @@
"""Пароль первичного доступа и сессии сотрудника (дизайн-базлайн v2, E5–E8)."""
import json
from django.test import TestCase
from chatballs.identity.employee_password import generate_initial_password
from chatballs.identity.models import (
AuditEvent,
EmployeeRole,
HumanUser,
Organization,
OrganizationMembership,
)
from chatballs.testing import TenantAPIClient as APIClient
class EmployeePasswordTests(TestCase):
def setUp(self) -> None:
self.organization = Organization.objects.create(name="Ателье", slug="atelie-pwd")
self.owner = self._member("owner@atelie.test", EmployeeRole.OWNER)
self.employee = self._member("operator@atelie.test", EmployeeRole.EMPLOYEE)
self.client = APIClient()
self.client.force_authenticate(self.owner.user)
def _member(self, email: str, role: str) -> OrganizationMembership:
user = HumanUser.objects.create_user(email=email, password="Password-123")
return OrganizationMembership.objects.create(
user=user, organization=self.organization, role=role, position_title="Specialist"
)
def _post(self, path: str, body: dict | None = None):
return self.client.post(path, data=json.dumps(body or {}), content_type="application/json")
def test_generated_password_is_readable(self) -> None:
"""Пароль вида «kv7-Rt94-Xmz2»: три группы, без похожих символов."""
password = generate_initial_password()
groups = password.split("-")
self.assertEqual([len(part) for part in groups], [3, 4, 4])
self.assertFalse(set(password) & set("lI0Oo1"))
def test_reset_show_returns_password_once_and_stores_only_hash(self) -> None:
response = self._post(
f"/api/v1/employees/{self.employee.user_id}/reset-password/", {"mode": "show"}
)
self.assertEqual(response.status_code, 200)
password = response.json()["password"]
self.assertTrue(password)
self.employee.user.refresh_from_db()
# В базе только хеш: открытый пароль живёт лишь в этом ответе.
self.assertNotIn(password, self.employee.user.password)
self.assertTrue(self.employee.user.check_password(password))
self.assertTrue(self.employee.user.must_change_password)
self.assertIsNotNone(self.employee.user.password_changed_at)
self.assertTrue(
AuditEvent.objects.filter(
organization=self.organization, action="identity.employee_password_reset"
).exists()
)
def test_reset_mail_does_not_return_password(self) -> None:
response = self._post(
f"/api/v1/employees/{self.employee.user_id}/reset-password/", {"mode": "mail"}
)
self.assertEqual(response.status_code, 200)
self.assertIsNone(response.json()["password"])
self.employee.user.refresh_from_db()
self.assertFalse(self.employee.user.has_usable_password())
def test_unknown_password_mode_is_rejected(self) -> None:
response = self._post(
f"/api/v1/employees/{self.employee.user_id}/reset-password/", {"mode": "sms"}
)
self.assertEqual(response.status_code, 400)
def test_employee_cannot_reset_another_password(self) -> None:
client = APIClient()
client.force_authenticate(self.employee.user)
response = client.post(
f"/api/v1/employees/{self.owner.user_id}/reset-password/",
data=json.dumps({"mode": "show"}),
content_type="application/json",
)
self.assertEqual(response.status_code, 403)
def test_sessions_are_terminated_and_audited(self) -> None:
response = self._post(f"/api/v1/employees/{self.employee.user_id}/revoke-sessions/")
self.assertEqual(response.status_code, 200)
self.assertIn("revoked", response.json())
self.assertTrue(
AuditEvent.objects.filter(
organization=self.organization, action="identity.employee_sessions_terminated"
).exists()
)
def test_create_returns_password_only_in_show_mode(self) -> None:
shown = self._post(
"/api/v1/employees/operators/",
{
"email": "new@atelie.test",
"fullName": "Ольга Титова",
"positionTitle": "Оператор",
"role": EmployeeRole.EMPLOYEE,
"passwordMode": "show",
},
)
self.assertEqual(shown.status_code, 201)
password = shown.json()["password"]
self.assertTrue(password)
created = HumanUser.objects.get(email="new@atelie.test")
self.assertTrue(created.check_password(password))
self.assertTrue(created.must_change_password)
mailed = self._post(
"/api/v1/employees/operators/",
{
"email": "mail@atelie.test",
"fullName": "Павел Зайцев",
"positionTitle": "Оператор",
"role": EmployeeRole.EMPLOYEE,
"passwordMode": "mail",
},
)
self.assertEqual(mailed.status_code, 201)
self.assertIsNone(mailed.json()["password"])
@@ -0,0 +1,30 @@
"""Разбор устройства и адреса для карточки «Активные сессии» (кадр P1)."""
from django.test import SimpleTestCase
from chatballs.identity.sessions import describe_agent, device_kind, mask_ip
class SessionMetaTests(SimpleTestCase):
def test_describes_browser_and_platform(self) -> None:
chrome_mac = (
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/126.0 Safari/537.36"
)
safari_iphone = (
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 "
"(KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1"
)
self.assertEqual(describe_agent(chrome_mac), "Chrome · macOS")
self.assertEqual(describe_agent(safari_iphone), "Safari · iPhone")
self.assertEqual(describe_agent(""), "Браузер")
def test_picks_device_icon(self) -> None:
self.assertEqual(device_kind("iPhone"), "phone")
self.assertEqual(device_kind("Macintosh"), "laptop")
self.assertEqual(device_kind("Windows NT 10.0"), "monitor")
def test_masks_address(self) -> None:
self.assertEqual(mask_ip("91.108.4.17"), "91.108.•.•")
self.assertEqual(mask_ip("2a00:1450:4010:c07::8a"), "2a00:•")
self.assertEqual(mask_ip(""), "")
+29 -4
View File
@@ -539,16 +539,41 @@ class EmployeeEndpointTests(TestCase):
self.assertEqual(operator.memberships.get().phone, "+7 916 245 14 03")
self.assertFalse(operator.totp_enabled)
def test_owner_cannot_reset_operator_global_password(self) -> None:
def test_owner_resets_operator_password(self) -> None:
"""Кадр E8: владелец сбрасывает пароль и получает его один раз."""
operator = HumanUser.objects.get(email="staff.member@example.org")
password_hash = operator.password
response = self.client.post(f"/api/v1/employees/{operator.id}/reset-password/")
response = self.client.post(
f"/api/v1/employees/{operator.id}/reset-password/",
data=json.dumps({"mode": "show"}),
content_type="application/json",
)
self.assertEqual(response.status_code, 403)
self.assertEqual(response.status_code, 200)
operator.refresh_from_db()
self.assertNotEqual(operator.password, password_hash)
self.assertTrue(operator.must_change_password)
self.assertTrue(operator.check_password(response.json()["password"]))
def test_reset_is_refused_for_multi_organization_user(self) -> None:
"""Пароль общий для всех организаций человека — сбрасывать его из одной нельзя."""
operator = HumanUser.objects.get(email="staff.member@example.org")
other = Organization.objects.create(name="Другая", slug="other-org")
OrganizationMembership.objects.create(
user=operator, organization=other, role=EmployeeRole.EMPLOYEE, position_title="Оператор"
)
password_hash = operator.password
response = self.client.post(
f"/api/v1/employees/{operator.id}/reset-password/",
data=json.dumps({"mode": "show"}),
content_type="application/json",
)
self.assertEqual(response.status_code, 409)
operator.refresh_from_db()
self.assertEqual(operator.password, password_hash)
self.assertFalse(operator.must_change_password)
def test_owner_unblocks_operator(self) -> None:
operator = HumanUser.objects.get(email="staff.member@example.org")
@@ -1,14 +1,26 @@
from chatballs.integrations.models import Integration
def secret_mask(secret: str) -> str:
"""Маска секрета для колонки «Секрет» (кадр N3): только публичный префикс
ключа («sk-or-»), сам секрет наружу не отдаётся."""
if not secret:
return ""
head = secret[:8]
cut = head.rfind("-")
prefix = head[: cut + 1] if cut > 0 else ""
return f"{prefix}••••••••"
def integration_payload(integration: Integration) -> dict[str, object]:
# Секрет не возвращаем; отдаём только признак его наличия.
# Секрет не возвращаем; отдаём признак его наличия и маску префикса.
payload = {
"id": integration.id,
"kind": integration.kind,
"provider": integration.provider,
"name": integration.name,
"hasSecret": bool(integration.secret),
"secretMasked": secret_mask(integration.secret),
"isActive": integration.is_active,
"config": {
"baseUrl": integration.config.get("base_url", ""),
@@ -0,0 +1,47 @@
from django.db import migrations
FORWARD_SQL = """
ALTER TABLE conversations_contactmerge OWNER TO chatballs_schema;
ALTER TABLE conversations_contactmerge ENABLE ROW LEVEL SECURITY;
ALTER TABLE conversations_contactmerge FORCE ROW LEVEL SECURITY;
REVOKE ALL ON conversations_contactmerge FROM PUBLIC;
GRANT SELECT, INSERT, UPDATE, DELETE
ON conversations_contactmerge TO chatballs_runtime_app;
GRANT ALL ON conversations_contactmerge TO chatballs_schema;
GRANT USAGE, SELECT ON SEQUENCE conversations_contactmerge_id_seq
TO chatballs_runtime_app, chatballs_schema;
DROP POLICY IF EXISTS chatballs_tenant_isolation ON conversations_contactmerge;
CREATE POLICY chatballs_tenant_isolation ON conversations_contactmerge
FOR ALL TO chatballs_runtime_app
USING (organization_id = chatballs.current_organization_id())
WITH CHECK (organization_id = chatballs.current_organization_id());
DROP POLICY IF EXISTS chatballs_schema_access ON conversations_contactmerge;
CREATE POLICY chatballs_schema_access ON conversations_contactmerge
FOR ALL TO chatballs_schema
USING (true)
WITH CHECK (true);
"""
REVERSE_SQL = """
DROP POLICY IF EXISTS chatballs_tenant_isolation ON conversations_contactmerge;
DROP POLICY IF EXISTS chatballs_schema_access ON conversations_contactmerge;
ALTER TABLE conversations_contactmerge NO FORCE ROW LEVEL SECURITY;
ALTER TABLE conversations_contactmerge DISABLE ROW LEVEL SECURITY;
REVOKE ALL ON conversations_contactmerge FROM chatballs_runtime_app;
REVOKE USAGE, SELECT ON SEQUENCE conversations_contactmerge_id_seq
FROM chatballs_runtime_app;
"""
class Migration(migrations.Migration):
dependencies = [
("tenancy", "0025_storage_settings"),
("conversations", "0018_contact_merge"),
]
operations = [migrations.RunSQL(FORWARD_SQL, REVERSE_SQL)]
@@ -76,6 +76,7 @@ MIDDLEWARE = [
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"chatballs.identity.middleware.SessionActivityMiddleware",
"chatballs.events.middleware.CorrelationIdMiddleware",
"chatballs.tenancy.middleware.TenantContextMiddleware",
]
+2 -2
View File
@@ -84,7 +84,7 @@ export function App() {
if (activeUser && !initialRoute.organizationPublicId) {
const nextPath = pathFromRoute(
initialRoute.route,
initialRoute.employeeId || initialRoute.agentId || initialRoute.knowledgeId || initialRoute.clientId || initialRoute.channelId || initialRoute.supportPortalId,
initialRoute.employeeId || initialRoute.agentId || initialRoute.knowledgeId || initialRoute.clientId || initialRoute.channelId || initialRoute.supportPortalId || initialRoute.settingsSection,
initialRoute.productCode,
activeUser.organizationPublicId,
);
@@ -170,7 +170,7 @@ export function App() {
) : !canAccess(user, navigation.route) ? (
<PermissionScreen onReturn={() => navigate(defaultRoute(user), null, true)} />
) : (
<Shell route={navigation.route} setRoute={(nextRoute) => navigate(nextRoute)} selectedEmployeeId={navigation.selectedEmployeeId} selectedProductCode={navigation.selectedProductCode} selectedAgentId={navigation.selectedAgentId} selectedKnowledgeId={navigation.selectedKnowledgeId} selectedConversationId={navigation.selectedConversationId} selectedClientId={navigation.selectedClientId} openClientRoute={(clientId) => navigate("salesClientDetail", clientId)} selectedChannelId={navigation.selectedChannelId} openChannelRoute={(channelId) => navigate("agentDetail", channelId)} selectedSupportPortalId={navigation.selectedSupportPortalId} openSupportPortalRoute={(portalId) => navigate("supportPortalDetail", portalId)} openEmployeeRoute={(employeeId) => navigate("employeeDetail", employeeId)} openAgentCreateRoute={() => navigate("agents")} openAgentRoute={(agentId) => navigate("agentDetail", agentId)} openKnowledgeRoute={(knowledgeId) => navigate("aiKnowledgeDetail", knowledgeId)} openConversationRoute={(conversationId) => navigate("chat", conversationId)} user={user} data={data} reload={loadData} onUserUpdated={refreshIdentity} onLogout={logout} />
<Shell route={navigation.route} setRoute={(nextRoute) => navigate(nextRoute)} selectedEmployeeId={navigation.selectedEmployeeId} selectedProductCode={navigation.selectedProductCode} selectedAgentId={navigation.selectedAgentId} selectedKnowledgeId={navigation.selectedKnowledgeId} selectedConversationId={navigation.selectedConversationId} selectedClientId={navigation.selectedClientId} openClientRoute={(clientId) => navigate("salesClientDetail", clientId)} selectedChannelId={navigation.selectedChannelId} openChannelRoute={(channelId) => navigate("agentDetail", channelId)} selectedSupportPortalId={navigation.selectedSupportPortalId} openSupportPortalRoute={(portalId) => navigate("supportPortalDetail", portalId)} settingsSection={navigation.selectedSettingsSection} openSettingsRoute={(section) => navigate("settings", section)} openEmployeeRoute={(employeeId) => navigate("employeeDetail", employeeId)} openAgentCreateRoute={() => navigate("agents")} openAgentRoute={(agentId) => navigate("agentDetail", agentId)} openKnowledgeRoute={(knowledgeId) => navigate("aiKnowledgeDetail", knowledgeId)} openConversationRoute={(conversationId) => navigate("chat", conversationId)} user={user} data={data} reload={loadData} onUserUpdated={refreshIdentity} onLogout={logout} />
)}
</ConfigProvider>
);
+4 -1
View File
@@ -10,6 +10,7 @@ function userWith(role: Role, capabilities: string[] = []): SessionUser {
fullName: "Test User",
mustChangePassword: false,
totpEnabled: false,
totpLastUsedAt: null,
deliveryMode: "CLOUD",
uiTheme: "SYSTEM",
uiAccent: "",
@@ -23,6 +24,7 @@ function userWith(role: Role, capabilities: string[] = []): SessionUser {
totpRequired: false,
capabilities,
groups: [],
joinedAt: "2026-01-01T00:00:00Z",
};
}
@@ -44,7 +46,8 @@ describe("role-based navigation (SPEC-HUB-0031 §3)", () => {
expect(isManager(user)).toBe(false);
expect(canAccess(user, "chat")).toBe(true);
expect(canAccess(user, "profile")).toBe(true);
expect(canAccess(user, "settings")).toBe(true);
// «Настройки» — настройки организации, сотруднику недоступны (дизайн-базлайн v2).
expect(canAccess(user, "settings")).toBe(false);
expect(canAccess(user, "employees")).toBe(false);
expect(canAccess(user, "agents")).toBe(false);
expect(canAccess(user, "aiKnowledge")).toBe(false);
+3 -2
View File
@@ -7,7 +7,6 @@ import type { RouteKey, SessionUser } from "../types";
const EMPLOYEE_ROUTES: ReadonlySet<RouteKey> = new Set<RouteKey>([
"chat",
"profile",
"settings",
]);
export function isManager(user: SessionUser): boolean {
@@ -19,7 +18,9 @@ export function hasCapability(user: SessionUser, capability: string): boolean {
}
export function canAccess(user: SessionUser, route: RouteKey): boolean {
if (route === "profile" || route === "settings") return true;
// «Настройки» — настройки организации: только владелец и админ. Личные
// параметры сотрудника живут на странице «Профиль» (дизайн-базлайн v2).
if (route === "profile") return true;
if (isManager(user)) return true;
return EMPLOYEE_ROUTES.has(route);
}
@@ -30,39 +30,38 @@ export function OrganizationLogoField({
</div>
<div className="administration-logo-copy">
<strong>Логотип</strong>
<span>PNG, JPEG или WebP · до 2 МБ</span>
{!disabled && (
<div className="administration-logo-actions">
<input
ref={input}
type="file"
accept="image/png,image/jpeg,image/webp"
hidden
onChange={(event) => {
const file = event.target.files?.[0];
if (file) onUpload(file);
event.target.value = "";
}}
/>
<Button
variant="secondary"
disabled={saving}
onClick={() => input.current?.click()}
>
{logoUrl ? "Заменить" : "Загрузить"}
</Button>
{logoUrl && (
<Button
variant="danger-outline"
disabled={saving}
onClick={() => setConfirmingRemoval(true)}
>
Удалить
</Button>
)}
</div>
)}
<span>PNG, JPEG или WebP · до 2 МБ. Виден в сайдбаре, виджете и письмах.</span>
</div>
{!disabled && (
<div className="administration-logo-actions">
<input
ref={input}
type="file"
accept="image/png,image/jpeg,image/webp"
hidden
onChange={(event) => {
const file = event.target.files?.[0];
if (file) onUpload(file);
event.target.value = "";
}}
/>
<Button
variant="secondary"
disabled={saving}
onClick={() => input.current?.click()}
>
Загрузить
</Button>
<button
className="administration-logo-remove"
type="button"
disabled={saving || !logoUrl}
onClick={() => setConfirmingRemoval(true)}
>
Убрать
</button>
</div>
)}
<DecisionDialog
open={confirmingRemoval}
onClose={() => setConfirmingRemoval(false)}
@@ -1,10 +1,16 @@
import type { FormEvent } from "react";
import { FormField, SelectField } from "../../shared/form-controls";
import { shortDateTime, timezoneLabel } from "../../shared/utils";
import { Button } from "../../shared/ui-controls";
import type { OrganizationSettings } from "./model";
import { OrganizationLogoField } from "./OrganizationLogoField";
function savedLabel(updatedAt: string | null): string {
if (!updatedAt) return "Ещё не сохранялось";
return `Сохранено ${shortDateTime(updatedAt)}`;
}
export function OrganizationSettingsForm({
organization,
canManage,
@@ -44,35 +50,36 @@ export function OrganizationSettingsForm({
onRemove={onRemoveLogo}
/>
<div className="administration-fields">
<FormField
label="Название организации"
value={organization.name}
disabled={!canManage}
onChange={canManage
? (name) => onChange({ ...organization, name })
: undefined}
/>
<div className="administration-field-wide">
<FormField
label="Название организации"
value={organization.name}
disabled={!canManage}
onChange={canManage
? (name) => onChange({ ...organization, name })
: undefined}
/>
</div>
<SelectField
label="Часовой пояс"
value={organization.timezone}
disabled={!canManage}
onChange={(timezone) => onChange({ ...organization, timezone })}
options={timezones.map((timezone) => [timezone, timezone])}
options={timezones.map((timezone) => [timezone, timezoneLabel(timezone)])}
/>
<SelectField
label="Валюта"
value={organization.currency}
disabled={!canManage}
onChange={(currency) => onChange({ ...organization, currency })}
options={[["RUB", "Российский рубль (RUB)"]]}
/>
<div className="administration-currency">
<SelectField
label="Валюта"
value={organization.currency}
disabled={!canManage}
onChange={(currency) => onChange({ ...organization, currency })}
options={[["RUB", "Российский рубль (RUB)"]]}
/>
</div>
</div>
{error && <div className="administration-message error" role="alert">{error}</div>}
{message && <div className="administration-message">{message}</div>}
{canManage && (
<div className="administration-actions">
{/* «Сохранено 2 сен, 14:12» — подпись у кнопки (кадр N1). */}
<small className="administration-saved">{message || savedLabel(organization.updatedAt)}</small>
<Button
type="submit"
variant="primary"
@@ -10,6 +10,8 @@ export type OrganizationSettings = {
timezone: string;
currency: string;
logoUrl: string | null;
// Когда настройки сохраняли в последний раз — подпись у кнопки (кадр N1).
updatedAt: string | null;
};
export type AuditEvent = {
@@ -12,34 +12,37 @@
.administration-card {
width: 100%;
padding: 20px;
padding: 22px 24px;
background: var(--surface-card);
border: 1px solid var(--border-default);
border-radius: var(--radius-2xl);
box-shadow: var(--shadow-sm);
}
/* Логотип организации (дизайн-базлайн v2, кадр N1): плитка · подпись ·
«Загрузить»/«Убрать» справа, снизу — разделитель. */
.administration-logo-field {
display: flex;
align-items: center;
gap: 14px;
padding-bottom: 18px;
border-bottom: 1px solid var(--border-default);
gap: 16px;
padding-bottom: 20px;
margin-bottom: 20px;
border-bottom: 1px solid var(--n-9);
}
.administration-logo-preview {
width: 56px;
height: 56px;
width: 64px;
height: 64px;
display: flex;
align-items: center;
justify-content: center;
flex: none;
overflow: hidden;
color: var(--primary);
background: var(--primary-bg);
border: 1px solid var(--primary-border);
border-radius: var(--radius-xl);
font-size: 17px;
color: var(--n-1);
background: var(--n-9);
border: 1px dashed var(--n-6);
border-radius: var(--radius-4xl);
font-size: 22px;
font-weight: 700;
}
@@ -51,43 +54,62 @@
}
.administration-logo-copy {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 3px;
}
.administration-logo-copy > strong {
color: var(--text-body);
font-size: 13px;
color: var(--n-1);
font-size: 13.5px;
}
.administration-logo-copy > span {
color: var(--text-tertiary);
font-size: 11.5px;
color: var(--n-4);
font-size: 12px;
line-height: 1.45;
}
.administration-logo-actions {
display: flex;
flex: none;
gap: 8px;
margin-top: 7px;
}
.administration-logo-actions button {
height: 30px;
padding-inline: 10px;
font-size: 12px;
height: 32px;
padding-inline: 12px;
font-size: 12.5px;
}
.administration-logo-remove {
height: 32px;
padding: 0 12px;
border: 0;
border-radius: 8px;
background: transparent;
color: var(--n-4);
font-family: inherit;
font-weight: 500;
cursor: pointer;
}
.administration-logo-remove:hover {
background: var(--n-9);
color: var(--n-2);
}
.administration-fields {
display: grid;
grid-template-columns: minmax(280px, 460px) 220px;
gap: 14px;
grid-template-columns: 1fr 1fr;
gap: 16px 20px;
align-items: end;
margin-top: 18px;
}
.administration-currency {
width: 220px;
.administration-field-wide {
grid-column: 1 / -1;
}
.administration-message {
@@ -108,8 +130,17 @@
.administration-actions {
display: flex;
align-items: center;
justify-content: flex-end;
margin-top: 18px;
gap: 12px;
margin-top: 22px;
padding-top: 18px;
border-top: 1px solid var(--n-9);
}
.administration-saved {
color: var(--n-4);
font-size: 12px;
}
.administration-audit {
@@ -1,29 +1,43 @@
import { Modal } from "antd";
import { Dropdown, Modal } from "antd";
import { useCallback, useEffect, useState } from "react";
import { ApiError } from "../../api/client";
import { ChannelBadge } from "../../shared/badges";
import { FormField, KeyValue, SelectField, TextAreaField } from "../../shared/form-controls";
import { api, ApiError } from "../../api/client";
import { ChannelGlyph } from "../../shared/badges";
import { Icon } from "../../shared/icons";
import { providerLabel } from "../../shared/providers";
import { EmptyState, ErrorScreen, LoadingState, StatusPill } from "../../shared/ui";
import { Button } from "../../shared/ui-controls";
import { formatDate } from "../../shared/utils";
import { EmptyState, ErrorScreen, LoadingState } from "../../shared/ui";
import { BackLink, Button, CopyButton } from "../../shared/ui-controls";
import type { EmployeeGroup, RouteKey } from "../../types";
import { api } from "../../api/client";
import { fetchLlmProviders, type Integration } from "../integrations/model";
import { connectionStatus } from "./connection-status";
import { groupColorOf } from "../conversations/model";
import { linkKnowledgeToAgent, linkPortalArticlesToAgent } from "../ai/knowledge/api";
import { fetchLlmProviders, webWidgetSnippet, type Integration } from "../integrations/model";
import { AgentKnowledgeDialog } from "./AgentKnowledgeDialog";
import {
agentStatusMeta,
agentTile,
agentTint,
bindAgentConnection,
connectionStatusMeta,
connectionSubtitle,
createdLabel,
dailyCostCents,
dailyCostInput,
deleteAgent,
fetchAgent,
knowledgeLine,
knowledgeRows,
openDialogsLine,
patchAgent,
setAgentAiActive,
unbindAgentConnection,
type AgentCard,
type AgentConnection,
type AgentPatch,
} from "./model";
// Карточка агента (дизайн-базлайн v2, «Агенты Baseline», кадры G3–G5): слева —
// что агент знает и как говорит (инструкции, знания), справа — как он работает
// (назначение, подключения, модель). Изменения применяются сразу (ADR-HUB-0023).
type Feedback = { kind: "error" | "warning"; text: string } | null;
function useAgentCard(agentId: number | null) {
@@ -60,7 +74,7 @@ export function AgentDetailPage({
openAgents,
openKnowledge,
openIntegrations,
setRoute,
openAiProvider,
onLoaded,
}: {
agentId: number | null;
@@ -70,6 +84,7 @@ export function AgentDetailPage({
openAgents: () => void;
openKnowledge: (knowledgeId: number) => void;
openIntegrations: () => void;
openAiProvider: () => void;
setRoute: (route: RouteKey) => void;
onLoaded: (name: string | null) => void;
}) {
@@ -77,6 +92,7 @@ export function AgentDetailPage({
const [busy, setBusy] = useState(false);
const [feedback, setFeedback] = useState<Feedback>(null);
const [deleting, setDeleting] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
const [providers, setProviders] = useState<Integration[]>([]);
const [messengers, setMessengers] = useState<Integration[]>([]);
@@ -194,60 +210,90 @@ export function AgentDetailPage({
}
}
const status = card.aiStatus === "ACTIVE" ? "active" : card.aiStatus === "DRAFT" ? "draft" : "disabled";
const tile = agentTile(card);
const status = agentStatusMeta(card);
const running = card.aiStatus === "ACTIVE";
const menuItems = [
{ key: "copy-code", label: <button type="button" onClick={() => { setMenuOpen(false); void navigator.clipboard?.writeText(card.code); }}><Icon name="copy" size={15} />Скопировать код</button> },
];
return (
<div className="agent-detail">
<header className="agent-detail-header">
<div>
<div className="agent-detail-title">
<h1>{card.name}</h1>
<StatusPill status={card.isActive ? status : "archived"} />
<div className="agent-page">
<BackLink label="Агенты" onClick={openAgents} />
<header className="agent-head">
<span className="agent-head-tile" style={{ background: tile.background, color: tile.color }}>
<Icon name="robot" size={28} strokeWidth={1.8} />
</span>
<div className="agent-head-text">
<div>
<h2>{card.name}</h2>
<b className="agents-status" style={{ background: status.bg, color: status.color }}><i />{status.text}</b>
</div>
<div className="agent-detail-meta">
<span>{card.groupName ?? "Без группы"}</span>
<span>·</span>
<p>
<span><i style={{ background: card.groupId === null ? "var(--n-5)" : groupColorOf(card.groupId, card.groupColor) }} />{card.groupName ?? "Без группы"}</span>
<i />
<code>{card.code}</code>
</div>
<i />
{openDialogsLine(card.counters.openConversations)}
<i />
создан {createdLabel(card.createdAt)}
</p>
</div>
{canManage && (
<div className="agent-detail-actions">
<Button variant="secondary" icon={card.aiStatus === "ACTIVE" ? "pause" : "bolt"} disabled={busy} onClick={() => void toggleAi()}>
{card.aiStatus === "ACTIVE" ? "Остановить AI" : "Запустить AI"}
<div className="agent-head-actions">
<Button variant="secondary" className="agent-toggle" icon={running ? "pause" : "bolt"} disabled={busy} onClick={() => void toggleAi()}>
{running ? "Остановить AI" : "Запустить AI"}
</Button>
<Dropdown menu={{ items: menuItems }} open={menuOpen} onOpenChange={setMenuOpen} trigger={["click"]} overlayClassName="app-dropdown">
<button className="agent-head-menu" type="button" aria-label="Действия агента" title="Действия"><Icon name="more" size={17} strokeWidth={2} /></button>
</Dropdown>
</div>
)}
</header>
{card.providerIntegrationId === null && (
<div className="agent-blocker">
<Icon name="alert" size={18} strokeWidth={2.2} />
<div>
<strong>AI не может отвечать: провайдер не выбран</strong>
<small>Добавьте ключ в «Настройки → AI-провайдер» и выберите провайдера в блоке «Модель». До этого диалоги агента ждут человека.</small>
</div>
{canManage && (
<button type="button" onClick={openAiProvider}>Открыть настройки<Icon name="external" size={13} strokeWidth={2.2} /></button>
)}
</div>
)}
{feedback && <div className={`agent-feedback is-${feedback.kind}`}>{feedback.text}</div>}
<AssignmentSection card={card} groups={groups} canManage={canManage} busy={busy} apply={apply} />
<InstructionsSection card={card} canManage={canManage} busy={busy} apply={apply} />
<ModelSection card={card} providers={providers} canManage={canManage} busy={busy} apply={apply} />
<KnowledgeSection card={card} openKnowledge={openKnowledge} setRoute={setRoute} />
<ConnectionsSection
card={card}
canManage={canManageConnections}
busy={busy}
available={messengers.filter((item) => item.channel === null)}
openIntegrations={openIntegrations}
bind={bind}
unbind={unbind}
/>
<div className="agent-grid">
<div className="agent-column">
<InstructionsCard card={card} canManage={canManage} busy={busy} apply={apply} />
<KnowledgeCard card={card} canManage={canManage} openKnowledge={openKnowledge} reload={reload} />
</div>
<div className="agent-column">
<AssignmentCard card={card} groups={groups} canManage={canManage} busy={busy} apply={apply} />
<ConnectionsCard
card={card}
canManage={canManageConnections}
busy={busy}
available={messengers.filter((item) => item.channel === null)}
openIntegrations={openIntegrations}
bind={bind}
unbind={unbind}
/>
<ModelCard card={card} providers={providers} canManage={canManage} busy={busy} apply={apply} />
</div>
</div>
{canManage && (
<footer className="agent-danger-actions">
<Button
variant="secondary"
icon="pause"
disabled={busy}
onClick={() => void apply({ isActive: !card.isActive })}
>
<footer className="agent-footer">
<button className="agent-footer-button" type="button" disabled={busy} onClick={() => void apply({ isActive: !card.isActive })}>
{card.isActive ? "Выключить агента" : "Включить агента"}
</Button>
<Button variant="danger-outline" icon="trash" disabled={busy} onClick={() => setDeleting(true)}>
Удалить
</Button>
</button>
<button className="agent-footer-button is-danger" type="button" disabled={busy} onClick={() => setDeleting(true)}>Удалить</button>
<small>Удалить можно только без диалогов и подключений</small>
</footer>
)}
@@ -268,9 +314,129 @@ export function AgentDetailPage({
);
}
function AssignmentSection({
card, groups, canManage, busy, apply,
}: {
// --- Инструкции (кадры G3/G5) ---
const INSTRUCTION_FIELDS = [
{ key: "persona", label: "Кто он и что делает", hint: "персонализация", rows: 2, placeholder: "" },
{ key: "tone", label: "Как он должен говорить", hint: "тон", rows: 1, placeholder: "Например: спокойно и вежливо, на «вы»" },
{ key: "instructions", label: "Правила работы", hint: "инструкции", rows: 3, placeholder: "Например: не называй цены, направляй к консультанту" },
] as const;
function InstructionsCard({ card, canManage, busy, apply }: {
card: AgentCard;
canManage: boolean;
busy: boolean;
apply: (patch: AgentPatch) => Promise<boolean>;
}) {
const [draft, setDraft] = useState({ persona: card.persona, tone: card.tone, instructions: card.instructions });
useEffect(() => {
setDraft({ persona: card.persona, tone: card.tone, instructions: card.instructions });
}, [card.persona, card.tone, card.instructions]);
const dirty = draft.persona !== card.persona || draft.tone !== card.tone || draft.instructions !== card.instructions;
return (
<section className="agent-card">
<div className="agent-card-head">
<h3>Инструкции</h3>
<small>Системный промпт собирается из трёх частей в этом порядке</small>
</div>
<div className="agent-instructions">
{INSTRUCTION_FIELDS.map((field) => (
<label className={`agent-field is-text is-rows-${field.rows}`} key={field.key}>
<span>{field.label}<small>{field.hint}</small></span>
{canManage ? (
<textarea
value={draft[field.key]}
placeholder={field.placeholder}
onChange={(event) => setDraft((current) => ({ ...current, [field.key]: event.target.value }))}
/>
) : (
<span className="agent-field-static">{card[field.key] || "—"}</span>
)}
</label>
))}
</div>
{canManage && dirty && (
<div className="agent-dirty">
<small>Есть несохранённые изменения — применятся в runtime сразу</small>
<button type="button" disabled={busy} onClick={() => setDraft({ persona: card.persona, tone: card.tone, instructions: card.instructions })}>Отменить</button>
<button className="is-primary" type="button" disabled={busy} onClick={() => void apply(draft)}>Сохранить</button>
</div>
)}
</section>
);
}
// --- Знания (кадры G3/G4/G5) ---
function KnowledgeCard({ card, canManage, openKnowledge, reload }: {
card: AgentCard;
canManage: boolean;
openKnowledge: (knowledgeId: number) => void;
reload: () => void;
}) {
const [picking, setPicking] = useState(false);
const rows = knowledgeRows(card);
async function detach(kind: "knowledge" | "article", id: number) {
if (kind === "knowledge") await linkKnowledgeToAgent({ agentId: card.aiAgentId, action: "detach", knowledgeIds: [id] });
else await linkPortalArticlesToAgent({ agentId: card.aiAgentId, action: "detach", articleIds: [id] });
reload();
}
return (
<section className="agent-card">
<div className="agent-card-head is-row">
<div><h3>Знания</h3><small>{knowledgeLine(card)}</small></div>
{canManage && (
<button className="agent-inline-button" type="button" onClick={() => setPicking(true)}>
<Icon name="plus" size={13} strokeWidth={2.2} />Выбрать
</button>
)}
</div>
{rows.length === 0 ? (
<p className="agent-knowledge-empty">Знания не прикреплены — агент отвечает только по инструкциям. Выберите статьи из библиотеки или портала поддержки.</p>
) : (
<div className="agent-knowledge-list">
{rows.map((row) => (
<div className="agent-knowledge-row" key={row.key}>
<Icon name={row.icon} size={15} strokeWidth={1.9} />
{row.kind === "knowledge"
? <button className="agent-knowledge-title" type="button" onClick={() => openKnowledge(row.id)}>{row.title}</button>
: <a className="agent-knowledge-title" href={row.href} rel="noreferrer" target="_blank">{row.title}</a>}
{row.chip && <small className={`agent-chip is-${row.chipTone}`}>{row.chip}</small>}
<small className="agent-knowledge-meta">{row.meta}</small>
{canManage && (
<button className="agent-knowledge-remove" type="button" aria-label="Убрать" title="Убрать" onClick={() => void detach(row.kind, row.id)}>
<Icon name="close" size={12} strokeWidth={2.4} />
</button>
)}
</div>
))}
</div>
)}
{picking && (
<AgentKnowledgeDialog
card={card}
onClose={() => setPicking(false)}
onSave={async ({ knowledgeIds, articleIds }) => {
await patchAgent(card.id, { knowledgeIds });
const current = card.portalArticles.map((article) => article.id);
const attach = articleIds.filter((id) => !current.includes(id));
const detachIds = current.filter((id) => !articleIds.includes(id));
if (attach.length) await linkPortalArticlesToAgent({ agentId: card.aiAgentId, action: "attach", articleIds: attach });
if (detachIds.length) await linkPortalArticlesToAgent({ agentId: card.aiAgentId, action: "detach", articleIds: detachIds });
reload();
}}
/>
)}
</section>
);
}
// --- Назначение (кадры G3–G5) ---
function AssignmentCard({ card, groups, canManage, busy, apply }: {
card: AgentCard;
groups: EmployeeGroup[];
canManage: boolean;
@@ -282,192 +448,107 @@ function AssignmentSection({
const dirty = name.trim() !== card.name && name.trim().length > 0;
return (
<section className="agent-card-section">
<header>
<h3>Назначение</h3>
<span>Группа определяет, какие сотрудники видят диалоги агента; без группы диалоги видны всем</span>
</header>
<div className="agent-assignment">
{canManage ? (
<FormField label="Название" value={name} onChange={setName} />
) : (
<KeyValue label="Название" value={card.name} />
)}
{canManage ? (
<SelectField
label="Группа"
value={card.groupId === null ? "" : String(card.groupId)}
onChange={(value) => void apply({ groupId: value ? Number(value) : null })}
options={[["", "Без группы"], ...groups.map((item) => [String(item.id), item.name] as [string, string])]}
/>
) : (
<KeyValue label="Группа" value={card.groupName ?? "Без группы"} />
)}
<KeyValue label="Код" value={<code>{card.code}</code>} />
<KeyValue label="Создан" value={formatDate(card.createdAt)} />
<section className="agent-card is-side">
<h3>Назначение</h3>
<p>Группа определяет, кто из сотрудников видит диалоги агента; без группы — видны всем.</p>
<div className="agent-side-fields">
<label className="agent-field">
<span>Название</span>
{canManage
? <input value={name} onChange={(event) => setName(event.target.value)} onBlur={() => { if (dirty) void apply({ name: name.trim() }); }} />
: <span className="agent-field-static">{card.name}</span>}
</label>
<label className="agent-field is-select">
<span>Группа</span>
<span className="agent-field-control">
<i className="agent-group-dot" style={{ background: card.groupId === null ? "var(--n-5)" : groupColorOf(card.groupId, card.groupColor) }} />
{canManage ? (
<select
disabled={busy}
value={card.groupId === null ? "" : String(card.groupId)}
onChange={(event) => void apply({ groupId: event.target.value ? Number(event.target.value) : null })}
>
<option value="">Без группы</option>
{groups.map((group) => <option value={String(group.id)} key={group.id}>{group.name}</option>)}
</select>
) : (
<span className="agent-field-static">{card.groupName ?? "Без группы"}</span>
)}
<Icon name="chevron" size={14} strokeWidth={2.2} />
</span>
</label>
</div>
{canManage && dirty && (
<div className="agent-section-actions">
<Button variant="secondary" disabled={busy} onClick={() => setName(card.name)}>Отменить</Button>
<Button variant="primary" disabled={busy} onClick={() => void apply({ name: name.trim() })}>Сохранить</Button>
</div>
)}
</section>
);
}
function InstructionsSection({
card, canManage, busy, apply,
}: {
card: AgentCard;
canManage: boolean;
busy: boolean;
apply: (patch: AgentPatch) => Promise<boolean>;
}) {
const [persona, setPersona] = useState(card.persona);
const [tone, setTone] = useState(card.tone);
const [instructions, setInstructions] = useState(card.instructions);
useEffect(() => {
setPersona(card.persona);
setTone(card.tone);
setInstructions(card.instructions);
}, [card.persona, card.tone, card.instructions]);
const dirty = persona !== card.persona || tone !== card.tone || instructions !== card.instructions;
// --- Модель (кадры G3–G5) ---
if (!canManage) {
return (
<section className="agent-card-section">
<header><h3>Инструкции</h3></header>
{card.persona || card.tone || card.instructions ? (
<div className="agent-instructions-grid">
{card.persona && <KeyValue label="Кто он" value={card.persona} />}
{card.tone && <KeyValue label="Как говорит" value={card.tone} />}
{card.instructions && <KeyValue label="Правила" value={card.instructions} />}
</div>
) : (
<p className="agent-muted">Инструкции не заданы.</p>
)}
</section>
);
}
return (
<section className="agent-card-section">
<header>
<h3>Инструкции</h3>
<span>Системный промпт собирается из трёх частей в этом порядке</span>
</header>
<div className="agent-instructions-grid">
<TextAreaField label="Кто он и что делает" value={persona} onChange={setPersona} />
<TextAreaField label="Как он должен говорить" value={tone} onChange={setTone} />
<TextAreaField label="Правила работы" value={instructions} onChange={setInstructions} />
</div>
{dirty && (
<div className="agent-section-actions">
<Button variant="secondary" disabled={busy} onClick={() => { setPersona(card.persona); setTone(card.tone); setInstructions(card.instructions); }}>
Отменить
</Button>
<Button variant="primary" disabled={busy} onClick={() => void apply({ persona, tone, instructions })}>
Сохранить
</Button>
</div>
)}
</section>
);
}
function ModelSection({
card, providers, canManage, busy, apply,
}: {
function ModelCard({ card, providers, canManage, busy, apply }: {
card: AgentCard;
providers: Integration[];
canManage: boolean;
busy: boolean;
apply: (patch: AgentPatch) => Promise<boolean>;
}) {
const [limit, setLimit] = useState(dailyCostInput(card.limits));
useEffect(() => setLimit(dailyCostInput(card.limits)), [card.limits]);
const missingProvider = card.providerIntegrationId === null;
const providerName = providers.find((item) => item.id === card.providerIntegrationId)?.name ?? "";
function saveLimit() {
const cents = dailyCostCents(limit);
if (cents === Number(card.limits.dailyCostUsd ?? 0)) return;
void apply({ limits: { ...card.limits, dailyCostUsd: cents } });
}
return (
<section className="agent-card-section">
<header>
<h3>Модель и провайдер</h3>
<span>AI отвечает через провайдера вашей организации — ключ добавляется в «Настройках», раздел «AI-провайдер»</span>
</header>
<div className="agent-assignment">
<KeyValue label="Модель" value={<code>{card.model}</code>} />
{canManage ? (
<SelectField
label="Провайдер"
value={card.providerIntegrationId ? String(card.providerIntegrationId) : ""}
onChange={(value) => void apply({ providerIntegrationId: value ? Number(value) : null })}
options={[["", "Не выбран"], ...providers.map((item) => [String(item.id), item.name] as [string, string])]}
/>
) : (
<KeyValue
label="Провайдер"
value={card.providerIntegrationId === null
? "Не выбран"
: providers.find((item) => item.id === card.providerIntegrationId)?.name ?? String(card.providerIntegrationId)}
/>
)}
<section className="agent-card is-side">
<h3>Модель</h3>
<p>Ключ провайдера — в «Настройки → AI-провайдер». Модель выбирается из каталога провайдера.</p>
<div className="agent-side-fields">
<label className={`agent-field is-select ${missingProvider ? "is-invalid" : ""}`}>
<span>Провайдер</span>
<span className="agent-field-control">
{canManage ? (
<select
disabled={busy}
value={card.providerIntegrationId ? String(card.providerIntegrationId) : ""}
onChange={(event) => void apply({ providerIntegrationId: event.target.value ? Number(event.target.value) : null })}
>
<option value="">Не выбран</option>
{providers.map((item) => <option value={String(item.id)} key={item.id}>{item.name}</option>)}
</select>
) : (
<span className="agent-field-static">{providerName || "Не выбран"}</span>
)}
<Icon name="chevron" size={14} strokeWidth={2.2} />
</span>
</label>
<label className="agent-field is-model">
<span>Модель</span>
<span className="agent-field-control">
<span className={`agent-field-static ${missingProvider ? "is-placeholder" : ""}`}>{missingProvider ? "выберите провайдера" : card.model}</span>
<Icon name="search" size={14} strokeWidth={1.8} />
</span>
</label>
<div className="agent-limit">
<span>Лимит в день</span>
<span>
{canManage
? <input value={limit} inputMode="decimal" placeholder="0.00" onChange={(event) => setLimit(event.target.value)} onBlur={saveLimit} />
: <span className="agent-limit-value">{limit || "0.00"}</span>}
<small>USD</small>
</span>
</div>
</div>
{busy && <p className="agent-muted">Сохранение…</p>}
</section>
);
}
function KnowledgeSection({
card, openKnowledge, setRoute,
}: {
card: AgentCard;
openKnowledge: (knowledgeId: number) => void;
setRoute: (route: RouteKey) => void;
}) {
const total = card.knowledge.length + card.portalArticles.length;
return (
<section className="agent-card-section">
<header>
<h3>Знания</h3>
<button className="link has-icon" type="button" onClick={() => setRoute("aiKnowledge")}>
Перейти в знания
<Icon name="arrow" size={14} />
</button>
</header>
{total === 0 ? (
<p className="agent-muted">Знания не прикреплены — выберите их в разделе «Знания» или статьи на портале поддержки.</p>
) : (
<>
{card.knowledge.length > 0 && (
<ul className="agent-knowledge-list is-readonly">
{card.knowledge.map((item) => (
<li key={item.id}>
<button className="link is-strong is-neutral agent-knowledge-text" type="button" onClick={() => openKnowledge(item.id)}>
{item.title}
</button>
{!item.isEnabled && <span className="ai-doc-chip off">Выключено</span>}
</li>
))}
</ul>
)}
{card.portalArticles.length > 0 && (
<ul className="agent-knowledge-list is-readonly">
{card.portalArticles.map((article) => (
<li key={article.id}>
<a className="link is-strong is-neutral agent-knowledge-text" href={article.publicUrl} rel="noreferrer" target="_blank">
{article.title}
</a>
<span className="ai-doc-chip plain">{article.portal.name}</span>
</li>
))}
</ul>
)}
</>
)}
</section>
);
}
// --- Подключения (кадры G3/G4) ---
function ConnectionsSection({
card, canManage, busy, available, openIntegrations, bind, unbind,
}: {
function ConnectionsCard({ card, canManage, busy, available, openIntegrations, bind, unbind }: {
card: AgentCard;
canManage: boolean;
busy: boolean;
@@ -476,50 +557,68 @@ function ConnectionsSection({
bind: (integrationId: number) => void;
unbind: (integrationId: number) => void;
}) {
const widget = card.connections.find((connection) => connection.provider === "WEB" && connection.widgetPublicKey);
return (
<section className="agent-card-section">
<header>
<section className="agent-card is-side">
<div className="agent-card-head is-tight">
<h3>Подключения</h3>
{canManage && (
<button className="link has-icon" type="button" onClick={openIntegrations}>
Открыть настройки
<Icon name="arrow" size={14} />
</button>
<button className="link has-icon" type="button" onClick={openIntegrations}>Интеграции<Icon name="external" size={13} strokeWidth={2.2} /></button>
)}
</header>
{card.connections.length === 0 ? (
<p className="agent-muted">Подключений нет. Добавьте бота, почту или веб-виджет в «Настройках», раздел «Интеграции», и привяжите к агенту.</p>
) : (
<div className="agent-connections">
{card.connections.map((connection) => (
<div className="agent-connection" key={connection.id}>
<ChannelBadge provider={connection.provider} />
<div className="agent-connection-text">
<strong>{connection.name}</strong>
<span>{providerLabel(connection.provider)}</span>
</div>
<StatusPill status={connectionStatus(connection.status)} />
{canManage && (
<Button variant="secondary" disabled={busy} onClick={() => unbind(connection.id)}>Отвязать</Button>
)}
</div>
))}
</div>
)}
{canManage && available.length > 0 && (
<div className="agent-connections" style={{ marginTop: 8 }}>
{available.map((integration) => (
<div className="agent-connection" key={integration.id}>
<ChannelBadge provider={integration.provider} />
<div className="agent-connection-text">
<strong>{integration.name}</strong>
<span>Свободное подключение</span>
</div>
<Button variant="secondary" disabled={busy} onClick={() => bind(integration.id)}>Привязать</Button>
</div>
))}
</div>
<p>Через них клиенты попадают к агенту. Одно подключение — один агент.</p>
{card.connections.map((connection) => (
<ConnectionRow
connection={connection}
status={connectionStatusMeta(connection.status)}
action={canManage ? { label: "Отвязать", run: () => unbind(connection.id) } : null}
busy={busy}
key={connection.id}
/>
))}
{canManage && available.map((integration) => (
<ConnectionRow
connection={{ id: integration.id, provider: integration.provider, name: integration.name, status: integration.status, botUsername: "", email: "", allowedOrigins: [], widgetPublicKey: "" }}
subtitle="Свободное подключение"
status={{ text: "Свободно", bg: "var(--n-9)", color: "var(--n-4)" }}
action={{ label: "Привязать", run: () => bind(integration.id) }}
busy={busy}
key={`free-${integration.id}`}
/>
))}
{widget && (
<div className="agent-widget">
<div>
<small>Код вставки виджета</small>
<CopyButton className="agent-widget-copy" label="Копировать" value={webWidgetSnippet(widget.widgetPublicKey)} />
</div>
<code>{webWidgetSnippet(widget.widgetPublicKey)}</code>
<small>Вставьте перед &lt;/body&gt; на сайте</small>
</div>
)}
</section>
);
}
function ConnectionRow({ connection, subtitle, status, action, busy }: {
connection: AgentConnection;
subtitle?: string;
status: { text: string; bg: string; color: string };
action: { label: string; run: () => void } | null;
busy: boolean;
}) {
const tint = agentTint(connection.provider);
return (
<div className="agent-connection">
<span className="agent-connection-tile" style={{ background: tint.bg, color: tint.color }}>
<ChannelGlyph provider={connection.provider} size={16} />
</span>
<div>
<strong>{connection.name}</strong>
<small>{subtitle ?? connectionSubtitle(connection)}</small>
</div>
<b className="agent-chip" style={{ background: status.bg, color: status.color }}>{status.text}</b>
{action && <button className="agent-connection-action" type="button" disabled={busy} onClick={action.run}>{action.label}</button>}
</div>
);
}
@@ -0,0 +1,141 @@
import { Modal } from "antd";
import { useEffect, useMemo, useState } from "react";
import { Icon } from "../../shared/icons";
import { LoadingState } from "../../shared/ui";
import { Button, SearchInput } from "../../shared/ui-controls";
import { shortDate } from "../../shared/utils";
import { fetchKnowledgeList } from "../ai/knowledge/api";
import { listPortalArticles, listSupportPortals } from "../support-portals/api";
import type { AgentCard } from "./model";
// «Выбрать» в блоке «Знания» карточки агента (кадры G3/G5): один список из
// материалов библиотеки и опубликованных статей порталов. Агент отвечает
// только по явно выбранным знаниям (ADR-HUB-0041 §8).
type Choice = {
key: string;
kind: "knowledge" | "article";
id: number;
title: string;
icon: "doc" | "globe";
meta: string;
disabled: boolean;
};
export function AgentKnowledgeDialog({ card, onClose, onSave }: {
card: AgentCard;
onClose: () => void;
onSave: (selection: { knowledgeIds: number[]; articleIds: number[] }) => Promise<void>;
}) {
const [choices, setChoices] = useState<Choice[] | null>(null);
const [failed, setFailed] = useState(false);
const [search, setSearch] = useState("");
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const [picked, setPicked] = useState<Set<string>>(
() => new Set([
...card.knowledge.map((item) => `k${item.id}`),
...card.portalArticles.map((article) => `a${article.id}`),
]),
);
useEffect(() => {
let cancelled = false;
async function load() {
try {
const library = await fetchKnowledgeList();
const portals = await listSupportPortals();
const articleLists = await Promise.all(portals.items.map((portal) => listPortalArticles(portal.id)));
if (cancelled) return;
const articles: Choice[] = portals.items.flatMap((portal, index) => articleLists[index].items
.filter((article) => article.status === "PUBLISHED")
.map((article) => ({
key: `a${article.id}`,
kind: "article" as const,
id: article.id,
title: article.publishedRevision?.title ?? article.slug,
icon: "globe" as const,
meta: `Портал · ${portal.name}`,
disabled: false,
})));
setChoices([
...library.items.map((item): Choice => ({
key: `k${item.id}`,
kind: "knowledge",
id: item.id,
title: item.title,
icon: "doc",
meta: item.isEnabled ? `обновлено ${shortDate(item.updatedAt)}` : "Выключено",
disabled: false,
})),
...articles,
]);
} catch {
if (!cancelled) setFailed(true);
}
}
void load();
return () => { cancelled = true; };
}, []);
const visible = useMemo(() => {
const needle = search.trim().toLowerCase();
if (!needle || choices === null) return choices ?? [];
return choices.filter((choice) => choice.title.toLowerCase().includes(needle));
}, [choices, search]);
function toggle(key: string) {
setPicked((current) => {
const next = new Set(current);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
}
async function submit() {
if (choices === null) return;
setSaving(true);
setError("");
try {
await onSave({
knowledgeIds: choices.filter((choice) => choice.kind === "knowledge" && picked.has(choice.key)).map((choice) => choice.id),
articleIds: choices.filter((choice) => choice.kind === "article" && picked.has(choice.key)).map((choice) => choice.id),
});
onClose();
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Не удалось сохранить выбор");
setSaving(false);
}
}
return (
<Modal className="agent-knowledge-modal" open width={560} title="Выбрать знания" onCancel={onClose} footer={null} destroyOnClose>
<p className="agent-create-lead">Агент отвечает только по выбранным материалам: знаниям библиотеки и опубликованным статьям порталов.</p>
<SearchInput className="agent-knowledge-search" placeholder="Поиск по названию" value={search} onChange={setSearch} />
{choices === null && !failed && <LoadingState />}
{failed && <div className="agent-form-error">Не удалось загрузить список материалов</div>}
{choices !== null && (
<div className="agent-knowledge-choices">
{visible.length === 0 && <p className="agent-knowledge-nothing">Ничего не найдено</p>}
{visible.map((choice) => (
<label className={`agent-knowledge-choice ${picked.has(choice.key) ? "is-picked" : ""}`} key={choice.key}>
<input type="checkbox" checked={picked.has(choice.key)} onChange={() => toggle(choice.key)} />
<Icon name={choice.icon} size={15} strokeWidth={1.9} />
<strong>{choice.title}</strong>
<small>{choice.meta}</small>
</label>
))}
</div>
)}
{error && <div className="agent-form-error">{error}</div>}
<div className="agent-create-actions">
<Button variant="secondary" onClick={onClose}>Отмена</Button>
<Button variant="primary" disabled={choices === null || saving} onClick={() => void submit()}>
{saving ? "Сохранение…" : "Сохранить"}
</Button>
</div>
</Modal>
);
}
@@ -1,17 +1,26 @@
import { Dropdown, Modal } from "antd";
import { useState } from "react";
import { Icon } from "../../shared/icons";
import { ChannelBadge } from "../../shared/badges";
import { ChannelGlyph } from "../../shared/badges";
import { FormField, SelectField } from "../../shared/form-controls";
import { EmptyState, PageHeader, StatusPill } from "../../shared/ui";
import { Icon } from "../../shared/icons";
import { Button } from "../../shared/ui-controls";
import type { EmployeeGroup } from "../../types";
import { agentStatusLabel, createAgent, setAgentAiActive, type AgentCard } from "./model";
import { groupColorOf } from "../conversations/model";
import {
agentModelLabel,
agentStatusMeta,
agentTile,
agentTint,
createAgent,
setAgentAiActive,
type AgentCard,
} from "./model";
const STATUS_PILL = { active: "active", paused: "draft", disabled: "disabled" } as const;
// Список агентов (дизайн-базлайн v2, «Агенты Baseline», кадры G1, G2, S1):
// агент · группа · статус · модель · подключения · открытые · ⋯.
/** Мастер одного шага (SPEC-HUB-0031 §4.3): имя и необязательная группа. */
/** Мастер одного шага (SPEC-HUB-0031 §4.3, кадр G2): имя и группа. */
function CreateAgentModal({ groups, onClose, onCreated }: { groups: EmployeeGroup[]; onClose: () => void; onCreated: (agentId: number) => void }) {
const [name, setName] = useState("");
const [groupId, setGroupId] = useState<number | null>(null);
@@ -32,8 +41,9 @@ function CreateAgentModal({ groups, onClose, onCreated }: { groups: EmployeeGrou
}
return (
<Modal open title="Создать агента" onCancel={onClose} footer={null} destroyOnClose>
<div className="integration-form">
<Modal className="agent-create-modal" open width={440} title="Создать агента" onCancel={onClose} footer={null} destroyOnClose>
<p className="agent-create-lead">Один шаг. Инструкции, модель, знания и подключения — потом, на карточке агента.</p>
<div className="agent-create-fields">
<FormField label="Название" value={name} onChange={setName} placeholder="Например: Приёмная" />
<SelectField
label="Группа"
@@ -41,18 +51,76 @@ function CreateAgentModal({ groups, onClose, onCreated }: { groups: EmployeeGrou
onChange={(value) => setGroupId(value ? Number(value) : null)}
options={[["", "Без группы — диалоги видны всем"], ...groups.map((item) => [String(item.id), item.name] as [string, string])]}
/>
{error && <div className="integration-form-error">{error}</div>}
<div className="integration-form-actions">
<Button variant="secondary" onClick={onClose}>Отмена</Button>
<Button variant="primary" disabled={!name.trim() || submitting} onClick={() => void submit()}>
{submitting ? "Создание…" : "Создать"}
</Button>
</div>
{error && <div className="agent-form-error">{error}</div>}
</div>
<div className="agent-create-actions">
<Button variant="secondary" onClick={onClose}>Отмена</Button>
<Button variant="primary" disabled={!name.trim() || submitting} onClick={() => void submit()}>
{submitting ? "Создание…" : "Создать"}
</Button>
</div>
</Modal>
);
}
/** Строка списка (кадр G1). Вся строка открывает карточку. */
function AgentRow({ card, openAgent, onToggleAi }: { card: AgentCard; openAgent: (agentId: number) => void; onToggleAi: (card: AgentCard) => void }) {
const [menuOpen, setMenuOpen] = useState(false);
const tile = agentTile(card);
const status = agentStatusMeta(card);
const open = card.counters.openConversations;
const menuItems = [
{ key: "open", label: <button type="button" onClick={() => { setMenuOpen(false); openAgent(card.id); }}><Icon name="external" size={15} />Открыть карточку</button> },
{ type: "divider" as const },
{
key: "toggle",
label: (
<button type="button" className={card.aiStatus === "ACTIVE" ? "warning" : ""} onClick={() => { setMenuOpen(false); onToggleAi(card); }}>
<Icon name={card.aiStatus === "ACTIVE" ? "pause" : "bolt"} size={15} />
{card.aiStatus === "ACTIVE" ? "Остановить AI" : "Запустить AI"}
</button>
),
},
];
return (
<div className={`agents-row ${card.isActive ? "" : "is-off"}`} role="button" tabIndex={0} onClick={() => openAgent(card.id)} onKeyDown={(event) => { if (event.key === "Enter") openAgent(card.id); }}>
<div className="agents-row-agent">
<span className="agents-tile" style={{ background: tile.background, color: tile.color }}>
<Icon name="robot" size={20} strokeWidth={1.9} />
</span>
<span>
<strong>{card.name}</strong>
<small>{card.code}</small>
</span>
</div>
<span className={`agents-row-group ${card.groupId === null ? "is-none" : ""}`}>
{card.groupId !== null && <i style={{ background: groupColorOf(card.groupId, card.groupColor) }} />}
{card.groupName ?? "Без группы"}
</span>
<b className="agents-status" style={{ background: status.bg, color: status.color }}><i />{status.text}</b>
<span className="agents-row-model">{agentModelLabel(card)}</span>
<div className="agents-row-conns">
{card.connections.length === 0
? <span className="agents-row-dash">—</span>
: card.connections.map((connection) => {
const tint = agentTint(connection.provider);
return (
<span className="agents-conn-tile" style={{ background: tint.bg, color: tint.color }} title={tint.full} key={connection.id}>
<ChannelGlyph provider={connection.provider} size={14} />
{connection.status === "ERROR" && <i />}
</span>
);
})}
</div>
<div className="agents-row-open" style={{ color: open ? "var(--warning-text)" : "var(--n-5)" }}>{open || "—"}</div>
<Dropdown menu={{ items: menuItems }} open={menuOpen} onOpenChange={setMenuOpen} trigger={["click"]} overlayClassName="app-dropdown">
<button className="agents-row-menu" type="button" aria-label="Действия агента" title="Действия" onClick={(event) => event.stopPropagation()}><Icon name="more" size={16} strokeWidth={2} /></button>
</Dropdown>
</div>
);
}
export function AgentsPage({
agents,
groups,
@@ -65,87 +133,44 @@ export function AgentsPage({
openAgent: (agentId: number) => void;
}) {
const [creating, setCreating] = useState(false);
const [menuId, setMenuId] = useState<number | null>(null);
async function toggleAi(card: AgentCard) {
setMenuId(null);
await setAgentAiActive(card.id, card.aiStatus !== "ACTIVE").catch(() => undefined);
reload();
}
return (
<div className="ai-page">
<PageHeader
title="Агенты"
text="Агент отвечает клиентам и собирает диалоги в группу"
action={<Button variant="primary" icon="plus" onClick={() => setCreating(true)}>Создать агента</Button>}
/>
<div className="agents-page">
<header className="agents-header">
<div>
<h2>Агенты</h2>
<p>Агент отвечает клиентам первым и собирает диалоги в группу</p>
</div>
<Button variant="primary" className="agents-create" icon="plus" iconSize={15} onClick={() => setCreating(true)}>Создать агента</Button>
</header>
{agents.length === 0 ? (
<EmptyState title="Агент отвечает клиентам первым. Создайте первого" />
<div className="agents-empty">
<span><Icon name="robot" size={24} strokeWidth={1.8} /></span>
<div>
<strong>Агент отвечает клиентам первым. Создайте первого</strong>
<p>Имя и группа — всё, что нужно для старта. Инструкции, знания и подключения настраиваются потом на карточке.</p>
</div>
<Button variant="primary" className="agents-create is-wide" onClick={() => setCreating(true)}>Создать агента</Button>
</div>
) : (
<div className="table-card">
<table className="baseline-table">
<thead>
<tr>
<th>АГЕНТ</th>
<th>ГРУППА</th>
<th>СТАТУС</th>
<th>ПОДКЛЮЧЕНИЯ</th>
<th className="numeric">ОТКРЫТЫЕ ДИАЛОГИ</th>
<th />
</tr>
</thead>
<tbody>
{agents.map((card) => {
const status = agentStatusLabel(card);
const menuItems = [
{ key: "open", label: <button type="button" onClick={() => { setMenuId(null); openAgent(card.id); }}><Icon name="external" size={15} />Открыть карточку</button> },
{ type: "divider" as const },
{
key: "toggle",
label: (
<button type="button" className={card.aiStatus === "ACTIVE" ? "warning" : ""} onClick={() => void toggleAi(card)}>
<Icon name={card.aiStatus === "ACTIVE" ? "pause" : "bolt"} size={15} />
{card.aiStatus === "ACTIVE" ? "Остановить AI" : "Запустить AI"}
</button>
),
},
];
return (
<tr key={card.id}>
<td>
<button className="link is-strong is-neutral" type="button" onClick={() => openAgent(card.id)}>{card.name}</button>
</td>
<td>{card.groupName ?? <span className="agent-muted">Без группы</span>}</td>
<td><StatusPill status={STATUS_PILL[status.tone]} /></td>
<td>
{card.connections.length === 0 ? (
<span className="agent-muted">—</span>
) : (
<span style={{ display: "inline-flex", gap: 6 }}>
{card.connections.map((connection) => (
<ChannelBadge key={connection.id} provider={connection.provider} />
))}
</span>
)}
</td>
<td className="numeric">{card.counters.openConversations || <span className="agent-muted">—</span>}</td>
<td className="row-actions">
<Dropdown
menu={{ items: menuItems }}
open={menuId === card.id}
onOpenChange={(open) => setMenuId(open ? card.id : null)}
trigger={["click"]}
overlayClassName="app-dropdown"
>
<button className="row-menu-button" type="button" aria-label="Действия агента"><Icon name="more" /></button>
</Dropdown>
</td>
</tr>
);
})}
</tbody>
</table>
<div className="agents-table">
<div className="agents-thead">
<span>Агент</span>
<span>Группа</span>
<span>Статус</span>
<span>Модель</span>
<span>Подключения</span>
<span className="is-right">Открытые</span>
<span />
</div>
{agents.map((card) => (
<AgentRow card={card} openAgent={openAgent} onToggleAi={(item) => void toggleAi(item)} key={card.id} />
))}
</div>
)}
{creating && (
@@ -1,13 +0,0 @@
import type { StatusPillKey } from "../../shared/ui";
/** Состояние подключения приходит строкой — на экран идёт словарь, не энум. */
const CONNECTION_STATUS: Record<string, StatusPillKey> = {
OK: "healthy",
ERROR: "error",
PENDING: "pending",
UNCHECKED: "unchecked",
};
export function connectionStatus(status: string): StatusPillKey {
return CONNECTION_STATUS[status] ?? "unchecked";
}
+150 -5
View File
@@ -1,6 +1,10 @@
// Единая сущность «Агент» = канал + AI-конфигурация (ADR-HUB-0041 §4).
// Источник данных — агрегированный API /api/v1/agents/.
// Экранная модель — дизайн-базлайн v2, «Агенты Baseline», кадры G1–G5, S1.
import { api } from "../../api/client";
import { agentColorOf } from "../conversations/model";
import { channelMap, providerKey } from "../../shared/providers";
import { pluralRu, shortDate } from "../../shared/utils";
import type { AgentKnowledgeRef, AgentPortalArticleRef } from "../ai/model";
export type AgentConnection = {
@@ -8,6 +12,12 @@ export type AgentConnection = {
provider: string;
name: string;
status: string;
// Чем подписана строка подключения: бот, ящик, домен сайта (кадры G3/G4).
botUsername: string;
email: string;
allowedOrigins: string[];
// Публичный ключ веб-виджета — из него собирается код вставки (кадр G4).
widgetPublicKey: string;
};
export type AgentAiStatus = "DRAFT" | "ACTIVE" | "DISABLED";
@@ -20,6 +30,7 @@ export type AgentCard = {
isActive: boolean;
groupId: number | null;
groupName: string | null;
groupColor: string;
aiStatus: AgentAiStatus;
model: string;
providerIntegrationId: number | null;
@@ -30,6 +41,8 @@ export type AgentCard = {
instructions: string;
knowledge: AgentKnowledgeRef[];
portalArticles: AgentPortalArticleRef[];
// Сколько всего материалов можно выбрать: «4 из 18» в шапке блока «Знания».
knowledgeTotal: number;
connections: AgentConnection[];
counters: { openConversations: number; connections: number };
createdAt: string;
@@ -83,9 +96,141 @@ export function unbindAgentConnection(agentId: number, integrationId: number): P
return api<{ agent: AgentCard }>(`/api/v1/agents/${agentId}/connections/${integrationId}/`, { method: "DELETE" });
}
// Статус карточки для списка: канал выключен > AI активен > черновик.
export function agentStatusLabel(card: AgentCard): { text: string; tone: "active" | "paused" | "disabled" } {
if (!card.isActive) return { text: "Выключен", tone: "disabled" };
if (card.aiStatus === "ACTIVE") return { text: "AI отвечает", tone: "active" };
return { text: "Без AI", tone: "paused" };
export type AgentStatusTone = "active" | "paused" | "disabled";
// Статус карточки: канал выключен > AI активен > черновик. Подписи и цвета —
// таблица ST макета: «AI отвечает» на --ai, «Без AI» на предупреждении,
// «Выключен» серым.
const STATUS_META: Record<AgentStatusTone, { text: string; bg: string; color: string }> = {
active: { text: "AI отвечает", bg: "color-mix(in srgb, var(--ai) 14%, var(--surface-card))", color: "var(--ai)" },
paused: { text: "Без AI", bg: "var(--warning-bg)", color: "var(--warning-text)" },
disabled: { text: "Выключен", bg: "var(--n-9)", color: "var(--n-4)" },
};
export function agentStatusTone(card: AgentCard): AgentStatusTone {
if (!card.isActive) return "disabled";
if (card.aiStatus === "ACTIVE") return "active";
return "paused";
}
export function agentStatusMeta(card: AgentCard): { text: string; bg: string; color: string } {
return STATUS_META[agentStatusTone(card)];
}
/** Плитка агента (кадры G1/G3): цвет из палитры по агенту, подложка — 12% от
* него; у выключенного агента плитка серая, как «Архив · Летняя акция». */
export function agentTile(card: Pick<AgentCard, "id" | "isActive">): { color: string; background: string } {
const color = card.isActive ? agentColorOf(card.id) : "#8c8c8c";
return { color, background: `color-mix(in srgb, ${color} 12%, var(--surface-card))` };
}
/** Модель в списке: пока провайдер не выбран, показывать нечего (кадр G1). */
export function agentModelLabel(card: AgentCard): string {
return card.providerIntegrationId ? card.model : "—";
}
export function agentTint(provider: string): { color: string; bg: string; full: string } {
const key = providerKey(provider);
return key ? channelMap[key] : { color: "var(--n-4)", bg: "var(--n-9)", full: provider };
}
// Подпись строки подключения (кадры G3/G4): у Telegram — «Telegram · @bot»,
// у MAX — марка, у веб-виджета — домен сайта, у почты — адрес ящика.
export function connectionSubtitle(connection: AgentConnection): string {
const key = providerKey(connection.provider);
if (key === "TG") return connection.botUsername ? `Telegram · @${connection.botUsername}` : "Telegram";
if (key === "WEB") return connection.allowedOrigins[0] ?? "Web-виджет";
if (key === "EMAIL") return connection.email || "Email";
return key ? channelMap[key].full : connection.provider;
}
// Таблица CS макета: подключено · ошибка · не проверено.
const CONNECTION_STATUS_META: Record<string, { text: string; bg: string; color: string }> = {
OK: { text: "Подключено", bg: "var(--success-bg)", color: "var(--success-text)" },
ERROR: { text: "Ошибка", bg: "var(--error-bg)", color: "var(--error-text)" },
};
export function connectionStatusMeta(status: string): { text: string; bg: string; color: string } {
return CONNECTION_STATUS_META[status] ?? { text: "Не проверено", bg: "var(--n-9)", color: "var(--n-4)" };
}
/** «5 открытых диалогов» / «нет диалогов» — подзаголовок шапки карточки. */
export function openDialogsLine(count: number): string {
return count === 0 ? "нет диалогов" : pluralRu(count, ["открытый диалог", "открытых диалога", "открытых диалогов"]);
}
/** «создан 12 мар 2026»; созданный сегодня — «сегодня» (кадр G5). */
export function createdLabel(value: string, now = new Date()): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "";
if (date.toDateString() === now.toDateString()) return "сегодня";
return `${shortDate(date)} ${date.getFullYear()}`;
}
export type AgentKnowledgeRow = {
key: string;
kind: "knowledge" | "article";
id: number;
title: string;
icon: "doc" | "globe";
chip: string;
chipTone: "muted" | "accent";
meta: string;
href: string;
};
/** Строки блока «Знания»: сначала знания библиотеки, затем статьи порталов. */
export function knowledgeRows(card: AgentCard): AgentKnowledgeRow[] {
return [
...card.knowledge.map((item): AgentKnowledgeRow => ({
key: `k${item.id}`,
kind: "knowledge",
id: item.id,
title: item.title,
icon: "doc",
chip: item.isEnabled ? "" : "Выключено",
chipTone: "muted",
meta: item.updatedAt ? `обновлено ${shortDate(item.updatedAt)}` : "",
href: "",
})),
...card.portalArticles.map((article): AgentKnowledgeRow => ({
key: `a${article.id}`,
kind: "article",
id: article.id,
title: article.title,
icon: "globe",
chip: `Портал · ${article.portal.name}`,
chipTone: "accent",
meta: "статья",
href: article.publicUrl,
})),
];
}
/** «4 из 18 · 3 знания + 1 статья» в шапке блока «Знания» (кадры G3/G4/G5). */
export function knowledgeLine(card: AgentCard): string {
const knowledge = card.knowledge.length;
const articles = card.portalArticles.length;
const selected = knowledge + articles;
if (selected === 0) return "ничего не выбрано";
const head = `${selected} из ${card.knowledgeTotal}`;
if (knowledge === 0 || articles === 0) return head;
const parts = [
pluralRu(knowledge, ["знание", "знания", "знаний"]),
pluralRu(articles, ["статья", "статьи", "статей"]),
];
return `${head} · ${parts.join(" + ")}`;
}
// Дневной бюджет агента хранится в целых центах USD (limits.dailyCostUsd,
// ADR-HUB-0023); на экране — доллары с двумя знаками.
export function dailyCostInput(limits: Record<string, unknown>): string {
const cents = Number(limits.dailyCostUsd ?? 0);
return Number.isFinite(cents) && cents > 0 ? (cents / 100).toFixed(2) : "";
}
export function dailyCostCents(value: string): number {
const amount = Number(value.replace(",", ".").trim());
if (!Number.isFinite(amount) || amount <= 0) return 0;
return Math.round(amount * 100);
}
File diff suppressed because it is too large. Load diff
@@ -23,7 +23,7 @@ export function KnowledgeDetailSidebar({
<div className="knowledge-agent-list">
{assignedAgents.map((agent) => (
<button type="button" onClick={() => openAgent(agent.id)} key={agent.id}>
<span style={{ color: agentColorOf(agent.code), background: `color-mix(in srgb, ${agentColorOf(agent.code)} 14%, var(--surface-card))` }}><Icon name="robot" size={16} /></span>
<span style={{ color: agentColorOf(agent.id), background: `color-mix(in srgb, ${agentColorOf(agent.id)} 14%, var(--surface-card))` }}><Icon name="robot" size={16} /></span>
<div><strong>{agent.name}</strong><small>{agent.groupName ?? "Без группы"}</small></div>
</button>
))}
@@ -59,13 +59,6 @@
font-weight: 600;
}
.knowledge-table-card {
border: 1px solid var(--n-8);
border-radius: 12px;
background: var(--surface-card);
box-shadow: var(--shadow-xs);
}
.knowledge-table { table-layout: fixed; }
.knowledge-col-select { width: 38px; }
.knowledge-col-title { width: 30%; }
+1 -1
View File
@@ -1,4 +1,4 @@
export type AgentKnowledgeRef = { id: number; title: string; isEnabled: boolean };
export type AgentKnowledgeRef = { id: number; title: string; isEnabled: boolean; updatedAt: string };
export type AgentPortalArticleRef = {
id: number;
@@ -31,10 +31,6 @@
color: var(--n-2);
}
.ai-agents-scroll {
overflow-x: auto;
}
.ai-mono {
font-family: "SF Mono", ui-monospace, Menlo, monospace;
font-size: 12px;
@@ -69,7 +69,6 @@ export function ConversationWorkspace({ isOwner = false, viewerId = null, listTi
const [ctxOpen, setCtxOpen] = useState(false);
// Кадры M1/M2: на ≤768px список и лента — отдельные экраны.
const [mobileDialogOpen, setMobileDialogOpen] = useState(false);
const searchRef = useRef<HTMLInputElement | null>(null);
const [search, setSearch] = useState("");
const [conversations, setConversations] = useState<ApiConversation[]>([]);
const [listLoaded, setListLoaded] = useState(false);
@@ -137,18 +136,13 @@ export function ConversationWorkspace({ isOwner = false, viewerId = null, listTi
const callController = useConversationCall({ conversationId: selectedId, onConversationChanged });
useIncomingMessageSound(conversations, listLoaded);
// Клавиатура (SPEC-HUB-0031 §9): ↑/↓ — по списку, Enter — открыть (мобайл),
// «/» — фокус в поиск. Не перехватываем ввод в полях.
// Клавиатура (SPEC-HUB-0031 §9): ↑/↓ — по списку, Enter — открыть (мобайл).
// «/» — фокус в поиск, эту клавишу держит сам SearchInput.
const filteredRef = useRef<ConversationListItem[]>([]);
useEffect(() => {
function onKeyDown(event: KeyboardEvent) {
const target = event.target as HTMLElement | null;
if (target?.closest("input, textarea, [contenteditable], .ant-dropdown")) return;
if (event.key === "/") {
event.preventDefault();
searchRef.current?.focus();
return;
}
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
const list = filteredRef.current;
if (!list.length) return;
@@ -253,7 +247,6 @@ export function ConversationWorkspace({ isOwner = false, viewerId = null, listTi
}}
mobileHeader={mobileHeader}
hint={hint}
searchRef={searchRef}
/>
{!selectedDialog && (
<section className="sales-conversation"><div className="sales-conversation-empty">Выберите диалог</div></section>
@@ -194,7 +194,7 @@ export function DialogControls({
<div className="ctx-grid">
<div>
<label className="ctx-label">Агент</label>
<div className="ctx-readonly" style={{ color: agentColorOf(detail.channel.code) }}><Icon name="robot" size={14} />{detail.channel.name}</div>
<div className="ctx-readonly" style={{ color: agentColorOf(detail.channel.id) }}><Icon name="robot" size={14} />{detail.channel.name}</div>
</div>
<div>
<label className="ctx-label">Режим</label>
@@ -1,5 +1,5 @@
import { Dropdown } from "antd";
import type { CSSProperties, ReactNode, RefObject } from "react";
import type { CSSProperties, ReactNode } from "react";
import { modeDots } from "./data";
import { ContactAvatar } from "./ContactAvatar";
@@ -14,7 +14,7 @@ import { SearchInput } from "../../shared/ui-controls";
export type ListSort = "activity" | "waiting";
export function DialogList({ title = "Диалоги", searchPlaceholder = "Поиск по контакту, сообщению…", viewerId = null, scope, counters, setScope, showScopeSwitcher = true, mobileHeader, hint, searchRef, dialogs, filtered, 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, filtered, listTab, selectedId, search, errorText, sort, setSort, onCollapse, setSearch, setListTab, setSelectedId }: {
title?: string;
searchPlaceholder?: string;
sort: ListSort;
@@ -27,7 +27,6 @@ export function DialogList({ title = "Диалоги", searchPlaceholder = "По
mobileHeader?: (info: { total: number }) => ReactNode;
viewerId?: number | null;
hint?: ReactNode;
searchRef?: RefObject<HTMLInputElement | null>;
dialogs: ConversationListItem[];
filtered: ConversationListItem[];
listTab: ListTab;
@@ -67,7 +66,7 @@ export function DialogList({ title = "Диалоги", searchPlaceholder = "По
{onCollapse && <button type="button" aria-label="Скрыть список" title="Скрыть список" onClick={onCollapse}><Icon name="collapseLeft" size={15} /></button>}
</span>
</div>
<SearchInput className="sales-dialog-search" inputRef={searchRef} placeholder={searchPlaceholder} value={search} onChange={setSearch} hotkey="/" />
<SearchInput className="sales-dialog-search" placeholder={searchPlaceholder} value={search} onChange={setSearch} hotkey="/" />
</div>
<div className="sales-dialog-tabs">
<DialogTab active={listTab === "all"} onClick={() => setListTab("all")}>Все</DialogTab>
@@ -171,7 +170,7 @@ function ScopeSwitcher({ scope, counters, setScope, fallbackTitle, total, viewer
? [
head("agents-head", "Агенты"),
...counters.agents.map((agent) => {
const color = agentColorOf(agent.code);
const color = agentColorOf(agent.id);
return item(`agent-${agent.id}`, { kind: "agent", id: agent.id, label: agent.name }, <span className="scope-agent" style={{ color, background: `color-mix(in srgb, ${color} 16%, var(--surface-card))` }}><Icon name="robot" size={11} /></span>, agent.name, agent.count);
}),
]
@@ -81,7 +81,7 @@ export type ReplyTemplateRef = { id: number; title: string; text: string; update
export type ApiConversation = {
id: number;
channel: { code: string; name: string; product: { code: string; name: string } | null };
channel: { id: number; code: string; name: string; product: { code: string; name: string } | null };
// voiceMessages/audioCalls/videoCalls — что разрешено в точке входа («Настройки → Голосовые и звонки»).
connection: { id: number; provider: "EMAIL" | "MAX" | "TELEGRAM" | "WEB"; name: string; voiceMessages?: boolean; audioCalls?: boolean; videoCalls?: boolean } | null;
// Источник identity: sales Contact (лид) ИЛИ verified SupportIdentitySnapshot.
@@ -110,19 +110,16 @@ export type ApiConversation = {
};
const AVATAR_PALETTE = ["#eb6f4b", "#3b82c4", "#9254de", "#13a8a8", "#d4860b", "#52a838", "#c4413b", "#6b5be0"];
// Цвет агента (решение 6a: Консультант — фиолетовый, Поддержка сайта — бирюзовый)
// и цвет точки группы — стабильно из идентификатора.
// Цвет агента и цвет точки группы — стабильно из идентификатора. Палитра идёт
// по порядку создания агентов, как в дизайн-базлайне (решение 6a): первый
// агент «Консультант» — фиолетовый, второй «Поддержка сайта» — бирюзовый,
// четвёртый «Приёмная» — оранжевый. Хеш кода здесь не годился: он давал
// и другие цвета, и совпадения у разных агентов.
const AGENT_PALETTE = ["var(--ai)", "#0f9b8e", "#6d5dfc", "#e8590c", "#d4860b"];
const GROUP_PALETTE = ["var(--primary)", "#2aa876", "#e8590c", "#6d5dfc", "#d4860b"];
function hashCode(value: string): number {
let hash = 0;
for (let index = 0; index < value.length; index += 1) hash = (hash * 31 + value.charCodeAt(index)) >>> 0;
return hash;
}
export function agentColorOf(code: string): string {
return AGENT_PALETTE[hashCode(code) % AGENT_PALETTE.length];
export function agentColorOf(agentId: number): string {
return AGENT_PALETTE[(agentId - 1) % AGENT_PALETTE.length];
}
// Цвет группы — заданный в настройках (дизайн-базлайн v2), иначе палитра по id.
@@ -218,7 +215,7 @@ export function toConversationListItem(conversation: ApiConversation): Conversat
priority: conversation.priority ?? "NONE",
labels: conversation.labels ?? [],
agentName: conversation.channel.name,
agentColor: agentColorOf(conversation.channel.code),
agentColor: agentColorOf(conversation.channel.id),
groupName: conversation.group?.name ?? null,
groupColor: conversation.group ? groupColorOf(conversation.group.id, conversation.group.color) : "var(--n-5)",
waitLabel:
@@ -1,70 +1,185 @@
import { useEffect, useState, type ReactNode } from "react";
import { useState, type ReactNode } from "react";
import { api } from "../../api/client";
import { Icon } from "../../shared/icons";
import { groupColorOf } from "../conversations/model";
import type { Employee, EmployeeGroup, Role } from "../../types";
import type { EmployeeGroup, Role } from "../../types";
import { createEmployee, type IssuedPassword, type PasswordMode } from "./api";
export function EmployeeCreateDrawer({ groups, onClose, onCreated, open }: {
// Новый сотрудник (дизайн-базлайн v2, кадры E5/E6): выезжающая панель 520px,
// четыре шага — учётные данные · роль · должность и группы · пароль.
const ROLE_OPTIONS: Array<{ value: Extract<Role, "ADMIN" | "EMPLOYEE">; label: string; description: string }> = [
{ value: "EMPLOYEE", label: "Сотрудник", description: "Работает в чате. Видит диалоги своих групп, диалоги без группы и назначенные ему." },
{ value: "ADMIN", label: "Администратор", description: "Полный доступ, идентичен владельцу. Единственное отличие владельца — его нельзя удалить." },
];
const PASSWORD_OPTIONS: Array<{ value: PasswordMode; label: string; description: string }> = [
{ value: "mail", label: "Отправить на рабочую почту", description: "Сотрудник получит письмо со ссылкой первого входа. Подходит, когда почта организации настроена и работает." },
{ value: "show", label: "Сгенерировать и показать мне", description: "Пароль покажется один раз после создания — скопируйте и передайте лично. Нужен, когда письма не доходят или ящик ещё не работает." },
];
export function EmployeeCreateDrawer({ groups, onClose, onCreated }: {
groups: EmployeeGroup[];
onClose: () => void;
onCreated: (employee: Employee) => void;
open: boolean;
onCreated: (password: IssuedPassword | null) => void;
}) {
const [fullName, setFullName] = useState("");
const [phone, setPhone] = useState("");
const [email, setEmail] = useState("");
const [role, setRole] = useState<Role>("EMPLOYEE");
const [role, setRole] = useState<Extract<Role, "ADMIN" | "EMPLOYEE">>("EMPLOYEE");
const [positionTitle, setPositionTitle] = useState("");
const [groupIds, setGroupIds] = useState<number[]>([]);
const [passwordMode, setPasswordMode] = useState<PasswordMode>("mail");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
if (!open) return;
setFullName(""); setPhone(""); setEmail(""); setRole("EMPLOYEE");
setPositionTitle(""); setGroupIds([]); setError("");
}, [open]);
if (!open) return null;
const canSubmit = Boolean(fullName.trim() && email.trim() && positionTitle.trim() && !submitting);
function toggleGroup(groupId: number) {
setGroupIds((items) => items.includes(groupId)
? items.filter((item) => item !== groupId)
: [...items, groupId]);
setGroupIds((items) => items.includes(groupId) ? items.filter((item) => item !== groupId) : [...items, groupId]);
}
async function submit() {
if (!canSubmit) return;
setSubmitting(true); setError("");
setSubmitting(true);
setError("");
try {
const payload = await api<{ employee: Employee }>("/api/v1/employees/operators/", {
method: "POST",
body: JSON.stringify({ fullName, phone, email, role, positionTitle, groupIds }),
});
onCreated(payload.employee);
const issued = await createEmployee({ fullName, phone, email, role, positionTitle, groupIds, passwordMode });
onCreated(issued);
} catch (reason) {
setError(reason instanceof Error ? reason.message : "Ошибка создания");
} finally {
setSubmitting(false);
}
}
return <>
<button className="employee-drawer-scrim" aria-label="Закрыть форму" onClick={onClose} />
<aside className="employee-create-drawer" aria-label="Новый сотрудник">
<header><div><strong>Новый сотрудник</strong><span>Учётные данные · роль · группы</span></div><button aria-label="Закрыть" onClick={onClose}><Icon name="xCircle" size={19} /></button></header>
<div className="employee-create-body">
<CreateStep number="1" title="Учётные данные"><div className="employee-create-grid"><CreateField label="Имя" value={fullName} onChange={setFullName} placeholder="Имя и фамилия" /><CreateField label="Телефон" value={phone} onChange={setPhone} placeholder="+7 …" /><CreateField className="wide" label="Рабочий email · используется для входа" value={email} onChange={setEmail} placeholder="name@company.ru" mono /></div></CreateStep>
<CreateStep number="2" title="Роль"><div className="employee-role-options"><RoleOption active={role === "EMPLOYEE"} description="Работает в одном окне — чате. Видит диалоги своих групп, диалоги без группы и назначенные ему." label="Сотрудник" onClick={() => setRole("EMPLOYEE")} /><RoleOption active={role === "ADMIN"} description="Полный доступ, идентичен владельцу. Единственное отличие владельца — его нельзя удалить." label="Администратор" onClick={() => setRole("ADMIN")} /></div></CreateStep>
<CreateStep number="3" title="Должность и группы"><div className="employee-create-grid"><CreateField className="wide" label="Должность" value={positionTitle} onChange={setPositionTitle} placeholder="напр. Оператор" /></div>{role === "EMPLOYEE" && (groups.length ? <div className="employee-create-profiles">{groups.map((group) => { const active = groupIds.includes(group.id); return <label className={active ? "selected" : ""} key={group.id}><button type="button" aria-pressed={active} onClick={() => toggleGroup(group.id)}><i>{active && <Icon name="check" size={11} />}</i><span><strong><i className="chat-scope-dot" style={{ background: groupColorOf(group.id, group.color) }} />{group.name}</strong><small>{group.memberCount} сотр.</small></span></button></label>; })}<p>Без групп сотрудник видит только диалоги без группы. Группы можно изменить позже.</p></div> : <p>Групп пока нет — сотрудник будет видеть все диалоги. Группы создаются на странице сотрудников.</p>)}</CreateStep>
</div>
<footer><span>{error || "Пароль первичного доступа отправится на email"}</span><div><button className="secondary-button" onClick={onClose}>Отмена</button><button className="primary-button" disabled={!canSubmit} onClick={submit}>{submitting ? "Создание" : "Создать сотрудника"}</button></div></footer>
</aside>
</>;
const footerHint = passwordMode === "mail"
? `Пароль первичного доступа отправится на ${email.trim() || "рабочую почту"}`
: "Пароль покажем сразу после создания — скопируйте его";
return (
<>
<button className="employee-drawer-scrim" type="button" aria-label="Закрыть форму" onClick={onClose} />
<aside className="employee-create-drawer" aria-label="Новый сотрудник">
<header>
<div>
<strong>Новый сотрудник</strong>
<span>Учётные данные · роль · группы</span>
</div>
<button type="button" aria-label="Закрыть" onClick={onClose}><Icon name="xCircle" size={19} strokeWidth={1.8} /></button>
</header>
<div className="employee-create-body">
<CreateStep number="1" title="Учётные данные">
<div className="employee-create-grid">
<CreateField label="Имя" value={fullName} onChange={setFullName} placeholder="Имя и фамилия" />
<CreateField label="Телефон" value={phone} onChange={setPhone} placeholder="+7 …" />
<CreateField className="wide" label="Рабочий email · используется для входа" value={email} onChange={setEmail} placeholder="name@company.ru" mono />
</div>
</CreateStep>
<CreateStep number="2" title="Роль">
<div className="employee-choice-list">
{ROLE_OPTIONS.map((option) => (
<ChoiceOption
active={role === option.value}
description={option.description}
label={option.label}
onClick={() => setRole(option.value)}
key={option.value}
/>
))}
</div>
</CreateStep>
<CreateStep number="3" title="Должность и группы">
<CreateField className="is-single" label="Должность" value={positionTitle} onChange={setPositionTitle} placeholder="напр. Оператор" />
{groups.length > 0 ? (
<>
<div className="employee-group-picks">
{groups.map((group) => {
const active = groupIds.includes(group.id);
return (
<button className={`employee-group-pick ${active ? "is-on" : ""}`} type="button" aria-pressed={active} onClick={() => toggleGroup(group.id)} key={group.id}>
<i>{active && <Icon name="check" size={11} strokeWidth={3} />}</i>
<span>
<strong><i className="employee-group-dot" style={{ background: groupColorOf(group.id, group.color) }} />{group.name}</strong>
<small>{group.memberCount} сотр.</small>
</span>
</button>
);
})}
</div>
<p className="employee-create-note">Без групп сотрудник видит только диалоги без группы. Группы можно изменить позже.</p>
</>
) : (
<p className="employee-create-note">Групп пока нет — сотрудник будет видеть все диалоги. Группы создаются в «Настройках».</p>
)}
</CreateStep>
<CreateStep number="4" title="Пароль первичного доступа">
<div className="employee-choice-list">
{PASSWORD_OPTIONS.map((option) => (
<ChoiceOption
active={passwordMode === option.value}
description={option.description}
label={option.label}
onClick={() => setPasswordMode(option.value)}
key={option.value}
/>
))}
</div>
</CreateStep>
</div>
<footer>
<span className={error ? "is-error" : ""}>{error || footerHint}</span>
<div>
<button className="secondary-button" type="button" onClick={onClose}>Отмена</button>
<button className="primary-button" type="button" disabled={!canSubmit} onClick={() => void submit()}>
{submitting ? "Создание" : "Создать сотрудника"}
</button>
</div>
</footer>
</aside>
</>
);
}
function CreateStep({ children, number, title }: { children: ReactNode; number: string; title: string }) { return <section className="employee-create-step"><h3><i>{number}</i>{title}</h3>{children}</section>; }
function CreateField({ className = "", label, mono = false, onChange, placeholder, value }: { className?: string; label: string; mono?: boolean; onChange: (value: string) => void; placeholder: string; value: string }) { return <label className={`employee-create-field ${className}`}><span>{label}</span><input className={mono ? "mono" : ""} value={value} onChange={(event) => onChange(event.target.value)} placeholder={placeholder} /></label>; }
function RoleOption({ active, description, label, onClick }: { active: boolean; description: string; label: string; onClick: () => void }) { return <button className={active ? "active" : ""} type="button" onClick={onClick}><i>{active && <span />}</i><span><strong>{label}</strong><small>{description}</small></span></button>; }
function CreateStep({ children, number, title }: { children: ReactNode; number: string; title: string }) {
return <section className="employee-create-step"><h3><i>{number}</i>{title}</h3>{children}</section>;
}
function CreateField({ className = "", label, mono = false, onChange, placeholder, value }: {
className?: string;
label: string;
mono?: boolean;
onChange: (value: string) => void;
placeholder: string;
value: string;
}) {
return (
<label className={`employee-create-field ${className}`}>
<span>{label}</span>
<input className={mono ? "mono" : ""} value={value} placeholder={placeholder} onChange={(event) => onChange(event.target.value)} />
</label>
);
}
/** Радио-вариант с описанием — роль и режим пароля (кадры E5/E6, E3). */
function ChoiceOption({ active, description, label, locked = false, onClick }: {
active: boolean;
description: string;
label: string;
locked?: boolean;
onClick?: () => void;
}) {
return (
<button className={`employee-choice ${active ? "is-on" : ""} ${locked ? "is-locked" : ""}`} type="button" disabled={locked} onClick={onClick}>
<i>{active && <span />}</i>
<span>
<strong>{label}{locked && <Icon name="lock" size={12} strokeWidth={1.8} />}</strong>
<small>{description}</small>
</span>
</button>
);
}
@@ -1,28 +1,50 @@
import type { Employee, RouteKey } from "../../types";
import { Icon } from "../../shared/icons";
import { Avatar, RoleBadge, StatusPill } from "../../shared/ui";
import { Button } from "../../shared/ui-controls";
import { groupsLabel, type EmployeeForm, type EmployeeStatus } from "./model";
import { Avatar } from "../../shared/ui";
import { BackLink, Button } from "../../shared/ui-controls";
import { employeeAvatarColor, groupsLabel, roleBadge, statusBadge, type EmployeeForm } from "./model";
export function EmployeeDetailHeader({ employee, form, saveEmployee, saving, setRoute, status }: {
// Шапка карточки сотрудника (дизайн-базлайн v2, кадры E3/E4): аватар 60px,
// имя, бейджи роли и статуса, строка «email · должность · группы».
// У владельца правки закрыты — вместо кнопок «Только просмотр».
export function EmployeeDetailHeader({ employee, form, saveEmployee, saving, setRoute }: {
employee: Employee;
form: EmployeeForm;
saveEmployee: () => void;
saving: boolean;
setRoute: (route: RouteKey) => void;
status: EmployeeStatus;
}) {
const editable = employee.permissions?.canUpdateProfile ?? false;
return <>
<button className="link is-muted has-icon" type="button" onClick={() => setRoute("employees")}><Icon name="arrow" size={15} />Все сотрудники</button>
<section className="employee-detail-header">
<div className="employee-detail-main">
<Avatar employee={employee} />
<div className="employee-title"><div><h1>{form.fullName || form.email}</h1><RoleBadge role={form.role} /><StatusPill status={status} /></div><p><span className="employee-title-email">{form.email}</span> · {form.positionTitle} · {groupsLabel(employee)}</p></div>
</div>
<div className="employee-header-actions">
{!editable ? <span className="employee-readonly-label"><Icon name="lock" size={14} />Только просмотр</span> : <><Button type="button" variant="secondary" onClick={() => setRoute("employees")}>Отмена</Button><Button icon="save" type="button" variant="primary" onClick={saveEmployee} disabled={saving}>{saving ? "Сохранение" : "Сохранить"}</Button></>}
</div>
</section>
</>;
const role = roleBadge(form.role);
const status = statusBadge(employee);
return (
<>
<BackLink label="Все сотрудники" onClick={() => setRoute("employees")} />
<header className="employee-head">
<Avatar employee={employee} background={employeeAvatarColor(employee)} />
<div className="employee-head-text">
<div>
<h2>{form.fullName || form.email}</h2>
<b className="employees-badge" style={{ background: role.bg, color: role.color }}>{role.text}</b>
<b className="employees-badge has-dot" style={{ background: status.bg, color: status.color }}><i />{status.text}</b>
</div>
<p><span>{form.email}</span> · {form.positionTitle} · {groupsLabel(employee)}</p>
</div>
<div className="employee-head-actions">
{editable ? (
<>
<Button variant="secondary" type="button" onClick={() => setRoute("employees")}>Отмена</Button>
<Button variant="primary" type="button" icon="save" iconSize={15} disabled={saving} onClick={saveEmployee}>
{saving ? "Сохранение" : "Сохранить"}
</Button>
</>
) : (
<span className="employee-readonly-label"><Icon name="lock" size={14} strokeWidth={1.8} />Только просмотр</span>
)}
</div>
</header>
</>
);
}
@@ -1,23 +1,33 @@
import { useCallback, useEffect, useState } from "react";
import { api } from "../../api/client";
import type { Employee, EmployeeGroup, RouteKey, SessionUser } from "../../types";
import { Icon } from "../../shared/icons";
import type { Employee, EmployeeGroup, RouteKey } from "../../types";
import { blockEmployee, resetEmployeePassword, terminateEmployeeSessions, type IssuedPassword } from "./api";
import { EmployeeDetailHeader } from "./EmployeeDetailHeader";
import { EmployeeDetailRail } from "./EmployeeDetailRail";
import { EmployeeDetailSections } from "./EmployeeDetailSections";
import { EmployeeIdentitySections } from "./EmployeeIdentitySections";
import { EmployeePasswordDialog } from "./EmployeePasswordDialog";
import { EmployeeSecuritySections } from "./EmployeeSecuritySections";
import { OwnershipTransferModal } from "./OwnershipTransferModal";
import { employeeForm, employeeStatusKey, type EmployeeForm } from "./model";
export function EmployeeDetailPage({ groups, employee, reload, setRoute, user }: {
// Карточка сотрудника (дизайн-базлайн v2, кадры E3/E4).
export function EmployeeDetailPage({ groups, employee, employees, reload, setRoute }: {
groups: EmployeeGroup[];
employee: Employee;
employees: Employee[];
reload: () => void;
setRoute: (route: RouteKey) => void;
user: SessionUser;
}) {
const [currentEmployee, setCurrentEmployee] = useState(employee);
const [form, setForm] = useState<EmployeeForm>(() => employeeForm(employee));
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 () => {
@@ -35,7 +45,8 @@ export function EmployeeDetailPage({ groups, employee, reload, setRoute, user }:
}
async function saveEmployee() {
setSaving(true); setMessage("");
setSaving(true);
setMessage("");
try {
await api(`/api/v1/employees/${currentEmployee.id}/update/`, { method: "POST", body: JSON.stringify(form) });
await refresh();
@@ -47,11 +58,56 @@ export function EmployeeDetailPage({ groups, employee, reload, setRoute, user }:
}
}
const ownerReadOnly = currentEmployee.role === "OWNER" && user.id !== currentEmployee.id;
return <div className="employee-detail-page">
<EmployeeDetailHeader employee={currentEmployee} form={form} saveEmployee={saveEmployee} saving={saving} setRoute={setRoute} status={status} />
{ownerReadOnly && <div className="employee-readonly-banner">Владельца нельзя удалить, заблокировать или сменить ему роль — единственный путь изменения роли владельца это передача владения.</div>}
{message && <div className="employee-action-message">{message}</div>}
<div className="employee-detail-grid"><EmployeeDetailSections blocked={status === "blocked"} groups={groups} employee={currentEmployee} form={form} updateForm={updateForm} /><EmployeeDetailRail employee={currentEmployee} /></div>
</div>;
async function run(action: () => Promise<void>) {
setBusy(true);
setMessage("");
try {
await action();
await refresh();
reload();
} catch (reason) {
setMessage(reason instanceof Error ? reason.message : "Не удалось выполнить действие");
} finally {
setBusy(false);
}
}
// Кадр E4: на карточке владельца всегда стоит напоминание о правиле роли —
// сменить её можно только передачей владения.
const isOwnerCard = currentEmployee.role === "OWNER";
return (
<div className="employee-page">
<EmployeeDetailHeader employee={currentEmployee} form={form} saveEmployee={() => void saveEmployee()} saving={saving} setRoute={setRoute} />
{isOwnerCard && (
<div className="employee-owner-banner">
<Icon name="lock" size={16} strokeWidth={1.8} />
<span>Владельца нельзя удалить, заблокировать или сменить ему роль — единственный путь изменения роли владельца это передача владения.</span>
</div>
)}
{message && <div className="employees-error"><Icon name="alert" size={16} strokeWidth={1.8} />{message}</div>}
<div className="employee-grid">
<div className="employee-column">
<EmployeeIdentitySections groups={groups} employee={currentEmployee} form={form} updateForm={updateForm} />
<EmployeeSecuritySections
blocked={status === "blocked"}
busy={busy}
employee={currentEmployee}
form={form}
updateForm={updateForm}
onResetPassword={() => void run(async () => { setIssued(await resetEmployeePassword(currentEmployee.id, "show")); })}
onTerminateSessions={() => void run(() => terminateEmployeeSessions(currentEmployee.id))}
onToggleBlock={() => void run(() => blockEmployee(currentEmployee.id, !currentEmployee.isBlocked))}
onTransferOwnership={() => setTransferOpen(true)}
/>
</div>
<EmployeeDetailRail employee={currentEmployee} />
</div>
{issued && <EmployeePasswordDialog issued={issued} onClose={() => setIssued(null)} />}
{transferOpen && <OwnershipTransferModal employees={employees} onClose={() => setTransferOpen(false)} />}
</div>
);
}
@@ -1,26 +1,40 @@
import type { Employee } from "../../types";
import { KeyValue } from "../../shared/form-controls";
import { StatusPill } from "../../shared/ui";
import { auditItem, employeeStatusKey, formatDate, formatLastLogin } from "./model";
import { auditItem, formatDate, formatLastLogin, roleBadge, statusBadge } from "./model";
const ROLE_LABELS = { OWNER: "Владелец", ADMIN: "Администратор", EMPLOYEE: "Сотрудник" } as const;
// Правая колонка карточки (кадры E3/E4): учётная запись и аудит.
export function EmployeeDetailRail({ employee }: { employee: Employee }) {
const audit = (employee.auditEvents ?? []).map(auditItem);
const account = [
{ label: "Статус", value: statusBadge(employee).text },
{ label: "Роль", value: roleBadge(employee.role).text },
{ label: "Создан", value: formatDate(employee.createdAt) },
{ label: "Последний вход", value: formatLastLogin(employee.lastLogin) },
];
return (
<aside className="employee-detail-rail">
<section className="employee-detail-card compact">
<aside className="employee-rail">
<section className="employee-card is-rail">
<h3>Учётная запись</h3>
<KeyValue label="Статус" value={<StatusPill status={employeeStatusKey(employee)} />} />
<KeyValue label="Роль" value={ROLE_LABELS[employee.role]} />
<KeyValue label="Создан" value={formatDate(employee.createdAt)} />
<KeyValue label="Последний вход" value={formatLastLogin(employee.lastLogin)} />
{account.map((row) => (
<div className="employee-account-row" key={row.label}>
<small>{row.label}</small>
<span>{row.value}</span>
</div>
))}
</section>
<section className="employee-detail-card compact">
<section className="employee-card is-rail">
<h3>Аудит</h3>
<div className="employee-activity-list">
{audit.map((item, index) => <div key={`${item.code}-${index}`}><span style={{ background: item.dot }} /><p><strong>{item.text}</strong><small>{item.code} · {item.time}</small></p></div>)}
</div>
{audit.map((item, index) => (
<div className="employee-audit-row" key={`${item.code}-${index}`}>
<i style={{ background: item.dot }} />
<div>
<strong>{item.text}</strong>
<small>{item.code} · {item.time}</small>
</div>
</div>
))}
{audit.length === 0 && <p className="employee-create-note">Событий пока нет.</p>}
</section>
</aside>
);
@@ -1,19 +0,0 @@
import type { Employee, EmployeeGroup } from "../../types";
import { EmployeeIdentitySections } from "./EmployeeIdentitySections";
import { EmployeeSecuritySections } from "./EmployeeSecuritySections";
import type { EmployeeForm } from "./model";
export function EmployeeDetailSections({ blocked, groups, employee, form, updateForm }: {
blocked: boolean;
groups: EmployeeGroup[];
employee: Employee;
form: EmployeeForm;
updateForm: (field: keyof EmployeeForm, value: string | boolean | number[]) => void;
}) {
return (
<div className="employee-detail-left">
<EmployeeIdentitySections groups={groups} employee={employee} form={form} updateForm={updateForm} />
<EmployeeSecuritySections blocked={blocked} employee={employee} form={form} updateForm={updateForm} />
</div>
);
}
@@ -1,9 +1,11 @@
import type { Employee, EmployeeGroup, Role } from "../../types";
import { FormField } from "../../shared/form-controls";
import { Icon } from "../../shared/icons";
import { groupColorOf } from "../conversations/model";
import type { EmployeeForm } from "./model";
// Основные данные · Должность и группы · Системная роль (кадры E3/E4).
// Владельцу все роли закрыты: роль владельца меняется только передачей владения.
const ROLE_OPTIONS: Array<{ role: Role; label: string; description: string }> = [
{ role: "OWNER", label: "Владелец", description: "Полный доступ. Ровно один в организации. Изменяется только через передачу владения." },
{ role: "ADMIN", label: "Администратор", description: "Полный доступ, идентичен владельцу. Единственное отличие владельца — его нельзя удалить." },
@@ -19,6 +21,7 @@ export function EmployeeIdentitySections({ groups, employee, form, updateForm }:
const canProfile = employee.permissions?.canUpdateProfile ?? false;
const canGroups = employee.permissions?.canChangeGroups ?? false;
const canRole = employee.permissions?.canChangeRole ?? false;
const isOwner = employee.role === "OWNER";
function toggleGroup(groupId: number) {
const next = form.groupIds.includes(groupId)
@@ -27,9 +30,84 @@ export function EmployeeIdentitySections({ groups, employee, form, updateForm }:
updateForm("groupIds", next);
}
return <>
<section className="employee-detail-card"><h3>Основные данные</h3><div className="employee-form-grid"><FormField disabled={!canProfile} label="Имя" value={form.fullName} onChange={(value) => updateForm("fullName", value)} /><FormField disabled={!canProfile} label="Телефон" value={form.phone} onChange={(value) => updateForm("phone", value)} /><FormField disabled={!canProfile} label="Email · используется для входа" value={form.email} onChange={(value) => updateForm("email", value)} mono wide /></div></section>
<section className="employee-detail-card employee-placement-card"><h3>Должность и группы</h3><p>Группа задаёт только видимость диалогов и не выдаёт прав.</p><div className="employee-form-grid"><FormField disabled={!canProfile} label="Должность" value={form.positionTitle} onChange={(value) => updateForm("positionTitle", value)} placeholder="напр. Оператор" wide /></div>{groups.length ? <div className="employee-create-profiles">{groups.map((group) => { const active = form.groupIds.includes(group.id); return <label className={active ? "selected" : ""} key={group.id}><button type="button" aria-pressed={active} disabled={!canGroups} onClick={() => toggleGroup(group.id)}><i>{active && <Icon name="check" size={11} />}</i><span><strong><i className="chat-scope-dot" style={{ background: groupColorOf(group.id, group.color) }} />{group.name}</strong><small>{group.memberCount} сотр.</small></span></button></label>; })}</div> : <small>Групп пока нет — все сотрудники видят все диалоги.</small>}</section>
<section className="employee-detail-card employee-role-card"><h3>Системная роль</h3><p>{employee.role === "OWNER" ? "Роль владельца меняется только через передачу владения." : "OWNER и ADMIN идентичны по правам; EMPLOYEE работает только в чате."}</p><div>{ROLE_OPTIONS.map((option) => { const active = form.role === option.role; const disabled = !canRole || option.role === "OWNER"; return <button className={active ? "active" : ""} type="button" disabled={disabled} onClick={() => updateForm("role", option.role)} key={option.role}><i>{active && <span />}</i><span><strong>{option.label}{option.role === "OWNER" && <Icon name="lock" size={12} />}</strong><small>{option.description}</small></span></button>; })}</div></section>
</>;
return (
<>
<section className="employee-card">
<h3>Основные данные</h3>
<div className="employee-field-grid">
<EmployeeField label="Имя" value={form.fullName} readOnly={!canProfile} onChange={(value) => updateForm("fullName", value)} />
<EmployeeField label="Телефон" value={form.phone} readOnly={!canProfile} onChange={(value) => updateForm("phone", value)} />
<EmployeeField className="is-wide" label="Email · используется для входа" value={form.email} readOnly={!canProfile} mono onChange={(value) => updateForm("email", value)} />
</div>
</section>
<section className="employee-card">
<h3>Должность и группы</h3>
<p>Группа задаёт только видимость диалогов и не выдаёт прав.</p>
<EmployeeField className="is-single" label="Должность" value={form.positionTitle} readOnly={!canProfile} placeholder="напр. Оператор" onChange={(value) => updateForm("positionTitle", value)} />
{groups.length > 0 ? (
<div className="employee-group-picks">
{groups.map((group) => {
const active = form.groupIds.includes(group.id);
return (
<button className={`employee-group-pick ${active ? "is-on" : ""}`} type="button" aria-pressed={active} disabled={!canGroups} onClick={() => toggleGroup(group.id)} key={group.id}>
<i>{active && <Icon name="check" size={11} strokeWidth={3} />}</i>
<span>
<strong><i className="employee-group-dot" style={{ background: groupColorOf(group.id, group.color) }} />{group.name}</strong>
<small>{group.memberCount} сотр.</small>
</span>
</button>
);
})}
</div>
) : (
<p className="employee-create-note">Групп пока нет — все сотрудники видят все диалоги.</p>
)}
</section>
<section className="employee-card">
<h3>Системная роль</h3>
<p>{isOwner ? "Роль владельца меняется только через передачу владения." : "OWNER и ADMIN идентичны по правам; EMPLOYEE работает только в чате."}</p>
<div className="employee-choice-list">
{ROLE_OPTIONS.map((option) => {
const active = form.role === option.role;
// Владельца нельзя выбрать ролью: единственный путь — передача владения.
const locked = isOwner || !canRole || option.role === "OWNER";
return (
<button
className={`employee-choice ${active ? "is-on" : ""} ${locked ? "is-locked" : ""}`}
type="button"
disabled={locked}
onClick={() => updateForm("role", option.role)}
key={option.role}
>
<i>{active && <span />}</i>
<span>
<strong>{option.label}{locked && <Icon name="lock" size={12} strokeWidth={1.8} />}</strong>
<small>{option.description}</small>
</span>
</button>
);
})}
</div>
</section>
</>
);
}
function EmployeeField({ className = "", label, mono = false, onChange, placeholder = "", readOnly, value }: {
className?: string;
label: string;
mono?: boolean;
onChange: (value: string) => void;
placeholder?: string;
readOnly: boolean;
value: string;
}) {
return (
<label className={`employee-field ${className} ${readOnly ? "is-readonly" : ""}`}>
<span>{label}</span>
<input className={mono ? "mono" : ""} value={value} placeholder={placeholder} readOnly={readOnly} onChange={(event) => onChange(event.target.value)} />
</label>
);
}
@@ -0,0 +1,83 @@
import { useState } from "react";
import { Icon } from "../../shared/icons";
import { Avatar } from "../../shared/ui";
import { CopyButton } from "../../shared/ui-controls";
import { resetEmployeePassword, type IssuedPassword } from "./api";
import { employeeAvatarColor } from "./model";
// Пароль первичного доступа (дизайн-базлайн v2, кадры E7/E8). Показывается один
// раз: сервер отдал его в ответе и хранит только хеш.
const TITLES = {
created: { title: "Сотрудник создан", sub: "Пароль сгенерирован и показан один раз — скопируйте его сейчас." },
reset: { title: "Пароль сброшен", sub: "Активные сессии сотрудника завершены, при первом входе он задаст свой пароль." },
} as const;
export function EmployeePasswordDialog({ issued, onClose }: { issued: IssuedPassword; onClose: () => void }) {
const [masked, setMasked] = useState(false);
const [sending, setSending] = useState(false);
const [sent, setSent] = useState(false);
const [error, setError] = useState("");
const { title, sub } = TITLES[issued.reason];
const employee = issued.employee;
async function sendByMail() {
setSending(true);
setError("");
try {
await resetEmployeePassword(employee.id, "mail");
setSent(true);
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Не удалось отправить письмо");
} finally {
setSending(false);
}
}
return (
<div className="employee-dialog-scrim">
<section className="employee-password-dialog" role="dialog" aria-label={title}>
<header>
<i><Icon name="lock" size={20} strokeWidth={1.9} /></i>
<div>
<h2>{title}</h2>
<p>{sub}</p>
</div>
</header>
<div className="employee-password-body">
<div className="employee-password-person">
<Avatar employee={employee} background={employeeAvatarColor(employee)} />
<span>
<strong>{employee.fullName || employee.email}</strong>
<small>{employee.email}</small>
</span>
</div>
<label className="employee-password-field">
<span>Пароль первичного доступа</span>
<span className="employee-password-value">
<code>{masked ? "•".repeat(issued.password.length) : issued.password}</code>
<button className="employee-password-mask" type="button" title={masked ? "Показать" : "Скрыть"} aria-label={masked ? "Показать" : "Скрыть"} onClick={() => setMasked((value) => !value)}>
<Icon name={masked ? "eyeOff" : "eye"} size={16} strokeWidth={1.8} />
</button>
<CopyButton className="employee-password-copy" label="Копировать" value={issued.password} />
</span>
</label>
<div className="employee-password-warning">
<Icon name="warning" size={17} strokeWidth={1.9} />
<p>Пароль показывается один раз и на сервере не хранится в открытом виде. Скопируйте и передайте сотруднику лично — при первом входе он сменит его сам. Если окно закрыть, останется только сбросить пароль заново.</p>
</div>
<button className="employee-password-mail" type="button" disabled={sending || sent} onClick={() => void sendByMail()}>
<Icon name="mail" size={14} strokeWidth={1.8} />
{sent ? "Письмо отправлено" : sending ? "Отправка…" : "Отправить на почту вместо этого"}
</button>
{error && <div className="employees-error"><Icon name="alert" size={16} strokeWidth={1.8} />{error}</div>}
</div>
<footer>
<small>Действие записано в аудит</small>
<button className="primary-button employee-dialog-primary" type="button" onClick={onClose}>Скопировал, закрыть</button>
</footer>
</section>
</div>
);
}
@@ -1,25 +1,81 @@
import { Icon } from "../../shared/icons";
import { SwitchButton } from "../../shared/form-controls";
import { Button } from "../../shared/ui-controls";
import type { Employee } from "../../types";
import type { EmployeeForm } from "./model";
import { formatDate, type EmployeeForm } from "./model";
export function EmployeeSecuritySections({ blocked, employee, form, updateForm }: {
// Статус и безопасность · Опасная зона (кадры E3/E4). У владельца единственное
// опасное действие — передача владения: заблокировать или удалить его нельзя.
export function EmployeeSecuritySections({ blocked, employee, form, busy, updateForm, onResetPassword, onTerminateSessions, onToggleBlock, onTransferOwnership }: {
blocked: boolean;
employee: Employee;
form: EmployeeForm;
busy: boolean;
updateForm: (field: keyof EmployeeForm, value: string | boolean) => void;
onResetPassword: () => void;
onTerminateSessions: () => void;
onToggleBlock: () => void;
onTransferOwnership: () => void;
}) {
// Confirmation/result states for security commands are absent from the approved baseline.
const permissions = employee.permissions;
const canToggle = blocked ? permissions?.canUnblock : permissions?.canBlock;
return <>
<section className="employee-detail-card employee-security-card">
<h3>Статус и безопасность</h3>
<div className="security-row"><div><strong>Пароль</strong><span>Последняя смена: —</span></div><Button type="button" variant="secondary" disabled={!permissions?.canResetPassword}>Сбросить пароль</Button></div>
<div className="security-row"><div><strong>Двухфакторная аутентификация (TOTP)</strong><span>{form.totpEnabled ? "Включена · требуется при каждом входе" : "Отключена для этого сотрудника"}</span></div><SwitchButton checked={form.totpEnabled} className="ui-switch is-large" label="Переключить TOTP" disabled={!permissions?.canUpdateProfile} onClick={() => updateForm("totpEnabled", !form.totpEnabled)} /></div>
<div className="security-row last"><div><strong>Активные сессии</strong><span>{employee.activeSessionCount === undefined ? "—" : `${employee.activeSessionCount} устройств`}</span></div><Button type="button" variant="secondary" disabled={!permissions?.canTerminateSessions}>Завершить все</Button></div>
</section>
{canToggle && <section className="employee-danger-card"><h3>Опасная зона</h3><div><p>{blocked ? "Сотрудник заблокирован и не может войти. Разблокировка восстановит доступ по активным назначениям." : "Блокировка немедленно завершит все сессии и прекратит HTTP и realtime actions. Аудит и авторство сохраняются. Требует подтверждения."}</p><Button className="employee-danger-action" type="button" variant="danger-outline">{blocked ? "Разблокировать сотрудника" : "Заблокировать сотрудника"}</Button></div></section>}
</>;
const isOwner = employee.role === "OWNER";
const canBlock = blocked ? permissions?.canUnblock : permissions?.canBlock;
const sessions = employee.activeSessionCount === undefined ? "—" : `${employee.activeSessionCount} устройств`;
return (
<>
<section className="employee-card">
<h3 className="is-tight">Статус и безопасность</h3>
<div className="employee-security-row">
<div>
<strong>Пароль</strong>
<small>Последняя смена: {formatDate(employee.passwordChangedAt)} · новый можно отправить письмом или показать и скопировать</small>
</div>
<button className="employee-security-action" type="button" disabled={busy || !permissions?.canResetPassword} onClick={onResetPassword}>Сбросить пароль</button>
</div>
<div className="employee-security-row">
<div>
<strong>Двухфакторная аутентификация (TOTP)</strong>
<small>{form.totpEnabled ? "Включена · требуется при каждом входе" : "Отключена для этого сотрудника"}</small>
</div>
<SwitchButton checked={form.totpEnabled} className="ui-switch is-large" label="Переключить TOTP" disabled={!permissions?.canUpdateProfile} onClick={() => updateForm("totpEnabled", !form.totpEnabled)} />
</div>
<div className="employee-security-row is-last">
<div>
<strong>Активные сессии</strong>
<small>{sessions}</small>
</div>
<button className="employee-security-action" type="button" disabled={busy || !permissions?.canTerminateSessions} onClick={onTerminateSessions}>Завершить все</button>
</div>
</section>
{(isOwner ? permissions?.canTransferOwnership : canBlock) && (
<section className="employee-danger">
<h3>Опасная зона</h3>
{isOwner ? (
<div>
<strong>Передача владения</strong>
<p>Владение передаётся только администратору: у него уже полный доступ, и после передачи он получит защищённые действия владельца. Операция атомарна, аудируется и требует подтверждения.</p>
<button type="button" disabled={busy} onClick={onTransferOwnership}>
<Icon name="transfer" size={14} strokeWidth={1.8} />Передать владение
</button>
<small>Владельца нельзя заблокировать или удалить, поэтому других опасных действий на его карточке нет.</small>
</div>
) : (
<div>
<strong>Блокировка доступа</strong>
<p>
{blocked
? "Сотрудник заблокирован и не может войти. Разблокировка восстановит доступ по активным назначениям."
: "Немедленно завершит все сессии и прекратит HTTP и realtime actions. Аудит и авторство сохраняются. Требует подтверждения."}
</p>
<button type="button" disabled={busy} onClick={onToggleBlock}>
<Icon name="lock" size={14} strokeWidth={1.8} />{blocked ? "Разблокировать сотрудника" : "Заблокировать сотрудника"}
</button>
</div>
)}
</section>
)}
</>
);
}
@@ -1,88 +1,131 @@
import { Dropdown } from "antd";
import type { Employee } from "../../types";
import type { Employee, EmployeeGroup } from "../../types";
import { Icon } from "../../shared/icons";
import { Avatar, EmptyState, RoleBadge, StatusPill } from "../../shared/ui";
import { employeeStatusKey, formatLastLogin, groupsLabel, roleAccessLabel } from "./model";
import { Avatar, EmptyState } from "../../shared/ui";
import { groupColorOf } from "../conversations/model";
import { employeeAvatarColor, formatLastLogin, roleAccessLabel, roleBadge, statusBadge } from "./model";
// Таблица сотрудников (дизайн-базлайн v2, «Сотрудники Baseline», кадры E1/E2):
// сотрудник · роль · должность · группы · доступ · статус · последний вход · ⋯.
export function EmployeeTable({
employees,
groups,
menuId,
onTransfer,
onBlock,
onResetPassword,
onTerminateSessions,
openEmployee,
setMenuId,
total,
}: {
employees: Employee[];
groups: EmployeeGroup[];
menuId: number | null;
onTransfer: () => void;
onBlock: (employee: Employee) => void;
onResetPassword: (employee: Employee) => void;
onTerminateSessions: (employee: Employee) => void;
openEmployee: (employee: Employee) => void;
setMenuId: (id: number | null) => void;
total: number;
}) {
return (
<div className="table-card employees-card">
<div className="table-scroll">
<table className="baseline-table employees-table">
<thead><tr><th>СОТРУДНИК</th><th>РОЛЬ</th><th>ДОЛЖНОСТЬ</th><th>ГРУППЫ</th><th>ДОСТУП</th><th>СТАТУС</th><th>ПОСЛЕДНИЙ ВХОД</th><th /></tr></thead>
<tbody>
{employees.map((employee) => (
<EmployeeRow
employee={employee}
menuOpen={menuId === employee.id}
onTransfer={onTransfer}
openEmployee={openEmployee}
setMenuId={setMenuId}
key={employee.id}
/>
))}
</tbody>
</table>
<div className="employees-table">
<div className="employees-thead">
<span>Сотрудник</span>
<span>Роль</span>
<span>Должность</span>
<span>Группы</span>
<span>Доступ</span>
<span>Статус</span>
<span>Последний вход</span>
<span />
</div>
{employees.map((employee) => (
<EmployeeRow
employee={employee}
groups={groups}
menuOpen={menuId === employee.id}
onBlock={onBlock}
onResetPassword={onResetPassword}
onTerminateSessions={onTerminateSessions}
openEmployee={openEmployee}
setMenuId={setMenuId}
key={employee.id}
/>
))}
{!employees.length && <EmptyState title="Сотрудники не найдены" />}
<div className="employees-footer">
<span>Показано {employees.length} из {total}</span>
<span>Группа задаёт только видимость диалогов и не выдаёт прав</span>
<div className="employees-foot">
<small>Показано {employees.length} из {total}</small>
<small>Группа задаёт только видимость диалогов и не выдаёт прав</small>
</div>
</div>
);
}
function EmployeeRow({ employee, menuOpen, onTransfer, openEmployee, setMenuId }: {
function EmployeeRow({ employee, groups, menuOpen, onBlock, onResetPassword, onTerminateSessions, openEmployee, setMenuId }: {
employee: Employee;
groups: EmployeeGroup[];
menuOpen: boolean;
onTransfer: () => void;
onBlock: (employee: Employee) => void;
onResetPassword: (employee: Employee) => void;
onTerminateSessions: (employee: Employee) => void;
openEmployee: (employee: Employee) => void;
setMenuId: (id: number | null) => void;
}) {
// Destructive row actions stay visual until their baseline confirmation states are approved.
const permissions = employee.permissions;
const manageable = Boolean(permissions?.canBlock || permissions?.canUnblock || permissions?.canResetPassword || permissions?.canTerminateSessions);
const role = roleBadge(employee.role);
const status = statusBadge(employee);
const open = () => { setMenuId(null); openEmployee(employee); };
const act = (run: () => void) => { setMenuId(null); run(); };
// Кадр E2: карточка · сессии · пароль · разделитель · блокировка.
// Передачи владения в меню строки нет — она только в опасной зоне владельца.
const menuItems = [
{ key: "open", label: <button type="button" onClick={open}><Icon name="external" size={15} />Открыть карточку</button> },
...(permissions?.canTransferOwnership ? [{ key: "transfer", label: <button type="button" onClick={onTransfer}><Icon name="split" size={15} />Передать владение</button> }] : []),
...(manageable ? [
{ key: "sessions", disabled: !permissions?.canTerminateSessions, label: <button type="button"><Icon name="logout" size={15} />Завершить сессии</button> },
{ key: "password", disabled: !permissions?.canResetPassword, label: <button type="button"><Icon name="lock" size={15} />Сбросить пароль</button> },
{ type: "divider" as const },
{ key: "block", disabled: employee.isBlocked ? !permissions?.canUnblock : !permissions?.canBlock, label: <button className={employee.isBlocked ? "success" : "danger"} type="button">{employee.isBlocked ? "Разблокировать" : "Заблокировать"}</button> },
] : []),
{ key: "sessions", disabled: !permissions?.canTerminateSessions, label: <button type="button" onClick={() => act(() => onTerminateSessions(employee))}><Icon name="logout" size={15} />Завершить сессии</button> },
{ key: "password", disabled: !permissions?.canResetPassword, label: <button type="button" onClick={() => act(() => onResetPassword(employee))}><Icon name="lock" size={15} />Сбросить пароль</button> },
{ type: "divider" as const },
{
key: "block",
disabled: employee.isBlocked ? !permissions?.canUnblock : !permissions?.canBlock,
label: (
<button className={employee.isBlocked ? "success" : "danger"} type="button" onClick={() => act(() => onBlock(employee))}>
<Icon name="lock" size={15} />{employee.isBlocked ? "Разблокировать" : "Заблокировать"}
</button>
),
},
];
return (
<tr>
<td><div className="person-cell"><Avatar employee={employee} /><button className="person-link" type="button" onClick={open}><strong>{employee.fullName || employee.email}</strong><small>{employee.email}</small></button></div></td>
<td><RoleBadge role={employee.role} /></td>
<td>{employee.positionTitle}</td>
<td>{groupsLabel(employee)}</td>
<td>{roleAccessLabel(employee)}</td>
<td><StatusPill status={employeeStatusKey(employee)} /></td>
<td className="employee-last-login">{formatLastLogin(employee.lastLogin)}</td>
<td className="row-actions">
<Dropdown menu={{ items: menuItems }} open={menuOpen} onOpenChange={(next) => setMenuId(next ? employee.id : null)} trigger={["click"]} overlayClassName="app-dropdown is-wide">
<button className="row-menu-button" type="button" aria-label={`Действия: ${employee.fullName || employee.email}`}><Icon name="more" /></button>
</Dropdown>
</td>
</tr>
<div className={`employees-row ${employee.isBlocked ? "is-blocked" : ""}`} role="button" tabIndex={0} onClick={open} onKeyDown={(event) => { if (event.key === "Enter") open(); }}>
<div className="employees-person">
<Avatar employee={employee} background={employeeAvatarColor(employee)} />
<span>
<strong>{employee.fullName || employee.email}</strong>
<small>{employee.email}</small>
</span>
</div>
<b className="employees-badge" style={{ background: role.bg, color: role.color }}>{role.text}</b>
<span className="employees-position">{employee.positionTitle}</span>
<div className="employees-groups">
{employee.groups.length === 0
? <span className="employees-nogroup">Без группы</span>
: employee.groups.map((group) => (
<span className="employees-group" key={group.id}>
<i style={{ background: groupColorOf(group.id, groups.find((item) => item.id === group.id)?.color) }} />
{group.name}
</span>
))}
</div>
<span className={`employees-access ${employee.role === "EMPLOYEE" ? "is-limited" : ""}`}>{roleAccessLabel(employee)}</span>
<b className="employees-badge has-dot" style={{ background: status.bg, color: status.color }}><i />{status.text}</b>
<span className="employees-login">{formatLastLogin(employee.lastLogin)}</span>
<Dropdown menu={{ items: menuItems }} open={menuOpen} onOpenChange={(next) => setMenuId(next ? employee.id : null)} trigger={["click"]} overlayClassName="app-dropdown is-employee-menu">
<button className={`employees-row-menu ${menuOpen ? "is-open" : ""}`} type="button" aria-label={`Действия: ${employee.fullName || employee.email}`} title="Действия" onClick={(event) => event.stopPropagation()}>
<Icon name="more" size={16} />
</button>
</Dropdown>
</div>
);
}
@@ -1,8 +1,13 @@
import { useState } from "react";
import { Segmented } from "../../shared/ui";
import { SearchInput } from "../../shared/ui-controls";
import { FilterDropdown, SearchInput } from "../../shared/ui-controls";
import type { EmployeeGroup } from "../../types";
import type { EmployeeRoleFilter } from "./model";
// Фильтры списка сотрудников (кадры E1/E2): поиск · роль · группа · «Сбросить».
// «Сбросить» подсвечивается акцентом, когда фильтры отличаются от исходных.
export function EmployeesFilters({
groupId,
groups,
@@ -22,23 +27,32 @@ export function EmployeesFilters({
setQuery: (query: string) => void;
setRole: (role: EmployeeRoleFilter) => void;
}) {
const [groupOpen, setGroupOpen] = useState(false);
const dirty = query.trim() !== "" || role !== "all" || groupId !== "all";
const group = groups.find((item) => item.id === groupId) ?? null;
return (
<div className="filter-bar employees-filter">
<SearchInput className="employee-search" value={query} onChange={setQuery} placeholder="Поиск по имени, email, должности…" />
<div className="filter-group">
<div className="employees-filters">
<SearchInput className="employees-search" value={query} onChange={setQuery} hotkey="/" placeholder="Поиск по имени, email, должности…" />
<div className="employees-filter-group">
<span>Роль</span>
<Segmented value={role} setValue={setRole} items={[["all", "Все"], ["OWNER", "Владелец"], ["ADMIN", "Администратор"], ["EMPLOYEE", "Сотрудник"]]} />
</div>
{groups.length > 0 && (
<div className="filter-group">
<div className="employees-filter-group">
<span>Группа</span>
<select value={String(groupId)} onChange={(event) => setGroupId(event.target.value === "all" ? "all" : Number(event.target.value))}>
<option value="all">Все</option>
{groups.map((group) => <option value={group.id} key={group.id}>{group.name}</option>)}
</select>
<FilterDropdown
className="employees-group-filter"
label={group ? group.name : "Все"}
open={groupOpen}
options={[{ value: "all", label: "Все" }, ...groups.map((item) => ({ value: String(item.id), label: item.name }))]}
selected={groupId === "all" ? [] : [String(groupId)]}
onOpenChange={setGroupOpen}
onSelect={(value) => setGroupId(value === "all" ? "all" : Number(value))}
/>
</div>
)}
<button className="reset-filter" type="button" onClick={resetFilters}>Сбросить</button>
<div className="employees-filters-spacer" />
<button className={`employees-reset ${dirty ? "is-dirty" : ""}`} type="button" onClick={resetFilters}>Сбросить</button>
</div>
);
}
@@ -1,16 +1,19 @@
import { useState } from "react";
import { hasCapability } from "../../auth/access";
import { PageHeader } from "../../shared/ui";
import { Icon } from "../../shared/icons";
import { Button } from "../../shared/ui-controls";
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 { OwnershipTransferModal } from "./OwnershipTransferModal";
export function EmployeesPage({ groups, employees, reload, openEmployee, setRoute, user }: {
// Список сотрудников (дизайн-базлайн v2, «Сотрудники Baseline», кадры E1/E2).
export function EmployeesPage({ groups, employees, reload, openEmployee, user }: {
groups: EmployeeGroup[];
employees: Employee[];
reload: () => void;
@@ -23,7 +26,8 @@ export function EmployeesPage({ groups, employees, reload, openEmployee, setRout
const [query, setQuery] = useState("");
const [menuId, setMenuId] = useState<number | null>(null);
const [createOpen, setCreateOpen] = useState(false);
const [transferOpen, setTransferOpen] = useState(false);
const [issued, setIssued] = useState<IssuedPassword | null>(null);
const [error, setError] = useState("");
const canManage = hasCapability(user, "employees.manage");
const filtered = filterEmployees(employees, role, groupId, query);
@@ -31,17 +35,61 @@ export function EmployeesPage({ groups, employees, reload, openEmployee, setRout
setQuery(""); setRole("all"); setGroupId("all"); setMenuId(null);
}
async function run(action: () => Promise<void>) {
setError("");
try {
await action();
reload();
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Не удалось выполнить действие");
}
}
return (
<>
<PageHeader
title="Сотрудники"
text={<>Роли и группы · показано <b>{filtered.length}</b> из {employees.length}</>}
action={canManage && <div className="employee-page-actions"><Button icon="team" iconSize={16} type="button" variant="primary" onClick={() => setCreateOpen(true)}>Добавить сотрудника</Button></div>}
<div className="employees-page">
<header className="employees-header">
<div>
<h2>Сотрудники</h2>
<p>Роли и группы · показано <b>{filtered.length}</b> из {employees.length}</p>
</div>
{canManage && (
<Button className="employees-create" variant="primary" icon="team" iconSize={16} onClick={() => setCreateOpen(true)}>Добавить сотрудника</Button>
)}
</header>
<EmployeesFilters
groupId={groupId}
groups={groups}
query={query}
role={role}
resetFilters={resetFilters}
setGroupId={(value) => { setGroupId(value); setMenuId(null); }}
setQuery={(value) => { setQuery(value); setMenuId(null); }}
setRole={(value) => { setRole(value); setMenuId(null); }}
/>
<EmployeesFilters groupId={groupId} groups={groups} query={query} role={role} resetFilters={resetFilters} setGroupId={(value) => { setGroupId(value); setMenuId(null); }} setQuery={(value) => { setQuery(value); setMenuId(null); }} setRole={(value) => { setRole(value); setMenuId(null); }} />
<EmployeeTable employees={filtered} menuId={menuId} onTransfer={() => { setMenuId(null); setTransferOpen(true); }} openEmployee={openEmployee} setMenuId={setMenuId} total={employees.length} />
<EmployeeCreateDrawer groups={groups} open={createOpen} onClose={() => setCreateOpen(false)} onCreated={() => { setCreateOpen(false); reload(); }} />
<OwnershipTransferModal employees={employees} open={transferOpen} onClose={() => setTransferOpen(false)} />
</>
{error && <div className="employees-error"><Icon name="alert" size={16} strokeWidth={1.8} />{error}</div>}
<EmployeeTable
employees={filtered}
groups={groups}
menuId={menuId}
onBlock={(employee) => void run(() => blockEmployee(employee.id, !employee.isBlocked))}
onResetPassword={(employee) => void run(async () => { setIssued(await resetEmployeePassword(employee.id, "show")); })}
onTerminateSessions={(employee) => void run(() => terminateEmployeeSessions(employee.id))}
openEmployee={openEmployee}
setMenuId={setMenuId}
total={employees.length}
/>
{createOpen && (
<EmployeeCreateDrawer
groups={groups}
onClose={() => setCreateOpen(false)}
onCreated={(password) => { setCreateOpen(false); reload(); if (password) setIssued(password); }}
/>
)}
{issued && <EmployeePasswordDialog issued={issued} onClose={() => setIssued(null)} />}
</div>
);
}
@@ -1,33 +1,38 @@
import { useEffect, useMemo, useState } from "react";
import { useMemo, 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 { employeeAvatarColor, roleBadge } from "./model";
export function OwnershipTransferModal({ employees, onClose, open }: { employees: Employee[]; onClose: () => void; open: boolean }) {
// Передача владения (дизайн-базлайн v2, кадр E9). Кандидаты — только активные
// администраторы: чтобы передать сотруднику, его сначала делают админом.
const PREVIOUS_ROLES: Array<{ value: Extract<Role, "ADMIN" | "EMPLOYEE">; label: string }> = [
{ value: "ADMIN", 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 !== "OWNER" && employee.isActive && !employee.isBlocked), [employees]);
const [targetId, setTargetId] = useState<number | null>(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);
const [previousOwnerRole, setPreviousOwnerRole] = useState<Extract<Role, "ADMIN" | "EMPLOYEE">>("ADMIN");
const [confirmed, setConfirmed] = useState(false);
const [sending, setSending] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
if (!open) return;
setTargetId(candidates[0]?.id ?? null);
setPreviousOwnerRole("ADMIN");
setConfirmed(false);
setError("");
}, [candidates, open]);
if (!open || !owner) return null;
if (!owner) return null;
const target = candidates.find((employee) => employee.id === targetId) ?? null;
async function transfer() {
if (!confirmed || !target || sending) return;
setSending(true); setError("");
setSending(true);
setError("");
try {
await api(`/api/v1/employees/${target.id}/transfer-ownership/`, {
method: "POST",
@@ -41,24 +46,93 @@ export function OwnershipTransferModal({ employees, onClose, open }: { employees
}
return (
<div className="ownership-modal-scrim" role="presentation" onMouseDown={(event) => event.target === event.currentTarget && onClose()}>
<section className="ownership-modal" role="dialog" aria-modal="true" aria-labelledby="ownership-title">
<header><i><Icon name="split" size={21} /></i><div><h2 id="ownership-title">Передача владения</h2><p>Атомарная операция · в организации остаётся ровно один владелец</p></div></header>
<div className="ownership-modal-body">
<label className="ownership-select"><span>Новый владелец</span><select value={targetId ?? ""} onChange={(event) => setTargetId(Number(event.target.value))}>{candidates.map((employee) => <option value={employee.id} key={employee.id}>{employee.fullName} · {employee.role === "ADMIN" ? "Администратор" : "Сотрудник"} · {employee.positionTitle}</option>)}</select></label>
{target && <div className="ownership-transition"><div><Avatar employee={owner} /><span><strong>{owner.fullName}</strong><small>Владелец</small></span></div><Icon name="arrow" size={20} /><div><Avatar employee={target} /><span><strong>{target.fullName}</strong><small>→ Владелец</small></span></div></div>}
<span className="ownership-label">Новая роль прежнего владельца</span>
<div className="ownership-role-options"><RoleOption active={previousOwnerRole === "ADMIN"} label="Администратор" onClick={() => setPreviousOwnerRole("ADMIN")} /><RoleOption active={previousOwnerRole === "EMPLOYEE"} label="Сотрудник" onClick={() => setPreviousOwnerRole("EMPLOYEE")} /></div>
<div className="ownership-warning"><Icon name="warning" size={17} /><p>Новый владелец получит полный доступ и company placement. Вы потеряете права владельца, включая передачу владения и управление администраторами. Операция аудируется и необратима без повторной передачи.</p></div>
<button className={`ownership-confirm ${confirmed ? "checked" : ""}`} type="button" onClick={() => setConfirmed((value) => !value)}><i>{confirmed && <Icon name="check" size={12} />}</i><span>Я понимаю последствия и подтверждаю передачу владения организацией.</span></button>
{error && <p className="ownership-error">{error}</p>}
<div className="employee-dialog-scrim">
<section className="employee-transfer-dialog" role="dialog" aria-label="Передача владения">
<header>
<i><Icon name="transfer" size={21} strokeWidth={1.9} /></i>
<div>
<h2>Передача владения</h2>
<p>Атомарная операция · в организации остаётся ровно один владелец</p>
</div>
</header>
<div className="employee-transfer-body">
{candidates.length === 0 ? (
<p className="employee-create-note">Активных администраторов нет. Чтобы передать владение сотруднику, сначала сделайте его администратором.</p>
) : (
<>
<label className="employee-field is-single">
<span>Новый владелец · только администраторы</span>
<span className="employee-transfer-select">
<select value={targetId === null ? "" : String(targetId)} onChange={(event) => setTargetId(Number(event.target.value))}>
{candidates.map((employee) => (
<option value={employee.id} key={employee.id}>
{employee.fullName || employee.email} · {roleBadge(employee.role).text} · {employee.positionTitle}
</option>
))}
</select>
<Icon name="chevron" size={14} strokeWidth={1.8} />
</span>
<small className="employee-transfer-note">В списке только активные администраторы. Чтобы передать владение сотруднику, сначала сделайте его администратором.</small>
</label>
<div className="employee-transfer-pair">
<div>
<Avatar employee={owner} background={employeeAvatarColor(owner)} />
<span>
<strong>{owner.fullName || owner.email}</strong>
<small>Владелец</small>
</span>
</div>
<Icon name="arrow" size={20} strokeWidth={1.8} />
<div>
{target && <Avatar employee={target} background={employeeAvatarColor(target)} />}
<span>
<strong>{target?.fullName || target?.email}</strong>
<small className="is-accent">→ Владелец</small>
</span>
</div>
</div>
<div className="employee-transfer-role">
<span>Новая роль прежнего владельца</span>
<div>
{PREVIOUS_ROLES.map((option) => (
<button
className={`employee-transfer-role-option ${previousOwnerRole === option.value ? "is-on" : ""}`}
type="button"
onClick={() => setPreviousOwnerRole(option.value)}
key={option.value}
>
<i>{previousOwnerRole === option.value && <span />}</i>
{option.label}
</button>
))}
</div>
</div>
<div className="employee-password-warning">
<Icon name="warning" size={17} strokeWidth={1.9} />
<p>Новый владелец получит полный доступ. Вы потеряете права владельца, включая передачу владения и управление администраторами. Операция аудируется и необратима без повторной передачи.</p>
</div>
<button className="employee-transfer-confirm" type="button" aria-pressed={confirmed} onClick={() => setConfirmed((value) => !value)}>
<i>{confirmed && <Icon name="check" size={12} strokeWidth={3} />}</i>
<span>Я понимаю последствия и подтверждаю передачу владения организацией.</span>
</button>
{error && <div className="employees-error"><Icon name="alert" size={16} strokeWidth={1.8} />{error}</div>}
</>
)}
</div>
<footer><button className="secondary-button" type="button" onClick={onClose}>Отмена</button><button className="ownership-submit" type="button" disabled={!confirmed || !target || sending} onClick={transfer}>{sending ? "Передача" : "Передать владение"}</button></footer>
<footer>
<button className="secondary-button" type="button" onClick={onClose}>Отмена</button>
<button className="employee-transfer-submit" type="button" disabled={!confirmed || !target || sending} onClick={() => void transfer()}>
{sending ? "Передача…" : "Передать владение"}
</button>
</footer>
</section>
</div>
);
}
function RoleOption({ active, label, onClick }: { active: boolean; label: string; onClick: () => void }) {
return <button className={active ? "active" : ""} type="button" onClick={onClick}><i>{active && <span />}</i>{label}</button>;
}
@@ -0,0 +1,48 @@
import { api } from "../../api/client";
import type { Employee, Role } from "../../types";
// Действия над сотрудником (дизайн-базлайн v2, кадры E2, E3, E5–E8).
// Пароль первичного доступа сервер отдаёт ровно один раз — открытым он нигде
// не хранится, поэтому ответ показывается сразу в диалоге.
export type PasswordMode = "mail" | "show";
export type IssuedPassword = {
employee: Employee;
password: string;
reason: "created" | "reset";
};
export type EmployeeCreateInput = {
fullName: string;
phone: string;
email: string;
role: Role;
positionTitle: string;
groupIds: number[];
passwordMode: PasswordMode;
};
export async function createEmployee(input: EmployeeCreateInput): Promise<IssuedPassword | null> {
const payload = await api<{ employee: Employee; password: string | null }>("/api/v1/employees/operators/", {
method: "POST",
body: JSON.stringify(input),
});
return payload.password ? { employee: payload.employee, password: payload.password, reason: "created" } : null;
}
export async function resetEmployeePassword(userId: number, mode: PasswordMode): Promise<IssuedPassword | null> {
const payload = await api<{ employee: Employee; password: string | null }>(`/api/v1/employees/${userId}/reset-password/`, {
method: "POST",
body: JSON.stringify({ mode }),
});
return payload.password ? { employee: payload.employee, password: payload.password, reason: "reset" } : null;
}
export async function terminateEmployeeSessions(userId: number): Promise<void> {
await api(`/api/v1/employees/${userId}/revoke-sessions/`, { method: "POST" });
}
export async function blockEmployee(userId: number, block: boolean): Promise<void> {
await api(`/api/v1/employees/${userId}/${block ? "block" : "unblock"}/`, { method: "POST" });
}
@@ -1,3 +1,4 @@
import { shortDateYear } from "../../shared/utils";
import type { Employee, EmployeeAuditEvent, Role } from "../../types";
export type EmployeeStatus = "active" | "blocked" | "invited";
@@ -25,6 +26,38 @@ const AUDIT_LABELS: Record<string, string> = {
"identity.ownership_transferred": "Передано владение организацией",
};
// Бейджи роли и статуса (дизайн-базлайн v2, «Сотрудники Baseline», таблицы
// ROLE и STATUS): владелец — акцентом, администратор — цветом AI, сотрудник —
// нейтральный; статус — полутон своего цвета с точкой.
const ROLE_BADGE: Record<Role, { text: string; bg: string; color: string }> = {
OWNER: { text: "Владелец", bg: "var(--primary-bg)", color: "var(--primary-text)" },
ADMIN: { text: "Администратор", bg: "color-mix(in srgb, var(--ai) 14%, var(--surface-card))", color: "var(--ai)" },
EMPLOYEE: { text: "Сотрудник", bg: "var(--n-9)", color: "var(--n-3)" },
};
const STATUS_BADGE: Record<EmployeeStatus, { text: string; bg: string; color: string }> = {
active: { text: "Активен", bg: "var(--success-bg)", color: "var(--success-text)" },
invited: { text: "Приглашён", bg: "var(--warning-bg)", color: "var(--warning-text)" },
blocked: { text: "Заблокирован", bg: "var(--error-bg)", color: "var(--error-text)" },
};
// Цвет аватара без фото — по порядку сотрудников, как в касте дизайн-базлайна;
// у заблокированного серый (кадр E1).
const AVATAR_PALETTE = ["#c4456b", "#4c6ef0", "#3b82c4", "#13a8a8", "#d4860b"];
export function employeeAvatarColor(employee: Employee): string {
if (employee.isBlocked) return "#8c8c8c";
return AVATAR_PALETTE[(employee.id - 1) % AVATAR_PALETTE.length];
}
export function roleBadge(role: Role) {
return ROLE_BADGE[role];
}
export function statusBadge(employee: Employee) {
return STATUS_BADGE[employeeStatusKey(employee)];
}
export function employeeStatusKey(employee: Employee): EmployeeStatus {
if (employee.isBlocked) return "blocked";
if (employee.mustChangePassword) return "invited";
@@ -72,11 +105,7 @@ export function roleAccessLabel(employee: Employee) {
export function formatDate(value?: string | null, empty = "—") {
if (!value) return empty;
return new Intl.DateTimeFormat("ru-RU", {
day: "2-digit",
month: "short",
year: "numeric",
}).format(new Date(value)).replace(/\s?г\.$/u, "");
return shortDateYear(value) || empty;
}
export function formatLastLogin(value?: string | null, empty = "не входил") {
@@ -1,44 +0,0 @@
.access-profiles-layout { display: flex; align-items: flex-start; gap: 16px; }
.access-profile-list { width: 340px; display: flex; flex: none; flex-direction: column; gap: 10px; }
.access-profile-list > button { padding: 14px 15px; color: var(--n-2); background: var(--surface-card); border: 1px solid var(--n-8); border-radius: 11px; box-shadow: var(--shadow-xs); text-align: left; }
.access-profile-list > button.selected { border-color: var(--primary-border); }
.access-profile-card-title { display: flex; align-items: center; gap: 8px; }
.access-profile-card-title strong { font-size: 13.5px; }
.access-profile-card-title b,
.access-profile-card-title em { padding: 1px 7px; border-radius: 5px; font-size: 10px; font-style: normal; letter-spacing: 0.03em; }
.access-profile-card-title b { color: var(--n-4); background: var(--n-8); }
.access-profile-card-title em { margin-left: auto; color: var(--warning-text); background: var(--warning-bg-strong); }
.access-profile-description { display: block; margin-top: 4px; color: var(--n-4); font-size: 11.5px; line-height: 1.45; }
.access-profile-meta { display: flex; align-items: center; gap: 14px; margin-top: 11px; color: var(--n-4); font-size: 11.5px; }
.access-profile-meta i { font-style: normal; }
.access-profile-editor { min-width: 0; flex: 1; padding: 20px 22px; background: var(--surface-card); border: 1px solid var(--n-8); border-radius: 12px; box-shadow: var(--shadow-sm); }
.access-profile-editor > header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; padding-bottom: 16px; border-bottom: 1px solid var(--n-9); }
.access-profile-editor-title { min-width: 0; flex: 1; }
.access-profile-editor-title > div { display: flex; align-items: center; gap: 9px; }
.access-profile-editor-title h2 { margin: 0; color: var(--n-1); font-size: 17px; font-weight: 700; letter-spacing: -0.01em; }
.access-profile-editor-title p { max-width: 520px; margin: 6px 0 0; color: var(--n-4); font-size: 12.5px; line-height: 1.5; }
.access-profile-editor-title b { padding: 2px 8px; color: var(--n-4); background: var(--n-8); border-radius: 5px; font-size: 10px; letter-spacing: 0.03em; }
.access-profile-editor-actions { display: flex; flex: none; gap: 8px; }
.access-profile-editor-actions button { height: 34px; border-radius: 8px; font-size: 12.5px; }
.access-profile-summary { display: flex; align-items: center; gap: 20px; padding: 14px 0; color: var(--n-4); border-bottom: 1px solid var(--n-9); font-size: 12px; }
.access-profile-summary b { color: var(--n-3); }
.access-capabilities-header { display: flex; align-items: center; justify-content: space-between; padding-top: 16px; margin-bottom: 12px; }
.access-capabilities-header h3 { margin: 0; color: var(--n-1); font-size: 13px; }
.access-capabilities-header span { color: var(--n-5); font-size: 11.5px; }
.access-capability-groups { display: flex; flex-direction: column; gap: 16px; }
.access-capability-groups h4 { margin: 0 0 8px; color: var(--n-5); font-size: 11px; letter-spacing: 0.05em; text-transform: uppercase; }
.access-capability-groups section > div { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.access-capability-groups button { display: flex; align-items: flex-start; gap: 10px; padding: 9px 11px; color: var(--n-2); background: var(--surface-card); border: 1px solid var(--n-8); border-radius: 8px; text-align: left; }
.access-capability-groups button.checked { background: var(--primary-bg); border-color: var(--primary-border); }
.access-capability-groups button.protected { background: var(--error-bg); }
.access-capability-groups button > i { width: 16px; height: 16px; display: flex; align-items: center; justify-content: center; flex: none; margin-top: 1px; color: var(--surface-card); background: var(--surface-card); border: 1.6px solid var(--n-6); border-radius: 5px; }
.access-capability-groups button.checked > i { background: var(--primary); border-color: var(--primary); }
.access-capability-groups button.protected > i { border-color: var(--error-border); }
.access-capability-groups code { color: var(--n-2); font-size: 12px; }
.access-capability-groups button.protected code { color: var(--error-text); }
.access-capability-groups b { padding: 0 5px; margin-left: 6px; color: var(--error-text); background: var(--error-bg); border-radius: 4px; font-size: 9.5px; }
.access-capability-groups small { display: block; margin-top: 2px; color: var(--n-4); font-size: 11px; line-height: 1.4; }
.access-protected-note { display: flex; align-items: flex-start; gap: 9px; padding: 11px 13px; margin-top: 16px; color: var(--error-text); background: var(--error-bg); border: 1px solid var(--error-border); border-radius: 9px; }
.access-protected-note p { margin: 0; color: var(--n-3); font-size: 11.5px; line-height: 1.5; }
.access-profile-error { padding: 10px 12px; margin-top: 12px; color: var(--error-text); background: var(--error-bg); border: 1px solid var(--error-border); border-radius: 8px; font-size: 12px; }
@@ -1,9 +1,13 @@
/* Новый сотрудник и пароль первичного доступа (дизайн-базлайн v2, кадры
E5–E8): панель 520px, четыре шага, диалог с паролем 520px. */
.employee-drawer-scrim {
position: fixed;
inset: 0;
z-index: 60;
background: rgba(0, 0, 0, 0.32);
border: 0;
background: rgba(0, 0, 0, 0.4);
cursor: default;
}
.employee-create-drawer {
@@ -12,78 +16,569 @@
right: 0;
bottom: 0;
z-index: 61;
display: flex;
width: 520px;
max-width: 94vw;
display: flex;
max-width: 100%;
flex-direction: column;
border-left: 1px solid var(--n-8);
background: var(--surface-card);
box-shadow: -8px 0 32px rgba(0, 0, 0, 0.12);
}
.employee-create-drawer > header,
.employee-create-drawer > footer,
.employee-create-drawer > footer > div {
display: flex;
align-items: center;
box-shadow: -12px 0 40px rgba(0, 0, 0, 0.18);
}
.employee-create-drawer > header {
height: 60px;
justify-content: space-between;
display: flex;
flex: none;
padding: 0 22px;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 18px 22px;
border-bottom: 1px solid var(--n-8);
}
.employee-create-drawer > header strong,
.employee-create-drawer > header span { display: block; }
.employee-create-drawer > header strong { color: var(--n-1); font-size: 16px; }
.employee-create-drawer > header span { margin-top: 1px; color: var(--n-4); font-size: 11.5px; }
.employee-create-drawer > header button { width: 34px; height: 34px; display: flex; align-items: center; justify-content: center; color: var(--n-4); background: transparent; border: 0; border-radius: 8px; }
.employee-create-drawer > header button:hover { color: var(--n-2); background: var(--n-9); }
.employee-create-drawer > header strong {
display: block;
color: var(--n-1);
font-size: 16px;
font-weight: 700;
}
.employee-create-body { flex: 1; overflow-y: auto; padding: 20px 22px 24px; }
.employee-create-step { margin-bottom: 22px; }
.employee-create-step:last-child { margin-bottom: 6px; }
.employee-create-step h3 { display: flex; align-items: center; gap: 9px; margin: 0 0 13px; color: var(--n-1); font-size: 13.5px; }
.employee-create-step h3 > i { width: 22px; height: 22px; display: flex; align-items: center; justify-content: center; color: #fff; background: var(--primary); border-radius: 50%; font-size: 11px; font-style: normal; }
.employee-create-step.muted h3 { color: var(--n-5); }
.employee-create-step.muted h3 > i { color: var(--n-5); background: var(--n-8); }
.employee-create-step > p { margin: 9px 0 0; color: var(--n-4); font-size: 11.5px; line-height: 1.45; }
.employee-create-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.employee-create-field.wide { grid-column: span 2; }
.employee-create-field > span { display: block; margin-bottom: 6px; color: var(--n-4); font-size: 12px; font-weight: 500; }
.employee-create-field input,
.employee-create-field select { width: 100%; height: 38px; padding: 0 12px; color: var(--n-2); background: var(--surface-card); border: 1px solid var(--n-7); border-radius: 8px; font-size: 13px; outline: none; }
.employee-create-field input:focus,
.employee-create-field select:focus { border-color: var(--primary); }
.employee-create-field .mono { font-family: "SF Mono", ui-monospace, Menlo, monospace; }
.employee-create-drawer > header span {
display: block;
margin-top: 3px;
color: var(--n-4);
font-size: 12.5px;
}
.employee-role-options { display: flex; flex-direction: column; gap: 9px; }
.employee-role-options > button { display: flex; align-items: flex-start; gap: 11px; padding: 12px 13px; color: var(--n-2); background: var(--surface-card); border: 1px solid var(--n-8); border-radius: 9px; text-align: left; }
.employee-role-options > button.active { background: var(--primary-bg); border-color: var(--primary-border); }
.employee-role-options > button > i { width: 17px; height: 17px; display: flex; align-items: center; justify-content: center; flex: none; margin-top: 1px; border: 1.6px solid var(--n-6); border-radius: 50%; }
.employee-role-options > button.active > i { border-color: var(--primary); }
.employee-role-options > button > i span { width: 7px; height: 7px; background: var(--primary); border-radius: 50%; }
.employee-role-options strong,
.employee-role-options small { display: block; }
.employee-role-options strong { font-size: 13px; }
.employee-role-options small { margin-top: 3px; color: var(--n-4); font-size: 11.5px; line-height: 1.45; }
.employee-create-drawer > header button {
display: flex;
width: 30px;
height: 30px;
align-items: center;
justify-content: center;
border: 0;
border-radius: 8px;
background: transparent;
color: var(--n-4);
cursor: pointer;
}
.employee-create-profiles { display: flex; flex-direction: column; gap: 8px; }
.employee-create-profiles > label { display: flex; align-items: center; gap: 8px; padding: 10px 12px; background: var(--surface-card); border: 1px solid var(--n-8); border-radius: 9px; }
.employee-create-profiles > label.selected { background: var(--primary-bg); border-color: var(--primary-border); }
.employee-create-profiles > label > button { min-width: 0; display: flex; align-items: center; flex: 1; gap: 11px; padding: 0; color: var(--n-2); background: transparent; border: 0; text-align: left; }
.employee-create-profiles > label > button > i { width: 16px; height: 16px; display: flex; align-items: center; justify-content: center; flex: none; color: var(--surface-card); background: var(--surface-card); border: 1.6px solid var(--n-6); border-radius: 5px; }
.employee-create-profiles > label.selected > button > i { background: var(--primary); border-color: var(--primary); }
.employee-create-profiles strong,
.employee-create-profiles small { display: block; }
.employee-create-profiles strong { display: flex; align-items: center; gap: 7px; font-size: 13px; }
.employee-create-profiles small { margin-top: 2px; overflow: hidden; color: var(--n-4); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
.employee-create-profiles select { height: 30px; padding: 0 8px; color: var(--n-3); background: var(--surface-card); border: 1px solid var(--n-7); border-radius: 7px; font-size: 11.5px; }
.employee-create-profiles > p { margin: 5px 0 0; padding: 10px 12px; color: var(--n-4); background: var(--n-10); border: 1px solid var(--n-8); border-radius: 8px; font-size: 11.5px; line-height: 1.45; }
.employee-create-drawer > header button:hover {
background: var(--n-9);
color: var(--n-2);
}
.employee-create-drawer > footer { justify-content: space-between; gap: 12px; flex: none; padding: 14px 22px; border-top: 1px solid var(--n-8); }
.employee-create-drawer > footer > span { color: var(--n-5); font-size: 11.5px; }
.employee-create-drawer > footer > div { gap: 9px; }
.employee-create-drawer > footer button { height: 38px; border-radius: 9px; }
.employee-create-body {
display: flex;
min-height: 0;
flex: 1;
flex-direction: column;
gap: 22px;
padding: 20px 22px;
overflow-y: auto;
}
.employee-create-step h3 {
display: flex;
align-items: center;
gap: 9px;
margin: 0 0 12px;
color: var(--n-1);
font-size: 13.5px;
font-weight: 700;
}
.employee-create-step h3 i {
display: inline-flex;
width: 20px;
height: 20px;
align-items: center;
justify-content: center;
border-radius: 50%;
background: var(--primary-bg);
color: var(--primary-text);
font-size: 11.5px;
font-style: normal;
font-weight: 700;
}
.employee-create-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px 16px;
}
.employee-create-field {
display: flex;
flex-direction: column;
gap: 6px;
}
.employee-create-field.wide {
grid-column: 1 / -1;
}
/* Отдельное поле вне сетки — «Должность» на шаге 3 (кадр E5). */
.employee-create-field.is-single {
margin-bottom: 12px;
}
.employee-create-field > span {
color: var(--n-3);
font-size: 12.5px;
font-weight: 600;
}
.employee-create-field input {
width: 100%;
height: 36px;
padding: 0 12px;
border: 1px solid var(--n-7);
border-radius: 8px;
outline: none;
background: var(--n-10);
color: var(--n-1);
font-family: inherit;
font-size: 13.5px;
}
.employee-create-field input.mono {
font-family: var(--font-mono);
font-size: 13px;
}
.employee-create-field input::placeholder {
color: var(--n-5);
}
.employee-create-field input:focus {
border-color: var(--primary);
background: var(--surface-card);
box-shadow: 0 0 0 3px var(--primary-bg);
}
/* Радио-варианты: роль и режим пароля. */
.employee-choice-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.employee-choice {
display: flex;
align-items: flex-start;
gap: 11px;
padding: 11px 13px;
border: 1px solid var(--n-7);
border-radius: 10px;
background: var(--surface-card);
font-family: inherit;
text-align: left;
cursor: pointer;
}
.employee-choice.is-on {
border-color: var(--primary);
background: var(--primary-bg);
}
.employee-choice.is-locked {
opacity: 0.55;
cursor: default;
}
.employee-choice > i {
display: inline-flex;
width: 16px;
height: 16px;
flex: none;
align-items: center;
justify-content: center;
margin-top: 1px;
border: 1.5px solid var(--n-6);
border-radius: 50%;
}
.employee-choice.is-on > i {
border-color: var(--primary);
}
.employee-choice > i span {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--primary);
}
.employee-choice > span {
min-width: 0;
}
.employee-choice strong {
display: flex;
align-items: center;
gap: 6px;
color: var(--n-1);
font-size: 13px;
font-weight: 600;
}
.employee-choice small {
display: block;
margin-top: 3px;
color: var(--n-4);
font-size: 12px;
line-height: 1.45;
}
/* Выбор групп — чекбоксы плитками. */
.employee-group-picks {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.employee-group-pick {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border: 1px solid var(--n-7);
border-radius: 9px;
background: var(--surface-card);
font-family: inherit;
text-align: left;
cursor: pointer;
}
.employee-group-pick.is-on {
border-color: var(--primary);
background: var(--primary-bg);
}
.employee-group-pick > i {
display: inline-flex;
width: 16px;
height: 16px;
flex: none;
align-items: center;
justify-content: center;
border: 1px solid var(--n-6);
border-radius: 5px;
color: #fff;
}
.employee-group-pick.is-on > i {
border-color: var(--primary);
background: var(--primary);
}
.employee-group-pick > span {
min-width: 0;
}
.employee-group-pick strong {
display: flex;
align-items: center;
gap: 6px;
color: var(--n-1);
font-size: 13px;
font-weight: 600;
}
.employee-group-dot {
width: 7px;
height: 7px;
flex: none;
border-radius: 50%;
}
.employee-group-pick small {
display: block;
margin-top: 2px;
color: var(--n-4);
font-size: 11.5px;
}
.employee-create-note {
margin: 10px 0 0;
color: var(--n-4);
font-size: 12px;
line-height: 1.5;
}
.employee-create-drawer > footer {
display: flex;
flex: none;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 22px;
border-top: 1px solid var(--n-8);
background: var(--surface-sidebar);
}
.employee-create-drawer > footer > span {
color: var(--n-4);
font-size: 12px;
}
.employee-create-drawer > footer > span.is-error {
color: var(--error-text);
}
.employee-create-drawer > footer > div {
display: flex;
gap: 8px;
}
.employee-create-drawer > footer button {
min-height: 0;
height: 34px;
padding: 0 14px;
border-radius: 8px;
box-shadow: none;
font-size: 13px;
font-weight: 600;
}
.employee-create-drawer > footer .secondary-button {
border-color: var(--n-7);
color: var(--n-2);
}
.employee-create-drawer > footer .primary-button {
padding: 0 16px;
}
/* --- Пароль первичного доступа (кадры E7/E8) --- */
.employee-dialog-scrim {
position: fixed;
inset: 0;
z-index: 70;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.45);
}
.employee-password-dialog {
width: 520px;
max-width: calc(100% - 32px);
overflow: hidden;
border-radius: 14px;
background: var(--surface-card);
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.32);
}
.employee-password-dialog > header {
display: flex;
align-items: flex-start;
gap: 14px;
padding: 20px 24px;
border-bottom: 1px solid var(--n-8);
}
.employee-password-dialog > header > i {
display: inline-flex;
width: 40px;
height: 40px;
flex: none;
align-items: center;
justify-content: center;
border-radius: 11px;
background: var(--primary-bg);
color: var(--primary-text);
}
.employee-password-dialog h2 {
margin: 0;
color: var(--n-1);
font-size: 17px;
font-weight: 700;
}
.employee-password-dialog > header p {
margin: 4px 0 0;
color: var(--n-4);
font-size: 12.5px;
line-height: 1.5;
}
.employee-password-body {
display: flex;
flex-direction: column;
gap: 14px;
padding: 20px 24px;
}
.employee-password-person {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 14px;
border: 1px solid var(--n-8);
border-radius: 10px;
background: var(--n-10);
}
.employee-password-person .avatar {
width: 32px;
height: 32px;
font-size: 12px;
}
.employee-password-person strong {
display: block;
color: var(--n-1);
font-size: 13px;
}
.employee-password-person small {
display: block;
color: var(--n-4);
font-family: var(--font-mono);
font-size: 11.5px;
}
.employee-password-field {
display: flex;
flex-direction: column;
gap: 6px;
}
.employee-password-field > span:first-child {
color: var(--n-3);
font-size: 12.5px;
font-weight: 600;
}
.employee-password-value {
display: flex;
align-items: center;
gap: 8px;
height: 44px;
padding: 0 8px 0 14px;
border: 1px solid var(--primary);
border-radius: 9px;
background: var(--primary-bg);
}
.employee-password-value code {
flex: 1;
color: var(--n-1);
font-family: var(--font-mono);
font-size: 16px;
font-weight: 600;
letter-spacing: 0.06em;
}
.employee-password-mask {
display: inline-flex;
width: 30px;
height: 30px;
flex: none;
align-items: center;
justify-content: center;
border: 0;
border-radius: 7px;
background: transparent;
color: var(--n-4);
cursor: pointer;
}
.employee-password-mask:hover {
background: var(--surface-card);
}
.employee-password-copy {
display: inline-flex;
align-items: center;
gap: 6px;
height: 30px;
flex: none;
padding: 0 11px;
border: 0;
border-radius: 7px;
background: var(--primary);
color: #fff;
font-family: inherit;
font-size: 12.5px;
font-weight: 600;
cursor: pointer;
}
.employee-password-warning {
display: flex;
gap: 10px;
padding: 12px 14px;
border: 1px solid var(--warning-border);
border-radius: 10px;
background: var(--warning-bg);
}
.employee-password-warning svg {
flex: none;
margin-top: 1px;
color: var(--warning-text);
}
.employee-password-warning p {
margin: 0;
color: var(--n-2);
font-size: 12.5px;
line-height: 1.5;
}
.employee-password-mail {
display: inline-flex;
align-self: flex-start;
align-items: center;
gap: 7px;
height: 32px;
padding: 0 12px;
border: 1px solid var(--n-7);
border-radius: 8px;
background: var(--surface-card);
color: var(--n-2);
font-family: inherit;
font-size: 12.5px;
font-weight: 600;
cursor: pointer;
}
.employee-password-mail:hover:not(:disabled) {
background: var(--n-9);
}
.employee-password-mail:disabled {
color: var(--n-5);
cursor: default;
}
.employee-password-dialog > footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 24px;
border-top: 1px solid var(--n-8);
background: var(--surface-sidebar);
}
.employee-password-dialog > footer small {
color: var(--n-4);
font-size: 12px;
}
.employee-dialog-primary {
min-height: 0;
height: 34px;
padding: 0 16px;
border-radius: 8px;
box-shadow: none;
font-size: 13px;
font-weight: 600;
}
@@ -1,165 +1,669 @@
.employee-detail-page {
width: 100%;
max-width: 1080px;
margin: 0 auto;
/* Карточка сотрудника (дизайн-базлайн v2, кадры E3/E4) и передача владения
(кадр E9). Значения — из макета. */
.employee-page {
display: flex;
max-width: 1160px;
flex-direction: column;
gap: 16px;
padding: 22px 32px 40px;
}
.employee-detail-header {
/* --- Шапка --- */
.employee-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
padding: 20px 22px;
margin-bottom: 16px;
background: var(--surface-card);
border: 1px solid var(--n-8);
border-radius: 12px;
box-shadow: var(--shadow-sm);
gap: 16px;
}
.employee-detail-main,
.employee-title > div,
.employee-header-actions {
.employee-head .avatar {
width: 60px;
height: 60px;
font-size: 19px;
font-weight: 600;
}
.employee-head-text {
flex: 1;
min-width: 0;
}
.employee-head-text > div {
display: flex;
align-items: center;
gap: 10px;
}
.employee-detail-main { min-width: 0; gap: 16px; }
.employee-detail-header .avatar { width: 56px; height: 56px; font-size: 19px; }
.employee-title { min-width: 0; }
.employee-title > div { gap: 11px; flex-wrap: wrap; }
.employee-title h1 { margin: 0; color: var(--n-1); font-size: 21px; font-weight: 700; }
.employee-title p { margin: 6px 0 0; color: var(--n-4); font-size: 12.5px; }
.employee-title-email { font-family: "SF Mono", ui-monospace, Menlo, monospace; }
.employee-header-actions { gap: 9px; flex: none; }
.employee-head-text h2 {
margin: 0;
color: var(--n-1);
font-size: 22px;
font-weight: 700;
letter-spacing: -0.01em;
}
.employee-detail-header .role-badge.owner { color: var(--ai); background: var(--ai-bg); }
.employee-detail-header .role-badge.admin { color: var(--primary-text); background: var(--primary-bg); }
.employee-detail-header .role-badge.employee { color: var(--n-3); background: var(--n-9); }
.employee-detail-header .status-pill.active { background: var(--success-bg) !important; border-color: var(--success-border) !important; }
.employee-detail-header .status-pill.blocked { background: var(--error-bg) !important; border-color: var(--error-border) !important; }
.employee-detail-header .status-pill.invited { background: var(--primary-bg) !important; border-color: var(--primary-border) !important; }
.employee-head-text p {
margin: 5px 0 0;
color: var(--n-4);
font-size: 13px;
}
.employee-head-text p span {
color: var(--n-2);
}
.employee-head-actions {
display: flex;
flex: none;
align-items: center;
gap: 8px;
}
.employee-head-actions button {
gap: 7px;
min-height: 0;
height: 34px;
padding: 0 14px;
border-radius: 8px;
box-shadow: none;
font-size: 13px;
font-weight: 600;
}
.employee-head-actions .secondary-button {
border-color: var(--n-7);
color: var(--n-2);
}
.employee-readonly-label {
height: 38px;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 0 13px;
color: var(--n-4);
background: var(--n-9);
border-radius: 9px;
font-size: 12.5px;
font-weight: 500;
}
.employee-readonly-banner {
padding: 13px 15px;
margin-bottom: 16px;
color: var(--n-3);
background: var(--warning-bg);
.employee-owner-banner {
display: flex;
align-items: center;
gap: 10px;
padding: 11px 14px;
border: 1px solid var(--warning-border);
border-radius: 10px;
background: var(--warning-bg);
}
.employee-owner-banner svg {
flex: none;
color: var(--warning-text);
}
.employee-owner-banner span {
color: var(--n-2);
font-size: 12.5px;
line-height: 1.5;
}
.employee-action-message {
padding: 9px 12px;
/* --- Две колонки --- */
.employee-grid {
display: grid;
grid-template-columns: minmax(0, 1.55fr) minmax(0, 1fr);
gap: 16px;
align-items: start;
}
.employee-column,
.employee-rail {
display: flex;
min-width: 0;
flex-direction: column;
gap: 16px;
}
.employee-card {
padding: 20px 24px;
border: 1px solid var(--n-8);
border-radius: 12px;
background: var(--surface-card);
}
.employee-card.is-rail {
padding: 18px 22px;
}
.employee-card h3 {
margin: 0 0 14px;
color: var(--primary-text);
background: var(--primary-bg);
border: 1px solid var(--primary-border);
border-radius: 9px;
color: var(--n-1);
font-size: 14px;
font-weight: 700;
}
/* С подзаголовком заголовок прижат к нему (кадр E3). */
.employee-card h3:has(+ p) {
margin-bottom: 4px;
}
.employee-card h3.is-tight,
.employee-card.is-rail > h3 {
margin-bottom: 12px;
}
.employee-card > p {
margin: 0 0 14px;
color: var(--n-4);
font-size: 12px;
line-height: 1.5;
}
/* --- Поля --- */
.employee-field-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px 18px;
}
.employee-field {
display: flex;
flex-direction: column;
gap: 6px;
}
.employee-field.is-wide {
grid-column: 1 / -1;
}
.employee-field.is-single {
margin-bottom: 14px;
}
.employee-field > span:first-child {
color: var(--n-3);
font-size: 12.5px;
font-weight: 600;
}
.employee-field input {
width: 100%;
height: 36px;
padding: 0 12px;
border: 1px solid var(--n-7);
border-radius: 8px;
outline: none;
background: var(--n-10);
color: var(--n-1);
font-family: inherit;
font-size: 13.5px;
}
.employee-field input.mono {
font-family: var(--font-mono);
font-size: 13px;
}
.employee-field.is-readonly input {
background: var(--n-9);
color: var(--n-4);
}
.employee-field input:focus {
border-color: var(--primary);
background: var(--surface-card);
box-shadow: 0 0 0 3px var(--primary-bg);
}
/* --- Статус и безопасность --- */
.employee-security-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 0;
border-top: 1px solid var(--n-9);
}
.employee-security-row.is-last {
padding-bottom: 0;
}
.employee-security-row strong {
display: block;
color: var(--n-1);
font-size: 13px;
}
.employee-security-row small {
display: block;
margin-top: 2px;
color: var(--n-4);
font-size: 12px;
}
.employee-security-action {
height: 32px;
flex: none;
padding: 0 12px;
border: 1px solid var(--n-7);
border-radius: 8px;
background: var(--surface-card);
color: var(--n-2);
font-family: inherit;
font-size: 12.5px;
font-weight: 600;
cursor: pointer;
}
.employee-security-action:hover:not(:disabled) {
background: var(--n-9);
}
.employee-security-action:disabled {
color: var(--n-5);
cursor: default;
}
/* --- Опасная зона --- */
.employee-danger {
padding: 18px 22px;
border: 1px solid var(--error-border);
border-radius: 12px;
background: var(--error-bg);
}
.employee-danger h3 {
margin: 0 0 12px;
color: var(--error-text);
font-size: 14px;
font-weight: 700;
}
.employee-danger > div {
padding: 12px 0;
border-top: 1px solid var(--error-border);
}
.employee-danger strong {
display: block;
margin-bottom: 5px;
color: var(--n-1);
font-size: 13px;
}
.employee-danger p {
margin: 0 0 12px;
color: var(--n-2);
font-size: 12.5px;
line-height: 1.5;
}
.employee-danger button {
display: inline-flex;
align-items: center;
gap: 7px;
height: 32px;
padding: 0 14px;
border: 1px solid var(--error);
border-radius: 8px;
background: transparent;
color: var(--error);
font-family: inherit;
font-size: 12.5px;
font-weight: 600;
cursor: pointer;
}
.employee-danger button:disabled {
opacity: 0.6;
cursor: default;
}
.employee-danger > div > small {
display: block;
margin-top: 8px;
color: var(--n-4);
font-size: 12px;
line-height: 1.45;
}
/* --- Правая колонка --- */
.employee-account-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 8px 0;
border-top: 1px solid var(--n-9);
}
.employee-account-row small {
color: var(--n-4);
font-size: 12.5px;
}
.employee-detail-grid { display: flex; align-items: flex-start; gap: 16px; }
.employee-detail-left { min-width: 0; display: flex; flex: 1; flex-direction: column; gap: 16px; }
.employee-detail-rail { width: 308px; display: flex; flex: none; flex-direction: column; gap: 16px; }
.employee-account-row span {
color: var(--n-1);
font-size: 13px;
font-weight: 500;
}
.employee-detail-card,
.employee-danger-card {
padding: 18px 20px;
.employee-audit-row {
display: flex;
gap: 10px;
padding: 8px 0;
}
.employee-audit-row > i {
width: 8px;
height: 8px;
flex: none;
margin-top: 5px;
border-radius: 50%;
}
.employee-audit-row > div {
min-width: 0;
}
.employee-audit-row strong {
display: block;
color: var(--n-1);
font-size: 12.5px;
font-weight: 600;
line-height: 1.4;
}
.employee-audit-row small {
display: block;
margin-top: 2px;
color: var(--n-4);
font-family: var(--font-mono);
font-size: 11px;
}
/* --- Передача владения (кадр E9) --- */
.employee-transfer-dialog {
width: 560px;
max-width: calc(100% - 32px);
overflow: hidden;
border-radius: 14px;
background: var(--surface-card);
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.32);
}
.employee-transfer-dialog > header {
display: flex;
align-items: flex-start;
gap: 14px;
padding: 20px 24px;
border-bottom: 1px solid var(--n-8);
}
.employee-transfer-dialog > header > i {
display: inline-flex;
width: 40px;
height: 40px;
flex: none;
align-items: center;
justify-content: center;
border-radius: 11px;
background: var(--warning-bg);
color: var(--warning-text);
}
.employee-transfer-dialog h2 {
margin: 0;
color: var(--n-1);
font-size: 17px;
font-weight: 700;
}
.employee-transfer-dialog > header p {
margin: 4px 0 0;
color: var(--n-4);
font-size: 12.5px;
}
.employee-transfer-body {
display: flex;
flex-direction: column;
gap: 16px;
padding: 20px 24px;
}
.employee-transfer-body .employee-field.is-single {
margin-bottom: 0;
}
.employee-transfer-select {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
height: 36px;
padding: 0 12px;
border: 1px solid var(--n-7);
border-radius: 8px;
background: var(--n-10);
color: var(--n-1);
font-size: 13.5px;
}
.employee-transfer-select select {
flex: 1;
height: 34px;
padding: 0;
border: 0;
outline: none;
background: transparent;
color: inherit;
font-family: inherit;
font-size: 13.5px;
appearance: none;
cursor: pointer;
}
.employee-transfer-select > svg {
flex: none;
color: var(--n-4);
}
.employee-transfer-note {
color: var(--n-4);
font-size: 12px;
line-height: 1.45;
}
.employee-transfer-pair {
display: flex;
align-items: center;
gap: 16px;
padding: 14px;
border: 1px solid var(--n-8);
border-radius: 12px;
box-shadow: var(--shadow-sm);
border-radius: 10px;
background: var(--n-10);
}
.employee-detail-card.compact { padding: 16px 18px; }
.employee-detail-card h3,
.employee-danger-card h3 { margin: 0 0 16px; color: var(--n-1); font-size: 14px; font-weight: 700; }
.employee-detail-card.compact h3 { margin-bottom: 13px; }
.employee-detail-rail .key-value { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 7px 0; }
.employee-detail-rail .key-value > span { color: var(--n-4); font-size: 12.5px; }
.employee-detail-rail .key-value > strong { color: var(--n-2); font-size: 12.5px; font-weight: 600; text-align: right; }
.employee-detail-rail .status-pill { font-size: 12.5px; }
.employee-form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
.employee-placement-card > p,
.employee-role-card > p,
.employee-access-card > p { margin: -12px 0 15px; color: var(--n-5); font-size: 12px; }
.employee-placement-card > small { display: block; padding: 9px 11px; margin-top: 11px; color: var(--n-4); background: var(--n-10); border: 1px solid var(--n-8); border-radius: 8px; font-size: 11.5px; }
.employee-role-card > div { display: flex; flex-direction: column; gap: 9px; }
.employee-role-card > div > button { display: flex; align-items: flex-start; gap: 11px; padding: 12px 13px; color: var(--n-2); background: var(--surface-card); border: 1px solid var(--n-8); border-radius: 9px; text-align: left; }
.employee-role-card > div > button.active { background: var(--primary-bg); border-color: var(--primary-border); }
.employee-role-card > div > button > i { width: 17px; height: 17px; display: flex; align-items: center; justify-content: center; flex: none; margin-top: 1px; border: 1.6px solid var(--n-6); border-radius: 50%; }
.employee-role-card > div > button.active > i { background: var(--primary); border-color: var(--primary); }
.employee-role-card > div > button > i span { width: 7px; height: 7px; background: var(--surface-card); border-radius: 50%; }
.employee-role-card strong { display: flex; align-items: center; gap: 8px; font-size: 13px; }
.employee-role-card small { display: block; margin-top: 3px; color: var(--n-4); font-size: 11.5px; line-height: 1.45; }
.employee-access-card > header { display: flex; align-items: center; justify-content: space-between; }
.employee-assign-button { height: 32px; display: inline-flex; align-items: center; gap: 6px; padding: 0 12px; color: var(--n-3); background: var(--surface-card); border: 1px solid var(--n-6); border-radius: 8px; font-size: 12.5px; font-weight: 500; }
.employee-assign-button:hover { color: var(--primary); border-color: var(--primary); }
.employee-assignment-list { display: flex; flex-direction: column; gap: 10px; }
.employee-assignment-list article { padding: 12px 14px; border: 1px solid var(--n-8); border-radius: 10px; }
.employee-assignment-list article > header,
.employee-assignment-list article > header > div { display: flex; align-items: center; justify-content: space-between; gap: 9px; }
.employee-assignment-list article > header > div:first-child > i { width: 30px; height: 30px; display: flex; align-items: center; justify-content: center; color: var(--ai); background: var(--ai-bg); border-radius: 8px; }
.employee-assignment-list strong,
.employee-assignment-list small { display: block; }
.employee-assignment-list strong { font-size: 13px; }
.employee-assignment-list small { margin-top: 2px; color: var(--n-4); font-size: 11.5px; }
.employee-assignment-list b { padding: 3px 9px; border-radius: 6px; font-size: 10.5px; letter-spacing: 0.03em; }
.employee-assignment-list b.organization { color: var(--ai); background: var(--ai-bg); }
.employee-assignment-list b.department { color: var(--primary-text); background: var(--primary-bg); }
.employee-assignment-list header button { width: 28px; height: 28px; display: flex; align-items: center; justify-content: center; color: var(--n-5); background: transparent; border: 0; border-radius: 7px; }
.employee-assignment-list header button:hover { color: var(--error-text); background: var(--error-bg); }
.employee-assignment-list footer { display: flex; flex-wrap: wrap; gap: 6px; padding-top: 10px; margin-top: 10px; border-top: 1px solid var(--n-9); }
.employee-assignment-list footer code { padding: 2px 8px; color: var(--n-3); background: var(--n-9); border-radius: 6px; font-size: 11px; }
.employee-no-access,
.employee-admin-access-note { display: flex; align-items: flex-start; gap: 10px; padding: 14px 15px; border-radius: 10px; }
.employee-no-access { color: var(--error-text); background: var(--error-bg); border: 1px dashed var(--error-border); }
.employee-admin-access-note { color: var(--primary); background: var(--primary-bg); border: 1px solid var(--primary-border); }
.employee-no-access p,
.employee-admin-access-note p { margin: 0; color: var(--n-3); font-size: 12.5px; line-height: 1.5; }
.employee-no-access strong { color: var(--error-text); }
.security-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 14px 0; border-bottom: 1px solid var(--n-9); }
.security-row.last { padding-bottom: 4px; border-bottom: 0; }
.security-row strong,
.security-row span { display: block; }
.security-row strong { color: var(--n-2); font-size: 13px; font-weight: 500; }
.security-row span { margin-top: 2px; color: var(--n-4); font-size: 11.5px; }
.employee-danger-card { border-color: var(--error-border); }
.employee-danger-card h3 { margin-bottom: 4px; color: var(--error-text); }
.employee-danger-card > div { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-top: 8px; }
.employee-danger-card p { margin: 0; color: var(--n-3); font-size: 12.5px; line-height: 1.5; }
.employee-danger-action { height: 38px; flex: none; font-weight: 600; }
.employee-activity-list > div { display: flex; gap: 10px; padding: 8px 0; border-bottom: 1px solid var(--n-9); }
.employee-activity-list > div > span { width: 6px; height: 6px; flex: none; margin-top: 5px; border-radius: 50%; }
.employee-activity-list p { margin: 0; }
.employee-activity-list strong,
.employee-activity-list small { display: block; }
.employee-activity-list strong { color: var(--n-2); font-size: 12.5px; font-weight: 500; }
.employee-activity-list small { margin-top: 1px; color: var(--n-5); font-family: "SF Mono", ui-monospace, Menlo, monospace; font-size: 10.5px; }
@media (max-width: 1120px) {
.employee-detail-grid { flex-direction: column; }
.employee-detail-rail { width: 100%; display: grid; grid-template-columns: 1fr 1fr; }
.employee-transfer-pair > div {
display: flex;
flex: 1;
align-items: center;
gap: 10px;
min-width: 0;
}
.employee-transfer-pair .avatar {
width: 34px;
height: 34px;
font-size: 12.5px;
}
.employee-transfer-pair strong {
display: block;
color: var(--n-1);
font-size: 13px;
}
.employee-transfer-pair small {
display: block;
color: var(--n-4);
font-size: 11.5px;
}
.employee-transfer-pair small.is-accent {
color: var(--primary-text);
font-weight: 600;
}
.employee-transfer-pair > svg {
flex: none;
color: var(--n-4);
}
.employee-transfer-role > span {
display: block;
margin-bottom: 8px;
color: var(--n-3);
font-size: 12.5px;
font-weight: 600;
}
.employee-transfer-role > div {
display: flex;
gap: 8px;
}
.employee-transfer-role-option {
display: flex;
flex: 1;
align-items: center;
gap: 9px;
padding: 10px 12px;
border: 1px solid var(--n-7);
border-radius: 9px;
background: var(--surface-card);
color: var(--n-2);
font-family: inherit;
font-size: 13px;
font-weight: 500;
cursor: pointer;
}
.employee-transfer-role-option.is-on {
border-color: var(--primary);
background: var(--primary-bg);
color: var(--n-1);
font-weight: 600;
}
.employee-transfer-role-option > i {
display: inline-flex;
width: 15px;
height: 15px;
flex: none;
align-items: center;
justify-content: center;
border: 1.5px solid var(--n-6);
border-radius: 50%;
}
.employee-transfer-role-option.is-on > i {
border-color: var(--primary);
}
.employee-transfer-role-option > i span {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--primary);
}
.employee-transfer-confirm {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 0;
border: 0;
background: transparent;
font-family: inherit;
text-align: left;
cursor: pointer;
}
.employee-transfer-confirm > i {
display: inline-flex;
width: 17px;
height: 17px;
flex: none;
align-items: center;
justify-content: center;
margin-top: 1px;
border: 1px solid var(--n-6);
border-radius: 5px;
color: #fff;
}
.employee-transfer-confirm[aria-pressed="true"] > i {
border-color: var(--primary);
background: var(--primary);
}
.employee-transfer-confirm span {
color: var(--n-2);
font-size: 12.5px;
line-height: 1.5;
}
.employee-transfer-dialog > footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 14px 24px;
border-top: 1px solid var(--n-8);
background: var(--surface-sidebar);
}
.employee-transfer-dialog > footer button {
min-height: 0;
height: 34px;
padding: 0 14px;
border-radius: 8px;
box-shadow: none;
font-family: inherit;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.employee-transfer-dialog > footer .secondary-button {
border-color: var(--n-7);
color: var(--n-2);
}
.employee-transfer-submit {
padding: 0 16px;
border: 0;
background: var(--error);
color: #fff;
}
.employee-transfer-submit:disabled {
opacity: 0.55;
cursor: default;
}
@media (max-width: 1100px) {
.employee-grid {
grid-template-columns: minmax(0, 1fr);
}
.employee-page {
padding-right: 20px;
padding-left: 20px;
}
}
@@ -1,41 +1,368 @@
.employee-page-actions {
/* Список сотрудников (дизайн-базлайн v2, «Сотрудники Baseline», кадры E1/E2).
Значения — из макета: строки 62px, бейджи 22px, фильтры 34px. */
.hub-scroll.employees-scroll {
padding: 0;
background: var(--surface-feed);
}
/* Колонка контента — как в макете (кадр 1440px минус боковая панель 220px):
лист держится читаемой ширины и центрируется, фон раздела остаётся на всю
ширину окна. */
.hub-page.employees-page-shell {
width: 100%;
max-width: 1220px;
margin: 0 auto;
}
.employees-page {
display: flex;
flex-direction: column;
gap: 16px;
padding: 26px 32px 40px;
}
/* --- Шапка --- */
.employees-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.employees-header h2 {
margin: 0;
color: var(--n-1);
font-size: 20px;
font-weight: 700;
letter-spacing: -0.01em;
}
.employees-header p {
margin: 5px 0 0;
color: var(--n-4);
font-size: 13px;
}
.employees-header p b {
color: var(--n-2);
}
.employees-page .employees-create {
flex: none;
justify-content: normal;
gap: 7px;
min-height: 0;
height: 34px;
padding: 0 14px;
border-radius: 8px;
box-shadow: none;
font-size: 13px;
font-weight: 600;
}
/* --- Фильтры --- */
.employees-filters {
display: flex;
align-items: center;
gap: 14px;
}
.employees-page .employees-search {
width: 300px;
}
.employees-filter-group {
display: flex;
align-items: center;
gap: 8px;
}
.employees-filter-group > span:first-child {
color: var(--n-4);
font-size: 12.5px;
}
.employees-filters-spacer {
flex: 1;
}
.employees-filters .segmented {
align-items: normal;
gap: normal;
padding: 3px;
border-radius: 9px;
background: var(--n-9);
}
.employees-filters .segmented button {
height: 28px;
padding: 0 11px;
border-radius: 7px;
font-weight: 600;
}
.employees-filters .segmented button.active {
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
}
/* Фильтр «Группа» — общий селект списка, ширина из макета. */
.employees-group-filter {
min-width: 120px;
justify-content: space-between;
padding: 0 10px;
}
.employees-reset {
height: 34px;
padding: 0 10px;
border: 0;
border-radius: 8px;
background: transparent;
color: var(--n-4);
font-family: inherit;
font-size: 13px;
font-weight: 500;
cursor: pointer;
}
.employees-reset.is-dirty {
color: var(--primary-text);
}
.employees-reset:hover {
background: var(--n-9);
}
.employees-error {
display: flex;
align-items: center;
gap: 9px;
padding: 11px 14px;
border: 1px solid var(--error-border);
border-radius: 10px;
background: var(--error-bg);
color: var(--error-text);
font-size: 12.5px;
}
.employee-page-actions .secondary-button,
.employee-page-actions .primary-button {
height: 38px;
border-radius: 9px;
}
/* --- Таблица --- */
.employees-table {
min-width: 1040px;
overflow: hidden;
background: var(--surface-card);
border: 1px solid var(--n-8);
border-radius: 12px;
}
.employees-thead,
.employees-row {
display: grid;
grid-template-columns:
minmax(0, 1.9fr) minmax(0, 1fr) minmax(0, 1.1fr) minmax(0, 1.5fr)
minmax(0, 0.9fr) minmax(0, 1fr) minmax(0, 1fr) 44px;
gap: 14px;
align-items: center;
}
.employees-thead {
padding: 10px 20px;
border-bottom: 1px solid var(--n-8);
background: var(--surface-sidebar);
}
.employees-thead span {
color: var(--n-4);
font-size: 11px;
font-weight: 600;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.employees-row {
height: 62px;
padding: 0 20px;
border: 0;
border-bottom: 1px solid var(--n-9);
background: transparent;
text-align: left;
cursor: pointer;
}
.employees-row:hover {
background: var(--n-10);
}
.employees-row.is-blocked {
opacity: 0.6;
}
.employees-person {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.employees-table .avatar {
width: 34px;
height: 34px;
width: 36px;
height: 36px;
font-size: 12.5px;
font-weight: 600;
}
.employees-person > span {
min-width: 0;
}
.employees-person strong {
display: block;
overflow: hidden;
color: var(--n-1);
font-size: 13.5px;
font-weight: 600;
white-space: nowrap;
text-overflow: ellipsis;
}
.employees-person small {
display: block;
margin-top: 2px;
overflow: hidden;
color: var(--n-4);
font-size: 11.5px;
white-space: nowrap;
text-overflow: ellipsis;
}
/* Бейджи роли и статуса — одна форма, цвета приходят данными. */
.employees-badge {
display: inline-flex;
align-items: center;
height: 22px;
padding: 0 8px;
border-radius: 6px;
font-size: 11.5px;
font-weight: 600;
white-space: nowrap;
justify-self: start;
}
.employees-badge.has-dot {
gap: 6px;
}
.employees-badge i {
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
}
.employees-position {
overflow: hidden;
color: var(--n-2);
font-size: 13px;
white-space: nowrap;
text-overflow: ellipsis;
}
.employees-groups {
display: flex;
flex-wrap: wrap;
gap: 5px;
min-width: 0;
}
.employees-group {
display: inline-flex;
align-items: center;
gap: 5px;
height: 22px;
padding: 0 8px;
border-radius: 6px;
background: var(--n-9);
color: var(--n-2);
font-size: 11.5px;
font-weight: 500;
white-space: nowrap;
}
.employees-group i {
width: 7px;
height: 7px;
border-radius: 50%;
}
.employees-nogroup {
color: var(--n-5);
font-size: 12.5px;
}
.employees-access {
color: var(--n-2);
font-size: 12.5px;
white-space: nowrap;
}
.employees-access.is-limited {
color: var(--n-4);
}
.employees-login {
color: var(--n-4);
font-size: 12.5px;
white-space: nowrap;
}
.employees-row-menu {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
border: 0;
border-radius: 7px;
background: transparent;
color: var(--n-4);
cursor: pointer;
justify-self: end;
}
.employees-row-menu.is-open,
.employees-row-menu:hover {
background: var(--n-9);
color: var(--n-2);
}
.employees-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 11px 20px;
background: var(--surface-sidebar);
}
.employees-foot small {
color: var(--n-4);
font-size: 12px;
}
.employees-table .role-badge.owner {
color: var(--error-text);
background: var(--error-bg);
}
@media (max-width: 1200px) {
.employees-page {
padding-right: 20px;
padding-left: 20px;
}
.employees-table .role-badge.admin {
color: var(--primary-text);
background: var(--primary-bg);
}
.employees-table {
overflow-x: auto;
}
.employees-table .role-badge.employee {
color: var(--n-3);
background: var(--n-8);
}
.employee-last-login {
color: var(--n-3) !important;
white-space: nowrap;
.employees-thead,
.employees-row {
min-width: 1040px;
}
}
@@ -1,35 +0,0 @@
.ownership-modal-scrim { position: fixed; inset: 0; z-index: 70; display: flex; align-items: center; justify-content: center; padding: 24px; background: rgba(0, 0, 0, 0.32); }
.ownership-modal { width: 560px; max-width: 100%; max-height: 90vh; overflow-y: auto; background: var(--surface-card); border-radius: 14px; box-shadow: var(--shadow-lg); }
.ownership-modal > header { display: flex; align-items: center; gap: 12px; padding: 22px 24px 0; }
.ownership-modal > header > i { width: 42px; height: 42px; display: flex; align-items: center; justify-content: center; flex: none; color: var(--warning-text); background: var(--warning-bg-strong); border-radius: 11px; }
.ownership-modal h2 { margin: 0; color: var(--n-1); font-size: 18px; }
.ownership-modal header p { margin: 3px 0 0; color: var(--n-4); font-size: 12.5px; }
.ownership-modal-body { padding: 20px 24px; }
.ownership-select > span,
.ownership-label { display: block; margin-bottom: 7px; color: var(--n-4); font-size: 12px; font-weight: 500; }
.ownership-select select { width: 100%; height: 40px; padding: 0 12px; margin-bottom: 16px; color: var(--n-2); background: var(--surface-card); border: 1px solid var(--n-7); border-radius: 9px; font-size: 13px; }
.ownership-transition { display: flex; align-items: center; gap: 14px; padding: 14px 16px; margin-bottom: 18px; background: var(--n-10); border: 1px solid var(--n-8); border-radius: 10px; }
.ownership-transition > div { display: flex; align-items: center; gap: 9px; flex: 1; }
.ownership-transition strong,
.ownership-transition small { display: block; }
.ownership-transition strong { color: var(--n-2); font-size: 12.5px; }
.ownership-transition small { width: max-content; padding: 1px 7px; margin-top: 2px; border-radius: 5px; font-size: 10px; }
.ownership-transition > div:first-child small { color: var(--n-3); background: var(--n-8); }
.ownership-transition > div:last-child small { color: var(--success-text); background: var(--success-bg); }
.ownership-role-options { display: flex; gap: 9px; margin-bottom: 16px; }
.ownership-role-options button { display: flex; align-items: center; flex: 1; gap: 9px; padding: 11px 13px; color: var(--n-2); background: var(--surface-card); border: 1px solid var(--n-8); border-radius: 9px; font-size: 13px; font-weight: 600; }
.ownership-role-options button.active { background: var(--primary-bg); border-color: var(--primary-border); }
.ownership-role-options i { width: 16px; height: 16px; display: flex; align-items: center; justify-content: center; border: 1.6px solid var(--n-6); border-radius: 50%; }
.ownership-role-options button.active i { border-color: var(--primary); }
.ownership-role-options i span { width: 7px; height: 7px; background: var(--primary); border-radius: 50%; }
.ownership-warning { display: flex; align-items: flex-start; gap: 10px; padding: 13px 15px; margin-bottom: 16px; color: var(--warning-text); background: var(--warning-bg); border: 1px solid var(--warning-border); border-radius: 10px; }
.ownership-warning p { margin: 0; color: var(--n-3); font-size: 12px; line-height: 1.5; }
.ownership-confirm { display: flex; align-items: flex-start; gap: 10px; padding: 0; color: var(--n-3); background: transparent; border: 0; text-align: left; }
.ownership-confirm i { width: 18px; height: 18px; display: flex; align-items: center; justify-content: center; flex: none; color: var(--surface-card); background: var(--surface-card); border: 1.6px solid var(--n-6); border-radius: 5px; }
.ownership-confirm.checked i { background: var(--primary); border-color: var(--primary); }
.ownership-confirm span { font-size: 12.5px; line-height: 1.45; }
.ownership-error { margin: 12px 0 0; color: var(--error-text); font-size: 12px; }
.ownership-modal > footer { display: flex; justify-content: flex-end; gap: 9px; padding: 14px 24px; border-top: 1px solid var(--n-8); }
.ownership-modal > footer button { height: 40px; border-radius: 9px; }
.ownership-submit { padding: 0 18px; color: #fff; background: var(--error-text); border: 0; font-size: 13px; font-weight: 600; box-shadow: 0 1px 2px color-mix(in srgb, var(--error-text) 30%, transparent); }
.ownership-submit:disabled { color: var(--n-5); background: var(--n-9); box-shadow: none; }
@@ -1,5 +1,3 @@
@import "./styles-list.css";
@import "./styles-detail.css";
@import "./styles-create.css";
@import "./styles-access.css";
@import "./styles-ownership.css";
@@ -1,3 +1,5 @@
import { agentColorOf } from "../conversations/model";
import { Icon } from "../../shared/icons";
import { PROVIDERS, type Integration } from "./model";
import { ConnectionIcon, RowActions, StatusCell } from "./rows";
@@ -28,7 +30,7 @@ export function ConnectionsTable({ items, testingId, onTest, onEdit, onToggleAct
<thead>
<tr>
<th>НАЗВАНИЕ</th>
<th>КАНАЛ ОБРАБОТКИ</th>
<th>АГЕНТ</th>
<th>СТАТУС</th>
<th />
</tr>
@@ -42,7 +44,15 @@ export function ConnectionsTable({ items, testingId, onTest, onEdit, onToggleAct
<span><strong>{item.name}</strong><small>{subtitle(item)}</small></span>
</div>
</td>
<td>{item.channel ? item.channel.name : <span className="product-empty-value">—</span>}</td>
<td>
{item.channel
? (
<span className="integration-agent" style={{ color: agentColorOf(item.channel.id) }}>
<Icon name="robot" size={14} />{item.channel.name}
</span>
)
: <span className="product-empty-value">—</span>}
</td>
<td><StatusCell integration={item} /></td>
<td className="row-actions">
<RowActions integration={item} testing={testingId === item.id} onTest={onTest} onEdit={onEdit} onToggleActive={onToggleActive} onDelete={onDelete} />
@@ -1,67 +1,48 @@
import { Modal } from "antd";
import { useCallback, useEffect, useState } from "react";
import { useState } from "react";
import { api } from "../../api/client";
import { EmptyState, LoadingState } from "../../shared/ui";
import { EmptyState } from "../../shared/ui";
import { Button } from "../../shared/ui-controls";
import { ConnectionsTable } from "./ConnectionsTable";
import { IntegrationForm } from "./IntegrationForm";
import type { Integration, IntegrationKind } from "./model";
import { ProvidersTable } from "./ProvidersTable";
// Секция интеграций одного рода — блок экрана «Настройки» (SPEC-HUB-0031
// §8.6): подключения (MESSENGER) и AI-провайдеры (LLM_PROVIDER) — отдельные
// вертикальные секции, отдельный раздел «Интеграции» упразднён.
// Таблица интеграций одного рода — раздел экрана «Настройки» (дизайн-базлайн v2,
// кадры N3/N4): подключения (MESSENGER) и AI-провайдеры (LLM_PROVIDER) — два
// раздела субменю. Список грузит страница настроек (счётчики в субменю), форма
// создания открывается primary-кнопкой в шапке раздела.
export function IntegrationsSection({ kind }: { kind: IntegrationKind }) {
const [items, setItems] = useState<Integration[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
const [form, setForm] = useState<{ initial: Integration | null } | null>(null);
export function IntegrationsSection({ kind, items, reload, onEdit }: {
kind: IntegrationKind;
items: Integration[];
reload: () => void;
onEdit: (integration: Integration) => void;
}) {
const [testingId, setTestingId] = useState<number | null>(null);
const [deleting, setDeleting] = useState<Integration | null>(null);
const [deletingError, setDeletingError] = useState<string | null>(null);
const load = useCallback(async () => {
setLoading(true);
setError(false);
try {
const payload = await api<{ items: Integration[] }>("/api/v1/integrations/");
setItems(payload.items.filter((item) => item.kind === kind));
} catch {
setError(true);
} finally {
setLoading(false);
}
}, [kind]);
useEffect(() => {
void load();
}, [load]);
async function test(integration: Integration) {
setTestingId(integration.id);
try {
const payload = await api<{ integration: Integration }>(`/api/v1/integrations/${integration.id}/test/`, { method: "POST" });
setItems((current) => current.map((item) => (item.id === integration.id ? payload.integration : item)));
await api(`/api/v1/integrations/${integration.id}/test/`, { method: "POST" });
} catch {
void load();
/* статус придёт из перезагрузки списка */
} finally {
setTestingId(null);
reload();
}
}
async function toggleActive(integration: Integration) {
try {
const payload = await api<{ integration: Integration }>(`/api/v1/integrations/${integration.id}/`, {
await api(`/api/v1/integrations/${integration.id}/`, {
method: "PATCH",
body: JSON.stringify({ isActive: !integration.isActive }),
});
setItems((current) => current.map((item) => (
item.id === integration.id ? payload.integration : item
)));
} catch {
void load();
} finally {
reload();
}
}
@@ -70,7 +51,7 @@ export function IntegrationsSection({ kind }: { kind: IntegrationKind }) {
try {
await api(`/api/v1/integrations/${deleting.id}/`, { method: "DELETE" });
setDeleting(null);
void load();
reload();
} catch (caught) {
setDeletingError(caught instanceof Error ? caught.message : "Не удалось удалить");
}
@@ -80,21 +61,13 @@ export function IntegrationsSection({ kind }: { kind: IntegrationKind }) {
const rowHandlers = {
testingId,
onTest: test,
onEdit: (item: Integration) => setForm({ initial: item }),
onEdit,
onToggleActive: toggleActive,
onDelete: (item: Integration) => { setDeletingError(null); setDeleting(item); },
};
if (loading) return <LoadingState />;
if (error) return <EmptyState title="Не удалось загрузить интеграции" />;
return (
<div className="integrations-section">
<div className="integrations-section-actions">
<Button variant="primary" icon="plus" onClick={() => setForm({ initial: null })}>
{isConnections ? "Добавить подключение" : "Добавить провайдера"}
</Button>
</div>
{items.length === 0 ? (
<EmptyState title={isConnections ? "Подключений пока нет. Добавьте бота, почту или Web-виджет." : "Провайдеров пока нет. Добавьте OpenRouter или Custom endpoint."} />
) : isConnections ? (
@@ -102,13 +75,8 @@ export function IntegrationsSection({ kind }: { kind: IntegrationKind }) {
) : (
<ProvidersTable items={items} {...rowHandlers} />
)}
{form && (
<IntegrationForm
initial={form.initial}
kind={kind}
onClose={() => setForm(null)}
onSaved={() => { setForm(null); void load(); }}
/>
{isConnections && items.length > 0 && (
<p className="settings-section-note">Код вставки Web-виджета копируется на карточке его агента.</p>
)}
{deleting && (
<Modal open title="Удалить интеграцию?" onCancel={() => setDeleting(null)} footer={null} destroyOnClose>
@@ -34,7 +34,7 @@ export function ProvidersTable({ items, testingId, onTest, onEdit, onToggleActiv
<span><strong>{item.name}</strong><small>{meta.label}</small></span>
</div>
</td>
<td>{item.hasSecret ? <code className="ai-mono">••••••••</code> : <span className="product-empty-value">—</span>}</td>
<td>{item.hasSecret ? <code className="ai-mono">{item.secretMasked || "••••••••"}</code> : <span className="product-empty-value">—</span>}</td>
<td className="integration-config">
<span>{item.config.baseUrl || meta.defaultBaseUrl || "—"}</span>
{item.config.defaultModel && <small>{item.config.defaultModel}</small>}
@@ -19,6 +19,8 @@ export type Integration = {
provider: IntegrationProvider;
name: string;
hasSecret: boolean;
// Публичный префикс ключа + маска («sk-or-••••••••») для колонки «Секрет».
secretMasked: string;
isActive: boolean;
// purpose="notifications" — сервисный бот уведомлений сотрудников (не привязан к каналу продаж).
// email/imap*/smtp* — Email-подключение (SPEC-HUB-0025 §3.1).
@@ -3,6 +3,7 @@ import { useState } from "react";
import { Icon, MaxLogo, TelegramLogo } from "../../shared/icons";
import { Button, ToneBadge } from "../../shared/ui-controls";
import { shortDateTime } from "../../shared/utils";
import { PROVIDERS, STATUS_META, type Integration, type IntegrationProvider } from "./model";
// Плитка подключения: фирменная марка на фирменном фоне (SPEC-HUB-0025 §2.3).
@@ -16,20 +17,28 @@ const TILE_CLASS: Partial<Record<IntegrationProvider, string>> = {
export function ConnectionIcon({ provider }: { provider: IntegrationProvider }) {
const tile = TILE_CLASS[provider];
if (!tile) return <span className="product-icon"><Icon name="robot" size={20} /></span>;
// AI-провайдеры (кадр N3): знак «искры» на подложке цвета AI, у остальных —
// нейтральная плитка.
if (!tile) {
return (
<span className={`product-icon integration-tile ${provider === "OPENROUTER" ? "integration-tile--ai" : "integration-tile--neutral"}`}>
<Icon name="sparkles" size={20} strokeWidth={1.9} />
</span>
);
}
return (
<span className={`product-icon integration-tile ${tile}`}>
{provider === "TELEGRAM" && <TelegramLogo size={24} />}
{provider === "MAX" && <MaxLogo size={24} />}
{provider === "WEB" && <Icon name="message" size={22} />}
{provider === "EMAIL" && <Icon name="mail" size={21} />}
{provider === "TELEGRAM" && <TelegramLogo size={20} />}
{provider === "MAX" && <MaxLogo size={20} />}
{provider === "WEB" && <Icon name="message" size={20} strokeWidth={1.9} />}
{provider === "EMAIL" && <Icon name="mail" size={20} strokeWidth={1.9} />}
</span>
);
}
export function formatChecked(value: string | null): string {
if (!value) return "ещё не проверялось";
return new Date(value).toLocaleString("ru-RU", { day: "2-digit", month: "short", hour: "2-digit", minute: "2-digit" });
return shortDateTime(value);
}
export function StatusCell({ integration }: { integration: Integration }) {
@@ -78,7 +87,7 @@ export function RowActions({ integration, testing, onTest, onEdit, onToggleActiv
];
return (
<div className="ai-row-actions">
<Button variant="secondary" icon="refresh" iconSize={14} disabled={!meta.checkable || testing} onClick={() => onTest(integration)}>
<Button variant="secondary" icon="refresh" iconSize={13} disabled={!meta.checkable || testing} onClick={() => onTest(integration)}>
{testing ? "Проверка…" : "Проверить"}
</Button>
<Dropdown menu={{ items: menuItems }} open={open} onOpenChange={setOpen} trigger={["click"]} overlayClassName="app-dropdown">
@@ -52,24 +52,42 @@
/* Плитки подключений: белая фирменная марка на фирменном фоне (SPEC-HUB-0025 §2.3).
Цвета — из семейства channel-* дизайн-системы; Web — цвет кнопки-лончера виджета. */
.integrations-page .integration-tile {
color: #ffffff;
/* Плитка подключения (кадры N3/N4): 36×36, радиус 9, подложка — марка
в 14%, сам глиф — цветом марки. */
.integration-tile {
width: 36px;
height: 36px;
border-radius: 9px;
}
.integrations-page .integration-tile--telegram {
background: #2f8fd0;
.integration-tile--telegram {
color: #229ed9;
background: color-mix(in srgb, #229ed9 14%, var(--surface-card));
}
.integrations-page .integration-tile--max {
background: #6b5be0;
.integration-tile--max {
color: #5b5bd6;
background: color-mix(in srgb, #5b5bd6 14%, var(--surface-card));
}
.integrations-page .integration-tile--web {
background: var(--primary);
.integration-tile--web {
color: var(--primary-text);
background: var(--primary-bg);
}
.integrations-page .integration-tile--email {
background: var(--warning-text);
.integration-tile--email {
color: #fa8c16;
background: color-mix(in srgb, #fa8c16 14%, var(--surface-card));
}
.integration-tile--ai {
color: var(--ai);
background: color-mix(in srgb, var(--ai) 12%, var(--surface-card));
}
.integration-tile--neutral {
color: var(--n-3);
background: var(--n-9);
}
.integration-config {
@@ -188,3 +206,12 @@
.integration-snippet .form-field input.mono {
font-size: 12px;
}
/* Колонка «Агент» таблицы подключений (кадр N4): бот в цвете агента. */
.integration-agent {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 500;
}
@@ -2,26 +2,26 @@ import { useState } from "react";
import { api } from "../../api/client";
import { ACCENT_PRESETS, DEFAULT_ACCENT, type UiTheme } from "../../shared/appearance";
import { Icon } from "../../shared/icons";
import type { AuthenticatedUser, SessionUser } from "../../types";
// «Внешний вид» (SPEC-HUB-0031 §7): тема (светлая/тёмная/системная) и акцентный
// цвет — пресеты плюс произвольный HEX. Настройка глобальная для пользователя;
// применение мгновенное через App (uiTheme/uiAccent в session-payload).
// «Внешний вид» (дизайн-базлайн v2, кадр P1; SPEC-HUB-0031 §7): тема сегментом
// со значками, акцент — четыре пресета и произвольный HEX. Настройка личная и
// хранится в учётной записи (ADR-0029).
const THEME_OPTIONS: Array<[UiTheme, string]> = [
["LIGHT", "Светлая"],
["DARK", "Тёмная"],
["SYSTEM", "Как в системе"],
const THEME_OPTIONS: Array<[UiTheme, string, Parameters<typeof Icon>[0]["name"]]> = [
["LIGHT", "Светлая", "sunny"],
["DARK", "Тёмная", "moon"],
["SYSTEM", "Как в системе", "monitor"],
];
export function ProfileAppearanceCard({ user, onUserUpdated }: { user: SessionUser; onUserUpdated: (user: SessionUser) => void }) {
const [saving, setSaving] = useState(false);
const [errorText, setErrorText] = useState("");
const [customAccent, setCustomAccent] = useState(
ACCENT_PRESETS.some(([value]) => value === (user.uiAccent || DEFAULT_ACCENT)) ? "" : user.uiAccent,
);
const accent = user.uiAccent || DEFAULT_ACCENT;
const [customAccent, setCustomAccent] = useState(
ACCENT_PRESETS.some(([value]) => value === accent) ? "" : accent,
);
async function save(theme: UiTheme, nextAccent: string) {
setSaving(true);
@@ -51,11 +51,12 @@ export function ProfileAppearanceCard({ user, onUserUpdated }: { user: SessionUs
return (
<section className="profile-card appearance-card">
<h3>Внешний вид</h3>
{errorText && <div className="profile-message">{errorText}</div>}
<p className="profile-card-lead">Настройка личная и хранится в учётной записи: применяется на всех ваших устройствах, без перезагрузки.</p>
{errorText && <div className="profile-message error">{errorText}</div>}
<div className="appearance-row">
<span>Тема</span>
<div className="appearance-theme-options">
{THEME_OPTIONS.map(([value, label]) => (
{THEME_OPTIONS.map(([value, label, icon]) => (
<button
className={user.uiTheme === value ? "active" : ""}
disabled={saving}
@@ -63,6 +64,7 @@ export function ProfileAppearanceCard({ user, onUserUpdated }: { user: SessionUs
type="button"
onClick={() => void save(value, user.uiAccent)}
>
<Icon name={icon} size={13} strokeWidth={2} />
{label}
</button>
))}
@@ -77,13 +79,17 @@ export function ProfileAppearanceCard({ user, onUserUpdated }: { user: SessionUs
className={accent === value ? "active" : ""}
disabled={saving}
key={value}
style={{ background: value }}
style={{ background: value, "--accent-ring": value } as Record<string, string>}
title={label}
type="button"
onClick={() => void save(user.uiTheme, value)}
/>
>
{accent === value && <Icon name="check" size={13} strokeWidth={3} />}
</button>
))}
<i className="appearance-separator" />
<div className="appearance-custom">
<i className="appearance-custom-swatch" style={{ background: /^#[0-9a-f]{6}$/i.test(customAccent.trim()) ? customAccent.trim() : undefined }} />
<input
disabled={saving}
maxLength={7}
@@ -94,13 +100,10 @@ export function ProfileAppearanceCard({ user, onUserUpdated }: { user: SessionUs
if (event.key === "Enter") submitCustomAccent();
}}
/>
<button disabled={saving || !customAccent.trim()} type="button" onClick={submitCustomAccent}>
Применить
</button>
<button disabled={saving || !customAccent.trim()} type="button" onClick={submitCustomAccent}>Применить</button>
</div>
</div>
</div>
<p className="appearance-note">Настройка личная: тема и цвет применяются только к вашему интерфейсу.</p>
</section>
);
}
@@ -1,17 +1,36 @@
import type { SessionUser } from "../../types";
import { Avatar, RoleBadge } from "../../shared/ui";
import { Avatar } from "../../shared/ui";
import { Button } from "../../shared/ui-controls";
import type { ProfileFormState } from "./types";
import { monthYear } from "../../shared/utils";
export function ProfileHeader({ profile, user, onLogout }: { profile: ProfileFormState; user: SessionUser; onLogout: () => void }) {
// Шапка «Профиля» (дизайн-базлайн v2, кадры P1/P2): фото 72 · имя с бейджем
// роли · email, дата вступления и группы · «Выйти». Роль показана бейджем,
// отдельного поля «Роль» в форме нет.
const ROLE_LABEL: Record<SessionUser["role"], string> = {
OWNER: "Владелец",
ADMIN: "Администратор",
EMPLOYEE: "Сотрудник",
};
export function ProfileHeader({ user, compact = false, onLogout }: { user: SessionUser; compact?: boolean; onLogout: () => void }) {
const groups = user.groups.map((group) => group.name).join(", ");
return (
<section className="profile-header-card">
<Avatar user={user} />
<header className="profile-header">
<span className="profile-header-photo"><Avatar user={user} /></span>
<div className="profile-header-main">
<div><h1>{profile.fullName || profile.email}</h1><RoleBadge role={user.role} /></div>
<p>{profile.email}</p>
<div>
<h2>{user.fullName || user.email}</h2>
<b className={`profile-role ${user.role === "EMPLOYEE" ? "is-muted" : ""}`}>{ROLE_LABEL[user.role]}</b>
</div>
{/* Кадр M: на узком экране под именем только email. */}
<p>
{user.email}
{!compact && user.joinedAt ? ` · в организации с ${monthYear(user.joinedAt)}` : ""}
{!compact && groups ? ` · группы: ${groups}` : ""}
</p>
</div>
<Button icon="logout" type="button" variant="danger-outline" onClick={onLogout}>Выйти</Button>
</section>
</header>
);
}
@@ -1,18 +1,31 @@
import type { Dispatch, FormEvent, SetStateAction } from "react";
import type { SessionUser } from "../../types";
import { FormField } from "../../shared/form-controls";
import { Button } from "../../shared/ui-controls";
import { ProfileField } from "./ProfileField";
import { ProfilePhotoField } from "./ProfilePhotoField";
import type { ProfileFormState } from "./types";
export function ProfileInfoForm({ profile, user, message, saving, setProfile, onSubmit }: { profile: ProfileFormState; user: SessionUser; message: string; saving: boolean; setProfile: Dispatch<SetStateAction<ProfileFormState>>; onSubmit: (event: FormEvent) => void }) {
// «Личные данные» (дизайн-базлайн v2, кадр P1): фото отдельной строкой сверху,
// затем имя и email в две колонки. Поля «Роль» здесь нет — роль показана
// бейджем в шапке страницы.
export function ProfileInfoForm({ profile, user, message, saving, setProfile, onUserUpdated, onSubmit }: {
profile: ProfileFormState;
user: SessionUser;
message: string;
saving: boolean;
setProfile: Dispatch<SetStateAction<ProfileFormState>>;
onUserUpdated: (user: SessionUser) => void;
onSubmit: (event: FormEvent) => void;
}) {
return (
<form className="profile-card" onSubmit={onSubmit}>
<h3>Личные данные</h3>
<ProfilePhotoField user={user} onUserUpdated={onUserUpdated} />
<div className="profile-grid">
<ProfileField label="Имя" value={profile.fullName} onChange={(value) => setProfile((current) => ({ ...current, fullName: value }))} />
<ProfileField label="Email · используется для входа" value={profile.email} onChange={(value) => setProfile((current) => ({ ...current, email: value }))} mono />
<ProfileField label="Роль" value={user.role} disabled />
<FormField label="Имя" value={profile.fullName} onChange={(value) => setProfile((current) => ({ ...current, fullName: value }))} />
<FormField label="Email · используется для входа" value={profile.email} onChange={(value) => setProfile((current) => ({ ...current, email: value }))} mono />
</div>
{message && <div className="profile-message">{message}</div>}
<div className="profile-actions"><Button type="submit" variant="primary" disabled={saving}>{saving ? "Сохранение" : "Сохранить"}</Button></div>
@@ -1,14 +1,19 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { api } from "../../api/client";
import { LogoSpinner } from "../../shared/icons";
import { Icon, LogoSpinner, MaxLogo, TelegramLogo } from "../../shared/icons";
import { Button } from "../../shared/ui-controls";
// «Уведомления в мессенджер» (дизайн-базлайн v2, кадр P1): по строке на бота —
// фирменная плитка, статус привязки и типы событий галочками (ADR-0015).
// Мессенджер один: привязка нового заменяет текущий.
type BindingItem = { integrationId: number; provider: "TELEGRAM" | "MAX"; name: string; botUsername: string; bound: boolean; pushTypes: string[] };
type TypeOption = { code: string; label: string };
type BindingsResponse = { items: BindingItem[]; availableTypes: TypeOption[] };
const PROVIDER_LABEL: Record<BindingItem["provider"], string> = { TELEGRAM: "Telegram", MAX: "MAX" };
const PROVIDER_TILE: Record<BindingItem["provider"], string> = { TELEGRAM: "integration-tile--telegram", MAX: "integration-tile--max" };
const fetchBindings = () => api<BindingsResponse>("/api/v1/notifications/messenger-bindings/");
@@ -77,41 +82,51 @@ export function ProfileNotificationsCard() {
return (
<section className="profile-card">
<h3>Уведомления в мессенджер</h3>
<p className="profile-card-lead">Рабочие события приходят и при закрытом браузере. Один мессенджер: привязка нового заменяет текущий.</p>
{loaded && items.length === 0 && (
<p className="profile-notifications-note">Боты уведомлений не настроены. Владелец может добавить бота в разделе «Интеграции» (флажок «Бот уведомлений для сотрудников»).</p>
<p className="profile-notifications-note">Боты уведомлений не настроены. Владелец может добавить бота в «Настройках» → «Интеграции» (флажок «Бот уведомлений для сотрудников»).</p>
)}
{items.map((item) => (
<div className="profile-notifications-block" key={item.integrationId}>
<div className="profile-notifications-row">
<div>
<span className={`profile-notifications-tile integration-tile ${PROVIDER_TILE[item.provider]}`}>
{item.provider === "TELEGRAM" ? <TelegramLogo size={19} /> : <MaxLogo size={19} />}
</span>
<div className="profile-notifications-name">
<strong>{PROVIDER_LABEL[item.provider] ?? item.provider}</strong>
<small>{item.botUsername ? `@${item.botUsername}` : item.name}</small>
<small>{item.botUsername ? `@${item.botUsername} · сервисный бот уведомлений` : item.name}</small>
</div>
{item.bound ? (
<div className="profile-notifications-actions">
<span className="profile-notifications-status ok">Подключено</span>
<>
<b className="profile-notifications-status">Подключено</b>
<Button variant="secondary" disabled={busy} onClick={() => void disconnect(item)}>Отключить</Button>
</div>
</>
) : links[item.integrationId]?.deepLink ? (
<a className="profile-notifications-bind" href={links[item.integrationId].deepLink} target="_blank" rel="noreferrer">Привязать бота</a>
<a className="profile-notifications-bind" href={links[item.integrationId].deepLink} target="_blank" rel="noreferrer">
Привязать бота<Icon name="external" size={12} strokeWidth={2.2} />
</a>
) : (
<span className="profile-notifications-status wait">{loaded ? "Готовим ссылку…" : <LogoSpinner size={16} />}</span>
<span className="profile-notifications-wait">{loaded ? "Готовим ссылку…" : <LogoSpinner size={16} />}</span>
)}
</div>
{item.bound && types.length > 0 && (
<div className="profile-notifications-types">
{types.map((type) => (
<label key={type.code}>
<input type="checkbox" checked={item.pushTypes.includes(type.code)} onChange={() => void toggleType(item, type.code)} />
{type.label}
</label>
))}
{types.map((type) => {
const on = item.pushTypes.includes(type.code);
return (
<label key={type.code}>
<input type="checkbox" checked={on} onChange={() => void toggleType(item, type.code)} />
<span className={`profile-check ${on ? "is-on" : ""}`}>{on && <Icon name="check" size={11} strokeWidth={3} />}</span>
{type.label}
</label>
);
})}
</div>
)}
</div>
))}
{hasUnbound && (
<p className="profile-notifications-note">Нажмите «Привязать бота» и в открывшемся чате нажмите «Начать» — привязка подтвердится автоматически. Уведомления приходят в один мессенджер: привязка нового заменяет текущий.</p>
<p className="profile-notifications-note">Нажмите «Привязать бота» и в открывшемся чате — «Начать»: привязка подтвердится сама.</p>
)}
</section>
);
@@ -1,15 +1,115 @@
import { useEffect, useState } from "react";
import { api } from "../../api/client";
import { Icon } from "../../shared/icons";
import { useMediaQuery } from "../../shared/useMediaQuery";
import type { SessionUser } from "../../types";
import { fetchProfileSessions } from "./model";
import { ProfileAppearanceCard } from "./ProfileAppearanceCard";
import { ProfileHeader } from "./ProfileHeader";
import { ProfileInfoForm } from "./ProfileInfoForm";
import { ProfileNotificationsCard } from "./ProfileNotificationsCard";
import { ProfilePasswordForm } from "./ProfilePasswordForm";
import { ProfileSessionsCard } from "./ProfileSessionsCard";
import { ProfileTotpCard } from "./ProfileTotpCard";
import { useProfilePage } from "./useProfilePage";
export function ProfilePage({ user, onUserUpdated, reload, onLogout }: { user: SessionUser; onUserUpdated: (user: SessionUser) => void; reload: () => void; onLogout: () => void }) {
const profilePage = useProfilePage({ user, onUserUpdated, reload });
// «Профиль» — отдельная страница из меню пользователя (дизайн-базлайн v2,
// кадры P1/P2): слева «кто я и как выглядит интерфейс», справа безопасность.
// Всё здесь принадлежит учётной записи, а не организации (ADR-0029).
type MobileSection = "notifications" | "password" | "totp" | "sessions";
const MOBILE_TITLE: Record<MobileSection, string> = {
notifications: "Уведомления в мессенджер",
password: "Смена пароля",
totp: "Двухфакторная аутентификация",
sessions: "Активные сессии",
};
export function ProfilePage({ user, onUserUpdated, reload, onLogout, onBack }: { user: SessionUser; onUserUpdated: (user: SessionUser) => void; reload: () => void; onLogout: () => void; onBack: () => void }) {
const page = useProfilePage({ user, onUserUpdated, reload });
// Кадр M: безопасность и уведомления уходят в подэкраны.
const mobile = useMediaQuery("(max-width: 900px)");
const [section, setSection] = useState<MobileSection | null>(null);
const summary = useProfileSummary(mobile);
const security = (
<>
<ProfilePasswordForm passwords={page.passwords} message={page.passwordMessage} mismatch={page.passwordMismatch} ready={page.passwordReady} saving={page.savingPassword} setPasswords={page.setPasswords} onSubmit={page.updatePassword} />
<ProfileTotpCard user={user} message={page.totpMessage} saving={page.savingTotp} onToggle={page.toggleTotp} />
<ProfileSessionsCard message={page.sessionsMessage} revoking={page.revokingSessions} onRevoke={page.revokeOtherSessions} />
</>
);
if (mobile) {
return (
<div className="profile-page is-mobile">
<div className="profile-mobile-bar">
<button type="button" aria-label="Назад" onClick={() => (section ? setSection(null) : onBack())}><Icon name="chevron" size={20} strokeWidth={2} /></button>
<h2>{section ? MOBILE_TITLE[section] : "Профиль"}</h2>
{!section && <button className="profile-mobile-logout" type="button" onClick={onLogout}>Выйти</button>}
</div>
<div className="profile-mobile-body">
{section === "notifications" && <ProfileNotificationsCard />}
{section === "password" && <ProfilePasswordForm passwords={page.passwords} message={page.passwordMessage} mismatch={page.passwordMismatch} ready={page.passwordReady} saving={page.savingPassword} setPasswords={page.setPasswords} onSubmit={page.updatePassword} />}
{section === "totp" && <ProfileTotpCard user={user} message={page.totpMessage} saving={page.savingTotp} onToggle={page.toggleTotp} />}
{section === "sessions" && <ProfileSessionsCard message={page.sessionsMessage} revoking={page.revokingSessions} onRevoke={page.revokeOtherSessions} />}
{!section && (
<>
<ProfileHeader user={user} compact onLogout={onLogout} />
<ProfileInfoForm profile={page.profile} user={user} message={page.profileMessage} saving={page.savingProfile} setProfile={page.setProfile} onUserUpdated={onUserUpdated} onSubmit={page.saveProfile} />
<ProfileAppearanceCard user={user} onUserUpdated={onUserUpdated} />
<div className="profile-mobile-list">
<MobileLink label={MOBILE_TITLE.notifications} value={summary.messenger} tone="ok" onOpen={() => setSection("notifications")} />
<MobileLink label={MOBILE_TITLE.password} onOpen={() => setSection("password")} />
<MobileLink label={MOBILE_TITLE.totp} value={user.totpEnabled ? "Вкл" : "Выкл"} onOpen={() => setSection("totp")} />
<MobileLink label={MOBILE_TITLE.sessions} value={summary.sessions ? String(summary.sessions) : ""} onOpen={() => setSection("sessions")} />
</div>
</>
)}
</div>
</div>
);
}
return (
<div className="profile-stack">
<ProfileHeader profile={profilePage.profile} user={user} onLogout={onLogout} />
<ProfileInfoForm profile={profilePage.profile} user={user} message={profilePage.profileMessage} saving={profilePage.savingProfile} setProfile={profilePage.setProfile} onSubmit={profilePage.saveProfile} />
<div className="profile-page">
<ProfileHeader user={user} onLogout={onLogout} />
<div className="profile-columns">
<div className="profile-column">
<ProfileInfoForm profile={page.profile} user={user} message={page.profileMessage} saving={page.savingProfile} setProfile={page.setProfile} onUserUpdated={onUserUpdated} onSubmit={page.saveProfile} />
<ProfileAppearanceCard user={user} onUserUpdated={onUserUpdated} />
<ProfileNotificationsCard />
</div>
<div className="profile-column">{security}</div>
</div>
</div>
);
}
function MobileLink({ label, value = "", tone = "", onOpen }: { label: string; value?: string; tone?: string; onOpen: () => void }) {
return (
<button type="button" onClick={onOpen}>
<span>{label}</span>
{value && <small className={tone}>{value}</small>}
<Icon name="chevron" size={16} strokeWidth={2} />
</button>
);
}
// Подписи в списке подэкранов кадра M: привязанный мессенджер и число сессий.
function useProfileSummary(enabled: boolean) {
const [summary, setSummary] = useState<{ messenger: string; sessions: number }>({ messenger: "", sessions: 0 });
useEffect(() => {
if (!enabled) return;
fetchProfileSessions().then((items) => setSummary((current) => ({ ...current, sessions: items.length }))).catch(() => undefined);
api<{ items: Array<{ provider: string; bound: boolean }> }>("/api/v1/notifications/messenger-bindings/")
.then((payload) => {
const bound = payload.items.find((item) => item.bound);
setSummary((current) => ({ ...current, messenger: bound ? (bound.provider === "TELEGRAM" ? "Telegram" : "MAX") : "" }));
})
.catch(() => undefined);
}, [enabled]);
return summary;
}
@@ -1,17 +1,20 @@
import type { Dispatch, FormEvent, SetStateAction } from "react";
import { ProfileField } from "./ProfileField";
import { FormField } from "../../shared/form-controls";
import { Button } from "../../shared/ui-controls";
import type { PasswordFormState } from "./types";
// «Смена пароля» (дизайн-базлайн v2, кадр P1): три поля столбиком, кнопка
// справа внизу — вторичная, активна только когда форма заполнена.
export function ProfilePasswordForm({ passwords, message, mismatch, ready, saving, setPasswords, onSubmit }: { passwords: PasswordFormState; message: string; mismatch: boolean; ready: boolean; saving: boolean; setPasswords: Dispatch<SetStateAction<PasswordFormState>>; onSubmit: (event: FormEvent) => void }) {
return (
<form className="profile-card" onSubmit={onSubmit}>
<h3>Смена пароля</h3>
<div className="password-fields">
<ProfileField label="Текущий пароль" value={passwords.current} onChange={(value) => setPasswords((current) => ({ ...current, current: value }))} type="password" />
<ProfileField label="Новый пароль" value={passwords.next} onChange={(value) => setPasswords((current) => ({ ...current, next: value }))} placeholder="мин. 10 символов" type="password" />
<ProfileField label="Повторите новый пароль" value={passwords.repeat} onChange={(value) => setPasswords((current) => ({ ...current, repeat: value }))} type="password" />
<FormField label="Текущий пароль" value={passwords.current} onChange={(value) => setPasswords((current) => ({ ...current, current: value }))} type="password" />
<FormField label="Новый пароль" value={passwords.next} onChange={(value) => setPasswords((current) => ({ ...current, next: value }))} placeholder="мин. 10 символов" type="password" />
<FormField label="Повторите новый пароль" value={passwords.repeat} onChange={(value) => setPasswords((current) => ({ ...current, repeat: value }))} type="password" />
</div>
{mismatch && <div className="profile-message error">Пароли не совпадают</div>}
{message && <div className="profile-message">{message}</div>}
@@ -0,0 +1,86 @@
import { useRef, useState } from "react";
import { api, apiUpload } from "../../api/client";
import { DecisionDialog } from "../../shared/DecisionDialog";
import { Avatar } from "../../shared/ui";
import { Button } from "../../shared/ui-controls";
import type { AuthenticatedUser, SessionUser } from "../../types";
// Строка «Фото» внутри карточки «Личные данные» (дизайн-базлайн v2, кадр P1):
// круглое превью 56 · подпись · «Заменить» и «Удалить». Видно коллегам в чате,
// подписях сообщений и выборе ответственного.
export function ProfilePhotoField({ user, onUserUpdated }: { user: SessionUser; onUserUpdated: (user: SessionUser) => void }) {
const input = useRef<HTMLInputElement>(null);
const [saving, setSaving] = useState(false);
const [errorText, setErrorText] = useState("");
const [confirmingRemoval, setConfirmingRemoval] = useState(false);
async function upload(file: File) {
const form = new FormData();
form.append("file", file);
setSaving(true);
setErrorText("");
try {
const payload = await apiUpload<{ user: AuthenticatedUser }>("/api/v1/auth/profile/avatar/", form);
onUserUpdated({ ...user, ...payload.user });
} catch (error) {
setErrorText(error instanceof Error ? error.message : "Не удалось загрузить фото");
} finally {
setSaving(false);
}
}
async function remove() {
setSaving(true);
setErrorText("");
try {
const payload = await api<{ user: AuthenticatedUser }>("/api/v1/auth/profile/avatar/", { method: "DELETE" });
onUserUpdated({ ...user, ...payload.user });
} catch (error) {
setErrorText(error instanceof Error ? error.message : "Не удалось удалить фото");
} finally {
setSaving(false);
}
}
return (
<div className="profile-photo-field">
<span className="profile-photo-preview"><Avatar user={user} /></span>
<div className="profile-photo-copy">
<strong>Фото</strong>
<small>PNG, JPEG или WebP · до 2 МБ. Видно коллегам в чате, подписях сообщений и выборе ответственного.</small>
{errorText && <small className="profile-photo-error">{errorText}</small>}
</div>
<div className="profile-photo-actions">
<input
ref={input}
type="file"
accept="image/png,image/jpeg,image/webp"
hidden
onChange={(event) => {
const file = event.target.files?.[0];
if (file) void upload(file);
event.target.value = "";
}}
/>
<Button variant="secondary" disabled={saving} onClick={() => input.current?.click()}>Заменить</Button>
<button className="profile-photo-remove" type="button" disabled={saving || !user.avatarUrl} onClick={() => setConfirmingRemoval(true)}>Удалить</button>
</div>
<DecisionDialog
open={confirmingRemoval}
onClose={() => setConfirmingRemoval(false)}
tone="danger"
icon="trash"
title="Удалить фото?"
description="Вместо фото коллеги снова увидят ваши инициалы."
actions={(
<>
<Button variant="secondary" onClick={() => setConfirmingRemoval(false)}>Отмена</Button>
<Button variant="danger-outline" disabled={saving} onClick={() => { setConfirmingRemoval(false); void remove(); }}>Удалить</Button>
</>
)}
/>
</div>
);
}
@@ -1,12 +1,57 @@
import { useCallback, useEffect, useState } from "react";
import { Icon } from "../../shared/icons";
import { Button } from "../../shared/ui-controls";
import { fetchProfileSessions, lastSeenLabel, type ProfileSession } from "./model";
// «Активные сессии» (дизайн-базлайн v2, кадр P1): устройство, частично скрытый
// адрес и когда сессия была активна; текущая помечена бейджем.
const KIND_ICON: Record<ProfileSession["kind"], Parameters<typeof Icon>[0]["name"]> = {
laptop: "laptop",
phone: "smartphone",
monitor: "monitor",
};
export function ProfileSessionsCard({ message, revoking, onRevoke }: { message: string; revoking: boolean; onRevoke: () => void }) {
const [items, setItems] = useState<ProfileSession[]>([]);
const load = useCallback(() => {
fetchProfileSessions().then(setItems).catch(() => undefined);
}, []);
useEffect(() => {
load();
}, [load]);
// После «Завершить другие» список перечитываем.
useEffect(() => {
if (!revoking) load();
}, [load, revoking]);
return (
<section className="sessions-card">
<div className="sessions-head"><h3>Активные сессии</h3><Button className="sessions-revoke" type="button" variant="danger-outline" onClick={onRevoke} disabled={revoking}>{revoking ? "Завершение" : "Завершить другие сессии"}</Button></div>
<section className="profile-card sessions-card">
<div className="sessions-head">
<h3>Активные сессии</h3>
<Button className="sessions-revoke" type="button" variant="danger-outline" onClick={onRevoke} disabled={revoking || items.length < 2}>
{revoking ? "Завершение" : "Завершить другие"}
</Button>
</div>
{message && <div className="profile-message sessions">{message}</div>}
<div className="session-row"><span className="session-icon"><Icon name="user" /></span><span><strong>Браузер</strong><small>сейчас активна</small></span><b>текущая</b></div>
{items.map((session) => {
const seen = lastSeenLabel(session.lastSeenAt);
const where = [session.address, session.current ? seen : ""].filter(Boolean).join(" · ");
return (
<div className="session-row" key={session.id}>
<span className="session-icon"><Icon name={KIND_ICON[session.kind]} size={16} strokeWidth={1.9} /></span>
<span className="session-main">
<strong>{session.device}</strong>
<small>{where || "адрес неизвестен"}</small>
</span>
{session.current ? <b className="session-current">текущая</b> : <small className="session-time">{seen}</small>}
</div>
);
})}
</section>
);
}
@@ -1,16 +1,37 @@
import type { SessionUser } from "../../types";
import { SwitchButton } from "../../shared/form-controls";
import { Icon } from "../../shared/icons";
import { lastSeenLabel } from "./model";
// «Двухфакторная аутентификация» (дизайн-базлайн v2, кадр P1): описание слева,
// переключатель справа, ниже — полоса состояния: зелёная при включённой 2FA,
// жёлтое предупреждение при выключенной (кадр P2).
export function ProfileTotpCard({ user, message, saving, onToggle }: { user: SessionUser; message: string; saving: boolean; onToggle: () => void }) {
const enabled = user.totpEnabled;
const lastUsed = lastSeenLabel(user.totpLastUsedAt);
return (
<section className="profile-card totp-card">
<div>
<h3>Двухфакторная аутентификация (TOTP)</h3>
<p>{user.totpEnabled ? "Включена. Для OWNER рекомендуется держать включённой." : "Отключена. Для роли OWNER настоятельно рекомендуется включить."}</p>
<div className="totp-head">
<div>
<h3>Двухфакторная аутентификация</h3>
<p>
{enabled
? "Включена. Код из приложения-аутентификатора запрашивается при каждом входе."
: "Отключена. Для владельца и администраторов настоятельно рекомендуется включить."}
</p>
</div>
<SwitchButton checked={enabled} className="ui-switch is-large" label="Переключить двухфакторную аутентификацию" onClick={onToggle} disabled={saving} />
</div>
{message && <div className="profile-message">{message}</div>}
<div className={`security-note ${enabled ? "ok" : "warning"}`}>
<Icon name={enabled ? "check" : "warning"} size={15} strokeWidth={2.2} />
<span>
{enabled
? `Аутентификатор подключён${lastUsed ? ` · последний код принят ${lastUsed}` : ""}`
: "Без второго фактора доступ к организации защищён только паролем"}
</span>
</div>
<SwitchButton checked={user.totpEnabled} className="ui-switch is-large" label="Переключить TOTP" onClick={onToggle} disabled={saving} />
{message && <div className="security-note warning">{message}</div>}
{user.totpEnabled && <div className="security-note ok">Приложение-аутентификатор подключено · последний код принят 5 мин назад</div>}
</section>
);
}
@@ -0,0 +1,31 @@
import { api } from "../../api/client";
// Активные сессии учётной записи (дизайн-базлайн v2, кадр P1): устройство,
// частично скрытый адрес и когда сессия была активна.
export type ProfileSession = {
id: string;
device: string;
kind: "laptop" | "phone" | "monitor";
address: string;
startedAt: string | null;
lastSeenAt: string | null;
current: boolean;
};
export const fetchProfileSessions = () =>
api<{ items: ProfileSession[] }>("/api/v1/auth/profile/sessions/").then((payload) => payload.items);
// «сейчас» · «2 ч назад» · «3 дня назад» — подпись справа в строке сессии.
export function lastSeenLabel(value: string | null, now = new Date()): string {
if (!value) return "";
const seen = new Date(value);
if (Number.isNaN(seen.getTime())) return "";
const minutes = Math.max(0, Math.round((now.getTime() - seen.getTime()) / 60000));
if (minutes < 2) return "сейчас";
if (minutes < 60) return `${minutes} мин назад`;
const hours = Math.round(minutes / 60);
if (hours < 24) return `${hours} ч назад`;
const days = Math.round(hours / 24);
return days === 1 ? "вчера" : `${days} дн. назад`;
}
File diff suppressed because it is too large. Load diff
Loaded 100 of 169 files, more files were not shown because too many files have changed in this diff. Show more