diff --git a/apiserver/plane/app/views/analytic/advance.py b/apiserver/plane/app/views/analytic/advance.py index 8b5832772a5..9b258eca00a 100644 --- a/apiserver/plane/app/views/analytic/advance.py +++ b/apiserver/plane/app/views/analytic/advance.py @@ -17,6 +17,17 @@ 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 @@ def initialize_workspace(self, slug: str, type: str) -> None: 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 @@ def get_overview_data(self) -> Dict[str, Dict[str, int]]: ), } + def get_work_items_stats(self) -> Dict[str, Dict[str, int]]: base_queryset = Issue.objects.filter(**self.filters["base_filters"]) @@ -193,6 +206,7 @@ def project_chart(self) -> List[Dict[str, Any]]: # 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"] diff --git a/packages/constants/src/analytics-v2/common.ts b/packages/constants/src/analytics-v2/common.ts new file mode 100644 index 00000000000..6eab3ab2966 --- /dev/null +++ b/packages/constants/src/analytics-v2/common.ts @@ -0,0 +1,105 @@ +import { TAnalyticsTabsV2Base } from "@plane/types"; +import { ChartXAxisProperty, ChartYAxisMetric } from "../chart"; + +export const insightsFields: Record = { + 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"]; diff --git a/packages/constants/src/analytics-v2/index.ts b/packages/constants/src/analytics-v2/index.ts new file mode 100644 index 00000000000..3e2372da34e --- /dev/null +++ b/packages/constants/src/analytics-v2/index.ts @@ -0,0 +1 @@ +export * from "./common" \ No newline at end of file diff --git a/packages/constants/src/chart.ts b/packages/constants/src/chart.ts index bddd0fd3835..be736d80749 100644 --- a/packages/constants/src/chart.ts +++ b/packages/constants/src/chart.ts @@ -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", + ], + }, + ]; diff --git a/packages/constants/src/index.ts b/packages/constants/src/index.ts index 057627fcd61..49e10c3d194 100644 --- a/packages/constants/src/index.ts +++ b/packages/constants/src/index.ts @@ -33,3 +33,4 @@ export * from "./page"; export * from "./emoji"; export * from "./subscription"; export * from "./icon"; +export * from "./analytics-v2"; diff --git a/packages/constants/src/state.ts b/packages/constants/src/state.ts index fa0f5d27700..3b6de4c8f5e 100644 --- a/packages/constants/src/state.ts +++ b/packages/constants/src/state.ts @@ -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; diff --git a/packages/i18n/src/locales/cs/translations.json b/packages/i18n/src/locales/cs/translations.json index b4a7f248011..4aa64f40df7 100644 --- a/packages/i18n/src/locales/cs/translations.json +++ b/packages/i18n/src/locales/cs/translations.json @@ -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ů}}", diff --git a/packages/i18n/src/locales/de/translations.json b/packages/i18n/src/locales/de/translations.json index 6032432d1f8..1fa8eaa0e5c 100644 --- a/packages/i18n/src/locales/de/translations.json +++ b/packages/i18n/src/locales/de/translations.json @@ -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}}", diff --git a/packages/i18n/src/locales/en/translations.json b/packages/i18n/src/locales/en/translations.json index 4e233ace32f..ef16944ef2f 100644 --- a/packages/i18n/src/locales/en/translations.json +++ b/packages/i18n/src/locales/en/translations.json @@ -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": { diff --git a/packages/i18n/src/locales/es/translations.json b/packages/i18n/src/locales/es/translations.json index bd0402cab74..966e3178dd6 100644 --- a/packages/i18n/src/locales/es/translations.json +++ b/packages/i18n/src/locales/es/translations.json @@ -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}}", diff --git a/packages/i18n/src/locales/fr/translations.json b/packages/i18n/src/locales/fr/translations.json index 9d3e25b4d54..5188b333495 100644 --- a/packages/i18n/src/locales/fr/translations.json +++ b/packages/i18n/src/locales/fr/translations.json @@ -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}}", diff --git a/packages/i18n/src/locales/id/translations.json b/packages/i18n/src/locales/id/translations.json index b48df1890e7..3a6c92873a0 100644 --- a/packages/i18n/src/locales/id/translations.json +++ b/packages/i18n/src/locales/id/translations.json @@ -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}}", diff --git a/packages/i18n/src/locales/it/translations.json b/packages/i18n/src/locales/it/translations.json index b222ed68b54..ff58fee3135 100644 --- a/packages/i18n/src/locales/it/translations.json +++ b/packages/i18n/src/locales/it/translations.json @@ -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}}", diff --git a/packages/i18n/src/locales/ja/translations.json b/packages/i18n/src/locales/ja/translations.json index fdb1f2fd0cb..9656f04391d 100644 --- a/packages/i18n/src/locales/ja/translations.json +++ b/packages/i18n/src/locales/ja/translations.json @@ -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 {プロジェクト}}", diff --git a/packages/i18n/src/locales/ko/translations.json b/packages/i18n/src/locales/ko/translations.json index 4d53d1e8c2e..eb9b97bf4c2 100644 --- a/packages/i18n/src/locales/ko/translations.json +++ b/packages/i18n/src/locales/ko/translations.json @@ -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 {프로젝트}}", diff --git a/packages/i18n/src/locales/pl/translations.json b/packages/i18n/src/locales/pl/translations.json index 49caa7350dc..28290e3d012 100644 --- a/packages/i18n/src/locales/pl/translations.json +++ b/packages/i18n/src/locales/pl/translations.json @@ -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}}", diff --git a/packages/i18n/src/locales/pt-BR/translations.json b/packages/i18n/src/locales/pt-BR/translations.json index 9c15dcb6d11..6b31fcbf4a0 100644 --- a/packages/i18n/src/locales/pt-BR/translations.json +++ b/packages/i18n/src/locales/pt-BR/translations.json @@ -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}}", diff --git a/packages/i18n/src/locales/ro/translations.json b/packages/i18n/src/locales/ro/translations.json index f3d2053a382..704ee840f58 100644 --- a/packages/i18n/src/locales/ro/translations.json +++ b/packages/i18n/src/locales/ro/translations.json @@ -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}}", diff --git a/packages/i18n/src/locales/ru/translations.json b/packages/i18n/src/locales/ru/translations.json index 751f91613c9..f1a9659e356 100644 --- a/packages/i18n/src/locales/ru/translations.json +++ b/packages/i18n/src/locales/ru/translations.json @@ -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 {Проекты}}", diff --git a/packages/i18n/src/locales/sk/translations.json b/packages/i18n/src/locales/sk/translations.json index e02cad8cc65..0aa8f4f84bd 100644 --- a/packages/i18n/src/locales/sk/translations.json +++ b/packages/i18n/src/locales/sk/translations.json @@ -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}}", diff --git a/packages/i18n/src/locales/tr-TR/translations.json b/packages/i18n/src/locales/tr-TR/translations.json index b3df9a08166..7d4cde25dd4 100644 --- a/packages/i18n/src/locales/tr-TR/translations.json +++ b/packages/i18n/src/locales/tr-TR/translations.json @@ -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}}", diff --git a/packages/i18n/src/locales/ua/translations.json b/packages/i18n/src/locales/ua/translations.json index 7aa3848908c..841dbf8031f 100644 --- a/packages/i18n/src/locales/ua/translations.json +++ b/packages/i18n/src/locales/ua/translations.json @@ -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 {Проєктів}}", diff --git a/packages/i18n/src/locales/vi-VN/translations.json b/packages/i18n/src/locales/vi-VN/translations.json index f9f334efb37..de2c722ebdd 100644 --- a/packages/i18n/src/locales/vi-VN/translations.json +++ b/packages/i18n/src/locales/vi-VN/translations.json @@ -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}}", diff --git a/packages/i18n/src/locales/zh-CN/translations.json b/packages/i18n/src/locales/zh-CN/translations.json index 019f1379521..d3e3e599863 100644 --- a/packages/i18n/src/locales/zh-CN/translations.json +++ b/packages/i18n/src/locales/zh-CN/translations.json @@ -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 {项目}}", diff --git a/packages/i18n/src/locales/zh-TW/translations.json b/packages/i18n/src/locales/zh-TW/translations.json index 4246e5aed53..ed49e1fe3d3 100644 --- a/packages/i18n/src/locales/zh-TW/translations.json +++ b/packages/i18n/src/locales/zh-TW/translations.json @@ -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 {專案}}", diff --git a/packages/propel/package.json b/packages/propel/package.json index 3522c2f64a2..7c1e9668452 100644 --- a/packages/propel/package.json +++ b/packages/propel/package.json @@ -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" } -} +} \ No newline at end of file diff --git a/packages/propel/src/charts/area-chart/root.tsx b/packages/propel/src/charts/area-chart/root.tsx index 7d4e9e6ba97..b90de27cfea 100644 --- a/packages/propel/src/charts/area-chart/root.tsx +++ b/packages/propel/src/charts/area-chart/root.tsx @@ -29,13 +29,21 @@ export const AreaChart = React.memo((props: // states const [activeArea, setActiveArea] = useState(null); const [activeLegend, setActiveLegend] = useState(null); + // derived values - const itemKeys = useMemo(() => areas.map((area) => area.key), [areas]); - const itemLabels: Record = 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 = {}; + const colors: Record = {}; + + 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((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((props: }; }); }, [data, xAxis.key]); - return (
@@ -128,8 +135,8 @@ export const AreaChart = React.memo((props: value: yAxis.label, angle: -90, position: "bottom", - offset: -24, - dx: -16, + offset: yAxis.offset ?? -24, + dx: yAxis.dx ?? -16, className: AXIS_LABEL_CLASSNAME, } } diff --git a/packages/propel/src/charts/bar-chart/root.tsx b/packages/propel/src/charts/bar-chart/root.tsx index abe936d5c71..e3dbe1d8c26 100644 --- a/packages/propel/src/charts/bar-chart/root.tsx +++ b/packages/propel/src/charts/bar-chart/root.tsx @@ -40,13 +40,22 @@ export const BarChart = React.memo((props: T // states const [activeBar, setActiveBar] = useState(null); const [activeLegend, setActiveLegend] = useState(null); + // derived values - const stackKeys = useMemo(() => bars.map((bar) => bar.key), [bars]); - const stackLabels: Record = 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 = {}; + const colors: Record = {}; + + 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((props: T axisLine={false} label={{ value: xAxis.label, - dy: 28, + dy: xAxis.dy ?? 28, className: AXIS_LABEL_CLASSNAME, }} tickCount={tickCount.x} diff --git a/packages/propel/src/charts/components/legend.tsx b/packages/propel/src/charts/components/legend.tsx index 2be69c5cb55..3c455812083 100644 --- a/packages/propel/src/charts/components/legend.tsx +++ b/packages/propel/src/charts/components/legend.tsx @@ -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: , }; @@ -33,8 +34,8 @@ export const getLegendProps = (args: TChartLegend): LegendProps => { const CustomLegend = React.forwardRef< HTMLDivElement, React.ComponentProps<"div"> & - Pick & - TChartLegend + Pick & + TChartLegend >((props, ref) => { const { formatter, layout, onClick, onMouseEnter, onMouseLeave, payload } = props; diff --git a/packages/propel/src/charts/components/tick.tsx b/packages/propel/src/charts/components/tick.tsx index e26e25ef3d0..4b64e83736c 100644 --- a/packages/propel/src/charts/components/tick.tsx +++ b/packages/propel/src/charts/components/tick.tsx @@ -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(({ x, y, payload }: any) => ( +export const CustomXAxisTick = React.memo(({ x, y, payload, getLabel }: any) => ( - {payload.value} + {getLabel ? getLabel(payload.value) : payload.value} )); @@ -20,4 +20,28 @@ export const CustomYAxisTick = React.memo(({ x, y, payload }: any) => ( )); + CustomYAxisTick.displayName = "CustomYAxisTick"; + +export const CustomRadarAxisTick = React.memo( + ({ 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 ( + + + {getLabel ? getLabel(payload.value) : payload.value} + + + ); + } +); +CustomRadarAxisTick.displayName = "CustomRadarAxisTick"; diff --git a/packages/propel/src/charts/line-chart/root.tsx b/packages/propel/src/charts/line-chart/root.tsx index 6812797b792..f3b2ef72c47 100644 --- a/packages/propel/src/charts/line-chart/root.tsx +++ b/packages/propel/src/charts/line-chart/root.tsx @@ -38,13 +38,21 @@ export const LineChart = React.memo((props: // states const [activeLine, setActiveLine] = useState(null); const [activeLegend, setActiveLegend] = useState(null); + // derived values - const itemKeys = useMemo(() => lines.map((line) => line.key), [lines]); - const itemLabels: Record = 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 = {}; + const colors: Record = {}; + + 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( () => diff --git a/packages/propel/src/charts/radar-chart/index.ts b/packages/propel/src/charts/radar-chart/index.ts new file mode 100644 index 00000000000..50a9c47c01f --- /dev/null +++ b/packages/propel/src/charts/radar-chart/index.ts @@ -0,0 +1 @@ +export * from "./root"; \ No newline at end of file diff --git a/packages/propel/src/charts/radar-chart/root.tsx b/packages/propel/src/charts/radar-chart/root.tsx new file mode 100644 index 00000000000..b8a1b95d716 --- /dev/null +++ b/packages/propel/src/charts/radar-chart/root.tsx @@ -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 = (props: TRadarChartProps) => { + const { data, radars, margin, showTooltip, legend, className, angleAxis } = props; + + // states + const [, setActiveIndex] = useState(null); + const [activeLegend, setActiveLegend] = useState(null); + + const { itemKeys, itemLabels, itemDotColors } = useMemo(() => { + const keys: string[] = []; + const labels: Record = {}; + const colors: Record = {}; + + 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 ( +
+ + + + } /> + {showTooltip && ( + ( + + )} + /> + )} + {legend && ( + // @ts-expect-error recharts types are not up to date + { + // @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) => ( + + ))} + + +
+ ); +}; + +export { RadarChart }; diff --git a/packages/propel/src/charts/scatter-chart/index.ts b/packages/propel/src/charts/scatter-chart/index.ts new file mode 100644 index 00000000000..50a9c47c01f --- /dev/null +++ b/packages/propel/src/charts/scatter-chart/index.ts @@ -0,0 +1 @@ +export * from "./root"; \ No newline at end of file diff --git a/packages/propel/src/charts/scatter-chart/root.tsx b/packages/propel/src/charts/scatter-chart/root.tsx new file mode 100644 index 00000000000..d7996c990d0 --- /dev/null +++ b/packages/propel/src/charts/scatter-chart/root.tsx @@ -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((props: TScatterChartProps) => { + const { + data, + scatterPoints, + margin, + xAxis, + yAxis, + + className, + tickCount = { + x: undefined, + y: 10, + }, + legend, + showTooltip = true, + } = props; + // states + const [activePoint, setActivePoint] = useState(null); + const [activeLegend, setActiveLegend] = useState(null); + + //derived values + const { itemKeys, itemLabels, itemDotColors } = useMemo(() => { + const keys: string[] = []; + const labels: Record = {}; + const colors: Record = {}; + + 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) => ( + setActivePoint(point.key)} + onMouseLeave={() => setActivePoint(null)} + /> + )), + [activeLegend, scatterPoints] + ); + + return ( +
+ + + + } + tickLine={false} + axisLine={false} + label={ + xAxis.label && { + value: xAxis.label, + dy: 28, + className: AXIS_LABEL_CLASSNAME, + } + } + tickCount={tickCount.x} + /> + } + tickCount={tickCount.y} + allowDecimals={!!yAxis.allowDecimals} + /> + {legend && ( + // @ts-expect-error recharts types are not up to date + setActiveLegend(payload.value)} + onMouseLeave={() => setActiveLegend(null)} + formatter={(value) => itemLabels[value]} + {...getLegendProps(legend)} + /> + )} + {showTooltip && ( + ( + + )} + /> + )} + {renderPoints} + + +
+ ); +}); +ScatterChart.displayName = "ScatterChart"; diff --git a/packages/propel/src/table/core.tsx b/packages/propel/src/table/core.tsx new file mode 100644 index 00000000000..e6e7ad59c43 --- /dev/null +++ b/packages/propel/src/table/core.tsx @@ -0,0 +1,120 @@ +import * as React from "react" + +import { cn } from "@plane/utils" + +const Table = React.forwardRef< + HTMLTableElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+ + +)) +Table.displayName = "Table" + +const TableHeader = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableHeader.displayName = "TableHeader" + +const TableBody = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableBody.displayName = "TableBody" + +const TableFooter = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableFooter.displayName = "TableFooter" + +const TableRow = React.forwardRef< + HTMLTableRowElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableRow.displayName = "TableRow" + +const TableHead = React.forwardRef< + HTMLTableHeaderCellElement, + React.ThHTMLAttributes +>(({ className, ...props }, ref) => ( +
[role=checkbox]]:translate-y-[2px]", + className + )} + {...props} + /> +)) +TableHead.displayName = "TableHead" + +const TableCell = React.forwardRef< + HTMLTableDataCellElement, + React.TdHTMLAttributes +>(({ className, ...props }, ref) => ( + [role=checkbox]]:translate-y-[2px]", + className + )} + {...props} + /> +)) +TableCell.displayName = "TableCell" + +const TableCaption = React.forwardRef< + HTMLTableDataCellElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +TableCaption.displayName = "TableCaption" + +export { + Table, + TableHeader, + TableBody, + TableFooter, + TableHead, + TableRow, + TableCell, + TableCaption, +} \ No newline at end of file diff --git a/packages/propel/src/table/index.ts b/packages/propel/src/table/index.ts new file mode 100644 index 00000000000..8b83d73fe97 --- /dev/null +++ b/packages/propel/src/table/index.ts @@ -0,0 +1 @@ +export * from "./core"; \ No newline at end of file diff --git a/packages/types/src/analytics-v2.d.ts b/packages/types/src/analytics-v2.d.ts new file mode 100644 index 00000000000..176cd1191cd --- /dev/null +++ b/packages/types/src/analytics-v2.d.ts @@ -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; + data: TChartData[]; +} + +// 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; +} \ No newline at end of file diff --git a/packages/types/src/charts/common.d.ts b/packages/types/src/charts/common.d.ts new file mode 100644 index 00000000000..85034c2fe71 --- /dev/null +++ b/packages/types/src/charts/common.d.ts @@ -0,0 +1,16 @@ + + +export type TChartColorScheme = "modern" | "horizon" | "earthen"; + +export type TChartDatum = { + key: string; + name: string; + count: number; +} & Record; + +export type TChart = { + data: TChartDatum[]; + schema: Record; +}; + + diff --git a/packages/types/src/charts.d.ts b/packages/types/src/charts/index.d.ts similarity index 63% rename from packages/types/src/charts.d.ts rename to packages/types/src/charts/index.d.ts index b1fc2997db3..2747973aa78 100644 --- a/packages/types/src/charts.d.ts +++ b/packages/types/src/charts/index.d.ts @@ -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 = { key: keyof TChartData; label?: string; strokeColor?: string; + dy?: number; }; yAxis: { allowDecimals?: boolean; @@ -29,6 +37,8 @@ type TChartProps = { key: keyof TChartData; label?: string; strokeColor?: string; + offset?: number; + dx?: number; }; className?: string; legend?: TChartLegend; @@ -40,6 +50,10 @@ type TChartProps = { showTooltip?: boolean; }; +// ============================================================ +// Bar Chart +// ============================================================ + export type TBarItem = { key: T; label: string; @@ -56,6 +70,10 @@ export type TBarChartProps = TChartProps = { key: T; label: string; @@ -71,6 +89,25 @@ export type TLineChartProps = TChartProps[]; }; +// ============================================================ +// Scatter Chart +// ============================================================ + +export type TScatterPointItem = { + key: T; + label: string; + fill: string; + stroke: string; +}; + +export type TScatterChartProps = TChartProps & { + scatterPoints: TScatterPointItem[]; +}; + +// ============================================================ +// Area Chart +// ============================================================ + export type TAreaItem = { key: T; label: string; @@ -92,6 +129,10 @@ export type TAreaChartProps = TChartProps = { key: T; fill: string; @@ -119,6 +160,10 @@ export type TPieChartProps = 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 = { + key: T; + name: string; + fill?: string; + stroke?: string; + fillOpacity?: number; + dot?: { + r: number; + fillOpacity: number; + } +} + +export type TRadarChartProps = Pick< + TChartProps, + "className" | "showTooltip" | "margin" | "data" | "legend" +> & { + dataKey: T; + radars: TRadarItem[]; + angleAxis: { + key: keyof TChartData; + label?: string; + strokeColor?: string; + }; +} diff --git a/packages/types/src/enums.ts b/packages/types/src/enums.ts index 53138a1d798..a49bec7ab22 100644 --- a/packages/types/src/enums.ts +++ b/packages/types/src/enums.ts @@ -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", +} \ No newline at end of file diff --git a/packages/types/src/index.d.ts b/packages/types/src/index.d.ts index b6af3b562b7..0ac656fc69f 100644 --- a/packages/types/src/index.d.ts +++ b/packages/types/src/index.d.ts @@ -43,3 +43,4 @@ export * from "./home"; export * from "./stickies"; export * from "./utils"; export * from "./payment"; +export * from "./analytics-v2"; \ No newline at end of file diff --git a/packages/ui/src/icons/at-risk-icon.tsx b/packages/ui/src/icons/at-risk-icon.tsx index bb4437e6d9b..65e5ae63d60 100644 --- a/packages/ui/src/icons/at-risk-icon.tsx +++ b/packages/ui/src/icons/at-risk-icon.tsx @@ -3,27 +3,18 @@ import * as React from "react"; import { ISvgIcons } from "./type"; export const AtRiskIcon: React.FC = ({ width = "16", height = "16" }) => ( - - - - + + - - + + diff --git a/packages/ui/src/icons/off-track-icon.tsx b/packages/ui/src/icons/off-track-icon.tsx index 0d93d1b6057..cbb8ba1f868 100644 --- a/packages/ui/src/icons/off-track-icon.tsx +++ b/packages/ui/src/icons/off-track-icon.tsx @@ -3,27 +3,18 @@ import * as React from "react"; import { ISvgIcons } from "./type"; export const OffTrackIcon: React.FC = ({ width = "16", height = "16" }) => ( - - - - + + - - + + diff --git a/packages/ui/src/icons/on-track-icon.tsx b/packages/ui/src/icons/on-track-icon.tsx index c384d4c8d76..5dcabcec956 100644 --- a/packages/ui/src/icons/on-track-icon.tsx +++ b/packages/ui/src/icons/on-track-icon.tsx @@ -3,45 +3,39 @@ import * as React from "react"; import { ISvgIcons } from "./type"; export const OnTrackIcon: React.FC = ({ width = "16", height = "16" }) => ( - - - - + + - - + + diff --git a/web/app/[workspaceSlug]/(projects)/analytics/layout.tsx b/web/app/[workspaceSlug]/(projects)/analytics/layout.tsx index 8dfc8b3b0f3..6f087aa5683 100644 --- a/web/app/[workspaceSlug]/(projects)/analytics/layout.tsx +++ b/web/app/[workspaceSlug]/(projects)/analytics/layout.tsx @@ -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 }) { diff --git a/web/app/[workspaceSlug]/(projects)/analytics/page.tsx b/web/app/[workspaceSlug]/(projects)/analytics/page.tsx index 8875e1465f3..b4972363355 100644 --- a/web/app/[workspaceSlug]/(projects)/analytics/page.tsx +++ b/web/app/[workspaceSlug]/(projects)/analytics/page.tsx @@ -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: , + onClick: () => { + router.push(`?tab=${tab.key}`); + }, + })), + [router, t] + ); + const defaultTab = searchParams.get("tab") || ANALYTICS_TABS[0].key; + return ( <> {workspaceProjectIds && ( <> {workspaceProjectIds.length > 0 || loader === "init-loader" ? ( -
- -
- - {ANALYTICS_TABS.map((tab) => ( - - {({ selected }) => ( - - )} - - ))} - -
- - - - - - - - -
+
+ } + />
) : ( { return ( <> - setAnalyticsModal(false)} projectDetails={currentProjectDetails ?? undefined} diff --git a/web/app/page.tsx b/web/app/page.tsx index 8d52af80c61..aac36f0a1ea 100644 --- a/web/app/page.tsx +++ b/web/app/page.tsx @@ -1,5 +1,4 @@ "use client"; - import React from "react"; import { observer } from "mobx-react"; import Image from "next/image"; diff --git a/web/ce/components/analytics-v2/tabs.ts b/web/ce/components/analytics-v2/tabs.ts new file mode 100644 index 00000000000..8390601eba3 --- /dev/null +++ b/web/ce/components/analytics-v2/tabs.ts @@ -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 }, + ]; diff --git a/web/core/components/analytics-v2/analytics-filter-actions.tsx b/web/core/components/analytics-v2/analytics-filter-actions.tsx new file mode 100644 index 00000000000..b9b69bed912 --- /dev/null +++ b/web/core/components/analytics-v2/analytics-filter-actions.tsx @@ -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 ( +
+ { + updateSelectedProjects(val ?? []); + }} + projectIds={workspaceProjectIds} + /> + { + updateSelectedDuration(val); + }} + dropdownArrow + /> +
+ ); +}); + +export default AnalyticsFilterActions; diff --git a/web/core/components/analytics-v2/analytics-section-wrapper.tsx b/web/core/components/analytics-v2/analytics-section-wrapper.tsx new file mode 100644 index 00000000000..deb691644c8 --- /dev/null +++ b/web/core/components/analytics-v2/analytics-section-wrapper.tsx @@ -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) => { + const { title, children, className, subtitle, actions, headerClassName } = props; + return ( +
+
+ {title && ( +
+

{title}

+ {subtitle &&

• {subtitle}

} +
+ )} + {actions} +
+ {children} +
+ ); +}; + +export default AnalyticsSectionWrapper; diff --git a/web/core/components/analytics-v2/analytics-wrapper.tsx b/web/core/components/analytics-v2/analytics-wrapper.tsx new file mode 100644 index 00000000000..d6193a2b324 --- /dev/null +++ b/web/core/components/analytics-v2/analytics-wrapper.tsx @@ -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) => { + const { title, children, className } = props; + + return ( +
+

{title}

+ {children} +
+ ); +}; + +export default AnalyticsWrapper; diff --git a/web/core/components/analytics-v2/empty-state.tsx b/web/core/components/analytics-v2/empty-state.tsx new file mode 100644 index 00000000000..1a1ee86e821 --- /dev/null +++ b/web/core/components/analytics-v2/empty-state.tsx @@ -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 ( +
+
+ {assetPath && ( +
+ {title} +
+ {title} +
+
+ )} +
+

{title}

+ {description &&

{description}

} +
+
+
+ ); +}; +export default AnalyticsV2EmptyState; diff --git a/web/core/components/analytics-v2/index.ts b/web/core/components/analytics-v2/index.ts new file mode 100644 index 00000000000..8ac82df5dfb --- /dev/null +++ b/web/core/components/analytics-v2/index.ts @@ -0,0 +1 @@ +export * from "./overview/root"; diff --git a/web/core/components/analytics-v2/insight-card.tsx b/web/core/components/analytics-v2/insight-card.tsx new file mode 100644 index 00000000000..cd22b7e922b --- /dev/null +++ b/web/core/components/analytics-v2/insight-card.tsx @@ -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 ( +
+
{label}
+ {!isLoading ? ( +
+
{count}
+ {percentage && ( +
+ + {versus &&
vs {versus}
} +
+ )} +
+ ) : ( + + )} +
+ ); +}; + +export default InsightCard; diff --git a/web/core/components/analytics-v2/insight-table/data-table.tsx b/web/core/components/analytics-v2/insight-table/data-table.tsx new file mode 100644 index 00000000000..c811c92659d --- /dev/null +++ b/web/core/components/analytics-v2/insight-table/data-table.tsx @@ -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 { + columns: ColumnDef[]; + data: TData[]; + searchPlaceholder: string; + actions?: (table: TanstackTable) => React.ReactNode; +} + +export function DataTable({ columns, data, searchPlaceholder, actions }: DataTableProps) { + const [rowSelection, setRowSelection] = React.useState({}); + const [columnVisibility, setColumnVisibility] = React.useState({}); + const [columnFilters, setColumnFilters] = React.useState([]); + const [sorting, setSorting] = React.useState([]); + const { t } = useTranslation(); + const inputRef = React.useRef(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 ( +
+
+
+ {table.getHeaderGroups()?.[0]?.headers?.[0]?.id && ( +
+ {searchPlaceholder} +
+ )} + {!isSearchOpen && ( + + )} +
+ + { + 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 && ( + + )} +
+
+ {actions &&
{actions(table)}
} +
+ +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : (flexRender(header.column.columnDef.header, header.getContext()) as any)} + + ))} + + ))} + + + {table.getRowModel().rows?.length > 0 ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext()) as any} + + ))} + + )) + ) : ( + + +
+ +
+
+
+ )} +
+
+
+
+ ); +} diff --git a/web/core/components/analytics-v2/insight-table/index.ts b/web/core/components/analytics-v2/insight-table/index.ts new file mode 100644 index 00000000000..1efe34c51ec --- /dev/null +++ b/web/core/components/analytics-v2/insight-table/index.ts @@ -0,0 +1 @@ +export * from "./root"; diff --git a/web/core/components/analytics-v2/insight-table/loader.tsx b/web/core/components/analytics-v2/insight-table/loader.tsx new file mode 100644 index 00000000000..0f7f9dc358d --- /dev/null +++ b/web/core/components/analytics-v2/insight-table/loader.tsx @@ -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[]; + rows: number; +} + +export const TableLoader: React.FC = ({ columns, rows }) => ( + + + + {columns.map((column, index) => ( + + {typeof column.header === "string" ? column.header : ""} + + ))} + + + + {Array.from({ length: rows }).map((_, rowIndex) => ( + + {columns.map((_, colIndex) => ( + + + + ))} + + ))} + +
+); diff --git a/web/core/components/analytics-v2/insight-table/root.tsx b/web/core/components/analytics-v2/insight-table/root.tsx new file mode 100644 index 00000000000..0e482a40afd --- /dev/null +++ b/web/core/components/analytics-v2/insight-table/root.tsx @@ -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> { + analyticsType: T; + data?: AnalyticsTableDataMap[T][]; + isLoading?: boolean; + columns: ColumnDef[]; + columnsLabels?: Record; +} + +export const InsightTable = >( + props: InsightTableProps +): React.ReactElement => { + const { data, isLoading, columns, columnsLabels } = props; + const params = useParams(); + const { t } = useTranslation(); + const workspaceSlug = params.workspaceSlug as string; + if (isLoading) { + return ; + } + + const csvConfig = mkConfig({ + fieldSeparator: ",", + filename: `${workspaceSlug}-analytics`, + decimalSeparator: ".", + useKeysAsHeaders: true, + }); + + const exportCSV = (rows: Row[]) => { + 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 ( +
+ {data ? ( + ) => ( + + )} + /> + ) : ( +
No data
+ )} +
+ ); +}; diff --git a/web/core/components/analytics-v2/loaders.tsx b/web/core/components/analytics-v2/loaders.tsx new file mode 100644 index 00000000000..e35d235cecb --- /dev/null +++ b/web/core/components/analytics-v2/loaders.tsx @@ -0,0 +1,23 @@ +import { Loader } from "@plane/ui"; + +export const ProjectInsightsLoader = () => ( +
+ + + +
+ + + + + + +
+
+); + +export const ChartLoader = () => ( + + + +); diff --git a/web/core/components/analytics-v2/overview/active-project-item.tsx b/web/core/components/analytics-v2/overview/active-project-item.tsx new file mode 100644 index 00000000000..088bf61852e --- /dev/null +++ b/web/core/components/analytics-v2/overview/active-project-item.tsx @@ -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 ( +
+ {percentage}% +
+ ); +}; + +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 ( +
+
+
+ + {projectDetails?.logo_props ? ( + + ) : ( + + + + )} + +
+

{projectDetails?.name}

+
+ +
+ ); +}; + +export default ActiveProjectItem; diff --git a/web/core/components/analytics-v2/overview/active-projects.tsx b/web/core/components/analytics-v2/overview/active-projects.tsx new file mode 100644 index 00000000000..2d5d7111669 --- /dev/null +++ b/web/core/components/analytics-v2/overview/active-projects.tsx @@ -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 ( + +
+ {isProjectAnalyticsCountLoading && + Array.from({ length: 5 }).map((_, index) => )} + {!isProjectAnalyticsCountLoading && + projectAnalyticsCount?.map((project) => )} +
+
+ ); +}); + +export default ActiveProjects; diff --git a/web/core/components/analytics-v2/overview/index.ts b/web/core/components/analytics-v2/overview/index.ts new file mode 100644 index 00000000000..1efe34c51ec --- /dev/null +++ b/web/core/components/analytics-v2/overview/index.ts @@ -0,0 +1 @@ +export * from "./root"; diff --git a/web/core/components/analytics-v2/overview/project-insights.tsx b/web/core/components/analytics-v2/overview/project-insights.tsx new file mode 100644 index 00000000000..a767cf47657 --- /dev/null +++ b/web/core/components/analytics-v2/overview/project-insights.tsx @@ -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[]>(workspaceSlug, "projects", { + date_filter: selectedDuration, + ...(selectedProjects?.length > 0 && { project_ids: selectedProjects?.join(",") }), + }) + ); + + return ( + + {isLoadingProjectInsight ? ( + + ) : projectInsightsData && projectInsightsData?.length == 0 ? ( + + ) : ( +
+ {projectInsightsData && ( + + )} +
+
{t("workspace_analytics.summary_of_projects")}
+
{t("workspace_analytics.all_projects")}
+
+
+
{t("workspace_analytics.trend_on_charts")}
+
{t("common.work_items")}
+
+ {projectInsightsData?.map((item) => ( +
+
{item.name}
+
+ {/* */} +
{item.count}
+
+
+ ))} +
+
+
+ )} +
+ ); +}); + +export default ProjectInsights; diff --git a/web/core/components/analytics-v2/overview/root.tsx b/web/core/components/analytics-v2/overview/root.tsx new file mode 100644 index 00000000000..3856353aa54 --- /dev/null +++ b/web/core/components/analytics-v2/overview/root.tsx @@ -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 = () => ( + +
+ +
+ + +
+
+
+); + +export { Overview }; diff --git a/web/core/components/analytics-v2/select/analytics-params.tsx b/web/core/components/analytics-v2/select/analytics-params.tsx new file mode 100644 index 00000000000..61a9d1b1f9e --- /dev/null +++ b/web/core/components/analytics-v2/select/analytics-params.tsx @@ -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; + setValue: UseFormSetValue; + params: IAnalyticsV2Params; + workspaceSlug: string; + classNames?: string; +}; + +export const AnalyticsV2SelectParams: React.FC = 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 ( +
+
+ ( + { + onChange(val); + }} + options={ANALYTICS_V2_Y_AXIS_VALUES} + hiddenOptions={[ChartYAxisMetric.ESTIMATE_POINT_COUNT]} + /> + )} + /> + ( + { + onChange(val); + }} + label={ +
+ + + {xAxisOptions.find((v) => v.value === value)?.label || "Add Property"} + +
+ } + options={xAxisOptions} + /> + )} + /> + ( + { + onChange(val); + }} + label={ +
+ + + {groupByOptions.find((v) => v.value === value)?.label || "Add Property"} + +
+ } + options={groupByOptions} + placeholder="Group By" + allowNoValue + /> + )} + /> +
+
+ ); +}); diff --git a/web/core/components/analytics-v2/select/duration.tsx b/web/core/components/analytics-v2/select/duration.tsx new file mode 100644 index 00000000000..de18ab2023f --- /dev/null +++ b/web/core/components/analytics-v2/select/duration.tsx @@ -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: ( +
+ {option.name} +
+ ), + })); + return ( + + + {value ? ANALYTICS_V2_DURATION_FILTER_OPTIONS.find((opt) => opt.value === value)?.name : placeholder} +
+ } + /> + ); +} + +export default DurationDropdown; diff --git a/web/core/components/analytics-v2/select/project.tsx b/web/core/components/analytics-v2/select/project.tsx new file mode 100644 index 00000000000..61a9942081e --- /dev/null +++ b/web/core/components/analytics-v2/select/project.tsx @@ -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 = 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: ( +
+ {projectDetails?.logo_props ? ( + + ) : ( + + )} + {projectDetails?.name} +
+ ), + }; + }); + + return ( + onChange(val)} + options={options} + label={ +
+ + {value && value.length > 3 + ? `3+ projects` + : value && value.length > 0 + ? projectIds + ?.filter((p) => value.includes(p)) + .map((p) => getProjectById(p)?.name) + .join(", ") + : "All projects"} +
+ } + multiple + /> + ); +}); diff --git a/web/core/components/analytics-v2/select/select-x-axis.tsx b/web/core/components/analytics-v2/select/select-x-axis.tsx new file mode 100644 index 00000000000..a655c9a13de --- /dev/null +++ b/web/core/components/analytics-v2/select/select-x-axis.tsx @@ -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) => { + const { value, onChange, options, hiddenOptions, allowNoValue, label } = props; + return ( + + {allowNoValue && No value} + {options.map((item) => { + if (hiddenOptions?.includes(item.value)) return null; + return ( + + {item.label} + + ); + })} + + ); +}; diff --git a/web/core/components/analytics-v2/select/select-y-axis.tsx b/web/core/components/analytics-v2/select/select-y-axis.tsx new file mode 100644 index 00000000000..c80e2a1e47e --- /dev/null +++ b/web/core/components/analytics-v2/select/select-y-axis.tsx @@ -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 = 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 ( + + + {options.find((v) => v.value === value)?.label ?? "Add Metric"} + + } + onChange={onChange} + maxHeight="lg" + > + {options.map((item) => { + if (hiddenOptions?.includes(item.value)) return null; + return ( + isEstimateEnabled(item.value) && ( + + {item.label} + + ) + ); + })} + + ); +}); diff --git a/web/core/components/analytics-v2/total-insights.tsx b/web/core/components/analytics-v2/total-insights.tsx new file mode 100644 index 00000000000..ac8914e1137 --- /dev/null +++ b/web/core/components/analytics-v2/total-insights.tsx @@ -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(workspaceSlug, analyticsType, { + date_filter: selectedDuration, + ...(selectedProjects?.length > 0 ? { project_ids: selectedProjects.join(",") } : {}), + }) + ); + return ( +
+ {insightsFields[analyticsType]?.map((item: string) => ( + + ))} +
+ ); + } +); + +export default TotalInsights; diff --git a/web/core/components/analytics-v2/trend-piece.tsx b/web/core/components/analytics-v2/trend-piece.tsx new file mode 100644 index 00000000000..23daa89be44 --- /dev/null +++ b/web/core/components/analytics-v2/trend-piece.tsx @@ -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 ( +
+ {isPositive ? : } + {Math.round(Math.abs(percentage))}% +
+ ); +}; + +export default TrendPiece; diff --git a/web/core/components/analytics-v2/work-items/created-vs-resolved.tsx b/web/core/components/analytics-v2/work-items/created-vs-resolved.tsx new file mode 100644 index 00000000000..bd4673f4650 --- /dev/null +++ b/web/core/components/analytics-v2/work-items/created-vs-resolved.tsx @@ -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(workspaceSlug, "work-items", { + date_filter: selectedDuration, + ...(selectedProjects?.length > 0 && { project_ids: selectedProjects?.join(",") }), + }) + ); + const parsedData: TChartData[] = 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 ( + + {isCreatedVsResolvedLoading ? ( + + ) : parsedData && parsedData.length > 0 ? ( + + ) : ( + + )} + + ); +}); + +export default CreatedVsResolved; diff --git a/web/core/components/analytics-v2/work-items/customized-insights.tsx b/web/core/components/analytics-v2/work-items/customized-insights.tsx new file mode 100644 index 00000000000..86fea0c83b7 --- /dev/null +++ b/web/core/components/analytics-v2/work-items/customized-insights.tsx @@ -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({ + defaultValues: { + ...defaultValues, + }, + }); + + const params = { + x_axis: watch("x_axis"), + y_axis: watch("y_axis"), + group_by: watch("group_by"), + }; + + return ( + + } + > + + + ); +}); + +export default CustomizedInsights; diff --git a/web/core/components/analytics-v2/work-items/index.ts b/web/core/components/analytics-v2/work-items/index.ts new file mode 100644 index 00000000000..1efe34c51ec --- /dev/null +++ b/web/core/components/analytics-v2/work-items/index.ts @@ -0,0 +1 @@ +export * from "./root"; diff --git a/web/core/components/analytics-v2/work-items/modal/content.tsx b/web/core/components/analytics-v2/work-items/modal/content.tsx new file mode 100644 index 00000000000..85004d9af68 --- /dev/null +++ b/web/core/components/analytics-v2/work-items/modal/content.tsx @@ -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 = 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 ( +
+ +
+ ); + + return ( + +
+ + + + +
+
+ ); +}); diff --git a/web/core/components/analytics-v2/work-items/modal/header.tsx b/web/core/components/analytics-v2/work-items/modal/header.tsx new file mode 100644 index 00000000000..f4bcdee3819 --- /dev/null +++ b/web/core/components/analytics-v2/work-items/modal/header.tsx @@ -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>; + title: string; +}; + +export const WorkItemsModalHeader: React.FC = observer((props) => { + const { fullScreen, handleClose, setFullScreen, title } = props; + + return ( +
+

Analytics for {title}

+
+ + +
+
+ ); +}); diff --git a/web/core/components/analytics-v2/work-items/modal/index.tsx b/web/core/components/analytics-v2/work-items/modal/index.tsx new file mode 100644 index 00000000000..1404f862c31 --- /dev/null +++ b/web/core/components/analytics-v2/work-items/modal/index.tsx @@ -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 = observer((props) => { + const { isOpen, onClose, projectDetails } = props; + + const [fullScreen, setFullScreen] = useState(false); + + const handleClose = () => { + onClose(); + }; + + return ( + + + +
+ +
+
+ + +
+
+
+
+
+
+
+ ); +}); diff --git a/web/core/components/analytics-v2/work-items/priority-chart.tsx b/web/core/components/analytics-v2/work-items/priority-chart.tsx new file mode 100644 index 00000000000..acf0b6cf9ac --- /dev/null +++ b/web/core/components/analytics-v2/work-items/priority-chart.tsx @@ -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(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[] = useMemo(() => { + if (!parsedData) return []; + let parsedBars: TBarItem[]; + 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[] = useMemo( + () => [ + { + accessorKey: "name", + header: () => "Name", + }, + { + accessorKey: "count", + header: () =>
Count
, + cell: ({ row }) =>
{row.original.count}
, + }, + ], + [] + ); + + const columns: ColumnDef[] = useMemo( + () => + parsedData + ? Object.keys(parsedData?.schema ?? {}).map((key) => ({ + accessorKey: key, + header: () =>
{parsedData.schema[key]}
, + cell: ({ row }) =>
{row.original[key]}
, + })) + : [], + [parsedData] + ); + + const csvConfig = mkConfig({ + fieldSeparator: ",", + filename: `${workspaceSlug}-analytics`, + decimalSeparator: ".", + useKeysAsHeaders: true, + }); + + const exportCSV = (rows: Row[]) => { + 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 ( +
+ {priorityChartLoading ? ( + + ) : parsedData?.data && parsedData.data.length > 0 ? ( + <> + + ) => ( + + )} + /> + + ) : ( + + )} +
+ ); +}); + +export default PriorityChart; diff --git a/web/core/components/analytics-v2/work-items/root.tsx b/web/core/components/analytics-v2/work-items/root.tsx new file mode 100644 index 00000000000..80e8aef6207 --- /dev/null +++ b/web/core/components/analytics-v2/work-items/root.tsx @@ -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 = () => ( + +
+ + + + +
+
+); + +export { WorkItems }; diff --git a/web/core/components/analytics-v2/work-items/utils.ts b/web/core/components/analytics-v2/work-items/utils.ts new file mode 100644 index 00000000000..6e0b47a440d --- /dev/null +++ b/web/core/components/analytics-v2/work-items/utils.ts @@ -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; +}; diff --git a/web/core/components/analytics-v2/work-items/workitems-insight-table.tsx b/web/core/components/analytics-v2/work-items/workitems-insight-table.tsx new file mode 100644 index 00000000000..cd3e7ae4e40 --- /dev/null +++ b/web/core/components/analytics-v2/work-items/workitems-insight-table.tsx @@ -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(workspaceSlug, "work-items", { + date_filter: selectedDuration, + ...(selectedProjects?.length > 0 ? { project_ids: selectedProjects.join(",") } : {}), + }) + ); + // derived values + const columnsLabels: Record = { + 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: () =>
{columnsLabels["project__name"]}
, + cell: ({ row }) => { + const project = getProjectById(row.original.project_id); + return ( +
+ {project?.logo_props ? : } + {project?.name} +
+ ); + }, + }, + { + accessorKey: "backlog_work_items", + header: () =>
{columnsLabels["backlog_work_items"]}
, + cell: ({ row }) =>
{row.original.backlog_work_items}
, + }, + { + accessorKey: "started_work_items", + header: () =>
{columnsLabels["started_work_items"]}
, + cell: ({ row }) =>
{row.original.started_work_items}
, + }, + { + accessorKey: "un_started_work_items", + header: () =>
{columnsLabels["un_started_work_items"]}
, + cell: ({ row }) =>
{row.original.un_started_work_items}
, + }, + { + accessorKey: "completed_work_items", + header: () =>
{columnsLabels["completed_work_items"]}
, + cell: ({ row }) =>
{row.original.completed_work_items}
, + }, + { + accessorKey: "cancelled_work_items", + header: () =>
{columnsLabels["cancelled_work_items"]}
, + cell: ({ row }) =>
{row.original.cancelled_work_items}
, + }, + ] as ColumnDef[], + [getProjectById] + ); + + return ( + + analyticsType="work-items" + data={workItemsData} + isLoading={isLoading} + columns={columns} + columnsLabels={columnsLabels} + /> + ); +}); + +export default WorkItemsInsightTable; diff --git a/web/core/components/chart/utils.ts b/web/core/components/chart/utils.ts new file mode 100644 index 00000000000..9e1d779bf8a --- /dev/null +++ b/web/core/components/chart/utils.ts @@ -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 = 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); +}; \ No newline at end of file diff --git a/web/core/components/empty-state/detailed-empty-state-root.tsx b/web/core/components/empty-state/detailed-empty-state-root.tsx index 4ae97e839c4..887a1eb4228 100644 --- a/web/core/components/empty-state/detailed-empty-state-root.tsx +++ b/web/core/components/empty-state/detailed-empty-state-root.tsx @@ -85,9 +85,7 @@ export const DetailedEmptyState: React.FC = observer((props) => { {description &&

{description}

} - {assetPath && ( - {title} - )} + {assetPath && {title}} {hasButtons && (
diff --git a/web/core/components/issues/filters.tsx b/web/core/components/issues/filters.tsx index 346c960b769..a50afe50ebc 100644 --- a/web/core/components/issues/filters.tsx +++ b/web/core/components/issues/filters.tsx @@ -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 ( <> - setAnalyticsModal(false)} projectDetails={currentProjectDetails ?? undefined} diff --git a/web/core/hooks/store/index.ts b/web/core/hooks/store/index.ts index 7f2d3b89d88..6a1b91e5d0f 100644 --- a/web/core/hooks/store/index.ts +++ b/web/core/hooks/store/index.ts @@ -31,3 +31,4 @@ export * from "./use-workspace"; export * from "./user"; export * from "./use-transient"; export * from "./workspace-draft"; +export * from "./use-analytics-v2"; diff --git a/web/core/hooks/store/use-analytics-v2.ts b/web/core/hooks/store/use-analytics-v2.ts new file mode 100644 index 00000000000..c8c13ba6130 --- /dev/null +++ b/web/core/hooks/store/use-analytics-v2.ts @@ -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; +}; diff --git a/web/core/services/analytics-v2.service.ts b/web/core/services/analytics-v2.service.ts new file mode 100644 index 00000000000..87257cbc6b0 --- /dev/null +++ b/web/core/services/analytics-v2.service.ts @@ -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( + workspaceSlug: string, + tab: TAnalyticsTabsV2Base, + params?: Record + ): Promise { + return this.get(`/api/workspaces/${workspaceSlug}/advance-analytics/`, { + params: { + tab, + ...params, + }, + }) + .then((res) => res?.data) + .catch((err) => { + throw err?.response?.data; + }); + } + + async getAdvanceAnalyticsStats( + workspaceSlug: string, + tab: Exclude, + params?: Record + ): Promise { + 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( + workspaceSlug: string, + tab: TAnalyticsGraphsV2Base, + params?: Record + ): Promise { + return this.get(`/api/workspaces/${workspaceSlug}/advance-analytics-charts/`, { + params: { + type: tab, + ...params, + }, + }) + .then((res) => res?.data) + .catch((err) => { + throw err?.response?.data; + }); + } +} diff --git a/web/core/store/analytics-v2.store.ts b/web/core/store/analytics-v2.store.ts new file mode 100644 index 00000000000..bf8f91a72f0 --- /dev/null +++ b/web/core/store/analytics-v2.store.ts @@ -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; + } + }; +} diff --git a/web/core/store/root.store.ts b/web/core/store/root.store.ts index d06ed2418d0..d2355de78fb 100644 --- a/web/core/store/root.store.ts +++ b/web/core/store/root.store.ts @@ -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() { diff --git a/web/package.json b/web/package.json index 868fa5742c8..b5e6b80ebad 100644 --- a/web/package.json +++ b/web/package.json @@ -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" } -} +} \ No newline at end of file diff --git a/web/public/empty-state/analytics-v2/empty-chart-area-dark.webp b/web/public/empty-state/analytics-v2/empty-chart-area-dark.webp new file mode 100644 index 00000000000..509f66ccc55 Binary files /dev/null and b/web/public/empty-state/analytics-v2/empty-chart-area-dark.webp differ diff --git a/web/public/empty-state/analytics-v2/empty-chart-area-light.webp b/web/public/empty-state/analytics-v2/empty-chart-area-light.webp new file mode 100644 index 00000000000..cfa27c8ca6b Binary files /dev/null and b/web/public/empty-state/analytics-v2/empty-chart-area-light.webp differ diff --git a/web/public/empty-state/analytics-v2/empty-chart-bar-dark.webp b/web/public/empty-state/analytics-v2/empty-chart-bar-dark.webp new file mode 100644 index 00000000000..e519574cb83 Binary files /dev/null and b/web/public/empty-state/analytics-v2/empty-chart-bar-dark.webp differ diff --git a/web/public/empty-state/analytics-v2/empty-chart-bar-light.webp b/web/public/empty-state/analytics-v2/empty-chart-bar-light.webp new file mode 100644 index 00000000000..e6eeb09dbc2 Binary files /dev/null and b/web/public/empty-state/analytics-v2/empty-chart-bar-light.webp differ diff --git a/web/public/empty-state/analytics-v2/empty-chart-radar-dark.webp b/web/public/empty-state/analytics-v2/empty-chart-radar-dark.webp new file mode 100644 index 00000000000..94e2f514b15 Binary files /dev/null and b/web/public/empty-state/analytics-v2/empty-chart-radar-dark.webp differ diff --git a/web/public/empty-state/analytics-v2/empty-chart-radar-light.webp b/web/public/empty-state/analytics-v2/empty-chart-radar-light.webp new file mode 100644 index 00000000000..29820169bbc Binary files /dev/null and b/web/public/empty-state/analytics-v2/empty-chart-radar-light.webp differ diff --git a/web/public/empty-state/analytics-v2/empty-grid-background-dark.webp b/web/public/empty-state/analytics-v2/empty-grid-background-dark.webp new file mode 100644 index 00000000000..5e420f2852c Binary files /dev/null and b/web/public/empty-state/analytics-v2/empty-grid-background-dark.webp differ diff --git a/web/public/empty-state/analytics-v2/empty-grid-background-light.webp b/web/public/empty-state/analytics-v2/empty-grid-background-light.webp new file mode 100644 index 00000000000..592a1281668 Binary files /dev/null and b/web/public/empty-state/analytics-v2/empty-grid-background-light.webp differ diff --git a/web/public/empty-state/analytics-v2/empty-table-dark.webp b/web/public/empty-state/analytics-v2/empty-table-dark.webp new file mode 100644 index 00000000000..f5fcedd5101 Binary files /dev/null and b/web/public/empty-state/analytics-v2/empty-table-dark.webp differ diff --git a/web/public/empty-state/analytics-v2/empty-table-light.webp b/web/public/empty-state/analytics-v2/empty-table-light.webp new file mode 100644 index 00000000000..c9e8fec9515 Binary files /dev/null and b/web/public/empty-state/analytics-v2/empty-table-light.webp differ diff --git a/yarn.lock b/yarn.lock index 6f35385ebc4..47ce955c875 100644 --- a/yarn.lock +++ b/yarn.lock @@ -257,7 +257,7 @@ "@babel/traverse" "^7.25.9" "@babel/types" "^7.25.9" -"@babel/helpers@7.26.10", "@babel/helpers@^7.26.7": +"@babel/helpers@^7.26.7": version "7.26.10" resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.26.10.tgz#6baea3cd62ec2d0c1068778d63cb1314f6637384" integrity sha512-UPYc3SauzZ3JGgj87GgZ89JVdC5dj0AoetR5Bw6wj4niittNyFh6+eOGonYvJ1ao6B8lEa3Q3klS7ADZ53bc5g== @@ -852,7 +852,7 @@ "@babel/plugin-transform-modules-commonjs" "^7.25.9" "@babel/plugin-transform-typescript" "^7.25.9" -"@babel/runtime@7.26.10", "@babel/runtime@^7.0.0", "@babel/runtime@^7.1.2", "@babel/runtime@^7.12.5", "@babel/runtime@^7.17.8", "@babel/runtime@^7.18.3", "@babel/runtime@^7.20.13", "@babel/runtime@^7.23.9", "@babel/runtime@^7.5.5", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7": +"@babel/runtime@^7.0.0", "@babel/runtime@^7.1.2", "@babel/runtime@^7.12.5", "@babel/runtime@^7.17.8", "@babel/runtime@^7.18.3", "@babel/runtime@^7.20.13", "@babel/runtime@^7.23.9", "@babel/runtime@^7.5.5", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7": version "7.26.10" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.26.10.tgz#a07b4d8fa27af131a633d7b3524db803eb4764c2" integrity sha512-2WJMeRQPHKSPemqk/awGrAiuFfzBmOIPXKizAsVhWH9YJqLZ0H+HS4c8loHGgW6utJ3E/ejXQUsiGaQy2NZ9Fw== @@ -1124,126 +1124,251 @@ resolved "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz#5e13fac887f08c44f76b0ccaf3370eb00fec9bb6" integrity sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg== +"@esbuild/aix-ppc64@0.24.2": + version "0.24.2" + resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz#38848d3e25afe842a7943643cbcd387cc6e13461" + integrity sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA== + "@esbuild/aix-ppc64@0.25.0": version "0.25.0" resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.0.tgz#499600c5e1757a524990d5d92601f0ac3ce87f64" integrity sha512-O7vun9Sf8DFjH2UtqK8Ku3LkquL9SZL8OLY1T5NZkA34+wG3OQF7cl4Ql8vdNzM6fzBbYfLaiRLIOZ+2FOCgBQ== +"@esbuild/android-arm64@0.24.2": + version "0.24.2" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz#f592957ae8b5643129fa889c79e69cd8669bb894" + integrity sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg== + "@esbuild/android-arm64@0.25.0": version "0.25.0" resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.25.0.tgz#b9b8231561a1dfb94eb31f4ee056b92a985c324f" integrity sha512-grvv8WncGjDSyUBjN9yHXNt+cq0snxXbDxy5pJtzMKGmmpPxeAmAhWxXI+01lU5rwZomDgD3kJwulEnhTRUd6g== +"@esbuild/android-arm@0.24.2": + version "0.24.2" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.24.2.tgz#72d8a2063aa630308af486a7e5cbcd1e134335b3" + integrity sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q== + "@esbuild/android-arm@0.25.0": version "0.25.0" resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.25.0.tgz#ca6e7888942505f13e88ac9f5f7d2a72f9facd2b" integrity sha512-PTyWCYYiU0+1eJKmw21lWtC+d08JDZPQ5g+kFyxP0V+es6VPPSUhM6zk8iImp2jbV6GwjX4pap0JFbUQN65X1g== +"@esbuild/android-x64@0.24.2": + version "0.24.2" + resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.24.2.tgz#9a7713504d5f04792f33be9c197a882b2d88febb" + integrity sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw== + "@esbuild/android-x64@0.25.0": version "0.25.0" resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.25.0.tgz#e765ea753bac442dfc9cb53652ce8bd39d33e163" integrity sha512-m/ix7SfKG5buCnxasr52+LI78SQ+wgdENi9CqyCXwjVR2X4Jkz+BpC3le3AoBPYTC9NHklwngVXvbJ9/Akhrfg== +"@esbuild/darwin-arm64@0.24.2": + version "0.24.2" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz#02ae04ad8ebffd6e2ea096181b3366816b2b5936" + integrity sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA== + "@esbuild/darwin-arm64@0.25.0": version "0.25.0" resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.25.0.tgz#fa394164b0d89d4fdc3a8a21989af70ef579fa2c" integrity sha512-mVwdUb5SRkPayVadIOI78K7aAnPamoeFR2bT5nszFUZ9P8UpK4ratOdYbZZXYSqPKMHfS1wdHCJk1P1EZpRdvw== +"@esbuild/darwin-x64@0.24.2": + version "0.24.2" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz#9ec312bc29c60e1b6cecadc82bd504d8adaa19e9" + integrity sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA== + "@esbuild/darwin-x64@0.25.0": version "0.25.0" resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.25.0.tgz#91979d98d30ba6e7d69b22c617cc82bdad60e47a" integrity sha512-DgDaYsPWFTS4S3nWpFcMn/33ZZwAAeAFKNHNa1QN0rI4pUjgqf0f7ONmXf6d22tqTY+H9FNdgeaAa+YIFUn2Rg== +"@esbuild/freebsd-arm64@0.24.2": + version "0.24.2" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz#5e82f44cb4906d6aebf24497d6a068cfc152fa00" + integrity sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg== + "@esbuild/freebsd-arm64@0.25.0": version "0.25.0" resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.0.tgz#b97e97073310736b430a07b099d837084b85e9ce" integrity sha512-VN4ocxy6dxefN1MepBx/iD1dH5K8qNtNe227I0mnTRjry8tj5MRk4zprLEdG8WPyAPb93/e4pSgi1SoHdgOa4w== +"@esbuild/freebsd-x64@0.24.2": + version "0.24.2" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz#3fb1ce92f276168b75074b4e51aa0d8141ecce7f" + integrity sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q== + "@esbuild/freebsd-x64@0.25.0": version "0.25.0" resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.0.tgz#f3b694d0da61d9910ec7deff794d444cfbf3b6e7" integrity sha512-mrSgt7lCh07FY+hDD1TxiTyIHyttn6vnjesnPoVDNmDfOmggTLXRv8Id5fNZey1gl/V2dyVK1VXXqVsQIiAk+A== +"@esbuild/linux-arm64@0.24.2": + version "0.24.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz#856b632d79eb80aec0864381efd29de8fd0b1f43" + integrity sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg== + "@esbuild/linux-arm64@0.25.0": version "0.25.0" resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.25.0.tgz#f921f699f162f332036d5657cad9036f7a993f73" integrity sha512-9QAQjTWNDM/Vk2bgBl17yWuZxZNQIF0OUUuPZRKoDtqF2k4EtYbpyiG5/Dk7nqeK6kIJWPYldkOcBqjXjrUlmg== +"@esbuild/linux-arm@0.24.2": + version "0.24.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz#c846b4694dc5a75d1444f52257ccc5659021b736" + integrity sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA== + "@esbuild/linux-arm@0.25.0": version "0.25.0" resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.25.0.tgz#cc49305b3c6da317c900688995a4050e6cc91ca3" integrity sha512-vkB3IYj2IDo3g9xX7HqhPYxVkNQe8qTK55fraQyTzTX/fxaDtXiEnavv9geOsonh2Fd2RMB+i5cbhu2zMNWJwg== +"@esbuild/linux-ia32@0.24.2": + version "0.24.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz#f8a16615a78826ccbb6566fab9a9606cfd4a37d5" + integrity sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw== + "@esbuild/linux-ia32@0.25.0": version "0.25.0" resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.25.0.tgz#3e0736fcfab16cff042dec806247e2c76e109e19" integrity sha512-43ET5bHbphBegyeqLb7I1eYn2P/JYGNmzzdidq/w0T8E2SsYL1U6un2NFROFRg1JZLTzdCoRomg8Rvf9M6W6Gg== +"@esbuild/linux-loong64@0.24.2": + version "0.24.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz#1c451538c765bf14913512c76ed8a351e18b09fc" + integrity sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ== + "@esbuild/linux-loong64@0.25.0": version "0.25.0" resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.25.0.tgz#ea2bf730883cddb9dfb85124232b5a875b8020c7" integrity sha512-fC95c/xyNFueMhClxJmeRIj2yrSMdDfmqJnyOY4ZqsALkDrrKJfIg5NTMSzVBr5YW1jf+l7/cndBfP3MSDpoHw== +"@esbuild/linux-mips64el@0.24.2": + version "0.24.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz#0846edeefbc3d8d50645c51869cc64401d9239cb" + integrity sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw== + "@esbuild/linux-mips64el@0.25.0": version "0.25.0" resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.0.tgz#4cababb14eede09248980a2d2d8b966464294ff1" integrity sha512-nkAMFju7KDW73T1DdH7glcyIptm95a7Le8irTQNO/qtkoyypZAnjchQgooFUDQhNAy4iu08N79W4T4pMBwhPwQ== +"@esbuild/linux-ppc64@0.24.2": + version "0.24.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz#8e3fc54505671d193337a36dfd4c1a23b8a41412" + integrity sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw== + "@esbuild/linux-ppc64@0.25.0": version "0.25.0" resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.0.tgz#8860a4609914c065373a77242e985179658e1951" integrity sha512-NhyOejdhRGS8Iwv+KKR2zTq2PpysF9XqY+Zk77vQHqNbo/PwZCzB5/h7VGuREZm1fixhs4Q/qWRSi5zmAiO4Fw== +"@esbuild/linux-riscv64@0.24.2": + version "0.24.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz#6a1e92096d5e68f7bb10a0d64bb5b6d1daf9a694" + integrity sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q== + "@esbuild/linux-riscv64@0.25.0": version "0.25.0" resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.0.tgz#baf26e20bb2d38cfb86ee282dff840c04f4ed987" integrity sha512-5S/rbP5OY+GHLC5qXp1y/Mx//e92L1YDqkiBbO9TQOvuFXM+iDqUNG5XopAnXoRH3FjIUDkeGcY1cgNvnXp/kA== +"@esbuild/linux-s390x@0.24.2": + version "0.24.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz#ab18e56e66f7a3c49cb97d337cd0a6fea28a8577" + integrity sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw== + "@esbuild/linux-s390x@0.25.0": version "0.25.0" resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.25.0.tgz#8323afc0d6cb1b6dc6e9fd21efd9e1542c3640a4" integrity sha512-XM2BFsEBz0Fw37V0zU4CXfcfuACMrppsMFKdYY2WuTS3yi8O1nFOhil/xhKTmE1nPmVyvQJjJivgDT+xh8pXJA== +"@esbuild/linux-x64@0.24.2": + version "0.24.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz#8140c9b40da634d380b0b29c837a0b4267aff38f" + integrity sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q== + "@esbuild/linux-x64@0.25.0": version "0.25.0" resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.25.0.tgz#08fcf60cb400ed2382e9f8e0f5590bac8810469a" integrity sha512-9yl91rHw/cpwMCNytUDxwj2XjFpxML0y9HAOH9pNVQDpQrBxHy01Dx+vaMu0N1CKa/RzBD2hB4u//nfc+Sd3Cw== +"@esbuild/netbsd-arm64@0.24.2": + version "0.24.2" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.24.2.tgz#65f19161432bafb3981f5f20a7ff45abb2e708e6" + integrity sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw== + "@esbuild/netbsd-arm64@0.25.0": version "0.25.0" resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.0.tgz#935c6c74e20f7224918fbe2e6c6fe865b6c6ea5b" integrity sha512-RuG4PSMPFfrkH6UwCAqBzauBWTygTvb1nxWasEJooGSJ/NwRw7b2HOwyRTQIU97Hq37l3npXoZGYMy3b3xYvPw== +"@esbuild/netbsd-x64@0.24.2": + version "0.24.2" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz#7a3a97d77abfd11765a72f1c6f9b18f5396bcc40" + integrity sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw== + "@esbuild/netbsd-x64@0.25.0": version "0.25.0" resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.0.tgz#414677cef66d16c5a4d210751eb2881bb9c1b62b" integrity sha512-jl+qisSB5jk01N5f7sPCsBENCOlPiS/xptD5yxOx2oqQfyourJwIKLRA2yqWdifj3owQZCL2sn6o08dBzZGQzA== +"@esbuild/openbsd-arm64@0.24.2": + version "0.24.2" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz#58b00238dd8f123bfff68d3acc53a6ee369af89f" + integrity sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A== + "@esbuild/openbsd-arm64@0.25.0": version "0.25.0" resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.0.tgz#8fd55a4d08d25cdc572844f13c88d678c84d13f7" integrity sha512-21sUNbq2r84YE+SJDfaQRvdgznTD8Xc0oc3p3iW/a1EVWeNj/SdUCbm5U0itZPQYRuRTW20fPMWMpcrciH2EJw== +"@esbuild/openbsd-x64@0.24.2": + version "0.24.2" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz#0ac843fda0feb85a93e288842936c21a00a8a205" + integrity sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA== + "@esbuild/openbsd-x64@0.25.0": version "0.25.0" resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.0.tgz#0c48ddb1494bbc2d6bcbaa1429a7f465fa1dedde" integrity sha512-2gwwriSMPcCFRlPlKx3zLQhfN/2WjJ2NSlg5TKLQOJdV0mSxIcYNTMhk3H3ulL/cak+Xj0lY1Ym9ysDV1igceg== +"@esbuild/sunos-x64@0.24.2": + version "0.24.2" + resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz#8b7aa895e07828d36c422a4404cc2ecf27fb15c6" + integrity sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig== + "@esbuild/sunos-x64@0.25.0": version "0.25.0" resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.25.0.tgz#86ff9075d77962b60dd26203d7352f92684c8c92" integrity sha512-bxI7ThgLzPrPz484/S9jLlvUAHYMzy6I0XiU1ZMeAEOBcS0VePBFxh1JjTQt3Xiat5b6Oh4x7UC7IwKQKIJRIg== +"@esbuild/win32-arm64@0.24.2": + version "0.24.2" + resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz#c023afb647cabf0c3ed13f0eddfc4f1d61c66a85" + integrity sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ== + "@esbuild/win32-arm64@0.25.0": version "0.25.0" resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.25.0.tgz#849c62327c3229467f5b5cd681bf50588442e96c" integrity sha512-ZUAc2YK6JW89xTbXvftxdnYy3m4iHIkDtK3CLce8wg8M2L+YZhIvO1DKpxrd0Yr59AeNNkTiic9YLf6FTtXWMw== +"@esbuild/win32-ia32@0.24.2": + version "0.24.2" + resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz#96c356132d2dda990098c8b8b951209c3cd743c2" + integrity sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA== + "@esbuild/win32-ia32@0.25.0": version "0.25.0" resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.25.0.tgz#f62eb480cd7cca088cb65bb46a6db25b725dc079" integrity sha512-eSNxISBu8XweVEWG31/JzjkIGbGIJN/TrRoiSVZwZ6pkC6VX4Im/WV2cz559/TXLcYbcrDN8JtKgd9DJVIo8GA== +"@esbuild/win32-x64@0.24.2": + version "0.24.2" + resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz#34aa0b52d0fbb1a654b596acfa595f0c7b77a77b" + integrity sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg== + "@esbuild/win32-x64@0.25.0": version "0.25.0" resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.25.0.tgz#c8e119a30a7c8d60b9d2e22d2073722dde3b710b" @@ -2775,6 +2900,13 @@ lodash.merge "^4.6.2" postcss-selector-parser "6.0.10" +"@tanstack/react-table@^8.21.3": + version "8.21.3" + resolved "https://registry.yarnpkg.com/@tanstack/react-table/-/react-table-8.21.3.tgz#2c38c747a5731c1a07174fda764b9c2b1fb5e91b" + integrity sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww== + dependencies: + "@tanstack/table-core" "8.21.3" + "@tanstack/react-virtual@^3.0.0-beta.60": version "3.13.0" resolved "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.0.tgz#f50bccdfbb792cb11fdc0342fd3ec6945c730389" @@ -2782,6 +2914,11 @@ dependencies: "@tanstack/virtual-core" "3.13.0" +"@tanstack/table-core@8.21.3": + version "8.21.3" + resolved "https://registry.yarnpkg.com/@tanstack/table-core/-/table-core-8.21.3.tgz#2977727d8fc8dfa079112d9f4d4c019110f1732c" + integrity sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg== + "@tanstack/virtual-core@3.13.0": version "3.13.0" resolved "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.0.tgz#8db0ccc9d6c32b6393551a6d19c87dbb259a8828" @@ -5918,7 +6055,38 @@ esbuild-register@^3.5.0: dependencies: debug "^4.3.4" -esbuild@0.25.0, "esbuild@^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0", esbuild@^0.25.0: +"esbuild@^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0": + version "0.24.2" + resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.24.2.tgz#b5b55bee7de017bff5fb8a4e3e44f2ebe2c3567d" + integrity sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA== + optionalDependencies: + "@esbuild/aix-ppc64" "0.24.2" + "@esbuild/android-arm" "0.24.2" + "@esbuild/android-arm64" "0.24.2" + "@esbuild/android-x64" "0.24.2" + "@esbuild/darwin-arm64" "0.24.2" + "@esbuild/darwin-x64" "0.24.2" + "@esbuild/freebsd-arm64" "0.24.2" + "@esbuild/freebsd-x64" "0.24.2" + "@esbuild/linux-arm" "0.24.2" + "@esbuild/linux-arm64" "0.24.2" + "@esbuild/linux-ia32" "0.24.2" + "@esbuild/linux-loong64" "0.24.2" + "@esbuild/linux-mips64el" "0.24.2" + "@esbuild/linux-ppc64" "0.24.2" + "@esbuild/linux-riscv64" "0.24.2" + "@esbuild/linux-s390x" "0.24.2" + "@esbuild/linux-x64" "0.24.2" + "@esbuild/netbsd-arm64" "0.24.2" + "@esbuild/netbsd-x64" "0.24.2" + "@esbuild/openbsd-arm64" "0.24.2" + "@esbuild/openbsd-x64" "0.24.2" + "@esbuild/sunos-x64" "0.24.2" + "@esbuild/win32-arm64" "0.24.2" + "@esbuild/win32-ia32" "0.24.2" + "@esbuild/win32-x64" "0.24.2" + +esbuild@^0.25.0: version "0.25.0" resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.25.0.tgz#0de1787a77206c5a79eeb634a623d39b5006ce92" integrity sha512-BXq5mqc8ltbaN34cDqWuYKyNhX8D/Z0J1xdtdQ8UcIIIyJyz+ZMKUt58tF3SrZ85jcfN/PZYhjR5uDQAYNVbuw== @@ -6254,6 +6422,11 @@ expand-template@^2.0.3: resolved "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz#6e14b3fcee0f3a6340ecb57d2e8918692052a47c" integrity sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg== +export-to-csv@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/export-to-csv/-/export-to-csv-1.4.0.tgz#03fb42a4a4262cd03bde57a7b9bcad115149cf4b" + integrity sha512-6CX17Cu+rC2Fi2CyZ4CkgVG3hLl6BFsdAxfXiZkmDFIDY4mRx2y2spdeH6dqPHI9rP+AsHEfGeKz84Uuw7+Pmg== + express-ws@^5.0.2: version "5.0.2" resolved "https://registry.npmjs.org/express-ws/-/express-ws-5.0.2.tgz#5b02d41b937d05199c6c266d7cc931c823bda8eb" @@ -8379,7 +8552,7 @@ mz@^2.7.0: object-assign "^4.0.1" thenify-all "^1.0.0" -nanoid@3.3.8, nanoid@^3.3.6, nanoid@^3.3.8: +nanoid@^3.3.6, nanoid@^3.3.8: version "3.3.8" resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.8.tgz#b1be3030bee36aaff18bacb375e5cce521684baf" integrity sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==