diff --git a/backend/api/v1/procurement/serializers.py b/backend/api/v1/procurement/serializers.py index d81f136d..719742de 100644 --- a/backend/api/v1/procurement/serializers.py +++ b/backend/api/v1/procurement/serializers.py @@ -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( diff --git a/backend/api/v1/procurement/views.py b/backend/api/v1/procurement/views.py index 100b90b2..f7a0fa83 100644 --- a/backend/api/v1/procurement/views.py +++ b/backend/api/v1/procurement/views.py @@ -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, diff --git a/backend/procurement/models.py b/backend/procurement/models.py index 09cabf73..bff21599 100644 --- a/backend/procurement/models.py +++ b/backend/procurement/models.py @@ -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): """Общая стоимость позиции по фактической или ориентировочной цене.""" diff --git a/backend/tests/api/v1/procurement/test_procurement_requests.py b/backend/tests/api/v1/procurement/test_procurement_requests.py index 8e7175f6..1bbd6105 100644 --- a/backend/tests/api/v1/procurement/test_procurement_requests.py +++ b/backend/tests/api/v1/procurement/test_procurement_requests.py @@ -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 diff --git a/frontend/src/app/departments/page.tsx b/frontend/src/app/departments/page.tsx index defa7556..e51b316d 100644 --- a/frontend/src/app/departments/page.tsx +++ b/frontend/src/app/departments/page.tsx @@ -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((params) => + apiClient.getDepartments(params), + ); + setDepartments(allDepartments); } catch (err) { console.error("Ошибка загрузки отделов:", err); setError("Не удалось загрузить отделы"); diff --git a/frontend/src/app/procurement/page.tsx b/frontend/src/app/procurement/page.tsx index 29819cc0..291bd6ee 100644 --- a/frontend/src/app/procurement/page.tsx +++ b/frontend/src/app/procurement/page.tsx @@ -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 = { +const statusMeta: Record = { draft: { label: "Черновик", cls: "app-badge" }, waiting: { label: "Ожидает", cls: "app-feedback-warning" }, pending: { label: "На согласовании", cls: "app-feedback-warning" }, @@ -53,7 +53,11 @@ const statusMeta: Record = { 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 = { 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(null); const [detailModalLoading, setDetailModalLoading] = useState(false); const [detailModalError, setDetailModalError] = useState(null); const procurementMenuRef = useRef(null); + const statusFilterRef = useRef(null); const [procurementMenuOpenId, setProcurementMenuOpenId] = useState(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 && (
- +
+ + {statusFilterOpen ? ( +
+
+ Статусы + {statusFilter.length > 0 ? ( + + ) : null} +
+
+ {statusOptions.map(([key, meta]) => ( + + ))} +
+
+ ) : null} +
{activeFilterCount > 0 && ( - )} @@ -648,7 +755,7 @@ export default function ProcurementPage() {
) : 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() {
- - {statusMeta[String(selectedRequest.status || "").toLowerCase()]?.label ?? defaultStatusMeta.label} + + {getStatusMeta(selectedRequest.status).label} {(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"} />
@@ -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 ── */} diff --git a/frontend/src/hooks/useProcurementPage.ts b/frontend/src/hooks/useProcurementPage.ts index 3ee205b2..284ebdb9 100644 --- a/frontend/src/hooks/useProcurementPage.ts +++ b/frontend/src/hooks/useProcurementPage.ts @@ -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("all"); const [searchQuery, setSearchQuery] = useState(""); const deferredSearchQuery = useDeferredValue(searchQuery); - const [statusFilter, setStatusFilter] = useState(""); + const [statusFilter, setStatusFilter] = useState([]); 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 { diff --git a/frontend/src/lib/api/employees.ts b/frontend/src/lib/api/employees.ts index 092f4404..3d17f08f 100644 --- a/frontend/src/lib/api/employees.ts +++ b/frontend/src/lib/api/employees.ts @@ -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 : ''}`); diff --git a/frontend/src/lib/shared/paginationUtils.ts b/frontend/src/lib/shared/paginationUtils.ts index 29f63610..5f6bef26 100644 --- a/frontend/src/lib/shared/paginationUtils.ts +++ b/frontend/src/lib/shared/paginationUtils.ts @@ -19,7 +19,7 @@ export async function loadAllPages(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;