ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: source-side принятие и возврат внешнего запроса

This commit is contained in:
DCCONSTRUCTIONS 2026-04-19 00:29:23 +03:00
parent 3fe3539614
commit 8195c3fc80
17 changed files with 366 additions and 23 deletions

View File

@ -147,11 +147,15 @@
- в карточке отображается блок маршрутизации с ключевой source-target связью - в карточке отображается блок маршрутизации с ключевой source-target связью
- текущий статус берется из фактического state целевой задачи - текущий статус берется из фактического state целевой задачи
- если у инициатора нет membership в target project, карточка переключается в source-side readonly режим без прямого открытия чужого проекта - если у инициатора нет membership в target project, карточка переключается в source-side readonly режим без прямого открытия чужого проекта
- для закрытого внешнего запроса доступны source-side действия `Принять` и `Отклонить`
- `Принять` фиксирует решение источника в bridge metadata и помечает запрос как принятый во внутренний контур
- `Отклонить` возвращает target issue в default-state целевого проекта и переносит source-side карточку обратно в список `Открытые`
Что остается: Что остается:
- зеркалирование комментариев - зеркалирование комментариев
- зеркалирование файлов - зеркалирование файлов
- зеркалирование activity stream и обновлений описания - зеркалирование activity stream и обновлений описания
- комментарий причины отклонения и ответ обратно во внешний контур
## Этап 4. Уведомления ## Этап 4. Уведомления

View File

@ -55,6 +55,7 @@ from .intake import (
) )
from .external_contours import ( from .external_contours import (
ExternalContourRequestCreateSerializer, ExternalContourRequestCreateSerializer,
ExternalContourRequestDecisionSerializer,
ExternalContourRequestSerializer, ExternalContourRequestSerializer,
ExternalContourTargetOptionsSerializer, ExternalContourTargetOptionsSerializer,
ExternalContourTargetProjectSerializer, ExternalContourTargetProjectSerializer,

View File

@ -27,6 +27,10 @@ class ExternalContourRequestCreateSerializer(serializers.Serializer):
issue = ExternalContourIssuePayloadSerializer() issue = ExternalContourIssuePayloadSerializer()
class ExternalContourRequestDecisionSerializer(serializers.Serializer):
action = serializers.ChoiceField(choices=["accept", "decline"])
class ExternalContourTargetProjectSerializer(BaseSerializer): class ExternalContourTargetProjectSerializer(BaseSerializer):
inbox_view = serializers.BooleanField(read_only=True, source="intake_view") inbox_view = serializers.BooleanField(read_only=True, source="intake_view")
@ -95,6 +99,9 @@ class ExternalContourRequestSerializer(BaseSerializer):
issue = ExternalContourIssueSerializer(read_only=True) issue = ExternalContourIssueSerializer(read_only=True)
source_project_id = serializers.SerializerMethodField() source_project_id = serializers.SerializerMethodField()
source_project_name = serializers.SerializerMethodField() source_project_name = serializers.SerializerMethodField()
source_decision = serializers.SerializerMethodField()
source_decision_at = serializers.SerializerMethodField()
source_decision_by_name = serializers.SerializerMethodField()
target_project_id = serializers.SerializerMethodField() target_project_id = serializers.SerializerMethodField()
target_project_name = serializers.SerializerMethodField() target_project_name = serializers.SerializerMethodField()
requested_by_id = serializers.SerializerMethodField() requested_by_id = serializers.SerializerMethodField()
@ -112,6 +119,9 @@ class ExternalContourRequestSerializer(BaseSerializer):
"issue", "issue",
"source_project_id", "source_project_id",
"source_project_name", "source_project_name",
"source_decision",
"source_decision_at",
"source_decision_by_name",
"target_project_id", "target_project_id",
"target_project_name", "target_project_name",
"requested_by_id", "requested_by_id",
@ -127,6 +137,15 @@ class ExternalContourRequestSerializer(BaseSerializer):
def get_source_project_name(self, obj): def get_source_project_name(self, obj):
return obj.extra.get("source_project_name") return obj.extra.get("source_project_name")
def get_source_decision(self, obj):
return obj.extra.get("source_decision")
def get_source_decision_at(self, obj):
return obj.extra.get("source_decision_at")
def get_source_decision_by_name(self, obj):
return obj.extra.get("source_decision_by_name")
def get_target_project_id(self, obj): def get_target_project_id(self, obj):
target_project_id = obj.extra.get("target_project_id") target_project_id = obj.extra.get("target_project_id")
if target_project_id: if target_project_id:

View File

@ -6,6 +6,7 @@ from django.urls import path
from plane.api.views import ( from plane.api.views import (
ExternalContourDetailAPIEndpoint, ExternalContourDetailAPIEndpoint,
ExternalContourDecisionAPIEndpoint,
ExternalContourListCreateAPIEndpoint, ExternalContourListCreateAPIEndpoint,
ExternalContourTargetOptionsAPIEndpoint, ExternalContourTargetOptionsAPIEndpoint,
ExternalContourTargetProjectListAPIEndpoint, ExternalContourTargetProjectListAPIEndpoint,
@ -32,4 +33,9 @@ urlpatterns = [
ExternalContourDetailAPIEndpoint.as_view(http_method_names=["get"]), ExternalContourDetailAPIEndpoint.as_view(http_method_names=["get"]),
name="external-contour-detail", name="external-contour-detail",
), ),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/external-contours/<uuid:request_id>/decision/",
ExternalContourDecisionAPIEndpoint.as_view(http_method_names=["post"]),
name="external-contour-decision",
),
] ]

View File

@ -56,10 +56,11 @@ from .intake import (
IntakeIssueDetailAPIEndpoint, IntakeIssueDetailAPIEndpoint,
) )
from .external_contours import ( from .external_contours import (
ExternalContourListCreateAPIEndpoint, ExternalContourListCreateEndpoint as ExternalContourListCreateAPIEndpoint,
ExternalContourDetailAPIEndpoint, ExternalContourDetailEndpoint as ExternalContourDetailAPIEndpoint,
ExternalContourTargetProjectListAPIEndpoint, ExternalContourDecisionEndpoint as ExternalContourDecisionAPIEndpoint,
ExternalContourTargetOptionsAPIEndpoint, ExternalContourTargetProjectListEndpoint as ExternalContourTargetProjectListAPIEndpoint,
ExternalContourTargetOptionsEndpoint as ExternalContourTargetOptionsAPIEndpoint,
) )
from .asset import UserAssetEndpoint, UserServerAssetEndpoint, GenericAssetEndpoint from .asset import UserAssetEndpoint, UserServerAssetEndpoint, GenericAssetEndpoint

View File

@ -3,29 +3,40 @@
# See the LICENSE file for details. # See the LICENSE file for details.
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.utils import timezone
from rest_framework import status from rest_framework import status
from rest_framework.response import Response from rest_framework.response import Response
from plane.api.serializers import ( from plane.api.serializers import (
ExternalContourRequestCreateSerializer, ExternalContourRequestCreateSerializer,
ExternalContourRequestDecisionSerializer,
ExternalContourRequestSerializer, ExternalContourRequestSerializer,
ExternalContourTargetOptionsSerializer, ExternalContourTargetOptionsSerializer,
ExternalContourTargetProjectSerializer, ExternalContourTargetProjectSerializer,
) )
from plane.app.permissions import ProjectLitePermission
from plane.db.models import Intake, IntakeIssue, Label, Project, ProjectMember, State, StateGroup
from plane.api.serializers.issue import IssueSerializer as IssueCreateSerializer from plane.api.serializers.issue import IssueSerializer as IssueCreateSerializer
from plane.app.permissions import ProjectLitePermission
from .base import BaseAPIView from .base import BaseAPIView
from plane.db.models import Intake, IntakeIssue, Label, Project, ProjectMember, State, StateGroup
from plane.db.models.intake import IntakeIssueStatus, SourceType from plane.db.models.intake import IntakeIssueStatus, SourceType
class ExternalContourListCreateAPIEndpoint(BaseAPIView): class ExternalContourListCreateEndpoint(BaseAPIView):
permission_classes = [ProjectLitePermission] permission_classes = [ProjectLitePermission]
serializer_class = ExternalContourRequestSerializer serializer_class = ExternalContourRequestSerializer
def get_source_project(self, slug, project_id): def get_source_project(self, slug, project_id):
return get_object_or_404(Project, workspace__slug=slug, pk=project_id) return get_object_or_404(Project, workspace__slug=slug, pk=project_id)
def get_default_target_state(self, target_project):
return (
State.objects.filter(project=target_project, default=True)
.exclude(group=StateGroup.TRIAGE.value)
.first()
) or State.objects.filter(project=target_project).exclude(group=StateGroup.TRIAGE.value).order_by(
"sequence", "created_at"
).first()
def get_queryset(self): def get_queryset(self):
return ( return (
IntakeIssue.objects.filter( IntakeIssue.objects.filter(
@ -93,11 +104,7 @@ class ExternalContourListCreateAPIEndpoint(BaseAPIView):
default=False, default=False,
) )
target_default_state = ( target_default_state = self.get_default_target_state(target_project)
State.objects.filter(project=target_project, default=True)
.exclude(group=StateGroup.TRIAGE.value)
.first()
) or State.objects.filter(project=target_project).exclude(group=StateGroup.TRIAGE.value).order_by("sequence", "created_at").first()
if not target_default_state: if not target_default_state:
return Response({"error": "Target project has no available workflow state"}, status=status.HTTP_400_BAD_REQUEST) return Response({"error": "Target project has no available workflow state"}, status=status.HTTP_400_BAD_REQUEST)
@ -166,7 +173,7 @@ class ExternalContourListCreateAPIEndpoint(BaseAPIView):
return Response(response_serializer.data, status=status.HTTP_201_CREATED) return Response(response_serializer.data, status=status.HTTP_201_CREATED)
class ExternalContourTargetProjectListAPIEndpoint(BaseAPIView): class ExternalContourTargetProjectListEndpoint(BaseAPIView):
permission_classes = [ProjectLitePermission] permission_classes = [ProjectLitePermission]
serializer_class = ExternalContourTargetProjectSerializer serializer_class = ExternalContourTargetProjectSerializer
@ -190,7 +197,7 @@ class ExternalContourTargetProjectListAPIEndpoint(BaseAPIView):
return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.data, status=status.HTTP_200_OK)
class ExternalContourTargetOptionsAPIEndpoint(BaseAPIView): class ExternalContourTargetOptionsEndpoint(BaseAPIView):
permission_classes = [ProjectLitePermission] permission_classes = [ProjectLitePermission]
serializer_class = ExternalContourTargetOptionsSerializer serializer_class = ExternalContourTargetOptionsSerializer
@ -239,7 +246,7 @@ class ExternalContourTargetOptionsAPIEndpoint(BaseAPIView):
return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.data, status=status.HTTP_200_OK)
class ExternalContourDetailAPIEndpoint(BaseAPIView): class ExternalContourDetailEndpoint(BaseAPIView):
permission_classes = [ProjectLitePermission] permission_classes = [ProjectLitePermission]
serializer_class = ExternalContourRequestSerializer serializer_class = ExternalContourRequestSerializer
@ -264,3 +271,84 @@ class ExternalContourDetailAPIEndpoint(BaseAPIView):
contour_request = get_object_or_404(self.get_queryset()) contour_request = get_object_or_404(self.get_queryset())
serializer = ExternalContourRequestSerializer(contour_request) serializer = ExternalContourRequestSerializer(contour_request)
return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.data, status=status.HTTP_200_OK)
class ExternalContourDecisionEndpoint(BaseAPIView):
permission_classes = [ProjectLitePermission]
serializer_class = ExternalContourRequestSerializer
def get_queryset(self):
return (
IntakeIssue.objects.filter(
workspace__slug=self.kwargs.get("slug"),
extra__bridge="external-contours",
extra__source_project_id=str(self.kwargs.get("project_id")),
pk=self.kwargs.get("request_id"),
)
.select_related(
"issue",
"issue__state",
"issue__project",
"issue__created_by",
)
.prefetch_related("issue__issue_assignee__assignee", "issue__label_issue__label")
)
def post(self, request, slug, project_id, request_id):
contour_request = get_object_or_404(self.get_queryset())
serializer = ExternalContourRequestDecisionSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
action = serializer.validated_data["action"]
issue = contour_request.issue
if not issue or not issue.state or issue.state.group not in [StateGroup.COMPLETED.value, StateGroup.CANCELLED.value]:
return Response(
{"error": "Source decision is available only after the target contour finishes processing"},
status=status.HTTP_400_BAD_REQUEST,
)
if action == "accept":
contour_request.extra = {
**contour_request.extra,
"source_decision": "accepted",
"source_decision_at": timezone.now().isoformat(),
"source_decision_by_name": request.user.display_name,
}
contour_request.save(update_fields=["extra", "updated_at"])
else:
target_default_state = (
State.objects.filter(project=issue.project, default=True)
.exclude(group=StateGroup.TRIAGE.value)
.first()
) or State.objects.filter(project=issue.project).exclude(group=StateGroup.TRIAGE.value).order_by(
"sequence", "created_at"
).first()
if not target_default_state:
return Response({"error": "Target project has no available workflow state"}, status=status.HTTP_400_BAD_REQUEST)
issue.state = target_default_state
issue.save(update_fields=["state", "updated_at"])
extra = dict(contour_request.extra or {})
extra.pop("source_decision", None)
extra.pop("source_decision_at", None)
extra.pop("source_decision_by_name", None)
extra["last_reopened_at"] = issue.updated_at.isoformat() if issue.updated_at else None
extra["last_reopened_by_name"] = request.user.display_name
contour_request.extra = extra
contour_request.save(update_fields=["extra", "updated_at"])
contour_request.refresh_from_db()
serializer = ExternalContourRequestSerializer(
IntakeIssue.objects.select_related(
"issue",
"issue__state",
"issue__project",
"issue__created_by",
)
.prefetch_related("issue__issue_assignee__assignee", "issue__label_issue__label")
.get(pk=contour_request.id)
)
return Response(serializer.data, status=status.HTTP_200_OK)

View File

@ -6,6 +6,7 @@ from django.urls import path
from plane.app.views import ( from plane.app.views import (
ExternalContourDetailEndpoint, ExternalContourDetailEndpoint,
ExternalContourDecisionEndpoint,
ExternalContourListCreateEndpoint, ExternalContourListCreateEndpoint,
ExternalContourTargetOptionsEndpoint, ExternalContourTargetOptionsEndpoint,
ExternalContourTargetProjectListEndpoint, ExternalContourTargetProjectListEndpoint,
@ -33,4 +34,9 @@ urlpatterns = [
ExternalContourDetailEndpoint.as_view(http_method_names=["get"]), ExternalContourDetailEndpoint.as_view(http_method_names=["get"]),
name="external-contour-detail", name="external-contour-detail",
), ),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/external-contours/<uuid:request_id>/decision/",
ExternalContourDecisionEndpoint.as_view(http_method_names=["post"]),
name="external-contour-decision",
),
] ]

View File

@ -227,6 +227,7 @@ from .exporter.base import ExportIssuesEndpoint
from .external_contours import ( from .external_contours import (
ExternalContourListCreateEndpoint, ExternalContourListCreateEndpoint,
ExternalContourDetailEndpoint, ExternalContourDetailEndpoint,
ExternalContourDecisionEndpoint,
ExternalContourTargetProjectListEndpoint, ExternalContourTargetProjectListEndpoint,
ExternalContourTargetOptionsEndpoint, ExternalContourTargetOptionsEndpoint,
) )

View File

@ -3,11 +3,13 @@
# See the LICENSE file for details. # See the LICENSE file for details.
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.utils import timezone
from rest_framework import status from rest_framework import status
from rest_framework.response import Response from rest_framework.response import Response
from plane.api.serializers import ( from plane.api.serializers import (
ExternalContourRequestCreateSerializer, ExternalContourRequestCreateSerializer,
ExternalContourRequestDecisionSerializer,
ExternalContourRequestSerializer, ExternalContourRequestSerializer,
ExternalContourTargetOptionsSerializer, ExternalContourTargetOptionsSerializer,
ExternalContourTargetProjectSerializer, ExternalContourTargetProjectSerializer,
@ -26,6 +28,15 @@ class ExternalContourListCreateEndpoint(BaseAPIView):
def get_source_project(self, slug, project_id): def get_source_project(self, slug, project_id):
return get_object_or_404(Project, workspace__slug=slug, pk=project_id) return get_object_or_404(Project, workspace__slug=slug, pk=project_id)
def get_default_target_state(self, target_project):
return (
State.objects.filter(project=target_project, default=True)
.exclude(group=StateGroup.TRIAGE.value)
.first()
) or State.objects.filter(project=target_project).exclude(group=StateGroup.TRIAGE.value).order_by(
"sequence", "created_at"
).first()
def get_queryset(self): def get_queryset(self):
return ( return (
IntakeIssue.objects.filter( IntakeIssue.objects.filter(
@ -93,11 +104,7 @@ class ExternalContourListCreateEndpoint(BaseAPIView):
default=False, default=False,
) )
target_default_state = ( target_default_state = self.get_default_target_state(target_project)
State.objects.filter(project=target_project, default=True)
.exclude(group=StateGroup.TRIAGE.value)
.first()
) or State.objects.filter(project=target_project).exclude(group=StateGroup.TRIAGE.value).order_by("sequence", "created_at").first()
if not target_default_state: if not target_default_state:
return Response({"error": "Target project has no available workflow state"}, status=status.HTTP_400_BAD_REQUEST) return Response({"error": "Target project has no available workflow state"}, status=status.HTTP_400_BAD_REQUEST)
@ -264,3 +271,84 @@ class ExternalContourDetailEndpoint(BaseAPIView):
contour_request = get_object_or_404(self.get_queryset()) contour_request = get_object_or_404(self.get_queryset())
serializer = ExternalContourRequestSerializer(contour_request) serializer = ExternalContourRequestSerializer(contour_request)
return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.data, status=status.HTTP_200_OK)
class ExternalContourDecisionEndpoint(BaseAPIView):
permission_classes = [ProjectLitePermission]
serializer_class = ExternalContourRequestSerializer
def get_queryset(self):
return (
IntakeIssue.objects.filter(
workspace__slug=self.kwargs.get("slug"),
extra__bridge="external-contours",
extra__source_project_id=str(self.kwargs.get("project_id")),
pk=self.kwargs.get("request_id"),
)
.select_related(
"issue",
"issue__state",
"issue__project",
"issue__created_by",
)
.prefetch_related("issue__issue_assignee__assignee", "issue__label_issue__label")
)
def post(self, request, slug, project_id, request_id):
contour_request = get_object_or_404(self.get_queryset())
serializer = ExternalContourRequestDecisionSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
action = serializer.validated_data["action"]
issue = contour_request.issue
if not issue or not issue.state or issue.state.group not in [StateGroup.COMPLETED.value, StateGroup.CANCELLED.value]:
return Response(
{"error": "Source decision is available only after the target contour finishes processing"},
status=status.HTTP_400_BAD_REQUEST,
)
if action == "accept":
contour_request.extra = {
**contour_request.extra,
"source_decision": "accepted",
"source_decision_at": timezone.now().isoformat(),
"source_decision_by_name": request.user.display_name,
}
contour_request.save(update_fields=["extra", "updated_at"])
else:
target_default_state = (
State.objects.filter(project=issue.project, default=True)
.exclude(group=StateGroup.TRIAGE.value)
.first()
) or State.objects.filter(project=issue.project).exclude(group=StateGroup.TRIAGE.value).order_by(
"sequence", "created_at"
).first()
if not target_default_state:
return Response({"error": "Target project has no available workflow state"}, status=status.HTTP_400_BAD_REQUEST)
issue.state = target_default_state
issue.save(update_fields=["state", "updated_at"])
extra = dict(contour_request.extra or {})
extra.pop("source_decision", None)
extra.pop("source_decision_at", None)
extra.pop("source_decision_by_name", None)
extra["last_reopened_at"] = issue.updated_at.isoformat() if issue.updated_at else None
extra["last_reopened_by_name"] = request.user.display_name
contour_request.extra = extra
contour_request.save(update_fields=["extra", "updated_at"])
contour_request.refresh_from_db()
serializer = ExternalContourRequestSerializer(
IntakeIssue.objects.select_related(
"issue",
"issue__state",
"issue__project",
"issue__created_by",
)
.prefetch_related("issue__issue_assignee__assignee", "issue__label_issue__label")
.get(pk=contour_request.id)
)
return Response(serializer.data, status=status.HTTP_200_OK)

View File

@ -8,11 +8,13 @@ import { useCallback, useEffect } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { PanelLeft } from "lucide-react"; import { PanelLeft } from "lucide-react";
import { useTranslation } from "@plane/i18n"; import { useTranslation } from "@plane/i18n";
import { Badge } from "@plane/propel/badge";
import { Button } from "@plane/propel/button"; import { Button } from "@plane/propel/button";
import { IconButton } from "@plane/propel/icon-button"; import { IconButton } from "@plane/propel/icon-button";
import { ChevronDownIcon, ChevronUpIcon, LinkIcon, NewTabIcon } from "@plane/propel/icons"; import { CheckCircleFilledIcon, ChevronDownIcon, ChevronUpIcon, CloseCircleFilledIcon, LinkIcon, NewTabIcon } from "@plane/propel/icons";
import { TOAST_TYPE, setToast } from "@plane/propel/toast"; import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { TExternalContourRequest, TNameDescriptionLoader } from "@plane/types"; import type { TExternalContourRequest, TNameDescriptionLoader } from "@plane/types";
import { EInboxIssueCurrentTab } from "@plane/types";
import { ControlLink, Header, Row } from "@plane/ui"; import { ControlLink, Header, Row } from "@plane/ui";
import { copyUrlToClipboard, generateWorkItemLink } from "@plane/utils"; import { copyUrlToClipboard, generateWorkItemLink } from "@plane/utils";
import { NameDescriptionUpdateStatus } from "@/components/issues/issue-update-status"; import { NameDescriptionUpdateStatus } from "@/components/issues/issue-update-status";
@ -43,11 +45,13 @@ export const ExternalContoursIssueActionsHeader = observer(function ExternalCont
} = props; } = props;
const { t } = useTranslation(); const { t } = useTranslation();
const router = useAppRouter(); const router = useAppRouter();
const { currentTab, filteredRequestIds } = useProjectExternalContours(); const { currentTab, decideRequest, filteredRequestIds, handleCurrentTab } = useProjectExternalContours();
const { getProjectById } = useProject(); const { getProjectById } = useProject();
const issue = contourRequest.issue; const issue = contourRequest.issue;
const currentRequestId = contourRequest.id; const currentRequestId = contourRequest.id;
const canReviewClosedRequest = contourRequest.status === "closed" && contourRequest.source_decision !== "accepted";
const isSourceAccepted = contourRequest.source_decision === "accepted";
const redirectToRelativeIssue = useCallback( const redirectToRelativeIssue = useCallback(
(direction: "next" | "prev") => { (direction: "next" | "prev") => {
@ -92,6 +96,33 @@ export const ExternalContoursIssueActionsHeader = observer(function ExternalCont
}) })
); );
const handleDecision = async (action: "accept" | "decline") => {
try {
await decideRequest(workspaceSlug, sourceProjectId, contourRequest.id, action);
if (action === "decline") {
await handleCurrentTab(workspaceSlug, sourceProjectId, EInboxIssueCurrentTab.OPEN);
router.push(
`/${workspaceSlug}/projects/${sourceProjectId}/external-contours?currentTab=${EInboxIssueCurrentTab.OPEN}&inboxIssueId=${contourRequest.id}`
);
}
setToast({
type: TOAST_TYPE.SUCCESS,
title: t("success"),
message: t(
action === "accept"
? "external_contours_page.actions.accept_success"
: "external_contours_page.actions.decline_success"
),
});
} catch (error: any) {
setToast({
type: TOAST_TYPE.ERROR,
title: t("error"),
message: error?.error || t("external_contours_page.actions.decision_error"),
});
}
};
return ( return (
<> <>
<Row className="relative z-15 hidden h-full w-full items-center justify-between gap-2 border-b border-subtle bg-surface-1 lg:flex"> <Row className="relative z-15 hidden h-full w-full items-center justify-between gap-2 border-b border-subtle bg-surface-1 lg:flex">
@ -114,6 +145,23 @@ export const ExternalContoursIssueActionsHeader = observer(function ExternalCont
</div> </div>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
{canReviewClosedRequest && (
<>
<Button variant="secondary" size="lg" onClick={() => handleDecision("accept")}>
<CheckCircleFilledIcon className="size-4 shrink-0 text-success-secondary" />
{t("external_contours_page.actions.accept")}
</Button>
<Button variant="secondary" size="lg" onClick={() => handleDecision("decline")}>
<CloseCircleFilledIcon className="size-4 shrink-0 text-danger-secondary" />
{t("external_contours_page.actions.decline")}
</Button>
</>
)}
{isSourceAccepted && (
<Badge variant="neutral">{t("external_contours_page.traceability.source_decision_accepted")}</Badge>
)}
<Button variant="secondary" size="lg" prependIcon={<LinkIcon className="h-2.5 w-2.5" />} onClick={handleCopyLink}> <Button variant="secondary" size="lg" prependIcon={<LinkIcon className="h-2.5 w-2.5" />} onClick={handleCopyLink}>
{t("external_contours_page.actions.copy")} {t("external_contours_page.actions.copy")}
</Button> </Button>
@ -136,6 +184,19 @@ export const ExternalContoursIssueActionsHeader = observer(function ExternalCont
<div className="flex w-full items-center gap-2"> <div className="flex w-full items-center gap-2">
<ExternalContourStatePill request={contourRequest} /> <ExternalContourStatePill request={contourRequest} />
<div className="ml-auto flex items-center gap-2"> <div className="ml-auto flex items-center gap-2">
{canReviewClosedRequest && (
<>
<Button variant="secondary" size="sm" onClick={() => handleDecision("accept")}>
{t("external_contours_page.actions.accept")}
</Button>
<Button variant="secondary" size="sm" onClick={() => handleDecision("decline")}>
{t("external_contours_page.actions.decline")}
</Button>
</>
)}
{isSourceAccepted && (
<Badge variant="neutral">{t("external_contours_page.traceability.source_decision_accepted")}</Badge>
)}
{hasDirectTargetAccess && ( {hasDirectTargetAccess && (
<ControlLink href={workItemLink} onClick={() => router.push(workItemLink)} target="_self"> <ControlLink href={workItemLink} onClick={() => router.push(workItemLink)} target="_self">
<Button variant="secondary" size="sm"> <Button variant="secondary" size="sm">

View File

@ -25,6 +25,10 @@ export const ExternalContoursRequestTraceability = observer(function ExternalCon
const requestedAt = contourRequest.requested_at || contourRequest.created_at; const requestedAt = contourRequest.requested_at || contourRequest.created_at;
const lastUpdatedAt = issue.updated_at || contourRequest.updated_at; const lastUpdatedAt = issue.updated_at || contourRequest.updated_at;
const targetProjectName = contourRequest.target_project_name || issue.project_detail?.name || t("common.none"); const targetProjectName = contourRequest.target_project_name || issue.project_detail?.name || t("common.none");
const sourceDecision =
contourRequest.source_decision === "accepted"
? t("external_contours_page.traceability.source_decision_accepted")
: t("external_contours_page.traceability.source_decision_pending");
const targetIssueKey = const targetIssueKey =
issue.project_detail?.identifier && issue.sequence_id issue.project_detail?.identifier && issue.sequence_id
? `${issue.project_detail.identifier}-${issue.sequence_id}` ? `${issue.project_detail.identifier}-${issue.sequence_id}`
@ -61,6 +65,11 @@ export const ExternalContoursRequestTraceability = observer(function ExternalCon
</div> </div>
</div> </div>
<div className="rounded-md border border-subtle bg-surface-1 p-3">
<div className="text-11 font-medium text-tertiary">{t("external_contours_page.traceability.source_decision")}</div>
<div className="mt-1 text-13 font-medium text-secondary">{sourceDecision}</div>
</div>
<div className="rounded-md border border-subtle bg-surface-1 p-3"> <div className="rounded-md border border-subtle bg-surface-1 p-3">
<div className="text-11 font-medium text-tertiary">{t("external_contours_page.traceability.requested_by")}</div> <div className="text-11 font-medium text-tertiary">{t("external_contours_page.traceability.requested_by")}</div>
<div className="mt-1 flex items-center gap-2"> <div className="mt-1 flex items-center gap-2">

View File

@ -44,7 +44,7 @@ export const ExternalContoursRoot = observer(function ExternalContoursRoot(props
fetchRequests(workspaceSlug.toString(), projectId.toString(), navigationTab || EInboxIssueCurrentTab.OPEN); fetchRequests(workspaceSlug.toString(), projectId.toString(), navigationTab || EInboxIssueCurrentTab.OPEN);
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [workspaceSlug, projectId]); }, [workspaceSlug, projectId, navigationTab]);
if (loader === "init-loading") { if (loader === "init-loading") {
return ( return (

View File

@ -76,4 +76,19 @@ export class ExternalContourService extends APIService {
throw error?.response?.data; throw error?.response?.data;
}); });
} }
async decide(
workspaceSlug: string,
projectId: string,
requestId: string,
action: "accept" | "decline"
): Promise<TExternalContourRequest> {
return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/external-contours/${requestId}/decision/`, {
action,
})
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
} }

View File

@ -34,6 +34,12 @@ export interface IProjectExternalContoursStore {
projectId: string, projectId: string,
data: Partial<TIssue> & { target_project_id?: string | null } data: Partial<TIssue> & { target_project_id?: string | null }
) => Promise<TExternalContourRequest | undefined>; ) => Promise<TExternalContourRequest | undefined>;
decideRequest: (
workspaceSlug: string,
projectId: string,
requestId: string,
action: "accept" | "decline"
) => Promise<TExternalContourRequest | undefined>;
fetchTargetProjects: (workspaceSlug: string, projectId: string) => Promise<void>; fetchTargetProjects: (workspaceSlug: string, projectId: string) => Promise<void>;
fetchTargetOptions: (workspaceSlug: string, projectId: string, targetProjectId: string) => Promise<TExternalContourTargetOptions | undefined>; fetchTargetOptions: (workspaceSlug: string, projectId: string, targetProjectId: string) => Promise<TExternalContourTargetOptions | undefined>;
fetchRequestById: (workspaceSlug: string, projectId: string, requestId: string) => Promise<TExternalContourRequest | undefined>; fetchRequestById: (workspaceSlug: string, projectId: string, requestId: string) => Promise<TExternalContourRequest | undefined>;
@ -82,6 +88,7 @@ export class ProjectExternalContoursStore implements IProjectExternalContoursSto
fetchRequests: action, fetchRequests: action,
fetchRequestById: action, fetchRequestById: action,
createRequest: action, createRequest: action,
decideRequest: action,
handleCurrentTab: action, handleCurrentTab: action,
upsertRequests: action, upsertRequests: action,
updateRequestIssue: action, updateRequestIssue: action,
@ -220,6 +227,28 @@ export class ProjectExternalContoursStore implements IProjectExternalContoursSto
} }
}; };
decideRequest = async (
workspaceSlug: string,
projectId: string,
requestId: string,
action: "accept" | "decline"
) => {
this.loader = "mutation-loading";
try {
const request = await this.externalContourService.decide(workspaceSlug, projectId, requestId, action);
runInAction(() => {
this.upsertRequests([request]);
this.loader = undefined;
});
return request;
} catch (error) {
runInAction(() => {
this.loader = undefined;
});
throw error;
}
};
updateRequestIssue = (requestId: string, issueData: Partial<TExternalContourRequest["issue"]>) => { updateRequestIssue = (requestId: string, issueData: Partial<TExternalContourRequest["issue"]>) => {
if (!this.requests[requestId]) return; if (!this.requests[requestId]) return;
const nextStatus = const nextStatus =

View File

@ -342,6 +342,9 @@ export default {
description: description:
"This block shows which contour sent the request, where it was routed, and which linked work item now carries the execution.", "This block shows which contour sent the request, where it was routed, and which linked work item now carries the execution.",
source_contour: "Source internal contour", source_contour: "Source internal contour",
source_decision: "Source-side decision",
source_decision_pending: "Awaiting decision",
source_decision_accepted: "Accepted into the internal contour",
target_contour: "Target external contour", target_contour: "Target external contour",
status: "Current status", status: "Current status",
requested_by: "Requested by", requested_by: "Requested by",
@ -353,6 +356,9 @@ export default {
send: "Send", send: "Send",
accept: "Accept", accept: "Accept",
decline: "Decline", decline: "Decline",
accept_success: "The external contour result has been accepted.",
decline_success: "The request has been returned to the external contour for rework.",
decision_error: "The external contour action could not be completed.",
copy: "Copy link", copy: "Copy link",
open: "Open request", open: "Open request",
unsupported_title: "This action will be connected in the next stage", unsupported_title: "This action will be connected in the next stage",

View File

@ -498,6 +498,9 @@ export default {
title: "Маршрутизация", title: "Маршрутизация",
description: "Здесь отображается, из какого контура ушёл запрос, куда он направлен и с каким связанным элементом он сейчас работает.", description: "Здесь отображается, из какого контура ушёл запрос, куда он направлен и с каким связанным элементом он сейчас работает.",
source_contour: "Исходный внутренний контур", source_contour: "Исходный внутренний контур",
source_decision: "Решение источника",
source_decision_pending: "Ожидает решения",
source_decision_accepted: "Принято во внутренний контур",
target_contour: "Целевой внешний контур", target_contour: "Целевой внешний контур",
status: "Текущий статус", status: "Текущий статус",
requested_by: "Отправитель", requested_by: "Отправитель",
@ -509,6 +512,9 @@ export default {
send: "Отправить", send: "Отправить",
accept: "Принять", accept: "Принять",
decline: "Отклонить", decline: "Отклонить",
accept_success: "Результат внешнего контура принят.",
decline_success: "Запрос возвращён во внешний контур на доработку.",
decision_error: "Не удалось выполнить действие по внешнему запросу.",
copy: "Копировать ссылку", copy: "Копировать ссылку",
open: "Открыть запрос", open: "Открыть запрос",
unsupported_title: "Действие будет подключено следующим этапом", unsupported_title: "Действие будет подключено следующим этапом",

View File

@ -24,6 +24,9 @@ export type TExternalContourRequest = {
created_by: string | null; created_by: string | null;
id: string; id: string;
issue: TExternalContourIssue; issue: TExternalContourIssue;
source_decision?: "accepted" | null;
source_decision_at?: string | null;
source_decision_by_name?: string | null;
source_project_id: string; source_project_id: string;
source_project_name?: string | null; source_project_name?: string | null;
target_project_id?: string | null; target_project_id?: string | null;