feat(procurement): tighten approval flow and request actions
This commit is contained in:
parent
44da9c46c1
commit
af423d8d0a
@ -86,9 +86,6 @@ class CanApproveProcurementRequest(permissions.BasePermission):
|
||||
if not request.user or not request.user.is_authenticated:
|
||||
return False
|
||||
|
||||
if request.user.is_superuser:
|
||||
return True
|
||||
|
||||
return ProcurementApprovalResolver.user_can_approve(request.user, obj)
|
||||
|
||||
|
||||
|
||||
@ -6,6 +6,7 @@ from drf_spectacular.utils import extend_schema_field
|
||||
from rest_framework import serializers
|
||||
|
||||
from procurement.constants import get_default_approval_step_name
|
||||
from procurement.services import ProcurementApprovalResolver
|
||||
from procurement.models import (
|
||||
Approval,
|
||||
Budget,
|
||||
@ -123,6 +124,13 @@ class ProcurementRequestListSerializer(serializers.ModelSerializer):
|
||||
source="executor.get_full_name", read_only=True, allow_null=True
|
||||
)
|
||||
comments_count = serializers.IntegerField(read_only=True, default=0)
|
||||
can_current_user_approve = serializers.SerializerMethodField()
|
||||
|
||||
@extend_schema_field(serializers.BooleanField())
|
||||
def get_can_current_user_approve(self, obj):
|
||||
request = self.context.get("request")
|
||||
user = getattr(request, "user", None)
|
||||
return ProcurementApprovalResolver.user_can_approve(user, obj)
|
||||
|
||||
class Meta:
|
||||
model = ProcurementRequest
|
||||
@ -142,6 +150,7 @@ class ProcurementRequestListSerializer(serializers.ModelSerializer):
|
||||
"total_cost",
|
||||
"items_count",
|
||||
"comments_count",
|
||||
"can_current_user_approve",
|
||||
"created_at",
|
||||
"submitted_at",
|
||||
"started_at",
|
||||
@ -159,6 +168,7 @@ class ProcurementRequestListSerializer(serializers.ModelSerializer):
|
||||
"items_count",
|
||||
"total_cost",
|
||||
"comments_count",
|
||||
"can_current_user_approve",
|
||||
]
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
@ -204,6 +214,13 @@ class ProcurementRequestDetailSerializer(serializers.ModelSerializer):
|
||||
)
|
||||
is_editable = serializers.BooleanField(read_only=True)
|
||||
comments_count = serializers.IntegerField(read_only=True, default=0)
|
||||
can_current_user_approve = serializers.SerializerMethodField()
|
||||
|
||||
@extend_schema_field(serializers.BooleanField())
|
||||
def get_can_current_user_approve(self, obj):
|
||||
request = self.context.get("request")
|
||||
user = getattr(request, "user", None)
|
||||
return ProcurementApprovalResolver.user_can_approve(user, obj)
|
||||
|
||||
class Meta:
|
||||
model = ProcurementRequest
|
||||
@ -229,6 +246,7 @@ class ProcurementRequestDetailSerializer(serializers.ModelSerializer):
|
||||
"required_approval_priorities",
|
||||
"is_editable",
|
||||
"comments_count",
|
||||
"can_current_user_approve",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"submitted_at",
|
||||
@ -246,6 +264,7 @@ class ProcurementRequestDetailSerializer(serializers.ModelSerializer):
|
||||
"total_cost",
|
||||
"executor_name",
|
||||
"comments_count",
|
||||
"can_current_user_approve",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@ -357,12 +357,6 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet):
|
||||
)
|
||||
|
||||
with transaction.atomic():
|
||||
procurement_request.status = ProcurementStatus.PENDING
|
||||
procurement_request.submitted_at = timezone.now()
|
||||
procurement_request.save(
|
||||
update_fields=["status", "submitted_at", "updated_at"]
|
||||
)
|
||||
|
||||
for priority, approver, step_name in resolved_approvals:
|
||||
Approval.objects.create(
|
||||
request=procurement_request,
|
||||
@ -372,10 +366,13 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet):
|
||||
status=ApprovalStatus.PENDING,
|
||||
)
|
||||
|
||||
procurement_request.refresh_from_db()
|
||||
procurement_request.status = ProcurementStatus.PENDING
|
||||
procurement_request.submitted_at = timezone.now()
|
||||
procurement_request.save(
|
||||
update_fields=["status", "submitted_at", "updated_at"]
|
||||
)
|
||||
|
||||
# Уведомления согласующим отправит сигнал post_save(Approval,
|
||||
# created=True)
|
||||
procurement_request.refresh_from_db()
|
||||
|
||||
serializer = self.get_serializer(procurement_request)
|
||||
return Response(serializer.data)
|
||||
|
||||
@ -13,6 +13,19 @@ from .config import NotificationVerbs, MessageTemplates, ActionURLs
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_current_pending_approvals(request):
|
||||
"""Вернуть pending approvals только текущего этапа."""
|
||||
from ..constants import ApprovalStatus
|
||||
|
||||
pending_approvals = request.approvals.filter(
|
||||
status=ApprovalStatus.PENDING
|
||||
).order_by("priority", "created_at", "id")
|
||||
first_pending = pending_approvals.first()
|
||||
if first_pending is None:
|
||||
return pending_approvals.none()
|
||||
return pending_approvals.filter(priority=first_pending.priority)
|
||||
|
||||
|
||||
def notify_new_request(request):
|
||||
"""
|
||||
Уведомить о новой заявке на закупку.
|
||||
@ -54,16 +67,12 @@ def notify_new_request(request):
|
||||
|
||||
def notify_approvers(request):
|
||||
"""
|
||||
Уведомить всех согласующих о необходимости согласования заявки.
|
||||
Уведомить согласующих только текущего этапа.
|
||||
|
||||
Args:
|
||||
request: Объект ProcurementRequest
|
||||
"""
|
||||
from ..constants import ApprovalStatus
|
||||
|
||||
pending_approvals = request.approvals.filter(
|
||||
status=ApprovalStatus.PENDING
|
||||
)
|
||||
pending_approvals = get_current_pending_approvals(request)
|
||||
|
||||
if not pending_approvals.exists():
|
||||
logger.warning(
|
||||
@ -318,6 +327,7 @@ def notify_stage_approved(approval):
|
||||
notification_title,
|
||||
description
|
||||
)
|
||||
notify_approvers(approval.request)
|
||||
|
||||
|
||||
def notify_stage_rejected(approval):
|
||||
|
||||
@ -105,17 +105,15 @@ def create_approval_notifications(sender, instance, created, **kwargs):
|
||||
Создает уведомления при создании или изменении согласования.
|
||||
|
||||
Обрабатывает:
|
||||
1. Новое согласование - уведомление согласующему
|
||||
2. Одобрение - уведомление создателю заявки
|
||||
3. Отклонение - уведомление создателю заявки
|
||||
1. Одобрение - уведомление создателю заявки и следующему этапу
|
||||
2. Отклонение - уведомление создателю заявки
|
||||
"""
|
||||
try:
|
||||
approval = instance
|
||||
|
||||
if created:
|
||||
# Новое согласование создано
|
||||
from .handlers import notify_approver
|
||||
notify_approver(approval)
|
||||
# Нотификацию при submit запускает сигнал заявки, когда уже
|
||||
# созданы все Approval и можно определить только текущий этап.
|
||||
return
|
||||
|
||||
# Проверяем изменение статуса согласования
|
||||
|
||||
@ -202,8 +202,6 @@ class ProcurementApprovalResolver:
|
||||
def user_can_approve(cls, user, procurement_request) -> bool:
|
||||
if not user or not user.is_authenticated:
|
||||
return False
|
||||
if user.is_superuser:
|
||||
return True
|
||||
return cls.get_available_approval(user, procurement_request) is not None
|
||||
|
||||
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.urls import reverse
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
@ -103,6 +104,47 @@ def staff_user(db):
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def superuser(db):
|
||||
"""Суперпользователь без назначения в текущем approval stage."""
|
||||
return Employee.objects.create_user(
|
||||
email="superuser@example.com",
|
||||
password="testpass123",
|
||||
phone_number="+79996666666",
|
||||
first_name="Супер",
|
||||
last_name="Пользователь",
|
||||
is_staff=True,
|
||||
is_superuser=True,
|
||||
is_active=True,
|
||||
email_verified=True,
|
||||
send_activation_email=False,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def approver_with_permission(db, department):
|
||||
"""Пользователь с модельным permission approve_procurementrequest."""
|
||||
employee = Employee.objects.create_user(
|
||||
email="approver@example.com",
|
||||
password="testpass123",
|
||||
phone_number="+79997777777",
|
||||
first_name="Разрешенный",
|
||||
last_name="Согласующий",
|
||||
is_active=True,
|
||||
email_verified=True,
|
||||
send_activation_email=False,
|
||||
)
|
||||
EmployeeDepartment.objects.create(
|
||||
employee=employee,
|
||||
department=department,
|
||||
is_active=True,
|
||||
)
|
||||
employee.user_permissions.add(
|
||||
Permission.objects.get(codename="approve_procurementrequest")
|
||||
)
|
||||
return employee
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def budget(db, department):
|
||||
"""Бюджет отдела."""
|
||||
@ -498,6 +540,338 @@ class TestProcurementRequestWorkflow:
|
||||
employee=director,
|
||||
)
|
||||
|
||||
def test_submit_notifies_only_current_stage_approver(
|
||||
self, api_client, user, procurement_request, procurement_item, budget, monkeypatch
|
||||
):
|
||||
"""При submit уведомление должно уйти только текущему этапу."""
|
||||
sent = []
|
||||
|
||||
def fake_notify_send(**kwargs):
|
||||
sent.append(kwargs)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"procurement.notifications.handlers.notify.send",
|
||||
fake_notify_send,
|
||||
)
|
||||
|
||||
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_200_OK
|
||||
|
||||
pending_recipients = [
|
||||
item["recipient"].email
|
||||
for item in sent
|
||||
if item["verb"] == "procurement_pending_approval"
|
||||
]
|
||||
assert pending_recipients == ["head@example.com"]
|
||||
|
||||
def test_approve_notifies_only_next_stage_approver(
|
||||
self, api_client, department_head, procurement_request, procurement_item, monkeypatch
|
||||
):
|
||||
"""После approve уведомление переходит только следующему этапу."""
|
||||
sent = []
|
||||
|
||||
def fake_notify_send(**kwargs):
|
||||
sent.append(kwargs)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"procurement.notifications.handlers.notify.send",
|
||||
fake_notify_send,
|
||||
)
|
||||
|
||||
procurement_request.status = ProcurementStatus.PENDING
|
||||
procurement_request.save()
|
||||
|
||||
Approval.objects.create(
|
||||
request=procurement_request,
|
||||
approver=department_head,
|
||||
priority=HEAD_PRIORITY,
|
||||
status=ApprovalStatus.PENDING,
|
||||
step_name="Руководитель отдела",
|
||||
)
|
||||
Approval.objects.create(
|
||||
request=procurement_request,
|
||||
approver=Employee.objects.get(email="user@example.com"),
|
||||
priority=FINANCE_PRIORITY,
|
||||
status=ApprovalStatus.PENDING,
|
||||
step_name="Финансовый контроль",
|
||||
)
|
||||
Approval.objects.create(
|
||||
request=procurement_request,
|
||||
approver=Employee.objects.get(email="director@example.com"),
|
||||
priority=DIRECTOR_PRIORITY,
|
||||
status=ApprovalStatus.PENDING,
|
||||
step_name="Финальное одобрение",
|
||||
)
|
||||
sent.clear()
|
||||
|
||||
api_client.force_authenticate(user=department_head)
|
||||
url = reverse(
|
||||
'api:v1:procurement:procurementrequest-approve',
|
||||
kwargs={'pk': procurement_request.id}
|
||||
)
|
||||
|
||||
response = api_client.post(url, {'comment': 'Одобрено'})
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
pending_recipients = [
|
||||
item["recipient"].email
|
||||
for item in sent
|
||||
if item["verb"] == "procurement_pending_approval"
|
||||
]
|
||||
assert pending_recipients == ["user@example.com"]
|
||||
|
||||
def test_final_approve_does_not_notify_future_approvers(
|
||||
self, api_client, approver_with_permission, procurement_request, procurement_item, monkeypatch
|
||||
):
|
||||
"""На финальном approve не должно быть нового pending-уведомления."""
|
||||
sent = []
|
||||
|
||||
def fake_notify_send(**kwargs):
|
||||
sent.append(kwargs)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"procurement.notifications.handlers.notify.send",
|
||||
fake_notify_send,
|
||||
)
|
||||
|
||||
procurement_request.status = ProcurementStatus.PENDING
|
||||
procurement_request.save()
|
||||
|
||||
Approval.objects.create(
|
||||
request=procurement_request,
|
||||
approver=approver_with_permission,
|
||||
priority=HEAD_PRIORITY,
|
||||
status=ApprovalStatus.PENDING,
|
||||
step_name="Финальный этап",
|
||||
)
|
||||
sent.clear()
|
||||
|
||||
api_client.force_authenticate(user=approver_with_permission)
|
||||
url = reverse(
|
||||
'api:v1:procurement:procurementrequest-approve',
|
||||
kwargs={'pk': procurement_request.id}
|
||||
)
|
||||
|
||||
response = api_client.post(url, {'comment': 'Финально одобрено'})
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
pending_notifications = [
|
||||
item for item in sent
|
||||
if item["verb"] == "procurement_pending_approval"
|
||||
]
|
||||
assert pending_notifications == []
|
||||
|
||||
def test_reject_notifies_requestor_without_next_stage_notification(
|
||||
self, api_client, department_head, procurement_request, procurement_item, monkeypatch
|
||||
):
|
||||
"""Reject не должен уведомлять следующий этап, только создателя."""
|
||||
sent = []
|
||||
|
||||
def fake_notify_send(**kwargs):
|
||||
sent.append(kwargs)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"procurement.notifications.handlers.notify.send",
|
||||
fake_notify_send,
|
||||
)
|
||||
|
||||
procurement_request.status = ProcurementStatus.PENDING
|
||||
procurement_request.save()
|
||||
|
||||
Approval.objects.create(
|
||||
request=procurement_request,
|
||||
approver=department_head,
|
||||
priority=HEAD_PRIORITY,
|
||||
status=ApprovalStatus.PENDING,
|
||||
step_name="Руководитель отдела",
|
||||
)
|
||||
Approval.objects.create(
|
||||
request=procurement_request,
|
||||
approver=Employee.objects.get(email="user@example.com"),
|
||||
priority=FINANCE_PRIORITY,
|
||||
status=ApprovalStatus.PENDING,
|
||||
step_name="Финансовый контроль",
|
||||
)
|
||||
sent.clear()
|
||||
|
||||
api_client.force_authenticate(user=department_head)
|
||||
url = reverse(
|
||||
'api:v1:procurement:procurementrequest-reject',
|
||||
kwargs={'pk': procurement_request.id}
|
||||
)
|
||||
|
||||
response = api_client.post(url, {'comment': 'Отклонено'})
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
verbs = [item["verb"] for item in sent]
|
||||
recipients = [item["recipient"].email for item in sent]
|
||||
assert "procurement_rejected" in verbs
|
||||
assert procurement_request.requestor.email in recipients
|
||||
assert "procurement_pending_approval" not in verbs
|
||||
|
||||
def test_cancel_notifies_all_related_approvers(
|
||||
self, api_client, user, department_head, procurement_request, procurement_item, monkeypatch
|
||||
):
|
||||
"""Cancel уведомляет всех причастных согласующих."""
|
||||
sent = []
|
||||
|
||||
def fake_notify_send(**kwargs):
|
||||
sent.append(kwargs)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"procurement.notifications.handlers.notify.send",
|
||||
fake_notify_send,
|
||||
)
|
||||
|
||||
procurement_request.status = ProcurementStatus.PENDING
|
||||
procurement_request.save()
|
||||
|
||||
Approval.objects.create(
|
||||
request=procurement_request,
|
||||
approver=department_head,
|
||||
priority=HEAD_PRIORITY,
|
||||
status=ApprovalStatus.APPROVED,
|
||||
step_name="Руководитель отдела",
|
||||
)
|
||||
Approval.objects.create(
|
||||
request=procurement_request,
|
||||
approver=user,
|
||||
priority=FINANCE_PRIORITY,
|
||||
status=ApprovalStatus.PENDING,
|
||||
step_name="Финансовый контроль",
|
||||
)
|
||||
sent.clear()
|
||||
|
||||
api_client.force_authenticate(user=procurement_request.requestor)
|
||||
url = reverse(
|
||||
'api:v1:procurement:procurementrequest-cancel',
|
||||
kwargs={'pk': procurement_request.id}
|
||||
)
|
||||
|
||||
response = api_client.post(url, {'reason': 'Передумали'})
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
cancelled_recipients = [
|
||||
item["recipient"].email
|
||||
for item in sent
|
||||
if item["verb"] == "procurement_cancelled"
|
||||
]
|
||||
assert cancelled_recipients == ["head@example.com", "user@example.com"]
|
||||
|
||||
def test_start_work_notifies_requestor_and_approved_approvers(
|
||||
self, api_client, user, staff_user, department_head, procurement_request, procurement_item, monkeypatch
|
||||
):
|
||||
"""При взятии в работу уведомляются создатель и уже одобрившие этапы."""
|
||||
sent = []
|
||||
|
||||
def fake_notify_send(**kwargs):
|
||||
sent.append(kwargs)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"procurement.notifications.handlers.notify.send",
|
||||
fake_notify_send,
|
||||
)
|
||||
|
||||
procurement_request.status = ProcurementStatus.APPROVED
|
||||
procurement_request.executor = None
|
||||
procurement_request.save()
|
||||
|
||||
Approval.objects.create(
|
||||
request=procurement_request,
|
||||
approver=department_head,
|
||||
priority=HEAD_PRIORITY,
|
||||
status=ApprovalStatus.APPROVED,
|
||||
step_name="Руководитель отдела",
|
||||
)
|
||||
Approval.objects.create(
|
||||
request=procurement_request,
|
||||
approver=user,
|
||||
priority=FINANCE_PRIORITY,
|
||||
status=ApprovalStatus.APPROVED,
|
||||
step_name="Финансовый контроль",
|
||||
)
|
||||
sent.clear()
|
||||
|
||||
api_client.force_authenticate(user=staff_user)
|
||||
url = reverse(
|
||||
'api:v1:procurement:procurementrequest-start-work',
|
||||
kwargs={'pk': procurement_request.id}
|
||||
)
|
||||
|
||||
response = api_client.post(url)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
in_progress_recipients = [
|
||||
item["recipient"].email
|
||||
for item in sent
|
||||
if item["verb"] == "procurement_in_progress"
|
||||
]
|
||||
assert in_progress_recipients == [
|
||||
procurement_request.requestor.email,
|
||||
"head@example.com",
|
||||
"user@example.com",
|
||||
]
|
||||
|
||||
def test_complete_notifies_requestor_and_approved_approvers(
|
||||
self, api_client, user, staff_user, department_head, procurement_request, procurement_item, monkeypatch
|
||||
):
|
||||
"""При завершении уведомляются создатель и уже одобрившие этапы."""
|
||||
sent = []
|
||||
|
||||
def fake_notify_send(**kwargs):
|
||||
sent.append(kwargs)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"procurement.notifications.handlers.notify.send",
|
||||
fake_notify_send,
|
||||
)
|
||||
|
||||
procurement_request.status = ProcurementStatus.IN_PROGRESS
|
||||
procurement_request.executor = staff_user
|
||||
procurement_request.save()
|
||||
|
||||
Approval.objects.create(
|
||||
request=procurement_request,
|
||||
approver=department_head,
|
||||
priority=HEAD_PRIORITY,
|
||||
status=ApprovalStatus.APPROVED,
|
||||
step_name="Руководитель отдела",
|
||||
)
|
||||
Approval.objects.create(
|
||||
request=procurement_request,
|
||||
approver=user,
|
||||
priority=FINANCE_PRIORITY,
|
||||
status=ApprovalStatus.APPROVED,
|
||||
step_name="Финансовый контроль",
|
||||
)
|
||||
sent.clear()
|
||||
|
||||
api_client.force_authenticate(user=staff_user)
|
||||
url = reverse(
|
||||
'api:v1:procurement:procurementrequest-complete',
|
||||
kwargs={'pk': procurement_request.id}
|
||||
)
|
||||
|
||||
response = api_client.post(url)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
completed_recipients = [
|
||||
item["recipient"].email
|
||||
for item in sent
|
||||
if item["verb"] == "procurement_completed"
|
||||
]
|
||||
assert completed_recipients == [
|
||||
procurement_request.requestor.email,
|
||||
"head@example.com",
|
||||
"user@example.com",
|
||||
]
|
||||
|
||||
def test_submit_request(
|
||||
self, api_client, user, procurement_request, procurement_item, budget
|
||||
):
|
||||
@ -692,6 +1066,261 @@ class TestProcurementRequestWorkflow:
|
||||
response = api_client.post(url, {'comment': 'Рано'})
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
def test_staff_without_pending_stage_cannot_approve(
|
||||
self, api_client, staff_user, department_head, procurement_request, procurement_item
|
||||
):
|
||||
"""Staff без текущего этапа не должен видеть или выполнять approve."""
|
||||
procurement_request.status = ProcurementStatus.PENDING
|
||||
procurement_request.save()
|
||||
|
||||
Approval.objects.create(
|
||||
request=procurement_request,
|
||||
approver=department_head,
|
||||
priority=HEAD_PRIORITY,
|
||||
status=ApprovalStatus.PENDING,
|
||||
)
|
||||
|
||||
list_url = reverse('api:v1:procurement:procurementrequest-list')
|
||||
detail_url = reverse(
|
||||
'api:v1:procurement:procurementrequest-detail',
|
||||
kwargs={'pk': procurement_request.id}
|
||||
)
|
||||
approve_url = reverse(
|
||||
'api:v1:procurement:procurementrequest-approve',
|
||||
kwargs={'pk': procurement_request.id}
|
||||
)
|
||||
|
||||
api_client.force_authenticate(user=staff_user)
|
||||
|
||||
list_response = api_client.get(list_url)
|
||||
assert list_response.status_code == status.HTTP_200_OK
|
||||
assert list_response.data['results'][0]['can_current_user_approve'] is False
|
||||
|
||||
detail_response = api_client.get(detail_url)
|
||||
assert detail_response.status_code == status.HTTP_200_OK
|
||||
assert detail_response.data['can_current_user_approve'] is False
|
||||
|
||||
approve_response = api_client.post(approve_url, {'comment': 'Нельзя'})
|
||||
assert approve_response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
def test_superuser_without_pending_stage_cannot_approve(
|
||||
self, api_client, superuser, department_head, procurement_request, procurement_item
|
||||
):
|
||||
"""Суперпользователь не должен получать approve без назначения на этап."""
|
||||
procurement_request.status = ProcurementStatus.PENDING
|
||||
procurement_request.save()
|
||||
|
||||
Approval.objects.create(
|
||||
request=procurement_request,
|
||||
approver=department_head,
|
||||
priority=HEAD_PRIORITY,
|
||||
status=ApprovalStatus.PENDING,
|
||||
)
|
||||
|
||||
list_url = reverse('api:v1:procurement:procurementrequest-list')
|
||||
detail_url = reverse(
|
||||
'api:v1:procurement:procurementrequest-detail',
|
||||
kwargs={'pk': procurement_request.id}
|
||||
)
|
||||
approve_url = reverse(
|
||||
'api:v1:procurement:procurementrequest-approve',
|
||||
kwargs={'pk': procurement_request.id}
|
||||
)
|
||||
|
||||
api_client.force_authenticate(user=superuser)
|
||||
|
||||
list_response = api_client.get(list_url)
|
||||
assert list_response.status_code == status.HTTP_200_OK
|
||||
assert list_response.data['results'][0]['can_current_user_approve'] is False
|
||||
|
||||
detail_response = api_client.get(detail_url)
|
||||
assert detail_response.status_code == status.HTTP_200_OK
|
||||
assert detail_response.data['can_current_user_approve'] is False
|
||||
|
||||
approve_response = api_client.post(approve_url, {'comment': 'Нельзя'})
|
||||
assert approve_response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
def test_permission_without_pending_stage_cannot_approve(
|
||||
self, api_client, approver_with_permission, department_head, procurement_request, procurement_item
|
||||
):
|
||||
"""Одного model-permission недостаточно без назначения на текущий этап."""
|
||||
procurement_request.status = ProcurementStatus.PENDING
|
||||
procurement_request.save()
|
||||
|
||||
Approval.objects.create(
|
||||
request=procurement_request,
|
||||
approver=department_head,
|
||||
priority=HEAD_PRIORITY,
|
||||
status=ApprovalStatus.PENDING,
|
||||
)
|
||||
|
||||
list_url = reverse('api:v1:procurement:procurementrequest-list')
|
||||
detail_url = reverse(
|
||||
'api:v1:procurement:procurementrequest-detail',
|
||||
kwargs={'pk': procurement_request.id}
|
||||
)
|
||||
approve_url = reverse(
|
||||
'api:v1:procurement:procurementrequest-approve',
|
||||
kwargs={'pk': procurement_request.id}
|
||||
)
|
||||
|
||||
api_client.force_authenticate(user=approver_with_permission)
|
||||
|
||||
list_response = api_client.get(list_url)
|
||||
assert list_response.status_code == status.HTTP_200_OK
|
||||
assert list_response.data['results'][0]['can_current_user_approve'] is False
|
||||
|
||||
detail_response = api_client.get(detail_url)
|
||||
assert detail_response.status_code == status.HTTP_200_OK
|
||||
assert detail_response.data['can_current_user_approve'] is False
|
||||
|
||||
approve_response = api_client.post(approve_url, {'comment': 'Нельзя'})
|
||||
assert approve_response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
def test_permissioned_current_approver_can_approve(
|
||||
self, api_client, approver_with_permission, procurement_request, procurement_item
|
||||
):
|
||||
"""Если пользователь и назначен, и имеет permission, approve доступен."""
|
||||
procurement_request.status = ProcurementStatus.PENDING
|
||||
procurement_request.save()
|
||||
|
||||
Approval.objects.create(
|
||||
request=procurement_request,
|
||||
approver=approver_with_permission,
|
||||
priority=HEAD_PRIORITY,
|
||||
status=ApprovalStatus.PENDING,
|
||||
)
|
||||
|
||||
list_url = reverse('api:v1:procurement:procurementrequest-list')
|
||||
detail_url = reverse(
|
||||
'api:v1:procurement:procurementrequest-detail',
|
||||
kwargs={'pk': procurement_request.id}
|
||||
)
|
||||
approve_url = reverse(
|
||||
'api:v1:procurement:procurementrequest-approve',
|
||||
kwargs={'pk': procurement_request.id}
|
||||
)
|
||||
|
||||
api_client.force_authenticate(user=approver_with_permission)
|
||||
|
||||
list_response = api_client.get(list_url)
|
||||
assert list_response.status_code == status.HTTP_200_OK
|
||||
assert list_response.data['results'][0]['can_current_user_approve'] is True
|
||||
|
||||
detail_response = api_client.get(detail_url)
|
||||
assert detail_response.status_code == status.HTTP_200_OK
|
||||
assert detail_response.data['can_current_user_approve'] is True
|
||||
|
||||
approve_response = api_client.post(approve_url, {'comment': 'Можно'})
|
||||
assert approve_response.status_code == status.HTTP_200_OK
|
||||
|
||||
def test_requestor_can_approve_when_requestor_is_current_approver(
|
||||
self, api_client, user, procurement_request, procurement_item
|
||||
):
|
||||
"""Если заявитель назначен текущим согласующим, approve для него доступен."""
|
||||
procurement_request.status = ProcurementStatus.PENDING
|
||||
procurement_request.save()
|
||||
|
||||
Approval.objects.create(
|
||||
request=procurement_request,
|
||||
approver=user,
|
||||
priority=HEAD_PRIORITY,
|
||||
status=ApprovalStatus.PENDING,
|
||||
)
|
||||
|
||||
list_url = reverse('api:v1:procurement:procurementrequest-list')
|
||||
detail_url = reverse(
|
||||
'api:v1:procurement:procurementrequest-detail',
|
||||
kwargs={'pk': procurement_request.id}
|
||||
)
|
||||
approve_url = reverse(
|
||||
'api:v1:procurement:procurementrequest-approve',
|
||||
kwargs={'pk': procurement_request.id}
|
||||
)
|
||||
|
||||
api_client.force_authenticate(user=user)
|
||||
|
||||
list_response = api_client.get(list_url)
|
||||
assert list_response.status_code == status.HTTP_200_OK
|
||||
assert list_response.data['results'][0]['can_current_user_approve'] is True
|
||||
|
||||
detail_response = api_client.get(detail_url)
|
||||
assert detail_response.status_code == status.HTTP_200_OK
|
||||
assert detail_response.data['can_current_user_approve'] is True
|
||||
|
||||
approve_response = api_client.post(approve_url, {'comment': 'Сам себе согласовал'})
|
||||
assert approve_response.status_code == status.HTTP_200_OK
|
||||
|
||||
procurement_request.refresh_from_db()
|
||||
assert procurement_request.status == ProcurementStatus.APPROVED
|
||||
|
||||
def test_list_marks_only_current_approver_as_can_approve(
|
||||
self, api_client, user, department_head, procurement_request, procurement_item
|
||||
):
|
||||
"""Список должен показывать approve/reject только текущему согласующему."""
|
||||
procurement_request.status = ProcurementStatus.PENDING
|
||||
procurement_request.save()
|
||||
|
||||
Approval.objects.create(
|
||||
request=procurement_request,
|
||||
approver=department_head,
|
||||
priority=HEAD_PRIORITY,
|
||||
status=ApprovalStatus.PENDING,
|
||||
)
|
||||
Approval.objects.create(
|
||||
request=procurement_request,
|
||||
approver=user,
|
||||
priority=FINANCE_PRIORITY,
|
||||
status=ApprovalStatus.PENDING,
|
||||
)
|
||||
|
||||
url = reverse('api:v1:procurement:procurementrequest-list')
|
||||
|
||||
api_client.force_authenticate(user=department_head)
|
||||
head_response = api_client.get(url)
|
||||
assert head_response.status_code == status.HTTP_200_OK
|
||||
assert head_response.data['results'][0]['can_current_user_approve'] is True
|
||||
|
||||
api_client.force_authenticate(user=user)
|
||||
finance_response = api_client.get(url)
|
||||
assert finance_response.status_code == status.HTTP_200_OK
|
||||
assert finance_response.data['results'][0]['can_current_user_approve'] is False
|
||||
|
||||
def test_detail_marks_next_stage_approver_after_previous_stage_complete(
|
||||
self, api_client, user, department_head, procurement_request, procurement_item
|
||||
):
|
||||
"""Detail должен отдавать backend-истину после смены текущего этапа."""
|
||||
procurement_request.status = ProcurementStatus.PENDING
|
||||
procurement_request.save()
|
||||
|
||||
Approval.objects.create(
|
||||
request=procurement_request,
|
||||
approver=department_head,
|
||||
priority=HEAD_PRIORITY,
|
||||
status=ApprovalStatus.APPROVED,
|
||||
)
|
||||
Approval.objects.create(
|
||||
request=procurement_request,
|
||||
approver=user,
|
||||
priority=FINANCE_PRIORITY,
|
||||
status=ApprovalStatus.PENDING,
|
||||
)
|
||||
|
||||
url = reverse(
|
||||
'api:v1:procurement:procurementrequest-detail',
|
||||
kwargs={'pk': procurement_request.id}
|
||||
)
|
||||
|
||||
api_client.force_authenticate(user=department_head)
|
||||
head_response = api_client.get(url)
|
||||
assert head_response.status_code == status.HTTP_200_OK
|
||||
assert head_response.data['can_current_user_approve'] is False
|
||||
|
||||
api_client.force_authenticate(user=user)
|
||||
finance_response = api_client.get(url)
|
||||
assert finance_response.status_code == status.HTTP_200_OK
|
||||
assert finance_response.data['can_current_user_approve'] is True
|
||||
|
||||
def test_reject_request(
|
||||
self, api_client, department_head, procurement_request,
|
||||
procurement_item
|
||||
|
||||
2
frontend/next-env.d.ts
vendored
2
frontend/next-env.d.ts
vendored
@ -1,6 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
@ -593,14 +593,22 @@ function EquipmentPageContent() {
|
||||
<article id={`equipment-${item.id}`} key={item.id} className={`app-surface-muted rounded-xl transition hover:border-[var(--border-strong)] ${equipmentMenuOpenId === item.id ? "relative z-20 overflow-visible" : "overflow-hidden"}`}>
|
||||
<div className="px-4 py-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleRow(item.id)}
|
||||
aria-label={rowOpen ? "Свернуть детали" : "Развернуть детали"}
|
||||
className="app-action-secondary mt-0.5 inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg"
|
||||
>
|
||||
<ChevronDown size={15} className={`transition ${rowOpen ? "rotate-180" : ""}`} />
|
||||
</button>
|
||||
<div className="flex shrink-0 flex-col items-center gap-3 pt-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleRow(item.id)}
|
||||
aria-label={rowOpen ? "Свернуть детали" : "Развернуть детали"}
|
||||
className="app-action-secondary inline-flex h-8 w-8 items-center justify-center rounded-lg"
|
||||
>
|
||||
<ChevronDown size={15} className={`transition ${rowOpen ? "rotate-180" : ""}`} />
|
||||
</button>
|
||||
<button type="button" title={`Комментарии (${commentsTotal})`} onClick={() => toggleComments(item.id)} className="app-action-secondary relative inline-flex h-8 w-8 items-center justify-center rounded-lg">
|
||||
<MessageSquare size={15} />
|
||||
{commentsTotal > 0 && (
|
||||
<span className="app-counter absolute -right-1 -top-1 inline-flex min-w-4 items-center justify-center px-1 py-0.5 text-[10px] font-bold text-white">{commentsTotal}</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
@ -702,14 +710,6 @@ function EquipmentPageContent() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center gap-1.5">
|
||||
<button type="button" title={`Комментарии (${commentsTotal})`} onClick={() => toggleComments(item.id)} className="app-action-secondary relative inline-flex h-8 w-8 items-center justify-center rounded-lg">
|
||||
<MessageSquare size={15} />
|
||||
{commentsTotal > 0 && (
|
||||
<span className="app-counter absolute -right-1 -top-1 inline-flex min-w-4 items-center justify-center px-1 py-0.5 text-[10px] font-bold text-white">{commentsTotal}</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -86,6 +86,7 @@ const periodOptions = [
|
||||
|
||||
const fmt = formatDate;
|
||||
const money = formatMoney;
|
||||
type RequestActionDialogKind = "approve" | "reject" | "cancel" | "delete";
|
||||
|
||||
const getReadableError = (error: unknown, fallback: string): string => {
|
||||
const raw = String((error as Error)?.message || fallback);
|
||||
@ -114,6 +115,7 @@ function ProcurementRequestActionButtons({
|
||||
isAuthor,
|
||||
isExecutor,
|
||||
isFinal,
|
||||
showApprovalActions = true,
|
||||
showSecondaryActions = true,
|
||||
onSubmit,
|
||||
onEdit,
|
||||
@ -130,21 +132,23 @@ function ProcurementRequestActionButtons({
|
||||
isAuthor: boolean;
|
||||
isExecutor: boolean;
|
||||
isFinal: (status?: string) => boolean;
|
||||
showApprovalActions?: boolean;
|
||||
showSecondaryActions?: boolean;
|
||||
onSubmit: (id: number) => void | Promise<void>;
|
||||
onSubmit: (id: number) => void | Promise<unknown>;
|
||||
onEdit: (request: ProcurementRequest) => void;
|
||||
onDelete: (id: number) => void | Promise<void>;
|
||||
onApprove: (id: number) => void | Promise<void>;
|
||||
onReject: (id: number) => void | Promise<void>;
|
||||
onStart: (id: number) => void | Promise<void>;
|
||||
onComplete: (id: number) => void | Promise<void>;
|
||||
onCancel: (id: number) => void | Promise<void>;
|
||||
onDelete: (id: number) => void | Promise<unknown>;
|
||||
onApprove: (id: number) => void | Promise<unknown>;
|
||||
onReject: (id: number) => void | Promise<unknown>;
|
||||
onStart: (id: number) => void | Promise<unknown>;
|
||||
onComplete: (id: number) => void | Promise<unknown>;
|
||||
onCancel: (id: number) => void | Promise<unknown>;
|
||||
}) {
|
||||
const status = String(request.status || "").toLowerCase();
|
||||
const isDraft = status === "draft";
|
||||
const isPending = status === "pending";
|
||||
const isApproved = status === "approved";
|
||||
const isInProgress = status === "in_progress";
|
||||
const canApproveThis = Boolean(request.can_current_user_approve);
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1.5 pt-1">
|
||||
@ -180,7 +184,7 @@ function ProcurementRequestActionButtons({
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
) : null}
|
||||
{isPending && !isAuthor ? (
|
||||
{showApprovalActions && isPending && canApproveThis ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
@ -323,6 +327,8 @@ export default function ProcurementPage() {
|
||||
const [detailModalError, setDetailModalError] = useState<string | null>(null);
|
||||
const procurementMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
const [procurementMenuOpenId, setProcurementMenuOpenId] = useState<number | null>(null);
|
||||
const [requestActionDialog, setRequestActionDialog] = useState<{ kind: RequestActionDialogKind; requestId: number } | null>(null);
|
||||
const [requestActionComment, setRequestActionComment] = useState("");
|
||||
|
||||
const syncRequestUrl = useCallback((requestId: number | null) => {
|
||||
if (typeof window === "undefined") return;
|
||||
@ -342,12 +348,62 @@ export default function ProcurementPage() {
|
||||
return detailsCache[detailModalId] || requests.find((request) => request.id === detailModalId) || null;
|
||||
}, [detailModalId, detailsCache, requests]);
|
||||
|
||||
const selectedActionRequest = useMemo(() => {
|
||||
if (!requestActionDialog) return null;
|
||||
return detailsCache[requestActionDialog.requestId] || requests.find((request) => request.id === requestActionDialog.requestId) || null;
|
||||
}, [detailsCache, requestActionDialog, requests]);
|
||||
|
||||
const closeDetailModal = useCallback(() => {
|
||||
setDetailModalId(null);
|
||||
setDetailModalError(null);
|
||||
syncRequestUrl(null);
|
||||
}, [syncRequestUrl]);
|
||||
|
||||
const openRequestActionDialog = useCallback((kind: RequestActionDialogKind, requestId: number) => {
|
||||
setRequestActionComment("");
|
||||
setRequestActionDialog({ kind, requestId });
|
||||
}, []);
|
||||
|
||||
const closeRequestActionDialog = useCallback(() => {
|
||||
setRequestActionDialog(null);
|
||||
setRequestActionComment("");
|
||||
}, []);
|
||||
|
||||
const submitRequestActionDialog = useCallback(async () => {
|
||||
if (!requestActionDialog) return;
|
||||
|
||||
const { kind, requestId } = requestActionDialog;
|
||||
const payload = requestActionComment.trim();
|
||||
let success = false;
|
||||
|
||||
if (kind === "approve") {
|
||||
success = await handleApprove(requestId, payload);
|
||||
} else if (kind === "reject") {
|
||||
success = await handleReject(requestId, payload);
|
||||
} else if (kind === "cancel") {
|
||||
success = await handleCancel(requestId, payload);
|
||||
} else if (kind === "delete") {
|
||||
success = await handleDelete(requestId);
|
||||
}
|
||||
|
||||
if (success) {
|
||||
closeRequestActionDialog();
|
||||
if (detailModalId === requestId && kind === "delete") {
|
||||
closeDetailModal();
|
||||
}
|
||||
}
|
||||
}, [
|
||||
closeDetailModal,
|
||||
closeRequestActionDialog,
|
||||
detailModalId,
|
||||
handleApprove,
|
||||
handleCancel,
|
||||
handleDelete,
|
||||
handleReject,
|
||||
requestActionComment,
|
||||
requestActionDialog,
|
||||
]);
|
||||
|
||||
const openDetailModal = useCallback(async (requestId: number) => {
|
||||
setDetailModalLoading(true);
|
||||
setDetailModalError(null);
|
||||
@ -567,16 +623,16 @@ export default function ProcurementPage() {
|
||||
const isAuthor = Boolean(resolveUserId(req.requestor) && user?.id && resolveUserId(req.requestor) === user.id);
|
||||
const isExecutor = Boolean(resolveUserId(req.executor) && user?.id && resolveUserId(req.executor) === user.id);
|
||||
const isDraft = st === "draft";
|
||||
const isPending = st === "pending";
|
||||
const isApproved = st === "approved";
|
||||
const isInProgress = st === "in_progress";
|
||||
const detail = detailsCache[req.id];
|
||||
const resolvedDetail = detail || req;
|
||||
const canApproveThis = Boolean((resolvedDetail.can_current_user_approve ?? req.can_current_user_approve));
|
||||
const canEditThis = Boolean(isDraft && isAuthor);
|
||||
const canDeleteThis = Boolean(isDraft && (isAuthor || canManage));
|
||||
const canCancelThis = Boolean(isAuthor && !isFinal(st) && st !== "draft");
|
||||
const hasSecondaryActions = canEditThis || canDeleteThis || canCancelThis;
|
||||
const expanded = expandedIds.has(req.id);
|
||||
const detail = detailsCache[req.id];
|
||||
const resolvedDetail = detail || req;
|
||||
const comments = commentsMap[req.id] || [];
|
||||
const commentsOpen = Boolean(expandedComments[req.id]);
|
||||
const commentsTotal = resolvedDetail.comments_count ?? req.comments_count ?? comments.length;
|
||||
@ -591,14 +647,27 @@ export default function ProcurementPage() {
|
||||
<article key={req.id} className={`app-surface-muted rounded-xl transition hover:border-[var(--border-strong)] ${procurementMenuOpenId === req.id ? "relative z-20 overflow-visible" : "overflow-hidden"}`}>
|
||||
<div className="px-4 py-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleExpand(req.id)}
|
||||
aria-label={expanded ? "Свернуть детали" : "Развернуть детали"}
|
||||
className="app-action-secondary mt-0.5 inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg"
|
||||
>
|
||||
<ChevronDown size={15} className={`transition ${expanded ? "rotate-180" : ""}`} />
|
||||
</button>
|
||||
<div className="flex shrink-0 flex-col items-center gap-3 pt-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleExpand(req.id)}
|
||||
aria-label={expanded ? "Свернуть детали" : "Развернуть детали"}
|
||||
className="app-action-secondary inline-flex h-8 w-8 items-center justify-center rounded-lg"
|
||||
>
|
||||
<ChevronDown size={15} className={`transition ${expanded ? "rotate-180" : ""}`} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title={`Комментарии (${commentsTotal})`}
|
||||
onClick={() => void toggleComments(req.id)}
|
||||
className="app-action-secondary relative inline-flex h-8 w-8 items-center justify-center rounded-lg"
|
||||
>
|
||||
<MessageSquare size={15} />
|
||||
{commentsTotal > 0 && (
|
||||
<span className="app-counter absolute -right-1 -top-1 inline-flex min-w-4 items-center justify-center px-1 py-0.5 text-[10px] font-bold text-white">{commentsTotal}</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
@ -651,7 +720,7 @@ export default function ProcurementPage() {
|
||||
disabled={busyKey === `cancel-${req.id}`}
|
||||
onClick={() => {
|
||||
setProcurementMenuOpenId(null);
|
||||
void handleCancel(req.id);
|
||||
openRequestActionDialog("cancel", req.id);
|
||||
}}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)] disabled:opacity-50"
|
||||
>
|
||||
@ -678,7 +747,7 @@ export default function ProcurementPage() {
|
||||
disabled={busyKey === `delete-${req.id}`}
|
||||
onClick={() => {
|
||||
setProcurementMenuOpenId(null);
|
||||
void handleDelete(req.id);
|
||||
openRequestActionDialog("delete", req.id);
|
||||
}}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--danger-foreground)] transition hover:bg-[var(--danger-soft)] disabled:opacity-50"
|
||||
>
|
||||
@ -728,14 +797,31 @@ export default function ProcurementPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center gap-1.5">
|
||||
<button type="button" title={`Комментарии (${commentsTotal})`} onClick={() => void toggleComments(req.id)} className="app-action-secondary relative inline-flex h-8 w-8 items-center justify-center rounded-lg">
|
||||
<MessageSquare size={15} />
|
||||
{commentsTotal > 0 && (
|
||||
<span className="app-counter absolute -right-1 -top-1 inline-flex min-w-4 items-center justify-center px-1 py-0.5 text-[10px] font-bold text-white">{commentsTotal}</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{canApproveThis ? (
|
||||
<div className="mt-3 flex flex-wrap items-center gap-1.5">
|
||||
<span className="ml-auto inline-flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
title="Одобрить"
|
||||
onClick={() => openRequestActionDialog("approve", req.id)}
|
||||
disabled={busyKey === `approve-${req.id}`}
|
||||
className="app-feedback-success inline-flex items-center justify-center rounded-lg p-2 disabled:opacity-60"
|
||||
>
|
||||
<ThumbsUp size={18} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title="Отклонить"
|
||||
onClick={() => openRequestActionDialog("reject", req.id)}
|
||||
disabled={busyKey === `reject-${req.id}`}
|
||||
className="app-action-danger inline-flex items-center justify-center rounded-lg p-2 disabled:opacity-60"
|
||||
>
|
||||
<ThumbsDown size={18} />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -754,6 +840,7 @@ export default function ProcurementPage() {
|
||||
isAuthor={isAuthor}
|
||||
isExecutor={isExecutor}
|
||||
isFinal={isFinal}
|
||||
showApprovalActions={false}
|
||||
showSecondaryActions={false}
|
||||
onSubmit={handleSubmit}
|
||||
onEdit={openEdit}
|
||||
@ -894,12 +981,12 @@ export default function ProcurementPage() {
|
||||
isFinal={isFinal}
|
||||
onSubmit={handleSubmit}
|
||||
onEdit={openEdit}
|
||||
onDelete={handleDelete}
|
||||
onApprove={handleApprove}
|
||||
onReject={handleReject}
|
||||
onDelete={(id) => openRequestActionDialog("delete", id)}
|
||||
onApprove={(id) => openRequestActionDialog("approve", id)}
|
||||
onReject={(id) => openRequestActionDialog("reject", id)}
|
||||
onStart={handleStart}
|
||||
onComplete={handleComplete}
|
||||
onCancel={handleCancel}
|
||||
onCancel={(id) => openRequestActionDialog("cancel", id)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
@ -944,6 +1031,95 @@ export default function ProcurementPage() {
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
isOpen={requestActionDialog !== null}
|
||||
onClose={closeRequestActionDialog}
|
||||
title={
|
||||
requestActionDialog?.kind === "approve" ? "Одобрить заявку"
|
||||
: requestActionDialog?.kind === "reject" ? "Отклонить заявку"
|
||||
: requestActionDialog?.kind === "cancel" ? "Отменить заявку"
|
||||
: "Удалить заявку"
|
||||
}
|
||||
size="md"
|
||||
closeOnClickOutside
|
||||
>
|
||||
<div className="space-y-4 pb-1">
|
||||
{selectedActionRequest ? (
|
||||
<div className="app-surface-muted rounded-xl px-4 py-3">
|
||||
<p className="text-sm font-semibold text-[var(--foreground)]">{selectedActionRequest.title || "Без названия"}</p>
|
||||
<p className="app-text-muted mt-1 text-xs">
|
||||
{getDeptName(selectedActionRequest)}{getRequestAmount(selectedActionRequest) ? ` • ${money(getRequestAmount(selectedActionRequest))}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{actionError ? (
|
||||
<div className="app-feedback-danger rounded-xl px-4 py-3 text-sm">
|
||||
{actionError}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{requestActionDialog?.kind === "delete" ? (
|
||||
<p className="app-text-muted text-sm">
|
||||
Удалить эту заявку? Действие доступно только для черновиков и будет необратимым.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<label className="app-card-caption">
|
||||
{requestActionDialog?.kind === "approve"
|
||||
? "Комментарий к одобрению"
|
||||
: requestActionDialog?.kind === "reject"
|
||||
? "Комментарий к отклонению"
|
||||
: "Причина отмены"}
|
||||
</label>
|
||||
<textarea
|
||||
value={requestActionComment}
|
||||
onChange={(event) => setRequestActionComment(event.target.value)}
|
||||
rows={4}
|
||||
placeholder={
|
||||
requestActionDialog?.kind === "approve"
|
||||
? "Необязательно"
|
||||
: requestActionDialog?.kind === "reject"
|
||||
? "Необязательно, но желательно"
|
||||
: "Необязательно"
|
||||
}
|
||||
className="app-input app-text-wrap min-h-28 w-full rounded-xl px-3 py-2.5 text-sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<button type="button" onClick={closeRequestActionDialog} className="app-action-secondary rounded-xl px-4 py-2.5 text-sm font-medium">
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void submitRequestActionDialog()}
|
||||
disabled={
|
||||
requestActionDialog?.kind === "approve" ? busyKey === `approve-${requestActionDialog.requestId}`
|
||||
: requestActionDialog?.kind === "reject" ? busyKey === `reject-${requestActionDialog.requestId}`
|
||||
: requestActionDialog?.kind === "cancel" ? busyKey === `cancel-${requestActionDialog.requestId}`
|
||||
: requestActionDialog?.kind === "delete" ? busyKey === `delete-${requestActionDialog.requestId}`
|
||||
: false
|
||||
}
|
||||
className={`rounded-xl px-4 py-2.5 text-sm font-medium disabled:opacity-60 ${
|
||||
requestActionDialog?.kind === "delete" || requestActionDialog?.kind === "reject"
|
||||
? "app-action-danger"
|
||||
: "app-action-primary"
|
||||
}`}
|
||||
>
|
||||
{requestActionDialog?.kind === "approve"
|
||||
? "Одобрить"
|
||||
: requestActionDialog?.kind === "reject"
|
||||
? "Отклонить"
|
||||
: requestActionDialog?.kind === "cancel"
|
||||
? "Отменить заявку"
|
||||
: "Удалить"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* ══════════ Create / Edit modal ══════════ */}
|
||||
<Modal isOpen={isModalOpen} onClose={closeModal} title={modalMode === "create" ? "Новая заявка на закупку" : "Редактировать заявку"} size="lg" footer={
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
|
||||
@ -268,7 +268,13 @@ function RequestsPageContent() {
|
||||
<article key={item.id} className={`app-surface-muted rounded-xl transition hover:border-[var(--border-strong)] ${requestMenuOpenId === item.id ? "relative z-20 overflow-visible" : "overflow-hidden"}`}>
|
||||
<div className="p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<button type="button" onClick={() => h.toggleRow(item.id)} className="app-action-secondary mt-0.5 inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg transition"><ChevronDown size={15} className={`transition ${rowOpen ? "rotate-180" : ""}`} /></button>
|
||||
<div className="flex shrink-0 flex-col items-center gap-3 pt-0.5">
|
||||
<button type="button" onClick={() => h.toggleRow(item.id)} className="app-action-secondary inline-flex h-8 w-8 items-center justify-center rounded-lg transition"><ChevronDown size={15} className={`transition ${rowOpen ? "rotate-180" : ""}`} /></button>
|
||||
<button type="button" title={`Комментарии (${item.comments_count ?? comments.length})`} onClick={() => h.toggleComments(item.id)} className="app-action-secondary relative inline-flex h-8 w-8 items-center justify-center rounded-lg">
|
||||
<MessageSquare size={15} />
|
||||
{(item.comments_count ?? comments.length) > 0 && <span className="app-counter absolute -right-1.5 -top-1.5 flex h-4 min-w-4 px-1 text-[10px] font-bold">{item.comments_count ?? comments.length}</span>}
|
||||
</button>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
@ -360,10 +366,7 @@ function RequestsPageContent() {
|
||||
</div>
|
||||
</div>
|
||||
{summary && <p className={`${rowOpen ? "app-text-wrap line-clamp-10" : "app-text-wrap line-clamp-3"} mt-3 text-sm text-[var(--foreground)]`}>{summary}</p>}
|
||||
<div className={`${summary ? "mt-3" : "mt-2"} flex flex-wrap items-center gap-1.5`}>
|
||||
<button type="button" title={`Комментарии (${item.comments_count ?? comments.length})`} onClick={() => h.toggleComments(item.id)} className="app-action-secondary relative inline-flex items-center justify-center rounded-lg p-1.5"><MessageSquare size={15} />{(item.comments_count ?? comments.length) > 0 && <span className="app-counter absolute -right-1.5 -top-1.5 flex h-4 min-w-4 px-1 text-[10px] font-bold">{item.comments_count ?? comments.length}</span>}</button>
|
||||
{canPr && <span className="ml-auto inline-flex items-center gap-2"><button type="button" title="Одобрить" onClick={() => h.handleApprove(item.id)} disabled={h.busyKey === `approve-${item.id}`} className="app-feedback-success inline-flex items-center justify-center rounded-lg p-2 disabled:opacity-60"><ThumbsUp size={18} /></button><button type="button" title="Отклонить" onClick={() => h.handleReject(item.id)} disabled={h.busyKey === `reject-${item.id}`} className="app-action-danger inline-flex items-center justify-center rounded-lg p-2 disabled:opacity-60"><ThumbsDown size={18} /></button></span>}
|
||||
</div>
|
||||
{canPr && <div className={`${summary ? "mt-3" : "mt-2"} flex flex-wrap items-center gap-1.5`}><span className="ml-auto inline-flex items-center gap-2"><button type="button" title="Одобрить" onClick={() => h.handleApprove(item.id)} disabled={h.busyKey === `approve-${item.id}`} className="app-feedback-success inline-flex items-center justify-center rounded-lg p-2 disabled:opacity-60"><ThumbsUp size={18} /></button><button type="button" title="Отклонить" onClick={() => h.handleReject(item.id)} disabled={h.busyKey === `reject-${item.id}`} className="app-action-danger inline-flex items-center justify-center rounded-lg p-2 disabled:opacity-60"><ThumbsDown size={18} /></button></span></div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -485,8 +485,10 @@ export function useProcurementPage(user: User | null) {
|
||||
await action();
|
||||
setActionSuccess(successMessage);
|
||||
await refreshOne(id);
|
||||
return true;
|
||||
} catch (actionErrorValue) {
|
||||
setActionError(getReadableError(actionErrorValue, "Ошибка"));
|
||||
return false;
|
||||
} finally {
|
||||
setBusyKey(null);
|
||||
}
|
||||
@ -625,15 +627,11 @@ export function useProcurementPage(user: User | null) {
|
||||
"Заявка отправлена на согласование.",
|
||||
), [doAction]);
|
||||
|
||||
const handleApprove = useCallback((id: number) => {
|
||||
const comment = window.prompt("Комментарий к одобрению (необязательно)", "");
|
||||
if (comment === null) return;
|
||||
const handleApprove = useCallback((id: number, comment = "") => {
|
||||
return doAction(`approve-${id}`, () => apiClient.approveProcurementRequest(id, comment), id, "Заявка одобрена.");
|
||||
}, [doAction]);
|
||||
|
||||
const handleReject = useCallback((id: number) => {
|
||||
const comment = window.prompt("Комментарий к отклонению", "");
|
||||
if (comment === null) return;
|
||||
const handleReject = useCallback((id: number, comment = "") => {
|
||||
return doAction(`reject-${id}`, () => apiClient.rejectProcurementRequest(id, comment), id, "Заявка отклонена.");
|
||||
}, [doAction]);
|
||||
|
||||
@ -651,17 +649,14 @@ export function useProcurementPage(user: User | null) {
|
||||
"Заявка завершена.",
|
||||
), [doAction]);
|
||||
|
||||
const handleCancel = useCallback((id: number) => {
|
||||
const reason = window.prompt("Причина отмены", "");
|
||||
if (reason === null) return;
|
||||
const handleCancel = useCallback((id: number, reason = "") => {
|
||||
return doAction(`cancel-${id}`, () => apiClient.cancelProcurementRequest(id, reason), id, "Заявка отменена.");
|
||||
}, [doAction]);
|
||||
|
||||
const handleDelete = useCallback(async (id: number) => {
|
||||
if (!window.confirm("Удалить эту заявку? Доступно только для черновиков.")) return;
|
||||
|
||||
try {
|
||||
setBusyKey(`delete-${id}`);
|
||||
setActionError(null);
|
||||
await apiClient.deleteProcurementRequest(id);
|
||||
setRequests((previous) => previous.filter((request) => request.id !== id));
|
||||
setDetailsCache((previous) => {
|
||||
@ -670,8 +665,11 @@ export function useProcurementPage(user: User | null) {
|
||||
detailsCacheRef.current = next;
|
||||
return next;
|
||||
});
|
||||
setActionSuccess("Заявка удалена.");
|
||||
return true;
|
||||
} catch (deleteError) {
|
||||
setActionError(getReadableError(deleteError, "Не удалось удалить"));
|
||||
return false;
|
||||
} finally {
|
||||
setBusyKey(null);
|
||||
}
|
||||
|
||||
@ -455,6 +455,7 @@ export interface ProcurementRequest {
|
||||
total_cost?: string | number;
|
||||
total_estimated_cost?: string | number;
|
||||
comments_count?: number;
|
||||
can_current_user_approve?: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
submitted_at?: string | null;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user