[WEB-3781] Analytics page enhancements (#7005)
* chore: analytics endpoint * added anlytics v2 * updated status icons * added area chart in workitems and en translations * active projects * chore: created analytics chart * chore: validation errors * improved radar-chart , added empty states , added projects summary * chore: added a new graph in advance analytics * integrated priority chart * chore: added csv exporter * added priority dropdown * integrated created vs resolved chart * custom x and y axis label in bar and area chart * added wrapper styles to legends * added filter components * fixed temp data imports * integrated filters in priority charts * added label to priority chart and updated duration filter * refactor * reverted to void onchange * fixed some contant exports * fixed type issues * fixed some type and build issues * chore: updated the filtering logic for analytics * updated default value to last_30_days * percentage value whole number and added some rules for axis options * fixed some translations * added - custom tick for radar, calc of insight cards, filter labels * chore: opitmised the analytics endpoint * replace old analytics path with new , updated labels of insight card, done some store fixes * chore: updated the export request * Enhanced ProjectSelect to support multi-select, improved state management, and optimized data fetching and component structure. * fix: round completion percentage calculation in ActiveProjectItem * added empty states in project insights * Added loader and empty state in created/resolved chart * added loaders * added icons in filters * added custom colors in customised charts * cleaned up some code * added some responsiveness * updated translations * updated serrchbar for the table * added work item modal in project analytics * fixed some of the layput issues in the peek view * chore: updated the base function for viewsets * synced tab to url * code cleanup * chore: updated the export logic * fixed project_ids filter * added icon in projectdropdown * updated export button position * export csv and emptystates icons * refactor * code refactor * updated loaders, moved color pallete to contants, added nullish collasece operator in neccessary places * removed uneccessary cn * fixed formatting issues * fixed empty project_ids in payload * improved null checks * optimized charts * modified relevant variables to observable.ref * fixed the duration type * optimized some code * updated query key in project-insight * updated query key in project-insight * updated formatting * chore: replaced analytics route with new one and done some optimizations * removed the old analytics --------- Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
|
|
@ -17,6 +17,17 @@ from plane.db.models import (
|
|||
ProjectPage,
|
||||
)
|
||||
|
||||
from django.db.models import (
|
||||
Q,
|
||||
Count,
|
||||
)
|
||||
from plane.utils.build_chart import build_analytics_chart
|
||||
from datetime import timedelta
|
||||
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
|
||||
|
|
@ -35,6 +46,7 @@ class AdvanceAnalyticsBaseView(BaseAPIView):
|
|||
|
||||
|
||||
class AdvanceAnalyticsEndpoint(AdvanceAnalyticsBaseView):
|
||||
|
||||
def get_filtered_counts(self, queryset: QuerySet) -> Dict[str, int]:
|
||||
def get_filtered_count() -> int:
|
||||
if self.filters["analytics_date_range"]:
|
||||
|
|
@ -111,6 +123,7 @@ class AdvanceAnalyticsEndpoint(AdvanceAnalyticsBaseView):
|
|||
),
|
||||
}
|
||||
|
||||
|
||||
def get_work_items_stats(self) -> Dict[str, Dict[str, int]]:
|
||||
base_queryset = Issue.objects.filter(**self.filters["base_filters"])
|
||||
|
||||
|
|
@ -193,6 +206,7 @@ class AdvanceAnalyticsChartEndpoint(AdvanceAnalyticsBaseView):
|
|||
# Get the base queryset with workspace and project filters
|
||||
base_queryset = Issue.issue_objects.filter(**self.filters["base_filters"])
|
||||
date_filter = {}
|
||||
|
||||
# Apply date range filter if available
|
||||
if self.filters["chart_period_range"]:
|
||||
start_date, end_date = self.filters["chart_period_range"]
|
||||
|
|
|
|||
105
packages/constants/src/analytics-v2/common.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import { TAnalyticsTabsV2Base } from "@plane/types";
|
||||
import { ChartXAxisProperty, ChartYAxisMetric } from "../chart";
|
||||
|
||||
export const insightsFields: Record<TAnalyticsTabsV2Base, string[]> = {
|
||||
overview: [
|
||||
"total_users",
|
||||
"total_admins",
|
||||
"total_members",
|
||||
"total_guests",
|
||||
"total_projects",
|
||||
"total_work_items",
|
||||
"total_cycles",
|
||||
"total_intake",
|
||||
],
|
||||
"work-items": [
|
||||
"total_work_items",
|
||||
"started_work_items",
|
||||
"backlog_work_items",
|
||||
"un_started_work_items",
|
||||
"completed_work_items",
|
||||
],
|
||||
};
|
||||
|
||||
export const ANALYTICS_V2_DURATION_FILTER_OPTIONS = [
|
||||
{
|
||||
name: "Yesterday",
|
||||
value: "yesterday",
|
||||
},
|
||||
{
|
||||
name: "Last 7 days",
|
||||
value: "last_7_days",
|
||||
},
|
||||
{
|
||||
name: "Last 30 days",
|
||||
value: "last_30_days",
|
||||
},
|
||||
{
|
||||
name: "Last 3 months",
|
||||
value: "last_3_months",
|
||||
},
|
||||
];
|
||||
|
||||
export const ANALYTICS_V2_X_AXIS_VALUES: { value: ChartXAxisProperty; label: string }[] = [
|
||||
{
|
||||
value: ChartXAxisProperty.STATES,
|
||||
label: "State name",
|
||||
},
|
||||
{
|
||||
value: ChartXAxisProperty.STATE_GROUPS,
|
||||
label: "State group",
|
||||
},
|
||||
{
|
||||
value: ChartXAxisProperty.PRIORITY,
|
||||
label: "Priority",
|
||||
},
|
||||
{
|
||||
value: ChartXAxisProperty.LABELS,
|
||||
label: "Label",
|
||||
},
|
||||
{
|
||||
value: ChartXAxisProperty.ASSIGNEES,
|
||||
label: "Assignee",
|
||||
},
|
||||
{
|
||||
value: ChartXAxisProperty.ESTIMATE_POINTS,
|
||||
label: "Estimate point",
|
||||
},
|
||||
{
|
||||
value: ChartXAxisProperty.CYCLES,
|
||||
label: "Cycle",
|
||||
},
|
||||
{
|
||||
value: ChartXAxisProperty.MODULES,
|
||||
label: "Module",
|
||||
},
|
||||
{
|
||||
value: ChartXAxisProperty.COMPLETED_AT,
|
||||
label: "Completed date",
|
||||
},
|
||||
{
|
||||
value: ChartXAxisProperty.TARGET_DATE,
|
||||
label: "Due date",
|
||||
},
|
||||
{
|
||||
value: ChartXAxisProperty.START_DATE,
|
||||
label: "Start date",
|
||||
},
|
||||
{
|
||||
value: ChartXAxisProperty.CREATED_AT,
|
||||
label: "Created date",
|
||||
},
|
||||
];
|
||||
|
||||
export const ANALYTICS_V2_Y_AXIS_VALUES: { value: ChartYAxisMetric; label: string }[] = [
|
||||
{
|
||||
value: ChartYAxisMetric.WORK_ITEM_COUNT,
|
||||
label: "Work item",
|
||||
},
|
||||
{
|
||||
value: ChartYAxisMetric.ESTIMATE_POINT_COUNT,
|
||||
label: "Estimate",
|
||||
},
|
||||
];
|
||||
|
||||
export const ANALYTICS_V2_DATE_KEYS = ["completed_at", "target_date", "start_date", "created_at"];
|
||||
1
packages/constants/src/analytics-v2/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "./common"
|
||||
|
|
@ -1,2 +1,157 @@
|
|||
import { TChartColorScheme } from "@plane/types";
|
||||
|
||||
export const LABEL_CLASSNAME = "uppercase text-custom-text-300/60 text-sm tracking-wide";
|
||||
export const AXIS_LABEL_CLASSNAME = "uppercase text-custom-text-300/60 text-sm tracking-wide";
|
||||
|
||||
|
||||
export enum ChartXAxisProperty {
|
||||
STATES = "STATES",
|
||||
STATE_GROUPS = "STATE_GROUPS",
|
||||
LABELS = "LABELS",
|
||||
ASSIGNEES = "ASSIGNEES",
|
||||
ESTIMATE_POINTS = "ESTIMATE_POINTS",
|
||||
CYCLES = "CYCLES",
|
||||
MODULES = "MODULES",
|
||||
PRIORITY = "PRIORITY",
|
||||
START_DATE = "START_DATE",
|
||||
TARGET_DATE = "TARGET_DATE",
|
||||
CREATED_AT = "CREATED_AT",
|
||||
COMPLETED_AT = "COMPLETED_AT",
|
||||
CREATED_BY = "CREATED_BY",
|
||||
WORK_ITEM_TYPES = "WORK_ITEM_TYPES",
|
||||
PROJECTS = "PROJECTS",
|
||||
EPICS = "EPICS",
|
||||
}
|
||||
|
||||
export enum ChartYAxisMetric {
|
||||
WORK_ITEM_COUNT = "WORK_ITEM_COUNT",
|
||||
ESTIMATE_POINT_COUNT = "ESTIMATE_POINT_COUNT",
|
||||
PENDING_WORK_ITEM_COUNT = "PENDING_WORK_ITEM_COUNT",
|
||||
COMPLETED_WORK_ITEM_COUNT = "COMPLETED_WORK_ITEM_COUNT",
|
||||
IN_PROGRESS_WORK_ITEM_COUNT = "IN_PROGRESS_WORK_ITEM_COUNT",
|
||||
WORK_ITEM_DUE_THIS_WEEK_COUNT = "WORK_ITEM_DUE_THIS_WEEK_COUNT",
|
||||
WORK_ITEM_DUE_TODAY_COUNT = "WORK_ITEM_DUE_TODAY_COUNT",
|
||||
BLOCKED_WORK_ITEM_COUNT = "BLOCKED_WORK_ITEM_COUNT",
|
||||
}
|
||||
|
||||
|
||||
export enum ChartXAxisDateGrouping {
|
||||
DAY = "DAY",
|
||||
WEEK = "WEEK",
|
||||
MONTH = "MONTH",
|
||||
YEAR = "YEAR",
|
||||
}
|
||||
|
||||
export const TO_CAPITALIZE_PROPERTIES: ChartXAxisProperty[] = [
|
||||
ChartXAxisProperty.PRIORITY,
|
||||
ChartXAxisProperty.STATE_GROUPS,
|
||||
];
|
||||
|
||||
export const CHART_X_AXIS_DATE_PROPERTIES: ChartXAxisProperty[] = [
|
||||
ChartXAxisProperty.START_DATE,
|
||||
ChartXAxisProperty.TARGET_DATE,
|
||||
ChartXAxisProperty.CREATED_AT,
|
||||
ChartXAxisProperty.COMPLETED_AT,
|
||||
];
|
||||
|
||||
|
||||
export enum EChartModels {
|
||||
BASIC = "BASIC",
|
||||
STACKED = "STACKED",
|
||||
GROUPED = "GROUPED",
|
||||
MULTI_LINE = "MULTI_LINE",
|
||||
COMPARISON = "COMPARISON",
|
||||
PROGRESS = "PROGRESS",
|
||||
}
|
||||
|
||||
export const CHART_COLOR_PALETTES: {
|
||||
key: TChartColorScheme;
|
||||
i18n_label: string;
|
||||
light: string[];
|
||||
dark: string[];
|
||||
}[] = [
|
||||
{
|
||||
key: "modern",
|
||||
i18n_label: "dashboards.widget.color_palettes.modern",
|
||||
light: [
|
||||
"#6172E8",
|
||||
"#8B6EDB",
|
||||
"#E05F99",
|
||||
"#29A383",
|
||||
"#CB8A37",
|
||||
"#3AA7C1",
|
||||
"#F1B24A",
|
||||
"#E84855",
|
||||
"#50C799",
|
||||
"#B35F9E",
|
||||
],
|
||||
dark: [
|
||||
"#6B7CDE",
|
||||
"#8E9DE6",
|
||||
"#D45D9E",
|
||||
"#2EAF85",
|
||||
"#D4A246",
|
||||
"#29A7C1",
|
||||
"#B89F6A",
|
||||
"#D15D64",
|
||||
"#4ED079",
|
||||
"#A169A4",
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "horizon",
|
||||
i18n_label: "dashboards.widget.color_palettes.horizon",
|
||||
light: [
|
||||
"#E76E50",
|
||||
"#289D90",
|
||||
"#F3A362",
|
||||
"#E9C368",
|
||||
"#264753",
|
||||
"#8A6FA0",
|
||||
"#5B9EE5",
|
||||
"#7CC474",
|
||||
"#BA7DB5",
|
||||
"#CF8640",
|
||||
],
|
||||
dark: [
|
||||
"#E05A3A",
|
||||
"#1D8A7E",
|
||||
"#D98B4D",
|
||||
"#D1AC50",
|
||||
"#3A6B7C",
|
||||
"#7D6297",
|
||||
"#4D8ACD",
|
||||
"#569C64",
|
||||
"#C16A8C",
|
||||
"#B77436",
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "earthen",
|
||||
i18n_label: "dashboards.widget.color_palettes.earthen",
|
||||
light: [
|
||||
"#386641",
|
||||
"#6A994E",
|
||||
"#A7C957",
|
||||
"#E97F4E",
|
||||
"#BC4749",
|
||||
"#9E2A2B",
|
||||
"#80CED1",
|
||||
"#5C3E79",
|
||||
"#526EAB",
|
||||
"#6B5B95",
|
||||
],
|
||||
dark: [
|
||||
"#497752",
|
||||
"#7BAA5F",
|
||||
"#B8DA68",
|
||||
"#FA905F",
|
||||
"#CD585A",
|
||||
"#AF3B3C",
|
||||
"#91DFE2",
|
||||
"#6D4F8A",
|
||||
"#637FBC",
|
||||
"#7C6CA6",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -33,3 +33,4 @@ export * from "./page";
|
|||
export * from "./emoji";
|
||||
export * from "./subscription";
|
||||
export * from "./icon";
|
||||
export * from "./analytics-v2";
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
"use client"
|
||||
export type TStateGroups = "backlog" | "unstarted" | "started" | "completed" | "cancelled";
|
||||
|
||||
export type TDraggableData = {
|
||||
|
|
@ -77,4 +78,5 @@ export const PROGRESS_STATE_GROUPS_DETAILS = [
|
|||
},
|
||||
];
|
||||
|
||||
|
||||
export const DISPLAY_WORKFLOW_PRO_CTA = false;
|
||||
|
|
|
|||
|
|
@ -1311,7 +1311,38 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"empty_state_v2": {
|
||||
"customized_insights": {
|
||||
"description": "Pracovní položky přiřazené vám, rozdělené podle stavu, se zde zobrazí.",
|
||||
"title": "Zatím žádná data"
|
||||
},
|
||||
"created_vs_resolved": {
|
||||
"description": "Pracovní položky vytvořené a vyřešené v průběhu času se zde zobrazí.",
|
||||
"title": "Zatím žádná data"
|
||||
},
|
||||
"project_insights": {
|
||||
"title": "Zatím žádná data",
|
||||
"description": "Pracovní položky přiřazené vám, rozdělené podle stavu, se zde zobrazí."
|
||||
}
|
||||
},
|
||||
"created_vs_resolved": "Vytvořeno vs Vyřešeno",
|
||||
"customized_insights": "Přizpůsobené přehledy",
|
||||
"backlog_work_items": "Pracovní položky v backlogu",
|
||||
"active_projects": "Aktivní projekty",
|
||||
"trend_on_charts": "Trend na grafech",
|
||||
"all_projects": "Všechny projekty",
|
||||
"summary_of_projects": "Souhrn projektů",
|
||||
"project_insights": "Přehled projektu",
|
||||
"started_work_items": "Zahájené pracovní položky",
|
||||
"total_work_items": "Celkový počet pracovních položek",
|
||||
"total_projects": "Celkový počet projektů",
|
||||
"total_admins": "Celkový počet administrátorů",
|
||||
"total_users": "Celkový počet uživatelů",
|
||||
"total_intake": "Celkový příjem",
|
||||
"un_started_work_items": "Nezahájené pracovní položky",
|
||||
"total_guests": "Celkový počet hostů",
|
||||
"completed_work_items": "Dokončené pracovní položky"
|
||||
},
|
||||
"workspace_projects": {
|
||||
"label": "{count, plural, one {Projekt} few {Projekty} other {Projektů}}",
|
||||
|
|
|
|||
|
|
@ -1311,7 +1311,38 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"empty_state_v2": {
|
||||
"customized_insights": {
|
||||
"description": "Ihnen zugewiesene Arbeitselemente, aufgeschlüsselt nach Status, werden hier angezeigt.",
|
||||
"title": "Noch keine Daten"
|
||||
},
|
||||
"created_vs_resolved": {
|
||||
"description": "Im Laufe der Zeit erstellte und gelöste Arbeitselemente werden hier angezeigt.",
|
||||
"title": "Noch keine Daten"
|
||||
},
|
||||
"project_insights": {
|
||||
"title": "Noch keine Daten",
|
||||
"description": "Ihnen zugewiesene Arbeitselemente, aufgeschlüsselt nach Status, werden hier angezeigt."
|
||||
}
|
||||
},
|
||||
"created_vs_resolved": "Erstellt vs Gelöst",
|
||||
"customized_insights": "Individuelle Einblicke",
|
||||
"backlog_work_items": "Backlog-Arbeitselemente",
|
||||
"active_projects": "Aktive Projekte",
|
||||
"trend_on_charts": "Trend in Diagrammen",
|
||||
"all_projects": "Alle Projekte",
|
||||
"summary_of_projects": "Projektübersicht",
|
||||
"project_insights": "Projekteinblicke",
|
||||
"started_work_items": "Begonnene Arbeitselemente",
|
||||
"total_work_items": "Gesamte Arbeitselemente",
|
||||
"total_projects": "Gesamtprojekte",
|
||||
"total_admins": "Gesamtanzahl der Admins",
|
||||
"total_users": "Gesamtanzahl der Benutzer",
|
||||
"total_intake": "Gesamteinnahmen",
|
||||
"un_started_work_items": "Nicht begonnene Arbeitselemente",
|
||||
"total_guests": "Gesamtanzahl der Gäste",
|
||||
"completed_work_items": "Abgeschlossene Arbeitselemente"
|
||||
},
|
||||
"workspace_projects": {
|
||||
"label": "{count, plural, one {Projekt} few {Projekte} other {Projekte}}",
|
||||
|
|
|
|||
|
|
@ -699,7 +699,8 @@
|
|||
"view": "View",
|
||||
"deactivated_user": "Deactivated user",
|
||||
"apply": "Apply",
|
||||
"applying": "Applying"
|
||||
"applying": "Applying",
|
||||
"overview": "Overview"
|
||||
},
|
||||
"chart": {
|
||||
"x_axis": "X-axis",
|
||||
|
|
@ -1146,6 +1147,37 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"total_work_items": "Total work items",
|
||||
"started_work_items": "Started work items",
|
||||
"backlog_work_items": "Backlog work items",
|
||||
"un_started_work_items": "Unstarted work items",
|
||||
"completed_work_items": "Completed work items",
|
||||
"total_guests": "Total Guests",
|
||||
"total_intake": "Total Intake",
|
||||
"total_users": "Total Users",
|
||||
"total_admins": "Total Admins",
|
||||
"total_projects": "Total Projects",
|
||||
"project_insights": "Project Insights",
|
||||
"summary_of_projects": "Summary of Projects",
|
||||
"all_projects": "All Projects",
|
||||
"trend_on_charts": "Trend on charts",
|
||||
"active_projects": "Active Projects",
|
||||
"customized_insights": "Customized Insights",
|
||||
"created_vs_resolved": "Created vs Resolved",
|
||||
"empty_state_v2": {
|
||||
"project_insights": {
|
||||
"title": "No data yet",
|
||||
"description": "Work items assigned to you, broken down by state, will show up here."
|
||||
},
|
||||
"created_vs_resolved": {
|
||||
"title": "No data yet",
|
||||
"description": "Work items created and resolved over time will show up here."
|
||||
},
|
||||
"customized_insights": {
|
||||
"title": "No data yet",
|
||||
"description": "Work items assigned to you, broken down by state, will show up here."
|
||||
}
|
||||
}
|
||||
},
|
||||
"workspace_projects": {
|
||||
|
|
|
|||
|
|
@ -1314,7 +1314,38 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"empty_state_v2": {
|
||||
"customized_insights": {
|
||||
"description": "Los elementos de trabajo asignados a ti, desglosados por estado, aparecerán aquí.",
|
||||
"title": "Aún no hay datos"
|
||||
},
|
||||
"created_vs_resolved": {
|
||||
"description": "Los elementos de trabajo creados y resueltos con el tiempo aparecerán aquí.",
|
||||
"title": "Aún no hay datos"
|
||||
},
|
||||
"project_insights": {
|
||||
"title": "Aún no hay datos",
|
||||
"description": "Los elementos de trabajo asignados a ti, desglosados por estado, aparecerán aquí."
|
||||
}
|
||||
},
|
||||
"created_vs_resolved": "Creado vs Resuelto",
|
||||
"customized_insights": "Información personalizada",
|
||||
"backlog_work_items": "Elementos de trabajo en backlog",
|
||||
"active_projects": "Proyectos activos",
|
||||
"trend_on_charts": "Tendencia en gráficos",
|
||||
"all_projects": "Todos los proyectos",
|
||||
"summary_of_projects": "Resumen de proyectos",
|
||||
"project_insights": "Información del proyecto",
|
||||
"started_work_items": "Elementos de trabajo iniciados",
|
||||
"total_work_items": "Total de elementos de trabajo",
|
||||
"total_projects": "Total de proyectos",
|
||||
"total_admins": "Total de administradores",
|
||||
"total_users": "Total de usuarios",
|
||||
"total_intake": "Ingreso total",
|
||||
"un_started_work_items": "Elementos de trabajo no iniciados",
|
||||
"total_guests": "Total de invitados",
|
||||
"completed_work_items": "Elementos de trabajo completados"
|
||||
},
|
||||
"workspace_projects": {
|
||||
"label": "{count, plural, one {Proyecto} other {Proyectos}}",
|
||||
|
|
|
|||
|
|
@ -1312,7 +1312,38 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"empty_state_v2": {
|
||||
"customized_insights": {
|
||||
"description": "Les éléments de travail qui vous sont assignés, répartis par état, s'afficheront ici.",
|
||||
"title": "Pas encore de données"
|
||||
},
|
||||
"created_vs_resolved": {
|
||||
"description": "Les éléments de travail créés et résolus au fil du temps s'afficheront ici.",
|
||||
"title": "Pas encore de données"
|
||||
},
|
||||
"project_insights": {
|
||||
"title": "Pas encore de données",
|
||||
"description": "Les éléments de travail qui vous sont assignés, répartis par état, s'afficheront ici."
|
||||
}
|
||||
},
|
||||
"created_vs_resolved": "Créé vs Résolu",
|
||||
"customized_insights": "Informations personnalisées",
|
||||
"backlog_work_items": "Éléments de travail en backlog",
|
||||
"active_projects": "Projets actifs",
|
||||
"trend_on_charts": "Tendance sur les graphiques",
|
||||
"all_projects": "Tous les projets",
|
||||
"summary_of_projects": "Résumé des projets",
|
||||
"project_insights": "Aperçus du projet",
|
||||
"started_work_items": "Éléments de travail commencés",
|
||||
"total_work_items": "Total des éléments de travail",
|
||||
"total_projects": "Total des projets",
|
||||
"total_admins": "Total des administrateurs",
|
||||
"total_users": "Nombre total d'utilisateurs",
|
||||
"total_intake": "Revenu total",
|
||||
"un_started_work_items": "Éléments de travail non commencés",
|
||||
"total_guests": "Nombre total d'invités",
|
||||
"completed_work_items": "Éléments de travail terminés"
|
||||
},
|
||||
"workspace_projects": {
|
||||
"label": "{count, plural, one {Projet} other {Projets}}",
|
||||
|
|
|
|||
|
|
@ -1311,7 +1311,38 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"empty_state_v2": {
|
||||
"customized_insights": {
|
||||
"description": "Item pekerjaan yang ditugaskan kepada Anda, dipecah berdasarkan status, akan muncul di sini.",
|
||||
"title": "Belum ada data"
|
||||
},
|
||||
"created_vs_resolved": {
|
||||
"description": "Item pekerjaan yang dibuat dan diselesaikan dari waktu ke waktu akan muncul di sini.",
|
||||
"title": "Belum ada data"
|
||||
},
|
||||
"project_insights": {
|
||||
"title": "Belum ada data",
|
||||
"description": "Item pekerjaan yang ditugaskan kepada Anda, dipecah berdasarkan status, akan muncul di sini."
|
||||
}
|
||||
},
|
||||
"created_vs_resolved": "Dibuat vs Diselesaikan",
|
||||
"customized_insights": "Wawasan yang Disesuaikan",
|
||||
"backlog_work_items": "Item pekerjaan backlog",
|
||||
"active_projects": "Proyek Aktif",
|
||||
"trend_on_charts": "Tren pada grafik",
|
||||
"all_projects": "Semua Proyek",
|
||||
"summary_of_projects": "Ringkasan Proyek",
|
||||
"project_insights": "Wawasan Proyek",
|
||||
"started_work_items": "Item pekerjaan yang telah dimulai",
|
||||
"total_work_items": "Total item pekerjaan",
|
||||
"total_projects": "Total Proyek",
|
||||
"total_admins": "Total Admin",
|
||||
"total_users": "Total Pengguna",
|
||||
"total_intake": "Total Pemasukan",
|
||||
"un_started_work_items": "Item pekerjaan yang belum dimulai",
|
||||
"total_guests": "Total Tamu",
|
||||
"completed_work_items": "Item pekerjaan yang telah selesai"
|
||||
},
|
||||
"workspace_projects": {
|
||||
"label": "{count, plural, one {Proyek} other {Proyek}}",
|
||||
|
|
|
|||
|
|
@ -1310,7 +1310,38 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"empty_state_v2": {
|
||||
"customized_insights": {
|
||||
"description": "Gli elementi di lavoro assegnati a te, suddivisi per stato, verranno visualizzati qui.",
|
||||
"title": "Nessun dato disponibile"
|
||||
},
|
||||
"created_vs_resolved": {
|
||||
"description": "Gli elementi di lavoro creati e risolti nel tempo verranno visualizzati qui.",
|
||||
"title": "Nessun dato disponibile"
|
||||
},
|
||||
"project_insights": {
|
||||
"title": "Nessun dato disponibile",
|
||||
"description": "Gli elementi di lavoro assegnati a te, suddivisi per stato, verranno visualizzati qui."
|
||||
}
|
||||
},
|
||||
"created_vs_resolved": "Creato vs Risolto",
|
||||
"customized_insights": "Approfondimenti personalizzati",
|
||||
"backlog_work_items": "Elementi di lavoro nel backlog",
|
||||
"active_projects": "Progetti attivi",
|
||||
"trend_on_charts": "Tendenza nei grafici",
|
||||
"all_projects": "Tutti i progetti",
|
||||
"summary_of_projects": "Riepilogo dei progetti",
|
||||
"project_insights": "Approfondimenti sul progetto",
|
||||
"started_work_items": "Elementi di lavoro iniziati",
|
||||
"total_work_items": "Totale elementi di lavoro",
|
||||
"total_projects": "Progetti totali",
|
||||
"total_admins": "Totale amministratori",
|
||||
"total_users": "Totale utenti",
|
||||
"total_intake": "Entrate totali",
|
||||
"un_started_work_items": "Elementi di lavoro non avviati",
|
||||
"total_guests": "Totale ospiti",
|
||||
"completed_work_items": "Elementi di lavoro completati"
|
||||
},
|
||||
"workspace_projects": {
|
||||
"label": "{count, plural, one {Progetto} other {Progetti}}",
|
||||
|
|
|
|||
|
|
@ -1312,7 +1312,38 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"empty_state_v2": {
|
||||
"customized_insights": {
|
||||
"description": "あなたに割り当てられた作業項目は、ステータスごとに分類されてここに表示されます。",
|
||||
"title": "まだデータがありません"
|
||||
},
|
||||
"created_vs_resolved": {
|
||||
"description": "時間の経過とともに作成および解決された作業項目がここに表示されます。",
|
||||
"title": "まだデータがありません"
|
||||
},
|
||||
"project_insights": {
|
||||
"title": "まだデータがありません",
|
||||
"description": "あなたに割り当てられた作業項目は、ステータスごとに分類されてここに表示されます。"
|
||||
}
|
||||
},
|
||||
"created_vs_resolved": "作成 vs 解決",
|
||||
"customized_insights": "カスタマイズされたインサイト",
|
||||
"backlog_work_items": "バックログの作業項目",
|
||||
"active_projects": "アクティブなプロジェクト",
|
||||
"trend_on_charts": "グラフの傾向",
|
||||
"all_projects": "すべてのプロジェクト",
|
||||
"summary_of_projects": "プロジェクトの概要",
|
||||
"project_insights": "プロジェクトのインサイト",
|
||||
"started_work_items": "開始された作業項目",
|
||||
"total_work_items": "作業項目の合計",
|
||||
"total_projects": "プロジェクト合計",
|
||||
"total_admins": "管理者の合計",
|
||||
"total_users": "ユーザー総数",
|
||||
"total_intake": "総収入",
|
||||
"un_started_work_items": "未開始の作業項目",
|
||||
"total_guests": "ゲストの合計",
|
||||
"completed_work_items": "完了した作業項目"
|
||||
},
|
||||
"workspace_projects": {
|
||||
"label": "{count, plural, one {プロジェクト} other {プロジェクト}}",
|
||||
|
|
|
|||
|
|
@ -1313,7 +1313,38 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"empty_state_v2": {
|
||||
"customized_insights": {
|
||||
"description": "귀하에게 할당된 작업 항목이 상태별로 나누어 여기에 표시됩니다.",
|
||||
"title": "아직 데이터가 없습니다"
|
||||
},
|
||||
"created_vs_resolved": {
|
||||
"description": "시간이 지나면서 생성되고 해결된 작업 항목이 여기에 표시됩니다.",
|
||||
"title": "아직 데이터가 없습니다"
|
||||
},
|
||||
"project_insights": {
|
||||
"title": "아직 데이터가 없습니다",
|
||||
"description": "귀하에게 할당된 작업 항목이 상태별로 나누어 여기에 표시됩니다."
|
||||
}
|
||||
},
|
||||
"created_vs_resolved": "생성됨 vs 해결됨",
|
||||
"customized_insights": "맞춤형 인사이트",
|
||||
"backlog_work_items": "백로그 작업 항목",
|
||||
"active_projects": "활성 프로젝트",
|
||||
"trend_on_charts": "차트의 추세",
|
||||
"all_projects": "모든 프로젝트",
|
||||
"summary_of_projects": "프로젝트 요약",
|
||||
"project_insights": "프로젝트 인사이트",
|
||||
"started_work_items": "시작된 작업 항목",
|
||||
"total_work_items": "총 작업 항목",
|
||||
"total_projects": "총 프로젝트 수",
|
||||
"total_admins": "총 관리자 수",
|
||||
"total_users": "총 사용자 수",
|
||||
"total_intake": "총 수입",
|
||||
"un_started_work_items": "시작되지 않은 작업 항목",
|
||||
"total_guests": "총 게스트 수",
|
||||
"completed_work_items": "완료된 작업 항목"
|
||||
},
|
||||
"workspace_projects": {
|
||||
"label": "{count, plural, one {프로젝트} other {프로젝트}}",
|
||||
|
|
|
|||
|
|
@ -1313,7 +1313,38 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"empty_state_v2": {
|
||||
"customized_insights": {
|
||||
"description": "Przypisane do Ciebie elementy pracy, podzielone według stanu, pojawią się tutaj.",
|
||||
"title": "Brak danych"
|
||||
},
|
||||
"created_vs_resolved": {
|
||||
"description": "Elementy pracy utworzone i rozwiązane w czasie pojawią się tutaj.",
|
||||
"title": "Brak danych"
|
||||
},
|
||||
"project_insights": {
|
||||
"title": "Brak danych",
|
||||
"description": "Przypisane do Ciebie elementy pracy, podzielone według stanu, pojawią się tutaj."
|
||||
}
|
||||
},
|
||||
"created_vs_resolved": "Utworzone vs Rozwiązane",
|
||||
"customized_insights": "Dostosowane informacje",
|
||||
"backlog_work_items": "Elementy pracy w backlogu",
|
||||
"active_projects": "Aktywne projekty",
|
||||
"trend_on_charts": "Trend na wykresach",
|
||||
"all_projects": "Wszystkie projekty",
|
||||
"summary_of_projects": "Podsumowanie projektów",
|
||||
"project_insights": "Wgląd w projekt",
|
||||
"started_work_items": "Rozpoczęte elementy pracy",
|
||||
"total_work_items": "Łączna liczba elementów pracy",
|
||||
"total_projects": "Łączna liczba projektów",
|
||||
"total_admins": "Łączna liczba administratorów",
|
||||
"total_users": "Łączna liczba użytkowników",
|
||||
"total_intake": "Całkowity dochód",
|
||||
"un_started_work_items": "Nierozpoczęte elementy pracy",
|
||||
"total_guests": "Łączna liczba gości",
|
||||
"completed_work_items": "Ukończone elementy pracy"
|
||||
},
|
||||
"workspace_projects": {
|
||||
"label": "{count, plural, one {Projekt} few {Projekty} other {Projektów}}",
|
||||
|
|
|
|||
|
|
@ -1313,7 +1313,38 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"empty_state_v2": {
|
||||
"customized_insights": {
|
||||
"description": "Os itens de trabalho atribuídos a você, divididos por estado, aparecerão aqui.",
|
||||
"title": "Ainda não há dados"
|
||||
},
|
||||
"created_vs_resolved": {
|
||||
"description": "Os itens de trabalho criados e resolvidos ao longo do tempo aparecerão aqui.",
|
||||
"title": "Ainda não há dados"
|
||||
},
|
||||
"project_insights": {
|
||||
"title": "Ainda não há dados",
|
||||
"description": "Os itens de trabalho atribuídos a você, divididos por estado, aparecerão aqui."
|
||||
}
|
||||
},
|
||||
"created_vs_resolved": "Criado vs Resolvido",
|
||||
"customized_insights": "Insights personalizados",
|
||||
"backlog_work_items": "Itens de trabalho no backlog",
|
||||
"active_projects": "Projetos ativos",
|
||||
"trend_on_charts": "Tendência nos gráficos",
|
||||
"all_projects": "Todos os projetos",
|
||||
"summary_of_projects": "Resumo dos projetos",
|
||||
"project_insights": "Insights do projeto",
|
||||
"started_work_items": "Itens de trabalho iniciados",
|
||||
"total_work_items": "Total de itens de trabalho",
|
||||
"total_projects": "Total de projetos",
|
||||
"total_admins": "Total de administradores",
|
||||
"total_users": "Total de usuários",
|
||||
"total_intake": "Receita total",
|
||||
"un_started_work_items": "Itens de trabalho não iniciados",
|
||||
"total_guests": "Total de convidados",
|
||||
"completed_work_items": "Itens de trabalho concluídos"
|
||||
},
|
||||
"workspace_projects": {
|
||||
"label": "{count, plural, one {Projeto} other {Projetos}}",
|
||||
|
|
|
|||
|
|
@ -1311,7 +1311,38 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"empty_state_v2": {
|
||||
"customized_insights": {
|
||||
"description": "Elementele de lucru atribuite ție, împărțite pe stări, vor apărea aici.",
|
||||
"title": "Nu există date încă"
|
||||
},
|
||||
"created_vs_resolved": {
|
||||
"description": "Elementele de lucru create și rezolvate în timp vor apărea aici.",
|
||||
"title": "Nu există date încă"
|
||||
},
|
||||
"project_insights": {
|
||||
"title": "Nu există date încă",
|
||||
"description": "Elementele de lucru atribuite ție, împărțite pe stări, vor apărea aici."
|
||||
}
|
||||
},
|
||||
"created_vs_resolved": "Creat vs Rezolvat",
|
||||
"customized_insights": "Perspective personalizate",
|
||||
"backlog_work_items": "Elemente de lucru din backlog",
|
||||
"active_projects": "Proiecte active",
|
||||
"trend_on_charts": "Tendință în grafice",
|
||||
"all_projects": "Toate proiectele",
|
||||
"summary_of_projects": "Sumarul proiectelor",
|
||||
"project_insights": "Informații despre proiect",
|
||||
"started_work_items": "Elemente de lucru începute",
|
||||
"total_work_items": "Totalul elementelor de lucru",
|
||||
"total_projects": "Total proiecte",
|
||||
"total_admins": "Total administratori",
|
||||
"total_users": "Total utilizatori",
|
||||
"total_intake": "Venit total",
|
||||
"un_started_work_items": "Elemente de lucru neîncepute",
|
||||
"total_guests": "Total invitați",
|
||||
"completed_work_items": "Elemente de lucru finalizate"
|
||||
},
|
||||
"workspace_projects": {
|
||||
"label": "{count, plural, one {Proiect} other {Proiecte}}",
|
||||
|
|
|
|||
|
|
@ -1313,7 +1313,38 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"empty_state_v2": {
|
||||
"customized_insights": {
|
||||
"description": "Назначенные вам рабочие элементы, разбитые по статусам, появятся здесь.",
|
||||
"title": "Данных пока нет"
|
||||
},
|
||||
"created_vs_resolved": {
|
||||
"description": "Созданные и решённые со временем рабочие элементы появятся здесь.",
|
||||
"title": "Данных пока нет"
|
||||
},
|
||||
"project_insights": {
|
||||
"title": "Данных пока нет",
|
||||
"description": "Назначенные вам рабочие элементы, разбитые по статусам, появятся здесь."
|
||||
}
|
||||
},
|
||||
"created_vs_resolved": "Создано vs Решено",
|
||||
"customized_insights": "Индивидуальные аналитические данные",
|
||||
"backlog_work_items": "Элементы работы в бэклоге",
|
||||
"active_projects": "Активные проекты",
|
||||
"trend_on_charts": "Тренд на графиках",
|
||||
"all_projects": "Все проекты",
|
||||
"summary_of_projects": "Сводка по проектам",
|
||||
"project_insights": "Аналитика проекта",
|
||||
"started_work_items": "Начатые рабочие элементы",
|
||||
"total_work_items": "Общее количество рабочих элементов",
|
||||
"total_projects": "Всего проектов",
|
||||
"total_admins": "Всего администраторов",
|
||||
"total_users": "Всего пользователей",
|
||||
"total_intake": "Общий доход",
|
||||
"un_started_work_items": "Не начатые рабочие элементы",
|
||||
"total_guests": "Всего гостей",
|
||||
"completed_work_items": "Завершённые рабочие элементы"
|
||||
},
|
||||
"workspace_projects": {
|
||||
"label": "{count, plural, one {Проект} other {Проекты}}",
|
||||
|
|
|
|||
|
|
@ -1313,7 +1313,38 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"empty_state_v2": {
|
||||
"customized_insights": {
|
||||
"description": "Pracovné položky priradené vám, rozdelené podľa stavu, sa zobrazia tu.",
|
||||
"title": "Zatiaľ žiadne údaje"
|
||||
},
|
||||
"created_vs_resolved": {
|
||||
"description": "Pracovné položky vytvorené a vyriešené v priebehu času sa zobrazia tu.",
|
||||
"title": "Zatiaľ žiadne údaje"
|
||||
},
|
||||
"project_insights": {
|
||||
"title": "Zatiaľ žiadne údaje",
|
||||
"description": "Pracovné položky priradené vám, rozdelené podľa stavu, sa zobrazia tu."
|
||||
}
|
||||
},
|
||||
"created_vs_resolved": "Vytvorené vs Vyriešené",
|
||||
"customized_insights": "Prispôsobené prehľady",
|
||||
"backlog_work_items": "Pracovné položky v backlogu",
|
||||
"active_projects": "Aktívne projekty",
|
||||
"trend_on_charts": "Trend na grafoch",
|
||||
"all_projects": "Všetky projekty",
|
||||
"summary_of_projects": "Súhrn projektov",
|
||||
"project_insights": "Prehľad projektu",
|
||||
"started_work_items": "Spustené pracovné položky",
|
||||
"total_work_items": "Celkový počet pracovných položiek",
|
||||
"total_projects": "Celkový počet projektov",
|
||||
"total_admins": "Celkový počet administrátorov",
|
||||
"total_users": "Celkový počet používateľov",
|
||||
"total_intake": "Celkový príjem",
|
||||
"un_started_work_items": "Nespustené pracovné položky",
|
||||
"total_guests": "Celkový počet hostí",
|
||||
"completed_work_items": "Dokončené pracovné položky"
|
||||
},
|
||||
"workspace_projects": {
|
||||
"label": "{count, plural, one {Projekt} few {Projekty} other {Projektov}}",
|
||||
|
|
|
|||
|
|
@ -1314,7 +1314,38 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"empty_state_v2": {
|
||||
"customized_insights": {
|
||||
"description": "Size atanan iş öğeleri, duruma göre ayrılarak burada gösterilecektir.",
|
||||
"title": "Henüz veri yok"
|
||||
},
|
||||
"created_vs_resolved": {
|
||||
"description": "Zaman içinde oluşturulan ve çözümlenen iş öğeleri burada gösterilecektir.",
|
||||
"title": "Henüz veri yok"
|
||||
},
|
||||
"project_insights": {
|
||||
"title": "Henüz veri yok",
|
||||
"description": "Size atanan iş öğeleri, duruma göre ayrılarak burada gösterilecektir."
|
||||
}
|
||||
},
|
||||
"created_vs_resolved": "Oluşturulan vs Çözülen",
|
||||
"customized_insights": "Özelleştirilmiş İçgörüler",
|
||||
"backlog_work_items": "Backlog iş öğeleri",
|
||||
"active_projects": "Aktif Projeler",
|
||||
"trend_on_charts": "Grafiklerdeki eğilim",
|
||||
"all_projects": "Tüm Projeler",
|
||||
"summary_of_projects": "Projelerin Özeti",
|
||||
"project_insights": "Proje İçgörüleri",
|
||||
"started_work_items": "Başlatılan iş öğeleri",
|
||||
"total_work_items": "Toplam iş öğesi",
|
||||
"total_projects": "Toplam Proje",
|
||||
"total_admins": "Toplam Yönetici",
|
||||
"total_users": "Toplam Kullanıcı",
|
||||
"total_intake": "Toplam Gelir",
|
||||
"un_started_work_items": "Başlanmamış iş öğeleri",
|
||||
"total_guests": "Toplam Misafir",
|
||||
"completed_work_items": "Tamamlanmış iş öğeleri"
|
||||
},
|
||||
"workspace_projects": {
|
||||
"label": "{count, plural, one {Proje} other {Projeler}}",
|
||||
|
|
|
|||
|
|
@ -1313,7 +1313,38 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"empty_state_v2": {
|
||||
"customized_insights": {
|
||||
"description": "Призначені вам робочі елементи, розбиті за станом, з’являться тут.",
|
||||
"title": "Ще немає даних"
|
||||
},
|
||||
"created_vs_resolved": {
|
||||
"description": "Створені та вирішені з часом робочі елементи з’являться тут.",
|
||||
"title": "Ще немає даних"
|
||||
},
|
||||
"project_insights": {
|
||||
"title": "Ще немає даних",
|
||||
"description": "Призначені вам робочі елементи, розбиті за станом, з’являться тут."
|
||||
}
|
||||
},
|
||||
"created_vs_resolved": "Створено vs Вирішено",
|
||||
"customized_insights": "Персоналізовані аналітичні дані",
|
||||
"backlog_work_items": "Робочі елементи у беклозі",
|
||||
"active_projects": "Активні проєкти",
|
||||
"trend_on_charts": "Тенденція на графіках",
|
||||
"all_projects": "Усі проєкти",
|
||||
"summary_of_projects": "Зведення проєктів",
|
||||
"project_insights": "Аналітика проєкту",
|
||||
"started_work_items": "Розпочаті робочі елементи",
|
||||
"total_work_items": "Усього робочих елементів",
|
||||
"total_projects": "Усього проєктів",
|
||||
"total_admins": "Усього адміністраторів",
|
||||
"total_users": "Усього користувачів",
|
||||
"total_intake": "Загальний дохід",
|
||||
"un_started_work_items": "Нерозпочаті робочі елементи",
|
||||
"total_guests": "Усього гостей",
|
||||
"completed_work_items": "Завершені робочі елементи"
|
||||
},
|
||||
"workspace_projects": {
|
||||
"label": "{count, plural, one {Проєкт} few {Проєкти} other {Проєктів}}",
|
||||
|
|
|
|||
|
|
@ -1312,7 +1312,38 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"empty_state_v2": {
|
||||
"customized_insights": {
|
||||
"description": "Các hạng mục công việc được giao cho bạn, phân loại theo trạng thái, sẽ hiển thị tại đây.",
|
||||
"title": "Chưa có dữ liệu"
|
||||
},
|
||||
"created_vs_resolved": {
|
||||
"description": "Các hạng mục công việc được tạo và giải quyết theo thời gian sẽ hiển thị tại đây.",
|
||||
"title": "Chưa có dữ liệu"
|
||||
},
|
||||
"project_insights": {
|
||||
"title": "Chưa có dữ liệu",
|
||||
"description": "Các hạng mục công việc được giao cho bạn, phân loại theo trạng thái, sẽ hiển thị tại đây."
|
||||
}
|
||||
},
|
||||
"created_vs_resolved": "Đã tạo vs Đã giải quyết",
|
||||
"customized_insights": "Thông tin chi tiết tùy chỉnh",
|
||||
"backlog_work_items": "Các hạng mục công việc tồn đọng",
|
||||
"active_projects": "Dự án đang hoạt động",
|
||||
"trend_on_charts": "Xu hướng trên biểu đồ",
|
||||
"all_projects": "Tất cả dự án",
|
||||
"summary_of_projects": "Tóm tắt dự án",
|
||||
"project_insights": "Thông tin chi tiết dự án",
|
||||
"started_work_items": "Hạng mục công việc đã bắt đầu",
|
||||
"total_work_items": "Tổng số hạng mục công việc",
|
||||
"total_projects": "Tổng số dự án",
|
||||
"total_admins": "Tổng số quản trị viên",
|
||||
"total_users": "Tổng số người dùng",
|
||||
"total_intake": "Tổng thu",
|
||||
"un_started_work_items": "Hạng mục công việc chưa bắt đầu",
|
||||
"total_guests": "Tổng số khách",
|
||||
"completed_work_items": "Hạng mục công việc đã hoàn thành"
|
||||
},
|
||||
"workspace_projects": {
|
||||
"label": "{count, plural, one {dự án} other {dự án}}",
|
||||
|
|
|
|||
|
|
@ -1312,7 +1312,38 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"empty_state_v2": {
|
||||
"customized_insights": {
|
||||
"description": "分配给您的工作项将按状态分类显示在此处。",
|
||||
"title": "暂无数据"
|
||||
},
|
||||
"created_vs_resolved": {
|
||||
"description": "随着时间推移创建和解决的工作项将显示在此处。",
|
||||
"title": "暂无数据"
|
||||
},
|
||||
"project_insights": {
|
||||
"title": "暂无数据",
|
||||
"description": "分配给您的工作项将按状态分类显示在此处。"
|
||||
}
|
||||
},
|
||||
"created_vs_resolved": "已创建 vs 已解决",
|
||||
"customized_insights": "自定义洞察",
|
||||
"backlog_work_items": "待办工作项",
|
||||
"active_projects": "活跃项目",
|
||||
"trend_on_charts": "图表趋势",
|
||||
"all_projects": "所有项目",
|
||||
"summary_of_projects": "项目概览",
|
||||
"project_insights": "项目洞察",
|
||||
"started_work_items": "已开始的工作项",
|
||||
"total_work_items": "工作项总数",
|
||||
"total_projects": "项目总数",
|
||||
"total_admins": "管理员总数",
|
||||
"total_users": "用户总数",
|
||||
"total_intake": "总收入",
|
||||
"un_started_work_items": "未开始的工作项",
|
||||
"total_guests": "访客总数",
|
||||
"completed_work_items": "已完成的工作项"
|
||||
},
|
||||
"workspace_projects": {
|
||||
"label": "{count, plural, one {项目} other {项目}}",
|
||||
|
|
|
|||
|
|
@ -1313,7 +1313,38 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"empty_state_v2": {
|
||||
"customized_insights": {
|
||||
"description": "指派給您的工作項目將依狀態分類顯示在此處。",
|
||||
"title": "尚無資料"
|
||||
},
|
||||
"created_vs_resolved": {
|
||||
"description": "隨著時間推移所建立與解決的工作項目將顯示在此處。",
|
||||
"title": "尚無資料"
|
||||
},
|
||||
"project_insights": {
|
||||
"title": "尚無資料",
|
||||
"description": "指派給您的工作項目將依狀態分類顯示在此處。"
|
||||
}
|
||||
},
|
||||
"created_vs_resolved": "已建立 vs 已解決",
|
||||
"customized_insights": "自訂化洞察",
|
||||
"backlog_work_items": "待辦工作項目",
|
||||
"active_projects": "啟用中的專案",
|
||||
"trend_on_charts": "圖表趨勢",
|
||||
"all_projects": "所有專案",
|
||||
"summary_of_projects": "專案摘要",
|
||||
"project_insights": "專案洞察",
|
||||
"started_work_items": "已開始的工作項目",
|
||||
"total_work_items": "工作項目總數",
|
||||
"total_projects": "專案總數",
|
||||
"total_admins": "管理員總數",
|
||||
"total_users": "使用者總數",
|
||||
"total_intake": "總收入",
|
||||
"un_started_work_items": "未開始的工作項目",
|
||||
"total_guests": "訪客總數",
|
||||
"completed_work_items": "已完成的工作項目"
|
||||
},
|
||||
"workspace_projects": {
|
||||
"label": "{count, plural, one {專案} other {專案}}",
|
||||
|
|
|
|||
|
|
@ -10,10 +10,12 @@
|
|||
"exports": {
|
||||
"./ui/*": "./src/ui/*.tsx",
|
||||
"./charts/*": "./src/charts/*/index.ts",
|
||||
"./table": "./src/table/index.ts",
|
||||
"./styles/fonts": "./src/styles/fonts/index.css"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "^1.1.1",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"lucide-react": "^0.469.0",
|
||||
"react": "^18.3.1",
|
||||
|
|
@ -29,4 +31,4 @@
|
|||
"@types/react-dom": "18.3.0",
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -29,13 +29,21 @@ export const AreaChart = React.memo(<K extends string, T extends string>(props:
|
|||
// states
|
||||
const [activeArea, setActiveArea] = useState<string | null>(null);
|
||||
const [activeLegend, setActiveLegend] = useState<string | null>(null);
|
||||
|
||||
// derived values
|
||||
const itemKeys = useMemo(() => areas.map((area) => area.key), [areas]);
|
||||
const itemLabels: Record<string, string> = useMemo(
|
||||
() => areas.reduce((acc, area) => ({ ...acc, [area.key]: area.label }), {}),
|
||||
[areas]
|
||||
);
|
||||
const itemDotColors = useMemo(() => areas.reduce((acc, area) => ({ ...acc, [area.key]: area.fill }), {}), [areas]);
|
||||
const { itemKeys, itemLabels, itemDotColors } = useMemo(() => {
|
||||
const keys: string[] = [];
|
||||
const labels: Record<string, string> = {};
|
||||
const colors: Record<string, string> = {};
|
||||
|
||||
for (const area of areas) {
|
||||
keys.push(area.key);
|
||||
labels[area.key] = area.label;
|
||||
colors[area.key] = area.fill;
|
||||
}
|
||||
|
||||
return { itemKeys: keys, itemLabels: labels, itemDotColors: colors };
|
||||
}, [areas]);
|
||||
|
||||
const renderAreas = useMemo(
|
||||
() =>
|
||||
|
|
@ -77,7 +85,7 @@ export const AreaChart = React.memo(<K extends string, T extends string>(props:
|
|||
// get the last data point
|
||||
const lastPoint = data[data.length - 1];
|
||||
// for the y-value in the last point, use its yAxis key value
|
||||
const lastYValue = lastPoint[yAxis.key] || 0;
|
||||
const lastYValue = lastPoint[yAxis.key] ?? 0;
|
||||
// create data for a straight line that has points at each x-axis position
|
||||
return data.map((item, index) => {
|
||||
// calculate the y value for this point on the straight line
|
||||
|
|
@ -91,7 +99,6 @@ export const AreaChart = React.memo(<K extends string, T extends string>(props:
|
|||
};
|
||||
});
|
||||
}, [data, xAxis.key]);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
|
|
@ -128,8 +135,8 @@ export const AreaChart = React.memo(<K extends string, T extends string>(props:
|
|||
value: yAxis.label,
|
||||
angle: -90,
|
||||
position: "bottom",
|
||||
offset: -24,
|
||||
dx: -16,
|
||||
offset: yAxis.offset ?? -24,
|
||||
dx: yAxis.dx ?? -16,
|
||||
className: AXIS_LABEL_CLASSNAME,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,13 +40,22 @@ export const BarChart = React.memo(<K extends string, T extends string>(props: T
|
|||
// states
|
||||
const [activeBar, setActiveBar] = useState<string | null>(null);
|
||||
const [activeLegend, setActiveLegend] = useState<string | null>(null);
|
||||
|
||||
// derived values
|
||||
const stackKeys = useMemo(() => bars.map((bar) => bar.key), [bars]);
|
||||
const stackLabels: Record<string, string> = useMemo(
|
||||
() => bars.reduce((acc, bar) => ({ ...acc, [bar.key]: bar.label }), {}),
|
||||
[bars]
|
||||
);
|
||||
const stackDotColors = useMemo(() => bars.reduce((acc, bar) => ({ ...acc, [bar.key]: bar.fill }), {}), [bars]);
|
||||
const { stackKeys, stackLabels, stackDotColors } = useMemo(() => {
|
||||
const keys: string[] = [];
|
||||
const labels: Record<string, string> = {};
|
||||
const colors: Record<string, string> = {};
|
||||
|
||||
for (const bar of bars) {
|
||||
keys.push(bar.key);
|
||||
labels[bar.key] = bar.label;
|
||||
// For tooltip, we need a string color. If fill is a function, use a default color
|
||||
colors[bar.key] = typeof bar.fill === "function" ? "#000000" : bar.fill;
|
||||
}
|
||||
|
||||
return { stackKeys: keys, stackLabels: labels, stackDotColors: colors };
|
||||
}, [bars]);
|
||||
|
||||
const renderBars = useMemo(
|
||||
() =>
|
||||
|
|
@ -102,7 +111,7 @@ export const BarChart = React.memo(<K extends string, T extends string>(props: T
|
|||
axisLine={false}
|
||||
label={{
|
||||
value: xAxis.label,
|
||||
dy: 28,
|
||||
dy: xAxis.dy ?? 28,
|
||||
className: AXIS_LABEL_CLASSNAME,
|
||||
}}
|
||||
tickCount={tickCount.x}
|
||||
|
|
|
|||
|
|
@ -15,16 +15,17 @@ export const getLegendProps = (args: TChartLegend): LegendProps => {
|
|||
overflow: "hidden",
|
||||
...(layout === "vertical"
|
||||
? {
|
||||
top: 0,
|
||||
alignItems: "center",
|
||||
height: "100%",
|
||||
}
|
||||
top: 0,
|
||||
alignItems: "center",
|
||||
height: "100%",
|
||||
}
|
||||
: {
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
width: "100%",
|
||||
justifyContent: "center",
|
||||
}),
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
width: "100%",
|
||||
justifyContent: "center",
|
||||
}),
|
||||
...args.wrapperStyles,
|
||||
},
|
||||
content: <CustomLegend {...args} />,
|
||||
};
|
||||
|
|
@ -33,8 +34,8 @@ export const getLegendProps = (args: TChartLegend): LegendProps => {
|
|||
const CustomLegend = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div"> &
|
||||
Pick<LegendProps, "payload" | "formatter" | "onClick" | "onMouseEnter" | "onMouseLeave"> &
|
||||
TChartLegend
|
||||
Pick<LegendProps, "payload" | "formatter" | "onClick" | "onMouseEnter" | "onMouseLeave"> &
|
||||
TChartLegend
|
||||
>((props, ref) => {
|
||||
const { formatter, layout, onClick, onMouseEnter, onMouseLeave, payload } = props;
|
||||
|
||||
|
|
|
|||
|
|
@ -4,10 +4,10 @@ import React from "react";
|
|||
// Common classnames
|
||||
const AXIS_TICK_CLASSNAME = "fill-custom-text-300 text-sm";
|
||||
|
||||
export const CustomXAxisTick = React.memo<any>(({ x, y, payload }: any) => (
|
||||
export const CustomXAxisTick = React.memo<any>(({ x, y, payload, getLabel }: any) => (
|
||||
<g transform={`translate(${x},${y})`}>
|
||||
<text y={0} dy={16} textAnchor="middle" className={AXIS_TICK_CLASSNAME}>
|
||||
{payload.value}
|
||||
{getLabel ? getLabel(payload.value) : payload.value}
|
||||
</text>
|
||||
</g>
|
||||
));
|
||||
|
|
@ -20,4 +20,28 @@ export const CustomYAxisTick = React.memo<any>(({ x, y, payload }: any) => (
|
|||
</text>
|
||||
</g>
|
||||
));
|
||||
|
||||
CustomYAxisTick.displayName = "CustomYAxisTick";
|
||||
|
||||
export const CustomRadarAxisTick = React.memo<any>(
|
||||
({ x, y, payload, getLabel, cx, cy, offset = 16 }: any) => {
|
||||
// Calculate direction vector from center to tick
|
||||
const dx = x - cx;
|
||||
const dy = y - cy;
|
||||
// Normalize and apply offset
|
||||
const length = Math.sqrt(dx * dx + dy * dy);
|
||||
const normX = dx / length;
|
||||
const normY = dy / length;
|
||||
const labelX = x + normX * offset;
|
||||
const labelY = y + normY * offset;
|
||||
|
||||
return (
|
||||
<g transform={`translate(${labelX},${labelY})`}>
|
||||
<text y={0} textAnchor="middle" className={AXIS_TICK_CLASSNAME}>
|
||||
{getLabel ? getLabel(payload.value) : payload.value}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
);
|
||||
CustomRadarAxisTick.displayName = "CustomRadarAxisTick";
|
||||
|
|
|
|||
|
|
@ -38,13 +38,21 @@ export const LineChart = React.memo(<K extends string, T extends string>(props:
|
|||
// states
|
||||
const [activeLine, setActiveLine] = useState<string | null>(null);
|
||||
const [activeLegend, setActiveLegend] = useState<string | null>(null);
|
||||
|
||||
// derived values
|
||||
const itemKeys = useMemo(() => lines.map((line) => line.key), [lines]);
|
||||
const itemLabels: Record<string, string> = useMemo(
|
||||
() => lines.reduce((acc, line) => ({ ...acc, [line.key]: line.label }), {}),
|
||||
[lines]
|
||||
);
|
||||
const itemDotColors = useMemo(() => lines.reduce((acc, line) => ({ ...acc, [line.key]: line.stroke }), {}), [lines]);
|
||||
const { itemKeys, itemLabels, itemDotColors } = useMemo(() => {
|
||||
const keys: string[] = [];
|
||||
const labels: Record<string, string> = {};
|
||||
const colors: Record<string, string> = {};
|
||||
|
||||
for (const line of lines) {
|
||||
keys.push(line.key);
|
||||
labels[line.key] = line.label;
|
||||
colors[line.key] = line.stroke;
|
||||
}
|
||||
|
||||
return { itemKeys: keys, itemLabels: labels, itemDotColors: colors };
|
||||
}, [lines]);
|
||||
|
||||
const renderLines = useMemo(
|
||||
() =>
|
||||
|
|
|
|||
1
packages/propel/src/charts/radar-chart/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "./root";
|
||||
95
packages/propel/src/charts/radar-chart/root.tsx
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
PolarGrid,
|
||||
Radar,
|
||||
RadarChart as CoreRadarChart,
|
||||
ResponsiveContainer,
|
||||
PolarAngleAxis,
|
||||
Tooltip,
|
||||
Legend,
|
||||
} from "recharts";
|
||||
import { TRadarChartProps } from "@plane/types";
|
||||
import { getLegendProps } from "../components/legend";
|
||||
import { CustomRadarAxisTick } from "../components/tick";
|
||||
import { CustomTooltip } from "../components/tooltip";
|
||||
|
||||
const RadarChart = <T extends string, K extends string>(props: TRadarChartProps<T, K>) => {
|
||||
const { data, radars, margin, showTooltip, legend, className, angleAxis } = props;
|
||||
|
||||
// states
|
||||
const [, setActiveIndex] = useState<number | null>(null);
|
||||
const [activeLegend, setActiveLegend] = useState<string | null>(null);
|
||||
|
||||
const { itemKeys, itemLabels, itemDotColors } = useMemo(() => {
|
||||
const keys: string[] = [];
|
||||
const labels: Record<string, string> = {};
|
||||
const colors: Record<string, string> = {};
|
||||
|
||||
for (const radar of radars) {
|
||||
keys.push(radar.key);
|
||||
labels[radar.key] = radar.name;
|
||||
colors[radar.key] = radar.stroke ?? radar.fill ?? "#000000";
|
||||
}
|
||||
return { itemKeys: keys, itemLabels: labels, itemDotColors: colors };
|
||||
}, [radars]);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<CoreRadarChart cx="50%" cy="50%" outerRadius="80%" data={data} margin={margin}>
|
||||
<PolarGrid stroke="rgba(var(--color-border-100), 0.9)" />
|
||||
<PolarAngleAxis dataKey={angleAxis.key} tick={(props) => <CustomRadarAxisTick {...props} />} />
|
||||
{showTooltip && (
|
||||
<Tooltip
|
||||
cursor={{
|
||||
stroke: "rgba(var(--color-text-300))",
|
||||
strokeDasharray: "4 4",
|
||||
}}
|
||||
wrapperStyle={{
|
||||
pointerEvents: "auto",
|
||||
}}
|
||||
content={({ active, label, payload }) => (
|
||||
<CustomTooltip
|
||||
active={active}
|
||||
activeKey={activeLegend}
|
||||
label={label}
|
||||
payload={payload}
|
||||
itemKeys={itemKeys}
|
||||
itemLabels={itemLabels}
|
||||
itemDotColors={itemDotColors}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{legend && (
|
||||
// @ts-expect-error recharts types are not up to date
|
||||
<Legend
|
||||
onMouseEnter={(payload) => {
|
||||
// @ts-expect-error recharts types are not up to date
|
||||
const key: string | undefined = payload.payload?.key;
|
||||
if (!key) return;
|
||||
setActiveLegend(key);
|
||||
setActiveIndex(null);
|
||||
}}
|
||||
onMouseLeave={() => setActiveLegend(null)}
|
||||
{...getLegendProps(legend)}
|
||||
/>
|
||||
)}
|
||||
{radars.map((radar) => (
|
||||
<Radar
|
||||
key={radar.key}
|
||||
name={radar.name}
|
||||
dataKey={radar.key}
|
||||
stroke={radar.stroke}
|
||||
fill={radar.fill}
|
||||
fillOpacity={radar.fillOpacity}
|
||||
dot={radar.dot}
|
||||
/>
|
||||
))}
|
||||
</CoreRadarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { RadarChart };
|
||||
1
packages/propel/src/charts/scatter-chart/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "./root";
|
||||
155
packages/propel/src/charts/scatter-chart/root.tsx
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
"use client";
|
||||
|
||||
import React, { useMemo, useState } from "react";
|
||||
import {
|
||||
CartesianGrid,
|
||||
ScatterChart as CoreScatterChart,
|
||||
Legend,
|
||||
Scatter,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
// plane imports
|
||||
import { AXIS_LABEL_CLASSNAME } from "@plane/constants";
|
||||
import { TScatterChartProps } from "@plane/types";
|
||||
// local components
|
||||
import { getLegendProps } from "../components/legend";
|
||||
import { CustomXAxisTick, CustomYAxisTick } from "../components/tick";
|
||||
import { CustomTooltip } from "../components/tooltip";
|
||||
|
||||
export const ScatterChart = React.memo(<K extends string, T extends string>(props: TScatterChartProps<K, T>) => {
|
||||
const {
|
||||
data,
|
||||
scatterPoints,
|
||||
margin,
|
||||
xAxis,
|
||||
yAxis,
|
||||
|
||||
className,
|
||||
tickCount = {
|
||||
x: undefined,
|
||||
y: 10,
|
||||
},
|
||||
legend,
|
||||
showTooltip = true,
|
||||
} = props;
|
||||
// states
|
||||
const [activePoint, setActivePoint] = useState<string | null>(null);
|
||||
const [activeLegend, setActiveLegend] = useState<string | null>(null);
|
||||
|
||||
//derived values
|
||||
const { itemKeys, itemLabels, itemDotColors } = useMemo(() => {
|
||||
const keys: string[] = [];
|
||||
const labels: Record<string, string> = {};
|
||||
const colors: Record<string, string> = {};
|
||||
|
||||
for (const point of scatterPoints) {
|
||||
keys.push(point.key);
|
||||
labels[point.key] = point.label;
|
||||
colors[point.key] = point.fill;
|
||||
}
|
||||
return { itemKeys: keys, itemLabels: labels, itemDotColors: colors };
|
||||
}, [scatterPoints]);
|
||||
|
||||
const renderPoints = useMemo(
|
||||
() =>
|
||||
scatterPoints.map((point) => (
|
||||
<Scatter
|
||||
key={point.key}
|
||||
dataKey={point.key}
|
||||
fill={point.fill}
|
||||
stroke={point.stroke}
|
||||
opacity={!!activeLegend && activeLegend !== point.key ? 0.1 : 1}
|
||||
onMouseEnter={() => setActivePoint(point.key)}
|
||||
onMouseLeave={() => setActivePoint(null)}
|
||||
/>
|
||||
)),
|
||||
[activeLegend, scatterPoints]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<CoreScatterChart
|
||||
data={data}
|
||||
margin={{
|
||||
top: margin?.top === undefined ? 5 : margin.top,
|
||||
right: margin?.right === undefined ? 30 : margin.right,
|
||||
bottom: margin?.bottom === undefined ? 5 : margin.bottom,
|
||||
left: margin?.left === undefined ? 20 : margin.left,
|
||||
}}
|
||||
>
|
||||
<CartesianGrid stroke="rgba(var(--color-border-100), 0.8)" vertical={false} />
|
||||
<XAxis
|
||||
dataKey={xAxis.key}
|
||||
tick={(props) => <CustomXAxisTick {...props} />}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
label={
|
||||
xAxis.label && {
|
||||
value: xAxis.label,
|
||||
dy: 28,
|
||||
className: AXIS_LABEL_CLASSNAME,
|
||||
}
|
||||
}
|
||||
tickCount={tickCount.x}
|
||||
/>
|
||||
<YAxis
|
||||
domain={yAxis.domain}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
label={
|
||||
yAxis.label && {
|
||||
value: yAxis.label,
|
||||
angle: -90,
|
||||
position: "bottom",
|
||||
offset: -24,
|
||||
dx: -16,
|
||||
className: AXIS_LABEL_CLASSNAME,
|
||||
}
|
||||
}
|
||||
tick={(props) => <CustomYAxisTick {...props} />}
|
||||
tickCount={tickCount.y}
|
||||
allowDecimals={!!yAxis.allowDecimals}
|
||||
/>
|
||||
{legend && (
|
||||
// @ts-expect-error recharts types are not up to date
|
||||
<Legend
|
||||
onMouseEnter={(payload) => setActiveLegend(payload.value)}
|
||||
onMouseLeave={() => setActiveLegend(null)}
|
||||
formatter={(value) => itemLabels[value]}
|
||||
{...getLegendProps(legend)}
|
||||
/>
|
||||
)}
|
||||
{showTooltip && (
|
||||
<Tooltip
|
||||
cursor={{
|
||||
stroke: "rgba(var(--color-text-300))",
|
||||
strokeDasharray: "4 4",
|
||||
}}
|
||||
wrapperStyle={{
|
||||
pointerEvents: "auto",
|
||||
}}
|
||||
content={({ active, label, payload }) => (
|
||||
<CustomTooltip
|
||||
active={active}
|
||||
activeKey={activePoint}
|
||||
label={label}
|
||||
payload={payload}
|
||||
itemKeys={itemKeys}
|
||||
itemLabels={itemLabels}
|
||||
itemDotColors={itemDotColors}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{renderPoints}
|
||||
</CoreScatterChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
ScatterChart.displayName = "ScatterChart";
|
||||
120
packages/propel/src/table/core.tsx
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import * as React from "react"
|
||||
|
||||
import { cn } from "@plane/utils"
|
||||
|
||||
const Table = React.forwardRef<
|
||||
HTMLTableElement,
|
||||
React.HTMLAttributes<HTMLTableElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="relative w-full overflow-auto">
|
||||
<table
|
||||
ref={ref}
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
Table.displayName = "Table"
|
||||
|
||||
const TableHeader = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<thead ref={ref} className={cn("bg-custom-background-80 py-4 border-y border-custom-border-200", className)} {...props} />
|
||||
))
|
||||
TableHeader.displayName = "TableHeader"
|
||||
|
||||
const TableBody = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tbody
|
||||
ref={ref}
|
||||
className={cn("", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableBody.displayName = "TableBody"
|
||||
|
||||
const TableFooter = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tfoot
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"bg-custom-background-300 font-medium",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableFooter.displayName = "TableFooter"
|
||||
|
||||
const TableRow = React.forwardRef<
|
||||
HTMLTableRowElement,
|
||||
React.HTMLAttributes<HTMLTableRowElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"transition-colors data-[state=selected]:bg-custom-background-100",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableRow.displayName = "TableRow"
|
||||
|
||||
const TableHead = React.forwardRef<
|
||||
HTMLTableHeaderCellElement,
|
||||
React.ThHTMLAttributes<HTMLTableHeaderCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"h-10 px-2 text-left align-middle font-medium text-custom-text-300 [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableHead.displayName = "TableHead"
|
||||
|
||||
const TableCell = React.forwardRef<
|
||||
HTMLTableDataCellElement,
|
||||
React.TdHTMLAttributes<HTMLTableDataCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableCell.displayName = "TableCell"
|
||||
|
||||
const TableCaption = React.forwardRef<
|
||||
HTMLTableDataCellElement,
|
||||
React.HTMLAttributes<HTMLTableDataCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<caption
|
||||
ref={ref}
|
||||
className={cn("mt-4 text-sm text-custom-text-300", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableCaption.displayName = "TableCaption"
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||
1
packages/propel/src/table/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "./core";
|
||||
52
packages/types/src/analytics-v2.d.ts
vendored
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
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"
|
||||
|
||||
|
||||
// service types
|
||||
|
||||
export interface IAnalyticsResponseV2 {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface IAnalyticsResponseFieldsV2 {
|
||||
count: number;
|
||||
filter_count: number;
|
||||
}
|
||||
|
||||
export interface IAnalyticsRadarEntityV2 {
|
||||
key: string,
|
||||
name: string,
|
||||
count: number
|
||||
}
|
||||
|
||||
// chart types
|
||||
|
||||
export interface IChartResponseV2 {
|
||||
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;
|
||||
}
|
||||
|
||||
export type AnalyticsTableDataMap = {
|
||||
"work-items": WorkItemInsightColumns,
|
||||
}
|
||||
|
||||
export interface IAnalyticsV2Params {
|
||||
x_axis: ChartXAxisProperty;
|
||||
y_axis: ChartYAxisMetric;
|
||||
group_by?: ChartXAxisProperty;
|
||||
}
|
||||
16
packages/types/src/charts/common.d.ts
vendored
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
|
||||
|
||||
export type TChartColorScheme = "modern" | "horizon" | "earthen";
|
||||
|
||||
export type TChartDatum = {
|
||||
key: string;
|
||||
name: string;
|
||||
count: number;
|
||||
} & Record<string, number>;
|
||||
|
||||
export type TChart = {
|
||||
data: TChartDatum[];
|
||||
schema: Record<string, string>;
|
||||
};
|
||||
|
||||
|
||||
|
|
@ -1,7 +1,14 @@
|
|||
|
||||
|
||||
// ============================================================
|
||||
// Chart Base
|
||||
// ============================================================
|
||||
export * from "./common";
|
||||
export type TChartLegend = {
|
||||
align: "left" | "center" | "right";
|
||||
verticalAlign: "top" | "middle" | "bottom";
|
||||
layout: "horizontal" | "vertical";
|
||||
wrapperStyles?: React.CSSProperties;
|
||||
};
|
||||
|
||||
export type TChartMargin = {
|
||||
|
|
@ -22,6 +29,7 @@ type TChartProps<K extends string, T extends string> = {
|
|||
key: keyof TChartData<K, T>;
|
||||
label?: string;
|
||||
strokeColor?: string;
|
||||
dy?: number;
|
||||
};
|
||||
yAxis: {
|
||||
allowDecimals?: boolean;
|
||||
|
|
@ -29,6 +37,8 @@ type TChartProps<K extends string, T extends string> = {
|
|||
key: keyof TChartData<K, T>;
|
||||
label?: string;
|
||||
strokeColor?: string;
|
||||
offset?: number;
|
||||
dx?: number;
|
||||
};
|
||||
className?: string;
|
||||
legend?: TChartLegend;
|
||||
|
|
@ -40,6 +50,10 @@ type TChartProps<K extends string, T extends string> = {
|
|||
showTooltip?: boolean;
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Bar Chart
|
||||
// ============================================================
|
||||
|
||||
export type TBarItem<T extends string> = {
|
||||
key: T;
|
||||
label: string;
|
||||
|
|
@ -56,6 +70,10 @@ export type TBarChartProps<K extends string, T extends string> = TChartProps<K,
|
|||
barSize?: number;
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Line Chart
|
||||
// ============================================================
|
||||
|
||||
export type TLineItem<T extends string> = {
|
||||
key: T;
|
||||
label: string;
|
||||
|
|
@ -71,6 +89,25 @@ export type TLineChartProps<K extends string, T extends string> = TChartProps<K,
|
|||
lines: TLineItem<T>[];
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Scatter Chart
|
||||
// ============================================================
|
||||
|
||||
export type TScatterPointItem<T extends string> = {
|
||||
key: T;
|
||||
label: string;
|
||||
fill: string;
|
||||
stroke: string;
|
||||
};
|
||||
|
||||
export type TScatterChartProps<K extends string, T extends string> = TChartProps<K, T> & {
|
||||
scatterPoints: TScatterPointItem<T>[];
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Area Chart
|
||||
// ============================================================
|
||||
|
||||
export type TAreaItem<T extends string> = {
|
||||
key: T;
|
||||
label: string;
|
||||
|
|
@ -92,6 +129,10 @@ export type TAreaChartProps<K extends string, T extends string> = TChartProps<K,
|
|||
};
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Pie Chart
|
||||
// ============================================================
|
||||
|
||||
export type TCellItem<T extends string> = {
|
||||
key: T;
|
||||
fill: string;
|
||||
|
|
@ -119,6 +160,10 @@ export type TPieChartProps<K extends string, T extends string> = Pick<
|
|||
customLegend?: (props: any) => React.ReactNode;
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Tree Map
|
||||
// ============================================================
|
||||
|
||||
export type TreeMapItem = {
|
||||
name: string;
|
||||
value: number;
|
||||
|
|
@ -126,13 +171,13 @@ export type TreeMapItem = {
|
|||
textClassName?: string;
|
||||
icon?: React.ReactElement;
|
||||
} & (
|
||||
| {
|
||||
| {
|
||||
fillColor: string;
|
||||
}
|
||||
| {
|
||||
| {
|
||||
fillClassName: string;
|
||||
}
|
||||
);
|
||||
);
|
||||
|
||||
export type TreeMapChartProps = {
|
||||
data: TreeMapItem[];
|
||||
|
|
@ -158,3 +203,32 @@ export type TContentVisibility = {
|
|||
top: TTopSectionConfig;
|
||||
bottom: TBottomSectionConfig;
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Radar Chart
|
||||
// ============================================================
|
||||
|
||||
export type TRadarItem<T extends string> = {
|
||||
key: T;
|
||||
name: string;
|
||||
fill?: string;
|
||||
stroke?: string;
|
||||
fillOpacity?: number;
|
||||
dot?: {
|
||||
r: number;
|
||||
fillOpacity: number;
|
||||
}
|
||||
}
|
||||
|
||||
export type TRadarChartProps<K extends string, T extends string> = Pick<
|
||||
TChartProps<K, T>,
|
||||
"className" | "showTooltip" | "margin" | "data" | "legend"
|
||||
> & {
|
||||
dataKey: T;
|
||||
radars: TRadarItem<T>[];
|
||||
angleAxis: {
|
||||
key: keyof TChartData<K, T>;
|
||||
label?: string;
|
||||
strokeColor?: string;
|
||||
};
|
||||
}
|
||||
|
|
@ -67,3 +67,9 @@ export enum EFileAssetType {
|
|||
PROJECT_DESCRIPTION = "PROJECT_DESCRIPTION",
|
||||
TEAM_SPACE_COMMENT_DESCRIPTION = "TEAM_SPACE_COMMENT_DESCRIPTION",
|
||||
}
|
||||
|
||||
export enum EUpdateStatus {
|
||||
OFF_TRACK = "OFF-TRACK",
|
||||
ON_TRACK = "ON-TRACK",
|
||||
AT_RISK = "AT-RISK",
|
||||
}
|
||||
1
packages/types/src/index.d.ts
vendored
|
|
@ -43,3 +43,4 @@ export * from "./home";
|
|||
export * from "./stickies";
|
||||
export * from "./utils";
|
||||
export * from "./payment";
|
||||
export * from "./analytics-v2";
|
||||
|
|
@ -3,27 +3,18 @@ import * as React from "react";
|
|||
import { ISvgIcons } from "./type";
|
||||
|
||||
export const AtRiskIcon: React.FC<ISvgIcons> = ({ width = "16", height = "16" }) => (
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M16 1C7.71573 1 1 7.71573 1 16C1 24.2843 7.71573 31 16 31C24.2843 31 31 24.2843 31 16C31 7.71573 24.2843 1 16 1Z"
|
||||
fill="#CC7700"
|
||||
/>
|
||||
<path
|
||||
d="M16 1C7.71573 1 1 7.71573 1 16C1 24.2843 7.71573 31 16 31C24.2843 31 31 24.2843 31 16C31 7.71573 24.2843 1 16 1Z"
|
||||
stroke="#F3F4F7"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<g clip-path="url(#clip0_21157_25600)">
|
||||
<svg width={width} height={height} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_365_7561)">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M10.0369 15.3346H10.667C11.0352 15.3346 11.3337 15.6331 11.3337 16.0013C11.3337 16.3695 11.0352 16.668 10.667 16.668H10.0369C10.3686 19.6679 12.912 22.0013 16.0003 22.0013C19.0887 22.0013 21.6321 19.6679 21.9637 16.668H21.3337C20.9655 16.668 20.667 16.3695 20.667 16.0013C20.667 15.6331 20.9655 15.3346 21.3337 15.3346H21.9637C21.6321 12.3347 19.0887 10.0013 16.0003 10.0013C12.912 10.0013 10.3686 12.3347 10.0369 15.3346ZM8.66699 16.0013C8.66699 11.9512 11.9502 8.66797 16.0003 8.66797C20.0504 8.66797 23.3337 11.9512 23.3337 16.0013C23.3337 20.0514 20.0504 23.3346 16.0003 23.3346C11.9502 23.3346 8.66699 20.0514 8.66699 16.0013ZM16.0003 12.668C16.3685 12.668 16.667 12.9664 16.667 13.3346V16.0013C16.667 16.3695 16.3685 16.668 16.0003 16.668C15.6321 16.668 15.3337 16.3695 15.3337 16.0013V13.3346C15.3337 12.9664 15.6321 12.668 16.0003 12.668ZM15.3337 18.668C15.3337 18.2998 15.6321 18.0013 16.0003 18.0013H16.007C16.3752 18.0013 16.6737 18.2998 16.6737 18.668C16.6737 19.0362 16.3752 19.3346 16.007 19.3346H16.0003C15.6321 19.3346 15.3337 19.0362 15.3337 18.668Z"
|
||||
fill="white"
|
||||
d="M2.03658 7.33335H2.66663C3.03482 7.33335 3.33329 7.63183 3.33329 8.00002C3.33329 8.36821 3.03482 8.66669 2.66663 8.66669H2.03658C2.36821 11.6667 4.91159 14 7.99996 14C11.0883 14 13.6317 11.6667 13.9633 8.66669H13.3333C12.9651 8.66669 12.6666 8.36821 12.6666 8.00002C12.6666 7.63183 12.9651 7.33335 13.3333 7.33335H13.9633C13.6317 4.33339 11.0883 2.00002 7.99996 2.00002C4.91159 2.00002 2.36821 4.33339 2.03658 7.33335ZM0.666626 8.00002C0.666626 3.94993 3.94987 0.666687 7.99996 0.666687C12.05 0.666687 15.3333 3.94993 15.3333 8.00002C15.3333 12.0501 12.05 15.3334 7.99996 15.3334C3.94987 15.3334 0.666626 12.0501 0.666626 8.00002ZM7.99996 4.66669C8.36815 4.66669 8.66663 4.96516 8.66663 5.33335V8.00002C8.66663 8.36821 8.36815 8.66669 7.99996 8.66669C7.63177 8.66669 7.33329 8.36821 7.33329 8.00002V5.33335C7.33329 4.96516 7.63177 4.66669 7.99996 4.66669ZM7.33329 10.6667C7.33329 10.2985 7.63177 10 7.99996 10H8.00663C8.37482 10 8.67329 10.2985 8.67329 10.6667C8.67329 11.0349 8.37482 11.3334 8.00663 11.3334H7.99996C7.63177 11.3334 7.33329 11.0349 7.33329 10.6667Z"
|
||||
fill="#CC7700"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_21157_25600">
|
||||
<rect width="16" height="16" fill="white" transform="translate(8 8)" />
|
||||
<clipPath id="clip0_365_7561">
|
||||
<rect width="16" height="16" fill="white" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
|
|
|||
|
|
@ -3,27 +3,18 @@ import * as React from "react";
|
|||
import { ISvgIcons } from "./type";
|
||||
|
||||
export const OffTrackIcon: React.FC<ISvgIcons> = ({ width = "16", height = "16" }) => (
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M16 1C7.71573 1 1 7.71573 1 16C1 24.2843 7.71573 31 16 31C24.2843 31 31 24.2843 31 16C31 7.71573 24.2843 1 16 1Z"
|
||||
fill="#CC0000"
|
||||
/>
|
||||
<path
|
||||
d="M16 1C7.71573 1 1 7.71573 1 16C1 24.2843 7.71573 31 16 31C24.2843 31 31 24.2843 31 16C31 7.71573 24.2843 1 16 1Z"
|
||||
stroke="#F3F4F7"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<g clip-path="url(#clip0_21157_78200)">
|
||||
<svg width={width} height={height} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_365_7595)">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M10.0369 15.3346H10.667C11.0352 15.3346 11.3337 15.6331 11.3337 16.0013C11.3337 16.3695 11.0352 16.668 10.667 16.668H10.0369C10.3686 19.6679 12.912 22.0013 16.0003 22.0013C19.0887 22.0013 21.6321 19.6679 21.9637 16.668H21.3337C20.9655 16.668 20.667 16.3695 20.667 16.0013C20.667 15.6331 20.9655 15.3346 21.3337 15.3346H21.9637C21.6321 12.3347 19.0887 10.0013 16.0003 10.0013C12.912 10.0013 10.3686 12.3347 10.0369 15.3346ZM8.66699 16.0013C8.66699 11.9512 11.9502 8.66797 16.0003 8.66797C20.0504 8.66797 23.3337 11.9512 23.3337 16.0013C23.3337 20.0514 20.0504 23.3346 16.0003 23.3346C11.9502 23.3346 8.66699 20.0514 8.66699 16.0013ZM14.667 12.668C15.0352 12.668 15.3337 12.9664 15.3337 13.3346V16.0013C15.3337 16.3695 15.0352 16.668 14.667 16.668C14.2988 16.668 14.0003 16.3695 14.0003 16.0013V13.3346C14.0003 12.9664 14.2988 12.668 14.667 12.668ZM17.3337 12.668C17.7018 12.668 18.0003 12.9664 18.0003 13.3346V16.0013C18.0003 16.3695 17.7018 16.668 17.3337 16.668C16.9655 16.668 16.667 16.3695 16.667 16.0013V13.3346C16.667 12.9664 16.9655 12.668 17.3337 12.668ZM14.0003 18.668C14.0003 18.2998 14.2988 18.0013 14.667 18.0013H14.6737C15.0418 18.0013 15.3403 18.2998 15.3403 18.668C15.3403 19.0362 15.0418 19.3346 14.6737 19.3346H14.667C14.2988 19.3346 14.0003 19.0362 14.0003 18.668ZM16.667 18.668C16.667 18.2998 16.9655 18.0013 17.3337 18.0013H17.3403C17.7085 18.0013 18.007 18.2998 18.007 18.668C18.007 19.0362 17.7085 19.3346 17.3403 19.3346H17.3337C16.9655 19.3346 16.667 19.0362 16.667 18.668Z"
|
||||
fill="white"
|
||||
d="M2.03658 7.33335H2.66663C3.03482 7.33335 3.33329 7.63183 3.33329 8.00002C3.33329 8.36821 3.03482 8.66669 2.66663 8.66669H2.03658C2.36821 11.6667 4.91159 14 7.99996 14C11.0883 14 13.6317 11.6667 13.9633 8.66669H13.3333C12.9651 8.66669 12.6666 8.36821 12.6666 8.00002C12.6666 7.63183 12.9651 7.33335 13.3333 7.33335H13.9633C13.6317 4.33339 11.0883 2.00002 7.99996 2.00002C4.91159 2.00002 2.36821 4.33339 2.03658 7.33335ZM0.666626 8.00002C0.666626 3.94993 3.94987 0.666687 7.99996 0.666687C12.05 0.666687 15.3333 3.94993 15.3333 8.00002C15.3333 12.0501 12.05 15.3334 7.99996 15.3334C3.94987 15.3334 0.666626 12.0501 0.666626 8.00002ZM6.66663 4.66669C7.03482 4.66669 7.33329 4.96516 7.33329 5.33335V8.00002C7.33329 8.36821 7.03482 8.66669 6.66663 8.66669C6.29844 8.66669 5.99996 8.36821 5.99996 8.00002V5.33335C5.99996 4.96516 6.29844 4.66669 6.66663 4.66669ZM9.33329 4.66669C9.70148 4.66669 9.99996 4.96516 9.99996 5.33335V8.00002C9.99996 8.36821 9.70148 8.66669 9.33329 8.66669C8.9651 8.66669 8.66663 8.36821 8.66663 8.00002V5.33335C8.66663 4.96516 8.9651 4.66669 9.33329 4.66669ZM5.99996 10.6667C5.99996 10.2985 6.29844 10 6.66663 10H6.67329C7.04148 10 7.33996 10.2985 7.33996 10.6667C7.33996 11.0349 7.04148 11.3334 6.67329 11.3334H6.66663C6.29844 11.3334 5.99996 11.0349 5.99996 10.6667ZM8.66663 10.6667C8.66663 10.2985 8.9651 10 9.33329 10H9.33996C9.70815 10 10.0066 10.2985 10.0066 10.6667C10.0066 11.0349 9.70815 11.3334 9.33996 11.3334H9.33329C8.9651 11.3334 8.66663 11.0349 8.66663 10.6667Z"
|
||||
fill="#CC0000"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_21157_78200">
|
||||
<rect width="16" height="16" fill="white" transform="translate(8 8)" />
|
||||
<clipPath id="clip0_365_7595">
|
||||
<rect width="16" height="16" fill="white" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
|
|
|||
|
|
@ -3,45 +3,39 @@ import * as React from "react";
|
|||
import { ISvgIcons } from "./type";
|
||||
|
||||
export const OnTrackIcon: React.FC<ISvgIcons> = ({ width = "16", height = "16" }) => (
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M16 1C7.71573 1 1 7.71573 1 16C1 24.2843 7.71573 31 16 31C24.2843 31 31 24.2843 31 16C31 7.71573 24.2843 1 16 1Z"
|
||||
fill="#1FAD40"
|
||||
/>
|
||||
<path
|
||||
d="M16 1C7.71573 1 1 7.71573 1 16C1 24.2843 7.71573 31 16 31C24.2843 31 31 24.2843 31 16C31 7.71573 24.2843 1 16 1Z"
|
||||
stroke="#F3F4F7"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<g clip-path="url(#clip0_21157_107468)">
|
||||
<svg width={width} height={height} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_365_7535)">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M19.0005 10.8004C17.8118 10.1146 16.4238 9.85693 15.0681 10.0705C13.7124 10.2841 12.4709 10.956 11.5507 11.9741C10.6304 12.9923 10.087 14.2952 10.0111 15.6655C9.93516 17.0358 10.3313 18.3907 11.1334 19.5043C11.9356 20.6179 13.0953 21.4228 14.4191 21.7849C15.7429 22.1469 17.1508 22.0442 18.408 21.4938C19.6652 20.9435 20.6958 19.9787 21.3278 18.7605C21.9598 17.5423 22.1551 16.1442 21.8811 14.7994C21.8076 14.4387 22.0404 14.0866 22.4012 14.0131C22.762 13.9396 23.1141 14.1725 23.1876 14.5332C23.5225 16.1768 23.2838 17.8856 22.5113 19.3745C21.7389 20.8635 20.4793 22.0426 18.9427 22.7153C17.4061 23.3879 15.6853 23.5135 14.0673 23.071C12.4493 22.6285 11.032 21.6447 10.0516 20.2836C9.07117 18.9226 8.58699 17.2665 8.67979 15.5917C8.77259 13.9169 9.43675 12.3245 10.5615 11.0801C11.6863 9.83568 13.2037 9.01448 14.8606 8.75343C16.5176 8.49238 18.2139 8.80726 19.6668 9.64556C19.9857 9.82957 20.0951 10.2373 19.9111 10.5562C19.7271 10.8751 19.3194 10.9845 19.0005 10.8004ZM23.1384 10.1949C23.3987 10.4553 23.3987 10.8774 23.1384 11.1377L16.4717 17.8044C16.2114 18.0648 15.7893 18.0648 15.5289 17.8044L13.5289 15.8044C13.2686 15.5441 13.2686 15.1219 13.5289 14.8616C13.7893 14.6012 14.2114 14.6012 14.4717 14.8616L16.0003 16.3902L22.1956 10.1949C22.4559 9.93458 22.878 9.93458 23.1384 10.1949Z"
|
||||
fill="white"
|
||||
d="M11.0001 2.80075C9.81139 2.11486 8.42345 1.85723 7.06776 2.07082C5.71206 2.28441 4.47057 2.9563 3.55031 3.97445C2.63004 4.9926 2.08664 6.29547 2.01071 7.66578C1.93479 9.03609 2.33093 10.391 3.13308 11.5046C3.93523 12.6182 5.0949 13.4231 6.4187 13.7852C7.74249 14.1472 9.1504 14.0445 10.4076 13.4941C11.6649 12.9438 12.6954 11.979 13.3274 10.7608C13.9594 9.5426 14.1547 8.14453 13.8807 6.79975C13.8072 6.43897 14.0401 6.08691 14.4009 6.0134C14.7616 5.93988 15.1137 6.17276 15.1872 6.53353C15.5221 8.17715 15.2834 9.88591 14.511 11.3748C13.7385 12.8638 12.4789 14.0429 10.9423 14.7156C9.4057 15.3882 7.68493 15.5138 6.06696 15.0713C4.44898 14.6288 3.03161 13.645 2.05121 12.2839C1.0708 10.9229 0.586626 9.26684 0.679423 7.59202C0.772221 5.91719 1.43638 4.3248 2.56115 3.08039C3.68591 1.83598 5.2033 1.01478 6.86025 0.753732C8.51721 0.492682 10.2136 0.807564 11.6665 1.64587C11.9854 1.82987 12.0947 2.23757 11.9107 2.55648C11.7267 2.8754 11.319 2.98476 11.0001 2.80075ZM15.138 2.19524C15.3984 2.45559 15.3984 2.8777 15.138 3.13805L8.47136 9.80471C8.21101 10.0651 7.7889 10.0651 7.52855 9.80471L5.52856 7.80471C5.26821 7.54436 5.26821 7.12225 5.52856 6.8619C5.7889 6.60155 6.21101 6.60155 6.47136 6.8619L7.99996 8.3905L14.1952 2.19524C14.4556 1.93489 14.8777 1.93489 15.138 2.19524Z"
|
||||
fill="#1FAD40"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M16.0003 23.333C15.6321 23.333 15.3337 23.0345 15.3337 22.6663V21.333C15.3337 20.9648 15.6321 20.6663 16.0003 20.6663C16.3685 20.6663 16.667 20.9648 16.667 21.333V22.6663C16.667 23.0345 16.3685 23.333 16.0003 23.333Z"
|
||||
fill="white"
|
||||
d="M7.99996 15.3333C7.63177 15.3333 7.33329 15.0348x33329 14.6666V13.3333C7.33329 12.9651 7.63177 12.6666 7.99996 12.6666C8.36815 12.6666 8.66663 12.9651 8.66663 13.3333V14.6666C8.66663 15.0348 8.36815 15.3333 7.99996 15.3333Z"
|
||||
fill="#1FAD40"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M16.0003 11.333C15.6321 11.333 15.3337 11.0345 15.3337 10.6663V9.333C15.3337 8.96481 15.6321 8.66634 16.0003 8.66634C16.3685 8.66634 16.667 8.96481 16.667 9.333V10.6663C16.667 11.0345 16.3685 11.333 16.0003 11.333Z"
|
||||
fill="white"
|
||||
d="M7.99996 3.33331C7.63177 3.33331 7.33329 3.03483 7.33329 2.66664V1.33331C7.33329 0.965117 7.63177 0.66664 7.99996 0.66664C8.36815 0.66664 8.66663 0.965117 8.66663 1.33331V2.66664C8.66663 3.03483 8.36815 3.33331 7.99996 3.33331Z"
|
||||
fill="#1FAD40"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M11.3337 15.9997C11.3337 16.3679 11.0352 16.6663 10.667 16.6663H9.33366C8.96547 16.6663 8.66699 16.3679 8.66699 15.9997C8.66699 15.6315 8.96547 15.333 9.33366 15.333H10.667C11.0352 15.333 11.3337 15.6315 11.3337 15.9997Z"
|
||||
fill="white"
|
||||
d="M3.33329 7.99997C3.33329 8.36816 3.03482 8.66664 2.66663 8.66664H1.33329C0.965103 8.66664 0.666626 8.36816 0.666626 7.99997C0.666626 7.63178 0.965103 7.33331 1.33329 7.33331H2.66663C3.03482 7.33331 3.33329 7.63178 3.33329 7.99997Z"
|
||||
fill="#1FAD40"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_21157_107468">
|
||||
<rect width="16" height="16" fill="white" transform="translate(8 8)" />
|
||||
<clipPath id="clip0_365_7535">
|
||||
<path
|
||||
d="M0 2C0 0.895431 0.895431 0 2 0H14C15.1046 0 16 0.895431 16 2V14C16 15.1046 15.1046 16 14 16H2C0.895431 16 0 15.1046 0 14V2Z"
|
||||
fill="white"
|
||||
/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
// components
|
||||
import { AppHeader, ContentWrapper } from "@/components/core";
|
||||
// plane web components
|
||||
import { WorkspaceAnalyticsHeader } from "./header";
|
||||
|
||||
export default function WorkspaceAnalyticsLayout({ children }: { children: React.ReactNode }) {
|
||||
|
|
|
|||
|
|
@ -1,24 +1,24 @@
|
|||
"use client";
|
||||
|
||||
import React, { Fragment } from "react";
|
||||
import { useMemo } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { Tab } from "@headlessui/react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
// plane package imports
|
||||
import { ANALYTICS_TABS, EUserPermissionsLevel, EUserPermissions } from "@plane/constants";
|
||||
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Header, EHeaderVariant } from "@plane/ui";
|
||||
import { Tabs } from "@plane/ui";
|
||||
// components
|
||||
import { CustomAnalytics, ScopeAndDemand } from "@/components/analytics";
|
||||
import AnalyticsFilterActions from "@/components/analytics-v2/analytics-filter-actions";
|
||||
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";
|
||||
import { ANALYTICS_TABS } from "@/plane-web/components/analytics-v2/tabs";
|
||||
|
||||
const AnalyticsPage = observer(() => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const analytics_tab = searchParams.get("analytics_tab");
|
||||
// plane imports
|
||||
const { t } = useTranslation();
|
||||
// store hooks
|
||||
|
|
@ -40,44 +40,38 @@ const AnalyticsPage = observer(() => {
|
|||
EUserPermissionsLevel.WORKSPACE
|
||||
);
|
||||
|
||||
// TODO: refactor loader implementation
|
||||
const tabs = useMemo(
|
||||
() =>
|
||||
ANALYTICS_TABS.map((tab) => ({
|
||||
key: tab.key,
|
||||
label: t(tab.i18nKey),
|
||||
content: <tab.content />,
|
||||
onClick: () => {
|
||||
router.push(`?tab=${tab.key}`);
|
||||
},
|
||||
})),
|
||||
[router, t]
|
||||
);
|
||||
const defaultTab = searchParams.get("tab") || ANALYTICS_TABS[0].key;
|
||||
|
||||
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 className="flex h-full overflow-hidden bg-custom-background-100 justify-between items-center ">
|
||||
<Tabs
|
||||
tabs={tabs}
|
||||
storageKey={`analytics-page-${currentWorkspace?.id}`}
|
||||
defaultTab={defaultTab}
|
||||
size="md"
|
||||
tabListContainerClassName="px-6 py-2 border-b border-custom-border-200 flex items-center justify-between"
|
||||
tabListClassName="my-2 max-w-36"
|
||||
tabPanelClassName="h-full w-full overflow-hidden overflow-y-auto"
|
||||
storeInLocalStorage={false}
|
||||
actions={<AnalyticsFilterActions />}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<DetailedEmptyState
|
||||
|
|
|
|||
|
|
@ -20,7 +20,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,
|
||||
|
|
@ -105,7 +105,7 @@ export const ProjectIssuesMobileHeader = observer(() => {
|
|||
|
||||
return (
|
||||
<>
|
||||
<ProjectAnalyticsModal
|
||||
<WorkItemsModal
|
||||
isOpen={analyticsModal}
|
||||
onClose={() => setAnalyticsModal(false)}
|
||||
projectDetails={currentProjectDetails ?? undefined}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Image from "next/image";
|
||||
|
|
|
|||
11
web/ce/components/analytics-v2/tabs.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { TAnalyticsTabsV2Base } from "@plane/types";
|
||||
import { Overview } from "@/components/analytics-v2/overview";
|
||||
import { WorkItems } from "@/components/analytics-v2/work-items";
|
||||
export const ANALYTICS_TABS: {
|
||||
key: TAnalyticsTabsV2Base;
|
||||
i18nKey: string;
|
||||
content: React.FC;
|
||||
}[] = [
|
||||
{ key: "overview", i18nKey: "common.overview", content: Overview },
|
||||
{ key: "work-items", i18nKey: "sidebar.work_items", content: WorkItems },
|
||||
];
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
// plane web components
|
||||
import { observer } from "mobx-react-lite";
|
||||
// hooks
|
||||
import { useProject } from "@/hooks/store";
|
||||
import { useAnalyticsV2 } from "@/hooks/store/use-analytics-v2";
|
||||
// components
|
||||
import DurationDropdown from "./select/duration";
|
||||
import { ProjectSelect } from "./select/project";
|
||||
|
||||
const AnalyticsFilterActions = observer(() => {
|
||||
const { selectedProjects, selectedDuration, updateSelectedProjects, updateSelectedDuration } = useAnalyticsV2();
|
||||
const { workspaceProjectIds } = useProject();
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<ProjectSelect
|
||||
value={selectedProjects}
|
||||
onChange={(val) => {
|
||||
updateSelectedProjects(val ?? []);
|
||||
}}
|
||||
projectIds={workspaceProjectIds}
|
||||
/>
|
||||
<DurationDropdown
|
||||
buttonVariant="border-with-text"
|
||||
value={selectedDuration}
|
||||
onChange={(val) => {
|
||||
updateSelectedDuration(val);
|
||||
}}
|
||||
dropdownArrow
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default AnalyticsFilterActions;
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
import { cn } from "@plane/utils";
|
||||
|
||||
type Props = {
|
||||
title?: string;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
subtitle?: string | null;
|
||||
actions?: React.ReactNode;
|
||||
headerClassName?: string;
|
||||
};
|
||||
|
||||
const AnalyticsSectionWrapper: React.FC<Props> = (props) => {
|
||||
const { title, children, className, subtitle, actions, headerClassName } = props;
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className={cn("mb-6 flex items-center gap-2 text-nowrap ", headerClassName)}>
|
||||
{title && (
|
||||
<div className="flex items-center gap-2 ">
|
||||
<h1 className={"text-lg font-medium"}>{title}</h1>
|
||||
{subtitle && <p className="text-lg text-custom-text-300"> • {subtitle}</p>}
|
||||
</div>
|
||||
)}
|
||||
{actions}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AnalyticsSectionWrapper;
|
||||
22
web/core/components/analytics-v2/analytics-wrapper.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import React from "react";
|
||||
// plane package imports
|
||||
import { cn } from "@plane/utils";
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const AnalyticsWrapper: React.FC<Props> = (props) => {
|
||||
const { title, children, className } = props;
|
||||
|
||||
return (
|
||||
<div className={cn("px-6 py-4", className)}>
|
||||
<h1 className={"mb-4 text-2xl font-bold md:mb-6"}>{title}</h1>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AnalyticsWrapper;
|
||||
48
web/core/components/analytics-v2/empty-state.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import React from "react";
|
||||
import Image from "next/image";
|
||||
// plane package imports
|
||||
import { cn } from "@plane/utils";
|
||||
import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
description?: string;
|
||||
assetPath?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const AnalyticsV2EmptyState = ({ title, description, assetPath, className }: Props) => {
|
||||
const backgroundReolvedPath = useResolvedAssetPath({ basePath: "/empty-state/analytics-v2/empty-grid-background" });
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-full w-full items-center justify-center overflow-y-auto rounded-lg border border-custom-border-100 px-5 py-10 md:px-20",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className={cn("flex flex-col items-center")}>
|
||||
{assetPath && (
|
||||
<div className="relative flex max-h-[200px] max-w-[200px] items-center justify-center">
|
||||
<Image src={assetPath} alt={title} width={100} height={100} layout="fixed" className="z-10 h-2/3 w-2/3" />
|
||||
<div className="absolute inset-0">
|
||||
<Image
|
||||
src={backgroundReolvedPath}
|
||||
alt={title}
|
||||
width={100}
|
||||
height={100}
|
||||
layout="fixed"
|
||||
className="h-full w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-shrink flex-col items-center gap-1.5 text-center">
|
||||
<h3 className={cn("text-xl font-semibold")}>{title}</h3>
|
||||
{description && <p className="text-sm text-custom-text-300">{description}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default AnalyticsV2EmptyState;
|
||||
1
web/core/components/analytics-v2/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "./overview/root";
|
||||
47
web/core/components/analytics-v2/insight-card.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
// plane package imports
|
||||
import React, { useMemo } from "react";
|
||||
import { IAnalyticsResponseFieldsV2 } from "@plane/types";
|
||||
import { Loader } from "@plane/ui";
|
||||
// components
|
||||
import TrendPiece from "./trend-piece";
|
||||
|
||||
export type InsightCardProps = {
|
||||
data?: IAnalyticsResponseFieldsV2;
|
||||
label: string;
|
||||
isLoading?: boolean;
|
||||
versus?: string | null;
|
||||
};
|
||||
|
||||
const InsightCard = (props: InsightCardProps) => {
|
||||
const { data, label, isLoading, versus } = props;
|
||||
const { count, filter_count } = data || {};
|
||||
const percentage = useMemo(() => {
|
||||
if (count != null && filter_count != null) {
|
||||
const result = ((count - filter_count) / count) * 100;
|
||||
const isFiniteAndNotNaNOrZero = Number.isFinite(result) && !Number.isNaN(result) && result !== 0;
|
||||
return isFiniteAndNotNaNOrZero ? result : null;
|
||||
}
|
||||
return null;
|
||||
}, [count, filter_count]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="text-sm text-custom-text-300">{label}</div>
|
||||
{!isLoading ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="text-2xl font-bold text-custom-text-100">{count}</div>
|
||||
{percentage && (
|
||||
<div className="flex gap-1 text-xs text-custom-text-300">
|
||||
<TrendPiece percentage={percentage} size="xs" />
|
||||
{versus && <div>vs {versus}</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Loader.Item height="50px" width="100%" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default InsightCard;
|
||||
177
web/core/components/analytics-v2/insight-table/data-table.tsx
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import {
|
||||
ColumnDef,
|
||||
ColumnFiltersState,
|
||||
SortingState,
|
||||
VisibilityState,
|
||||
Table as TanstackTable,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFacetedRowModel,
|
||||
getFacetedUniqueValues,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
import { Search, X } from "lucide-react";
|
||||
// plane package imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@plane/propel/table";
|
||||
import { cn } from "@plane/utils";
|
||||
// plane web components
|
||||
import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
|
||||
import AnalyticsV2EmptyState from "../empty-state";
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
data: TData[];
|
||||
searchPlaceholder: string;
|
||||
actions?: (table: TanstackTable<TData>) => React.ReactNode;
|
||||
}
|
||||
|
||||
export function DataTable<TData, TValue>({ columns, data, searchPlaceholder, actions }: DataTableProps<TData, TValue>) {
|
||||
const [rowSelection, setRowSelection] = React.useState({});
|
||||
const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>({});
|
||||
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([]);
|
||||
const [sorting, setSorting] = React.useState<SortingState>([]);
|
||||
const { t } = useTranslation();
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
const [isSearchOpen, setIsSearchOpen] = React.useState(false);
|
||||
const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/analytics-v2/empty-table" });
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
state: {
|
||||
sorting,
|
||||
columnVisibility,
|
||||
rowSelection,
|
||||
columnFilters,
|
||||
},
|
||||
enableRowSelection: true,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
onSortingChange: setSorting,
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFacetedRowModel: getFacetedRowModel(),
|
||||
getFacetedUniqueValues: getFacetedUniqueValues(),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="relative flex max-w-[300px] items-center gap-4 ">
|
||||
{table.getHeaderGroups()?.[0]?.headers?.[0]?.id && (
|
||||
<div className="flex items-center gap-2 whitespace-nowrap text-sm text-custom-text-400">
|
||||
{searchPlaceholder}
|
||||
</div>
|
||||
)}
|
||||
{!isSearchOpen && (
|
||||
<button
|
||||
type="button"
|
||||
className="-mr-5 grid place-items-center rounded p-2 text-custom-text-400 hover:bg-custom-background-80"
|
||||
onClick={() => {
|
||||
setIsSearchOpen(true);
|
||||
inputRef.current?.focus();
|
||||
}}
|
||||
>
|
||||
<Search className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"mr-auto flex w-0 items-center justify-start gap-1 overflow-hidden rounded-md border border-transparent bg-custom-background-100 text-custom-text-400 opacity-0 transition-[width] ease-linear",
|
||||
{
|
||||
"w-64 border-custom-border-200 px-2.5 py-1.5 opacity-100": isSearchOpen,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<Search className="h-3.5 w-3.5" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="w-full max-w-[234px] border-none bg-transparent text-sm text-custom-text-100 placeholder:text-custom-text-400 focus:outline-none"
|
||||
placeholder="Search"
|
||||
value={table.getColumn(table.getHeaderGroups()?.[0].headers[0].id)?.getFilterValue() as string}
|
||||
onChange={(e) => {
|
||||
const columnId = table.getHeaderGroups()?.[0]?.headers?.[0]?.id;
|
||||
if (columnId) table.getColumn(columnId)?.setFilterValue(e.target.value);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
setIsSearchOpen(true);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{isSearchOpen && (
|
||||
<button
|
||||
type="button"
|
||||
className="grid place-items-center"
|
||||
onClick={() => {
|
||||
const columnId = table.getHeaderGroups()?.[0]?.headers?.[0]?.id;
|
||||
if (columnId) {
|
||||
table.getColumn(columnId)?.setFilterValue("");
|
||||
}
|
||||
setIsSearchOpen(false);
|
||||
}}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{actions && <div>{actions(table)}</div>}
|
||||
</div>
|
||||
|
||||
<div className="rounded-md">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead key={header.id} colSpan={header.colSpan}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: (flexRender(header.column.columnDef.header, header.getContext()) as any)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length > 0 ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow key={row.id} data-state={row.getIsSelected() && "selected"}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext()) as any}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="p-0">
|
||||
<div className="flex h-[350px] w-full items-center justify-center border border-custom-border-100 ">
|
||||
<AnalyticsV2EmptyState
|
||||
title={t("workspace_analytics.empty_state_v2.customized_insights.title")}
|
||||
description={t("workspace_analytics.empty_state_v2.customized_insights.description")}
|
||||
className="border-0"
|
||||
assetPath={resolvedPath}
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1
web/core/components/analytics-v2/insight-table/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "./root";
|
||||
34
web/core/components/analytics-v2/insight-table/loader.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import * as React from "react";
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@plane/propel/table";
|
||||
import { Loader } from "@plane/ui";
|
||||
|
||||
interface TableSkeletonProps {
|
||||
columns: ColumnDef<any>[];
|
||||
rows: number;
|
||||
}
|
||||
|
||||
export const TableLoader: React.FC<TableSkeletonProps> = ({ columns, rows }) => (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{columns.map((column, index) => (
|
||||
<TableHead key={column.header?.toString() ?? index}>
|
||||
{typeof column.header === "string" ? column.header : ""}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{Array.from({ length: rows }).map((_, rowIndex) => (
|
||||
<TableRow key={rowIndex}>
|
||||
{columns.map((_, colIndex) => (
|
||||
<TableCell key={colIndex}>
|
||||
<Loader.Item height="20px" width="100%" />
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
74
web/core/components/analytics-v2/insight-table/root.tsx
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import { ColumnDef, Row, Table } from "@tanstack/react-table";
|
||||
import { download, generateCsv, mkConfig } from "export-to-csv";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Download } from "lucide-react";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { AnalyticsTableDataMap, TAnalyticsTabsV2Base } from "@plane/types";
|
||||
import { Button } from "@plane/ui";
|
||||
import { DataTable } from "./data-table";
|
||||
import { TableLoader } from "./loader";
|
||||
interface InsightTableProps<T extends Exclude<TAnalyticsTabsV2Base, "overview">> {
|
||||
analyticsType: T;
|
||||
data?: AnalyticsTableDataMap[T][];
|
||||
isLoading?: boolean;
|
||||
columns: ColumnDef<AnalyticsTableDataMap[T]>[];
|
||||
columnsLabels?: Record<string, string>;
|
||||
}
|
||||
|
||||
export const InsightTable = <T extends Exclude<TAnalyticsTabsV2Base, "overview">>(
|
||||
props: InsightTableProps<T>
|
||||
): React.ReactElement => {
|
||||
const { data, isLoading, columns, columnsLabels } = props;
|
||||
const params = useParams();
|
||||
const { t } = useTranslation();
|
||||
const workspaceSlug = params.workspaceSlug as string;
|
||||
if (isLoading) {
|
||||
return <TableLoader columns={columns} rows={5} />;
|
||||
}
|
||||
|
||||
const csvConfig = mkConfig({
|
||||
fieldSeparator: ",",
|
||||
filename: `${workspaceSlug}-analytics`,
|
||||
decimalSeparator: ".",
|
||||
useKeysAsHeaders: true,
|
||||
});
|
||||
|
||||
const exportCSV = (rows: Row<AnalyticsTableDataMap[T]>[]) => {
|
||||
const rowData: any = rows.map((row) => {
|
||||
const { project_id, ...exportableData } = row.original;
|
||||
return Object.fromEntries(
|
||||
Object.entries(exportableData).map(([key, value]) => {
|
||||
if (columnsLabels?.[key]) {
|
||||
return [columnsLabels[key], value];
|
||||
}
|
||||
return [key, value];
|
||||
})
|
||||
);
|
||||
});
|
||||
const csv = generateCsv(csvConfig)(rowData);
|
||||
download(csvConfig)(csv);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="">
|
||||
{data ? (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={data}
|
||||
searchPlaceholder={`${data.length} Projects`}
|
||||
actions={(table: Table<AnalyticsTableDataMap[T]>) => (
|
||||
<Button
|
||||
variant="accent-primary"
|
||||
prependIcon={<Download className="h-3.5 w-3.5" />}
|
||||
onClick={() => exportCSV(table.getFilteredRowModel().rows)}
|
||||
>
|
||||
<div>{t("exporter.csv.short_description")}</div>
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<div>No data</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
23
web/core/components/analytics-v2/loaders.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { Loader } from "@plane/ui";
|
||||
|
||||
export const ProjectInsightsLoader = () => (
|
||||
<div className="flex h-[200px] gap-1">
|
||||
<Loader className="h-full w-full">
|
||||
<Loader.Item height="100%" width="100%" />
|
||||
</Loader>
|
||||
<div className="flex h-full w-full flex-col gap-1">
|
||||
<Loader className="h-12 w-full">
|
||||
<Loader.Item height="100%" width="100%" />
|
||||
</Loader>
|
||||
<Loader className="h-full w-full">
|
||||
<Loader.Item height="100%" width="100%" />
|
||||
</Loader>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const ChartLoader = () => (
|
||||
<Loader className="h-[350px] w-full">
|
||||
<Loader.Item height="100%" width="100%" />
|
||||
</Loader>
|
||||
);
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
import { Briefcase } from "lucide-react";
|
||||
// plane package imports
|
||||
import { Logo } from "@plane/ui";
|
||||
import { cn } from "@plane/utils";
|
||||
// plane web hooks
|
||||
import { useProject } from "@/hooks/store";
|
||||
|
||||
type Props = {
|
||||
project: {
|
||||
id: string;
|
||||
completed_issues?: number;
|
||||
total_issues?: number;
|
||||
};
|
||||
isLoading?: boolean;
|
||||
};
|
||||
const CompletionPercentage = ({ percentage }: { percentage: number }) => {
|
||||
const percentageColor = percentage > 50 ? "bg-green-500/30 text-green-500" : "bg-red-500/30 text-red-500";
|
||||
return (
|
||||
<div className={cn("flex items-center gap-2 rounded p-1 text-xs", percentageColor)}>
|
||||
<span>{percentage}%</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ActiveProjectItem = (props: Props) => {
|
||||
const { project } = props;
|
||||
const { getProjectById } = useProject();
|
||||
const { id, completed_issues, total_issues } = project;
|
||||
|
||||
const projectDetails = getProjectById(id);
|
||||
|
||||
if (!projectDetails) return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2 ">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-xl bg-custom-background-80">
|
||||
<span className="grid h-4 w-4 flex-shrink-0 place-items-center">
|
||||
{projectDetails?.logo_props ? (
|
||||
<Logo logo={projectDetails?.logo_props} size={16} />
|
||||
) : (
|
||||
<span className="grid h-4 w-4 flex-shrink-0 place-items-center">
|
||||
<Briefcase className="h-4 w-4" />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm font-medium">{projectDetails?.name}</p>
|
||||
</div>
|
||||
<CompletionPercentage
|
||||
percentage={completed_issues && total_issues ? Math.round((completed_issues / total_issues) * 100) : 0}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ActiveProjectItem;
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import useSWR from "swr";
|
||||
// plane package imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Loader } from "@plane/ui";
|
||||
// plane web hooks
|
||||
import { useAnalyticsV2, useProject } from "@/hooks/store";
|
||||
// plane web components
|
||||
import AnalyticsSectionWrapper from "../analytics-section-wrapper";
|
||||
import ActiveProjectItem from "./active-project-item";
|
||||
|
||||
const ActiveProjects = observer(() => {
|
||||
const { t } = useTranslation();
|
||||
const { fetchProjectAnalyticsCount } = useProject();
|
||||
const { workspaceSlug } = useParams();
|
||||
const { selectedDurationLabel } = useAnalyticsV2();
|
||||
const { data: projectAnalyticsCount, isLoading: isProjectAnalyticsCountLoading } = useSWR(
|
||||
workspaceSlug ? ["projectAnalyticsCount", workspaceSlug] : null,
|
||||
workspaceSlug
|
||||
? () =>
|
||||
fetchProjectAnalyticsCount(workspaceSlug.toString(), {
|
||||
fields: "total_work_items,total_completed_work_items",
|
||||
})
|
||||
: null
|
||||
);
|
||||
return (
|
||||
<AnalyticsSectionWrapper
|
||||
title={`${t("workspace_analytics.active_projects")}`}
|
||||
subtitle={selectedDurationLabel}
|
||||
className="md:col-span-2"
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
{isProjectAnalyticsCountLoading &&
|
||||
Array.from({ length: 5 }).map((_, index) => <Loader.Item key={index} height="40px" width="100%" />)}
|
||||
{!isProjectAnalyticsCountLoading &&
|
||||
projectAnalyticsCount?.map((project) => <ActiveProjectItem key={project.id} project={project} />)}
|
||||
</div>
|
||||
</AnalyticsSectionWrapper>
|
||||
);
|
||||
});
|
||||
|
||||
export default ActiveProjects;
|
||||
1
web/core/components/analytics-v2/overview/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "./root";
|
||||
109
web/core/components/analytics-v2/overview/project-insights.tsx
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import { observer } from "mobx-react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useParams } from "next/navigation";
|
||||
import useSWR from "swr";
|
||||
// plane package imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { TChartData } from "@plane/types";
|
||||
// hooks
|
||||
import { useAnalyticsV2 } from "@/hooks/store/use-analytics-v2";
|
||||
// services
|
||||
import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
|
||||
import { AnalyticsV2Service } from "@/services/analytics-v2.service";
|
||||
// plane web components
|
||||
import AnalyticsSectionWrapper from "../analytics-section-wrapper";
|
||||
import AnalyticsV2EmptyState from "../empty-state";
|
||||
import { ProjectInsightsLoader } from "../loaders";
|
||||
|
||||
const RadarChart = dynamic(() =>
|
||||
import("@plane/propel/charts/radar-chart").then((mod) => ({
|
||||
default: mod.RadarChart,
|
||||
}))
|
||||
);
|
||||
|
||||
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 resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/analytics-v2/empty-chart-radar" });
|
||||
|
||||
const { data: projectInsightsData, isLoading: isLoadingProjectInsight } = useSWR(
|
||||
`radar-chart-${workspaceSlug}-${selectedDuration}-${selectedProjects}`,
|
||||
() =>
|
||||
analyticsV2Service.getAdvanceAnalyticsCharts<TChartData<string, string>[]>(workspaceSlug, "projects", {
|
||||
date_filter: selectedDuration,
|
||||
...(selectedProjects?.length > 0 && { project_ids: selectedProjects?.join(",") }),
|
||||
})
|
||||
);
|
||||
|
||||
return (
|
||||
<AnalyticsSectionWrapper
|
||||
title={`${t("workspace_analytics.project_insights")}`}
|
||||
subtitle={selectedDurationLabel}
|
||||
className="md:col-span-3"
|
||||
>
|
||||
{isLoadingProjectInsight ? (
|
||||
<ProjectInsightsLoader />
|
||||
) : projectInsightsData && projectInsightsData?.length == 0 ? (
|
||||
<AnalyticsV2EmptyState
|
||||
title={t("workspace_analytics.empty_state_v2.project_insights.title")}
|
||||
description={t("workspace_analytics.empty_state_v2.project_insights.description")}
|
||||
className="h-[300px]"
|
||||
assetPath={resolvedPath}
|
||||
/>
|
||||
) : (
|
||||
<div className="gap-8 lg:flex">
|
||||
{projectInsightsData && (
|
||||
<RadarChart
|
||||
className="h-[350px] w-full lg:w-3/5"
|
||||
data={projectInsightsData}
|
||||
dataKey="key"
|
||||
radars={[
|
||||
{
|
||||
key: "count",
|
||||
name: "Count",
|
||||
fill: "rgba(var(--color-primary-300))",
|
||||
stroke: "rgba(var(--color-primary-300))",
|
||||
fillOpacity: 0.6,
|
||||
dot: {
|
||||
r: 4,
|
||||
fillOpacity: 1,
|
||||
},
|
||||
},
|
||||
]}
|
||||
margin={{ top: 0, right: 40, bottom: 10, left: 40 }}
|
||||
showTooltip
|
||||
angleAxis={{
|
||||
key: "name",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className="w-full lg:w-2/5">
|
||||
<div className="text-sm text-custom-text-300">{t("workspace_analytics.summary_of_projects")}</div>
|
||||
<div className=" mb-3 border-b border-custom-border-100 py-2">{t("workspace_analytics.all_projects")}</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between text-sm text-custom-text-300">
|
||||
<div>{t("workspace_analytics.trend_on_charts")}</div>
|
||||
<div>{t("common.work_items")}</div>
|
||||
</div>
|
||||
{projectInsightsData?.map((item) => (
|
||||
<div key={item.key} className="flex items-center justify-between text-sm text-custom-text-100">
|
||||
<div>{item.name}</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{/* <TrendPiece key={item.key} size='xs' /> */}
|
||||
<div className="text-custom-text-200">{item.count}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</AnalyticsSectionWrapper>
|
||||
);
|
||||
});
|
||||
|
||||
export default ProjectInsights;
|
||||
19
web/core/components/analytics-v2/overview/root.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import React from "react";
|
||||
import AnalyticsWrapper from "../analytics-wrapper";
|
||||
import TotalInsights from "../total-insights";
|
||||
import ActiveProjects from "./active-projects";
|
||||
import ProjectInsights from "./project-insights";
|
||||
|
||||
const Overview: React.FC = () => (
|
||||
<AnalyticsWrapper title="Overview">
|
||||
<div className="flex flex-col gap-14">
|
||||
<TotalInsights analyticsType="overview" />
|
||||
<div className="grid grid-cols-1 gap-14 md:grid-cols-5 ">
|
||||
<ProjectInsights />
|
||||
<ActiveProjects />
|
||||
</div>
|
||||
</div>
|
||||
</AnalyticsWrapper>
|
||||
);
|
||||
|
||||
export { Overview };
|
||||
98
web/core/components/analytics-v2/select/analytics-params.tsx
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import { useMemo } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Control, Controller, UseFormSetValue } from "react-hook-form";
|
||||
import { Calendar, SlidersHorizontal } from "lucide-react";
|
||||
// plane package imports
|
||||
import { ANALYTICS_V2_X_AXIS_VALUES, ANALYTICS_V2_Y_AXIS_VALUES, ChartYAxisMetric } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { IAnalyticsV2Params } from "@plane/types";
|
||||
import { cn } from "@plane/utils";
|
||||
// plane web components
|
||||
import { AnalyticsV2Service } from "@/services/analytics-v2.service";
|
||||
import { SelectXAxis } from "./select-x-axis";
|
||||
import { SelectYAxis } from "./select-y-axis";
|
||||
|
||||
type Props = {
|
||||
control: Control<IAnalyticsV2Params, unknown>;
|
||||
setValue: UseFormSetValue<IAnalyticsV2Params>;
|
||||
params: IAnalyticsV2Params;
|
||||
workspaceSlug: string;
|
||||
classNames?: string;
|
||||
};
|
||||
|
||||
export const AnalyticsV2SelectParams: React.FC<Props> = observer((props) => {
|
||||
const { control, params, classNames } = props;
|
||||
const xAxisOptions = useMemo(
|
||||
() => ANALYTICS_V2_X_AXIS_VALUES.filter((option) => option.value !== params.group_by),
|
||||
[params.group_by]
|
||||
);
|
||||
const groupByOptions = useMemo(
|
||||
() => ANALYTICS_V2_X_AXIS_VALUES.filter((option) => option.value !== params.x_axis),
|
||||
[params.x_axis]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cn("flex w-full justify-between", classNames)}>
|
||||
<div className={`flex items-center gap-2`}>
|
||||
<Controller
|
||||
name="y_axis"
|
||||
control={control}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<SelectYAxis
|
||||
value={value}
|
||||
onChange={(val: ChartYAxisMetric | null) => {
|
||||
onChange(val);
|
||||
}}
|
||||
options={ANALYTICS_V2_Y_AXIS_VALUES}
|
||||
hiddenOptions={[ChartYAxisMetric.ESTIMATE_POINT_COUNT]}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="x_axis"
|
||||
control={control}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<SelectXAxis
|
||||
value={value}
|
||||
onChange={(val) => {
|
||||
onChange(val);
|
||||
}}
|
||||
label={
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="h-3 w-3" />
|
||||
<span className={cn("text-custom-text-200", value && "text-custom-text-100")}>
|
||||
{xAxisOptions.find((v) => v.value === value)?.label || "Add Property"}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
options={xAxisOptions}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="group_by"
|
||||
control={control}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<SelectXAxis
|
||||
value={value}
|
||||
onChange={(val) => {
|
||||
onChange(val);
|
||||
}}
|
||||
label={
|
||||
<div className="flex items-center gap-2">
|
||||
<SlidersHorizontal className="h-3 w-3" />
|
||||
<span className={cn("text-custom-text-200", value && "text-custom-text-100")}>
|
||||
{groupByOptions.find((v) => v.value === value)?.label || "Add Property"}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
options={groupByOptions}
|
||||
placeholder="Group By"
|
||||
allowNoValue
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
50
web/core/components/analytics-v2/select/duration.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
// plane package imports
|
||||
import React, { ReactNode } from "react";
|
||||
import { Calendar } from "lucide-react";
|
||||
// plane package imports
|
||||
import { ANALYTICS_V2_DURATION_FILTER_OPTIONS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { CustomSearchSelect } from "@plane/ui";
|
||||
// types
|
||||
import { TDropdownProps } from "@/components/dropdowns/types";
|
||||
|
||||
type Props = TDropdownProps & {
|
||||
value: string | null;
|
||||
onChange: (val: (typeof ANALYTICS_V2_DURATION_FILTER_OPTIONS)[number]["value"]) => void;
|
||||
//optional
|
||||
button?: ReactNode;
|
||||
dropdownArrow?: boolean;
|
||||
dropdownArrowClassName?: string;
|
||||
onClose?: () => void;
|
||||
renderByDefault?: boolean;
|
||||
tabIndex?: number;
|
||||
};
|
||||
|
||||
function DurationDropdown({ placeholder = "Duration", onChange, value }: Props) {
|
||||
useTranslation();
|
||||
|
||||
const options = ANALYTICS_V2_DURATION_FILTER_OPTIONS.map((option) => ({
|
||||
value: option.value,
|
||||
query: option.name,
|
||||
content: (
|
||||
<div className="flex max-w-[300px] items-center gap-2">
|
||||
<span className="flex-grow truncate">{option.name}</span>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
return (
|
||||
<CustomSearchSelect
|
||||
value={value ? [value] : []}
|
||||
onChange={onChange}
|
||||
options={options}
|
||||
label={
|
||||
<div className="flex items-center gap-2 p-1 ">
|
||||
<Calendar className="h-4 w-4" />
|
||||
{value ? ANALYTICS_V2_DURATION_FILTER_OPTIONS.find((opt) => opt.value === value)?.name : placeholder}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default DurationDropdown;
|
||||
60
web/core/components/analytics-v2/select/project.tsx
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { Briefcase } from "lucide-react";
|
||||
// plane package imports
|
||||
import { CustomSearchSelect, Logo } from "@plane/ui";
|
||||
// hooks
|
||||
import { useProject } from "@/hooks/store";
|
||||
|
||||
type Props = {
|
||||
value: string[] | undefined;
|
||||
onChange: (val: string[] | null) => void;
|
||||
projectIds: string[] | undefined;
|
||||
};
|
||||
|
||||
export const ProjectSelect: React.FC<Props> = observer((props) => {
|
||||
const { value, onChange, projectIds } = props;
|
||||
const { getProjectById } = useProject();
|
||||
|
||||
const options = projectIds?.map((projectId) => {
|
||||
const projectDetails = getProjectById(projectId);
|
||||
|
||||
return {
|
||||
value: projectDetails?.id,
|
||||
query: `${projectDetails?.name} ${projectDetails?.identifier}`,
|
||||
content: (
|
||||
<div className="flex max-w-[300px] items-center gap-2">
|
||||
{projectDetails?.logo_props ? (
|
||||
<Logo logo={projectDetails?.logo_props} size={16} />
|
||||
) : (
|
||||
<Briefcase className="h-4 w-4" />
|
||||
)}
|
||||
<span className="flex-grow truncate">{projectDetails?.name}</span>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<CustomSearchSelect
|
||||
value={value ?? []}
|
||||
onChange={(val: string[]) => onChange(val)}
|
||||
options={options}
|
||||
label={
|
||||
<div className="flex items-center gap-2 p-1 ">
|
||||
<Briefcase className="h-4 w-4" />
|
||||
{value && value.length > 3
|
||||
? `3+ projects`
|
||||
: value && value.length > 0
|
||||
? projectIds
|
||||
?.filter((p) => value.includes(p))
|
||||
.map((p) => getProjectById(p)?.name)
|
||||
.join(", ")
|
||||
: "All projects"}
|
||||
</div>
|
||||
}
|
||||
multiple
|
||||
/>
|
||||
);
|
||||
});
|
||||
31
web/core/components/analytics-v2/select/select-x-axis.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
"use client";
|
||||
// plane package imports
|
||||
import { ChartXAxisProperty } from "@plane/constants";
|
||||
import { CustomSelect } from "@plane/ui";
|
||||
|
||||
type Props = {
|
||||
value?: ChartXAxisProperty;
|
||||
onChange: (val: ChartXAxisProperty | null) => void;
|
||||
options: { value: ChartXAxisProperty; label: string }[];
|
||||
placeholder?: string;
|
||||
hiddenOptions?: ChartXAxisProperty[];
|
||||
allowNoValue?: boolean;
|
||||
label?: string | JSX.Element;
|
||||
};
|
||||
|
||||
export const SelectXAxis: React.FC<Props> = (props) => {
|
||||
const { value, onChange, options, hiddenOptions, allowNoValue, label } = props;
|
||||
return (
|
||||
<CustomSelect value={value} label={label} onChange={onChange} maxHeight="lg">
|
||||
{allowNoValue && <CustomSelect.Option value={null}>No value</CustomSelect.Option>}
|
||||
{options.map((item) => {
|
||||
if (hiddenOptions?.includes(item.value)) return null;
|
||||
return (
|
||||
<CustomSelect.Option key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</CustomSelect.Option>
|
||||
);
|
||||
})}
|
||||
</CustomSelect>
|
||||
);
|
||||
};
|
||||
67
web/core/components/analytics-v2/select/select-y-axis.tsx
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Briefcase } from "lucide-react";
|
||||
// plane package imports
|
||||
import { ChartYAxisMetric } from "@plane/constants";
|
||||
import { CustomSelect } from "@plane/ui";
|
||||
// hooks
|
||||
import { useProjectEstimates } from "@/hooks/store";
|
||||
// plane web constants
|
||||
import { EEstimateSystem } from "@/plane-web/constants/estimates";
|
||||
|
||||
type Props = {
|
||||
value: ChartYAxisMetric;
|
||||
onChange: (val: ChartYAxisMetric | null) => void;
|
||||
hiddenOptions?: ChartYAxisMetric[];
|
||||
options: { value: ChartYAxisMetric; label: string }[];
|
||||
};
|
||||
|
||||
export const SelectYAxis: React.FC<Props> = observer(({ value, onChange, hiddenOptions, options }) => {
|
||||
// hooks
|
||||
const { projectId } = useParams();
|
||||
const { areEstimateEnabledByProjectId, currentActiveEstimateId, estimateById } = useProjectEstimates();
|
||||
|
||||
const isEstimateEnabled = (analyticsOption: string) => {
|
||||
if (analyticsOption === "estimate") {
|
||||
if (
|
||||
projectId &&
|
||||
currentActiveEstimateId &&
|
||||
areEstimateEnabledByProjectId(projectId.toString()) &&
|
||||
estimateById(currentActiveEstimateId)?.type === EEstimateSystem.POINTS
|
||||
) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
return (
|
||||
<CustomSelect
|
||||
value={value}
|
||||
label={
|
||||
<div className="flex items-center gap-2">
|
||||
<Briefcase className="h-3 w-3" />
|
||||
<span>{options.find((v) => v.value === value)?.label ?? "Add Metric"}</span>
|
||||
</div>
|
||||
}
|
||||
onChange={onChange}
|
||||
maxHeight="lg"
|
||||
>
|
||||
{options.map((item) => {
|
||||
if (hiddenOptions?.includes(item.value)) return null;
|
||||
return (
|
||||
isEstimateEnabled(item.value) && (
|
||||
<CustomSelect.Option key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</CustomSelect.Option>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</CustomSelect>
|
||||
);
|
||||
});
|
||||
58
web/core/components/analytics-v2/total-insights.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
// plane package imports
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useParams } from "next/navigation";
|
||||
import useSWR from "swr";
|
||||
import { insightsFields } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { IAnalyticsResponseV2, TAnalyticsTabsV2Base } from "@plane/types";
|
||||
//hooks
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
import { useAnalyticsV2 } from "@/hooks/store/use-analytics-v2";
|
||||
//services
|
||||
import { AnalyticsV2Service } from "@/services/analytics-v2.service";
|
||||
// plane web components
|
||||
import InsightCard from "./insight-card";
|
||||
|
||||
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 { t } = useTranslation();
|
||||
const { selectedDuration, selectedProjects, selectedDurationLabel } = useAnalyticsV2();
|
||||
|
||||
const { data: totalInsightsData, isLoading } = useSWR(
|
||||
`total-insights-${analyticsType}-${selectedDuration}-${selectedProjects}`,
|
||||
() =>
|
||||
analyticsV2Service.getAdvanceAnalytics<IAnalyticsResponseV2>(workspaceSlug, analyticsType, {
|
||||
date_filter: selectedDuration,
|
||||
...(selectedProjects?.length > 0 ? { project_ids: selectedProjects.join(",") } : {}),
|
||||
})
|
||||
);
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"grid grid-cols-1 gap-8 sm:grid-cols-2 md:gap-10",
|
||||
!peekView
|
||||
? insightsFields[analyticsType].length % 5 === 0
|
||||
? "gap-10 lg:grid-cols-5"
|
||||
: "gap-8 lg:grid-cols-4"
|
||||
: "grid-cols-2"
|
||||
)}
|
||||
>
|
||||
{insightsFields[analyticsType]?.map((item: string) => (
|
||||
<InsightCard
|
||||
key={`${analyticsType}-${item}`}
|
||||
isLoading={isLoading}
|
||||
data={totalInsightsData?.[item]}
|
||||
label={t(`workspace_analytics.${item}`)}
|
||||
versus={selectedDurationLabel}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export default TotalInsights;
|
||||
47
web/core/components/analytics-v2/trend-piece.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
// plane package imports
|
||||
import React from "react";
|
||||
import { TrendingDown, TrendingUp } from "lucide-react";
|
||||
import { cn } from "@plane/utils";
|
||||
// plane web components
|
||||
|
||||
type Props = {
|
||||
percentage: number;
|
||||
className?: string;
|
||||
size?: "xs" | "sm" | "md" | "lg";
|
||||
};
|
||||
|
||||
const sizeConfig = {
|
||||
xs: {
|
||||
text: "text-xs",
|
||||
icon: "w-3 h-3",
|
||||
},
|
||||
sm: {
|
||||
text: "text-sm",
|
||||
icon: "w-4 h-4",
|
||||
},
|
||||
md: {
|
||||
text: "text-base",
|
||||
icon: "w-5 h-5",
|
||||
},
|
||||
lg: {
|
||||
text: "text-lg",
|
||||
icon: "w-6 h-6",
|
||||
},
|
||||
} as const;
|
||||
|
||||
const TrendPiece = (props: Props) => {
|
||||
const { percentage, className, size = "sm" } = props;
|
||||
const isPositive = percentage > 0;
|
||||
const config = sizeConfig[size];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("flex items-center gap-1", isPositive ? "text-green-500" : "text-red-500", config.text, className)}
|
||||
>
|
||||
{isPositive ? <TrendingUp className={config.icon} /> : <TrendingDown className={config.icon} />}
|
||||
{Math.round(Math.abs(percentage))}%
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TrendPiece;
|
||||
|
|
@ -0,0 +1,119 @@
|
|||
import { useMemo } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import useSWR from "swr";
|
||||
// plane package imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { AreaChart } from "@plane/propel/charts/area-chart";
|
||||
import { IChartResponseV2, TChartData } from "@plane/types";
|
||||
import { renderFormattedDate } from "@plane/utils";
|
||||
// hooks
|
||||
import { useAnalyticsV2 } from "@/hooks/store/use-analytics-v2";
|
||||
// services
|
||||
import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
|
||||
import { AnalyticsV2Service } from "@/services/analytics-v2.service";
|
||||
// plane web components
|
||||
import AnalyticsSectionWrapper from "../analytics-section-wrapper";
|
||||
import AnalyticsV2EmptyState from "../empty-state";
|
||||
import { ChartLoader } from "../loaders";
|
||||
|
||||
const analyticsV2Service = new AnalyticsV2Service();
|
||||
const CreatedVsResolved = observer(() => {
|
||||
const { selectedDuration, selectedDurationLabel, selectedProjects } = useAnalyticsV2();
|
||||
const params = useParams();
|
||||
const { t } = useTranslation();
|
||||
const workspaceSlug = params.workspaceSlug as string;
|
||||
const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/analytics-v2/empty-chart-area" });
|
||||
const { data: createdVsResolvedData, isLoading: isCreatedVsResolvedLoading } = useSWR(
|
||||
`created-vs-resolved-${workspaceSlug}-${selectedDuration}-${selectedProjects}`,
|
||||
() =>
|
||||
analyticsV2Service.getAdvanceAnalyticsCharts<IChartResponseV2>(workspaceSlug, "work-items", {
|
||||
date_filter: selectedDuration,
|
||||
...(selectedProjects?.length > 0 && { project_ids: selectedProjects?.join(",") }),
|
||||
})
|
||||
);
|
||||
const parsedData: TChartData<string, string>[] = useMemo(() => {
|
||||
if (!createdVsResolvedData?.data) return [];
|
||||
return createdVsResolvedData.data.map((datum) => ({
|
||||
...datum,
|
||||
[datum.key]: datum.count,
|
||||
name: renderFormattedDate(datum.key) ?? datum.key,
|
||||
}));
|
||||
}, [createdVsResolvedData]);
|
||||
|
||||
const areas = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: "completed_issues",
|
||||
label: "Resolved",
|
||||
fill: "#19803833",
|
||||
fillOpacity: 1,
|
||||
stackId: "bar-one",
|
||||
showDot: false,
|
||||
smoothCurves: true,
|
||||
strokeColor: "#198038",
|
||||
strokeOpacity: 1,
|
||||
},
|
||||
{
|
||||
key: "created_issues",
|
||||
label: "Created",
|
||||
fill: "#1192E833",
|
||||
fillOpacity: 1,
|
||||
stackId: "bar-one",
|
||||
showDot: false,
|
||||
smoothCurves: true,
|
||||
strokeColor: "#1192E8",
|
||||
strokeOpacity: 1,
|
||||
},
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<AnalyticsSectionWrapper
|
||||
title={t("workspace_analytics.created_vs_resolved")}
|
||||
subtitle={selectedDurationLabel}
|
||||
className="col-span-1"
|
||||
>
|
||||
{isCreatedVsResolvedLoading ? (
|
||||
<ChartLoader />
|
||||
) : parsedData && parsedData.length > 0 ? (
|
||||
<AreaChart
|
||||
className="h-[350px] w-full"
|
||||
data={parsedData}
|
||||
areas={areas}
|
||||
xAxis={{
|
||||
key: "name",
|
||||
label: "Date",
|
||||
}}
|
||||
yAxis={{
|
||||
key: "count",
|
||||
label: "Number of Issues",
|
||||
offset: -30,
|
||||
dx: -22,
|
||||
}}
|
||||
legend={{
|
||||
align: "left",
|
||||
verticalAlign: "bottom",
|
||||
layout: "horizontal",
|
||||
wrapperStyles: {
|
||||
justifyContent: "start",
|
||||
alignContent: "start",
|
||||
paddingLeft: "40px",
|
||||
paddingTop: "10px",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<AnalyticsV2EmptyState
|
||||
title={t("workspace_analytics.empty_state_v2.created_vs_resolved.title")}
|
||||
description={t("workspace_analytics.empty_state_v2.created_vs_resolved.description")}
|
||||
className="h-[350px]"
|
||||
assetPath={resolvedPath}
|
||||
/>
|
||||
)}
|
||||
</AnalyticsSectionWrapper>
|
||||
);
|
||||
});
|
||||
|
||||
export default CreatedVsResolved;
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useForm } from "react-hook-form";
|
||||
// plane package imports
|
||||
import { ChartXAxisProperty, ChartYAxisMetric } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { IAnalyticsV2Params } from "@plane/types";
|
||||
import { cn } from "@plane/utils";
|
||||
// plane web components
|
||||
import AnalyticsSectionWrapper from "../analytics-section-wrapper";
|
||||
import { AnalyticsV2SelectParams } from "../select/analytics-params";
|
||||
import PriorityChart from "./priority-chart";
|
||||
|
||||
const defaultValues: IAnalyticsV2Params = {
|
||||
x_axis: ChartXAxisProperty.PRIORITY,
|
||||
y_axis: ChartYAxisMetric.WORK_ITEM_COUNT,
|
||||
};
|
||||
|
||||
const CustomizedInsights = observer(({ peekView }: { peekView?: boolean }) => {
|
||||
const { t } = useTranslation();
|
||||
const { workspaceSlug } = useParams();
|
||||
const { control, watch, setValue } = useForm<IAnalyticsV2Params>({
|
||||
defaultValues: {
|
||||
...defaultValues,
|
||||
},
|
||||
});
|
||||
|
||||
const params = {
|
||||
x_axis: watch("x_axis"),
|
||||
y_axis: watch("y_axis"),
|
||||
group_by: watch("group_by"),
|
||||
};
|
||||
|
||||
return (
|
||||
<AnalyticsSectionWrapper
|
||||
title={t("workspace_analytics.customized_insights")}
|
||||
className="col-span-1"
|
||||
headerClassName={cn(peekView ? "flex-col items-start" : "")}
|
||||
actions={
|
||||
<AnalyticsV2SelectParams
|
||||
control={control}
|
||||
setValue={setValue}
|
||||
params={params}
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<PriorityChart x_axis={params.x_axis} y_axis={params.y_axis} group_by={params.group_by} />
|
||||
</AnalyticsSectionWrapper>
|
||||
);
|
||||
});
|
||||
|
||||
export default CustomizedInsights;
|
||||
1
web/core/components/analytics-v2/work-items/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "./root";
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
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 { Spinner } from "@plane/ui";
|
||||
// hooks
|
||||
import { useAnalyticsV2 } from "@/hooks/store";
|
||||
// plane web components
|
||||
import TotalInsights from "../../total-insights";
|
||||
import CreatedVsResolved from "../created-vs-resolved";
|
||||
import CustomizedInsights from "../customized-insights";
|
||||
import WorkItemsInsightTable from "../workitems-insight-table";
|
||||
|
||||
type Props = {
|
||||
fullScreen: boolean;
|
||||
projectDetails: IProject | undefined;
|
||||
};
|
||||
|
||||
export const WorkItemsModalMainContent: React.FC<Props> = observer((props) => {
|
||||
const { projectDetails, fullScreen } = props;
|
||||
const { updateSelectedProjects } = useAnalyticsV2();
|
||||
const [isProjectConfigured, setIsProjectConfigured] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectDetails?.id) return;
|
||||
updateSelectedProjects([projectDetails?.id ?? ""]);
|
||||
setIsProjectConfigured(true);
|
||||
}, [projectDetails?.id, updateSelectedProjects]);
|
||||
|
||||
if (!isProjectConfigured)
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Tab.Group as={React.Fragment}>
|
||||
<div className="flex flex-col gap-14 overflow-y-auto p-6">
|
||||
<TotalInsights analyticsType="work-items" peekView={!fullScreen} />
|
||||
<CreatedVsResolved />
|
||||
<CustomizedInsights peekView={!fullScreen} />
|
||||
<WorkItemsInsightTable />
|
||||
</div>
|
||||
</Tab.Group>
|
||||
);
|
||||
});
|
||||
37
web/core/components/analytics-v2/work-items/modal/header.tsx
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { observer } from "mobx-react";
|
||||
|
||||
// icons
|
||||
import { Expand, Shrink, X } from "lucide-react";
|
||||
|
||||
type Props = {
|
||||
fullScreen: boolean;
|
||||
handleClose: () => void;
|
||||
setFullScreen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
title: string;
|
||||
};
|
||||
|
||||
export const WorkItemsModalHeader: React.FC<Props> = observer((props) => {
|
||||
const { fullScreen, handleClose, setFullScreen, title } = 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>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="hidden place-items-center p-1 text-custom-text-200 hover:text-custom-text-100 md:grid"
|
||||
onClick={() => setFullScreen((prevData) => !prevData)}
|
||||
>
|
||||
{fullScreen ? <Shrink size={14} strokeWidth={2} /> : <Expand size={14} strokeWidth={2} />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="grid place-items-center p-1 text-custom-text-200 hover:text-custom-text-100"
|
||||
onClick={handleClose}
|
||||
>
|
||||
<X size={14} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
64
web/core/components/analytics-v2/work-items/modal/index.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import React, { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
// plane package imports
|
||||
import { IProject } from "@plane/types";
|
||||
// plane web components
|
||||
import { WorkItemsModalMainContent } from "./content";
|
||||
import { WorkItemsModalHeader } from "./header";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
projectDetails?: IProject | undefined;
|
||||
};
|
||||
|
||||
export const WorkItemsModal: React.FC<Props> = observer((props) => {
|
||||
const { isOpen, onClose, projectDetails } = props;
|
||||
|
||||
const [fullScreen, setFullScreen] = useState(false);
|
||||
|
||||
const handleClose = () => {
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Transition.Root appear show={isOpen} as={React.Fragment}>
|
||||
<Dialog as="div" className="relative z-20" onClose={handleClose}>
|
||||
<Transition.Child
|
||||
as={React.Fragment}
|
||||
enter="transition-transform duration-300"
|
||||
enterFrom="translate-x-full"
|
||||
enterTo="translate-x-0"
|
||||
leave="transition-transform duration-200"
|
||||
leaveFrom="translate-x-0"
|
||||
leaveTo="translate-x-full"
|
||||
>
|
||||
<div className="fixed inset-0 z-20 h-full w-full overflow-y-auto">
|
||||
<Dialog.Panel>
|
||||
<div
|
||||
className={`fixed right-0 top-0 z-20 h-full bg-custom-background-100 shadow-custom-shadow-md ${
|
||||
fullScreen ? "w-full p-2" : "w-full sm:w-full md:w-1/2"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`flex h-full flex-col overflow-hidden border-custom-border-200 bg-custom-background-100 text-left ${
|
||||
fullScreen ? "rounded-lg border" : "border-l"
|
||||
}`}
|
||||
>
|
||||
<WorkItemsModalHeader
|
||||
fullScreen={fullScreen}
|
||||
handleClose={handleClose}
|
||||
setFullScreen={setFullScreen}
|
||||
title={projectDetails?.name ?? ""}
|
||||
/>
|
||||
<WorkItemsModalMainContent fullScreen={fullScreen} projectDetails={projectDetails} />
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</div>
|
||||
</Transition.Child>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
);
|
||||
});
|
||||
230
web/core/components/analytics-v2/work-items/priority-chart.tsx
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
import { useMemo } from "react";
|
||||
import { ColumnDef, Row, Table } from "@tanstack/react-table";
|
||||
import { mkConfig, generateCsv, download } from "export-to-csv";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useTheme } from "next-themes";
|
||||
import useSWR from "swr";
|
||||
// plane package imports
|
||||
import { Download } from "lucide-react";
|
||||
import {
|
||||
ANALYTICS_V2_X_AXIS_VALUES,
|
||||
ANALYTICS_V2_Y_AXIS_VALUES,
|
||||
CHART_COLOR_PALETTES,
|
||||
ChartXAxisDateGrouping,
|
||||
ChartXAxisProperty,
|
||||
ChartYAxisMetric,
|
||||
EChartModels,
|
||||
} from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { BarChart } from "@plane/propel/charts/bar-chart";
|
||||
import { IChartResponseV2 } from "@plane/types";
|
||||
import { TBarItem, TChart, TChartData, TChartDatum } from "@plane/types/src/charts";
|
||||
// plane web components
|
||||
import { Button } from "@plane/ui";
|
||||
import { generateExtendedColors, parseChartData } from "@/components/chart/utils";
|
||||
// hooks
|
||||
import { useProjectState } from "@/hooks/store";
|
||||
import { useAnalyticsV2 } from "@/hooks/store/use-analytics-v2";
|
||||
import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
|
||||
import { AnalyticsV2Service } from "@/services/analytics-v2.service";
|
||||
import AnalyticsV2EmptyState from "../empty-state";
|
||||
import { DataTable } from "../insight-table/data-table";
|
||||
import { ChartLoader } from "../loaders";
|
||||
import { generateBarColor } from "./utils";
|
||||
|
||||
interface Props {
|
||||
x_axis: ChartXAxisProperty;
|
||||
y_axis: ChartYAxisMetric;
|
||||
group_by?: ChartXAxisProperty;
|
||||
x_axis_date_grouping?: ChartXAxisDateGrouping;
|
||||
}
|
||||
|
||||
const analyticsV2Service = new AnalyticsV2Service();
|
||||
const PriorityChart = observer((props: Props) => {
|
||||
const { x_axis, y_axis, group_by } = props;
|
||||
const { t } = useTranslation();
|
||||
const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/analytics-v2/empty-chart-bar" });
|
||||
// store hooks
|
||||
const { selectedDuration, selectedProjects } = useAnalyticsV2();
|
||||
const { workspaceStates } = useProjectState();
|
||||
const { resolvedTheme } = useTheme();
|
||||
// router
|
||||
const params = useParams();
|
||||
const workspaceSlug = params.workspaceSlug as string;
|
||||
|
||||
const { data: priorityChartData, isLoading: priorityChartLoading } = useSWR(
|
||||
`customized-insights-chart-${workspaceSlug}-${selectedDuration}-${selectedProjects}-${props.x_axis}-${props.y_axis}-${props.group_by}`,
|
||||
() =>
|
||||
analyticsV2Service.getAdvanceAnalyticsCharts<TChart>(workspaceSlug, "custom-work-items", {
|
||||
date_filter: selectedDuration,
|
||||
...(selectedProjects?.length > 0 && { project_ids: selectedProjects?.join(",") }),
|
||||
...props,
|
||||
})
|
||||
);
|
||||
const parsedData = useMemo(
|
||||
() =>
|
||||
priorityChartData && parseChartData(priorityChartData, props.x_axis, props.group_by, props.x_axis_date_grouping),
|
||||
[priorityChartData, props.x_axis, props.group_by, props.x_axis_date_grouping]
|
||||
);
|
||||
const chart_model = props.group_by ? EChartModels.STACKED : EChartModels.BASIC;
|
||||
|
||||
const bars: TBarItem<string>[] = useMemo(() => {
|
||||
if (!parsedData) return [];
|
||||
let parsedBars: TBarItem<string>[];
|
||||
const schemaKeys = Object.keys(parsedData.schema);
|
||||
const baseColors = CHART_COLOR_PALETTES[0]?.[resolvedTheme === "dark" ? "dark" : "light"];
|
||||
const extendedColors = generateExtendedColors(baseColors ?? [], schemaKeys.length);
|
||||
if (chart_model === EChartModels.BASIC) {
|
||||
parsedBars = [
|
||||
{
|
||||
key: "count",
|
||||
label: "Count",
|
||||
stackId: "bar-one",
|
||||
fill: (payload) => generateBarColor(payload.key, { x_axis, y_axis, group_by }, baseColors, workspaceStates),
|
||||
textClassName: "",
|
||||
showPercentage: false,
|
||||
showTopBorderRadius: () => true,
|
||||
showBottomBorderRadius: () => true,
|
||||
},
|
||||
];
|
||||
} else if (chart_model === EChartModels.STACKED && parsedData.schema) {
|
||||
const parsedExtremes: {
|
||||
[key: string]: {
|
||||
top: string | null;
|
||||
bottom: string | null;
|
||||
};
|
||||
} = {};
|
||||
parsedData.data.forEach((datum) => {
|
||||
let top = null;
|
||||
let bottom = null;
|
||||
for (let i = 0; i < schemaKeys.length; i++) {
|
||||
const key = schemaKeys[i];
|
||||
if (datum[key] === 0) continue;
|
||||
if (!bottom) bottom = key;
|
||||
top = key;
|
||||
}
|
||||
parsedExtremes[datum.key] = { top, bottom };
|
||||
});
|
||||
|
||||
parsedBars = schemaKeys.map((key, index) => ({
|
||||
key: key,
|
||||
label: parsedData.schema[key],
|
||||
stackId: "bar-one",
|
||||
fill: extendedColors[index],
|
||||
textClassName: "",
|
||||
showPercentage: false,
|
||||
showTopBorderRadius: (value, payload: TChartDatum) => parsedExtremes[payload.key].top === value,
|
||||
showBottomBorderRadius: (value, payload: TChartDatum) => parsedExtremes[payload.key].bottom === value,
|
||||
}));
|
||||
} else {
|
||||
parsedBars = [];
|
||||
}
|
||||
return parsedBars;
|
||||
}, [chart_model, group_by, parsedData, resolvedTheme, workspaceStates, x_axis, y_axis]);
|
||||
|
||||
const defaultColumns: ColumnDef<TChartDatum>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: () => "Name",
|
||||
},
|
||||
{
|
||||
accessorKey: "count",
|
||||
header: () => <div className="text-right">Count</div>,
|
||||
cell: ({ row }) => <div className="text-right">{row.original.count}</div>,
|
||||
},
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
const columns: ColumnDef<TChartDatum>[] = useMemo(
|
||||
() =>
|
||||
parsedData
|
||||
? Object.keys(parsedData?.schema ?? {}).map((key) => ({
|
||||
accessorKey: key,
|
||||
header: () => <div className="text-right">{parsedData.schema[key]}</div>,
|
||||
cell: ({ row }) => <div className="text-right">{row.original[key]}</div>,
|
||||
}))
|
||||
: [],
|
||||
[parsedData]
|
||||
);
|
||||
|
||||
const csvConfig = mkConfig({
|
||||
fieldSeparator: ",",
|
||||
filename: `${workspaceSlug}-analytics`,
|
||||
decimalSeparator: ".",
|
||||
useKeysAsHeaders: true,
|
||||
});
|
||||
|
||||
const exportCSV = (rows: Row<TChartDatum>[]) => {
|
||||
const rowData = rows.map((row) => ({
|
||||
name: row.original.name,
|
||||
count: row.original.count,
|
||||
}));
|
||||
const csv = generateCsv(csvConfig)(rowData);
|
||||
download(csvConfig)(csv);
|
||||
};
|
||||
|
||||
const yAxisLabel = useMemo(
|
||||
() => ANALYTICS_V2_Y_AXIS_VALUES.find((item) => item.value === props.y_axis)?.label ?? props.y_axis,
|
||||
[props.y_axis]
|
||||
);
|
||||
const xAxisLabel = useMemo(
|
||||
() => ANALYTICS_V2_X_AXIS_VALUES.find((item) => item.value === props.x_axis)?.label ?? props.x_axis,
|
||||
[props.x_axis]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-12 ">
|
||||
{priorityChartLoading ? (
|
||||
<ChartLoader />
|
||||
) : parsedData?.data && parsedData.data.length > 0 ? (
|
||||
<>
|
||||
<BarChart
|
||||
className="h-[370px] w-full"
|
||||
data={parsedData.data}
|
||||
bars={bars}
|
||||
margin={{
|
||||
bottom: 30,
|
||||
}}
|
||||
xAxis={{
|
||||
key: "name",
|
||||
label: xAxisLabel.replace("_", " "),
|
||||
dy: 30,
|
||||
}}
|
||||
yAxis={{
|
||||
key: "count",
|
||||
label: yAxisLabel,
|
||||
offset: -40,
|
||||
dx: -26,
|
||||
}}
|
||||
/>
|
||||
<DataTable
|
||||
data={parsedData.data}
|
||||
columns={[...defaultColumns, ...columns]}
|
||||
searchPlaceholder={`${parsedData.data.length} ${yAxisLabel}`}
|
||||
actions={(table: Table<TChartDatum>) => (
|
||||
<Button
|
||||
variant="accent-primary"
|
||||
prependIcon={<Download className="h-3.5 w-3.5" />}
|
||||
onClick={() => exportCSV(table.getFilteredRowModel().rows)}
|
||||
>
|
||||
<div>{t("exporter.csv.short_description")}</div>
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<AnalyticsV2EmptyState
|
||||
title={t("workspace_analytics.empty_state_v2.customized_insights.title")}
|
||||
description={t("workspace_analytics.empty_state_v2.customized_insights.description")}
|
||||
className="h-[350px]"
|
||||
assetPath={resolvedPath}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default PriorityChart;
|
||||
19
web/core/components/analytics-v2/work-items/root.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import React from "react";
|
||||
import AnalyticsWrapper from "../analytics-wrapper";
|
||||
import TotalInsights from "../total-insights";
|
||||
import CreatedVsResolved from "./created-vs-resolved";
|
||||
import CustomizedInsights from "./customized-insights";
|
||||
import WorkItemsInsightTable from "./workitems-insight-table";
|
||||
|
||||
const WorkItems: React.FC = () => (
|
||||
<AnalyticsWrapper title="Work Items">
|
||||
<div className="flex flex-col gap-14">
|
||||
<TotalInsights analyticsType="work-items" />
|
||||
<CreatedVsResolved />
|
||||
<CustomizedInsights />
|
||||
<WorkItemsInsightTable />
|
||||
</div>
|
||||
</AnalyticsWrapper>
|
||||
);
|
||||
|
||||
export { WorkItems };
|
||||
47
web/core/components/analytics-v2/work-items/utils.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
// plane package imports
|
||||
import { ChartXAxisProperty, ChartYAxisMetric } from "@plane/constants";
|
||||
import { IState } from "@plane/types";
|
||||
|
||||
interface ParamsProps {
|
||||
x_axis: ChartXAxisProperty;
|
||||
y_axis: ChartYAxisMetric;
|
||||
group_by?: ChartXAxisProperty;
|
||||
}
|
||||
|
||||
export const generateBarColor = (
|
||||
value: string | null | undefined,
|
||||
params: ParamsProps,
|
||||
baseColors: string[],
|
||||
workspaceStates?: IState[]
|
||||
): string => {
|
||||
if (!value) return baseColors[0];
|
||||
let color = baseColors[0];
|
||||
// Priority
|
||||
if (params.x_axis === ChartXAxisProperty.PRIORITY) {
|
||||
color =
|
||||
value === "urgent"
|
||||
? "#ef4444"
|
||||
: value === "high"
|
||||
? "#f97316"
|
||||
: value === "medium"
|
||||
? "#eab308"
|
||||
: value === "low"
|
||||
? "#22c55e"
|
||||
: "#ced4da";
|
||||
}
|
||||
|
||||
// State
|
||||
if (params.x_axis === ChartXAxisProperty.STATES) {
|
||||
if (workspaceStates && workspaceStates.length > 0) {
|
||||
const state = workspaceStates.find((s) => s.id === value);
|
||||
if (state) {
|
||||
color = state.color;
|
||||
} else {
|
||||
const index = Math.abs(value.split("").reduce((acc, char) => acc + char.charCodeAt(0), 0)) % baseColors.length;
|
||||
color = baseColors[index];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return color;
|
||||
};
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
import { useMemo } from "react";
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import useSWR from "swr";
|
||||
import { Briefcase } from "lucide-react";
|
||||
// plane package imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { WorkItemInsightColumns, AnalyticsTableDataMap } from "@plane/types";
|
||||
// plane web components
|
||||
import { Logo } from "@/components/common/logo";
|
||||
// hooks
|
||||
import { useAnalyticsV2 } from "@/hooks/store/use-analytics-v2";
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { AnalyticsV2Service } from "@/services/analytics-v2.service";
|
||||
// plane web components
|
||||
import { InsightTable } from "../insight-table";
|
||||
|
||||
const analyticsV2Service = new AnalyticsV2Service();
|
||||
|
||||
const WorkItemsInsightTable = observer(() => {
|
||||
// router
|
||||
const params = useParams();
|
||||
const workspaceSlug = params.workspaceSlug as string;
|
||||
const { t } = useTranslation();
|
||||
// store hooks
|
||||
const { getProjectById } = useProject();
|
||||
const { selectedDuration, selectedProjects } = useAnalyticsV2();
|
||||
const { data: workItemsData, isLoading } = useSWR(
|
||||
`insights-table-work-items-${workspaceSlug}-${selectedDuration}-${selectedProjects}`,
|
||||
() =>
|
||||
analyticsV2Service.getAdvanceAnalyticsStats<WorkItemInsightColumns[]>(workspaceSlug, "work-items", {
|
||||
date_filter: selectedDuration,
|
||||
...(selectedProjects?.length > 0 ? { project_ids: selectedProjects.join(",") } : {}),
|
||||
})
|
||||
);
|
||||
// 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 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>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "backlog_work_items",
|
||||
header: () => <div className="text-right">{columnsLabels["backlog_work_items"]}</div>,
|
||||
cell: ({ row }) => <div className="text-right">{row.original.backlog_work_items}</div>,
|
||||
},
|
||||
{
|
||||
accessorKey: "started_work_items",
|
||||
header: () => <div className="text-right">{columnsLabels["started_work_items"]}</div>,
|
||||
cell: ({ row }) => <div className="text-right">{row.original.started_work_items}</div>,
|
||||
},
|
||||
{
|
||||
accessorKey: "un_started_work_items",
|
||||
header: () => <div className="text-right">{columnsLabels["un_started_work_items"]}</div>,
|
||||
cell: ({ row }) => <div className="text-right">{row.original.un_started_work_items}</div>,
|
||||
},
|
||||
{
|
||||
accessorKey: "completed_work_items",
|
||||
header: () => <div className="text-right">{columnsLabels["completed_work_items"]}</div>,
|
||||
cell: ({ row }) => <div className="text-right">{row.original.completed_work_items}</div>,
|
||||
},
|
||||
{
|
||||
accessorKey: "cancelled_work_items",
|
||||
header: () => <div className="text-right">{columnsLabels["cancelled_work_items"]}</div>,
|
||||
cell: ({ row }) => <div className="text-right">{row.original.cancelled_work_items}</div>,
|
||||
},
|
||||
] as ColumnDef<AnalyticsTableDataMap["work-items"]>[],
|
||||
[getProjectById]
|
||||
);
|
||||
|
||||
return (
|
||||
<InsightTable<"work-items">
|
||||
analyticsType="work-items"
|
||||
data={workItemsData}
|
||||
isLoading={isLoading}
|
||||
columns={columns}
|
||||
columnsLabels={columnsLabels}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export default WorkItemsInsightTable;
|
||||
166
web/core/components/chart/utils.ts
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
import { getWeekOfMonth, isValid } from "date-fns";
|
||||
import { CHART_X_AXIS_DATE_PROPERTIES, ChartXAxisDateGrouping, ChartXAxisProperty, TO_CAPITALIZE_PROPERTIES } from "@plane/constants";
|
||||
import { TChart, TChartDatum } from "@plane/types";
|
||||
import { capitalizeFirstLetter, hexToHsl, hslToHex, renderFormattedDate } from "@plane/utils";
|
||||
import { renderFormattedDateWithoutYear } from "@/helpers/date-time.helper";
|
||||
|
||||
const getDateGroupingName = (date: string, dateGrouping: ChartXAxisDateGrouping): string => {
|
||||
if (!date || ["none", "null"].includes(date.toLowerCase())) return "None";
|
||||
|
||||
const formattedData = new Date(date);
|
||||
const isValidDate = isValid(formattedData);
|
||||
|
||||
if (!isValidDate) return date;
|
||||
|
||||
const year = formattedData.getFullYear();
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
const isCurrentYear = year === currentYear;
|
||||
|
||||
let parsedName: string | undefined;
|
||||
|
||||
switch (dateGrouping) {
|
||||
case ChartXAxisDateGrouping.DAY:
|
||||
if (isCurrentYear) parsedName = renderFormattedDateWithoutYear(formattedData);
|
||||
else parsedName = renderFormattedDate(formattedData);
|
||||
break;
|
||||
case ChartXAxisDateGrouping.WEEK: {
|
||||
const month = renderFormattedDate(formattedData, "MMM");
|
||||
parsedName = `${month}, Week ${getWeekOfMonth(formattedData)}`;
|
||||
break;
|
||||
}
|
||||
case ChartXAxisDateGrouping.MONTH:
|
||||
if (isCurrentYear) parsedName = renderFormattedDate(formattedData, "MMM");
|
||||
else parsedName = renderFormattedDate(formattedData, "MMM, yyyy");
|
||||
break;
|
||||
case ChartXAxisDateGrouping.YEAR:
|
||||
parsedName = `${year}`;
|
||||
break;
|
||||
default:
|
||||
parsedName = date;
|
||||
}
|
||||
|
||||
return parsedName ?? date;
|
||||
};
|
||||
|
||||
export const parseChartData = (
|
||||
data: TChart | null | undefined,
|
||||
xAxisProperty: ChartXAxisProperty | null | undefined,
|
||||
groupByProperty: ChartXAxisProperty | null | undefined,
|
||||
xAxisDateGrouping: ChartXAxisDateGrouping | null | undefined
|
||||
): TChart => {
|
||||
if (!data) {
|
||||
return {
|
||||
data: [],
|
||||
schema: {},
|
||||
};
|
||||
}
|
||||
const widgetData = structuredClone(data.data);
|
||||
const schema = structuredClone(data.schema);
|
||||
const allKeys = Object.keys(schema);
|
||||
const updatedWidgetData: TChartDatum[] = widgetData.map((datum) => {
|
||||
const keys = Object.keys(datum);
|
||||
const missingKeys = allKeys.filter((key) => !keys.includes(key));
|
||||
const missingValues: Record<string, number> = Object.fromEntries(missingKeys.map(key => [key, 0]));
|
||||
|
||||
if (xAxisProperty) {
|
||||
// capitalize first letter if xAxisProperty is in TO_CAPITALIZE_PROPERTIES and no groupByProperty is set
|
||||
if (TO_CAPITALIZE_PROPERTIES.includes(xAxisProperty)) {
|
||||
datum.name = capitalizeFirstLetter(datum.name);
|
||||
}
|
||||
|
||||
// parse timestamp to visual date if xAxisProperty is in WIDGET_X_AXIS_DATE_PROPERTIES
|
||||
if (CHART_X_AXIS_DATE_PROPERTIES.includes(xAxisProperty)) {
|
||||
datum.name = getDateGroupingName(datum.name, xAxisDateGrouping ?? ChartXAxisDateGrouping.DAY);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...datum,
|
||||
...missingValues,
|
||||
};
|
||||
});
|
||||
|
||||
// capitalize first letter if groupByProperty is in TO_CAPITALIZE_PROPERTIES
|
||||
const updatedSchema = schema;
|
||||
if (groupByProperty) {
|
||||
if (TO_CAPITALIZE_PROPERTIES.includes(groupByProperty)) {
|
||||
Object.keys(updatedSchema).forEach((key) => {
|
||||
updatedSchema[key] = capitalizeFirstLetter(updatedSchema[key]);
|
||||
});
|
||||
}
|
||||
|
||||
if (CHART_X_AXIS_DATE_PROPERTIES.includes(groupByProperty)) {
|
||||
Object.keys(updatedSchema).forEach((key) => {
|
||||
updatedSchema[key] = getDateGroupingName(updatedSchema[key], xAxisDateGrouping ?? ChartXAxisDateGrouping.DAY);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
data: updatedWidgetData,
|
||||
schema: updatedSchema,
|
||||
};
|
||||
};
|
||||
|
||||
export const generateExtendedColors = (baseColorSet: string[], targetCount: number) => {
|
||||
const colors = [...baseColorSet];
|
||||
const baseCount = baseColorSet.length;
|
||||
|
||||
if (targetCount <= baseCount) {
|
||||
return colors.slice(0, targetCount);
|
||||
}
|
||||
|
||||
// Convert base colors to HSL
|
||||
const baseHSL = baseColorSet.map(hexToHsl);
|
||||
|
||||
// Calculate average saturation and lightness from base colors
|
||||
const avgSat = baseHSL.reduce((sum, hsl) => sum + hsl.s, 0) / baseHSL.length;
|
||||
const avgLight = baseHSL.reduce((sum, hsl) => sum + hsl.l, 0) / baseHSL.length;
|
||||
|
||||
// Sort base colors by hue for better distribution
|
||||
const sortedBaseHSL = [...baseHSL].sort((a, b) => a.h - b.h);
|
||||
|
||||
// Generate additional colors for each base color
|
||||
const colorsNeeded = targetCount - baseCount;
|
||||
const colorsPerBase = Math.ceil(colorsNeeded / baseCount);
|
||||
|
||||
for (let i = 0; i < baseCount; i++) {
|
||||
const baseColor = sortedBaseHSL[i];
|
||||
const nextBaseColor = sortedBaseHSL[(i + 1) % baseCount];
|
||||
|
||||
// Calculate hue distance to next base color
|
||||
const hueDistance = (nextBaseColor.h - baseColor.h + 360) % 360;
|
||||
const hueParts = colorsPerBase + 1;
|
||||
|
||||
// Narrower ranges for more consistency
|
||||
const satRange = [Math.max(40, avgSat - 5), Math.min(60, avgSat + 5)];
|
||||
const lightRange = [Math.max(40, avgLight - 5), Math.min(60, avgLight + 5)];
|
||||
|
||||
for (let j = 1; j <= colorsPerBase; j++) {
|
||||
if (colors.length >= targetCount) break;
|
||||
|
||||
// Create evenly spaced hue variations between base colors
|
||||
const hueStep = (hueDistance / hueParts) * j;
|
||||
const newHue = (baseColor.h + hueStep) % 360;
|
||||
|
||||
// Keep saturation and lightness closer to base color
|
||||
const newSat = baseColor.s * 0.8 + avgSat * 0.2;
|
||||
const newLight = baseColor.l * 0.8 + avgLight * 0.2;
|
||||
|
||||
// Ensure values stay within desired ranges
|
||||
const finalSat = Math.max(satRange[0], Math.min(satRange[1], newSat));
|
||||
const finalLight = Math.max(lightRange[0], Math.min(lightRange[1], newLight));
|
||||
|
||||
colors.push(
|
||||
hslToHex({
|
||||
h: newHue,
|
||||
s: finalSat,
|
||||
l: finalLight,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return colors.slice(0, targetCount);
|
||||
};
|
||||
|
|
@ -85,9 +85,7 @@ export const DetailedEmptyState: React.FC<Props> = observer((props) => {
|
|||
{description && <p className="text-sm">{description}</p>}
|
||||
</div>
|
||||
|
||||
{assetPath && (
|
||||
<Image src={assetPath} alt={title} width={384} height={250} layout="responsive" lazyBoundary="100%" />
|
||||
)}
|
||||
{assetPath && <Image src={assetPath} alt={title} width={384} height={250} lazyBoundary="100%" />}
|
||||
|
||||
{hasButtons && (
|
||||
<div className="relative flex items-center justify-center gap-2 flex-shrink-0 w-full">
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import { useLabel, useProjectState, useMember, useIssues } from "@/hooks/store";
|
|||
// plane web types
|
||||
import { TProject } from "@/plane-web/types";
|
||||
import { ProjectAnalyticsModal } from "../analytics";
|
||||
import { WorkItemsModal } from "../analytics-v2/work-items/modal";
|
||||
|
||||
type Props = {
|
||||
currentProjectDetails: TProject | undefined;
|
||||
|
|
@ -97,7 +98,7 @@ const HeaderFilters = observer((props: Props) => {
|
|||
|
||||
return (
|
||||
<>
|
||||
<ProjectAnalyticsModal
|
||||
<WorkItemsModal
|
||||
isOpen={analyticsModal}
|
||||
onClose={() => setAnalyticsModal(false)}
|
||||
projectDetails={currentProjectDetails ?? undefined}
|
||||
|
|
|
|||
|
|
@ -31,3 +31,4 @@ export * from "./use-workspace";
|
|||
export * from "./user";
|
||||
export * from "./use-transient";
|
||||
export * from "./workspace-draft";
|
||||
export * from "./use-analytics-v2";
|
||||
|
|
|
|||
11
web/core/hooks/store/use-analytics-v2.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { useContext } from "react";
|
||||
// mobx store
|
||||
import { StoreContext } from "@/lib/store-context";
|
||||
// types
|
||||
import { IAnalyticsStoreV2 } from "@/store/analytics-v2.store";
|
||||
|
||||
export const useAnalyticsV2 = (): IAnalyticsStoreV2 => {
|
||||
const context = useContext(StoreContext);
|
||||
if (context === undefined) throw new Error("useAnalyticsV2 must be used within StoreProvider");
|
||||
return context.analyticsV2;
|
||||
};
|
||||
60
web/core/services/analytics-v2.service.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { API_BASE_URL } from "@plane/constants";
|
||||
import { IAnalyticsResponseV2, TAnalyticsTabsV2Base, TAnalyticsGraphsV2Base } from "@plane/types";
|
||||
import { APIService } from "./api.service";
|
||||
|
||||
export class AnalyticsV2Service extends APIService {
|
||||
constructor() {
|
||||
super(API_BASE_URL);
|
||||
}
|
||||
|
||||
async getAdvanceAnalytics<T extends IAnalyticsResponseV2>(
|
||||
workspaceSlug: string,
|
||||
tab: TAnalyticsTabsV2Base,
|
||||
params?: Record<string, any>
|
||||
): Promise<T> {
|
||||
return this.get(`/api/workspaces/${workspaceSlug}/advance-analytics/`, {
|
||||
params: {
|
||||
tab,
|
||||
...params,
|
||||
},
|
||||
})
|
||||
.then((res) => res?.data)
|
||||
.catch((err) => {
|
||||
throw err?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
async getAdvanceAnalyticsStats<T>(
|
||||
workspaceSlug: string,
|
||||
tab: Exclude<TAnalyticsTabsV2Base, "overview">,
|
||||
params?: Record<string, any>
|
||||
): Promise<T> {
|
||||
return this.get(`/api/workspaces/${workspaceSlug}/advance-analytics-stats/`, {
|
||||
params: {
|
||||
type: tab,
|
||||
...params,
|
||||
},
|
||||
})
|
||||
.then((res) => res?.data)
|
||||
.catch((err) => {
|
||||
throw err?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
async getAdvanceAnalyticsCharts<T>(
|
||||
workspaceSlug: string,
|
||||
tab: TAnalyticsGraphsV2Base,
|
||||
params?: Record<string, any>
|
||||
): Promise<T> {
|
||||
return this.get(`/api/workspaces/${workspaceSlug}/advance-analytics-charts/`, {
|
||||
params: {
|
||||
type: tab,
|
||||
...params,
|
||||
},
|
||||
})
|
||||
.then((res) => res?.data)
|
||||
.catch((err) => {
|
||||
throw err?.response?.data;
|
||||
});
|
||||
}
|
||||
}
|
||||
68
web/core/store/analytics-v2.store.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import { action, computed, makeObservable, observable, runInAction } from "mobx";
|
||||
import { ANALYTICS_V2_DURATION_FILTER_OPTIONS } from "@plane/constants";
|
||||
import { TAnalyticsTabsV2Base } from "@plane/types";
|
||||
import { CoreRootStore } from "./root.store";
|
||||
|
||||
type DurationType = (typeof ANALYTICS_V2_DURATION_FILTER_OPTIONS)[number]["value"];
|
||||
|
||||
export interface IAnalyticsStoreV2 {
|
||||
//observables
|
||||
currentTab: TAnalyticsTabsV2Base;
|
||||
selectedProjects: string[];
|
||||
selectedDuration: DurationType;
|
||||
|
||||
//computed
|
||||
selectedDurationLabel: DurationType | null;
|
||||
|
||||
//actions
|
||||
updateSelectedProjects: (projects: string[]) => void;
|
||||
updateSelectedDuration: (duration: DurationType) => void;
|
||||
}
|
||||
|
||||
export class AnalyticsStoreV2 implements IAnalyticsStoreV2 {
|
||||
//observables
|
||||
currentTab: TAnalyticsTabsV2Base = "overview";
|
||||
selectedProjects: DurationType[] = [];
|
||||
selectedDuration: DurationType = "last_30_days";
|
||||
|
||||
constructor(_rootStore: CoreRootStore) {
|
||||
makeObservable(this, {
|
||||
// observables
|
||||
currentTab: observable.ref,
|
||||
selectedDuration: observable.ref,
|
||||
selectedProjects: observable.ref,
|
||||
// computed
|
||||
selectedDurationLabel: computed,
|
||||
// actions
|
||||
updateSelectedProjects: action,
|
||||
updateSelectedDuration: action,
|
||||
});
|
||||
}
|
||||
|
||||
get selectedDurationLabel() {
|
||||
return ANALYTICS_V2_DURATION_FILTER_OPTIONS.find((item) => item.value === this.selectedDuration)?.name ?? null;
|
||||
}
|
||||
|
||||
updateSelectedProjects = (projects: string[]) => {
|
||||
const initialState = this.selectedProjects;
|
||||
try {
|
||||
runInAction(() => {
|
||||
this.selectedProjects = projects;
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to update selected project");
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
updateSelectedDuration = (duration: DurationType) => {
|
||||
try {
|
||||
runInAction(() => {
|
||||
this.selectedDuration = duration;
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to update selected duration");
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ import { CommandPaletteStore, ICommandPaletteStore } from "@/plane-web/store/com
|
|||
import { RootStore } from "@/plane-web/store/root.store";
|
||||
import { IStateStore, StateStore } from "@/plane-web/store/state.store";
|
||||
// stores
|
||||
import { IAnalyticsStoreV2, AnalyticsStoreV2 } from "./analytics-v2.store";
|
||||
import { CycleStore, ICycleStore } from "./cycle.store";
|
||||
import { CycleFilterStore, ICycleFilterStore } from "./cycle_filter.store";
|
||||
import { DashboardStore, IDashboardStore } from "./dashboard.store";
|
||||
|
|
@ -49,6 +50,7 @@ export class CoreRootStore {
|
|||
state: IStateStore;
|
||||
label: ILabelStore;
|
||||
dashboard: IDashboardStore;
|
||||
analyticsV2: IAnalyticsStoreV2;
|
||||
projectPages: IProjectPageStore;
|
||||
router: IRouterStore;
|
||||
commandPalette: ICommandPaletteStore;
|
||||
|
|
@ -94,6 +96,7 @@ export class CoreRootStore {
|
|||
this.transient = new TransientStore();
|
||||
this.stickyStore = new StickyStore();
|
||||
this.editorAssetStore = new EditorAssetStore();
|
||||
this.analyticsV2 = new AnalyticsStoreV2(this);
|
||||
}
|
||||
|
||||
resetOnSignOut() {
|
||||
|
|
|
|||
|
|
@ -39,12 +39,14 @@
|
|||
"@plane/utils": "*",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"@react-pdf/renderer": "^3.4.5",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"axios": "^1.8.3",
|
||||
"clsx": "^2.0.0",
|
||||
"cmdk": "^1.0.0",
|
||||
"comlink": "^4.4.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"dotenv": "^16.0.3",
|
||||
"export-to-csv": "^1.4.0",
|
||||
"isomorphic-dompurify": "^2.12.0",
|
||||
"lodash": "^4.17.21",
|
||||
"lucide-react": "^0.469.0",
|
||||
|
|
@ -91,4 +93,4 @@
|
|||
"prettier": "^3.2.5",
|
||||
"typescript": "5.3.3"
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
web/public/empty-state/analytics-v2/empty-chart-area-dark.webp
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
web/public/empty-state/analytics-v2/empty-chart-area-light.webp
Normal file
|
After Width: | Height: | Size: 694 B |
BIN
web/public/empty-state/analytics-v2/empty-chart-bar-dark.webp
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
web/public/empty-state/analytics-v2/empty-chart-bar-light.webp
Normal file
|
After Width: | Height: | Size: 512 B |
BIN
web/public/empty-state/analytics-v2/empty-chart-radar-dark.webp
Normal file
|
After Width: | Height: | Size: 3 KiB |
BIN
web/public/empty-state/analytics-v2/empty-chart-radar-light.webp
Normal file
|
After Width: | Height: | Size: 716 B |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 2.5 KiB |