ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: календарная привязка Ганта на главной
This commit is contained in:
parent
e5036fc95b
commit
a8b6f9f9ce
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -4,17 +4,18 @@
|
||||||
* See the LICENSE file for details.
|
* See the LICENSE file for details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useMemo, useState } from "react";
|
|
||||||
import { CalendarDays, Filter, SlidersHorizontal } from "lucide-react";
|
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import { useTranslation } from "@plane/i18n";
|
import { useTranslation } from "@plane/i18n";
|
||||||
import type { TIssue, TProjectAnalyticsCount } from "@plane/types";
|
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 { IssueService } from "@/services/issue";
|
||||||
import { getCompletionRate, type THomeProjectData } from "./home.utils";
|
import type { THomeProjectData } from "./home.utils";
|
||||||
|
|
||||||
const issueService = new IssueService();
|
const issueService = new IssueService();
|
||||||
const GANTT_PREVIEW_LIMIT = 6;
|
const GANTT_PREVIEW_LIMIT = 30;
|
||||||
const GANTT_PREVIEW_CURSOR = `${GANTT_PREVIEW_LIMIT}:0:0`;
|
const GANTT_PREVIEW_CURSOR = `${GANTT_PREVIEW_LIMIT}:0:0`;
|
||||||
|
|
||||||
type HomeGanttPreviewProps = {
|
type HomeGanttPreviewProps = {
|
||||||
|
|
@ -23,85 +24,31 @@ type HomeGanttPreviewProps = {
|
||||||
workspaceSlug: string;
|
workspaceSlug: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type TGanttPreviewItem = {
|
const getIssueResults = (response: unknown): TIssue[] => {
|
||||||
id: string;
|
if (Array.isArray(response)) return response as TIssue[];
|
||||||
label: string;
|
if (response && typeof response === "object" && "results" in response) {
|
||||||
subtitle: string;
|
const results = (response as { results?: unknown }).results;
|
||||||
start: number;
|
if (Array.isArray(results)) return results as TIssue[];
|
||||||
width: number;
|
}
|
||||||
tone: "accent" | "muted" | "white";
|
|
||||||
|
return [];
|
||||||
};
|
};
|
||||||
|
|
||||||
const GANTT_RANGES = ["Live", "1D", "1W", "1M"] as const;
|
const buildPreviewItems = (issues: TIssue[], project: THomeProjectData | undefined): TGanttTimelinePreviewItem[] =>
|
||||||
type TGanttRange = (typeof GANTT_RANGES)[number];
|
issues.map((issue, index) => ({
|
||||||
|
|
||||||
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,
|
id: issue.id,
|
||||||
label: issue.name,
|
identifier: project
|
||||||
subtitle: `${project.identifier}-${issue.sequence_id ?? index + 1}`,
|
? `${project.identifier}-${issue.sequence_id ?? index + 1}`
|
||||||
start,
|
: `#${issue.sequence_id ?? index + 1}`,
|
||||||
width,
|
name: issue.name,
|
||||||
tone: index % 3 === 0 ? "accent" : index % 3 === 1 ? "white" : "muted",
|
sort_order: issue.sort_order,
|
||||||
};
|
start_date: issue.start_date,
|
||||||
});
|
target_date: issue.target_date,
|
||||||
|
}));
|
||||||
|
|
||||||
export function HomeGanttPreview(props: HomeGanttPreviewProps) {
|
export function HomeGanttPreview(props: HomeGanttPreviewProps) {
|
||||||
const { analytics, project, workspaceSlug } = props;
|
const { project, workspaceSlug } = props;
|
||||||
const { currentLocale } = useTranslation();
|
const { currentLocale } = useTranslation();
|
||||||
const [activeRange, setActiveRange] = useState<TGanttRange>("Live");
|
|
||||||
const [isCompactMode, setIsCompactMode] = useState(false);
|
|
||||||
const [isFilterActive, setIsFilterActive] = useState(false);
|
|
||||||
|
|
||||||
const { data: issueResponse, isLoading } = useSWR(
|
const { data: issueResponse, isLoading } = useSWR(
|
||||||
project ? `HOME_GANTT_PREVIEW_${workspaceSlug}_${project.id}` : null,
|
project ? `HOME_GANTT_PREVIEW_${workspaceSlug}_${project.id}` : null,
|
||||||
|
|
@ -120,139 +67,17 @@ export function HomeGanttPreview(props: HomeGanttPreviewProps) {
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const timelineLabels = useMemo(() => {
|
const issues = getIssueResults(issueResponse);
|
||||||
const locale = currentLocale || "ru-RU";
|
const previewItems = buildPreviewItems(issues, project);
|
||||||
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`;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="nodedc-home-gantt-card">
|
<GanttTimelinePreview
|
||||||
<div className="nodedc-home-gantt-toolbar">
|
emptyMessage="На главной показываются только задачи выбранного проекта, у которых заполнены start date или target date."
|
||||||
<div className="flex min-w-0 items-center gap-3">
|
isLoading={isLoading}
|
||||||
<div className="grid size-10 shrink-0 place-items-center rounded-full bg-black text-[rgb(var(--nodedc-card-active-rgb))]">
|
items={previewItems}
|
||||||
<CalendarDays className="size-4" />
|
locale={currentLocale}
|
||||||
</div>
|
title="Календарное окно Ганта"
|
||||||
<div className="min-w-0">
|
subtitle={project ? `${project.name} / реальные даты задач` : "Выберите проект для календарного окна"}
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1782,11 +1782,12 @@
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 1rem 1rem 1rem 11.5rem;
|
inset: 1rem 1rem 1rem 11.5rem;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
display: grid;
|
|
||||||
gap: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.nodedc-home-gantt-grid-column {
|
.nodedc-home-gantt-grid-column {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
min-height: 22rem;
|
min-height: 22rem;
|
||||||
border-left: 1px solid rgba(255, 255, 255, 0.04);
|
border-left: 1px solid rgba(255, 255, 255, 0.04);
|
||||||
padding-left: 0.75rem;
|
padding-left: 0.75rem;
|
||||||
|
|
@ -1797,9 +1798,17 @@
|
||||||
text-transform: uppercase;
|
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 {
|
.nodedc-home-gantt-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(7.5rem, 10rem) minmax(0, 1fr);
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
min-height: 3.45rem;
|
min-height: 3.45rem;
|
||||||
|
|
@ -1846,6 +1855,31 @@
|
||||||
background: rgba(255, 255, 255, 0.18);
|
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) {
|
@media (max-width: 767px) {
|
||||||
.nodedc-home-gantt-card {
|
.nodedc-home-gantt-card {
|
||||||
min-height: auto;
|
min-height: auto;
|
||||||
|
|
@ -1875,7 +1909,6 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.nodedc-home-gantt-row {
|
.nodedc-home-gantt-row {
|
||||||
grid-template-columns: minmax(7rem, 8.25rem) minmax(0, 1fr);
|
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
min-height: 3.9rem;
|
min-height: 3.9rem;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue