Add audited quick project assignment to procurement requests

This commit is contained in:
lizerk 2026-11-10 18:38:07 +03:00
parent b72ac5dd0d
commit 8be419c03b
17 changed files with 636 additions and 16 deletions

View File

@ -6,16 +6,19 @@ from .equipment import (
)
from .requests import (
CanApproveProcurementRequest,
CanAssignProcurementProjects,
CanCreateProcurementRequest,
CanEditOwnProcurementRequest,
CanManageProcurementRequest,
IsDepartmentHead,
IsDirector,
user_can_assign_procurement_projects,
user_can_view_procurement_request,
)
__all__ = [
"CanApproveProcurementRequest",
"CanAssignProcurementProjects",
"CanCreateProcurementRequest",
"CanEditOwnProcurementRequest",
"CanManageEquipment",
@ -26,4 +29,5 @@ __all__ = [
"IsDirector",
"IsResponsibleForEquipment",
"user_can_view_procurement_request",
"user_can_assign_procurement_projects",
]

View File

@ -22,6 +22,7 @@ def user_can_view_procurement_request(user, procurement_request) -> bool:
"procurement.change_procurementrequest",
"procurement.delete_procurementrequest",
"procurement.execute_procurement",
"procurement.assign_procurement_projects",
)
):
return True
@ -267,3 +268,30 @@ class CanManageProcurementRequest(permissions.BasePermission):
handler = self._action_handlers().get(action)
return bool(handler and handler(user, obj))
def user_can_assign_procurement_projects(user, procurement_request) -> bool:
"""Independent project-only access, not blanket permission to edit a request."""
if not (user and user.is_authenticated and user.is_active):
return False
if not user_can_view_procurement_request(user, procurement_request):
return False
return bool(
user.is_staff
or user.is_superuser
or user.has_perm("procurement.assign_procurement_projects")
or user.has_perm("procurement.execute_procurement")
or procurement_request.executor_id == user.pk
)
class CanAssignProcurementProjects(permissions.BasePermission):
"""Authorize just the project association action."""
def has_permission(self, request, view):
return bool(
request.user and request.user.is_authenticated and request.user.is_active
)
def has_object_permission(self, request, view, obj):
return user_can_assign_procurement_projects(request.user, obj)

View File

@ -17,6 +17,7 @@ from .items import (
ProcurementItemSerializer,
)
from .maintenance import MaintenanceRecordSerializer
from .request_write import ProcurementProjectsSerializer
from .requests import (
ProcurementRequestCreateSerializer,
ProcurementRequestDetailSerializer,
@ -50,6 +51,7 @@ __all__ = [
"ProcurementRequestActivitySerializer",
"ProcurementRequestCreateOptionsSerializer",
"ProcurementRequestCreateSerializer",
"ProcurementProjectsSerializer",
"ProcurementRequestDetailSerializer",
"ProcurementRequestListSerializer",
"ProcurementRequestPermissionFieldsMixin",

View File

@ -7,6 +7,7 @@ from procurement.constants import ProcurementItemExecutionStatus
from procurement.models import ProcurementRequest, ProcurementRequestView
from procurement.services import ProcurementApprovalResolver
from ..permission_classes.requests import user_can_assign_procurement_projects
from .common import (
PROBLEM_ITEM_STATUSES,
_effective_ordered_quantity,
@ -22,6 +23,7 @@ class ProcurementRequestSummaryMixin(serializers.Serializer):
is_viewed = serializers.SerializerMethodField()
is_deferred = serializers.SerializerMethodField()
project_names = serializers.SerializerMethodField()
can_assign_projects = serializers.SerializerMethodField()
last_arrival_notice_at = serializers.DateTimeField(
read_only=True,
allow_null=True,
@ -79,6 +81,12 @@ class ProcurementRequestSummaryMixin(serializers.Serializer):
def get_project_names(self, obj):
return [project.name for project in obj.projects.all()]
@extend_schema_field(serializers.BooleanField())
def get_can_assign_projects(self, obj):
request = self.context.get("request")
user = getattr(request, "user", None)
return user_can_assign_procurement_projects(user, obj)
@extend_schema_field(serializers.DateField(allow_null=True))
def get_next_expected_delivery_date(self, obj):
items = _request_items(obj)

View File

@ -81,6 +81,7 @@ class ProcurementRequestListSerializer(
"request_type",
"projects",
"project_names",
"can_assign_projects",
"title",
"description",
"department",
@ -273,6 +274,7 @@ class ProcurementRequestDetailSerializer(
"request_type",
"projects",
"project_names",
"can_assign_projects",
"title",
"description",
"department",

View File

@ -17,6 +17,21 @@ from .items import (
)
class ProcurementProjectsSerializer(serializers.Serializer):
"""Only project IDs, never general request fields."""
projects = serializers.PrimaryKeyRelatedField(
many=True,
queryset=Project.objects.all(),
allow_empty=True,
)
def validate_projects(self, projects):
if len(projects) > 50:
raise serializers.ValidationError("Не более 50 проектов в одной заявке.")
return projects
class ProcurementRequestCreateSerializer(serializers.ModelSerializer):
"""Сериализатор для создания заявки с позициями."""

View File

@ -141,6 +141,7 @@ class ProcurementRequestQueryMixin:
or user.has_perm("procurement.change_procurementrequest")
or user.has_perm("procurement.delete_procurementrequest")
or user.has_perm("procurement.execute_procurement")
or user.has_perm("procurement.assign_procurement_projects")
)
def _available_queryset(self, queryset, user, department_ids):
@ -157,7 +158,8 @@ class ProcurementRequestQueryMixin:
@staticmethod
def _default_visible_queryset(queryset, user, department_ids):
return queryset.filter(
Q(requestor=user)
Q(executor=user)
| Q(requestor=user)
| Q(department_id__in=department_ids)
| Q(processing_department_id__in=department_ids)
| Q(approvals__approver=user)
@ -253,7 +255,9 @@ class ProcurementRequestQueryMixin:
"created_at__date__lte",
)
project_id = query_params.get("project")
if project_id and project_id.isdigit():
if project_id == "unassigned":
queryset = queryset.filter(projects__isnull=True)
elif project_id and project_id.isdigit():
queryset = queryset.filter(projects__id=int(project_id))
if query_params.get("deferred") in {"true", "1", "yes"}:
queryset = queryset.filter(is_deferred_for_current_user=True)

View File

@ -1,5 +1,6 @@
"""ViewSet composition for procurement requests."""
from django.db import transaction
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework import filters, permissions, viewsets
from rest_framework.decorators import action
@ -11,12 +12,15 @@ from procurement.models import (
ProcurementActivityObjectKind,
ProcurementRequest,
)
from projects.models import Project
from ..permissions import (
CanApproveProcurementRequest,
CanAssignProcurementProjects,
CanManageProcurementRequest,
)
from ..serializers import (
ProcurementProjectsSerializer,
ProcurementRequestActivitySerializer,
ProcurementRequestCreateSerializer,
ProcurementRequestDetailSerializer,
@ -86,6 +90,7 @@ class ProcurementRequestViewSet(
"set_deferred",
"approval_options",
"activity",
"projects",
}:
return ProcurementRequestDetailSerializer
return ProcurementRequestListSerializer
@ -100,6 +105,52 @@ class ProcurementRequestViewSet(
serializer = ProcurementRequestActivitySerializer(queryset, many=True)
return Response(serializer.data)
@action(detail=True, methods=["get"], url_path="project-options")
def project_options(self, request, pk=None):
"""Compact company project directory for authorized purchasing staff."""
self.get_object()
return Response(
list(Project.objects.order_by("name", "pk").values("id", "name"))
)
@action(detail=True, methods=["patch"], url_path="projects")
def projects(self, request, pk=None):
"""Change only project associations; usable after approval or completion."""
payload = ProcurementProjectsSerializer(
data=request.data, context={"request": request}
)
payload.is_valid(raise_exception=True)
with transaction.atomic():
visible_request = self.get_object()
procurement_request = ProcurementRequest.objects.select_for_update().get(
pk=visible_request.pk
)
old_projects = list(
procurement_request.projects.order_by("id").values("id", "name")
)
selected = payload.validated_data["projects"]
next_ids = {project.pk for project in selected}
if {project["id"] for project in old_projects} != next_ids:
procurement_request.projects.set(selected)
procurement_request.save(update_fields=["updated_at"])
new_projects = list(
procurement_request.projects.order_by("id").values("id", "name")
)
create_procurement_activity(
procurement_request,
request.user,
ProcurementActivityAction.UPDATED,
object_kind=ProcurementActivityObjectKind.REQUEST,
object_id=procurement_request.pk,
metadata={
"fields": ["projects"],
"previous_projects": old_projects,
"new_projects": new_projects,
},
)
result = self.get_serializer(procurement_request)
return Response(result.data)
def perform_update(self, serializer):
tracked_fields = [
"title",
@ -112,26 +163,42 @@ class ProcurementRequestViewSet(
before = {
field: getattr(serializer.instance, field) for field in tracked_fields
}
old_projects = list(
serializer.instance.projects.order_by("id").values("id", "name")
)
procurement_request = serializer.save()
new_projects = list(
procurement_request.projects.order_by("id").values("id", "name")
)
changed_fields = [
field
for field in tracked_fields
if before[field] != getattr(procurement_request, field)
]
if old_projects != new_projects:
changed_fields.append("projects")
if changed_fields:
metadata = {"fields": changed_fields}
if old_projects != new_projects:
metadata.update(
previous_projects=old_projects,
new_projects=new_projects,
)
create_procurement_activity(
procurement_request,
self.request.user,
ProcurementActivityAction.UPDATED,
object_kind=ProcurementActivityObjectKind.REQUEST,
object_id=procurement_request.pk,
metadata={"fields": changed_fields},
metadata=metadata,
)
def get_permissions(self):
"""Выбрать права доступа в зависимости от действия."""
if self.action in {"approve", "reject"}:
permission_classes = [CanApproveProcurementRequest]
elif self.action in {"projects", "project_options"}:
permission_classes = [CanAssignProcurementProjects]
elif self.action in {
"submit",
"set_viewed",

View File

@ -0,0 +1,29 @@
# Generated by Django 5.2.4 on 2026-11-10 15:23
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("procurement", "0022_procurement_projects_deferred_flag"),
]
operations = [
migrations.AlterModelOptions(
name="procurementrequest",
options={
"ordering": ["-created_at"],
"permissions": [
("approve_procurementrequest", "Can approve procurement requests"),
("view_all_requests", "Can view all department requests"),
("execute_procurement", "Can execute approved requests"),
(
"assign_procurement_projects",
"Can assign projects to procurement requests",
),
],
"verbose_name": "Заявка на закупку",
"verbose_name_plural": "Заявки на закупку",
},
),
]

View File

@ -259,6 +259,10 @@ class ProcurementRequest(models.Model):
("approve_procurementrequest", "Can approve procurement requests"),
("view_all_requests", "Can view all department requests"),
("execute_procurement", "Can execute approved requests"),
(
"assign_procurement_projects",
"Can assign projects to procurement requests",
),
]
def __str__(self):

View File

@ -0,0 +1,196 @@
"""Quick project reassignment without granting unrelated procurement mutations."""
import pytest
from django.contrib.auth.models import Permission
from django.urls import reverse
from rest_framework.test import APIClient
from employees.models import Department, Employee, EmployeeDepartment
from procurement.models import (
ProcurementActivityAction,
ProcurementRequest,
ProcurementRequestActivity,
)
from projects.models import Project
pytestmark = pytest.mark.django_db
def employee(number, *, staff=False):
return Employee.objects.create_user(
email=f"project-assignment-{number}@example.org",
password="testpass123",
phone_number=f"+7999888{number:04d}",
first_name="Тест",
last_name="Проект",
is_active=True,
is_staff=staff,
email_verified=True,
send_activation_email=False,
)
@pytest.fixture
def setup():
requester = employee(1)
buyer = employee(2)
outsider = employee(3)
staff = employee(4, staff=True)
department = Department.objects.create(name="Закупки тест")
EmployeeDepartment.objects.create(
employee=requester, department=department, is_active=True
)
project_a = Project.objects.create(
name="Проект А", manager=requester, created_by=requester
)
project_b = Project.objects.create(
name="Проект Б", manager=requester, created_by=requester
)
hidden = Project.objects.create(
name="Секретный проект", manager=outsider, created_by=outsider
)
req = ProcurementRequest.objects.create(
title="Комплектующие",
department=department,
requestor=requester,
executor=buyer,
status="completed",
description="Закупка",
)
return requester, buyer, outsider, staff, project_a, project_b, hidden, req
def endpoint(req):
return reverse("api:v1:procurement:procurementrequest-projects", args=[req.pk])
def client(user=None):
api = APIClient()
if user is not None:
api.force_authenticate(user=user)
return api
def test_assigned_executor_may_fix_completed_request_and_audit_changes(setup):
_, buyer, _, _, project_a, project_b, _, req = setup
# The authorized buyer can use the compact project directory.
options = client(buyer).get(
reverse("api:v1:procurement:procurementrequest-project-options", args=[req.id])
)
assert options.status_code == 200
assert {value["id"] for value in options.data} >= {project_a.pk, project_b.pk}
response = client(buyer).patch(
endpoint(req), {"projects": [project_a.id, project_b.id]}, format="json"
)
assert response.status_code == 200, response.data
assert response.data["can_assign_projects"] is True
assert sorted(response.data["projects"]) == sorted([project_a.pk, project_b.pk])
req.refresh_from_db()
assert req.status == "completed"
changes = ProcurementRequestActivity.objects.filter(
request=req, action=ProcurementActivityAction.UPDATED
)
assert changes.count() == 1
entry = changes.get()
assert entry.actor == buyer
assert entry.metadata["fields"] == ["projects"]
assert entry.metadata["previous_projects"] == []
assert [value["name"] for value in entry.metadata["new_projects"]] == [
"Проект А",
"Проект Б",
]
again = client(buyer).patch(
endpoint(req), {"projects": [project_b.pk, project_a.pk]}, format="json"
)
assert again.status_code == 200
assert changes.count() == 1
cleared = client(buyer).patch(endpoint(req), {"projects": []}, format="json")
assert cleared.status_code == 200
assert list(req.projects.all()) == []
assert changes.count() == 2
def test_ordinary_requester_cannot_assign_even_to_own_request(setup):
requester, _, _, _, project_a, _, _, req = setup
response = client(requester).patch(
endpoint(req), {"projects": [project_a.pk]}, format="json"
)
assert response.status_code == 403
assert req.projects.count() == 0
assert (
client(requester)
.get(reverse("api:v1:procurement:procurementrequest-detail", args=[req.pk]))
.data["can_assign_projects"]
is False
)
def test_dedicated_permission_allows_project_edit_but_not_other_edits(setup):
requester, _, outsider, _, project_a, _, _, req = setup
requester.user_permissions.add(
Permission.objects.get(codename="assign_procurement_projects")
)
requester = Employee.objects.get(pk=requester.pk)
success = client(requester).patch(
endpoint(req), {"projects": [project_a.pk], "title": "Подмена"}, format="json"
)
assert success.status_code == 200, success.data
req.refresh_from_db()
assert req.title == "Комплектующие"
assert list(req.projects.values_list("id", flat=True)) == [project_a.pk]
forbidden = client(requester).patch(
reverse("api:v1:procurement:procurementrequest-detail", args=[req.pk]),
{"title": "Подмена"},
format="json",
)
assert forbidden.status_code == 403
assert client(outsider).patch(
endpoint(req), {"projects": []}, format="json"
).status_code in (403, 404)
def test_staff_cannot_assign_nonexistent_or_hidden_projects_without_error_leak(setup):
requester, _, _, staff, project_a, _, hidden, req = setup
assert client().patch(
endpoint(req), {"projects": []}, format="json"
).status_code in (401, 403)
assert (
client(staff)
.patch(endpoint(req), {"projects": [999999]}, format="json")
.status_code
== 400
)
assert (
client(staff)
.patch(endpoint(req), {"projects": "not-a-list"}, format="json")
.status_code
== 400
)
req.projects.set([project_a])
# An unauthorized project ID must not become a side-channel for requester.
assert (
client(requester)
.patch(endpoint(req), {"projects": [hidden.pk]}, format="json")
.status_code
== 403
)
def test_unassigned_filter_matches_only_requests_without_projects(setup):
requester, _, _, _, project_a, _, _, assigned_request = setup
assigned_request.projects.add(project_a)
unassigned = ProcurementRequest.objects.create(
title="Заявка без проекта",
department=assigned_request.department,
requestor=requester,
)
response = client(requester).get(
reverse("api:v1:procurement:procurementrequest-list"),
{"project": "unassigned"},
)
assert response.status_code == 200, response.data
rows = response.data["results"]
assert unassigned.id in {row["id"] for row in rows}
assert assigned_request.id not in {row["id"] for row in rows}

View File

@ -298,3 +298,33 @@ company scope with their content preserved. Authors use protected foreign
keys and do not vanish when their account is deactivated. Company/board
templates survive staff departure. Pending proposals remain snapshots even
if the original personal template changes or is archived.
## Quick project assignment for procurement requests
A procurement request may reference zero, one, or several existing company
projects. For buying staff, the API provides a **project-only action** independent
from editing the whole request:
- `GET /api/v1/procurement/requests/{id}/project-options/` — compact
`[{"id":1,"name":"Project"}]` directory of existing projects, available
only to users allowed to assign projects on this request.
- `PATCH /api/v1/procurement/requests/{id}/projects/` with
`{"projects":[1,2]}` — replace project associations, including on
approved/completed requests; `{"projects":[]}` clears them. Other request
fields are not mutated by this endpoint. Repeating the same associations
is idempotent and does not create duplicate history entries.
- `GET /api/v1/procurement/requests/?project=unassigned` — find requests
with no project. This works together with scope, status and period filters.
Access is granted to staff/superusers, users with the explicit Django model
permission `procurement.assign_procurement_projects`, users with the
existing `procurement.execute_procurement` permission, or the current request
executor. Regular applicants do not acquire unrelated editing permissions.
Assignment audit records actor, time, previous project IDs/names and new
project IDs/names in `ProcurementRequestActivity`. Edits through the normal
request PATCH endpoint also record project-association changes. The frontend
presents this before/after information in the request history.
The create/edit form shows a soft reminder when a project has not yet been
selected, but still allows saving a request without it.

View File

@ -15,6 +15,7 @@ import { ProjectSelectMenu } from "@/components/ui/ProjectSelectMenu";
import { useUser } from "@/contexts/UserContext";
import Link from "next/link";
import { ProcurementRequestDetailContent } from "@/components/procurement/ProcurementRequestDetailContent";
import ProcurementProjectsQuickEditor from "@/components/procurement/ProcurementProjectsQuickEditor";
import { ProcurementTaskLinks } from "@/components/procurement/ProcurementTaskLinks";
import ProcurementStatsPanel from "@/components/procurement/ProcurementStatsPanel";
import ProcurementSuppliersPanel from "@/components/procurement/ProcurementSuppliersPanel";
@ -582,6 +583,7 @@ export default function ProcurementPage() {
isFinal,
isModalOpen,
loadPage1,
refreshOne,
loading,
loadingMore,
modalMode,
@ -1474,6 +1476,7 @@ export default function ProcurementPage() {
ariaLabel="Проект"
options={[
{ value: "", label: "Все проекты" },
{ value: "unassigned", label: "Без проекта" },
...projects.map((project) => ({
value: String(project.id),
label: project.name,
@ -1727,11 +1730,13 @@ export default function ProcurementPage() {
{getDeptName(req)}
</span>
</span>
{(req.project_names || []).length > 0 ? (
<span className="app-text-muted mt-1 block truncate text-[11px]">
Проект: {req.project_names?.join(", ")}
</span>
) : null}
<span
className={`mt-1 block truncate text-[11px] ${(req.project_names || []).length > 0 ? "app-text-muted" : "text-amber-600 dark:text-amber-400"}`}
>
{(req.project_names || []).length > 0
? `Проект: ${req.project_names?.join(", ")}`
: "Без проекта"}
</span>
</span>
<span className="flex shrink-0 flex-col items-end gap-1">
<span
@ -1968,14 +1973,6 @@ export default function ProcurementPage() {
>
{getStatusMeta(selectedRequest.status).label}
</span>
{(selectedRequest.project_names || []).map((name) => (
<span
key={name}
className="app-badge inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium"
>
{name}
</span>
))}
<span
className={`shrink-0 text-[11px] font-medium ${urgencyMeta[selectedRequest.urgency]?.cls ?? urgencyMeta.medium.cls}`}
>
@ -2030,6 +2027,18 @@ export default function ProcurementPage() {
</span>
) : null}
</div>
<div className="mt-3">
<ProcurementProjectsQuickEditor
key={selectedRequest.id}
request={selectedRequest}
onSaved={async () => {
await Promise.all([
refreshOne(selectedRequest.id),
loadPage1(),
]);
}}
/>
</div>
<p className="app-text-wrap mt-2 text-sm text-[var(--foreground)]">
<span className="app-text-muted">Описание: </span>
{selectedRequest.description || "—"}
@ -3811,6 +3820,12 @@ export default function ProcurementPage() {
}))
}
/>
{form.projects.length === 0 ? (
<p className="app-text-muted text-xs">
Проект пока не выбран. Заявку можно сохранить без него, но для
точного учёта закупки проект желательно указать.
</p>
) : null}
{canCreateProjects ? (
<div className="flex gap-2">
<input

View File

@ -0,0 +1,179 @@
"use client";
import { useState } from "react";
import { FolderKanban, Loader2, Pencil } from "lucide-react";
import { SearchableSelectMulti } from "@/components/shared/SearchableSelect";
import { apiClient } from "@/lib/api";
import type { ProcurementRequest } from "@/types/api";
type ProjectOption = { id: number; name: string };
type Props = {
request: ProcurementRequest;
onSaved: () => Promise<void>;
};
export default function ProcurementProjectsQuickEditor({
request,
onSaved,
}: Props) {
const [editing, setEditing] = useState(false);
const [busy, setBusy] = useState(false);
const [loading, setLoading] = useState(false);
const [options, setOptions] = useState<ProjectOption[]>([]);
const [selected, setSelected] = useState<number[]>([]);
const [error, setError] = useState("");
const openEditor = async () => {
if (loading || busy) return;
setEditing(true);
setSelected(request.projects || []);
setError("");
setLoading(true);
try {
const available = await apiClient.getProcurementRequestProjectOptions(
request.id,
);
setOptions(available);
} catch (cause) {
setError(
cause instanceof Error
? cause.message
: "Не удалось загрузить справочник проектов.",
);
} finally {
setLoading(false);
}
};
const save = async () => {
if (busy || loading) return;
setBusy(true);
setError("");
try {
await apiClient.setProcurementRequestProjects(request.id, selected);
await onSaved();
setEditing(false);
} catch (cause) {
setError(
cause instanceof Error
? cause.message
: "Не удалось сохранить проекты закупки.",
);
} finally {
setBusy(false);
}
};
const currentIds = [...(request.projects || [])].sort((a, b) => a - b);
const nextIds = [...selected].sort((a, b) => a - b);
const unchanged =
currentIds.length === nextIds.length &&
currentIds.every((id, index) => id === nextIds[index]);
const names = request.project_names || [];
return (
<section className="space-y-2 rounded-xl border border-[var(--border-subtle)] p-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="flex items-center gap-2 text-sm font-semibold text-[var(--foreground)]">
<FolderKanban className="app-text-muted" size={16} />
Проекты закупки
</span>
{request.can_assign_projects ? (
<button
type="button"
onClick={() => {
if (editing) {
setEditing(false);
setError("");
} else {
void openEditor();
}
}}
disabled={busy}
className="app-action-secondary inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs disabled:opacity-50"
aria-expanded={editing}
>
{editing ? null : <Pencil size={13} />}
{editing ? "Закрыть" : "Изменить"}
</button>
) : null}
</div>
{!editing ? (
names.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{names.map((name, index) => (
<span
key={`${request.projects?.[index] ?? index}-${name}`}
className="app-badge inline-flex rounded-lg px-2.5 py-1 text-xs"
>
{name}
</span>
))}
</div>
) : (
<p className="app-text-muted text-xs">
Проект не указан. Его можно назначить для корректного учёта закупки.
</p>
)
) : (
<div className="space-y-3">
{loading ? (
<p className="app-text-muted flex items-center gap-2 text-xs">
<Loader2 size={15} className="animate-spin" /> Загрузка
проектов...
</p>
) : (
<>
<SearchableSelectMulti
label="Выберите проекты"
placeholder="Поиск и выбор проектов"
items={options}
selectedIds={selected}
onToggle={(id) => {
if (busy) return;
setSelected((current) =>
current.includes(id)
? current.filter((value) => value !== id)
: [...current, id],
);
}}
/>
<p className="app-text-muted text-xs">
Можно выбрать несколько проектов или очистить список.
</p>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => void save()}
disabled={busy || unchanged || selected.length > 50}
className="app-action-primary inline-flex items-center gap-1.5 rounded-lg px-3 py-2 text-xs font-medium disabled:opacity-50"
>
{busy ? <Loader2 size={14} className="animate-spin" /> : null}
Сохранить
</button>
<button
type="button"
disabled={busy}
onClick={() => {
setEditing(false);
setError("");
}}
className="app-action-secondary rounded-lg px-3 py-2 text-xs"
>
Отмена
</button>
</div>
</>
)}
</div>
)}
{error ? (
<p className="text-xs text-red-500" role="alert">
{error}
</p>
) : null}
</section>
);
}

View File

@ -34,6 +34,7 @@ const fieldLabels: Record<string, string> = {
actual_unit_price: "фактическую цену",
executor_comment: "комментарий исполнителя",
links: "ссылки",
projects: "проекты закупки",
};
function textMetadata(metadata: Record<string, unknown>, key: string): string {
@ -49,6 +50,31 @@ function getActivityDetail(activity: ProcurementRequestActivity): string {
);
const itemName = textMetadata(metadata, "item_name");
const prefix = itemName ? `${itemName}. ` : "";
if (fields.includes("projects")) {
const previous = Array.isArray(metadata.previous_projects)
? metadata.previous_projects
.map((item) =>
item && typeof item === "object" && "name" in item
? String(item.name)
: "",
)
.filter(Boolean)
: [];
const next = Array.isArray(metadata.new_projects)
? metadata.new_projects
.map((item) =>
item && typeof item === "object" && "name" in item
? String(item.name)
: "",
)
.filter(Boolean)
: [];
const summary = `Проекты: ${previous.join(", ") || "не указаны"} → ${next.join(", ") || "не указаны"}`;
return [
summary,
...labels.filter((label) => label !== "проекты закупки"),
].join(". ");
}
return labels.length > 0
? `${prefix}Изменено: ${labels.join(", ")}`
: itemName;

View File

@ -39,6 +39,16 @@ export function createProcurementApi(
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
}),
getProcurementRequestProjectOptions: (
id: number,
): Promise<{ id: number; name: string }[]> =>
request(`/api/v1/procurement/requests/${id}/project-options/`),
setProcurementRequestProjects: (id: number, projects: number[]) =>
request(`/api/v1/procurement/requests/${id}/projects/`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ projects }),
}),
updateProcurementRequest: (id: number, data: Record<string, any>) =>
request(`/api/v1/procurement/requests/${id}/`, {
method: "PATCH",

View File

@ -1659,6 +1659,7 @@ export interface ProcurementRequest {
request_type?: "standard" | "subscription";
projects?: number[];
project_names?: string[];
can_assign_projects?: boolean;
title: string;
description: string;
department: number;