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):
|
||||
link_module = ModuleLinkSerializer(read_only=True, many=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):
|
||||
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):
|
||||
|
@ -46,6 +46,7 @@ from plane.db.models import (
|
||||
Issue,
|
||||
Label,
|
||||
User,
|
||||
Project,
|
||||
)
|
||||
from plane.utils.analytics_plot import burndown_plot
|
||||
|
||||
@ -325,7 +326,6 @@ class CycleViewSet(BaseViewSet):
|
||||
|
||||
def list(self, request, slug, project_id):
|
||||
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")
|
||||
|
||||
# Update the order by
|
||||
@ -373,8 +373,108 @@ class CycleViewSet(BaseViewSet):
|
||||
"status",
|
||||
"created_by",
|
||||
)
|
||||
estimate_type = Project.objects.filter(
|
||||
workspace__slug=slug,
|
||||
pk=project_id,
|
||||
estimate__isnull=False,
|
||||
estimate__type="points",
|
||||
).exists()
|
||||
|
||||
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 = (
|
||||
Issue.objects.filter(
|
||||
issue_cycle__cycle_id=data[0]["id"],
|
||||
@ -388,7 +488,10 @@ class CycleViewSet(BaseViewSet):
|
||||
.annotate(
|
||||
total_issues=Count(
|
||||
"id",
|
||||
filter=Q(archived_at__isnull=True, is_draft=False),
|
||||
filter=Q(
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
),
|
||||
)
|
||||
.annotate(
|
||||
@ -427,8 +530,11 @@ class CycleViewSet(BaseViewSet):
|
||||
.annotate(
|
||||
total_issues=Count(
|
||||
"id",
|
||||
filter=Q(archived_at__isnull=True, is_draft=False),
|
||||
)
|
||||
filter=Q(
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
),
|
||||
)
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
@ -464,7 +570,7 @@ class CycleViewSet(BaseViewSet):
|
||||
queryset=queryset.first(),
|
||||
slug=slug,
|
||||
project_id=project_id,
|
||||
plot_type=plot_type,
|
||||
plot_type="issues",
|
||||
cycle_id=data[0]["id"],
|
||||
)
|
||||
)
|
||||
@ -659,7 +765,6 @@ class CycleViewSet(BaseViewSet):
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def retrieve(self, request, slug, project_id, pk):
|
||||
plot_type = request.GET.get("plot_type", "issues")
|
||||
queryset = (
|
||||
self.get_queryset().filter(archived_at__isnull=True).filter(pk=pk)
|
||||
)
|
||||
@ -710,12 +815,107 @@ class CycleViewSet(BaseViewSet):
|
||||
)
|
||||
queryset = queryset.first()
|
||||
|
||||
if data is None:
|
||||
return Response(
|
||||
{"error": "Cycle does not exist"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
estimate_type = Project.objects.filter(
|
||||
workspace__slug=slug,
|
||||
pk=project_id,
|
||||
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 = (
|
||||
Issue.objects.filter(
|
||||
@ -738,7 +938,10 @@ class CycleViewSet(BaseViewSet):
|
||||
.annotate(
|
||||
total_issues=Count(
|
||||
"id",
|
||||
filter=Q(archived_at__isnull=True, is_draft=False),
|
||||
filter=Q(
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
),
|
||||
)
|
||||
.annotate(
|
||||
@ -778,7 +981,10 @@ class CycleViewSet(BaseViewSet):
|
||||
.annotate(
|
||||
total_issues=Count(
|
||||
"id",
|
||||
filter=Q(archived_at__isnull=True, is_draft=False),
|
||||
filter=Q(
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
),
|
||||
)
|
||||
.annotate(
|
||||
@ -815,7 +1021,7 @@ class CycleViewSet(BaseViewSet):
|
||||
queryset=queryset,
|
||||
slug=slug,
|
||||
project_id=project_id,
|
||||
plot_type=plot_type,
|
||||
plot_type="issues",
|
||||
cycle_id=pk,
|
||||
)
|
||||
|
||||
@ -932,7 +1138,6 @@ class TransferCycleIssueEndpoint(BaseAPIView):
|
||||
|
||||
def post(self, request, slug, project_id, cycle_id):
|
||||
new_cycle_id = request.data.get("new_cycle_id", False)
|
||||
plot_type = request.GET.get("plot_type", "issues")
|
||||
|
||||
if not new_cycle_id:
|
||||
return Response(
|
||||
@ -1009,14 +1214,127 @@ class TransferCycleIssueEndpoint(BaseAPIView):
|
||||
)
|
||||
)
|
||||
|
||||
# Pass the new_cycle queryset to burndown_plot
|
||||
completion_chart = burndown_plot(
|
||||
queryset=old_cycle.first(),
|
||||
slug=slug,
|
||||
project_id=project_id,
|
||||
plot_type=plot_type,
|
||||
cycle_id=cycle_id,
|
||||
)
|
||||
estimate_type = Project.objects.filter(
|
||||
workspace__slug=slug,
|
||||
pk=project_id,
|
||||
estimate__isnull=False,
|
||||
estimate__type="points",
|
||||
).exists()
|
||||
|
||||
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
|
||||
assignee_distribution = (
|
||||
@ -1032,7 +1350,10 @@ class TransferCycleIssueEndpoint(BaseAPIView):
|
||||
.annotate(
|
||||
total_issues=Count(
|
||||
"id",
|
||||
filter=Q(archived_at__isnull=True, is_draft=False),
|
||||
filter=Q(
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
),
|
||||
)
|
||||
.annotate(
|
||||
@ -1086,8 +1407,11 @@ class TransferCycleIssueEndpoint(BaseAPIView):
|
||||
.annotate(
|
||||
total_issues=Count(
|
||||
"id",
|
||||
filter=Q(archived_at__isnull=True, is_draft=False),
|
||||
)
|
||||
filter=Q(
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
),
|
||||
)
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
@ -1112,20 +1436,6 @@ class TransferCycleIssueEndpoint(BaseAPIView):
|
||||
.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_data = [
|
||||
{
|
||||
@ -1141,6 +1451,15 @@ class TransferCycleIssueEndpoint(BaseAPIView):
|
||||
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(
|
||||
workspace__slug=slug, project_id=project_id, pk=cycle_id
|
||||
).first()
|
||||
@ -1157,6 +1476,15 @@ class TransferCycleIssueEndpoint(BaseAPIView):
|
||||
"assignees": assignee_distribution_data,
|
||||
"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"])
|
||||
|
||||
|
@ -12,8 +12,9 @@ from django.db.models import (
|
||||
Subquery,
|
||||
UUIDField,
|
||||
Value,
|
||||
Sum
|
||||
)
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.db.models.functions import Coalesce, Cast
|
||||
from django.utils import timezone
|
||||
|
||||
# Third party imports
|
||||
@ -25,7 +26,7 @@ from plane.app.permissions import (
|
||||
from plane.app.serializers import (
|
||||
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.user_timezone_converter import user_timezone_converter
|
||||
|
||||
@ -165,7 +166,6 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
|
||||
)
|
||||
|
||||
def get(self, request, slug, project_id, pk=None):
|
||||
plot_type = request.GET.get("plot_type", "issues")
|
||||
if pk is None:
|
||||
queryset = self.get_queryset()
|
||||
modules = queryset.values( # Required fields
|
||||
@ -218,6 +218,116 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
|
||||
.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 = (
|
||||
Issue.objects.filter(
|
||||
issue_module__module_id=pk,
|
||||
@ -310,7 +420,6 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
|
||||
.order_by("label_name")
|
||||
)
|
||||
|
||||
data = ModuleDetailSerializer(queryset.first()).data
|
||||
data["distribution"] = {
|
||||
"assignees": assignee_distribution,
|
||||
"labels": label_distribution,
|
||||
@ -318,13 +427,12 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
|
||||
}
|
||||
|
||||
# Fetch the modules
|
||||
modules = queryset.first()
|
||||
if modules and modules.start_date and modules.target_date:
|
||||
data["distribution"]["completion_chart"] = burndown_plot(
|
||||
queryset=modules,
|
||||
slug=slug,
|
||||
project_id=project_id,
|
||||
plot_type=plot_type,
|
||||
plot_type="issues",
|
||||
module_id=pk,
|
||||
)
|
||||
|
||||
|
@ -157,6 +157,62 @@ class ModuleViewSet(BaseViewSet):
|
||||
)
|
||||
.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 (
|
||||
super()
|
||||
.get_queryset()
|
||||
@ -211,6 +267,30 @@ class ModuleViewSet(BaseViewSet):
|
||||
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(
|
||||
completed_estimate_points=Coalesce(
|
||||
Subquery(completed_estimate_point),
|
||||
@ -346,7 +426,6 @@ class ModuleViewSet(BaseViewSet):
|
||||
return Response(modules, status=status.HTTP_200_OK)
|
||||
|
||||
def retrieve(self, request, slug, project_id, pk):
|
||||
plot_type = request.GET.get("plot_type", "burndown")
|
||||
queryset = (
|
||||
self.get_queryset()
|
||||
.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 = (
|
||||
Issue.objects.filter(
|
||||
issue_module__module_id=pk,
|
||||
@ -388,7 +577,7 @@ class ModuleViewSet(BaseViewSet):
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
),
|
||||
)
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
@ -455,21 +644,17 @@ class ModuleViewSet(BaseViewSet):
|
||||
.order_by("label_name")
|
||||
)
|
||||
|
||||
data = ModuleDetailSerializer(queryset.first()).data
|
||||
data["distribution"] = {
|
||||
"assignees": assignee_distribution,
|
||||
"labels": label_distribution,
|
||||
"completion_chart": {},
|
||||
}
|
||||
|
||||
# Fetch the modules
|
||||
modules = queryset.first()
|
||||
if modules and modules.start_date and modules.target_date:
|
||||
data["distribution"]["completion_chart"] = burndown_plot(
|
||||
queryset=modules,
|
||||
slug=slug,
|
||||
project_id=project_id,
|
||||
plot_type=plot_type,
|
||||
plot_type="issues",
|
||||
module_id=pk,
|
||||
)
|
||||
|
||||
|
@ -430,17 +430,14 @@ class IssueVotePublicViewSet(BaseViewSet):
|
||||
return IssueVote.objects.none()
|
||||
|
||||
def create(self, request, anchor, issue_id):
|
||||
print("hite")
|
||||
project_deploy_board = DeployBoard.objects.get(
|
||||
anchor=anchor, entity_name="project"
|
||||
)
|
||||
print("awer")
|
||||
issue_vote, _ = IssueVote.objects.get_or_create(
|
||||
actor_id=request.user.id,
|
||||
project_id=project_deploy_board.project_id,
|
||||
issue_id=issue_id,
|
||||
)
|
||||
print("AWer")
|
||||
# Add the user for workspace tracking
|
||||
if not ProjectMember.objects.filter(
|
||||
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 interface ICycle {
|
||||
backlog_issues: number;
|
||||
cancelled_issues: number;
|
||||
export type TCycleCompletionChartDistribution = {
|
||||
[key: string]: number | null;
|
||||
};
|
||||
|
||||
export type TCycleDistributionBase = {
|
||||
total_issues: number;
|
||||
pending_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_by?: string;
|
||||
description: string;
|
||||
distribution?: {
|
||||
assignees: TAssigneesDistribution[];
|
||||
completion_chart: TCompletionChartDistribution;
|
||||
labels: TLabelsDistribution[];
|
||||
};
|
||||
end_date: string | null;
|
||||
id: string;
|
||||
is_favorite?: boolean;
|
||||
name: string;
|
||||
owned_by_id: string;
|
||||
progress_snapshot: TProgressSnapshot;
|
||||
project_id: string;
|
||||
status?: TCycleGroups;
|
||||
sort_order: number;
|
||||
start_date: string | null;
|
||||
started_issues: number;
|
||||
sub_issues?: number;
|
||||
total_issues: number;
|
||||
unstarted_issues: number;
|
||||
updated_at?: string;
|
||||
updated_by?: string;
|
||||
archived_at: string | null;
|
||||
@ -38,47 +87,6 @@ export interface ICycle {
|
||||
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 {
|
||||
id: string;
|
||||
issue_detail: TIssue;
|
||||
@ -102,3 +110,5 @@ export type CycleDateCheckData = {
|
||||
end_date: 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 {
|
||||
TIssue,
|
||||
IIssueFilterOptions,
|
||||
ILinkDetails,
|
||||
TAssigneesDistribution,
|
||||
TCompletionChartDistribution,
|
||||
TLabelsDistribution,
|
||||
} from "@plane/types";
|
||||
import type { TIssue, IIssueFilterOptions, ILinkDetails } from "@plane/types";
|
||||
|
||||
export type TModuleStatus =
|
||||
| "backlog"
|
||||
@ -15,44 +8,88 @@ export type TModuleStatus =
|
||||
| "completed"
|
||||
| "cancelled";
|
||||
|
||||
export interface IModule {
|
||||
backlog_issues: number;
|
||||
cancelled_issues: number;
|
||||
export type TModuleCompletionChartDistribution = {
|
||||
[key: string]: number | null;
|
||||
};
|
||||
|
||||
export type TModuleDistributionBase = {
|
||||
total_issues: number;
|
||||
pending_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_text: any;
|
||||
description_html: any;
|
||||
distribution?: {
|
||||
assignees: TAssigneesDistribution[];
|
||||
completion_chart: TCompletionChartDistribution;
|
||||
labels: TLabelsDistribution[];
|
||||
};
|
||||
id: string;
|
||||
lead_id: string | null;
|
||||
link_module?: ILinkDetails[];
|
||||
member_ids: string[];
|
||||
is_favorite: boolean;
|
||||
name: string;
|
||||
workspace_id: string;
|
||||
project_id: string;
|
||||
sort_order: number;
|
||||
lead_id: string | null;
|
||||
member_ids: string[];
|
||||
link_module?: ILinkDetails[];
|
||||
sub_issues?: number;
|
||||
start_date: string | null;
|
||||
started_issues: 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;
|
||||
is_favorite: boolean;
|
||||
sort_order: number;
|
||||
view_props: {
|
||||
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 {
|
||||
@ -78,3 +115,5 @@ export type ModuleLink = {
|
||||
export type SelectModuleType =
|
||||
| (IModule & { actionType: "edit" | "delete" | "create-issue" })
|
||||
| undefined;
|
||||
|
||||
export type TModulePlotType = "burndown" | "points";
|
||||
|
@ -7,9 +7,7 @@ import useSWR from "swr";
|
||||
import { EmptyState } from "@/components/common";
|
||||
import { PageHead } from "@/components/core";
|
||||
import { ModuleLayoutRoot } from "@/components/issues";
|
||||
import { ModuleDetailsSidebar } from "@/components/modules";
|
||||
// constants
|
||||
// import { EIssuesStoreType } from "@/constants/issue";
|
||||
import { ModuleAnalyticsSidebar } from "@/components/modules";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
// 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)",
|
||||
}}
|
||||
>
|
||||
<ModuleDetailsSidebar moduleId={moduleId.toString()} handleClose={toggleSidebar} />
|
||||
<ModuleAnalyticsSidebar moduleId={moduleId.toString()} handleClose={toggleSidebar} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
@ -1,4 +1,3 @@
|
||||
export * from "./links-list";
|
||||
export * from "./sidebar-progress-stats";
|
||||
export * from "./single-progress-stats";
|
||||
export * from "./sidebar-menu-hamburger-toggle";
|
||||
|
@ -1,6 +1,6 @@
|
||||
import React from "react";
|
||||
import { eachDayOfInterval, isValid } from "date-fns";
|
||||
import { TCompletionChartDistribution } from "@plane/types";
|
||||
import { TModuleCompletionChartDistribution } from "@plane/types";
|
||||
// ui
|
||||
import { LineGraph } from "@/components/ui";
|
||||
// helpers
|
||||
@ -8,11 +8,12 @@ import { getDate, renderFormattedDateWithoutYear } from "@/helpers/date-time.hel
|
||||
//types
|
||||
|
||||
type Props = {
|
||||
distribution: TCompletionChartDistribution;
|
||||
distribution: TModuleCompletionChartDistribution;
|
||||
startDate: string | Date;
|
||||
endDate: string | Date;
|
||||
totalIssues: number;
|
||||
className?: string;
|
||||
plotTitle?: string;
|
||||
};
|
||||
|
||||
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) => ({
|
||||
currentDate: renderFormattedDateWithoutYear(key),
|
||||
pending: distribution[key],
|
||||
@ -129,7 +137,7 @@ const ProgressChart: React.FC<Props> = ({ distribution, startDate, endDate, tota
|
||||
sliceTooltip={(datum) => (
|
||||
<div className="rounded-md border border-custom-border-200 bg-custom-background-80 p-2 text-xs">
|
||||
{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}
|
||||
</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";
|
||||
// types
|
||||
import { ICycle } from "@plane/types";
|
||||
import { ICycle, TCyclePlotType } from "@plane/types";
|
||||
import { CustomSelect, Spinner } from "@plane/ui";
|
||||
// components
|
||||
import ProgressChart from "@/components/core/sidebar/progress-chart";
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
// constants
|
||||
import { EmptyStateType } from "@/constants/empty-state";
|
||||
import { useCycle } from "@/hooks/store";
|
||||
|
||||
export type ActiveCycleProductivityProps = {
|
||||
workspaceSlug: string;
|
||||
@ -14,51 +15,113 @@ export type ActiveCycleProductivityProps = {
|
||||
cycle: ICycle;
|
||||
};
|
||||
|
||||
const cycleBurnDownChartOptions = [
|
||||
{ value: "burndown", label: "Issues" },
|
||||
{ value: "points", label: "Points" },
|
||||
];
|
||||
|
||||
export const ActiveCycleProductivity: FC<ActiveCycleProductivityProps> = (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 (
|
||||
<Link
|
||||
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 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">
|
||||
<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>
|
||||
{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="flex items-center gap-3 text-custom-text-300">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<span className="h-2 w-2 rounded-full bg-[#A9BBD0]" />
|
||||
<span>Ideal</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<span className="h-2 w-2 rounded-full bg-[#4C8FFF]" />
|
||||
<span>Current</span>
|
||||
<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="flex items-center gap-3 text-custom-text-300">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<span className="h-2 w-2 rounded-full bg-[#A9BBD0]" />
|
||||
<span>Ideal</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<span className="h-2 w-2 rounded-full bg-[#4C8FFF]" />
|
||||
<span>Current</span>
|
||||
</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>
|
||||
<span>{`Pending issues - ${cycle.backlog_issues + cycle.unstarted_issues + cycle.started_issues}`}</span>
|
||||
</div>
|
||||
<div className="relative h-full">
|
||||
<ProgressChart
|
||||
className="h-full"
|
||||
distribution={cycle.distribution?.completion_chart ?? {}}
|
||||
startDate={cycle.start_date ?? ""}
|
||||
endDate={cycle.end_date ?? ""}
|
||||
totalIssues={cycle.total_issues}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<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>
|
||||
</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>
|
||||
</>
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
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";
|
||||
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import isEmpty from "lodash/isEmpty";
|
||||
import isEqual from "lodash/isEqual";
|
||||
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";
|
||||
// icons
|
||||
import {
|
||||
ArchiveRestoreIcon,
|
||||
ChevronDown,
|
||||
LinkIcon,
|
||||
Trash2,
|
||||
AlertCircle,
|
||||
ChevronRight,
|
||||
CalendarClock,
|
||||
SquareUser,
|
||||
} from "lucide-react";
|
||||
import { Disclosure, Transition } from "@headlessui/react";
|
||||
import { ArchiveRestoreIcon, LinkIcon, Trash2, ChevronRight, CalendarClock, SquareUser } from "lucide-react";
|
||||
// types
|
||||
import { ICycle, IIssueFilterOptions } from "@plane/types";
|
||||
import { ICycle } from "@plane/types";
|
||||
// ui
|
||||
import { Avatar, ArchiveIcon, CustomMenu, Loader, LayersIcon, TOAST_TYPE, setToast, TextArea } from "@plane/ui";
|
||||
// components
|
||||
import { SidebarProgressStats } from "@/components/core";
|
||||
import ProgressChart from "@/components/core/sidebar/progress-chart";
|
||||
import { ArchiveCycleModal, CycleDeleteModal } from "@/components/cycles";
|
||||
import { ArchiveCycleModal, CycleDeleteModal, CycleAnalyticsProgress } from "@/components/cycles";
|
||||
import { DateRangeDropdown } from "@/components/dropdowns";
|
||||
// constants
|
||||
import { CYCLE_STATUS } from "@/constants/cycle";
|
||||
import { EEstimateSystem } from "@/constants/estimates";
|
||||
import { CYCLE_UPDATED } from "@/constants/event-tracker";
|
||||
import { EIssueFilterType, EIssuesStoreType } from "@/constants/issue";
|
||||
import { EUserWorkspaceRoles } from "@/constants/workspace";
|
||||
// helpers
|
||||
import { findHowManyDaysLeft, getDate, renderFormattedPayloadDate } from "@/helpers/date-time.helper";
|
||||
import { copyUrlToClipboard } from "@/helpers/string.helper";
|
||||
// hooks
|
||||
import { useEventTracker, useCycle, useUser, useMember, useIssues } from "@/hooks/store";
|
||||
import { useEventTracker, useCycle, useUser, useMember, useProjectEstimates } from "@/hooks/store";
|
||||
// services
|
||||
import { CycleService } from "@/services/cycle.service";
|
||||
|
||||
@ -63,10 +50,9 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = useParams();
|
||||
const searchParams = useSearchParams();
|
||||
const peekCycle = searchParams.get("peekCycle");
|
||||
// store hooks
|
||||
const { setTrackElement, captureCycleEvent } = useEventTracker();
|
||||
const { areEstimateEnabledByProjectId, currentActiveEstimateId, estimateById } = useProjectEstimates();
|
||||
const {
|
||||
membership: { currentProjectRole },
|
||||
} = 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 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)
|
||||
return (
|
||||
<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 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 =
|
||||
isCompleted && !isEmpty(cycleDetails.progress_snapshot)
|
||||
? cycleDetails.progress_snapshot.total_issues === 0
|
||||
@ -275,6 +223,15 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
|
||||
? "0 Issue"
|
||||
: `${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 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>
|
||||
</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 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">
|
||||
{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>
|
||||
{workspaceSlug && projectId && cycleDetails?.id && (
|
||||
<CycleAnalyticsProgress
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
projectId={projectId.toString()}
|
||||
cycleId={cycleDetails?.id}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
);
|
@ -5,7 +5,7 @@ import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { generateQueryParams } from "@/helpers/router.helper";
|
||||
import { useCycle } from "@/hooks/store";
|
||||
// components
|
||||
import { CycleDetailsSidebar } from "./sidebar";
|
||||
import { CycleDetailsSidebar } from "./";
|
||||
|
||||
type Props = {
|
||||
projectId: string;
|
||||
@ -25,7 +25,7 @@ export const CyclePeekOverview: React.FC<Props> = observer(({ projectId, workspa
|
||||
const { fetchCycleDetails, fetchArchivedCycleDetails } = useCycle();
|
||||
|
||||
const handleClose = () => {
|
||||
const query = generateQueryParams(searchParams, ['peekCycle']);
|
||||
const query = generateQueryParams(searchParams, ["peekCycle"]);
|
||||
router.push(`${pathname}?${query}`);
|
||||
};
|
||||
|
||||
|
@ -11,10 +11,10 @@ export * from "./delete-modal";
|
||||
export * from "./form";
|
||||
export * from "./modal";
|
||||
export * from "./quick-actions";
|
||||
export * from "./sidebar";
|
||||
export * from "./transfer-issues-modal";
|
||||
export * from "./transfer-issues";
|
||||
export * from "./cycles-view-header";
|
||||
|
||||
export * from "./analytics-sidebar";
|
||||
// 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";
|
||||
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import isEqual from "lodash/isEqual";
|
||||
import React, { useEffect, useState } from "react";
|
||||
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 {
|
||||
AlertCircle,
|
||||
ArchiveRestoreIcon,
|
||||
CalendarClock,
|
||||
ChevronDown,
|
||||
@ -19,7 +17,7 @@ import {
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { Disclosure, Transition } from "@headlessui/react";
|
||||
import { IIssueFilterOptions, ILinkDetails, IModule, ModuleLink } from "@plane/types";
|
||||
import { ILinkDetails, IModule, ModuleLink } from "@plane/types";
|
||||
// ui
|
||||
import {
|
||||
CustomMenu,
|
||||
@ -33,26 +31,24 @@ import {
|
||||
TextArea,
|
||||
} from "@plane/ui";
|
||||
// components
|
||||
import { LinkModal, LinksList, SidebarProgressStats } from "@/components/core";
|
||||
import ProgressChart from "@/components/core/sidebar/progress-chart";
|
||||
import { LinkModal, LinksList } from "@/components/core";
|
||||
import { DateRangeDropdown, MemberDropdown } from "@/components/dropdowns";
|
||||
import { ArchiveModuleModal, DeleteModuleModal } from "@/components/modules";
|
||||
import { ArchiveModuleModal, DeleteModuleModal, ModuleAnalyticsProgress } from "@/components/modules";
|
||||
// constant
|
||||
import { EEstimateSystem } from "@/constants/estimates";
|
||||
import {
|
||||
MODULE_LINK_CREATED,
|
||||
MODULE_LINK_DELETED,
|
||||
MODULE_LINK_UPDATED,
|
||||
MODULE_UPDATED,
|
||||
} from "@/constants/event-tracker";
|
||||
import { EIssueFilterType, EIssuesStoreType } from "@/constants/issue";
|
||||
import { MODULE_STATUS } from "@/constants/module";
|
||||
import { EUserProjectRoles } from "@/constants/project";
|
||||
// helpers
|
||||
import { getDate, renderFormattedPayloadDate } from "@/helpers/date-time.helper";
|
||||
import { copyUrlToClipboard } from "@/helpers/string.helper";
|
||||
// hooks
|
||||
import { useModule, useUser, useEventTracker, useIssues } from "@/hooks/store";
|
||||
// types
|
||||
import { useModule, useUser, useEventTracker, useProjectEstimates } from "@/hooks/store";
|
||||
|
||||
const defaultValues: Partial<IModule> = {
|
||||
lead_id: "",
|
||||
@ -69,7 +65,7 @@ type Props = {
|
||||
};
|
||||
|
||||
// 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;
|
||||
// states
|
||||
const [moduleDeleteModal, setModuleDeleteModal] = useState(false);
|
||||
@ -79,8 +75,7 @@ export const ModuleDetailsSidebar: React.FC<Props> = observer((props) => {
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = useParams();
|
||||
const searchParams = useSearchParams();
|
||||
const peekModule = searchParams.get("peekModule");
|
||||
|
||||
// store hooks
|
||||
const {
|
||||
membership: { currentProjectRole },
|
||||
@ -88,14 +83,17 @@ export const ModuleDetailsSidebar: React.FC<Props> = observer((props) => {
|
||||
const { getModuleById, updateModuleDetails, createModuleLink, updateModuleLink, deleteModuleLink, restoreModule } =
|
||||
useModule();
|
||||
const { setTrackElement, captureModuleEvent, captureEvent } = useEventTracker();
|
||||
const {
|
||||
issuesFilter: { issueFilters, updateFilters },
|
||||
} = useIssues(EIssuesStoreType.MODULE);
|
||||
const moduleDetails = getModuleById(moduleId);
|
||||
const { areEstimateEnabledByProjectId, currentActiveEstimateId, estimateById } = useProjectEstimates();
|
||||
|
||||
// derived values
|
||||
const moduleDetails = getModuleById(moduleId);
|
||||
const moduleState = moduleDetails?.status?.toLocaleLowerCase();
|
||||
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({
|
||||
defaultValues,
|
||||
});
|
||||
@ -254,46 +252,6 @@ export const ModuleDetailsSidebar: React.FC<Props> = observer((props) => {
|
||||
});
|
||||
}, [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) => {
|
||||
setSelectedLinkToUpdate(link);
|
||||
setModuleLinkModal(true);
|
||||
@ -319,6 +277,11 @@ export const ModuleDetailsSidebar: React.FC<Props> = observer((props) => {
|
||||
const issueCount =
|
||||
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;
|
||||
|
||||
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>
|
||||
</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>
|
||||
|
||||
{workspaceSlug && projectId && moduleDetails?.id && (
|
||||
<ModuleAnalyticsProgress
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
projectId={projectId.toString()}
|
||||
moduleId={moduleDetails?.id}
|
||||
/>
|
||||
)}
|
||||
|
||||
<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">
|
||||
{/* 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}>
|
@ -7,7 +7,6 @@ export * from "./form";
|
||||
export * from "./gantt-chart";
|
||||
export * from "./modal";
|
||||
export * from "./modules-list-view";
|
||||
export * from "./sidebar";
|
||||
export * from "./module-card-item";
|
||||
export * from "./module-list-item";
|
||||
export * from "./module-peek-overview";
|
||||
@ -15,5 +14,6 @@ export * from "./quick-actions";
|
||||
export * from "./module-list-item-action";
|
||||
export * from "./module-view-header";
|
||||
|
||||
export * from "./analytics-sidebar";
|
||||
// archived modules
|
||||
export * from "./archived-modules";
|
||||
|
@ -5,7 +5,7 @@ import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { generateQueryParams } from "@/helpers/router.helper";
|
||||
import { useModule } from "@/hooks/store";
|
||||
// components
|
||||
import { ModuleDetailsSidebar } from "./sidebar";
|
||||
import { ModuleAnalyticsSidebar } from "./";
|
||||
|
||||
type Props = {
|
||||
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)",
|
||||
}}
|
||||
>
|
||||
<ModuleDetailsSidebar
|
||||
<ModuleAnalyticsSidebar
|
||||
moduleId={peekModule?.toString() ?? ""}
|
||||
handleClose={handleClose}
|
||||
isArchived={isArchived}
|
||||
|
@ -4,7 +4,7 @@ import sortBy from "lodash/sortBy";
|
||||
import { action, computed, observable, makeObservable, runInAction } from "mobx";
|
||||
import { computedFn } from "mobx-utils";
|
||||
// types
|
||||
import { ICycle, CycleDateCheckData } from "@plane/types";
|
||||
import { ICycle, CycleDateCheckData, TCyclePlotType } from "@plane/types";
|
||||
// helpers
|
||||
import { orderCycles, shouldFilterCycle } from "@/helpers/cycle.helper";
|
||||
import { getDate } from "@/helpers/date-time.helper";
|
||||
@ -22,6 +22,7 @@ export interface ICycleStore {
|
||||
// observables
|
||||
fetchedMap: Record<string, boolean>;
|
||||
cycleMap: Record<string, ICycle>;
|
||||
plotType: Record<string, TCyclePlotType>;
|
||||
activeCycleIdMap: Record<string, boolean>;
|
||||
// computed
|
||||
currentProjectCycleIds: string[] | null;
|
||||
@ -39,8 +40,10 @@ export interface ICycleStore {
|
||||
getCycleNameById: (cycleId: string) => string | undefined;
|
||||
getActiveCycleById: (cycleId: string) => ICycle | null;
|
||||
getProjectCycleIds: (projectId: string) => string[] | null;
|
||||
getPlotTypeByCycleId: (cycleId: string) => TCyclePlotType;
|
||||
// actions
|
||||
validateDate: (workspaceSlug: string, projectId: string, payload: CycleDateCheckData) => Promise<any>;
|
||||
setPlotType: (cycleId: string, plotType: TCyclePlotType) => void;
|
||||
// fetch
|
||||
fetchWorkspaceCycles: (workspaceSlug: string) => Promise<ICycle[]>;
|
||||
fetchAllCycles: (workspaceSlug: string, projectId: string) => Promise<undefined | ICycle[]>;
|
||||
@ -69,6 +72,7 @@ export class CycleStore implements ICycleStore {
|
||||
// observables
|
||||
loader: boolean = false;
|
||||
cycleMap: Record<string, ICycle> = {};
|
||||
plotType: Record<string, TCyclePlotType> = {};
|
||||
activeCycleIdMap: Record<string, boolean> = {};
|
||||
//loaders
|
||||
fetchedMap: Record<string, boolean> = {};
|
||||
@ -85,6 +89,7 @@ export class CycleStore implements ICycleStore {
|
||||
// observables
|
||||
loader: observable.ref,
|
||||
cycleMap: observable,
|
||||
plotType: observable.ref,
|
||||
activeCycleIdMap: observable,
|
||||
fetchedMap: observable,
|
||||
// computed
|
||||
@ -96,6 +101,7 @@ export class CycleStore implements ICycleStore {
|
||||
currentProjectActiveCycleId: computed,
|
||||
currentProjectArchivedCycleIds: computed,
|
||||
// actions
|
||||
setPlotType: action,
|
||||
fetchWorkspaceCycles: action,
|
||||
fetchAllCycles: action,
|
||||
fetchActiveCycle: action,
|
||||
@ -334,6 +340,26 @@ export class CycleStore implements ICycleStore {
|
||||
validateDate = async (workspaceSlug: string, projectId: string, payload: CycleDateCheckData) =>
|
||||
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
|
||||
* @param workspaceSlug
|
||||
|
@ -5,7 +5,7 @@ import update from "lodash/update";
|
||||
import { action, computed, observable, makeObservable, runInAction } from "mobx";
|
||||
import { computedFn } from "mobx-utils";
|
||||
// types
|
||||
import { IModule, ILinkDetails } from "@plane/types";
|
||||
import { IModule, ILinkDetails, TModulePlotType } from "@plane/types";
|
||||
// helpers
|
||||
import { orderModules, shouldFilterModule } from "@/helpers/module.helper";
|
||||
// services
|
||||
@ -19,6 +19,7 @@ export interface IModuleStore {
|
||||
//Loaders
|
||||
loader: boolean;
|
||||
fetchedMap: Record<string, boolean>;
|
||||
plotType: Record<string, TModulePlotType>;
|
||||
// observables
|
||||
moduleMap: Record<string, IModule>;
|
||||
// computed
|
||||
@ -30,7 +31,9 @@ export interface IModuleStore {
|
||||
getModuleById: (moduleId: string) => IModule | null;
|
||||
getModuleNameById: (moduleId: string) => string;
|
||||
getProjectModuleIds: (projectId: string) => string[] | null;
|
||||
getPlotTypeByModuleId: (moduleId: string) => TModulePlotType;
|
||||
// actions
|
||||
setPlotType: (moduleId: string, plotType: TModulePlotType) => void;
|
||||
// fetch
|
||||
fetchWorkspaceModules: (workspaceSlug: string) => Promise<IModule[]>;
|
||||
fetchModules: (workspaceSlug: string, projectId: string) => Promise<undefined | IModule[]>;
|
||||
@ -72,6 +75,7 @@ export class ModulesStore implements IModuleStore {
|
||||
// observables
|
||||
loader: boolean = false;
|
||||
moduleMap: Record<string, IModule> = {};
|
||||
plotType: Record<string, TModulePlotType> = {};
|
||||
//loaders
|
||||
fetchedMap: Record<string, boolean> = {};
|
||||
// root store
|
||||
@ -86,11 +90,13 @@ export class ModulesStore implements IModuleStore {
|
||||
// observables
|
||||
loader: observable.ref,
|
||||
moduleMap: observable,
|
||||
plotType: observable.ref,
|
||||
fetchedMap: observable,
|
||||
// computed
|
||||
projectModuleIds: computed,
|
||||
projectArchivedModuleIds: computed,
|
||||
// actions
|
||||
setPlotType: action,
|
||||
fetchWorkspaceModules: action,
|
||||
fetchModules: action,
|
||||
fetchArchivedModules: action,
|
||||
@ -213,6 +219,26 @@ export class ModulesStore implements IModuleStore {
|
||||
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
|
||||
* @param workspaceSlug
|
||||
|
Loading…
Reference in New Issue
Block a user