ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: календарная привязка Ганта на главной
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.
|
||||
*/
|
||||
|
||||
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} / реальные даты задач` : "Выберите проект для календарного окна"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue