Add subscription and project-aware procurement workflows
This commit is contained in:
parent
5ded148251
commit
18d170ffd2
@ -17,6 +17,10 @@ class ProcurementRequestCreateOptionsSerializer(serializers.Serializer):
|
||||
|
||||
processing_departments = DepartmentBriefSerializer(many=True)
|
||||
default_processing_department = serializers.IntegerField(allow_null=True)
|
||||
subscription_processing_departments = DepartmentBriefSerializer(many=True)
|
||||
subscription_default_processing_department = serializers.IntegerField(
|
||||
allow_null=True
|
||||
)
|
||||
|
||||
|
||||
class ProcurementManualApprovalStepSerializer(serializers.Serializer):
|
||||
|
||||
@ -6,7 +6,7 @@ from datetime import date
|
||||
from rest_framework import serializers
|
||||
|
||||
from procurement.constants import ProcurementItemExecutionStatus
|
||||
from procurement.models import ProcurementSettings
|
||||
from procurement.models import ProcurementRequest, ProcurementSettings
|
||||
|
||||
PROBLEM_ITEM_STATUSES = {
|
||||
ProcurementItemExecutionStatus.REJECTED,
|
||||
@ -85,23 +85,27 @@ def _expected_delivery_date_values(item):
|
||||
return dates
|
||||
|
||||
|
||||
def _validate_processing_department_available(processing_department):
|
||||
def _validate_processing_department_available(
|
||||
processing_department,
|
||||
request_type=ProcurementRequest.RequestType.STANDARD,
|
||||
):
|
||||
if not processing_department:
|
||||
return
|
||||
|
||||
settings = ProcurementSettings.get_solo()
|
||||
available_departments = settings.available_processing_departments.all()
|
||||
if request_type == ProcurementRequest.RequestType.SUBSCRIPTION:
|
||||
available_departments = (
|
||||
settings.subscription_available_processing_departments.all()
|
||||
)
|
||||
error_message = "Выберите отдел-исполнитель из допустимых для подписок."
|
||||
else:
|
||||
available_departments = settings.available_processing_departments.all()
|
||||
error_message = "Выберите отдел-исполнитель из доступных для закупок."
|
||||
if (
|
||||
available_departments.exists()
|
||||
and not available_departments.filter(pk=processing_department.pk).exists()
|
||||
):
|
||||
raise serializers.ValidationError(
|
||||
{
|
||||
"processing_department": (
|
||||
"Выберите отдел-исполнитель из доступных для закупок."
|
||||
)
|
||||
}
|
||||
)
|
||||
raise serializers.ValidationError({"processing_department": error_message})
|
||||
|
||||
|
||||
def _request_items(obj):
|
||||
|
||||
@ -20,6 +20,8 @@ from .common import (
|
||||
|
||||
class ProcurementRequestSummaryMixin(serializers.Serializer):
|
||||
is_viewed = serializers.SerializerMethodField()
|
||||
is_deferred = serializers.SerializerMethodField()
|
||||
project_names = serializers.SerializerMethodField()
|
||||
last_arrival_notice_at = serializers.DateTimeField(
|
||||
read_only=True,
|
||||
allow_null=True,
|
||||
@ -56,6 +58,27 @@ class ProcurementRequestSummaryMixin(serializers.Serializer):
|
||||
is_viewed=True,
|
||||
).exists()
|
||||
|
||||
@extend_schema_field(serializers.BooleanField())
|
||||
def get_is_deferred(self, obj):
|
||||
annotated_value = getattr(obj, "is_deferred_for_current_user", None)
|
||||
if annotated_value is not None:
|
||||
return bool(annotated_value)
|
||||
|
||||
request = self.context.get("request")
|
||||
user = getattr(request, "user", None)
|
||||
if not user or not user.is_authenticated:
|
||||
return False
|
||||
|
||||
return ProcurementRequestView.objects.filter(
|
||||
request=obj,
|
||||
user=user,
|
||||
is_deferred=True,
|
||||
).exists()
|
||||
|
||||
@extend_schema_field(serializers.ListField(child=serializers.CharField()))
|
||||
def get_project_names(self, obj):
|
||||
return [project.name for project in obj.projects.all()]
|
||||
|
||||
@extend_schema_field(serializers.DateField(allow_null=True))
|
||||
def get_next_expected_delivery_date(self, obj):
|
||||
items = _request_items(obj)
|
||||
|
||||
@ -8,6 +8,8 @@ from procurement.application import (
|
||||
update_procurement_request_with_items,
|
||||
)
|
||||
from procurement.models import ProcurementRequest
|
||||
from projects.models import Project
|
||||
from projects.permissions import visible_projects
|
||||
|
||||
from .approvals import ApprovalSerializer
|
||||
from .common import (
|
||||
@ -58,11 +60,27 @@ class ProcurementRequestListSerializer(
|
||||
source="executor.get_full_name", read_only=True, allow_null=True
|
||||
)
|
||||
comments_count = serializers.IntegerField(read_only=True, default=0)
|
||||
projects = serializers.PrimaryKeyRelatedField(
|
||||
many=True,
|
||||
queryset=Project.objects.none(),
|
||||
required=False,
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
request = self.context.get("request")
|
||||
if request and request.user.is_authenticated:
|
||||
self.fields["projects"].child_relation.queryset = visible_projects(
|
||||
request.user
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ProcurementRequest
|
||||
fields = [
|
||||
"id",
|
||||
"request_type",
|
||||
"projects",
|
||||
"project_names",
|
||||
"title",
|
||||
"description",
|
||||
"department",
|
||||
@ -80,6 +98,7 @@ class ProcurementRequestListSerializer(
|
||||
"fulfillment_status",
|
||||
"fulfillment_status_display",
|
||||
"is_viewed",
|
||||
"is_deferred",
|
||||
"last_arrival_notice_at",
|
||||
"last_defective_at",
|
||||
"total_cost",
|
||||
@ -107,6 +126,7 @@ class ProcurementRequestListSerializer(
|
||||
]
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"request_type",
|
||||
"created_at",
|
||||
"submitted_at",
|
||||
"started_at",
|
||||
@ -176,7 +196,8 @@ class ProcurementRequestListSerializer(
|
||||
attrs = super().validate(attrs)
|
||||
if "processing_department" in attrs:
|
||||
_validate_processing_department_available(
|
||||
attrs.get("processing_department")
|
||||
attrs.get("processing_department"),
|
||||
request_type=self.instance.request_type,
|
||||
)
|
||||
items_data = attrs.get("items")
|
||||
if items_data is None:
|
||||
@ -232,6 +253,7 @@ class ProcurementRequestDetailSerializer(
|
||||
max_digits=12, decimal_places=2, read_only=True
|
||||
)
|
||||
items = ProcurementItemSerializer(many=True, read_only=True)
|
||||
projects = serializers.PrimaryKeyRelatedField(many=True, read_only=True)
|
||||
approvals = ApprovalSerializer(many=True, read_only=True)
|
||||
required_approval_priorities = serializers.ListField(
|
||||
source="get_required_approval_priorities", read_only=True
|
||||
@ -248,6 +270,9 @@ class ProcurementRequestDetailSerializer(
|
||||
model = ProcurementRequest
|
||||
fields = [
|
||||
"id",
|
||||
"request_type",
|
||||
"projects",
|
||||
"project_names",
|
||||
"title",
|
||||
"description",
|
||||
"department",
|
||||
@ -266,6 +291,7 @@ class ProcurementRequestDetailSerializer(
|
||||
"fulfillment_status",
|
||||
"fulfillment_status_display",
|
||||
"is_viewed",
|
||||
"is_deferred",
|
||||
"last_arrival_notice_at",
|
||||
"last_defective_at",
|
||||
"total_cost",
|
||||
|
||||
@ -5,7 +5,9 @@ from rest_framework import serializers
|
||||
from procurement.application import (
|
||||
create_procurement_request,
|
||||
)
|
||||
from procurement.models import ProcurementRequest
|
||||
from procurement.models import ProcurementRequest, ProcurementSettings
|
||||
from projects.models import Project
|
||||
from projects.permissions import visible_projects
|
||||
|
||||
from .common import (
|
||||
_validate_processing_department_available,
|
||||
@ -23,12 +25,27 @@ class ProcurementRequestCreateSerializer(serializers.ModelSerializer):
|
||||
allow_blank=True,
|
||||
)
|
||||
items = ProcurementItemCreateSerializer(many=True, required=False)
|
||||
projects = serializers.PrimaryKeyRelatedField(
|
||||
many=True,
|
||||
queryset=Project.objects.none(),
|
||||
required=False,
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
request = self.context.get("request")
|
||||
if request and request.user.is_authenticated:
|
||||
self.fields["projects"].child_relation.queryset = visible_projects(
|
||||
request.user
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ProcurementRequest
|
||||
fields = [
|
||||
"id",
|
||||
"title",
|
||||
"request_type",
|
||||
"projects",
|
||||
"description",
|
||||
"department",
|
||||
"processing_department",
|
||||
@ -41,7 +58,23 @@ class ProcurementRequestCreateSerializer(serializers.ModelSerializer):
|
||||
read_only_fields = ["id", "requestor", "status", "created_at"]
|
||||
|
||||
def validate(self, attrs):
|
||||
_validate_processing_department_available(attrs.get("processing_department"))
|
||||
_validate_processing_department_available(
|
||||
attrs.get("processing_department"),
|
||||
request_type=attrs.get(
|
||||
"request_type", ProcurementRequest.RequestType.STANDARD
|
||||
),
|
||||
)
|
||||
if (
|
||||
attrs.get("request_type") == ProcurementRequest.RequestType.SUBSCRIPTION
|
||||
and not ProcurementSettings.get_solo().subscription_approver_id
|
||||
):
|
||||
raise serializers.ValidationError(
|
||||
{
|
||||
"request_type": (
|
||||
"В настройках закупок не выбран согласующий заявок на подписки."
|
||||
)
|
||||
}
|
||||
)
|
||||
if (
|
||||
not attrs.get("processing_department")
|
||||
and not attrs.get("description", "").strip()
|
||||
@ -54,9 +87,12 @@ class ProcurementRequestCreateSerializer(serializers.ModelSerializer):
|
||||
def create(self, validated_data):
|
||||
"""Создать заявку с позициями."""
|
||||
items_data = validated_data.pop("items", [])
|
||||
projects = validated_data.pop("projects", [])
|
||||
request = self.context["request"]
|
||||
return create_procurement_request(
|
||||
procurement_request = create_procurement_request(
|
||||
requestor=request.user,
|
||||
attributes=validated_data,
|
||||
items=items_data,
|
||||
)
|
||||
procurement_request.projects.set(projects)
|
||||
return procurement_request
|
||||
|
||||
@ -400,6 +400,30 @@ class RequestExecutionMixin:
|
||||
serializer = self.get_serializer(procurement_request)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=True, methods=["post"])
|
||||
def set_deferred(self, request, pk=None):
|
||||
"""Установить личную отметку «отложено» для заявки."""
|
||||
procurement_request = self.get_object()
|
||||
raw_value = request.data.get("is_deferred")
|
||||
if not isinstance(raw_value, bool):
|
||||
return Response(
|
||||
{"is_deferred": "Ожидается булево значение."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
view_state, _ = ProcurementRequestView.objects.get_or_create(
|
||||
request=procurement_request,
|
||||
user=request.user,
|
||||
defaults={"is_viewed": False, "is_deferred": raw_value},
|
||||
)
|
||||
if view_state.is_deferred != raw_value:
|
||||
view_state.is_deferred = raw_value
|
||||
view_state.save(update_fields=["is_deferred", "updated_at"])
|
||||
|
||||
procurement_request.is_deferred_for_current_user = raw_value
|
||||
serializer = self.get_serializer(procurement_request)
|
||||
return Response(serializer.data)
|
||||
|
||||
@extend_schema(
|
||||
responses=ProcurementNotificationReadResponseSerializer,
|
||||
summary="Отметить уведомления по заявке как прочитанные",
|
||||
|
||||
@ -103,6 +103,17 @@ class ProcurementRequestQueryMixin:
|
||||
output_field=IntegerField(),
|
||||
),
|
||||
is_viewed_for_current_user=viewed,
|
||||
is_deferred_for_current_user=(
|
||||
Exists(
|
||||
ProcurementRequestView.objects.filter(
|
||||
request_id=OuterRef("pk"),
|
||||
user=user,
|
||||
is_deferred=True,
|
||||
)
|
||||
)
|
||||
if user and user.is_authenticated
|
||||
else Value(False, output_field=BooleanField())
|
||||
),
|
||||
last_arrival_notice_at=Subquery(
|
||||
self._arrival_notice_subquery(content_type),
|
||||
output_field=DateTimeField(),
|
||||
@ -157,6 +168,14 @@ class ProcurementRequestQueryMixin:
|
||||
)
|
||||
|
||||
def _apply_scope(self, queryset, user, scope, department_ids):
|
||||
if scope == "subscriptions":
|
||||
queryset = queryset.filter(
|
||||
request_type=ProcurementRequest.RequestType.SUBSCRIPTION,
|
||||
)
|
||||
if self._can_view_all(user):
|
||||
return queryset
|
||||
return self._default_visible_queryset(queryset, user, department_ids)
|
||||
|
||||
filters_by_scope = {
|
||||
"mine": {"requestor": user},
|
||||
"department": {"department_id__in": department_ids},
|
||||
@ -204,10 +223,22 @@ class ProcurementRequestQueryMixin:
|
||||
department_ids = (
|
||||
ProcurementApprovalResolver.get_user_department_participant_ids(user)
|
||||
)
|
||||
scope = query_params.get("scope")
|
||||
base_queryset = self._annotated_queryset(user)
|
||||
if scope != "subscriptions" and self.action in {
|
||||
"list",
|
||||
"pending_approvals",
|
||||
"my_requests",
|
||||
"my_work",
|
||||
"available",
|
||||
}:
|
||||
base_queryset = base_queryset.exclude(
|
||||
request_type=ProcurementRequest.RequestType.SUBSCRIPTION,
|
||||
)
|
||||
queryset = self._apply_scope(
|
||||
self._annotated_queryset(user),
|
||||
base_queryset,
|
||||
user,
|
||||
query_params.get("scope"),
|
||||
scope,
|
||||
department_ids,
|
||||
)
|
||||
queryset = self._apply_period(queryset, query_params.get("period"))
|
||||
@ -221,6 +252,11 @@ class ProcurementRequestQueryMixin:
|
||||
query_params.get("date_to"),
|
||||
"created_at__date__lte",
|
||||
)
|
||||
project_id = query_params.get("project")
|
||||
if project_id and project_id.isdigit():
|
||||
queryset = queryset.filter(projects__id=int(project_id))
|
||||
if query_params.get("deferred") in {"true", "1", "yes"}:
|
||||
queryset = queryset.filter(is_deferred_for_current_user=True)
|
||||
if not query_params.get("ordering"):
|
||||
queryset = queryset.order_by("-default_sort_at", "-id")
|
||||
return queryset.distinct()
|
||||
|
||||
@ -14,6 +14,7 @@ from procurement.constants import (
|
||||
from procurement.models import (
|
||||
Approval,
|
||||
ApprovalRoute,
|
||||
ProcurementRequest,
|
||||
ProcurementSettings,
|
||||
)
|
||||
from procurement.services import ProcurementApprovalResolver
|
||||
@ -37,11 +38,28 @@ class RequestWorkflowMixin:
|
||||
and not departments.filter(pk=default_department_id).exists()
|
||||
):
|
||||
default_department_id = None
|
||||
subscription_departments = (
|
||||
settings.get_subscription_processing_departments_queryset().order_by("name")
|
||||
)
|
||||
subscription_default_department_id = (
|
||||
settings.subscription_default_processing_department_id
|
||||
)
|
||||
if (
|
||||
subscription_default_department_id
|
||||
and not subscription_departments.filter(
|
||||
pk=subscription_default_department_id
|
||||
).exists()
|
||||
):
|
||||
subscription_default_department_id = None
|
||||
|
||||
serializer = ProcurementRequestCreateOptionsSerializer(
|
||||
{
|
||||
"processing_departments": departments,
|
||||
"default_processing_department": default_department_id,
|
||||
"subscription_processing_departments": subscription_departments,
|
||||
"subscription_default_processing_department": (
|
||||
subscription_default_department_id
|
||||
),
|
||||
}
|
||||
)
|
||||
return Response(serializer.data)
|
||||
@ -364,7 +382,11 @@ class RequestWorkflowMixin:
|
||||
).count()
|
||||
|
||||
if pending_approvals == 0:
|
||||
if procurement_request.processing_department_id:
|
||||
if (
|
||||
procurement_request.processing_department_id
|
||||
and procurement_request.request_type
|
||||
!= ProcurementRequest.RequestType.SUBSCRIPTION
|
||||
):
|
||||
procurement_request.status = (
|
||||
ProcurementStatus.IN_PROGRESS
|
||||
if procurement_request.executor_id
|
||||
|
||||
@ -45,6 +45,7 @@ class ProcurementRequestViewSet(
|
||||
"requestor",
|
||||
"executor",
|
||||
).prefetch_related(
|
||||
"projects",
|
||||
"items__attachments__uploaded_by",
|
||||
"approvals__approver",
|
||||
)
|
||||
@ -55,6 +56,7 @@ class ProcurementRequestViewSet(
|
||||
]
|
||||
filterset_fields = {
|
||||
"status": ["exact", "in"],
|
||||
"request_type": ["exact"],
|
||||
"urgency": ["exact"],
|
||||
"department": ["exact"],
|
||||
"processing_department": ["exact"],
|
||||
@ -81,6 +83,7 @@ class ProcurementRequestViewSet(
|
||||
"mark_all_received",
|
||||
"notify_arrival",
|
||||
"set_viewed",
|
||||
"set_deferred",
|
||||
"approval_options",
|
||||
"activity",
|
||||
}:
|
||||
@ -132,6 +135,7 @@ class ProcurementRequestViewSet(
|
||||
elif self.action in {
|
||||
"submit",
|
||||
"set_viewed",
|
||||
"set_deferred",
|
||||
"create_options",
|
||||
"approval_options",
|
||||
}:
|
||||
|
||||
@ -85,6 +85,12 @@ class ProcurementSettingsAdminForm(forms.ModelForm):
|
||||
cleaned_data = super().clean()
|
||||
default_department = cleaned_data.get("default_processing_department")
|
||||
available_departments = cleaned_data.get("available_processing_departments")
|
||||
subscription_default_department = cleaned_data.get(
|
||||
"subscription_default_processing_department"
|
||||
)
|
||||
subscription_available_departments = cleaned_data.get(
|
||||
"subscription_available_processing_departments"
|
||||
)
|
||||
|
||||
if (
|
||||
default_department
|
||||
@ -100,6 +106,21 @@ class ProcurementSettingsAdminForm(forms.ModelForm):
|
||||
),
|
||||
)
|
||||
|
||||
if (
|
||||
subscription_default_department
|
||||
and subscription_available_departments is not None
|
||||
and subscription_available_departments.exists()
|
||||
and subscription_default_department
|
||||
not in subscription_available_departments
|
||||
):
|
||||
self.add_error(
|
||||
"subscription_default_processing_department",
|
||||
(
|
||||
"Отдел по умолчанию для подписок должен входить в список "
|
||||
"допустимых отделов-исполнителей для подписок."
|
||||
),
|
||||
)
|
||||
|
||||
return cleaned_data
|
||||
|
||||
|
||||
@ -110,18 +131,26 @@ class ProcurementSettingsAdmin(admin.ModelAdmin):
|
||||
form = ProcurementSettingsAdminForm
|
||||
list_display = [
|
||||
"name",
|
||||
"subscription_approver",
|
||||
"default_processing_department",
|
||||
"subscription_default_processing_department",
|
||||
"available_processing_departments_count",
|
||||
"updated_at",
|
||||
]
|
||||
fields = [
|
||||
"name",
|
||||
"subscription_approver",
|
||||
"default_processing_department",
|
||||
"available_processing_departments",
|
||||
"subscription_default_processing_department",
|
||||
"subscription_available_processing_departments",
|
||||
"updated_at",
|
||||
]
|
||||
readonly_fields = ["updated_at"]
|
||||
filter_horizontal = ["available_processing_departments"]
|
||||
filter_horizontal = [
|
||||
"available_processing_departments",
|
||||
"subscription_available_processing_departments",
|
||||
]
|
||||
|
||||
def has_add_permission(self, request):
|
||||
if ProcurementSettings.objects.exists():
|
||||
@ -144,6 +173,7 @@ class ProcurementRequestAdmin(admin.ModelAdmin):
|
||||
|
||||
list_display = [
|
||||
"id",
|
||||
"request_type",
|
||||
"title",
|
||||
"department",
|
||||
"processing_department",
|
||||
@ -155,6 +185,7 @@ class ProcurementRequestAdmin(admin.ModelAdmin):
|
||||
"created_at",
|
||||
]
|
||||
list_filter = [
|
||||
"request_type",
|
||||
"status",
|
||||
"fulfillment_status",
|
||||
"urgency",
|
||||
@ -178,6 +209,7 @@ class ProcurementRequestAdmin(admin.ModelAdmin):
|
||||
"Основная информация",
|
||||
{
|
||||
"fields": (
|
||||
"request_type",
|
||||
"title",
|
||||
"description",
|
||||
"department",
|
||||
|
||||
@ -3,11 +3,18 @@
|
||||
from collections.abc import Mapping, Sequence
|
||||
from typing import Any
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
|
||||
from communications import comments_helpers
|
||||
from procurement.constants import ProcurementStatus
|
||||
from procurement.models import ProcurementItem, ProcurementRequest
|
||||
from procurement.constants import ApprovalStatus, ProcurementStatus
|
||||
from procurement.models import (
|
||||
Approval,
|
||||
ProcurementItem,
|
||||
ProcurementRequest,
|
||||
ProcurementSettings,
|
||||
)
|
||||
|
||||
|
||||
class ProcurementRequestUpdateError(Exception):
|
||||
@ -44,6 +51,8 @@ def create_procurement_request(
|
||||
**request_attributes,
|
||||
)
|
||||
procurement_request._notification_actor = requestor
|
||||
if procurement_request.request_type == ProcurementRequest.RequestType.SUBSCRIPTION:
|
||||
procurement_request._suppress_created_procurement_notifications = True
|
||||
procurement_request.save()
|
||||
|
||||
for raw_item in items:
|
||||
@ -59,6 +68,25 @@ def create_procurement_request(
|
||||
content=initial_comment,
|
||||
)
|
||||
|
||||
if procurement_request.request_type == ProcurementRequest.RequestType.SUBSCRIPTION:
|
||||
approver_id = ProcurementSettings.get_solo().subscription_approver_id
|
||||
if not approver_id:
|
||||
raise ValidationError(
|
||||
"В настройках закупок не выбран согласующий заявок на подписки."
|
||||
)
|
||||
Approval.objects.create(
|
||||
request=procurement_request,
|
||||
approver_id=approver_id,
|
||||
priority=0,
|
||||
step_name="Согласование подписки",
|
||||
status=ApprovalStatus.PENDING,
|
||||
)
|
||||
procurement_request.status = ProcurementStatus.PENDING
|
||||
procurement_request.submitted_at = timezone.now()
|
||||
procurement_request.save(
|
||||
update_fields=["status", "submitted_at", "updated_at"],
|
||||
)
|
||||
|
||||
return procurement_request
|
||||
|
||||
|
||||
@ -74,6 +102,7 @@ def update_procurement_request_with_items(
|
||||
procurement_request = ProcurementRequest.objects.select_for_update().get(
|
||||
pk=request_id,
|
||||
)
|
||||
projects = attributes.pop("projects", None)
|
||||
existing_items = {
|
||||
item.id: item for item in procurement_request.items.select_for_update().all()
|
||||
}
|
||||
@ -81,6 +110,8 @@ def update_procurement_request_with_items(
|
||||
for field, value in attributes.items():
|
||||
setattr(procurement_request, field, value)
|
||||
procurement_request.save()
|
||||
if projects is not None:
|
||||
procurement_request.projects.set(projects)
|
||||
|
||||
kept_item_ids: set[int] = set()
|
||||
for raw_item in items:
|
||||
|
||||
37
backend/procurement/migrations/0020_subscription_requests.py
Normal file
37
backend/procurement/migrations/0020_subscription_requests.py
Normal file
@ -0,0 +1,37 @@
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("procurement", "0019_procurementitemattachment_thumbnail"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="procurementrequest",
|
||||
name="request_type",
|
||||
field=models.CharField(
|
||||
choices=[("standard", "Обычная закупка"), ("subscription", "Подписка")],
|
||||
db_index=True,
|
||||
default="standard",
|
||||
max_length=20,
|
||||
verbose_name="Тип заявки",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="procurementsettings",
|
||||
name="subscription_approver",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
help_text="Новые заявки на подписки сразу попадут этому сотруднику на согласование.",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="subscription_procurement_settings",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="Согласующий заявки на подписки",
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,39 @@
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("employees", "0062_employee_additional_positions"),
|
||||
("procurement", "0020_subscription_requests"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="procurementsettings",
|
||||
name="subscription_default_processing_department",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
help_text="Автоматически выбирается в новой заявке на подписку.",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="default_subscription_procurement_processing_settings",
|
||||
to="employees.department",
|
||||
verbose_name="Отдел-исполнитель подписок по умолчанию",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="procurementsettings",
|
||||
name="subscription_available_processing_departments",
|
||||
field=models.ManyToManyField(
|
||||
blank=True,
|
||||
help_text=(
|
||||
"Если список пуст, для подписок доступны все отделы. "
|
||||
"Заполните список, чтобы ограничить выбор отдельно от закупок."
|
||||
),
|
||||
related_name="subscription_procurement_processing_settings",
|
||||
to="employees.department",
|
||||
verbose_name="Допустимые отделы-исполнители для подписок",
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,28 @@
|
||||
# Generated by Django 5.2.4 on 2026-10-25 12:22
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("procurement", "0021_subscription_processing_departments"),
|
||||
("projects", "0002_projectboardlink"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="procurementrequest",
|
||||
name="projects",
|
||||
field=models.ManyToManyField(
|
||||
blank=True,
|
||||
related_name="procurement_requests",
|
||||
to="projects.project",
|
||||
verbose_name="Проекты",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="procurementrequestview",
|
||||
name="is_deferred",
|
||||
field=models.BooleanField(default=False, verbose_name="Отложено для себя"),
|
||||
),
|
||||
]
|
||||
@ -62,6 +62,34 @@ class ProcurementSettings(models.Model):
|
||||
verbose_name="Отдел-исполнитель по умолчанию",
|
||||
help_text="Этот отдел будет автоматически выбран в новой заявке.",
|
||||
)
|
||||
subscription_available_processing_departments = models.ManyToManyField(
|
||||
Department,
|
||||
blank=True,
|
||||
related_name="subscription_procurement_processing_settings",
|
||||
verbose_name="Допустимые отделы-исполнители для подписок",
|
||||
help_text=(
|
||||
"Если список пуст, для подписок доступны все отделы. "
|
||||
"Заполните список, чтобы ограничить выбор отдельно от закупок."
|
||||
),
|
||||
)
|
||||
subscription_default_processing_department = models.ForeignKey(
|
||||
Department,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="default_subscription_procurement_processing_settings",
|
||||
verbose_name="Отдел-исполнитель подписок по умолчанию",
|
||||
help_text="Автоматически выбирается в новой заявке на подписку.",
|
||||
)
|
||||
subscription_approver = models.ForeignKey(
|
||||
User,
|
||||
on_delete=models.PROTECT,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="subscription_procurement_settings",
|
||||
verbose_name="Согласующий заявки на подписки",
|
||||
help_text="Новые заявки на подписки сразу попадут этому сотруднику на согласование.",
|
||||
)
|
||||
updated_at = models.DateTimeField("Обновлено", auto_now=True)
|
||||
|
||||
class Meta:
|
||||
@ -96,6 +124,22 @@ class ProcurementSettings(models.Model):
|
||||
)
|
||||
}
|
||||
)
|
||||
if (
|
||||
self.pk
|
||||
and self.subscription_default_processing_department_id
|
||||
and self.subscription_available_processing_departments.exists()
|
||||
and not self.subscription_available_processing_departments.filter(
|
||||
pk=self.subscription_default_processing_department_id
|
||||
).exists()
|
||||
):
|
||||
raise ValidationError(
|
||||
{
|
||||
"subscription_default_processing_department": (
|
||||
"Отдел по умолчанию для подписок должен входить в "
|
||||
"список допустимых отделов-исполнителей для подписок."
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
def get_processing_departments_queryset(self):
|
||||
departments = self.available_processing_departments.all()
|
||||
@ -103,6 +147,12 @@ class ProcurementSettings(models.Model):
|
||||
return departments
|
||||
return Department.objects.all()
|
||||
|
||||
def get_subscription_processing_departments_queryset(self):
|
||||
departments = self.subscription_available_processing_departments.all()
|
||||
if departments.exists():
|
||||
return departments
|
||||
return Department.objects.all()
|
||||
|
||||
|
||||
class ProcurementRequest(models.Model):
|
||||
"""Заявка на закупку.
|
||||
@ -114,6 +164,24 @@ class ProcurementRequest(models.Model):
|
||||
title = models.CharField(
|
||||
"Название", max_length=255, help_text="Краткое название заявки"
|
||||
)
|
||||
|
||||
class RequestType(models.TextChoices):
|
||||
STANDARD = "standard", "Обычная закупка"
|
||||
SUBSCRIPTION = "subscription", "Подписка"
|
||||
|
||||
request_type = models.CharField(
|
||||
"Тип заявки",
|
||||
max_length=20,
|
||||
choices=RequestType.choices,
|
||||
default=RequestType.STANDARD,
|
||||
db_index=True,
|
||||
)
|
||||
projects = models.ManyToManyField(
|
||||
"projects.Project",
|
||||
blank=True,
|
||||
related_name="procurement_requests",
|
||||
verbose_name="Проекты",
|
||||
)
|
||||
description = models.TextField(
|
||||
"Описание",
|
||||
help_text="Подробное описание и обоснование необходимости закупки",
|
||||
@ -384,7 +452,7 @@ class ProcurementRequestActivity(models.Model):
|
||||
|
||||
|
||||
class ProcurementRequestView(models.Model):
|
||||
"""Персональная отметка просмотра заявки пользователем."""
|
||||
"""Персональные отметки просмотра и отложенной обработки заявки."""
|
||||
|
||||
request = models.ForeignKey(
|
||||
ProcurementRequest,
|
||||
@ -399,6 +467,7 @@ class ProcurementRequestView(models.Model):
|
||||
verbose_name="Пользователь",
|
||||
)
|
||||
is_viewed = models.BooleanField("Просмотрено", default=True)
|
||||
is_deferred = models.BooleanField("Отложено для себя", default=False)
|
||||
viewed_at = models.DateTimeField(
|
||||
"Просмотрено в",
|
||||
null=True,
|
||||
|
||||
@ -339,6 +339,13 @@ class ProcurementApprovalResolver:
|
||||
if not user or not user.is_authenticated:
|
||||
return False
|
||||
|
||||
if (
|
||||
procurement_request.request_type == "subscription"
|
||||
and procurement_request.status
|
||||
not in {ProcurementStatus.APPROVED, ProcurementStatus.IN_PROGRESS}
|
||||
):
|
||||
return False
|
||||
|
||||
if procurement_request.status not in cls.WORK_ASSIGNABLE_STATUSES:
|
||||
return False
|
||||
|
||||
@ -378,6 +385,13 @@ class ProcurementApprovalResolver:
|
||||
if not user or not user.is_authenticated:
|
||||
return False
|
||||
|
||||
if (
|
||||
procurement_request.request_type == "subscription"
|
||||
and procurement_request.status
|
||||
not in {ProcurementStatus.APPROVED, ProcurementStatus.IN_PROGRESS}
|
||||
):
|
||||
return False
|
||||
|
||||
if procurement_request.status not in {
|
||||
ProcurementStatus.WAITING,
|
||||
ProcurementStatus.IN_PROGRESS,
|
||||
|
||||
@ -18,6 +18,7 @@ import { AuthLegalNotice } from "@/components/auth/AuthLegalNotice";
|
||||
import { useUser } from "@/contexts/UserContext";
|
||||
import { useRegistrationAvailability } from "@/hooks/useRegistrationAvailability";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import { ApiError } from "@/lib/api/client";
|
||||
import type { QrLoginRequestStatus } from "@/types/api";
|
||||
|
||||
type LoginMode = "password" | "qr";
|
||||
@ -72,9 +73,19 @@ export default function Login() {
|
||||
// login, even when this was a regular login without an OIDC return URL.
|
||||
// Otherwise switching portal accounts can leave the previous user in the
|
||||
// Django session and the next external login silently reuses that user.
|
||||
const redirectUrl = await apiClient.establishBrowserSession(
|
||||
nextUrl ?? "",
|
||||
);
|
||||
let redirectUrl: string;
|
||||
try {
|
||||
redirectUrl = await apiClient.establishBrowserSession(nextUrl ?? "");
|
||||
} catch (caught) {
|
||||
// Local and standalone deployments can run without the OIDC provider.
|
||||
// A regular portal login should still continue when that optional
|
||||
// browser-session endpoint is disabled; an OIDC return URL must fail.
|
||||
if (!nextUrl && caught instanceof ApiError && caught.status === 404) {
|
||||
router.push("/");
|
||||
return;
|
||||
}
|
||||
throw caught;
|
||||
}
|
||||
if (!nextUrl) {
|
||||
router.push("/");
|
||||
return;
|
||||
|
||||
@ -37,6 +37,7 @@ import {
|
||||
ClipboardCheck,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Flag,
|
||||
Filter,
|
||||
Link2,
|
||||
Loader2,
|
||||
@ -61,7 +62,10 @@ import {
|
||||
CommentComposer,
|
||||
CommentDeleteButton,
|
||||
} from "@/components/shared/CommentControls";
|
||||
import { SearchableSelectSingle } from "@/components/shared/SearchableSelect";
|
||||
import {
|
||||
SearchableSelectMulti,
|
||||
SearchableSelectSingle,
|
||||
} from "@/components/shared/SearchableSelect";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import { formatDate, formatDateTime, formatMoney } from "@/lib/shared";
|
||||
import { useProcurementPage } from "@/hooks/useProcurementPage";
|
||||
@ -183,7 +187,8 @@ type ScopeTab =
|
||||
| "processing_department"
|
||||
| "pending_approvals"
|
||||
| "my_work"
|
||||
| "available";
|
||||
| "available"
|
||||
| "subscriptions";
|
||||
const scopeTabs: { value: ScopeTab; label: string }[] = [
|
||||
{ value: "all", label: "Все" },
|
||||
{ value: "mine", label: "Мои" },
|
||||
@ -521,11 +526,21 @@ export default function ProcurementPage() {
|
||||
busyKey,
|
||||
canManage,
|
||||
canSupplierManage,
|
||||
canCreateProjects,
|
||||
creatingProject,
|
||||
deferredOnly,
|
||||
setDeferredOnly,
|
||||
handleCreateProject,
|
||||
handleToggleDeferred,
|
||||
closeModal,
|
||||
departmentFilter,
|
||||
departments,
|
||||
employees,
|
||||
projects,
|
||||
newProjectName,
|
||||
setNewProjectName,
|
||||
processingDepartments,
|
||||
subscriptionProcessingDepartments,
|
||||
detailsCache,
|
||||
displayUserName,
|
||||
ensureRequestDetail,
|
||||
@ -575,6 +590,7 @@ export default function ProcurementPage() {
|
||||
openEdit,
|
||||
ordering,
|
||||
periodFilter,
|
||||
projectFilter,
|
||||
processingDepartmentFilter,
|
||||
requestorFilter,
|
||||
executorFilter,
|
||||
@ -594,6 +610,7 @@ export default function ProcurementPage() {
|
||||
setForm,
|
||||
setOrdering,
|
||||
setPeriodFilter,
|
||||
setProjectFilter,
|
||||
setDateFromFilter,
|
||||
setDateToFilter,
|
||||
setScope,
|
||||
@ -621,8 +638,14 @@ export default function ProcurementPage() {
|
||||
markRequestNotificationsRead,
|
||||
toggleItemComments,
|
||||
} = useProcurementPage(user);
|
||||
const configuredProcessingDepartments =
|
||||
form.request_type === "subscription"
|
||||
? subscriptionProcessingDepartments
|
||||
: processingDepartments;
|
||||
const processingDepartmentOptions =
|
||||
processingDepartments.length > 0 ? processingDepartments : departments;
|
||||
configuredProcessingDepartments.length > 0
|
||||
? configuredProcessingDepartments
|
||||
: departments;
|
||||
const userDepartmentIds = useMemo(
|
||||
() =>
|
||||
new Set(
|
||||
@ -1120,9 +1143,17 @@ export default function ProcurementPage() {
|
||||
</button>
|
||||
{activeSection === "requests" && (
|
||||
<ModulePrimaryAction
|
||||
onClick={openCreate}
|
||||
onClick={() =>
|
||||
openCreate(
|
||||
scope === "subscriptions" ? "subscription" : "standard",
|
||||
)
|
||||
}
|
||||
icon={<Plus size={14} />}
|
||||
label="Создать заявку"
|
||||
label={
|
||||
scope === "subscriptions"
|
||||
? "Новая подписка"
|
||||
: "Создать заявку"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@ -1169,31 +1200,55 @@ export default function ProcurementPage() {
|
||||
</div>
|
||||
|
||||
<HorizontalChipScroller
|
||||
className="mb-4"
|
||||
ariaLabel="Разделы закупок"
|
||||
className="mb-3"
|
||||
ariaLabel="Тип заявок закупок"
|
||||
>
|
||||
{scopeTabs.map((tab) => (
|
||||
<button
|
||||
key={tab.value}
|
||||
type="button"
|
||||
onClick={() => setScope(tab.value)}
|
||||
className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium transition ${
|
||||
scope === tab.value ? "app-pill-active" : "app-pill"
|
||||
}`}
|
||||
>
|
||||
<span>{tab.label}</span>
|
||||
<span
|
||||
className={`app-badge px-1.5 py-0.5 text-[10px] font-bold ${
|
||||
scope === tab.value
|
||||
? "app-pill-count-active"
|
||||
: "app-pill-count"
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setScope("all")}
|
||||
aria-pressed={scope !== "subscriptions"}
|
||||
className={`inline-flex h-8 items-center rounded-full px-3 text-xs font-medium transition ${scope !== "subscriptions" ? "app-pill-active" : "app-pill"}`}
|
||||
>
|
||||
Закупки
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setScope("subscriptions")}
|
||||
aria-pressed={scope === "subscriptions"}
|
||||
className={`inline-flex h-8 items-center rounded-full px-3 text-xs font-medium transition ${scope === "subscriptions" ? "app-pill-active" : "app-pill"}`}
|
||||
>
|
||||
Подписки
|
||||
</button>
|
||||
</HorizontalChipScroller>
|
||||
|
||||
{scope === "subscriptions" ? null : (
|
||||
<HorizontalChipScroller
|
||||
className="mb-4"
|
||||
ariaLabel="Фильтры заявок на закупку"
|
||||
>
|
||||
{scopeTabs.map((tab) => (
|
||||
<button
|
||||
key={tab.value}
|
||||
type="button"
|
||||
onClick={() => setScope(tab.value)}
|
||||
className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium transition ${
|
||||
scope === tab.value ? "app-pill-active" : "app-pill"
|
||||
}`}
|
||||
>
|
||||
{scopeCounts[tab.value] ?? 0}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</HorizontalChipScroller>
|
||||
<span>{tab.label}</span>
|
||||
<span
|
||||
className={`app-badge px-1.5 py-0.5 text-[10px] font-bold ${
|
||||
scope === tab.value
|
||||
? "app-pill-count-active"
|
||||
: "app-pill-count"
|
||||
}`}
|
||||
>
|
||||
{scopeCounts[tab.value] ?? 0}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</HorizontalChipScroller>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</AutoRevealStickyHeader>
|
||||
@ -1395,6 +1450,24 @@ export default function ProcurementPage() {
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="min-w-0">
|
||||
<span className="app-text-muted mb-1 block text-xs font-medium">
|
||||
Проект
|
||||
</span>
|
||||
<ProjectSelectMenu
|
||||
value={projectFilter}
|
||||
onChange={setProjectFilter}
|
||||
ariaLabel="Проект"
|
||||
options={[
|
||||
{ value: "", label: "Все проекты" },
|
||||
...projects.map((project) => ({
|
||||
value: String(project.id),
|
||||
label: project.name,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="min-w-0">
|
||||
<span className="app-text-muted mb-1 block text-xs font-medium">
|
||||
Быстрый период
|
||||
@ -1443,6 +1516,18 @@ export default function ProcurementPage() {
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<label className="app-surface-muted flex cursor-pointer items-center gap-2 rounded-lg px-3 py-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={deferredOnly}
|
||||
onChange={(event) =>
|
||||
setDeferredOnly(event.target.checked)
|
||||
}
|
||||
className="h-4 w-4 rounded accent-[var(--accent-primary)]"
|
||||
/>
|
||||
<Flag size={14} className="text-amber-500" />
|
||||
Отложенные мной
|
||||
</label>
|
||||
{activeFilterCount > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
@ -1455,6 +1540,8 @@ export default function ProcurementPage() {
|
||||
setRequestorFilter("");
|
||||
setExecutorFilter("");
|
||||
setPeriodFilter("");
|
||||
setProjectFilter("");
|
||||
setDeferredOnly(false);
|
||||
setDateFromFilter("");
|
||||
setDateToFilter("");
|
||||
}}
|
||||
@ -1472,10 +1559,16 @@ export default function ProcurementPage() {
|
||||
>
|
||||
<aside
|
||||
className="app-surface-muted min-h-0 overflow-y-auto rounded-xl border border-[var(--border-subtle)]"
|
||||
aria-label="Список заявок на закупку"
|
||||
aria-label={
|
||||
scope === "subscriptions"
|
||||
? "Список заявок на подписку"
|
||||
: "Список заявок на закупку"
|
||||
}
|
||||
>
|
||||
<div className="sticky top-0 z-10 flex items-center justify-between border-b border-[var(--border-subtle)] bg-[var(--surface-primary)] px-3 py-2.5">
|
||||
<span className="app-card-caption">Заявки</span>
|
||||
<span className="app-card-caption">
|
||||
{scope === "subscriptions" ? "Подписки" : "Заявки"}
|
||||
</span>
|
||||
<span className="app-counter inline-flex h-5 min-w-5 items-center justify-center px-1 text-[10px] font-bold">
|
||||
{filteredRequests.length}
|
||||
</span>
|
||||
@ -1488,7 +1581,9 @@ export default function ProcurementPage() {
|
||||
className="app-text-muted mx-auto mb-2"
|
||||
/>
|
||||
<p className="app-text-muted text-sm">
|
||||
Заявок на закупку не найдено
|
||||
{scope === "subscriptions"
|
||||
? "Заявок на подписку не найдено"
|
||||
: "Заявок на закупку не найдено"}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
@ -1618,6 +1713,11 @@ export default function ProcurementPage() {
|
||||
{getDeptName(req)}
|
||||
</span>
|
||||
</span>
|
||||
{(req.project_names || []).length > 0 ? (
|
||||
<span className="app-text-muted mt-1 block truncate text-[11px]">
|
||||
Проект: {req.project_names?.join(", ")}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="flex shrink-0 flex-col items-end gap-1">
|
||||
<span
|
||||
@ -1719,6 +1819,32 @@ export default function ProcurementPage() {
|
||||
</>
|
||||
) : null}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title={
|
||||
req.is_deferred
|
||||
? "Снять личную отметку «отложено»"
|
||||
: "Отложить для себя"
|
||||
}
|
||||
aria-label={
|
||||
req.is_deferred
|
||||
? "Снять отметку «отложено»"
|
||||
: "Отложить заявку для себя"
|
||||
}
|
||||
disabled={busyKey === `deferred-${req.id}`}
|
||||
onClick={() =>
|
||||
void handleToggleDeferred(
|
||||
req.id,
|
||||
!req.is_deferred,
|
||||
)
|
||||
}
|
||||
className={`${req.is_deferred ? "app-selected text-amber-500" : "app-action-secondary"} absolute bottom-2 right-11 z-[1] inline-flex h-8 w-8 items-center justify-center rounded-lg shadow-sm disabled:opacity-60`}
|
||||
>
|
||||
<Flag
|
||||
size={15}
|
||||
fill={req.is_deferred ? "currentColor" : "none"}
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title={
|
||||
@ -1781,11 +1907,51 @@ export default function ProcurementPage() {
|
||||
#{selectedRequest.id} ·{" "}
|
||||
{selectedRequest.title || "Без названия"}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
title={
|
||||
selectedRequest.is_deferred
|
||||
? "Снять личную отметку «отложено»"
|
||||
: "Отложить для себя"
|
||||
}
|
||||
aria-label={
|
||||
selectedRequest.is_deferred
|
||||
? "Снять отметку «отложено»"
|
||||
: "Отложить заявку для себя"
|
||||
}
|
||||
disabled={
|
||||
busyKey === `deferred-${selectedRequest.id}`
|
||||
}
|
||||
onClick={() =>
|
||||
void handleToggleDeferred(
|
||||
selectedRequest.id,
|
||||
!selectedRequest.is_deferred,
|
||||
)
|
||||
}
|
||||
className={`${selectedRequest.is_deferred ? "app-selected text-amber-500" : "app-action-secondary"} inline-flex h-8 w-8 items-center justify-center rounded-lg disabled:opacity-60`}
|
||||
>
|
||||
<Flag
|
||||
size={15}
|
||||
fill={
|
||||
selectedRequest.is_deferred
|
||||
? "currentColor"
|
||||
: "none"
|
||||
}
|
||||
/>
|
||||
</button>
|
||||
<span
|
||||
className={`app-status-pill shrink-0 ${getStatusMeta(selectedRequest.status).cls}`}
|
||||
>
|
||||
{getStatusMeta(selectedRequest.status).label}
|
||||
</span>
|
||||
{(selectedRequest.project_names || []).map((name) => (
|
||||
<span
|
||||
key={name}
|
||||
className="app-badge inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium"
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
))}
|
||||
<span
|
||||
className={`shrink-0 text-[11px] font-medium ${urgencyMeta[selectedRequest.urgency]?.cls ?? urgencyMeta.medium.cls}`}
|
||||
>
|
||||
@ -2066,7 +2232,9 @@ export default function ProcurementPage() {
|
||||
className="app-text-muted mx-auto mb-2"
|
||||
/>
|
||||
<p className="app-text-muted text-sm">
|
||||
Заявок на закупку не найдено
|
||||
{scope === "subscriptions"
|
||||
? "Заявок на подписку не найдено"
|
||||
: "Заявок на закупку не найдено"}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
@ -2338,6 +2506,14 @@ export default function ProcurementPage() {
|
||||
</span>
|
||||
</span>
|
||||
<span>{requestDateLabel}</span>
|
||||
{(req.project_names || []).map((name) => (
|
||||
<span
|
||||
key={name}
|
||||
className="app-badge inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium"
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
))}
|
||||
{getRequestAmount(req) && (
|
||||
<span className="font-medium text-[var(--foreground)]">
|
||||
{money(getRequestAmount(req))}
|
||||
@ -2374,6 +2550,38 @@ export default function ProcurementPage() {
|
||||
>
|
||||
{sMeta.label}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
title={
|
||||
req.is_deferred
|
||||
? "Снять личную отметку «отложено»"
|
||||
: "Отложить для себя"
|
||||
}
|
||||
aria-label={
|
||||
req.is_deferred
|
||||
? "Снять отметку «отложено»"
|
||||
: "Отложить заявку для себя"
|
||||
}
|
||||
disabled={
|
||||
busyKey === `deferred-${req.id}`
|
||||
}
|
||||
onClick={() =>
|
||||
void handleToggleDeferred(
|
||||
req.id,
|
||||
!req.is_deferred,
|
||||
)
|
||||
}
|
||||
className={`${req.is_deferred ? "app-selected text-amber-500" : "app-action-secondary"} inline-flex h-8 w-8 items-center justify-center rounded-lg disabled:opacity-60`}
|
||||
>
|
||||
<Flag
|
||||
size={15}
|
||||
fill={
|
||||
req.is_deferred
|
||||
? "currentColor"
|
||||
: "none"
|
||||
}
|
||||
/>
|
||||
</button>
|
||||
{showRequestMenu ? (
|
||||
<div className="relative">
|
||||
<button
|
||||
@ -3440,7 +3648,9 @@ export default function ProcurementPage() {
|
||||
onClose={closeModal}
|
||||
title={
|
||||
modalMode === "create"
|
||||
? "Новая заявка на закупку"
|
||||
? form.request_type === "subscription"
|
||||
? "Новая заявка на подписку"
|
||||
: "Новая заявка на закупку"
|
||||
: "Редактировать заявку"
|
||||
}
|
||||
size="lg"
|
||||
@ -3459,7 +3669,11 @@ export default function ProcurementPage() {
|
||||
disabled={busyKey === "save" || busyKey === "save-submit"}
|
||||
className="app-action-primary rounded-lg px-3 py-2 text-sm font-medium disabled:opacity-60"
|
||||
>
|
||||
{modalMode === "create" ? "Создать заявку" : "Сохранить"}
|
||||
{modalMode === "create"
|
||||
? form.request_type === "subscription"
|
||||
? "Создать заявку на согласование"
|
||||
: "Создать заявку"
|
||||
: "Сохранить"}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
@ -3469,6 +3683,12 @@ export default function ProcurementPage() {
|
||||
{actionError}
|
||||
</p>
|
||||
)}
|
||||
{modalMode === "create" && form.request_type === "subscription" ? (
|
||||
<p className="app-feedback-info mb-4 rounded-lg px-3 py-2 text-sm">
|
||||
Заявка сразу отправится ответственному согласующему. Исполнители не
|
||||
смогут взять её в работу, пока она не будет одобрена.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Название */}
|
||||
@ -3547,6 +3767,48 @@ export default function ProcurementPage() {
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
<SearchableSelectMulti
|
||||
label="Проекты"
|
||||
placeholder="Выберите один или несколько проектов"
|
||||
items={projects}
|
||||
selectedIds={form.projects}
|
||||
onToggle={(id) =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
projects: current.projects.includes(id)
|
||||
? current.projects.filter((projectId) => projectId !== id)
|
||||
: [...current.projects, id],
|
||||
}))
|
||||
}
|
||||
/>
|
||||
{canCreateProjects ? (
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
value={newProjectName}
|
||||
onChange={(event) => setNewProjectName(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
void handleCreateProject();
|
||||
}
|
||||
}}
|
||||
placeholder="Название нового проекта"
|
||||
className="app-input min-w-0 flex-1 rounded-lg px-3 py-2 text-sm"
|
||||
aria-label="Название нового проекта"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleCreateProject()}
|
||||
disabled={creatingProject || !newProjectName.trim()}
|
||||
className="app-action-secondary rounded-lg px-3 py-2 text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{creatingProject ? "Создаём…" : "Создать проект"}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* ── Items ── */}
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
@ -3561,6 +3823,11 @@ export default function ProcurementPage() {
|
||||
<Plus size={13} /> Добавить
|
||||
</button>
|
||||
</div>
|
||||
{form.request_type === "subscription" ? (
|
||||
<p className="app-text-muted mb-2 text-xs">
|
||||
Укажите срок количеством периодов, например: 12 месяцев.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-3">
|
||||
{form.items.map((it, idx) => (
|
||||
@ -3652,7 +3919,10 @@ export default function ProcurementPage() {
|
||||
aria-label="Единица измерения"
|
||||
className="app-select min-w-0 rounded-lg px-3 py-2 text-sm"
|
||||
>
|
||||
{getProcurementUnitOptions(it.unit).map((unit) => (
|
||||
{getProcurementUnitOptions(
|
||||
it.unit,
|
||||
form.request_type === "subscription",
|
||||
).map((unit) => (
|
||||
<option key={unit.value} value={unit.value}>
|
||||
{unit.label}
|
||||
</option>
|
||||
|
||||
@ -30,6 +30,7 @@ import type {
|
||||
UrgencyLevel,
|
||||
User,
|
||||
} from "@/types/api";
|
||||
import type { ProjectOption } from "@/types/projects";
|
||||
|
||||
type ItemDraft = {
|
||||
id?: number;
|
||||
@ -63,20 +64,24 @@ const emptyItem: ItemDraft = {
|
||||
type FormState = {
|
||||
title: string;
|
||||
description: string;
|
||||
request_type: "standard" | "subscription";
|
||||
department: number | null;
|
||||
requireApproval: boolean;
|
||||
processing_department: number | null;
|
||||
urgency: UrgencyLevel;
|
||||
projects: number[];
|
||||
items: ItemDraft[];
|
||||
};
|
||||
|
||||
const emptyForm: FormState = {
|
||||
title: "",
|
||||
description: "",
|
||||
request_type: "standard",
|
||||
department: null,
|
||||
requireApproval: false,
|
||||
processing_department: null,
|
||||
urgency: "medium",
|
||||
projects: [],
|
||||
items: [{ ...emptyItem }],
|
||||
};
|
||||
|
||||
@ -87,7 +92,8 @@ type ScopeTab =
|
||||
| "processing_department"
|
||||
| "pending_approvals"
|
||||
| "my_work"
|
||||
| "available";
|
||||
| "available"
|
||||
| "subscriptions";
|
||||
type ProcurementSection = "requests" | "stats" | "suppliers";
|
||||
|
||||
type PaginatedLike<T> = {
|
||||
@ -176,7 +182,19 @@ export function useProcurementPage(user: User | null) {
|
||||
>([]);
|
||||
const [defaultProcessingDepartmentId, setDefaultProcessingDepartmentId] =
|
||||
useState<number | null>(null);
|
||||
const [
|
||||
subscriptionProcessingDepartments,
|
||||
setSubscriptionProcessingDepartments,
|
||||
] = useState<Department[]>([]);
|
||||
const [
|
||||
subscriptionDefaultProcessingDepartmentId,
|
||||
setSubscriptionDefaultProcessingDepartmentId,
|
||||
] = useState<number | null>(null);
|
||||
const [employees, setEmployees] = useState<User[]>([]);
|
||||
const [projects, setProjects] = useState<ProjectOption[]>([]);
|
||||
const [canCreateProjects, setCanCreateProjects] = useState(false);
|
||||
const [newProjectName, setNewProjectName] = useState("");
|
||||
const [creatingProject, setCreatingProject] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@ -191,6 +209,7 @@ export function useProcurementPage(user: User | null) {
|
||||
pending_approvals: 0,
|
||||
my_work: 0,
|
||||
available: 0,
|
||||
subscriptions: 0,
|
||||
});
|
||||
|
||||
const [scope, setScope] = useState<ScopeTab>("all");
|
||||
@ -204,6 +223,8 @@ export function useProcurementPage(user: User | null) {
|
||||
const [requestorFilter, setRequestorFilter] = useState("");
|
||||
const [executorFilter, setExecutorFilter] = useState("");
|
||||
const [fulfillmentFilter, setFulfillmentFilter] = useState("");
|
||||
const [projectFilter, setProjectFilter] = useState("");
|
||||
const [deferredOnly, setDeferredOnly] = useState(false);
|
||||
const [periodFilter, setPeriodFilter] = useState("");
|
||||
const [dateFromFilter, setDateFromFilter] = useState("");
|
||||
const [dateToFilter, setDateToFilter] = useState("");
|
||||
@ -301,6 +322,7 @@ export function useProcurementPage(user: User | null) {
|
||||
params.scope = "processing_department";
|
||||
else if (scope === "my_work") params.scope = "my_work";
|
||||
else if (scope === "available") params.scope = "available";
|
||||
else if (scope === "subscriptions") params.scope = "subscriptions";
|
||||
if (statusFilter.length === 1) params.status = statusFilter[0];
|
||||
else if (statusFilter.length > 1)
|
||||
params.status__in = statusFilter.join(",");
|
||||
@ -311,6 +333,8 @@ export function useProcurementPage(user: User | null) {
|
||||
if (requestorFilter) params.requestor = requestorFilter;
|
||||
if (executorFilter) params.executor = executorFilter;
|
||||
if (fulfillmentFilter) params.fulfillment_status = fulfillmentFilter;
|
||||
if (projectFilter) params.project = projectFilter;
|
||||
if (deferredOnly) params.deferred = "true";
|
||||
if (periodFilter) params.period = periodFilter;
|
||||
if (dateFromFilter) params.date_from = dateFromFilter;
|
||||
if (dateToFilter) params.date_to = dateToFilter;
|
||||
@ -325,6 +349,8 @@ export function useProcurementPage(user: User | null) {
|
||||
departmentFilter,
|
||||
executorFilter,
|
||||
fulfillmentFilter,
|
||||
projectFilter,
|
||||
deferredOnly,
|
||||
periodFilter,
|
||||
processingDepartmentFilter,
|
||||
requestorFilter,
|
||||
@ -343,6 +369,7 @@ export function useProcurementPage(user: User | null) {
|
||||
params.scope = "processing_department";
|
||||
else if (targetScope === "my_work") params.scope = "my_work";
|
||||
else if (targetScope === "available") params.scope = "available";
|
||||
else if (targetScope === "subscriptions") params.scope = "subscriptions";
|
||||
if (statusFilter.length === 1) params.status = statusFilter[0];
|
||||
else if (statusFilter.length > 1)
|
||||
params.status__in = statusFilter.join(",");
|
||||
@ -353,6 +380,8 @@ export function useProcurementPage(user: User | null) {
|
||||
if (requestorFilter) params.requestor = requestorFilter;
|
||||
if (executorFilter) params.executor = executorFilter;
|
||||
if (fulfillmentFilter) params.fulfillment_status = fulfillmentFilter;
|
||||
if (projectFilter) params.project = projectFilter;
|
||||
if (deferredOnly) params.deferred = "true";
|
||||
if (periodFilter) params.period = periodFilter;
|
||||
if (dateFromFilter) params.date_from = dateFromFilter;
|
||||
if (dateToFilter) params.date_to = dateToFilter;
|
||||
@ -367,6 +396,8 @@ export function useProcurementPage(user: User | null) {
|
||||
departmentFilter,
|
||||
executorFilter,
|
||||
fulfillmentFilter,
|
||||
projectFilter,
|
||||
deferredOnly,
|
||||
periodFilter,
|
||||
processingDepartmentFilter,
|
||||
requestorFilter,
|
||||
@ -412,6 +443,7 @@ export function useProcurementPage(user: User | null) {
|
||||
"pending_approvals",
|
||||
"my_work",
|
||||
"available",
|
||||
"subscriptions",
|
||||
];
|
||||
const results = await Promise.all(
|
||||
scopes.map(async (scopeKey) => {
|
||||
@ -469,6 +501,32 @@ export function useProcurementPage(user: User | null) {
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
void Promise.all([
|
||||
loadAllPages<ProjectOption>((params) =>
|
||||
apiClient.getLocalProjects("", params.page, false, 200),
|
||||
),
|
||||
apiClient.getProjectOptions(),
|
||||
])
|
||||
.then(([allProjects, projectOptions]) => {
|
||||
if (cancelled) return;
|
||||
setProjects(allProjects);
|
||||
setCanCreateProjects(projectOptions.can_create);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setProjects([]);
|
||||
setCanCreateProjects(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
@ -480,11 +538,19 @@ export function useProcurementPage(user: User | null) {
|
||||
setDefaultProcessingDepartmentId(
|
||||
options.default_processing_department ?? null,
|
||||
);
|
||||
setSubscriptionProcessingDepartments(
|
||||
options.subscription_processing_departments || [],
|
||||
);
|
||||
setSubscriptionDefaultProcessingDepartmentId(
|
||||
options.subscription_default_processing_department ?? null,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setProcessingDepartments([]);
|
||||
setDefaultProcessingDepartmentId(null);
|
||||
setSubscriptionProcessingDepartments([]);
|
||||
setSubscriptionDefaultProcessingDepartmentId(null);
|
||||
}
|
||||
}
|
||||
})();
|
||||
@ -521,11 +587,16 @@ export function useProcurementPage(user: User | null) {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const activeDefaultProcessingDepartmentId =
|
||||
form.request_type === "subscription"
|
||||
? subscriptionDefaultProcessingDepartmentId
|
||||
: defaultProcessingDepartmentId;
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
editingId !== null ||
|
||||
form.requireApproval ||
|
||||
!defaultProcessingDepartmentId
|
||||
!activeDefaultProcessingDepartmentId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@ -533,16 +604,16 @@ export function useProcurementPage(user: User | null) {
|
||||
setForm((previous) => {
|
||||
if (
|
||||
previous.requireApproval ||
|
||||
previous.processing_department === defaultProcessingDepartmentId
|
||||
previous.processing_department === activeDefaultProcessingDepartmentId
|
||||
) {
|
||||
return previous;
|
||||
}
|
||||
return {
|
||||
...previous,
|
||||
processing_department: defaultProcessingDepartmentId,
|
||||
processing_department: activeDefaultProcessingDepartmentId,
|
||||
};
|
||||
});
|
||||
}, [defaultProcessingDepartmentId, editingId, form.requireApproval]);
|
||||
}, [activeDefaultProcessingDepartmentId, editingId, form.requireApproval]);
|
||||
|
||||
const filteredRequests = useMemo(() => {
|
||||
const urgencyRank: Record<string, number> = {
|
||||
@ -584,20 +655,35 @@ export function useProcurementPage(user: User | null) {
|
||||
});
|
||||
}, [ordering, requests]);
|
||||
|
||||
const resetForm = useCallback(() => {
|
||||
setForm({
|
||||
...emptyForm,
|
||||
processing_department: defaultProcessingDepartmentId,
|
||||
items: [{ ...emptyItem }],
|
||||
});
|
||||
}, [defaultProcessingDepartmentId]);
|
||||
const resetForm = useCallback(
|
||||
(requestType: FormState["request_type"] = "standard") => {
|
||||
setForm({
|
||||
...emptyForm,
|
||||
request_type: requestType,
|
||||
processing_department:
|
||||
requestType === "subscription"
|
||||
? subscriptionDefaultProcessingDepartmentId
|
||||
: defaultProcessingDepartmentId,
|
||||
items: [
|
||||
{
|
||||
...emptyItem,
|
||||
unit: requestType === "subscription" ? "месяц" : emptyItem.unit,
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
[defaultProcessingDepartmentId, subscriptionDefaultProcessingDepartmentId],
|
||||
);
|
||||
|
||||
const openCreate = useCallback(() => {
|
||||
setEditingId(null);
|
||||
resetForm();
|
||||
setActionError(null);
|
||||
setCreateOpen(true);
|
||||
}, [resetForm]);
|
||||
const openCreate = useCallback(
|
||||
(requestType: FormState["request_type"] = "standard") => {
|
||||
setEditingId(null);
|
||||
resetForm(requestType);
|
||||
setActionError(null);
|
||||
setCreateOpen(true);
|
||||
},
|
||||
[resetForm],
|
||||
);
|
||||
|
||||
const openEdit = useCallback(async (request: ProcurementRequest) => {
|
||||
setCreateOpen(false);
|
||||
@ -654,10 +740,12 @@ export function useProcurementPage(user: User | null) {
|
||||
setForm({
|
||||
title: detail.title || "",
|
||||
description: detail.description || "",
|
||||
request_type: detail.request_type || "standard",
|
||||
department: detail.department ?? null,
|
||||
requireApproval: false,
|
||||
processing_department: detail.processing_department ?? null,
|
||||
urgency: detail.urgency || "medium",
|
||||
projects: detail.projects || [],
|
||||
items:
|
||||
existingItems.length > 0
|
||||
? existingItems.map((item) => ({
|
||||
@ -812,6 +900,8 @@ export function useProcurementPage(user: User | null) {
|
||||
const payload: Record<string, unknown> = {
|
||||
title: form.title,
|
||||
description: form.description.trim(),
|
||||
request_type: form.request_type,
|
||||
projects: form.projects,
|
||||
department: form.department,
|
||||
processing_department: form.processing_department,
|
||||
urgency: form.urgency,
|
||||
@ -843,6 +933,7 @@ export function useProcurementPage(user: User | null) {
|
||||
description: payload.description,
|
||||
urgency: payload.urgency,
|
||||
processing_department: payload.processing_department,
|
||||
projects: payload.projects,
|
||||
items: payload.items,
|
||||
});
|
||||
savedRequest = (await apiClient.getProcurementRequest(
|
||||
@ -885,7 +976,9 @@ export function useProcurementPage(user: User | null) {
|
||||
await refreshOne(savedRequest.id);
|
||||
toast.success(
|
||||
modalMode === "create"
|
||||
? "Заявка создана и направлена в отдел."
|
||||
? form.request_type === "subscription"
|
||||
? "Заявка создана и отправлена на согласование."
|
||||
: "Заявка создана и направлена в отдел."
|
||||
: "Заявка обновлена.",
|
||||
);
|
||||
|
||||
@ -1415,6 +1508,69 @@ export function useProcurementPage(user: User | null) {
|
||||
[applyRequestStateUpdate, markRequestNotificationsRead],
|
||||
);
|
||||
|
||||
const handleToggleDeferred = useCallback(
|
||||
async (id: number, isDeferred: boolean) => {
|
||||
try {
|
||||
setBusyKey(`deferred-${id}`);
|
||||
setActionError(null);
|
||||
const updated = (await apiClient.setProcurementRequestDeferred(
|
||||
id,
|
||||
isDeferred,
|
||||
)) as ProcurementRequest;
|
||||
applyRequestStateUpdate(updated);
|
||||
await refreshCurrentView();
|
||||
return true;
|
||||
} catch (deferredError) {
|
||||
setActionError(
|
||||
getReadableError(deferredError, "Не удалось обновить отметку заявки"),
|
||||
);
|
||||
return false;
|
||||
} finally {
|
||||
setBusyKey(null);
|
||||
}
|
||||
},
|
||||
[applyRequestStateUpdate, refreshCurrentView],
|
||||
);
|
||||
|
||||
const handleCreateProject = useCallback(async () => {
|
||||
const name = newProjectName.trim();
|
||||
if (!name) return;
|
||||
if (!user?.id || !canCreateProjects) {
|
||||
setActionError("У вас нет прав на создание проектов.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setCreatingProject(true);
|
||||
setActionError(null);
|
||||
const created = await apiClient.saveLocalProject({
|
||||
name,
|
||||
description: "",
|
||||
customer: "",
|
||||
manager: user.id,
|
||||
status: "planned",
|
||||
starts_on: null,
|
||||
ends_on: null,
|
||||
});
|
||||
setProjects((current) =>
|
||||
[...current, { id: created.id, name: created.name }].sort(
|
||||
(left, right) =>
|
||||
left.name.localeCompare(right.name, "ru", { sensitivity: "base" }),
|
||||
),
|
||||
);
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
projects: [...new Set([...current.projects, created.id])],
|
||||
}));
|
||||
setNewProjectName("");
|
||||
} catch (projectError) {
|
||||
setActionError(
|
||||
getReadableError(projectError, "Не удалось создать проект"),
|
||||
);
|
||||
} finally {
|
||||
setCreatingProject(false);
|
||||
}
|
||||
}, [canCreateProjects, newProjectName, user?.id]);
|
||||
|
||||
const handleUpdateItem = useCallback(
|
||||
async (
|
||||
requestId: number,
|
||||
@ -1709,6 +1865,8 @@ export function useProcurementPage(user: User | null) {
|
||||
requestorFilter,
|
||||
executorFilter,
|
||||
fulfillmentFilter,
|
||||
projectFilter,
|
||||
deferredOnly,
|
||||
periodFilter,
|
||||
dateFromFilter,
|
||||
dateToFilter,
|
||||
@ -1727,8 +1885,16 @@ export function useProcurementPage(user: User | null) {
|
||||
requests,
|
||||
departments,
|
||||
processingDepartments,
|
||||
subscriptionProcessingDepartments,
|
||||
projects,
|
||||
canCreateProjects,
|
||||
newProjectName,
|
||||
setNewProjectName,
|
||||
creatingProject,
|
||||
handleCreateProject,
|
||||
employees,
|
||||
defaultProcessingDepartmentId,
|
||||
subscriptionDefaultProcessingDepartmentId,
|
||||
loading,
|
||||
loadingMore,
|
||||
error,
|
||||
@ -1756,6 +1922,10 @@ export function useProcurementPage(user: User | null) {
|
||||
setExecutorFilter,
|
||||
fulfillmentFilter,
|
||||
setFulfillmentFilter,
|
||||
projectFilter,
|
||||
setProjectFilter,
|
||||
deferredOnly,
|
||||
setDeferredOnly,
|
||||
periodFilter,
|
||||
setPeriodFilter,
|
||||
dateFromFilter,
|
||||
@ -1806,6 +1976,7 @@ export function useProcurementPage(user: User | null) {
|
||||
handleMarkAllReceived,
|
||||
handleNotifyArrival,
|
||||
handleToggleViewed,
|
||||
handleToggleDeferred,
|
||||
handleUpdateItem,
|
||||
handleReportItemIssue,
|
||||
handleCancelItemIssue,
|
||||
|
||||
@ -98,6 +98,12 @@ export function createProcurementApi(
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ is_viewed: isViewed }),
|
||||
}),
|
||||
setProcurementRequestDeferred: (id: number, isDeferred: boolean) =>
|
||||
request(`/api/v1/procurement/requests/${id}/set_deferred/`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ is_deferred: isDeferred }),
|
||||
}),
|
||||
markProcurementRequestNotificationsRead: (
|
||||
id: number,
|
||||
): Promise<{ status: string; count: number; notification_ids: number[] }> =>
|
||||
|
||||
@ -26,11 +26,13 @@ export function createProjectsApi(request: RequestFn) {
|
||||
search = "",
|
||||
page = 1,
|
||||
mine = false,
|
||||
pageSize = 200,
|
||||
): Promise<{
|
||||
count: number;
|
||||
next: string | null;
|
||||
results: LocalProject[];
|
||||
}> => request(root + buildQuery({ search, page, mine })),
|
||||
}> =>
|
||||
request(root + buildQuery({ search, page, mine, page_size: pageSize })),
|
||||
getLocalProject: (id: number): Promise<LocalProject> =>
|
||||
request(`${root}${id}/`),
|
||||
getProjectOptions: (): Promise<{
|
||||
|
||||
@ -17,6 +17,13 @@ export const procurementUnitOptions = [
|
||||
{ value: "час", label: "час" },
|
||||
];
|
||||
|
||||
export const subscriptionPeriodUnitOptions = [
|
||||
{ value: "день", label: "день" },
|
||||
{ value: "неделя", label: "неделя" },
|
||||
{ value: "месяц", label: "месяц" },
|
||||
{ value: "год", label: "год" },
|
||||
];
|
||||
|
||||
const procurementUnitLabels: Record<string, string> = {
|
||||
шт: "штуки",
|
||||
компл: "комплекты",
|
||||
@ -34,6 +41,10 @@ const procurementUnitLabels: Record<string, string> = {
|
||||
пара: "пары",
|
||||
услуга: "услуги",
|
||||
час: "часы",
|
||||
день: "дней",
|
||||
неделя: "недель",
|
||||
месяц: "месяцев",
|
||||
год: "лет",
|
||||
};
|
||||
|
||||
type UnitAwareItem = {
|
||||
@ -48,17 +59,23 @@ const cleanUnitValue = (value?: string | null) =>
|
||||
const normalizeUnit = (value?: string | null) =>
|
||||
cleanUnitValue(value).toLowerCase();
|
||||
|
||||
export const getProcurementUnitOptions = (currentUnit: string) => {
|
||||
export const getProcurementUnitOptions = (
|
||||
currentUnit: string,
|
||||
includeSubscriptionPeriods = false,
|
||||
) => {
|
||||
const currentValue = cleanUnitValue(currentUnit);
|
||||
const availableOptions = includeSubscriptionPeriods
|
||||
? [...procurementUnitOptions, ...subscriptionPeriodUnitOptions]
|
||||
: procurementUnitOptions;
|
||||
if (
|
||||
!currentValue ||
|
||||
procurementUnitOptions.some((unit) => unit.value === currentValue)
|
||||
availableOptions.some((unit) => unit.value === currentValue)
|
||||
) {
|
||||
return procurementUnitOptions;
|
||||
return availableOptions;
|
||||
}
|
||||
|
||||
return [
|
||||
...procurementUnitOptions,
|
||||
...availableOptions,
|
||||
{ value: currentValue, label: `${currentValue} (текущее)` },
|
||||
];
|
||||
};
|
||||
|
||||
@ -929,6 +929,8 @@ export interface Department {
|
||||
export interface ProcurementCreateOptions {
|
||||
processing_departments: Pick<Department, "id" | "name">[];
|
||||
default_processing_department: number | null;
|
||||
subscription_processing_departments: Pick<Department, "id" | "name">[];
|
||||
subscription_default_processing_department: number | null;
|
||||
}
|
||||
|
||||
export interface DepartmentMemberRole {
|
||||
@ -1586,6 +1588,9 @@ export interface ProcurementApprovalOptions {
|
||||
|
||||
export interface ProcurementRequest {
|
||||
id: number;
|
||||
request_type?: "standard" | "subscription";
|
||||
projects?: number[];
|
||||
project_names?: string[];
|
||||
title: string;
|
||||
description: string;
|
||||
department: number;
|
||||
@ -1610,6 +1615,7 @@ export interface ProcurementRequest {
|
||||
actual_cost?: string | number | null;
|
||||
is_editable?: boolean;
|
||||
is_viewed?: boolean;
|
||||
is_deferred?: boolean;
|
||||
total_cost?: string | number;
|
||||
total_estimated_cost?: string | number;
|
||||
items_count?: number;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user