procurement: refine request workflow and filters

This commit is contained in:
lizerk 2026-05-27 15:54:10 +03:00
parent 47088fd098
commit ddeef4653c
9 changed files with 527 additions and 87 deletions

View File

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

View File

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

View File

@ -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):
"""Общая стоимость позиции по фактической или ориентировочной цене."""

View File

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

View File

@ -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("Не удалось загрузить отделы");

View File

@ -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 ── */}

View File

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

View File

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

View File

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