chore: revamped the analytics for cycle and module in peek view. (#7075)
* chore: added cycles and modules in analytics peek view * chore: added cycles and modules analytics * chore: added project filter for work items * chore: added a peekview flag and based on that table columns * chore: added peek view * chore: added check for display name * chore: cleaned up some code * chore: fixed export csv data * chore: added distinct work items * chore: assignee in peek view * updated csv fields * chore: updated workitems peek with assignee * fix: removed type assersions for workspaceslug * chore: added day wise filter in cycles and modules * chore: added extra validations --------- Co-authored-by: JayashTripathy <jayashtripathy371@gmail.com>
This commit is contained in:
parent
ba158d5d6e
commit
5b776392bd
21 changed files with 642 additions and 236 deletions
|
|
@ -11,7 +11,6 @@ from plane.app.views import (
|
|||
AdvanceAnalyticsChartEndpoint,
|
||||
DefaultAnalyticsEndpoint,
|
||||
ProjectStatsEndpoint,
|
||||
AdvanceAnalyticsExportEndpoint,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -68,9 +67,4 @@ urlpatterns = [
|
|||
AdvanceAnalyticsChartEndpoint.as_view(),
|
||||
name="advance-analytics-chart",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/advance-analytics-export/",
|
||||
AdvanceAnalyticsExportEndpoint.as_view(),
|
||||
name="advance-analytics-export",
|
||||
),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -203,7 +203,6 @@ from .analytic.advance import (
|
|||
AdvanceAnalyticsEndpoint,
|
||||
AdvanceAnalyticsStatsEndpoint,
|
||||
AdvanceAnalyticsChartEndpoint,
|
||||
AdvanceAnalyticsExportEndpoint,
|
||||
)
|
||||
|
||||
from .notification.base import (
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ from django.db.models import QuerySet, Q, Count
|
|||
from django.http import HttpRequest
|
||||
from django.db.models.functions import TruncMonth
|
||||
from django.utils import timezone
|
||||
|
||||
from datetime import timedelta
|
||||
from plane.app.views.base import BaseAPIView
|
||||
from plane.app.permissions import ROLE, allow_permission
|
||||
from plane.db.models import (
|
||||
|
|
@ -16,19 +16,18 @@ from plane.db.models import (
|
|||
Module,
|
||||
IssueView,
|
||||
ProjectPage,
|
||||
Workspace
|
||||
Workspace,
|
||||
CycleIssue,
|
||||
ModuleIssue,
|
||||
)
|
||||
|
||||
from django.db import models
|
||||
from django.db.models import F, Case, When, Value
|
||||
from django.db.models.functions import Concat
|
||||
from plane.utils.build_chart import build_analytics_chart
|
||||
from plane.bgtasks.analytic_plot_export import export_analytics_to_csv_email
|
||||
from plane.utils.date_utils import (
|
||||
get_analytics_filters,
|
||||
)
|
||||
|
||||
from plane.utils.build_chart import build_analytics_chart
|
||||
from plane.bgtasks.analytic_plot_export import export_analytics_to_csv_email
|
||||
from plane.utils.date_utils import get_analytics_filters
|
||||
|
||||
|
||||
class AdvanceAnalyticsBaseView(BaseAPIView):
|
||||
def initialize_workspace(self, slug: str, type: str) -> None:
|
||||
|
|
@ -73,7 +72,7 @@ class AdvanceAnalyticsEndpoint(AdvanceAnalyticsBaseView):
|
|||
|
||||
return {
|
||||
"count": get_filtered_count(),
|
||||
"filter_count": get_previous_count(),
|
||||
# "filter_count": get_previous_count(),
|
||||
}
|
||||
|
||||
def get_overview_data(self) -> Dict[str, Dict[str, int]]:
|
||||
|
|
@ -120,9 +119,25 @@ class AdvanceAnalyticsEndpoint(AdvanceAnalyticsBaseView):
|
|||
),
|
||||
}
|
||||
|
||||
|
||||
def get_work_items_stats(self) -> Dict[str, Dict[str, int]]:
|
||||
base_queryset = Issue.issue_objects.filter(**self.filters["base_filters"])
|
||||
def get_work_items_stats(
|
||||
self, cycle_id=None, module_id=None
|
||||
) -> Dict[str, Dict[str, int]]:
|
||||
"""
|
||||
Returns work item stats for the workspace, or filtered by cycle_id or module_id if provided.
|
||||
"""
|
||||
base_queryset = None
|
||||
if cycle_id is not None:
|
||||
cycle_issues = CycleIssue.objects.filter(
|
||||
**self.filters["base_filters"], cycle_id=cycle_id
|
||||
).values_list("issue_id", flat=True)
|
||||
base_queryset = Issue.issue_objects.filter(id__in=cycle_issues)
|
||||
elif module_id is not None:
|
||||
module_issues = ModuleIssue.objects.filter(
|
||||
**self.filters["base_filters"], module_id=module_id
|
||||
).values_list("issue_id", flat=True)
|
||||
base_queryset = Issue.issue_objects.filter(id__in=module_issues)
|
||||
else:
|
||||
base_queryset = Issue.issue_objects.filter(**self.filters["base_filters"])
|
||||
|
||||
return {
|
||||
"total_work_items": self.get_filtered_counts(base_queryset),
|
||||
|
|
@ -150,13 +165,14 @@ class AdvanceAnalyticsEndpoint(AdvanceAnalyticsBaseView):
|
|||
self.get_overview_data(),
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
elif tab == "work-items":
|
||||
# Optionally accept cycle_id or module_id as query params
|
||||
cycle_id = request.GET.get("cycle_id", None)
|
||||
module_id = request.GET.get("module_id", None)
|
||||
return Response(
|
||||
self.get_work_items_stats(),
|
||||
self.get_work_items_stats(cycle_id=cycle_id, module_id=module_id),
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
return Response({"message": "Invalid tab"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
|
|
@ -184,14 +200,100 @@ class AdvanceAnalyticsStatsEndpoint(AdvanceAnalyticsBaseView):
|
|||
.order_by("project_id")
|
||||
)
|
||||
|
||||
def get_work_items_stats(
|
||||
self, cycle_id=None, module_id=None, peek_view=False
|
||||
) -> Dict[str, Dict[str, int]]:
|
||||
base_queryset = None
|
||||
if cycle_id is not None:
|
||||
cycle_issues = CycleIssue.objects.filter(
|
||||
**self.filters["base_filters"], cycle_id=cycle_id
|
||||
).values_list("issue_id", flat=True)
|
||||
base_queryset = Issue.issue_objects.filter(id__in=cycle_issues)
|
||||
elif module_id is not None:
|
||||
module_issues = ModuleIssue.objects.filter(
|
||||
**self.filters["base_filters"], module_id=module_id
|
||||
).values_list("issue_id", flat=True)
|
||||
base_queryset = Issue.issue_objects.filter(id__in=module_issues)
|
||||
elif peek_view:
|
||||
base_queryset = Issue.issue_objects.filter(**self.filters["base_filters"])
|
||||
else:
|
||||
base_queryset = Issue.issue_objects.filter(**self.filters["base_filters"])
|
||||
return (
|
||||
base_queryset.values("project_id", "project__name")
|
||||
.annotate(
|
||||
cancelled_work_items=Count(
|
||||
"id", filter=Q(state__group="cancelled")
|
||||
),
|
||||
completed_work_items=Count(
|
||||
"id", filter=Q(state__group="completed")
|
||||
),
|
||||
backlog_work_items=Count("id", filter=Q(state__group="backlog")),
|
||||
un_started_work_items=Count(
|
||||
"id", filter=Q(state__group="unstarted")
|
||||
),
|
||||
started_work_items=Count("id", filter=Q(state__group="started")),
|
||||
)
|
||||
.order_by("project_id")
|
||||
)
|
||||
|
||||
return (
|
||||
base_queryset.annotate(display_name=F("assignees__display_name"))
|
||||
.annotate(assignee_id=F("assignees__id"))
|
||||
.annotate(avatar=F("assignees__avatar"))
|
||||
.annotate(
|
||||
avatar_url=Case(
|
||||
# If `avatar_asset` exists, use it to generate the asset URL
|
||||
When(
|
||||
assignees__avatar_asset__isnull=False,
|
||||
then=Concat(
|
||||
Value("/api/assets/v2/static/"),
|
||||
"assignees__avatar_asset", # Assuming avatar_asset has an id or relevant field
|
||||
Value("/"),
|
||||
),
|
||||
),
|
||||
# If `avatar_asset` is None, fall back to using `avatar` field directly
|
||||
When(
|
||||
assignees__avatar_asset__isnull=True, then="assignees__avatar"
|
||||
),
|
||||
default=Value(None),
|
||||
output_field=models.CharField(),
|
||||
)
|
||||
)
|
||||
.values("display_name", "assignee_id", "avatar_url")
|
||||
.annotate(
|
||||
cancelled_work_items=Count(
|
||||
"id", filter=Q(state__group="cancelled"), distinct=True
|
||||
),
|
||||
completed_work_items=Count(
|
||||
"id", filter=Q(state__group="completed"), distinct=True
|
||||
),
|
||||
backlog_work_items=Count(
|
||||
"id", filter=Q(state__group="backlog"), distinct=True
|
||||
),
|
||||
un_started_work_items=Count(
|
||||
"id", filter=Q(state__group="unstarted"), distinct=True
|
||||
),
|
||||
started_work_items=Count(
|
||||
"id", filter=Q(state__group="started"), distinct=True
|
||||
),
|
||||
)
|
||||
.order_by("display_name")
|
||||
)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
|
||||
def get(self, request: HttpRequest, slug: str) -> Response:
|
||||
self.initialize_workspace(slug, type="chart")
|
||||
type = request.GET.get("type", "work-items")
|
||||
|
||||
if type == "work-items":
|
||||
# Optionally accept cycle_id or module_id as query params
|
||||
cycle_id = request.GET.get("cycle_id", None)
|
||||
module_id = request.GET.get("module_id", None)
|
||||
peek_view = request.GET.get("peek_view", False)
|
||||
return Response(
|
||||
self.get_project_issues_stats(),
|
||||
self.get_work_items_stats(
|
||||
cycle_id=cycle_id, module_id=module_id, peek_view=peek_view
|
||||
),
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
|
|
@ -251,7 +353,9 @@ class AdvanceAnalyticsChartEndpoint(AdvanceAnalyticsBaseView):
|
|||
for key, value in data.items()
|
||||
]
|
||||
|
||||
def work_item_completion_chart(self) -> Dict[str, Any]:
|
||||
def work_item_completion_chart(
|
||||
self, cycle_id=None, module_id=None, peek_view=False
|
||||
) -> Dict[str, Any]:
|
||||
# Get the base queryset
|
||||
queryset = (
|
||||
Issue.issue_objects.filter(**self.filters["base_filters"])
|
||||
|
|
@ -261,61 +365,143 @@ class AdvanceAnalyticsChartEndpoint(AdvanceAnalyticsBaseView):
|
|||
)
|
||||
)
|
||||
|
||||
# Apply date range filter if available
|
||||
if self.filters["chart_period_range"]:
|
||||
start_date, end_date = self.filters["chart_period_range"]
|
||||
queryset = queryset.filter(
|
||||
created_at__date__gte=start_date, created_at__date__lte=end_date
|
||||
)
|
||||
|
||||
# Annotate by month and count
|
||||
monthly_stats = (
|
||||
queryset.annotate(month=TruncMonth("created_at"))
|
||||
.values("month")
|
||||
.annotate(
|
||||
created_count=Count("id"),
|
||||
completed_count=Count("id", filter=Q(completed_at__isnull=False)),
|
||||
)
|
||||
.order_by("month")
|
||||
)
|
||||
|
||||
# Create dictionary of month -> counts
|
||||
stats_dict = {
|
||||
stat["month"].strftime("%Y-%m-%d"): {
|
||||
"created_count": stat["created_count"],
|
||||
"completed_count": stat["completed_count"],
|
||||
}
|
||||
for stat in monthly_stats
|
||||
}
|
||||
|
||||
# Generate monthly data (ensure months with 0 count are included)
|
||||
data = []
|
||||
workspace = Workspace.objects.get(slug=self._workspace_slug)
|
||||
start_date = workspace.created_at.date().replace(day=1)
|
||||
# include the current date at the end
|
||||
end_date = timezone.now().date()
|
||||
last_month = end_date.replace(day=1)
|
||||
current_month = start_date
|
||||
|
||||
while current_month <= last_month:
|
||||
date_str = current_month.strftime("%Y-%m-%d")
|
||||
stats = stats_dict.get(date_str, {"created_count": 0, "completed_count": 0})
|
||||
data.append(
|
||||
{
|
||||
"key": date_str,
|
||||
"name": date_str,
|
||||
"count": stats[
|
||||
"created_count"
|
||||
], # <- Total created issues in that month
|
||||
"completed_issues": stats["completed_count"],
|
||||
"created_issues": stats["created_count"],
|
||||
}
|
||||
)
|
||||
# Move to next month
|
||||
if current_month.month == 12:
|
||||
current_month = current_month.replace(year=current_month.year + 1, month=1)
|
||||
if cycle_id is not None and peek_view:
|
||||
cycle_issues = CycleIssue.objects.filter(
|
||||
**self.filters["base_filters"], cycle_id=cycle_id
|
||||
).values_list("issue_id", flat=True)
|
||||
cycle = Cycle.objects.filter(id=cycle_id).first()
|
||||
if cycle and cycle.start_date:
|
||||
start_date = cycle.start_date.date()
|
||||
end_date = cycle.end_date.date()
|
||||
else:
|
||||
current_month = current_month.replace(month=current_month.month + 1)
|
||||
return {"data": [], "schema": {}}
|
||||
queryset = cycle_issues
|
||||
elif module_id is not None and peek_view:
|
||||
module_issues = ModuleIssue.objects.filter(
|
||||
**self.filters["base_filters"], module_id=module_id
|
||||
).values_list("issue_id", flat=True)
|
||||
module = Module.objects.filter(id=module_id).first()
|
||||
if module and module.start_date:
|
||||
start_date = module.start_date
|
||||
end_date = module.target_date
|
||||
else:
|
||||
return {"data": [], "schema": {}}
|
||||
queryset = module_issues
|
||||
elif peek_view:
|
||||
project_ids_str = self.request.GET.get("project_ids")
|
||||
if project_ids_str:
|
||||
project_id_list = [
|
||||
pid.strip() for pid in project_ids_str.split(",") if pid.strip()
|
||||
]
|
||||
else:
|
||||
project_id_list = []
|
||||
return {"data": [], "schema": {}}
|
||||
project_id = project_id_list[0]
|
||||
project = Project.objects.filter(id=project_id).first()
|
||||
if project.created_at:
|
||||
start_date = project.created_at.date().replace(day=1)
|
||||
else:
|
||||
return {"data": [], "schema": {}}
|
||||
else:
|
||||
workspace = Workspace.objects.get(slug=self._workspace_slug)
|
||||
start_date = workspace.created_at.date().replace(day=1)
|
||||
|
||||
if cycle_id or module_id:
|
||||
# Get daily stats with optimized query
|
||||
daily_stats = (
|
||||
queryset.values("created_at__date")
|
||||
.annotate(
|
||||
created_count=Count("id"),
|
||||
completed_count=Count(
|
||||
"id", filter=Q(issue__state__group="completed")
|
||||
),
|
||||
)
|
||||
.order_by("created_at__date")
|
||||
)
|
||||
|
||||
# Create a dictionary of existing stats with summed counts
|
||||
stats_dict = {
|
||||
stat["created_at__date"].strftime("%Y-%m-%d"): {
|
||||
"created_count": stat["created_count"],
|
||||
"completed_count": stat["completed_count"],
|
||||
}
|
||||
for stat in daily_stats
|
||||
}
|
||||
|
||||
# Generate data for all days in the range
|
||||
data = []
|
||||
current_date = start_date
|
||||
while current_date <= end_date:
|
||||
date_str = current_date.strftime("%Y-%m-%d")
|
||||
stats = stats_dict.get(
|
||||
date_str, {"created_count": 0, "completed_count": 0}
|
||||
)
|
||||
data.append(
|
||||
{
|
||||
"key": date_str,
|
||||
"name": date_str,
|
||||
"count": stats["created_count"] + stats["completed_count"],
|
||||
"completed_issues": stats["completed_count"],
|
||||
"created_issues": stats["created_count"],
|
||||
}
|
||||
)
|
||||
current_date += timedelta(days=1)
|
||||
else:
|
||||
# Apply date range filter if available
|
||||
if self.filters["chart_period_range"]:
|
||||
start_date, end_date = self.filters["chart_period_range"]
|
||||
queryset = queryset.filter(
|
||||
created_at__date__gte=start_date, created_at__date__lte=end_date
|
||||
)
|
||||
|
||||
# Annotate by month and count
|
||||
monthly_stats = (
|
||||
queryset.annotate(month=TruncMonth("created_at"))
|
||||
.values("month")
|
||||
.annotate(
|
||||
created_count=Count("id"),
|
||||
completed_count=Count("id", filter=Q(state__group="completed")),
|
||||
)
|
||||
.order_by("month")
|
||||
)
|
||||
|
||||
# Create dictionary of month -> counts
|
||||
stats_dict = {
|
||||
stat["month"].strftime("%Y-%m-%d"): {
|
||||
"created_count": stat["created_count"],
|
||||
"completed_count": stat["completed_count"],
|
||||
}
|
||||
for stat in monthly_stats
|
||||
}
|
||||
|
||||
# Generate monthly data (ensure months with 0 count are included)
|
||||
data = []
|
||||
# include the current date at the end
|
||||
end_date = timezone.now().date()
|
||||
last_month = end_date.replace(day=1)
|
||||
current_month = start_date
|
||||
|
||||
while current_month <= last_month:
|
||||
date_str = current_month.strftime("%Y-%m-%d")
|
||||
stats = stats_dict.get(
|
||||
date_str, {"created_count": 0, "completed_count": 0}
|
||||
)
|
||||
data.append(
|
||||
{
|
||||
"key": date_str,
|
||||
"name": date_str,
|
||||
"count": stats["created_count"],
|
||||
"completed_issues": stats["completed_count"],
|
||||
"created_issues": stats["created_count"],
|
||||
}
|
||||
)
|
||||
# Move to next month
|
||||
if current_month.month == 12:
|
||||
current_month = current_month.replace(
|
||||
year=current_month.year + 1, month=1
|
||||
)
|
||||
else:
|
||||
current_month = current_month.replace(month=current_month.month + 1)
|
||||
|
||||
schema = {
|
||||
"completed_issues": "completed_issues",
|
||||
|
|
@ -330,12 +516,13 @@ class AdvanceAnalyticsChartEndpoint(AdvanceAnalyticsBaseView):
|
|||
type = request.GET.get("type", "projects")
|
||||
group_by = request.GET.get("group_by", None)
|
||||
x_axis = request.GET.get("x_axis", "PRIORITY")
|
||||
cycle_id = request.GET.get("cycle_id", None)
|
||||
module_id = request.GET.get("module_id", None)
|
||||
|
||||
if type == "projects":
|
||||
return Response(self.project_chart(), status=status.HTTP_200_OK)
|
||||
|
||||
elif type == "custom-work-items":
|
||||
# Get the base queryset
|
||||
queryset = (
|
||||
Issue.issue_objects.filter(**self.filters["base_filters"])
|
||||
.select_related("workspace", "state", "parent")
|
||||
|
|
@ -344,6 +531,19 @@ class AdvanceAnalyticsChartEndpoint(AdvanceAnalyticsBaseView):
|
|||
)
|
||||
)
|
||||
|
||||
# Apply cycle/module filters if present
|
||||
if cycle_id is not None:
|
||||
cycle_issues = CycleIssue.objects.filter(
|
||||
**self.filters["base_filters"], cycle_id=cycle_id
|
||||
).values_list("issue_id", flat=True)
|
||||
queryset = queryset.filter(id__in=cycle_issues)
|
||||
|
||||
elif module_id is not None:
|
||||
module_issues = ModuleIssue.objects.filter(
|
||||
**self.filters["base_filters"], module_id=module_id
|
||||
).values_list("issue_id", flat=True)
|
||||
queryset = queryset.filter(id__in=module_issues)
|
||||
|
||||
# Apply date range filter if available
|
||||
if self.filters["chart_period_range"]:
|
||||
start_date, end_date = self.filters["chart_period_range"]
|
||||
|
|
@ -357,66 +557,15 @@ class AdvanceAnalyticsChartEndpoint(AdvanceAnalyticsBaseView):
|
|||
)
|
||||
|
||||
elif type == "work-items":
|
||||
# Optionally accept cycle_id or module_id as query params
|
||||
cycle_id = request.GET.get("cycle_id", None)
|
||||
module_id = request.GET.get("module_id", None)
|
||||
peek_view = request.GET.get("peek_view", False)
|
||||
return Response(
|
||||
self.work_item_completion_chart(),
|
||||
self.work_item_completion_chart(
|
||||
cycle_id=cycle_id, module_id=module_id, peek_view=peek_view
|
||||
),
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
return Response({"message": "Invalid type"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
class AdvanceAnalyticsExportEndpoint(AdvanceAnalyticsBaseView):
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
|
||||
def post(self, request: HttpRequest, slug: str) -> Response:
|
||||
self.initialize_workspace(slug, type="chart")
|
||||
queryset = Issue.issue_objects.filter(**self.filters["base_filters"])
|
||||
|
||||
# Apply date range filter if available
|
||||
if self.filters["chart_period_range"]:
|
||||
start_date, end_date = self.filters["chart_period_range"]
|
||||
queryset = queryset.filter(
|
||||
created_at__date__gte=start_date, created_at__date__lte=end_date
|
||||
)
|
||||
|
||||
queryset = (
|
||||
queryset.values("project_id", "project__name")
|
||||
.annotate(
|
||||
cancelled_work_items=Count("id", filter=Q(state__group="cancelled")),
|
||||
completed_work_items=Count("id", filter=Q(state__group="completed")),
|
||||
backlog_work_items=Count("id", filter=Q(state__group="backlog")),
|
||||
un_started_work_items=Count("id", filter=Q(state__group="unstarted")),
|
||||
started_work_items=Count("id", filter=Q(state__group="started")),
|
||||
)
|
||||
.order_by("project_id")
|
||||
)
|
||||
|
||||
# Convert QuerySet to list of dictionaries for serialization
|
||||
serialized_data = list(queryset)
|
||||
|
||||
headers = [
|
||||
"Projects",
|
||||
"Completed Issues",
|
||||
"Backlog Issues",
|
||||
"Unstarted Issues",
|
||||
"Started Issues",
|
||||
]
|
||||
|
||||
keys = [
|
||||
"project__name",
|
||||
"completed_work_items",
|
||||
"backlog_work_items",
|
||||
"un_started_work_items",
|
||||
"started_work_items",
|
||||
]
|
||||
|
||||
email = request.user.email
|
||||
|
||||
# Send serialized data to background task
|
||||
export_analytics_to_csv_email.delay(serialized_data, headers, keys, email, slug)
|
||||
|
||||
return Response(
|
||||
{
|
||||
"message": f"Once the export is ready it will be emailed to you at {str(email)}"
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -174,8 +174,8 @@ def get_analytics_filters(
|
|||
"workspace__slug": slug,
|
||||
"project_projectmember__member": user,
|
||||
"project_projectmember__is_active": True,
|
||||
"project__deleted_at__isnull": True,
|
||||
"project__archived_at__isnull": True,
|
||||
"deleted_at__isnull": True,
|
||||
"archived_at__isnull": True,
|
||||
}
|
||||
|
||||
# Add project IDs to filters if provided
|
||||
|
|
|
|||
51
packages/types/src/analytics-v2.d.ts
vendored
51
packages/types/src/analytics-v2.d.ts
vendored
|
|
@ -1,52 +1,55 @@
|
|||
import { ChartXAxisProperty, ChartYAxisMetric } from "@plane/constants";
|
||||
import { TChartData } from "./charts";
|
||||
|
||||
export type TAnalyticsTabsV2Base = "overview" | "work-items"
|
||||
export type TAnalyticsGraphsV2Base = "projects" | "work-items" | "custom-work-items"
|
||||
|
||||
export type TAnalyticsTabsV2Base = "overview" | "work-items";
|
||||
export type TAnalyticsGraphsV2Base = "projects" | "work-items" | "custom-work-items";
|
||||
|
||||
// service types
|
||||
|
||||
export interface IAnalyticsResponseV2 {
|
||||
[key: string]: any;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface IAnalyticsResponseFieldsV2 {
|
||||
count: number;
|
||||
filter_count: number;
|
||||
count: number;
|
||||
filter_count: number;
|
||||
}
|
||||
|
||||
export interface IAnalyticsRadarEntityV2 {
|
||||
key: string,
|
||||
name: string,
|
||||
count: number
|
||||
key: string;
|
||||
name: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
// chart types
|
||||
|
||||
export interface IChartResponseV2 {
|
||||
schema: Record<string, string>;
|
||||
data: TChartData<string, string>[];
|
||||
schema: Record<string, string>;
|
||||
data: TChartData<string, string>[];
|
||||
}
|
||||
|
||||
// table types
|
||||
|
||||
export interface WorkItemInsightColumns {
|
||||
project_id: string;
|
||||
project__name: string;
|
||||
cancelled_work_items: number;
|
||||
completed_work_items: number;
|
||||
backlog_work_items: number;
|
||||
un_started_work_items: number;
|
||||
started_work_items: number;
|
||||
project_id?: string;
|
||||
project__name?: string;
|
||||
cancelled_work_items: number;
|
||||
completed_work_items: number;
|
||||
backlog_work_items: number;
|
||||
un_started_work_items: number;
|
||||
started_work_items: number;
|
||||
// because of the peek view, we will display the name of the project instead of project__name
|
||||
display_name?: string;
|
||||
avatar_url?: string;
|
||||
assignee_id?: string;
|
||||
}
|
||||
|
||||
export type AnalyticsTableDataMap = {
|
||||
"work-items": WorkItemInsightColumns,
|
||||
}
|
||||
"work-items": WorkItemInsightColumns;
|
||||
};
|
||||
|
||||
export interface IAnalyticsV2Params {
|
||||
x_axis: ChartXAxisProperty;
|
||||
y_axis: ChartYAxisMetric;
|
||||
group_by?: ChartXAxisProperty;
|
||||
}
|
||||
x_axis: ChartXAxisProperty;
|
||||
y_axis: ChartYAxisMetric;
|
||||
group_by?: ChartXAxisProperty;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ import {
|
|||
// ui
|
||||
import { Breadcrumbs, Button, ContrastIcon, Tooltip, Header, CustomSearchSelect } from "@plane/ui";
|
||||
// components
|
||||
import { ProjectAnalyticsModal } from "@/components/analytics";
|
||||
import { WorkItemsModal } from "@/components/analytics-v2/work-items/modal";
|
||||
import { BreadcrumbLink, SwitcherLabel } from "@/components/common";
|
||||
import { CycleQuickActions } from "@/components/cycles";
|
||||
import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "@/components/issues";
|
||||
|
|
@ -161,7 +161,8 @@ export const CycleIssuesHeader: React.FC = observer(() => {
|
|||
|
||||
return (
|
||||
<>
|
||||
<ProjectAnalyticsModal
|
||||
<WorkItemsModal
|
||||
projectDetails={currentProjectDetails}
|
||||
isOpen={analyticsModal}
|
||||
onClose={() => setAnalyticsModal(false)}
|
||||
cycleDetails={cycleDetails ?? undefined}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOption
|
|||
// ui
|
||||
import { CustomMenu } from "@plane/ui";
|
||||
// components
|
||||
import { ProjectAnalyticsModal } from "@/components/analytics";
|
||||
import { WorkItemsModal } from "@/components/analytics-v2/work-items/modal";
|
||||
import { DisplayFiltersSelection, FilterSelection, FiltersDropdown, IssueLayoutIcon } from "@/components/issues";
|
||||
// helpers
|
||||
import { isIssueFilterActive } from "@/helpers/filter.helper";
|
||||
|
|
@ -123,7 +123,8 @@ export const CycleIssuesMobileHeader = () => {
|
|||
|
||||
return (
|
||||
<>
|
||||
<ProjectAnalyticsModal
|
||||
<WorkItemsModal
|
||||
projectDetails={currentProjectDetails}
|
||||
isOpen={analyticsModal}
|
||||
onClose={() => setAnalyticsModal(false)}
|
||||
cycleDetails={cycleDetails ?? undefined}
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ import {
|
|||
// ui
|
||||
import { Breadcrumbs, Button, DiceIcon, Tooltip, Header, CustomSearchSelect } from "@plane/ui";
|
||||
// components
|
||||
import { ProjectAnalyticsModal } from "@/components/analytics";
|
||||
import { WorkItemsModal } from "@/components/analytics-v2/work-items/modal";
|
||||
import { BreadcrumbLink, SwitcherLabel } from "@/components/common";
|
||||
import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "@/components/issues";
|
||||
// helpers
|
||||
|
|
@ -155,10 +155,11 @@ export const ModuleIssuesHeader: React.FC = observer(() => {
|
|||
|
||||
return (
|
||||
<>
|
||||
<ProjectAnalyticsModal
|
||||
<WorkItemsModal
|
||||
isOpen={analyticsModal}
|
||||
onClose={() => setAnalyticsModal(false)}
|
||||
moduleDetails={moduleDetails ?? undefined}
|
||||
projectDetails={currentProjectDetails}
|
||||
/>
|
||||
<Header>
|
||||
<Header.LeftItem>
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOption
|
|||
import { CustomMenu } from "@plane/ui";
|
||||
// components
|
||||
import { ProjectAnalyticsModal } from "@/components/analytics";
|
||||
import { WorkItemsModal } from "@/components/analytics-v2/work-items/modal";
|
||||
import {
|
||||
DisplayFiltersSelection,
|
||||
FilterSelection,
|
||||
|
|
@ -106,10 +107,11 @@ export const ModuleIssuesMobileHeader = observer(() => {
|
|||
|
||||
return (
|
||||
<div className="block md:hidden">
|
||||
<ProjectAnalyticsModal
|
||||
<WorkItemsModal
|
||||
isOpen={analyticsModal}
|
||||
onClose={() => setAnalyticsModal(false)}
|
||||
moduleDetails={moduleDetails ?? undefined}
|
||||
projectDetails={currentProjectDetails}
|
||||
/>
|
||||
<div className="flex justify-evenly border-b border-custom-border-200 bg-custom-background-100 py-2">
|
||||
<CustomMenu
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ export const InsightTable = <T extends Exclude<TAnalyticsTabsV2Base, "overview">
|
|||
const { data, isLoading, columns, columnsLabels } = props;
|
||||
const params = useParams();
|
||||
const { t } = useTranslation();
|
||||
const workspaceSlug = params.workspaceSlug as string;
|
||||
const workspaceSlug = params.workspaceSlug.toString();
|
||||
if (isLoading) {
|
||||
return <TableLoader columns={columns} rows={5} />;
|
||||
}
|
||||
|
|
@ -35,7 +35,7 @@ export const InsightTable = <T extends Exclude<TAnalyticsTabsV2Base, "overview">
|
|||
|
||||
const exportCSV = (rows: Row<AnalyticsTableDataMap[T]>[]) => {
|
||||
const rowData: any = rows.map((row) => {
|
||||
const { project_id, ...exportableData } = row.original;
|
||||
const { project_id, avatar_url, assignee_id, ...exportableData } = row.original;
|
||||
return Object.fromEntries(
|
||||
Object.entries(exportableData).map(([key, value]) => {
|
||||
if (columnsLabels?.[key]) {
|
||||
|
|
|
|||
|
|
@ -26,16 +26,20 @@ const analyticsV2Service = new AnalyticsV2Service();
|
|||
const ProjectInsights = observer(() => {
|
||||
const params = useParams();
|
||||
const { t } = useTranslation();
|
||||
const workspaceSlug = params.workspaceSlug as string;
|
||||
const { selectedDuration, selectedDurationLabel, selectedProjects } = useAnalyticsV2();
|
||||
const workspaceSlug = params.workspaceSlug.toString();
|
||||
const { selectedDuration, selectedDurationLabel, selectedProjects, selectedCycle, selectedModule, isPeekView } =
|
||||
useAnalyticsV2();
|
||||
const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/analytics-v2/empty-chart-radar" });
|
||||
|
||||
const { data: projectInsightsData, isLoading: isLoadingProjectInsight } = useSWR(
|
||||
`radar-chart-${workspaceSlug}-${selectedDuration}-${selectedProjects}`,
|
||||
`radar-chart-${workspaceSlug}-${selectedDuration}-${selectedProjects}-${selectedCycle}-${selectedModule}-${isPeekView}`,
|
||||
() =>
|
||||
analyticsV2Service.getAdvanceAnalyticsCharts<TChartData<string, string>[]>(workspaceSlug, "projects", {
|
||||
// date_filter: selectedDuration,
|
||||
...(selectedProjects?.length > 0 && { project_ids: selectedProjects?.join(",") }),
|
||||
...(selectedCycle ? { cycle_id: selectedCycle } : {}),
|
||||
...(selectedModule ? { module_id: selectedModule } : {}),
|
||||
...(isPeekView ? { peek_view: true } : {}),
|
||||
})
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -18,16 +18,20 @@ const analyticsV2Service = new AnalyticsV2Service();
|
|||
const TotalInsights: React.FC<{ analyticsType: TAnalyticsTabsV2Base; peekView?: boolean }> = observer(
|
||||
({ analyticsType, peekView }) => {
|
||||
const params = useParams();
|
||||
const workspaceSlug = params.workspaceSlug as string;
|
||||
const workspaceSlug = params.workspaceSlug.toString();
|
||||
const { t } = useTranslation();
|
||||
const { selectedDuration, selectedProjects, selectedDurationLabel } = useAnalyticsV2();
|
||||
const { selectedDuration, selectedProjects, selectedDurationLabel, selectedCycle, selectedModule, isPeekView } =
|
||||
useAnalyticsV2();
|
||||
|
||||
const { data: totalInsightsData, isLoading } = useSWR(
|
||||
`total-insights-${analyticsType}-${selectedDuration}-${selectedProjects}`,
|
||||
`total-insights-${analyticsType}-${selectedDuration}-${selectedProjects}-${selectedCycle}-${selectedModule}-${isPeekView}`,
|
||||
() =>
|
||||
analyticsV2Service.getAdvanceAnalytics<IAnalyticsResponseV2>(workspaceSlug, analyticsType, {
|
||||
// date_filter: selectedDuration,
|
||||
...(selectedProjects?.length > 0 ? { project_ids: selectedProjects.join(",") } : {}),
|
||||
...(selectedCycle ? { cycle_id: selectedCycle } : {}),
|
||||
...(selectedModule ? { module_id: selectedModule } : {}),
|
||||
...(isPeekView ? { peek_view: true } : {}),
|
||||
})
|
||||
);
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -19,17 +19,21 @@ import { ChartLoader } from "../loaders";
|
|||
|
||||
const analyticsV2Service = new AnalyticsV2Service();
|
||||
const CreatedVsResolved = observer(() => {
|
||||
const { selectedDuration, selectedDurationLabel, selectedProjects } = useAnalyticsV2();
|
||||
const { selectedDuration, selectedDurationLabel, selectedProjects, selectedCycle, selectedModule, isPeekView } =
|
||||
useAnalyticsV2();
|
||||
const params = useParams();
|
||||
const { t } = useTranslation();
|
||||
const workspaceSlug = params.workspaceSlug as string;
|
||||
const workspaceSlug = params.workspaceSlug.toString();
|
||||
const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/analytics-v2/empty-chart-area" });
|
||||
const { data: createdVsResolvedData, isLoading: isCreatedVsResolvedLoading } = useSWR(
|
||||
`created-vs-resolved-${workspaceSlug}-${selectedDuration}-${selectedProjects}`,
|
||||
`created-vs-resolved-${workspaceSlug}-${selectedDuration}-${selectedProjects}-${selectedCycle}-${selectedModule}-${isPeekView}`,
|
||||
() =>
|
||||
analyticsV2Service.getAdvanceAnalyticsCharts<IChartResponseV2>(workspaceSlug, "work-items", {
|
||||
// date_filter: selectedDuration,
|
||||
...(selectedProjects?.length > 0 && { project_ids: selectedProjects?.join(",") }),
|
||||
...(selectedCycle ? { cycle_id: selectedCycle } : {}),
|
||||
...(selectedModule ? { module_id: selectedModule } : {}),
|
||||
...(isPeekView ? { peek_view: true } : {}),
|
||||
})
|
||||
);
|
||||
const parsedData: TChartData<string, string>[] = useMemo(() => {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import React, { useEffect, useState } from "react";
|
|||
import { observer } from "mobx-react";
|
||||
import { Tab } from "@headlessui/react";
|
||||
// plane package imports
|
||||
import { IProject } from "@plane/types";
|
||||
import { ICycle, IModule, IProject } from "@plane/types";
|
||||
import { Spinner } from "@plane/ui";
|
||||
// hooks
|
||||
import { useAnalyticsV2 } from "@/hooks/store";
|
||||
|
|
@ -15,20 +15,52 @@ import WorkItemsInsightTable from "../workitems-insight-table";
|
|||
type Props = {
|
||||
fullScreen: boolean;
|
||||
projectDetails: IProject | undefined;
|
||||
cycleDetails: ICycle | undefined;
|
||||
moduleDetails: IModule | undefined;
|
||||
};
|
||||
|
||||
export const WorkItemsModalMainContent: React.FC<Props> = observer((props) => {
|
||||
const { projectDetails, fullScreen } = props;
|
||||
const { updateSelectedProjects } = useAnalyticsV2();
|
||||
const [isProjectConfigured, setIsProjectConfigured] = useState(false);
|
||||
const { projectDetails, cycleDetails, moduleDetails, fullScreen } = props;
|
||||
const { updateSelectedProjects, updateSelectedCycle, updateSelectedModule, updateIsPeekView } = useAnalyticsV2();
|
||||
const [isModalConfigured, setIsModalConfigured] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectDetails?.id) return;
|
||||
updateSelectedProjects([projectDetails?.id ?? ""]);
|
||||
setIsProjectConfigured(true);
|
||||
}, [projectDetails?.id, updateSelectedProjects]);
|
||||
updateIsPeekView(true);
|
||||
|
||||
if (!isProjectConfigured)
|
||||
// Handle project selection
|
||||
if (projectDetails?.id) {
|
||||
updateSelectedProjects([projectDetails.id]);
|
||||
}
|
||||
|
||||
// Handle cycle selection
|
||||
if (cycleDetails?.id) {
|
||||
updateSelectedCycle(cycleDetails.id);
|
||||
}
|
||||
|
||||
// Handle module selection
|
||||
if (moduleDetails?.id) {
|
||||
updateSelectedModule(moduleDetails.id);
|
||||
}
|
||||
setIsModalConfigured(true);
|
||||
|
||||
// Cleanup fields
|
||||
return () => {
|
||||
updateSelectedProjects([]);
|
||||
updateSelectedCycle("");
|
||||
updateSelectedModule("");
|
||||
updateIsPeekView(false);
|
||||
};
|
||||
}, [
|
||||
projectDetails?.id,
|
||||
cycleDetails?.id,
|
||||
moduleDetails?.id,
|
||||
updateSelectedProjects,
|
||||
updateSelectedCycle,
|
||||
updateSelectedModule,
|
||||
updateIsPeekView,
|
||||
]);
|
||||
|
||||
if (!isModalConfigured)
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Spinner />
|
||||
|
|
|
|||
|
|
@ -1,21 +1,26 @@
|
|||
import { observer } from "mobx-react";
|
||||
|
||||
// icons
|
||||
// plane package imports
|
||||
import { Expand, Shrink, X } from "lucide-react";
|
||||
import { ICycle, IModule } from "@plane/types";
|
||||
// icons
|
||||
|
||||
type Props = {
|
||||
fullScreen: boolean;
|
||||
handleClose: () => void;
|
||||
setFullScreen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
title: string;
|
||||
cycle?: ICycle;
|
||||
module?: IModule;
|
||||
};
|
||||
|
||||
export const WorkItemsModalHeader: React.FC<Props> = observer((props) => {
|
||||
const { fullScreen, handleClose, setFullScreen, title } = props;
|
||||
const { fullScreen, handleClose, setFullScreen, title, cycle, module } = props;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4 bg-custom-background-100 px-5 py-4 text-sm">
|
||||
<h3 className="break-words">Analytics for {title}</h3>
|
||||
<h3 className="break-words">
|
||||
Analytics for {title} {cycle && `in ${cycle.name}`} {module && `in ${module.name}`}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import React, { useState } from "react";
|
|||
import { observer } from "mobx-react";
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
// plane package imports
|
||||
import { IProject } from "@plane/types";
|
||||
import { ICycle, IModule, IProject } from "@plane/types";
|
||||
// plane web components
|
||||
import { WorkItemsModalMainContent } from "./content";
|
||||
import { WorkItemsModalHeader } from "./header";
|
||||
|
|
@ -11,10 +11,12 @@ type Props = {
|
|||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
projectDetails?: IProject | undefined;
|
||||
cycleDetails?: ICycle | undefined;
|
||||
moduleDetails?: IModule | undefined;
|
||||
};
|
||||
|
||||
export const WorkItemsModal: React.FC<Props> = observer((props) => {
|
||||
const { isOpen, onClose, projectDetails } = props;
|
||||
const { isOpen, onClose, projectDetails, moduleDetails, cycleDetails } = props;
|
||||
|
||||
const [fullScreen, setFullScreen] = useState(false);
|
||||
|
||||
|
|
@ -51,8 +53,15 @@ export const WorkItemsModal: React.FC<Props> = observer((props) => {
|
|||
handleClose={handleClose}
|
||||
setFullScreen={setFullScreen}
|
||||
title={projectDetails?.name ?? ""}
|
||||
cycle={cycleDetails}
|
||||
module={moduleDetails}
|
||||
/>
|
||||
<WorkItemsModalMainContent
|
||||
fullScreen={fullScreen}
|
||||
projectDetails={projectDetails}
|
||||
cycleDetails={cycleDetails}
|
||||
moduleDetails={moduleDetails}
|
||||
/>
|
||||
<WorkItemsModalMainContent fullScreen={fullScreen} projectDetails={projectDetails} />
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
|
|
|
|||
|
|
@ -46,19 +46,23 @@ const PriorityChart = observer((props: Props) => {
|
|||
const { t } = useTranslation();
|
||||
const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/analytics-v2/empty-chart-bar" });
|
||||
// store hooks
|
||||
const { selectedDuration, selectedProjects } = useAnalyticsV2();
|
||||
const { selectedDuration, selectedProjects, selectedCycle, selectedModule, isPeekView } = useAnalyticsV2();
|
||||
const { workspaceStates } = useProjectState();
|
||||
const { resolvedTheme } = useTheme();
|
||||
// router
|
||||
const params = useParams();
|
||||
const workspaceSlug = params.workspaceSlug as string;
|
||||
const workspaceSlug = params.workspaceSlug.toString();
|
||||
|
||||
const { data: priorityChartData, isLoading: priorityChartLoading } = useSWR(
|
||||
`customized-insights-chart-${workspaceSlug}-${selectedDuration}-${selectedProjects}-${props.x_axis}-${props.y_axis}-${props.group_by}`,
|
||||
`customized-insights-chart-${workspaceSlug}-${selectedDuration}-
|
||||
${selectedProjects}-${selectedCycle}-${selectedModule}-${props.x_axis}-${props.y_axis}-${props.group_by}-${isPeekView}`,
|
||||
() =>
|
||||
analyticsV2Service.getAdvanceAnalyticsCharts<TChart>(workspaceSlug, "custom-work-items", {
|
||||
// date_filter: selectedDuration,
|
||||
...(selectedProjects?.length > 0 && { project_ids: selectedProjects?.join(",") }),
|
||||
...(selectedCycle ? { cycle_id: selectedCycle } : {}),
|
||||
...(selectedModule ? { module_id: selectedModule } : {}),
|
||||
...(isPeekView ? { peek_view: true } : {}),
|
||||
...props,
|
||||
})
|
||||
);
|
||||
|
|
@ -158,10 +162,23 @@ const PriorityChart = observer((props: Props) => {
|
|||
});
|
||||
|
||||
const exportCSV = (rows: Row<TChartDatum>[]) => {
|
||||
const rowData = rows.map((row) => ({
|
||||
name: row.original.name,
|
||||
count: row.original.count,
|
||||
}));
|
||||
const rowData = rows.map((row) => {
|
||||
const hiddenFields = ["key", "avatar_url", "assignee_id", "project_id"];
|
||||
const otherFields = Object.keys(row.original).filter(
|
||||
(key) => key !== "name" && key !== "count" && !hiddenFields.includes(key) && !key.includes("id")
|
||||
);
|
||||
return {
|
||||
name: row.original.name,
|
||||
count: row.original.count,
|
||||
...otherFields.reduce(
|
||||
(acc, key) => {
|
||||
acc[parsedData?.schema[key] ?? key] = row.original[key];
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string | number>
|
||||
),
|
||||
};
|
||||
});
|
||||
const csv = generateCsv(csvConfig)(rowData);
|
||||
download(csvConfig)(csv);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,13 +1,15 @@
|
|||
import { useMemo } from "react";
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { ColumnDef, Row } from "@tanstack/react-table";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import useSWR from "swr";
|
||||
import { Briefcase } from "lucide-react";
|
||||
import { Briefcase, UserRound } from "lucide-react";
|
||||
// plane package imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { WorkItemInsightColumns, AnalyticsTableDataMap } from "@plane/types";
|
||||
// plane web components
|
||||
import { Avatar } from "@plane/ui";
|
||||
import { getFileURL } from "@plane/utils";
|
||||
import { Logo } from "@/components/common/logo";
|
||||
// hooks
|
||||
import { useAnalyticsV2 } from "@/hooks/store/use-analytics-v2";
|
||||
|
|
@ -21,44 +23,85 @@ const analyticsV2Service = new AnalyticsV2Service();
|
|||
const WorkItemsInsightTable = observer(() => {
|
||||
// router
|
||||
const params = useParams();
|
||||
const workspaceSlug = params.workspaceSlug as string;
|
||||
const workspaceSlug = params.workspaceSlug.toString();
|
||||
const { t } = useTranslation();
|
||||
// store hooks
|
||||
const { getProjectById } = useProject();
|
||||
const { selectedDuration, selectedProjects } = useAnalyticsV2();
|
||||
const { selectedDuration, selectedProjects, selectedCycle, selectedModule, isPeekView } = useAnalyticsV2();
|
||||
const { data: workItemsData, isLoading } = useSWR(
|
||||
`insights-table-work-items-${workspaceSlug}-${selectedDuration}-${selectedProjects}`,
|
||||
`insights-table-work-items-${workspaceSlug}-${selectedDuration}-${selectedProjects}-${selectedCycle}-${selectedModule}-${isPeekView}`,
|
||||
() =>
|
||||
analyticsV2Service.getAdvanceAnalyticsStats<WorkItemInsightColumns[]>(workspaceSlug, "work-items", {
|
||||
// date_filter: selectedDuration,
|
||||
...(selectedProjects?.length > 0 ? { project_ids: selectedProjects.join(",") } : {}),
|
||||
...(selectedCycle ? { cycle_id: selectedCycle } : {}),
|
||||
...(selectedModule ? { module_id: selectedModule } : {}),
|
||||
...(isPeekView ? { peek_view: true } : {}),
|
||||
})
|
||||
);
|
||||
// derived values
|
||||
const columnsLabels: Record<string, string> = {
|
||||
backlog_work_items: t("workspace_projects.state.backlog"),
|
||||
started_work_items: t("workspace_projects.state.started"),
|
||||
un_started_work_items: t("workspace_projects.state.unstarted"),
|
||||
completed_work_items: t("workspace_projects.state.completed"),
|
||||
cancelled_work_items: t("workspace_projects.state.cancelled"),
|
||||
project__name: t("common.project"),
|
||||
};
|
||||
const columnsLabels = useMemo(
|
||||
() => ({
|
||||
backlog_work_items: t("workspace_projects.state.backlog"),
|
||||
started_work_items: t("workspace_projects.state.started"),
|
||||
un_started_work_items: t("workspace_projects.state.unstarted"),
|
||||
completed_work_items: t("workspace_projects.state.completed"),
|
||||
cancelled_work_items: t("workspace_projects.state.cancelled"),
|
||||
project__name: t("common.project"),
|
||||
display_name: t("common.assignee"),
|
||||
}),
|
||||
[t]
|
||||
);
|
||||
const columns = useMemo(
|
||||
() =>
|
||||
[
|
||||
{
|
||||
accessorKey: "project__name",
|
||||
header: () => <div className="text-left">{columnsLabels["project__name"]}</div>,
|
||||
cell: ({ row }) => {
|
||||
const project = getProjectById(row.original.project_id);
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{project?.logo_props ? <Logo logo={project.logo_props} size={18} /> : <Briefcase className="h-4 w-4" />}
|
||||
{project?.name}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
!isPeekView
|
||||
? {
|
||||
accessorKey: "project__name",
|
||||
header: () => <div className="text-left">{columnsLabels["project__name"]}</div>,
|
||||
cell: ({ row }) => {
|
||||
const project = getProjectById(row.original.project_id);
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{project?.logo_props ? (
|
||||
<Logo logo={project.logo_props} size={18} />
|
||||
) : (
|
||||
<Briefcase className="h-4 w-4" />
|
||||
)}
|
||||
{project?.name}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}
|
||||
: {
|
||||
accessorKey: "display_name",
|
||||
header: () => <div className="text-left">{columnsLabels["display_name"]}</div>,
|
||||
cell: ({ row }: { row: Row<WorkItemInsightColumns> }) => (
|
||||
<div className="text-left">
|
||||
<div className="flex items-center gap-2">
|
||||
{row.original.avatar_url && row.original.avatar_url !== "" ? (
|
||||
<Avatar
|
||||
name={row.original.display_name}
|
||||
src={getFileURL(row.original.avatar_url)}
|
||||
size={24}
|
||||
shape="circle"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-full bg-custom-background-80 capitalize overflow-hidden">
|
||||
{row.original.display_name ? (
|
||||
row.original.display_name?.[0]
|
||||
) : (
|
||||
<UserRound className="text-custom-text-200 " size={12} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<span className="break-words text-custom-text-200">
|
||||
{row.original.display_name ?? t(`Unassigned`)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "backlog_work_items",
|
||||
header: () => <div className="text-right">{columnsLabels["backlog_work_items"]}</div>,
|
||||
|
|
@ -85,7 +128,7 @@ const WorkItemsInsightTable = observer(() => {
|
|||
cell: ({ row }) => <div className="text-right">{row.original.cancelled_work_items}</div>,
|
||||
},
|
||||
] as ColumnDef<AnalyticsTableDataMap["work-items"]>[],
|
||||
[getProjectById]
|
||||
[columnsLabels, getProjectById, isPeekView, t]
|
||||
);
|
||||
|
||||
return (
|
||||
|
|
|
|||
107
web/core/components/analytics/old-page.tsx
Normal file
107
web/core/components/analytics/old-page.tsx
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
"use client";
|
||||
|
||||
import React, { Fragment } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { Tab } from "@headlessui/react";
|
||||
// plane package imports
|
||||
import { ANALYTICS_TABS, EUserPermissionsLevel, EUserPermissions } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Header, EHeaderVariant } from "@plane/ui";
|
||||
// components
|
||||
import { CustomAnalytics, ScopeAndDemand } from "@/components/analytics";
|
||||
import { PageHead } from "@/components/core";
|
||||
import { ComicBoxButton, DetailedEmptyState } from "@/components/empty-state";
|
||||
// hooks
|
||||
import { useCommandPalette, useEventTracker, useProject, useUserPermissions, useWorkspace } from "@/hooks/store";
|
||||
import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
|
||||
|
||||
const OldAnalyticsPage = observer(() => {
|
||||
const searchParams = useSearchParams();
|
||||
const analytics_tab = searchParams.get("analytics_tab");
|
||||
// plane imports
|
||||
const { t } = useTranslation();
|
||||
// store hooks
|
||||
const { toggleCreateProjectModal } = useCommandPalette();
|
||||
const { setTrackElement } = useEventTracker();
|
||||
const { workspaceProjectIds, loader } = useProject();
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
// helper hooks
|
||||
const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/onboarding/analytics" });
|
||||
// derived values
|
||||
const pageTitle = currentWorkspace?.name
|
||||
? t(`workspace_analytics.page_label`, { workspace: currentWorkspace?.name })
|
||||
: undefined;
|
||||
|
||||
// permissions
|
||||
const canPerformEmptyStateActions = allowPermissions(
|
||||
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
|
||||
EUserPermissionsLevel.WORKSPACE
|
||||
);
|
||||
|
||||
// TODO: refactor loader implementation
|
||||
return (
|
||||
<>
|
||||
<PageHead title={pageTitle} />
|
||||
{workspaceProjectIds && (
|
||||
<>
|
||||
{workspaceProjectIds.length > 0 || loader === "init-loader" ? (
|
||||
<div className="flex h-full flex-col overflow-hidden bg-custom-background-100">
|
||||
<Tab.Group as={Fragment} defaultIndex={analytics_tab === "custom" ? 1 : 0}>
|
||||
<Header variant={EHeaderVariant.SECONDARY}>
|
||||
<Tab.List as="div" className="flex space-x-2 h-full">
|
||||
{ANALYTICS_TABS.map((tab) => (
|
||||
<Tab key={tab.key} as={Fragment}>
|
||||
{({ selected }) => (
|
||||
<button
|
||||
className={`text-sm group relative flex items-center gap-1 h-full px-3 cursor-pointer transition-all font-medium outline-none ${
|
||||
selected ? "text-custom-primary-100 " : "hover:text-custom-text-200"
|
||||
}`}
|
||||
>
|
||||
{t(tab.i18n_title)}
|
||||
<div
|
||||
className={`border absolute bottom-0 right-0 left-0 rounded-t-md ${selected ? "border-custom-primary-100" : "border-transparent group-hover:border-custom-border-200"}`}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</Tab>
|
||||
))}
|
||||
</Tab.List>
|
||||
</Header>
|
||||
<Tab.Panels as={Fragment}>
|
||||
<Tab.Panel as={Fragment}>
|
||||
<ScopeAndDemand fullScreen />
|
||||
</Tab.Panel>
|
||||
<Tab.Panel as={Fragment}>
|
||||
<CustomAnalytics fullScreen />
|
||||
</Tab.Panel>
|
||||
</Tab.Panels>
|
||||
</Tab.Group>
|
||||
</div>
|
||||
) : (
|
||||
<DetailedEmptyState
|
||||
title={t("workspace_analytics.empty_state.general.title")}
|
||||
description={t("workspace_analytics.empty_state.general.description")}
|
||||
assetPath={resolvedPath}
|
||||
customPrimaryButton={
|
||||
<ComicBoxButton
|
||||
label={t("workspace_analytics.empty_state.general.primary_button.text")}
|
||||
title={t("workspace_analytics.empty_state.general.primary_button.comic.title")}
|
||||
description={t("workspace_analytics.empty_state.general.primary_button.comic.description")}
|
||||
onClick={() => {
|
||||
setTrackElement("Analytics empty state");
|
||||
toggleCreateProjectModal(true);
|
||||
}}
|
||||
disabled={!canPerformEmptyStateActions}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default OldAnalyticsPage;
|
||||
|
|
@ -10,6 +10,9 @@ export interface IAnalyticsStoreV2 {
|
|||
currentTab: TAnalyticsTabsV2Base;
|
||||
selectedProjects: string[];
|
||||
selectedDuration: DurationType;
|
||||
selectedCycle: string;
|
||||
selectedModule: string;
|
||||
isPeekView?: boolean;
|
||||
|
||||
//computed
|
||||
selectedDurationLabel: DurationType | null;
|
||||
|
|
@ -17,25 +20,36 @@ export interface IAnalyticsStoreV2 {
|
|||
//actions
|
||||
updateSelectedProjects: (projects: string[]) => void;
|
||||
updateSelectedDuration: (duration: DurationType) => void;
|
||||
updateSelectedCycle: (cycle: string) => void;
|
||||
updateSelectedModule: (module: string) => void;
|
||||
updateIsPeekView: (isPeekView: boolean) => void;
|
||||
}
|
||||
|
||||
export class AnalyticsStoreV2 implements IAnalyticsStoreV2 {
|
||||
//observables
|
||||
currentTab: TAnalyticsTabsV2Base = "overview";
|
||||
selectedProjects: DurationType[] = [];
|
||||
selectedProjects: string[] = [];
|
||||
selectedDuration: DurationType = "last_30_days";
|
||||
|
||||
constructor(_rootStore: CoreRootStore) {
|
||||
selectedCycle: string = "";
|
||||
selectedModule: string = "";
|
||||
isPeekView: boolean = false;
|
||||
constructor() {
|
||||
makeObservable(this, {
|
||||
// observables
|
||||
currentTab: observable.ref,
|
||||
selectedDuration: observable.ref,
|
||||
selectedProjects: observable.ref,
|
||||
selectedCycle: observable.ref,
|
||||
selectedModule: observable.ref,
|
||||
isPeekView: observable.ref,
|
||||
// computed
|
||||
selectedDurationLabel: computed,
|
||||
// actions
|
||||
updateSelectedProjects: action,
|
||||
updateSelectedDuration: action,
|
||||
updateSelectedCycle: action,
|
||||
updateSelectedModule: action,
|
||||
updateIsPeekView: action,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -44,7 +58,6 @@ export class AnalyticsStoreV2 implements IAnalyticsStoreV2 {
|
|||
}
|
||||
|
||||
updateSelectedProjects = (projects: string[]) => {
|
||||
const initialState = this.selectedProjects;
|
||||
try {
|
||||
runInAction(() => {
|
||||
this.selectedProjects = projects;
|
||||
|
|
@ -65,4 +78,22 @@ export class AnalyticsStoreV2 implements IAnalyticsStoreV2 {
|
|||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
updateSelectedCycle = (cycle: string) => {
|
||||
runInAction(() => {
|
||||
this.selectedCycle = cycle;
|
||||
});
|
||||
};
|
||||
|
||||
updateSelectedModule = (module: string) => {
|
||||
runInAction(() => {
|
||||
this.selectedModule = module;
|
||||
});
|
||||
};
|
||||
|
||||
updateIsPeekView = (isPeekView: boolean) => {
|
||||
runInAction(() => {
|
||||
this.isPeekView = isPeekView;
|
||||
});
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -96,7 +96,7 @@ export class CoreRootStore {
|
|||
this.transient = new TransientStore();
|
||||
this.stickyStore = new StickyStore();
|
||||
this.editorAssetStore = new EditorAssetStore();
|
||||
this.analyticsV2 = new AnalyticsStoreV2(this);
|
||||
this.analyticsV2 = new AnalyticsStoreV2();
|
||||
}
|
||||
|
||||
resetOnSignOut() {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue