mirror of
https://github.com/makeplane/plane
synced 2024-06-14 14:31:34 +00:00
[WEB-522] chore: enabled estimate point analytics for module and cycle (#4763)
* chore: updated modal and form validations * chore: module estimate analytics * chore: state analytics * chore: cycle estimate analytics * chore: module points serializer * chore: added fields in serializer * chore: module state estimate points * dev: updated module analytics * dev: updated hover description on the burndown * dev: UI and module total percentage validation * chore: estimate points structure change * chore: module burndown * chore: key values changed * chore: cycle progress snapshot * chore: cycle detail endpoint * chore: progress snapshot payload change * chore: resolved merge conflicts * chore: updated issue and point dropdown in active cycle * chore: optimized grouped issues count in cycle and module --------- Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
This commit is contained in:
parent
8071350640
commit
61d8586f7f
@ -222,9 +222,13 @@ class ModuleSerializer(DynamicBaseSerializer):
|
|||||||
class ModuleDetailSerializer(ModuleSerializer):
|
class ModuleDetailSerializer(ModuleSerializer):
|
||||||
link_module = ModuleLinkSerializer(read_only=True, many=True)
|
link_module = ModuleLinkSerializer(read_only=True, many=True)
|
||||||
sub_issues = serializers.IntegerField(read_only=True)
|
sub_issues = serializers.IntegerField(read_only=True)
|
||||||
|
backlog_estimate_points = serializers.IntegerField(read_only=True)
|
||||||
|
unstarted_estimate_points = serializers.IntegerField(read_only=True)
|
||||||
|
started_estimate_points = serializers.IntegerField(read_only=True)
|
||||||
|
cancelled_estimate_points = serializers.IntegerField(read_only=True)
|
||||||
|
|
||||||
class Meta(ModuleSerializer.Meta):
|
class Meta(ModuleSerializer.Meta):
|
||||||
fields = ModuleSerializer.Meta.fields + ["link_module", "sub_issues"]
|
fields = ModuleSerializer.Meta.fields + ["link_module", "sub_issues", "backlog_estimate_points", "unstarted_estimate_points", "started_estimate_points", "cancelled_estimate_points"]
|
||||||
|
|
||||||
|
|
||||||
class ModuleUserPropertiesSerializer(BaseSerializer):
|
class ModuleUserPropertiesSerializer(BaseSerializer):
|
||||||
|
@ -46,6 +46,7 @@ from plane.db.models import (
|
|||||||
Issue,
|
Issue,
|
||||||
Label,
|
Label,
|
||||||
User,
|
User,
|
||||||
|
Project,
|
||||||
)
|
)
|
||||||
from plane.utils.analytics_plot import burndown_plot
|
from plane.utils.analytics_plot import burndown_plot
|
||||||
|
|
||||||
@ -325,7 +326,6 @@ class CycleViewSet(BaseViewSet):
|
|||||||
|
|
||||||
def list(self, request, slug, project_id):
|
def list(self, request, slug, project_id):
|
||||||
queryset = self.get_queryset().filter(archived_at__isnull=True)
|
queryset = self.get_queryset().filter(archived_at__isnull=True)
|
||||||
plot_type = request.GET.get("plot_type", "issues")
|
|
||||||
cycle_view = request.GET.get("cycle_view", "all")
|
cycle_view = request.GET.get("cycle_view", "all")
|
||||||
|
|
||||||
# Update the order by
|
# Update the order by
|
||||||
@ -373,8 +373,108 @@ class CycleViewSet(BaseViewSet):
|
|||||||
"status",
|
"status",
|
||||||
"created_by",
|
"created_by",
|
||||||
)
|
)
|
||||||
|
estimate_type = Project.objects.filter(
|
||||||
|
workspace__slug=slug,
|
||||||
|
pk=project_id,
|
||||||
|
estimate__isnull=False,
|
||||||
|
estimate__type="points",
|
||||||
|
).exists()
|
||||||
|
|
||||||
if data:
|
if data:
|
||||||
|
data[0]["estimate_distribution"] = {}
|
||||||
|
if estimate_type:
|
||||||
|
assignee_distribution = (
|
||||||
|
Issue.objects.filter(
|
||||||
|
issue_cycle__cycle_id=data[0]["id"],
|
||||||
|
workspace__slug=slug,
|
||||||
|
project_id=project_id,
|
||||||
|
)
|
||||||
|
.annotate(display_name=F("assignees__display_name"))
|
||||||
|
.annotate(assignee_id=F("assignees__id"))
|
||||||
|
.annotate(avatar=F("assignees__avatar"))
|
||||||
|
.values("display_name", "assignee_id", "avatar")
|
||||||
|
.annotate(
|
||||||
|
total_estimates=Sum(
|
||||||
|
Cast("estimate_point__value", IntegerField())
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
completed_estimates=Sum(
|
||||||
|
Cast("estimate_point__value", IntegerField()),
|
||||||
|
filter=Q(
|
||||||
|
completed_at__isnull=False,
|
||||||
|
archived_at__isnull=True,
|
||||||
|
is_draft=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
pending_estimates=Sum(
|
||||||
|
Cast("estimate_point__value", IntegerField()),
|
||||||
|
filter=Q(
|
||||||
|
completed_at__isnull=True,
|
||||||
|
archived_at__isnull=True,
|
||||||
|
is_draft=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by("display_name")
|
||||||
|
)
|
||||||
|
|
||||||
|
label_distribution = (
|
||||||
|
Issue.objects.filter(
|
||||||
|
issue_cycle__cycle_id=data[0]["id"],
|
||||||
|
workspace__slug=slug,
|
||||||
|
project_id=project_id,
|
||||||
|
)
|
||||||
|
.annotate(label_name=F("labels__name"))
|
||||||
|
.annotate(color=F("labels__color"))
|
||||||
|
.annotate(label_id=F("labels__id"))
|
||||||
|
.values("label_name", "color", "label_id")
|
||||||
|
.annotate(
|
||||||
|
total_estimates=Sum(
|
||||||
|
Cast("estimate_point__value", IntegerField())
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
completed_estimates=Sum(
|
||||||
|
Cast("estimate_point__value", IntegerField()),
|
||||||
|
filter=Q(
|
||||||
|
completed_at__isnull=False,
|
||||||
|
archived_at__isnull=True,
|
||||||
|
is_draft=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
pending_estimates=Sum(
|
||||||
|
Cast("estimate_point__value", IntegerField()),
|
||||||
|
filter=Q(
|
||||||
|
completed_at__isnull=True,
|
||||||
|
archived_at__isnull=True,
|
||||||
|
is_draft=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by("label_name")
|
||||||
|
)
|
||||||
|
data[0]["estimate_distribution"] = {
|
||||||
|
"assignees": assignee_distribution,
|
||||||
|
"labels": label_distribution,
|
||||||
|
"completion_chart": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
if data[0]["start_date"] and data[0]["end_date"]:
|
||||||
|
data[0]["estimate_distribution"]["completion_chart"] = (
|
||||||
|
burndown_plot(
|
||||||
|
queryset=queryset.first(),
|
||||||
|
slug=slug,
|
||||||
|
project_id=project_id,
|
||||||
|
plot_type="points",
|
||||||
|
cycle_id=data[0]["id"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
assignee_distribution = (
|
assignee_distribution = (
|
||||||
Issue.objects.filter(
|
Issue.objects.filter(
|
||||||
issue_cycle__cycle_id=data[0]["id"],
|
issue_cycle__cycle_id=data[0]["id"],
|
||||||
@ -388,7 +488,10 @@ class CycleViewSet(BaseViewSet):
|
|||||||
.annotate(
|
.annotate(
|
||||||
total_issues=Count(
|
total_issues=Count(
|
||||||
"id",
|
"id",
|
||||||
filter=Q(archived_at__isnull=True, is_draft=False),
|
filter=Q(
|
||||||
|
archived_at__isnull=True,
|
||||||
|
is_draft=False,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.annotate(
|
.annotate(
|
||||||
@ -427,8 +530,11 @@ class CycleViewSet(BaseViewSet):
|
|||||||
.annotate(
|
.annotate(
|
||||||
total_issues=Count(
|
total_issues=Count(
|
||||||
"id",
|
"id",
|
||||||
filter=Q(archived_at__isnull=True, is_draft=False),
|
filter=Q(
|
||||||
)
|
archived_at__isnull=True,
|
||||||
|
is_draft=False,
|
||||||
|
),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
.annotate(
|
.annotate(
|
||||||
completed_issues=Count(
|
completed_issues=Count(
|
||||||
@ -464,7 +570,7 @@ class CycleViewSet(BaseViewSet):
|
|||||||
queryset=queryset.first(),
|
queryset=queryset.first(),
|
||||||
slug=slug,
|
slug=slug,
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
plot_type=plot_type,
|
plot_type="issues",
|
||||||
cycle_id=data[0]["id"],
|
cycle_id=data[0]["id"],
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@ -659,7 +765,6 @@ class CycleViewSet(BaseViewSet):
|
|||||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
def retrieve(self, request, slug, project_id, pk):
|
def retrieve(self, request, slug, project_id, pk):
|
||||||
plot_type = request.GET.get("plot_type", "issues")
|
|
||||||
queryset = (
|
queryset = (
|
||||||
self.get_queryset().filter(archived_at__isnull=True).filter(pk=pk)
|
self.get_queryset().filter(archived_at__isnull=True).filter(pk=pk)
|
||||||
)
|
)
|
||||||
@ -710,12 +815,107 @@ class CycleViewSet(BaseViewSet):
|
|||||||
)
|
)
|
||||||
queryset = queryset.first()
|
queryset = queryset.first()
|
||||||
|
|
||||||
if data is None:
|
estimate_type = Project.objects.filter(
|
||||||
return Response(
|
workspace__slug=slug,
|
||||||
{"error": "Cycle does not exist"},
|
pk=project_id,
|
||||||
status=status.HTTP_400_BAD_REQUEST,
|
estimate__isnull=False,
|
||||||
|
estimate__type="points",
|
||||||
|
).exists()
|
||||||
|
|
||||||
|
data["estimate_distribution"] = {}
|
||||||
|
if estimate_type:
|
||||||
|
assignee_distribution = (
|
||||||
|
Issue.objects.filter(
|
||||||
|
issue_cycle__cycle_id=pk,
|
||||||
|
workspace__slug=slug,
|
||||||
|
project_id=project_id,
|
||||||
|
)
|
||||||
|
.annotate(display_name=F("assignees__display_name"))
|
||||||
|
.annotate(assignee_id=F("assignees__id"))
|
||||||
|
.annotate(avatar=F("assignees__avatar"))
|
||||||
|
.values("display_name", "assignee_id", "avatar")
|
||||||
|
.annotate(
|
||||||
|
total_estimates=Sum(
|
||||||
|
Cast("estimate_point__value", IntegerField())
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
completed_estimates=Sum(
|
||||||
|
Cast("estimate_point__value", IntegerField()),
|
||||||
|
filter=Q(
|
||||||
|
completed_at__isnull=False,
|
||||||
|
archived_at__isnull=True,
|
||||||
|
is_draft=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
pending_estimates=Sum(
|
||||||
|
Cast("estimate_point__value", IntegerField()),
|
||||||
|
filter=Q(
|
||||||
|
completed_at__isnull=True,
|
||||||
|
archived_at__isnull=True,
|
||||||
|
is_draft=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by("display_name")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
label_distribution = (
|
||||||
|
Issue.objects.filter(
|
||||||
|
issue_cycle__cycle_id=pk,
|
||||||
|
workspace__slug=slug,
|
||||||
|
project_id=project_id,
|
||||||
|
)
|
||||||
|
.annotate(label_name=F("labels__name"))
|
||||||
|
.annotate(color=F("labels__color"))
|
||||||
|
.annotate(label_id=F("labels__id"))
|
||||||
|
.values("label_name", "color", "label_id")
|
||||||
|
.annotate(
|
||||||
|
total_estimates=Sum(
|
||||||
|
Cast("estimate_point__value", IntegerField())
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
completed_estimates=Sum(
|
||||||
|
Cast("estimate_point__value", IntegerField()),
|
||||||
|
filter=Q(
|
||||||
|
completed_at__isnull=False,
|
||||||
|
archived_at__isnull=True,
|
||||||
|
is_draft=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
pending_estimates=Sum(
|
||||||
|
Cast("estimate_point__value", IntegerField()),
|
||||||
|
filter=Q(
|
||||||
|
completed_at__isnull=True,
|
||||||
|
archived_at__isnull=True,
|
||||||
|
is_draft=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by("label_name")
|
||||||
|
)
|
||||||
|
data["estimate_distribution"] = {
|
||||||
|
"assignees": assignee_distribution,
|
||||||
|
"labels": label_distribution,
|
||||||
|
"completion_chart": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
if data["start_date"] and data["end_date"]:
|
||||||
|
data["estimate_distribution"]["completion_chart"] = (
|
||||||
|
burndown_plot(
|
||||||
|
queryset=queryset,
|
||||||
|
slug=slug,
|
||||||
|
project_id=project_id,
|
||||||
|
plot_type="points",
|
||||||
|
cycle_id=pk,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
# Assignee Distribution
|
# Assignee Distribution
|
||||||
assignee_distribution = (
|
assignee_distribution = (
|
||||||
Issue.objects.filter(
|
Issue.objects.filter(
|
||||||
@ -738,7 +938,10 @@ class CycleViewSet(BaseViewSet):
|
|||||||
.annotate(
|
.annotate(
|
||||||
total_issues=Count(
|
total_issues=Count(
|
||||||
"id",
|
"id",
|
||||||
filter=Q(archived_at__isnull=True, is_draft=False),
|
filter=Q(
|
||||||
|
archived_at__isnull=True,
|
||||||
|
is_draft=False,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.annotate(
|
.annotate(
|
||||||
@ -778,7 +981,10 @@ class CycleViewSet(BaseViewSet):
|
|||||||
.annotate(
|
.annotate(
|
||||||
total_issues=Count(
|
total_issues=Count(
|
||||||
"id",
|
"id",
|
||||||
filter=Q(archived_at__isnull=True, is_draft=False),
|
filter=Q(
|
||||||
|
archived_at__isnull=True,
|
||||||
|
is_draft=False,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.annotate(
|
.annotate(
|
||||||
@ -815,7 +1021,7 @@ class CycleViewSet(BaseViewSet):
|
|||||||
queryset=queryset,
|
queryset=queryset,
|
||||||
slug=slug,
|
slug=slug,
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
plot_type=plot_type,
|
plot_type="issues",
|
||||||
cycle_id=pk,
|
cycle_id=pk,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -932,7 +1138,6 @@ class TransferCycleIssueEndpoint(BaseAPIView):
|
|||||||
|
|
||||||
def post(self, request, slug, project_id, cycle_id):
|
def post(self, request, slug, project_id, cycle_id):
|
||||||
new_cycle_id = request.data.get("new_cycle_id", False)
|
new_cycle_id = request.data.get("new_cycle_id", False)
|
||||||
plot_type = request.GET.get("plot_type", "issues")
|
|
||||||
|
|
||||||
if not new_cycle_id:
|
if not new_cycle_id:
|
||||||
return Response(
|
return Response(
|
||||||
@ -1009,14 +1214,127 @@ class TransferCycleIssueEndpoint(BaseAPIView):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Pass the new_cycle queryset to burndown_plot
|
estimate_type = Project.objects.filter(
|
||||||
completion_chart = burndown_plot(
|
workspace__slug=slug,
|
||||||
queryset=old_cycle.first(),
|
pk=project_id,
|
||||||
slug=slug,
|
estimate__isnull=False,
|
||||||
project_id=project_id,
|
estimate__type="points",
|
||||||
plot_type=plot_type,
|
).exists()
|
||||||
cycle_id=cycle_id,
|
|
||||||
)
|
if estimate_type:
|
||||||
|
assignee_estimate_data = (
|
||||||
|
Issue.objects.filter(
|
||||||
|
issue_cycle__cycle_id=cycle_id,
|
||||||
|
workspace__slug=slug,
|
||||||
|
project_id=project_id,
|
||||||
|
)
|
||||||
|
.annotate(display_name=F("assignees__display_name"))
|
||||||
|
.annotate(assignee_id=F("assignees__id"))
|
||||||
|
.annotate(avatar=F("assignees__avatar"))
|
||||||
|
.values("display_name", "assignee_id", "avatar")
|
||||||
|
.annotate(
|
||||||
|
total_estimates=Sum(
|
||||||
|
Cast("estimate_point__value", IntegerField())
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
completed_estimates=Sum(
|
||||||
|
Cast("estimate_point__value", IntegerField()),
|
||||||
|
filter=Q(
|
||||||
|
completed_at__isnull=False,
|
||||||
|
archived_at__isnull=True,
|
||||||
|
is_draft=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
pending_estimates=Sum(
|
||||||
|
Cast("estimate_point__value", IntegerField()),
|
||||||
|
filter=Q(
|
||||||
|
completed_at__isnull=True,
|
||||||
|
archived_at__isnull=True,
|
||||||
|
is_draft=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by("display_name")
|
||||||
|
)
|
||||||
|
# assignee distribution serialization
|
||||||
|
assignee_estimate_distribution = [
|
||||||
|
{
|
||||||
|
"display_name": item["display_name"],
|
||||||
|
"assignee_id": (
|
||||||
|
str(item["assignee_id"])
|
||||||
|
if item["assignee_id"]
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"avatar": item["avatar"],
|
||||||
|
"total_estimates": item["total_estimates"],
|
||||||
|
"completed_estimates": item["completed_estimates"],
|
||||||
|
"pending_estimates": item["pending_estimates"],
|
||||||
|
}
|
||||||
|
for item in assignee_estimate_data
|
||||||
|
]
|
||||||
|
|
||||||
|
label_distribution_data = (
|
||||||
|
Issue.objects.filter(
|
||||||
|
issue_cycle__cycle_id=cycle_id,
|
||||||
|
workspace__slug=slug,
|
||||||
|
project_id=project_id,
|
||||||
|
)
|
||||||
|
.annotate(label_name=F("labels__name"))
|
||||||
|
.annotate(color=F("labels__color"))
|
||||||
|
.annotate(label_id=F("labels__id"))
|
||||||
|
.values("label_name", "color", "label_id")
|
||||||
|
.annotate(
|
||||||
|
total_estimates=Sum(
|
||||||
|
Cast("estimate_point__value", IntegerField())
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
completed_estimates=Sum(
|
||||||
|
Cast("estimate_point__value", IntegerField()),
|
||||||
|
filter=Q(
|
||||||
|
completed_at__isnull=False,
|
||||||
|
archived_at__isnull=True,
|
||||||
|
is_draft=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
pending_estimates=Sum(
|
||||||
|
Cast("estimate_point__value", IntegerField()),
|
||||||
|
filter=Q(
|
||||||
|
completed_at__isnull=True,
|
||||||
|
archived_at__isnull=True,
|
||||||
|
is_draft=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by("label_name")
|
||||||
|
)
|
||||||
|
|
||||||
|
estimate_completion_chart = burndown_plot(
|
||||||
|
queryset=old_cycle.first(),
|
||||||
|
slug=slug,
|
||||||
|
project_id=project_id,
|
||||||
|
plot_type="points",
|
||||||
|
cycle_id=cycle_id,
|
||||||
|
)
|
||||||
|
# Label distribution serialization
|
||||||
|
label_estimate_distribution = [
|
||||||
|
{
|
||||||
|
"label_name": item["label_name"],
|
||||||
|
"color": item["color"],
|
||||||
|
"label_id": (
|
||||||
|
str(item["label_id"]) if item["label_id"] else None
|
||||||
|
),
|
||||||
|
"total_estimates": item["total_estimates"],
|
||||||
|
"completed_estimates": item["completed_estimates"],
|
||||||
|
"pending_estimates": item["pending_estimates"],
|
||||||
|
}
|
||||||
|
for item in label_distribution_data
|
||||||
|
]
|
||||||
|
|
||||||
# Get the assignee distribution
|
# Get the assignee distribution
|
||||||
assignee_distribution = (
|
assignee_distribution = (
|
||||||
@ -1032,7 +1350,10 @@ class TransferCycleIssueEndpoint(BaseAPIView):
|
|||||||
.annotate(
|
.annotate(
|
||||||
total_issues=Count(
|
total_issues=Count(
|
||||||
"id",
|
"id",
|
||||||
filter=Q(archived_at__isnull=True, is_draft=False),
|
filter=Q(
|
||||||
|
archived_at__isnull=True,
|
||||||
|
is_draft=False,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.annotate(
|
.annotate(
|
||||||
@ -1086,8 +1407,11 @@ class TransferCycleIssueEndpoint(BaseAPIView):
|
|||||||
.annotate(
|
.annotate(
|
||||||
total_issues=Count(
|
total_issues=Count(
|
||||||
"id",
|
"id",
|
||||||
filter=Q(archived_at__isnull=True, is_draft=False),
|
filter=Q(
|
||||||
)
|
archived_at__isnull=True,
|
||||||
|
is_draft=False,
|
||||||
|
),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
.annotate(
|
.annotate(
|
||||||
completed_issues=Count(
|
completed_issues=Count(
|
||||||
@ -1112,20 +1436,6 @@ class TransferCycleIssueEndpoint(BaseAPIView):
|
|||||||
.order_by("label_name")
|
.order_by("label_name")
|
||||||
)
|
)
|
||||||
|
|
||||||
assignee_distribution_data = [
|
|
||||||
{
|
|
||||||
"display_name": item["display_name"],
|
|
||||||
"assignee_id": (
|
|
||||||
str(item["assignee_id"]) if item["assignee_id"] else None
|
|
||||||
),
|
|
||||||
"avatar": item["avatar"],
|
|
||||||
"total_issues": item["total_issues"],
|
|
||||||
"completed_issues": item["completed_issues"],
|
|
||||||
"pending_issues": item["pending_issues"],
|
|
||||||
}
|
|
||||||
for item in assignee_distribution
|
|
||||||
]
|
|
||||||
|
|
||||||
# Label distribution serilization
|
# Label distribution serilization
|
||||||
label_distribution_data = [
|
label_distribution_data = [
|
||||||
{
|
{
|
||||||
@ -1141,6 +1451,15 @@ class TransferCycleIssueEndpoint(BaseAPIView):
|
|||||||
for item in label_distribution
|
for item in label_distribution
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Pass the new_cycle queryset to burndown_plot
|
||||||
|
completion_chart = burndown_plot(
|
||||||
|
queryset=old_cycle.first(),
|
||||||
|
slug=slug,
|
||||||
|
project_id=project_id,
|
||||||
|
plot_type="issues",
|
||||||
|
cycle_id=cycle_id,
|
||||||
|
)
|
||||||
|
|
||||||
current_cycle = Cycle.objects.filter(
|
current_cycle = Cycle.objects.filter(
|
||||||
workspace__slug=slug, project_id=project_id, pk=cycle_id
|
workspace__slug=slug, project_id=project_id, pk=cycle_id
|
||||||
).first()
|
).first()
|
||||||
@ -1157,6 +1476,15 @@ class TransferCycleIssueEndpoint(BaseAPIView):
|
|||||||
"assignees": assignee_distribution_data,
|
"assignees": assignee_distribution_data,
|
||||||
"completion_chart": completion_chart,
|
"completion_chart": completion_chart,
|
||||||
},
|
},
|
||||||
|
"estimate_distribution": (
|
||||||
|
{}
|
||||||
|
if not estimate_type
|
||||||
|
else {
|
||||||
|
"labels": label_estimate_distribution,
|
||||||
|
"assignees": assignee_estimate_distribution,
|
||||||
|
"completion_chart": estimate_completion_chart,
|
||||||
|
}
|
||||||
|
),
|
||||||
}
|
}
|
||||||
current_cycle.save(update_fields=["progress_snapshot"])
|
current_cycle.save(update_fields=["progress_snapshot"])
|
||||||
|
|
||||||
|
@ -12,8 +12,9 @@ from django.db.models import (
|
|||||||
Subquery,
|
Subquery,
|
||||||
UUIDField,
|
UUIDField,
|
||||||
Value,
|
Value,
|
||||||
|
Sum
|
||||||
)
|
)
|
||||||
from django.db.models.functions import Coalesce
|
from django.db.models.functions import Coalesce, Cast
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
# Third party imports
|
# Third party imports
|
||||||
@ -25,7 +26,7 @@ from plane.app.permissions import (
|
|||||||
from plane.app.serializers import (
|
from plane.app.serializers import (
|
||||||
ModuleDetailSerializer,
|
ModuleDetailSerializer,
|
||||||
)
|
)
|
||||||
from plane.db.models import Issue, Module, ModuleLink, UserFavorite
|
from plane.db.models import Issue, Module, ModuleLink, UserFavorite, Project
|
||||||
from plane.utils.analytics_plot import burndown_plot
|
from plane.utils.analytics_plot import burndown_plot
|
||||||
from plane.utils.user_timezone_converter import user_timezone_converter
|
from plane.utils.user_timezone_converter import user_timezone_converter
|
||||||
|
|
||||||
@ -165,7 +166,6 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def get(self, request, slug, project_id, pk=None):
|
def get(self, request, slug, project_id, pk=None):
|
||||||
plot_type = request.GET.get("plot_type", "issues")
|
|
||||||
if pk is None:
|
if pk is None:
|
||||||
queryset = self.get_queryset()
|
queryset = self.get_queryset()
|
||||||
modules = queryset.values( # Required fields
|
modules = queryset.values( # Required fields
|
||||||
@ -218,6 +218,116 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
|
|||||||
.values("count")
|
.values("count")
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
estimate_type = Project.objects.filter(
|
||||||
|
workspace__slug=slug,
|
||||||
|
pk=project_id,
|
||||||
|
estimate__isnull=False,
|
||||||
|
estimate__type="points",
|
||||||
|
).exists()
|
||||||
|
|
||||||
|
data = ModuleDetailSerializer(queryset.first()).data
|
||||||
|
modules = queryset.first()
|
||||||
|
|
||||||
|
data["estimate_distribution"] = {}
|
||||||
|
|
||||||
|
if estimate_type:
|
||||||
|
label_distribution = (
|
||||||
|
Issue.objects.filter(
|
||||||
|
issue_module__module_id=pk,
|
||||||
|
workspace__slug=slug,
|
||||||
|
project_id=project_id,
|
||||||
|
)
|
||||||
|
.annotate(first_name=F("assignees__first_name"))
|
||||||
|
.annotate(last_name=F("assignees__last_name"))
|
||||||
|
.annotate(assignee_id=F("assignees__id"))
|
||||||
|
.annotate(display_name=F("assignees__display_name"))
|
||||||
|
.annotate(avatar=F("assignees__avatar"))
|
||||||
|
.values(
|
||||||
|
"first_name",
|
||||||
|
"last_name",
|
||||||
|
"assignee_id",
|
||||||
|
"avatar",
|
||||||
|
"display_name",
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
total_estimates=Sum(
|
||||||
|
Cast("estimate_point__value", IntegerField())
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
completed_estimates=Sum(
|
||||||
|
Cast("estimate_point__value", IntegerField()),
|
||||||
|
filter=Q(
|
||||||
|
completed_at__isnull=False,
|
||||||
|
archived_at__isnull=True,
|
||||||
|
is_draft=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
pending_estimates=Sum(
|
||||||
|
Cast("estimate_point__value", IntegerField()),
|
||||||
|
filter=Q(
|
||||||
|
completed_at__isnull=True,
|
||||||
|
archived_at__isnull=True,
|
||||||
|
is_draft=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by("first_name", "last_name")
|
||||||
|
)
|
||||||
|
|
||||||
|
assignee_distribution = (
|
||||||
|
Issue.objects.filter(
|
||||||
|
issue_module__module_id=pk,
|
||||||
|
workspace__slug=slug,
|
||||||
|
project_id=project_id,
|
||||||
|
)
|
||||||
|
.annotate(label_name=F("labels__name"))
|
||||||
|
.annotate(color=F("labels__color"))
|
||||||
|
.annotate(label_id=F("labels__id"))
|
||||||
|
.values("label_name", "color", "label_id")
|
||||||
|
.annotate(
|
||||||
|
total_estimates=Sum(
|
||||||
|
Cast("estimate_point__value", IntegerField())
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
completed_estimates=Sum(
|
||||||
|
Cast("estimate_point__value", IntegerField()),
|
||||||
|
filter=Q(
|
||||||
|
completed_at__isnull=False,
|
||||||
|
archived_at__isnull=True,
|
||||||
|
is_draft=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
pending_estimates=Sum(
|
||||||
|
Cast("estimate_point__value", IntegerField()),
|
||||||
|
filter=Q(
|
||||||
|
completed_at__isnull=True,
|
||||||
|
archived_at__isnull=True,
|
||||||
|
is_draft=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by("label_name")
|
||||||
|
)
|
||||||
|
data["estimate_distribution"]["assignee"] = assignee_distribution
|
||||||
|
data["estimate_distribution"]["label"] = label_distribution
|
||||||
|
|
||||||
|
if modules and modules.start_date and modules.target_date:
|
||||||
|
data["estimate_distribution"]["completion_chart"] = (
|
||||||
|
burndown_plot(
|
||||||
|
queryset=modules,
|
||||||
|
slug=slug,
|
||||||
|
project_id=project_id,
|
||||||
|
plot_type="points",
|
||||||
|
module_id=pk,
|
||||||
|
)
|
||||||
|
)
|
||||||
assignee_distribution = (
|
assignee_distribution = (
|
||||||
Issue.objects.filter(
|
Issue.objects.filter(
|
||||||
issue_module__module_id=pk,
|
issue_module__module_id=pk,
|
||||||
@ -310,7 +420,6 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
|
|||||||
.order_by("label_name")
|
.order_by("label_name")
|
||||||
)
|
)
|
||||||
|
|
||||||
data = ModuleDetailSerializer(queryset.first()).data
|
|
||||||
data["distribution"] = {
|
data["distribution"] = {
|
||||||
"assignees": assignee_distribution,
|
"assignees": assignee_distribution,
|
||||||
"labels": label_distribution,
|
"labels": label_distribution,
|
||||||
@ -318,13 +427,12 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Fetch the modules
|
# Fetch the modules
|
||||||
modules = queryset.first()
|
|
||||||
if modules and modules.start_date and modules.target_date:
|
if modules and modules.start_date and modules.target_date:
|
||||||
data["distribution"]["completion_chart"] = burndown_plot(
|
data["distribution"]["completion_chart"] = burndown_plot(
|
||||||
queryset=modules,
|
queryset=modules,
|
||||||
slug=slug,
|
slug=slug,
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
plot_type=plot_type,
|
plot_type="issues",
|
||||||
module_id=pk,
|
module_id=pk,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -157,6 +157,62 @@ class ModuleViewSet(BaseViewSet):
|
|||||||
)
|
)
|
||||||
.values("total_estimate_points")[:1]
|
.values("total_estimate_points")[:1]
|
||||||
)
|
)
|
||||||
|
backlog_estimate_point = (
|
||||||
|
Issue.issue_objects.filter(
|
||||||
|
estimate_point__estimate__type="points",
|
||||||
|
state__group="backlog",
|
||||||
|
issue_module__module_id=OuterRef("pk"),
|
||||||
|
)
|
||||||
|
.values("issue_module__module_id")
|
||||||
|
.annotate(
|
||||||
|
backlog_estimate_point=Sum(
|
||||||
|
Cast("estimate_point__value", IntegerField())
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.values("backlog_estimate_point")[:1]
|
||||||
|
)
|
||||||
|
unstarted_estimate_point = (
|
||||||
|
Issue.issue_objects.filter(
|
||||||
|
estimate_point__estimate__type="points",
|
||||||
|
state__group="unstarted",
|
||||||
|
issue_module__module_id=OuterRef("pk"),
|
||||||
|
)
|
||||||
|
.values("issue_module__module_id")
|
||||||
|
.annotate(
|
||||||
|
unstarted_estimate_point=Sum(
|
||||||
|
Cast("estimate_point__value", IntegerField())
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.values("unstarted_estimate_point")[:1]
|
||||||
|
)
|
||||||
|
started_estimate_point = (
|
||||||
|
Issue.issue_objects.filter(
|
||||||
|
estimate_point__estimate__type="points",
|
||||||
|
state__group="started",
|
||||||
|
issue_module__module_id=OuterRef("pk"),
|
||||||
|
)
|
||||||
|
.values("issue_module__module_id")
|
||||||
|
.annotate(
|
||||||
|
started_estimate_point=Sum(
|
||||||
|
Cast("estimate_point__value", IntegerField())
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.values("started_estimate_point")[:1]
|
||||||
|
)
|
||||||
|
cancelled_estimate_point = (
|
||||||
|
Issue.issue_objects.filter(
|
||||||
|
estimate_point__estimate__type="points",
|
||||||
|
state__group="cancelled",
|
||||||
|
issue_module__module_id=OuterRef("pk"),
|
||||||
|
)
|
||||||
|
.values("issue_module__module_id")
|
||||||
|
.annotate(
|
||||||
|
cancelled_estimate_point=Sum(
|
||||||
|
Cast("estimate_point__value", IntegerField())
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.values("cancelled_estimate_point")[:1]
|
||||||
|
)
|
||||||
return (
|
return (
|
||||||
super()
|
super()
|
||||||
.get_queryset()
|
.get_queryset()
|
||||||
@ -211,6 +267,30 @@ class ModuleViewSet(BaseViewSet):
|
|||||||
Value(0, output_field=IntegerField()),
|
Value(0, output_field=IntegerField()),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
.annotate(
|
||||||
|
backlog_estimate_points=Coalesce(
|
||||||
|
Subquery(backlog_estimate_point),
|
||||||
|
Value(0, output_field=IntegerField()),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
unstarted_estimate_points=Coalesce(
|
||||||
|
Subquery(unstarted_estimate_point),
|
||||||
|
Value(0, output_field=IntegerField()),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
started_estimate_points=Coalesce(
|
||||||
|
Subquery(started_estimate_point),
|
||||||
|
Value(0, output_field=IntegerField()),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
cancelled_estimate_points=Coalesce(
|
||||||
|
Subquery(cancelled_estimate_point),
|
||||||
|
Value(0, output_field=IntegerField()),
|
||||||
|
),
|
||||||
|
)
|
||||||
.annotate(
|
.annotate(
|
||||||
completed_estimate_points=Coalesce(
|
completed_estimate_points=Coalesce(
|
||||||
Subquery(completed_estimate_point),
|
Subquery(completed_estimate_point),
|
||||||
@ -346,7 +426,6 @@ class ModuleViewSet(BaseViewSet):
|
|||||||
return Response(modules, status=status.HTTP_200_OK)
|
return Response(modules, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
def retrieve(self, request, slug, project_id, pk):
|
def retrieve(self, request, slug, project_id, pk):
|
||||||
plot_type = request.GET.get("plot_type", "burndown")
|
|
||||||
queryset = (
|
queryset = (
|
||||||
self.get_queryset()
|
self.get_queryset()
|
||||||
.filter(archived_at__isnull=True)
|
.filter(archived_at__isnull=True)
|
||||||
@ -363,6 +442,116 @@ class ModuleViewSet(BaseViewSet):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
estimate_type = Project.objects.filter(
|
||||||
|
workspace__slug=slug,
|
||||||
|
pk=project_id,
|
||||||
|
estimate__isnull=False,
|
||||||
|
estimate__type="points",
|
||||||
|
).exists()
|
||||||
|
|
||||||
|
data = ModuleDetailSerializer(queryset.first()).data
|
||||||
|
modules = queryset.first()
|
||||||
|
|
||||||
|
data["estimate_distribution"] = {}
|
||||||
|
|
||||||
|
if estimate_type:
|
||||||
|
assignee_distribution = (
|
||||||
|
Issue.objects.filter(
|
||||||
|
issue_module__module_id=pk,
|
||||||
|
workspace__slug=slug,
|
||||||
|
project_id=project_id,
|
||||||
|
)
|
||||||
|
.annotate(first_name=F("assignees__first_name"))
|
||||||
|
.annotate(last_name=F("assignees__last_name"))
|
||||||
|
.annotate(assignee_id=F("assignees__id"))
|
||||||
|
.annotate(display_name=F("assignees__display_name"))
|
||||||
|
.annotate(avatar=F("assignees__avatar"))
|
||||||
|
.values(
|
||||||
|
"first_name",
|
||||||
|
"last_name",
|
||||||
|
"assignee_id",
|
||||||
|
"avatar",
|
||||||
|
"display_name",
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
total_estimates=Sum(
|
||||||
|
Cast("estimate_point__value", IntegerField())
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
completed_estimates=Sum(
|
||||||
|
Cast("estimate_point__value", IntegerField()),
|
||||||
|
filter=Q(
|
||||||
|
completed_at__isnull=False,
|
||||||
|
archived_at__isnull=True,
|
||||||
|
is_draft=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
pending_estimates=Sum(
|
||||||
|
Cast("estimate_point__value", IntegerField()),
|
||||||
|
filter=Q(
|
||||||
|
completed_at__isnull=True,
|
||||||
|
archived_at__isnull=True,
|
||||||
|
is_draft=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by("first_name", "last_name")
|
||||||
|
)
|
||||||
|
|
||||||
|
label_distribution = (
|
||||||
|
Issue.objects.filter(
|
||||||
|
issue_module__module_id=pk,
|
||||||
|
workspace__slug=slug,
|
||||||
|
project_id=project_id,
|
||||||
|
)
|
||||||
|
.annotate(label_name=F("labels__name"))
|
||||||
|
.annotate(color=F("labels__color"))
|
||||||
|
.annotate(label_id=F("labels__id"))
|
||||||
|
.values("label_name", "color", "label_id")
|
||||||
|
.annotate(
|
||||||
|
total_estimates=Sum(
|
||||||
|
Cast("estimate_point__value", IntegerField())
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
completed_estimates=Sum(
|
||||||
|
Cast("estimate_point__value", IntegerField()),
|
||||||
|
filter=Q(
|
||||||
|
completed_at__isnull=False,
|
||||||
|
archived_at__isnull=True,
|
||||||
|
is_draft=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
pending_estimates=Sum(
|
||||||
|
Cast("estimate_point__value", IntegerField()),
|
||||||
|
filter=Q(
|
||||||
|
completed_at__isnull=True,
|
||||||
|
archived_at__isnull=True,
|
||||||
|
is_draft=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by("label_name")
|
||||||
|
)
|
||||||
|
data["estimate_distribution"]["assignees"] = assignee_distribution
|
||||||
|
data["estimate_distribution"]["labels"] = label_distribution
|
||||||
|
|
||||||
|
if modules and modules.start_date and modules.target_date:
|
||||||
|
data["estimate_distribution"]["completion_chart"] = (
|
||||||
|
burndown_plot(
|
||||||
|
queryset=modules,
|
||||||
|
slug=slug,
|
||||||
|
project_id=project_id,
|
||||||
|
plot_type="points",
|
||||||
|
module_id=pk,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
assignee_distribution = (
|
assignee_distribution = (
|
||||||
Issue.objects.filter(
|
Issue.objects.filter(
|
||||||
issue_module__module_id=pk,
|
issue_module__module_id=pk,
|
||||||
@ -388,7 +577,7 @@ class ModuleViewSet(BaseViewSet):
|
|||||||
archived_at__isnull=True,
|
archived_at__isnull=True,
|
||||||
is_draft=False,
|
is_draft=False,
|
||||||
),
|
),
|
||||||
)
|
),
|
||||||
)
|
)
|
||||||
.annotate(
|
.annotate(
|
||||||
completed_issues=Count(
|
completed_issues=Count(
|
||||||
@ -455,21 +644,17 @@ class ModuleViewSet(BaseViewSet):
|
|||||||
.order_by("label_name")
|
.order_by("label_name")
|
||||||
)
|
)
|
||||||
|
|
||||||
data = ModuleDetailSerializer(queryset.first()).data
|
|
||||||
data["distribution"] = {
|
data["distribution"] = {
|
||||||
"assignees": assignee_distribution,
|
"assignees": assignee_distribution,
|
||||||
"labels": label_distribution,
|
"labels": label_distribution,
|
||||||
"completion_chart": {},
|
"completion_chart": {},
|
||||||
}
|
}
|
||||||
|
|
||||||
# Fetch the modules
|
|
||||||
modules = queryset.first()
|
|
||||||
if modules and modules.start_date and modules.target_date:
|
if modules and modules.start_date and modules.target_date:
|
||||||
data["distribution"]["completion_chart"] = burndown_plot(
|
data["distribution"]["completion_chart"] = burndown_plot(
|
||||||
queryset=modules,
|
queryset=modules,
|
||||||
slug=slug,
|
slug=slug,
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
plot_type=plot_type,
|
plot_type="issues",
|
||||||
module_id=pk,
|
module_id=pk,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -430,17 +430,14 @@ class IssueVotePublicViewSet(BaseViewSet):
|
|||||||
return IssueVote.objects.none()
|
return IssueVote.objects.none()
|
||||||
|
|
||||||
def create(self, request, anchor, issue_id):
|
def create(self, request, anchor, issue_id):
|
||||||
print("hite")
|
|
||||||
project_deploy_board = DeployBoard.objects.get(
|
project_deploy_board = DeployBoard.objects.get(
|
||||||
anchor=anchor, entity_name="project"
|
anchor=anchor, entity_name="project"
|
||||||
)
|
)
|
||||||
print("awer")
|
|
||||||
issue_vote, _ = IssueVote.objects.get_or_create(
|
issue_vote, _ = IssueVote.objects.get_or_create(
|
||||||
actor_id=request.user.id,
|
actor_id=request.user.id,
|
||||||
project_id=project_deploy_board.project_id,
|
project_id=project_deploy_board.project_id,
|
||||||
issue_id=issue_id,
|
issue_id=issue_id,
|
||||||
)
|
)
|
||||||
print("AWer")
|
|
||||||
# Add the user for workspace tracking
|
# Add the user for workspace tracking
|
||||||
if not ProjectMember.objects.filter(
|
if not ProjectMember.objects.filter(
|
||||||
project_id=project_deploy_board.project_id,
|
project_id=project_deploy_board.project_id,
|
||||||
|
116
packages/types/src/cycle/cycle.d.ts
vendored
116
packages/types/src/cycle/cycle.d.ts
vendored
@ -2,32 +2,81 @@ import type { TIssue, IIssueFilterOptions } from "@plane/types";
|
|||||||
|
|
||||||
export type TCycleGroups = "current" | "upcoming" | "completed" | "draft";
|
export type TCycleGroups = "current" | "upcoming" | "completed" | "draft";
|
||||||
|
|
||||||
export interface ICycle {
|
export type TCycleCompletionChartDistribution = {
|
||||||
backlog_issues: number;
|
[key: string]: number | null;
|
||||||
cancelled_issues: number;
|
};
|
||||||
|
|
||||||
|
export type TCycleDistributionBase = {
|
||||||
|
total_issues: number;
|
||||||
|
pending_issues: number;
|
||||||
completed_issues: number;
|
completed_issues: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TCycleEstimateDistributionBase = {
|
||||||
|
total_estimates: number;
|
||||||
|
pending_estimates: number;
|
||||||
|
completed_estimates: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TCycleAssigneesDistribution = {
|
||||||
|
assignee_id: string | null;
|
||||||
|
avatar: string | null;
|
||||||
|
first_name: string | null;
|
||||||
|
last_name: string | null;
|
||||||
|
display_name: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TCycleLabelsDistribution = {
|
||||||
|
color: string | null;
|
||||||
|
label_id: string | null;
|
||||||
|
label_name: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TCycleDistribution = {
|
||||||
|
assignees: (TCycleAssigneesDistribution & TCycleDistributionBase)[];
|
||||||
|
completion_chart: TCycleCompletionChartDistribution;
|
||||||
|
labels: (TCycleLabelsDistribution & TCycleDistributionBase)[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TCycleEstimateDistribution = {
|
||||||
|
assignees: (TCycleAssigneesDistribution & TCycleEstimateDistributionBase)[];
|
||||||
|
completion_chart: TCycleCompletionChartDistribution;
|
||||||
|
labels: (TCycleLabelsDistribution & TCycleEstimateDistributionBase)[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TProgressSnapshot = {
|
||||||
|
total_issues: number;
|
||||||
|
completed_issues: number;
|
||||||
|
backlog_issues: number;
|
||||||
|
started_issues: number;
|
||||||
|
unstarted_issues: number;
|
||||||
|
cancelled_issues: number;
|
||||||
|
total_estimate_points?: number;
|
||||||
|
completed_estimate_points?: number;
|
||||||
|
backlog_estimate_points: number;
|
||||||
|
started_estimate_points: number;
|
||||||
|
unstarted_estimate_points: number;
|
||||||
|
cancelled_estimate_points: number;
|
||||||
|
distribution?: TCycleDistribution;
|
||||||
|
estimate_distribution?: TCycleEstimateDistribution;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ICycle extends TProgressSnapshot {
|
||||||
|
progress_snapshot: TProgressSnapshot | undefined;
|
||||||
|
|
||||||
created_at?: string;
|
created_at?: string;
|
||||||
created_by?: string;
|
created_by?: string;
|
||||||
description: string;
|
description: string;
|
||||||
distribution?: {
|
|
||||||
assignees: TAssigneesDistribution[];
|
|
||||||
completion_chart: TCompletionChartDistribution;
|
|
||||||
labels: TLabelsDistribution[];
|
|
||||||
};
|
|
||||||
end_date: string | null;
|
end_date: string | null;
|
||||||
id: string;
|
id: string;
|
||||||
is_favorite?: boolean;
|
is_favorite?: boolean;
|
||||||
name: string;
|
name: string;
|
||||||
owned_by_id: string;
|
owned_by_id: string;
|
||||||
progress_snapshot: TProgressSnapshot;
|
|
||||||
project_id: string;
|
project_id: string;
|
||||||
status?: TCycleGroups;
|
status?: TCycleGroups;
|
||||||
sort_order: number;
|
sort_order: number;
|
||||||
start_date: string | null;
|
start_date: string | null;
|
||||||
started_issues: number;
|
|
||||||
sub_issues?: number;
|
sub_issues?: number;
|
||||||
total_issues: number;
|
|
||||||
unstarted_issues: number;
|
|
||||||
updated_at?: string;
|
updated_at?: string;
|
||||||
updated_by?: string;
|
updated_by?: string;
|
||||||
archived_at: string | null;
|
archived_at: string | null;
|
||||||
@ -38,47 +87,6 @@ export interface ICycle {
|
|||||||
workspace_id: string;
|
workspace_id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TProgressSnapshot = {
|
|
||||||
backlog_issues: number;
|
|
||||||
cancelled_issues: number;
|
|
||||||
completed_estimates: number | null;
|
|
||||||
completed_issues: number;
|
|
||||||
distribution?: {
|
|
||||||
assignees: TAssigneesDistribution[];
|
|
||||||
completion_chart: TCompletionChartDistribution;
|
|
||||||
labels: TLabelsDistribution[];
|
|
||||||
};
|
|
||||||
started_estimates: number | null;
|
|
||||||
started_issues: number;
|
|
||||||
total_estimates: number | null;
|
|
||||||
total_issues: number;
|
|
||||||
unstarted_issues: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TAssigneesDistribution = {
|
|
||||||
assignee_id: string | null;
|
|
||||||
avatar: string | null;
|
|
||||||
completed_issues: number;
|
|
||||||
first_name: string | null;
|
|
||||||
last_name: string | null;
|
|
||||||
display_name: string | null;
|
|
||||||
pending_issues: number;
|
|
||||||
total_issues: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TCompletionChartDistribution = {
|
|
||||||
[key: string]: number | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TLabelsDistribution = {
|
|
||||||
color: string | null;
|
|
||||||
completed_issues: number;
|
|
||||||
label_id: string | null;
|
|
||||||
label_name: string | null;
|
|
||||||
pending_issues: number;
|
|
||||||
total_issues: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface CycleIssueResponse {
|
export interface CycleIssueResponse {
|
||||||
id: string;
|
id: string;
|
||||||
issue_detail: TIssue;
|
issue_detail: TIssue;
|
||||||
@ -102,3 +110,5 @@ export type CycleDateCheckData = {
|
|||||||
end_date: string;
|
end_date: string;
|
||||||
cycle_id?: string;
|
cycle_id?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type TCyclePlotType = "burndown" | "points";
|
||||||
|
113
packages/types/src/module/modules.d.ts
vendored
113
packages/types/src/module/modules.d.ts
vendored
@ -1,11 +1,4 @@
|
|||||||
import type {
|
import type { TIssue, IIssueFilterOptions, ILinkDetails } from "@plane/types";
|
||||||
TIssue,
|
|
||||||
IIssueFilterOptions,
|
|
||||||
ILinkDetails,
|
|
||||||
TAssigneesDistribution,
|
|
||||||
TCompletionChartDistribution,
|
|
||||||
TLabelsDistribution,
|
|
||||||
} from "@plane/types";
|
|
||||||
|
|
||||||
export type TModuleStatus =
|
export type TModuleStatus =
|
||||||
| "backlog"
|
| "backlog"
|
||||||
@ -15,44 +8,88 @@ export type TModuleStatus =
|
|||||||
| "completed"
|
| "completed"
|
||||||
| "cancelled";
|
| "cancelled";
|
||||||
|
|
||||||
export interface IModule {
|
export type TModuleCompletionChartDistribution = {
|
||||||
backlog_issues: number;
|
[key: string]: number | null;
|
||||||
cancelled_issues: number;
|
};
|
||||||
|
|
||||||
|
export type TModuleDistributionBase = {
|
||||||
|
total_issues: number;
|
||||||
|
pending_issues: number;
|
||||||
completed_issues: number;
|
completed_issues: number;
|
||||||
created_at: string;
|
};
|
||||||
created_by?: string;
|
|
||||||
|
export type TModuleEstimateDistributionBase = {
|
||||||
|
total_estimates: number;
|
||||||
|
pending_estimates: number;
|
||||||
|
completed_estimates: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TModuleAssigneesDistribution = {
|
||||||
|
assignee_id: string | null;
|
||||||
|
avatar: string | null;
|
||||||
|
first_name: string | null;
|
||||||
|
last_name: string | null;
|
||||||
|
display_name: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TModuleLabelsDistribution = {
|
||||||
|
color: string | null;
|
||||||
|
label_id: string | null;
|
||||||
|
label_name: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TModuleDistribution = {
|
||||||
|
assignees: (TModuleAssigneesDistribution & TModuleDistributionBase)[];
|
||||||
|
completion_chart: TModuleCompletionChartDistribution;
|
||||||
|
labels: (TModuleLabelsDistribution & TModuleDistributionBase)[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TModuleEstimateDistribution = {
|
||||||
|
assignees: (TModuleAssigneesDistribution & TModuleEstimateDistributionBase)[];
|
||||||
|
completion_chart: TModuleCompletionChartDistribution;
|
||||||
|
labels: (TModuleLabelsDistribution & TModuleEstimateDistributionBase)[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface IModule {
|
||||||
|
total_issues: number;
|
||||||
|
completed_issues: number;
|
||||||
|
backlog_issues: number;
|
||||||
|
started_issues: number;
|
||||||
|
unstarted_issues: number;
|
||||||
|
cancelled_issues: number;
|
||||||
|
total_estimate_points?: number;
|
||||||
|
completed_estimate_points?: number;
|
||||||
|
backlog_estimate_points: number;
|
||||||
|
started_estimate_points: number;
|
||||||
|
unstarted_estimate_points: number;
|
||||||
|
cancelled_estimate_points: number;
|
||||||
|
distribution?: TModuleDistribution;
|
||||||
|
estimate_distribution?: TModuleEstimateDistribution;
|
||||||
|
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
description_text: any;
|
description_text: any;
|
||||||
description_html: any;
|
description_html: any;
|
||||||
distribution?: {
|
workspace_id: string;
|
||||||
assignees: TAssigneesDistribution[];
|
|
||||||
completion_chart: TCompletionChartDistribution;
|
|
||||||
labels: TLabelsDistribution[];
|
|
||||||
};
|
|
||||||
id: string;
|
|
||||||
lead_id: string | null;
|
|
||||||
link_module?: ILinkDetails[];
|
|
||||||
member_ids: string[];
|
|
||||||
is_favorite: boolean;
|
|
||||||
name: string;
|
|
||||||
project_id: string;
|
project_id: string;
|
||||||
sort_order: number;
|
lead_id: string | null;
|
||||||
|
member_ids: string[];
|
||||||
|
link_module?: ILinkDetails[];
|
||||||
sub_issues?: number;
|
sub_issues?: number;
|
||||||
start_date: string | null;
|
is_favorite: boolean;
|
||||||
started_issues: number;
|
sort_order: number;
|
||||||
status?: TModuleStatus;
|
|
||||||
target_date: string | null;
|
|
||||||
total_issues: number;
|
|
||||||
unstarted_issues: number;
|
|
||||||
total_estimate_points?: number;
|
|
||||||
completed_estimate_points?: number;
|
|
||||||
updated_at: string;
|
|
||||||
updated_by?: string;
|
|
||||||
archived_at: string | null;
|
|
||||||
view_props: {
|
view_props: {
|
||||||
filters: IIssueFilterOptions;
|
filters: IIssueFilterOptions;
|
||||||
};
|
};
|
||||||
workspace_id: string;
|
status?: TModuleStatus;
|
||||||
|
archived_at: string | null;
|
||||||
|
start_date: string | null;
|
||||||
|
target_date: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
created_by?: string;
|
||||||
|
updated_by?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ModuleIssueResponse {
|
export interface ModuleIssueResponse {
|
||||||
@ -78,3 +115,5 @@ export type ModuleLink = {
|
|||||||
export type SelectModuleType =
|
export type SelectModuleType =
|
||||||
| (IModule & { actionType: "edit" | "delete" | "create-issue" })
|
| (IModule & { actionType: "edit" | "delete" | "create-issue" })
|
||||||
| undefined;
|
| undefined;
|
||||||
|
|
||||||
|
export type TModulePlotType = "burndown" | "points";
|
||||||
|
@ -7,9 +7,7 @@ import useSWR from "swr";
|
|||||||
import { EmptyState } from "@/components/common";
|
import { EmptyState } from "@/components/common";
|
||||||
import { PageHead } from "@/components/core";
|
import { PageHead } from "@/components/core";
|
||||||
import { ModuleLayoutRoot } from "@/components/issues";
|
import { ModuleLayoutRoot } from "@/components/issues";
|
||||||
import { ModuleDetailsSidebar } from "@/components/modules";
|
import { ModuleAnalyticsSidebar } from "@/components/modules";
|
||||||
// constants
|
|
||||||
// import { EIssuesStoreType } from "@/constants/issue";
|
|
||||||
// helpers
|
// helpers
|
||||||
import { cn } from "@/helpers/common.helper";
|
import { cn } from "@/helpers/common.helper";
|
||||||
// hooks
|
// hooks
|
||||||
@ -77,7 +75,7 @@ const ModuleIssuesPage = observer(() => {
|
|||||||
"0px 1px 4px 0px rgba(0, 0, 0, 0.06), 0px 2px 4px 0px rgba(16, 24, 40, 0.06), 0px 1px 8px -1px rgba(16, 24, 40, 0.06)",
|
"0px 1px 4px 0px rgba(0, 0, 0, 0.06), 0px 2px 4px 0px rgba(16, 24, 40, 0.06), 0px 1px 8px -1px rgba(16, 24, 40, 0.06)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ModuleDetailsSidebar moduleId={moduleId.toString()} handleClose={toggleSidebar} />
|
<ModuleAnalyticsSidebar moduleId={moduleId.toString()} handleClose={toggleSidebar} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,4 +1,3 @@
|
|||||||
export * from "./links-list";
|
export * from "./links-list";
|
||||||
export * from "./sidebar-progress-stats";
|
|
||||||
export * from "./single-progress-stats";
|
export * from "./single-progress-stats";
|
||||||
export * from "./sidebar-menu-hamburger-toggle";
|
export * from "./sidebar-menu-hamburger-toggle";
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { eachDayOfInterval, isValid } from "date-fns";
|
import { eachDayOfInterval, isValid } from "date-fns";
|
||||||
import { TCompletionChartDistribution } from "@plane/types";
|
import { TModuleCompletionChartDistribution } from "@plane/types";
|
||||||
// ui
|
// ui
|
||||||
import { LineGraph } from "@/components/ui";
|
import { LineGraph } from "@/components/ui";
|
||||||
// helpers
|
// helpers
|
||||||
@ -8,11 +8,12 @@ import { getDate, renderFormattedDateWithoutYear } from "@/helpers/date-time.hel
|
|||||||
//types
|
//types
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
distribution: TCompletionChartDistribution;
|
distribution: TModuleCompletionChartDistribution;
|
||||||
startDate: string | Date;
|
startDate: string | Date;
|
||||||
endDate: string | Date;
|
endDate: string | Date;
|
||||||
totalIssues: number;
|
totalIssues: number;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
plotTitle?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const styleById = {
|
const styleById = {
|
||||||
@ -41,7 +42,14 @@ const DashedLine = ({ series, lineGenerator, xScale, yScale }: any) =>
|
|||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
|
|
||||||
const ProgressChart: React.FC<Props> = ({ distribution, startDate, endDate, totalIssues, className = "" }) => {
|
const ProgressChart: React.FC<Props> = ({
|
||||||
|
distribution,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
totalIssues,
|
||||||
|
className = "",
|
||||||
|
plotTitle = "issues",
|
||||||
|
}) => {
|
||||||
const chartData = Object.keys(distribution ?? []).map((key) => ({
|
const chartData = Object.keys(distribution ?? []).map((key) => ({
|
||||||
currentDate: renderFormattedDateWithoutYear(key),
|
currentDate: renderFormattedDateWithoutYear(key),
|
||||||
pending: distribution[key],
|
pending: distribution[key],
|
||||||
@ -129,7 +137,7 @@ const ProgressChart: React.FC<Props> = ({ distribution, startDate, endDate, tota
|
|||||||
sliceTooltip={(datum) => (
|
sliceTooltip={(datum) => (
|
||||||
<div className="rounded-md border border-custom-border-200 bg-custom-background-80 p-2 text-xs">
|
<div className="rounded-md border border-custom-border-200 bg-custom-background-80 p-2 text-xs">
|
||||||
{datum.slice.points[0].data.yFormatted}
|
{datum.slice.points[0].data.yFormatted}
|
||||||
<span className="text-custom-text-200"> issues pending on </span>
|
<span className="text-custom-text-200"> {plotTitle} pending on </span>
|
||||||
{datum.slice.points[0].data.xFormatted}
|
{datum.slice.points[0].data.xFormatted}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
@ -1,285 +0,0 @@
|
|||||||
"use client";
|
|
||||||
import React from "react";
|
|
||||||
import { observer } from "mobx-react";
|
|
||||||
import Image from "next/image";
|
|
||||||
// headless ui
|
|
||||||
import { Tab } from "@headlessui/react";
|
|
||||||
import {
|
|
||||||
IIssueFilterOptions,
|
|
||||||
IIssueFilters,
|
|
||||||
IModule,
|
|
||||||
TAssigneesDistribution,
|
|
||||||
TCompletionChartDistribution,
|
|
||||||
TLabelsDistribution,
|
|
||||||
TStateGroups,
|
|
||||||
} from "@plane/types";
|
|
||||||
// hooks
|
|
||||||
import { Avatar, StateGroupIcon } from "@plane/ui";
|
|
||||||
import { SingleProgressStats } from "@/components/core";
|
|
||||||
import { useProjectState } from "@/hooks/store";
|
|
||||||
import useLocalStorage from "@/hooks/use-local-storage";
|
|
||||||
// images
|
|
||||||
import emptyLabel from "@/public/empty-state/empty_label.svg";
|
|
||||||
import emptyMembers from "@/public/empty-state/empty_members.svg";
|
|
||||||
// components
|
|
||||||
// ui
|
|
||||||
// types
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
distribution:
|
|
||||||
| {
|
|
||||||
assignees: TAssigneesDistribution[];
|
|
||||||
completion_chart: TCompletionChartDistribution;
|
|
||||||
labels: TLabelsDistribution[];
|
|
||||||
}
|
|
||||||
| undefined;
|
|
||||||
groupedIssues: {
|
|
||||||
[key: string]: number;
|
|
||||||
};
|
|
||||||
totalIssues: number;
|
|
||||||
module?: IModule;
|
|
||||||
roundedTab?: boolean;
|
|
||||||
noBackground?: boolean;
|
|
||||||
isPeekView?: boolean;
|
|
||||||
isCompleted?: boolean;
|
|
||||||
filters?: IIssueFilters | undefined;
|
|
||||||
handleFiltersUpdate: (key: keyof IIssueFilterOptions, value: string | string[]) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const SidebarProgressStats: React.FC<Props> = observer((props) => {
|
|
||||||
const {
|
|
||||||
distribution,
|
|
||||||
groupedIssues,
|
|
||||||
totalIssues,
|
|
||||||
module,
|
|
||||||
roundedTab,
|
|
||||||
noBackground,
|
|
||||||
isPeekView = false,
|
|
||||||
isCompleted = false,
|
|
||||||
filters,
|
|
||||||
handleFiltersUpdate,
|
|
||||||
} = props;
|
|
||||||
const { storedValue: tab, setValue: setTab } = useLocalStorage("tab", "Assignees");
|
|
||||||
|
|
||||||
const { groupedProjectStates } = useProjectState();
|
|
||||||
|
|
||||||
const currentValue = (tab: string | null) => {
|
|
||||||
switch (tab) {
|
|
||||||
case "Assignees":
|
|
||||||
return 0;
|
|
||||||
case "Labels":
|
|
||||||
return 1;
|
|
||||||
case "States":
|
|
||||||
return 2;
|
|
||||||
default:
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStateGroupState = (stateGroup: string) => {
|
|
||||||
const stateGroupStates = groupedProjectStates?.[stateGroup];
|
|
||||||
const stateGroupStatesId = stateGroupStates?.map((state) => state.id);
|
|
||||||
return stateGroupStatesId;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tab.Group
|
|
||||||
defaultIndex={currentValue(tab)}
|
|
||||||
onChange={(i) => {
|
|
||||||
switch (i) {
|
|
||||||
case 0:
|
|
||||||
return setTab("Assignees");
|
|
||||||
case 1:
|
|
||||||
return setTab("Labels");
|
|
||||||
case 2:
|
|
||||||
return setTab("States");
|
|
||||||
default:
|
|
||||||
return setTab("Assignees");
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Tab.List
|
|
||||||
as="div"
|
|
||||||
className={`flex w-full items-center justify-between gap-2 rounded-md ${
|
|
||||||
noBackground ? "" : "bg-custom-background-90"
|
|
||||||
} p-0.5
|
|
||||||
${module ? "text-xs" : "text-sm"}`}
|
|
||||||
>
|
|
||||||
<Tab
|
|
||||||
className={({ selected }) =>
|
|
||||||
`w-full ${
|
|
||||||
roundedTab ? "rounded-3xl border border-custom-border-200" : "rounded"
|
|
||||||
} px-3 py-1 text-custom-text-100 ${
|
|
||||||
selected
|
|
||||||
? "bg-custom-background-100 text-custom-text-300 shadow-custom-shadow-2xs"
|
|
||||||
: "text-custom-text-400 hover:text-custom-text-300"
|
|
||||||
}`
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Assignees
|
|
||||||
</Tab>
|
|
||||||
<Tab
|
|
||||||
className={({ selected }) =>
|
|
||||||
`w-full ${
|
|
||||||
roundedTab ? "rounded-3xl border border-custom-border-200" : "rounded"
|
|
||||||
} px-3 py-1 text-custom-text-100 ${
|
|
||||||
selected
|
|
||||||
? "bg-custom-background-100 text-custom-text-300 shadow-custom-shadow-2xs"
|
|
||||||
: "text-custom-text-400 hover:text-custom-text-300"
|
|
||||||
}`
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Labels
|
|
||||||
</Tab>
|
|
||||||
<Tab
|
|
||||||
className={({ selected }) =>
|
|
||||||
`w-full ${
|
|
||||||
roundedTab ? "rounded-3xl border border-custom-border-200" : "rounded"
|
|
||||||
} px-3 py-1 text-custom-text-100 ${
|
|
||||||
selected
|
|
||||||
? "bg-custom-background-100 text-custom-text-300 shadow-custom-shadow-2xs"
|
|
||||||
: "text-custom-text-400 hover:text-custom-text-300"
|
|
||||||
}`
|
|
||||||
}
|
|
||||||
>
|
|
||||||
States
|
|
||||||
</Tab>
|
|
||||||
</Tab.List>
|
|
||||||
<Tab.Panels className="flex w-full items-center justify-between text-custom-text-200">
|
|
||||||
<Tab.Panel
|
|
||||||
as="div"
|
|
||||||
className="flex w-full flex-col gap-1.5 overflow-y-auto pt-3.5 vertical-scrollbar scrollbar-sm"
|
|
||||||
>
|
|
||||||
{distribution && distribution?.assignees.length > 0 ? (
|
|
||||||
distribution.assignees.map((assignee, index) => {
|
|
||||||
if (assignee.assignee_id)
|
|
||||||
return (
|
|
||||||
<SingleProgressStats
|
|
||||||
key={assignee.assignee_id}
|
|
||||||
title={
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Avatar name={assignee?.display_name ?? undefined} src={assignee?.avatar ?? undefined} />
|
|
||||||
<span>{assignee?.display_name ?? ""}</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
completed={assignee.completed_issues}
|
|
||||||
total={assignee.total_issues}
|
|
||||||
{...(!isPeekView &&
|
|
||||||
!isCompleted && {
|
|
||||||
onClick: () => handleFiltersUpdate("assignees", assignee.assignee_id ?? ""),
|
|
||||||
selected: filters?.filters?.assignees?.includes(assignee.assignee_id ?? ""),
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
else
|
|
||||||
return (
|
|
||||||
<SingleProgressStats
|
|
||||||
key={`unassigned-${index}`}
|
|
||||||
title={
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="h-4 w-4 rounded-full border-2 border-custom-border-200 bg-custom-background-80">
|
|
||||||
<img src="/user.png" height="100%" width="100%" className="rounded-full" alt="User" />
|
|
||||||
</div>
|
|
||||||
<span>No assignee</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
completed={assignee.completed_issues}
|
|
||||||
total={assignee.total_issues}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
) : (
|
|
||||||
<div className="flex h-full flex-col items-center justify-center gap-2">
|
|
||||||
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-custom-background-80">
|
|
||||||
<Image src={emptyMembers} className="h-12 w-12" alt="empty members" />
|
|
||||||
</div>
|
|
||||||
<h6 className="text-base text-custom-text-300">No assignees yet</h6>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Tab.Panel>
|
|
||||||
<Tab.Panel
|
|
||||||
as="div"
|
|
||||||
className="flex w-full flex-col gap-1.5 overflow-y-auto pt-3.5 vertical-scrollbar scrollbar-sm"
|
|
||||||
>
|
|
||||||
{distribution && distribution?.labels.length > 0 ? (
|
|
||||||
distribution.labels.map((label, index) => {
|
|
||||||
if (label.label_id) {
|
|
||||||
return (
|
|
||||||
<SingleProgressStats
|
|
||||||
key={label.label_id}
|
|
||||||
title={
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span
|
|
||||||
className="block h-3 w-3 rounded-full"
|
|
||||||
style={{
|
|
||||||
backgroundColor: label.color ?? "transparent",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span className="text-xs">{label.label_name ?? "No labels"}</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
completed={label.completed_issues}
|
|
||||||
total={label.total_issues}
|
|
||||||
{...(!isPeekView &&
|
|
||||||
!isCompleted && {
|
|
||||||
onClick: () => handleFiltersUpdate("labels", label.label_id ?? ""),
|
|
||||||
selected: filters?.filters?.labels?.includes(label.label_id ?? `no-label-${index}`),
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return (
|
|
||||||
<SingleProgressStats
|
|
||||||
key={`no-label-${index}`}
|
|
||||||
title={
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span
|
|
||||||
className="block h-3 w-3 rounded-full"
|
|
||||||
style={{
|
|
||||||
backgroundColor: label.color ?? "transparent",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span className="text-xs">{label.label_name ?? "No labels"}</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
completed={label.completed_issues}
|
|
||||||
total={label.total_issues}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
) : (
|
|
||||||
<div className="flex h-full flex-col items-center justify-center gap-2">
|
|
||||||
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-custom-background-80">
|
|
||||||
<Image src={emptyLabel} className="h-12 w-12" alt="empty label" />
|
|
||||||
</div>
|
|
||||||
<h6 className="text-base text-custom-text-300">No labels yet</h6>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Tab.Panel>
|
|
||||||
<Tab.Panel
|
|
||||||
as="div"
|
|
||||||
className="flex w-full flex-col gap-1.5 overflow-y-auto pt-3.5 vertical-scrollbar scrollbar-sm"
|
|
||||||
>
|
|
||||||
{Object.keys(groupedIssues).map((group, index) => (
|
|
||||||
<SingleProgressStats
|
|
||||||
key={index}
|
|
||||||
title={
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<StateGroupIcon stateGroup={group as TStateGroups} />
|
|
||||||
<span className="text-xs capitalize">{group}</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
completed={groupedIssues[group]}
|
|
||||||
total={totalIssues}
|
|
||||||
{...(!isPeekView &&
|
|
||||||
!isCompleted && {
|
|
||||||
onClick: () => handleFiltersUpdate("state", getStateGroupState(group) ?? []),
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Tab.Panel>
|
|
||||||
</Tab.Panels>
|
|
||||||
</Tab.Group>
|
|
||||||
);
|
|
||||||
});
|
|
@ -1,12 +1,13 @@
|
|||||||
import { FC } from "react";
|
import { FC, Fragment, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
// types
|
import { ICycle, TCyclePlotType } from "@plane/types";
|
||||||
import { ICycle } from "@plane/types";
|
import { CustomSelect, Spinner } from "@plane/ui";
|
||||||
// components
|
// components
|
||||||
import ProgressChart from "@/components/core/sidebar/progress-chart";
|
import ProgressChart from "@/components/core/sidebar/progress-chart";
|
||||||
import { EmptyState } from "@/components/empty-state";
|
import { EmptyState } from "@/components/empty-state";
|
||||||
// constants
|
// constants
|
||||||
import { EmptyStateType } from "@/constants/empty-state";
|
import { EmptyStateType } from "@/constants/empty-state";
|
||||||
|
import { useCycle } from "@/hooks/store";
|
||||||
|
|
||||||
export type ActiveCycleProductivityProps = {
|
export type ActiveCycleProductivityProps = {
|
||||||
workspaceSlug: string;
|
workspaceSlug: string;
|
||||||
@ -14,51 +15,113 @@ export type ActiveCycleProductivityProps = {
|
|||||||
cycle: ICycle;
|
cycle: ICycle;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const cycleBurnDownChartOptions = [
|
||||||
|
{ value: "burndown", label: "Issues" },
|
||||||
|
{ value: "points", label: "Points" },
|
||||||
|
];
|
||||||
|
|
||||||
export const ActiveCycleProductivity: FC<ActiveCycleProductivityProps> = (props) => {
|
export const ActiveCycleProductivity: FC<ActiveCycleProductivityProps> = (props) => {
|
||||||
const { workspaceSlug, projectId, cycle } = props;
|
const { workspaceSlug, projectId, cycle } = props;
|
||||||
|
// hooks
|
||||||
|
const { getPlotTypeByCycleId, setPlotType, fetchCycleDetails } = useCycle();
|
||||||
|
// state
|
||||||
|
const [loader, setLoader] = useState(false);
|
||||||
|
// derived values
|
||||||
|
const plotType: TCyclePlotType = (cycle && getPlotTypeByCycleId(cycle.id)) || "burndown";
|
||||||
|
|
||||||
|
const onChange = async (value: TCyclePlotType) => {
|
||||||
|
if (!workspaceSlug || !projectId || !cycle || !cycle.id) return;
|
||||||
|
setPlotType(cycle.id, value);
|
||||||
|
try {
|
||||||
|
setLoader(true);
|
||||||
|
await fetchCycleDetails(workspaceSlug, projectId, cycle.id);
|
||||||
|
setLoader(false);
|
||||||
|
} catch (error) {
|
||||||
|
setLoader(false);
|
||||||
|
setPlotType(cycle.id, plotType);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const chartDistributionData = plotType === "points" ? cycle?.estimate_distribution : cycle?.distribution || undefined;
|
||||||
|
const completionChartDistributionData = chartDistributionData?.completion_chart || undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<div className="flex flex-col justify-center min-h-[17rem] gap-5 py-4 px-3.5 bg-custom-background-100 border border-custom-border-200 rounded-lg">
|
||||||
href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycle?.id}`}
|
|
||||||
className="flex flex-col justify-center min-h-[17rem] gap-5 py-4 px-3.5 bg-custom-background-100 border border-custom-border-200 rounded-lg"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<h3 className="text-base text-custom-text-300 font-semibold">Issue burndown</h3>
|
<Link href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycle?.id}`}>
|
||||||
|
<h3 className="text-base text-custom-text-300 font-semibold">Issue burndown</h3>
|
||||||
|
</Link>
|
||||||
|
<div id="no-redirection" className="flex items-center gap-2">
|
||||||
|
<CustomSelect
|
||||||
|
value={plotType}
|
||||||
|
label={<span>{cycleBurnDownChartOptions.find((v) => v.value === plotType)?.label ?? "None"}</span>}
|
||||||
|
onChange={onChange}
|
||||||
|
maxHeight="lg"
|
||||||
|
>
|
||||||
|
{cycleBurnDownChartOptions.map((item) => (
|
||||||
|
<CustomSelect.Option key={item.value} value={item.value}>
|
||||||
|
{item.label}
|
||||||
|
</CustomSelect.Option>
|
||||||
|
))}
|
||||||
|
</CustomSelect>
|
||||||
|
{loader && <Spinner className="h-3 w-3" />}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{cycle.total_issues > 0 ? (
|
<Link href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycle?.id}`}>
|
||||||
<>
|
{cycle.total_issues > 0 ? (
|
||||||
<div className="h-full w-full px-2">
|
<>
|
||||||
<div className="flex items-center justify-between gap-4 py-1 text-xs text-custom-text-300">
|
<div className="h-full w-full px-2">
|
||||||
<div className="flex items-center gap-3 text-custom-text-300">
|
<div className="flex items-center justify-between gap-4 py-1 text-xs text-custom-text-300">
|
||||||
<div className="flex items-center justify-center gap-1">
|
<div className="flex items-center gap-3 text-custom-text-300">
|
||||||
<span className="h-2 w-2 rounded-full bg-[#A9BBD0]" />
|
<div className="flex items-center justify-center gap-1">
|
||||||
<span>Ideal</span>
|
<span className="h-2 w-2 rounded-full bg-[#A9BBD0]" />
|
||||||
</div>
|
<span>Ideal</span>
|
||||||
<div className="flex items-center justify-center gap-1">
|
</div>
|
||||||
<span className="h-2 w-2 rounded-full bg-[#4C8FFF]" />
|
<div className="flex items-center justify-center gap-1">
|
||||||
<span>Current</span>
|
<span className="h-2 w-2 rounded-full bg-[#4C8FFF]" />
|
||||||
|
<span>Current</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{plotType === "points" ? (
|
||||||
|
<span>{`Pending issues - ${cycle.backlog_issues + cycle.unstarted_issues + cycle.started_issues}`}</span>
|
||||||
|
) : (
|
||||||
|
<span>{`Pending issues - ${cycle.backlog_issues + cycle.unstarted_issues + cycle.started_issues}`}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative h-full">
|
||||||
|
{completionChartDistributionData && (
|
||||||
|
<Fragment>
|
||||||
|
{plotType === "points" ? (
|
||||||
|
<ProgressChart
|
||||||
|
distribution={completionChartDistributionData}
|
||||||
|
startDate={cycle.start_date ?? ""}
|
||||||
|
endDate={cycle.end_date ?? ""}
|
||||||
|
totalIssues={cycle.total_issues}
|
||||||
|
plotTitle={"points"}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ProgressChart
|
||||||
|
distribution={completionChartDistributionData}
|
||||||
|
startDate={cycle.start_date ?? ""}
|
||||||
|
endDate={cycle.end_date ?? ""}
|
||||||
|
totalIssues={cycle.total_estimate_points || 0}
|
||||||
|
plotTitle={"issues"}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span>{`Pending issues - ${cycle.backlog_issues + cycle.unstarted_issues + cycle.started_issues}`}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="relative h-full">
|
</>
|
||||||
<ProgressChart
|
) : (
|
||||||
className="h-full"
|
<>
|
||||||
distribution={cycle.distribution?.completion_chart ?? {}}
|
<div className="flex items-center justify-center h-full w-full">
|
||||||
startDate={cycle.start_date ?? ""}
|
<EmptyState type={EmptyStateType.ACTIVE_CYCLE_CHART_EMPTY_STATE} layout="screen-simple" size="sm" />
|
||||||
endDate={cycle.end_date ?? ""}
|
|
||||||
totalIssues={cycle.total_issues}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
</>
|
)}
|
||||||
) : (
|
</Link>
|
||||||
<>
|
</div>
|
||||||
<div className="flex items-center justify-center h-full w-full">
|
|
||||||
<EmptyState type={EmptyStateType.ACTIVE_CYCLE_CHART_EMPTY_STATE} layout="screen-simple" size="sm" />
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Link>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
3
web/core/components/cycles/analytics-sidebar/index.ts
Normal file
3
web/core/components/cycles/analytics-sidebar/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export * from "./root";
|
||||||
|
export * from "./issue-progress";
|
||||||
|
export * from "./progress-stats";
|
276
web/core/components/cycles/analytics-sidebar/issue-progress.tsx
Normal file
276
web/core/components/cycles/analytics-sidebar/issue-progress.tsx
Normal file
@ -0,0 +1,276 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { FC, Fragment, useCallback, useMemo, useState } from "react";
|
||||||
|
import isEmpty from "lodash/isEmpty";
|
||||||
|
import isEqual from "lodash/isEqual";
|
||||||
|
import { observer } from "mobx-react";
|
||||||
|
import { useSearchParams } from "next/navigation";
|
||||||
|
import { AlertCircle, ChevronUp, ChevronDown } from "lucide-react";
|
||||||
|
import { Disclosure, Transition } from "@headlessui/react";
|
||||||
|
import { ICycle, IIssueFilterOptions, TCyclePlotType, TProgressSnapshot } from "@plane/types";
|
||||||
|
import { CustomSelect, Spinner } from "@plane/ui";
|
||||||
|
// components
|
||||||
|
import ProgressChart from "@/components/core/sidebar/progress-chart";
|
||||||
|
import { CycleProgressStats } from "@/components/cycles";
|
||||||
|
// constants
|
||||||
|
import { EEstimateSystem } from "@/constants/estimates";
|
||||||
|
import { EIssueFilterType, EIssuesStoreType } from "@/constants/issue";
|
||||||
|
// helpers
|
||||||
|
import { getDate } from "@/helpers/date-time.helper";
|
||||||
|
// hooks
|
||||||
|
import { useIssues, useCycle, useProjectEstimates } from "@/hooks/store";
|
||||||
|
|
||||||
|
type TCycleAnalyticsProgress = {
|
||||||
|
workspaceSlug: string;
|
||||||
|
projectId: string;
|
||||||
|
cycleId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const cycleBurnDownChartOptions = [
|
||||||
|
{ value: "burndown", label: "Issues" },
|
||||||
|
{ value: "points", label: "Points" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const validateCycleSnapshot = (cycleDetails: ICycle | null): ICycle | null => {
|
||||||
|
if (!cycleDetails || cycleDetails === null) return cycleDetails;
|
||||||
|
|
||||||
|
const updatedCycleDetails: any = { ...cycleDetails };
|
||||||
|
if (!isEmpty(cycleDetails.progress_snapshot)) {
|
||||||
|
Object.keys(cycleDetails.progress_snapshot || {}).forEach((key) => {
|
||||||
|
const currentKey = key as keyof TProgressSnapshot;
|
||||||
|
if (!isEmpty(cycleDetails.progress_snapshot) && !isEmpty(updatedCycleDetails)) {
|
||||||
|
updatedCycleDetails[currentKey as keyof ICycle] = cycleDetails?.progress_snapshot?.[currentKey];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return updatedCycleDetails;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CycleAnalyticsProgress: FC<TCycleAnalyticsProgress> = observer((props) => {
|
||||||
|
// props
|
||||||
|
const { workspaceSlug, projectId, cycleId } = props;
|
||||||
|
// router
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const peekCycle = searchParams.get("peekCycle") || undefined;
|
||||||
|
// hooks
|
||||||
|
const { areEstimateEnabledByProjectId, currentActiveEstimateId, estimateById } = useProjectEstimates();
|
||||||
|
const { getPlotTypeByCycleId, setPlotType, getCycleById, fetchCycleDetails } = useCycle();
|
||||||
|
const {
|
||||||
|
issuesFilter: { issueFilters, updateFilters },
|
||||||
|
} = useIssues(EIssuesStoreType.CYCLE);
|
||||||
|
// state
|
||||||
|
const [loader, setLoader] = useState(false);
|
||||||
|
|
||||||
|
// derived values
|
||||||
|
const cycleDetails = validateCycleSnapshot(getCycleById(cycleId));
|
||||||
|
const plotType: TCyclePlotType = getPlotTypeByCycleId(cycleId);
|
||||||
|
const isCurrentProjectEstimateEnabled = projectId && areEstimateEnabledByProjectId(projectId) ? true : false;
|
||||||
|
const estimateDetails =
|
||||||
|
isCurrentProjectEstimateEnabled && currentActiveEstimateId && estimateById(currentActiveEstimateId);
|
||||||
|
const isCurrentEstimateTypeIsPoints = estimateDetails && estimateDetails?.type === EEstimateSystem.POINTS;
|
||||||
|
|
||||||
|
const completedIssues = cycleDetails?.completed_issues || 0;
|
||||||
|
const totalIssues = cycleDetails?.total_issues || 0;
|
||||||
|
const completedEstimatePoints = cycleDetails?.completed_estimate_points || 0;
|
||||||
|
const totalEstimatePoints = cycleDetails?.total_estimate_points || 0;
|
||||||
|
|
||||||
|
const progressHeaderPercentage = cycleDetails
|
||||||
|
? plotType === "points"
|
||||||
|
? completedEstimatePoints != 0 && totalEstimatePoints != 0
|
||||||
|
? Math.round((completedEstimatePoints / totalEstimatePoints) * 100)
|
||||||
|
: 0
|
||||||
|
: completedIssues != 0 && completedIssues != 0
|
||||||
|
? Math.round((completedIssues / totalIssues) * 100)
|
||||||
|
: 0
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const chartDistributionData =
|
||||||
|
plotType === "points" ? cycleDetails?.estimate_distribution : cycleDetails?.distribution || undefined;
|
||||||
|
const completionChartDistributionData = chartDistributionData?.completion_chart || undefined;
|
||||||
|
|
||||||
|
const groupedIssues = useMemo(
|
||||||
|
() => ({
|
||||||
|
backlog: plotType === "points" ? cycleDetails?.backlog_estimate_points || 0 : cycleDetails?.backlog_issues || 0,
|
||||||
|
unstarted:
|
||||||
|
plotType === "points" ? cycleDetails?.unstarted_estimate_points || 0 : cycleDetails?.unstarted_issues || 0,
|
||||||
|
started: plotType === "points" ? cycleDetails?.started_estimate_points || 0 : cycleDetails?.started_issues || 0,
|
||||||
|
completed:
|
||||||
|
plotType === "points" ? cycleDetails?.completed_estimate_points || 0 : cycleDetails?.completed_issues || 0,
|
||||||
|
cancelled:
|
||||||
|
plotType === "points" ? cycleDetails?.cancelled_estimate_points || 0 : cycleDetails?.cancelled_issues || 0,
|
||||||
|
}),
|
||||||
|
[plotType, cycleDetails]
|
||||||
|
);
|
||||||
|
|
||||||
|
const cycleStartDate = getDate(cycleDetails?.start_date);
|
||||||
|
const cycleEndDate = getDate(cycleDetails?.end_date);
|
||||||
|
const isCycleStartDateValid = cycleStartDate && cycleStartDate <= new Date();
|
||||||
|
const isCycleEndDateValid = cycleStartDate && cycleEndDate && cycleEndDate >= cycleStartDate;
|
||||||
|
const isCycleDateValid = isCycleStartDateValid && isCycleEndDateValid;
|
||||||
|
|
||||||
|
// handlers
|
||||||
|
const onChange = async (value: TCyclePlotType) => {
|
||||||
|
setPlotType(cycleId, value);
|
||||||
|
if (!workspaceSlug || !projectId || !cycleId) return;
|
||||||
|
try {
|
||||||
|
setLoader(true);
|
||||||
|
await fetchCycleDetails(workspaceSlug, projectId, cycleId);
|
||||||
|
setLoader(false);
|
||||||
|
} catch (error) {
|
||||||
|
setLoader(false);
|
||||||
|
setPlotType(cycleId, plotType);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFiltersUpdate = useCallback(
|
||||||
|
(key: keyof IIssueFilterOptions, value: string | string[]) => {
|
||||||
|
if (!workspaceSlug || !projectId) return;
|
||||||
|
|
||||||
|
let newValues = issueFilters?.filters?.[key] ?? [];
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
if (key === "state") {
|
||||||
|
if (isEqual(newValues, value)) newValues = [];
|
||||||
|
else newValues = value;
|
||||||
|
} else {
|
||||||
|
value.forEach((val) => {
|
||||||
|
if (!newValues.includes(val)) newValues.push(val);
|
||||||
|
else newValues.splice(newValues.indexOf(val), 1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
|
||||||
|
else newValues.push(value);
|
||||||
|
}
|
||||||
|
updateFilters(
|
||||||
|
workspaceSlug.toString(),
|
||||||
|
projectId.toString(),
|
||||||
|
EIssueFilterType.FILTERS,
|
||||||
|
{ [key]: newValues },
|
||||||
|
cycleId
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[workspaceSlug, projectId, cycleId, issueFilters, updateFilters]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!cycleDetails) return <></>;
|
||||||
|
return (
|
||||||
|
<div className="border-t border-custom-border-200 space-y-4 py-4 px-3">
|
||||||
|
<Disclosure defaultOpen={isCycleDateValid ? true : false}>
|
||||||
|
{({ open }) => (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* progress bar header */}
|
||||||
|
{isCycleDateValid ? (
|
||||||
|
<div className="relative w-full flex justify-between items-center gap-2">
|
||||||
|
<Disclosure.Button className="relative flex items-center gap-2 w-full">
|
||||||
|
<div className="font-medium text-custom-text-200 text-sm">Progress</div>
|
||||||
|
{progressHeaderPercentage > 0 && (
|
||||||
|
<div className="flex h-5 w-9 items-center justify-center rounded bg-amber-500/20 text-xs font-medium text-amber-500">{`${progressHeaderPercentage}%`}</div>
|
||||||
|
)}
|
||||||
|
</Disclosure.Button>
|
||||||
|
{isCurrentEstimateTypeIsPoints && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<CustomSelect
|
||||||
|
value={plotType}
|
||||||
|
label={
|
||||||
|
<span>{cycleBurnDownChartOptions.find((v) => v.value === plotType)?.label ?? "None"}</span>
|
||||||
|
}
|
||||||
|
onChange={onChange}
|
||||||
|
maxHeight="lg"
|
||||||
|
>
|
||||||
|
{cycleBurnDownChartOptions.map((item) => (
|
||||||
|
<CustomSelect.Option key={item.value} value={item.value}>
|
||||||
|
{item.label}
|
||||||
|
</CustomSelect.Option>
|
||||||
|
))}
|
||||||
|
</CustomSelect>
|
||||||
|
</div>
|
||||||
|
{loader && <Spinner className="h-3 w-3" />}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Disclosure.Button className="ml-auto">
|
||||||
|
{open ? (
|
||||||
|
<ChevronUp className="h-3.5 w-3.5" aria-hidden="true" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="h-3.5 w-3.5" aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
</Disclosure.Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="relative w-full flex justify-between items-center gap-2">
|
||||||
|
<div className="font-medium text-custom-text-200 text-sm">Progress</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<AlertCircle height={14} width={14} className="text-custom-text-200" />
|
||||||
|
<span className="text-xs italic text-custom-text-200">
|
||||||
|
{cycleDetails?.start_date && cycleDetails?.end_date
|
||||||
|
? "This cycle isn't active yet."
|
||||||
|
: "Invalid date. Please enter valid date."}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Transition show={open}>
|
||||||
|
<Disclosure.Panel className="space-y-4">
|
||||||
|
{/* progress burndown chart */}
|
||||||
|
<div>
|
||||||
|
<div className="relative flex items-center gap-2">
|
||||||
|
<div className="flex items-center justify-center gap-1 text-xs">
|
||||||
|
<span className="h-2.5 w-2.5 rounded-full bg-[#A9BBD0]" />
|
||||||
|
<span>Ideal</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-center gap-1 text-xs">
|
||||||
|
<span className="h-2.5 w-2.5 rounded-full bg-[#4C8FFF]" />
|
||||||
|
<span>Current</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{cycleStartDate && cycleEndDate && completionChartDistributionData && (
|
||||||
|
<Fragment>
|
||||||
|
{plotType === "points" ? (
|
||||||
|
<ProgressChart
|
||||||
|
distribution={completionChartDistributionData}
|
||||||
|
startDate={cycleStartDate}
|
||||||
|
endDate={cycleEndDate}
|
||||||
|
totalIssues={totalEstimatePoints}
|
||||||
|
plotTitle={"points"}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ProgressChart
|
||||||
|
distribution={completionChartDistributionData}
|
||||||
|
startDate={cycleStartDate}
|
||||||
|
endDate={cycleEndDate}
|
||||||
|
totalIssues={totalIssues}
|
||||||
|
plotTitle={"issues"}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* progress detailed view */}
|
||||||
|
{chartDistributionData && (
|
||||||
|
<div className="w-full border-t border-custom-border-200 pt-5">
|
||||||
|
<CycleProgressStats
|
||||||
|
cycleId={cycleId}
|
||||||
|
plotType={plotType}
|
||||||
|
distribution={chartDistributionData}
|
||||||
|
groupedIssues={groupedIssues}
|
||||||
|
totalIssuesCount={plotType === "points" ? totalEstimatePoints || 0 : totalIssues || 0}
|
||||||
|
isEditable={Boolean(peekCycle)}
|
||||||
|
size="xs"
|
||||||
|
roundedTab={false}
|
||||||
|
noBackground={false}
|
||||||
|
filters={issueFilters}
|
||||||
|
handleFiltersUpdate={handleFiltersUpdate}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Disclosure.Panel>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Disclosure>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
372
web/core/components/cycles/analytics-sidebar/progress-stats.tsx
Normal file
372
web/core/components/cycles/analytics-sidebar/progress-stats.tsx
Normal file
@ -0,0 +1,372 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { FC } from "react";
|
||||||
|
import { observer } from "mobx-react";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { Tab } from "@headlessui/react";
|
||||||
|
import {
|
||||||
|
IIssueFilterOptions,
|
||||||
|
IIssueFilters,
|
||||||
|
TCycleDistribution,
|
||||||
|
TCycleEstimateDistribution,
|
||||||
|
TCyclePlotType,
|
||||||
|
TStateGroups,
|
||||||
|
} from "@plane/types";
|
||||||
|
import { Avatar, StateGroupIcon } from "@plane/ui";
|
||||||
|
// components
|
||||||
|
import { SingleProgressStats } from "@/components/core";
|
||||||
|
// helpers
|
||||||
|
import { cn } from "@/helpers/common.helper";
|
||||||
|
// hooks
|
||||||
|
import { useProjectState } from "@/hooks/store";
|
||||||
|
import useLocalStorage from "@/hooks/use-local-storage";
|
||||||
|
// public
|
||||||
|
import emptyLabel from "@/public/empty-state/empty_label.svg";
|
||||||
|
import emptyMembers from "@/public/empty-state/empty_members.svg";
|
||||||
|
|
||||||
|
// assignee types
|
||||||
|
type TAssigneeData = {
|
||||||
|
id: string | undefined;
|
||||||
|
title: string | undefined;
|
||||||
|
avatar: string | undefined;
|
||||||
|
completed: number;
|
||||||
|
total: number;
|
||||||
|
}[];
|
||||||
|
|
||||||
|
type TAssigneeStatComponent = {
|
||||||
|
distribution: TAssigneeData;
|
||||||
|
isEditable?: boolean;
|
||||||
|
filters?: IIssueFilters | undefined;
|
||||||
|
handleFiltersUpdate: (key: keyof IIssueFilterOptions, value: string | string[]) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
// labelTypes
|
||||||
|
type TLabelData = {
|
||||||
|
id: string | undefined;
|
||||||
|
title: string | undefined;
|
||||||
|
color: string | undefined;
|
||||||
|
completed: number;
|
||||||
|
total: number;
|
||||||
|
}[];
|
||||||
|
|
||||||
|
type TLabelStatComponent = {
|
||||||
|
distribution: TLabelData;
|
||||||
|
isEditable?: boolean;
|
||||||
|
filters?: IIssueFilters | undefined;
|
||||||
|
handleFiltersUpdate: (key: keyof IIssueFilterOptions, value: string | string[]) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
// stateTypes
|
||||||
|
type TStateData = {
|
||||||
|
state: string | undefined;
|
||||||
|
completed: number;
|
||||||
|
total: number;
|
||||||
|
}[];
|
||||||
|
|
||||||
|
type TStateStatComponent = {
|
||||||
|
distribution: TStateData;
|
||||||
|
totalIssuesCount: number;
|
||||||
|
isEditable?: boolean;
|
||||||
|
handleFiltersUpdate: (key: keyof IIssueFilterOptions, value: string | string[]) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AssigneeStatComponent = observer((props: TAssigneeStatComponent) => {
|
||||||
|
const { distribution, isEditable, filters, handleFiltersUpdate } = props;
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{distribution && distribution.length > 0 ? (
|
||||||
|
distribution.map((assignee, index) => {
|
||||||
|
if (assignee?.id)
|
||||||
|
return (
|
||||||
|
<SingleProgressStats
|
||||||
|
key={assignee?.id}
|
||||||
|
title={
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Avatar name={assignee?.title ?? undefined} src={assignee?.avatar ?? undefined} />
|
||||||
|
<span>{assignee?.title ?? ""}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
completed={assignee?.completed ?? 0}
|
||||||
|
total={assignee?.total ?? 0}
|
||||||
|
{...(isEditable && {
|
||||||
|
onClick: () => handleFiltersUpdate("assignees", assignee.id ?? ""),
|
||||||
|
selected: filters?.filters?.assignees?.includes(assignee.id ?? ""),
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
else
|
||||||
|
return (
|
||||||
|
<SingleProgressStats
|
||||||
|
key={`unassigned-${index}`}
|
||||||
|
title={
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="h-4 w-4 rounded-full border-2 border-custom-border-200 bg-custom-background-80">
|
||||||
|
<img src="/user.png" height="100%" width="100%" className="rounded-full" alt="User" />
|
||||||
|
</div>
|
||||||
|
<span>No assignee</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
completed={assignee?.completed ?? 0}
|
||||||
|
total={assignee?.total ?? 0}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full flex-col items-center justify-center gap-2">
|
||||||
|
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-custom-background-80">
|
||||||
|
<Image src={emptyMembers} className="h-12 w-12" alt="empty members" />
|
||||||
|
</div>
|
||||||
|
<h6 className="text-base text-custom-text-300">No assignees yet</h6>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export const LabelStatComponent = observer((props: TLabelStatComponent) => {
|
||||||
|
const { distribution, isEditable, filters, handleFiltersUpdate } = props;
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{distribution && distribution.length > 0 ? (
|
||||||
|
distribution.map((label, index) => {
|
||||||
|
if (label.id) {
|
||||||
|
return (
|
||||||
|
<SingleProgressStats
|
||||||
|
key={label.id}
|
||||||
|
title={
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className="block h-3 w-3 rounded-full"
|
||||||
|
style={{
|
||||||
|
backgroundColor: label.color ?? "transparent",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="text-xs">{label.title ?? "No labels"}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
completed={label.completed}
|
||||||
|
total={label.total}
|
||||||
|
{...(isEditable && {
|
||||||
|
onClick: () => handleFiltersUpdate("labels", label.id ?? ""),
|
||||||
|
selected: filters?.filters?.labels?.includes(label.id ?? `no-label-${index}`),
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<SingleProgressStats
|
||||||
|
key={`no-label-${index}`}
|
||||||
|
title={
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className="block h-3 w-3 rounded-full"
|
||||||
|
style={{
|
||||||
|
backgroundColor: label.color ?? "transparent",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="text-xs">{label.title ?? "No labels"}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
completed={label.completed}
|
||||||
|
total={label.total}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full flex-col items-center justify-center gap-2">
|
||||||
|
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-custom-background-80">
|
||||||
|
<Image src={emptyLabel} className="h-12 w-12" alt="empty label" />
|
||||||
|
</div>
|
||||||
|
<h6 className="text-base text-custom-text-300">No labels yet</h6>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export const StateStatComponent = observer((props: TStateStatComponent) => {
|
||||||
|
const { distribution, isEditable, totalIssuesCount, handleFiltersUpdate } = props;
|
||||||
|
// hooks
|
||||||
|
const { groupedProjectStates } = useProjectState();
|
||||||
|
// derived values
|
||||||
|
const getStateGroupState = (stateGroup: string) => {
|
||||||
|
const stateGroupStates = groupedProjectStates?.[stateGroup];
|
||||||
|
const stateGroupStatesId = stateGroupStates?.map((state) => state.id);
|
||||||
|
return stateGroupStatesId;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{distribution.map((group, index) => (
|
||||||
|
<SingleProgressStats
|
||||||
|
key={index}
|
||||||
|
title={
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<StateGroupIcon stateGroup={group.state as TStateGroups} />
|
||||||
|
<span className="text-xs capitalize">{group.state}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
completed={group.completed}
|
||||||
|
total={totalIssuesCount}
|
||||||
|
{...(isEditable && {
|
||||||
|
onClick: () => group.state && handleFiltersUpdate("state", getStateGroupState(group.state) ?? []),
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const progressStats = [
|
||||||
|
{
|
||||||
|
key: "stat-assignees",
|
||||||
|
title: "Assignees",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "stat-labels",
|
||||||
|
title: "Labels",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "stat-states",
|
||||||
|
title: "States",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
type TCycleProgressStats = {
|
||||||
|
cycleId: string;
|
||||||
|
plotType: TCyclePlotType;
|
||||||
|
distribution: TCycleDistribution | TCycleEstimateDistribution | undefined;
|
||||||
|
groupedIssues: Record<string, number>;
|
||||||
|
totalIssuesCount: number;
|
||||||
|
isEditable?: boolean;
|
||||||
|
filters?: IIssueFilters | undefined;
|
||||||
|
handleFiltersUpdate: (key: keyof IIssueFilterOptions, value: string | string[]) => void;
|
||||||
|
size?: "xs" | "sm";
|
||||||
|
roundedTab?: boolean;
|
||||||
|
noBackground?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CycleProgressStats: FC<TCycleProgressStats> = observer((props) => {
|
||||||
|
const {
|
||||||
|
cycleId,
|
||||||
|
plotType,
|
||||||
|
distribution,
|
||||||
|
groupedIssues,
|
||||||
|
totalIssuesCount,
|
||||||
|
isEditable = false,
|
||||||
|
filters,
|
||||||
|
handleFiltersUpdate,
|
||||||
|
size = "sm",
|
||||||
|
roundedTab = false,
|
||||||
|
noBackground = false,
|
||||||
|
} = props;
|
||||||
|
// hooks
|
||||||
|
const { storedValue: currentTab, setValue: setCycleTab } = useLocalStorage(
|
||||||
|
`cycle-analytics-tab-${cycleId}`,
|
||||||
|
"stat-assignees"
|
||||||
|
);
|
||||||
|
// derived values
|
||||||
|
const currentTabIndex = (tab: string): number => progressStats.findIndex((stat) => stat.key === tab);
|
||||||
|
|
||||||
|
const currentDistribution = distribution as TCycleDistribution;
|
||||||
|
const currentEstimateDistribution = distribution as TCycleEstimateDistribution;
|
||||||
|
|
||||||
|
const distributionAssigneeData: TAssigneeData =
|
||||||
|
plotType === "burndown"
|
||||||
|
? (currentDistribution?.assignees || []).map((assignee) => ({
|
||||||
|
id: assignee?.assignee_id || undefined,
|
||||||
|
title: assignee?.display_name || undefined,
|
||||||
|
avatar: assignee?.avatar || undefined,
|
||||||
|
completed: assignee.completed_issues,
|
||||||
|
total: assignee.total_issues,
|
||||||
|
}))
|
||||||
|
: (currentEstimateDistribution?.assignees || []).map((assignee) => ({
|
||||||
|
id: assignee?.assignee_id || undefined,
|
||||||
|
title: assignee?.display_name || undefined,
|
||||||
|
avatar: assignee?.avatar || undefined,
|
||||||
|
completed: assignee.completed_estimates,
|
||||||
|
total: assignee.total_estimates,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const distributionLabelData: TLabelData =
|
||||||
|
plotType === "burndown"
|
||||||
|
? (currentDistribution?.labels || []).map((label) => ({
|
||||||
|
id: label?.label_id || undefined,
|
||||||
|
title: label?.label_name || undefined,
|
||||||
|
color: label?.color || undefined,
|
||||||
|
completed: label.completed_issues,
|
||||||
|
total: label.total_issues,
|
||||||
|
}))
|
||||||
|
: (currentEstimateDistribution?.labels || []).map((label) => ({
|
||||||
|
id: label?.label_id || undefined,
|
||||||
|
title: label?.label_name || undefined,
|
||||||
|
color: label?.color || undefined,
|
||||||
|
completed: label.completed_estimates,
|
||||||
|
total: label.total_estimates,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const distributionStateData: TStateData = Object.keys(groupedIssues || {}).map((state) => ({
|
||||||
|
state: state,
|
||||||
|
completed: groupedIssues?.[state] || 0,
|
||||||
|
total: totalIssuesCount || 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Tab.Group defaultIndex={currentTabIndex(currentTab ? currentTab : "stat-assignees")}>
|
||||||
|
<Tab.List
|
||||||
|
as="div"
|
||||||
|
className={cn(
|
||||||
|
`flex w-full items-center justify-between gap-2 rounded-md p-1`,
|
||||||
|
roundedTab ? `rounded-3xl` : `rounded-md`,
|
||||||
|
noBackground ? `` : `bg-custom-background-90`,
|
||||||
|
size === "xs" ? `text-xs` : `text-sm`
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{progressStats.map((stat) => (
|
||||||
|
<Tab
|
||||||
|
className={cn(
|
||||||
|
`p-1 w-full text-custom-text-100 outline-none focus:outline-none cursor-pointer transition-all`,
|
||||||
|
roundedTab ? `rounded-3xl border border-custom-border-200` : `rounded`,
|
||||||
|
stat.key === currentTab
|
||||||
|
? "bg-custom-background-100 text-custom-text-300"
|
||||||
|
: "text-custom-text-400 hover:text-custom-text-300"
|
||||||
|
)}
|
||||||
|
key={stat.key}
|
||||||
|
onClick={() => setCycleTab(stat.key)}
|
||||||
|
>
|
||||||
|
{stat.title}
|
||||||
|
</Tab>
|
||||||
|
))}
|
||||||
|
</Tab.List>
|
||||||
|
<Tab.Panels className="py-3 text-custom-text-200">
|
||||||
|
<Tab.Panel key={"stat-assignees"}>
|
||||||
|
<AssigneeStatComponent
|
||||||
|
distribution={distributionAssigneeData}
|
||||||
|
isEditable={isEditable}
|
||||||
|
filters={filters}
|
||||||
|
handleFiltersUpdate={handleFiltersUpdate}
|
||||||
|
/>
|
||||||
|
</Tab.Panel>
|
||||||
|
<Tab.Panel key={"stat-labels"}>
|
||||||
|
<LabelStatComponent
|
||||||
|
distribution={distributionLabelData}
|
||||||
|
isEditable={isEditable}
|
||||||
|
filters={filters}
|
||||||
|
handleFiltersUpdate={handleFiltersUpdate}
|
||||||
|
/>
|
||||||
|
</Tab.Panel>
|
||||||
|
<Tab.Panel key={"stat-states"}>
|
||||||
|
<StateStatComponent
|
||||||
|
distribution={distributionStateData}
|
||||||
|
totalIssuesCount={totalIssuesCount}
|
||||||
|
isEditable={isEditable}
|
||||||
|
handleFiltersUpdate={handleFiltersUpdate}
|
||||||
|
/>
|
||||||
|
</Tab.Panel>
|
||||||
|
</Tab.Panels>
|
||||||
|
</Tab.Group>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
@ -1,42 +1,29 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useCallback, useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import isEmpty from "lodash/isEmpty";
|
import isEmpty from "lodash/isEmpty";
|
||||||
import isEqual from "lodash/isEqual";
|
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { Controller, useForm } from "react-hook-form";
|
import { Controller, useForm } from "react-hook-form";
|
||||||
// icons
|
// icons
|
||||||
import {
|
import { ArchiveRestoreIcon, LinkIcon, Trash2, ChevronRight, CalendarClock, SquareUser } from "lucide-react";
|
||||||
ArchiveRestoreIcon,
|
|
||||||
ChevronDown,
|
|
||||||
LinkIcon,
|
|
||||||
Trash2,
|
|
||||||
AlertCircle,
|
|
||||||
ChevronRight,
|
|
||||||
CalendarClock,
|
|
||||||
SquareUser,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { Disclosure, Transition } from "@headlessui/react";
|
|
||||||
// types
|
// types
|
||||||
import { ICycle, IIssueFilterOptions } from "@plane/types";
|
import { ICycle } from "@plane/types";
|
||||||
// ui
|
// ui
|
||||||
import { Avatar, ArchiveIcon, CustomMenu, Loader, LayersIcon, TOAST_TYPE, setToast, TextArea } from "@plane/ui";
|
import { Avatar, ArchiveIcon, CustomMenu, Loader, LayersIcon, TOAST_TYPE, setToast, TextArea } from "@plane/ui";
|
||||||
// components
|
// components
|
||||||
import { SidebarProgressStats } from "@/components/core";
|
import { ArchiveCycleModal, CycleDeleteModal, CycleAnalyticsProgress } from "@/components/cycles";
|
||||||
import ProgressChart from "@/components/core/sidebar/progress-chart";
|
|
||||||
import { ArchiveCycleModal, CycleDeleteModal } from "@/components/cycles";
|
|
||||||
import { DateRangeDropdown } from "@/components/dropdowns";
|
import { DateRangeDropdown } from "@/components/dropdowns";
|
||||||
// constants
|
// constants
|
||||||
import { CYCLE_STATUS } from "@/constants/cycle";
|
import { CYCLE_STATUS } from "@/constants/cycle";
|
||||||
|
import { EEstimateSystem } from "@/constants/estimates";
|
||||||
import { CYCLE_UPDATED } from "@/constants/event-tracker";
|
import { CYCLE_UPDATED } from "@/constants/event-tracker";
|
||||||
import { EIssueFilterType, EIssuesStoreType } from "@/constants/issue";
|
|
||||||
import { EUserWorkspaceRoles } from "@/constants/workspace";
|
import { EUserWorkspaceRoles } from "@/constants/workspace";
|
||||||
// helpers
|
// helpers
|
||||||
import { findHowManyDaysLeft, getDate, renderFormattedPayloadDate } from "@/helpers/date-time.helper";
|
import { findHowManyDaysLeft, getDate, renderFormattedPayloadDate } from "@/helpers/date-time.helper";
|
||||||
import { copyUrlToClipboard } from "@/helpers/string.helper";
|
import { copyUrlToClipboard } from "@/helpers/string.helper";
|
||||||
// hooks
|
// hooks
|
||||||
import { useEventTracker, useCycle, useUser, useMember, useIssues } from "@/hooks/store";
|
import { useEventTracker, useCycle, useUser, useMember, useProjectEstimates } from "@/hooks/store";
|
||||||
// services
|
// services
|
||||||
import { CycleService } from "@/services/cycle.service";
|
import { CycleService } from "@/services/cycle.service";
|
||||||
|
|
||||||
@ -63,10 +50,9 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
|
|||||||
// router
|
// router
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { workspaceSlug, projectId } = useParams();
|
const { workspaceSlug, projectId } = useParams();
|
||||||
const searchParams = useSearchParams();
|
|
||||||
const peekCycle = searchParams.get("peekCycle");
|
|
||||||
// store hooks
|
// store hooks
|
||||||
const { setTrackElement, captureCycleEvent } = useEventTracker();
|
const { setTrackElement, captureCycleEvent } = useEventTracker();
|
||||||
|
const { areEstimateEnabledByProjectId, currentActiveEstimateId, estimateById } = useProjectEstimates();
|
||||||
const {
|
const {
|
||||||
membership: { currentProjectRole },
|
membership: { currentProjectRole },
|
||||||
} = useUser();
|
} = useUser();
|
||||||
@ -197,58 +183,9 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const {
|
|
||||||
issuesFilter: { issueFilters, updateFilters },
|
|
||||||
} = useIssues(EIssuesStoreType.CYCLE);
|
|
||||||
|
|
||||||
const handleFiltersUpdate = useCallback(
|
|
||||||
(key: keyof IIssueFilterOptions, value: string | string[]) => {
|
|
||||||
if (!workspaceSlug || !projectId) return;
|
|
||||||
let newValues = issueFilters?.filters?.[key] ?? [];
|
|
||||||
|
|
||||||
if (Array.isArray(value)) {
|
|
||||||
if (key === "state") {
|
|
||||||
if (isEqual(newValues, value)) newValues = [];
|
|
||||||
else newValues = value;
|
|
||||||
} else {
|
|
||||||
value.forEach((val) => {
|
|
||||||
if (!newValues.includes(val)) newValues.push(val);
|
|
||||||
else newValues.splice(newValues.indexOf(val), 1);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
|
|
||||||
else newValues.push(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
updateFilters(
|
|
||||||
workspaceSlug.toString(),
|
|
||||||
projectId.toString(),
|
|
||||||
EIssueFilterType.FILTERS,
|
|
||||||
{ [key]: newValues },
|
|
||||||
cycleId
|
|
||||||
);
|
|
||||||
},
|
|
||||||
[workspaceSlug, projectId, cycleId, issueFilters, updateFilters]
|
|
||||||
);
|
|
||||||
|
|
||||||
const cycleStatus = cycleDetails?.status?.toLocaleLowerCase();
|
const cycleStatus = cycleDetails?.status?.toLocaleLowerCase();
|
||||||
const isCompleted = cycleStatus === "completed";
|
const isCompleted = cycleStatus === "completed";
|
||||||
|
|
||||||
const startDate = getDate(cycleDetails?.start_date);
|
|
||||||
const endDate = getDate(cycleDetails?.end_date);
|
|
||||||
|
|
||||||
const isStartValid = startDate && startDate <= new Date();
|
|
||||||
const isEndValid = endDate && startDate && endDate >= startDate;
|
|
||||||
|
|
||||||
const progressPercentage = cycleDetails
|
|
||||||
? isCompleted && cycleDetails?.progress_snapshot
|
|
||||||
? Math.round(
|
|
||||||
(cycleDetails.progress_snapshot.completed_issues / cycleDetails.progress_snapshot.total_issues) * 100
|
|
||||||
)
|
|
||||||
: Math.round((cycleDetails.completed_issues / cycleDetails.total_issues) * 100)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (!cycleDetails)
|
if (!cycleDetails)
|
||||||
return (
|
return (
|
||||||
<Loader className="px-5">
|
<Loader className="px-5">
|
||||||
@ -266,6 +203,17 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
|
|||||||
|
|
||||||
const currentCycle = CYCLE_STATUS.find((status) => status.value === cycleStatus);
|
const currentCycle = CYCLE_STATUS.find((status) => status.value === cycleStatus);
|
||||||
|
|
||||||
|
const areEstimateEnabled = projectId && areEstimateEnabledByProjectId(projectId.toString());
|
||||||
|
const estimateType = areEstimateEnabled && currentActiveEstimateId && estimateById(currentActiveEstimateId);
|
||||||
|
// NOTE: validate if the cycle is snapshot and the estimate system is points
|
||||||
|
const isEstimatePointValid = isEmpty(cycleDetails?.progress_snapshot || {})
|
||||||
|
? estimateType && estimateType?.type == EEstimateSystem.POINTS
|
||||||
|
? true
|
||||||
|
: false
|
||||||
|
: isEmpty(cycleDetails?.progress_snapshot?.estimate_distribution || {})
|
||||||
|
? false
|
||||||
|
: true;
|
||||||
|
|
||||||
const issueCount =
|
const issueCount =
|
||||||
isCompleted && !isEmpty(cycleDetails.progress_snapshot)
|
isCompleted && !isEmpty(cycleDetails.progress_snapshot)
|
||||||
? cycleDetails.progress_snapshot.total_issues === 0
|
? cycleDetails.progress_snapshot.total_issues === 0
|
||||||
@ -275,6 +223,15 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
|
|||||||
? "0 Issue"
|
? "0 Issue"
|
||||||
: `${cycleDetails.completed_issues}/${cycleDetails.total_issues}`;
|
: `${cycleDetails.completed_issues}/${cycleDetails.total_issues}`;
|
||||||
|
|
||||||
|
const issueEstimatePointCount =
|
||||||
|
isCompleted && !isEmpty(cycleDetails.progress_snapshot)
|
||||||
|
? cycleDetails.progress_snapshot.total_issues === 0
|
||||||
|
? "0 Issue"
|
||||||
|
: `${cycleDetails.progress_snapshot.completed_estimate_points}/${cycleDetails.progress_snapshot.total_estimate_points}`
|
||||||
|
: cycleDetails.total_issues === 0
|
||||||
|
? "0 Issue"
|
||||||
|
: `${cycleDetails.completed_estimate_points}/${cycleDetails.total_estimate_points}`;
|
||||||
|
|
||||||
const daysLeft = findHowManyDaysLeft(cycleDetails.end_date);
|
const daysLeft = findHowManyDaysLeft(cycleDetails.end_date);
|
||||||
|
|
||||||
const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserWorkspaceRoles.MEMBER;
|
const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserWorkspaceRoles.MEMBER;
|
||||||
@ -456,160 +413,30 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
|
|||||||
<span className="px-1.5 text-sm text-custom-text-300">{issueCount}</span>
|
<span className="px-1.5 text-sm text-custom-text-300">{issueCount}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/**
|
||||||
|
* NOTE: Render this section when estimate points of he projects is enabled and the estimate system is points
|
||||||
|
*/}
|
||||||
|
{isEstimatePointValid && (
|
||||||
|
<div className="flex items-center justify-start gap-1">
|
||||||
|
<div className="flex w-2/5 items-center justify-start gap-2 text-custom-text-300">
|
||||||
|
<LayersIcon className="h-4 w-4" />
|
||||||
|
<span className="text-base">Points</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex w-3/5 items-center">
|
||||||
|
<span className="px-1.5 text-sm text-custom-text-300">{issueEstimatePointCount}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col">
|
{workspaceSlug && projectId && cycleDetails?.id && (
|
||||||
<div className="flex w-full flex-col items-center justify-start gap-2 border-t border-custom-border-200 px-1.5 py-5">
|
<CycleAnalyticsProgress
|
||||||
<Disclosure defaultOpen>
|
workspaceSlug={workspaceSlug.toString()}
|
||||||
{({ open }) => (
|
projectId={projectId.toString()}
|
||||||
<div className={`relative flex h-full w-full flex-col ${open ? "" : "flex-row"}`}>
|
cycleId={cycleDetails?.id}
|
||||||
<Disclosure.Button
|
/>
|
||||||
className="flex w-full items-center justify-between gap-2 p-1.5"
|
)}
|
||||||
disabled={!isStartValid || !isEndValid}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-start gap-2 text-sm">
|
|
||||||
<span className="font-medium text-custom-text-200">Progress</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2.5">
|
|
||||||
{progressPercentage ? (
|
|
||||||
<span className="flex h-5 w-9 items-center justify-center rounded bg-amber-500/20 text-xs font-medium text-amber-500">
|
|
||||||
{progressPercentage ? `${progressPercentage}%` : ""}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
""
|
|
||||||
)}
|
|
||||||
{isStartValid && isEndValid ? (
|
|
||||||
<ChevronDown className={`h-3 w-3 ${open ? "rotate-180 transform" : ""}`} aria-hidden="true" />
|
|
||||||
) : (
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<AlertCircle height={14} width={14} className="text-custom-text-200" />
|
|
||||||
<span className="text-xs italic text-custom-text-200">
|
|
||||||
{cycleDetails?.start_date && cycleDetails?.end_date
|
|
||||||
? "This cycle isn't active yet."
|
|
||||||
: "Invalid date. Please enter valid date."}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Disclosure.Button>
|
|
||||||
<Transition show={open}>
|
|
||||||
<Disclosure.Panel>
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
{isCompleted && !isEmpty(cycleDetails.progress_snapshot) ? (
|
|
||||||
<>
|
|
||||||
{cycleDetails.progress_snapshot.distribution?.completion_chart &&
|
|
||||||
cycleDetails.start_date &&
|
|
||||||
cycleDetails.end_date && (
|
|
||||||
<div className="h-full w-full pt-4">
|
|
||||||
<div className="flex items-start gap-4 py-2 text-xs">
|
|
||||||
<div className="flex items-center gap-3 text-custom-text-100">
|
|
||||||
<div className="flex items-center justify-center gap-1">
|
|
||||||
<span className="h-2.5 w-2.5 rounded-full bg-[#A9BBD0]" />
|
|
||||||
<span>Ideal</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-center gap-1">
|
|
||||||
<span className="h-2.5 w-2.5 rounded-full bg-[#4C8FFF]" />
|
|
||||||
<span>Current</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="relative h-40 w-80">
|
|
||||||
<ProgressChart
|
|
||||||
distribution={cycleDetails.progress_snapshot.distribution?.completion_chart}
|
|
||||||
startDate={cycleDetails.start_date}
|
|
||||||
endDate={cycleDetails.end_date}
|
|
||||||
totalIssues={cycleDetails.progress_snapshot.total_issues}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{cycleDetails.distribution?.completion_chart &&
|
|
||||||
cycleDetails.start_date &&
|
|
||||||
cycleDetails.end_date && (
|
|
||||||
<div className="h-full w-full pt-4">
|
|
||||||
<div className="flex items-start gap-4 py-2 text-xs">
|
|
||||||
<div className="flex items-center gap-3 text-custom-text-100">
|
|
||||||
<div className="flex items-center justify-center gap-1">
|
|
||||||
<span className="h-2.5 w-2.5 rounded-full bg-[#A9BBD0]" />
|
|
||||||
<span>Ideal</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-center gap-1">
|
|
||||||
<span className="h-2.5 w-2.5 rounded-full bg-[#4C8FFF]" />
|
|
||||||
<span>Current</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="relative h-40 w-80">
|
|
||||||
<ProgressChart
|
|
||||||
distribution={cycleDetails.distribution?.completion_chart}
|
|
||||||
startDate={cycleDetails.start_date}
|
|
||||||
endDate={cycleDetails.end_date}
|
|
||||||
totalIssues={cycleDetails.total_issues}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{/* stats */}
|
|
||||||
{isCompleted && !isEmpty(cycleDetails.progress_snapshot) ? (
|
|
||||||
<>
|
|
||||||
{cycleDetails.progress_snapshot.total_issues > 0 &&
|
|
||||||
cycleDetails.progress_snapshot.distribution && (
|
|
||||||
<div className="h-full w-full border-t border-custom-border-200 pt-5">
|
|
||||||
<SidebarProgressStats
|
|
||||||
distribution={cycleDetails.progress_snapshot.distribution}
|
|
||||||
groupedIssues={{
|
|
||||||
backlog: cycleDetails.progress_snapshot.backlog_issues,
|
|
||||||
unstarted: cycleDetails.progress_snapshot.unstarted_issues,
|
|
||||||
started: cycleDetails.progress_snapshot.started_issues,
|
|
||||||
completed: cycleDetails.progress_snapshot.completed_issues,
|
|
||||||
cancelled: cycleDetails.progress_snapshot.cancelled_issues,
|
|
||||||
}}
|
|
||||||
totalIssues={cycleDetails.progress_snapshot.total_issues}
|
|
||||||
isPeekView={Boolean(peekCycle)}
|
|
||||||
isCompleted={isCompleted}
|
|
||||||
filters={issueFilters}
|
|
||||||
handleFiltersUpdate={handleFiltersUpdate}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{cycleDetails.total_issues > 0 && cycleDetails.distribution && (
|
|
||||||
<div className="h-full w-full border-t border-custom-border-200 pt-5">
|
|
||||||
<SidebarProgressStats
|
|
||||||
distribution={cycleDetails.distribution}
|
|
||||||
groupedIssues={{
|
|
||||||
backlog: cycleDetails.backlog_issues,
|
|
||||||
unstarted: cycleDetails.unstarted_issues,
|
|
||||||
started: cycleDetails.started_issues,
|
|
||||||
completed: cycleDetails.completed_issues,
|
|
||||||
cancelled: cycleDetails.cancelled_issues,
|
|
||||||
}}
|
|
||||||
totalIssues={cycleDetails.total_issues}
|
|
||||||
isPeekView={Boolean(peekCycle)}
|
|
||||||
isCompleted={isCompleted}
|
|
||||||
filters={issueFilters}
|
|
||||||
handleFiltersUpdate={handleFiltersUpdate}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Disclosure.Panel>
|
|
||||||
</Transition>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Disclosure>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
@ -5,7 +5,7 @@ import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
|||||||
import { generateQueryParams } from "@/helpers/router.helper";
|
import { generateQueryParams } from "@/helpers/router.helper";
|
||||||
import { useCycle } from "@/hooks/store";
|
import { useCycle } from "@/hooks/store";
|
||||||
// components
|
// components
|
||||||
import { CycleDetailsSidebar } from "./sidebar";
|
import { CycleDetailsSidebar } from "./";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
@ -25,7 +25,7 @@ export const CyclePeekOverview: React.FC<Props> = observer(({ projectId, workspa
|
|||||||
const { fetchCycleDetails, fetchArchivedCycleDetails } = useCycle();
|
const { fetchCycleDetails, fetchArchivedCycleDetails } = useCycle();
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
const query = generateQueryParams(searchParams, ['peekCycle']);
|
const query = generateQueryParams(searchParams, ["peekCycle"]);
|
||||||
router.push(`${pathname}?${query}`);
|
router.push(`${pathname}?${query}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -11,10 +11,10 @@ export * from "./delete-modal";
|
|||||||
export * from "./form";
|
export * from "./form";
|
||||||
export * from "./modal";
|
export * from "./modal";
|
||||||
export * from "./quick-actions";
|
export * from "./quick-actions";
|
||||||
export * from "./sidebar";
|
|
||||||
export * from "./transfer-issues-modal";
|
export * from "./transfer-issues-modal";
|
||||||
export * from "./transfer-issues";
|
export * from "./transfer-issues";
|
||||||
export * from "./cycles-view-header";
|
export * from "./cycles-view-header";
|
||||||
|
|
||||||
|
export * from "./analytics-sidebar";
|
||||||
// archived cycles
|
// archived cycles
|
||||||
export * from "./archived-cycles";
|
export * from "./archived-cycles";
|
||||||
|
3
web/core/components/modules/analytics-sidebar/index.ts
Normal file
3
web/core/components/modules/analytics-sidebar/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export * from "./root";
|
||||||
|
export * from "./issue-progress";
|
||||||
|
export * from "./progress-stats";
|
260
web/core/components/modules/analytics-sidebar/issue-progress.tsx
Normal file
260
web/core/components/modules/analytics-sidebar/issue-progress.tsx
Normal file
@ -0,0 +1,260 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { FC, Fragment, useCallback, useMemo, useState } from "react";
|
||||||
|
import isEqual from "lodash/isEqual";
|
||||||
|
import { observer } from "mobx-react";
|
||||||
|
import { useSearchParams } from "next/navigation";
|
||||||
|
import { AlertCircle, ChevronUp, ChevronDown } from "lucide-react";
|
||||||
|
import { Disclosure, Transition } from "@headlessui/react";
|
||||||
|
import { IIssueFilterOptions, TModulePlotType } from "@plane/types";
|
||||||
|
import { CustomSelect, Spinner } from "@plane/ui";
|
||||||
|
// components
|
||||||
|
import ProgressChart from "@/components/core/sidebar/progress-chart";
|
||||||
|
import { ModuleProgressStats } from "@/components/modules";
|
||||||
|
// constants
|
||||||
|
import { EEstimateSystem } from "@/constants/estimates";
|
||||||
|
import { EIssueFilterType, EIssuesStoreType } from "@/constants/issue";
|
||||||
|
// helpers
|
||||||
|
import { getDate } from "@/helpers/date-time.helper";
|
||||||
|
// hooks
|
||||||
|
import { useIssues, useModule, useProjectEstimates } from "@/hooks/store";
|
||||||
|
|
||||||
|
type TModuleAnalyticsProgress = {
|
||||||
|
workspaceSlug: string;
|
||||||
|
projectId: string;
|
||||||
|
moduleId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const moduleBurnDownChartOptions = [
|
||||||
|
{ value: "burndown", label: "Issues" },
|
||||||
|
{ value: "points", label: "Points" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const ModuleAnalyticsProgress: FC<TModuleAnalyticsProgress> = observer((props) => {
|
||||||
|
// props
|
||||||
|
const { workspaceSlug, projectId, moduleId } = props;
|
||||||
|
// router
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const peekModule = searchParams.get("peekModule") || undefined;
|
||||||
|
// hooks
|
||||||
|
const { areEstimateEnabledByProjectId, currentActiveEstimateId, estimateById } = useProjectEstimates();
|
||||||
|
const { getPlotTypeByModuleId, setPlotType, getModuleById, fetchModuleDetails } = useModule();
|
||||||
|
const {
|
||||||
|
issuesFilter: { issueFilters, updateFilters },
|
||||||
|
} = useIssues(EIssuesStoreType.MODULE);
|
||||||
|
// state
|
||||||
|
const [loader, setLoader] = useState(false);
|
||||||
|
|
||||||
|
// derived values
|
||||||
|
const moduleDetails = getModuleById(moduleId);
|
||||||
|
const plotType: TModulePlotType = getPlotTypeByModuleId(moduleId);
|
||||||
|
const isCurrentProjectEstimateEnabled = projectId && areEstimateEnabledByProjectId(projectId) ? true : false;
|
||||||
|
const estimateDetails =
|
||||||
|
isCurrentProjectEstimateEnabled && currentActiveEstimateId && estimateById(currentActiveEstimateId);
|
||||||
|
const isCurrentEstimateTypeIsPoints = estimateDetails && estimateDetails?.type === EEstimateSystem.POINTS;
|
||||||
|
|
||||||
|
const completedIssues = moduleDetails?.completed_issues || 0;
|
||||||
|
const totalIssues = moduleDetails?.total_issues || 0;
|
||||||
|
const completedEstimatePoints = moduleDetails?.completed_estimate_points || 0;
|
||||||
|
const totalEstimatePoints = moduleDetails?.total_estimate_points || 0;
|
||||||
|
|
||||||
|
const progressHeaderPercentage = moduleDetails
|
||||||
|
? plotType === "points"
|
||||||
|
? completedEstimatePoints != 0 && totalEstimatePoints != 0
|
||||||
|
? Math.round((completedEstimatePoints / totalEstimatePoints) * 100)
|
||||||
|
: 0
|
||||||
|
: completedIssues != 0 && completedIssues != 0
|
||||||
|
? Math.round((completedIssues / totalIssues) * 100)
|
||||||
|
: 0
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const chartDistributionData =
|
||||||
|
plotType === "points" ? moduleDetails?.estimate_distribution : moduleDetails?.distribution || undefined;
|
||||||
|
const completionChartDistributionData = chartDistributionData?.completion_chart || undefined;
|
||||||
|
|
||||||
|
const groupedIssues = useMemo(
|
||||||
|
() => ({
|
||||||
|
backlog: plotType === "points" ? moduleDetails?.backlog_estimate_points || 0 : moduleDetails?.backlog_issues || 0,
|
||||||
|
unstarted:
|
||||||
|
plotType === "points" ? moduleDetails?.unstarted_estimate_points || 0 : moduleDetails?.unstarted_issues || 0,
|
||||||
|
started: plotType === "points" ? moduleDetails?.started_estimate_points || 0 : moduleDetails?.started_issues || 0,
|
||||||
|
completed:
|
||||||
|
plotType === "points" ? moduleDetails?.completed_estimate_points || 0 : moduleDetails?.completed_issues || 0,
|
||||||
|
cancelled:
|
||||||
|
plotType === "points" ? moduleDetails?.cancelled_estimate_points || 0 : moduleDetails?.cancelled_issues || 0,
|
||||||
|
}),
|
||||||
|
[plotType, moduleDetails]
|
||||||
|
);
|
||||||
|
|
||||||
|
const moduleStartDate = getDate(moduleDetails?.start_date);
|
||||||
|
const moduleEndDate = getDate(moduleDetails?.target_date);
|
||||||
|
const isModuleStartDateValid = moduleStartDate && moduleStartDate <= new Date();
|
||||||
|
const isModuleEndDateValid = moduleStartDate && moduleEndDate && moduleEndDate >= moduleStartDate;
|
||||||
|
const isModuleDateValid = isModuleStartDateValid && isModuleEndDateValid;
|
||||||
|
|
||||||
|
// handlers
|
||||||
|
const onChange = async (value: TModulePlotType) => {
|
||||||
|
setPlotType(moduleId, value);
|
||||||
|
if (!workspaceSlug || !projectId || !moduleId) return;
|
||||||
|
try {
|
||||||
|
setLoader(true);
|
||||||
|
await fetchModuleDetails(workspaceSlug, projectId, moduleId);
|
||||||
|
setLoader(false);
|
||||||
|
} catch (error) {
|
||||||
|
setLoader(false);
|
||||||
|
setPlotType(moduleId, plotType);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFiltersUpdate = useCallback(
|
||||||
|
(key: keyof IIssueFilterOptions, value: string | string[]) => {
|
||||||
|
if (!workspaceSlug || !projectId) return;
|
||||||
|
|
||||||
|
let newValues = issueFilters?.filters?.[key] ?? [];
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
if (key === "state") {
|
||||||
|
if (isEqual(newValues, value)) newValues = [];
|
||||||
|
else newValues = value;
|
||||||
|
} else {
|
||||||
|
value.forEach((val) => {
|
||||||
|
if (!newValues.includes(val)) newValues.push(val);
|
||||||
|
else newValues.splice(newValues.indexOf(val), 1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
|
||||||
|
else newValues.push(value);
|
||||||
|
}
|
||||||
|
updateFilters(
|
||||||
|
workspaceSlug.toString(),
|
||||||
|
projectId.toString(),
|
||||||
|
EIssueFilterType.FILTERS,
|
||||||
|
{ [key]: newValues },
|
||||||
|
moduleId
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[workspaceSlug, projectId, moduleId, issueFilters, updateFilters]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!moduleDetails) return <></>;
|
||||||
|
return (
|
||||||
|
<div className="border-t border-custom-border-200 space-y-4 py-4 px-3">
|
||||||
|
<Disclosure defaultOpen={isModuleDateValid ? true : false}>
|
||||||
|
{({ open }) => (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* progress bar header */}
|
||||||
|
{isModuleDateValid ? (
|
||||||
|
<div className="relative w-full flex justify-between items-center gap-2">
|
||||||
|
<Disclosure.Button className="relative flex items-center gap-2 w-full">
|
||||||
|
<div className="font-medium text-custom-text-200 text-sm">Progress</div>
|
||||||
|
{progressHeaderPercentage > 0 && (
|
||||||
|
<div className="flex h-5 w-9 items-center justify-center rounded bg-amber-500/20 text-xs font-medium text-amber-500">{`${progressHeaderPercentage}%`}</div>
|
||||||
|
)}
|
||||||
|
</Disclosure.Button>
|
||||||
|
{isCurrentEstimateTypeIsPoints && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<CustomSelect
|
||||||
|
value={plotType}
|
||||||
|
label={
|
||||||
|
<span>{moduleBurnDownChartOptions.find((v) => v.value === plotType)?.label ?? "None"}</span>
|
||||||
|
}
|
||||||
|
onChange={onChange}
|
||||||
|
maxHeight="lg"
|
||||||
|
>
|
||||||
|
{moduleBurnDownChartOptions.map((item) => (
|
||||||
|
<CustomSelect.Option key={item.value} value={item.value}>
|
||||||
|
{item.label}
|
||||||
|
</CustomSelect.Option>
|
||||||
|
))}
|
||||||
|
</CustomSelect>
|
||||||
|
</div>
|
||||||
|
{loader && <Spinner className="h-3 w-3" />}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Disclosure.Button className="ml-auto">
|
||||||
|
{open ? (
|
||||||
|
<ChevronUp className="h-3.5 w-3.5" aria-hidden="true" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="h-3.5 w-3.5" aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
</Disclosure.Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="relative w-full flex justify-between items-center gap-2">
|
||||||
|
<div className="font-medium text-custom-text-200 text-sm">Progress</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<AlertCircle height={14} width={14} className="text-custom-text-200" />
|
||||||
|
<span className="text-xs italic text-custom-text-200">
|
||||||
|
{moduleDetails?.start_date && moduleDetails?.target_date
|
||||||
|
? "This module isn't active yet."
|
||||||
|
: "Invalid date. Please enter valid date."}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Transition show={open}>
|
||||||
|
<Disclosure.Panel className="space-y-4">
|
||||||
|
{/* progress burndown chart */}
|
||||||
|
<div>
|
||||||
|
<div className="relative flex items-center gap-2">
|
||||||
|
<div className="flex items-center justify-center gap-1 text-xs">
|
||||||
|
<span className="h-2.5 w-2.5 rounded-full bg-[#A9BBD0]" />
|
||||||
|
<span>Ideal</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-center gap-1 text-xs">
|
||||||
|
<span className="h-2.5 w-2.5 rounded-full bg-[#4C8FFF]" />
|
||||||
|
<span>Current</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{moduleStartDate && moduleEndDate && completionChartDistributionData && (
|
||||||
|
<Fragment>
|
||||||
|
{plotType === "points" ? (
|
||||||
|
<ProgressChart
|
||||||
|
distribution={completionChartDistributionData}
|
||||||
|
startDate={moduleStartDate}
|
||||||
|
endDate={moduleEndDate}
|
||||||
|
totalIssues={totalEstimatePoints}
|
||||||
|
plotTitle={"points"}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ProgressChart
|
||||||
|
distribution={completionChartDistributionData}
|
||||||
|
startDate={moduleStartDate}
|
||||||
|
endDate={moduleEndDate}
|
||||||
|
totalIssues={totalIssues}
|
||||||
|
plotTitle={"issues"}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* progress detailed view */}
|
||||||
|
{chartDistributionData && (
|
||||||
|
<div className="w-full border-t border-custom-border-200 pt-5">
|
||||||
|
<ModuleProgressStats
|
||||||
|
moduleId={moduleId}
|
||||||
|
plotType={plotType}
|
||||||
|
distribution={chartDistributionData}
|
||||||
|
groupedIssues={groupedIssues}
|
||||||
|
totalIssuesCount={plotType === "points" ? totalEstimatePoints || 0 : totalIssues || 0}
|
||||||
|
isEditable={Boolean(peekModule)}
|
||||||
|
size="xs"
|
||||||
|
roundedTab={false}
|
||||||
|
noBackground={false}
|
||||||
|
filters={issueFilters}
|
||||||
|
handleFiltersUpdate={handleFiltersUpdate}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Disclosure.Panel>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Disclosure>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
372
web/core/components/modules/analytics-sidebar/progress-stats.tsx
Normal file
372
web/core/components/modules/analytics-sidebar/progress-stats.tsx
Normal file
@ -0,0 +1,372 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { FC } from "react";
|
||||||
|
import { observer } from "mobx-react";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { Tab } from "@headlessui/react";
|
||||||
|
import {
|
||||||
|
IIssueFilterOptions,
|
||||||
|
IIssueFilters,
|
||||||
|
TModuleDistribution,
|
||||||
|
TModuleEstimateDistribution,
|
||||||
|
TModulePlotType,
|
||||||
|
TStateGroups,
|
||||||
|
} from "@plane/types";
|
||||||
|
import { Avatar, StateGroupIcon } from "@plane/ui";
|
||||||
|
// components
|
||||||
|
import { SingleProgressStats } from "@/components/core";
|
||||||
|
// helpers
|
||||||
|
import { cn } from "@/helpers/common.helper";
|
||||||
|
// hooks
|
||||||
|
import { useProjectState } from "@/hooks/store";
|
||||||
|
import useLocalStorage from "@/hooks/use-local-storage";
|
||||||
|
// public
|
||||||
|
import emptyLabel from "@/public/empty-state/empty_label.svg";
|
||||||
|
import emptyMembers from "@/public/empty-state/empty_members.svg";
|
||||||
|
|
||||||
|
// assignee types
|
||||||
|
type TAssigneeData = {
|
||||||
|
id: string | undefined;
|
||||||
|
title: string | undefined;
|
||||||
|
avatar: string | undefined;
|
||||||
|
completed: number;
|
||||||
|
total: number;
|
||||||
|
}[];
|
||||||
|
|
||||||
|
type TAssigneeStatComponent = {
|
||||||
|
distribution: TAssigneeData;
|
||||||
|
isEditable?: boolean;
|
||||||
|
filters?: IIssueFilters | undefined;
|
||||||
|
handleFiltersUpdate: (key: keyof IIssueFilterOptions, value: string | string[]) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
// labelTypes
|
||||||
|
type TLabelData = {
|
||||||
|
id: string | undefined;
|
||||||
|
title: string | undefined;
|
||||||
|
color: string | undefined;
|
||||||
|
completed: number;
|
||||||
|
total: number;
|
||||||
|
}[];
|
||||||
|
|
||||||
|
type TLabelStatComponent = {
|
||||||
|
distribution: TLabelData;
|
||||||
|
isEditable?: boolean;
|
||||||
|
filters?: IIssueFilters | undefined;
|
||||||
|
handleFiltersUpdate: (key: keyof IIssueFilterOptions, value: string | string[]) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
// stateTypes
|
||||||
|
type TStateData = {
|
||||||
|
state: string | undefined;
|
||||||
|
completed: number;
|
||||||
|
total: number;
|
||||||
|
}[];
|
||||||
|
|
||||||
|
type TStateStatComponent = {
|
||||||
|
distribution: TStateData;
|
||||||
|
totalIssuesCount: number;
|
||||||
|
isEditable?: boolean;
|
||||||
|
handleFiltersUpdate: (key: keyof IIssueFilterOptions, value: string | string[]) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AssigneeStatComponent = observer((props: TAssigneeStatComponent) => {
|
||||||
|
const { distribution, isEditable, filters, handleFiltersUpdate } = props;
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{distribution && distribution.length > 0 ? (
|
||||||
|
distribution.map((assignee, index) => {
|
||||||
|
if (assignee?.id)
|
||||||
|
return (
|
||||||
|
<SingleProgressStats
|
||||||
|
key={assignee?.id}
|
||||||
|
title={
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Avatar name={assignee?.title ?? undefined} src={assignee?.avatar ?? undefined} />
|
||||||
|
<span>{assignee?.title ?? ""}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
completed={assignee?.completed ?? 0}
|
||||||
|
total={assignee?.total ?? 0}
|
||||||
|
{...(isEditable && {
|
||||||
|
onClick: () => handleFiltersUpdate("assignees", assignee.id ?? ""),
|
||||||
|
selected: filters?.filters?.assignees?.includes(assignee.id ?? ""),
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
else
|
||||||
|
return (
|
||||||
|
<SingleProgressStats
|
||||||
|
key={`unassigned-${index}`}
|
||||||
|
title={
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="h-4 w-4 rounded-full border-2 border-custom-border-200 bg-custom-background-80">
|
||||||
|
<img src="/user.png" height="100%" width="100%" className="rounded-full" alt="User" />
|
||||||
|
</div>
|
||||||
|
<span>No assignee</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
completed={assignee?.completed ?? 0}
|
||||||
|
total={assignee?.total ?? 0}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full flex-col items-center justify-center gap-2">
|
||||||
|
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-custom-background-80">
|
||||||
|
<Image src={emptyMembers} className="h-12 w-12" alt="empty members" />
|
||||||
|
</div>
|
||||||
|
<h6 className="text-base text-custom-text-300">No assignees yet</h6>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export const LabelStatComponent = observer((props: TLabelStatComponent) => {
|
||||||
|
const { distribution, isEditable, filters, handleFiltersUpdate } = props;
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{distribution && distribution.length > 0 ? (
|
||||||
|
distribution.map((label, index) => {
|
||||||
|
if (label.id) {
|
||||||
|
return (
|
||||||
|
<SingleProgressStats
|
||||||
|
key={label.id}
|
||||||
|
title={
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className="block h-3 w-3 rounded-full"
|
||||||
|
style={{
|
||||||
|
backgroundColor: label.color ?? "transparent",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="text-xs">{label.title ?? "No labels"}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
completed={label.completed}
|
||||||
|
total={label.total}
|
||||||
|
{...(isEditable && {
|
||||||
|
onClick: () => handleFiltersUpdate("labels", label.id ?? ""),
|
||||||
|
selected: filters?.filters?.labels?.includes(label.id ?? `no-label-${index}`),
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<SingleProgressStats
|
||||||
|
key={`no-label-${index}`}
|
||||||
|
title={
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className="block h-3 w-3 rounded-full"
|
||||||
|
style={{
|
||||||
|
backgroundColor: label.color ?? "transparent",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="text-xs">{label.title ?? "No labels"}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
completed={label.completed}
|
||||||
|
total={label.total}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full flex-col items-center justify-center gap-2">
|
||||||
|
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-custom-background-80">
|
||||||
|
<Image src={emptyLabel} className="h-12 w-12" alt="empty label" />
|
||||||
|
</div>
|
||||||
|
<h6 className="text-base text-custom-text-300">No labels yet</h6>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export const StateStatComponent = observer((props: TStateStatComponent) => {
|
||||||
|
const { distribution, isEditable, totalIssuesCount, handleFiltersUpdate } = props;
|
||||||
|
// hooks
|
||||||
|
const { groupedProjectStates } = useProjectState();
|
||||||
|
// derived values
|
||||||
|
const getStateGroupState = (stateGroup: string) => {
|
||||||
|
const stateGroupStates = groupedProjectStates?.[stateGroup];
|
||||||
|
const stateGroupStatesId = stateGroupStates?.map((state) => state.id);
|
||||||
|
return stateGroupStatesId;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{distribution.map((group, index) => (
|
||||||
|
<SingleProgressStats
|
||||||
|
key={index}
|
||||||
|
title={
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<StateGroupIcon stateGroup={group.state as TStateGroups} />
|
||||||
|
<span className="text-xs capitalize">{group.state}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
completed={group.completed}
|
||||||
|
total={totalIssuesCount}
|
||||||
|
{...(isEditable && {
|
||||||
|
onClick: () => group.state && handleFiltersUpdate("state", getStateGroupState(group.state) ?? []),
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const progressStats = [
|
||||||
|
{
|
||||||
|
key: "stat-assignees",
|
||||||
|
title: "Assignees",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "stat-labels",
|
||||||
|
title: "Labels",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "stat-states",
|
||||||
|
title: "States",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
type TModuleProgressStats = {
|
||||||
|
moduleId: string;
|
||||||
|
plotType: TModulePlotType;
|
||||||
|
distribution: TModuleDistribution | TModuleEstimateDistribution | undefined;
|
||||||
|
groupedIssues: Record<string, number>;
|
||||||
|
totalIssuesCount: number;
|
||||||
|
isEditable?: boolean;
|
||||||
|
filters?: IIssueFilters | undefined;
|
||||||
|
handleFiltersUpdate: (key: keyof IIssueFilterOptions, value: string | string[]) => void;
|
||||||
|
size?: "xs" | "sm";
|
||||||
|
roundedTab?: boolean;
|
||||||
|
noBackground?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ModuleProgressStats: FC<TModuleProgressStats> = observer((props) => {
|
||||||
|
const {
|
||||||
|
moduleId,
|
||||||
|
plotType,
|
||||||
|
distribution,
|
||||||
|
groupedIssues,
|
||||||
|
totalIssuesCount,
|
||||||
|
isEditable = false,
|
||||||
|
filters,
|
||||||
|
handleFiltersUpdate,
|
||||||
|
size = "sm",
|
||||||
|
roundedTab = false,
|
||||||
|
noBackground = false,
|
||||||
|
} = props;
|
||||||
|
// hooks
|
||||||
|
const { storedValue: currentTab, setValue: setModuleTab } = useLocalStorage(
|
||||||
|
`module-analytics-tab-${moduleId}`,
|
||||||
|
"stat-assignees"
|
||||||
|
);
|
||||||
|
// derived values
|
||||||
|
const currentTabIndex = (tab: string): number => progressStats.findIndex((stat) => stat.key === tab);
|
||||||
|
|
||||||
|
const currentDistribution = distribution as TModuleDistribution;
|
||||||
|
const currentEstimateDistribution = distribution as TModuleEstimateDistribution;
|
||||||
|
|
||||||
|
const distributionAssigneeData: TAssigneeData =
|
||||||
|
plotType === "burndown"
|
||||||
|
? (currentDistribution?.assignees || []).map((assignee) => ({
|
||||||
|
id: assignee?.assignee_id || undefined,
|
||||||
|
title: assignee?.display_name || undefined,
|
||||||
|
avatar: assignee?.avatar || undefined,
|
||||||
|
completed: assignee.completed_issues,
|
||||||
|
total: assignee.total_issues,
|
||||||
|
}))
|
||||||
|
: (currentEstimateDistribution?.assignees || []).map((assignee) => ({
|
||||||
|
id: assignee?.assignee_id || undefined,
|
||||||
|
title: assignee?.display_name || undefined,
|
||||||
|
avatar: assignee?.avatar || undefined,
|
||||||
|
completed: assignee.completed_estimates,
|
||||||
|
total: assignee.total_estimates,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const distributionLabelData: TLabelData =
|
||||||
|
plotType === "burndown"
|
||||||
|
? (currentDistribution?.labels || []).map((label) => ({
|
||||||
|
id: label?.label_id || undefined,
|
||||||
|
title: label?.label_name || undefined,
|
||||||
|
color: label?.color || undefined,
|
||||||
|
completed: label.completed_issues,
|
||||||
|
total: label.total_issues,
|
||||||
|
}))
|
||||||
|
: (currentEstimateDistribution?.labels || []).map((label) => ({
|
||||||
|
id: label?.label_id || undefined,
|
||||||
|
title: label?.label_name || undefined,
|
||||||
|
color: label?.color || undefined,
|
||||||
|
completed: label.completed_estimates,
|
||||||
|
total: label.total_estimates,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const distributionStateData: TStateData = Object.keys(groupedIssues || {}).map((state) => ({
|
||||||
|
state: state,
|
||||||
|
completed: groupedIssues?.[state] || 0,
|
||||||
|
total: totalIssuesCount || 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Tab.Group defaultIndex={currentTabIndex(currentTab ? currentTab : "stat-assignees")}>
|
||||||
|
<Tab.List
|
||||||
|
as="div"
|
||||||
|
className={cn(
|
||||||
|
`flex w-full items-center justify-between gap-2 rounded-md p-1`,
|
||||||
|
roundedTab ? `rounded-3xl` : `rounded-md`,
|
||||||
|
noBackground ? `` : `bg-custom-background-90`,
|
||||||
|
size === "xs" ? `text-xs` : `text-sm`
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{progressStats.map((stat) => (
|
||||||
|
<Tab
|
||||||
|
className={cn(
|
||||||
|
`p-1 w-full text-custom-text-100 outline-none focus:outline-none cursor-pointer transition-all`,
|
||||||
|
roundedTab ? `rounded-3xl border border-custom-border-200` : `rounded`,
|
||||||
|
stat.key === currentTab
|
||||||
|
? "bg-custom-background-100 text-custom-text-300"
|
||||||
|
: "text-custom-text-400 hover:text-custom-text-300"
|
||||||
|
)}
|
||||||
|
key={stat.key}
|
||||||
|
onClick={() => setModuleTab(stat.key)}
|
||||||
|
>
|
||||||
|
{stat.title}
|
||||||
|
</Tab>
|
||||||
|
))}
|
||||||
|
</Tab.List>
|
||||||
|
<Tab.Panels className="py-3 text-custom-text-200">
|
||||||
|
<Tab.Panel key={"stat-assignees"}>
|
||||||
|
<AssigneeStatComponent
|
||||||
|
distribution={distributionAssigneeData}
|
||||||
|
isEditable={isEditable}
|
||||||
|
filters={filters}
|
||||||
|
handleFiltersUpdate={handleFiltersUpdate}
|
||||||
|
/>
|
||||||
|
</Tab.Panel>
|
||||||
|
<Tab.Panel key={"stat-labels"}>
|
||||||
|
<LabelStatComponent
|
||||||
|
distribution={distributionLabelData}
|
||||||
|
isEditable={isEditable}
|
||||||
|
filters={filters}
|
||||||
|
handleFiltersUpdate={handleFiltersUpdate}
|
||||||
|
/>
|
||||||
|
</Tab.Panel>
|
||||||
|
<Tab.Panel key={"stat-states"}>
|
||||||
|
<StateStatComponent
|
||||||
|
distribution={distributionStateData}
|
||||||
|
totalIssuesCount={totalIssuesCount}
|
||||||
|
isEditable={isEditable}
|
||||||
|
handleFiltersUpdate={handleFiltersUpdate}
|
||||||
|
/>
|
||||||
|
</Tab.Panel>
|
||||||
|
</Tab.Panels>
|
||||||
|
</Tab.Group>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
@ -1,12 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useCallback, useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import isEqual from "lodash/isEqual";
|
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { Controller, useForm } from "react-hook-form";
|
import { Controller, useForm } from "react-hook-form";
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
|
||||||
ArchiveRestoreIcon,
|
ArchiveRestoreIcon,
|
||||||
CalendarClock,
|
CalendarClock,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
@ -19,7 +17,7 @@ import {
|
|||||||
Users,
|
Users,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Disclosure, Transition } from "@headlessui/react";
|
import { Disclosure, Transition } from "@headlessui/react";
|
||||||
import { IIssueFilterOptions, ILinkDetails, IModule, ModuleLink } from "@plane/types";
|
import { ILinkDetails, IModule, ModuleLink } from "@plane/types";
|
||||||
// ui
|
// ui
|
||||||
import {
|
import {
|
||||||
CustomMenu,
|
CustomMenu,
|
||||||
@ -33,26 +31,24 @@ import {
|
|||||||
TextArea,
|
TextArea,
|
||||||
} from "@plane/ui";
|
} from "@plane/ui";
|
||||||
// components
|
// components
|
||||||
import { LinkModal, LinksList, SidebarProgressStats } from "@/components/core";
|
import { LinkModal, LinksList } from "@/components/core";
|
||||||
import ProgressChart from "@/components/core/sidebar/progress-chart";
|
|
||||||
import { DateRangeDropdown, MemberDropdown } from "@/components/dropdowns";
|
import { DateRangeDropdown, MemberDropdown } from "@/components/dropdowns";
|
||||||
import { ArchiveModuleModal, DeleteModuleModal } from "@/components/modules";
|
import { ArchiveModuleModal, DeleteModuleModal, ModuleAnalyticsProgress } from "@/components/modules";
|
||||||
// constant
|
// constant
|
||||||
|
import { EEstimateSystem } from "@/constants/estimates";
|
||||||
import {
|
import {
|
||||||
MODULE_LINK_CREATED,
|
MODULE_LINK_CREATED,
|
||||||
MODULE_LINK_DELETED,
|
MODULE_LINK_DELETED,
|
||||||
MODULE_LINK_UPDATED,
|
MODULE_LINK_UPDATED,
|
||||||
MODULE_UPDATED,
|
MODULE_UPDATED,
|
||||||
} from "@/constants/event-tracker";
|
} from "@/constants/event-tracker";
|
||||||
import { EIssueFilterType, EIssuesStoreType } from "@/constants/issue";
|
|
||||||
import { MODULE_STATUS } from "@/constants/module";
|
import { MODULE_STATUS } from "@/constants/module";
|
||||||
import { EUserProjectRoles } from "@/constants/project";
|
import { EUserProjectRoles } from "@/constants/project";
|
||||||
// helpers
|
// helpers
|
||||||
import { getDate, renderFormattedPayloadDate } from "@/helpers/date-time.helper";
|
import { getDate, renderFormattedPayloadDate } from "@/helpers/date-time.helper";
|
||||||
import { copyUrlToClipboard } from "@/helpers/string.helper";
|
import { copyUrlToClipboard } from "@/helpers/string.helper";
|
||||||
// hooks
|
// hooks
|
||||||
import { useModule, useUser, useEventTracker, useIssues } from "@/hooks/store";
|
import { useModule, useUser, useEventTracker, useProjectEstimates } from "@/hooks/store";
|
||||||
// types
|
|
||||||
|
|
||||||
const defaultValues: Partial<IModule> = {
|
const defaultValues: Partial<IModule> = {
|
||||||
lead_id: "",
|
lead_id: "",
|
||||||
@ -69,7 +65,7 @@ type Props = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// TODO: refactor this component
|
// TODO: refactor this component
|
||||||
export const ModuleDetailsSidebar: React.FC<Props> = observer((props) => {
|
export const ModuleAnalyticsSidebar: React.FC<Props> = observer((props) => {
|
||||||
const { moduleId, handleClose, isArchived } = props;
|
const { moduleId, handleClose, isArchived } = props;
|
||||||
// states
|
// states
|
||||||
const [moduleDeleteModal, setModuleDeleteModal] = useState(false);
|
const [moduleDeleteModal, setModuleDeleteModal] = useState(false);
|
||||||
@ -79,8 +75,7 @@ export const ModuleDetailsSidebar: React.FC<Props> = observer((props) => {
|
|||||||
// router
|
// router
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { workspaceSlug, projectId } = useParams();
|
const { workspaceSlug, projectId } = useParams();
|
||||||
const searchParams = useSearchParams();
|
|
||||||
const peekModule = searchParams.get("peekModule");
|
|
||||||
// store hooks
|
// store hooks
|
||||||
const {
|
const {
|
||||||
membership: { currentProjectRole },
|
membership: { currentProjectRole },
|
||||||
@ -88,14 +83,17 @@ export const ModuleDetailsSidebar: React.FC<Props> = observer((props) => {
|
|||||||
const { getModuleById, updateModuleDetails, createModuleLink, updateModuleLink, deleteModuleLink, restoreModule } =
|
const { getModuleById, updateModuleDetails, createModuleLink, updateModuleLink, deleteModuleLink, restoreModule } =
|
||||||
useModule();
|
useModule();
|
||||||
const { setTrackElement, captureModuleEvent, captureEvent } = useEventTracker();
|
const { setTrackElement, captureModuleEvent, captureEvent } = useEventTracker();
|
||||||
const {
|
const { areEstimateEnabledByProjectId, currentActiveEstimateId, estimateById } = useProjectEstimates();
|
||||||
issuesFilter: { issueFilters, updateFilters },
|
|
||||||
} = useIssues(EIssuesStoreType.MODULE);
|
|
||||||
const moduleDetails = getModuleById(moduleId);
|
|
||||||
|
|
||||||
|
// derived values
|
||||||
|
const moduleDetails = getModuleById(moduleId);
|
||||||
const moduleState = moduleDetails?.status?.toLocaleLowerCase();
|
const moduleState = moduleDetails?.status?.toLocaleLowerCase();
|
||||||
const isInArchivableGroup = !!moduleState && ["completed", "cancelled"].includes(moduleState);
|
const isInArchivableGroup = !!moduleState && ["completed", "cancelled"].includes(moduleState);
|
||||||
|
|
||||||
|
const areEstimateEnabled = projectId && areEstimateEnabledByProjectId(projectId.toString());
|
||||||
|
const estimateType = areEstimateEnabled && currentActiveEstimateId && estimateById(currentActiveEstimateId);
|
||||||
|
const isEstimatePointValid = estimateType && estimateType?.type == EEstimateSystem.POINTS ? true : false;
|
||||||
|
|
||||||
const { reset, control } = useForm({
|
const { reset, control } = useForm({
|
||||||
defaultValues,
|
defaultValues,
|
||||||
});
|
});
|
||||||
@ -254,46 +252,6 @@ export const ModuleDetailsSidebar: React.FC<Props> = observer((props) => {
|
|||||||
});
|
});
|
||||||
}, [moduleDetails, reset]);
|
}, [moduleDetails, reset]);
|
||||||
|
|
||||||
const handleFiltersUpdate = useCallback(
|
|
||||||
(key: keyof IIssueFilterOptions, value: string | string[]) => {
|
|
||||||
if (!workspaceSlug || !projectId) return;
|
|
||||||
let newValues = issueFilters?.filters?.[key] ?? [];
|
|
||||||
|
|
||||||
if (Array.isArray(value)) {
|
|
||||||
if (key === "state") {
|
|
||||||
if (isEqual(newValues, value)) newValues = [];
|
|
||||||
else newValues = value;
|
|
||||||
} else {
|
|
||||||
value.forEach((val) => {
|
|
||||||
if (!newValues.includes(val)) newValues.push(val);
|
|
||||||
else newValues.splice(newValues.indexOf(val), 1);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
|
|
||||||
else newValues.push(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
updateFilters(
|
|
||||||
workspaceSlug.toString(),
|
|
||||||
projectId.toString(),
|
|
||||||
EIssueFilterType.FILTERS,
|
|
||||||
{ [key]: newValues },
|
|
||||||
moduleId
|
|
||||||
);
|
|
||||||
},
|
|
||||||
[workspaceSlug, projectId, moduleId, issueFilters, updateFilters]
|
|
||||||
);
|
|
||||||
|
|
||||||
const startDate = getDate(moduleDetails?.start_date);
|
|
||||||
const endDate = getDate(moduleDetails?.target_date);
|
|
||||||
const isStartValid = startDate && startDate <= new Date();
|
|
||||||
const isEndValid = startDate && endDate && endDate >= startDate;
|
|
||||||
|
|
||||||
const progressPercentage = moduleDetails
|
|
||||||
? Math.round((moduleDetails.completed_issues / moduleDetails.total_issues) * 100)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const handleEditLink = (link: ILinkDetails) => {
|
const handleEditLink = (link: ILinkDetails) => {
|
||||||
setSelectedLinkToUpdate(link);
|
setSelectedLinkToUpdate(link);
|
||||||
setModuleLinkModal(true);
|
setModuleLinkModal(true);
|
||||||
@ -319,6 +277,11 @@ export const ModuleDetailsSidebar: React.FC<Props> = observer((props) => {
|
|||||||
const issueCount =
|
const issueCount =
|
||||||
moduleDetails.total_issues === 0 ? "0 Issue" : `${moduleDetails.completed_issues}/${moduleDetails.total_issues}`;
|
moduleDetails.total_issues === 0 ? "0 Issue" : `${moduleDetails.completed_issues}/${moduleDetails.total_issues}`;
|
||||||
|
|
||||||
|
const issueEstimatePointCount =
|
||||||
|
moduleDetails.total_estimate_points === 0
|
||||||
|
? "0 Issue"
|
||||||
|
: `${moduleDetails.completed_estimate_points}/${moduleDetails.total_estimate_points}`;
|
||||||
|
|
||||||
const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER;
|
const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -559,99 +522,32 @@ export const ModuleDetailsSidebar: React.FC<Props> = observer((props) => {
|
|||||||
<span className="px-1.5 text-sm text-custom-text-300">{issueCount}</span>
|
<span className="px-1.5 text-sm text-custom-text-300">{issueCount}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/**
|
||||||
|
* NOTE: Render this section when estimate points of he projects is enabled and the estimate system is points
|
||||||
|
*/}
|
||||||
|
{isEstimatePointValid && (
|
||||||
|
<div className="flex items-center justify-start gap-1">
|
||||||
|
<div className="flex w-2/5 items-center justify-start gap-2 text-custom-text-300">
|
||||||
|
<LayersIcon className="h-4 w-4" />
|
||||||
|
<span className="text-base">Points</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex h-7 w-3/5 items-center">
|
||||||
|
<span className="px-1.5 text-sm text-custom-text-300">{issueEstimatePointCount}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{workspaceSlug && projectId && moduleDetails?.id && (
|
||||||
|
<ModuleAnalyticsProgress
|
||||||
|
workspaceSlug={workspaceSlug.toString()}
|
||||||
|
projectId={projectId.toString()}
|
||||||
|
moduleId={moduleDetails?.id}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<div className="flex w-full flex-col items-center justify-start gap-2 border-t border-custom-border-200 px-1.5 py-5">
|
|
||||||
<Disclosure defaultOpen>
|
|
||||||
{({ open }) => (
|
|
||||||
<div className={`relative flex h-full w-full flex-col ${open ? "" : "flex-row"}`}>
|
|
||||||
<Disclosure.Button
|
|
||||||
className="flex w-full items-center justify-between gap-2 p-1.5"
|
|
||||||
disabled={!isStartValid || !isEndValid}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-start gap-2 text-sm">
|
|
||||||
<span className="font-medium text-custom-text-200">Progress</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2.5">
|
|
||||||
{progressPercentage ? (
|
|
||||||
<span className="flex h-5 w-9 items-center justify-center rounded bg-amber-500/20 text-xs font-medium text-amber-500">
|
|
||||||
{progressPercentage ? `${progressPercentage}%` : ""}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
""
|
|
||||||
)}
|
|
||||||
{isStartValid && isEndValid ? (
|
|
||||||
<ChevronDown className={`h-3 w-3 ${open ? "rotate-180 transform" : ""}`} aria-hidden="true" />
|
|
||||||
) : (
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<AlertCircle height={14} width={14} className="text-custom-text-200" />
|
|
||||||
<span className="text-xs italic text-custom-text-200">
|
|
||||||
{moduleDetails?.start_date && moduleDetails?.target_date
|
|
||||||
? "This module isn't active yet."
|
|
||||||
: "Invalid date. Please enter valid date."}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Disclosure.Button>
|
|
||||||
<Transition show={open}>
|
|
||||||
<Disclosure.Panel>
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
{moduleDetails.start_date && moduleDetails.target_date ? (
|
|
||||||
<div className=" h-full w-full pt-4">
|
|
||||||
<div className="flex items-start gap-4 py-2 text-xs">
|
|
||||||
<div className="flex items-center gap-3 text-custom-text-100">
|
|
||||||
<div className="flex items-center justify-center gap-1">
|
|
||||||
<span className="h-2.5 w-2.5 rounded-full bg-[#A9BBD0]" />
|
|
||||||
<span>Ideal</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-center gap-1">
|
|
||||||
<span className="h-2.5 w-2.5 rounded-full bg-[#4C8FFF]" />
|
|
||||||
<span>Current</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="relative h-40 w-full max-w-80">
|
|
||||||
<ProgressChart
|
|
||||||
distribution={moduleDetails.distribution?.completion_chart ?? {}}
|
|
||||||
startDate={moduleDetails.start_date}
|
|
||||||
endDate={moduleDetails.target_date}
|
|
||||||
totalIssues={moduleDetails.total_issues}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
""
|
|
||||||
)}
|
|
||||||
{moduleDetails.total_issues > 0 && (
|
|
||||||
<div className="h-full w-full border-t border-custom-border-200 pt-5">
|
|
||||||
<SidebarProgressStats
|
|
||||||
distribution={moduleDetails.distribution}
|
|
||||||
groupedIssues={{
|
|
||||||
backlog: moduleDetails.backlog_issues,
|
|
||||||
unstarted: moduleDetails.unstarted_issues,
|
|
||||||
started: moduleDetails.started_issues,
|
|
||||||
completed: moduleDetails.completed_issues,
|
|
||||||
cancelled: moduleDetails.cancelled_issues,
|
|
||||||
}}
|
|
||||||
totalIssues={moduleDetails.total_issues}
|
|
||||||
module={moduleDetails}
|
|
||||||
isPeekView={Boolean(peekModule)}
|
|
||||||
filters={issueFilters}
|
|
||||||
handleFiltersUpdate={handleFiltersUpdate}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Disclosure.Panel>
|
|
||||||
</Transition>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Disclosure>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex w-full flex-col items-center justify-start gap-2 border-t border-custom-border-200 px-1.5 py-5">
|
<div className="flex w-full flex-col items-center justify-start gap-2 border-t border-custom-border-200 px-1.5 py-5">
|
||||||
{/* Accessing link outside the disclosure as mobx is not considering the children inside Disclosure as part of the component hence not observing their state change*/}
|
{/* Accessing link outside the disclosure as mobx is not considering the children inside Disclosure as part of the component hence not observing their state change*/}
|
||||||
<Disclosure defaultOpen={!!moduleDetails?.link_module?.length}>
|
<Disclosure defaultOpen={!!moduleDetails?.link_module?.length}>
|
@ -7,7 +7,6 @@ export * from "./form";
|
|||||||
export * from "./gantt-chart";
|
export * from "./gantt-chart";
|
||||||
export * from "./modal";
|
export * from "./modal";
|
||||||
export * from "./modules-list-view";
|
export * from "./modules-list-view";
|
||||||
export * from "./sidebar";
|
|
||||||
export * from "./module-card-item";
|
export * from "./module-card-item";
|
||||||
export * from "./module-list-item";
|
export * from "./module-list-item";
|
||||||
export * from "./module-peek-overview";
|
export * from "./module-peek-overview";
|
||||||
@ -15,5 +14,6 @@ export * from "./quick-actions";
|
|||||||
export * from "./module-list-item-action";
|
export * from "./module-list-item-action";
|
||||||
export * from "./module-view-header";
|
export * from "./module-view-header";
|
||||||
|
|
||||||
|
export * from "./analytics-sidebar";
|
||||||
// archived modules
|
// archived modules
|
||||||
export * from "./archived-modules";
|
export * from "./archived-modules";
|
||||||
|
@ -5,7 +5,7 @@ import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
|||||||
import { generateQueryParams } from "@/helpers/router.helper";
|
import { generateQueryParams } from "@/helpers/router.helper";
|
||||||
import { useModule } from "@/hooks/store";
|
import { useModule } from "@/hooks/store";
|
||||||
// components
|
// components
|
||||||
import { ModuleDetailsSidebar } from "./sidebar";
|
import { ModuleAnalyticsSidebar } from "./";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
@ -46,7 +46,7 @@ export const ModulePeekOverview: React.FC<Props> = observer(({ projectId, worksp
|
|||||||
"0px 1px 4px 0px rgba(0, 0, 0, 0.06), 0px 2px 4px 0px rgba(16, 24, 40, 0.06), 0px 1px 8px -1px rgba(16, 24, 40, 0.06)",
|
"0px 1px 4px 0px rgba(0, 0, 0, 0.06), 0px 2px 4px 0px rgba(16, 24, 40, 0.06), 0px 1px 8px -1px rgba(16, 24, 40, 0.06)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ModuleDetailsSidebar
|
<ModuleAnalyticsSidebar
|
||||||
moduleId={peekModule?.toString() ?? ""}
|
moduleId={peekModule?.toString() ?? ""}
|
||||||
handleClose={handleClose}
|
handleClose={handleClose}
|
||||||
isArchived={isArchived}
|
isArchived={isArchived}
|
||||||
|
@ -4,7 +4,7 @@ import sortBy from "lodash/sortBy";
|
|||||||
import { action, computed, observable, makeObservable, runInAction } from "mobx";
|
import { action, computed, observable, makeObservable, runInAction } from "mobx";
|
||||||
import { computedFn } from "mobx-utils";
|
import { computedFn } from "mobx-utils";
|
||||||
// types
|
// types
|
||||||
import { ICycle, CycleDateCheckData } from "@plane/types";
|
import { ICycle, CycleDateCheckData, TCyclePlotType } from "@plane/types";
|
||||||
// helpers
|
// helpers
|
||||||
import { orderCycles, shouldFilterCycle } from "@/helpers/cycle.helper";
|
import { orderCycles, shouldFilterCycle } from "@/helpers/cycle.helper";
|
||||||
import { getDate } from "@/helpers/date-time.helper";
|
import { getDate } from "@/helpers/date-time.helper";
|
||||||
@ -22,6 +22,7 @@ export interface ICycleStore {
|
|||||||
// observables
|
// observables
|
||||||
fetchedMap: Record<string, boolean>;
|
fetchedMap: Record<string, boolean>;
|
||||||
cycleMap: Record<string, ICycle>;
|
cycleMap: Record<string, ICycle>;
|
||||||
|
plotType: Record<string, TCyclePlotType>;
|
||||||
activeCycleIdMap: Record<string, boolean>;
|
activeCycleIdMap: Record<string, boolean>;
|
||||||
// computed
|
// computed
|
||||||
currentProjectCycleIds: string[] | null;
|
currentProjectCycleIds: string[] | null;
|
||||||
@ -39,8 +40,10 @@ export interface ICycleStore {
|
|||||||
getCycleNameById: (cycleId: string) => string | undefined;
|
getCycleNameById: (cycleId: string) => string | undefined;
|
||||||
getActiveCycleById: (cycleId: string) => ICycle | null;
|
getActiveCycleById: (cycleId: string) => ICycle | null;
|
||||||
getProjectCycleIds: (projectId: string) => string[] | null;
|
getProjectCycleIds: (projectId: string) => string[] | null;
|
||||||
|
getPlotTypeByCycleId: (cycleId: string) => TCyclePlotType;
|
||||||
// actions
|
// actions
|
||||||
validateDate: (workspaceSlug: string, projectId: string, payload: CycleDateCheckData) => Promise<any>;
|
validateDate: (workspaceSlug: string, projectId: string, payload: CycleDateCheckData) => Promise<any>;
|
||||||
|
setPlotType: (cycleId: string, plotType: TCyclePlotType) => void;
|
||||||
// fetch
|
// fetch
|
||||||
fetchWorkspaceCycles: (workspaceSlug: string) => Promise<ICycle[]>;
|
fetchWorkspaceCycles: (workspaceSlug: string) => Promise<ICycle[]>;
|
||||||
fetchAllCycles: (workspaceSlug: string, projectId: string) => Promise<undefined | ICycle[]>;
|
fetchAllCycles: (workspaceSlug: string, projectId: string) => Promise<undefined | ICycle[]>;
|
||||||
@ -69,6 +72,7 @@ export class CycleStore implements ICycleStore {
|
|||||||
// observables
|
// observables
|
||||||
loader: boolean = false;
|
loader: boolean = false;
|
||||||
cycleMap: Record<string, ICycle> = {};
|
cycleMap: Record<string, ICycle> = {};
|
||||||
|
plotType: Record<string, TCyclePlotType> = {};
|
||||||
activeCycleIdMap: Record<string, boolean> = {};
|
activeCycleIdMap: Record<string, boolean> = {};
|
||||||
//loaders
|
//loaders
|
||||||
fetchedMap: Record<string, boolean> = {};
|
fetchedMap: Record<string, boolean> = {};
|
||||||
@ -85,6 +89,7 @@ export class CycleStore implements ICycleStore {
|
|||||||
// observables
|
// observables
|
||||||
loader: observable.ref,
|
loader: observable.ref,
|
||||||
cycleMap: observable,
|
cycleMap: observable,
|
||||||
|
plotType: observable.ref,
|
||||||
activeCycleIdMap: observable,
|
activeCycleIdMap: observable,
|
||||||
fetchedMap: observable,
|
fetchedMap: observable,
|
||||||
// computed
|
// computed
|
||||||
@ -96,6 +101,7 @@ export class CycleStore implements ICycleStore {
|
|||||||
currentProjectActiveCycleId: computed,
|
currentProjectActiveCycleId: computed,
|
||||||
currentProjectArchivedCycleIds: computed,
|
currentProjectArchivedCycleIds: computed,
|
||||||
// actions
|
// actions
|
||||||
|
setPlotType: action,
|
||||||
fetchWorkspaceCycles: action,
|
fetchWorkspaceCycles: action,
|
||||||
fetchAllCycles: action,
|
fetchAllCycles: action,
|
||||||
fetchActiveCycle: action,
|
fetchActiveCycle: action,
|
||||||
@ -334,6 +340,26 @@ export class CycleStore implements ICycleStore {
|
|||||||
validateDate = async (workspaceSlug: string, projectId: string, payload: CycleDateCheckData) =>
|
validateDate = async (workspaceSlug: string, projectId: string, payload: CycleDateCheckData) =>
|
||||||
await this.cycleService.cycleDateCheck(workspaceSlug, projectId, payload);
|
await this.cycleService.cycleDateCheck(workspaceSlug, projectId, payload);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description gets the plot type for the module store
|
||||||
|
* @param {TCyclePlotType} plotType
|
||||||
|
*/
|
||||||
|
getPlotTypeByCycleId = (cycleId: string) => {
|
||||||
|
const { projectId } = this.rootStore.router;
|
||||||
|
|
||||||
|
return projectId && this.rootStore.projectEstimate.areEstimateEnabledByProjectId(projectId)
|
||||||
|
? this.plotType[cycleId] || "burndown"
|
||||||
|
: "burndown";
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description updates the plot type for the module store
|
||||||
|
* @param {TCyclePlotType} plotType
|
||||||
|
*/
|
||||||
|
setPlotType = (cycleId: string, plotType: TCyclePlotType) => {
|
||||||
|
set(this.plotType, [cycleId], plotType);
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description fetch all cycles
|
* @description fetch all cycles
|
||||||
* @param workspaceSlug
|
* @param workspaceSlug
|
||||||
|
@ -5,7 +5,7 @@ import update from "lodash/update";
|
|||||||
import { action, computed, observable, makeObservable, runInAction } from "mobx";
|
import { action, computed, observable, makeObservable, runInAction } from "mobx";
|
||||||
import { computedFn } from "mobx-utils";
|
import { computedFn } from "mobx-utils";
|
||||||
// types
|
// types
|
||||||
import { IModule, ILinkDetails } from "@plane/types";
|
import { IModule, ILinkDetails, TModulePlotType } from "@plane/types";
|
||||||
// helpers
|
// helpers
|
||||||
import { orderModules, shouldFilterModule } from "@/helpers/module.helper";
|
import { orderModules, shouldFilterModule } from "@/helpers/module.helper";
|
||||||
// services
|
// services
|
||||||
@ -19,6 +19,7 @@ export interface IModuleStore {
|
|||||||
//Loaders
|
//Loaders
|
||||||
loader: boolean;
|
loader: boolean;
|
||||||
fetchedMap: Record<string, boolean>;
|
fetchedMap: Record<string, boolean>;
|
||||||
|
plotType: Record<string, TModulePlotType>;
|
||||||
// observables
|
// observables
|
||||||
moduleMap: Record<string, IModule>;
|
moduleMap: Record<string, IModule>;
|
||||||
// computed
|
// computed
|
||||||
@ -30,7 +31,9 @@ export interface IModuleStore {
|
|||||||
getModuleById: (moduleId: string) => IModule | null;
|
getModuleById: (moduleId: string) => IModule | null;
|
||||||
getModuleNameById: (moduleId: string) => string;
|
getModuleNameById: (moduleId: string) => string;
|
||||||
getProjectModuleIds: (projectId: string) => string[] | null;
|
getProjectModuleIds: (projectId: string) => string[] | null;
|
||||||
|
getPlotTypeByModuleId: (moduleId: string) => TModulePlotType;
|
||||||
// actions
|
// actions
|
||||||
|
setPlotType: (moduleId: string, plotType: TModulePlotType) => void;
|
||||||
// fetch
|
// fetch
|
||||||
fetchWorkspaceModules: (workspaceSlug: string) => Promise<IModule[]>;
|
fetchWorkspaceModules: (workspaceSlug: string) => Promise<IModule[]>;
|
||||||
fetchModules: (workspaceSlug: string, projectId: string) => Promise<undefined | IModule[]>;
|
fetchModules: (workspaceSlug: string, projectId: string) => Promise<undefined | IModule[]>;
|
||||||
@ -72,6 +75,7 @@ export class ModulesStore implements IModuleStore {
|
|||||||
// observables
|
// observables
|
||||||
loader: boolean = false;
|
loader: boolean = false;
|
||||||
moduleMap: Record<string, IModule> = {};
|
moduleMap: Record<string, IModule> = {};
|
||||||
|
plotType: Record<string, TModulePlotType> = {};
|
||||||
//loaders
|
//loaders
|
||||||
fetchedMap: Record<string, boolean> = {};
|
fetchedMap: Record<string, boolean> = {};
|
||||||
// root store
|
// root store
|
||||||
@ -86,11 +90,13 @@ export class ModulesStore implements IModuleStore {
|
|||||||
// observables
|
// observables
|
||||||
loader: observable.ref,
|
loader: observable.ref,
|
||||||
moduleMap: observable,
|
moduleMap: observable,
|
||||||
|
plotType: observable.ref,
|
||||||
fetchedMap: observable,
|
fetchedMap: observable,
|
||||||
// computed
|
// computed
|
||||||
projectModuleIds: computed,
|
projectModuleIds: computed,
|
||||||
projectArchivedModuleIds: computed,
|
projectArchivedModuleIds: computed,
|
||||||
// actions
|
// actions
|
||||||
|
setPlotType: action,
|
||||||
fetchWorkspaceModules: action,
|
fetchWorkspaceModules: action,
|
||||||
fetchModules: action,
|
fetchModules: action,
|
||||||
fetchArchivedModules: action,
|
fetchArchivedModules: action,
|
||||||
@ -213,6 +219,26 @@ export class ModulesStore implements IModuleStore {
|
|||||||
return projectModuleIds;
|
return projectModuleIds;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description gets the plot type for the module store
|
||||||
|
* @param {TModulePlotType} plotType
|
||||||
|
*/
|
||||||
|
getPlotTypeByModuleId = (moduleId: string) => {
|
||||||
|
const { projectId } = this.rootStore.router;
|
||||||
|
|
||||||
|
return projectId && this.rootStore.projectEstimate.areEstimateEnabledByProjectId(projectId)
|
||||||
|
? this.plotType[moduleId] || "burndown"
|
||||||
|
: "burndown";
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description updates the plot type for the module store
|
||||||
|
* @param {TModulePlotType} plotType
|
||||||
|
*/
|
||||||
|
setPlotType = (moduleId: string, plotType: TModulePlotType) => {
|
||||||
|
set(this.plotType, [moduleId], plotType);
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description fetch all modules
|
* @description fetch all modules
|
||||||
* @param workspaceSlug
|
* @param workspaceSlug
|
||||||
|
Loading…
Reference in New Issue
Block a user