Fix procurement viewed state and item actions
This commit is contained in:
parent
d6a7ce530d
commit
4555ee9bf7
@ -24,6 +24,7 @@ from procurement.models import (
|
||||
MaintenanceRecord,
|
||||
ProcurementItem,
|
||||
ProcurementRequest,
|
||||
ProcurementRequestView,
|
||||
Supplier,
|
||||
)
|
||||
from ..employees.serializers import EmployeeBriefSerializer
|
||||
@ -129,6 +130,7 @@ def _effective_received_quantity(item):
|
||||
|
||||
|
||||
class ProcurementRequestSummaryMixin(serializers.Serializer):
|
||||
is_viewed = serializers.SerializerMethodField()
|
||||
next_expected_delivery_date = serializers.SerializerMethodField()
|
||||
items_total_count = serializers.SerializerMethodField()
|
||||
items_received_count = serializers.SerializerMethodField()
|
||||
@ -139,6 +141,23 @@ class ProcurementRequestSummaryMixin(serializers.Serializer):
|
||||
total_received_quantity = serializers.SerializerMethodField()
|
||||
quantity_unit_label = serializers.SerializerMethodField()
|
||||
|
||||
@extend_schema_field(serializers.BooleanField())
|
||||
def get_is_viewed(self, obj):
|
||||
annotated_value = getattr(obj, "is_viewed_for_current_user", None)
|
||||
if annotated_value is not None:
|
||||
return bool(annotated_value)
|
||||
|
||||
request = self.context.get("request")
|
||||
user = getattr(request, "user", None)
|
||||
if not user or not user.is_authenticated:
|
||||
return False
|
||||
|
||||
return ProcurementRequestView.objects.filter(
|
||||
request=obj,
|
||||
user=user,
|
||||
is_viewed=True,
|
||||
).exists()
|
||||
|
||||
@extend_schema_field(serializers.DateField(allow_null=True))
|
||||
def get_next_expected_delivery_date(self, obj):
|
||||
items = _request_items(obj)
|
||||
@ -525,6 +544,7 @@ class ProcurementRequestListSerializer(
|
||||
"urgency_display",
|
||||
"fulfillment_status",
|
||||
"fulfillment_status_display",
|
||||
"is_viewed",
|
||||
"total_cost",
|
||||
"items_count",
|
||||
"next_expected_delivery_date",
|
||||
@ -545,12 +565,14 @@ class ProcurementRequestListSerializer(
|
||||
"created_at",
|
||||
"submitted_at",
|
||||
"started_at",
|
||||
"completed_at",
|
||||
]
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"created_at",
|
||||
"submitted_at",
|
||||
"started_at",
|
||||
"completed_at",
|
||||
"department_name",
|
||||
"processing_department_name",
|
||||
"requestor_name",
|
||||
@ -559,6 +581,7 @@ class ProcurementRequestListSerializer(
|
||||
"urgency_display",
|
||||
"fulfillment_status",
|
||||
"fulfillment_status_display",
|
||||
"is_viewed",
|
||||
"items_count",
|
||||
"total_cost",
|
||||
"next_expected_delivery_date",
|
||||
@ -761,6 +784,7 @@ class ProcurementRequestDetailSerializer(
|
||||
"urgency_display",
|
||||
"fulfillment_status",
|
||||
"fulfillment_status_display",
|
||||
"is_viewed",
|
||||
"total_cost",
|
||||
"actual_cost",
|
||||
"next_expected_delivery_date",
|
||||
@ -795,6 +819,7 @@ class ProcurementRequestDetailSerializer(
|
||||
"started_at",
|
||||
"completed_at",
|
||||
"is_editable",
|
||||
"is_viewed",
|
||||
"total_cost",
|
||||
"next_expected_delivery_date",
|
||||
"items_total_count",
|
||||
|
||||
@ -24,6 +24,7 @@ from procurement.models import (
|
||||
MaintenanceRecord,
|
||||
ProcurementItem,
|
||||
ProcurementRequest,
|
||||
ProcurementRequestView,
|
||||
Supplier,
|
||||
)
|
||||
from procurement.notifications.handlers import (
|
||||
@ -97,6 +98,7 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet):
|
||||
"complete",
|
||||
"cancel",
|
||||
"mark_all_received",
|
||||
"set_viewed",
|
||||
]:
|
||||
return ProcurementRequestDetailSerializer
|
||||
return ProcurementRequestListSerializer
|
||||
@ -105,7 +107,7 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet):
|
||||
"""Выбрать права доступа в зависимости от действия."""
|
||||
if self.action in ["approve", "reject"]:
|
||||
permission_classes = [CanApproveProcurementRequest]
|
||||
elif self.action == "submit":
|
||||
elif self.action in ["submit", "set_viewed"]:
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
else:
|
||||
# Для create, update, delete и остальных действий
|
||||
@ -116,7 +118,15 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet):
|
||||
"""Фильтровать заявки в зависимости от роли пользователя и scope."""
|
||||
from datetime import timedelta
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db.models import Count, IntegerField, OuterRef, Subquery, Value
|
||||
from django.db.models import (
|
||||
BooleanField,
|
||||
Count,
|
||||
Exists,
|
||||
IntegerField,
|
||||
OuterRef,
|
||||
Subquery,
|
||||
Value,
|
||||
)
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.utils import timezone
|
||||
from communications.models import Chat
|
||||
@ -136,14 +146,27 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet):
|
||||
.values("msg_count")[:1]
|
||||
)
|
||||
|
||||
user = self.request.user
|
||||
viewed_annotation = (
|
||||
Exists(
|
||||
ProcurementRequestView.objects.filter(
|
||||
request_id=OuterRef("pk"),
|
||||
user=user,
|
||||
is_viewed=True,
|
||||
)
|
||||
)
|
||||
if user and user.is_authenticated
|
||||
else Value(False, output_field=BooleanField())
|
||||
)
|
||||
|
||||
queryset = super().get_queryset().annotate(
|
||||
comments_count=Coalesce(
|
||||
Subquery(comments_sub, output_field=IntegerField()),
|
||||
Value(0),
|
||||
output_field=IntegerField(),
|
||||
)
|
||||
),
|
||||
is_viewed_for_current_user=viewed_annotation,
|
||||
)
|
||||
user = self.request.user
|
||||
scope = self.request.query_params.get("scope", None)
|
||||
period = self.request.query_params.get("period", None)
|
||||
participant_department_ids = (
|
||||
@ -788,6 +811,49 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet):
|
||||
serializer = self.get_serializer(procurement_request)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=True, methods=["post"])
|
||||
def set_viewed(self, request, pk=None):
|
||||
"""Установить отметку просмотра заявки."""
|
||||
procurement_request = self.get_object()
|
||||
raw_value = request.data.get("is_viewed", True)
|
||||
|
||||
if isinstance(raw_value, bool):
|
||||
is_viewed = raw_value
|
||||
elif isinstance(raw_value, str):
|
||||
normalized = raw_value.strip().lower()
|
||||
if normalized in {"true", "1", "yes", "y", "on"}:
|
||||
is_viewed = True
|
||||
elif normalized in {"false", "0", "no", "n", "off"}:
|
||||
is_viewed = False
|
||||
else:
|
||||
return Response(
|
||||
{"is_viewed": "Ожидается булево значение."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
else:
|
||||
return Response(
|
||||
{"is_viewed": "Ожидается булево значение."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
view_state, _ = ProcurementRequestView.objects.get_or_create(
|
||||
request=procurement_request,
|
||||
user=request.user,
|
||||
defaults={
|
||||
"is_viewed": is_viewed,
|
||||
"viewed_at": timezone.now() if is_viewed else None,
|
||||
},
|
||||
)
|
||||
if view_state.is_viewed != is_viewed:
|
||||
view_state.is_viewed = is_viewed
|
||||
view_state.viewed_at = timezone.now() if is_viewed else None
|
||||
view_state.save(update_fields=["is_viewed", "viewed_at", "updated_at"])
|
||||
|
||||
procurement_request.is_viewed_for_current_user = is_viewed
|
||||
|
||||
serializer = self.get_serializer(procurement_request)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=True, methods=["post"])
|
||||
def cancel(self, request, pk=None):
|
||||
"""Отменить заявку."""
|
||||
@ -1178,20 +1244,33 @@ class ProcurementItemViewSet(viewsets.ModelViewSet):
|
||||
if error_response is not None:
|
||||
return error_response
|
||||
|
||||
if (item.received_quantity or 0) <= 0:
|
||||
if item.effective_received_quantity <= 0:
|
||||
return Response(
|
||||
{"detail": "По позиции нет подтверждённого получения"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
effective_ordered_quantity = item.effective_ordered_quantity
|
||||
item.received_quantity = 0
|
||||
if item.ordered_quantity is None and effective_ordered_quantity > 0:
|
||||
item.ordered_quantity = min(effective_ordered_quantity, item.quantity)
|
||||
item.execution_status = self._workflow_status_from_quantities(item)
|
||||
item.save(update_fields=["execution_status", "received_quantity"])
|
||||
item.save(
|
||||
update_fields=[
|
||||
"execution_status",
|
||||
"ordered_quantity",
|
||||
"received_quantity",
|
||||
]
|
||||
)
|
||||
self._reopen_completed_request(procurement_request, user)
|
||||
notify_item_updated(
|
||||
item,
|
||||
actor=user,
|
||||
changed_fields=["execution_status", "received_quantity"],
|
||||
changed_fields=[
|
||||
"execution_status",
|
||||
"ordered_quantity",
|
||||
"received_quantity",
|
||||
],
|
||||
)
|
||||
|
||||
item.refresh_from_db()
|
||||
|
||||
@ -12,6 +12,7 @@ from .models import (
|
||||
MaintenanceRecord,
|
||||
ProcurementItem,
|
||||
ProcurementRequest,
|
||||
ProcurementRequestView,
|
||||
Supplier,
|
||||
)
|
||||
|
||||
@ -52,6 +53,27 @@ class ApprovalInline(admin.TabularInline):
|
||||
can_delete = False
|
||||
|
||||
|
||||
@admin.register(ProcurementRequestView)
|
||||
class ProcurementRequestViewAdmin(admin.ModelAdmin):
|
||||
"""Админка для персональных отметок просмотра заявок."""
|
||||
|
||||
list_display = [
|
||||
"request",
|
||||
"user",
|
||||
"is_viewed",
|
||||
"viewed_at",
|
||||
"updated_at",
|
||||
]
|
||||
list_filter = ["is_viewed", "viewed_at", "updated_at"]
|
||||
search_fields = [
|
||||
"request__title",
|
||||
"user__email",
|
||||
"user__first_name",
|
||||
"user__last_name",
|
||||
]
|
||||
readonly_fields = ["updated_at"]
|
||||
|
||||
|
||||
@admin.register(ProcurementRequest)
|
||||
class ProcurementRequestAdmin(admin.ModelAdmin):
|
||||
"""Админка для заявок на закупку."""
|
||||
|
||||
@ -0,0 +1,75 @@
|
||||
# Generated by Codex on 2026-06-08
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("procurement", "0012_expected_delivery_dates"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="ProcurementRequestView",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"is_viewed",
|
||||
models.BooleanField(default=True, verbose_name="Просмотрено"),
|
||||
),
|
||||
(
|
||||
"viewed_at",
|
||||
models.DateTimeField(
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name="Просмотрено в",
|
||||
),
|
||||
),
|
||||
(
|
||||
"updated_at",
|
||||
models.DateTimeField(auto_now=True, verbose_name="Обновлено"),
|
||||
),
|
||||
(
|
||||
"request",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="view_states",
|
||||
to="procurement.procurementrequest",
|
||||
verbose_name="Заявка",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="procurement_request_views",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="Пользователь",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Отметка просмотра заявки",
|
||||
"verbose_name_plural": "Отметки просмотра заявок",
|
||||
},
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="procurementrequestview",
|
||||
constraint=models.UniqueConstraint(
|
||||
fields=("request", "user"),
|
||||
name="unique_procurement_request_view_user",
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -108,7 +108,6 @@ class ProcurementRequest(models.Model):
|
||||
blank=True,
|
||||
validators=[MinValueValidator(Decimal("0.01"))],
|
||||
)
|
||||
|
||||
# Даты
|
||||
created_at = models.DateTimeField("Создано", auto_now_add=True)
|
||||
updated_at = models.DateTimeField("Обновлено", auto_now=True)
|
||||
@ -244,6 +243,43 @@ class ProcurementRequest(models.Model):
|
||||
return new_status
|
||||
|
||||
|
||||
class ProcurementRequestView(models.Model):
|
||||
"""Персональная отметка просмотра заявки пользователем."""
|
||||
|
||||
request = models.ForeignKey(
|
||||
ProcurementRequest,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="view_states",
|
||||
verbose_name="Заявка",
|
||||
)
|
||||
user = models.ForeignKey(
|
||||
User,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="procurement_request_views",
|
||||
verbose_name="Пользователь",
|
||||
)
|
||||
is_viewed = models.BooleanField("Просмотрено", default=True)
|
||||
viewed_at = models.DateTimeField(
|
||||
"Просмотрено в",
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
updated_at = models.DateTimeField("Обновлено", auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Отметка просмотра заявки"
|
||||
verbose_name_plural = "Отметки просмотра заявок"
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["request", "user"],
|
||||
name="unique_procurement_request_view_user",
|
||||
),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.request_id}: {self.user_id} viewed={self.is_viewed}"
|
||||
|
||||
|
||||
class ProcurementItem(models.Model):
|
||||
"""Позиция в заявке на закупку."""
|
||||
|
||||
|
||||
@ -243,6 +243,7 @@ class ProcurementApprovalResolver:
|
||||
if procurement_request.status not in {
|
||||
ProcurementStatus.WAITING,
|
||||
ProcurementStatus.IN_PROGRESS,
|
||||
ProcurementStatus.COMPLETED,
|
||||
}:
|
||||
return False
|
||||
|
||||
|
||||
@ -1037,6 +1037,19 @@ class TestProcessingDepartmentWorkflow:
|
||||
assert role_response.data["can_current_user_start_work"] is True
|
||||
assert role_response.data["can_current_user_process_items"] is True
|
||||
|
||||
processing_request.status = ProcurementStatus.COMPLETED
|
||||
processing_request.completed_at = timezone.now()
|
||||
processing_request.save(
|
||||
update_fields=["status", "completed_at", "updated_at"],
|
||||
)
|
||||
|
||||
completed_role_response = api_client.get(detail_url)
|
||||
assert completed_role_response.status_code == status.HTTP_200_OK
|
||||
assert (
|
||||
completed_role_response.data["can_current_user_process_items"]
|
||||
is True
|
||||
)
|
||||
|
||||
def test_outsider_cannot_start_processing_department_request(
|
||||
self, api_client, outsider, processing_request, procurement_item_factory
|
||||
):
|
||||
@ -2153,6 +2166,47 @@ class TestProcessingDepartmentWorkflow:
|
||||
assert processing_request.status == ProcurementStatus.IN_PROGRESS
|
||||
assert processing_request.completed_at is None
|
||||
|
||||
def test_cancel_received_uses_received_status_as_effective_quantity(
|
||||
self, api_client, user, supply_user, processing_request,
|
||||
procurement_item_factory
|
||||
):
|
||||
item = procurement_item_factory(
|
||||
request=processing_request,
|
||||
quantity=2,
|
||||
ordered_quantity=2,
|
||||
received_quantity=2,
|
||||
execution_status=ProcurementItemExecutionStatus.RECEIVED,
|
||||
)
|
||||
ProcurementItem.objects.filter(pk=item.pk).update(
|
||||
ordered_quantity=None,
|
||||
received_quantity=None,
|
||||
execution_status=ProcurementItemExecutionStatus.RECEIVED,
|
||||
)
|
||||
processing_request.executor = supply_user
|
||||
processing_request.status = ProcurementStatus.COMPLETED
|
||||
processing_request.completed_at = timezone.now()
|
||||
processing_request.fulfillment_status = (
|
||||
ProcurementFulfillmentStatus.COMPLETED
|
||||
)
|
||||
processing_request.save()
|
||||
|
||||
api_client.force_authenticate(user=user)
|
||||
url = reverse(
|
||||
'api:v1:procurement:procurementitem-cancel-received',
|
||||
kwargs={'pk': item.id},
|
||||
)
|
||||
|
||||
response = api_client.post(url, {}, format='json')
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
item.refresh_from_db()
|
||||
processing_request.refresh_from_db()
|
||||
assert item.execution_status == ProcurementItemExecutionStatus.ORDERED
|
||||
assert item.ordered_quantity == item.quantity
|
||||
assert item.received_quantity == 0
|
||||
assert processing_request.status == ProcurementStatus.IN_PROGRESS
|
||||
assert processing_request.completed_at is None
|
||||
|
||||
def test_outsider_cannot_confirm_item_received(
|
||||
self, api_client, outsider, processing_request,
|
||||
procurement_item_factory
|
||||
|
||||
@ -6,6 +6,7 @@ from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
@ -17,6 +18,7 @@ from procurement.models import (
|
||||
EquipmentCategory,
|
||||
ProcurementItem,
|
||||
ProcurementRequest,
|
||||
ProcurementRequestView,
|
||||
Supplier,
|
||||
)
|
||||
|
||||
@ -98,6 +100,22 @@ class TestProcurementRequestAPI:
|
||||
assert len(response.data['results']) == 1
|
||||
assert response.data['results'][0]['title'] == "Тестовая заявка"
|
||||
|
||||
def test_list_completed_request_includes_completed_at(
|
||||
self, api_client, user, procurement_request
|
||||
):
|
||||
"""Тест: список отдаёт дату завершения для завершённых заявок."""
|
||||
procurement_request.status = ProcurementStatus.COMPLETED
|
||||
procurement_request.completed_at = timezone.now()
|
||||
procurement_request.save(update_fields=["status", "completed_at"])
|
||||
|
||||
api_client.force_authenticate(user=user)
|
||||
url = reverse('api:v1:procurement:procurementrequest-list')
|
||||
response = api_client.get(url)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data['results'][0]['status'] == ProcurementStatus.COMPLETED
|
||||
assert response.data['results'][0]['completed_at'] is not None
|
||||
|
||||
def test_create_request(
|
||||
self, api_client, user, department, link_factory
|
||||
):
|
||||
@ -188,6 +206,35 @@ class TestProcurementRequestAPI:
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data['results'][0]['quantity_unit_label'] == "единиц"
|
||||
|
||||
def test_set_request_viewed_flag(
|
||||
self, api_client, user, procurement_request
|
||||
):
|
||||
"""Тест: пользователь может отметить видимую заявку просмотренной."""
|
||||
api_client.force_authenticate(user=user)
|
||||
url = reverse(
|
||||
'api:v1:procurement:procurementrequest-set-viewed',
|
||||
kwargs={'pk': procurement_request.id},
|
||||
)
|
||||
|
||||
response = api_client.post(url, {'is_viewed': True}, format='json')
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data['is_viewed'] is True
|
||||
view_state = ProcurementRequestView.objects.get(
|
||||
request=procurement_request,
|
||||
user=user,
|
||||
)
|
||||
assert view_state.is_viewed is True
|
||||
assert view_state.viewed_at is not None
|
||||
|
||||
response = api_client.post(url, {'is_viewed': False}, format='json')
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data['is_viewed'] is False
|
||||
view_state.refresh_from_db()
|
||||
assert view_state.is_viewed is False
|
||||
assert view_state.viewed_at is None
|
||||
|
||||
def test_update_own_request_draft(
|
||||
self, api_client, user, procurement_request
|
||||
):
|
||||
|
||||
@ -17,6 +17,8 @@ import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
ClipboardCheck,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Filter,
|
||||
Loader2,
|
||||
MessageSquare,
|
||||
@ -118,6 +120,15 @@ const fmt = formatDate;
|
||||
const money = formatMoney;
|
||||
type RequestActionDialogKind = "approve" | "reject" | "cancel" | "delete";
|
||||
|
||||
const getRequestListDateLabel = (request: ProcurementRequest, status: string) => {
|
||||
const createdAt = fmt(request.created_at);
|
||||
if (status === "completed" && request.completed_at) {
|
||||
const completedAt = fmt(request.completed_at);
|
||||
return completedAt ? `${createdAt} - ${completedAt}` : createdAt;
|
||||
}
|
||||
return createdAt;
|
||||
};
|
||||
|
||||
const getReadableError = (error: unknown, fallback: string): string => {
|
||||
const raw = String((error as Error)?.message || fallback);
|
||||
const jsonStart = raw.indexOf("{");
|
||||
@ -332,6 +343,7 @@ export default function ProcurementPage() {
|
||||
handleCancel,
|
||||
handleComplete,
|
||||
handleMarkAllReceived,
|
||||
handleToggleViewed,
|
||||
handleReportItemIssue,
|
||||
handleCancelItemIssue,
|
||||
handleConfirmItemReceived,
|
||||
@ -803,6 +815,12 @@ export default function ProcurementPage() {
|
||||
const comments = commentsMap[req.id] || [];
|
||||
const commentsOpen = Boolean(expandedComments[req.id]);
|
||||
const commentsTotal = resolvedDetail.comments_count ?? req.comments_count ?? comments.length;
|
||||
const isViewed = Boolean(resolvedDetail.is_viewed ?? req.is_viewed);
|
||||
const rowExpanded = !isViewed && expanded;
|
||||
const rowCommentsOpen = !isViewed && commentsOpen;
|
||||
const showInlineRequestActions = !isViewed && hasInlineRequestActions;
|
||||
const showSecondaryActions = !isViewed && hasSecondaryActions;
|
||||
const requestMenuOpen = showSecondaryActions && procurementMenuOpenId === req.id;
|
||||
const requestorName = displayUserName(req.requestor, req.requestor_name, req.requestor_email);
|
||||
const executorName = req.executor || req.executor_name ? displayUserName(req.executor, req.executor_name || undefined) : "";
|
||||
const requestorLink = userLink(req.requestor);
|
||||
@ -824,30 +842,48 @@ export default function ProcurementPage() {
|
||||
);
|
||||
const nextExpectedDeliveryDate = resolvedDetail.next_expected_delivery_date ?? req.next_expected_delivery_date ?? null;
|
||||
const approvalsCount = resolvedDetail.approvals?.length ?? 0;
|
||||
const requestDateLabel = getRequestListDateLabel(resolvedDetail, st);
|
||||
|
||||
return (
|
||||
<article key={req.id} className={`app-surface-muted rounded-xl transition hover:border-[var(--border-strong)] ${procurementMenuOpenId === req.id ? "relative z-20 overflow-visible" : "overflow-hidden"}`}>
|
||||
<div className="px-4 py-3">
|
||||
<article key={req.id} className={`app-surface-muted rounded-xl transition hover:border-[var(--border-strong)] ${requestMenuOpen ? "relative z-20 overflow-visible" : "overflow-hidden"}`}>
|
||||
<div className={isViewed ? "px-4 py-2.5" : "px-4 py-3"}>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex shrink-0 flex-col items-center gap-3 pt-0.5">
|
||||
<div className={`flex shrink-0 flex-col items-center ${isViewed ? "gap-0 pt-0.5" : "gap-3 pt-0.5"}`}>
|
||||
{!isViewed ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleExpand(req.id)}
|
||||
aria-label={rowExpanded ? "Свернуть детали" : "Развернуть детали"}
|
||||
className="app-action-secondary inline-flex h-8 w-8 items-center justify-center rounded-lg"
|
||||
>
|
||||
<ChevronDown size={15} className={`transition ${rowExpanded ? "rotate-180" : ""}`} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title={`Комментарии (${commentsTotal})`}
|
||||
onClick={() => void toggleComments(req.id)}
|
||||
className="app-action-secondary relative inline-flex h-8 w-8 items-center justify-center rounded-lg"
|
||||
>
|
||||
<MessageSquare size={15} />
|
||||
{commentsTotal > 0 && (
|
||||
<span className="app-counter absolute -right-1 -top-1 inline-flex min-w-4 items-center justify-center px-1 py-0.5 text-[10px] font-bold text-white">{commentsTotal}</span>
|
||||
)}
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleExpand(req.id)}
|
||||
aria-label={expanded ? "Свернуть детали" : "Развернуть детали"}
|
||||
className="app-action-secondary inline-flex h-8 w-8 items-center justify-center rounded-lg"
|
||||
title={isViewed ? "Отметить непросмотренной" : "Отметить просмотренной"}
|
||||
aria-label={isViewed ? "Отметить заявку непросмотренной" : "Отметить заявку просмотренной"}
|
||||
disabled={busyKey === `viewed-${req.id}`}
|
||||
onClick={() => {
|
||||
setProcurementMenuOpenId(null);
|
||||
void handleToggleViewed(req.id, !isViewed);
|
||||
}}
|
||||
className={`${isViewed ? "app-selected app-accent-text" : "app-action-secondary"} inline-flex h-8 w-8 items-center justify-center rounded-lg disabled:cursor-not-allowed disabled:opacity-60`}
|
||||
>
|
||||
<ChevronDown size={15} className={`transition ${expanded ? "rotate-180" : ""}`} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title={`Комментарии (${commentsTotal})`}
|
||||
onClick={() => void toggleComments(req.id)}
|
||||
className="app-action-secondary relative inline-flex h-8 w-8 items-center justify-center rounded-lg"
|
||||
>
|
||||
<MessageSquare size={15} />
|
||||
{commentsTotal > 0 && (
|
||||
<span className="app-counter absolute -right-1 -top-1 inline-flex min-w-4 items-center justify-center px-1 py-0.5 text-[10px] font-bold text-white">{commentsTotal}</span>
|
||||
)}
|
||||
{isViewed ? <Eye size={15} /> : <EyeOff size={15} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@ -862,23 +898,23 @@ export default function ProcurementPage() {
|
||||
className="truncate text-left text-sm font-semibold text-[var(--foreground)] transition hover:text-[var(--accent-primary-strong)]"
|
||||
title="Открыть карточку заявки"
|
||||
>
|
||||
{req.title || "Без названия"}
|
||||
{req.title || "Без названия"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="app-text-muted mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
|
||||
<span>Отдел-заказчик: <span className="font-medium text-[var(--foreground)]">{getDeptName(req)}</span></span>
|
||||
<span>{fmt(req.created_at)}</span>
|
||||
<span>{requestDateLabel}</span>
|
||||
{getRequestAmount(req) && <span className="font-medium text-[var(--foreground)]">{money(getRequestAmount(req))}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={procurementMenuOpenId === req.id ? procurementMenuRef : null}
|
||||
ref={requestMenuOpen ? procurementMenuRef : null}
|
||||
className="flex shrink-0 flex-col items-end gap-2"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`app-status-pill shrink-0 ${sMeta.cls}`}>{sMeta.label}</span>
|
||||
{hasSecondaryActions ? (
|
||||
{showSecondaryActions ? (
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
@ -886,15 +922,15 @@ export default function ProcurementPage() {
|
||||
className="app-action-ghost flex h-8 w-8 items-center justify-center rounded-md"
|
||||
title="Действия с заявкой"
|
||||
aria-label="Действия с заявкой"
|
||||
aria-expanded={procurementMenuOpenId === req.id}
|
||||
aria-expanded={requestMenuOpen}
|
||||
aria-haspopup="menu"
|
||||
>
|
||||
<ChevronRight
|
||||
size={15}
|
||||
className={`transition-transform duration-200 ${procurementMenuOpenId === req.id ? "rotate-90" : ""}`}
|
||||
className={`transition-transform duration-200 ${requestMenuOpen ? "rotate-90" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
{procurementMenuOpenId === req.id ? (
|
||||
{requestMenuOpen ? (
|
||||
<div className="app-menu absolute right-0 top-full z-20 mt-2 w-44 rounded-xl py-1.5">
|
||||
{canCancelThis ? (
|
||||
<button
|
||||
@ -942,79 +978,83 @@ export default function ProcurementPage() {
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<span className={`text-[11px] font-medium ${urg.cls}`}>{urg.label} срочность</span>
|
||||
{!isViewed ? (
|
||||
<span className={`text-[11px] font-medium ${urg.cls}`}>{urg.label} срочность</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="app-text-muted mt-2 grid grid-cols-1 gap-x-3 gap-y-1 text-xs sm:grid-cols-2">
|
||||
<div className="min-w-0">
|
||||
<span>Заявитель:</span>{" "}
|
||||
{requestorLink
|
||||
? <Link href={requestorLink} className="font-medium text-[var(--accent-primary-strong)] hover:text-[var(--accent-primary)]">{requestorName}</Link>
|
||||
: <span className="font-medium text-[var(--foreground)]">{requestorName}</span>}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<span>Исполнитель:</span>{" "}
|
||||
{req.executor ? (
|
||||
executorLink
|
||||
? <Link href={executorLink} className="font-medium text-[var(--accent-primary-strong)] hover:text-[var(--accent-primary)]">{executorName}</Link>
|
||||
: <span className="font-medium text-[var(--foreground)]">{executorName}</span>
|
||||
) : (
|
||||
<span>не назначен</span>
|
||||
)}
|
||||
</div>
|
||||
{(fulfillmentStatusDisplay || itemsTotalCount > 0 || approvalsCount > 0 || nextExpectedDeliveryDate) && (
|
||||
<div className="col-span-2 flex flex-wrap items-center gap-2 pt-0.5">
|
||||
{fulfillmentStatusDisplay ? (
|
||||
<span className={`${getFulfillmentStatusClass(fulfillmentStatus)} inline-flex items-center gap-1 rounded-full px-2 py-1 text-[11px] font-medium`}>
|
||||
{fulfillmentStatusDisplay}
|
||||
</span>
|
||||
) : null}
|
||||
{nextExpectedDeliveryDate ? (
|
||||
<span className="app-badge inline-flex items-center gap-1 rounded-full px-2 py-1 text-[11px] font-medium">
|
||||
<CalendarDays size={11} /> {fmt(nextExpectedDeliveryDate)}
|
||||
</span>
|
||||
) : null}
|
||||
{itemsTotalCount > 0 && (
|
||||
<span className="app-badge inline-flex items-center gap-1 rounded-full px-2 py-1 text-[11px] font-medium">
|
||||
<Package size={11} /> {itemsTotalCount} поз.
|
||||
</span>
|
||||
)}
|
||||
{itemsTotalCount > 0 && (
|
||||
<span className={`${getReceivedProgressClass(itemsReceivedCount, itemsTotalCount)} inline-flex items-center gap-1 rounded-full px-2 py-1 text-[11px] font-medium`}>
|
||||
<Check size={11} /> Получено {itemsReceivedCount}/{itemsTotalCount} поз.
|
||||
</span>
|
||||
)}
|
||||
{itemsProblemCount > 0 && (
|
||||
<span className="app-feedback-warning inline-flex items-center gap-1 rounded-full px-2 py-1 text-[11px] font-medium">
|
||||
<AlertTriangle size={11} /> Проблемы {itemsProblemCount}/{itemsTotalCount} поз.
|
||||
</span>
|
||||
)}
|
||||
{totalRequestedQuantity > 0 && (
|
||||
<span className={`${getOrderedProgressClass(totalOrderedQuantity, totalRequestedQuantity)} inline-flex items-center gap-1 rounded-full px-2 py-1 text-[11px] font-medium`}>
|
||||
Заказано {totalOrderedQuantity}/{totalRequestedQuantity} {quantityUnitLabel}
|
||||
</span>
|
||||
)}
|
||||
{totalRequestedQuantity > 0 && (
|
||||
<span className={`${getReceivedProgressClass(totalReceivedQuantity, totalRequestedQuantity)} inline-flex items-center gap-1 rounded-full px-2 py-1 text-[11px] font-medium`}>
|
||||
Получено {totalReceivedQuantity}/{totalRequestedQuantity} {quantityUnitLabel}
|
||||
</span>
|
||||
)}
|
||||
{itemsPendingCount > 0 && (
|
||||
<span className="app-feedback-warning inline-flex items-center gap-1 rounded-full px-2 py-1 text-[11px] font-medium">
|
||||
Не обработано {itemsPendingCount}
|
||||
</span>
|
||||
)}
|
||||
{approvalsCount > 0 && (
|
||||
<span className="app-badge inline-flex items-center gap-1 rounded-full px-2 py-1 text-[11px] font-medium">
|
||||
<Check size={11} /> {approvalsCount} соглас.
|
||||
</span>
|
||||
{!isViewed ? (
|
||||
<div className="app-text-muted mt-2 grid grid-cols-1 gap-x-3 gap-y-1 text-xs sm:grid-cols-2">
|
||||
<div className="min-w-0">
|
||||
<span>Заявитель:</span>{" "}
|
||||
{requestorLink
|
||||
? <Link href={requestorLink} className="font-medium text-[var(--accent-primary-strong)] hover:text-[var(--accent-primary)]">{requestorName}</Link>
|
||||
: <span className="font-medium text-[var(--foreground)]">{requestorName}</span>}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<span>Исполнитель:</span>{" "}
|
||||
{req.executor ? (
|
||||
executorLink
|
||||
? <Link href={executorLink} className="font-medium text-[var(--accent-primary-strong)] hover:text-[var(--accent-primary)]">{executorName}</Link>
|
||||
: <span className="font-medium text-[var(--foreground)]">{executorName}</span>
|
||||
) : (
|
||||
<span>не назначен</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{(fulfillmentStatusDisplay || itemsTotalCount > 0 || approvalsCount > 0 || nextExpectedDeliveryDate) && (
|
||||
<div className="col-span-2 flex flex-wrap items-center gap-2 pt-0.5">
|
||||
{fulfillmentStatusDisplay ? (
|
||||
<span className={`${getFulfillmentStatusClass(fulfillmentStatus)} inline-flex items-center gap-1 rounded-full px-2 py-1 text-[11px] font-medium`}>
|
||||
{fulfillmentStatusDisplay}
|
||||
</span>
|
||||
) : null}
|
||||
{nextExpectedDeliveryDate ? (
|
||||
<span className="app-badge inline-flex items-center gap-1 rounded-full px-2 py-1 text-[11px] font-medium">
|
||||
<CalendarDays size={11} /> {fmt(nextExpectedDeliveryDate)}
|
||||
</span>
|
||||
) : null}
|
||||
{itemsTotalCount > 0 && (
|
||||
<span className="app-badge inline-flex items-center gap-1 rounded-full px-2 py-1 text-[11px] font-medium">
|
||||
<Package size={11} /> {itemsTotalCount} поз.
|
||||
</span>
|
||||
)}
|
||||
{itemsTotalCount > 0 && (
|
||||
<span className={`${getReceivedProgressClass(itemsReceivedCount, itemsTotalCount)} inline-flex items-center gap-1 rounded-full px-2 py-1 text-[11px] font-medium`}>
|
||||
<Check size={11} /> Получено {itemsReceivedCount}/{itemsTotalCount} поз.
|
||||
</span>
|
||||
)}
|
||||
{itemsProblemCount > 0 && (
|
||||
<span className="app-feedback-warning inline-flex items-center gap-1 rounded-full px-2 py-1 text-[11px] font-medium">
|
||||
<AlertTriangle size={11} /> Проблемы {itemsProblemCount}/{itemsTotalCount} поз.
|
||||
</span>
|
||||
)}
|
||||
{totalRequestedQuantity > 0 && (
|
||||
<span className={`${getOrderedProgressClass(totalOrderedQuantity, totalRequestedQuantity)} inline-flex items-center gap-1 rounded-full px-2 py-1 text-[11px] font-medium`}>
|
||||
Заказано {totalOrderedQuantity}/{totalRequestedQuantity} {quantityUnitLabel}
|
||||
</span>
|
||||
)}
|
||||
{totalRequestedQuantity > 0 && (
|
||||
<span className={`${getReceivedProgressClass(totalReceivedQuantity, totalRequestedQuantity)} inline-flex items-center gap-1 rounded-full px-2 py-1 text-[11px] font-medium`}>
|
||||
Получено {totalReceivedQuantity}/{totalRequestedQuantity} {quantityUnitLabel}
|
||||
</span>
|
||||
)}
|
||||
{itemsPendingCount > 0 && (
|
||||
<span className="app-feedback-warning inline-flex items-center gap-1 rounded-full px-2 py-1 text-[11px] font-medium">
|
||||
Не обработано {itemsPendingCount}
|
||||
</span>
|
||||
)}
|
||||
{approvalsCount > 0 && (
|
||||
<span className="app-badge inline-flex items-center gap-1 rounded-full px-2 py-1 text-[11px] font-medium">
|
||||
<Check size={11} /> {approvalsCount} соглас.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{hasInlineRequestActions ? (
|
||||
{showInlineRequestActions ? (
|
||||
<div className="mt-3 flex justify-end">
|
||||
<ProcurementRequestActionButtons
|
||||
request={resolvedDetail}
|
||||
@ -1040,10 +1080,10 @@ export default function ProcurementPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(expanded || commentsOpen) && (
|
||||
{(rowExpanded || rowCommentsOpen) && (
|
||||
<div className="mt-4 space-y-3 px-4 pb-4">
|
||||
{commentsOpen ? (
|
||||
<div className={expanded ? "app-surface rounded-xl p-3" : "app-surface-elevated rounded-xl p-3"}>
|
||||
{rowCommentsOpen ? (
|
||||
<div className={rowExpanded ? "app-surface rounded-xl p-3" : "app-surface-elevated rounded-xl p-3"}>
|
||||
<div className="space-y-2">
|
||||
{comments.length === 0 ? (
|
||||
<p className="app-text-muted text-xs">Комментариев пока нет</p>
|
||||
@ -1080,7 +1120,7 @@ export default function ProcurementPage() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{expanded ? (
|
||||
{rowExpanded ? (
|
||||
<ProcurementRequestDetailContent
|
||||
currentUserId={user?.id}
|
||||
request={resolvedDetail}
|
||||
|
||||
@ -76,6 +76,26 @@ const toNumber = (value?: string | number | null): number => {
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
};
|
||||
|
||||
const effectiveOrderedQuantity = (item: ProcurementItem, requestedQuantity: number): number => {
|
||||
if (item.ordered_quantity !== null && item.ordered_quantity !== undefined) {
|
||||
return item.ordered_quantity;
|
||||
}
|
||||
if (item.execution_status === "ordered" || item.execution_status === "received") {
|
||||
return requestedQuantity;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
const effectiveReceivedQuantity = (item: ProcurementItem, requestedQuantity: number): number => {
|
||||
if (item.received_quantity !== null && item.received_quantity !== undefined) {
|
||||
return item.received_quantity;
|
||||
}
|
||||
if (item.execution_status === "received") {
|
||||
return requestedQuantity;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
const toOptionalInteger = (value: string): number | null => {
|
||||
if (value.trim() === "") return null;
|
||||
const parsed = Number(value);
|
||||
@ -181,8 +201,8 @@ function ProcurementItemCard({
|
||||
const expectedDeliveryDates = toDateRows(item.expected_delivery_dates);
|
||||
const commentsTotal = item.comments_count ?? comments.length;
|
||||
const requestedQuantity = Math.max(0, Math.trunc(toNumber(item.quantity)));
|
||||
const orderedQuantity = item.ordered_quantity ?? 0;
|
||||
const receivedQuantity = item.received_quantity ?? 0;
|
||||
const orderedQuantity = effectiveOrderedQuantity(item, requestedQuantity);
|
||||
const receivedQuantity = effectiveReceivedQuantity(item, requestedQuantity);
|
||||
const itemUnitLabel = getProcurementUnitLabel(item.unit);
|
||||
const status = item.execution_status || "pending";
|
||||
const statusLabel = item.execution_status_display || executionStatusOptions.find((option) => option.value === status)?.label || "Не выполнено";
|
||||
@ -209,8 +229,7 @@ function ProcurementItemCard({
|
||||
canReportIssue &&
|
||||
onReportItemIssue &&
|
||||
status !== "rejected" &&
|
||||
status !== "defective" &&
|
||||
(canEditItemProcessing || status === "received")
|
||||
status !== "defective"
|
||||
);
|
||||
|
||||
const updateDraft = (patch: Partial<ItemProcessingDraft>) => {
|
||||
@ -662,6 +681,7 @@ export function ProcurementRequestDetailContent({
|
||||
}: ProcurementRequestDetailContentProps) {
|
||||
const canEditItemProcessing = Boolean(canProcessItems && onUpdateItem);
|
||||
const isRequestPending = String(request.status || "").toLowerCase() === "pending";
|
||||
const canMarkAllReceived = canEditItemProcessing && ["waiting", "in_progress"].includes(String(request.status || "").toLowerCase());
|
||||
const displayItems = request.items ? [...request.items].reverse() : [];
|
||||
const requestorId = typeof request.requestor === "number" ? request.requestor : request.requestor?.id ?? null;
|
||||
const executorId = typeof request.executor === "number" ? request.executor : request.executor?.id ?? null;
|
||||
@ -725,7 +745,7 @@ export function ProcurementRequestDetailContent({
|
||||
<div className="app-surface rounded-xl p-4">
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="text-sm font-semibold text-[var(--foreground)]">Позиции</p>
|
||||
{canEditItemProcessing && onMarkAllReceived ? (
|
||||
{canMarkAllReceived && onMarkAllReceived ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onMarkAllReceived(request.id)}
|
||||
|
||||
@ -527,6 +527,21 @@ export function useProcurementPage(user: User | null) {
|
||||
await refreshCurrentView();
|
||||
}, [refreshCurrentView, refreshOne]);
|
||||
|
||||
const applyRequestStateUpdate = useCallback((updated: ProcurementRequest) => {
|
||||
setRequests((previous) => previous.map((request) => (
|
||||
request.id === updated.id ? { ...request, ...updated } : request
|
||||
)));
|
||||
setDetailsCache((previous) => {
|
||||
const cached = previous[updated.id] || detailsCacheRef.current[updated.id];
|
||||
const next = {
|
||||
...previous,
|
||||
[updated.id]: cached ? { ...cached, ...updated } : updated,
|
||||
};
|
||||
detailsCacheRef.current = next;
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const ensureRequestDetail = useCallback(async (id: number) => {
|
||||
const cached = detailsCacheRef.current[id];
|
||||
if (cached) {
|
||||
@ -900,6 +915,37 @@ export function useProcurementPage(user: User | null) {
|
||||
"Все позиции отмечены полученными.",
|
||||
), [doAction]);
|
||||
|
||||
const handleToggleViewed = useCallback(async (id: number, isViewed: boolean) => {
|
||||
try {
|
||||
setBusyKey(`viewed-${id}`);
|
||||
setActionError(null);
|
||||
const updated = await apiClient.setProcurementRequestViewed(id, isViewed) as ProcurementRequest;
|
||||
applyRequestStateUpdate(updated);
|
||||
if (isViewed) {
|
||||
setExpandedIds((previous) => {
|
||||
if (!previous.has(id)) {
|
||||
return previous;
|
||||
}
|
||||
const next = new Set(previous);
|
||||
next.delete(id);
|
||||
return next;
|
||||
});
|
||||
setExpandedComments((previous) => {
|
||||
if (!previous[id]) {
|
||||
return previous;
|
||||
}
|
||||
return { ...previous, [id]: false };
|
||||
});
|
||||
}
|
||||
return true;
|
||||
} catch (viewedError) {
|
||||
setActionError(getReadableError(viewedError, "Не удалось обновить отметку просмотра"));
|
||||
return false;
|
||||
} finally {
|
||||
setBusyKey(null);
|
||||
}
|
||||
}, [applyRequestStateUpdate]);
|
||||
|
||||
const handleUpdateItem = useCallback(async (requestId: number, itemId: number, patch: Record<string, unknown>) => {
|
||||
try {
|
||||
setBusyKey(`item-${itemId}`);
|
||||
@ -1102,6 +1148,7 @@ export function useProcurementPage(user: User | null) {
|
||||
handleStart,
|
||||
handleComplete,
|
||||
handleMarkAllReceived,
|
||||
handleToggleViewed,
|
||||
handleUpdateItem,
|
||||
handleReportItemIssue,
|
||||
handleCancelItemIssue,
|
||||
|
||||
@ -16,6 +16,8 @@ export function createProcurementApi(request: RequestFn) {
|
||||
startWorkProcurementRequest: (id: number) => request(`/api/v1/procurement/requests/${id}/start_work/`, { method: 'POST' }),
|
||||
completeProcurementRequest: (id: number) => request(`/api/v1/procurement/requests/${id}/complete/`, { method: 'POST' }),
|
||||
markAllReceivedProcurementRequest: (id: number) => request(`/api/v1/procurement/requests/${id}/mark_all_received/`, { method: 'POST' }),
|
||||
setProcurementRequestViewed: (id: number, isViewed: boolean) =>
|
||||
request(`/api/v1/procurement/requests/${id}/set_viewed/`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ is_viewed: isViewed }) }),
|
||||
cancelProcurementRequest: (id: number, reason?: string) =>
|
||||
request(`/api/v1/procurement/requests/${id}/cancel/`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ reason: reason || '' }) }),
|
||||
getProcurementComments: (requestId: number) => request(`/api/v1/procurement/requests/${requestId}/comments/`),
|
||||
|
||||
@ -591,6 +591,7 @@ export interface ProcurementRequest {
|
||||
required_approval_priorities?: number[];
|
||||
actual_cost?: string | number | null;
|
||||
is_editable?: boolean;
|
||||
is_viewed?: boolean;
|
||||
total_cost?: string | number;
|
||||
total_estimated_cost?: string | number;
|
||||
items_count?: number;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user