[WEB-3781] Analytics page enhancements (#7005)
* chore: analytics endpoint * added anlytics v2 * updated status icons * added area chart in workitems and en translations * active projects * chore: created analytics chart * chore: validation errors * improved radar-chart , added empty states , added projects summary * chore: added a new graph in advance analytics * integrated priority chart * chore: added csv exporter * added priority dropdown * integrated created vs resolved chart * custom x and y axis label in bar and area chart * added wrapper styles to legends * added filter components * fixed temp data imports * integrated filters in priority charts * added label to priority chart and updated duration filter * refactor * reverted to void onchange * fixed some contant exports * fixed type issues * fixed some type and build issues * chore: updated the filtering logic for analytics * updated default value to last_30_days * percentage value whole number and added some rules for axis options * fixed some translations * added - custom tick for radar, calc of insight cards, filter labels * chore: opitmised the analytics endpoint * replace old analytics path with new , updated labels of insight card, done some store fixes * chore: updated the export request * Enhanced ProjectSelect to support multi-select, improved state management, and optimized data fetching and component structure. * fix: round completion percentage calculation in ActiveProjectItem * added empty states in project insights * Added loader and empty state in created/resolved chart * added loaders * added icons in filters * added custom colors in customised charts * cleaned up some code * added some responsiveness * updated translations * updated serrchbar for the table * added work item modal in project analytics * fixed some of the layput issues in the peek view * chore: updated the base function for viewsets * synced tab to url * code cleanup * chore: updated the export logic * fixed project_ids filter * added icon in projectdropdown * updated export button position * export csv and emptystates icons * refactor * code refactor * updated loaders, moved color pallete to contants, added nullish collasece operator in neccessary places * removed uneccessary cn * fixed formatting issues * fixed empty project_ids in payload * improved null checks * optimized charts * modified relevant variables to observable.ref * fixed the duration type * optimized some code * updated query key in project-insight * updated query key in project-insight * updated formatting * chore: replaced analytics route with new one and done some optimizations * removed the old analytics --------- Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
This commit is contained in:
parent
0d5c7c6653
commit
75d81f9e95
103 changed files with 3919 additions and 162 deletions
|
|
@ -0,0 +1,34 @@
|
|||
// plane web components
|
||||
import { observer } from "mobx-react-lite";
|
||||
// hooks
|
||||
import { useProject } from "@/hooks/store";
|
||||
import { useAnalyticsV2 } from "@/hooks/store/use-analytics-v2";
|
||||
// components
|
||||
import DurationDropdown from "./select/duration";
|
||||
import { ProjectSelect } from "./select/project";
|
||||
|
||||
const AnalyticsFilterActions = observer(() => {
|
||||
const { selectedProjects, selectedDuration, updateSelectedProjects, updateSelectedDuration } = useAnalyticsV2();
|
||||
const { workspaceProjectIds } = useProject();
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<ProjectSelect
|
||||
value={selectedProjects}
|
||||
onChange={(val) => {
|
||||
updateSelectedProjects(val ?? []);
|
||||
}}
|
||||
projectIds={workspaceProjectIds}
|
||||
/>
|
||||
<DurationDropdown
|
||||
buttonVariant="border-with-text"
|
||||
value={selectedDuration}
|
||||
onChange={(val) => {
|
||||
updateSelectedDuration(val);
|
||||
}}
|
||||
dropdownArrow
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default AnalyticsFilterActions;
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
import { cn } from "@plane/utils";
|
||||
|
||||
type Props = {
|
||||
title?: string;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
subtitle?: string | null;
|
||||
actions?: React.ReactNode;
|
||||
headerClassName?: string;
|
||||
};
|
||||
|
||||
const AnalyticsSectionWrapper: React.FC<Props> = (props) => {
|
||||
const { title, children, className, subtitle, actions, headerClassName } = props;
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className={cn("mb-6 flex items-center gap-2 text-nowrap ", headerClassName)}>
|
||||
{title && (
|
||||
<div className="flex items-center gap-2 ">
|
||||
<h1 className={"text-lg font-medium"}>{title}</h1>
|
||||
{subtitle && <p className="text-lg text-custom-text-300"> • {subtitle}</p>}
|
||||
</div>
|
||||
)}
|
||||
{actions}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AnalyticsSectionWrapper;
|
||||
22
web/core/components/analytics-v2/analytics-wrapper.tsx
Normal file
22
web/core/components/analytics-v2/analytics-wrapper.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import React from "react";
|
||||
// plane package imports
|
||||
import { cn } from "@plane/utils";
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const AnalyticsWrapper: React.FC<Props> = (props) => {
|
||||
const { title, children, className } = props;
|
||||
|
||||
return (
|
||||
<div className={cn("px-6 py-4", className)}>
|
||||
<h1 className={"mb-4 text-2xl font-bold md:mb-6"}>{title}</h1>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AnalyticsWrapper;
|
||||
48
web/core/components/analytics-v2/empty-state.tsx
Normal file
48
web/core/components/analytics-v2/empty-state.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import React from "react";
|
||||
import Image from "next/image";
|
||||
// plane package imports
|
||||
import { cn } from "@plane/utils";
|
||||
import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
description?: string;
|
||||
assetPath?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const AnalyticsV2EmptyState = ({ title, description, assetPath, className }: Props) => {
|
||||
const backgroundReolvedPath = useResolvedAssetPath({ basePath: "/empty-state/analytics-v2/empty-grid-background" });
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-full w-full items-center justify-center overflow-y-auto rounded-lg border border-custom-border-100 px-5 py-10 md:px-20",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className={cn("flex flex-col items-center")}>
|
||||
{assetPath && (
|
||||
<div className="relative flex max-h-[200px] max-w-[200px] items-center justify-center">
|
||||
<Image src={assetPath} alt={title} width={100} height={100} layout="fixed" className="z-10 h-2/3 w-2/3" />
|
||||
<div className="absolute inset-0">
|
||||
<Image
|
||||
src={backgroundReolvedPath}
|
||||
alt={title}
|
||||
width={100}
|
||||
height={100}
|
||||
layout="fixed"
|
||||
className="h-full w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-shrink flex-col items-center gap-1.5 text-center">
|
||||
<h3 className={cn("text-xl font-semibold")}>{title}</h3>
|
||||
{description && <p className="text-sm text-custom-text-300">{description}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default AnalyticsV2EmptyState;
|
||||
1
web/core/components/analytics-v2/index.ts
Normal file
1
web/core/components/analytics-v2/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "./overview/root";
|
||||
47
web/core/components/analytics-v2/insight-card.tsx
Normal file
47
web/core/components/analytics-v2/insight-card.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
// plane package imports
|
||||
import React, { useMemo } from "react";
|
||||
import { IAnalyticsResponseFieldsV2 } from "@plane/types";
|
||||
import { Loader } from "@plane/ui";
|
||||
// components
|
||||
import TrendPiece from "./trend-piece";
|
||||
|
||||
export type InsightCardProps = {
|
||||
data?: IAnalyticsResponseFieldsV2;
|
||||
label: string;
|
||||
isLoading?: boolean;
|
||||
versus?: string | null;
|
||||
};
|
||||
|
||||
const InsightCard = (props: InsightCardProps) => {
|
||||
const { data, label, isLoading, versus } = props;
|
||||
const { count, filter_count } = data || {};
|
||||
const percentage = useMemo(() => {
|
||||
if (count != null && filter_count != null) {
|
||||
const result = ((count - filter_count) / count) * 100;
|
||||
const isFiniteAndNotNaNOrZero = Number.isFinite(result) && !Number.isNaN(result) && result !== 0;
|
||||
return isFiniteAndNotNaNOrZero ? result : null;
|
||||
}
|
||||
return null;
|
||||
}, [count, filter_count]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="text-sm text-custom-text-300">{label}</div>
|
||||
{!isLoading ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="text-2xl font-bold text-custom-text-100">{count}</div>
|
||||
{percentage && (
|
||||
<div className="flex gap-1 text-xs text-custom-text-300">
|
||||
<TrendPiece percentage={percentage} size="xs" />
|
||||
{versus && <div>vs {versus}</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Loader.Item height="50px" width="100%" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default InsightCard;
|
||||
177
web/core/components/analytics-v2/insight-table/data-table.tsx
Normal file
177
web/core/components/analytics-v2/insight-table/data-table.tsx
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import {
|
||||
ColumnDef,
|
||||
ColumnFiltersState,
|
||||
SortingState,
|
||||
VisibilityState,
|
||||
Table as TanstackTable,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFacetedRowModel,
|
||||
getFacetedUniqueValues,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
import { Search, X } from "lucide-react";
|
||||
// plane package imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@plane/propel/table";
|
||||
import { cn } from "@plane/utils";
|
||||
// plane web components
|
||||
import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
|
||||
import AnalyticsV2EmptyState from "../empty-state";
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
data: TData[];
|
||||
searchPlaceholder: string;
|
||||
actions?: (table: TanstackTable<TData>) => React.ReactNode;
|
||||
}
|
||||
|
||||
export function DataTable<TData, TValue>({ columns, data, searchPlaceholder, actions }: DataTableProps<TData, TValue>) {
|
||||
const [rowSelection, setRowSelection] = React.useState({});
|
||||
const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>({});
|
||||
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([]);
|
||||
const [sorting, setSorting] = React.useState<SortingState>([]);
|
||||
const { t } = useTranslation();
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
const [isSearchOpen, setIsSearchOpen] = React.useState(false);
|
||||
const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/analytics-v2/empty-table" });
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
state: {
|
||||
sorting,
|
||||
columnVisibility,
|
||||
rowSelection,
|
||||
columnFilters,
|
||||
},
|
||||
enableRowSelection: true,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
onSortingChange: setSorting,
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFacetedRowModel: getFacetedRowModel(),
|
||||
getFacetedUniqueValues: getFacetedUniqueValues(),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="relative flex max-w-[300px] items-center gap-4 ">
|
||||
{table.getHeaderGroups()?.[0]?.headers?.[0]?.id && (
|
||||
<div className="flex items-center gap-2 whitespace-nowrap text-sm text-custom-text-400">
|
||||
{searchPlaceholder}
|
||||
</div>
|
||||
)}
|
||||
{!isSearchOpen && (
|
||||
<button
|
||||
type="button"
|
||||
className="-mr-5 grid place-items-center rounded p-2 text-custom-text-400 hover:bg-custom-background-80"
|
||||
onClick={() => {
|
||||
setIsSearchOpen(true);
|
||||
inputRef.current?.focus();
|
||||
}}
|
||||
>
|
||||
<Search className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"mr-auto flex w-0 items-center justify-start gap-1 overflow-hidden rounded-md border border-transparent bg-custom-background-100 text-custom-text-400 opacity-0 transition-[width] ease-linear",
|
||||
{
|
||||
"w-64 border-custom-border-200 px-2.5 py-1.5 opacity-100": isSearchOpen,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<Search className="h-3.5 w-3.5" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="w-full max-w-[234px] border-none bg-transparent text-sm text-custom-text-100 placeholder:text-custom-text-400 focus:outline-none"
|
||||
placeholder="Search"
|
||||
value={table.getColumn(table.getHeaderGroups()?.[0].headers[0].id)?.getFilterValue() as string}
|
||||
onChange={(e) => {
|
||||
const columnId = table.getHeaderGroups()?.[0]?.headers?.[0]?.id;
|
||||
if (columnId) table.getColumn(columnId)?.setFilterValue(e.target.value);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
setIsSearchOpen(true);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{isSearchOpen && (
|
||||
<button
|
||||
type="button"
|
||||
className="grid place-items-center"
|
||||
onClick={() => {
|
||||
const columnId = table.getHeaderGroups()?.[0]?.headers?.[0]?.id;
|
||||
if (columnId) {
|
||||
table.getColumn(columnId)?.setFilterValue("");
|
||||
}
|
||||
setIsSearchOpen(false);
|
||||
}}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{actions && <div>{actions(table)}</div>}
|
||||
</div>
|
||||
|
||||
<div className="rounded-md">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead key={header.id} colSpan={header.colSpan}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: (flexRender(header.column.columnDef.header, header.getContext()) as any)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length > 0 ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow key={row.id} data-state={row.getIsSelected() && "selected"}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext()) as any}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="p-0">
|
||||
<div className="flex h-[350px] w-full items-center justify-center border border-custom-border-100 ">
|
||||
<AnalyticsV2EmptyState
|
||||
title={t("workspace_analytics.empty_state_v2.customized_insights.title")}
|
||||
description={t("workspace_analytics.empty_state_v2.customized_insights.description")}
|
||||
className="border-0"
|
||||
assetPath={resolvedPath}
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1
web/core/components/analytics-v2/insight-table/index.ts
Normal file
1
web/core/components/analytics-v2/insight-table/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "./root";
|
||||
34
web/core/components/analytics-v2/insight-table/loader.tsx
Normal file
34
web/core/components/analytics-v2/insight-table/loader.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import * as React from "react";
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@plane/propel/table";
|
||||
import { Loader } from "@plane/ui";
|
||||
|
||||
interface TableSkeletonProps {
|
||||
columns: ColumnDef<any>[];
|
||||
rows: number;
|
||||
}
|
||||
|
||||
export const TableLoader: React.FC<TableSkeletonProps> = ({ columns, rows }) => (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{columns.map((column, index) => (
|
||||
<TableHead key={column.header?.toString() ?? index}>
|
||||
{typeof column.header === "string" ? column.header : ""}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{Array.from({ length: rows }).map((_, rowIndex) => (
|
||||
<TableRow key={rowIndex}>
|
||||
{columns.map((_, colIndex) => (
|
||||
<TableCell key={colIndex}>
|
||||
<Loader.Item height="20px" width="100%" />
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
74
web/core/components/analytics-v2/insight-table/root.tsx
Normal file
74
web/core/components/analytics-v2/insight-table/root.tsx
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import { ColumnDef, Row, Table } from "@tanstack/react-table";
|
||||
import { download, generateCsv, mkConfig } from "export-to-csv";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Download } from "lucide-react";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { AnalyticsTableDataMap, TAnalyticsTabsV2Base } from "@plane/types";
|
||||
import { Button } from "@plane/ui";
|
||||
import { DataTable } from "./data-table";
|
||||
import { TableLoader } from "./loader";
|
||||
interface InsightTableProps<T extends Exclude<TAnalyticsTabsV2Base, "overview">> {
|
||||
analyticsType: T;
|
||||
data?: AnalyticsTableDataMap[T][];
|
||||
isLoading?: boolean;
|
||||
columns: ColumnDef<AnalyticsTableDataMap[T]>[];
|
||||
columnsLabels?: Record<string, string>;
|
||||
}
|
||||
|
||||
export const InsightTable = <T extends Exclude<TAnalyticsTabsV2Base, "overview">>(
|
||||
props: InsightTableProps<T>
|
||||
): React.ReactElement => {
|
||||
const { data, isLoading, columns, columnsLabels } = props;
|
||||
const params = useParams();
|
||||
const { t } = useTranslation();
|
||||
const workspaceSlug = params.workspaceSlug as string;
|
||||
if (isLoading) {
|
||||
return <TableLoader columns={columns} rows={5} />;
|
||||
}
|
||||
|
||||
const csvConfig = mkConfig({
|
||||
fieldSeparator: ",",
|
||||
filename: `${workspaceSlug}-analytics`,
|
||||
decimalSeparator: ".",
|
||||
useKeysAsHeaders: true,
|
||||
});
|
||||
|
||||
const exportCSV = (rows: Row<AnalyticsTableDataMap[T]>[]) => {
|
||||
const rowData: any = rows.map((row) => {
|
||||
const { project_id, ...exportableData } = row.original;
|
||||
return Object.fromEntries(
|
||||
Object.entries(exportableData).map(([key, value]) => {
|
||||
if (columnsLabels?.[key]) {
|
||||
return [columnsLabels[key], value];
|
||||
}
|
||||
return [key, value];
|
||||
})
|
||||
);
|
||||
});
|
||||
const csv = generateCsv(csvConfig)(rowData);
|
||||
download(csvConfig)(csv);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="">
|
||||
{data ? (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={data}
|
||||
searchPlaceholder={`${data.length} Projects`}
|
||||
actions={(table: Table<AnalyticsTableDataMap[T]>) => (
|
||||
<Button
|
||||
variant="accent-primary"
|
||||
prependIcon={<Download className="h-3.5 w-3.5" />}
|
||||
onClick={() => exportCSV(table.getFilteredRowModel().rows)}
|
||||
>
|
||||
<div>{t("exporter.csv.short_description")}</div>
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<div>No data</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
23
web/core/components/analytics-v2/loaders.tsx
Normal file
23
web/core/components/analytics-v2/loaders.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { Loader } from "@plane/ui";
|
||||
|
||||
export const ProjectInsightsLoader = () => (
|
||||
<div className="flex h-[200px] gap-1">
|
||||
<Loader className="h-full w-full">
|
||||
<Loader.Item height="100%" width="100%" />
|
||||
</Loader>
|
||||
<div className="flex h-full w-full flex-col gap-1">
|
||||
<Loader className="h-12 w-full">
|
||||
<Loader.Item height="100%" width="100%" />
|
||||
</Loader>
|
||||
<Loader className="h-full w-full">
|
||||
<Loader.Item height="100%" width="100%" />
|
||||
</Loader>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const ChartLoader = () => (
|
||||
<Loader className="h-[350px] w-full">
|
||||
<Loader.Item height="100%" width="100%" />
|
||||
</Loader>
|
||||
);
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
import { Briefcase } from "lucide-react";
|
||||
// plane package imports
|
||||
import { Logo } from "@plane/ui";
|
||||
import { cn } from "@plane/utils";
|
||||
// plane web hooks
|
||||
import { useProject } from "@/hooks/store";
|
||||
|
||||
type Props = {
|
||||
project: {
|
||||
id: string;
|
||||
completed_issues?: number;
|
||||
total_issues?: number;
|
||||
};
|
||||
isLoading?: boolean;
|
||||
};
|
||||
const CompletionPercentage = ({ percentage }: { percentage: number }) => {
|
||||
const percentageColor = percentage > 50 ? "bg-green-500/30 text-green-500" : "bg-red-500/30 text-red-500";
|
||||
return (
|
||||
<div className={cn("flex items-center gap-2 rounded p-1 text-xs", percentageColor)}>
|
||||
<span>{percentage}%</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ActiveProjectItem = (props: Props) => {
|
||||
const { project } = props;
|
||||
const { getProjectById } = useProject();
|
||||
const { id, completed_issues, total_issues } = project;
|
||||
|
||||
const projectDetails = getProjectById(id);
|
||||
|
||||
if (!projectDetails) return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2 ">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-xl bg-custom-background-80">
|
||||
<span className="grid h-4 w-4 flex-shrink-0 place-items-center">
|
||||
{projectDetails?.logo_props ? (
|
||||
<Logo logo={projectDetails?.logo_props} size={16} />
|
||||
) : (
|
||||
<span className="grid h-4 w-4 flex-shrink-0 place-items-center">
|
||||
<Briefcase className="h-4 w-4" />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm font-medium">{projectDetails?.name}</p>
|
||||
</div>
|
||||
<CompletionPercentage
|
||||
percentage={completed_issues && total_issues ? Math.round((completed_issues / total_issues) * 100) : 0}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ActiveProjectItem;
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import useSWR from "swr";
|
||||
// plane package imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Loader } from "@plane/ui";
|
||||
// plane web hooks
|
||||
import { useAnalyticsV2, useProject } from "@/hooks/store";
|
||||
// plane web components
|
||||
import AnalyticsSectionWrapper from "../analytics-section-wrapper";
|
||||
import ActiveProjectItem from "./active-project-item";
|
||||
|
||||
const ActiveProjects = observer(() => {
|
||||
const { t } = useTranslation();
|
||||
const { fetchProjectAnalyticsCount } = useProject();
|
||||
const { workspaceSlug } = useParams();
|
||||
const { selectedDurationLabel } = useAnalyticsV2();
|
||||
const { data: projectAnalyticsCount, isLoading: isProjectAnalyticsCountLoading } = useSWR(
|
||||
workspaceSlug ? ["projectAnalyticsCount", workspaceSlug] : null,
|
||||
workspaceSlug
|
||||
? () =>
|
||||
fetchProjectAnalyticsCount(workspaceSlug.toString(), {
|
||||
fields: "total_work_items,total_completed_work_items",
|
||||
})
|
||||
: null
|
||||
);
|
||||
return (
|
||||
<AnalyticsSectionWrapper
|
||||
title={`${t("workspace_analytics.active_projects")}`}
|
||||
subtitle={selectedDurationLabel}
|
||||
className="md:col-span-2"
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
{isProjectAnalyticsCountLoading &&
|
||||
Array.from({ length: 5 }).map((_, index) => <Loader.Item key={index} height="40px" width="100%" />)}
|
||||
{!isProjectAnalyticsCountLoading &&
|
||||
projectAnalyticsCount?.map((project) => <ActiveProjectItem key={project.id} project={project} />)}
|
||||
</div>
|
||||
</AnalyticsSectionWrapper>
|
||||
);
|
||||
});
|
||||
|
||||
export default ActiveProjects;
|
||||
1
web/core/components/analytics-v2/overview/index.ts
Normal file
1
web/core/components/analytics-v2/overview/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "./root";
|
||||
109
web/core/components/analytics-v2/overview/project-insights.tsx
Normal file
109
web/core/components/analytics-v2/overview/project-insights.tsx
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import { observer } from "mobx-react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useParams } from "next/navigation";
|
||||
import useSWR from "swr";
|
||||
// plane package imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { TChartData } from "@plane/types";
|
||||
// hooks
|
||||
import { useAnalyticsV2 } from "@/hooks/store/use-analytics-v2";
|
||||
// services
|
||||
import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
|
||||
import { AnalyticsV2Service } from "@/services/analytics-v2.service";
|
||||
// plane web components
|
||||
import AnalyticsSectionWrapper from "../analytics-section-wrapper";
|
||||
import AnalyticsV2EmptyState from "../empty-state";
|
||||
import { ProjectInsightsLoader } from "../loaders";
|
||||
|
||||
const RadarChart = dynamic(() =>
|
||||
import("@plane/propel/charts/radar-chart").then((mod) => ({
|
||||
default: mod.RadarChart,
|
||||
}))
|
||||
);
|
||||
|
||||
const analyticsV2Service = new AnalyticsV2Service();
|
||||
|
||||
const ProjectInsights = observer(() => {
|
||||
const params = useParams();
|
||||
const { t } = useTranslation();
|
||||
const workspaceSlug = params.workspaceSlug as string;
|
||||
const { selectedDuration, selectedDurationLabel, selectedProjects } = useAnalyticsV2();
|
||||
const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/analytics-v2/empty-chart-radar" });
|
||||
|
||||
const { data: projectInsightsData, isLoading: isLoadingProjectInsight } = useSWR(
|
||||
`radar-chart-${workspaceSlug}-${selectedDuration}-${selectedProjects}`,
|
||||
() =>
|
||||
analyticsV2Service.getAdvanceAnalyticsCharts<TChartData<string, string>[]>(workspaceSlug, "projects", {
|
||||
date_filter: selectedDuration,
|
||||
...(selectedProjects?.length > 0 && { project_ids: selectedProjects?.join(",") }),
|
||||
})
|
||||
);
|
||||
|
||||
return (
|
||||
<AnalyticsSectionWrapper
|
||||
title={`${t("workspace_analytics.project_insights")}`}
|
||||
subtitle={selectedDurationLabel}
|
||||
className="md:col-span-3"
|
||||
>
|
||||
{isLoadingProjectInsight ? (
|
||||
<ProjectInsightsLoader />
|
||||
) : projectInsightsData && projectInsightsData?.length == 0 ? (
|
||||
<AnalyticsV2EmptyState
|
||||
title={t("workspace_analytics.empty_state_v2.project_insights.title")}
|
||||
description={t("workspace_analytics.empty_state_v2.project_insights.description")}
|
||||
className="h-[300px]"
|
||||
assetPath={resolvedPath}
|
||||
/>
|
||||
) : (
|
||||
<div className="gap-8 lg:flex">
|
||||
{projectInsightsData && (
|
||||
<RadarChart
|
||||
className="h-[350px] w-full lg:w-3/5"
|
||||
data={projectInsightsData}
|
||||
dataKey="key"
|
||||
radars={[
|
||||
{
|
||||
key: "count",
|
||||
name: "Count",
|
||||
fill: "rgba(var(--color-primary-300))",
|
||||
stroke: "rgba(var(--color-primary-300))",
|
||||
fillOpacity: 0.6,
|
||||
dot: {
|
||||
r: 4,
|
||||
fillOpacity: 1,
|
||||
},
|
||||
},
|
||||
]}
|
||||
margin={{ top: 0, right: 40, bottom: 10, left: 40 }}
|
||||
showTooltip
|
||||
angleAxis={{
|
||||
key: "name",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className="w-full lg:w-2/5">
|
||||
<div className="text-sm text-custom-text-300">{t("workspace_analytics.summary_of_projects")}</div>
|
||||
<div className=" mb-3 border-b border-custom-border-100 py-2">{t("workspace_analytics.all_projects")}</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between text-sm text-custom-text-300">
|
||||
<div>{t("workspace_analytics.trend_on_charts")}</div>
|
||||
<div>{t("common.work_items")}</div>
|
||||
</div>
|
||||
{projectInsightsData?.map((item) => (
|
||||
<div key={item.key} className="flex items-center justify-between text-sm text-custom-text-100">
|
||||
<div>{item.name}</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{/* <TrendPiece key={item.key} size='xs' /> */}
|
||||
<div className="text-custom-text-200">{item.count}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</AnalyticsSectionWrapper>
|
||||
);
|
||||
});
|
||||
|
||||
export default ProjectInsights;
|
||||
19
web/core/components/analytics-v2/overview/root.tsx
Normal file
19
web/core/components/analytics-v2/overview/root.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import React from "react";
|
||||
import AnalyticsWrapper from "../analytics-wrapper";
|
||||
import TotalInsights from "../total-insights";
|
||||
import ActiveProjects from "./active-projects";
|
||||
import ProjectInsights from "./project-insights";
|
||||
|
||||
const Overview: React.FC = () => (
|
||||
<AnalyticsWrapper title="Overview">
|
||||
<div className="flex flex-col gap-14">
|
||||
<TotalInsights analyticsType="overview" />
|
||||
<div className="grid grid-cols-1 gap-14 md:grid-cols-5 ">
|
||||
<ProjectInsights />
|
||||
<ActiveProjects />
|
||||
</div>
|
||||
</div>
|
||||
</AnalyticsWrapper>
|
||||
);
|
||||
|
||||
export { Overview };
|
||||
98
web/core/components/analytics-v2/select/analytics-params.tsx
Normal file
98
web/core/components/analytics-v2/select/analytics-params.tsx
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import { useMemo } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Control, Controller, UseFormSetValue } from "react-hook-form";
|
||||
import { Calendar, SlidersHorizontal } from "lucide-react";
|
||||
// plane package imports
|
||||
import { ANALYTICS_V2_X_AXIS_VALUES, ANALYTICS_V2_Y_AXIS_VALUES, ChartYAxisMetric } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { IAnalyticsV2Params } from "@plane/types";
|
||||
import { cn } from "@plane/utils";
|
||||
// plane web components
|
||||
import { AnalyticsV2Service } from "@/services/analytics-v2.service";
|
||||
import { SelectXAxis } from "./select-x-axis";
|
||||
import { SelectYAxis } from "./select-y-axis";
|
||||
|
||||
type Props = {
|
||||
control: Control<IAnalyticsV2Params, unknown>;
|
||||
setValue: UseFormSetValue<IAnalyticsV2Params>;
|
||||
params: IAnalyticsV2Params;
|
||||
workspaceSlug: string;
|
||||
classNames?: string;
|
||||
};
|
||||
|
||||
export const AnalyticsV2SelectParams: React.FC<Props> = observer((props) => {
|
||||
const { control, params, classNames } = props;
|
||||
const xAxisOptions = useMemo(
|
||||
() => ANALYTICS_V2_X_AXIS_VALUES.filter((option) => option.value !== params.group_by),
|
||||
[params.group_by]
|
||||
);
|
||||
const groupByOptions = useMemo(
|
||||
() => ANALYTICS_V2_X_AXIS_VALUES.filter((option) => option.value !== params.x_axis),
|
||||
[params.x_axis]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cn("flex w-full justify-between", classNames)}>
|
||||
<div className={`flex items-center gap-2`}>
|
||||
<Controller
|
||||
name="y_axis"
|
||||
control={control}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<SelectYAxis
|
||||
value={value}
|
||||
onChange={(val: ChartYAxisMetric | null) => {
|
||||
onChange(val);
|
||||
}}
|
||||
options={ANALYTICS_V2_Y_AXIS_VALUES}
|
||||
hiddenOptions={[ChartYAxisMetric.ESTIMATE_POINT_COUNT]}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="x_axis"
|
||||
control={control}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<SelectXAxis
|
||||
value={value}
|
||||
onChange={(val) => {
|
||||
onChange(val);
|
||||
}}
|
||||
label={
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="h-3 w-3" />
|
||||
<span className={cn("text-custom-text-200", value && "text-custom-text-100")}>
|
||||
{xAxisOptions.find((v) => v.value === value)?.label || "Add Property"}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
options={xAxisOptions}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="group_by"
|
||||
control={control}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<SelectXAxis
|
||||
value={value}
|
||||
onChange={(val) => {
|
||||
onChange(val);
|
||||
}}
|
||||
label={
|
||||
<div className="flex items-center gap-2">
|
||||
<SlidersHorizontal className="h-3 w-3" />
|
||||
<span className={cn("text-custom-text-200", value && "text-custom-text-100")}>
|
||||
{groupByOptions.find((v) => v.value === value)?.label || "Add Property"}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
options={groupByOptions}
|
||||
placeholder="Group By"
|
||||
allowNoValue
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
50
web/core/components/analytics-v2/select/duration.tsx
Normal file
50
web/core/components/analytics-v2/select/duration.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
// plane package imports
|
||||
import React, { ReactNode } from "react";
|
||||
import { Calendar } from "lucide-react";
|
||||
// plane package imports
|
||||
import { ANALYTICS_V2_DURATION_FILTER_OPTIONS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { CustomSearchSelect } from "@plane/ui";
|
||||
// types
|
||||
import { TDropdownProps } from "@/components/dropdowns/types";
|
||||
|
||||
type Props = TDropdownProps & {
|
||||
value: string | null;
|
||||
onChange: (val: (typeof ANALYTICS_V2_DURATION_FILTER_OPTIONS)[number]["value"]) => void;
|
||||
//optional
|
||||
button?: ReactNode;
|
||||
dropdownArrow?: boolean;
|
||||
dropdownArrowClassName?: string;
|
||||
onClose?: () => void;
|
||||
renderByDefault?: boolean;
|
||||
tabIndex?: number;
|
||||
};
|
||||
|
||||
function DurationDropdown({ placeholder = "Duration", onChange, value }: Props) {
|
||||
useTranslation();
|
||||
|
||||
const options = ANALYTICS_V2_DURATION_FILTER_OPTIONS.map((option) => ({
|
||||
value: option.value,
|
||||
query: option.name,
|
||||
content: (
|
||||
<div className="flex max-w-[300px] items-center gap-2">
|
||||
<span className="flex-grow truncate">{option.name}</span>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
return (
|
||||
<CustomSearchSelect
|
||||
value={value ? [value] : []}
|
||||
onChange={onChange}
|
||||
options={options}
|
||||
label={
|
||||
<div className="flex items-center gap-2 p-1 ">
|
||||
<Calendar className="h-4 w-4" />
|
||||
{value ? ANALYTICS_V2_DURATION_FILTER_OPTIONS.find((opt) => opt.value === value)?.name : placeholder}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default DurationDropdown;
|
||||
60
web/core/components/analytics-v2/select/project.tsx
Normal file
60
web/core/components/analytics-v2/select/project.tsx
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { Briefcase } from "lucide-react";
|
||||
// plane package imports
|
||||
import { CustomSearchSelect, Logo } from "@plane/ui";
|
||||
// hooks
|
||||
import { useProject } from "@/hooks/store";
|
||||
|
||||
type Props = {
|
||||
value: string[] | undefined;
|
||||
onChange: (val: string[] | null) => void;
|
||||
projectIds: string[] | undefined;
|
||||
};
|
||||
|
||||
export const ProjectSelect: React.FC<Props> = observer((props) => {
|
||||
const { value, onChange, projectIds } = props;
|
||||
const { getProjectById } = useProject();
|
||||
|
||||
const options = projectIds?.map((projectId) => {
|
||||
const projectDetails = getProjectById(projectId);
|
||||
|
||||
return {
|
||||
value: projectDetails?.id,
|
||||
query: `${projectDetails?.name} ${projectDetails?.identifier}`,
|
||||
content: (
|
||||
<div className="flex max-w-[300px] items-center gap-2">
|
||||
{projectDetails?.logo_props ? (
|
||||
<Logo logo={projectDetails?.logo_props} size={16} />
|
||||
) : (
|
||||
<Briefcase className="h-4 w-4" />
|
||||
)}
|
||||
<span className="flex-grow truncate">{projectDetails?.name}</span>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<CustomSearchSelect
|
||||
value={value ?? []}
|
||||
onChange={(val: string[]) => onChange(val)}
|
||||
options={options}
|
||||
label={
|
||||
<div className="flex items-center gap-2 p-1 ">
|
||||
<Briefcase className="h-4 w-4" />
|
||||
{value && value.length > 3
|
||||
? `3+ projects`
|
||||
: value && value.length > 0
|
||||
? projectIds
|
||||
?.filter((p) => value.includes(p))
|
||||
.map((p) => getProjectById(p)?.name)
|
||||
.join(", ")
|
||||
: "All projects"}
|
||||
</div>
|
||||
}
|
||||
multiple
|
||||
/>
|
||||
);
|
||||
});
|
||||
31
web/core/components/analytics-v2/select/select-x-axis.tsx
Normal file
31
web/core/components/analytics-v2/select/select-x-axis.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
"use client";
|
||||
// plane package imports
|
||||
import { ChartXAxisProperty } from "@plane/constants";
|
||||
import { CustomSelect } from "@plane/ui";
|
||||
|
||||
type Props = {
|
||||
value?: ChartXAxisProperty;
|
||||
onChange: (val: ChartXAxisProperty | null) => void;
|
||||
options: { value: ChartXAxisProperty; label: string }[];
|
||||
placeholder?: string;
|
||||
hiddenOptions?: ChartXAxisProperty[];
|
||||
allowNoValue?: boolean;
|
||||
label?: string | JSX.Element;
|
||||
};
|
||||
|
||||
export const SelectXAxis: React.FC<Props> = (props) => {
|
||||
const { value, onChange, options, hiddenOptions, allowNoValue, label } = props;
|
||||
return (
|
||||
<CustomSelect value={value} label={label} onChange={onChange} maxHeight="lg">
|
||||
{allowNoValue && <CustomSelect.Option value={null}>No value</CustomSelect.Option>}
|
||||
{options.map((item) => {
|
||||
if (hiddenOptions?.includes(item.value)) return null;
|
||||
return (
|
||||
<CustomSelect.Option key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</CustomSelect.Option>
|
||||
);
|
||||
})}
|
||||
</CustomSelect>
|
||||
);
|
||||
};
|
||||
67
web/core/components/analytics-v2/select/select-y-axis.tsx
Normal file
67
web/core/components/analytics-v2/select/select-y-axis.tsx
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Briefcase } from "lucide-react";
|
||||
// plane package imports
|
||||
import { ChartYAxisMetric } from "@plane/constants";
|
||||
import { CustomSelect } from "@plane/ui";
|
||||
// hooks
|
||||
import { useProjectEstimates } from "@/hooks/store";
|
||||
// plane web constants
|
||||
import { EEstimateSystem } from "@/plane-web/constants/estimates";
|
||||
|
||||
type Props = {
|
||||
value: ChartYAxisMetric;
|
||||
onChange: (val: ChartYAxisMetric | null) => void;
|
||||
hiddenOptions?: ChartYAxisMetric[];
|
||||
options: { value: ChartYAxisMetric; label: string }[];
|
||||
};
|
||||
|
||||
export const SelectYAxis: React.FC<Props> = observer(({ value, onChange, hiddenOptions, options }) => {
|
||||
// hooks
|
||||
const { projectId } = useParams();
|
||||
const { areEstimateEnabledByProjectId, currentActiveEstimateId, estimateById } = useProjectEstimates();
|
||||
|
||||
const isEstimateEnabled = (analyticsOption: string) => {
|
||||
if (analyticsOption === "estimate") {
|
||||
if (
|
||||
projectId &&
|
||||
currentActiveEstimateId &&
|
||||
areEstimateEnabledByProjectId(projectId.toString()) &&
|
||||
estimateById(currentActiveEstimateId)?.type === EEstimateSystem.POINTS
|
||||
) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
return (
|
||||
<CustomSelect
|
||||
value={value}
|
||||
label={
|
||||
<div className="flex items-center gap-2">
|
||||
<Briefcase className="h-3 w-3" />
|
||||
<span>{options.find((v) => v.value === value)?.label ?? "Add Metric"}</span>
|
||||
</div>
|
||||
}
|
||||
onChange={onChange}
|
||||
maxHeight="lg"
|
||||
>
|
||||
{options.map((item) => {
|
||||
if (hiddenOptions?.includes(item.value)) return null;
|
||||
return (
|
||||
isEstimateEnabled(item.value) && (
|
||||
<CustomSelect.Option key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</CustomSelect.Option>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</CustomSelect>
|
||||
);
|
||||
});
|
||||
58
web/core/components/analytics-v2/total-insights.tsx
Normal file
58
web/core/components/analytics-v2/total-insights.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
// plane package imports
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useParams } from "next/navigation";
|
||||
import useSWR from "swr";
|
||||
import { insightsFields } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { IAnalyticsResponseV2, TAnalyticsTabsV2Base } from "@plane/types";
|
||||
//hooks
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
import { useAnalyticsV2 } from "@/hooks/store/use-analytics-v2";
|
||||
//services
|
||||
import { AnalyticsV2Service } from "@/services/analytics-v2.service";
|
||||
// plane web components
|
||||
import InsightCard from "./insight-card";
|
||||
|
||||
const analyticsV2Service = new AnalyticsV2Service();
|
||||
|
||||
const TotalInsights: React.FC<{ analyticsType: TAnalyticsTabsV2Base; peekView?: boolean }> = observer(
|
||||
({ analyticsType, peekView }) => {
|
||||
const params = useParams();
|
||||
const workspaceSlug = params.workspaceSlug as string;
|
||||
const { t } = useTranslation();
|
||||
const { selectedDuration, selectedProjects, selectedDurationLabel } = useAnalyticsV2();
|
||||
|
||||
const { data: totalInsightsData, isLoading } = useSWR(
|
||||
`total-insights-${analyticsType}-${selectedDuration}-${selectedProjects}`,
|
||||
() =>
|
||||
analyticsV2Service.getAdvanceAnalytics<IAnalyticsResponseV2>(workspaceSlug, analyticsType, {
|
||||
date_filter: selectedDuration,
|
||||
...(selectedProjects?.length > 0 ? { project_ids: selectedProjects.join(",") } : {}),
|
||||
})
|
||||
);
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"grid grid-cols-1 gap-8 sm:grid-cols-2 md:gap-10",
|
||||
!peekView
|
||||
? insightsFields[analyticsType].length % 5 === 0
|
||||
? "gap-10 lg:grid-cols-5"
|
||||
: "gap-8 lg:grid-cols-4"
|
||||
: "grid-cols-2"
|
||||
)}
|
||||
>
|
||||
{insightsFields[analyticsType]?.map((item: string) => (
|
||||
<InsightCard
|
||||
key={`${analyticsType}-${item}`}
|
||||
isLoading={isLoading}
|
||||
data={totalInsightsData?.[item]}
|
||||
label={t(`workspace_analytics.${item}`)}
|
||||
versus={selectedDurationLabel}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export default TotalInsights;
|
||||
47
web/core/components/analytics-v2/trend-piece.tsx
Normal file
47
web/core/components/analytics-v2/trend-piece.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
// plane package imports
|
||||
import React from "react";
|
||||
import { TrendingDown, TrendingUp } from "lucide-react";
|
||||
import { cn } from "@plane/utils";
|
||||
// plane web components
|
||||
|
||||
type Props = {
|
||||
percentage: number;
|
||||
className?: string;
|
||||
size?: "xs" | "sm" | "md" | "lg";
|
||||
};
|
||||
|
||||
const sizeConfig = {
|
||||
xs: {
|
||||
text: "text-xs",
|
||||
icon: "w-3 h-3",
|
||||
},
|
||||
sm: {
|
||||
text: "text-sm",
|
||||
icon: "w-4 h-4",
|
||||
},
|
||||
md: {
|
||||
text: "text-base",
|
||||
icon: "w-5 h-5",
|
||||
},
|
||||
lg: {
|
||||
text: "text-lg",
|
||||
icon: "w-6 h-6",
|
||||
},
|
||||
} as const;
|
||||
|
||||
const TrendPiece = (props: Props) => {
|
||||
const { percentage, className, size = "sm" } = props;
|
||||
const isPositive = percentage > 0;
|
||||
const config = sizeConfig[size];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("flex items-center gap-1", isPositive ? "text-green-500" : "text-red-500", config.text, className)}
|
||||
>
|
||||
{isPositive ? <TrendingUp className={config.icon} /> : <TrendingDown className={config.icon} />}
|
||||
{Math.round(Math.abs(percentage))}%
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TrendPiece;
|
||||
|
|
@ -0,0 +1,119 @@
|
|||
import { useMemo } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import useSWR from "swr";
|
||||
// plane package imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { AreaChart } from "@plane/propel/charts/area-chart";
|
||||
import { IChartResponseV2, TChartData } from "@plane/types";
|
||||
import { renderFormattedDate } from "@plane/utils";
|
||||
// hooks
|
||||
import { useAnalyticsV2 } from "@/hooks/store/use-analytics-v2";
|
||||
// services
|
||||
import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
|
||||
import { AnalyticsV2Service } from "@/services/analytics-v2.service";
|
||||
// plane web components
|
||||
import AnalyticsSectionWrapper from "../analytics-section-wrapper";
|
||||
import AnalyticsV2EmptyState from "../empty-state";
|
||||
import { ChartLoader } from "../loaders";
|
||||
|
||||
const analyticsV2Service = new AnalyticsV2Service();
|
||||
const CreatedVsResolved = observer(() => {
|
||||
const { selectedDuration, selectedDurationLabel, selectedProjects } = useAnalyticsV2();
|
||||
const params = useParams();
|
||||
const { t } = useTranslation();
|
||||
const workspaceSlug = params.workspaceSlug as string;
|
||||
const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/analytics-v2/empty-chart-area" });
|
||||
const { data: createdVsResolvedData, isLoading: isCreatedVsResolvedLoading } = useSWR(
|
||||
`created-vs-resolved-${workspaceSlug}-${selectedDuration}-${selectedProjects}`,
|
||||
() =>
|
||||
analyticsV2Service.getAdvanceAnalyticsCharts<IChartResponseV2>(workspaceSlug, "work-items", {
|
||||
date_filter: selectedDuration,
|
||||
...(selectedProjects?.length > 0 && { project_ids: selectedProjects?.join(",") }),
|
||||
})
|
||||
);
|
||||
const parsedData: TChartData<string, string>[] = useMemo(() => {
|
||||
if (!createdVsResolvedData?.data) return [];
|
||||
return createdVsResolvedData.data.map((datum) => ({
|
||||
...datum,
|
||||
[datum.key]: datum.count,
|
||||
name: renderFormattedDate(datum.key) ?? datum.key,
|
||||
}));
|
||||
}, [createdVsResolvedData]);
|
||||
|
||||
const areas = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: "completed_issues",
|
||||
label: "Resolved",
|
||||
fill: "#19803833",
|
||||
fillOpacity: 1,
|
||||
stackId: "bar-one",
|
||||
showDot: false,
|
||||
smoothCurves: true,
|
||||
strokeColor: "#198038",
|
||||
strokeOpacity: 1,
|
||||
},
|
||||
{
|
||||
key: "created_issues",
|
||||
label: "Created",
|
||||
fill: "#1192E833",
|
||||
fillOpacity: 1,
|
||||
stackId: "bar-one",
|
||||
showDot: false,
|
||||
smoothCurves: true,
|
||||
strokeColor: "#1192E8",
|
||||
strokeOpacity: 1,
|
||||
},
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<AnalyticsSectionWrapper
|
||||
title={t("workspace_analytics.created_vs_resolved")}
|
||||
subtitle={selectedDurationLabel}
|
||||
className="col-span-1"
|
||||
>
|
||||
{isCreatedVsResolvedLoading ? (
|
||||
<ChartLoader />
|
||||
) : parsedData && parsedData.length > 0 ? (
|
||||
<AreaChart
|
||||
className="h-[350px] w-full"
|
||||
data={parsedData}
|
||||
areas={areas}
|
||||
xAxis={{
|
||||
key: "name",
|
||||
label: "Date",
|
||||
}}
|
||||
yAxis={{
|
||||
key: "count",
|
||||
label: "Number of Issues",
|
||||
offset: -30,
|
||||
dx: -22,
|
||||
}}
|
||||
legend={{
|
||||
align: "left",
|
||||
verticalAlign: "bottom",
|
||||
layout: "horizontal",
|
||||
wrapperStyles: {
|
||||
justifyContent: "start",
|
||||
alignContent: "start",
|
||||
paddingLeft: "40px",
|
||||
paddingTop: "10px",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<AnalyticsV2EmptyState
|
||||
title={t("workspace_analytics.empty_state_v2.created_vs_resolved.title")}
|
||||
description={t("workspace_analytics.empty_state_v2.created_vs_resolved.description")}
|
||||
className="h-[350px]"
|
||||
assetPath={resolvedPath}
|
||||
/>
|
||||
)}
|
||||
</AnalyticsSectionWrapper>
|
||||
);
|
||||
});
|
||||
|
||||
export default CreatedVsResolved;
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useForm } from "react-hook-form";
|
||||
// plane package imports
|
||||
import { ChartXAxisProperty, ChartYAxisMetric } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { IAnalyticsV2Params } from "@plane/types";
|
||||
import { cn } from "@plane/utils";
|
||||
// plane web components
|
||||
import AnalyticsSectionWrapper from "../analytics-section-wrapper";
|
||||
import { AnalyticsV2SelectParams } from "../select/analytics-params";
|
||||
import PriorityChart from "./priority-chart";
|
||||
|
||||
const defaultValues: IAnalyticsV2Params = {
|
||||
x_axis: ChartXAxisProperty.PRIORITY,
|
||||
y_axis: ChartYAxisMetric.WORK_ITEM_COUNT,
|
||||
};
|
||||
|
||||
const CustomizedInsights = observer(({ peekView }: { peekView?: boolean }) => {
|
||||
const { t } = useTranslation();
|
||||
const { workspaceSlug } = useParams();
|
||||
const { control, watch, setValue } = useForm<IAnalyticsV2Params>({
|
||||
defaultValues: {
|
||||
...defaultValues,
|
||||
},
|
||||
});
|
||||
|
||||
const params = {
|
||||
x_axis: watch("x_axis"),
|
||||
y_axis: watch("y_axis"),
|
||||
group_by: watch("group_by"),
|
||||
};
|
||||
|
||||
return (
|
||||
<AnalyticsSectionWrapper
|
||||
title={t("workspace_analytics.customized_insights")}
|
||||
className="col-span-1"
|
||||
headerClassName={cn(peekView ? "flex-col items-start" : "")}
|
||||
actions={
|
||||
<AnalyticsV2SelectParams
|
||||
control={control}
|
||||
setValue={setValue}
|
||||
params={params}
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<PriorityChart x_axis={params.x_axis} y_axis={params.y_axis} group_by={params.group_by} />
|
||||
</AnalyticsSectionWrapper>
|
||||
);
|
||||
});
|
||||
|
||||
export default CustomizedInsights;
|
||||
1
web/core/components/analytics-v2/work-items/index.ts
Normal file
1
web/core/components/analytics-v2/work-items/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "./root";
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Tab } from "@headlessui/react";
|
||||
// plane package imports
|
||||
import { IProject } from "@plane/types";
|
||||
import { Spinner } from "@plane/ui";
|
||||
// hooks
|
||||
import { useAnalyticsV2 } from "@/hooks/store";
|
||||
// plane web components
|
||||
import TotalInsights from "../../total-insights";
|
||||
import CreatedVsResolved from "../created-vs-resolved";
|
||||
import CustomizedInsights from "../customized-insights";
|
||||
import WorkItemsInsightTable from "../workitems-insight-table";
|
||||
|
||||
type Props = {
|
||||
fullScreen: boolean;
|
||||
projectDetails: IProject | undefined;
|
||||
};
|
||||
|
||||
export const WorkItemsModalMainContent: React.FC<Props> = observer((props) => {
|
||||
const { projectDetails, fullScreen } = props;
|
||||
const { updateSelectedProjects } = useAnalyticsV2();
|
||||
const [isProjectConfigured, setIsProjectConfigured] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectDetails?.id) return;
|
||||
updateSelectedProjects([projectDetails?.id ?? ""]);
|
||||
setIsProjectConfigured(true);
|
||||
}, [projectDetails?.id, updateSelectedProjects]);
|
||||
|
||||
if (!isProjectConfigured)
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Tab.Group as={React.Fragment}>
|
||||
<div className="flex flex-col gap-14 overflow-y-auto p-6">
|
||||
<TotalInsights analyticsType="work-items" peekView={!fullScreen} />
|
||||
<CreatedVsResolved />
|
||||
<CustomizedInsights peekView={!fullScreen} />
|
||||
<WorkItemsInsightTable />
|
||||
</div>
|
||||
</Tab.Group>
|
||||
);
|
||||
});
|
||||
37
web/core/components/analytics-v2/work-items/modal/header.tsx
Normal file
37
web/core/components/analytics-v2/work-items/modal/header.tsx
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { observer } from "mobx-react";
|
||||
|
||||
// icons
|
||||
import { Expand, Shrink, X } from "lucide-react";
|
||||
|
||||
type Props = {
|
||||
fullScreen: boolean;
|
||||
handleClose: () => void;
|
||||
setFullScreen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
title: string;
|
||||
};
|
||||
|
||||
export const WorkItemsModalHeader: React.FC<Props> = observer((props) => {
|
||||
const { fullScreen, handleClose, setFullScreen, title } = props;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4 bg-custom-background-100 px-5 py-4 text-sm">
|
||||
<h3 className="break-words">Analytics for {title}</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="hidden place-items-center p-1 text-custom-text-200 hover:text-custom-text-100 md:grid"
|
||||
onClick={() => setFullScreen((prevData) => !prevData)}
|
||||
>
|
||||
{fullScreen ? <Shrink size={14} strokeWidth={2} /> : <Expand size={14} strokeWidth={2} />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="grid place-items-center p-1 text-custom-text-200 hover:text-custom-text-100"
|
||||
onClick={handleClose}
|
||||
>
|
||||
<X size={14} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
64
web/core/components/analytics-v2/work-items/modal/index.tsx
Normal file
64
web/core/components/analytics-v2/work-items/modal/index.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import React, { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
// plane package imports
|
||||
import { IProject } from "@plane/types";
|
||||
// plane web components
|
||||
import { WorkItemsModalMainContent } from "./content";
|
||||
import { WorkItemsModalHeader } from "./header";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
projectDetails?: IProject | undefined;
|
||||
};
|
||||
|
||||
export const WorkItemsModal: React.FC<Props> = observer((props) => {
|
||||
const { isOpen, onClose, projectDetails } = props;
|
||||
|
||||
const [fullScreen, setFullScreen] = useState(false);
|
||||
|
||||
const handleClose = () => {
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Transition.Root appear show={isOpen} as={React.Fragment}>
|
||||
<Dialog as="div" className="relative z-20" onClose={handleClose}>
|
||||
<Transition.Child
|
||||
as={React.Fragment}
|
||||
enter="transition-transform duration-300"
|
||||
enterFrom="translate-x-full"
|
||||
enterTo="translate-x-0"
|
||||
leave="transition-transform duration-200"
|
||||
leaveFrom="translate-x-0"
|
||||
leaveTo="translate-x-full"
|
||||
>
|
||||
<div className="fixed inset-0 z-20 h-full w-full overflow-y-auto">
|
||||
<Dialog.Panel>
|
||||
<div
|
||||
className={`fixed right-0 top-0 z-20 h-full bg-custom-background-100 shadow-custom-shadow-md ${
|
||||
fullScreen ? "w-full p-2" : "w-full sm:w-full md:w-1/2"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`flex h-full flex-col overflow-hidden border-custom-border-200 bg-custom-background-100 text-left ${
|
||||
fullScreen ? "rounded-lg border" : "border-l"
|
||||
}`}
|
||||
>
|
||||
<WorkItemsModalHeader
|
||||
fullScreen={fullScreen}
|
||||
handleClose={handleClose}
|
||||
setFullScreen={setFullScreen}
|
||||
title={projectDetails?.name ?? ""}
|
||||
/>
|
||||
<WorkItemsModalMainContent fullScreen={fullScreen} projectDetails={projectDetails} />
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</div>
|
||||
</Transition.Child>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
);
|
||||
});
|
||||
230
web/core/components/analytics-v2/work-items/priority-chart.tsx
Normal file
230
web/core/components/analytics-v2/work-items/priority-chart.tsx
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
import { useMemo } from "react";
|
||||
import { ColumnDef, Row, Table } from "@tanstack/react-table";
|
||||
import { mkConfig, generateCsv, download } from "export-to-csv";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useTheme } from "next-themes";
|
||||
import useSWR from "swr";
|
||||
// plane package imports
|
||||
import { Download } from "lucide-react";
|
||||
import {
|
||||
ANALYTICS_V2_X_AXIS_VALUES,
|
||||
ANALYTICS_V2_Y_AXIS_VALUES,
|
||||
CHART_COLOR_PALETTES,
|
||||
ChartXAxisDateGrouping,
|
||||
ChartXAxisProperty,
|
||||
ChartYAxisMetric,
|
||||
EChartModels,
|
||||
} from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { BarChart } from "@plane/propel/charts/bar-chart";
|
||||
import { IChartResponseV2 } from "@plane/types";
|
||||
import { TBarItem, TChart, TChartData, TChartDatum } from "@plane/types/src/charts";
|
||||
// plane web components
|
||||
import { Button } from "@plane/ui";
|
||||
import { generateExtendedColors, parseChartData } from "@/components/chart/utils";
|
||||
// hooks
|
||||
import { useProjectState } from "@/hooks/store";
|
||||
import { useAnalyticsV2 } from "@/hooks/store/use-analytics-v2";
|
||||
import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
|
||||
import { AnalyticsV2Service } from "@/services/analytics-v2.service";
|
||||
import AnalyticsV2EmptyState from "../empty-state";
|
||||
import { DataTable } from "../insight-table/data-table";
|
||||
import { ChartLoader } from "../loaders";
|
||||
import { generateBarColor } from "./utils";
|
||||
|
||||
interface Props {
|
||||
x_axis: ChartXAxisProperty;
|
||||
y_axis: ChartYAxisMetric;
|
||||
group_by?: ChartXAxisProperty;
|
||||
x_axis_date_grouping?: ChartXAxisDateGrouping;
|
||||
}
|
||||
|
||||
const analyticsV2Service = new AnalyticsV2Service();
|
||||
const PriorityChart = observer((props: Props) => {
|
||||
const { x_axis, y_axis, group_by } = props;
|
||||
const { t } = useTranslation();
|
||||
const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/analytics-v2/empty-chart-bar" });
|
||||
// store hooks
|
||||
const { selectedDuration, selectedProjects } = useAnalyticsV2();
|
||||
const { workspaceStates } = useProjectState();
|
||||
const { resolvedTheme } = useTheme();
|
||||
// router
|
||||
const params = useParams();
|
||||
const workspaceSlug = params.workspaceSlug as string;
|
||||
|
||||
const { data: priorityChartData, isLoading: priorityChartLoading } = useSWR(
|
||||
`customized-insights-chart-${workspaceSlug}-${selectedDuration}-${selectedProjects}-${props.x_axis}-${props.y_axis}-${props.group_by}`,
|
||||
() =>
|
||||
analyticsV2Service.getAdvanceAnalyticsCharts<TChart>(workspaceSlug, "custom-work-items", {
|
||||
date_filter: selectedDuration,
|
||||
...(selectedProjects?.length > 0 && { project_ids: selectedProjects?.join(",") }),
|
||||
...props,
|
||||
})
|
||||
);
|
||||
const parsedData = useMemo(
|
||||
() =>
|
||||
priorityChartData && parseChartData(priorityChartData, props.x_axis, props.group_by, props.x_axis_date_grouping),
|
||||
[priorityChartData, props.x_axis, props.group_by, props.x_axis_date_grouping]
|
||||
);
|
||||
const chart_model = props.group_by ? EChartModels.STACKED : EChartModels.BASIC;
|
||||
|
||||
const bars: TBarItem<string>[] = useMemo(() => {
|
||||
if (!parsedData) return [];
|
||||
let parsedBars: TBarItem<string>[];
|
||||
const schemaKeys = Object.keys(parsedData.schema);
|
||||
const baseColors = CHART_COLOR_PALETTES[0]?.[resolvedTheme === "dark" ? "dark" : "light"];
|
||||
const extendedColors = generateExtendedColors(baseColors ?? [], schemaKeys.length);
|
||||
if (chart_model === EChartModels.BASIC) {
|
||||
parsedBars = [
|
||||
{
|
||||
key: "count",
|
||||
label: "Count",
|
||||
stackId: "bar-one",
|
||||
fill: (payload) => generateBarColor(payload.key, { x_axis, y_axis, group_by }, baseColors, workspaceStates),
|
||||
textClassName: "",
|
||||
showPercentage: false,
|
||||
showTopBorderRadius: () => true,
|
||||
showBottomBorderRadius: () => true,
|
||||
},
|
||||
];
|
||||
} else if (chart_model === EChartModels.STACKED && parsedData.schema) {
|
||||
const parsedExtremes: {
|
||||
[key: string]: {
|
||||
top: string | null;
|
||||
bottom: string | null;
|
||||
};
|
||||
} = {};
|
||||
parsedData.data.forEach((datum) => {
|
||||
let top = null;
|
||||
let bottom = null;
|
||||
for (let i = 0; i < schemaKeys.length; i++) {
|
||||
const key = schemaKeys[i];
|
||||
if (datum[key] === 0) continue;
|
||||
if (!bottom) bottom = key;
|
||||
top = key;
|
||||
}
|
||||
parsedExtremes[datum.key] = { top, bottom };
|
||||
});
|
||||
|
||||
parsedBars = schemaKeys.map((key, index) => ({
|
||||
key: key,
|
||||
label: parsedData.schema[key],
|
||||
stackId: "bar-one",
|
||||
fill: extendedColors[index],
|
||||
textClassName: "",
|
||||
showPercentage: false,
|
||||
showTopBorderRadius: (value, payload: TChartDatum) => parsedExtremes[payload.key].top === value,
|
||||
showBottomBorderRadius: (value, payload: TChartDatum) => parsedExtremes[payload.key].bottom === value,
|
||||
}));
|
||||
} else {
|
||||
parsedBars = [];
|
||||
}
|
||||
return parsedBars;
|
||||
}, [chart_model, group_by, parsedData, resolvedTheme, workspaceStates, x_axis, y_axis]);
|
||||
|
||||
const defaultColumns: ColumnDef<TChartDatum>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: () => "Name",
|
||||
},
|
||||
{
|
||||
accessorKey: "count",
|
||||
header: () => <div className="text-right">Count</div>,
|
||||
cell: ({ row }) => <div className="text-right">{row.original.count}</div>,
|
||||
},
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
const columns: ColumnDef<TChartDatum>[] = useMemo(
|
||||
() =>
|
||||
parsedData
|
||||
? Object.keys(parsedData?.schema ?? {}).map((key) => ({
|
||||
accessorKey: key,
|
||||
header: () => <div className="text-right">{parsedData.schema[key]}</div>,
|
||||
cell: ({ row }) => <div className="text-right">{row.original[key]}</div>,
|
||||
}))
|
||||
: [],
|
||||
[parsedData]
|
||||
);
|
||||
|
||||
const csvConfig = mkConfig({
|
||||
fieldSeparator: ",",
|
||||
filename: `${workspaceSlug}-analytics`,
|
||||
decimalSeparator: ".",
|
||||
useKeysAsHeaders: true,
|
||||
});
|
||||
|
||||
const exportCSV = (rows: Row<TChartDatum>[]) => {
|
||||
const rowData = rows.map((row) => ({
|
||||
name: row.original.name,
|
||||
count: row.original.count,
|
||||
}));
|
||||
const csv = generateCsv(csvConfig)(rowData);
|
||||
download(csvConfig)(csv);
|
||||
};
|
||||
|
||||
const yAxisLabel = useMemo(
|
||||
() => ANALYTICS_V2_Y_AXIS_VALUES.find((item) => item.value === props.y_axis)?.label ?? props.y_axis,
|
||||
[props.y_axis]
|
||||
);
|
||||
const xAxisLabel = useMemo(
|
||||
() => ANALYTICS_V2_X_AXIS_VALUES.find((item) => item.value === props.x_axis)?.label ?? props.x_axis,
|
||||
[props.x_axis]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-12 ">
|
||||
{priorityChartLoading ? (
|
||||
<ChartLoader />
|
||||
) : parsedData?.data && parsedData.data.length > 0 ? (
|
||||
<>
|
||||
<BarChart
|
||||
className="h-[370px] w-full"
|
||||
data={parsedData.data}
|
||||
bars={bars}
|
||||
margin={{
|
||||
bottom: 30,
|
||||
}}
|
||||
xAxis={{
|
||||
key: "name",
|
||||
label: xAxisLabel.replace("_", " "),
|
||||
dy: 30,
|
||||
}}
|
||||
yAxis={{
|
||||
key: "count",
|
||||
label: yAxisLabel,
|
||||
offset: -40,
|
||||
dx: -26,
|
||||
}}
|
||||
/>
|
||||
<DataTable
|
||||
data={parsedData.data}
|
||||
columns={[...defaultColumns, ...columns]}
|
||||
searchPlaceholder={`${parsedData.data.length} ${yAxisLabel}`}
|
||||
actions={(table: Table<TChartDatum>) => (
|
||||
<Button
|
||||
variant="accent-primary"
|
||||
prependIcon={<Download className="h-3.5 w-3.5" />}
|
||||
onClick={() => exportCSV(table.getFilteredRowModel().rows)}
|
||||
>
|
||||
<div>{t("exporter.csv.short_description")}</div>
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<AnalyticsV2EmptyState
|
||||
title={t("workspace_analytics.empty_state_v2.customized_insights.title")}
|
||||
description={t("workspace_analytics.empty_state_v2.customized_insights.description")}
|
||||
className="h-[350px]"
|
||||
assetPath={resolvedPath}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default PriorityChart;
|
||||
19
web/core/components/analytics-v2/work-items/root.tsx
Normal file
19
web/core/components/analytics-v2/work-items/root.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import React from "react";
|
||||
import AnalyticsWrapper from "../analytics-wrapper";
|
||||
import TotalInsights from "../total-insights";
|
||||
import CreatedVsResolved from "./created-vs-resolved";
|
||||
import CustomizedInsights from "./customized-insights";
|
||||
import WorkItemsInsightTable from "./workitems-insight-table";
|
||||
|
||||
const WorkItems: React.FC = () => (
|
||||
<AnalyticsWrapper title="Work Items">
|
||||
<div className="flex flex-col gap-14">
|
||||
<TotalInsights analyticsType="work-items" />
|
||||
<CreatedVsResolved />
|
||||
<CustomizedInsights />
|
||||
<WorkItemsInsightTable />
|
||||
</div>
|
||||
</AnalyticsWrapper>
|
||||
);
|
||||
|
||||
export { WorkItems };
|
||||
47
web/core/components/analytics-v2/work-items/utils.ts
Normal file
47
web/core/components/analytics-v2/work-items/utils.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
// plane package imports
|
||||
import { ChartXAxisProperty, ChartYAxisMetric } from "@plane/constants";
|
||||
import { IState } from "@plane/types";
|
||||
|
||||
interface ParamsProps {
|
||||
x_axis: ChartXAxisProperty;
|
||||
y_axis: ChartYAxisMetric;
|
||||
group_by?: ChartXAxisProperty;
|
||||
}
|
||||
|
||||
export const generateBarColor = (
|
||||
value: string | null | undefined,
|
||||
params: ParamsProps,
|
||||
baseColors: string[],
|
||||
workspaceStates?: IState[]
|
||||
): string => {
|
||||
if (!value) return baseColors[0];
|
||||
let color = baseColors[0];
|
||||
// Priority
|
||||
if (params.x_axis === ChartXAxisProperty.PRIORITY) {
|
||||
color =
|
||||
value === "urgent"
|
||||
? "#ef4444"
|
||||
: value === "high"
|
||||
? "#f97316"
|
||||
: value === "medium"
|
||||
? "#eab308"
|
||||
: value === "low"
|
||||
? "#22c55e"
|
||||
: "#ced4da";
|
||||
}
|
||||
|
||||
// State
|
||||
if (params.x_axis === ChartXAxisProperty.STATES) {
|
||||
if (workspaceStates && workspaceStates.length > 0) {
|
||||
const state = workspaceStates.find((s) => s.id === value);
|
||||
if (state) {
|
||||
color = state.color;
|
||||
} else {
|
||||
const index = Math.abs(value.split("").reduce((acc, char) => acc + char.charCodeAt(0), 0)) % baseColors.length;
|
||||
color = baseColors[index];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return color;
|
||||
};
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
import { useMemo } from "react";
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import useSWR from "swr";
|
||||
import { Briefcase } from "lucide-react";
|
||||
// plane package imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { WorkItemInsightColumns, AnalyticsTableDataMap } from "@plane/types";
|
||||
// plane web components
|
||||
import { Logo } from "@/components/common/logo";
|
||||
// hooks
|
||||
import { useAnalyticsV2 } from "@/hooks/store/use-analytics-v2";
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { AnalyticsV2Service } from "@/services/analytics-v2.service";
|
||||
// plane web components
|
||||
import { InsightTable } from "../insight-table";
|
||||
|
||||
const analyticsV2Service = new AnalyticsV2Service();
|
||||
|
||||
const WorkItemsInsightTable = observer(() => {
|
||||
// router
|
||||
const params = useParams();
|
||||
const workspaceSlug = params.workspaceSlug as string;
|
||||
const { t } = useTranslation();
|
||||
// store hooks
|
||||
const { getProjectById } = useProject();
|
||||
const { selectedDuration, selectedProjects } = useAnalyticsV2();
|
||||
const { data: workItemsData, isLoading } = useSWR(
|
||||
`insights-table-work-items-${workspaceSlug}-${selectedDuration}-${selectedProjects}`,
|
||||
() =>
|
||||
analyticsV2Service.getAdvanceAnalyticsStats<WorkItemInsightColumns[]>(workspaceSlug, "work-items", {
|
||||
date_filter: selectedDuration,
|
||||
...(selectedProjects?.length > 0 ? { project_ids: selectedProjects.join(",") } : {}),
|
||||
})
|
||||
);
|
||||
// derived values
|
||||
const columnsLabels: Record<string, string> = {
|
||||
backlog_work_items: t("workspace_projects.state.backlog"),
|
||||
started_work_items: t("workspace_projects.state.started"),
|
||||
un_started_work_items: t("workspace_projects.state.unstarted"),
|
||||
completed_work_items: t("workspace_projects.state.completed"),
|
||||
cancelled_work_items: t("workspace_projects.state.cancelled"),
|
||||
project__name: t("common.project"),
|
||||
};
|
||||
const columns = useMemo(
|
||||
() =>
|
||||
[
|
||||
{
|
||||
accessorKey: "project__name",
|
||||
header: () => <div className="text-left">{columnsLabels["project__name"]}</div>,
|
||||
cell: ({ row }) => {
|
||||
const project = getProjectById(row.original.project_id);
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{project?.logo_props ? <Logo logo={project.logo_props} size={18} /> : <Briefcase className="h-4 w-4" />}
|
||||
{project?.name}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "backlog_work_items",
|
||||
header: () => <div className="text-right">{columnsLabels["backlog_work_items"]}</div>,
|
||||
cell: ({ row }) => <div className="text-right">{row.original.backlog_work_items}</div>,
|
||||
},
|
||||
{
|
||||
accessorKey: "started_work_items",
|
||||
header: () => <div className="text-right">{columnsLabels["started_work_items"]}</div>,
|
||||
cell: ({ row }) => <div className="text-right">{row.original.started_work_items}</div>,
|
||||
},
|
||||
{
|
||||
accessorKey: "un_started_work_items",
|
||||
header: () => <div className="text-right">{columnsLabels["un_started_work_items"]}</div>,
|
||||
cell: ({ row }) => <div className="text-right">{row.original.un_started_work_items}</div>,
|
||||
},
|
||||
{
|
||||
accessorKey: "completed_work_items",
|
||||
header: () => <div className="text-right">{columnsLabels["completed_work_items"]}</div>,
|
||||
cell: ({ row }) => <div className="text-right">{row.original.completed_work_items}</div>,
|
||||
},
|
||||
{
|
||||
accessorKey: "cancelled_work_items",
|
||||
header: () => <div className="text-right">{columnsLabels["cancelled_work_items"]}</div>,
|
||||
cell: ({ row }) => <div className="text-right">{row.original.cancelled_work_items}</div>,
|
||||
},
|
||||
] as ColumnDef<AnalyticsTableDataMap["work-items"]>[],
|
||||
[getProjectById]
|
||||
);
|
||||
|
||||
return (
|
||||
<InsightTable<"work-items">
|
||||
analyticsType="work-items"
|
||||
data={workItemsData}
|
||||
isLoading={isLoading}
|
||||
columns={columns}
|
||||
columnsLabels={columnsLabels}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export default WorkItemsInsightTable;
|
||||
166
web/core/components/chart/utils.ts
Normal file
166
web/core/components/chart/utils.ts
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
import { getWeekOfMonth, isValid } from "date-fns";
|
||||
import { CHART_X_AXIS_DATE_PROPERTIES, ChartXAxisDateGrouping, ChartXAxisProperty, TO_CAPITALIZE_PROPERTIES } from "@plane/constants";
|
||||
import { TChart, TChartDatum } from "@plane/types";
|
||||
import { capitalizeFirstLetter, hexToHsl, hslToHex, renderFormattedDate } from "@plane/utils";
|
||||
import { renderFormattedDateWithoutYear } from "@/helpers/date-time.helper";
|
||||
|
||||
const getDateGroupingName = (date: string, dateGrouping: ChartXAxisDateGrouping): string => {
|
||||
if (!date || ["none", "null"].includes(date.toLowerCase())) return "None";
|
||||
|
||||
const formattedData = new Date(date);
|
||||
const isValidDate = isValid(formattedData);
|
||||
|
||||
if (!isValidDate) return date;
|
||||
|
||||
const year = formattedData.getFullYear();
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
const isCurrentYear = year === currentYear;
|
||||
|
||||
let parsedName: string | undefined;
|
||||
|
||||
switch (dateGrouping) {
|
||||
case ChartXAxisDateGrouping.DAY:
|
||||
if (isCurrentYear) parsedName = renderFormattedDateWithoutYear(formattedData);
|
||||
else parsedName = renderFormattedDate(formattedData);
|
||||
break;
|
||||
case ChartXAxisDateGrouping.WEEK: {
|
||||
const month = renderFormattedDate(formattedData, "MMM");
|
||||
parsedName = `${month}, Week ${getWeekOfMonth(formattedData)}`;
|
||||
break;
|
||||
}
|
||||
case ChartXAxisDateGrouping.MONTH:
|
||||
if (isCurrentYear) parsedName = renderFormattedDate(formattedData, "MMM");
|
||||
else parsedName = renderFormattedDate(formattedData, "MMM, yyyy");
|
||||
break;
|
||||
case ChartXAxisDateGrouping.YEAR:
|
||||
parsedName = `${year}`;
|
||||
break;
|
||||
default:
|
||||
parsedName = date;
|
||||
}
|
||||
|
||||
return parsedName ?? date;
|
||||
};
|
||||
|
||||
export const parseChartData = (
|
||||
data: TChart | null | undefined,
|
||||
xAxisProperty: ChartXAxisProperty | null | undefined,
|
||||
groupByProperty: ChartXAxisProperty | null | undefined,
|
||||
xAxisDateGrouping: ChartXAxisDateGrouping | null | undefined
|
||||
): TChart => {
|
||||
if (!data) {
|
||||
return {
|
||||
data: [],
|
||||
schema: {},
|
||||
};
|
||||
}
|
||||
const widgetData = structuredClone(data.data);
|
||||
const schema = structuredClone(data.schema);
|
||||
const allKeys = Object.keys(schema);
|
||||
const updatedWidgetData: TChartDatum[] = widgetData.map((datum) => {
|
||||
const keys = Object.keys(datum);
|
||||
const missingKeys = allKeys.filter((key) => !keys.includes(key));
|
||||
const missingValues: Record<string, number> = Object.fromEntries(missingKeys.map(key => [key, 0]));
|
||||
|
||||
if (xAxisProperty) {
|
||||
// capitalize first letter if xAxisProperty is in TO_CAPITALIZE_PROPERTIES and no groupByProperty is set
|
||||
if (TO_CAPITALIZE_PROPERTIES.includes(xAxisProperty)) {
|
||||
datum.name = capitalizeFirstLetter(datum.name);
|
||||
}
|
||||
|
||||
// parse timestamp to visual date if xAxisProperty is in WIDGET_X_AXIS_DATE_PROPERTIES
|
||||
if (CHART_X_AXIS_DATE_PROPERTIES.includes(xAxisProperty)) {
|
||||
datum.name = getDateGroupingName(datum.name, xAxisDateGrouping ?? ChartXAxisDateGrouping.DAY);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...datum,
|
||||
...missingValues,
|
||||
};
|
||||
});
|
||||
|
||||
// capitalize first letter if groupByProperty is in TO_CAPITALIZE_PROPERTIES
|
||||
const updatedSchema = schema;
|
||||
if (groupByProperty) {
|
||||
if (TO_CAPITALIZE_PROPERTIES.includes(groupByProperty)) {
|
||||
Object.keys(updatedSchema).forEach((key) => {
|
||||
updatedSchema[key] = capitalizeFirstLetter(updatedSchema[key]);
|
||||
});
|
||||
}
|
||||
|
||||
if (CHART_X_AXIS_DATE_PROPERTIES.includes(groupByProperty)) {
|
||||
Object.keys(updatedSchema).forEach((key) => {
|
||||
updatedSchema[key] = getDateGroupingName(updatedSchema[key], xAxisDateGrouping ?? ChartXAxisDateGrouping.DAY);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
data: updatedWidgetData,
|
||||
schema: updatedSchema,
|
||||
};
|
||||
};
|
||||
|
||||
export const generateExtendedColors = (baseColorSet: string[], targetCount: number) => {
|
||||
const colors = [...baseColorSet];
|
||||
const baseCount = baseColorSet.length;
|
||||
|
||||
if (targetCount <= baseCount) {
|
||||
return colors.slice(0, targetCount);
|
||||
}
|
||||
|
||||
// Convert base colors to HSL
|
||||
const baseHSL = baseColorSet.map(hexToHsl);
|
||||
|
||||
// Calculate average saturation and lightness from base colors
|
||||
const avgSat = baseHSL.reduce((sum, hsl) => sum + hsl.s, 0) / baseHSL.length;
|
||||
const avgLight = baseHSL.reduce((sum, hsl) => sum + hsl.l, 0) / baseHSL.length;
|
||||
|
||||
// Sort base colors by hue for better distribution
|
||||
const sortedBaseHSL = [...baseHSL].sort((a, b) => a.h - b.h);
|
||||
|
||||
// Generate additional colors for each base color
|
||||
const colorsNeeded = targetCount - baseCount;
|
||||
const colorsPerBase = Math.ceil(colorsNeeded / baseCount);
|
||||
|
||||
for (let i = 0; i < baseCount; i++) {
|
||||
const baseColor = sortedBaseHSL[i];
|
||||
const nextBaseColor = sortedBaseHSL[(i + 1) % baseCount];
|
||||
|
||||
// Calculate hue distance to next base color
|
||||
const hueDistance = (nextBaseColor.h - baseColor.h + 360) % 360;
|
||||
const hueParts = colorsPerBase + 1;
|
||||
|
||||
// Narrower ranges for more consistency
|
||||
const satRange = [Math.max(40, avgSat - 5), Math.min(60, avgSat + 5)];
|
||||
const lightRange = [Math.max(40, avgLight - 5), Math.min(60, avgLight + 5)];
|
||||
|
||||
for (let j = 1; j <= colorsPerBase; j++) {
|
||||
if (colors.length >= targetCount) break;
|
||||
|
||||
// Create evenly spaced hue variations between base colors
|
||||
const hueStep = (hueDistance / hueParts) * j;
|
||||
const newHue = (baseColor.h + hueStep) % 360;
|
||||
|
||||
// Keep saturation and lightness closer to base color
|
||||
const newSat = baseColor.s * 0.8 + avgSat * 0.2;
|
||||
const newLight = baseColor.l * 0.8 + avgLight * 0.2;
|
||||
|
||||
// Ensure values stay within desired ranges
|
||||
const finalSat = Math.max(satRange[0], Math.min(satRange[1], newSat));
|
||||
const finalLight = Math.max(lightRange[0], Math.min(lightRange[1], newLight));
|
||||
|
||||
colors.push(
|
||||
hslToHex({
|
||||
h: newHue,
|
||||
s: finalSat,
|
||||
l: finalLight,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return colors.slice(0, targetCount);
|
||||
};
|
||||
|
|
@ -85,9 +85,7 @@ export const DetailedEmptyState: React.FC<Props> = observer((props) => {
|
|||
{description && <p className="text-sm">{description}</p>}
|
||||
</div>
|
||||
|
||||
{assetPath && (
|
||||
<Image src={assetPath} alt={title} width={384} height={250} layout="responsive" lazyBoundary="100%" />
|
||||
)}
|
||||
{assetPath && <Image src={assetPath} alt={title} width={384} height={250} lazyBoundary="100%" />}
|
||||
|
||||
{hasButtons && (
|
||||
<div className="relative flex items-center justify-center gap-2 flex-shrink-0 w-full">
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import { useLabel, useProjectState, useMember, useIssues } from "@/hooks/store";
|
|||
// plane web types
|
||||
import { TProject } from "@/plane-web/types";
|
||||
import { ProjectAnalyticsModal } from "../analytics";
|
||||
import { WorkItemsModal } from "../analytics-v2/work-items/modal";
|
||||
|
||||
type Props = {
|
||||
currentProjectDetails: TProject | undefined;
|
||||
|
|
@ -97,7 +98,7 @@ const HeaderFilters = observer((props: Props) => {
|
|||
|
||||
return (
|
||||
<>
|
||||
<ProjectAnalyticsModal
|
||||
<WorkItemsModal
|
||||
isOpen={analyticsModal}
|
||||
onClose={() => setAnalyticsModal(false)}
|
||||
projectDetails={currentProjectDetails ?? undefined}
|
||||
|
|
|
|||
|
|
@ -31,3 +31,4 @@ export * from "./use-workspace";
|
|||
export * from "./user";
|
||||
export * from "./use-transient";
|
||||
export * from "./workspace-draft";
|
||||
export * from "./use-analytics-v2";
|
||||
|
|
|
|||
11
web/core/hooks/store/use-analytics-v2.ts
Normal file
11
web/core/hooks/store/use-analytics-v2.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { useContext } from "react";
|
||||
// mobx store
|
||||
import { StoreContext } from "@/lib/store-context";
|
||||
// types
|
||||
import { IAnalyticsStoreV2 } from "@/store/analytics-v2.store";
|
||||
|
||||
export const useAnalyticsV2 = (): IAnalyticsStoreV2 => {
|
||||
const context = useContext(StoreContext);
|
||||
if (context === undefined) throw new Error("useAnalyticsV2 must be used within StoreProvider");
|
||||
return context.analyticsV2;
|
||||
};
|
||||
60
web/core/services/analytics-v2.service.ts
Normal file
60
web/core/services/analytics-v2.service.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { API_BASE_URL } from "@plane/constants";
|
||||
import { IAnalyticsResponseV2, TAnalyticsTabsV2Base, TAnalyticsGraphsV2Base } from "@plane/types";
|
||||
import { APIService } from "./api.service";
|
||||
|
||||
export class AnalyticsV2Service extends APIService {
|
||||
constructor() {
|
||||
super(API_BASE_URL);
|
||||
}
|
||||
|
||||
async getAdvanceAnalytics<T extends IAnalyticsResponseV2>(
|
||||
workspaceSlug: string,
|
||||
tab: TAnalyticsTabsV2Base,
|
||||
params?: Record<string, any>
|
||||
): Promise<T> {
|
||||
return this.get(`/api/workspaces/${workspaceSlug}/advance-analytics/`, {
|
||||
params: {
|
||||
tab,
|
||||
...params,
|
||||
},
|
||||
})
|
||||
.then((res) => res?.data)
|
||||
.catch((err) => {
|
||||
throw err?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
async getAdvanceAnalyticsStats<T>(
|
||||
workspaceSlug: string,
|
||||
tab: Exclude<TAnalyticsTabsV2Base, "overview">,
|
||||
params?: Record<string, any>
|
||||
): Promise<T> {
|
||||
return this.get(`/api/workspaces/${workspaceSlug}/advance-analytics-stats/`, {
|
||||
params: {
|
||||
type: tab,
|
||||
...params,
|
||||
},
|
||||
})
|
||||
.then((res) => res?.data)
|
||||
.catch((err) => {
|
||||
throw err?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
async getAdvanceAnalyticsCharts<T>(
|
||||
workspaceSlug: string,
|
||||
tab: TAnalyticsGraphsV2Base,
|
||||
params?: Record<string, any>
|
||||
): Promise<T> {
|
||||
return this.get(`/api/workspaces/${workspaceSlug}/advance-analytics-charts/`, {
|
||||
params: {
|
||||
type: tab,
|
||||
...params,
|
||||
},
|
||||
})
|
||||
.then((res) => res?.data)
|
||||
.catch((err) => {
|
||||
throw err?.response?.data;
|
||||
});
|
||||
}
|
||||
}
|
||||
68
web/core/store/analytics-v2.store.ts
Normal file
68
web/core/store/analytics-v2.store.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import { action, computed, makeObservable, observable, runInAction } from "mobx";
|
||||
import { ANALYTICS_V2_DURATION_FILTER_OPTIONS } from "@plane/constants";
|
||||
import { TAnalyticsTabsV2Base } from "@plane/types";
|
||||
import { CoreRootStore } from "./root.store";
|
||||
|
||||
type DurationType = (typeof ANALYTICS_V2_DURATION_FILTER_OPTIONS)[number]["value"];
|
||||
|
||||
export interface IAnalyticsStoreV2 {
|
||||
//observables
|
||||
currentTab: TAnalyticsTabsV2Base;
|
||||
selectedProjects: string[];
|
||||
selectedDuration: DurationType;
|
||||
|
||||
//computed
|
||||
selectedDurationLabel: DurationType | null;
|
||||
|
||||
//actions
|
||||
updateSelectedProjects: (projects: string[]) => void;
|
||||
updateSelectedDuration: (duration: DurationType) => void;
|
||||
}
|
||||
|
||||
export class AnalyticsStoreV2 implements IAnalyticsStoreV2 {
|
||||
//observables
|
||||
currentTab: TAnalyticsTabsV2Base = "overview";
|
||||
selectedProjects: DurationType[] = [];
|
||||
selectedDuration: DurationType = "last_30_days";
|
||||
|
||||
constructor(_rootStore: CoreRootStore) {
|
||||
makeObservable(this, {
|
||||
// observables
|
||||
currentTab: observable.ref,
|
||||
selectedDuration: observable.ref,
|
||||
selectedProjects: observable.ref,
|
||||
// computed
|
||||
selectedDurationLabel: computed,
|
||||
// actions
|
||||
updateSelectedProjects: action,
|
||||
updateSelectedDuration: action,
|
||||
});
|
||||
}
|
||||
|
||||
get selectedDurationLabel() {
|
||||
return ANALYTICS_V2_DURATION_FILTER_OPTIONS.find((item) => item.value === this.selectedDuration)?.name ?? null;
|
||||
}
|
||||
|
||||
updateSelectedProjects = (projects: string[]) => {
|
||||
const initialState = this.selectedProjects;
|
||||
try {
|
||||
runInAction(() => {
|
||||
this.selectedProjects = projects;
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to update selected project");
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
updateSelectedDuration = (duration: DurationType) => {
|
||||
try {
|
||||
runInAction(() => {
|
||||
this.selectedDuration = duration;
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to update selected duration");
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ import { CommandPaletteStore, ICommandPaletteStore } from "@/plane-web/store/com
|
|||
import { RootStore } from "@/plane-web/store/root.store";
|
||||
import { IStateStore, StateStore } from "@/plane-web/store/state.store";
|
||||
// stores
|
||||
import { IAnalyticsStoreV2, AnalyticsStoreV2 } from "./analytics-v2.store";
|
||||
import { CycleStore, ICycleStore } from "./cycle.store";
|
||||
import { CycleFilterStore, ICycleFilterStore } from "./cycle_filter.store";
|
||||
import { DashboardStore, IDashboardStore } from "./dashboard.store";
|
||||
|
|
@ -49,6 +50,7 @@ export class CoreRootStore {
|
|||
state: IStateStore;
|
||||
label: ILabelStore;
|
||||
dashboard: IDashboardStore;
|
||||
analyticsV2: IAnalyticsStoreV2;
|
||||
projectPages: IProjectPageStore;
|
||||
router: IRouterStore;
|
||||
commandPalette: ICommandPaletteStore;
|
||||
|
|
@ -94,6 +96,7 @@ export class CoreRootStore {
|
|||
this.transient = new TransientStore();
|
||||
this.stickyStore = new StickyStore();
|
||||
this.editorAssetStore = new EditorAssetStore();
|
||||
this.analyticsV2 = new AnalyticsStoreV2(this);
|
||||
}
|
||||
|
||||
resetOnSignOut() {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue