mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 01:14:58 +03:00
✨ 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:
1 parent
1a86906829
commit
3672463363
169 files changed
+13683
-3385
No files matched your search
@@ -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:
|
||||
|
||||
+15
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(""), "")
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>Вставьте перед </body> на сайте</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";
|
||||
}
|
||||
@@ -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,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
Reference in new issue
Block a user