feat: init
Some checks failed
CodeQL / Analyze (javascript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled

This commit is contained in:
chuan
2025-11-11 01:56:44 +08:00
commit bba4bb40c8
4638 changed files with 447437 additions and 0 deletions

View File

@@ -0,0 +1,34 @@
// plane web components
import { observer } from "mobx-react";
// hooks
import { useAnalytics } from "@/hooks/store/use-analytics";
import { useProject } from "@/hooks/store/use-project";
// components
import DurationDropdown from "./select/duration";
import { ProjectSelect } from "./select/project";
const AnalyticsFilterActions = observer(() => {
const { selectedProjects, selectedDuration, updateSelectedProjects, updateSelectedDuration } = useAnalytics();
const { joinedProjectIds } = useProject();
return (
<div className="flex items-center justify-end gap-2">
<ProjectSelect
value={selectedProjects}
onChange={(val) => {
updateSelectedProjects(val ?? []);
}}
projectIds={joinedProjectIds}
/>
{/* <DurationDropdown
buttonVariant="border-with-text"
value={selectedDuration}
onChange={(val) => {
updateSelectedDuration(val);
}}
dropdownArrow
/> */}
</div>
);
});
export default AnalyticsFilterActions;

View File

@@ -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;

View File

@@ -0,0 +1,23 @@
import React from "react";
// plane package imports
import { useTranslation } from "@plane/i18n";
import { cn } from "@plane/utils";
type Props = {
i18nTitle: string;
children: React.ReactNode;
className?: string;
};
const AnalyticsWrapper: React.FC<Props> = (props) => {
const { i18nTitle, children, className } = props;
const { t } = useTranslation();
return (
<div className={cn("px-6 py-4", className)}>
<h1 className={"mb-4 text-2xl font-bold md:mb-6"}>{t(i18nTitle)}</h1>
{children}
</div>
);
};
export default AnalyticsWrapper;

View 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 AnalyticsEmptyState = ({ title, description, assetPath, className }: Props) => {
const backgroundReolvedPath = useResolvedAssetPath({ basePath: "/empty-state/analytics/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 max-w-[350px]">{description}</p>}
</div>
</div>
</div>
);
};
export default AnalyticsEmptyState;

View File

@@ -0,0 +1,26 @@
import type { ColumnDef, Row } from "@tanstack/react-table";
import { download, generateCsv, mkConfig } from "export-to-csv";
export const csvConfig = (workspaceSlug: string) =>
mkConfig({
fieldSeparator: ",",
filename: `${workspaceSlug}-analytics`,
decimalSeparator: ".",
useKeysAsHeaders: true,
});
export const exportCSV = <T>(rows: Row<T>[], columns: ColumnDef<T>[], workspaceSlug: string) => {
const rowData = rows.map((row) => {
const exportColumns = columns.map((col) => col.meta?.export);
const cells = exportColumns.reduce((acc: Record<string, string | number>, col) => {
if (col) {
const cell = col?.value(row) ?? "-";
acc[col.label ?? col.key] = cell;
}
return acc;
}, {});
return cells;
});
const csv = generateCsv(csvConfig(workspaceSlug))(rowData);
download(csvConfig(workspaceSlug))(csv);
};

View File

@@ -0,0 +1,30 @@
// plane package imports
import React from "react";
import type { IAnalyticsResponseFields } from "@plane/types";
import { Loader } from "@plane/ui";
export type InsightCardProps = {
data?: IAnalyticsResponseFields;
label: string;
isLoading?: boolean;
};
const InsightCard = (props: InsightCardProps) => {
const { data, label, isLoading = false } = props;
const count = data?.count ?? 0;
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>
</div>
) : (
<Loader.Item height="50px" width="100%" />
)}
</div>
);
};
export default InsightCard;

View File

@@ -0,0 +1,175 @@
"use client";
import * as React from "react";
import type {
ColumnDef,
ColumnFiltersState,
SortingState,
VisibilityState,
Table as TanstackTable,
} from "@tanstack/react-table";
import {
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 AnalyticsEmptyState 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/empty-table" });
const table = useReactTable({
data,
columns,
state: {
sorting,
columnVisibility,
rowSelection,
columnFilters,
},
onColumnFiltersChange: setColumnFilters,
onColumnVisibilityChange: setColumnVisibility,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
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} className="whitespace-nowrap">
{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 ">
<AnalyticsEmptyState
title={t("workspace_analytics.empty_state.customized_insights.title")}
description={t("workspace_analytics.empty_state.customized_insights.description")}
className="border-0"
assetPath={resolvedPath}
/>
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
);
}

View File

@@ -0,0 +1 @@
export * from "./root";

View File

@@ -0,0 +1,34 @@
import * as React from "react";
import type { 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>
);

View File

@@ -0,0 +1,45 @@
import type { ColumnDef, Row, Table } from "@tanstack/react-table";
import { Download } from "lucide-react";
import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button";
import type { AnalyticsTableDataMap, TAnalyticsTabsBase } from "@plane/types";
import { DataTable } from "./data-table";
import { TableLoader } from "./loader";
interface InsightTableProps<T extends Exclude<TAnalyticsTabsBase, "overview">> {
analyticsType: T;
data?: AnalyticsTableDataMap[T][];
isLoading?: boolean;
columns: ColumnDef<AnalyticsTableDataMap[T]>[];
columnsLabels?: Record<string, string>;
headerText: string;
onExport?: (rows: Row<AnalyticsTableDataMap[T]>[]) => void;
}
export const InsightTable = <T extends Exclude<TAnalyticsTabsBase, "overview">>(
props: InsightTableProps<T>
): React.ReactElement => {
const { data, isLoading, columns, headerText, onExport } = props;
const { t } = useTranslation();
if (isLoading) {
return <TableLoader columns={columns} rows={5} />;
}
return (
<div className="">
<DataTable
columns={columns}
data={data || []}
searchPlaceholder={`${data?.length || 0} ${headerText}`}
actions={(table: Table<AnalyticsTableDataMap[T]>) => (
<Button
variant="accent-primary"
prependIcon={<Download className="h-3.5 w-3.5" />}
onClick={() => onExport?.(table.getFilteredRowModel().rows)}
>
<div>{t("exporter.csv.short_description")}</div>
</Button>
)}
/>
</div>
);
};

View 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>
);

View File

@@ -0,0 +1,57 @@
import { ProjectIcon } from "@plane/propel/icons";
// plane package imports
import { cn } from "@plane/utils";
import { Logo } from "@/components/common/logo";
// plane web hooks
import { useProject } from "@/hooks/store/use-project";
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">
<ProjectIcon 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;

View File

@@ -0,0 +1,45 @@
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 { useAnalytics } from "@/hooks/store/use-analytics";
import { useProject } from "@/hooks/store/use-project";
// 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 } = useAnalytics();
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 h-[350px] overflow-auto">
{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;

View File

@@ -0,0 +1 @@
export * from "./root";

View File

@@ -0,0 +1,117 @@
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 type { TChartData } from "@plane/types";
// hooks
import { useAnalytics } from "@/hooks/store/use-analytics";
// services
import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
import { AnalyticsService } from "@/services/analytics.service";
// plane web components
import AnalyticsSectionWrapper from "../analytics-section-wrapper";
import AnalyticsEmptyState from "../empty-state";
import { ProjectInsightsLoader } from "../loaders";
const RadarChart = dynamic(() =>
import("@plane/propel/charts/radar-chart").then((mod) => ({
default: mod.RadarChart,
}))
);
const analyticsService = new AnalyticsService();
const ProjectInsights = observer(() => {
const params = useParams();
const { t } = useTranslation();
const workspaceSlug = params.workspaceSlug.toString();
const { selectedDuration, selectedDurationLabel, selectedProjects, selectedCycle, selectedModule, isPeekView } =
useAnalytics();
const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/analytics/empty-chart-radar" });
const { data: projectInsightsData, isLoading: isLoadingProjectInsight } = useSWR(
`radar-chart-project-insights-${workspaceSlug}-${selectedDuration}-${selectedProjects}-${selectedCycle}-${selectedModule}-${isPeekView}`,
() =>
analyticsService.getAdvanceAnalyticsCharts<TChartData<string, string>[]>(
workspaceSlug,
"projects",
{
// date_filter: selectedDuration,
...(selectedProjects?.length > 0 && { project_ids: selectedProjects?.join(",") }),
...(selectedCycle ? { cycle_id: selectedCycle } : {}),
...(selectedModule ? { module_id: selectedModule } : {}),
},
isPeekView
)
);
return (
<AnalyticsSectionWrapper
title={`${t("workspace_analytics.project_insights")}`}
subtitle={selectedDurationLabel}
className="md:col-span-3"
>
{isLoadingProjectInsight ? (
<ProjectInsightsLoader />
) : projectInsightsData && projectInsightsData?.length == 0 ? (
<AnalyticsEmptyState
title={t("workspace_analytics.empty_state.project_insights.title")}
description={t("workspace_analytics.empty_state.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;

View 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 i18nTitle="common.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 };

View File

@@ -0,0 +1,102 @@
import { useMemo } from "react";
import { observer } from "mobx-react";
import type { Control, UseFormSetValue } from "react-hook-form";
import { Controller } from "react-hook-form";
import { Calendar, SlidersHorizontal } from "lucide-react";
// plane package imports
import { ANALYTICS_X_AXIS_VALUES, ANALYTICS_Y_AXIS_VALUES } from "@plane/constants";
import type { IAnalyticsParams } from "@plane/types";
import { ChartYAxisMetric } from "@plane/types";
import { cn } from "@plane/utils";
// plane web components
import { SelectXAxis } from "./select-x-axis";
import { SelectYAxis } from "./select-y-axis";
type Props = {
control: Control<IAnalyticsParams, unknown>;
setValue: UseFormSetValue<IAnalyticsParams>;
params: IAnalyticsParams;
workspaceSlug: string;
classNames?: string;
isEpic?: boolean;
};
export const AnalyticsSelectParams: React.FC<Props> = observer((props) => {
const { control, params, classNames, isEpic } = props;
const xAxisOptions = useMemo(
() => ANALYTICS_X_AXIS_VALUES.filter((option) => option.value !== params.group_by),
[params.group_by]
);
const groupByOptions = useMemo(
() => ANALYTICS_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_Y_AXIS_VALUES}
hiddenOptions={[
ChartYAxisMetric.ESTIMATE_POINT_COUNT,
isEpic ? ChartYAxisMetric.WORK_ITEM_COUNT : ChartYAxisMetric.EPIC_WORK_ITEM_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>
);
});

View File

@@ -0,0 +1,51 @@
// plane package imports
import type { ReactNode } from "react";
import React from "react";
import { Calendar } from "lucide-react";
// plane package imports
import { ANALYTICS_DURATION_FILTER_OPTIONS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { CustomSearchSelect } from "@plane/ui";
// types
import type { TDropdownProps } from "@/components/dropdowns/types";
type Props = TDropdownProps & {
value: string | null;
onChange: (val: (typeof ANALYTICS_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_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_DURATION_FILTER_OPTIONS.find((opt) => opt.value === value)?.name : placeholder}
</div>
}
/>
);
}
export default DurationDropdown;

View File

@@ -0,0 +1,62 @@
"use client";
import { observer } from "mobx-react";
import { ProjectIcon } from "@plane/propel/icons";
// plane package imports
import { CustomSearchSelect } from "@plane/ui";
// components
import { Logo } from "@/components/common/logo";
// hooks
import { useProject } from "@/hooks/store/use-project";
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} />
) : (
<ProjectIcon 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 ">
<ProjectIcon 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
/>
);
});

View File

@@ -0,0 +1,31 @@
"use client";
// plane package imports
import type { ChartXAxisProperty } from "@plane/types";
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 | React.ReactNode;
};
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>
);
};

View File

@@ -0,0 +1,66 @@
"use client";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { EEstimateSystem } from "@plane/constants";
import { ProjectIcon } from "@plane/propel/icons";
import type { ChartYAxisMetric } from "@plane/types";
// plane package imports
import { CustomSelect } from "@plane/ui";
// hooks
import { useProjectEstimates } from "@/hooks/store/estimates";
// plane web constants
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">
<ProjectIcon 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>
);
});

View File

@@ -0,0 +1,94 @@
// plane package imports
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import useSWR from "swr";
import type { IInsightField } from "@plane/constants";
import { ANALYTICS_INSIGHTS_FIELDS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import type { IAnalyticsResponse, TAnalyticsTabsBase } from "@plane/types";
import { cn } from "@plane/utils";
// hooks
import { useAnalytics } from "@/hooks/store/use-analytics";
// services
import { AnalyticsService } from "@/services/analytics.service";
// local imports
import InsightCard from "./insight-card";
const analyticsService = new AnalyticsService();
const getInsightLabel = (
analyticsType: TAnalyticsTabsBase,
item: IInsightField,
isEpic: boolean | undefined,
t: (key: string, params?: Record<string, unknown>) => string
) => {
if (analyticsType === "work-items") {
return isEpic
? t(item.i18nKey, { entity: t("common.epics") })
: t(item.i18nKey, { entity: t("common.work_items") });
}
// Get the base translation with entity
const baseTranslation = t(item.i18nKey, {
...item.i18nProps,
entity: item.i18nProps?.entity && t(item.i18nProps?.entity),
});
// Add prefix if available
const prefix = item.i18nProps?.prefix ? `${t(item.i18nProps.prefix)} ` : "";
// Add suffix if available
const suffix = item.i18nProps?.suffix ? ` ${t(item.i18nProps.suffix)}` : "";
// Combine prefix, base translation, and suffix
return `${prefix}${baseTranslation}${suffix}`;
};
const TotalInsights: React.FC<{
analyticsType: TAnalyticsTabsBase;
peekView?: boolean;
}> = observer(({ analyticsType, peekView }) => {
const params = useParams();
const workspaceSlug = params.workspaceSlug.toString();
const { t } = useTranslation();
const { selectedDuration, selectedProjects, selectedCycle, selectedModule, isPeekView, isEpic } = useAnalytics();
const { data: totalInsightsData, isLoading } = useSWR(
`total-insights-${analyticsType}-${selectedDuration}-${selectedProjects}-${selectedCycle}-${selectedModule}-${isEpic}`,
() =>
analyticsService.getAdvanceAnalytics<IAnalyticsResponse>(
workspaceSlug,
analyticsType,
{
// date_filter: selectedDuration,
...(selectedProjects?.length > 0 ? { project_ids: selectedProjects.join(",") } : {}),
...(selectedCycle ? { cycle_id: selectedCycle } : {}),
...(selectedModule ? { module_id: selectedModule } : {}),
...(isEpic ? { epic: true } : {}),
},
isPeekView
)
);
return (
<div
className={cn(
"grid grid-cols-1 gap-8 sm:grid-cols-2 md:gap-10",
!peekView
? ANALYTICS_INSIGHTS_FIELDS[analyticsType]?.length % 5 === 0
? "gap-10 lg:grid-cols-5"
: "gap-8 lg:grid-cols-4"
: "grid-cols-2"
)}
>
{ANALYTICS_INSIGHTS_FIELDS[analyticsType]?.map((item) => (
<InsightCard
key={`${analyticsType}-${item.key}`}
isLoading={isLoading}
data={totalInsightsData?.[item.key]}
label={getInsightLabel(analyticsType, item, isEpic, t)}
/>
))}
</div>
);
});
export default TotalInsights;

View File

@@ -0,0 +1,80 @@
// 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";
trendIconVisible?: boolean;
variant?: "simple" | "outlined" | "tinted";
};
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 variants: Record<NonNullable<Props["variant"]>, Record<"ontrack" | "offtrack" | "atrisk", string>> = {
simple: {
ontrack: "text-green-500",
offtrack: "text-yellow-500",
atrisk: "text-red-500",
},
outlined: {
ontrack: "text-green-500 border border-green-500",
offtrack: "text-yellow-500 border border-yellow-500",
atrisk: "text-red-500 border border-red-500",
},
tinted: {
ontrack: "text-green-500 bg-green-500/10",
offtrack: "text-yellow-500 bg-yellow-500/10",
atrisk: "text-red-500 bg-red-500/10",
},
} as const;
const TrendPiece = (props: Props) => {
const { percentage, className, trendIconVisible = true, size = "sm", variant = "simple" } = props;
const isOnTrack = percentage >= 66;
const isOffTrack = percentage >= 33 && percentage < 66;
const config = sizeConfig[size];
return (
<div
className={cn(
"flex items-center gap-1 p-1 rounded-md",
variants[variant][isOnTrack ? "ontrack" : isOffTrack ? "offtrack" : "atrisk"],
config.text,
className
)}
>
{trendIconVisible &&
(isOnTrack ? (
<TrendingUp className={config.icon} />
) : isOffTrack ? (
<TrendingDown className={config.icon} />
) : (
<TrendingDown className={config.icon} />
))}
{Math.round(Math.abs(percentage))}%
</div>
);
};
export default TrendPiece;

View File

@@ -0,0 +1,135 @@
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 type { IChartResponse, TChartData } from "@plane/types";
import { renderFormattedDate } from "@plane/utils";
// hooks
import { useAnalytics } from "@/hooks/store/use-analytics";
// services
import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
import { AnalyticsService } from "@/services/analytics.service";
// plane web components
import AnalyticsSectionWrapper from "../analytics-section-wrapper";
import AnalyticsEmptyState from "../empty-state";
import { ChartLoader } from "../loaders";
const analyticsService = new AnalyticsService();
const CreatedVsResolved = observer(() => {
const {
selectedDuration,
selectedDurationLabel,
selectedProjects,
selectedCycle,
selectedModule,
isPeekView,
isEpic,
} = useAnalytics();
const params = useParams();
const { t } = useTranslation();
const workspaceSlug = params.workspaceSlug.toString();
const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/analytics/empty-chart-area" });
const { data: createdVsResolvedData, isLoading: isCreatedVsResolvedLoading } = useSWR(
`created-vs-resolved-${workspaceSlug}-${selectedDuration}-${selectedProjects}-${selectedCycle}-${selectedModule}-${isPeekView}-${isEpic}`,
() =>
analyticsService.getAdvanceAnalyticsCharts<IChartResponse>(
workspaceSlug,
"work-items",
{
// date_filter: selectedDuration,
...(selectedProjects?.length > 0 && { project_ids: selectedProjects?.join(",") }),
...(selectedCycle ? { cycle_id: selectedCycle } : {}),
...(selectedModule ? { module_id: selectedModule } : {}),
...(isEpic ? { epic: true } : {}),
},
isPeekView
)
);
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: t("date"),
}}
yAxis={{
key: "count",
label: t("common.no_of", { entity: isEpic ? t("epics") : t("work_items") }),
offset: -60,
dx: -24,
}}
legend={{
align: "left",
verticalAlign: "bottom",
layout: "horizontal",
wrapperStyles: {
justifyContent: "start",
alignContent: "start",
paddingLeft: "40px",
paddingTop: "10px",
},
}}
/>
) : (
<AnalyticsEmptyState
title={t("workspace_analytics.empty_state.created_vs_resolved.title")}
description={t("workspace_analytics.empty_state.created_vs_resolved.description")}
className="h-[350px]"
assetPath={resolvedPath}
/>
)}
</AnalyticsSectionWrapper>
);
});
export default CreatedVsResolved;

View File

@@ -0,0 +1,50 @@
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { useForm } from "react-hook-form";
// plane package imports
import { useTranslation } from "@plane/i18n";
import type { IAnalyticsParams } from "@plane/types";
import { ChartXAxisProperty, ChartYAxisMetric } from "@plane/types";
import { cn } from "@plane/utils";
// plane web components
import AnalyticsSectionWrapper from "../analytics-section-wrapper";
import { AnalyticsSelectParams } from "../select/analytics-params";
import PriorityChart from "./priority-chart";
const CustomizedInsights = observer(({ peekView, isEpic }: { peekView?: boolean; isEpic?: boolean }) => {
const { t } = useTranslation();
const { workspaceSlug } = useParams();
const { control, watch, setValue } = useForm<IAnalyticsParams>({
defaultValues: {
x_axis: ChartXAxisProperty.PRIORITY,
y_axis: isEpic ? ChartYAxisMetric.EPIC_WORK_ITEM_COUNT : ChartYAxisMetric.WORK_ITEM_COUNT,
},
});
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={
<AnalyticsSelectParams
control={control}
setValue={setValue}
params={params}
workspaceSlug={workspaceSlug.toString()}
isEpic={isEpic}
/>
}
>
<PriorityChart x_axis={params.x_axis} y_axis={params.y_axis} group_by={params.group_by} />
</AnalyticsSectionWrapper>
);
});
export default CustomizedInsights;

View File

@@ -0,0 +1 @@
export * from "./root";

View File

@@ -0,0 +1,81 @@
import React, { useEffect, useState } from "react";
import { observer } from "mobx-react";
import { Tab } from "@headlessui/react";
// plane package imports
import type { ICycle, IModule, IProject } from "@plane/types";
import { Spinner } from "@plane/ui";
// hooks
import { useAnalytics } from "@/hooks/store/use-analytics";
// 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;
cycleDetails: ICycle | undefined;
moduleDetails: IModule | undefined;
isEpic?: boolean;
};
export const WorkItemsModalMainContent: React.FC<Props> = observer((props) => {
const { projectDetails, cycleDetails, moduleDetails, fullScreen, isEpic } = props;
const { updateSelectedProjects, updateSelectedCycle, updateSelectedModule, updateIsPeekView } = useAnalytics();
const [isModalConfigured, setIsModalConfigured] = useState(false);
useEffect(() => {
updateIsPeekView(true);
// Handle project selection
if (projectDetails?.id) {
updateSelectedProjects([projectDetails.id]);
}
// Handle cycle selection
if (cycleDetails?.id) {
updateSelectedCycle(cycleDetails.id);
}
// Handle module selection
if (moduleDetails?.id) {
updateSelectedModule(moduleDetails.id);
}
setIsModalConfigured(true);
// Cleanup fields
return () => {
updateSelectedProjects([]);
updateSelectedCycle("");
updateSelectedModule("");
updateIsPeekView(false);
};
}, [
projectDetails?.id,
cycleDetails?.id,
moduleDetails?.id,
updateSelectedProjects,
updateSelectedCycle,
updateSelectedModule,
updateIsPeekView,
]);
if (!isModalConfigured)
return (
<div className="flex h-full items-center justify-center">
<Spinner />
</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} isEpic={isEpic} />
<WorkItemsInsightTable />
</div>
</Tab.Group>
);
});

View File

@@ -0,0 +1,42 @@
import { observer } from "mobx-react";
// plane package imports
import { Expand, Shrink, X } from "lucide-react";
import type { ICycle, IModule } from "@plane/types";
// icons
type Props = {
fullScreen: boolean;
handleClose: () => void;
setFullScreen: React.Dispatch<React.SetStateAction<boolean>>;
title: string;
cycle?: ICycle;
module?: IModule;
};
export const WorkItemsModalHeader: React.FC<Props> = observer((props) => {
const { fullScreen, handleClose, setFullScreen, title, cycle, module } = props;
return (
<div className="flex items-center justify-between gap-4 bg-custom-background-100 px-5 py-4 text-sm">
<h3 className="break-words">
Analytics for {title} {cycle && `in ${cycle.name}`} {module && `in ${module.name}`}
</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>
);
});

View File

@@ -0,0 +1,65 @@
import React, { useEffect, useState } from "react";
import { observer } from "mobx-react";
// plane package imports
import { ModalPortal, EPortalWidth, EPortalPosition } from "@plane/propel/portal";
import type { ICycle, IModule, IProject } from "@plane/types";
import { useAnalytics } from "@/hooks/store/use-analytics";
// plane web components
import { WorkItemsModalMainContent } from "./content";
import { WorkItemsModalHeader } from "./header";
type Props = {
isOpen: boolean;
onClose: () => void;
projectDetails?: IProject | undefined;
cycleDetails?: ICycle | undefined;
moduleDetails?: IModule | undefined;
isEpic?: boolean;
};
export const WorkItemsModal: React.FC<Props> = observer((props) => {
const { isOpen, onClose, projectDetails, moduleDetails, cycleDetails, isEpic } = props;
const { updateIsEpic, isPeekView } = useAnalytics();
const [fullScreen, setFullScreen] = useState(false);
const handleClose = () => {
setFullScreen(false);
onClose();
};
useEffect(() => {
updateIsEpic(isPeekView ? (isEpic ?? false) : false);
}, [isEpic, updateIsEpic, isPeekView]);
return (
<ModalPortal
isOpen={isOpen}
onClose={handleClose}
width={fullScreen ? EPortalWidth.FULL : EPortalWidth.THREE_QUARTER}
position={EPortalPosition.RIGHT}
fullScreen={fullScreen}
>
<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 ?? ""}
cycle={cycleDetails}
module={moduleDetails}
/>
<WorkItemsModalMainContent
fullScreen={fullScreen}
projectDetails={projectDetails}
cycleDetails={cycleDetails}
moduleDetails={moduleDetails}
isEpic={isEpic}
/>
</div>
</ModalPortal>
);
});

View File

@@ -0,0 +1,246 @@
import { useMemo } from "react";
import type { ColumnDef, Row, RowData, Table } from "@tanstack/react-table";
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 type { ChartXAxisDateGrouping } from "@plane/constants";
import { ANALYTICS_X_AXIS_VALUES, ANALYTICS_Y_AXIS_VALUES, CHART_COLOR_PALETTES, EChartModels } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button";
import { BarChart } from "@plane/propel/charts/bar-chart";
import type { TBarItem, TChart, TChartDatum, ChartXAxisProperty, ChartYAxisMetric } from "@plane/types";
// plane web components
import { generateExtendedColors, parseChartData } from "@/components/chart/utils";
// hooks
import { useAnalytics } from "@/hooks/store/use-analytics";
import { useProjectState } from "@/hooks/store/use-project-state";
import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
import { AnalyticsService } from "@/services/analytics.service";
import AnalyticsEmptyState from "../empty-state";
import { exportCSV } from "../export";
import { DataTable } from "../insight-table/data-table";
import { ChartLoader } from "../loaders";
import { generateBarColor } from "./utils";
declare module "@tanstack/react-table" {
interface ColumnMeta<TData extends RowData, TValue> {
export: {
key: string;
value: (row: Row<TData>) => string | number;
label?: string;
};
}
}
interface Props {
x_axis: ChartXAxisProperty;
y_axis: ChartYAxisMetric;
group_by?: ChartXAxisProperty;
x_axis_date_grouping?: ChartXAxisDateGrouping;
}
const analyticsService = new AnalyticsService();
const PriorityChart = observer((props: Props) => {
const { x_axis, y_axis, group_by } = props;
const { t } = useTranslation();
const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/analytics/empty-chart-bar" });
// store hooks
const { selectedDuration, selectedProjects, selectedCycle, selectedModule, isPeekView, isEpic } = useAnalytics();
const { workspaceStates } = useProjectState();
const { resolvedTheme } = useTheme();
// router
const params = useParams();
const workspaceSlug = params.workspaceSlug.toString();
const { data: priorityChartData, isLoading: priorityChartLoading } = useSWR(
`customized-insights-chart-${workspaceSlug}-${selectedDuration}-
${selectedProjects}-${selectedCycle}-${selectedModule}-${props.x_axis}-${props.y_axis}-${props.group_by}-${isPeekView}-${isEpic}`,
() =>
analyticsService.getAdvanceAnalyticsCharts<TChart>(
workspaceSlug,
"custom-work-items",
{
// date_filter: selectedDuration,
...(selectedProjects?.length > 0 && { project_ids: selectedProjects?.join(",") }),
...(selectedCycle ? { cycle_id: selectedCycle } : {}),
...(selectedModule ? { module_id: selectedModule } : {}),
...(isEpic ? { epic: true } : {}),
...props,
},
isPeekView
)
);
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 yAxisLabel = useMemo(
() => ANALYTICS_Y_AXIS_VALUES.find((item) => item.value === props.y_axis)?.label ?? props.y_axis,
[props.y_axis]
);
const xAxisLabel = useMemo(
() => ANALYTICS_X_AXIS_VALUES.find((item) => item.value === props.x_axis)?.label ?? props.x_axis,
[props.x_axis]
);
const defaultColumns: ColumnDef<TChartDatum>[] = useMemo(
() => [
{
accessorKey: "name",
header: () => xAxisLabel,
meta: {
export: {
key: xAxisLabel,
value: (row) => row.original.name,
label: xAxisLabel,
},
},
},
{
accessorKey: "count",
header: () => <div className="text-right">Count</div>,
cell: ({ row }) => <div className="text-right">{row.original.count}</div>,
meta: {
export: {
key: "Count",
value: (row) => row.original.count,
label: "Count",
},
},
},
],
[xAxisLabel]
);
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>,
meta: {
export: {
key,
value: (row) => row.original[key],
label: parsedData.schema[key],
},
},
}))
: [],
[parsedData]
);
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: t("common.no_of", { entity: yAxisLabel.replace("_", " ") }),
offset: -60,
dx: -26,
}}
/>
<DataTable
data={parsedData.data}
columns={[...defaultColumns, ...columns]}
searchPlaceholder={`${parsedData.data.length} ${xAxisLabel}`}
actions={(table: Table<TChartDatum>) => (
<Button
variant="accent-primary"
prependIcon={<Download className="h-3.5 w-3.5" />}
onClick={() => exportCSV(table.getRowModel().rows, [...defaultColumns, ...columns], workspaceSlug)}
>
<div>{t("exporter.csv.short_description")}</div>
</Button>
)}
/>
</>
) : (
<AnalyticsEmptyState
title={t("workspace_analytics.empty_state.customized_insights.title")}
description={t("workspace_analytics.empty_state.customized_insights.description")}
className="h-[350px]"
assetPath={resolvedPath}
/>
)}
</div>
);
});
export default PriorityChart;

View 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 i18nTitle="sidebar.work_items">
<div className="flex flex-col gap-14">
<TotalInsights analyticsType="work-items" />
<CreatedVsResolved />
<CustomizedInsights />
<WorkItemsInsightTable />
</div>
</AnalyticsWrapper>
);
export { WorkItems };

View File

@@ -0,0 +1,47 @@
// plane package imports
import type { ChartYAxisMetric, IState } from "@plane/types";
import { ChartXAxisProperty } 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;
};

View File

@@ -0,0 +1,205 @@
import { useMemo } from "react";
import type { ColumnDef, Row, RowData } from "@tanstack/react-table";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import useSWR from "swr";
import { UserRound } from "lucide-react";
import { useTranslation } from "@plane/i18n";
import { ProjectIcon } from "@plane/propel/icons";
// plane package imports
import type { AnalyticsTableDataMap, WorkItemInsightColumns } from "@plane/types";
// plane web components
import { Avatar } from "@plane/ui";
import { getFileURL } from "@plane/utils";
import { Logo } from "@/components/common/logo";
// hooks
import { useAnalytics } from "@/hooks/store/use-analytics";
import { useProject } from "@/hooks/store/use-project";
import { AnalyticsService } from "@/services/analytics.service";
// plane web components
import { exportCSV } from "../export";
import { InsightTable } from "../insight-table";
const analyticsService = new AnalyticsService();
declare module "@tanstack/react-table" {
interface ColumnMeta<TData extends RowData, TValue> {
export: {
key: string;
value: (row: Row<TData>) => string | number;
label?: string;
};
}
}
const WorkItemsInsightTable = observer(() => {
// router
const params = useParams();
const workspaceSlug = params.workspaceSlug.toString();
const { t } = useTranslation();
// store hooks
const { getProjectById } = useProject();
const { selectedDuration, selectedProjects, selectedCycle, selectedModule, isPeekView, isEpic } = useAnalytics();
const { data: workItemsData, isLoading } = useSWR(
`insights-table-work-items-${workspaceSlug}-${selectedDuration}-${selectedProjects}-${selectedCycle}-${selectedModule}-${isPeekView}-${isEpic}`,
() =>
analyticsService.getAdvanceAnalyticsStats<WorkItemInsightColumns[]>(
workspaceSlug,
"work-items",
{
// date_filter: selectedDuration,
...(selectedProjects?.length > 0 ? { project_ids: selectedProjects.join(",") } : {}),
...(selectedCycle ? { cycle_id: selectedCycle } : {}),
...(selectedModule ? { module_id: selectedModule } : {}),
...(isEpic ? { epic: true } : {}),
},
isPeekView
)
);
// derived values
const columnsLabels: Record<keyof Omit<WorkItemInsightColumns, "project_id" | "avatar_url" | "assignee_id">, string> =
useMemo(
() => ({
backlog_work_items: t("workspace_projects.state.backlog"),
started_work_items: t("workspace_projects.state.started"),
un_started_work_items: t("workspace_projects.state.unstarted"),
completed_work_items: t("workspace_projects.state.completed"),
cancelled_work_items: t("workspace_projects.state.cancelled"),
project__name: t("common.project"),
display_name: t("common.assignee"),
}),
[t]
);
const columns: ColumnDef<AnalyticsTableDataMap["work-items"]>[] = useMemo(
() => [
!isPeekView
? {
accessorKey: "project__name",
header: () => <div className="text-left">{columnsLabels["project__name"]}</div>,
cell: ({ row }) => {
const project = getProjectById(row.original.project_id);
return (
<div className="flex items-center gap-2">
{project?.logo_props ? (
<Logo logo={project.logo_props} size={18} />
) : (
<ProjectIcon className="h-4 w-4" />
)}
{project?.name}
</div>
);
},
meta: {
export: {
key: columnsLabels["project__name"],
value: (row) => row.original.project__name?.toString() ?? "",
},
},
}
: {
accessorKey: "display_name",
header: () => <div className="text-left">{columnsLabels["display_name"]}</div>,
cell: ({ row }: { row: Row<WorkItemInsightColumns> }) => (
<div className="text-left">
<div className="flex items-center gap-2">
{row.original.avatar_url && row.original.avatar_url !== "" ? (
<Avatar
name={row.original.display_name}
src={getFileURL(row.original.avatar_url)}
size={24}
shape="circle"
/>
) : (
<div className="flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-full bg-custom-background-80 capitalize overflow-hidden">
{row.original.display_name ? (
row.original.display_name?.[0]
) : (
<UserRound className="text-custom-text-200 " size={12} />
)}
</div>
)}
<span className="break-words text-custom-text-200">
{row.original.display_name ?? t(`Unassigned`)}
</span>
</div>
</div>
),
meta: {
export: {
key: columnsLabels["display_name"],
value: (row) => row.original.display_name?.toString() ?? "",
},
},
},
{
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>,
meta: {
export: {
key: columnsLabels["backlog_work_items"],
value: (row) => row.original.backlog_work_items.toString(),
},
},
},
{
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>,
meta: {
export: {
key: columnsLabels["started_work_items"],
value: (row) => row.original.started_work_items.toString(),
},
},
},
{
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>,
meta: {
export: {
key: columnsLabels["un_started_work_items"],
value: (row) => row.original.un_started_work_items.toString(),
},
},
},
{
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>,
meta: {
export: {
key: columnsLabels["completed_work_items"],
value: (row) => row.original.completed_work_items.toString(),
},
},
},
{
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>,
meta: {
export: {
key: columnsLabels["cancelled_work_items"],
value: (row) => row.original.cancelled_work_items.toString(),
},
},
},
],
[columnsLabels, getProjectById, isPeekView, t]
);
return (
<InsightTable<"work-items">
analyticsType="work-items"
data={workItemsData}
isLoading={isLoading}
columns={columns}
columnsLabels={columnsLabels}
headerText={isPeekView ? t("common.assignee") : t("common.projects")}
onExport={(rows) => workItemsData && exportCSV(rows, columns, workspaceSlug)}
/>
);
});
export default WorkItemsInsightTable;