diff --git a/plane-src/apps/web/core/components/gantt-chart/preview/timeline-preview.tsx b/plane-src/apps/web/core/components/gantt-chart/preview/timeline-preview.tsx index ffef5c9..6ae5197 100644 --- a/plane-src/apps/web/core/components/gantt-chart/preview/timeline-preview.tsx +++ b/plane-src/apps/web/core/components/gantt-chart/preview/timeline-preview.tsx @@ -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 = { Live: { dayWidth: 76, @@ -81,6 +92,31 @@ const RANGE_SETTINGS: Record = { }, }; +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("Live"); - const [isCompactMode, setIsCompactMode] = useState(false); - const [showCompleteBlocksOnly, setShowCompleteBlocksOnly] = useState(false); + const [activePanel, setActivePanel] = useState<"filters" | "view" | null>(null); + const [activeDateFilters, setActiveDateFilters] = useState([]); + const [activeStatusFilters, setActiveStatusFilters] = useState([]); + const [showFullTaskName, setShowFullTaskName] = useState(false); + const [sortMode, setSortMode] = useState("target_date_asc"); + const scrollContainerRef = useRef(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 (
@@ -245,33 +373,148 @@ export function GanttTimelinePreview(props: TGanttTimelinePreviewProps) { {item} ))} - - +
+ + + + {activePanel === "view" && ( +
+
+ +
+
+ )} + + {activePanel === "filters" && ( +
+
+
Статус
+ {GANTT_STATUS_FILTER_OPTIONS.map((option) => { + const isActive = activeStatusFilters.includes(option.key); + + return ( + + ); + })} +
+ +
+
Даты
+ {GANTT_DATE_FILTER_OPTIONS.map((option) => { + const isActive = activeDateFilters.includes(option.key); + + return ( + + ); + })} +
+ +
+
Сортировка
+ {GANTT_SORT_OPTIONS.map((option) => { + const isActive = sortMode === option.key; + + return ( + + ); + })} +
+ + {activeFilterCount > 0 && ( + + )} +
+ )} +
-
+
-
+
{isLoading ? ( Array.from({ length: 4 }, (_, index) => (
@@ -293,13 +536,20 @@ export function GanttTimelinePreview(props: TGanttTimelinePreviewProps) { timeline.blocks.map((item) => (
-
{item.name}
+
+ {item.name} +
{item.identifier}
@@ -326,7 +576,7 @@ export function GanttTimelinePreview(props: TGanttTimelinePreviewProps) { {!isLoading && hiddenItemsCount > 0 && (
- {hiddenItemsCount} задач без подходящего диапазона скрыто из календарного окна. + {hiddenItemsCount} задач скрыто фильтрами или без подходящего диапазона.
)}
diff --git a/plane-src/apps/web/core/components/home/home-gantt-preview.tsx b/plane-src/apps/web/core/components/home/home-gantt-preview.tsx index b8a5019..cc6c4f2 100644 --- a/plane-src/apps/web/core/components/home/home-gantt-preview.tsx +++ b/plane-src/apps/web/core/components/home/home-gantt-preview.tsx @@ -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, })); diff --git a/plane-src/apps/web/styles/globals.css b/plane-src/apps/web/styles/globals.css index 2cc3fae..71055ee 100644 --- a/plane-src/apps/web/styles/globals.css +++ b/plane-src/apps/web/styles/globals.css @@ -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;