UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: уплотнение workspace home и интерактивный Гант

This commit is contained in:
DCCONSTRUCTIONS
2026-04-24 00:21:19 +03:00
parent 4c436a949e
commit e5036fc95b
6 changed files with 1328 additions and 437 deletions
@@ -7,36 +7,29 @@
import { useEffect, useState } from "react";
import { observer } from "mobx-react";
import { useParams, usePathname } from "next/navigation";
import { useTheme } from "next-themes";
import useSWR from "swr";
// plane imports
import { useTranslation } from "@plane/i18n";
import type { IUser, THomeWidgetKeys, THomeWidgetProps, TProjectAnalyticsCount } from "@plane/types";
import { cn } from "@plane/utils";
// assets
import darkWidgetsAsset from "@/app/assets/empty-state/dashboard/widgets-dark.webp?url";
import lightWidgetsAsset from "@/app/assets/empty-state/dashboard/widgets-light.webp?url";
// components
import { SimpleEmptyState } from "@/components/empty-state/simple-empty-state-root";
// hooks
import { useHome } from "@/hooks/store/use-home";
import { useProject } from "@/hooks/store/use-project";
import { useWorkspace } from "@/hooks/store/use-workspace";
// plane web components
import { HomePageHeader } from "@/plane-web/components/home/header";
import { ProjectService } from "@/services/project";
import { WorkspaceService } from "@/services/workspace.service";
// local imports
import { HomeCardShell } from "./home-card-shell";
import { HomeGanttPreview } from "./home-gantt-preview";
import { HomeRecentIssueDecks } from "./home-recent-issue-decks";
import { HomeProjectInsights } from "./home-project-insights";
import { HomeActivityTrendCard, HomeOperationsOverview } from "./home-project-insights";
import { HomeProjectStack } from "./home-project-stack";
import { aggregateProjectAnalytics, type THomeProjectData } from "./home.utils";
import { StickiesWidget } from "../stickies/widget";
import { HomeLoader, NoProjectsEmptyState, RecentActivityWidget } from "./widgets";
import { DashboardQuickLinks } from "./widgets/links";
import { ManageWidgetsModal } from "./widgets/manage";
import { UserGreetingsView } from "./user-greetings";
const projectService = new ProjectService();
const workspaceService = new WorkspaceService();
@@ -86,19 +79,14 @@ export const DashboardWidgets = observer(function DashboardWidgets(props: Dashbo
const workspaceSlugValue = Array.isArray(workspaceSlug) ? workspaceSlug[0] : workspaceSlug?.toString();
// navigation
const pathname = usePathname();
// theme hook
const { resolvedTheme } = useTheme();
// store hooks
const { toggleWidgetSettings, widgetsMap, showWidgetSettings, loading } = useHome();
const { loader, joinedProjectIds, getPartialProjectById, fetchProjectAnalyticsCount, getProjectAnalyticsCountById } =
useProject();
const { currentWorkspace } = useWorkspace();
// plane hooks
const { t, currentLocale } = useTranslation();
const { currentLocale } = useTranslation();
// states
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(null);
// derived values
const noWidgetsResolvedPath = resolvedTheme === "light" ? lightWidgetsAsset : darkWidgetsAsset;
// derived values
const isWikiApp = workspaceSlugValue ? pathname.includes(`/${workspaceSlugValue}/pages`) : false;
@@ -174,19 +162,18 @@ export const DashboardWidgets = observer(function DashboardWidgets(props: Dashbo
const isRecentsEnabled = !!widgetsMap.recents?.is_enabled;
const isQuickLinksEnabled = !!widgetsMap.quick_links?.is_enabled;
const isStickiesEnabled = !!widgetsMap.my_stickies?.is_enabled;
const hasDashboardContent = isRecentsEnabled || isQuickLinksEnabled || isStickiesEnabled;
const hasSecondaryWidgets = isQuickLinksEnabled || isStickiesEnabled;
if (!workspaceSlugValue) return null;
if (loading || loader !== "loaded") return <HomeLoader />;
const recentsCard = isRecentsEnabled ? (
<HomeCardShell className="overflow-hidden" contentClassName="p-5">
<RecentActivityWidget
workspaceSlug={workspaceSlugValue}
recents={workspaceRecents}
projectId={selectedProjectId}
/>
</HomeCardShell>
const recentActivityCard = isRecentsEnabled ? (
<RecentActivityWidget
recents={workspaceRecents}
projectId={selectedProject?.id ?? null}
showFilterSelect={false}
workspaceSlug={workspaceSlugValue}
/>
) : null;
const sideWidgetCards = [
@@ -204,78 +191,69 @@ export const DashboardWidgets = observer(function DashboardWidgets(props: Dashbo
return (
<div className="relative flex h-full w-full flex-col gap-6">
<HomePageHeader />
<HomePageHeader
currentUser={currentUser}
selectedProject={selectedProject}
selectedProjectAnalytics={selectedProjectAnalytics}
recents={workspaceRecents}
/>
<ManageWidgetsModal
workspaceSlug={workspaceSlugValue}
isModalOpen={showWidgetSettings}
handleOnClose={() => toggleWidgetSettings(false)}
/>
<div className="grid gap-5 xl:grid-cols-[340px_minmax(0,1fr)]">
<div className="grid gap-5 xl:grid-cols-[minmax(320px,360px)_minmax(0,1fr)] xl:items-stretch">
<div className="min-w-0">
<HomeProjectStack
className="h-full"
projects={homeProjects}
analyticsMap={analyticsMap}
recents={workspaceRecents}
selectedProjectId={selectedProjectId}
onSelectProject={setSelectedProjectId}
workspaceSlug={workspaceSlugValue}
/>
</div>
<div className="min-w-0 space-y-5">
{currentUser && (
<UserGreetingsView
user={currentUser}
workspaceName={currentWorkspace?.name}
selectedProject={selectedProject}
selectedProjectAnalytics={selectedProjectAnalytics}
/>
)}
<HomeRecentIssueDecks project={selectedProject} workspaceSlug={workspaceSlugValue} />
<HomeProjectInsights
<HomeGanttPreview
project={selectedProject}
analytics={selectedProjectAnalytics}
workspaceSlug={workspaceSlugValue}
/>
<HomeOperationsOverview
project={selectedProject}
analytics={selectedProjectAnalytics}
analyticsCollection={analyticsCollection}
recents={workspaceRecents}
locale={currentLocale || "ru-RU"}
recentActivitySlot={recentActivityCard}
locale={currentLocale}
/>
{!isWikiApp && <NoProjectsEmptyState />}
{hasDashboardContent ? (
<>
{recentsCard && sideWidgetCards.length > 0 ? (
<div className="grid gap-5 xl:grid-cols-[minmax(0,1.08fr)_minmax(320px,0.92fr)]">
{recentsCard}
<div className="grid min-w-0 gap-5">{sideWidgetCards}</div>
</div>
) : recentsCard ? (
recentsCard
) : (
<div
className={cn("grid gap-5", {
"md:grid-cols-2": sideWidgetCards.length > 1,
})}
>
{sideWidgetCards}
</div>
)}
</>
) : (
<HomeCardShell className="overflow-hidden" contentClassName="p-8">
<div className="grid min-h-[260px] place-items-center">
<SimpleEmptyState
title={t("home.empty.widgets.title")}
description={t("home.empty.widgets.description")}
assetPath={noWidgetsResolvedPath}
/>
</div>
</HomeCardShell>
)}
</div>
</div>
<HomeActivityTrendCard
project={selectedProject}
analytics={selectedProjectAnalytics}
analyticsCollection={analyticsCollection}
recents={workspaceRecents}
locale={currentLocale}
/>
<HomeRecentIssueDecks project={selectedProject} workspaceSlug={workspaceSlugValue} />
<div className="space-y-5">
{!isWikiApp && <NoProjectsEmptyState />}
{hasSecondaryWidgets && (
<div
className={cn("grid gap-5", {
"md:grid-cols-2": sideWidgetCards.length > 1,
})}
>
{sideWidgetCards}
</div>
)}
</div>
</div>
);
});
@@ -0,0 +1,258 @@
/**
* 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 useSWR from "swr";
import { useTranslation } from "@plane/i18n";
import type { TIssue, TProjectAnalyticsCount } from "@plane/types";
import { cn } from "@plane/utils";
import { IssueService } from "@/services/issue";
import { getCompletionRate, type THomeProjectData } from "./home.utils";
const issueService = new IssueService();
const GANTT_PREVIEW_LIMIT = 6;
const GANTT_PREVIEW_CURSOR = `${GANTT_PREVIEW_LIMIT}:0:0`;
type HomeGanttPreviewProps = {
analytics?: TProjectAnalyticsCount;
project?: THomeProjectData;
workspaceSlug: string;
};
type TGanttPreviewItem = {
id: string;
label: string;
subtitle: string;
start: number;
width: number;
tone: "accent" | "muted" | "white";
};
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",
};
});
export function HomeGanttPreview(props: HomeGanttPreviewProps) {
const { analytics, 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,
project
? () =>
issueService.getIssues(workspaceSlug, project.id, {
order_by: "target_date",
per_page: GANTT_PREVIEW_LIMIT.toString(),
cursor: GANTT_PREVIEW_CURSOR,
})
: null,
{
revalidateIfStale: false,
revalidateOnFocus: false,
revalidateOnReconnect: false,
}
);
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`;
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>
);
}
@@ -4,11 +4,9 @@
* See the LICENSE file for details.
*/
import { useId, useMemo } from "react";
import { type ReactNode, useId, useMemo } from "react";
import { Activity, CheckCircle2, Layers3, UsersRound } from "lucide-react";
import type { TActivityEntityData, TProjectAnalyticsCount } from "@plane/types";
import { cn } from "@plane/utils";
import { HomeCardShell } from "./home-card-shell";
import {
aggregateProjectAnalytics,
getActivityProjectId,
@@ -22,6 +20,7 @@ type HomeProjectInsightsProps = {
analyticsCollection?: TProjectAnalyticsCount[];
recents?: TActivityEntityData[];
locale: string;
recentActivitySlot?: ReactNode;
};
type TActivityPoint = {
@@ -33,10 +32,10 @@ type TActivityPoint = {
const formatCompactNumber = (value: number) => new Intl.NumberFormat("ru-RU", { notation: "compact" }).format(value);
const buildChartPaths = (data: TActivityPoint[]) => {
const width = 420;
const height = 180;
const paddingX = 10;
const paddingY = 18;
const width = 720;
const height = 220;
const paddingX = 24;
const paddingY = 26;
const maxValue = Math.max(...data.map((item) => item.value), 1);
const stepX = data.length > 1 ? (width - paddingX * 2) / (data.length - 1) : 0;
@@ -46,17 +45,28 @@ const buildChartPaths = (data: TActivityPoint[]) => {
return { x, y };
});
const linePath = points
.map((point, index) => `${index === 0 ? "M" : "L"} ${point.x.toFixed(2)} ${point.y.toFixed(2)}`)
.join(" ");
const linePath = points.reduce((path, point, index) => {
if (index === 0) return `M ${point.x.toFixed(2)} ${point.y.toFixed(2)}`;
const previousPoint = points[index - 1];
const controlDistance = (point.x - previousPoint.x) * 0.44;
return `${path} C ${(previousPoint.x + controlDistance).toFixed(2)} ${previousPoint.y.toFixed(2)}, ${(
point.x - controlDistance
).toFixed(2)} ${point.y.toFixed(2)}, ${point.x.toFixed(2)} ${point.y.toFixed(2)}`;
}, "");
const areaPath = `${linePath} L ${(points[points.length - 1]?.x ?? paddingX).toFixed(2)} ${(
height - paddingY
).toFixed(2)} L ${(points[0]?.x ?? paddingX).toFixed(2)} ${(height - paddingY).toFixed(2)} Z`;
const pointPercents = points.map((point) => ({
left: (point.x / width) * 100,
top: (point.y / height) * 100,
}));
return { width, height, paddingY, points, areaPath, linePath, maxValue };
return { width, height, paddingY, points, pointPercents, areaPath, linePath, maxValue };
};
export function HomeProjectInsights(props: HomeProjectInsightsProps) {
const useHomeProjectInsightData = (props: HomeProjectInsightsProps) => {
const { project, analytics, analyticsCollection, recents, locale } = props;
const chartId = useId();
@@ -155,61 +165,58 @@ export function HomeProjectInsights(props: HomeProjectInsightsProps) {
},
];
return (
<HomeCardShell
eyebrow={project ? "Фокус проекта" : "Workspace overview"}
title={project ? project.name : "Координационный обзор workspace"}
description={
project
? `${project.identifier} ${project.description ? `${project.description}` : "• домашняя сводка проекта в одном экране"}`
: "Агрегированный обзор по текущим проектам, recent activity и операционной нагрузке."
}
tone="default"
>
<div className="grid gap-5 xl:grid-cols-[minmax(0,1.2fr)_minmax(260px,0.8fr)]">
<div className="space-y-5">
<div className="grid gap-3 md:grid-cols-3">
{metricCards.map((metric) => (
<div
key={metric.label}
className={cn("nodedc-home-metric-card", { "nodedc-home-metric-card-accent": metric.accent })}
>
<div className="flex items-center justify-between gap-3">
<div className="text-12 font-medium text-secondary">{metric.label}</div>
<div className="text-[rgb(var(--nodedc-accent-rgb))]">{metric.icon}</div>
</div>
<div className="mt-4 text-28 font-semibold text-primary">{metric.value}</div>
<div className="mt-1 text-12 text-secondary">{metric.caption}</div>
</div>
))}
</div>
return {
activitySeries,
chart,
chartId,
completedIssues,
completionRate,
metricCards,
openIssues,
progressRows,
project,
recentTouchpoints,
totalIssues,
};
};
<div className="nodedc-home-chart-panel">
<div className="mb-4 flex items-center justify-between gap-3">
export function HomeActivityTrendCard(props: HomeProjectInsightsProps) {
const { activitySeries, chart, chartId, project, recentTouchpoints } = useHomeProjectInsightData(props);
return (
<section className="nodedc-home-focus-card nodedc-home-activity-card">
<div className="relative z-[1] p-5 xl:p-6">
<div className="space-y-5">
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<div className="text-14 font-semibold text-primary">Темп активности</div>
<div className="text-12 text-secondary">Последние 7 дней переходов и взаимодействий внутри сводки.</div>
</div>
<div className="nodedc-home-soft-badge rounded-full px-3 py-1.5 text-12 text-secondary">
{recentTouchpoints} событий
<div className="text-[11px] font-semibold tracking-[0.22em] text-placeholder uppercase">
{project?.identifier ?? "Workspace"}
</div>
<div className="mt-2 flex items-center gap-2">
<Activity className="size-4 text-[rgb(var(--nodedc-accent-rgb))]" />
<div className="text-18 font-semibold text-primary">Темп активности</div>
</div>
<div className="mt-1 text-12 text-secondary">Последние 7 дней переходов и взаимодействий.</div>
</div>
<div className="nodedc-home-focus-chip">{recentTouchpoints} событий</div>
</div>
<div className="nodedc-home-subpanel relative overflow-hidden p-4">
<div className="absolute inset-x-6 top-4 bottom-4 grid grid-cols-4 gap-4 opacity-25">
{["col-1", "col-2", "col-3", "col-4"].map((key) => (
<div key={key} className="border-r border-dashed border-white/6 last:border-r-0" />
<div className="nodedc-home-chart-panel relative overflow-hidden">
<div className="absolute inset-x-0 top-4 bottom-16 grid grid-cols-6 gap-4 opacity-30">
{["col-1", "col-2", "col-3", "col-4", "col-5", "col-6"].map((key) => (
<div key={key} className="border-r border-dashed border-white/[0.06] last:border-r-0" />
))}
</div>
<svg
viewBox={`0 0 ${chart.width} ${chart.height}`}
className="relative z-[1] h-[180px] w-full"
className="relative z-[1] h-[220px] w-full"
preserveAspectRatio="none"
>
<defs>
<linearGradient id={`${chartId}-fill`} x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stopColor="rgba(var(--nodedc-accent-rgb),0.64)" />
<stop offset="0%" stopColor="rgba(var(--nodedc-accent-rgb),0.28)" />
<stop offset="100%" stopColor="rgba(var(--nodedc-accent-rgb),0.02)" />
</linearGradient>
</defs>
@@ -222,7 +229,7 @@ export function HomeProjectInsights(props: HomeProjectInsightsProps) {
x2={x}
y1={12}
y2={chart.height - chart.paddingY}
stroke="rgba(255,255,255,0.05)"
stroke="rgba(255,255,255,0.04)"
/>
);
})}
@@ -235,7 +242,7 @@ export function HomeProjectInsights(props: HomeProjectInsightsProps) {
x2={chart.width - 10}
y1={y}
y2={y}
stroke="rgba(255,255,255,0.05)"
stroke="rgba(255,255,255,0.06)"
strokeDasharray="4 6"
/>
);
@@ -245,30 +252,34 @@ export function HomeProjectInsights(props: HomeProjectInsightsProps) {
d={chart.linePath}
fill="none"
stroke="rgb(var(--nodedc-accent-rgb))"
strokeWidth="4"
strokeWidth="5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<div className="pointer-events-none absolute inset-x-4 top-4 z-[2] h-[220px]">
{activitySeries.map((activityPoint, index) => {
const point = chart.points[index];
const point = chart.pointPercents[index];
if (!point) return null;
return (
<circle
<span
key={activityPoint.key}
cx={point.x}
cy={point.y}
r="5"
fill="rgb(var(--nodedc-accent-rgb))"
stroke="rgba(9,9,12,0.8)"
strokeWidth="3"
className="absolute size-2.5 rounded-full bg-[rgb(var(--nodedc-card-active-rgb))] shadow-[0_0_0_3px_rgba(8,8,10,0.82)]"
style={{
left: `${point.left}%`,
top: `${point.top}%`,
transform: "translate(-50%, -50%)",
}}
/>
);
})}
</svg>
</div>
<div className="relative z-[1] mt-4 grid grid-cols-7 gap-2">
<div className="mt-4 grid grid-cols-7 gap-2">
{activitySeries.map((point) => (
<div key={point.key} className="nodedc-home-soft-badge rounded-2xl px-2 py-2 text-center">
<div key={point.key} className="text-center">
<div className="text-[11px] tracking-[0.14em] text-placeholder uppercase">{point.label}</div>
<div className="mt-1 text-13 font-semibold text-primary">{point.value}</div>
</div>
@@ -277,93 +288,146 @@ export function HomeProjectInsights(props: HomeProjectInsightsProps) {
</div>
</div>
</div>
</div>
</section>
);
}
export function HomeOperationsOverview(props: HomeProjectInsightsProps) {
const { recentActivitySlot } = props;
const {
completedIssues,
completionRate,
metricCards,
openIssues,
progressRows,
project,
recentTouchpoints,
totalIssues,
} = useHomeProjectInsightData(props);
return (
<section className="grid gap-4 xl:grid-cols-[minmax(0,0.95fr)_minmax(0,0.95fr)_minmax(300px,1.1fr)]">
<div className="nodedc-home-subpanel space-y-4 p-5">
<div className="flex items-center gap-3">
<div className="grid size-11 place-items-center rounded-2xl bg-[rgba(var(--nodedc-accent-rgb),0.14)] text-[rgb(var(--nodedc-accent-rgb))]">
<UsersRound className="size-5" />
</div>
<div>
<div className="text-15 font-semibold text-primary">Операционный срез</div>
<div className="text-12 text-secondary">Команда, циклы и модули относительно текущего workspace.</div>
</div>
</div>
<div className="space-y-4">
<div className="nodedc-home-chart-panel p-5">
<div className="flex items-center gap-3">
<div className="grid size-11 place-items-center rounded-2xl bg-[rgba(0,0,0,0.28)] text-[rgb(var(--nodedc-accent-rgb))]">
<UsersRound className="size-5" />
</div>
<div>
<div className="text-13 font-semibold text-primary">Операционный срез</div>
<div className="text-12 text-secondary">
Нагрузка команды, циклов и модулей относительно остального workspace.
{progressRows.map((row) => {
const percent = row.max > 0 ? Math.max((row.value / row.max) * 100, row.value > 0 ? 10 : 0) : 0;
return (
<div key={row.label} className="space-y-2">
<div className="flex items-center justify-between gap-3 text-12">
<span className="text-secondary">{row.label}</span>
<span className="font-semibold text-primary">{row.value}</span>
</div>
<div className="nodedc-home-focus-track">
<div className="nodedc-home-focus-fill" style={{ width: `${Math.min(percent, 100)}%` }} />
</div>
</div>
);
})}
</div>
</div>
<div className="nodedc-home-subpanel space-y-4 p-5">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-15 font-semibold text-primary">Ритм исполнения</div>
<div className="text-12 text-secondary">Закрытый объём и открытый остаток по фокусу.</div>
</div>
<div className="grid size-11 place-items-center rounded-full bg-black text-[rgb(var(--nodedc-card-active-rgb))]">
<CheckCircle2 className="size-5" />
</div>
</div>
<div className="grid grid-cols-3 gap-2">
{metricCards.map((metric) => (
<div key={metric.label} className="rounded-[1.15rem] bg-black/[0.12] p-3">
<div className="text-11 leading-4 text-secondary">{metric.label}</div>
<div className="mt-2 text-18 leading-none font-semibold text-primary">{metric.value}</div>
</div>
))}
</div>
<div className="mt-5 space-y-4">
{progressRows.map((row) => {
const percent = row.max > 0 ? Math.max((row.value / row.max) * 100, row.value > 0 ? 10 : 0) : 0;
return (
<div key={row.label} className="space-y-2">
<div className="flex items-center justify-between gap-3 text-12">
<span className="text-secondary">{row.label}</span>
<span className="font-semibold text-primary">{row.value}</span>
</div>
<div className="nodedc-home-progress-track">
<div className="nodedc-home-progress-fill" style={{ width: `${Math.min(percent, 100)}%` }} />
</div>
</div>
);
})}
<div className="space-y-4">
<div className="space-y-2">
<div className="flex items-center justify-between gap-3 text-12">
<span className="text-secondary">Закрытые задачи</span>
<span className="font-semibold text-primary">{completedIssues}</span>
</div>
<div className="nodedc-home-focus-track">
<div
className="nodedc-home-focus-fill"
style={{ width: `${totalIssues > 0 ? (completedIssues / totalIssues) * 100 : 0}%` }}
/>
</div>
</div>
<div className="nodedc-home-chart-panel p-5">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-13 font-semibold text-primary">Ритм исполнения</div>
<div className="text-12 text-secondary">Сколько уже закрыто и какой объём ещё держим открытым.</div>
</div>
<div className="nodedc-home-soft-badge rounded-full px-3 py-1.5 text-12 text-secondary">
{completionRate}%
</div>
<div className="space-y-2">
<div className="flex items-center justify-between gap-3 text-12">
<span className="text-secondary">Открытый остаток</span>
<span className="font-semibold text-primary">{openIssues}</span>
</div>
<div className="mt-5 space-y-4">
<div className="nodedc-home-subpanel p-4">
<div className="flex items-center justify-between gap-3 text-12">
<span className="text-secondary">Закрытые задачи</span>
<span className="font-semibold text-primary">{completedIssues}</span>
</div>
<div className="nodedc-home-progress-track mt-3">
<div
className="nodedc-home-progress-fill"
style={{ width: `${totalIssues > 0 ? (completedIssues / totalIssues) * 100 : 0}%` }}
/>
</div>
</div>
<div className="nodedc-home-subpanel p-4">
<div className="flex items-center justify-between gap-3 text-12">
<span className="text-secondary">Открытый остаток</span>
<span className="font-semibold text-primary">{openIssues}</span>
</div>
<div className="nodedc-home-progress-track mt-3">
<div
className="rounded-full bg-white/16"
style={{
width: `${totalIssues > 0 ? (openIssues / totalIssues) * 100 : 0}%`,
height: "100%",
}}
/>
</div>
</div>
<div className="nodedc-home-subpanel p-4 text-12 text-secondary">
<span className="font-semibold text-primary">{project ? project.identifier : "Workspace"}</span>
<span> держит </span>
<span className="font-semibold text-primary">{totalIssues}</span>
<span> задач в общей матрице и </span>
<span className="font-semibold text-primary">{recentTouchpoints}</span>
<span> недавних касаний за неделю.</span>
</div>
<div className="nodedc-home-focus-track">
<div
className="nodedc-home-focus-fill opacity-65"
style={{
width: `${totalIssues > 0 ? (openIssues / totalIssues) * 100 : 0}%`,
height: "100%",
}}
/>
</div>
</div>
</div>
<div className="text-12 leading-5 text-secondary">
<span className="font-semibold text-primary">{project ? project.identifier : "Workspace"}</span>
<span> держит </span>
<span className="font-semibold text-primary">{totalIssues}</span>
<span> задач и </span>
<span className="font-semibold text-primary">{recentTouchpoints}</span>
<span> недавних касаний.</span>
</div>
</div>
</HomeCardShell>
<div className="nodedc-home-subpanel p-5">
{recentActivitySlot ? (
<div className="h-full min-h-[22rem]">{recentActivitySlot}</div>
) : (
<div className="flex h-full min-h-[22rem] flex-col justify-between">
<div>
<div className="flex items-center gap-3">
<div className="grid size-11 place-items-center rounded-full bg-black text-[rgb(var(--nodedc-card-active-rgb))]">
<Layers3 className="size-5" />
</div>
<div>
<div className="text-15 font-semibold text-primary">Недавние</div>
<div className="text-12 text-secondary">Виджет recent activity отключен в настройках.</div>
</div>
</div>
</div>
<div className="nodedc-home-focus-chip w-fit">{completionRate}% готовность</div>
</div>
)}
</div>
</section>
);
}
export function HomeProjectInsights(props: HomeProjectInsightsProps) {
return (
<div className="grid gap-5">
<HomeActivityTrendCard {...props} />
<HomeOperationsOverview {...props} />
</div>
);
}
@@ -4,8 +4,7 @@
* See the LICENSE file for details.
*/
import Link from "next/link";
import { ArrowUpRight, FolderOpenDot, Layers3, UsersRound } from "lucide-react";
import { FolderOpenDot, Layers3, Search, UsersRound } from "lucide-react";
import type { TActivityEntityData, TProjectAnalyticsCount } from "@plane/types";
import { Logo } from "@plane/propel/emoji-icon-picker";
import { cn } from "@plane/utils";
@@ -14,20 +13,29 @@ import { HomeCardShell } from "./home-card-shell";
import { getActivityProjectId, getCompletionRate, type THomeProjectData } from "./home.utils";
type HomeProjectStackProps = {
className?: string;
projects: THomeProjectData[];
analyticsMap: Record<string, TProjectAnalyticsCount | undefined>;
orientation?: "horizontal" | "vertical";
recents?: TActivityEntityData[];
selectedProjectId: string | null;
onSelectProject: (projectId: string) => void;
workspaceSlug: string;
};
const STACK_VISIBLE_LIMIT = 4;
const ACTIVE_CARD_HEIGHT = 228;
const STACK_OFFSET = 76;
const ACTIVE_CARD_HEIGHT = 248;
const STACK_OFFSET = 88;
export function HomeProjectStack(props: HomeProjectStackProps) {
const { projects, analyticsMap, recents, selectedProjectId, onSelectProject, workspaceSlug } = props;
const {
className,
projects,
analyticsMap,
orientation = "vertical",
recents,
selectedProjectId,
onSelectProject,
} = props;
const activeProject = projects.find((project: THomeProjectData) => project.id === selectedProjectId);
const orderedProjects = activeProject
@@ -45,19 +53,14 @@ export function HomeProjectStack(props: HomeProjectStackProps) {
const selectedProject =
orderedProjects.find((project: THomeProjectData) => project.id === selectedProjectId) ?? orderedProjects[0];
const selectedProjectPath = selectedProject ? `/${workspaceSlug}/projects/${selectedProject.id}/issues` : null;
const stackHeight =
visibleProjects.length > 0 ? ACTIVE_CARD_HEIGHT + (visibleProjects.length - 1) * STACK_OFFSET : 228;
const isHorizontal = orientation === "horizontal";
if (projects.length === 0) {
return (
<HomeCardShell
eyebrow="Workspace"
title="Доступные проекты"
description="Когда проекты появятся в workspace, здесь появится интерактивный стек для быстрого переключения домашней сводки."
tone="accent"
>
<div className="rounded-[26px] border border-white/8 bg-black/10 p-5">
<HomeCardShell eyebrow="Projects" title="Доступные проекты" description="Пока список проектов пуст.">
<div className="rounded-[26px] bg-black/10 p-5">
<div className="flex items-center gap-3">
<div className="grid size-12 place-items-center rounded-2xl bg-[rgba(var(--nodedc-accent-rgb),0.18)] text-[rgb(var(--nodedc-accent-rgb))]">
<FolderOpenDot className="size-5" />
@@ -74,160 +77,169 @@ export function HomeProjectStack(props: HomeProjectStackProps) {
);
}
return (
<HomeCardShell
eyebrow="Workspace"
title="Доступные проекты"
description="Нажатие на карточку проекта перестраивает домашнюю сводку, recent activity и аналитический фокус справа."
tone="accent"
action={
selectedProjectPath ? (
<Link href={selectedProjectPath} className="nodedc-toolbar-pill inline-flex items-center gap-2">
<span>Открыть проект</span>
<ArrowUpRight className="size-4" />
</Link>
) : null
}
>
<div className="space-y-4">
<div className="relative" style={{ height: `${stackHeight}px` }}>
{visibleProjects.map((project: THomeProjectData, index: number) => {
const analytics = analyticsMap[project.id];
const completionRate = getCompletionRate(analytics);
const totalIssues = analytics?.total_issues ?? 0;
const completedIssues = analytics?.completed_issues ?? 0;
const activeItems = Math.max(totalIssues - completedIssues, 0);
const activityCount = activityCountByProject[project.id] ?? 0;
const isActive = project.id === selectedProject?.id;
const renderProjectCard = (project: THomeProjectData, index: number, horizontal: boolean) => {
const analytics = analyticsMap[project.id];
const completionRate = getCompletionRate(analytics);
const totalIssues = analytics?.total_issues ?? 0;
const completedIssues = analytics?.completed_issues ?? 0;
const activeItems = Math.max(totalIssues - completedIssues, 0);
const activityCount = activityCountByProject[project.id] ?? 0;
const isActive = project.id === selectedProject?.id;
return (
<button
key={project.id}
type="button"
className={cn("nodedc-home-project-card text-left", {
"nodedc-home-project-card-horizontal shrink-0": horizontal,
"absolute inset-x-0": !horizontal,
"cursor-default": isActive,
})}
data-active={isActive}
onClick={() => onSelectProject(project.id)}
style={
horizontal
? { zIndex: visibleProjects.length - index }
: {
top: `${index * STACK_OFFSET}px`,
zIndex: visibleProjects.length - index,
}
}
>
<CoverImage
src={project.cover_image_url}
alt={project.name}
showDefaultWhenEmpty
className="absolute inset-0 h-full w-full"
/>
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(var(--nodedc-accent-rgb),0.28),transparent_34%),linear-gradient(160deg,rgba(5,5,8,0.08)_0%,rgba(5,5,8,0.42)_34%,rgba(5,5,8,0.84)_100%)]" />
<div className="relative flex h-full flex-col justify-between p-4">
<div className="flex items-start justify-between gap-3">
<div className="inline-flex items-center gap-2 rounded-full bg-black/25 px-2.5 py-1 text-[11px] font-medium text-white/[0.72] backdrop-blur-md">
<Logo logo={project.logo_props} size={14} />
<span>{project.identifier}</span>
</div>
<div
className={cn(
"rounded-full px-2.5 py-1 text-[11px] font-semibold backdrop-blur-md",
isActive
? "bg-[rgba(var(--nodedc-accent-rgb),0.82)] text-[rgb(var(--nodedc-on-card-active-rgb))]"
: "bg-white/[0.12] text-white/[0.72]"
)}
>
{isActive ? "В фокусе" : `${completionRate}%`}
</div>
</div>
<div className="space-y-3">
<div className="space-y-1">
<div className="text-18 font-semibold text-white">{project.name}</div>
{project.description && isActive && (
<p className="line-clamp-2 max-w-[18rem] text-12 leading-5 text-white/[0.72]">{project.description}</p>
)}
</div>
<div className="grid grid-cols-3 gap-2">
<div className="rounded-2xl bg-black/[0.24] px-3 py-2 backdrop-blur-md">
<div className="text-[11px] tracking-[0.18em] text-white/[0.45] uppercase">Открыто</div>
<div className="text-15 mt-1 font-semibold text-white">{activeItems}</div>
</div>
<div className="rounded-2xl bg-black/[0.24] px-3 py-2 backdrop-blur-md">
<div className="text-[11px] tracking-[0.18em] text-white/[0.45] uppercase">Закрыто</div>
<div className="text-15 mt-1 font-semibold text-white">{completedIssues}</div>
</div>
<div className="rounded-2xl bg-black/[0.24] px-3 py-2 backdrop-blur-md">
<div className="text-[11px] tracking-[0.18em] text-white/[0.45] uppercase">Касания</div>
<div className="text-15 mt-1 font-semibold text-white">{activityCount}</div>
</div>
</div>
</div>
</div>
</button>
);
};
return (
<section className={cn("nodedc-home-card nodedc-home-project-panel flex flex-col px-4 py-4", className)}>
<div className="mb-4 flex items-center justify-between gap-3 px-1">
<div>
<div className="text-[11px] font-semibold tracking-[0.22em] text-placeholder uppercase">Quick Project</div>
<div className="mt-1 text-16 font-semibold text-primary">Выбор проекта</div>
</div>
<button type="button" className="nodedc-home-gantt-round-button" aria-label="Поиск проекта">
<Search className="size-4" />
</button>
</div>
{isHorizontal ? (
<div className="nodedc-home-project-deck-scroller">
<div className="nodedc-home-project-deck-row flex items-start px-1 py-2">
{visibleProjects.map((project: THomeProjectData, index: number) => renderProjectCard(project, index, true))}
</div>
</div>
) : (
<div className="relative" style={{ height: `${stackHeight}px` }}>
{visibleProjects.map((project: THomeProjectData, index: number) => renderProjectCard(project, index, false))}
</div>
)}
<div className="mt-4 rounded-[24px] bg-black/10 p-4 xl:mt-auto">
<div className="mb-3 flex items-center justify-between gap-3">
<div>
<div className="text-13 font-semibold text-primary">Быстрый выбор</div>
<div className="text-12 text-secondary">Все проекты пользователя в текущем workspace.</div>
</div>
<div className="inline-flex items-center gap-2 rounded-full bg-white/[0.06] px-3 py-1.5 text-12 text-secondary">
<Layers3 className="size-3.5" />
<span>{projects.length}</span>
</div>
</div>
<div className="flex flex-wrap gap-2">
{orderedProjects.map((project: THomeProjectData) => {
const analytics = analyticsMap[project.id];
return (
<button
key={project.id}
type="button"
className={cn("nodedc-home-project-card absolute inset-x-0 text-left", {
"cursor-default": isActive,
className={cn("nodedc-toolbar-pill inline-flex items-center gap-2", {
"!bg-[rgb(var(--nodedc-card-active-rgb))] !text-[rgb(var(--nodedc-on-card-active-rgb))]":
project.id === selectedProject?.id,
})}
data-active={isActive}
onClick={() => onSelectProject(project.id)}
style={{
top: `${index * STACK_OFFSET}px`,
zIndex: visibleProjects.length - index,
}}
>
<CoverImage
src={project.cover_image_url}
alt={project.name}
showDefaultWhenEmpty
className="absolute inset-0 h-full w-full"
/>
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(var(--nodedc-accent-rgb),0.28),transparent_34%),linear-gradient(160deg,rgba(5,5,8,0.08)_0%,rgba(5,5,8,0.42)_34%,rgba(5,5,8,0.84)_100%)]" />
<div className="relative flex h-full flex-col justify-between p-4">
<div className="flex items-start justify-between gap-3">
<div className="inline-flex items-center gap-2 rounded-full bg-black/25 px-2.5 py-1 text-[11px] font-medium text-white/72 backdrop-blur-md">
<Logo logo={project.logo_props} size={14} />
<span>{project.identifier}</span>
</div>
<div
className={cn(
"rounded-full px-2.5 py-1 text-[11px] font-semibold backdrop-blur-md",
isActive
? "bg-[rgba(var(--nodedc-accent-rgb),0.82)] text-[rgb(var(--nodedc-on-card-active-rgb))]"
: "bg-white/12 text-white/72"
)}
>
{isActive ? "В фокусе" : `${completionRate}%`}
</div>
</div>
<div className="space-y-3">
<div className="space-y-1">
<div className="text-18 font-semibold text-white">{project.name}</div>
{project.description && isActive && (
<p className="line-clamp-2 max-w-[18rem] text-12 leading-5 text-white/72">
{project.description}
</p>
)}
</div>
<div className="grid grid-cols-3 gap-2">
<div className="rounded-2xl bg-black/24 px-3 py-2 backdrop-blur-md">
<div className="text-[11px] tracking-[0.18em] text-white/45 uppercase">Открыто</div>
<div className="text-15 mt-1 font-semibold text-white">{activeItems}</div>
</div>
<div className="rounded-2xl bg-black/24 px-3 py-2 backdrop-blur-md">
<div className="text-[11px] tracking-[0.18em] text-white/45 uppercase">Закрыто</div>
<div className="text-15 mt-1 font-semibold text-white">{completedIssues}</div>
</div>
<div className="rounded-2xl bg-black/24 px-3 py-2 backdrop-blur-md">
<div className="text-[11px] tracking-[0.18em] text-white/45 uppercase">Касания</div>
<div className="text-15 mt-1 font-semibold text-white">{activityCount}</div>
</div>
</div>
</div>
</div>
<Logo logo={project.logo_props} size={14} />
<span>{project.identifier}</span>
<span className="text-[11px] opacity-70">{getCompletionRate(analytics)}%</span>
</button>
);
})}
</div>
<div className="rounded-[24px] border border-white/6 bg-black/10 p-4">
<div className="mb-3 flex items-center justify-between gap-3">
<div>
<div className="text-13 font-semibold text-primary">Быстрый выбор</div>
<div className="text-12 text-secondary">Все проекты пользователя в текущем workspace.</div>
{selectedProject && (
<div className="mt-4 grid grid-cols-2 gap-3 rounded-[22px] bg-white/[0.04] p-3 md:grid-cols-3">
<div className="rounded-2xl bg-black/10 px-3 py-2">
<div className="text-[11px] tracking-[0.18em] text-placeholder uppercase">Фокус</div>
<div className="mt-1 text-13 font-semibold text-primary">{selectedProject.identifier}</div>
</div>
<div className="inline-flex items-center gap-2 rounded-full bg-white/6 px-3 py-1.5 text-12 text-secondary">
<Layers3 className="size-3.5" />
<span>{projects.length}</span>
<div className="rounded-2xl bg-black/10 px-3 py-2">
<div className="flex items-center gap-1 text-[11px] tracking-[0.18em] text-placeholder uppercase">
<UsersRound className="size-3.5" />
<span>Команда</span>
</div>
<div className="mt-1 text-13 font-semibold text-primary">
{analyticsMap[selectedProject.id]?.total_members ?? 0}
</div>
</div>
<div className="rounded-2xl bg-black/10 px-3 py-2">
<div className="text-[11px] tracking-[0.18em] text-placeholder uppercase">Контур</div>
<div className="mt-1 text-13 font-semibold text-primary">
{activityCountByProject[selectedProject.id] ?? 0} касаний
</div>
</div>
</div>
<div className="flex flex-wrap gap-2">
{orderedProjects.map((project: THomeProjectData) => {
const analytics = analyticsMap[project.id];
return (
<button
key={project.id}
type="button"
className={cn("nodedc-toolbar-pill inline-flex items-center gap-2", {
"!bg-[rgb(var(--nodedc-card-active-rgb))] !text-[rgb(var(--nodedc-on-card-active-rgb))]":
project.id === selectedProject?.id,
})}
onClick={() => onSelectProject(project.id)}
>
<Logo logo={project.logo_props} size={14} />
<span>{project.identifier}</span>
<span className="text-[11px] opacity-70">{getCompletionRate(analytics)}%</span>
</button>
);
})}
</div>
{selectedProject && (
<div className="mt-4 grid grid-cols-2 gap-3 rounded-[22px] bg-white/4 p-3 md:grid-cols-3">
<div className="rounded-2xl bg-black/10 px-3 py-2">
<div className="text-[11px] tracking-[0.18em] text-placeholder uppercase">Фокус</div>
<div className="mt-1 text-13 font-semibold text-primary">{selectedProject.identifier}</div>
</div>
<div className="rounded-2xl bg-black/10 px-3 py-2">
<div className="flex items-center gap-1 text-[11px] tracking-[0.18em] text-placeholder uppercase">
<UsersRound className="size-3.5" />
<span>Команда</span>
</div>
<div className="mt-1 text-13 font-semibold text-primary">
{analyticsMap[selectedProject.id]?.total_members ?? 0}
</div>
</div>
<div className="rounded-2xl bg-black/10 px-3 py-2">
<div className="text-[11px] tracking-[0.18em] text-placeholder uppercase">Контур</div>
<div className="mt-1 text-13 font-semibold text-primary">
{activityCountByProject[selectedProject.id] ?? 0} recent
</div>
</div>
</div>
)}
</div>
)}
</div>
</HomeCardShell>
</section>
);
}