UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: русификация и редизайн аналитики внутреннего контура

This commit is contained in:
DCCONSTRUCTIONS 2026-04-24 13:41:06 +03:00
parent cf6fca20aa
commit 52bd017d82
19 changed files with 498 additions and 140 deletions

View File

@ -22,7 +22,9 @@ function AnalyticsWrapper(props: Props) {
<div className={cn("flex flex-col gap-4 pb-5", className)}>
<div className="nodedc-external-panel flex items-center justify-between gap-3 px-5 py-4 md:px-6">
<div className="flex flex-col gap-1">
<span className="text-11 font-medium tracking-[0.24em] text-secondary uppercase">Analytics</span>
<span className="text-11 font-medium tracking-[0.24em] text-secondary uppercase">
{t("workspace_analytics.label")}
</span>
<h1 className="text-20 font-semibold text-primary md:text-[1.45rem]">{t(i18nTitle)}</h1>
</div>
</div>

View File

@ -65,18 +65,18 @@ export function DataTable<TData, TValue>({ columns, data, searchPlaceholder, act
});
return (
<div className="space-y-4">
<div className="flex w-full items-center justify-between">
<div className="nodedc-analytics-table space-y-4">
<div className="nodedc-analytics-table-toolbar 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 text-13 whitespace-nowrap text-placeholder">
<div className="nodedc-analytics-table-count flex items-center gap-2 text-13 whitespace-nowrap text-placeholder">
{searchPlaceholder}
</div>
)}
{!isSearchOpen && (
<button
type="button"
className="-mr-5 grid place-items-center rounded-sm p-2 text-placeholder hover:bg-layer-1"
className="nodedc-analytics-search-button -mr-5 grid place-items-center rounded-sm p-2 text-placeholder hover:bg-layer-1"
onClick={() => {
setIsSearchOpen(true);
inputRef.current?.focus();
@ -87,7 +87,7 @@ export function DataTable<TData, TValue>({ columns, data, searchPlaceholder, act
)}
<div
className={cn(
"mr-auto flex w-0 items-center justify-start gap-1 overflow-hidden rounded-md border border-transparent bg-surface-1 text-placeholder opacity-0 transition-[width] ease-linear",
"nodedc-analytics-search-field mr-auto flex w-0 items-center justify-start gap-1 overflow-hidden rounded-md border border-transparent bg-surface-1 text-placeholder opacity-0 transition-[width] ease-linear",
{
"w-64 border-subtle px-2.5 py-1.5 opacity-100": isSearchOpen,
}
@ -97,7 +97,7 @@ export function DataTable<TData, TValue>({ columns, data, searchPlaceholder, act
<input
ref={inputRef}
className="w-full max-w-[234px] border-none bg-transparent text-13 text-primary placeholder:text-placeholder focus:outline-none"
placeholder="Search"
placeholder="Поиск"
value={table.getColumn(table.getHeaderGroups()?.[0]?.headers?.[0]?.id)?.getFilterValue() as string}
onChange={(e) => {
const columnId = table.getHeaderGroups()?.[0]?.headers?.[0]?.id;
@ -129,7 +129,7 @@ export function DataTable<TData, TValue>({ columns, data, searchPlaceholder, act
{actions && <div>{actions(table)}</div>}
</div>
<div className="rounded-md">
<div className="nodedc-analytics-table-surface rounded-md">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (

View File

@ -39,6 +39,7 @@ export function InsightTable<T extends Exclude<TAnalyticsTabsBase, "overview">>(
actions={(table: Table<AnalyticsTableDataMap[T]>) => (
<Button
variant="secondary"
className="nodedc-analytics-export-button"
prependIcon={<Download className="h-3.5 w-3.5" />}
onClick={() => onExport?.(table.getFilteredRowModel().rows)}
>

View File

@ -0,0 +1,93 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { ChartXAxisProperty, ChartYAxisMetric } from "@plane/types";
const X_AXIS_LABELS: Partial<Record<ChartXAxisProperty, string>> = {
[ChartXAxisProperty.STATES]: "Статус",
[ChartXAxisProperty.STATE_GROUPS]: "Группа статуса",
[ChartXAxisProperty.PRIORITY]: "Приоритет",
[ChartXAxisProperty.LABELS]: "Метка",
[ChartXAxisProperty.ASSIGNEES]: "Исполнитель",
[ChartXAxisProperty.ESTIMATE_POINTS]: "Оценка",
[ChartXAxisProperty.CYCLES]: "Цикл",
[ChartXAxisProperty.MODULES]: "Модуль",
[ChartXAxisProperty.COMPLETED_AT]: "Дата завершения",
[ChartXAxisProperty.TARGET_DATE]: "Срок",
[ChartXAxisProperty.START_DATE]: "Дата начала",
[ChartXAxisProperty.CREATED_AT]: "Дата создания",
[ChartXAxisProperty.CREATED_BY]: "Автор",
[ChartXAxisProperty.WORK_ITEM_TYPES]: "Тип задачи",
[ChartXAxisProperty.PROJECTS]: "Проект",
[ChartXAxisProperty.EPICS]: "Эпик",
};
const Y_AXIS_LABELS: Partial<Record<ChartYAxisMetric, string>> = {
[ChartYAxisMetric.WORK_ITEM_COUNT]: "Количество задач",
[ChartYAxisMetric.ESTIMATE_POINT_COUNT]: "Оценка",
[ChartYAxisMetric.PENDING_WORK_ITEM_COUNT]: "Ожидающие задачи",
[ChartYAxisMetric.COMPLETED_WORK_ITEM_COUNT]: "Завершённые задачи",
[ChartYAxisMetric.IN_PROGRESS_WORK_ITEM_COUNT]: "Задачи в процессе",
[ChartYAxisMetric.WORK_ITEM_DUE_THIS_WEEK_COUNT]: "Срок на этой неделе",
[ChartYAxisMetric.WORK_ITEM_DUE_TODAY_COUNT]: "Срок сегодня",
[ChartYAxisMetric.BLOCKED_WORK_ITEM_COUNT]: "Заблокированные задачи",
[ChartYAxisMetric.EPIC_WORK_ITEM_COUNT]: "Количество эпиков",
};
const VALUE_LABELS: Record<string, string> = {
none: "Нет",
null: "Нет",
undefined: "Нет",
unassigned: "Без исполнителя",
urgent: "Срочно",
high: "Высокий",
medium: "Средний",
low: "Низкий",
backlog: "Бэклог",
unstarted: "Туду",
started: "В процессе",
completed: "Готово",
cancelled: "Отменено",
canceled: "Отменено",
"no priority": "Без приоритета",
"no value": "Нет",
"state name": "Статус",
"state group": "Группа статуса",
priority: "Приоритет",
label: "Метка",
assignee: "Исполнитель",
"estimate point": "Оценка",
cycle: "Цикл",
module: "Модуль",
"completed date": "Дата завершения",
"due date": "Срок",
"start date": "Дата начала",
"created date": "Дата создания",
count: "Количество",
};
const DURATION_LABELS: Record<string, string> = {
yesterday: "Вчера",
last_7_days: "Последние 7 дней",
last_30_days: "Последние 30 дней",
last_3_months: "Последние 3 месяца",
};
export const getAnalyticsXAxisLabel = (value: ChartXAxisProperty | string, fallback?: string) =>
X_AXIS_LABELS[value as ChartXAxisProperty] ?? fallback ?? String(value);
export const getAnalyticsYAxisLabel = (value: ChartYAxisMetric | string, fallback?: string) =>
Y_AXIS_LABELS[value as ChartYAxisMetric] ?? fallback ?? String(value);
export const getAnalyticsDurationLabel = (value: string, fallback?: string) =>
DURATION_LABELS[value] ?? fallback ?? value;
export const getAnalyticsValueLabel = (value: string | number | null | undefined) => {
if (value === null || value === undefined || value === "") return "Нет";
const stringValue = String(value);
return VALUE_LABELS[stringValue.trim().toLowerCase()] ?? stringValue;
};

View File

@ -77,7 +77,7 @@ const ProjectInsights = observer(function ProjectInsights() {
radars={[
{
key: "count",
name: "Count",
name: "Количество",
fill: "var(--text-color-accent-primary)",
stroke: "var(--text-color-accent-primary)",
fillOpacity: 0.6,

View File

@ -16,6 +16,7 @@ import type { IAnalyticsParams } from "@plane/types";
import { ChartYAxisMetric } from "@plane/types";
import { cn } from "@plane/utils";
// plane web components
import { getAnalyticsXAxisLabel, getAnalyticsYAxisLabel } from "../labels";
import { SelectXAxis } from "./select-x-axis";
import { SelectYAxis } from "./select-y-axis";
@ -31,17 +32,33 @@ type Props = {
export const AnalyticsSelectParams = observer(function AnalyticsSelectParams(props: Props) {
const { control, params, classNames, isEpic } = props;
const xAxisOptions = useMemo(
() => ANALYTICS_X_AXIS_VALUES.filter((option) => option.value !== params.group_by),
() =>
ANALYTICS_X_AXIS_VALUES.filter((option) => option.value !== params.group_by).map((option) => ({
...option,
label: getAnalyticsXAxisLabel(option.value, option.label),
})),
[params.group_by]
);
const groupByOptions = useMemo(
() => ANALYTICS_X_AXIS_VALUES.filter((option) => option.value !== params.x_axis),
() =>
ANALYTICS_X_AXIS_VALUES.filter((option) => option.value !== params.x_axis).map((option) => ({
...option,
label: getAnalyticsXAxisLabel(option.value, option.label),
})),
[params.x_axis]
);
const yAxisOptions = useMemo(
() =>
ANALYTICS_Y_AXIS_VALUES.map((option) => ({
...option,
label: getAnalyticsYAxisLabel(option.value, option.label),
})),
[]
);
return (
<div className={cn("flex w-full justify-between", classNames)}>
<div className={`flex items-center gap-2`}>
<div className="nodedc-analytics-filter-row flex items-center gap-2">
<Controller
name="y_axis"
control={control}
@ -51,7 +68,7 @@ export const AnalyticsSelectParams = observer(function AnalyticsSelectParams(pro
onChange={(val: ChartYAxisMetric | null) => {
onChange(val);
}}
options={ANALYTICS_Y_AXIS_VALUES}
options={yAxisOptions}
hiddenOptions={[
ChartYAxisMetric.ESTIMATE_POINT_COUNT,
isEpic ? ChartYAxisMetric.WORK_ITEM_COUNT : ChartYAxisMetric.EPIC_WORK_ITEM_COUNT,
@ -72,7 +89,7 @@ export const AnalyticsSelectParams = observer(function AnalyticsSelectParams(pro
<div className="flex items-center gap-2">
<CalendarLayoutIcon className="h-3 w-3" />
<span className={cn("text-secondary", value && "text-primary")}>
{xAxisOptions.find((v) => v.value === value)?.label || "Add Property"}
{xAxisOptions.find((v) => v.value === value)?.label || "Добавить поле"}
</span>
</div>
}
@ -93,12 +110,12 @@ export const AnalyticsSelectParams = observer(function AnalyticsSelectParams(pro
<div className="flex items-center gap-2">
<SlidersHorizontal className="h-3 w-3" />
<span className={cn("text-secondary", value && "text-primary")}>
{groupByOptions.find((v) => v.value === value)?.label || "Add Property"}
{groupByOptions.find((v) => v.value === value)?.label || "Группировка"}
</span>
</div>
}
options={groupByOptions}
placeholder="Group By"
placeholder="Группировка"
allowNoValue
/>
)}

View File

@ -14,6 +14,7 @@ import { useTranslation } from "@plane/i18n";
// types
import { SelectionDropdown } from "@/components/common/selection-dropdown";
import type { TDropdownProps } from "@/components/dropdowns/types";
import { getAnalyticsDurationLabel } from "../labels";
type Props = TDropdownProps & {
value: string | null;
@ -27,12 +28,12 @@ type Props = TDropdownProps & {
tabIndex?: number;
};
function DurationDropdown({ placeholder = "Duration", onChange, value }: Props) {
function DurationDropdown({ placeholder = "Период", onChange, value }: Props) {
useTranslation();
const options = ANALYTICS_DURATION_FILTER_OPTIONS.map((option) => ({
key: option.value,
title: option.name,
title: getAnalyticsDurationLabel(option.value, option.name),
isChecked: value === option.value,
onClick: () => onChange(option.value),
}));
@ -42,10 +43,16 @@ function DurationDropdown({ placeholder = "Duration", onChange, value }: Props)
menuButton={
<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}
{value
? getAnalyticsDurationLabel(
value,
ANALYTICS_DURATION_FILTER_OPTIONS.find((opt) => opt.value === value)?.name
)
: placeholder}
</div>
}
menuButtonWrapperClassName="flex items-center rounded-full border-0 outline-none"
menuButtonWrapperClassName="nodedc-analytics-select-trigger"
dropdownClassName="nodedc-analytics-dropdown"
/>
);
}

View File

@ -58,13 +58,13 @@ export const ProjectSelect = observer(function ProjectSelect(props: Props) {
>
<ProjectIcon className="h-4 w-4" />
{value && value.length > 3
? `3+ projects`
? `3+ проекта`
: value && value.length > 0
? projectIds
?.filter((p) => value.includes(p))
.map((p) => getProjectById(p)?.name)
.join(", ")
: "All projects"}
: "Все проекты"}
<ChevronDownIcon className="h-3 w-3" aria-hidden="true" />
</div>
)}

View File

@ -19,16 +19,18 @@ type Props = {
};
export function SelectXAxis(props: Props) {
const { value, onChange, options, hiddenOptions, allowNoValue, label } = props;
const { value, onChange, options, placeholder = "Выбрать", hiddenOptions, allowNoValue, label } = props;
return (
<SelectionDropdown
menuButton={label ?? "Select"}
menuButton={label ?? placeholder}
menuButtonWrapperClassName="nodedc-analytics-select-trigger"
dropdownClassName="nodedc-analytics-dropdown"
options={[
...(allowNoValue
? [
{
key: "__none__",
title: "No value",
title: "Без группировки",
isChecked: value == null,
onClick: () => onChange(null),
},

View File

@ -45,10 +45,12 @@ export const SelectYAxis = observer(function SelectYAxis({ value, onChange, hidd
return (
<SelectionDropdown
menuButtonWrapperClassName="nodedc-analytics-select-trigger"
dropdownClassName="nodedc-analytics-dropdown"
menuButton={
<div className="flex items-center gap-2">
<ProjectIcon className="h-3 w-3" />
<span>{options.find((v) => v.value === value)?.label ?? "Add Metric"}</span>
<span>{options.find((v) => v.value === value)?.label ?? "Выбрать метрику"}</span>
</div>
}
options={options

View File

@ -65,24 +65,24 @@ const CreatedVsResolved = observer(function CreatedVsResolved() {
() => [
{
key: "completed_issues",
label: "Resolved",
fill: "#19803833",
label: "Решено",
fill: "rgba(195, 255, 102, 0.18)",
fillOpacity: 1,
stackId: "bar-one",
showDot: false,
smoothCurves: true,
strokeColor: "#198038",
strokeColor: "#C3FF66",
strokeOpacity: 1,
},
{
key: "created_issues",
label: "Created",
fill: "#1192E833",
label: "Создано",
fill: "rgba(245, 247, 251, 0.14)",
fillOpacity: 1,
stackId: "bar-one",
showDot: false,
smoothCurves: true,
strokeColor: "#1192E8",
strokeColor: "#F5F7FB",
strokeOpacity: 1,
},
],

View File

@ -26,7 +26,7 @@ export const WorkItemsModalHeader = observer(function WorkItemsModalHeader(props
return (
<div className="flex items-center justify-between gap-4 bg-surface-1 px-5 py-4 text-13">
<h3 className="break-words">
Analytics for {title} {cycle && `in ${cycle.name}`} {module && `in ${module.name}`}
Аналитика: {title} {cycle && `в цикле ${cycle.name}`} {module && `в модуле ${module.name}`}
</h3>
<div className="flex items-center gap-2">
<button

View File

@ -8,27 +8,27 @@ 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 { EChartModels } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button";
import { BarChart } from "@plane/propel/charts/bar-chart";
import { EmptyStateCompact } from "@plane/propel/empty-state";
import type { TBarItem, TChart, TChartDatum, ChartXAxisProperty, ChartYAxisMetric } from "@plane/types";
// plane web components
import { generateExtendedColors, parseChartData } from "@/components/chart/utils";
import { parseChartData } from "@/components/chart/utils";
// hooks
import { useAnalytics } from "@/hooks/store/use-analytics";
import { useProjectState } from "@/hooks/store/use-project-state";
import { AnalyticsService } from "@/services/analytics.service";
import { exportCSV } from "../export";
import { DataTable } from "../insight-table/data-table";
import { getAnalyticsValueLabel, getAnalyticsXAxisLabel, getAnalyticsYAxisLabel } from "../labels";
import { ChartLoader } from "../loaders";
import { generateBarColor } from "./utils";
import { generateBarColor, NODEDC_ANALYTICS_COLORS } from "./utils";
declare module "@tanstack/react-table" {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
@ -55,7 +55,6 @@ const PriorityChart = observer(function PriorityChart(props: Props) {
// 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();
@ -83,70 +82,71 @@ const PriorityChart = observer(function PriorityChart(props: Props) {
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 localizedData = useMemo(() => {
if (!parsedData) return undefined;
const schema = Object.fromEntries(
Object.entries(parsedData.schema).map(([key, value]) => [key, getAnalyticsValueLabel(value)])
);
const data = parsedData.data.map((datum) => ({
...datum,
name: getAnalyticsValueLabel(datum.name),
}));
return { ...parsedData, schema, data };
}, [parsedData]);
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);
const baseColors = NODEDC_ANALYTICS_COLORS;
if (chart_model === EChartModels.BASIC) {
parsedBars = [
{
key: "count",
label: "Count",
label: "Количество",
stackId: "bar-one",
fill: (payload) => generateBarColor(payload.key, { x_axis, y_axis, group_by }, baseColors, workspaceStates),
textClassName: "",
showPercentage: false,
borderRadius: 11,
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) => ({
parsedBars = schemaKeys.map((key) => ({
key: key,
label: parsedData.schema[key],
label: localizedData?.schema[key] ?? getAnalyticsValueLabel(parsedData.schema[key]),
stackId: "bar-one",
fill: extendedColors[index],
fill: generateBarColor(
key,
{ x_axis: group_by ?? x_axis, y_axis, group_by: undefined },
baseColors,
workspaceStates
),
textClassName: "",
showPercentage: false,
showTopBorderRadius: (value, payload: TChartDatum) => parsedExtremes[payload.key].top === value,
showBottomBorderRadius: (value, payload: TChartDatum) => parsedExtremes[payload.key].bottom === value,
borderRadius: 10,
showTopBorderRadius: () => true,
showBottomBorderRadius: () => true,
}));
} else {
parsedBars = [];
}
return parsedBars;
}, [chart_model, group_by, parsedData, resolvedTheme, workspaceStates, x_axis, y_axis]);
}, [chart_model, group_by, localizedData?.schema, parsedData, 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 yAxisLabel = useMemo(() => getAnalyticsYAxisLabel(props.y_axis), [props.y_axis]);
const xAxisLabel = useMemo(() => getAnalyticsXAxisLabel(props.x_axis), [props.x_axis]);
const chartWidth = useMemo(() => {
const itemCount = localizedData?.data.length ?? 0;
const widthPerItem = chart_model === EChartModels.STACKED ? 88 : 100;
return Math.max(560, itemCount * widthPerItem);
}, [chart_model, localizedData?.data.length]);
const defaultColumns: ColumnDef<TChartDatum>[] = useMemo(
() => [
@ -163,13 +163,13 @@ const PriorityChart = observer(function PriorityChart(props: Props) {
},
{
accessorKey: "count",
header: () => <div className="text-right">Count</div>,
header: () => <div className="text-right">Количество</div>,
cell: ({ row }) => <div className="text-right">{row.original.count}</div>,
meta: {
export: {
key: "Count",
key: "Количество",
value: (row) => row.original.count,
label: "Count",
label: "Количество",
},
},
},
@ -179,55 +179,64 @@ const PriorityChart = observer(function PriorityChart(props: Props) {
const columns: ColumnDef<TChartDatum>[] = useMemo(
() =>
parsedData
? Object.keys(parsedData?.schema ?? {}).map((key) => ({
localizedData
? Object.keys(localizedData?.schema ?? {}).map((key) => ({
accessorKey: key,
header: () => <div className="text-right">{parsedData.schema[key]}</div>,
header: () => <div className="text-right">{localizedData.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],
label: localizedData.schema[key],
},
},
}))
: [],
[parsedData]
[localizedData]
);
return (
<div className="flex flex-col gap-12">
<div className="nodedc-analytics-chart-stack flex flex-col gap-8">
{priorityChartLoading ? (
<ChartLoader />
) : parsedData?.data && parsedData.data.length > 0 ? (
) : localizedData?.data && localizedData.data.length > 0 ? (
<>
<div className="nodedc-analytics-chart-viewport">
<div className="nodedc-analytics-chart-inner h-[370px]" style={{ width: `${chartWidth}px` }}>
<BarChart
className="h-[370px] w-full"
data={parsedData.data}
className="nodedc-analytics-bar-chart h-full w-full"
data={localizedData.data}
bars={bars}
barSize={chart_model === EChartModels.STACKED ? 72 : 86}
margin={{
bottom: 30,
top: 12,
right: 16,
bottom: 34,
left: 8,
}}
xAxis={{
key: "name",
label: xAxisLabel.replace("_", " "),
label: xAxisLabel,
dy: 30,
}}
yAxis={{
key: "count",
label: t("common.no_of", { entity: yAxisLabel.replace("_", " ") }),
label: yAxisLabel,
offset: -60,
dx: -26,
}}
/>
</div>
</div>
<DataTable
data={parsedData.data}
data={localizedData.data}
columns={[...defaultColumns, ...columns]}
searchPlaceholder={`${parsedData.data.length} ${xAxisLabel}`}
searchPlaceholder={`${localizedData.data.length} ${xAxisLabel}`}
actions={(table: Table<TChartDatum>) => (
<Button
variant="secondary"
className="nodedc-analytics-export-button"
prependIcon={<Download className="h-3.5 w-3.5" />}
onClick={() => exportCSV(table.getRowModel().rows, [...defaultColumns, ...columns], workspaceSlug)}
>

View File

@ -5,7 +5,7 @@
*/
// plane package imports
import type { ChartYAxisMetric, IState } from "@plane/types";
import type { ChartYAxisMetric, IState, TStateGroups } from "@plane/types";
import { ChartXAxisProperty } from "@plane/types";
interface ParamsProps {
@ -14,26 +14,56 @@ interface ParamsProps {
group_by?: ChartXAxisProperty;
}
export const NODEDC_ANALYTICS_COLORS = [
"#C3FF66",
"#F5F7FB",
"#7C7F85",
"#050505",
"#A6ADBA",
"#DDE3EA",
"#2A2B2E",
"#9BFF38",
];
const STATE_GROUP_COLORS: Record<TStateGroups, string> = {
backlog: "#050505",
unstarted: "#7C7F85",
started: "#FFFFFF",
completed: "#C3FF66",
cancelled: "#050505",
};
const PRIORITY_COLORS: Record<string, string> = {
urgent: "#C3FF66",
high: "#F5F7FB",
medium: "#7C7F85",
low: "#2A2B2E",
none: "#050505",
};
const getFallbackColor = (value: string, baseColors: string[]) => {
const fallbackColors = baseColors.length > 0 ? baseColors : NODEDC_ANALYTICS_COLORS;
const index = Math.abs(value.split("").reduce((acc, char) => acc + char.charCodeAt(0), 0)) % fallbackColors.length;
return fallbackColors[index];
};
export const generateBarColor = (
value: string | null | undefined,
params: ParamsProps,
baseColors: string[],
workspaceStates?: IState[]
): string => {
if (!value) return baseColors[0];
let color = baseColors[0];
if (!value) return baseColors[0] ?? NODEDC_ANALYTICS_COLORS[0];
let color = getFallbackColor(value, baseColors);
// Priority
if (params.x_axis === ChartXAxisProperty.PRIORITY) {
color =
value === "urgent"
? "#ef4444"
: value === "high"
? "#f97316"
: value === "medium"
? "#eab308"
: value === "low"
? "#22c55e"
: "#ced4da";
color = PRIORITY_COLORS[value] ?? color;
}
// State group
if (params.x_axis === ChartXAxisProperty.STATE_GROUPS) {
color = STATE_GROUP_COLORS[value as TStateGroups] ?? color;
}
// State
@ -41,10 +71,9 @@ export const generateBarColor = (
if (workspaceStates && workspaceStates.length > 0) {
const state = workspaceStates.find((s) => s.id === value);
if (state) {
color = state.color;
color = STATE_GROUP_COLORS[state.group] ?? color;
} else {
const index = Math.abs(value.split("").reduce((acc, char) => acc + char.charCodeAt(0), 0)) % baseColors.length;
color = baseColors[index];
color = getFallbackColor(value, baseColors);
}
}
}

View File

@ -125,7 +125,7 @@ const WorkItemsInsightTable = observer(function WorkItemsInsightTable() {
)}
</div>
)}
<span className="break-words text-secondary">{row.original.display_name ?? t(`Unassigned`)}</span>
<span className="break-words text-secondary">{row.original.display_name ?? "Без исполнителя"}</span>
</div>
</div>
),

View File

@ -2573,6 +2573,201 @@
outline: none !important;
}
.nodedc-analytics-filter-row {
flex-wrap: wrap;
}
.nodedc-analytics-select-trigger {
display: inline-flex !important;
min-height: 2.35rem;
align-items: center;
justify-content: center;
border: 0 !important;
outline: none !important;
border-radius: 999px !important;
background: rgba(255, 255, 255, 0.06) !important;
padding: 0 0.95rem !important;
color: var(--text-color-secondary) !important;
font-size: 0.75rem;
font-weight: 650;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.018) !important;
transition:
background 160ms ease,
color 160ms ease,
box-shadow 160ms ease;
}
.nodedc-analytics-select-trigger:hover,
.nodedc-analytics-select-trigger:focus-visible {
background: rgba(255, 255, 255, 0.095) !important;
color: var(--text-color-primary) !important;
box-shadow: inset 0 0 0 1px rgba(var(--nodedc-card-active-rgb), 0.2) !important;
}
.nodedc-analytics-select-trigger .text-primary,
.nodedc-analytics-select-trigger svg {
color: rgb(var(--nodedc-card-active-rgb)) !important;
}
.nodedc-analytics-dropdown {
border-radius: 1.25rem !important;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.04) 0%, rgba(255, 255, 255, 0.014) 100%), rgba(10, 10, 12, 0.96) !important;
box-shadow:
0 24px 58px rgba(0, 0, 0, 0.34),
inset 0 1px 0 rgba(255, 255, 255, 0.025) !important;
}
.nodedc-analytics-chart-stack {
gap: 1.35rem !important;
}
.nodedc-analytics-chart-viewport {
overflow-x: auto;
overflow-y: hidden;
border-radius: 1.35rem;
background: rgba(0, 0, 0, 0.18);
padding: 0.45rem 0.35rem 0.15rem;
scrollbar-color: rgba(var(--nodedc-card-active-rgb), 0.7) rgba(255, 255, 255, 0.045);
scrollbar-width: thin;
}
.nodedc-analytics-chart-viewport::-webkit-scrollbar {
height: 0.55rem;
}
.nodedc-analytics-chart-viewport::-webkit-scrollbar-track {
border-radius: 999px;
background: rgba(255, 255, 255, 0.045);
}
.nodedc-analytics-chart-viewport::-webkit-scrollbar-thumb {
border-radius: 999px;
background: rgba(var(--nodedc-card-active-rgb), 0.7);
}
.nodedc-analytics-chart-inner {
min-width: 35rem;
}
.nodedc-analytics-bar-chart .recharts-cartesian-grid line {
stroke: rgba(255, 255, 255, 0.055) !important;
}
.nodedc-analytics-bar-chart .recharts-cartesian-axis-tick-value,
.nodedc-analytics-bar-chart .recharts-label {
fill: rgba(255, 255, 255, 0.56) !important;
letter-spacing: 0 !important;
}
.nodedc-analytics-bar-chart .recharts-tooltip-cursor {
fill: rgba(255, 255, 255, 0.055) !important;
rx: 18px;
ry: 18px;
}
.nodedc-analytics-bar-chart path {
stroke: none !important;
}
.nodedc-analytics-table-toolbar {
gap: 1rem;
}
.nodedc-analytics-table-count {
color: rgba(255, 255, 255, 0.54) !important;
}
.nodedc-analytics-search-button {
margin-right: 0 !important;
border-radius: 999px !important;
background: rgba(255, 255, 255, 0.055) !important;
color: var(--text-color-secondary) !important;
}
.nodedc-analytics-search-button:hover {
background: rgba(255, 255, 255, 0.095) !important;
color: var(--text-color-primary) !important;
}
.nodedc-analytics-search-field {
border: 0 !important;
border-radius: 999px !important;
background: rgba(255, 255, 255, 0.065) !important;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.018) !important;
}
.nodedc-analytics-table-surface {
overflow-x: auto;
border-radius: 1.25rem !important;
background: rgba(255, 255, 255, 0.026) !important;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.014) !important;
scrollbar-color: rgba(var(--nodedc-card-active-rgb), 0.62) rgba(255, 255, 255, 0.035);
scrollbar-width: thin;
}
.nodedc-analytics-table-surface::-webkit-scrollbar {
height: 0.5rem;
}
.nodedc-analytics-table-surface::-webkit-scrollbar-track {
border-radius: 999px;
background: rgba(255, 255, 255, 0.035);
}
.nodedc-analytics-table-surface::-webkit-scrollbar-thumb {
border-radius: 999px;
background: rgba(var(--nodedc-card-active-rgb), 0.62);
}
.nodedc-analytics-table-surface table {
min-width: max-content;
border-collapse: separate;
border-spacing: 0;
}
.nodedc-analytics-table-surface th,
.nodedc-analytics-table-surface td {
border-color: rgba(255, 255, 255, 0.055) !important;
padding: 0.75rem 0.9rem !important;
white-space: nowrap;
}
.nodedc-analytics-table-surface th {
color: rgba(255, 255, 255, 0.62) !important;
font-size: 0.73rem !important;
font-weight: 700 !important;
}
.nodedc-analytics-table-surface td {
color: rgba(255, 255, 255, 0.84) !important;
font-size: 0.78rem !important;
}
.nodedc-analytics-table-surface tbody tr:hover td {
background: rgba(255, 255, 255, 0.04) !important;
}
.nodedc-analytics-export-button {
min-height: 2.35rem;
border: 0 !important;
outline: none !important;
border-radius: 999px !important;
background: rgba(255, 255, 255, 0.06) !important;
color: var(--text-color-primary) !important;
padding-inline: 0.95rem !important;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.018) !important;
}
.nodedc-analytics-export-button:hover {
background: rgba(255, 255, 255, 0.095) !important;
}
.nodedc-analytics-export-button,
.nodedc-analytics-export-button * {
color: var(--text-color-primary) !important;
}
.nodedc-attachment-upload {
min-height: 4.5rem;
border: 0 !important;

View File

@ -36,6 +36,7 @@ interface TBarProps extends TShapeProps {
showPercentage?: boolean;
showTopBorderRadius?: boolean;
showBottomBorderRadius?: boolean;
borderRadius?: number;
dotted?: boolean;
}
@ -95,6 +96,7 @@ const CustomBar = React.memo(function CustomBar(props: TBarProps) {
showPercentage,
showTopBorderRadius,
showBottomBorderRadius,
borderRadius,
} = props;
if (!height) return null;
@ -109,8 +111,8 @@ const CustomBar = React.memo(function CustomBar(props: TBarProps) {
currentBarPercentage !== undefined &&
!Number.isNaN(currentBarPercentage);
const topBorderRadius = showTopBorderRadius ? BAR_TOP_BORDER_RADIUS : 0;
const bottomBorderRadius = showBottomBorderRadius ? BAR_BOTTOM_BORDER_RADIUS : 0;
const topBorderRadius = showTopBorderRadius ? (borderRadius ?? BAR_TOP_BORDER_RADIUS) : 0;
const bottomBorderRadius = showBottomBorderRadius ? (borderRadius ?? BAR_BOTTOM_BORDER_RADIUS) : 0;
return (
<g>
@ -177,6 +179,7 @@ const createShapeVariant =
showPercentage={bar.showPercentage}
showTopBorderRadius={!!showTopBorderRadius}
showBottomBorderRadius={!!showBottomBorderRadius}
borderRadius={bar.borderRadius}
{...factoryProps}
/>
);

View File

@ -78,6 +78,7 @@ export type TBarItem<T extends string> = {
stackId: string;
showTopBorderRadius?: (barKey: string, payload: any) => boolean;
showBottomBorderRadius?: (barKey: string, payload: any) => boolean;
borderRadius?: number;
shapeVariant?: TBarChartShapeVariant;
};

View File

@ -180,13 +180,10 @@ export function CustomSearchSelect(props: ICustomSearchSelectProps) {
key={option.value}
value={option.value}
className={({ active }) =>
cn(
"nodedc-dropdown-option",
{
cn("nodedc-dropdown-option", {
"bg-white/6": active,
"cursor-not-allowed text-placeholder opacity-60": option.disabled,
}
)
})
}
onClick={() => {
if (!multiple) closeDropdown();
@ -216,7 +213,7 @@ export function CustomSearchSelect(props: ICustomSearchSelectProps) {
<p className="px-1.5 py-1 text-placeholder italic">{noResultsMessage}</p>
)
) : (
<p className="px-1.5 py-1 text-placeholder italic">Loading...</p>
<p className="px-1.5 py-1 text-placeholder italic">Загрузка...</p>
)}
</div>
{footerOption}