UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: фильтры и настройки вида домашнего Ганта
This commit is contained in:
parent
ad1d9c34ea
commit
eff71d7258
|
|
@ -4,8 +4,8 @@
|
||||||
* See the LICENSE file for details.
|
* See the LICENSE file for details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useMemo, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { CalendarDays, Filter, SlidersHorizontal } from "lucide-react";
|
import { CalendarDays, Check, Filter, SlidersHorizontal } from "lucide-react";
|
||||||
import type { ChartDataType, IGanttBlock } from "@plane/types";
|
import type { ChartDataType, IGanttBlock } from "@plane/types";
|
||||||
import { cn } from "@plane/utils";
|
import { cn } from "@plane/utils";
|
||||||
import { getItemPositionWidth } from "@/components/gantt-chart/views/helpers";
|
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 TGanttPreviewRange = (typeof GANTT_RANGES)[number];
|
||||||
|
|
||||||
export type TGanttTimelinePreviewItem = {
|
export type TGanttTimelinePreviewItem = {
|
||||||
|
assignee_ids?: string[] | null;
|
||||||
|
completed_at?: string | null;
|
||||||
|
created_at?: string | null;
|
||||||
|
created_by?: string | null;
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
identifier: string;
|
identifier: string;
|
||||||
|
priority?: string | null;
|
||||||
sort_order?: number | null;
|
sort_order?: number | null;
|
||||||
start_date?: string | null;
|
start_date?: string | null;
|
||||||
|
state_group?: TGanttPreviewStateGroup | null;
|
||||||
target_date?: string | null;
|
target_date?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -50,6 +56,11 @@ type TGanttPreviewBlock = TGanttTimelinePreviewItem & {
|
||||||
width: number;
|
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> = {
|
const RANGE_SETTINGS: Record<TGanttPreviewRange, TRangeSettings> = {
|
||||||
Live: {
|
Live: {
|
||||||
dayWidth: 76,
|
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 startOfDay = (date: Date) => {
|
||||||
const nextDate = new Date(date);
|
const nextDate = new Date(date);
|
||||||
nextDate.setHours(0, 0, 0, 0);
|
nextDate.setHours(0, 0, 0, 0);
|
||||||
|
|
@ -156,15 +192,74 @@ const getShortDateLabel = (date: Date, locale: string) =>
|
||||||
.format(date)
|
.format(date)
|
||||||
.toUpperCase();
|
.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) {
|
export function GanttTimelinePreview(props: TGanttTimelinePreviewProps) {
|
||||||
const { emptyMessage, isLoading = false, items, locale, subtitle, title } = props;
|
const { emptyMessage, isLoading = false, items, locale, subtitle, title } = props;
|
||||||
const [activeRange, setActiveRange] = useState<TGanttPreviewRange>("Live");
|
const [activeRange, setActiveRange] = useState<TGanttPreviewRange>("Live");
|
||||||
const [isCompactMode, setIsCompactMode] = useState(false);
|
const [activePanel, setActivePanel] = useState<"filters" | "view" | null>(null);
|
||||||
const [showCompleteBlocksOnly, setShowCompleteBlocksOnly] = useState(false);
|
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 timeline = useMemo(() => {
|
||||||
const settings = RANGE_SETTINGS[activeRange];
|
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 totalDays = getDaysBetween(startDate, endDate) + 1;
|
||||||
const timelineWidth = Math.max(totalDays * settings.dayWidth, 620);
|
const timelineWidth = Math.max(totalDays * settings.dayWidth, 620);
|
||||||
const canvasWidth = GANTT_LABEL_COLUMN_WIDTH + GANTT_LABEL_GAP + timelineWidth + GANTT_CANVAS_PADDING;
|
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) => {
|
.map((item, index) => {
|
||||||
if (showCompleteBlocksOnly && (!item.start_date || !item.target_date)) return undefined;
|
|
||||||
|
|
||||||
const block: IGanttBlock = {
|
const block: IGanttBlock = {
|
||||||
data: item,
|
data: item,
|
||||||
id: item.id,
|
id: item.id,
|
||||||
|
|
@ -216,9 +309,44 @@ export function GanttTimelinePreview(props: TGanttTimelinePreviewProps) {
|
||||||
timelineWidth,
|
timelineWidth,
|
||||||
todayLeft: getDaysBetween(startDate, today) * settings.dayWidth,
|
todayLeft: getDaysBetween(startDate, today) * settings.dayWidth,
|
||||||
};
|
};
|
||||||
}, [activeRange, items, locale, showCompleteBlocksOnly]);
|
}, [activeRange, locale, visibleItems]);
|
||||||
|
|
||||||
const hiddenItemsCount = Math.max(items.length - timeline.blocks.length, 0);
|
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 (
|
return (
|
||||||
<section className="nodedc-home-gantt-card">
|
<section className="nodedc-home-gantt-card">
|
||||||
|
|
@ -245,33 +373,148 @@ export function GanttTimelinePreview(props: TGanttTimelinePreviewProps) {
|
||||||
{item}
|
{item}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
<button
|
<div className="nodedc-home-gantt-action-group">
|
||||||
type="button"
|
<button
|
||||||
className={cn("nodedc-home-gantt-round-button", {
|
type="button"
|
||||||
"nodedc-home-gantt-round-button-active": isCompactMode,
|
className={cn("nodedc-home-gantt-round-button", {
|
||||||
})}
|
"nodedc-home-gantt-round-button-active": showFullTaskName || activePanel === "view",
|
||||||
aria-pressed={isCompactMode}
|
})}
|
||||||
aria-label="Плотный режим Ганта"
|
aria-expanded={activePanel === "view"}
|
||||||
onClick={() => setIsCompactMode((value) => !value)}
|
aria-label="Настройки вида Ганта"
|
||||||
>
|
aria-pressed={showFullTaskName}
|
||||||
<SlidersHorizontal className="size-4" />
|
onClick={() => setActivePanel((currentPanel) => (currentPanel === "view" ? null : "view"))}
|
||||||
</button>
|
>
|
||||||
<button
|
<SlidersHorizontal className="size-4" />
|
||||||
type="button"
|
</button>
|
||||||
className={cn("nodedc-home-gantt-round-button", {
|
<button
|
||||||
"nodedc-home-gantt-round-button-active": showCompleteBlocksOnly,
|
type="button"
|
||||||
})}
|
className={cn("nodedc-home-gantt-round-button", {
|
||||||
aria-pressed={showCompleteBlocksOnly}
|
"nodedc-home-gantt-round-button-active": activeFilterCount > 0 || activePanel === "filters",
|
||||||
aria-label="Показывать только задачи с началом и сроком"
|
})}
|
||||||
onClick={() => setShowCompleteBlocksOnly((value) => !value)}
|
aria-expanded={activePanel === "filters"}
|
||||||
>
|
aria-label="Фильтры задач Ганта"
|
||||||
<Filter className="size-4" />
|
aria-pressed={activeFilterCount > 0}
|
||||||
</button>
|
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>
|
</div>
|
||||||
|
|
||||||
<div className="nodedc-home-gantt-surface">
|
<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-canvas" style={{ width: `${timeline.canvasWidth}px` }}>
|
||||||
<div className="nodedc-home-gantt-grid" style={{ width: `${timeline.timelineWidth}px` }} aria-hidden="true">
|
<div className="nodedc-home-gantt-grid" style={{ width: `${timeline.timelineWidth}px` }} aria-hidden="true">
|
||||||
{timeline.ticks.map((tick) => (
|
{timeline.ticks.map((tick) => (
|
||||||
|
|
@ -284,7 +527,7 @@ export function GanttTimelinePreview(props: TGanttTimelinePreviewProps) {
|
||||||
</div>
|
</div>
|
||||||
</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 ? (
|
{isLoading ? (
|
||||||
Array.from({ length: 4 }, (_, index) => (
|
Array.from({ length: 4 }, (_, index) => (
|
||||||
<div key={index} className="h-12 animate-pulse rounded-[1.25rem] bg-white/5" />
|
<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) => (
|
timeline.blocks.map((item) => (
|
||||||
<div
|
<div
|
||||||
key={item.id}
|
key={item.id}
|
||||||
className={cn("nodedc-home-gantt-row", { "nodedc-home-gantt-row-compact": isCompactMode })}
|
className="nodedc-home-gantt-row"
|
||||||
style={{
|
style={{
|
||||||
gridTemplateColumns: `${GANTT_LABEL_COLUMN_WIDTH}px ${timeline.timelineWidth}px`,
|
gridTemplateColumns: `${GANTT_LABEL_COLUMN_WIDTH}px ${timeline.timelineWidth}px`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="nodedc-home-gantt-row-label min-w-0">
|
<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 className="mt-0.5 truncate text-11 text-placeholder">{item.identifier}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="nodedc-home-gantt-track" style={{ width: `${timeline.timelineWidth}px` }}>
|
<div className="nodedc-home-gantt-track" style={{ width: `${timeline.timelineWidth}px` }}>
|
||||||
|
|
@ -326,7 +576,7 @@ export function GanttTimelinePreview(props: TGanttTimelinePreviewProps) {
|
||||||
|
|
||||||
{!isLoading && hiddenItemsCount > 0 && (
|
{!isLoading && hiddenItemsCount > 0 && (
|
||||||
<div className="nodedc-home-gantt-footnote">
|
<div className="nodedc-home-gantt-footnote">
|
||||||
{hiddenItemsCount} задач без подходящего диапазона скрыто из календарного окна.
|
{hiddenItemsCount} задач скрыто фильтрами или без подходящего диапазона.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -34,15 +34,26 @@ const getIssueResults = (response: unknown): TIssue[] => {
|
||||||
return [];
|
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[] =>
|
const buildPreviewItems = (issues: TIssue[], project: THomeProjectData | undefined): TGanttTimelinePreviewItem[] =>
|
||||||
issues.map((issue, index) => ({
|
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,
|
id: issue.id,
|
||||||
identifier: project
|
identifier: project
|
||||||
? `${project.identifier}-${issue.sequence_id ?? index + 1}`
|
? `${project.identifier}-${issue.sequence_id ?? index + 1}`
|
||||||
: `#${issue.sequence_id ?? index + 1}`,
|
: `#${issue.sequence_id ?? index + 1}`,
|
||||||
name: issue.name,
|
name: issue.name,
|
||||||
|
priority: issue.priority,
|
||||||
sort_order: issue.sort_order,
|
sort_order: issue.sort_order,
|
||||||
start_date: issue.start_date,
|
start_date: issue.start_date,
|
||||||
|
state_group: getIssueStateGroup(issue),
|
||||||
target_date: issue.target_date,
|
target_date: issue.target_date,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1721,6 +1721,118 @@
|
||||||
color: rgb(var(--nodedc-on-card-active-rgb)) !important;
|
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 {
|
.nodedc-home-gantt-surface {
|
||||||
position: relative;
|
position: relative;
|
||||||
min-height: 23.5rem;
|
min-height: 23.5rem;
|
||||||
|
|
@ -1868,6 +1980,13 @@
|
||||||
min-height: 2.8rem;
|
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 {
|
.nodedc-home-gantt-row-label {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
left: 0;
|
left: 0;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue