mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 09:14:58 +03:00
✨ feat(identity): employee roles, position and org placement (ADR-HUB-0027 этап 1)
Системные роли OWNER/ADMIN/EMPLOYEE (удалён OPERATOR), обязательная должность position_title и основной отдел primary_department. Инварианты: владелец на уровне компании, ровно один OWNER на организацию. Двухфазная миграция OPERATOR->EMPLOYEE без расширения прав; операционный доступ сохранён compatibility-адаптером. Обновлены DTO/API/формы, список и карточка сотрудника, тесты модели и инвариантов. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
192497ead0
commit
cb3932e3f6
31 files changed
+377
-94
No files matched your search
@@ -176,8 +176,9 @@ class AIAgentPermissionTests(TestCase):
|
||||
EmployeeProfile.objects.create(
|
||||
user=operator,
|
||||
organization=Organization.objects.get(slug="edevs"),
|
||||
role=EmployeeRole.OPERATOR,
|
||||
department=None,
|
||||
role=EmployeeRole.EMPLOYEE,
|
||||
position_title="Оператор",
|
||||
primary_department=None,
|
||||
)
|
||||
self.client = APIClient()
|
||||
self.client.login(username="operator@edevs.tech", password="operator-password")
|
||||
|
||||
@@ -12,11 +12,12 @@ def ensure_conversation_call_access(*, user, conversation: Conversation) -> None
|
||||
or profile is None
|
||||
or profile.is_blocked
|
||||
or profile.organization_id != conversation.organization_id
|
||||
or profile.role not in {EmployeeRole.OWNER, EmployeeRole.OPERATOR}
|
||||
or profile.role not in {EmployeeRole.OWNER, EmployeeRole.EMPLOYEE}
|
||||
):
|
||||
raise CallAccessDenied("Нет доступа к звонкам этого диалога")
|
||||
if profile.role == EmployeeRole.OPERATOR and (
|
||||
profile.department_id is None or profile.department_id != conversation.channel.department_id
|
||||
if profile.role == EmployeeRole.EMPLOYEE and (
|
||||
profile.primary_department_id is None
|
||||
or profile.primary_department_id != conversation.channel.department_id
|
||||
):
|
||||
raise CallAccessDenied("Нет доступа к звонкам этого отдела")
|
||||
|
||||
|
||||
@@ -53,8 +53,9 @@ class CallDomainMixin:
|
||||
EmployeeProfile.objects.create(
|
||||
user=user,
|
||||
organization=self.organization,
|
||||
role=EmployeeRole.OPERATOR,
|
||||
department=self.support_department,
|
||||
role=EmployeeRole.EMPLOYEE,
|
||||
position_title="Оператор поддержки",
|
||||
primary_department=self.support_department,
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
@@ -67,8 +67,9 @@ class ChannelRenamePermissionTests(TestCase):
|
||||
EmployeeProfile.objects.create(
|
||||
user=operator,
|
||||
organization=organization,
|
||||
role=EmployeeRole.OPERATOR,
|
||||
department=None,
|
||||
role=EmployeeRole.EMPLOYEE,
|
||||
position_title="Оператор",
|
||||
primary_department=None,
|
||||
)
|
||||
self.client = APIClient()
|
||||
self.client.login(username="operator@edevs.tech", password="operator-password")
|
||||
|
||||
@@ -47,8 +47,17 @@ class DepartmentAdmin(admin.ModelAdmin):
|
||||
|
||||
@admin.register(EmployeeProfile)
|
||||
class EmployeeProfileAdmin(admin.ModelAdmin):
|
||||
list_display = ["user", "organization", "role", "department", "must_change_password", "totp_required", "blocked_at"]
|
||||
list_filter = ["organization", "role", "department", "must_change_password", "totp_required"]
|
||||
list_display = [
|
||||
"user",
|
||||
"organization",
|
||||
"role",
|
||||
"position_title",
|
||||
"primary_department",
|
||||
"must_change_password",
|
||||
"totp_required",
|
||||
"blocked_at",
|
||||
]
|
||||
list_filter = ["organization", "role", "primary_department", "must_change_password", "totp_required"]
|
||||
search_fields = ["user__email", "user__full_name"]
|
||||
|
||||
|
||||
|
||||
@@ -11,9 +11,10 @@ def _user_payload(user: HumanUser) -> dict[str, object]:
|
||||
"email": user.email,
|
||||
"fullName": user.full_name,
|
||||
"role": profile.role,
|
||||
"positionTitle": profile.position_title,
|
||||
"organizationName": profile.organization.name,
|
||||
"organization": profile.organization.slug,
|
||||
"department": profile.department.code if profile.department else None,
|
||||
"department": profile.primary_department.code if profile.primary_department else None,
|
||||
"mustChangePassword": profile.must_change_password,
|
||||
"totpRequired": profile.totp_required,
|
||||
"totpEnabled": profile.totp_enabled,
|
||||
|
||||
@@ -70,7 +70,9 @@ def bootstrap_edevs_owner(*, email: str, password: str, full_name: str = "") ->
|
||||
defaults={
|
||||
"organization": organization,
|
||||
"role": EmployeeRole.OWNER,
|
||||
"department": sales_department,
|
||||
"position_title": "Владелец",
|
||||
# OWNER всегда на уровне компании (ADR-HUB-0027): без основного отдела.
|
||||
"primary_department": None,
|
||||
"totp_required": False,
|
||||
},
|
||||
)
|
||||
@@ -91,9 +93,10 @@ def bootstrap_edevs_owner(*, email: str, password: str, full_name: str = "") ->
|
||||
user=operator,
|
||||
defaults={
|
||||
"organization": organization,
|
||||
"role": EmployeeRole.OPERATOR,
|
||||
"role": EmployeeRole.EMPLOYEE,
|
||||
"position_title": "Оператор отдела продаж",
|
||||
"phone": "+7 916 245 14 02",
|
||||
"department": sales_department,
|
||||
"primary_department": sales_department,
|
||||
},
|
||||
)
|
||||
if not operator_profile.phone:
|
||||
|
||||
@@ -9,7 +9,7 @@ from hub_platform.identity.models import Department, EmployeeRole
|
||||
|
||||
def _department_payload(department: Department) -> dict[str, object]:
|
||||
employees = list(department.employees.select_related("user").all())
|
||||
operators = [employee for employee in employees if employee.role == EmployeeRole.OPERATOR]
|
||||
operators = [employee for employee in employees if employee.role == EmployeeRole.EMPLOYEE]
|
||||
products = [link.product for link in department.product_links.select_related("product").order_by("product__name")]
|
||||
# AI-агенты отдела: AIAgent живёт на канале обработки (ADR-HUB-0019),
|
||||
# канал принадлежит отделу. Считаем активных агентов каналов этого отдела.
|
||||
@@ -35,6 +35,6 @@ class DepartmentListView(APIView):
|
||||
def get(self, request: Request) -> Response:
|
||||
profile = request.user.employee_profile
|
||||
departments = Department.objects.filter(organization=profile.organization).order_by("name")
|
||||
if profile.role == EmployeeRole.OPERATOR:
|
||||
departments = departments.filter(id=profile.department_id)
|
||||
if profile.role == EmployeeRole.EMPLOYEE:
|
||||
departments = departments.filter(id=profile.primary_department_id)
|
||||
return Response({"items": [_department_payload(department) for department in departments]})
|
||||
@@ -12,8 +12,9 @@ def employee_payload(profile: EmployeeProfile) -> dict[str, object]:
|
||||
"email": profile.user.email,
|
||||
"fullName": profile.user.full_name,
|
||||
"role": profile.role,
|
||||
"positionTitle": profile.position_title,
|
||||
"phone": profile.phone,
|
||||
"department": profile.department.code if profile.department else None,
|
||||
"department": profile.primary_department.code if profile.primary_department else None,
|
||||
"isActive": profile.user.is_active,
|
||||
"isBlocked": profile.is_blocked,
|
||||
"mustChangePassword": profile.must_change_password,
|
||||
@@ -25,7 +26,7 @@ def employee_payload(profile: EmployeeProfile) -> dict[str, object]:
|
||||
def get_owned_profile(request: Request, user_id: int) -> EmployeeProfile | None:
|
||||
owner_profile = request.user.employee_profile
|
||||
try:
|
||||
return EmployeeProfile.objects.select_related("user", "department").get(
|
||||
return EmployeeProfile.objects.select_related("user", "primary_department").get(
|
||||
user_id=user_id,
|
||||
organization=owner_profile.organization,
|
||||
)
|
||||
|
||||
@@ -7,20 +7,37 @@ from rest_framework.views import APIView
|
||||
from hub_platform.api.permissions import IsOwner
|
||||
from hub_platform.identity.audit import record_audit_event
|
||||
from hub_platform.identity.employee_support import employee_payload, get_owned_profile, temporary_password
|
||||
from hub_platform.identity.models import Department, EmployeeProfile, EmployeeRole, HumanUser
|
||||
from hub_platform.identity.models import (
|
||||
POSITION_TITLE_MAX_LENGTH,
|
||||
Department,
|
||||
EmployeeProfile,
|
||||
EmployeeRole,
|
||||
HumanUser,
|
||||
)
|
||||
from hub_platform.identity.sessions import revoke_user_sessions
|
||||
|
||||
|
||||
def _clean_position_title(raw: object) -> tuple[str, str | None]:
|
||||
"""Нормализует должность (SPEC-HUB-0016 §5). Возвращает (значение, ошибка)."""
|
||||
value = str(raw or "").strip()
|
||||
if not value:
|
||||
return "", "Position title is required"
|
||||
if len(value) > POSITION_TITLE_MAX_LENGTH:
|
||||
return value, f"Position title must be at most {POSITION_TITLE_MAX_LENGTH} characters"
|
||||
return value, None
|
||||
|
||||
|
||||
class EmployeeListView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request: Request) -> Response:
|
||||
profile = request.user.employee_profile
|
||||
employees = EmployeeProfile.objects.select_related("user", "department").filter(
|
||||
employees = EmployeeProfile.objects.select_related("user", "primary_department").filter(
|
||||
organization=profile.organization
|
||||
)
|
||||
if profile.role == EmployeeRole.OPERATOR:
|
||||
employees = employees.filter(department=profile.department)
|
||||
# Compatibility (этап 1): обычный сотрудник видит только свой основной отдел.
|
||||
if profile.role == EmployeeRole.EMPLOYEE:
|
||||
employees = employees.filter(primary_department=profile.primary_department)
|
||||
return Response({"items": [employee_payload(employee) for employee in employees.order_by("user__email")]})
|
||||
|
||||
|
||||
@@ -34,8 +51,11 @@ class OperatorCreateView(APIView):
|
||||
email = HumanUser.objects.normalize_email(str(body.get("email", "")))
|
||||
full_name = str(body.get("fullName", ""))
|
||||
provided_password = str(body.get("temporaryPassword", ""))
|
||||
position_title, position_error = _clean_position_title(body.get("positionTitle"))
|
||||
if not email:
|
||||
return Response({"detail": "Email is required"}, status=400)
|
||||
if position_error:
|
||||
return Response({"detail": position_error}, status=400)
|
||||
if len(provided_password) < 12:
|
||||
return Response({"detail": "Temporary password must contain at least 12 characters"}, status=400)
|
||||
|
||||
@@ -50,8 +70,9 @@ class OperatorCreateView(APIView):
|
||||
profile = EmployeeProfile.objects.create(
|
||||
user=user,
|
||||
organization=owner_profile.organization,
|
||||
role=EmployeeRole.OPERATOR,
|
||||
department=sales_department,
|
||||
role=EmployeeRole.EMPLOYEE,
|
||||
position_title=position_title,
|
||||
primary_department=sales_department,
|
||||
must_change_password=True,
|
||||
)
|
||||
record_audit_event(
|
||||
@@ -78,8 +99,10 @@ class EmployeeUpdateView(APIView):
|
||||
full_name = str(body.get("fullName", "")).strip()
|
||||
email = HumanUser.objects.normalize_email(str(body.get("email", "")).strip())
|
||||
phone = str(body.get("phone", "")).strip()
|
||||
role = str(body.get("role", profile.role))
|
||||
department_code = str(body.get("department", profile.department.code if profile.department else ""))
|
||||
position_title, position_error = _clean_position_title(body.get("positionTitle", profile.position_title))
|
||||
current_department_code = profile.primary_department.code if profile.primary_department else ""
|
||||
department_code = str(body.get("department", current_department_code))
|
||||
requested_role = str(body.get("role", profile.role))
|
||||
totp_enabled = body.get("totpEnabled", profile.totp_enabled)
|
||||
|
||||
if not full_name:
|
||||
@@ -88,8 +111,16 @@ class EmployeeUpdateView(APIView):
|
||||
return Response({"detail": "Email is required"}, status=400)
|
||||
if HumanUser.objects.exclude(id=profile.user_id).filter(email=email).exists():
|
||||
return Response({"detail": "Email is already used"}, status=400)
|
||||
if role not in EmployeeRole.values:
|
||||
return Response({"detail": "Invalid role"}, status=400)
|
||||
if position_error:
|
||||
return Response({"detail": position_error}, status=400)
|
||||
|
||||
# Governance-инварианты этапа 1 (ADR-HUB-0027, SPEC-HUB-0016 §7/§10):
|
||||
# владелец не меняется обычным update; повышение до ADMIN/OWNER откроется
|
||||
# на этапах 2-3. Здесь допускается управление только обычными сотрудниками.
|
||||
if profile.role != EmployeeRole.EMPLOYEE:
|
||||
return Response({"detail": "This employee cannot be modified by this operation"}, status=403)
|
||||
if requested_role != EmployeeRole.EMPLOYEE:
|
||||
return Response({"detail": "Role changes are not available yet"}, status=403)
|
||||
|
||||
department = None
|
||||
if department_code:
|
||||
@@ -102,12 +133,14 @@ class EmployeeUpdateView(APIView):
|
||||
profile.user.email = email
|
||||
profile.user.save(update_fields=["full_name", "email"])
|
||||
profile.phone = phone
|
||||
profile.role = role
|
||||
profile.department = department
|
||||
profile.position_title = position_title
|
||||
profile.primary_department = department
|
||||
profile.totp_enabled = bool(totp_enabled)
|
||||
if not profile.totp_enabled:
|
||||
profile.totp_secret = ""
|
||||
profile.save(update_fields=["phone", "role", "department", "totp_enabled", "totp_secret"])
|
||||
profile.save(
|
||||
update_fields=["phone", "position_title", "primary_department", "totp_enabled", "totp_secret"]
|
||||
)
|
||||
|
||||
record_audit_event(
|
||||
action="identity.employee_updated",
|
||||
|
||||
@@ -99,7 +99,9 @@ def _seed_core(*, owner_email: str, owner_password: str, owner_name: str) -> Cor
|
||||
defaults={
|
||||
"organization": organization,
|
||||
"role": EmployeeRole.OWNER,
|
||||
"department": sales_department,
|
||||
"position_title": "Владелец",
|
||||
# OWNER всегда на уровне компании (ADR-HUB-0027).
|
||||
"primary_department": None,
|
||||
"totp_required": False,
|
||||
},
|
||||
)
|
||||
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
# ADR-HUB-0027 / SPEC-HUB-0018 фаза M1-M2 (этап 1): additive schema + backfill.
|
||||
# Роли OWNER/ADMIN/EMPLOYEE, обязательная должность (пока nullable), основной отдел
|
||||
# и размещение владельца на уровне компании. Доступ существующих сотрудников не
|
||||
# расширяется: OPERATOR → EMPLOYEE, права сохраняются compatibility-адаптером.
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def operator_to_employee(apps, schema_editor):
|
||||
EmployeeProfile = apps.get_model("identity", "EmployeeProfile")
|
||||
# OPERATOR больше не является системной ролью (ADR-HUB-0027).
|
||||
EmployeeProfile.objects.filter(role="OPERATOR").update(role="EMPLOYEE")
|
||||
# Владелец всегда на уровне компании: снимаем основной отдел.
|
||||
EmployeeProfile.objects.filter(role="OWNER").exclude(primary_department__isnull=True).update(
|
||||
primary_department=None
|
||||
)
|
||||
|
||||
|
||||
def employee_to_operator(apps, schema_editor):
|
||||
# Обратная миграция для rollback до cleanup: EMPLOYEE → OPERATOR.
|
||||
# ADMIN и размещение владельца восстановить нельзя — остаются как есть.
|
||||
EmployeeProfile = apps.get_model("identity", "EmployeeProfile")
|
||||
EmployeeProfile.objects.filter(role="EMPLOYEE").update(role="OPERATOR")
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("identity", "0006_alter_employeeprofile_totp_secret"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameField(
|
||||
model_name="employeeprofile",
|
||||
old_name="department",
|
||||
new_name="primary_department",
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="employeeprofile",
|
||||
name="position_title",
|
||||
field=models.CharField(blank=True, max_length=120, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="employeeprofile",
|
||||
name="role",
|
||||
field=models.CharField(
|
||||
choices=[("OWNER", "Owner"), ("ADMIN", "Admin"), ("EMPLOYEE", "Employee")],
|
||||
max_length=32,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="employeeprofile",
|
||||
name="primary_department",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="employees",
|
||||
to="identity.department",
|
||||
),
|
||||
),
|
||||
migrations.RunPython(operator_to_employee, employee_to_operator),
|
||||
migrations.AddConstraint(
|
||||
model_name="employeeprofile",
|
||||
constraint=models.CheckConstraint(
|
||||
check=models.Q(("role", "OWNER"), _negated=True)
|
||||
| models.Q(("primary_department__isnull", True)),
|
||||
name="owner_is_company_level",
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="employeeprofile",
|
||||
constraint=models.UniqueConstraint(
|
||||
condition=models.Q(("role", "OWNER")),
|
||||
fields=("organization",),
|
||||
name="uniq_owner_per_organization",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,29 @@
|
||||
# ADR-HUB-0027 / SPEC-HUB-0018 фаза M2 (этап 1): после явного заполнения должностей
|
||||
# включается обязательность. Оставшиеся незаполненные значения нормализуются в "";
|
||||
# непустая должность гарантируется application contract (SPEC-HUB-0016 §5).
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def coalesce_null_titles(apps, schema_editor):
|
||||
EmployeeProfile = apps.get_model("identity", "EmployeeProfile")
|
||||
EmployeeProfile.objects.filter(position_title__isnull=True).update(position_title="")
|
||||
|
||||
|
||||
def noop(apps, schema_editor):
|
||||
pass
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("identity", "0007_employee_roles_position_department"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(coalesce_null_titles, noop),
|
||||
migrations.AlterField(
|
||||
model_name="employeeprofile",
|
||||
name="position_title",
|
||||
field=models.CharField(blank=True, default="", max_length=120),
|
||||
),
|
||||
]
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import AbstractUser, UserManager
|
||||
from django.db import models
|
||||
from django.db.models import Q
|
||||
from django.utils import timezone
|
||||
|
||||
from hub_platform.identity.crypto import EncryptedCharField
|
||||
@@ -94,17 +95,27 @@ class Department(models.Model):
|
||||
return f"{self.organization.slug}/{self.code}"
|
||||
|
||||
|
||||
# ADR-HUB-0027 / SPEC-HUB-0016 §5: лимит должности задаётся backend-константой.
|
||||
POSITION_TITLE_MAX_LENGTH = 120
|
||||
|
||||
|
||||
class EmployeeRole(models.TextChoices):
|
||||
OWNER = "OWNER", "Owner"
|
||||
OPERATOR = "OPERATOR", "Operator"
|
||||
ADMIN = "ADMIN", "Admin"
|
||||
EMPLOYEE = "EMPLOYEE", "Employee"
|
||||
|
||||
|
||||
class EmployeeProfile(models.Model):
|
||||
user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="employee_profile")
|
||||
organization = models.ForeignKey(Organization, on_delete=models.PROTECT, related_name="employees")
|
||||
role = models.CharField(max_length=32, choices=EmployeeRole.choices)
|
||||
# Должность вводится вручную; обязательна для новых записей (SPEC-HUB-0016 §5).
|
||||
# Пустая строка допускается на уровне БД только для legacy-записей до backfill.
|
||||
position_title = models.CharField(max_length=POSITION_TITLE_MAX_LENGTH, blank=True, default="")
|
||||
phone = models.CharField(max_length=32, blank=True)
|
||||
department = models.ForeignKey(
|
||||
# Основной отдел описывает оргструктуру, но не выдаёт прав (ADR-HUB-0027).
|
||||
# null = сотрудник на верхнем уровне компании; OWNER всегда на уровне компании.
|
||||
primary_department = models.ForeignKey(
|
||||
Department,
|
||||
on_delete=models.PROTECT,
|
||||
related_name="employees",
|
||||
@@ -118,6 +129,21 @@ class EmployeeProfile(models.Model):
|
||||
blocked_at = models.DateTimeField(null=True, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
# OWNER всегда на уровне компании (ADR-HUB-0027, инварианты размещения).
|
||||
models.CheckConstraint(
|
||||
check=~Q(role=EmployeeRole.OWNER) | Q(primary_department__isnull=True),
|
||||
name="owner_is_company_level",
|
||||
),
|
||||
# В организации ровно один владелец (ADR-HUB-0027).
|
||||
models.UniqueConstraint(
|
||||
fields=["organization"],
|
||||
condition=Q(role=EmployeeRole.OWNER),
|
||||
name="uniq_owner_per_organization",
|
||||
),
|
||||
]
|
||||
|
||||
@property
|
||||
def is_blocked(self) -> bool:
|
||||
return self.blocked_at is not None
|
||||
|
||||
@@ -17,7 +17,10 @@ def is_owner(user: HumanUser | AnonymousUser) -> bool:
|
||||
|
||||
|
||||
def is_operator(user: HumanUser | AnonymousUser) -> bool:
|
||||
return get_employee_role(user) == EmployeeRole.OPERATOR
|
||||
"""Compatibility-адаптер этапа 1 (ADR-HUB-0027): «оператор» — это рабочая функция,
|
||||
которую после миграции выполняет обычный сотрудник (EMPLOYEE) в своём отделе.
|
||||
Операционная авторизация остаётся прежней до этапа 3 (capability-модель)."""
|
||||
return get_employee_role(user) == EmployeeRole.EMPLOYEE
|
||||
|
||||
|
||||
def can_access_global_settings(user: HumanUser | AnonymousUser) -> bool:
|
||||
@@ -25,22 +28,22 @@ def can_access_global_settings(user: HumanUser | AnonymousUser) -> bool:
|
||||
|
||||
|
||||
def can_access_sales_workspace(user: HumanUser | AnonymousUser) -> bool:
|
||||
return get_employee_role(user) in {EmployeeRole.OWNER, EmployeeRole.OPERATOR}
|
||||
return get_employee_role(user) in {EmployeeRole.OWNER, EmployeeRole.EMPLOYEE}
|
||||
|
||||
|
||||
def _operator_department_code(user: HumanUser | AnonymousUser) -> str | None:
|
||||
"""Код отдела оператора (EmployeeProfile.department.code) или None.
|
||||
"""Код основного отдела сотрудника (EmployeeProfile.primary_department.code) или None.
|
||||
|
||||
SPEC-HUB-0010 §8.1: оператор с доступом к нескольким отделам не поддерживается
|
||||
(одиночный FK department). Для полного покрытия требуется DepartmentMembership.
|
||||
Сейчас оператор строго в одном отделе: sales ИЛИ support (§10 изоляция inbox).
|
||||
SPEC-HUB-0010 §8.1: доступ к нескольким отделам не поддерживается (одиночный FK
|
||||
primary_department). Для полного покрытия требуется scoped-модель этапа 3.
|
||||
Сейчас сотрудник строго в одном отделе: sales ИЛИ support (§10 изоляция inbox).
|
||||
"""
|
||||
if not user.is_authenticated:
|
||||
return None
|
||||
profile = getattr(user, "employee_profile", None)
|
||||
if profile is None or profile.is_blocked or profile.department_id is None:
|
||||
if profile is None or profile.is_blocked or profile.primary_department_id is None:
|
||||
return None
|
||||
return profile.department.code
|
||||
return profile.primary_department.code
|
||||
|
||||
|
||||
def is_sales_operator(user: HumanUser | AnonymousUser) -> bool:
|
||||
|
||||
@@ -49,7 +49,7 @@ class BootstrapOwnerTests(TestCase):
|
||||
self.assertTrue(result.owner.is_superuser)
|
||||
operator = HumanUser.objects.get(email="a.kotova@edevs.tech")
|
||||
self.assertEqual(operator.full_name, "Анна Котова")
|
||||
self.assertEqual(operator.employee_profile.role, EmployeeRole.OPERATOR)
|
||||
self.assertEqual(operator.employee_profile.role, EmployeeRole.EMPLOYEE)
|
||||
self.assertEqual(operator.employee_profile.phone, "+7 916 245 14 02")
|
||||
self.assertTrue(AuditEvent.objects.filter(action="identity.owner_bootstrapped").exists())
|
||||
|
||||
@@ -91,8 +91,8 @@ class PermissionTests(TestCase):
|
||||
EmployeeProfile.objects.create(
|
||||
user=operator,
|
||||
organization=self.organization,
|
||||
role=EmployeeRole.OPERATOR,
|
||||
department=self.sales,
|
||||
role=EmployeeRole.EMPLOYEE,
|
||||
primary_department=self.sales,
|
||||
must_change_password=True,
|
||||
)
|
||||
|
||||
@@ -403,6 +403,7 @@ class EmployeeEndpointTests(TestCase):
|
||||
{
|
||||
"email": "operator@edevs.tech",
|
||||
"fullName": "Operator",
|
||||
"positionTitle": "Оператор продаж",
|
||||
"temporaryPassword": "operator-password",
|
||||
}
|
||||
),
|
||||
@@ -412,7 +413,7 @@ class EmployeeEndpointTests(TestCase):
|
||||
self.assertEqual(response.status_code, 201)
|
||||
operator = HumanUser.objects.get(email="operator@edevs.tech")
|
||||
self.assertTrue(operator.check_password("operator-password"))
|
||||
self.assertEqual(operator.employee_profile.role, EmployeeRole.OPERATOR)
|
||||
self.assertEqual(operator.employee_profile.role, EmployeeRole.EMPLOYEE)
|
||||
self.assertTrue(operator.employee_profile.must_change_password)
|
||||
self.assertTrue(AuditEvent.objects.filter(action="identity.operator_created").exists())
|
||||
|
||||
@@ -421,8 +422,8 @@ class EmployeeEndpointTests(TestCase):
|
||||
EmployeeProfile.objects.create(
|
||||
user=operator,
|
||||
organization=self.organization,
|
||||
role=EmployeeRole.OPERATOR,
|
||||
department=self.sales,
|
||||
role=EmployeeRole.EMPLOYEE,
|
||||
primary_department=self.sales,
|
||||
)
|
||||
self.client.logout()
|
||||
self.client.login(username="operator@edevs.tech", password="operator-password")
|
||||
@@ -446,8 +447,8 @@ class EmployeeEndpointTests(TestCase):
|
||||
EmployeeProfile.objects.create(
|
||||
user=operator,
|
||||
organization=self.organization,
|
||||
role=EmployeeRole.OPERATOR,
|
||||
department=self.sales,
|
||||
role=EmployeeRole.EMPLOYEE,
|
||||
primary_department=self.sales,
|
||||
)
|
||||
|
||||
response = self.client.post(f"/api/v1/employees/{operator.id}/block/")
|
||||
@@ -468,7 +469,8 @@ class EmployeeEndpointTests(TestCase):
|
||||
"fullName": "Анна Котова",
|
||||
"email": "anna.kotova@edevs.tech",
|
||||
"phone": "+7 916 245 14 03",
|
||||
"role": EmployeeRole.OPERATOR,
|
||||
"positionTitle": "Оператор продаж",
|
||||
"role": EmployeeRole.EMPLOYEE,
|
||||
"department": "sales",
|
||||
"totpEnabled": True,
|
||||
}
|
||||
@@ -553,8 +555,8 @@ class CompanyEndpointTests(TestCase):
|
||||
EmployeeProfile.objects.create(
|
||||
user=operator,
|
||||
organization=self.organization,
|
||||
role=EmployeeRole.OPERATOR,
|
||||
department=self.sales,
|
||||
role=EmployeeRole.EMPLOYEE,
|
||||
primary_department=self.sales,
|
||||
)
|
||||
self.client.logout()
|
||||
self.client.login(username="operator@edevs.tech", password="operator-password")
|
||||
@@ -637,3 +639,72 @@ class SeedIdempotencyTests(TestCase):
|
||||
self.assertIn("seed skipped", out.getvalue())
|
||||
# Ни новые офферы, ни цены не создаются и не изменяются на развёрнутой установке.
|
||||
self.assertEqual(Offer.objects.count(), offers_before)
|
||||
|
||||
|
||||
class EmployeeModelInvariantTests(TestCase):
|
||||
"""ADR-HUB-0027 / SPEC-HUB-0016 §5,§7 — инварианты модели сотрудника после
|
||||
миграции этапа 1: роли OWNER/ADMIN/EMPLOYEE, обязательная должность,
|
||||
размещение владельца на уровне компании и ровно один владелец на организацию."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password")
|
||||
self.organization = Organization.objects.get(slug="edevs")
|
||||
self.sales = Department.objects.get(code="sales")
|
||||
|
||||
def test_bootstrap_owner_is_company_level_with_title(self) -> None:
|
||||
owner = HumanUser.objects.get(email="owner@edevs.tech")
|
||||
self.assertEqual(owner.employee_profile.role, EmployeeRole.OWNER)
|
||||
self.assertIsNone(owner.employee_profile.primary_department)
|
||||
self.assertTrue(owner.employee_profile.position_title)
|
||||
|
||||
def test_bootstrapped_operator_is_employee_in_sales(self) -> None:
|
||||
operator = HumanUser.objects.get(email="a.kotova@edevs.tech")
|
||||
self.assertEqual(operator.employee_profile.role, EmployeeRole.EMPLOYEE)
|
||||
self.assertEqual(operator.employee_profile.primary_department, self.sales)
|
||||
self.assertTrue(operator.employee_profile.position_title)
|
||||
|
||||
def test_second_owner_is_rejected(self) -> None:
|
||||
from django.db import IntegrityError, transaction
|
||||
|
||||
second = HumanUser.objects.create_user(email="owner2@edevs.tech", password="temporary-password")
|
||||
with self.assertRaises(IntegrityError):
|
||||
with transaction.atomic():
|
||||
EmployeeProfile.objects.create(
|
||||
user=second,
|
||||
organization=self.organization,
|
||||
role=EmployeeRole.OWNER,
|
||||
position_title="Второй владелец",
|
||||
primary_department=None,
|
||||
)
|
||||
|
||||
def test_owner_with_primary_department_is_rejected(self) -> None:
|
||||
from django.db import IntegrityError, transaction
|
||||
|
||||
user = HumanUser.objects.create_user(email="owner3@edevs.tech", password="temporary-password")
|
||||
other_org = Organization.objects.create(name="Other", slug="other")
|
||||
with self.assertRaises(IntegrityError):
|
||||
with transaction.atomic():
|
||||
EmployeeProfile.objects.create(
|
||||
user=user,
|
||||
organization=other_org,
|
||||
role=EmployeeRole.OWNER,
|
||||
position_title="Владелец",
|
||||
primary_department=self.sales,
|
||||
)
|
||||
|
||||
def test_create_employee_requires_position_title(self) -> None:
|
||||
client = APIClient()
|
||||
client.login(username="owner@edevs.tech", password="temporary-password")
|
||||
response = client.post(
|
||||
"/api/v1/employees/operators/",
|
||||
data=json.dumps(
|
||||
{
|
||||
"email": "no-title@edevs.tech",
|
||||
"fullName": "No Title",
|
||||
"temporaryPassword": "temporary-password",
|
||||
}
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertFalse(HumanUser.objects.filter(email="no-title@edevs.tech").exists())
|
||||
@@ -40,8 +40,8 @@ def _recipient_user_ids(notification: Notification) -> list[int]:
|
||||
if notification.audience == NotificationAudience.OWNER:
|
||||
profiles = profiles.filter(role=EmployeeRole.OWNER)
|
||||
elif notification.audience == NotificationAudience.OPERATORS:
|
||||
# Зеркало visible_for: аудиторию OPERATORS видят и операторы, и владелец.
|
||||
profiles = profiles.filter(role__in=(EmployeeRole.OPERATOR, EmployeeRole.OWNER))
|
||||
# Зеркало visible_for: аудиторию OPERATORS видят и сотрудники, и владелец.
|
||||
profiles = profiles.filter(role__in=(EmployeeRole.EMPLOYEE, EmployeeRole.OWNER))
|
||||
return list(profiles.values_list("user_id", flat=True))
|
||||
|
||||
|
||||
|
||||
@@ -4,43 +4,43 @@ import type { RouteKey } from "../types";
|
||||
import { canAccess, defaultRoute } from "./access";
|
||||
|
||||
const OWNER_ONLY: RouteKey[] = ["command", "departments", "employees", "employeeDetail", "products", "productDetail"];
|
||||
const SALES_OPERATOR_ROUTES: RouteKey[] = ["salesOverview", "salesDialogs", "salesClients", "salesClientDetail", "salesOrders", "salesOrderDetail", "profile"];
|
||||
const SUPPORT_OPERATOR_ROUTES: RouteKey[] = ["supportOverview", "supportDialogs", "profile"];
|
||||
const SALES_EMPLOYEE_ROUTES: RouteKey[] = ["salesOverview", "salesDialogs", "salesClients", "salesClientDetail", "salesOrders", "salesOrderDetail", "profile"];
|
||||
const SUPPORT_EMPLOYEE_ROUTES: RouteKey[] = ["supportOverview", "supportDialogs", "profile"];
|
||||
|
||||
describe("role access", () => {
|
||||
it("grants OWNER every route", () => {
|
||||
const all = [...OWNER_ONLY, ...SALES_OPERATOR_ROUTES, ...SUPPORT_OPERATOR_ROUTES];
|
||||
const all = [...OWNER_ONLY, ...SALES_EMPLOYEE_ROUTES, ...SUPPORT_EMPLOYEE_ROUTES];
|
||||
for (const route of all) {
|
||||
expect(canAccess("OWNER", route)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("grants sales OPERATOR only the sales workspace and profile", () => {
|
||||
for (const route of SALES_OPERATOR_ROUTES) {
|
||||
expect(canAccess("OPERATOR", route, "sales")).toBe(true);
|
||||
it("grants sales EMPLOYEE only the sales workspace and profile", () => {
|
||||
for (const route of SALES_EMPLOYEE_ROUTES) {
|
||||
expect(canAccess("EMPLOYEE", route, "sales")).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("blocks sales OPERATOR from support workspace and owner-only routes", () => {
|
||||
it("blocks sales EMPLOYEE from support workspace and owner-only routes", () => {
|
||||
for (const route of OWNER_ONLY) {
|
||||
expect(canAccess("OPERATOR", route, "sales")).toBe(false);
|
||||
expect(canAccess("EMPLOYEE", route, "sales")).toBe(false);
|
||||
}
|
||||
expect(canAccess("OPERATOR", "supportDialogs", "sales")).toBe(false);
|
||||
expect(canAccess("OPERATOR", "supportOverview", "sales")).toBe(false);
|
||||
expect(canAccess("EMPLOYEE", "supportDialogs", "sales")).toBe(false);
|
||||
expect(canAccess("EMPLOYEE", "supportOverview", "sales")).toBe(false);
|
||||
});
|
||||
|
||||
it("grants support OPERATOR only the support workspace and profile (§10 изоляция)", () => {
|
||||
for (const route of SUPPORT_OPERATOR_ROUTES) {
|
||||
expect(canAccess("OPERATOR", route, "support")).toBe(true);
|
||||
it("grants support EMPLOYEE only the support workspace and profile (§10 изоляция)", () => {
|
||||
for (const route of SUPPORT_EMPLOYEE_ROUTES) {
|
||||
expect(canAccess("EMPLOYEE", route, "support")).toBe(true);
|
||||
}
|
||||
// Support operator не видит sales inbox.
|
||||
expect(canAccess("OPERATOR", "salesDialogs", "support")).toBe(false);
|
||||
expect(canAccess("OPERATOR", "salesOverview", "support")).toBe(false);
|
||||
expect(canAccess("EMPLOYEE", "salesDialogs", "support")).toBe(false);
|
||||
expect(canAccess("EMPLOYEE", "salesOverview", "support")).toBe(false);
|
||||
});
|
||||
|
||||
it("lands each role on its default route", () => {
|
||||
expect(defaultRoute("OWNER")).toBe("command");
|
||||
expect(defaultRoute("OPERATOR", "sales")).toBe("salesDialogs");
|
||||
expect(defaultRoute("OPERATOR", "support")).toBe("supportDialogs");
|
||||
expect(defaultRoute("EMPLOYEE", "sales")).toBe("salesDialogs");
|
||||
expect(defaultRoute("EMPLOYEE", "support")).toBe("supportDialogs");
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,11 @@
|
||||
import type { Role, RouteKey } from "../types";
|
||||
|
||||
// Матрица доступа SPEC-HUB-0004 §9 + SPEC-HUB-0010 §8.1/§10. OWNER имеет сквозной
|
||||
// доступ; OPERATOR работает только в пространстве своего отдела и личном профиле.
|
||||
// Department-scoped: sales operator видит только sales, support operator — только
|
||||
// support (изоляция inbox §10). §8.1 (оператор в нескольких отделах) не покрыт —
|
||||
// оператор строго в одном отделе (одиночный FK department на backend).
|
||||
// доступ; EMPLOYEE работает только в пространстве своего отдела и личном профиле.
|
||||
// Compat-адаптер этапа 1 (ADR-HUB-0027): операционный доступ по-прежнему определяется
|
||||
// department. Полная capability-модель придёт на этапе 3. Department-scoped: sales →
|
||||
// только sales, support → только support (изоляция inbox §10). §8.1 (несколько
|
||||
// отделов) не покрыт — сотрудник строго в одном основном отделе (одиночный FK).
|
||||
const SALES_ROUTES: ReadonlySet<RouteKey> = new Set<RouteKey>([
|
||||
"salesOverview",
|
||||
"salesDialogs",
|
||||
@@ -23,7 +24,7 @@ export function canAccess(role: Role, route: RouteKey, department?: string | nul
|
||||
if (role === "OWNER") return true;
|
||||
if (route === "profile") return true;
|
||||
if (department === "support") return SUPPORT_ROUTES.has(route);
|
||||
// По умолчанию OPERATOR — sales-пространство (обратная совместимость).
|
||||
// По умолчанию EMPLOYEE — sales-пространство (обратная совместимость).
|
||||
return SALES_ROUTES.has(route);
|
||||
}
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ export function EmployeeDetailHeader({
|
||||
<RoleBadge role={form.role} />
|
||||
<StatusPill status={status} />
|
||||
</div>
|
||||
<p>{form.email} · {departmentLabel}</p>
|
||||
<p>{form.email} · {form.positionTitle || "Должность не указана"} · {departmentLabel}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="employee-header-actions">
|
||||
|
||||
@@ -29,6 +29,7 @@ export function EmployeeDetailSections({
|
||||
<h3>Основные данные</h3>
|
||||
<div className="employee-form-grid">
|
||||
<FormField label="Имя" value={form.fullName} onChange={(value) => updateForm("fullName", value)} />
|
||||
<FormField label="Должность" value={form.positionTitle} onChange={(value) => updateForm("positionTitle", value)} />
|
||||
<FormField label="Телефон" value={form.phone} onChange={(value) => updateForm("phone", value)} />
|
||||
<FormField label="Email · используется для входа" value={form.email} onChange={(value) => updateForm("email", value)} mono wide />
|
||||
</div>
|
||||
@@ -37,7 +38,7 @@ export function EmployeeDetailSections({
|
||||
<section className="employee-detail-card">
|
||||
<h3>Роль и доступ</h3>
|
||||
<div className="employee-form-grid">
|
||||
<SelectField label="Роль" value={form.role} onChange={(value) => updateForm("role", value)} options={[["OPERATOR", "OPERATOR"], ["OWNER", "OWNER"]]} />
|
||||
<SelectField label="Роль" value={form.role} onChange={(value) => updateForm("role", value)} options={[["EMPLOYEE", "Сотрудник"], ["OWNER", "Владелец"]]} />
|
||||
<SelectField label="Отдел" value={form.department} onChange={(value) => updateForm("department", value)} options={[["sales", "Отдел продаж"]]} />
|
||||
</div>
|
||||
<label className="employee-access-label">Доступные разделы</label>
|
||||
|
||||
@@ -48,7 +48,7 @@ function EmployeeRow({ employee, block, openEmployee, menuId, setMenuId }: { emp
|
||||
};
|
||||
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><div className="person-cell"><Avatar employee={employee} /><button className="person-link" type="button" onClick={open}><strong>{employee.fullName || employee.email}</strong><small>{employee.positionTitle || "Должность не указана"} · {employee.email}</small></button></div></td>
|
||||
<td><RoleBadge role={employee.role} /></td>
|
||||
<td>{employee.department === "sales" ? "Продажи" : "—"}</td>
|
||||
<td><StatusPill status={status} /></td>
|
||||
|
||||
@@ -25,7 +25,7 @@ export function EmployeesFilters({
|
||||
<SearchInput className="employee-search" value={query} onChange={setQuery} placeholder="Поиск по имени или email…" />
|
||||
<div className="filter-group">
|
||||
<span>Роль</span>
|
||||
<Segmented value={role} setValue={setRole} items={[["all", "Все"], ["OWNER", "OWNER"], ["OPERATOR", "OPERATOR"]]} />
|
||||
<Segmented value={role} setValue={setRole} items={[["all", "Все"], ["OWNER", "Владелец"], ["EMPLOYEE", "Сотрудник"]]} />
|
||||
</div>
|
||||
<div className="filter-group">
|
||||
<span>Статус</span>
|
||||
|
||||
@@ -35,7 +35,7 @@ export function EmployeesPage({ employees, reload, openEmployee }: { employees:
|
||||
<PageHeader
|
||||
title="Сотрудники"
|
||||
text={<>Доступ к Hub · показано <b>{filtered.length}</b> из {employees.length}</>}
|
||||
action={<Button icon="team" iconSize={16} type="button" variant="primary">Добавить оператора</Button>}
|
||||
action={<Button icon="team" iconSize={16} type="button" variant="primary">Добавить сотрудника</Button>}
|
||||
/>
|
||||
<EmployeesFilters
|
||||
query={query}
|
||||
|
||||
@@ -9,6 +9,7 @@ export type EmployeeForm = {
|
||||
email: string;
|
||||
fullName: string;
|
||||
phone: string;
|
||||
positionTitle: string;
|
||||
role: Role;
|
||||
totpEnabled: boolean;
|
||||
};
|
||||
@@ -36,6 +37,7 @@ export function employeeForm(employee: Employee): EmployeeForm {
|
||||
fullName: employee.fullName || employee.email,
|
||||
phone: employee.phone || employeeDetails(employee).phone,
|
||||
email: employee.email,
|
||||
positionTitle: employee.positionTitle,
|
||||
role: employee.role,
|
||||
department: employee.department ?? "sales",
|
||||
totpEnabled: employee.totpEnabled,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Icon } from "../../../shared/icons";
|
||||
import { EmptyState, LoadingState } from "../../../shared/ui";
|
||||
import type { RouteKey } from "../../../types";
|
||||
import type { Role, RouteKey } from "../../../types";
|
||||
import { StatusBadge } from "../orders/StatusBadge";
|
||||
import { actorView, attributionLabel, dateTimeLong, money, sourceBadge, statusBadge } from "../registry/model";
|
||||
import { SaleActionPanel } from "./SaleActionPanel";
|
||||
@@ -16,7 +16,7 @@ export function SaleDetailPage({
|
||||
openDialog,
|
||||
}: {
|
||||
saleId: number | null;
|
||||
role: "OWNER" | "OPERATOR";
|
||||
role: Role;
|
||||
setRoute: (route: RouteKey) => void;
|
||||
openDialog: (conversationId: number) => void;
|
||||
}) {
|
||||
|
||||
@@ -94,10 +94,11 @@ export function Shell({ route, setRoute, selectedEmployeeId, selectedProductId,
|
||||
// Подменю AI показываем только там, где оно есть в baseline.
|
||||
const isAiSection = route === "aiAgents" || route === "aiKnowledge" || route === "aiUsage";
|
||||
const isAiFullWidth = route === "aiAgentCreate";
|
||||
// OPERATOR работает в пространстве своего отдела (SPEC-HUB-0004 §9 + §0010 §10):
|
||||
// sales operator → sales-sidebar, support operator → support-sidebar.
|
||||
const showSalesSidebar = isSalesWorkspace || (user.role === "OPERATOR" && user.department !== "support" && !isSupportWorkspace);
|
||||
const showSupportSidebar = isSupportWorkspace || (user.role === "OPERATOR" && user.department === "support");
|
||||
// Сотрудник (EMPLOYEE) работает в пространстве своего отдела (SPEC-HUB-0004 §9 +
|
||||
// §0010 §10): sales → sales-sidebar, support → support-sidebar. Compat-адаптер
|
||||
// этапа 1 (ADR-HUB-0027): department по-прежнему определяет рабочее пространство.
|
||||
const showSalesSidebar = isSalesWorkspace || (user.role === "EMPLOYEE" && user.department !== "support" && !isSupportWorkspace);
|
||||
const showSupportSidebar = isSupportWorkspace || (user.role === "EMPLOYEE" && user.department === "support");
|
||||
return (
|
||||
<div className="hub-shell">
|
||||
{showSupportSidebar ? <SupportSidebar route={route} user={user} setRoute={setRoute} waitingCount={waitingCount} /> : showSalesSidebar ? <SalesSidebar route={route} user={user} setRoute={setRoute} waitingCount={waitingCount} /> : <Sidebar route={route} user={user} setRoute={setRoute} />}
|
||||
|
||||
@@ -222,11 +222,16 @@
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
.role-badge.operator {
|
||||
.role-badge.employee {
|
||||
color: #0958d9;
|
||||
background: #e6f4ff;
|
||||
}
|
||||
|
||||
.role-badge.admin {
|
||||
color: #ad4e00;
|
||||
background: #fff7e6;
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -32,8 +32,14 @@ export function StatusPill({ status }: { status: "normal" | "active" | "blocked"
|
||||
return <span className="status-pill" style={{ background: bg, borderColor: border, color }}><span style={{ background: color }} />{label}</span>;
|
||||
}
|
||||
|
||||
const ROLE_LABELS: Record<Role, string> = {
|
||||
OWNER: "Владелец",
|
||||
ADMIN: "Администратор",
|
||||
EMPLOYEE: "Сотрудник",
|
||||
};
|
||||
|
||||
export function RoleBadge({ role }: { role: Role }) {
|
||||
return <span className={`role-badge ${role.toLowerCase()}`}>{role}</span>;
|
||||
return <span className={`role-badge ${role.toLowerCase()}`}>{ROLE_LABELS[role] ?? role}</span>;
|
||||
}
|
||||
|
||||
export function ProductTag({ product }: { product: Product }) {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { AiAgent } from "./features/ai/model";
|
||||
|
||||
export type Role = "OWNER" | "OPERATOR";
|
||||
// Системные роли ADR-HUB-0027. OPERATOR удалён как системная роль (этап 1);
|
||||
// «оператор» — рабочая функция сотрудника (EMPLOYEE) в своём отделе.
|
||||
export type Role = "OWNER" | "ADMIN" | "EMPLOYEE";
|
||||
export type ProductStatus = "ACTIVE" | "DISABLED";
|
||||
|
||||
export type SessionUser = {
|
||||
@@ -8,6 +10,7 @@ export type SessionUser = {
|
||||
email: string;
|
||||
fullName: string;
|
||||
role: Role;
|
||||
positionTitle: string;
|
||||
organization: string;
|
||||
organizationName: string;
|
||||
department: string | null;
|
||||
@@ -31,6 +34,7 @@ export type Employee = {
|
||||
email: string;
|
||||
fullName: string;
|
||||
role: Role;
|
||||
positionTitle: string;
|
||||
phone: string;
|
||||
department: string | null;
|
||||
isActive: boolean;
|
||||
|
||||
@@ -10,6 +10,7 @@ const OWNER = {
|
||||
email: "owner@edevs.tech",
|
||||
fullName: "Владелец",
|
||||
role: "OWNER",
|
||||
positionTitle: "Владелец",
|
||||
organization: "edevs",
|
||||
organizationName: "Edevs",
|
||||
department: null,
|
||||
@@ -18,7 +19,8 @@ const OWNER = {
|
||||
totpEnabled: false,
|
||||
};
|
||||
|
||||
const OPERATOR = { ...OWNER, id: 2, email: "operator@edevs.tech", fullName: "Оператор", role: "OPERATOR", department: "sales" };
|
||||
// «Оператор» — рабочая функция обычного сотрудника (EMPLOYEE) в отделе (ADR-HUB-0027).
|
||||
const OPERATOR = { ...OWNER, id: 2, email: "operator@edevs.tech", fullName: "Оператор", role: "EMPLOYEE", positionTitle: "Оператор отдела продаж", department: "sales" };
|
||||
|
||||
async function mockData(page: Page) {
|
||||
await page.route("**/api/v1/employees/**", (route) => route.fulfill({ json: { items: [] } }));
|
||||
|
||||
Reference in new issue
Block a user