UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: фиксация active-состояния внутренней колоды задач на home

This commit is contained in:
DCCONSTRUCTIONS 2026-04-23 13:42:06 +03:00
parent 4d6aba098d
commit 3034ba5089
3 changed files with 559 additions and 0 deletions

View File

@ -28,6 +28,7 @@ import { ProjectService } from "@/services/project";
import { WorkspaceService } from "@/services/workspace.service";
// local imports
import { HomeCardShell } from "./home-card-shell";
import { HomeRecentIssueDecks } from "./home-recent-issue-decks";
import { HomeProjectInsights } from "./home-project-insights";
import { HomeProjectStack } from "./home-project-stack";
import { aggregateProjectAnalytics, type THomeProjectData } from "./home.utils";
@ -239,6 +240,8 @@ export const DashboardWidgets = observer(function DashboardWidgets(props: Dashbo
locale={currentLocale || "ru-RU"}
/>
<HomeRecentIssueDecks project={selectedProject} workspaceSlug={workspaceSlugValue} />
{!isWikiApp && <NoProjectsEmptyState />}
{hasDashboardContent ? (

View File

@ -0,0 +1,454 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import type { ReactNode } from "react";
import { useEffect, useMemo, useState } from "react";
import { CalendarDays, Sparkles } from "lucide-react";
import { observer } from "mobx-react";
import useSWR from "swr";
import { useTranslation } from "@plane/i18n";
import { PriorityIcon, StateGroupIcon } from "@plane/propel/icons";
import type { TExternalContourRequest, TIssue } from "@plane/types";
import { Avatar } from "@plane/ui";
import { cn, getFileURL, renderFormattedDate } from "@plane/utils";
import { ButtonAvatars } from "@/components/dropdowns/member/avatar";
import { useMember } from "@/hooks/store/use-member";
import { useProject } from "@/hooks/store/use-project";
import { useProjectState } from "@/hooks/store/use-project-state";
import {
NodedcWorkItemCard,
getNodedcWorkItemCardAppearance,
} from "@/components/issues/issue-layouts/shared/nodedc-work-item-card";
import { ExternalContourService } from "@/services/external-contours";
import { IssueService } from "@/services/issue";
import { HomeCardShell } from "./home-card-shell";
import type { THomeProjectData } from "./home.utils";
const issueService = new IssueService();
const externalContourService = new ExternalContourService();
const INTERNAL_DECK_LIMIT = 10;
const EXTERNAL_DECK_LIMIT = 10;
const INTERNAL_CURSOR = `${INTERNAL_DECK_LIMIT}:0:0`;
type HomeRecentIssueDecksProps = {
project?: THomeProjectData;
workspaceSlug: string;
};
type DeckSectionProps = {
count: number;
description: string;
emptyDescription: string;
emptyTitle: string;
isLoading: boolean;
items: ReactNode[];
title: string;
};
type InternalIssueCardProps = {
isActive: boolean;
issue: TIssue;
onSelect: () => void;
project: THomeProjectData;
};
type ExternalIssueCardProps = {
isActive: boolean;
onSelect: () => void;
project: THomeProjectData;
request: TExternalContourRequest;
};
const sortByRecentCreatedDate = <
T extends { created_at?: string | null; requested_at?: string | null; updated_at?: string | null },
>(
items: T[]
) => {
// oxlint-disable-next-line unicorn/no-array-sort
return [...items].sort((left: T, right: T) => {
const leftDate = Date.parse(left.requested_at ?? left.created_at ?? left.updated_at ?? "") || 0;
const rightDate = Date.parse(right.requested_at ?? right.created_at ?? right.updated_at ?? "") || 0;
return rightDate - leftDate;
});
};
const DeckSection = (props: DeckSectionProps) => {
const { count, description, emptyDescription, emptyTitle, isLoading, items, title } = props;
return (
<div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<div className="text-14 font-semibold text-primary">{title}</div>
<div className="text-12 text-secondary">{description}</div>
</div>
<div className="nodedc-toolbar-pill inline-flex items-center gap-2">
<Sparkles className="size-3.5" />
<span>{count}</span>
</div>
</div>
<div className="nodedc-home-task-deck-scroller">
<div className="flex min-h-[236px] items-end px-1 py-4">
{isLoading
? Array.from({ length: 4 }, (_, index) => (
<div
key={`skeleton-${title}-${index}`}
className={cn("nodedc-home-task-card nodedc-home-task-card-skeleton animate-pulse", {
"-ml-16": index > 0,
})}
style={{ zIndex: 5 - index }}
/>
))
: items}
</div>
</div>
{!isLoading && items.length === 0 && (
<div className="rounded-[24px] border border-white/6 bg-black/10 px-4 py-5">
<div className="text-14 font-semibold text-primary">{emptyTitle}</div>
<div className="mt-1 text-12 leading-5 text-secondary">{emptyDescription}</div>
</div>
)}
</div>
);
};
const HomeInternalContourDeckCard = observer(function HomeInternalContourDeckCard(props: InternalIssueCardProps) {
const { isActive, issue, onSelect, project } = props;
const { t } = useTranslation();
const { getProjectById } = useProject();
const { getUserDetails } = useMember();
const { getStateById } = useProjectState();
const creatorDetails = useMemo(() => {
if (issue.created_by_detail) return issue.created_by_detail;
if (issue.created_by && getUserDetails(issue.created_by)) return getUserDetails(issue.created_by);
if (issue.created_by_display_name || issue.created_by_avatar_url) {
return {
id: issue.created_by,
display_name: issue.created_by_display_name ?? t("common.none"),
avatar_url: issue.created_by_avatar_url ?? "",
first_name: "",
last_name: "",
is_bot: false,
};
}
return undefined;
}, [
getUserDetails,
issue.created_by,
issue.created_by_avatar_url,
issue.created_by_detail,
issue.created_by_display_name,
t,
]);
const sourceContourName = issue.source_project_name ?? getProjectById(issue.project_id)?.name ?? project.name;
const selectedState = getStateById(issue.state_id);
const { iconBubbleClasses, pillBackgroundClasses } = getNodedcWorkItemCardAppearance(isActive);
const statusIconColor =
selectedState?.color ?? (isActive ? "rgb(var(--nodedc-on-card-active-rgb))" : "var(--text-color-primary)");
const creatorName = creatorDetails?.display_name ?? t("common.none");
const dueDateLabel = issue.target_date ? renderFormattedDate(issue.target_date, "d MMM, yyyy") : t("common.none");
const header = (
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 flex-1 items-center gap-3">
<div className="shrink-0">
<Avatar src={getFileURL(creatorDetails?.avatar_url ?? "")} name={creatorName} size="md" />
</div>
<div className="truncate text-body-sm-medium leading-5">{creatorName}</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<div className={cn("flex h-8 w-8 items-center justify-center rounded-full", iconBubbleClasses)}>
<PriorityIcon priority={issue.priority} className="h-3.5 w-3.5" />
</div>
<div className={cn("flex h-8 w-8 items-center justify-center rounded-full", iconBubbleClasses)}>
<StateGroupIcon
stateGroup={selectedState?.group ?? "backlog"}
color={statusIconColor}
className="h-3.5 w-3.5"
percentage={selectedState?.order}
/>
</div>
</div>
</div>
);
const footer = (
<>
<div className={cn("inline-flex min-h-9 items-center rounded-full pl-1 pr-2", pillBackgroundClasses)}>
{(issue.assignee_ids?.length ?? 0) > 0 ? (
<ButtonAvatars showTooltip={false} userIds={issue.assignee_ids ?? []} size="sm" />
) : (
<span className="px-2 text-[11px] font-medium">{t("external_contours_page.list.unassigned")}</span>
)}
</div>
<div className={cn("inline-flex min-h-9 items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-medium", pillBackgroundClasses)}>
<CalendarDays className="h-3.5 w-3.5" />
<span className="truncate">{dueDateLabel}</span>
</div>
</>
);
return (
<button type="button" className="nodedc-home-task-card" data-active={isActive} onClick={onSelect} title={issue.name}>
<NodedcWorkItemCard
isActive={isActive}
surfaceClassName={cn(
"nodedc-home-task-card-surface px-0",
isActive ? "nodedc-home-task-card-surface-active" : "nodedc-home-task-card-surface-passive"
)}
contentClassName="px-1"
header={header}
subtitle={sourceContourName}
title={issue.name}
footer={footer}
/>
</button>
);
});
const HomeExternalContourDeckCard = observer(function HomeExternalContourDeckCard(props: ExternalIssueCardProps) {
const { isActive, onSelect, project, request } = props;
const { t } = useTranslation();
const { getStateById } = useProjectState();
const { iconBubbleClasses, pillBackgroundClasses } = getNodedcWorkItemCardAppearance(isActive);
const issue = request.issue;
const isOutgoing = request.direction
? request.direction === "outgoing"
: request.source_project_id === project.id;
const requester =
request.requested_by?.display_name ||
request.requested_by_name ||
issue.created_by_detail?.display_name ||
t("external_contours_page.mirror.system_actor");
const requesterAvatar = issue.created_by_detail?.avatar_url || "";
const counterpartContourName = isOutgoing
? request.target_project?.name || request.target_project_name || issue.project_detail?.name || t("common.none")
: request.source_project?.name || request.source_project_name || t("common.none");
const fallbackState = getStateById(issue.state_id);
const selectedState = issue.state_detail ?? fallbackState;
const statusIconColor =
selectedState?.color ?? (isActive ? "rgb(var(--nodedc-on-card-active-rgb))" : "var(--text-color-primary)");
const dueDateLabel = issue.target_date ? renderFormattedDate(issue.target_date, "d MMM, yyyy") : t("common.none");
return (
<button type="button" className="nodedc-home-task-card" data-active={isActive} onClick={onSelect} title={issue.name}>
<div
data-active={isActive}
className={cn(
"nodedc-external-card nodedc-home-task-card-surface relative flex min-h-[220px] w-full flex-col p-4",
isActive ? "nodedc-home-task-card-surface-active" : "nodedc-home-task-card-surface-passive"
)}
>
<div className={cn("relative flex min-h-[220px] flex-col px-1", isActive ? "text-[rgb(var(--nodedc-on-card-active-rgb))]" : "text-white")}>
<div className="space-y-0.5">
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 flex-1 items-center gap-3">
<div className="shrink-0">
<Avatar src={getFileURL(requesterAvatar)} name={requester} size="md" />
</div>
<div className="truncate text-body-sm-medium leading-5">{requester}</div>
</div>
<div className="flex shrink-0 items-center gap-2">
{request.has_unread_updates && (
<span
className={cn("size-2 rounded-full", isActive ? "bg-black/70" : "bg-accent-primary")}
title={t("external_contours_page.list.unread_updates")}
/>
)}
<div className={cn("flex h-8 w-8 items-center justify-center rounded-full", iconBubbleClasses)}>
<PriorityIcon priority={issue.priority} className="h-3.5 w-3.5" />
</div>
<div className={cn("flex h-8 w-8 items-center justify-center rounded-full", iconBubbleClasses)}>
<StateGroupIcon
stateGroup={selectedState?.group ?? "backlog"}
color={statusIconColor}
className="h-3.5 w-3.5"
percentage={fallbackState?.order}
/>
</div>
</div>
</div>
<div
className={cn(
"truncate -mt-0.5 pl-8 text-[11px] font-medium leading-4",
isActive ? "text-[rgb(var(--nodedc-on-card-active-rgb))]/72" : "text-[#B3B3B8]"
)}
>
{counterpartContourName}
</div>
</div>
<div className="flex flex-1 items-center justify-center px-5 py-4 text-center">
<div className="line-clamp-4 max-w-full text-lg font-semibold leading-6">{issue.name}</div>
</div>
<div className="flex items-center justify-between gap-3">
<div className={cn("inline-flex min-h-9 items-center rounded-full pl-1 pr-2", pillBackgroundClasses)}>
{(issue.assignee_ids?.length ?? 0) > 0 ? (
<ButtonAvatars showTooltip={false} userIds={issue.assignee_ids ?? []} size="sm" />
) : (
<span className="px-2 text-[11px] font-medium">{t("external_contours_page.list.unassigned")}</span>
)}
</div>
<div className={cn("inline-flex min-h-9 items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-medium", pillBackgroundClasses)}>
<CalendarDays className="h-3.5 w-3.5" />
<span className="truncate">{dueDateLabel}</span>
</div>
</div>
</div>
</div>
</button>
);
});
export const HomeRecentIssueDecks = observer(function HomeRecentIssueDecks(props: HomeRecentIssueDecksProps) {
const { project, workspaceSlug } = props;
const [selectedInternalIssueId, setSelectedInternalIssueId] = useState<string | null>(null);
const [selectedExternalRequestId, setSelectedExternalRequestId] = useState<string | null>(null);
const { data: internalIssueResponse, isLoading: isInternalIssuesLoading } = useSWR(
project ? `HOME_PROJECT_INTERNAL_ISSUES_${workspaceSlug}_${project.id}` : null,
project
? () =>
issueService.getIssues(workspaceSlug, project.id, {
order_by: "-created_at",
per_page: INTERNAL_DECK_LIMIT.toString(),
cursor: INTERNAL_CURSOR,
})
: null,
{
revalidateIfStale: false,
revalidateOnFocus: false,
revalidateOnReconnect: false,
}
);
const { data: externalRequestsResponse, isLoading: isExternalRequestsLoading } = useSWR(
project ? `HOME_PROJECT_EXTERNAL_CONTOURS_${workspaceSlug}_${project.id}` : null,
project ? () => externalContourService.list(workspaceSlug, project.id) : null,
{
revalidateIfStale: false,
revalidateOnFocus: false,
revalidateOnReconnect: false,
}
);
const internalIssues = useMemo<TIssue[]>(() => {
const results = internalIssueResponse?.results;
return Array.isArray(results) ? results.slice(0, INTERNAL_DECK_LIMIT) : [];
}, [internalIssueResponse]);
const externalRequests = useMemo<TExternalContourRequest[]>(
() => sortByRecentCreatedDate(externalRequestsResponse?.results ?? []).slice(0, EXTERNAL_DECK_LIMIT),
[externalRequestsResponse]
);
useEffect(() => {
if (internalIssues.length === 0) {
if (selectedInternalIssueId !== null) setSelectedInternalIssueId(null);
return;
}
if (!selectedInternalIssueId || !internalIssues.some((issue) => issue.id === selectedInternalIssueId)) {
setSelectedInternalIssueId(internalIssues[0].id);
}
}, [internalIssues, selectedInternalIssueId]);
useEffect(() => {
if (externalRequests.length === 0) {
if (selectedExternalRequestId !== null) setSelectedExternalRequestId(null);
return;
}
if (!selectedExternalRequestId || !externalRequests.some((request) => request.id === selectedExternalRequestId)) {
setSelectedExternalRequestId(externalRequests[0].id);
}
}, [externalRequests, selectedExternalRequestId]);
if (!project) {
return (
<HomeCardShell
eyebrow="Task Decks"
title="Последние задачи по проекту"
description="Выберите проект слева, и здесь появятся колоды последних задач внешнего и внутреннего контуров."
>
<div className="rounded-[24px] border border-white/6 bg-black/10 px-5 py-6 text-13 text-secondary">
Фокус проекта пока не выбран.
</div>
</HomeCardShell>
);
}
const internalIssueCards = internalIssues.map((issue, index) => (
<div
key={issue.id}
className={cn({ "-ml-16": index > 0 })}
style={{ zIndex: issue.id === selectedInternalIssueId ? internalIssues.length + 6 : index + 1 }}
>
<HomeInternalContourDeckCard
issue={issue}
isActive={issue.id === selectedInternalIssueId}
onSelect={() => setSelectedInternalIssueId(issue.id)}
project={project}
/>
</div>
));
const externalIssueCards = externalRequests.map((request, index) => (
<div
key={request.id}
className={cn({ "-ml-16": index > 0 })}
style={{ zIndex: request.id === selectedExternalRequestId ? externalRequests.length + 6 : index + 1 }}
>
<HomeExternalContourDeckCard
isActive={request.id === selectedExternalRequestId}
onSelect={() => setSelectedExternalRequestId(request.id)}
project={project}
request={request}
/>
</div>
));
return (
<HomeCardShell
eyebrow={`${project.identifier} • последние задачи`}
title="Последние задачи проекта"
description="Ниже собраны две колоды для быстрого просмотра новых карточек во внешнем и внутреннем контурах."
contentClassName="space-y-5 p-5"
>
<DeckSection
count={externalRequests.length}
description="Последние запросы и задачи внешнего контура по текущему проекту."
emptyDescription="У проекта пока нет внешних контурных задач. Когда появится первый обмен с контрагентом, он сразу попадет в эту колоду."
emptyTitle="Внешний контур пока пуст"
isLoading={isExternalRequestsLoading}
items={externalIssueCards}
title="Последние задачи внешнего контура"
/>
<DeckSection
count={internalIssues.length}
description="Последние добавленные внутренние задачи выбранного проекта."
emptyDescription="Во внутреннем контуре пока нет задач. Как только в проекте появится новая карточка, она ляжет сюда первой."
emptyTitle="Внутренний контур пока пуст"
isLoading={isInternalIssuesLoading}
items={internalIssueCards}
title="Последние задачи внутреннего контура"
/>
</HomeCardShell>
);
});

View File

@ -1609,6 +1609,108 @@
filter: saturate(1);
}
.nodedc-home-task-deck-scroller {
overflow-x: auto;
overflow-y: visible;
padding-bottom: 0.25rem;
scrollbar-width: none;
}
.nodedc-home-task-deck-scroller::-webkit-scrollbar {
display: none;
}
.nodedc-home-task-card {
width: 18.5rem;
min-width: 18.5rem;
border: 0 !important;
outline: none !important;
background: transparent !important;
padding: 0 !important;
position: relative;
display: block;
cursor: pointer;
text-align: left;
transition:
transform 180ms ease,
filter 180ms ease;
}
.nodedc-home-task-card[data-active="true"] {
transform: translateY(-0.85rem) scale(1.015);
}
.nodedc-home-task-card[data-active="false"] {
filter: saturate(0.88);
transform: scale(0.975);
}
.nodedc-home-task-card[data-active="false"]:hover {
transform: translateY(-0.2rem) scale(0.985);
filter: saturate(1);
}
.nodedc-home-task-card-surface {
overflow: hidden;
isolation: isolate;
border-radius: 2rem !important;
box-shadow:
0 24px 48px rgba(0, 0, 0, 0.24),
inset 0 1px 0 rgba(255, 255, 255, 0.03) !important;
-webkit-backdrop-filter: blur(24px);
backdrop-filter: blur(24px);
transition:
background 180ms ease,
box-shadow 180ms ease,
color 180ms ease;
}
.nodedc-home-task-card[data-active="false"] .nodedc-home-task-card-surface {
-webkit-backdrop-filter: blur(24px);
backdrop-filter: blur(24px);
}
.nodedc-home-task-card-surface-passive {
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.038) 0%, rgba(255, 255, 255, 0.012) 100%),
rgba(7, 7, 9, 0.74) !important;
}
.nodedc-home-task-card-surface-active {
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.05) 0%, rgba(255, 255, 255, 0.018) 100%),
rgba(var(--nodedc-card-active-rgb), 0.96) !important;
color: rgb(var(--nodedc-on-card-active-rgb)) !important;
box-shadow:
0 30px 56px rgba(0, 0, 0, 0.28),
inset 0 0 0 1px rgba(255, 255, 255, 0.18),
inset 0 1px 0 rgba(255, 255, 255, 0.04) !important;
}
.nodedc-home-task-card[data-active="true"] .nodedc-home-task-card-surface {
background: rgb(var(--nodedc-card-active-rgb)) !important;
color: rgb(var(--nodedc-on-card-active-rgb)) !important;
-webkit-backdrop-filter: none !important;
backdrop-filter: none !important;
box-shadow:
0 30px 56px rgba(0, 0, 0, 0.28),
inset 0 0 0 1px rgba(255, 255, 255, 0.18),
inset 0 1px 0 rgba(255, 255, 255, 0.04) !important;
}
.nodedc-home-task-card-skeleton {
height: 14.75rem;
border-radius: 2rem !important;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.028) 0%, rgba(255, 255, 255, 0.01) 100%),
rgba(7, 7, 9, 0.68) !important;
box-shadow:
0 24px 48px rgba(0, 0, 0, 0.22),
inset 0 1px 0 rgba(255, 255, 255, 0.02) !important;
-webkit-backdrop-filter: blur(24px);
backdrop-filter: blur(24px);
}
.nodedc-home-metric-card {
border-radius: 1.5rem !important;
border: 1px solid rgba(255, 255, 255, 0.06);