procurement: refine request workflow and filters
This commit is contained in:
parent
47088fd098
commit
ddeef4653c
@ -3,6 +3,7 @@
|
||||
"""
|
||||
|
||||
from drf_spectacular.utils import extend_schema_field
|
||||
from django.db import transaction
|
||||
from rest_framework import serializers
|
||||
|
||||
from communications import comments_helpers
|
||||
@ -49,22 +50,11 @@ def _request_items(obj):
|
||||
|
||||
|
||||
def _effective_ordered_quantity(item):
|
||||
if item.ordered_quantity is not None:
|
||||
return item.ordered_quantity
|
||||
if item.execution_status in {
|
||||
ProcurementItemExecutionStatus.ORDERED,
|
||||
ProcurementItemExecutionStatus.RECEIVED,
|
||||
}:
|
||||
return item.quantity
|
||||
return 0
|
||||
return item.effective_ordered_quantity
|
||||
|
||||
|
||||
def _effective_received_quantity(item):
|
||||
if item.received_quantity is not None:
|
||||
return item.received_quantity
|
||||
if item.execution_status == ProcurementItemExecutionStatus.RECEIVED:
|
||||
return item.quantity
|
||||
return 0
|
||||
return item.effective_received_quantity
|
||||
|
||||
|
||||
class ProcurementRequestSummaryMixin(serializers.Serializer):
|
||||
@ -159,9 +149,7 @@ class ProcurementItemSerializer(serializers.ModelSerializer):
|
||||
total_price = serializers.DecimalField(
|
||||
max_digits=12, decimal_places=2, read_only=True
|
||||
)
|
||||
execution_status_display = serializers.CharField(
|
||||
source="get_execution_status_display", read_only=True
|
||||
)
|
||||
execution_status_display = serializers.SerializerMethodField()
|
||||
comments_count = serializers.SerializerMethodField()
|
||||
|
||||
@extend_schema_field(serializers.IntegerField())
|
||||
@ -174,6 +162,10 @@ class ProcurementItemSerializer(serializers.ModelSerializer):
|
||||
|
||||
return get_comment_count(obj)
|
||||
|
||||
@extend_schema_field(serializers.CharField())
|
||||
def get_execution_status_display(self, obj):
|
||||
return obj.execution_status_display
|
||||
|
||||
class Meta:
|
||||
model = ProcurementItem
|
||||
fields = [
|
||||
@ -272,6 +264,26 @@ class ProcurementItemCreateSerializer(serializers.ModelSerializer):
|
||||
return _validate_item_quantities(attrs, self.instance)
|
||||
|
||||
|
||||
class ProcurementItemEditSerializer(ProcurementItemCreateSerializer):
|
||||
"""Сериализатор для редактирования позиций внутри заявки."""
|
||||
|
||||
id = serializers.IntegerField(required=False)
|
||||
|
||||
class Meta:
|
||||
model = ProcurementItem
|
||||
fields = [
|
||||
"id",
|
||||
"name",
|
||||
"description",
|
||||
"quantity",
|
||||
"unit",
|
||||
"estimated_unit_price",
|
||||
"supplier_info",
|
||||
"links",
|
||||
"initial_comment",
|
||||
]
|
||||
|
||||
|
||||
class ApprovalSerializer(serializers.ModelSerializer):
|
||||
"""Сериализатор для согласований."""
|
||||
|
||||
@ -321,6 +333,12 @@ class ProcurementRequestListSerializer(
|
||||
):
|
||||
"""Сериализатор для списка заявок (краткий)."""
|
||||
|
||||
description = serializers.CharField(required=False, allow_blank=True)
|
||||
items = ProcurementItemEditSerializer(
|
||||
many=True,
|
||||
required=False,
|
||||
write_only=True,
|
||||
)
|
||||
department_name = serializers.CharField(
|
||||
source="department.name", read_only=True
|
||||
)
|
||||
@ -370,6 +388,7 @@ class ProcurementRequestListSerializer(
|
||||
fields = [
|
||||
"id",
|
||||
"title",
|
||||
"description",
|
||||
"department",
|
||||
"department_name",
|
||||
"processing_department",
|
||||
@ -397,6 +416,7 @@ class ProcurementRequestListSerializer(
|
||||
"comments_count",
|
||||
"can_current_user_approve",
|
||||
"can_current_user_submit_for_approval",
|
||||
"items",
|
||||
"created_at",
|
||||
"submitted_at",
|
||||
"started_at",
|
||||
@ -431,14 +451,93 @@ class ProcurementRequestListSerializer(
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
"""
|
||||
При обновлении заявки запрещаем изменение requestor и department.
|
||||
Эти поля игнорируются при обновлении.
|
||||
Обновить заявку и, если передан список, синхронизировать позиции.
|
||||
"""
|
||||
# Игнорируем попытки изменить requestor
|
||||
validated_data.pop("requestor", None)
|
||||
# Игнорируем попытки изменить department
|
||||
validated_data.pop("department", None)
|
||||
return super().update(instance, validated_data)
|
||||
items_data = validated_data.pop("items", None)
|
||||
|
||||
if items_data is None:
|
||||
instance = super().update(instance, validated_data)
|
||||
return instance
|
||||
|
||||
if not items_data:
|
||||
raise serializers.ValidationError(
|
||||
{"items": "Добавьте хотя бы одну позицию."}
|
||||
)
|
||||
|
||||
request = self.context.get("request")
|
||||
author = getattr(request, "user", None)
|
||||
with transaction.atomic():
|
||||
instance = super().update(instance, validated_data)
|
||||
existing_items = {item.id: item for item in instance.items.all()}
|
||||
kept_item_ids = set()
|
||||
|
||||
for item_data in items_data:
|
||||
item_id = item_data.pop("id", None)
|
||||
initial_comment = item_data.pop("initial_comment", "").strip()
|
||||
if item_id is not None:
|
||||
item = existing_items.get(item_id)
|
||||
if item is None:
|
||||
raise serializers.ValidationError(
|
||||
{"items": f"Позиция {item_id} не найдена в заявке."}
|
||||
)
|
||||
for field, value in item_data.items():
|
||||
setattr(item, field, value)
|
||||
item.save()
|
||||
else:
|
||||
item = ProcurementItem.objects.create(
|
||||
request=instance,
|
||||
**item_data,
|
||||
)
|
||||
|
||||
kept_item_ids.add(item.id)
|
||||
if initial_comment and author and author.is_authenticated:
|
||||
comments_helpers.create_comment(
|
||||
obj=item,
|
||||
author=author,
|
||||
content=initial_comment,
|
||||
)
|
||||
|
||||
for item_id, item in existing_items.items():
|
||||
if item_id not in kept_item_ids:
|
||||
item.delete()
|
||||
|
||||
instance.recalculate_fulfillment_status(save=True)
|
||||
return instance
|
||||
|
||||
def validate(self, attrs):
|
||||
attrs = super().validate(attrs)
|
||||
items_data = attrs.get("items")
|
||||
if items_data is None:
|
||||
return attrs
|
||||
if not items_data:
|
||||
raise serializers.ValidationError(
|
||||
{"items": "Добавьте хотя бы одну позицию."}
|
||||
)
|
||||
names = [item.get("name", "").strip() for item in items_data]
|
||||
if not any(names):
|
||||
raise serializers.ValidationError(
|
||||
{"items": "Добавьте хотя бы одну позицию."}
|
||||
)
|
||||
if self.instance is not None:
|
||||
valid_item_ids = set(
|
||||
self.instance.items.values_list("id", flat=True)
|
||||
)
|
||||
incoming_item_ids = {
|
||||
item["id"] for item in items_data if item.get("id") is not None
|
||||
}
|
||||
unknown_item_ids = incoming_item_ids - valid_item_ids
|
||||
if unknown_item_ids:
|
||||
raise serializers.ValidationError(
|
||||
{
|
||||
"items": (
|
||||
"Позиция "
|
||||
f"{min(unknown_item_ids)} не найдена в заявке."
|
||||
)
|
||||
}
|
||||
)
|
||||
return attrs
|
||||
|
||||
|
||||
class ProcurementRequestDetailSerializer(
|
||||
|
||||
@ -72,14 +72,14 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet):
|
||||
filters.SearchFilter,
|
||||
filters.OrderingFilter,
|
||||
]
|
||||
filterset_fields = [
|
||||
"status",
|
||||
"urgency",
|
||||
"department",
|
||||
"processing_department",
|
||||
"executor",
|
||||
"fulfillment_status",
|
||||
]
|
||||
filterset_fields = {
|
||||
"status": ["exact", "in"],
|
||||
"urgency": ["exact"],
|
||||
"department": ["exact"],
|
||||
"processing_department": ["exact"],
|
||||
"executor": ["exact"],
|
||||
"fulfillment_status": ["exact"],
|
||||
}
|
||||
search_fields = ["title", "description"]
|
||||
ordering_fields = ["created_at", "status"]
|
||||
ordering = ["-created_at"]
|
||||
@ -555,7 +555,7 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet):
|
||||
"""Получить заявки, ожидающие согласования текущим польз."""
|
||||
# Находим заявки где есть pending approval для текущего юзера
|
||||
base_queryset = (
|
||||
self.get_queryset()
|
||||
self.filter_queryset(self.get_queryset())
|
||||
.filter(
|
||||
approvals__approver=request.user,
|
||||
approvals__status=ApprovalStatus.PENDING,
|
||||
|
||||
@ -187,45 +187,39 @@ class ProcurementRequest(models.Model):
|
||||
] and self.executor_id is None
|
||||
|
||||
def recalculate_fulfillment_status(self, save=True):
|
||||
"""Пересчитать рабочий статус заявки по статусам позиций."""
|
||||
item_statuses = list(
|
||||
self.items.values_list("execution_status", flat=True)
|
||||
)
|
||||
if not item_statuses:
|
||||
"""Пересчитать рабочий статус заявки по количествам позиций."""
|
||||
items = list(self.items.model.objects.filter(request_id=self.pk))
|
||||
if not items:
|
||||
new_status = ProcurementFulfillmentStatus.PENDING
|
||||
elif any(
|
||||
item_status
|
||||
in {
|
||||
item.execution_status in {
|
||||
ProcurementItemExecutionStatus.REJECTED,
|
||||
ProcurementItemExecutionStatus.COMPLETED_WITH_ISSUE,
|
||||
ProcurementItemExecutionStatus.EDITED,
|
||||
ProcurementItemExecutionStatus.DEFECTIVE,
|
||||
}
|
||||
for item_status in item_statuses
|
||||
for item in items
|
||||
):
|
||||
new_status = ProcurementFulfillmentStatus.ISSUES
|
||||
elif all(
|
||||
item_status == ProcurementItemExecutionStatus.RECEIVED
|
||||
for item_status in item_statuses
|
||||
):
|
||||
new_status = ProcurementFulfillmentStatus.COMPLETED
|
||||
elif any(
|
||||
item_status == ProcurementItemExecutionStatus.RECEIVED
|
||||
for item_status in item_statuses
|
||||
):
|
||||
new_status = ProcurementFulfillmentStatus.PARTIALLY_RECEIVED
|
||||
elif all(
|
||||
item_status == ProcurementItemExecutionStatus.ORDERED
|
||||
for item_status in item_statuses
|
||||
):
|
||||
new_status = ProcurementFulfillmentStatus.ORDERED
|
||||
elif any(
|
||||
item_status == ProcurementItemExecutionStatus.ORDERED
|
||||
for item_status in item_statuses
|
||||
):
|
||||
new_status = ProcurementFulfillmentStatus.PARTIALLY_ORDERED
|
||||
else:
|
||||
new_status = ProcurementFulfillmentStatus.PENDING
|
||||
total_quantity = sum(item.quantity for item in items)
|
||||
ordered_quantity = sum(
|
||||
item.effective_ordered_quantity for item in items
|
||||
)
|
||||
received_quantity = sum(
|
||||
item.effective_received_quantity for item in items
|
||||
)
|
||||
|
||||
if received_quantity >= total_quantity:
|
||||
new_status = ProcurementFulfillmentStatus.COMPLETED
|
||||
elif received_quantity > 0:
|
||||
new_status = ProcurementFulfillmentStatus.PARTIALLY_RECEIVED
|
||||
elif ordered_quantity >= total_quantity:
|
||||
new_status = ProcurementFulfillmentStatus.ORDERED
|
||||
elif ordered_quantity > 0:
|
||||
new_status = ProcurementFulfillmentStatus.PARTIALLY_ORDERED
|
||||
else:
|
||||
new_status = ProcurementFulfillmentStatus.PENDING
|
||||
|
||||
should_close = (
|
||||
new_status == ProcurementFulfillmentStatus.COMPLETED
|
||||
@ -351,6 +345,45 @@ class ProcurementItem(models.Model):
|
||||
def __str__(self):
|
||||
return f"{self.name} ({self.quantity} {self.unit})"
|
||||
|
||||
@property
|
||||
def effective_ordered_quantity(self):
|
||||
if self.ordered_quantity is not None:
|
||||
return self.ordered_quantity
|
||||
if self.execution_status in {
|
||||
ProcurementItemExecutionStatus.ORDERED,
|
||||
ProcurementItemExecutionStatus.RECEIVED,
|
||||
}:
|
||||
return self.quantity
|
||||
return 0
|
||||
|
||||
@property
|
||||
def effective_received_quantity(self):
|
||||
if self.received_quantity is not None:
|
||||
return self.received_quantity
|
||||
if self.execution_status == ProcurementItemExecutionStatus.RECEIVED:
|
||||
return self.quantity
|
||||
return 0
|
||||
|
||||
@property
|
||||
def execution_status_display(self):
|
||||
problem_statuses = {
|
||||
ProcurementItemExecutionStatus.REJECTED,
|
||||
ProcurementItemExecutionStatus.COMPLETED_WITH_ISSUE,
|
||||
ProcurementItemExecutionStatus.EDITED,
|
||||
ProcurementItemExecutionStatus.DEFECTIVE,
|
||||
}
|
||||
if self.execution_status in problem_statuses:
|
||||
return self.get_execution_status_display()
|
||||
if self.effective_received_quantity > 0:
|
||||
if self.effective_received_quantity < self.quantity:
|
||||
return "Получено частично"
|
||||
return ProcurementItemExecutionStatus.RECEIVED.label
|
||||
if self.effective_ordered_quantity > 0:
|
||||
if self.effective_ordered_quantity < self.quantity:
|
||||
return "Заказано частично"
|
||||
return ProcurementItemExecutionStatus.ORDERED.label
|
||||
return self.get_execution_status_display()
|
||||
|
||||
@property
|
||||
def total_price(self):
|
||||
"""Общая стоимость позиции по фактической или ориентировочной цене."""
|
||||
|
||||
@ -982,6 +982,82 @@ class TestProcessingDepartmentWorkflow:
|
||||
)
|
||||
assert edit_response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
def test_author_can_edit_waiting_request_with_items_before_work(
|
||||
self, api_client, user, department, supply_department,
|
||||
processing_request, procurement_item_factory
|
||||
):
|
||||
kept_item = procurement_item_factory(
|
||||
request=processing_request,
|
||||
name="Старое название",
|
||||
quantity=2,
|
||||
unit="шт",
|
||||
estimated_unit_price=Decimal("10.00"),
|
||||
)
|
||||
removed_item = procurement_item_factory(
|
||||
request=processing_request,
|
||||
name="Удалить",
|
||||
quantity=1,
|
||||
)
|
||||
api_client.force_authenticate(user=user)
|
||||
url = reverse(
|
||||
'api:v1:procurement:procurementrequest-detail',
|
||||
kwargs={'pk': processing_request.id},
|
||||
)
|
||||
|
||||
response = api_client.patch(
|
||||
url,
|
||||
{
|
||||
"title": "Обновленная закупка",
|
||||
"description": "Уточненное описание",
|
||||
"department": department.id,
|
||||
"processing_department": supply_department.id,
|
||||
"urgency": UrgencyLevel.HIGH,
|
||||
"items": [
|
||||
{
|
||||
"id": kept_item.id,
|
||||
"name": "Обновленная позиция",
|
||||
"description": "Новые детали",
|
||||
"quantity": 5,
|
||||
"unit": "упак",
|
||||
"estimated_unit_price": "12.50",
|
||||
"supplier_info": "Поставщик",
|
||||
"links": ["https://example.com/updated"],
|
||||
},
|
||||
{
|
||||
"name": "Новая позиция",
|
||||
"description": "",
|
||||
"quantity": 1,
|
||||
"unit": "шт",
|
||||
"estimated_unit_price": None,
|
||||
"supplier_info": "",
|
||||
"links": [],
|
||||
"initial_comment": "Первичный комментарий",
|
||||
},
|
||||
],
|
||||
},
|
||||
format='json',
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
processing_request.refresh_from_db()
|
||||
kept_item.refresh_from_db()
|
||||
assert processing_request.title == "Обновленная закупка"
|
||||
assert processing_request.description == "Уточненное описание"
|
||||
assert processing_request.urgency == UrgencyLevel.HIGH
|
||||
assert kept_item.name == "Обновленная позиция"
|
||||
assert kept_item.description == "Новые детали"
|
||||
assert kept_item.quantity == 5
|
||||
assert kept_item.unit == "упак"
|
||||
assert kept_item.estimated_unit_price == Decimal("12.50")
|
||||
assert kept_item.supplier_info == "Поставщик"
|
||||
assert kept_item.links == ["https://example.com/updated"]
|
||||
assert not ProcurementItem.objects.filter(id=removed_item.id).exists()
|
||||
|
||||
new_item = processing_request.items.get(name="Новая позиция")
|
||||
comments = get_comments(new_item)
|
||||
assert len(comments) == 1
|
||||
assert comments[0].content == "Первичный комментарий"
|
||||
|
||||
@pytest.fixture
|
||||
def department_head_approval_route(self, department_head):
|
||||
return ApprovalRoute.objects.create(
|
||||
@ -1340,6 +1416,92 @@ class TestProcessingDepartmentWorkflow:
|
||||
assert item.ordered_quantity == 2
|
||||
assert item.execution_status == ProcurementItemExecutionStatus.ORDERED
|
||||
|
||||
def test_partial_quantities_drive_item_display_and_request_fulfillment(
|
||||
self, api_client, supply_user, processing_request,
|
||||
procurement_item_factory
|
||||
):
|
||||
item = procurement_item_factory(
|
||||
request=processing_request,
|
||||
quantity=4,
|
||||
execution_status=ProcurementItemExecutionStatus.PENDING,
|
||||
)
|
||||
processing_request.executor = supply_user
|
||||
processing_request.status = ProcurementStatus.IN_PROGRESS
|
||||
processing_request.save()
|
||||
|
||||
api_client.force_authenticate(user=supply_user)
|
||||
item_url = reverse(
|
||||
'api:v1:procurement:procurementitem-detail',
|
||||
kwargs={'pk': item.id},
|
||||
)
|
||||
|
||||
partial_ordered_response = api_client.patch(
|
||||
item_url,
|
||||
{'ordered_quantity': 1},
|
||||
format='json',
|
||||
)
|
||||
|
||||
assert partial_ordered_response.status_code == status.HTTP_200_OK
|
||||
assert (
|
||||
partial_ordered_response.data["execution_status_display"]
|
||||
== "Заказано частично"
|
||||
)
|
||||
processing_request.refresh_from_db()
|
||||
assert (
|
||||
processing_request.fulfillment_status
|
||||
== ProcurementFulfillmentStatus.PARTIALLY_ORDERED
|
||||
)
|
||||
assert processing_request.status == ProcurementStatus.IN_PROGRESS
|
||||
|
||||
ordered_response = api_client.patch(
|
||||
item_url,
|
||||
{'ordered_quantity': 4},
|
||||
format='json',
|
||||
)
|
||||
|
||||
assert ordered_response.status_code == status.HTTP_200_OK
|
||||
assert ordered_response.data["execution_status_display"] == "Заказано"
|
||||
processing_request.refresh_from_db()
|
||||
assert (
|
||||
processing_request.fulfillment_status
|
||||
== ProcurementFulfillmentStatus.ORDERED
|
||||
)
|
||||
assert processing_request.status == ProcurementStatus.IN_PROGRESS
|
||||
|
||||
partial_received_response = api_client.patch(
|
||||
item_url,
|
||||
{'received_quantity': 2},
|
||||
format='json',
|
||||
)
|
||||
|
||||
assert partial_received_response.status_code == status.HTTP_200_OK
|
||||
assert (
|
||||
partial_received_response.data["execution_status_display"]
|
||||
== "Получено частично"
|
||||
)
|
||||
processing_request.refresh_from_db()
|
||||
assert (
|
||||
processing_request.fulfillment_status
|
||||
== ProcurementFulfillmentStatus.PARTIALLY_RECEIVED
|
||||
)
|
||||
assert processing_request.status == ProcurementStatus.IN_PROGRESS
|
||||
|
||||
received_response = api_client.patch(
|
||||
item_url,
|
||||
{'received_quantity': 4},
|
||||
format='json',
|
||||
)
|
||||
|
||||
assert received_response.status_code == status.HTTP_200_OK
|
||||
assert received_response.data["execution_status_display"] == "Получено"
|
||||
processing_request.refresh_from_db()
|
||||
assert (
|
||||
processing_request.fulfillment_status
|
||||
== ProcurementFulfillmentStatus.COMPLETED
|
||||
)
|
||||
assert processing_request.status == ProcurementStatus.COMPLETED
|
||||
assert processing_request.completed_at is not None
|
||||
|
||||
def test_received_quantity_sets_ordered_or_received_status(
|
||||
self, api_client, supply_user, processing_request,
|
||||
procurement_item_factory
|
||||
|
||||
@ -4,6 +4,7 @@ import { AppShell } from "../../components/AppShell";
|
||||
import { Modal } from "@/components/ui/Modal";
|
||||
import { useUser } from "@/contexts/UserContext";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import { loadAllPages } from "@/lib/shared";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import type { Department } from "@/types/api";
|
||||
@ -39,8 +40,10 @@ export default function DepartmentsPage() {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const response = await apiClient.getDepartments();
|
||||
setDepartments(response.results || []);
|
||||
const allDepartments = await loadAllPages<Department>((params) =>
|
||||
apiClient.getDepartments(params),
|
||||
);
|
||||
setDepartments(allDepartments);
|
||||
} catch (err) {
|
||||
console.error("Ошибка загрузки отделов:", err);
|
||||
setError("Не удалось загрузить отделы");
|
||||
|
||||
@ -8,7 +8,7 @@ import Link from "next/link";
|
||||
import { ProcurementRequestDetailContent } from "@/components/procurement/ProcurementRequestDetailContent";
|
||||
import ProcurementStatsPanel from "@/components/procurement/ProcurementStatsPanel";
|
||||
import ProcurementSuppliersPanel from "@/components/procurement/ProcurementSuppliersPanel";
|
||||
import type { ProcurementRequest, UrgencyLevel } from "@/types/api";
|
||||
import type { ProcurementRequest, ProcurementStatus, UrgencyLevel } from "@/types/api";
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowUpDown,
|
||||
@ -43,7 +43,7 @@ import { Modal } from "@/components/ui";
|
||||
Constants & helpers
|
||||
══════════════════════════════════════════════════════ */
|
||||
|
||||
const statusMeta: Record<string, { label: string; cls: string }> = {
|
||||
const statusMeta: Record<ProcurementStatus, { label: string; cls: string }> = {
|
||||
draft: { label: "Черновик", cls: "app-badge" },
|
||||
waiting: { label: "Ожидает", cls: "app-feedback-warning" },
|
||||
pending: { label: "На согласовании", cls: "app-feedback-warning" },
|
||||
@ -53,7 +53,11 @@ const statusMeta: Record<string, { label: string; cls: string }> = {
|
||||
rejected: { label: "Отклонено", cls: "app-feedback-danger" },
|
||||
cancelled: { label: "Отменено", cls: "app-badge" },
|
||||
};
|
||||
const statusOptions = Object.entries(statusMeta) as [ProcurementStatus, { label: string; cls: string }][];
|
||||
const defaultStatusMeta = { label: "—", cls: "app-badge" };
|
||||
const getStatusMeta = (status?: string | null) => (
|
||||
statusMeta[String(status || "").toLowerCase() as ProcurementStatus] ?? defaultStatusMeta
|
||||
);
|
||||
|
||||
const urgencyMeta: Record<string, { label: string; cls: string }> = {
|
||||
low: { label: "Низкая", cls: "text-gray-500" },
|
||||
@ -352,14 +356,35 @@ export default function ProcurementPage() {
|
||||
toggleItemComments,
|
||||
} = useProcurementPage(user);
|
||||
|
||||
const toggleStatusFilter = useCallback((status: ProcurementStatus) => {
|
||||
setStatusFilter((current) => (
|
||||
current.includes(status)
|
||||
? current.filter((item) => item !== status)
|
||||
: [...current, status]
|
||||
));
|
||||
}, [setStatusFilter]);
|
||||
|
||||
const [detailModalId, setDetailModalId] = useState<number | null>(null);
|
||||
const [detailModalLoading, setDetailModalLoading] = useState(false);
|
||||
const [detailModalError, setDetailModalError] = useState<string | null>(null);
|
||||
const procurementMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
const statusFilterRef = useRef<HTMLDivElement | null>(null);
|
||||
const [procurementMenuOpenId, setProcurementMenuOpenId] = useState<number | null>(null);
|
||||
const [statusFilterOpen, setStatusFilterOpen] = useState(false);
|
||||
const [requestActionDialog, setRequestActionDialog] = useState<{ kind: RequestActionDialogKind; requestId: number } | null>(null);
|
||||
const [requestActionComment, setRequestActionComment] = useState("");
|
||||
|
||||
const statusFilterSummary = useMemo(() => {
|
||||
if (statusFilter.length === 0) return "Все статусы";
|
||||
|
||||
const labels = statusFilter
|
||||
.map((status) => statusMeta[status]?.label)
|
||||
.filter(Boolean);
|
||||
|
||||
if (labels.length <= 2) return labels.join(", ");
|
||||
return `Выбрано статусов: ${labels.length}`;
|
||||
}, [statusFilter]);
|
||||
|
||||
const syncRequestUrl = useCallback((requestId: number | null) => {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
@ -499,6 +524,36 @@ export default function ProcurementPage() {
|
||||
};
|
||||
}, [procurementMenuOpenId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!statusFilterOpen) return;
|
||||
|
||||
const handlePointerDown = (event: MouseEvent) => {
|
||||
if (statusFilterRef.current && !statusFilterRef.current.contains(event.target as Node)) {
|
||||
setStatusFilterOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
setStatusFilterOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handlePointerDown);
|
||||
document.addEventListener("keydown", handleEscape);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handlePointerDown);
|
||||
document.removeEventListener("keydown", handleEscape);
|
||||
};
|
||||
}, [statusFilterOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!filtersOpen) {
|
||||
setStatusFilterOpen(false);
|
||||
}
|
||||
}, [filtersOpen]);
|
||||
|
||||
/* ══════════════════════════════════════════════════════
|
||||
RENDER
|
||||
══════════════════════════════════════════════════════ */
|
||||
@ -616,10 +671,62 @@ export default function ProcurementPage() {
|
||||
{/* ── filters panel ── */}
|
||||
{filtersOpen && (
|
||||
<div className="app-surface-muted mb-3 flex flex-col gap-2 rounded-xl p-3">
|
||||
<select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)} className="app-select rounded-lg px-3 py-2 text-sm">
|
||||
<option value="">Все статусы</option>
|
||||
{Object.entries(statusMeta).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)}
|
||||
</select>
|
||||
<div ref={statusFilterRef} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStatusFilterOpen((current) => !current)}
|
||||
aria-expanded={statusFilterOpen}
|
||||
className="app-select flex w-full items-center justify-between gap-3 rounded-lg px-3 py-2 text-left text-sm"
|
||||
>
|
||||
<span className={statusFilter.length > 0 ? "truncate text-[var(--foreground)]" : "app-text-muted truncate"}>
|
||||
{statusFilterSummary}
|
||||
</span>
|
||||
<span className="flex shrink-0 items-center gap-2">
|
||||
{statusFilter.length > 0 ? (
|
||||
<span className="app-badge rounded-full px-1.5 py-0.5 text-[10px] font-bold">
|
||||
{statusFilter.length}
|
||||
</span>
|
||||
) : null}
|
||||
<ChevronDown size={14} className={`app-text-muted transition ${statusFilterOpen ? "rotate-180" : ""}`} />
|
||||
</span>
|
||||
</button>
|
||||
{statusFilterOpen ? (
|
||||
<div className="app-menu absolute inset-x-0 z-50 mt-1 overflow-hidden rounded-lg">
|
||||
<div className="app-divider flex items-center justify-between gap-2 border-b px-3 py-2">
|
||||
<span className="app-text-muted text-xs font-semibold uppercase">Статусы</span>
|
||||
{statusFilter.length > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStatusFilter([])}
|
||||
className="app-link-accent text-xs font-medium"
|
||||
>
|
||||
Сбросить
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="max-h-64 overflow-y-auto p-1">
|
||||
{statusOptions.map(([key, meta]) => (
|
||||
<label
|
||||
key={key}
|
||||
className={`flex cursor-pointer items-center gap-2 rounded-md px-2 py-2 text-sm transition ${
|
||||
statusFilter.includes(key)
|
||||
? "app-selected app-accent-text font-medium"
|
||||
: "hover:bg-[var(--surface-secondary)]"
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={statusFilter.includes(key)}
|
||||
onChange={() => toggleStatusFilter(key)}
|
||||
className="h-4 w-4 rounded border-[var(--border-strong)] accent-[var(--accent-primary)]"
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate">{meta.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<select value={urgencyFilter} onChange={(e) => setUrgencyFilter(e.target.value)} className="app-select rounded-lg px-3 py-2 text-sm">
|
||||
<option value="">Все уровни срочности</option>
|
||||
{Object.entries(urgencyMeta).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)}
|
||||
@ -632,7 +739,7 @@ export default function ProcurementPage() {
|
||||
{periodOptions.map((option) => <option key={option.value || "all"} value={option.value}>{option.label}</option>)}
|
||||
</select>
|
||||
{activeFilterCount > 0 && (
|
||||
<button type="button" onClick={() => { setStatusFilter(""); setUrgencyFilter(""); setDepartmentFilter(""); setPeriodFilter(""); }} className="app-action-secondary rounded-lg px-3 py-2 text-sm font-medium transition">
|
||||
<button type="button" onClick={() => { setStatusFilter([]); setUrgencyFilter(""); setDepartmentFilter(""); setPeriodFilter(""); }} className="app-action-secondary rounded-lg px-3 py-2 text-sm font-medium transition">
|
||||
Очистить фильтры
|
||||
</button>
|
||||
)}
|
||||
@ -648,7 +755,7 @@ export default function ProcurementPage() {
|
||||
</div>
|
||||
) : filteredRequests.map((req) => {
|
||||
const st = String(req.status || "").toLowerCase();
|
||||
const sMeta = statusMeta[st] ?? defaultStatusMeta;
|
||||
const sMeta = getStatusMeta(st);
|
||||
const urg = urgencyMeta[req.urgency] ?? urgencyMeta.medium;
|
||||
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);
|
||||
@ -1021,8 +1128,8 @@ export default function ProcurementPage() {
|
||||
<div className="app-surface-muted rounded-xl px-4 py-4">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className={`app-status-pill ${statusMeta[String(selectedRequest.status || "").toLowerCase()]?.cls ?? defaultStatusMeta.cls}`}>
|
||||
{statusMeta[String(selectedRequest.status || "").toLowerCase()]?.label ?? defaultStatusMeta.label}
|
||||
<span className={`app-status-pill ${getStatusMeta(selectedRequest.status).cls}`}>
|
||||
{getStatusMeta(selectedRequest.status).label}
|
||||
</span>
|
||||
<span className={`text-[11px] font-medium ${urgencyMeta[selectedRequest.urgency]?.cls ?? urgencyMeta.medium.cls}`}>
|
||||
{(urgencyMeta[selectedRequest.urgency]?.label ?? urgencyMeta.medium.label)} срочность
|
||||
@ -1290,6 +1397,7 @@ export default function ProcurementPage() {
|
||||
items={departments.map((d) => ({ id: d.id, name: d.name }))}
|
||||
selectedId={form.department}
|
||||
onSelect={(id) => setForm((f) => ({ ...f, department: id }))}
|
||||
disabled={modalMode === "edit"}
|
||||
/>
|
||||
<div>
|
||||
<label className="app-text-muted mb-1 block text-xs font-medium">Срочность</label>
|
||||
@ -1312,7 +1420,7 @@ export default function ProcurementPage() {
|
||||
items={departments.map((d) => ({ id: d.id, name: d.name }))}
|
||||
selectedId={form.processing_department}
|
||||
onSelect={(id) => setForm((f) => ({ ...f, processing_department: id }))}
|
||||
disabled={modalMode === "create" && Boolean(defaultProcessingDepartmentId)}
|
||||
disabled={Boolean(defaultProcessingDepartmentId)}
|
||||
/>
|
||||
|
||||
{/* ── Items ── */}
|
||||
|
||||
@ -11,11 +11,13 @@ import type {
|
||||
ProcurementItem,
|
||||
ProcurementItemComment,
|
||||
ProcurementRequest,
|
||||
ProcurementStatus,
|
||||
UrgencyLevel,
|
||||
User,
|
||||
} from "@/types/api";
|
||||
|
||||
type ItemDraft = {
|
||||
id?: number;
|
||||
name: string;
|
||||
description: string;
|
||||
quantity: string;
|
||||
@ -155,7 +157,7 @@ export function useProcurementPage(user: User | null) {
|
||||
const [scope, setScope] = useState<ScopeTab>("all");
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const deferredSearchQuery = useDeferredValue(searchQuery);
|
||||
const [statusFilter, setStatusFilter] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState<ProcurementStatus[]>([]);
|
||||
const [urgencyFilter, setUrgencyFilter] = useState("");
|
||||
const [departmentFilter, setDepartmentFilter] = useState("");
|
||||
const [periodFilter, setPeriodFilter] = useState("");
|
||||
@ -225,7 +227,8 @@ export function useProcurementPage(user: User | null) {
|
||||
else if (scope === "department") params.scope = "department";
|
||||
else if (scope === "my_work") params.scope = "my_work";
|
||||
else if (scope === "available") params.scope = "available";
|
||||
if (statusFilter) params.status = statusFilter;
|
||||
if (statusFilter.length === 1) params.status = statusFilter[0];
|
||||
else if (statusFilter.length > 1) params.status__in = statusFilter.join(",");
|
||||
if (urgencyFilter) params.urgency = urgencyFilter;
|
||||
if (departmentFilter) params.department = departmentFilter;
|
||||
if (periodFilter) params.period = periodFilter;
|
||||
@ -242,7 +245,8 @@ export function useProcurementPage(user: User | null) {
|
||||
else if (targetScope === "department") params.scope = "department";
|
||||
else if (targetScope === "my_work") params.scope = "my_work";
|
||||
else if (targetScope === "available") params.scope = "available";
|
||||
if (statusFilter) params.status = statusFilter;
|
||||
if (statusFilter.length === 1) params.status = statusFilter[0];
|
||||
else if (statusFilter.length > 1) params.status__in = statusFilter.join(",");
|
||||
if (urgencyFilter) params.urgency = urgencyFilter;
|
||||
if (departmentFilter) params.department = departmentFilter;
|
||||
if (periodFilter) params.period = periodFilter;
|
||||
@ -398,13 +402,33 @@ export function useProcurementPage(user: User | null) {
|
||||
setCreateOpen(true);
|
||||
}, [resetForm]);
|
||||
|
||||
const openEdit = useCallback((request: ProcurementRequest) => {
|
||||
const openEdit = useCallback(async (request: ProcurementRequest) => {
|
||||
setCreateOpen(false);
|
||||
setEditingId(request.id);
|
||||
setActionError(null);
|
||||
setActionSuccess(null);
|
||||
|
||||
const detail = detailsCache[request.id] || request;
|
||||
let detail: ProcurementRequest;
|
||||
try {
|
||||
detail = detailsCacheRef.current[request.id];
|
||||
if (!detail) {
|
||||
detail = await apiClient.getProcurementRequest(request.id);
|
||||
detailsCacheRef.current = {
|
||||
...detailsCacheRef.current,
|
||||
[request.id]: detail,
|
||||
};
|
||||
setDetailsCache((previous) => ({ ...previous, [request.id]: detail }));
|
||||
setRequests((previous) => previous.map((currentRequest) => (
|
||||
currentRequest.id === request.id
|
||||
? { ...currentRequest, ...detail }
|
||||
: currentRequest
|
||||
)));
|
||||
}
|
||||
} catch {
|
||||
setActionError("Не удалось загрузить заявку для редактирования.");
|
||||
return;
|
||||
}
|
||||
|
||||
setEditingId(request.id);
|
||||
setForm({
|
||||
title: detail.title || "",
|
||||
description: detail.description || "",
|
||||
@ -414,6 +438,7 @@ export function useProcurementPage(user: User | null) {
|
||||
urgency: detail.urgency || "medium",
|
||||
items: detail.items && detail.items.length > 0
|
||||
? detail.items.map((item) => ({
|
||||
id: item.id,
|
||||
name: item.name || "",
|
||||
description: item.description || "",
|
||||
quantity: String(item.quantity || "1"),
|
||||
@ -425,7 +450,7 @@ export function useProcurementPage(user: User | null) {
|
||||
}))
|
||||
: [{ ...emptyItem }],
|
||||
});
|
||||
}, [detailsCache]);
|
||||
}, []);
|
||||
|
||||
const closeModal = useCallback(() => {
|
||||
setCreateOpen(false);
|
||||
@ -505,6 +530,7 @@ export function useProcurementPage(user: User | null) {
|
||||
processing_department: form.processing_department,
|
||||
urgency: form.urgency,
|
||||
items: validItems.map((item) => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
description: item.description || undefined,
|
||||
quantity: item.quantity,
|
||||
@ -526,6 +552,7 @@ export function useProcurementPage(user: User | null) {
|
||||
description: payload.description,
|
||||
urgency: payload.urgency,
|
||||
processing_department: payload.processing_department,
|
||||
items: payload.items,
|
||||
});
|
||||
setActionSuccess("Заявка обновлена.");
|
||||
setEditingId(null);
|
||||
@ -898,7 +925,12 @@ export function useProcurementPage(user: User | null) {
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const activeFilterCount = [statusFilter, urgencyFilter, departmentFilter, periodFilter].filter(Boolean).length;
|
||||
const activeFilterCount = [
|
||||
statusFilter.length > 0,
|
||||
urgencyFilter,
|
||||
departmentFilter,
|
||||
periodFilter,
|
||||
].filter(Boolean).length;
|
||||
const isFinal = useCallback((status?: string) => ["completed", "rejected", "cancelled"].includes(String(status || "").toLowerCase()), []);
|
||||
|
||||
return {
|
||||
|
||||
@ -13,12 +13,13 @@ export function createEmployeesApi(request: RequestFn) {
|
||||
request('/api/v1/directory/me/login/'),
|
||||
refreshDirectoryLogin: () =>
|
||||
request('/api/v1/directory/me/login/refresh/', { method: 'POST' }),
|
||||
getEmployees: (params?: { search?: string; department?: string; page?: number; limit?: number; is_active?: boolean; ordering?: string }) => {
|
||||
getEmployees: (params?: { search?: string; department?: string; page?: number; page_size?: number; limit?: number; is_active?: boolean; ordering?: string }) => {
|
||||
const qp = new URLSearchParams();
|
||||
if (params?.search) qp.append('search', params.search);
|
||||
if (params?.department) qp.append('department', params.department);
|
||||
if (params?.page) qp.append('page', params.page.toString());
|
||||
if (params?.limit) qp.append('limit', params.limit.toString());
|
||||
const pageSize = params?.page_size ?? params?.limit;
|
||||
if (pageSize) qp.append('page_size', pageSize.toString());
|
||||
if (params?.is_active !== undefined) qp.append('active', params.is_active.toString());
|
||||
if (params?.ordering) qp.append('ordering', params.ordering);
|
||||
const qs = qp.toString();
|
||||
@ -46,11 +47,12 @@ export function createEmployeesApi(request: RequestFn) {
|
||||
request(`/api/v1/employee-actions/${actionId}/`, { method: 'PATCH', body: JSON.stringify(data) }),
|
||||
deleteEmployeeAction: (actionId: number) =>
|
||||
request(`/api/v1/employee-actions/${actionId}/`, { method: 'DELETE' }),
|
||||
getDepartments: (params?: { search?: string; page?: number; limit?: number }) => {
|
||||
getDepartments: (params?: { search?: string; page?: number; page_size?: number; limit?: number }) => {
|
||||
const qp = new URLSearchParams();
|
||||
if (params?.search) qp.append('search', params.search);
|
||||
if (params?.page) qp.append('page', params.page.toString());
|
||||
if (params?.limit) qp.append('limit', params.limit.toString());
|
||||
const pageSize = params?.page_size ?? params?.limit;
|
||||
if (pageSize) qp.append('page_size', pageSize.toString());
|
||||
const qs = qp.toString();
|
||||
return request(`/api/v1/departments/${qs ? '?' + qs : ''}`);
|
||||
},
|
||||
@ -96,11 +98,12 @@ export function createEmployeesApi(request: RequestFn) {
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
getMyDepartments: () => request('/api/v1/departments/my-departments/'),
|
||||
getDepartmentRoles: (params?: { department?: number | string; page?: number; limit?: number; ordering?: string }) => {
|
||||
getDepartmentRoles: (params?: { department?: number | string; page?: number; page_size?: number; limit?: number; ordering?: string }) => {
|
||||
const qp = new URLSearchParams();
|
||||
if (params?.department) qp.append('department', String(params.department));
|
||||
if (params?.page) qp.append('page', params.page.toString());
|
||||
if (params?.limit) qp.append('limit', params.limit.toString());
|
||||
const pageSize = params?.page_size ?? params?.limit;
|
||||
if (pageSize) qp.append('page_size', pageSize.toString());
|
||||
if (params?.ordering) qp.append('ordering', params.ordering);
|
||||
const qs = qp.toString();
|
||||
return request(`/api/v1/department-roles/${qs ? '?' + qs : ''}`);
|
||||
|
||||
@ -19,7 +19,7 @@ export async function loadAllPages<T extends { id: number }>(fetcher: PageFetche
|
||||
const all: T[] = [];
|
||||
let page = 1;
|
||||
for (;;) {
|
||||
const res = await fetcher({ page, limit: 200 });
|
||||
const res = await fetcher({ page, page_size: 200, limit: 200 });
|
||||
const results = Array.isArray(res) ? res : (res.results || []);
|
||||
all.push(...results);
|
||||
if (Array.isArray(res) || !(res as { next?: string | null }).next) break;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user