UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: фильтры и настройки вида домашнего Ганта

This commit is contained in:
DCCONSTRUCTIONS 2026-04-24 14:43:13 +03:00
parent ad1d9c34ea
commit eff71d7258
3 changed files with 416 additions and 36 deletions

View File

@ -4,8 +4,8 @@
* See the LICENSE file for details.
*/
import { useMemo, useState } from "react";
import { CalendarDays, Filter, SlidersHorizontal } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { CalendarDays, Check, Filter, SlidersHorizontal } from "lucide-react";
import type { ChartDataType, IGanttBlock } from "@plane/types";
import { cn } from "@plane/utils";
import { getItemPositionWidth } from "@/components/gantt-chart/views/helpers";
@ -19,11 +19,17 @@ const GANTT_CANVAS_PADDING = 32;
export type TGanttPreviewRange = (typeof GANTT_RANGES)[number];
export type TGanttTimelinePreviewItem = {
assignee_ids?: string[] | null;
completed_at?: string | null;
created_at?: string | null;
created_by?: string | null;
id: string;
name: string;
identifier: string;
priority?: string | null;
sort_order?: number | null;
start_date?: string | null;
state_group?: TGanttPreviewStateGroup | null;
target_date?: string | null;
};
@ -50,6 +56,11 @@ type TGanttPreviewBlock = TGanttTimelinePreviewItem & {
width: number;
};
type TGanttPreviewStateGroup = "backlog" | "unstarted" | "started" | "completed" | "cancelled";
type TGanttPreviewStatusFilter = "open" | "backlog" | "unstarted" | "started" | "completed" | "cancelled";
type TGanttPreviewDateFilter = "overdue" | "complete-range";
type TGanttPreviewSortMode = "target_date_asc" | "target_date_desc" | "start_date_asc" | "created_at_desc";
const RANGE_SETTINGS: Record<TGanttPreviewRange, TRangeSettings> = {
Live: {
dayWidth: 76,
@ -81,6 +92,31 @@ const RANGE_SETTINGS: Record<TGanttPreviewRange, TRangeSettings> = {
},
};
const GANTT_STATUS_FILTER_OPTIONS: {
groups: TGanttPreviewStateGroup[];
key: TGanttPreviewStatusFilter;
label: string;
}[] = [
{ groups: ["backlog", "unstarted", "started"], key: "open", label: "Открытые" },
{ groups: ["backlog"], key: "backlog", label: "Бэклог" },
{ groups: ["unstarted"], key: "unstarted", label: "Todo" },
{ groups: ["started"], key: "started", label: "В процессе" },
{ groups: ["completed"], key: "completed", label: "Закрытые" },
{ groups: ["cancelled"], key: "cancelled", label: "Отмененные" },
];
const GANTT_DATE_FILTER_OPTIONS: { key: TGanttPreviewDateFilter; label: string }[] = [
{ key: "overdue", label: "Просроченные" },
{ key: "complete-range", label: "С началом и сроком" },
];
const GANTT_SORT_OPTIONS: { key: TGanttPreviewSortMode; label: string }[] = [
{ key: "target_date_asc", label: "Ближайший срок" },
{ key: "target_date_desc", label: "Дальний срок" },
{ key: "start_date_asc", label: "Раннее начало" },
{ key: "created_at_desc", label: "Новые сверху" },
];
const startOfDay = (date: Date) => {
const nextDate = new Date(date);
nextDate.setHours(0, 0, 0, 0);
@ -156,15 +192,74 @@ const getShortDateLabel = (date: Date, locale: string) =>
.format(date)
.toUpperCase();
const getDateSortValue = (value?: string | null, emptyValue = Number.POSITIVE_INFINITY) =>
getDateFromValue(value)?.getTime() ?? emptyValue;
const isOverdueItem = (item: TGanttTimelinePreviewItem, today: Date) => {
const targetDate = getDateFromValue(item.target_date);
if (!targetDate) return false;
return targetDate < today && item.state_group !== "completed" && item.state_group !== "cancelled";
};
const matchesStatusFilters = (item: TGanttTimelinePreviewItem, activeStatusFilters: TGanttPreviewStatusFilter[]) => {
if (activeStatusFilters.length === 0) return true;
if (!item.state_group) return false;
return activeStatusFilters.some((filterKey) =>
GANTT_STATUS_FILTER_OPTIONS.find((option) => option.key === filterKey)?.groups.includes(item.state_group)
);
};
const sortPreviewItems = (items: TGanttTimelinePreviewItem[], sortMode: TGanttPreviewSortMode) =>
[...items].sort((firstItem, secondItem) => {
if (sortMode === "target_date_desc") {
return (
getDateSortValue(secondItem.target_date, Number.NEGATIVE_INFINITY) -
getDateSortValue(firstItem.target_date, Number.NEGATIVE_INFINITY)
);
}
if (sortMode === "start_date_asc") {
return getDateSortValue(firstItem.start_date) - getDateSortValue(secondItem.start_date);
}
if (sortMode === "created_at_desc") {
return (
getDateSortValue(secondItem.created_at, Number.NEGATIVE_INFINITY) -
getDateSortValue(firstItem.created_at, Number.NEGATIVE_INFINITY)
);
}
return getDateSortValue(firstItem.target_date) - getDateSortValue(secondItem.target_date);
});
export function GanttTimelinePreview(props: TGanttTimelinePreviewProps) {
const { emptyMessage, isLoading = false, items, locale, subtitle, title } = props;
const [activeRange, setActiveRange] = useState<TGanttPreviewRange>("Live");
const [isCompactMode, setIsCompactMode] = useState(false);
const [showCompleteBlocksOnly, setShowCompleteBlocksOnly] = useState(false);
const [activePanel, setActivePanel] = useState<"filters" | "view" | null>(null);
const [activeDateFilters, setActiveDateFilters] = useState<TGanttPreviewDateFilter[]>([]);
const [activeStatusFilters, setActiveStatusFilters] = useState<TGanttPreviewStatusFilter[]>([]);
const [showFullTaskName, setShowFullTaskName] = useState(false);
const [sortMode, setSortMode] = useState<TGanttPreviewSortMode>("target_date_asc");
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
const visibleItems = useMemo(() => {
const today = startOfDay(new Date());
const filteredItems = items.filter((item) => {
if (!matchesStatusFilters(item, activeStatusFilters)) return false;
if (activeDateFilters.includes("overdue") && !isOverdueItem(item, today)) return false;
if (activeDateFilters.includes("complete-range") && (!item.start_date || !item.target_date)) return false;
return true;
});
return sortPreviewItems(filteredItems, sortMode);
}, [activeDateFilters, activeStatusFilters, items, sortMode]);
const timeline = useMemo(() => {
const settings = RANGE_SETTINGS[activeRange];
const { endDate, startDate, today } = getTimelineBounds(items, settings);
const { endDate, startDate, today } = getTimelineBounds(visibleItems, settings);
const totalDays = getDaysBetween(startDate, endDate) + 1;
const timelineWidth = Math.max(totalDays * settings.dayWidth, 620);
const canvasWidth = GANTT_LABEL_COLUMN_WIDTH + GANTT_LABEL_GAP + timelineWidth + GANTT_CANVAS_PADDING;
@ -180,10 +275,8 @@ export function GanttTimelinePreview(props: TGanttTimelinePreviewProps) {
},
};
const blocks = items
const blocks = visibleItems
.map((item, index) => {
if (showCompleteBlocksOnly && (!item.start_date || !item.target_date)) return undefined;
const block: IGanttBlock = {
data: item,
id: item.id,
@ -216,9 +309,44 @@ export function GanttTimelinePreview(props: TGanttTimelinePreviewProps) {
timelineWidth,
todayLeft: getDaysBetween(startDate, today) * settings.dayWidth,
};
}, [activeRange, items, locale, showCompleteBlocksOnly]);
}, [activeRange, locale, visibleItems]);
const hiddenItemsCount = Math.max(items.length - timeline.blocks.length, 0);
const activeFilterCount =
activeDateFilters.length + activeStatusFilters.length + (sortMode === "target_date_asc" ? 0 : 1);
const toggleStatusFilter = (filterKey: TGanttPreviewStatusFilter) =>
setActiveStatusFilters((currentFilters) =>
currentFilters.includes(filterKey)
? currentFilters.filter((currentFilter) => currentFilter !== filterKey)
: [...currentFilters, filterKey]
);
const toggleDateFilter = (filterKey: TGanttPreviewDateFilter) =>
setActiveDateFilters((currentFilters) =>
currentFilters.includes(filterKey)
? currentFilters.filter((currentFilter) => currentFilter !== filterKey)
: [...currentFilters, filterKey]
);
const resetFilters = () => {
setActiveDateFilters([]);
setActiveStatusFilters([]);
setSortMode("target_date_asc");
};
useEffect(() => {
const scrollElement = scrollContainerRef.current;
if (!scrollElement || isLoading) return;
const todayCanvasLeft = GANTT_LABEL_COLUMN_WIDTH + GANTT_LABEL_GAP + timeline.todayLeft;
const nextScrollLeft = Math.max(todayCanvasLeft - scrollElement.clientWidth * 0.45, 0);
const frame = window.requestAnimationFrame(() => {
scrollElement.scrollTo({ behavior: "auto", left: nextScrollLeft });
});
return () => window.cancelAnimationFrame(frame);
}, [activeRange, isLoading, items.length, timeline.timelineWidth, timeline.todayLeft]);
return (
<section className="nodedc-home-gantt-card">
@ -245,33 +373,148 @@ export function GanttTimelinePreview(props: TGanttTimelinePreviewProps) {
{item}
</button>
))}
<div className="nodedc-home-gantt-action-group">
<button
type="button"
className={cn("nodedc-home-gantt-round-button", {
"nodedc-home-gantt-round-button-active": isCompactMode,
"nodedc-home-gantt-round-button-active": showFullTaskName || activePanel === "view",
})}
aria-pressed={isCompactMode}
aria-label="Плотный режим Ганта"
onClick={() => setIsCompactMode((value) => !value)}
aria-expanded={activePanel === "view"}
aria-label="Настройки вида Ганта"
aria-pressed={showFullTaskName}
onClick={() => setActivePanel((currentPanel) => (currentPanel === "view" ? null : "view"))}
>
<SlidersHorizontal className="size-4" />
</button>
<button
type="button"
className={cn("nodedc-home-gantt-round-button", {
"nodedc-home-gantt-round-button-active": showCompleteBlocksOnly,
"nodedc-home-gantt-round-button-active": activeFilterCount > 0 || activePanel === "filters",
})}
aria-pressed={showCompleteBlocksOnly}
aria-label="Показывать только задачи с началом и сроком"
onClick={() => setShowCompleteBlocksOnly((value) => !value)}
aria-expanded={activePanel === "filters"}
aria-label="Фильтры задач Ганта"
aria-pressed={activeFilterCount > 0}
onClick={() => setActivePanel((currentPanel) => (currentPanel === "filters" ? null : "filters"))}
>
<Filter className="size-4" />
</button>
{activePanel === "view" && (
<div className="nodedc-home-gantt-popover">
<div className="nodedc-home-gantt-popover-section">
<button
type="button"
className={cn("nodedc-home-gantt-popover-option", {
"nodedc-home-gantt-popover-option-active": showFullTaskName,
})}
onClick={() => setShowFullTaskName((value) => !value)}
>
<span className="nodedc-home-gantt-popover-option-left">
<span className="nodedc-home-gantt-popover-check">
{showFullTaskName && <Check className="size-3" />}
</span>
<span>Показать полное название</span>
</span>
</button>
</div>
</div>
)}
{activePanel === "filters" && (
<div className="nodedc-home-gantt-popover nodedc-home-gantt-popover-wide">
<div className="nodedc-home-gantt-popover-section">
<div className="nodedc-home-gantt-popover-title">Статус</div>
{GANTT_STATUS_FILTER_OPTIONS.map((option) => {
const isActive = activeStatusFilters.includes(option.key);
return (
<button
key={option.key}
type="button"
className={cn("nodedc-home-gantt-popover-option", {
"nodedc-home-gantt-popover-option-active": isActive,
})}
onClick={() => toggleStatusFilter(option.key)}
>
<span className="nodedc-home-gantt-popover-option-left">
<span className="nodedc-home-gantt-popover-check">
{isActive && <Check className="size-3" />}
</span>
<span>{option.label}</span>
</span>
</button>
);
})}
</div>
<div className="nodedc-home-gantt-popover-section">
<div className="nodedc-home-gantt-popover-title">Даты</div>
{GANTT_DATE_FILTER_OPTIONS.map((option) => {
const isActive = activeDateFilters.includes(option.key);
return (
<button
key={option.key}
type="button"
className={cn("nodedc-home-gantt-popover-option", {
"nodedc-home-gantt-popover-option-active": isActive,
})}
onClick={() => toggleDateFilter(option.key)}
>
<span className="nodedc-home-gantt-popover-option-left">
<span className="nodedc-home-gantt-popover-check">
{isActive && <Check className="size-3" />}
</span>
<span>{option.label}</span>
</span>
</button>
);
})}
</div>
<div className="nodedc-home-gantt-popover-section">
<div className="nodedc-home-gantt-popover-title">Сортировка</div>
{GANTT_SORT_OPTIONS.map((option) => {
const isActive = sortMode === option.key;
return (
<button
key={option.key}
type="button"
className={cn("nodedc-home-gantt-popover-option", {
"nodedc-home-gantt-popover-option-active": isActive,
})}
onClick={() => setSortMode(option.key)}
>
<span className="nodedc-home-gantt-popover-option-left">
<span className="nodedc-home-gantt-popover-check">
{isActive && <Check className="size-3" />}
</span>
<span>{option.label}</span>
</span>
</button>
);
})}
</div>
{activeFilterCount > 0 && (
<button type="button" className="nodedc-home-gantt-popover-reset" onClick={resetFilters}>
Сбросить фильтры
</button>
)}
</div>
)}
</div>
</div>
</div>
<div className="nodedc-home-gantt-surface">
<div className="nodedc-home-gantt-scroll" tabIndex={0} aria-label="Горизонтальная прокрутка окна Ганта">
<div
ref={scrollContainerRef}
className="nodedc-home-gantt-scroll"
tabIndex={0}
aria-label="Горизонтальная прокрутка окна Ганта"
>
<div className="nodedc-home-gantt-canvas" style={{ width: `${timeline.canvasWidth}px` }}>
<div className="nodedc-home-gantt-grid" style={{ width: `${timeline.timelineWidth}px` }} aria-hidden="true">
{timeline.ticks.map((tick) => (
@ -284,7 +527,7 @@ export function GanttTimelinePreview(props: TGanttTimelinePreviewProps) {
</div>
</div>
<div className={cn("relative z-[1] space-y-3 pt-12", { "space-y-2": isCompactMode })}>
<div className="relative z-[1] space-y-3 pt-12">
{isLoading ? (
Array.from({ length: 4 }, (_, index) => (
<div key={index} className="h-12 animate-pulse rounded-[1.25rem] bg-white/5" />
@ -293,13 +536,20 @@ export function GanttTimelinePreview(props: TGanttTimelinePreviewProps) {
timeline.blocks.map((item) => (
<div
key={item.id}
className={cn("nodedc-home-gantt-row", { "nodedc-home-gantt-row-compact": isCompactMode })}
className="nodedc-home-gantt-row"
style={{
gridTemplateColumns: `${GANTT_LABEL_COLUMN_WIDTH}px ${timeline.timelineWidth}px`,
}}
>
<div className="nodedc-home-gantt-row-label min-w-0">
<div className="truncate text-12 font-semibold text-primary">{item.name}</div>
<div
className={cn("text-12 font-semibold text-primary", {
"nodedc-home-gantt-row-name-full": showFullTaskName,
truncate: !showFullTaskName,
})}
>
{item.name}
</div>
<div className="mt-0.5 truncate text-11 text-placeholder">{item.identifier}</div>
</div>
<div className="nodedc-home-gantt-track" style={{ width: `${timeline.timelineWidth}px` }}>
@ -326,7 +576,7 @@ export function GanttTimelinePreview(props: TGanttTimelinePreviewProps) {
{!isLoading && hiddenItemsCount > 0 && (
<div className="nodedc-home-gantt-footnote">
{hiddenItemsCount} задач без подходящего диапазона скрыто из календарного окна.
{hiddenItemsCount} задач скрыто фильтрами или без подходящего диапазона.
</div>
)}
</div>

View File

@ -34,15 +34,26 @@ const getIssueResults = (response: unknown): TIssue[] => {
return [];
};
const getIssueStateGroup = (issue: TIssue): TGanttTimelinePreviewItem["state_group"] =>
issue.state__group ??
(issue as TIssue & { state_detail?: { group?: TGanttTimelinePreviewItem["state_group"] } }).state_detail?.group ??
null;
const buildPreviewItems = (issues: TIssue[], project: THomeProjectData | undefined): TGanttTimelinePreviewItem[] =>
issues.map((issue, index) => ({
assignee_ids: issue.assignee_ids,
completed_at: issue.completed_at,
created_at: issue.created_at,
created_by: issue.created_by,
id: issue.id,
identifier: project
? `${project.identifier}-${issue.sequence_id ?? index + 1}`
: `#${issue.sequence_id ?? index + 1}`,
name: issue.name,
priority: issue.priority,
sort_order: issue.sort_order,
start_date: issue.start_date,
state_group: getIssueStateGroup(issue),
target_date: issue.target_date,
}));

View File

@ -1721,6 +1721,118 @@
color: rgb(var(--nodedc-on-card-active-rgb)) !important;
}
.nodedc-home-gantt-action-group {
position: relative;
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.nodedc-home-gantt-popover {
position: absolute;
top: calc(100% + 0.55rem);
right: 0;
z-index: 12;
min-width: 13.5rem;
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 1.25rem;
background: rgba(9, 9, 11, 0.96);
padding: 0.38rem;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.04),
0 18px 38px rgba(0, 0, 0, 0.28);
-webkit-backdrop-filter: blur(24px);
backdrop-filter: blur(24px);
}
.nodedc-home-gantt-popover-wide {
min-width: 18rem;
}
.nodedc-home-gantt-popover-section {
display: grid;
gap: 0.25rem;
}
.nodedc-home-gantt-popover-section + .nodedc-home-gantt-popover-section {
margin-top: 0.45rem;
border-top: 1px solid rgba(255, 255, 255, 0.055);
padding-top: 0.45rem;
}
.nodedc-home-gantt-popover-title {
padding: 0.35rem 0.7rem 0.25rem;
color: var(--text-color-placeholder);
font-size: 0.65rem;
font-weight: 800;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.nodedc-home-gantt-popover-option {
display: flex;
width: 100%;
min-height: 2.35rem;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
border: 0 !important;
outline: none !important;
border-radius: 0.95rem !important;
background: transparent !important;
padding: 0.55rem 0.7rem;
color: var(--text-color-secondary);
font-size: 0.75rem;
font-weight: 750;
text-align: left;
}
.nodedc-home-gantt-popover-option-left {
display: flex;
min-width: 0;
align-items: center;
gap: 0.58rem;
}
.nodedc-home-gantt-popover-option:hover,
.nodedc-home-gantt-popover-option-active {
background: rgba(var(--nodedc-card-active-rgb), 0.16) !important;
color: var(--text-color-primary);
}
.nodedc-home-gantt-popover-check {
display: grid;
width: 1.1rem;
min-width: 1.1rem;
height: 1.1rem;
place-items: center;
border: 1px solid rgba(255, 255, 255, 0.16);
border-radius: 999px;
color: rgb(var(--nodedc-card-active-rgb));
}
.nodedc-home-gantt-popover-option-active .nodedc-home-gantt-popover-check {
border-color: rgb(var(--nodedc-card-active-rgb));
background: rgba(var(--nodedc-card-active-rgb), 0.16);
}
.nodedc-home-gantt-popover-reset {
width: 100%;
min-height: 2.35rem;
margin-top: 0.45rem;
border: 0 !important;
outline: none !important;
border-radius: 0.95rem !important;
background: rgba(255, 255, 255, 0.08) !important;
color: var(--text-color-primary);
font-size: 0.75rem;
font-weight: 800;
}
.nodedc-home-gantt-popover-reset:hover {
background: rgba(255, 255, 255, 0.12) !important;
}
.nodedc-home-gantt-surface {
position: relative;
min-height: 23.5rem;
@ -1868,6 +1980,13 @@
min-height: 2.8rem;
}
.nodedc-home-gantt-row-name-full {
overflow: visible;
line-height: 1.2;
overflow-wrap: anywhere;
white-space: normal;
}
.nodedc-home-gantt-row-label {
position: sticky;
left: 0;