ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: календарная привязка Ганта на главной

This commit is contained in:
DCCONSTRUCTIONS 2026-04-24 03:01:54 +03:00
parent e5036fc95b
commit a8b6f9f9ce
3 changed files with 399 additions and 215 deletions

View File

@ -0,0 +1,326 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useMemo, useState } from "react";
import { CalendarDays, 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";
const DAY_IN_MS = 24 * 60 * 60 * 1000;
const GANTT_RANGES = ["Live", "1D", "1W", "1M"] as const;
const GANTT_LABEL_COLUMN_WIDTH = 160;
const GANTT_LABEL_GAP = 16;
const GANTT_CANVAS_PADDING = 32;
export type TGanttPreviewRange = (typeof GANTT_RANGES)[number];
export type TGanttTimelinePreviewItem = {
id: string;
name: string;
identifier: string;
sort_order?: number | null;
start_date?: string | null;
target_date?: string | null;
};
type TGanttTimelinePreviewProps = {
emptyMessage?: string;
isLoading?: boolean;
items: TGanttTimelinePreviewItem[];
locale: string;
subtitle?: string;
title: string;
};
type TRangeSettings = {
dayWidth: number;
horizonDays: number;
paddingAfterDays: number;
paddingBeforeDays: number;
tickStepDays: number;
};
type TGanttPreviewBlock = TGanttTimelinePreviewItem & {
left: number;
tone: "accent" | "muted" | "white";
width: number;
};
const RANGE_SETTINGS: Record<TGanttPreviewRange, TRangeSettings> = {
Live: {
dayWidth: 76,
horizonDays: 14,
paddingAfterDays: 3,
paddingBeforeDays: 2,
tickStepDays: 1,
},
"1D": {
dayWidth: 120,
horizonDays: 1,
paddingAfterDays: 1,
paddingBeforeDays: 1,
tickStepDays: 1,
},
"1W": {
dayWidth: 72,
horizonDays: 7,
paddingAfterDays: 2,
paddingBeforeDays: 2,
tickStepDays: 1,
},
"1M": {
dayWidth: 34,
horizonDays: 30,
paddingAfterDays: 7,
paddingBeforeDays: 4,
tickStepDays: 5,
},
};
const startOfDay = (date: Date) => {
const nextDate = new Date(date);
nextDate.setHours(0, 0, 0, 0);
return nextDate;
};
const addDays = (date: Date, days: number) => {
const nextDate = new Date(date);
nextDate.setDate(nextDate.getDate() + days);
return nextDate;
};
const getDateFromValue = (value?: string | null) => {
if (!value) return undefined;
const date = new Date(value);
if (Number.isNaN(date.getTime())) return undefined;
return startOfDay(date);
};
const getDaysBetween = (startDate: Date, endDate: Date) =>
Math.max(Math.round((startOfDay(endDate).getTime() - startOfDay(startDate).getTime()) / DAY_IN_MS), 0);
const getBlockTone = (block: IGanttBlock, index: number): TGanttPreviewBlock["tone"] => {
const today = startOfDay(new Date());
const startDate = getDateFromValue(block.start_date);
const targetDate = getDateFromValue(block.target_date);
if (targetDate && targetDate < today) return "white";
if (startDate && targetDate && startDate <= today && targetDate >= today) return "accent";
return index % 3 === 0 ? "accent" : index % 3 === 1 ? "white" : "muted";
};
const getTimelineBounds = (items: TGanttTimelinePreviewItem[], settings: TRangeSettings) => {
const today = startOfDay(new Date());
const itemDates = items.flatMap((item) => [getDateFromValue(item.start_date), getDateFromValue(item.target_date)]);
const validDates = itemDates.filter((date): date is Date => !!date);
const minTime = Math.min(today.getTime(), ...validDates.map((date) => date.getTime()));
const maxTime = Math.max(addDays(today, settings.horizonDays).getTime(), ...validDates.map((date) => date.getTime()));
return {
endDate: addDays(new Date(maxTime), settings.paddingAfterDays),
startDate: addDays(new Date(minTime), -settings.paddingBeforeDays),
today,
};
};
const getTimelineTicks = (startDate: Date, endDate: Date, dayWidth: number, stepDays: number, locale: string) => {
const formatter = new Intl.DateTimeFormat(locale || "ru-RU", {
day: "numeric",
month: "short",
});
const days = getDaysBetween(startDate, endDate);
return Array.from({ length: Math.floor(days / stepDays) + 1 }, (_, index) => {
const date = addDays(startDate, index * stepDays);
return {
id: date.toISOString(),
label: formatter.format(date).toUpperCase(),
left: getDaysBetween(startDate, date) * dayWidth,
};
});
};
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 timeline = useMemo(() => {
const settings = RANGE_SETTINGS[activeRange];
const { endDate, startDate, today } = getTimelineBounds(items, 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;
const chartData: ChartDataType = {
key: activeRange,
i18n_title: activeRange,
data: {
approxFilterRange: 0,
currentDate: today,
dayWidth: settings.dayWidth,
endDate,
startDate,
},
};
const blocks = items
.map((item, index) => {
if (showCompleteBlocksOnly && (!item.start_date || !item.target_date)) return undefined;
const block: IGanttBlock = {
data: item,
id: item.id,
name: item.name,
sort_order: item.sort_order ?? undefined,
start_date: item.start_date ?? undefined,
target_date: item.target_date ?? undefined,
};
const position = getItemPositionWidth(chartData, block);
if (!position) return undefined;
const overflowLeft = Math.min(position.marginLeft, 0);
const left = Math.max(position.marginLeft, 0);
const width = Math.max(Math.min(position.width + overflowLeft, timelineWidth - left), settings.dayWidth * 0.78);
return {
...item,
left,
tone: getBlockTone(block, index),
width,
};
})
.filter((block): block is TGanttPreviewBlock => !!block);
return {
blocks,
canvasWidth,
ticks: getTimelineTicks(startDate, endDate, settings.dayWidth, settings.tickStepDays, locale),
timelineWidth,
todayLeft: getDaysBetween(startDate, today) * settings.dayWidth,
};
}, [activeRange, items, locale, showCompleteBlocksOnly]);
const hiddenItemsCount = Math.max(items.length - timeline.blocks.length, 0);
return (
<section className="nodedc-home-gantt-card">
<div className="nodedc-home-gantt-toolbar">
<div className="flex min-w-0 items-center gap-3">
<div className="grid size-10 shrink-0 place-items-center rounded-full bg-black text-[rgb(var(--nodedc-card-active-rgb))]">
<CalendarDays className="size-4" />
</div>
<div className="min-w-0">
<div className="text-18 leading-none font-semibold text-primary">{title}</div>
{subtitle && <div className="mt-1 truncate text-12 text-secondary">{subtitle}</div>}
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
{GANTT_RANGES.map((item) => (
<button
key={item}
type="button"
aria-pressed={activeRange === item}
className={cn("nodedc-home-gantt-chip", { "nodedc-home-gantt-chip-active": activeRange === item })}
onClick={() => setActiveRange(item)}
>
{item}
</button>
))}
<button
type="button"
className={cn("nodedc-home-gantt-round-button", {
"nodedc-home-gantt-round-button-active": isCompactMode,
})}
aria-pressed={isCompactMode}
aria-label="Плотный режим Ганта"
onClick={() => setIsCompactMode((value) => !value)}
>
<SlidersHorizontal className="size-4" />
</button>
<button
type="button"
className={cn("nodedc-home-gantt-round-button", {
"nodedc-home-gantt-round-button-active": showCompleteBlocksOnly,
})}
aria-pressed={showCompleteBlocksOnly}
aria-label="Показывать только задачи с началом и сроком"
onClick={() => setShowCompleteBlocksOnly((value) => !value)}
>
<Filter className="size-4" />
</button>
</div>
</div>
<div className="nodedc-home-gantt-surface">
<div 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) => (
<div key={tick.id} className="nodedc-home-gantt-grid-column" style={{ left: `${tick.left}px` }}>
<span>{tick.label}</span>
</div>
))}
<div className="nodedc-home-gantt-today-line" style={{ left: `${timeline.todayLeft}px` }} />
</div>
<div className={cn("relative z-[1] space-y-3 pt-12", { "space-y-2": isCompactMode })}>
{isLoading ? (
Array.from({ length: 4 }, (_, index) => (
<div key={index} className="h-12 animate-pulse rounded-[1.25rem] bg-white/5" />
))
) : timeline.blocks.length > 0 ? (
timeline.blocks.map((item) => (
<div
key={item.id}
className={cn("nodedc-home-gantt-row", { "nodedc-home-gantt-row-compact": isCompactMode })}
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="mt-0.5 truncate text-11 text-placeholder">{item.identifier}</div>
</div>
<div className="nodedc-home-gantt-track" style={{ width: `${timeline.timelineWidth}px` }}>
<div
className={cn("nodedc-home-gantt-bar", `nodedc-home-gantt-bar-${item.tone}`)}
style={{
left: `${item.left}px`,
width: `${item.width}px`,
}}
/>
</div>
</div>
))
) : (
<div className="nodedc-home-gantt-empty">
<div className="text-14 font-semibold text-primary">Нет задач с датами для Ганта</div>
<div className="mt-1 text-12 text-secondary">
{emptyMessage ??
"Добавьте start date или target date, чтобы задача появилась на календарной шкале."}
</div>
</div>
)}
</div>
{!isLoading && hiddenItemsCount > 0 && (
<div className="nodedc-home-gantt-footnote">
{hiddenItemsCount} задач без подходящего диапазона скрыто из календарного окна.
</div>
)}
</div>
</div>
</div>
</section>
);
}

View File

@ -4,17 +4,18 @@
* See the LICENSE file for details.
*/
import { useMemo, useState } from "react";
import { CalendarDays, Filter, SlidersHorizontal } from "lucide-react";
import useSWR from "swr";
import { useTranslation } from "@plane/i18n";
import type { TIssue, TProjectAnalyticsCount } from "@plane/types";
import { cn } from "@plane/utils";
import {
GanttTimelinePreview,
type TGanttTimelinePreviewItem,
} from "@/components/gantt-chart/preview/timeline-preview";
import { IssueService } from "@/services/issue";
import { getCompletionRate, type THomeProjectData } from "./home.utils";
import type { THomeProjectData } from "./home.utils";
const issueService = new IssueService();
const GANTT_PREVIEW_LIMIT = 6;
const GANTT_PREVIEW_LIMIT = 30;
const GANTT_PREVIEW_CURSOR = `${GANTT_PREVIEW_LIMIT}:0:0`;
type HomeGanttPreviewProps = {
@ -23,85 +24,31 @@ type HomeGanttPreviewProps = {
workspaceSlug: string;
};
type TGanttPreviewItem = {
id: string;
label: string;
subtitle: string;
start: number;
width: number;
tone: "accent" | "muted" | "white";
const getIssueResults = (response: unknown): TIssue[] => {
if (Array.isArray(response)) return response as TIssue[];
if (response && typeof response === "object" && "results" in response) {
const results = (response as { results?: unknown }).results;
if (Array.isArray(results)) return results as TIssue[];
}
return [];
};
const GANTT_RANGES = ["Live", "1D", "1W", "1M"] as const;
type TGanttRange = (typeof GANTT_RANGES)[number];
const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max);
const buildSyntheticItems = (project: THomeProjectData | undefined, analytics: TProjectAnalyticsCount | undefined) => {
const completionRate = getCompletionRate(analytics);
const openIssues = Math.max((analytics?.total_issues ?? 0) - (analytics?.completed_issues ?? 0), 0);
const baseName = project?.identifier ?? "NODE";
return [
{
id: "synthetic-approval",
label: "Согласование расходов",
subtitle: `${baseName} / финконтроль`,
start: 6,
width: clamp(34 + completionRate * 0.22, 26, 58),
tone: "accent",
},
{
id: "synthetic-docs",
label: "Контроль документов",
subtitle: `${baseName} / внешний обмен`,
start: 22,
width: clamp(28 + openIssues * 2, 24, 54),
tone: "white",
},
{
id: "synthetic-sync",
label: "Синхронизация статусов",
subtitle: `${baseName} / внутренний контур`,
start: 42,
width: 36,
tone: "muted",
},
{
id: "synthetic-close",
label: "Закрытие остатка",
subtitle: `${baseName} / итог недели`,
start: 58,
width: 28,
tone: "accent",
},
] satisfies TGanttPreviewItem[];
};
const buildIssueItems = (issues: TIssue[], project: THomeProjectData): TGanttPreviewItem[] =>
issues.slice(0, GANTT_PREVIEW_LIMIT).map((issue, index) => {
const createdDate = Date.parse(issue.created_at ?? "") || Date.now();
const targetDate = Date.parse(issue.target_date ?? "") || createdDate + (index + 3) * 24 * 60 * 60 * 1000;
const durationDays = Math.max((targetDate - createdDate) / (24 * 60 * 60 * 1000), 1);
const start = clamp((index * 13 + durationDays * 2) % 68, 4, 72);
const width = clamp(20 + durationDays * 5, 22, 48);
return {
id: issue.id,
label: issue.name,
subtitle: `${project.identifier}-${issue.sequence_id ?? index + 1}`,
start,
width,
tone: index % 3 === 0 ? "accent" : index % 3 === 1 ? "white" : "muted",
};
});
const buildPreviewItems = (issues: TIssue[], project: THomeProjectData | undefined): TGanttTimelinePreviewItem[] =>
issues.map((issue, index) => ({
id: issue.id,
identifier: project
? `${project.identifier}-${issue.sequence_id ?? index + 1}`
: `#${issue.sequence_id ?? index + 1}`,
name: issue.name,
sort_order: issue.sort_order,
start_date: issue.start_date,
target_date: issue.target_date,
}));
export function HomeGanttPreview(props: HomeGanttPreviewProps) {
const { analytics, project, workspaceSlug } = props;
const { project, workspaceSlug } = props;
const { currentLocale } = useTranslation();
const [activeRange, setActiveRange] = useState<TGanttRange>("Live");
const [isCompactMode, setIsCompactMode] = useState(false);
const [isFilterActive, setIsFilterActive] = useState(false);
const { data: issueResponse, isLoading } = useSWR(
project ? `HOME_GANTT_PREVIEW_${workspaceSlug}_${project.id}` : null,
@ -120,139 +67,17 @@ export function HomeGanttPreview(props: HomeGanttPreviewProps) {
}
);
const timelineLabels = useMemo(() => {
const locale = currentLocale || "ru-RU";
const dayFormatter = new Intl.DateTimeFormat(locale, {
day: "numeric",
month: "short",
});
const hourFormatter = new Intl.DateTimeFormat(locale, {
hour: "2-digit",
minute: "2-digit",
});
if (activeRange === "Live" || activeRange === "1D") {
const date = new Date();
const step = activeRange === "Live" ? 2 : 3;
return Array.from({ length: activeRange === "Live" ? 8 : 9 }, (_, index) => {
const labelDate = new Date(date);
labelDate.setHours(date.getHours() + index * step);
return hourFormatter.format(labelDate);
});
}
return Array.from({ length: activeRange === "1W" ? 7 : 8 }, (_, index) => {
const date = new Date();
date.setDate(date.getDate() + index * (activeRange === "1W" ? 1 : 4));
return dayFormatter.format(date);
});
}, [activeRange, currentLocale]);
const previewItems = useMemo(() => {
const issues = issueResponse?.results;
if (project && Array.isArray(issues) && issues.length > 0) return buildIssueItems(issues, project);
return buildSyntheticItems(project, analytics);
}, [analytics, issueResponse, project]);
const visibleItems = isFilterActive ? previewItems.filter((item) => item.tone !== "muted") : previewItems;
const timelineWidth = `${Math.max(timelineLabels.length * 168 + 240, 1080)}px`;
const issues = getIssueResults(issueResponse);
const previewItems = buildPreviewItems(issues, project);
return (
<section className="nodedc-home-gantt-card">
<div className="nodedc-home-gantt-toolbar">
<div className="flex min-w-0 items-center gap-3">
<div className="grid size-10 shrink-0 place-items-center rounded-full bg-black text-[rgb(var(--nodedc-card-active-rgb))]">
<CalendarDays className="size-4" />
</div>
<div className="min-w-0">
<div className="text-18 leading-none font-semibold text-primary">Календарное окно Ганта</div>
<div className="mt-1 truncate text-12 text-secondary">
{project ? `${project.name} / ближайший рабочий горизонт` : "Выберите проект для живого окна"}
</div>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
{GANTT_RANGES.map((item) => (
<button
key={item}
type="button"
aria-pressed={activeRange === item}
className={cn("nodedc-home-gantt-chip", { "nodedc-home-gantt-chip-active": activeRange === item })}
onClick={() => setActiveRange(item)}
>
{item}
</button>
))}
<button
type="button"
className={cn("nodedc-home-gantt-round-button", {
"nodedc-home-gantt-round-button-active": isCompactMode,
})}
aria-pressed={isCompactMode}
aria-label="Плотный режим Ганта"
onClick={() => setIsCompactMode((value) => !value)}
>
<SlidersHorizontal className="size-4" />
</button>
<button
type="button"
className={cn("nodedc-home-gantt-round-button", {
"nodedc-home-gantt-round-button-active": isFilterActive,
})}
aria-pressed={isFilterActive}
aria-label="Фильтры Ганта"
onClick={() => setIsFilterActive((value) => !value)}
>
<Filter className="size-4" />
</button>
</div>
</div>
<div className="nodedc-home-gantt-surface">
<div className="nodedc-home-gantt-scroll" tabIndex={0} aria-label="Горизонтальная прокрутка окна Ганта">
<div className="nodedc-home-gantt-canvas" style={{ width: timelineWidth }}>
<div
className="nodedc-home-gantt-grid"
style={{ gridTemplateColumns: `repeat(${timelineLabels.length}, minmax(10rem, 1fr))` }}
aria-hidden="true"
>
{timelineLabels.map((label, index) => (
<div key={`${label}-${index}`} className="nodedc-home-gantt-grid-column">
<span>{label}</span>
</div>
))}
</div>
<div className={cn("relative z-[1] space-y-3 pt-12", { "space-y-2": isCompactMode })}>
{isLoading
? Array.from({ length: 4 }, (_, index) => (
<div key={index} className="h-12 animate-pulse rounded-[1.25rem] bg-white/5" />
))
: visibleItems.map((item) => (
<div
key={item.id}
className={cn("nodedc-home-gantt-row", { "nodedc-home-gantt-row-compact": isCompactMode })}
>
<div className="nodedc-home-gantt-row-label min-w-0">
<div className="truncate text-12 font-semibold text-primary">{item.label}</div>
<div className="mt-0.5 truncate text-11 text-placeholder">{item.subtitle}</div>
</div>
<div className="nodedc-home-gantt-track">
<div
className={cn("nodedc-home-gantt-bar", `nodedc-home-gantt-bar-${item.tone}`)}
style={{
left: `${item.start}%`,
width: `${item.width}%`,
}}
/>
</div>
</div>
))}
</div>
</div>
</div>
</div>
</section>
<GanttTimelinePreview
emptyMessage="На главной показываются только задачи выбранного проекта, у которых заполнены start date или target date."
isLoading={isLoading}
items={previewItems}
locale={currentLocale}
title="Календарное окно Ганта"
subtitle={project ? `${project.name} / реальные даты задач` : "Выберите проект для календарного окна"}
/>
);
}

View File

@ -1782,11 +1782,12 @@
position: absolute;
inset: 1rem 1rem 1rem 11.5rem;
z-index: 1;
display: grid;
gap: 0;
}
.nodedc-home-gantt-grid-column {
position: absolute;
top: 0;
bottom: 0;
min-height: 22rem;
border-left: 1px solid rgba(255, 255, 255, 0.04);
padding-left: 0.75rem;
@ -1797,9 +1798,17 @@
text-transform: uppercase;
}
.nodedc-home-gantt-today-line {
position: absolute;
top: 0;
bottom: 0;
width: 2px;
background: rgba(var(--nodedc-card-active-rgb), 0.42);
box-shadow: 0 0 18px rgba(var(--nodedc-card-active-rgb), 0.28);
}
.nodedc-home-gantt-row {
display: grid;
grid-template-columns: minmax(7.5rem, 10rem) minmax(0, 1fr);
align-items: center;
gap: 1rem;
min-height: 3.45rem;
@ -1846,6 +1855,31 @@
background: rgba(255, 255, 255, 0.18);
}
.nodedc-home-gantt-empty {
display: flex;
min-height: 14rem;
max-width: 32rem;
flex-direction: column;
justify-content: center;
border-radius: 1.6rem;
background: rgba(0, 0, 0, 0.2);
padding: 1.5rem;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.018) !important;
}
.nodedc-home-gantt-footnote {
position: sticky;
left: 1rem;
z-index: 2;
margin-top: 1rem;
width: fit-content;
border-radius: 999px;
background: rgba(255, 255, 255, 0.06);
padding: 0.5rem 0.8rem;
color: var(--text-color-secondary);
font-size: 0.72rem;
}
@media (max-width: 767px) {
.nodedc-home-gantt-card {
min-height: auto;
@ -1875,7 +1909,6 @@
}
.nodedc-home-gantt-row {
grid-template-columns: minmax(7rem, 8.25rem) minmax(0, 1fr);
gap: 0.75rem;
min-height: 3.9rem;
}