procurement: refine item fulfillment workflow
This commit is contained in:
parent
b1a5ac34ef
commit
520abbf4d0
@ -941,12 +941,73 @@ class ProcurementItemViewSet(viewsets.ModelViewSet):
|
||||
"comments",
|
||||
"delete_comment",
|
||||
"report_issue",
|
||||
"confirm_received",
|
||||
"cancel_received",
|
||||
"cancel_issue",
|
||||
]:
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
else:
|
||||
permission_classes = [CanManageProcurementRequest]
|
||||
return [permission() for permission in permission_classes]
|
||||
|
||||
def _can_use_item_quick_action(self, user, procurement_request):
|
||||
return (
|
||||
procurement_request.requestor_id == user.id
|
||||
or self._can_process_request_items(user, procurement_request)
|
||||
)
|
||||
|
||||
def _validate_item_quick_action(self, user, procurement_request):
|
||||
if not self._can_use_item_quick_action(user, procurement_request):
|
||||
return Response(
|
||||
{"detail": "Нет прав изменить эту позицию"},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
if procurement_request.status in [
|
||||
ProcurementStatus.PENDING,
|
||||
ProcurementStatus.CANCELLED,
|
||||
ProcurementStatus.REJECTED,
|
||||
]:
|
||||
return Response(
|
||||
{
|
||||
"detail": (
|
||||
"Нельзя изменить позицию в заявке на согласовании, "
|
||||
"в отменённой или отклонённой заявке"
|
||||
)
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
return None
|
||||
|
||||
def _workflow_status_from_quantities(self, item):
|
||||
received_quantity = item.received_quantity or 0
|
||||
ordered_quantity = item.ordered_quantity or 0
|
||||
|
||||
if received_quantity > 0:
|
||||
return (
|
||||
ProcurementItemExecutionStatus.RECEIVED
|
||||
if received_quantity >= item.quantity
|
||||
else ProcurementItemExecutionStatus.ORDERED
|
||||
)
|
||||
if ordered_quantity > 0:
|
||||
return ProcurementItemExecutionStatus.ORDERED
|
||||
return ProcurementItemExecutionStatus.PENDING
|
||||
|
||||
def _reopen_completed_request(self, procurement_request, user):
|
||||
if procurement_request.status != ProcurementStatus.COMPLETED:
|
||||
return
|
||||
|
||||
procurement_request.status = (
|
||||
ProcurementStatus.IN_PROGRESS
|
||||
if procurement_request.executor_id
|
||||
else ProcurementStatus.WAITING
|
||||
)
|
||||
procurement_request.completed_at = None
|
||||
procurement_request._notification_actor = user
|
||||
procurement_request.save(
|
||||
update_fields=["status", "completed_at", "updated_at"]
|
||||
)
|
||||
|
||||
def perform_create(self, serializer):
|
||||
"""Проверяем права при создании позиции.
|
||||
|
||||
@ -1064,44 +1125,17 @@ class ProcurementItemViewSet(viewsets.ModelViewSet):
|
||||
procurement_request = item.request
|
||||
user = request.user
|
||||
|
||||
if not (
|
||||
procurement_request.requestor_id == user.id
|
||||
or self._can_process_request_items(user, procurement_request)
|
||||
):
|
||||
return Response(
|
||||
{"detail": "Нет прав отметить проблему по этой позиции"},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
if procurement_request.status in [
|
||||
ProcurementStatus.PENDING,
|
||||
ProcurementStatus.CANCELLED,
|
||||
ProcurementStatus.REJECTED,
|
||||
]:
|
||||
return Response(
|
||||
{
|
||||
"detail": (
|
||||
"Нельзя изменить позицию в заявке на согласовании, "
|
||||
"в отменённой или отклонённой заявке"
|
||||
)
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
error_response = self._validate_item_quick_action(
|
||||
user,
|
||||
procurement_request,
|
||||
)
|
||||
if error_response is not None:
|
||||
return error_response
|
||||
|
||||
item.execution_status = ProcurementItemExecutionStatus.DEFECTIVE
|
||||
item.save(update_fields=["execution_status"])
|
||||
|
||||
if procurement_request.status == ProcurementStatus.COMPLETED:
|
||||
procurement_request.status = (
|
||||
ProcurementStatus.IN_PROGRESS
|
||||
if procurement_request.executor_id
|
||||
else ProcurementStatus.WAITING
|
||||
)
|
||||
procurement_request.completed_at = None
|
||||
procurement_request._notification_actor = user
|
||||
procurement_request.save(
|
||||
update_fields=["status", "completed_at", "updated_at"]
|
||||
)
|
||||
self._reopen_completed_request(procurement_request, user)
|
||||
|
||||
text = request.data.get("text", "")
|
||||
if isinstance(text, str):
|
||||
@ -1130,6 +1164,110 @@ class ProcurementItemViewSet(viewsets.ModelViewSet):
|
||||
serializer = self.get_serializer(item)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=True, methods=["post"])
|
||||
def cancel_received(self, request, pk=None):
|
||||
"""Отменить подтверждение получения позиции."""
|
||||
item = self.get_object()
|
||||
procurement_request = item.request
|
||||
user = request.user
|
||||
|
||||
error_response = self._validate_item_quick_action(
|
||||
user,
|
||||
procurement_request,
|
||||
)
|
||||
if error_response is not None:
|
||||
return error_response
|
||||
|
||||
if (item.received_quantity or 0) <= 0:
|
||||
return Response(
|
||||
{"detail": "По позиции нет подтверждённого получения"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
item.received_quantity = 0
|
||||
item.execution_status = self._workflow_status_from_quantities(item)
|
||||
item.save(update_fields=["execution_status", "received_quantity"])
|
||||
self._reopen_completed_request(procurement_request, user)
|
||||
notify_item_updated(
|
||||
item,
|
||||
actor=user,
|
||||
changed_fields=["execution_status", "received_quantity"],
|
||||
)
|
||||
|
||||
item.refresh_from_db()
|
||||
serializer = self.get_serializer(item)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=True, methods=["post"])
|
||||
def cancel_issue(self, request, pk=None):
|
||||
"""Снять отметку брака/перезаказа с позиции."""
|
||||
item = self.get_object()
|
||||
procurement_request = item.request
|
||||
user = request.user
|
||||
|
||||
error_response = self._validate_item_quick_action(
|
||||
user,
|
||||
procurement_request,
|
||||
)
|
||||
if error_response is not None:
|
||||
return error_response
|
||||
|
||||
if item.execution_status != ProcurementItemExecutionStatus.DEFECTIVE:
|
||||
return Response(
|
||||
{"detail": "Позиция не отмечена как брак"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
item.execution_status = self._workflow_status_from_quantities(item)
|
||||
item.save(update_fields=["execution_status"])
|
||||
notify_item_updated(
|
||||
item,
|
||||
actor=user,
|
||||
changed_fields=["execution_status"],
|
||||
)
|
||||
|
||||
item.refresh_from_db()
|
||||
serializer = self.get_serializer(item)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=True, methods=["post"])
|
||||
def confirm_received(self, request, pk=None):
|
||||
"""Подтвердить получение позиции."""
|
||||
item = self.get_object()
|
||||
procurement_request = item.request
|
||||
user = request.user
|
||||
|
||||
error_response = self._validate_item_quick_action(
|
||||
user,
|
||||
procurement_request,
|
||||
)
|
||||
if error_response is not None:
|
||||
return error_response
|
||||
|
||||
item.execution_status = ProcurementItemExecutionStatus.RECEIVED
|
||||
item.ordered_quantity = item.quantity
|
||||
item.received_quantity = item.quantity
|
||||
item.save(
|
||||
update_fields=[
|
||||
"execution_status",
|
||||
"ordered_quantity",
|
||||
"received_quantity",
|
||||
]
|
||||
)
|
||||
notify_item_updated(
|
||||
item,
|
||||
actor=user,
|
||||
changed_fields=[
|
||||
"execution_status",
|
||||
"ordered_quantity",
|
||||
"received_quantity",
|
||||
],
|
||||
)
|
||||
|
||||
item.refresh_from_db()
|
||||
serializer = self.get_serializer(item)
|
||||
return Response(serializer.data)
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
"""Проверяем права при удалении позиции."""
|
||||
self._check_item_edit_permission(instance)
|
||||
|
||||
@ -1447,6 +1447,14 @@ class TestProcessingDepartmentWorkflow:
|
||||
'api:v1:procurement:procurementitem-report-issue',
|
||||
kwargs={'pk': item.id},
|
||||
)
|
||||
confirm_received_url = reverse(
|
||||
'api:v1:procurement:procurementitem-confirm-received',
|
||||
kwargs={'pk': item.id},
|
||||
)
|
||||
cancel_received_url = reverse(
|
||||
'api:v1:procurement:procurementitem-cancel-received',
|
||||
kwargs={'pk': item.id},
|
||||
)
|
||||
mark_all_url = reverse(
|
||||
'api:v1:procurement:procurementrequest-mark-all-received',
|
||||
kwargs={'pk': processing_request.id},
|
||||
@ -1470,12 +1478,16 @@ class TestProcessingDepartmentWorkflow:
|
||||
{'text': 'Брак'},
|
||||
format='json',
|
||||
)
|
||||
confirm_received_response = api_client.post(confirm_received_url)
|
||||
cancel_received_response = api_client.post(cancel_received_url)
|
||||
mark_all_response = api_client.post(mark_all_url)
|
||||
complete_response = api_client.post(complete_url)
|
||||
start_response = api_client.post(start_url)
|
||||
|
||||
assert item_response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert issue_response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert confirm_received_response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert cancel_received_response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert mark_all_response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert complete_response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert start_response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
@ -1886,6 +1898,22 @@ class TestProcessingDepartmentWorkflow:
|
||||
assert [comment.content for comment in get_comments(item)] == [
|
||||
"Обнаружен брак"
|
||||
]
|
||||
|
||||
api_client.force_authenticate(user=supply_user)
|
||||
my_work_url = reverse(
|
||||
'api:v1:procurement:procurementrequest-list'
|
||||
)
|
||||
my_work_response = api_client.get(
|
||||
my_work_url,
|
||||
{'scope': 'my_work'},
|
||||
)
|
||||
assert my_work_response.status_code == status.HTTP_200_OK
|
||||
my_work_item = next(
|
||||
item for item in my_work_response.data["results"]
|
||||
if item["id"] == processing_request.id
|
||||
)
|
||||
assert my_work_item["status"] == ProcurementStatus.IN_PROGRESS
|
||||
|
||||
issue_recipients = [
|
||||
notification["recipient"].email
|
||||
for notification in sent
|
||||
@ -1893,6 +1921,201 @@ class TestProcessingDepartmentWorkflow:
|
||||
]
|
||||
assert supply_user.email in issue_recipients
|
||||
|
||||
def test_executor_issue_reopens_request_for_requestor_and_notifies(
|
||||
self, api_client, user, supply_user, processing_request,
|
||||
procurement_item_factory, monkeypatch
|
||||
):
|
||||
sent = []
|
||||
|
||||
def fake_notify_send(**kwargs):
|
||||
sent.append(kwargs)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"procurement.notifications.handlers.notify.send",
|
||||
fake_notify_send,
|
||||
)
|
||||
|
||||
item = procurement_item_factory(
|
||||
request=processing_request,
|
||||
execution_status=ProcurementItemExecutionStatus.RECEIVED,
|
||||
ordered_quantity=1,
|
||||
received_quantity=1,
|
||||
)
|
||||
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()
|
||||
sent.clear()
|
||||
|
||||
api_client.force_authenticate(user=supply_user)
|
||||
url = reverse(
|
||||
'api:v1:procurement:procurementitem-report-issue',
|
||||
kwargs={'pk': item.id},
|
||||
)
|
||||
|
||||
response = api_client.post(url, {'text': 'Брак'}, format='json')
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
item.refresh_from_db()
|
||||
processing_request.refresh_from_db()
|
||||
assert item.execution_status == ProcurementItemExecutionStatus.DEFECTIVE
|
||||
assert processing_request.status == ProcurementStatus.IN_PROGRESS
|
||||
assert processing_request.completed_at is None
|
||||
|
||||
api_client.force_authenticate(user=user)
|
||||
mine_url = reverse('api:v1:procurement:procurementrequest-list')
|
||||
mine_response = api_client.get(mine_url, {'scope': 'mine'})
|
||||
assert mine_response.status_code == status.HTTP_200_OK
|
||||
mine_item = next(
|
||||
item for item in mine_response.data["results"]
|
||||
if item["id"] == processing_request.id
|
||||
)
|
||||
assert mine_item["status"] == ProcurementStatus.IN_PROGRESS
|
||||
|
||||
item_updated_recipients = [
|
||||
notification["recipient"].email
|
||||
for notification in sent
|
||||
if notification["verb"] == "procurement_item_updated"
|
||||
]
|
||||
in_progress_recipients = [
|
||||
notification["recipient"].email
|
||||
for notification in sent
|
||||
if notification["verb"] == "procurement_in_progress"
|
||||
]
|
||||
assert user.email in item_updated_recipients
|
||||
assert user.email in in_progress_recipients
|
||||
|
||||
def test_requestor_can_confirm_item_received(
|
||||
self, api_client, user, supply_user, processing_request,
|
||||
procurement_item_factory
|
||||
):
|
||||
item = procurement_item_factory(
|
||||
request=processing_request,
|
||||
quantity=2,
|
||||
ordered_quantity=1,
|
||||
received_quantity=0,
|
||||
execution_status=ProcurementItemExecutionStatus.ORDERED,
|
||||
)
|
||||
processing_request.executor = supply_user
|
||||
processing_request.status = ProcurementStatus.IN_PROGRESS
|
||||
processing_request.save()
|
||||
|
||||
api_client.force_authenticate(user=user)
|
||||
url = reverse(
|
||||
'api:v1:procurement:procurementitem-confirm-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.RECEIVED
|
||||
assert item.ordered_quantity == item.quantity
|
||||
assert item.received_quantity == item.quantity
|
||||
assert (
|
||||
processing_request.fulfillment_status
|
||||
== ProcurementFulfillmentStatus.COMPLETED
|
||||
)
|
||||
assert processing_request.status == ProcurementStatus.COMPLETED
|
||||
assert processing_request.completed_at is not None
|
||||
|
||||
def test_requestor_can_cancel_item_issue(
|
||||
self, api_client, user, supply_user, processing_request,
|
||||
procurement_item_factory
|
||||
):
|
||||
item = procurement_item_factory(
|
||||
request=processing_request,
|
||||
quantity=1,
|
||||
ordered_quantity=1,
|
||||
received_quantity=1,
|
||||
execution_status=ProcurementItemExecutionStatus.DEFECTIVE,
|
||||
)
|
||||
processing_request.executor = supply_user
|
||||
processing_request.status = ProcurementStatus.IN_PROGRESS
|
||||
processing_request.fulfillment_status = ProcurementFulfillmentStatus.ISSUES
|
||||
processing_request.save()
|
||||
|
||||
api_client.force_authenticate(user=user)
|
||||
url = reverse(
|
||||
'api:v1:procurement:procurementitem-cancel-issue',
|
||||
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.RECEIVED
|
||||
assert (
|
||||
processing_request.fulfillment_status
|
||||
== ProcurementFulfillmentStatus.COMPLETED
|
||||
)
|
||||
assert processing_request.status == ProcurementStatus.COMPLETED
|
||||
|
||||
def test_requestor_can_cancel_item_received_and_reopens_completed_request(
|
||||
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,
|
||||
)
|
||||
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.fulfillment_status
|
||||
== ProcurementFulfillmentStatus.ORDERED
|
||||
)
|
||||
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
|
||||
):
|
||||
item = procurement_item_factory(
|
||||
request=processing_request,
|
||||
execution_status=ProcurementItemExecutionStatus.ORDERED,
|
||||
ordered_quantity=1,
|
||||
)
|
||||
|
||||
api_client.force_authenticate(user=outsider)
|
||||
url = reverse(
|
||||
'api:v1:procurement:procurementitem-confirm-received',
|
||||
kwargs={'pk': item.id},
|
||||
)
|
||||
|
||||
response = api_client.post(url, {}, format='json')
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
def test_list_exposes_procurement_progress_summary(
|
||||
self, api_client, supply_user, processing_request,
|
||||
procurement_item_factory
|
||||
|
||||
@ -447,6 +447,17 @@ html[data-bs-theme="dark"] .app-input::-webkit-calendar-picker-indicator {
|
||||
border-color: color-mix(in srgb, var(--danger-foreground) 24%, var(--border-strong));
|
||||
}
|
||||
|
||||
.app-action-approve {
|
||||
border: 1px solid color-mix(in srgb, var(--danger-foreground) 18%, var(--border-strong));
|
||||
background: var(--surface-elevated);
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.app-action-approve:hover {
|
||||
background: var(--success-soft);
|
||||
border-color: color-mix(in srgb, #10b981 24%, var(--border-strong));
|
||||
}
|
||||
|
||||
.app-selected {
|
||||
border: 1px solid color-mix(in srgb, var(--accent-primary) 22%, var(--border-subtle));
|
||||
background: color-mix(in srgb, var(--accent-primary) 12%, var(--surface-primary));
|
||||
|
||||
@ -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, ProcurementStatus, UrgencyLevel } from "@/types/api";
|
||||
import type { ProcurementFulfillmentStatus, ProcurementRequest, ProcurementStatus, UrgencyLevel } from "@/types/api";
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowUpDown,
|
||||
@ -59,6 +59,18 @@ const getStatusMeta = (status?: string | null) => (
|
||||
statusMeta[String(status || "").toLowerCase() as ProcurementStatus] ?? defaultStatusMeta
|
||||
);
|
||||
|
||||
const fulfillmentStatusClasses: Record<ProcurementFulfillmentStatus, string> = {
|
||||
pending: "app-badge",
|
||||
partially_ordered: "app-feedback-warning",
|
||||
ordered: "app-feedback-success",
|
||||
partially_received: "app-feedback-warning",
|
||||
completed: "app-feedback-success",
|
||||
issues: "app-feedback-warning",
|
||||
};
|
||||
const getFulfillmentStatusClass = (status?: string | null) => (
|
||||
fulfillmentStatusClasses[String(status || "").toLowerCase() as ProcurementFulfillmentStatus] ?? "app-badge"
|
||||
);
|
||||
|
||||
const urgencyMeta: Record<string, { label: string; cls: string }> = {
|
||||
low: { label: "Низкая", cls: "text-gray-500" },
|
||||
medium: { label: "Средняя", cls: "text-sky-600" },
|
||||
@ -211,10 +223,10 @@ function ProcurementRequestActionButtons({
|
||||
onClick={() => onApprove(request.id)}
|
||||
disabled={busyKey === `approve-${request.id}`}
|
||||
title="Одобрить"
|
||||
className={buttonClass("app-feedback-success")}
|
||||
className={buttonClass("app-action-approve")}
|
||||
>
|
||||
<ThumbsUp size={14} />
|
||||
{label("Одобрить")}
|
||||
<ThumbsUp size={14} className="text-emerald-500" />
|
||||
{showLabels ? <span className="text-emerald-500">Одобрить</span> : null}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@ -311,6 +323,9 @@ export default function ProcurementPage() {
|
||||
handleComplete,
|
||||
handleMarkAllReceived,
|
||||
handleReportItemIssue,
|
||||
handleCancelItemIssue,
|
||||
handleConfirmItemReceived,
|
||||
handleCancelItemReceived,
|
||||
handleUpdateItem,
|
||||
handleDelete,
|
||||
handleLoadMore,
|
||||
@ -776,6 +791,7 @@ export default function ProcurementPage() {
|
||||
const executorName = req.executor || req.executor_name ? displayUserName(req.executor, req.executor_name || undefined) : "";
|
||||
const requestorLink = userLink(req.requestor);
|
||||
const executorLink = userLink(req.executor);
|
||||
const fulfillmentStatus = resolvedDetail.fulfillment_status ?? req.fulfillment_status;
|
||||
const fulfillmentStatusDisplay = resolvedDetail.fulfillment_status_display ?? req.fulfillment_status_display;
|
||||
const itemsCount = resolvedDetail.items?.length ?? resolvedDetail.items_count ?? req.items_count ?? 0;
|
||||
const detailItems = resolvedDetail.items || req.items || [];
|
||||
@ -930,7 +946,7 @@ export default function ProcurementPage() {
|
||||
{(fulfillmentStatusDisplay || itemsTotalCount > 0 || approvalsCount > 0 || nextExpectedDeliveryDate) && (
|
||||
<div className="col-span-2 flex flex-wrap items-center gap-2 pt-0.5">
|
||||
{fulfillmentStatusDisplay ? (
|
||||
<span className="app-badge inline-flex items-center gap-1 rounded-full px-2 py-1 text-[11px] font-medium">
|
||||
<span className={`${getFulfillmentStatusClass(fulfillmentStatus)} inline-flex items-center gap-1 rounded-full px-2 py-1 text-[11px] font-medium`}>
|
||||
{fulfillmentStatusDisplay}
|
||||
</span>
|
||||
) : null}
|
||||
@ -986,9 +1002,9 @@ export default function ProcurementPage() {
|
||||
title="Одобрить"
|
||||
onClick={() => openRequestActionDialog("approve", req.id)}
|
||||
disabled={busyKey === `approve-${req.id}`}
|
||||
className="app-feedback-success inline-flex items-center justify-center rounded-lg p-2 disabled:opacity-60"
|
||||
className="app-action-approve inline-flex items-center justify-center rounded-lg p-2 disabled:opacity-60"
|
||||
>
|
||||
<ThumbsUp size={18} />
|
||||
<ThumbsUp size={18} className="text-emerald-500" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@ -1057,6 +1073,9 @@ export default function ProcurementPage() {
|
||||
canDeleteAnyComment={Boolean(user?.auth?.is_staff || user?.auth?.is_superuser)}
|
||||
onUpdateItem={handleUpdateItem}
|
||||
onReportItemIssue={handleReportItemIssue}
|
||||
onCancelItemIssue={handleCancelItemIssue}
|
||||
onConfirmItemReceived={handleConfirmItemReceived}
|
||||
onCancelItemReceived={handleCancelItemReceived}
|
||||
onMarkAllReceived={handleMarkAllReceived}
|
||||
itemCommentsMap={itemCommentsMap}
|
||||
itemCommentDrafts={itemCommentDrafts}
|
||||
@ -1145,7 +1164,7 @@ export default function ProcurementPage() {
|
||||
<span className="font-medium text-[var(--foreground)]">{money(getRequestAmount(selectedRequest))}</span>
|
||||
) : null}
|
||||
{selectedRequest.fulfillment_status_display ? (
|
||||
<span className="app-badge rounded-full px-2 py-0.5 text-[11px] font-medium">
|
||||
<span className={`${getFulfillmentStatusClass(selectedRequest.fulfillment_status)} rounded-full px-2 py-0.5 text-[11px] font-medium`}>
|
||||
{selectedRequest.fulfillment_status_display}
|
||||
</span>
|
||||
) : null}
|
||||
@ -1184,6 +1203,9 @@ export default function ProcurementPage() {
|
||||
canDeleteAnyComment={Boolean(user?.auth?.is_staff || user?.auth?.is_superuser)}
|
||||
onUpdateItem={handleUpdateItem}
|
||||
onReportItemIssue={handleReportItemIssue}
|
||||
onCancelItemIssue={handleCancelItemIssue}
|
||||
onConfirmItemReceived={handleConfirmItemReceived}
|
||||
onCancelItemReceived={handleCancelItemReceived}
|
||||
onMarkAllReceived={handleMarkAllReceived}
|
||||
itemCommentsMap={itemCommentsMap}
|
||||
itemCommentDrafts={itemCommentDrafts}
|
||||
@ -1457,10 +1479,38 @@ export default function ProcurementPage() {
|
||||
<textarea
|
||||
value={it.initial_comment}
|
||||
onChange={(e) => updateItemRow(idx, { initial_comment: e.target.value })}
|
||||
placeholder="Комментарий к позиции"
|
||||
placeholder={modalMode === "edit" ? "Новый комментарий к позиции" : "Комментарий к позиции"}
|
||||
rows={2}
|
||||
className="app-input app-text-wrap w-full rounded-lg px-3 py-2 text-sm"
|
||||
/>
|
||||
{modalMode === "edit" && it.id && (itemCommentsMap[it.id] || []).length > 0 ? (
|
||||
<div className="app-surface rounded-lg px-3 py-2.5">
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<p className="app-card-caption text-xs">Комментарии позиции</p>
|
||||
<span className="app-badge px-2 py-0.5 text-[10px] font-semibold">
|
||||
{(itemCommentsMap[it.id] || []).length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="max-h-40 space-y-2 overflow-y-auto pr-1">
|
||||
{(itemCommentsMap[it.id] || []).map((comment) => (
|
||||
<div
|
||||
key={comment.id}
|
||||
className="border-b border-[var(--border-subtle)] pb-2 text-xs last:border-b-0 last:pb-0"
|
||||
>
|
||||
<div className="mb-1 flex flex-wrap items-center gap-x-2 gap-y-0.5">
|
||||
<span className="font-medium text-[var(--foreground)]">
|
||||
{displayUserName(comment.author)}
|
||||
</span>
|
||||
<span className="app-text-muted text-[11px]">{fmt(comment.created_at)}</span>
|
||||
</div>
|
||||
<p className="app-text-wrap whitespace-pre-line leading-5 text-[var(--foreground)]">
|
||||
{comment.text}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
<input
|
||||
type="number"
|
||||
|
||||
@ -23,6 +23,9 @@ interface ProcurementRequestDetailContentProps {
|
||||
canDeleteAnyComment?: boolean;
|
||||
onUpdateItem?: (requestId: number, itemId: number, patch: Record<string, unknown>) => void | Promise<unknown>;
|
||||
onReportItemIssue?: (requestId: number, itemId: number, text?: string) => void | Promise<unknown>;
|
||||
onCancelItemIssue?: (requestId: number, itemId: number) => void | Promise<unknown>;
|
||||
onConfirmItemReceived?: (requestId: number, itemId: number) => void | Promise<unknown>;
|
||||
onCancelItemReceived?: (requestId: number, itemId: number) => void | Promise<unknown>;
|
||||
onMarkAllReceived?: (requestId: number) => void | Promise<unknown>;
|
||||
itemCommentsMap?: Record<number, ProcurementItemComment[]>;
|
||||
itemCommentDrafts?: Record<number, string>;
|
||||
@ -115,11 +118,17 @@ interface ProcurementItemCardProps {
|
||||
) => string;
|
||||
canEditItemProcessing: boolean;
|
||||
canReportIssue: boolean;
|
||||
canCancelIssue: boolean;
|
||||
canConfirmReceipt: boolean;
|
||||
canCancelReceipt: boolean;
|
||||
currentUserId?: number | null;
|
||||
canDeleteAnyComment?: boolean;
|
||||
busyKey?: string | null;
|
||||
onUpdateItem?: (requestId: number, itemId: number, patch: Record<string, unknown>) => void | Promise<unknown>;
|
||||
onReportItemIssue?: (requestId: number, itemId: number, text?: string) => void | Promise<unknown>;
|
||||
onCancelItemIssue?: (requestId: number, itemId: number) => void | Promise<unknown>;
|
||||
onConfirmItemReceived?: (requestId: number, itemId: number) => void | Promise<unknown>;
|
||||
onCancelItemReceived?: (requestId: number, itemId: number) => void | Promise<unknown>;
|
||||
comments?: ProcurementItemComment[];
|
||||
commentDraft?: string;
|
||||
commentsOpen?: boolean;
|
||||
@ -135,11 +144,17 @@ function ProcurementItemCard({
|
||||
displayUserName,
|
||||
canEditItemProcessing,
|
||||
canReportIssue,
|
||||
canCancelIssue,
|
||||
canConfirmReceipt,
|
||||
canCancelReceipt,
|
||||
currentUserId,
|
||||
canDeleteAnyComment = false,
|
||||
busyKey,
|
||||
onUpdateItem,
|
||||
onReportItemIssue,
|
||||
onCancelItemIssue,
|
||||
onConfirmItemReceived,
|
||||
onCancelItemReceived,
|
||||
comments = [],
|
||||
commentDraft = "",
|
||||
commentsOpen = false,
|
||||
@ -157,7 +172,25 @@ function ProcurementItemCard({
|
||||
const receivedQuantity = item.received_quantity ?? 0;
|
||||
const status = item.execution_status || "pending";
|
||||
const statusLabel = item.execution_status_display || executionStatusOptions.find((option) => option.value === status)?.label || "Не выполнено";
|
||||
const canCancelReceived = canEditItemProcessing && status === "received";
|
||||
const canCancelReceived = Boolean(
|
||||
canCancelReceipt &&
|
||||
onCancelItemReceived &&
|
||||
receivedQuantity > 0
|
||||
);
|
||||
const canConfirmReceived = Boolean(
|
||||
canConfirmReceipt &&
|
||||
onConfirmItemReceived &&
|
||||
requestedQuantity > 0 &&
|
||||
(status === "ordered" || receivedQuantity > 0) &&
|
||||
receivedQuantity < requestedQuantity &&
|
||||
status !== "rejected" &&
|
||||
status !== "defective"
|
||||
);
|
||||
const canCancelItemIssue = Boolean(
|
||||
canCancelIssue &&
|
||||
onCancelItemIssue &&
|
||||
status === "defective"
|
||||
);
|
||||
const canMarkIssue = Boolean(
|
||||
canReportIssue &&
|
||||
onReportItemIssue &&
|
||||
@ -189,14 +222,6 @@ function ProcurementItemCard({
|
||||
});
|
||||
};
|
||||
|
||||
const cancelReceived = () => {
|
||||
const nextStatus: ProcurementItemExecutionStatus = Number(item.ordered_quantity || 0) > 0 ? "ordered" : "pending";
|
||||
return onUpdateItem?.(requestId, item.id, {
|
||||
execution_status: nextStatus,
|
||||
received_quantity: 0,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="app-surface-muted rounded-lg px-3 py-3 text-xs">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
@ -320,24 +345,48 @@ function ProcurementItemCard({
|
||||
{canCancelReceived ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void cancelReceived()}
|
||||
disabled={busyKey === `item-${item.id}`}
|
||||
className="app-action-secondary inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs font-medium disabled:opacity-60"
|
||||
onClick={() => void onCancelItemReceived?.(requestId, item.id)}
|
||||
disabled={busyKey === `item-cancel-received-${item.id}`}
|
||||
className="app-action-ghost inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs font-medium disabled:opacity-60"
|
||||
>
|
||||
<RotateCcw size={13} />
|
||||
<span>Отменить получение</span>
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{canConfirmReceived ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void onConfirmItemReceived?.(requestId, item.id)}
|
||||
disabled={busyKey === `item-confirm-received-${item.id}`}
|
||||
className="app-action-ghost inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs font-medium disabled:opacity-60"
|
||||
>
|
||||
<CheckCircle2 size={13} className="text-emerald-500" />
|
||||
<span className="text-emerald-500">Подтвердить получение</span>
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{canMarkIssue ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void onReportItemIssue?.(requestId, item.id)}
|
||||
disabled={busyKey === `item-issue-${item.id}`}
|
||||
className="app-feedback-warning inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs font-medium disabled:opacity-60"
|
||||
className="app-action-ghost inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs font-medium disabled:opacity-60"
|
||||
>
|
||||
<AlertTriangle size={13} />
|
||||
<span>Брак</span>
|
||||
<AlertTriangle size={13} className="text-amber-500" />
|
||||
<span className="text-amber-500">Брак</span>
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{canCancelItemIssue ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void onCancelItemIssue?.(requestId, item.id)}
|
||||
disabled={busyKey === `item-cancel-issue-${item.id}`}
|
||||
className="app-action-ghost inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs font-medium disabled:opacity-60"
|
||||
>
|
||||
<RotateCcw size={13} className="text-amber-500" />
|
||||
<span className="text-amber-500">Отменить брак</span>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
@ -545,6 +594,9 @@ export function ProcurementRequestDetailContent({
|
||||
canDeleteAnyComment = false,
|
||||
onUpdateItem,
|
||||
onReportItemIssue,
|
||||
onCancelItemIssue,
|
||||
onConfirmItemReceived,
|
||||
onCancelItemReceived,
|
||||
onMarkAllReceived,
|
||||
itemCommentsMap = {},
|
||||
itemCommentDrafts = {},
|
||||
@ -557,6 +609,7 @@ export function ProcurementRequestDetailContent({
|
||||
}: ProcurementRequestDetailContentProps) {
|
||||
const canEditItemProcessing = Boolean(canProcessItems && onUpdateItem);
|
||||
const isRequestPending = String(request.status || "").toLowerCase() === "pending";
|
||||
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;
|
||||
const canReportIssues = Boolean(
|
||||
@ -566,6 +619,26 @@ export function ProcurementRequestDetailContent({
|
||||
currentUserId === executorId
|
||||
),
|
||||
);
|
||||
const canCancelIssues = Boolean(
|
||||
!isRequestPending && onCancelItemIssue && currentUserId && (
|
||||
canEditItemProcessing ||
|
||||
currentUserId === requestorId ||
|
||||
currentUserId === executorId
|
||||
),
|
||||
);
|
||||
const canConfirmReceipt = Boolean(
|
||||
!isRequestPending &&
|
||||
onConfirmItemReceived &&
|
||||
currentUserId &&
|
||||
currentUserId === requestorId,
|
||||
);
|
||||
const canCancelReceipt = Boolean(
|
||||
!isRequestPending && onCancelItemReceived && currentUserId && (
|
||||
currentUserId === requestorId ||
|
||||
currentUserId === executorId ||
|
||||
canEditItemProcessing
|
||||
),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
@ -595,7 +668,7 @@ export function ProcurementRequestDetailContent({
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{request.items && request.items.length > 0 ? (
|
||||
{displayItems.length > 0 ? (
|
||||
<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>
|
||||
@ -611,7 +684,7 @@ export function ProcurementRequestDetailContent({
|
||||
) : null}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{request.items.map((item) => (
|
||||
{displayItems.map((item) => (
|
||||
<ProcurementItemCard
|
||||
key={[
|
||||
item.id,
|
||||
@ -627,11 +700,17 @@ export function ProcurementRequestDetailContent({
|
||||
displayUserName={displayUserName}
|
||||
canEditItemProcessing={canEditItemProcessing}
|
||||
canReportIssue={canReportIssues}
|
||||
canCancelIssue={canCancelIssues}
|
||||
canConfirmReceipt={canConfirmReceipt}
|
||||
canCancelReceipt={canCancelReceipt}
|
||||
currentUserId={currentUserId}
|
||||
canDeleteAnyComment={canDeleteAnyComment}
|
||||
busyKey={busyKey}
|
||||
onUpdateItem={onUpdateItem}
|
||||
onReportItemIssue={onReportItemIssue}
|
||||
onCancelItemIssue={onCancelItemIssue}
|
||||
onConfirmItemReceived={onConfirmItemReceived}
|
||||
onCancelItemReceived={onCancelItemReceived}
|
||||
comments={itemCommentsMap[item.id] || []}
|
||||
commentDraft={itemCommentDrafts[item.id] || ""}
|
||||
commentsOpen={Boolean(expandedItemComments[item.id])}
|
||||
|
||||
@ -93,10 +93,10 @@ export function RequestDetailModal({
|
||||
type="button"
|
||||
onClick={() => void onApprove(request.id)}
|
||||
disabled={busyKey === `approve-${request.id}`}
|
||||
className="app-feedback-success inline-flex items-center gap-2 rounded-xl px-4 py-2.5 text-sm font-medium disabled:opacity-60"
|
||||
className="app-action-approve inline-flex items-center gap-2 rounded-xl px-4 py-2.5 text-sm font-medium disabled:opacity-60"
|
||||
>
|
||||
<ThumbsUp size={16} />
|
||||
Одобрить
|
||||
<ThumbsUp size={16} className="text-emerald-500" />
|
||||
<span className="text-emerald-500">Одобрить</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@ -269,9 +269,9 @@ export function RequestListItem({
|
||||
title="Одобрить"
|
||||
onClick={() => void onApprove(request.id)}
|
||||
disabled={busyKey === `approve-${request.id}`}
|
||||
className="app-feedback-success inline-flex items-center justify-center rounded-lg p-2 disabled:opacity-60"
|
||||
className="app-action-approve inline-flex items-center justify-center rounded-lg p-2 disabled:opacity-60"
|
||||
>
|
||||
<ThumbsUp size={18} />
|
||||
<ThumbsUp size={18} className="text-emerald-500" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@ -379,10 +379,10 @@ export default function SwipeApprovalMode({ requests, onApprove, onReject, onClo
|
||||
type="button"
|
||||
onClick={() => { setFlyOut("right"); setTimeout(() => performAction("approved"), 250); }}
|
||||
disabled={busy}
|
||||
className="app-feedback-success flex h-14 w-14 items-center justify-center rounded-full transition hover:shadow-md active:scale-95 disabled:opacity-50"
|
||||
className="app-action-approve flex h-14 w-14 items-center justify-center rounded-full transition hover:shadow-md active:scale-95 disabled:opacity-50"
|
||||
title="Одобрить (→)"
|
||||
>
|
||||
<ThumbsUp size={22} />
|
||||
<ThumbsUp size={22} className="text-emerald-500" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@ -442,6 +442,27 @@ export function useProcurementPage(user: User | null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existingItems = detail.items || [];
|
||||
const commentableItemIds = existingItems
|
||||
.map((item) => item.id)
|
||||
.filter((id): id is number => typeof id === "number");
|
||||
if (commentableItemIds.length > 0) {
|
||||
try {
|
||||
const entries = await Promise.all(
|
||||
commentableItemIds.map(async (itemId) => {
|
||||
const comments = await apiClient.getProcurementItemComments(itemId);
|
||||
return [itemId, Array.isArray(comments) ? comments : []] as const;
|
||||
}),
|
||||
);
|
||||
setItemCommentsMap((previous) => ({
|
||||
...previous,
|
||||
...Object.fromEntries(entries),
|
||||
}));
|
||||
} catch (commentsError) {
|
||||
console.error("Load procurement item comments for edit error:", commentsError);
|
||||
}
|
||||
}
|
||||
|
||||
setEditingId(request.id);
|
||||
setForm({
|
||||
title: detail.title || "",
|
||||
@ -450,8 +471,8 @@ export function useProcurementPage(user: User | null) {
|
||||
requireApproval: false,
|
||||
processing_department: detail.processing_department ?? null,
|
||||
urgency: detail.urgency || "medium",
|
||||
items: detail.items && detail.items.length > 0
|
||||
? detail.items.map((item) => ({
|
||||
items: existingItems.length > 0
|
||||
? existingItems.map((item) => ({
|
||||
id: item.id,
|
||||
name: item.name || "",
|
||||
description: item.description || "",
|
||||
@ -918,6 +939,54 @@ export function useProcurementPage(user: User | null) {
|
||||
}
|
||||
}, [refreshAfterMutation]);
|
||||
|
||||
const handleCancelItemIssue = useCallback(async (requestId: number, itemId: number) => {
|
||||
try {
|
||||
setBusyKey(`item-cancel-issue-${itemId}`);
|
||||
setActionError(null);
|
||||
await apiClient.cancelProcurementItemIssue(itemId);
|
||||
setActionSuccess("Отметка брака снята.");
|
||||
await refreshAfterMutation(requestId);
|
||||
return true;
|
||||
} catch (issueError) {
|
||||
setActionError(getReadableError(issueError, "Не удалось снять отметку брака"));
|
||||
return false;
|
||||
} finally {
|
||||
setBusyKey(null);
|
||||
}
|
||||
}, [refreshAfterMutation]);
|
||||
|
||||
const handleConfirmItemReceived = useCallback(async (requestId: number, itemId: number) => {
|
||||
try {
|
||||
setBusyKey(`item-confirm-received-${itemId}`);
|
||||
setActionError(null);
|
||||
await apiClient.confirmProcurementItemReceived(itemId);
|
||||
setActionSuccess("Получение позиции подтверждено.");
|
||||
await refreshAfterMutation(requestId);
|
||||
return true;
|
||||
} catch (confirmError) {
|
||||
setActionError(getReadableError(confirmError, "Не удалось подтвердить получение"));
|
||||
return false;
|
||||
} finally {
|
||||
setBusyKey(null);
|
||||
}
|
||||
}, [refreshAfterMutation]);
|
||||
|
||||
const handleCancelItemReceived = useCallback(async (requestId: number, itemId: number) => {
|
||||
try {
|
||||
setBusyKey(`item-cancel-received-${itemId}`);
|
||||
setActionError(null);
|
||||
await apiClient.cancelProcurementItemReceived(itemId);
|
||||
setActionSuccess("Получение позиции отменено.");
|
||||
await refreshAfterMutation(requestId);
|
||||
return true;
|
||||
} catch (cancelError) {
|
||||
setActionError(getReadableError(cancelError, "Не удалось отменить получение"));
|
||||
return false;
|
||||
} finally {
|
||||
setBusyKey(null);
|
||||
}
|
||||
}, [refreshAfterMutation]);
|
||||
|
||||
const handleCancel = useCallback((id: number, reason = "") => {
|
||||
return doAction(`cancel-${id}`, () => apiClient.cancelProcurementRequest(id, reason), id, "Заявка отменена.");
|
||||
}, [doAction]);
|
||||
@ -1035,6 +1104,9 @@ export function useProcurementPage(user: User | null) {
|
||||
handleMarkAllReceived,
|
||||
handleUpdateItem,
|
||||
handleReportItemIssue,
|
||||
handleCancelItemIssue,
|
||||
handleConfirmItemReceived,
|
||||
handleCancelItemReceived,
|
||||
handleCancel,
|
||||
handleDelete,
|
||||
handleAddComment,
|
||||
|
||||
@ -31,6 +31,9 @@ export function createProcurementApi(request: RequestFn) {
|
||||
updateProcurementItem: (id: number, data: Record<string, any>) => request(`/api/v1/procurement/items/${id}/`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }),
|
||||
reportProcurementItemIssue: (id: number, text?: string) =>
|
||||
request(`/api/v1/procurement/items/${id}/report_issue/`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: text || '' }) }),
|
||||
cancelProcurementItemIssue: (id: number) => request(`/api/v1/procurement/items/${id}/cancel_issue/`, { method: 'POST' }),
|
||||
confirmProcurementItemReceived: (id: number) => request(`/api/v1/procurement/items/${id}/confirm_received/`, { method: 'POST' }),
|
||||
cancelProcurementItemReceived: (id: number) => request(`/api/v1/procurement/items/${id}/cancel_received/`, { method: 'POST' }),
|
||||
deleteProcurementItem: (id: number): Promise<void> => request(`/api/v1/procurement/items/${id}/`, { method: 'DELETE' }),
|
||||
getProcurementItemComments: (itemId: number) => request(`/api/v1/procurement/items/${itemId}/comments/`),
|
||||
addProcurementItemComment: (itemId: number, text: string) =>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user