feat: refine procurement approval access and employee statuses

This commit is contained in:
lizerk 2026-07-06 10:43:52 +03:00
parent 0b45888f5c
commit 0dbd817c60
7 changed files with 437 additions and 71 deletions

View File

@ -23,6 +23,24 @@ from .shared import (
Employee = get_user_model()
def _employee_personnel_state_payload(obj):
prefetched = getattr(obj, "_prefetched_objects_cache", {})
actions = prefetched.get("actions") if prefetched else None
state = resolve_employee_personnel_state(
obj,
timezone.localdate(),
actions=actions,
)
return {
"status": state.status,
"label": state.label or "Работает",
"action_id": state.action_id,
"date_from": state.date_from,
"date_to": state.date_to,
"expects_attendance": state.expects_attendance,
}
class EmployeeDepartmentSerializer(serializers.Serializer):
id = serializers.IntegerField()
name = serializers.CharField(allow_null=True)
@ -275,15 +293,7 @@ class EmployeeSerializer(serializers.ModelSerializer):
@extend_schema_field(EmployeePersonnelStateSerializer())
def get_personnel_state(self, obj):
state = resolve_employee_personnel_state(obj, timezone.localdate())
return {
"status": state.status,
"label": state.label or "Работает",
"action_id": state.action_id,
"date_from": state.date_from,
"date_to": state.date_to,
"expects_attendance": state.expects_attendance,
}
return _employee_personnel_state_payload(obj)
class EmployeeBriefSerializer(serializers.ModelSerializer):
@ -343,6 +353,7 @@ class EmployeeListSerializer(serializers.ModelSerializer):
full_name = serializers.SerializerMethodField()
skills = SkillSerializer(many=True, read_only=True)
department_relation = serializers.SerializerMethodField()
personnel_state = serializers.SerializerMethodField(read_only=True)
class Meta:
model = Employee
@ -361,8 +372,13 @@ class EmployeeListSerializer(serializers.ModelSerializer):
"created_at",
"display_name",
"department_relation",
"personnel_state",
)
@extend_schema_field(EmployeePersonnelStateSerializer())
def get_personnel_state(self, obj):
return _employee_personnel_state_payload(obj)
@extend_schema_field(EmployeeDepartmentRelationSerializer(allow_null=True))
def get_department_relation(self, obj):
is_member = getattr(obj, "_is_dept_member", None)

View File

@ -199,9 +199,13 @@ class CanManageProcurementRequest(permissions.BasePermission):
dept_id = obj.department_id
# Submit (отправка на согласование) - только владелец
# Submit дополнительно валидируется resolver-ом: только сотрудник
# отдела-исполнителя, не автор заявки.
if action == "submit":
return obj.requestor == user
return ProcurementApprovalResolver.user_can_submit_for_approval(
user,
obj,
)
# Cancel (отмена заявки) - только владелец
if action == "cancel":

View File

@ -239,29 +239,21 @@ class ProcurementApprovalResolver:
):
return False
if procurement_request.processing_department_id:
if procurement_request.status not in {
ProcurementStatus.WAITING,
ProcurementStatus.IN_PROGRESS,
}:
return False
if (
user.is_superuser
or user.is_staff
or user.has_perm("procurement.change_procurementrequest")
or user.has_perm("procurement.execute_procurement")
):
return True
if procurement_request.executor_id == user.id:
return True
return cls.user_is_processing_department_member(
user,
procurement_request,
)
if not procurement_request.processing_department_id:
return False
return (
procurement_request.requestor_id == user.id
and procurement_request.is_editable
if procurement_request.status not in {
ProcurementStatus.WAITING,
ProcurementStatus.IN_PROGRESS,
}:
return False
if procurement_request.requestor_id == user.id:
return False
return cls.user_is_processing_department_member(
user,
procurement_request,
)
@classmethod

View File

@ -4,8 +4,10 @@ import itertools
import pytest
from django.contrib.auth import get_user_model
from django.urls import reverse
from django.utils import timezone
from employees.constants import ACTION_WORKING
from employees.models import (Department, DepartmentRole, EmployeeDepartment,
Position)
EmployeeAction, Position)
from rest_framework import status
from rest_framework.test import APIClient
from tests.conftest import _unique_phone
@ -72,6 +74,17 @@ def test_list_includes_basic_fields(api):
emp = make_user(
"emp@example.com", first_name="Ivan", last_name="Ivanov", position=pos
)
EmployeeAction.objects.filter(employee=emp).delete()
EmployeeAction.objects.create(
employee=emp,
action=ACTION_WORKING,
date=timezone.now().replace(
hour=23,
minute=59,
second=59,
microsecond=0,
),
)
url = reverse("api:v1:employees-list")
r = api.get(url)
@ -84,6 +97,8 @@ def test_list_includes_basic_fields(api):
assert row.get("first_name") == "Ivan"
assert row.get("last_name") == "Ivanov"
assert "avatar" in row, "Поле 'avatar' должно присутствовать (может быть пустым)"
assert row.get("personnel_state", {}).get("status") == "working"
assert row.get("personnel_state", {}).get("label") == "Работает"
if "position" in row and isinstance(row["position"], dict):
assert row["position"].get("id") == pos.id

View File

@ -52,6 +52,22 @@ FINANCE_PRIORITY = 2
DIRECTOR_PRIORITY = 3
def prepare_for_recipient_department_submit(
procurement_request,
recipient_department,
):
procurement_request.processing_department = recipient_department
procurement_request.status = ProcurementStatus.WAITING
procurement_request.save(
update_fields=[
"processing_department",
"status",
"updated_at",
]
)
return procurement_request
@pytest.fixture
def api_client():
"""API клиент."""
@ -67,6 +83,36 @@ def department(db):
)
@pytest.fixture
def recipient_department(db):
"""Отдел-получатель заявки на закупку."""
return Department.objects.create(
name="Снабжение",
description="Отдел-получатель заявок на закупку",
)
@pytest.fixture
def recipient_user(db, recipient_department):
"""Сотрудник отдела-получателя заявки."""
employee = Employee.objects.create_user(
email="recipient@example.com",
password="testpass123",
phone_number="+79990000001",
first_name="Получатель",
last_name="Заявки",
is_active=True,
email_verified=True,
send_activation_email=False,
)
EmployeeDepartment.objects.create(
employee=employee,
department=recipient_department,
is_active=True,
)
return employee
@pytest.fixture
def user(db, department):
"""Обычный пользователь."""
@ -1853,6 +1899,52 @@ class TestProcessingDepartmentWorkflow:
'api:v1:procurement:procurementrequest-submit',
kwargs={'pk': processing_request.id},
)
options_url = reverse(
'api:v1:procurement:procurementrequest-approval-options',
kwargs={'pk': processing_request.id},
)
detail_url = reverse(
'api:v1:procurement:procurementrequest-detail',
kwargs={'pk': processing_request.id},
)
response = api_client.post(url)
options_response = api_client.get(options_url)
detail_response = api_client.get(detail_url)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert options_response.status_code == status.HTTP_403_FORBIDDEN
assert detail_response.status_code == status.HTTP_200_OK
assert detail_response.data["can_current_user_submit_for_approval"] is False
processing_request.refresh_from_db()
assert processing_request.status == ProcurementStatus.WAITING
assert processing_request.approvals.count() == 0
def test_customer_department_user_cannot_submit_for_approval(
self, api_client, department, processing_request,
procurement_item_factory, department_head_approval_route
):
customer_user = Employee.objects.create_user(
email="customer-colleague@example.com",
password="testpass123",
phone_number="+79998888895",
first_name="Коллега",
last_name="Заказчика",
is_active=True,
email_verified=True,
send_activation_email=False,
)
EmployeeDepartment.objects.create(
employee=customer_user,
department=department,
is_active=True,
)
procurement_item_factory(request=processing_request)
api_client.force_authenticate(user=customer_user)
url = reverse(
'api:v1:procurement:procurementrequest-submit',
kwargs={'pk': processing_request.id},
)
response = api_client.post(url)
@ -1861,6 +1953,147 @@ class TestProcessingDepartmentWorkflow:
assert processing_request.status == ProcurementStatus.WAITING
assert processing_request.approvals.count() == 0
detail_url = reverse(
'api:v1:procurement:procurementrequest-detail',
kwargs={'pk': processing_request.id},
)
detail_response = api_client.get(detail_url)
assert detail_response.status_code == status.HTTP_200_OK
assert detail_response.data["can_current_user_submit_for_approval"] is False
def test_customer_department_user_can_submit_when_processing_department_same(
self, api_client, supply_department, supply_user,
procurement_item_factory, department_head_approval_route
):
supply_head = Employee.objects.create_user(
email="supply-head@example.com",
password="testpass123",
phone_number="+79998888896",
first_name="Руководитель",
last_name="Снабжения",
is_active=True,
email_verified=True,
send_activation_email=False,
)
supply_department.head = supply_head
supply_department.save(update_fields=["head"])
requestor = Employee.objects.create_user(
email="same-department-requestor@example.com",
password="testpass123",
phone_number="+79998888897",
first_name="Автор",
last_name="Снабжения",
is_active=True,
email_verified=True,
send_activation_email=False,
)
EmployeeDepartment.objects.create(
employee=requestor,
department=supply_department,
is_active=True,
)
procurement_request = ProcurementRequest.objects.create(
title="Внутренняя заявка снабжения",
description="Заказчик и исполнитель совпадают",
department=supply_department,
processing_department=supply_department,
requestor=requestor,
status=ProcurementStatus.WAITING,
urgency=UrgencyLevel.MEDIUM,
)
procurement_item_factory(request=procurement_request)
detail_url = reverse(
'api:v1:procurement:procurementrequest-detail',
kwargs={'pk': procurement_request.id},
)
options_url = reverse(
'api:v1:procurement:procurementrequest-approval-options',
kwargs={'pk': procurement_request.id},
)
submit_url = reverse(
'api:v1:procurement:procurementrequest-submit',
kwargs={'pk': procurement_request.id},
)
api_client.force_authenticate(user=requestor)
requestor_detail = api_client.get(detail_url)
requestor_submit = api_client.post(submit_url)
assert requestor_detail.status_code == status.HTTP_200_OK
assert requestor_detail.data["can_current_user_submit_for_approval"] is False
assert requestor_submit.status_code == status.HTTP_403_FORBIDDEN
api_client.force_authenticate(user=supply_user)
submitter_detail = api_client.get(detail_url)
options_response = api_client.get(options_url)
submit_response = api_client.post(submit_url)
assert submitter_detail.status_code == status.HTTP_200_OK
assert submitter_detail.data["can_current_user_submit_for_approval"] is True
assert options_response.status_code == status.HTTP_200_OK
assert submit_response.status_code == status.HTTP_200_OK
procurement_request.refresh_from_db()
assert procurement_request.status == ProcurementStatus.PENDING
assert procurement_request.approvals.count() == 1
def test_requestor_in_processing_department_cannot_submit_for_approval(
self, api_client, user, supply_department, processing_request,
procurement_item_factory, department_head_approval_route
):
EmployeeDepartment.objects.create(
employee=user,
department=supply_department,
is_active=True,
)
procurement_item_factory(request=processing_request)
api_client.force_authenticate(user=user)
url = reverse(
'api:v1:procurement:procurementrequest-submit',
kwargs={'pk': processing_request.id},
)
response = api_client.post(url)
assert response.status_code == status.HTTP_403_FORBIDDEN
processing_request.refresh_from_db()
assert processing_request.status == ProcurementStatus.WAITING
assert processing_request.approvals.count() == 0
def test_execute_permission_user_outside_processing_department_cannot_submit(
self, api_client, procurement_execute_user, processing_request,
procurement_item_factory, department_head_approval_route
):
procurement_item_factory(request=processing_request)
api_client.force_authenticate(user=procurement_execute_user)
url = reverse(
'api:v1:procurement:procurementrequest-submit',
kwargs={'pk': processing_request.id},
)
response = api_client.post(url)
assert response.status_code == status.HTTP_403_FORBIDDEN
processing_request.refresh_from_db()
assert processing_request.status == ProcurementStatus.WAITING
assert processing_request.approvals.count() == 0
def test_request_without_processing_department_cannot_be_submitted(
self, api_client, user, procurement_request, procurement_item,
department_head_approval_route
):
api_client.force_authenticate(user=user)
url = reverse(
'api:v1:procurement:procurementrequest-submit',
kwargs={'pk': procurement_request.id},
)
response = api_client.post(url)
assert response.status_code == status.HTTP_403_FORBIDDEN
procurement_request.refresh_from_db()
assert procurement_request.status == ProcurementStatus.DRAFT
assert procurement_request.approvals.count() == 0
def test_processing_department_request_cannot_be_submitted_twice_while_pending(
self, api_client, supply_user, processing_request,
procurement_item_factory, department_head_approval_route
@ -3541,9 +3774,14 @@ class TestProcurementRequestWorkflow:
return employee
def test_submit_notifies_only_current_stage_approver(
self, api_client, user, procurement_request, procurement_item, budget, monkeypatch
self, api_client, recipient_user, recipient_department,
procurement_request, procurement_item, budget, monkeypatch
):
"""При submit уведомление должно уйти только текущему этапу."""
prepare_for_recipient_department_submit(
procurement_request,
recipient_department,
)
sent = []
def fake_notify_send(**kwargs):
@ -3554,7 +3792,7 @@ class TestProcurementRequestWorkflow:
fake_notify_send,
)
api_client.force_authenticate(user=user)
api_client.force_authenticate(user=recipient_user)
url = reverse(
'api:v1:procurement:procurementrequest-submit',
kwargs={'pk': procurement_request.id}
@ -3571,9 +3809,13 @@ class TestProcurementRequestWorkflow:
assert pending_recipients == ["head@example.com"]
def test_submit_notifies_department_head_and_role_approvers(
self, api_client, user, department, procurement_request,
procurement_item, monkeypatch
self, api_client, recipient_user, recipient_department, department,
procurement_request, procurement_item, monkeypatch
):
prepare_for_recipient_department_submit(
procurement_request,
recipient_department,
)
role_approver = self._create_department_approval_role_user(
department,
)
@ -3587,7 +3829,7 @@ class TestProcurementRequestWorkflow:
fake_notify_send,
)
api_client.force_authenticate(user=user)
api_client.force_authenticate(user=recipient_user)
url = reverse(
'api:v1:procurement:procurementrequest-submit',
kwargs={'pk': procurement_request.id}
@ -3651,7 +3893,8 @@ class TestProcurementRequestWorkflow:
assert procurement_request.status == ProcurementStatus.APPROVED
def test_department_role_approver_allows_submit_without_head(
self, api_client, user, department
self, api_client, user, recipient_user, recipient_department,
department
):
department.head = None
department.save(update_fields=["head"])
@ -3664,8 +3907,9 @@ class TestProcurementRequestWorkflow:
title="Заявка без начальника, но с ролью",
description="Проверка согласующей роли",
department=department,
processing_department=recipient_department,
requestor=user,
status=ProcurementStatus.DRAFT,
status=ProcurementStatus.WAITING,
urgency=UrgencyLevel.MEDIUM,
)
ProcurementItem.objects.create(
@ -3676,7 +3920,7 @@ class TestProcurementRequestWorkflow:
estimated_unit_price=Decimal("12.00"),
)
api_client.force_authenticate(user=user)
api_client.force_authenticate(user=recipient_user)
url = reverse(
'api:v1:procurement:procurementrequest-submit',
kwargs={'pk': procurement_request.id}
@ -3689,9 +3933,14 @@ class TestProcurementRequestWorkflow:
assert approval.approver_id == role_approver.id
def test_approval_options_include_manual_routes_outside_amount(
self, api_client, user, procurement_request
self, api_client, recipient_user, recipient_department,
procurement_request
):
"""Предпросмотр показывает автоэтапы и ручные этапы вне суммы."""
prepare_for_recipient_department_submit(
procurement_request,
recipient_department,
)
ProcurementItem.objects.create(
request=procurement_request,
name="Недорогая позиция",
@ -3699,7 +3948,7 @@ class TestProcurementRequestWorkflow:
unit="шт",
estimated_unit_price=Decimal("100.00"),
)
api_client.force_authenticate(user=user)
api_client.force_authenticate(user=recipient_user)
url = reverse(
'api:v1:procurement:procurementrequest-approval-options',
kwargs={'pk': procurement_request.id},
@ -3721,9 +3970,14 @@ class TestProcurementRequestWorkflow:
assert available_by_priority[DIRECTOR_PRIORITY]["is_available"] is True
def test_submit_with_manual_approver_bypasses_amount_auto_routes(
self, api_client, user, procurement_request
self, api_client, recipient_user, recipient_department,
procurement_request
):
"""Ручной список используется вместо автоподбора по сумме."""
prepare_for_recipient_department_submit(
procurement_request,
recipient_department,
)
ProcurementItem.objects.create(
request=procurement_request,
name="Недорогая позиция",
@ -3732,7 +3986,7 @@ class TestProcurementRequestWorkflow:
estimated_unit_price=Decimal("100.00"),
)
director = Employee.objects.get(email="director@example.com")
api_client.force_authenticate(user=user)
api_client.force_authenticate(user=recipient_user)
url = reverse(
'api:v1:procurement:procurementrequest-submit',
kwargs={'pk': procurement_request.id},
@ -4069,10 +4323,15 @@ class TestProcurementRequestWorkflow:
]
def test_submit_request(
self, api_client, user, procurement_request, procurement_item, budget
self, api_client, recipient_user, recipient_department,
procurement_request, procurement_item, budget
):
"""Отправка заявки на согласование."""
api_client.force_authenticate(user=user)
prepare_for_recipient_department_submit(
procurement_request,
recipient_department,
)
api_client.force_authenticate(user=recipient_user)
url = reverse(
'api:v1:procurement:procurementrequest-submit',
kwargs={'pk': procurement_request.id}
@ -4105,15 +4364,17 @@ class TestProcurementRequestWorkflow:
],
)
def test_submit_request_applies_amount_thresholds(
self, api_client, user, department, unit_price, expected_priorities
self, api_client, user, recipient_user, recipient_department,
department, unit_price, expected_priorities
):
"""Маршруты согласования включаются по порогам суммы заявки."""
procurement_request = ProcurementRequest.objects.create(
title="Пороговая заявка",
description="Проверка маршрутов",
department=department,
processing_department=recipient_department,
requestor=user,
status=ProcurementStatus.DRAFT,
status=ProcurementStatus.WAITING,
urgency=UrgencyLevel.MEDIUM,
)
ProcurementItem.objects.create(
@ -4124,7 +4385,7 @@ class TestProcurementRequestWorkflow:
estimated_unit_price=unit_price,
)
api_client.force_authenticate(user=user)
api_client.force_authenticate(user=recipient_user)
url = reverse(
'api:v1:procurement:procurementrequest-submit',
kwargs={'pk': procurement_request.id}
@ -4139,15 +4400,17 @@ class TestProcurementRequestWorkflow:
) == expected_priorities
def test_submit_request_applies_amount_thresholds_by_actual_price(
self, api_client, user, department
self, api_client, user, recipient_user, recipient_department,
department
):
"""Если закупщик указал фактическую цену, пороги берутся по ней."""
procurement_request = ProcurementRequest.objects.create(
title="Уточненная пороговая заявка",
description="Проверка маршрутов",
department=department,
processing_department=recipient_department,
requestor=user,
status=ProcurementStatus.DRAFT,
status=ProcurementStatus.WAITING,
urgency=UrgencyLevel.MEDIUM,
)
ProcurementItem.objects.create(
@ -4159,7 +4422,7 @@ class TestProcurementRequestWorkflow:
actual_unit_price=Decimal("120000.00"),
)
api_client.force_authenticate(user=user)
api_client.force_authenticate(user=recipient_user)
url = reverse(
'api:v1:procurement:procurementrequest-submit',
kwargs={'pk': procurement_request.id}
@ -4175,7 +4438,8 @@ class TestProcurementRequestWorkflow:
) == [HEAD_PRIORITY, FINANCE_PRIORITY, DIRECTOR_PRIORITY]
def test_submit_request_without_department_head_returns_explicit_error(
self, api_client, user, department
self, api_client, user, recipient_user, recipient_department,
department
):
"""Если обязательный этап = руководитель отдела, ошибка должна быть явной."""
department.head = None
@ -4185,8 +4449,9 @@ class TestProcurementRequestWorkflow:
title="Заявка без начальника отдела",
description="Проверка понятной ошибки",
department=department,
processing_department=recipient_department,
requestor=user,
status=ProcurementStatus.DRAFT,
status=ProcurementStatus.WAITING,
urgency=UrgencyLevel.MEDIUM,
)
ProcurementItem.objects.create(
@ -4197,7 +4462,7 @@ class TestProcurementRequestWorkflow:
estimated_unit_price=Decimal("12.00"),
)
api_client.force_authenticate(user=user)
api_client.force_authenticate(user=recipient_user)
url = reverse(
'api:v1:procurement:procurementrequest-submit',
kwargs={'pk': procurement_request.id}
@ -4212,10 +4477,15 @@ class TestProcurementRequestWorkflow:
assert response.data["missing_routes"][0]["reason"] == "department_head_missing"
def test_submit_returns_manual_step_name(
self, api_client, user, procurement_request, procurement_item, budget
self, api_client, recipient_user, recipient_department,
procurement_request, procurement_item, budget
):
"""Ручное название этапа сохраняется в согласовании и отдаётся в API."""
api_client.force_authenticate(user=user)
prepare_for_recipient_department_submit(
procurement_request,
recipient_department,
)
api_client.force_authenticate(user=recipient_user)
url = reverse(
'api:v1:procurement:procurementrequest-submit',
kwargs={'pk': procurement_request.id}
@ -4231,10 +4501,15 @@ class TestProcurementRequestWorkflow:
assert approvals[1]['step_label'] == 'Финансовый контроль'
def test_submit_without_items_fails(
self, api_client, user, procurement_request
self, api_client, recipient_user, recipient_department,
procurement_request
):
"""Нельзя отправить заявку без позиций."""
api_client.force_authenticate(user=user)
prepare_for_recipient_department_submit(
procurement_request,
recipient_department,
)
api_client.force_authenticate(user=recipient_user)
url = reverse(
'api:v1:procurement:procurementrequest-submit',
kwargs={'pk': procurement_request.id}
@ -4694,15 +4969,17 @@ class TestProcurementRequestWorkflow:
assert response.data['results'][0]['can_current_user_approve'] is True
def test_higher_route_can_approve_even_if_not_required_by_amount(
self, api_client, user, department
self, api_client, user, recipient_user, recipient_department,
department
):
"""Вышестоящий маршрут может согласовать заявку ниже своего порога."""
procurement_request = ProcurementRequest.objects.create(
title="Низкая сумма для обхода сверху",
description="Проверка согласования верхним маршрутом",
department=department,
processing_department=recipient_department,
requestor=user,
status=ProcurementStatus.DRAFT,
status=ProcurementStatus.WAITING,
urgency=UrgencyLevel.MEDIUM,
)
ProcurementItem.objects.create(
@ -4713,7 +4990,7 @@ class TestProcurementRequestWorkflow:
estimated_unit_price=Decimal("12.00"),
)
api_client.force_authenticate(user=user)
api_client.force_authenticate(user=recipient_user)
submit_url = reverse(
'api:v1:procurement:procurementrequest-submit',
kwargs={'pk': procurement_request.id}
@ -4726,6 +5003,7 @@ class TestProcurementRequestWorkflow:
procurement_request.approvals.order_by('priority').values_list('priority', flat=True)
) == [HEAD_PRIORITY]
api_client.force_authenticate(user=user)
pending_url = reverse('api:v1:procurement:procurementrequest-pending-approvals')
pending_response = api_client.get(pending_url)
assert pending_response.status_code == status.HTTP_200_OK
@ -4749,7 +5027,7 @@ class TestProcurementRequestWorkflow:
ApprovalStatus.APPROVED,
ApprovalStatus.APPROVED,
]
assert procurement_request.status == ProcurementStatus.APPROVED
assert procurement_request.status == ProcurementStatus.WAITING
def test_detail_marks_next_stage_approver_after_previous_stage_complete(
self, api_client, user, department_head, procurement_request, procurement_item

View File

@ -10,6 +10,7 @@ import { AppShell } from "../../components/AppShell";
import { useUser } from "@/contexts/UserContext";
import { apiClient } from "@/lib/api";
import { resolveMediaUrl } from "@/lib/url";
import { getEmployeeActionTone } from "@/lib/users/userDetailUtils";
import type { Chat, User } from "@/types/api";
type PrivateChat = Chat & { member_ids?: number[] };
@ -196,6 +197,7 @@ export default function EmployeesPage() {
const initials = getInitials(employee);
const position = employee.position?.name || "Сотрудник";
const isCurrentUser = currentUser?.id === employee.id;
const personnelState = employee.personnel_state;
return (
<div
@ -223,9 +225,18 @@ export default function EmployeesPage() {
)}
</div>
<div className="min-w-0 flex-1">
<p className="app-text-wrap text-sm font-semibold text-[var(--foreground)]">
{fullName || "Сотрудник"}
</p>
<div className="flex min-w-0 flex-wrap items-center gap-2">
<p className="app-text-wrap min-w-0 text-sm font-semibold text-[var(--foreground)]">
{fullName || "Сотрудник"}
</p>
{personnelState ? (
<span
className={`app-status-pill shrink-0 ${getEmployeeActionTone(personnelState.status).badgeClass}`}
>
{personnelState.label || personnelState.status}
</span>
) : null}
</div>
<p className="app-text-muted text-xs">{position}</p>
{employee.email ? (
<p className="app-text-wrap app-text-muted mt-1 text-xs">

View File

@ -180,6 +180,7 @@ function ProcurementRequestActionButtons({
request,
busyKey,
canManage,
canSubmitForApproval,
isAuthor,
isExecutor,
isFinal,
@ -199,6 +200,7 @@ function ProcurementRequestActionButtons({
request: ProcurementRequest;
busyKey: string | null;
canManage: boolean;
canSubmitForApproval: boolean;
isAuthor: boolean;
isExecutor: boolean;
isFinal: (status?: string) => boolean;
@ -226,7 +228,6 @@ function ProcurementRequestActionButtons({
);
const startWorkLabel = isInProgress ? "Забрать в работу" : "Взять в работу";
const canApproveThis = Boolean(request.can_current_user_approve);
const canSubmitForApproval = Boolean(request.can_current_user_submit_for_approval);
const buttonClass = (variantClass: string) =>
`${variantClass} inline-flex h-9 items-center justify-center rounded-lg disabled:opacity-60 ${
showLabels ? "gap-1.5 px-3 text-xs font-medium" : "w-9"
@ -457,6 +458,35 @@ export default function ProcurementPage() {
const processingDepartmentOptions = processingDepartments.length > 0
? processingDepartments
: departments;
const userDepartmentIds = useMemo(
() => new Set((user?.departments || []).map((department) => Number(department.id))),
[user?.departments],
);
const userDepartmentsKnown = Array.isArray(user?.departments);
const canCurrentUserSubmitRequest = useCallback(
(request: ProcurementRequest) => {
const processingDepartmentId = Number(request.processing_department || 0);
const requestorId = resolveUserId(request.requestor);
const isProcessingDepartmentMember = (
!userDepartmentsKnown ||
userDepartmentIds.has(processingDepartmentId)
);
return Boolean(
request.can_current_user_submit_for_approval &&
user?.id &&
processingDepartmentId &&
requestorId !== user.id &&
isProcessingDepartmentMember
);
},
[
resolveUserId,
user?.id,
userDepartmentIds,
userDepartmentsKnown,
],
);
const toggleStatusFilter = useCallback((status: ProcurementStatus) => {
setStatusFilter((current) => (
@ -549,6 +579,18 @@ export default function ProcurementPage() {
}, []);
const openSubmitApprovalDialog = useCallback(async (requestId: number) => {
const requestForSubmit = detailsCache[requestId] || requests.find((request) => request.id === requestId);
if (requestForSubmit && !canCurrentUserSubmitRequest(requestForSubmit)) {
setSubmitApprovalRequestId(requestId);
setSubmitApprovalOptions(null);
setSubmitApprovalLoading(false);
setSubmitApprovalMode("auto");
setManualApprovalPriorities([]);
setManualApproverByPriority({});
setSubmitApprovalError("Отправить на согласование может только сотрудник отдела-получателя, не автор заявки.");
return;
}
setSubmitApprovalRequestId(requestId);
setSubmitApprovalOptions(null);
setSubmitApprovalError(null);
@ -573,7 +615,11 @@ export default function ProcurementPage() {
} finally {
setSubmitApprovalLoading(false);
}
}, []);
}, [
canCurrentUserSubmitRequest,
detailsCache,
requests,
]);
const toggleManualApprovalPriority = useCallback((priority: number) => {
setSubmitApprovalMode("manual");
@ -1077,11 +1123,12 @@ export default function ProcurementPage() {
const detail = detailsCache[req.id];
const resolvedDetail = detail || req;
const canApproveThis = Boolean((resolvedDetail.can_current_user_approve ?? req.can_current_user_approve));
const canSubmitThis = canCurrentUserSubmitRequest(resolvedDetail);
const canEditThis = Boolean(isEditableStatus && isAuthor);
const canDeleteThis = Boolean(isEditableStatus && (isAuthor || canManage));
const canCancelThis = Boolean(isAuthor && !isFinal(st) && st !== "draft");
const hasInlineRequestActions = Boolean(
(resolvedDetail.can_current_user_submit_for_approval ?? req.can_current_user_submit_for_approval) ||
canSubmitThis ||
canApproveThis ||
(resolvedDetail.can_current_user_start_work ?? req.can_current_user_start_work) ||
(st === "in_progress" && isExecutor)
@ -1350,6 +1397,7 @@ export default function ProcurementPage() {
request={resolvedDetail}
busyKey={busyKey}
canManage={canManage}
canSubmitForApproval={canSubmitThis}
isAuthor={isAuthor}
isExecutor={isExecutor}
isFinal={isFinal}
@ -1437,6 +1485,7 @@ export default function ProcurementPage() {
request={resolvedDetail}
busyKey={busyKey}
canManage={canManage}
canSubmitForApproval={canSubmitThis}
isAuthor={isAuthor}
isExecutor={isExecutor}
isFinal={isFinal}
@ -1487,6 +1536,7 @@ export default function ProcurementPage() {
request={selectedRequest}
busyKey={busyKey}
canManage={canManage}
canSubmitForApproval={canCurrentUserSubmitRequest(selectedRequest)}
isAuthor={Boolean(resolveUserId(selectedRequest.requestor) && user?.id && resolveUserId(selectedRequest.requestor) === user.id)}
isExecutor={Boolean(resolveUserId(selectedRequest.executor) && user?.id && resolveUserId(selectedRequest.executor) === user.id)}
isFinal={isFinal}