feat(procurement): tighten approval flow and request actions

This commit is contained in:
lizerk 2026-04-13 02:55:26 +03:00
parent 44da9c46c1
commit af423d8d0a
13 changed files with 918 additions and 92 deletions

View File

@ -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)

View File

@ -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",
]

View File

@ -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)

View File

@ -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):

View File

@ -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
# Проверяем изменение статуса согласования

View File

@ -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

View File

@ -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

View File

@ -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.

View File

@ -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>

View File

@ -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">

View File

@ -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>

View File

@ -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);
}

View File

@ -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;