Compare commits

..

No commits in common. "8b230d2670d985c1bf94f17da6e96147012f290c" and "e5036fc95b7f8d247e2394800a6c035f6188b294" have entirely different histories.

180 changed files with 1361 additions and 11398 deletions

View File

@ -46,10 +46,6 @@
## Кнопки ## Кнопки
- Все кнопки без жёсткого outline. - Все кнопки без жёсткого outline.
- Текстовые кнопки в модалках не сжимают текст:
- минимальный горизонтальный отступ от текста до края кнопки: `10px`
- для CTA предпочтительно использовать общий padding `1.25rem` или больше
- текст не должен визуально прилипать к радиусу кнопки
- Primary button: - Primary button:
- фон: акцентный или `active_card_rgb` - фон: акцентный или `active_card_rgb`
- текст: определяется автоматически по контрасту заливки - текст: определяется автоматически по контрасту заливки
@ -84,24 +80,6 @@
- единая вертикальная высота для одного класса контролов - единая вертикальная высота для одного класса контролов
- Placeholder и label должны быть читаемы и не прилипать к краям. - Placeholder и label должны быть читаемы и не прилипать к краям.
## Чекеры
- Для бинарных настроек в glass-интерфейсе используется круглый checker в стиле фильтров отображения.
- Активное состояние:
- круг залит `rgb(var(--nodedc-accent-rgb))`
- внутри маленькая точка `rgb(var(--nodedc-on-accent-rgb))`
- Неактивное состояние:
- круг на мягком `white/10`
- без внешнего outline и без синей browser-рамки
- Текстовый статус рядом с checker может дублировать состояние, но сам визуальный якорь должен оставаться круглым, а не квадратным checkbox.
- В деталях задачи структурные блоки создаются из меню `Добавить подэлемент` прямо в карточке, без отдельной модалки:
- порядок меню: `Создать текстовый блок`, `Создать чекер`, `Создать новую подзадачу`, `Добавить существующую подзадачу`
- текстовый блок содержит два поля: необязательный заголовок и текст
- чекер отображается без внешней подложки и без заголовка: только строки с круглым checker-якорем и plus-зона добавления строки
- первые 10 строк чекера видны сразу, дальше включается внутренний скролл списка
- у каждого структурного блока справа есть меню `...` с удалением блока
- блок хранится в штатном JSON-поле задачи `detail_layout`, а не в `description_html`: описание проходит HTML-sanitizer и не должно нести layout-состояние
- `detail_layout` является частью самой задачи, поэтому кастомные поля мультиплеерны и восстанавливаются после закрытия/повторного открытия карточки
## Toolbar и верхние панели ## Toolbar и верхние панели
- Элементы верхней панели центрируются по одной горизонтальной оси. - Элементы верхней панели центрируются по одной горизонтальной оси.
- Активный layout/tool mode выделяется кругом акцентного цвета, не квадратной плашкой. - Активный layout/tool mode выделяется кругом акцентного цвета, не квадратной плашкой.
@ -242,7 +220,6 @@
## Drag and drop ## Drag and drop
- Drag overlay использует акцентный контур. - Drag overlay использует акцентный контур.
- Во внутреннем kanban после успешного ручного переноса карточка остается в активной заливке `active_card_rgb` до выбора другой карточки/следующего переноса; сам drag-жест не открывает detail pane.
- Delete dropzone: - Delete dropzone:
- без красного технического свечения и без red-tinted text/fill - без красного технического свечения и без red-tinted text/fill
- текст локализован - текст локализован

View File

@ -25,8 +25,7 @@ x-aws-s3-env: &aws-s3-env
x-proxy-env: &proxy-env x-proxy-env: &proxy-env
APP_DOMAIN: ${APP_DOMAIN:-localhost} APP_DOMAIN: ${APP_DOMAIN:-localhost}
FILE_SIZE_LIMIT: ${PROXY_BODY_SIZE_LIMIT:-1073741824} FILE_SIZE_LIMIT: ${FILE_SIZE_LIMIT:-5242880}
PROXY_BODY_SIZE_LIMIT: ${PROXY_BODY_SIZE_LIMIT:-1073741824}
CERT_EMAIL: ${CERT_EMAIL} CERT_EMAIL: ${CERT_EMAIL}
CERT_ACME_CA: ${CERT_ACME_CA} CERT_ACME_CA: ${CERT_ACME_CA}
CERT_ACME_DNS: ${CERT_ACME_DNS} CERT_ACME_DNS: ${CERT_ACME_DNS}

View File

@ -64,7 +64,6 @@ AWS_SECRET_ACCESS_KEY=secret-key
AWS_S3_ENDPOINT_URL=http://plane-minio:9000 AWS_S3_ENDPOINT_URL=http://plane-minio:9000
AWS_S3_BUCKET_NAME=uploads AWS_S3_BUCKET_NAME=uploads
FILE_SIZE_LIMIT=5242880 FILE_SIZE_LIMIT=5242880
PROXY_BODY_SIZE_LIMIT=1073741824
POSTHOG_API_KEY= POSTHOG_API_KEY=
POSTHOG_HOST= POSTHOG_HOST=
INSTANCE_CHANGELOG_URL= INSTANCE_CHANGELOG_URL=

File diff suppressed because it is too large Load Diff

View File

@ -25,8 +25,7 @@ x-aws-s3-env: &aws-s3-env
x-proxy-env: &proxy-env x-proxy-env: &proxy-env
APP_DOMAIN: ${APP_DOMAIN:-localhost} APP_DOMAIN: ${APP_DOMAIN:-localhost}
FILE_SIZE_LIMIT: ${PROXY_BODY_SIZE_LIMIT:-1073741824} FILE_SIZE_LIMIT: ${FILE_SIZE_LIMIT:-5242880}
PROXY_BODY_SIZE_LIMIT: ${PROXY_BODY_SIZE_LIMIT:-1073741824}
CERT_EMAIL: ${CERT_EMAIL} CERT_EMAIL: ${CERT_EMAIL}
CERT_ACME_CA: ${CERT_ACME_CA} CERT_ACME_CA: ${CERT_ACME_CA}
CERT_ACME_DNS: ${CERT_ACME_DNS} CERT_ACME_DNS: ${CERT_ACME_DNS}
@ -48,9 +47,9 @@ x-live-env: &live-env
LIVE_SERVER_SECRET_KEY: ${LIVE_SERVER_SECRET_KEY:-2FiJk1U2aiVPEQtzLehYGlTSnTnrs7LW} LIVE_SERVER_SECRET_KEY: ${LIVE_SERVER_SECRET_KEY:-2FiJk1U2aiVPEQtzLehYGlTSnTnrs7LW}
x-app-env: &app-env x-app-env: &app-env
WEB_URL: ${WEB_URL:-http://localhost:8090} WEB_URL: ${WEB_URL:-http://localhost}
DEBUG: ${DEBUG:-0} DEBUG: ${DEBUG:-0}
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-http://localhost:8090} CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS}
GUNICORN_WORKERS: 1 GUNICORN_WORKERS: 1
POSTHOG_API_KEY: ${POSTHOG_API_KEY:-} POSTHOG_API_KEY: ${POSTHOG_API_KEY:-}
POSTHOG_HOST: ${POSTHOG_HOST:-} POSTHOG_HOST: ${POSTHOG_HOST:-}

View File

@ -64,7 +64,6 @@ AWS_SECRET_ACCESS_KEY=secret-key
AWS_S3_ENDPOINT_URL=http://plane-minio:9000 AWS_S3_ENDPOINT_URL=http://plane-minio:9000
AWS_S3_BUCKET_NAME=uploads AWS_S3_BUCKET_NAME=uploads
FILE_SIZE_LIMIT=5242880 FILE_SIZE_LIMIT=5242880
PROXY_BODY_SIZE_LIMIT=1073741824
POSTHOG_API_KEY= POSTHOG_API_KEY=
POSTHOG_HOST= POSTHOG_HOST=
INSTANCE_CHANGELOG_URL= INSTANCE_CHANGELOG_URL=

View File

@ -31,7 +31,6 @@ from plane.utils.content_validator import (
validate_html_content, validate_html_content,
validate_binary_data, validate_binary_data,
) )
from plane.utils.date_utils import set_default_issue_start_date
from .base import BaseSerializer from .base import BaseSerializer
from .cycle import CycleLiteSerializer, CycleSerializer from .cycle import CycleLiteSerializer, CycleSerializer
@ -74,9 +73,6 @@ class IssueSerializer(BaseSerializer):
exclude = ["description_json", "description_stripped"] exclude = ["description_json", "description_stripped"]
def validate(self, data): def validate(self, data):
if self.instance is None:
data = set_default_issue_start_date(data)
if ( if (
data.get("start_date", None) is not None data.get("start_date", None) is not None
and data.get("target_date", None) is not None and data.get("target_date", None) is not None

View File

@ -43,7 +43,6 @@ from plane.utils.openapi import (
asset_docs, asset_docs,
) )
from plane.utils.exception_logger import log_exception from plane.utils.exception_logger import log_exception
from plane.utils.upload_limits import resolve_workspace_upload_size_limit
class UserAssetEndpoint(BaseAPIView): class UserAssetEndpoint(BaseAPIView):
@ -513,6 +512,9 @@ class GenericAssetEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
) )
# Check if the file size is within the limit
size_limit = min(size, settings.FILE_SIZE_LIMIT)
# Check if the file type is allowed # Check if the file type is allowed
if not type or type not in settings.ATTACHMENT_MIME_TYPES: if not type or type not in settings.ATTACHMENT_MIME_TYPES:
return Response( return Response(
@ -523,9 +525,6 @@ class GenericAssetEndpoint(BaseAPIView):
# Get the workspace # Get the workspace
workspace = Workspace.objects.get(slug=slug) workspace = Workspace.objects.get(slug=slug)
# Check if the file size is within the limit
size_limit = resolve_workspace_upload_size_limit(workspace, size)
# asset key # asset key
asset_key = f"{workspace.id}/{uuid.uuid4().hex}-{name}" asset_key = f"{workspace.id}/{uuid.uuid4().hex}-{name}"

View File

@ -86,8 +86,6 @@ from plane.bgtasks.storage_metadata_task import get_asset_object_metadata
from .base import BaseAPIView from .base import BaseAPIView
from plane.utils.host import base_host from plane.utils.host import base_host
from plane.utils.issue_relation_mapper import get_actual_relation from plane.utils.issue_relation_mapper import get_actual_relation
from plane.utils.attachment_preview import attachment_object_exists, get_attachment_preview_response
from plane.utils.upload_limits import resolve_workspace_upload_size_limit
from plane.bgtasks.webhook_task import model_activity from plane.bgtasks.webhook_task import model_activity
from plane.app.permissions import ROLE from plane.app.permissions import ROLE
from plane.utils.openapi import ( from plane.utils.openapi import (
@ -1876,6 +1874,8 @@ class IssueAttachmentListCreateAPIEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
) )
size_limit = min(size, settings.FILE_SIZE_LIMIT)
if not type or type not in settings.ATTACHMENT_MIME_TYPES: if not type or type not in settings.ATTACHMENT_MIME_TYPES:
return Response( return Response(
{"error": "Invalid file type.", "status": False}, {"error": "Invalid file type.", "status": False},
@ -1885,8 +1885,6 @@ class IssueAttachmentListCreateAPIEndpoint(BaseAPIView):
# Get the workspace # Get the workspace
workspace = Workspace.objects.get(slug=slug) workspace = Workspace.objects.get(slug=slug)
size_limit = resolve_workspace_upload_size_limit(workspace, size)
# asset key # asset key
asset_key = f"{workspace.id}/{uuid.uuid4().hex}-{name}" asset_key = f"{workspace.id}/{uuid.uuid4().hex}-{name}"
@ -2102,8 +2100,13 @@ class IssueAttachmentDetailAPIEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
) )
disposition = "inline" if request.GET.get("preview") == "true" else "attachment" storage = S3Storage(request=request)
return get_attachment_preview_response(request, asset, disposition=disposition) presigned_url = storage.generate_presigned_url(
object_name=asset.asset.name,
disposition="attachment",
filename=asset.attributes.get("name"),
)
return HttpResponseRedirect(presigned_url)
@issue_attachment_docs( @issue_attachment_docs(
operation_id="upload_work_item_attachment", operation_id="upload_work_item_attachment",
@ -2154,11 +2157,6 @@ class IssueAttachmentDetailAPIEndpoint(BaseAPIView):
issue_attachment = FileAsset.objects.get(pk=pk, workspace__slug=slug, project_id=project_id) issue_attachment = FileAsset.objects.get(pk=pk, workspace__slug=slug, project_id=project_id)
serializer = IssueAttachmentSerializer(issue_attachment) serializer = IssueAttachmentSerializer(issue_attachment)
if not attachment_object_exists(issue_attachment):
return Response(
{"error": "The uploaded attachment object was not found.", "status": False},
status=status.HTTP_400_BAD_REQUEST,
)
# Send this activity only if the attachment is not uploaded before # Send this activity only if the attachment is not uploaded before
if not issue_attachment.is_uploaded: if not issue_attachment.is_uploaded:

View File

@ -125,7 +125,6 @@ from .notification import NotificationSerializer, UserNotificationPreferenceSeri
from .exporter import ExporterHistorySerializer from .exporter import ExporterHistorySerializer
from .webhook import WebhookSerializer, WebhookLogSerializer from .webhook import WebhookSerializer, WebhookLogSerializer
from .voice_tasker import WorkspaceAISettingsSerializer
from .favorite import UserFavoriteSerializer from .favorite import UserFavoriteSerializer

View File

@ -192,7 +192,6 @@ class DynamicBaseSerializer(BaseSerializer):
issue_attachments = FileAsset.objects.filter( issue_attachments = FileAsset.objects.filter(
issue_id=issue_id, issue_id=issue_id,
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
is_uploaded=True,
) )
# Serialize issue_attachments and add them to the response # Serialize issue_attachments and add them to the response
response["issue_attachments"] = IssueAttachmentLiteSerializer(issue_attachments, many=True).data response["issue_attachments"] = IssueAttachmentLiteSerializer(issue_attachments, many=True).data

View File

@ -47,7 +47,6 @@ from plane.utils.content_validator import (
validate_html_content, validate_html_content,
validate_binary_data, validate_binary_data,
) )
from plane.utils.date_utils import set_default_issue_start_date
class IssueFlatSerializer(BaseSerializer): class IssueFlatSerializer(BaseSerializer):
@ -125,9 +124,6 @@ class IssueCreateSerializer(BaseSerializer):
allow_triage = self.context.get("allow_triage_state", False) allow_triage = self.context.get("allow_triage_state", False)
state_manager = State.triage_objects if allow_triage else State.objects state_manager = State.triage_objects if allow_triage else State.objects
if self.instance is None:
attrs = set_default_issue_start_date(attrs)
if ( if (
attrs.get("start_date", None) is not None attrs.get("start_date", None) is not None
and attrs.get("target_date", None) is not None and attrs.get("target_date", None) is not None
@ -803,8 +799,6 @@ class IssueSerializer(DynamicBaseSerializer):
"link_count", "link_count",
"is_draft", "is_draft",
"archived_at", "archived_at",
"external_source",
"external_id",
] ]
read_only_fields = fields read_only_fields = fields
@ -858,8 +852,6 @@ class IssueListDetailSerializer(serializers.Serializer):
"updated_by": instance.updated_by_id, "updated_by": instance.updated_by_id,
"is_draft": instance.is_draft, "is_draft": instance.is_draft,
"archived_at": instance.archived_at, "archived_at": instance.archived_at,
"external_source": instance.external_source,
"external_id": instance.external_id,
"source_project_name": getattr(instance, "source_project_name", None), "source_project_name": getattr(instance, "source_project_name", None),
# Computed fields # Computed fields
"cycle_id": instance.cycle_id, "cycle_id": instance.cycle_id,
@ -941,7 +933,6 @@ class IssueDetailSerializer(IssueSerializer):
class Meta(IssueSerializer.Meta): class Meta(IssueSerializer.Meta):
fields = IssueSerializer.Meta.fields + [ fields = IssueSerializer.Meta.fields + [
"description_html", "description_html",
"detail_layout",
"is_subscribed", "is_subscribed",
"is_intake", "is_intake",
] ]

View File

@ -1,95 +0,0 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
from rest_framework import serializers
from plane.db.models import Project, WorkspaceAICredential, WorkspaceAISettings
from plane.license.utils.encryption import encrypt_data
from .base import BaseSerializer
class WorkspaceAISettingsSerializer(BaseSerializer):
default_project_id = serializers.UUIDField(required=False, allow_null=True)
openai_api_key = serializers.CharField(required=False, allow_blank=True, write_only=True, trim_whitespace=False)
credential = serializers.SerializerMethodField(read_only=True)
class Meta:
model = WorkspaceAISettings
fields = [
"id",
"workspace_id",
"voice_tasker_enabled",
"provider",
"transcription_model",
"structuring_model",
"default_project_id",
"access_mode",
"max_audio_duration_seconds",
"per_user_hourly_limit",
"workspace_hourly_limit",
"credential",
"openai_api_key",
"created_at",
"updated_at",
]
read_only_fields = ["id", "workspace_id", "provider", "created_at", "updated_at", "credential"]
def get_credential(self, obj):
credential = WorkspaceAICredential.objects.filter(workspace=obj.workspace, provider=obj.provider).first()
return {
"provider": obj.provider,
"has_key": bool(credential and credential.encrypted_api_key and credential.is_active),
"key_last4": credential.key_last4 if credential else "",
"updated_at": credential.updated_at if credential else None,
}
def validate_default_project_id(self, value):
if value is None:
return None
workspace = self.context["workspace"]
if not Project.objects.filter(workspace=workspace, id=value, archived_at__isnull=True).exists():
raise serializers.ValidationError("Default project must belong to this workspace.")
return value
def validate_max_audio_duration_seconds(self, value):
if value < 10 or value > 600:
raise serializers.ValidationError("Max audio duration must be between 10 and 600 seconds.")
return value
def validate_per_user_hourly_limit(self, value):
if value < 1 or value > 1000:
raise serializers.ValidationError("Per-user hourly limit must be between 1 and 1000.")
return value
def validate_workspace_hourly_limit(self, value):
if value < 1 or value > 10000:
raise serializers.ValidationError("Workspace hourly limit must be between 1 and 10000.")
return value
def update(self, instance, validated_data):
api_key = validated_data.pop("openai_api_key", None)
default_project_id = validated_data.pop("default_project_id", serializers.empty)
if default_project_id is not serializers.empty:
instance.default_project_id = default_project_id
for key, value in validated_data.items():
setattr(instance, key, value)
instance.save()
if api_key:
cleaned_api_key = api_key.strip()
credential, _ = WorkspaceAICredential.objects.get_or_create(
workspace=instance.workspace,
provider=instance.provider,
)
credential.encrypted_api_key = encrypt_data(cleaned_api_key)
credential.key_last4 = cleaned_api_key[-4:] if len(cleaned_api_key) >= 4 else cleaned_api_key
credential.is_active = True
credential.save()
return instance

View File

@ -61,11 +61,6 @@ class WorkSpaceSerializer(DynamicBaseSerializer):
) )
return value return value
def validate_storage_file_size_limit(self, value):
if value is not None and int(value) < 1:
raise serializers.ValidationError("Storage file size limit must be greater than zero")
return value
class Meta: class Meta:
model = Workspace model = Workspace
fields = "__all__" fields = "__all__"

View File

@ -23,7 +23,6 @@ from .webhook import urlpatterns as webhook_urls
from .workspace import urlpatterns as workspace_urls from .workspace import urlpatterns as workspace_urls
from .timezone import urlpatterns as timezone_urls from .timezone import urlpatterns as timezone_urls
from .exporter import urlpatterns as exporter_urls from .exporter import urlpatterns as exporter_urls
from .voice_tasker import urlpatterns as voice_tasker_urls
urlpatterns = [ urlpatterns = [
*analytic_urls, *analytic_urls,
@ -47,5 +46,4 @@ urlpatterns = [
*webhook_urls, *webhook_urls,
*timezone_urls, *timezone_urls,
*exporter_urls, *exporter_urls,
*voice_tasker_urls,
] ]

View File

@ -44,11 +44,6 @@ urlpatterns = [
UserEndpoint.as_view({"patch": "update_email"}), UserEndpoint.as_view({"patch": "update_email"}),
name="user-email-update", name="user-email-update",
), ),
path(
"users/me/email/direct/",
UserEndpoint.as_view({"patch": "update_email_without_verification"}),
name="user-email-direct-update",
),
# Profile # Profile
path("users/me/profile/", ProfileEndpoint.as_view(), name="accounts"), path("users/me/profile/", ProfileEndpoint.as_view(), name="accounts"),
# End profile # End profile

View File

@ -1,42 +0,0 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
from django.urls import path
from plane.app.views import (
VoiceTaskCommitEndpoint,
VoiceTaskParseEndpoint,
VoiceTaskPreflightEndpoint,
WorkspaceAISettingsEndpoint,
WorkspaceAISettingsTestConnectionEndpoint,
)
urlpatterns = [
path(
"workspaces/<str:slug>/voice-tasker/settings/",
WorkspaceAISettingsEndpoint.as_view(),
name="voice-tasker-settings",
),
path(
"workspaces/<str:slug>/voice-tasker/settings/test-connection/",
WorkspaceAISettingsTestConnectionEndpoint.as_view(),
name="voice-tasker-settings-test-connection",
),
path(
"workspaces/<str:slug>/voice-task/preflight/",
VoiceTaskPreflightEndpoint.as_view(),
name="voice-task-preflight",
),
path(
"workspaces/<str:slug>/voice-task/parse/",
VoiceTaskParseEndpoint.as_view(),
name="voice-task-parse",
),
path(
"workspaces/<str:slug>/voice-task/commit/",
VoiceTaskCommitEndpoint.as_view(),
name="voice-task-commit",
),
]

View File

@ -243,14 +243,6 @@ from .webhook.base import (
WebhookSecretRegenerateEndpoint, WebhookSecretRegenerateEndpoint,
) )
from .voice_tasker import (
VoiceTaskCommitEndpoint,
VoiceTaskParseEndpoint,
VoiceTaskPreflightEndpoint,
WorkspaceAISettingsEndpoint,
WorkspaceAISettingsTestConnectionEndpoint,
)
from .error_404 import custom_404_view from .error_404 import custom_404_view
from .notification.base import MarkAllReadNotificationViewSet from .notification.base import MarkAllReadNotificationViewSet

View File

@ -24,7 +24,6 @@ from plane.app.permissions import allow_permission, ROLE
from plane.utils.cache import invalidate_cache_directly from plane.utils.cache import invalidate_cache_directly
from plane.bgtasks.storage_metadata_task import get_asset_object_metadata from plane.bgtasks.storage_metadata_task import get_asset_object_metadata
from plane.throttles.asset import AssetRateThrottle from plane.throttles.asset import AssetRateThrottle
from plane.utils.upload_limits import resolve_workspace_upload_size_limit
class UserAssetsV2Endpoint(BaseAPIView): class UserAssetsV2Endpoint(BaseAPIView):
@ -343,12 +342,12 @@ class WorkspaceFileAssetEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
) )
# Get the size limit
size_limit = min(settings.FILE_SIZE_LIMIT, size)
# Get the workspace # Get the workspace
workspace = Workspace.objects.get(slug=slug) workspace = Workspace.objects.get(slug=slug)
# Get the size limit
size_limit = resolve_workspace_upload_size_limit(workspace, size)
# asset key # asset key
asset_key = f"{workspace.id}/{uuid.uuid4().hex}-{name}" asset_key = f"{workspace.id}/{uuid.uuid4().hex}-{name}"
@ -542,12 +541,12 @@ class ProjectAssetEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
) )
# Get the size limit
size_limit = min(settings.FILE_SIZE_LIMIT, size)
# Get the workspace # Get the workspace
workspace = Workspace.objects.get(slug=slug) workspace = Workspace.objects.get(slug=slug)
# Get the size limit
size_limit = resolve_workspace_upload_size_limit(workspace, size)
# asset key # asset key
asset_key = f"{workspace.id}/{uuid.uuid4().hex}-{name}" asset_key = f"{workspace.id}/{uuid.uuid4().hex}-{name}"

View File

@ -10,6 +10,7 @@ import uuid
from django.utils import timezone from django.utils import timezone
from django.core.serializers.json import DjangoJSONEncoder from django.core.serializers.json import DjangoJSONEncoder
from django.conf import settings from django.conf import settings
from django.http import HttpResponseRedirect
# Third Party imports # Third Party imports
from rest_framework.response import Response from rest_framework.response import Response
@ -25,8 +26,6 @@ from plane.app.permissions import allow_permission, ROLE
from plane.settings.storage import S3Storage from plane.settings.storage import S3Storage
from plane.bgtasks.storage_metadata_task import get_asset_object_metadata from plane.bgtasks.storage_metadata_task import get_asset_object_metadata
from plane.utils.host import base_host from plane.utils.host import base_host
from plane.utils.upload_limits import resolve_workspace_upload_size_limit
from plane.utils.attachment_preview import attachment_object_exists, get_attachment_preview_response
class IssueAttachmentEndpoint(BaseAPIView): class IssueAttachmentEndpoint(BaseAPIView):
@ -87,13 +86,7 @@ class IssueAttachmentEndpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def get(self, request, slug, project_id, issue_id): def get(self, request, slug, project_id, issue_id):
issue_attachments = FileAsset.objects.filter( issue_attachments = FileAsset.objects.filter(issue_id=issue_id, workspace__slug=slug, project_id=project_id)
issue_id=issue_id,
workspace__slug=slug,
project_id=project_id,
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
is_uploaded=True,
)
serializer = IssueAttachmentSerializer(issue_attachments, many=True) serializer = IssueAttachmentSerializer(issue_attachments, many=True)
return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.data, status=status.HTTP_200_OK)
@ -121,7 +114,7 @@ class IssueAttachmentV2Endpoint(BaseAPIView):
asset_key = f"{workspace.id}/{uuid.uuid4().hex}-{name}" asset_key = f"{workspace.id}/{uuid.uuid4().hex}-{name}"
# Get the size limit # Get the size limit
size_limit = resolve_workspace_upload_size_limit(workspace, size) size_limit = min(size, settings.FILE_SIZE_LIMIT)
# Create a File Asset # Create a File Asset
asset = FileAsset.objects.create( asset = FileAsset.objects.create(
@ -186,8 +179,13 @@ class IssueAttachmentV2Endpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
) )
disposition = "inline" if request.GET.get("preview") == "true" else "attachment" storage = S3Storage(request=request)
return get_attachment_preview_response(request, asset, disposition=disposition) presigned_url = storage.generate_presigned_url(
object_name=asset.asset.name,
disposition="attachment",
filename=asset.attributes.get("name"),
)
return HttpResponseRedirect(presigned_url)
# Get all the attachments # Get all the attachments
issue_attachments = FileAsset.objects.filter( issue_attachments = FileAsset.objects.filter(
@ -205,11 +203,6 @@ class IssueAttachmentV2Endpoint(BaseAPIView):
def patch(self, request, slug, project_id, issue_id, pk): def patch(self, request, slug, project_id, issue_id, pk):
issue_attachment = FileAsset.objects.get(pk=pk, workspace__slug=slug, project_id=project_id) issue_attachment = FileAsset.objects.get(pk=pk, workspace__slug=slug, project_id=project_id)
serializer = IssueAttachmentSerializer(issue_attachment) serializer = IssueAttachmentSerializer(issue_attachment)
if not attachment_object_exists(issue_attachment):
return Response(
{"error": "The uploaded attachment object was not found.", "status": False},
status=status.HTTP_400_BAD_REQUEST,
)
# Send this activity only if the attachment is not uploaded before # Send this activity only if the attachment is not uploaded before
if not issue_attachment.is_uploaded: if not issue_attachment.is_uploaded:

View File

@ -3,7 +3,6 @@
# See the LICENSE file for details. # See the LICENSE file for details.
# Python imports # Python imports
import os
import uuid import uuid
import json import json
import logging import logging
@ -51,7 +50,6 @@ from plane.bgtasks.user_deactivation_email_task import user_deactivation_email
from plane.utils.host import base_host from plane.utils.host import base_host
from plane.bgtasks.user_email_update_task import send_email_update_magic_code, send_email_update_confirmation from plane.bgtasks.user_email_update_task import send_email_update_magic_code, send_email_update_confirmation
from plane.authentication.rate_limit import EmailVerificationThrottle from plane.authentication.rate_limit import EmailVerificationThrottle
from plane.license.utils.instance_value import get_configuration_value
logger = logging.getLogger("plane") logger = logging.getLogger("plane")
@ -135,10 +133,6 @@ class UserEndpoint(BaseViewSet):
return None return None
def _is_smtp_configured(self):
(email_host,) = get_configuration_value([{"key": "EMAIL_HOST", "default": os.environ.get("EMAIL_HOST", "")}])
return bool(email_host)
def generate_email_verification_code(self, request): def generate_email_verification_code(self, request):
""" """
Generate and send a magic code to the new email address for verification. Generate and send a magic code to the new email address for verification.
@ -254,33 +248,6 @@ class UserEndpoint(BaseViewSet):
serialized_data = UserMeSerializer(user).data serialized_data = UserMeSerializer(user).data
return Response(serialized_data, status=status.HTTP_200_OK) return Response(serialized_data, status=status.HTTP_200_OK)
def update_email_without_verification(self, request):
"""
Update the current user's email when the instance has no SMTP configured.
Verified SMTP-backed installations must continue using the magic-code flow.
"""
if self._is_smtp_configured():
return Response(
{"error": "Email verification is required when SMTP is configured"},
status=status.HTTP_400_BAD_REQUEST,
)
user = self.get_object()
new_email = request.data.get("email", "").strip().lower()
validation_error = self._validate_new_email(user, new_email)
if validation_error:
return validation_error
user.email = new_email
user.is_email_verified = False
user.save()
logout(request)
serialized_data = UserMeSerializer(user).data
return Response(serialized_data, status=status.HTTP_200_OK)
def deactivate(self, request): def deactivate(self, request):
# Check all workspace user is active # Check all workspace user is active
user = self.get_object() user = self.get_object()

File diff suppressed because it is too large Load Diff

View File

@ -34,7 +34,9 @@ class WorkspaceHomePreferenceViewSet(BaseAPIView):
if key not in ["quick_tutorial", "new_at_plane"] if key not in ["quick_tutorial", "new_at_plane"]
] ]
for sort_order_counter, preference in enumerate(keys, start=1): sort_order_counter = 1
for preference in keys:
if preference not in get_preference.values_list("key", flat=True): if preference not in get_preference.values_list("key", flat=True):
create_preference_keys.append(preference) create_preference_keys.append(preference)
@ -53,6 +55,7 @@ class WorkspaceHomePreferenceViewSet(BaseAPIView):
batch_size=10, batch_size=10,
ignore_conflicts=True, ignore_conflicts=True,
) )
sort_order_counter += 1
preference = WorkspaceHomePreference.objects.filter(user=request.user, workspace_id=workspace.id) preference = WorkspaceHomePreference.objects.filter(user=request.user, workspace_id=workspace.id)

View File

@ -41,10 +41,9 @@ from plane.app.serializers import (
from plane.app.views.base import BaseAPIView from plane.app.views.base import BaseAPIView
from plane.db.models import ( from plane.db.models import (
CycleIssue, CycleIssue,
FileAsset,
IntakeIssue,
Issue, Issue,
IssueActivity, IssueActivity,
FileAsset,
IssueLink, IssueLink,
IssueSubscriber, IssueSubscriber,
Project, Project,
@ -109,18 +108,6 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView):
CycleIssue.objects.filter(issue=OuterRef("id"), deleted_at__isnull=True).values("cycle_id")[:1] CycleIssue.objects.filter(issue=OuterRef("id"), deleted_at__isnull=True).values("cycle_id")[:1]
) )
) )
.annotate(
created_by_display_name=F("created_by__display_name"),
created_by_avatar_url=F("created_by__avatar"),
)
.annotate(
source_project_name=Subquery(
IntakeIssue.objects.filter(
issue_id=OuterRef("id"),
extra__bridge="external-contours",
).values("extra__source_project_name")[:1]
)
)
.annotate( .annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id")) link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by() .order_by()

View File

@ -1,187 +0,0 @@
# Generated by Codex on 2026-04-24
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import uuid
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("db", "0123_force_profile_language_ru"),
]
operations = [
migrations.CreateModel(
name="WorkspaceAICredential",
fields=[
(
"created_at",
models.DateTimeField(auto_now_add=True, verbose_name="Created At"),
),
(
"updated_at",
models.DateTimeField(auto_now=True, verbose_name="Last Modified At"),
),
("deleted_at", models.DateTimeField(blank=True, editable=False, null=True)),
(
"id",
models.UUIDField(
db_index=True,
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
unique=True,
),
),
(
"provider",
models.CharField(
choices=[("openai", "OpenAI")],
default="openai",
max_length=32,
),
),
("encrypted_api_key", models.TextField(blank=True)),
("key_last4", models.CharField(blank=True, max_length=4)),
("is_active", models.BooleanField(default=True)),
(
"created_by",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="%(class)s_created_by",
to=settings.AUTH_USER_MODEL,
verbose_name="Created By",
),
),
(
"updated_by",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="%(class)s_updated_by",
to=settings.AUTH_USER_MODEL,
verbose_name="Last Modified By",
),
),
(
"workspace",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
related_name="ai_credential",
to="db.workspace",
),
),
],
options={
"verbose_name": "Workspace AI Credential",
"verbose_name_plural": "Workspace AI Credentials",
"db_table": "workspace_ai_credentials",
"ordering": ("-created_at",),
},
),
migrations.CreateModel(
name="WorkspaceAISettings",
fields=[
(
"created_at",
models.DateTimeField(auto_now_add=True, verbose_name="Created At"),
),
(
"updated_at",
models.DateTimeField(auto_now=True, verbose_name="Last Modified At"),
),
("deleted_at", models.DateTimeField(blank=True, editable=False, null=True)),
(
"id",
models.UUIDField(
db_index=True,
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
unique=True,
),
),
("voice_tasker_enabled", models.BooleanField(default=False)),
(
"provider",
models.CharField(
choices=[("openai", "OpenAI")],
default="openai",
max_length=32,
),
),
(
"transcription_model",
models.CharField(default="gpt-4o-mini-transcribe", max_length=80),
),
(
"structuring_model",
models.CharField(default="gpt-4o-mini", max_length=80),
),
(
"access_mode",
models.CharField(
choices=[
("all_workspace_members", "All workspace members"),
("admins_only", "Admins only"),
],
default="all_workspace_members",
max_length=40,
),
),
("max_audio_duration_seconds", models.PositiveIntegerField(default=120)),
("per_user_hourly_limit", models.PositiveIntegerField(default=30)),
("workspace_hourly_limit", models.PositiveIntegerField(default=300)),
(
"created_by",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="%(class)s_created_by",
to=settings.AUTH_USER_MODEL,
verbose_name="Created By",
),
),
(
"default_project",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="workspace_ai_default_project",
to="db.project",
),
),
(
"updated_by",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="%(class)s_updated_by",
to=settings.AUTH_USER_MODEL,
verbose_name="Last Modified By",
),
),
(
"workspace",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
related_name="ai_settings",
to="db.workspace",
),
),
],
options={
"verbose_name": "Workspace AI Settings",
"verbose_name_plural": "Workspace AI Settings",
"db_table": "workspace_ai_settings",
"ordering": ("-created_at",),
},
),
]

View File

@ -1,142 +0,0 @@
# Generated by Codex on 2026-04-24
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import uuid
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("db", "0124_workspace_ai_settings_and_credentials"),
]
operations = [
migrations.CreateModel(
name="VoiceTaskSession",
fields=[
(
"created_at",
models.DateTimeField(auto_now_add=True, verbose_name="Created At"),
),
(
"updated_at",
models.DateTimeField(auto_now=True, verbose_name="Last Modified At"),
),
("deleted_at", models.DateTimeField(blank=True, null=True, verbose_name="Deleted At")),
(
"id",
models.UUIDField(
db_index=True,
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
unique=True,
),
),
(
"status",
models.CharField(
choices=[
("uploaded", "Uploaded"),
("transcribing", "Transcribing"),
("transcribed", "Transcribed"),
("parsing", "Parsing"),
("parsed", "Parsed"),
("failed", "Failed"),
],
default="uploaded",
max_length=32,
),
),
("audio_duration_seconds", models.FloatField(blank=True, null=True)),
("audio_content_type", models.CharField(blank=True, max_length=100)),
("audio_size", models.PositiveIntegerField(blank=True, null=True)),
("transcript", models.TextField(blank=True)),
("intent", models.CharField(blank=True, max_length=40)),
("parsed_json", models.JSONField(blank=True, default=dict)),
("client_context", models.JSONField(blank=True, default=dict)),
("error_code", models.CharField(blank=True, max_length=80)),
("error_message", models.TextField(blank=True)),
(
"created_by",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="%(class)s_created_by",
to=settings.AUTH_USER_MODEL,
verbose_name="Created By",
),
),
(
"created_task",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="created_by_voice_sessions",
to="db.issue",
),
),
(
"updated_by",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="%(class)s_updated_by",
to=settings.AUTH_USER_MODEL,
verbose_name="Last Modified By",
),
),
(
"updated_task",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="updated_by_voice_sessions",
to="db.issue",
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="voice_task_sessions",
to=settings.AUTH_USER_MODEL,
),
),
(
"workspace",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="voice_task_sessions",
to="db.workspace",
),
),
],
options={
"verbose_name": "Voice Task Session",
"verbose_name_plural": "Voice Task Sessions",
"db_table": "voice_task_sessions",
"ordering": ("-created_at",),
},
),
migrations.AddIndex(
model_name="voicetasksession",
index=models.Index(
fields=["workspace", "user", "-created_at"],
name="voice_task_session_user_idx",
),
),
migrations.AddIndex(
model_name="voicetasksession",
index=models.Index(
fields=["workspace", "status", "-created_at"],
name="voice_task_session_status_idx",
),
),
]

View File

@ -1,23 +0,0 @@
# Generated by Codex on 2026-04-25
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("db", "0125_voice_task_sessions"),
]
operations = [
migrations.AddField(
model_name="workspace",
name="storage_file_size_limit_enabled",
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name="workspace",
name="storage_file_size_limit",
field=models.PositiveBigIntegerField(default=5242880),
),
]

View File

@ -1,18 +0,0 @@
# Generated by Codex on 2026-04-25
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("db", "0126_workspace_storage_limits"),
]
operations = [
migrations.AddField(
model_name="issue",
name="detail_layout",
field=models.JSONField(blank=True, default=dict),
),
]

View File

@ -65,7 +65,6 @@ from .state import State, StateGroup, DEFAULT_STATES
from .user import Account, Profile, User, BotTypeEnum from .user import Account, Profile, User, BotTypeEnum
from .view import IssueView from .view import IssueView
from .webhook import Webhook, WebhookLog from .webhook import Webhook, WebhookLog
from .voice_tasker import VoiceTaskSession, WorkspaceAICredential, WorkspaceAISettings
from .workspace import ( from .workspace import (
Workspace, Workspace,
WorkspaceBaseModel, WorkspaceBaseModel,

View File

@ -134,7 +134,6 @@ class Issue(ProjectBaseModel):
name = models.CharField(max_length=255, verbose_name="Issue Name") name = models.CharField(max_length=255, verbose_name="Issue Name")
description_json = models.JSONField(blank=True, default=dict) description_json = models.JSONField(blank=True, default=dict)
description_html = models.TextField(blank=True, default="<p></p>") description_html = models.TextField(blank=True, default="<p></p>")
detail_layout = models.JSONField(blank=True, default=dict)
description_stripped = models.TextField(blank=True, null=True) description_stripped = models.TextField(blank=True, null=True)
description_binary = models.BinaryField(null=True) description_binary = models.BinaryField(null=True)
priority = models.CharField( priority = models.CharField(

View File

@ -1,133 +0,0 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
from django.conf import settings
from django.db import models
from .base import BaseModel
class WorkspaceAISettings(BaseModel):
class Provider(models.TextChoices):
OPENAI = "openai", "OpenAI"
class AccessMode(models.TextChoices):
ALL_WORKSPACE_MEMBERS = "all_workspace_members", "All workspace members"
ADMINS_ONLY = "admins_only", "Admins only"
workspace = models.OneToOneField(
"db.Workspace",
on_delete=models.CASCADE,
related_name="ai_settings",
)
voice_tasker_enabled = models.BooleanField(default=False)
provider = models.CharField(max_length=32, choices=Provider.choices, default=Provider.OPENAI)
transcription_model = models.CharField(max_length=80, default="gpt-4o-mini-transcribe")
structuring_model = models.CharField(max_length=80, default="gpt-4o-mini")
default_project = models.ForeignKey(
"db.Project",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="workspace_ai_default_project",
)
access_mode = models.CharField(
max_length=40,
choices=AccessMode.choices,
default=AccessMode.ALL_WORKSPACE_MEMBERS,
)
max_audio_duration_seconds = models.PositiveIntegerField(default=120)
per_user_hourly_limit = models.PositiveIntegerField(default=30)
workspace_hourly_limit = models.PositiveIntegerField(default=300)
class Meta:
verbose_name = "Workspace AI Settings"
verbose_name_plural = "Workspace AI Settings"
db_table = "workspace_ai_settings"
ordering = ("-created_at",)
def __str__(self):
return f"{self.workspace.slug} AI settings"
class WorkspaceAICredential(BaseModel):
class Provider(models.TextChoices):
OPENAI = "openai", "OpenAI"
workspace = models.OneToOneField(
"db.Workspace",
on_delete=models.CASCADE,
related_name="ai_credential",
)
provider = models.CharField(max_length=32, choices=Provider.choices, default=Provider.OPENAI)
encrypted_api_key = models.TextField(blank=True)
key_last4 = models.CharField(max_length=4, blank=True)
is_active = models.BooleanField(default=True)
class Meta:
verbose_name = "Workspace AI Credential"
verbose_name_plural = "Workspace AI Credentials"
db_table = "workspace_ai_credentials"
ordering = ("-created_at",)
def __str__(self):
return f"{self.workspace.slug} {self.provider} credential"
class VoiceTaskSession(BaseModel):
class Status(models.TextChoices):
UPLOADED = "uploaded", "Uploaded"
TRANSCRIBING = "transcribing", "Transcribing"
TRANSCRIBED = "transcribed", "Transcribed"
PARSING = "parsing", "Parsing"
PARSED = "parsed", "Parsed"
FAILED = "failed", "Failed"
workspace = models.ForeignKey(
"db.Workspace",
on_delete=models.CASCADE,
related_name="voice_task_sessions",
)
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="voice_task_sessions",
)
status = models.CharField(max_length=32, choices=Status.choices, default=Status.UPLOADED)
audio_duration_seconds = models.FloatField(null=True, blank=True)
audio_content_type = models.CharField(max_length=100, blank=True)
audio_size = models.PositiveIntegerField(null=True, blank=True)
transcript = models.TextField(blank=True)
intent = models.CharField(max_length=40, blank=True)
parsed_json = models.JSONField(blank=True, default=dict)
client_context = models.JSONField(blank=True, default=dict)
created_task = models.ForeignKey(
"db.Issue",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="created_by_voice_sessions",
)
updated_task = models.ForeignKey(
"db.Issue",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="updated_by_voice_sessions",
)
error_code = models.CharField(max_length=80, blank=True)
error_message = models.TextField(blank=True)
class Meta:
verbose_name = "Voice Task Session"
verbose_name_plural = "Voice Task Sessions"
db_table = "voice_task_sessions"
ordering = ("-created_at",)
indexes = [
models.Index(fields=["workspace", "user", "-created_at"], name="voice_task_session_user_idx"),
models.Index(fields=["workspace", "status", "-created_at"], name="voice_task_session_status_idx"),
]
def __str__(self):
return f"{self.workspace_id} {self.user_id} {self.status}"

View File

@ -137,8 +137,6 @@ class Workspace(BaseModel):
organization_size = models.CharField(max_length=20, blank=True, null=True) organization_size = models.CharField(max_length=20, blank=True, null=True)
timezone = models.CharField(max_length=255, default="UTC", choices=TIMEZONE_CHOICES) timezone = models.CharField(max_length=255, default="UTC", choices=TIMEZONE_CHOICES)
background_color = models.CharField(max_length=255, default=get_random_color) background_color = models.CharField(max_length=255, default=get_random_color)
storage_file_size_limit_enabled = models.BooleanField(default=True)
storage_file_size_limit = models.PositiveBigIntegerField(default=5242880)
def __str__(self): def __str__(self):
"""Return name of the Workspace""" """Return name of the Workspace"""
@ -380,7 +378,6 @@ class WorkspaceHomePreference(BaseModel):
QUICK_LINKS = "quick_links", "Quick Links" QUICK_LINKS = "quick_links", "Quick Links"
RECENTS = "recents", "Recents" RECENTS = "recents", "Recents"
MY_STICKIES = "my_stickies", "My Stickies" MY_STICKIES = "my_stickies", "My Stickies"
PROJECT_LATEST_ISSUES = "project_latest_issues", "Project Latest Issues"
NEW_AT_PLANE = "new_at_plane", "New at Plane" NEW_AT_PLANE = "new_at_plane", "New at Plane"
QUICK_TUTORIAL = "quick_tutorial", "Quick Tutorial" QUICK_TUTORIAL = "quick_tutorial", "Quick Tutorial"

View File

@ -36,7 +36,6 @@ from plane.utils.content_validator import (
validate_html_content, validate_html_content,
validate_binary_data, validate_binary_data,
) )
from plane.utils.date_utils import set_default_issue_start_date
class IssueStateFlatSerializer(BaseSerializer): class IssueStateFlatSerializer(BaseSerializer):
@ -278,9 +277,6 @@ class IssueCreateSerializer(BaseSerializer):
return data return data
def validate(self, data): def validate(self, data):
if self.instance is None:
data = set_default_issue_start_date(data)
if ( if (
data.get("start_date", None) is not None data.get("start_date", None) is not None
and data.get("target_date", None) is not None and data.get("target_date", None) is not None

View File

@ -1,38 +0,0 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
from datetime import date
import pytest
from plane.utils.date_utils import set_default_issue_start_date
@pytest.mark.unit
class TestDefaultIssueStartDate:
def test_sets_today_for_future_target_date(self, monkeypatch):
monkeypatch.setattr("plane.utils.date_utils.timezone.localdate", lambda: date(2026, 4, 25))
attrs = set_default_issue_start_date({"target_date": date(2026, 5, 1)})
assert attrs["start_date"] == date(2026, 4, 25)
def test_preserves_explicit_start_date(self, monkeypatch):
monkeypatch.setattr("plane.utils.date_utils.timezone.localdate", lambda: date(2026, 4, 25))
attrs = set_default_issue_start_date(
{
"start_date": date(2026, 4, 20),
"target_date": date(2026, 5, 1),
}
)
assert attrs["start_date"] == date(2026, 4, 20)
def test_leaves_past_target_without_start_date(self, monkeypatch):
monkeypatch.setattr("plane.utils.date_utils.timezone.localdate", lambda: date(2026, 4, 25))
attrs = set_default_issue_start_date({"target_date": date(2026, 4, 20)})
assert "start_date" not in attrs

View File

@ -1,23 +0,0 @@
from types import SimpleNamespace
import pytest
from plane.utils.upload_limits import get_workspace_file_size_limit, resolve_workspace_upload_size_limit
@pytest.mark.unit
class TestWorkspaceUploadLimits:
def test_returns_none_when_workspace_limit_is_disabled(self):
workspace = SimpleNamespace(storage_file_size_limit_enabled=False, storage_file_size_limit=1024)
assert get_workspace_file_size_limit(workspace) is None
def test_caps_requested_size_by_workspace_limit(self):
workspace = SimpleNamespace(storage_file_size_limit_enabled=True, storage_file_size_limit=1024)
assert resolve_workspace_upload_size_limit(workspace, 4096) == 1024
def test_uses_requested_size_when_workspace_limit_is_disabled(self):
workspace = SimpleNamespace(storage_file_size_limit_enabled=False, storage_file_size_limit=1024)
assert resolve_workspace_upload_size_limit(workspace, 4096) == 4096

View File

@ -1,59 +0,0 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
from urllib.parse import quote
from botocore.exceptions import ClientError
from django.http import HttpResponse, StreamingHttpResponse
from plane.settings.storage import S3Storage
def attachment_object_exists(asset):
storage = S3Storage(request=None)
try:
storage.s3_client.head_object(
Bucket=storage.aws_storage_bucket_name,
Key=str(asset.asset.name),
)
return True
except ClientError:
return False
def get_attachment_preview_response(request, asset, disposition="inline"):
storage = S3Storage(request=None)
range_header = request.META.get("HTTP_RANGE")
request_kwargs = {
"Bucket": storage.aws_storage_bucket_name,
"Key": str(asset.asset.name),
}
if range_header:
request_kwargs["Range"] = range_header
try:
storage_response = storage.s3_client.get_object(**request_kwargs)
except ClientError:
return HttpResponse("Attachment object not found.", status=404)
content_type = (
asset.attributes.get("type")
or storage_response.get("ContentType")
or "application/octet-stream"
)
filename = quote(asset.attributes.get("name") or "attachment")
response = StreamingHttpResponse(
storage_response["Body"].iter_chunks(chunk_size=8192),
status=206 if storage_response.get("ContentRange") else 200,
content_type=content_type,
)
response["Content-Disposition"] = f"{disposition}; filename*=UTF-8''{filename}"
response["Accept-Ranges"] = "bytes"
if storage_response.get("ContentLength") is not None:
response["Content-Length"] = storage_response["ContentLength"]
if storage_response.get("ContentRange"):
response["Content-Range"] = storage_response["ContentRange"]
return response

View File

@ -122,16 +122,6 @@ def get_chart_period_range(
return period_ranges.get(date_filter, None) return period_ranges.get(date_filter, None)
def set_default_issue_start_date(attrs: Dict[str, Any]) -> Dict[str, Any]:
target_date = attrs.get("target_date")
if attrs.get("start_date") is None and target_date is not None:
today = timezone.localdate()
if target_date >= today:
attrs["start_date"] = today
return attrs
def get_analytics_filters( def get_analytics_filters(
slug: str, slug: str,
user: User, user: User,

View File

@ -1,24 +0,0 @@
from django.conf import settings
def get_workspace_file_size_limit(workspace):
if not getattr(workspace, "storage_file_size_limit_enabled", True):
return None
limit = getattr(workspace, "storage_file_size_limit", None) or settings.FILE_SIZE_LIMIT
return max(1, int(limit))
def resolve_workspace_upload_size_limit(workspace, requested_size):
try:
requested_size = int(requested_size)
except (TypeError, ValueError):
requested_size = settings.FILE_SIZE_LIMIT
requested_size = max(1, requested_size)
workspace_limit = get_workspace_file_size_limit(workspace)
if workspace_limit is None:
return requested_size
return min(requested_size, workspace_limit)

View File

@ -1,6 +1,6 @@
(plane_proxy) { (plane_proxy) {
request_body { request_body {
max_size {$PROXY_BODY_SIZE_LIMIT:1073741824} max_size {$FILE_SIZE_LIMIT}
} }
handle /spaces/* { handle /spaces/* {

View File

@ -1,6 +1,6 @@
(plane_proxy) { (plane_proxy) {
request_body { request_body {
max_size {$PROXY_BODY_SIZE_LIMIT:1073741824} max_size {$FILE_SIZE_LIMIT}
} }
redir /spaces /spaces/ permanent redir /spaces /spaces/ permanent

View File

@ -23,7 +23,7 @@ import { useTranslation } from "@plane/i18n";
import { InboxIcon, PlusIcon, ProjectIcon } from "@plane/propel/icons"; import { InboxIcon, PlusIcon, ProjectIcon } from "@plane/propel/icons";
import { TOAST_TYPE, setToast } from "@plane/propel/toast"; import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { Tooltip } from "@plane/propel/tooltip"; import { Tooltip } from "@plane/propel/tooltip";
import { cn, copyUrlToClipboard, joinUrlPath } from "@plane/utils"; import { copyUrlToClipboard, joinUrlPath } from "@plane/utils";
import { TopNavPowerK } from "@/components/navigation"; import { TopNavPowerK } from "@/components/navigation";
import { useCommandPalette } from "@/hooks/store/use-command-palette"; import { useCommandPalette } from "@/hooks/store/use-command-palette";
import { useProject } from "@/hooks/store/use-project"; import { useProject } from "@/hooks/store/use-project";
@ -246,15 +246,9 @@ export const ProjectShellTopToolbar = observer(function ProjectShellTopToolbar()
}).sort((a, b) => a.sort_order - b.sort_order), }).sort((a, b) => a.sort_order - b.sort_order),
[pathname, workspacePreferences, workspaceSlug] [pathname, workspacePreferences, workspaceSlug]
); );
const workspaceSlugValue = workspaceSlug?.toString();
const isWorkspaceHome = pathname === `/${workspaceSlugValue}` || pathname === `/${workspaceSlugValue}/`;
return ( return (
<div <div className="z-20 w-full flex-shrink-0 px-4 pt-4 pb-3">
className={cn("z-20 w-full flex-shrink-0 px-4 pt-4 pb-3", {
"nodedc-home-top-toolbar": isWorkspaceHome,
})}
>
<div className="nodedc-glass-modal flex w-full flex-wrap items-center justify-between gap-4 rounded-[1.6rem] px-4 py-3"> <div className="nodedc-glass-modal flex w-full flex-wrap items-center justify-between gap-4 rounded-[1.6rem] px-4 py-3">
<div className="flex min-w-0 items-center gap-3"> <div className="flex min-w-0 items-center gap-3">
<div className="nodedc-toolbar-group relative flex items-center gap-1 overflow-visible"> <div className="nodedc-toolbar-group relative flex items-center gap-1 overflow-visible">

View File

@ -1,40 +0,0 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
// plane imports
import { WORKSPACE_SETTINGS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Breadcrumbs } from "@plane/ui";
// components
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
import { SettingsPageHeader } from "@/components/settings/page-header";
import { WORKSPACE_SETTINGS_ICONS } from "@/components/settings/workspace/sidebar/item-icon";
export const AIVoiceTaskerWorkspaceSettingsHeader = observer(function AIVoiceTaskerWorkspaceSettingsHeader() {
const { t } = useTranslation();
const settingsDetails = WORKSPACE_SETTINGS["ai-voice-tasker"];
const Icon = WORKSPACE_SETTINGS_ICONS["ai-voice-tasker"];
return (
<SettingsPageHeader
leftItem={
<div className="flex items-center gap-2">
<Breadcrumbs>
<Breadcrumbs.Item
component={
<BreadcrumbLink
label={t(settingsDetails.i18n_label)}
icon={<Icon className="size-4 text-tertiary" />}
/>
}
/>
</Breadcrumbs>
</div>
}
/>
);
});

View File

@ -1,31 +0,0 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
// components
import { PageHead } from "@/components/core/page-title";
import { SettingsContentWrapper } from "@/components/settings/content-wrapper";
import { AIVoiceTaskerSettingsContent } from "@/components/workspace/settings/ai-voice-tasker-settings";
// hooks
import { useWorkspace } from "@/hooks/store/use-workspace";
// local imports
import type { Route } from "./+types/page";
import { AIVoiceTaskerWorkspaceSettingsHeader } from "./header";
function AIVoiceTaskerSettingsPage({ params }: Route.ComponentProps) {
const { workspaceSlug } = params;
const { currentWorkspace } = useWorkspace();
const pageTitle = currentWorkspace?.name ? `${currentWorkspace.name} - AI / Voice Tasker` : undefined;
return (
<SettingsContentWrapper header={<AIVoiceTaskerWorkspaceSettingsHeader />}>
<PageHead title={pageTitle} />
<AIVoiceTaskerSettingsContent workspaceSlug={workspaceSlug} />
</SettingsContentWrapper>
);
}
export default observer(AIVoiceTaskerSettingsPage);

View File

@ -10,7 +10,6 @@ import { WorkspaceContentWrapper } from "@/plane-web/components/workspace/conten
import { AppRailVisibilityProvider } from "@/plane-web/hooks/app-rail"; import { AppRailVisibilityProvider } from "@/plane-web/hooks/app-rail";
import { GlobalModals } from "@/plane-web/components/common/modal/global"; import { GlobalModals } from "@/plane-web/components/common/modal/global";
import { WorkspaceAuthWrapper } from "@/layouts/auth-layout/workspace-wrapper"; import { WorkspaceAuthWrapper } from "@/layouts/auth-layout/workspace-wrapper";
import { WorkspaceSettingsModal } from "@/components/workspace/settings/workspace-settings-modal";
import type { Route } from "./+types/layout"; import type { Route } from "./+types/layout";
export default function WorkspaceLayout(props: Route.ComponentProps) { export default function WorkspaceLayout(props: Route.ComponentProps) {
@ -20,9 +19,8 @@ export default function WorkspaceLayout(props: Route.ComponentProps) {
<AuthenticationWrapper> <AuthenticationWrapper>
<WorkspaceAuthWrapper> <WorkspaceAuthWrapper>
<AppRailVisibilityProvider> <AppRailVisibilityProvider>
<WorkspaceContentWrapper workspaceSlug={workspaceSlug}> <WorkspaceContentWrapper>
<GlobalModals workspaceSlug={workspaceSlug} /> <GlobalModals workspaceSlug={workspaceSlug} />
<WorkspaceSettingsModal />
<Outlet /> <Outlet />
</WorkspaceContentWrapper> </WorkspaceContentWrapper>
</AppRailVisibilityProvider> </AppRailVisibilityProvider>

View File

@ -289,10 +289,6 @@ export const coreRoutes: RouteConfigEntry[] = [
":workspaceSlug/settings/webhooks/:webhookId", ":workspaceSlug/settings/webhooks/:webhookId",
"./(all)/[workspaceSlug]/(settings)/settings/(workspace)/webhooks/[webhookId]/page.tsx" "./(all)/[workspaceSlug]/(settings)/settings/(workspace)/webhooks/[webhookId]/page.tsx"
), ),
route(
":workspaceSlug/settings/ai-voice-tasker",
"./(all)/[workspaceSlug]/(settings)/settings/(workspace)/ai-voice-tasker/page.tsx"
),
]), ]),
// -------------------------------------------------------------------- // --------------------------------------------------------------------

View File

@ -14,11 +14,10 @@ type HomePageHeaderProps = {
selectedProject?: THomeProjectData; selectedProject?: THomeProjectData;
selectedProjectAnalytics?: TProjectAnalyticsCount; selectedProjectAnalytics?: TProjectAnalyticsCount;
recents?: TActivityEntityData[]; recents?: TActivityEntityData[];
workspaceName?: string;
}; };
export function HomePageHeader(props: HomePageHeaderProps) { export function HomePageHeader(props: HomePageHeaderProps) {
const { currentUser, selectedProject, selectedProjectAnalytics, recents, workspaceName } = props; const { currentUser, selectedProject, selectedProjectAnalytics, recents } = props;
const { currentLocale } = useTranslation(); const { currentLocale } = useTranslation();
const { currentTime } = useCurrentTime(); const { currentTime } = useCurrentTime();
@ -49,7 +48,6 @@ export function HomePageHeader(props: HomePageHeaderProps) {
{ label: "Открытые задачи", value: openIssues.toString(), caption: "в работе" }, { label: "Открытые задачи", value: openIssues.toString(), caption: "в работе" },
{ label: "Касания 7 дней", value: recentTouchpoints.toString(), caption: "recent" }, { label: "Касания 7 дней", value: recentTouchpoints.toString(), caption: "recent" },
]; ];
const workspaceDisplayName = workspaceName?.trim() || "Workspace";
return ( return (
<section className="nodedc-home-hero"> <section className="nodedc-home-hero">
@ -59,19 +57,26 @@ export function HomePageHeader(props: HomePageHeaderProps) {
</div> </div>
<div className="nodedc-home-hero-grid"> <div className="nodedc-home-hero-grid">
<div className="nodedc-home-hero-title-cell">
<h1>WORKSPACE HOME</h1>
<p>
{selectedProject
? `${selectedProject.identifier} в фокусе домашней сводки.`
: "Выберите проект для фокуса, Ганта и рабочей аналитики."}
</p>
</div>
<div className="nodedc-home-market-band"> <div className="nodedc-home-market-band">
<div className="min-w-0"> <div className="min-w-0">
<div className="text-12 font-semibold text-black/[0.58]">Фокус</div> <div className="text-12 font-semibold text-black/[0.58]">Фокус</div>
<div className="mt-1 flex min-w-0 items-center gap-3"> <div className="mt-1 flex min-w-0 items-center gap-3">
<div className="min-w-0"> <div className="min-w-0">
<div className="truncate text-24 leading-none font-semibold text-black nodedc-home-market-focus-title"> <div className="truncate text-24 leading-none font-semibold text-black">
{selectedProject?.name ?? "Workspace"} {selectedProject?.name ?? "Workspace"}
</div> </div>
{selectedProject?.identifier && ( <div className="mt-1 truncate text-12 font-medium text-black/[0.54]">
<div className="mt-1 truncate text-12 font-medium text-black/[0.54]"> {selectedProject?.description || selectedProject?.identifier || "Координационный обзор"}
{selectedProject.identifier} </div>
</div>
)}
</div> </div>
</div> </div>
</div> </div>
@ -81,7 +86,7 @@ export function HomePageHeader(props: HomePageHeaderProps) {
<div key={metric.label} className="min-w-0"> <div key={metric.label} className="min-w-0">
<div className="truncate text-12 font-medium text-black/[0.58]">{metric.label}</div> <div className="truncate text-12 font-medium text-black/[0.58]">{metric.label}</div>
<div className="mt-1 text-[26px] leading-none font-semibold text-black">{metric.value}</div> <div className="mt-1 text-[26px] leading-none font-semibold text-black">{metric.value}</div>
<div className="nodedc-home-market-progress"> <div className="mt-2 h-2 overflow-hidden rounded-full bg-black/[0.18]">
<div <div
className="h-full rounded-full bg-black" className="h-full rounded-full bg-black"
style={{ style={{
@ -97,11 +102,6 @@ export function HomePageHeader(props: HomePageHeaderProps) {
))} ))}
</div> </div>
</div> </div>
<div className="nodedc-home-hero-title-cell">
<div className="nodedc-home-hero-title-label">Workspace</div>
<h1>{workspaceDisplayName}</h1>
</div>
</div> </div>
</section> </section>
); );

View File

@ -10,9 +10,14 @@ import { CalendarDays } from "lucide-react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { EUserPermissions } from "@plane/constants"; import { EUserPermissions } from "@plane/constants";
import { useTranslation } from "@plane/i18n"; import { useTranslation } from "@plane/i18n";
import { PriorityIcon, StateGroupIcon, getStateGroupColor } from "@plane/propel/icons"; import { PriorityIcon, StateGroupIcon } from "@plane/propel/icons";
import { TOAST_TYPE, setToast } from "@plane/propel/toast"; import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { IState, TExternalContourBoardDirection, TExternalContourRequest, TIssue } from "@plane/types"; import type {
IState,
TExternalContourBoardDirection,
TExternalContourRequest,
TIssue,
} from "@plane/types";
import { Avatar } from "@plane/ui"; import { Avatar } from "@plane/ui";
import { cn, renderFormattedDate, renderFormattedPayloadDate } from "@plane/utils"; import { cn, renderFormattedDate, renderFormattedPayloadDate } from "@plane/utils";
import { DateDropdown } from "@/components/dropdowns/date"; import { DateDropdown } from "@/components/dropdowns/date";
@ -51,7 +56,7 @@ const buildSourceStateMap = (
state.id, state.id,
{ {
id: state.id, id: state.id,
color: getStateGroupColor(state.group, state.color), color: state.color,
default: false, default: false,
description: "", description: "",
group: state.group, group: state.group,
@ -64,10 +69,7 @@ const buildSourceStateMap = (
]) ])
); );
const resolveRequestStatus = ( const resolveRequestStatus = (issue: TExternalContourRequest["issue"], fallbackStatus: TExternalContourRequest["status"]) => {
issue: TExternalContourRequest["issue"],
fallbackStatus: TExternalContourRequest["status"]
) => {
const stateGroup = issue.state_detail?.group; const stateGroup = issue.state_detail?.group;
if (!stateGroup) return fallbackStatus; if (!stateGroup) return fallbackStatus;
return stateGroup === "completed" || stateGroup === "cancelled" ? "closed" : "open"; return stateGroup === "completed" || stateGroup === "cancelled" ? "closed" : "open";
@ -81,20 +83,23 @@ export const ExternalContoursBoardItem = observer(function ExternalContoursBoard
const { getUserDetails, workspace } = useMember(); const { getUserDetails, workspace } = useMember();
const { getProjectRoleByWorkspaceSlugAndProjectId } = useUserPermissions(); const { getProjectRoleByWorkspaceSlugAndProjectId } = useUserPermissions();
const { getStateById, getProjectStateIds } = useProjectState(); const { getStateById, getProjectStateIds } = useProjectState();
const { fetchBoard, upsertBoardItems } = useProjectExternalContoursBoard(); const {
const { fetchTargetOptions, getTargetOptionsByProjectId, updateRequest, updateRequestIssue } = fetchBoard,
useProjectExternalContours(); upsertBoardItems,
} = useProjectExternalContoursBoard();
const {
fetchTargetOptions,
getTargetOptionsByProjectId,
updateRequest,
updateRequestIssue,
} = useProjectExternalContours();
const [isUpdating, setIsUpdating] = useState(false); const [isUpdating, setIsUpdating] = useState(false);
const [isSourceOptionsLoading, setIsSourceOptionsLoading] = useState(false); const [isSourceOptionsLoading, setIsSourceOptionsLoading] = useState(false);
const issue = request.issue; const issue = request.issue;
const selectedInboxIssueId = searchParams.get("inboxIssueId"); const selectedInboxIssueId = searchParams.get("inboxIssueId");
const isActive = selectedInboxIssueId === request.id; const isActive = selectedInboxIssueId === request.id;
const requester = const requester = request.requested_by?.display_name || request.requested_by_name || issue.created_by_detail?.display_name || "NODE.DC";
request.requested_by?.display_name ||
request.requested_by_name ||
issue.created_by_detail?.display_name ||
"NODE.DC";
const requesterAvatar = issue.created_by_detail?.avatar_url || ""; const requesterAvatar = issue.created_by_detail?.avatar_url || "";
const counterpartContourName = const counterpartContourName =
direction === "outgoing" direction === "outgoing"
@ -105,12 +110,8 @@ export const ExternalContoursBoardItem = observer(function ExternalContoursBoard
? getProjectRoleByWorkspaceSlugAndProjectId(workspaceSlug, targetProjectId) ? getProjectRoleByWorkspaceSlugAndProjectId(workspaceSlug, targetProjectId)
: undefined; : undefined;
const canEditTargetIssue = const canEditTargetIssue =
direction === "incoming" && direction === "incoming" && !!targetProjectId && projectRole !== undefined && projectRole !== EUserPermissions.GUEST;
!!targetProjectId && const canEditSourceRequest = direction === "outgoing" && !!request.capabilities?.can_edit_request && !!targetProjectId;
projectRole !== undefined &&
projectRole !== EUserPermissions.GUEST;
const canEditSourceRequest =
direction === "outgoing" && !!request.capabilities?.can_edit_request && !!targetProjectId;
const canEditCard = canEditTargetIssue || canEditSourceRequest; const canEditCard = canEditTargetIssue || canEditSourceRequest;
const requestLink = `/${workspaceSlug}/projects/${projectId}/external-contours?inboxIssueId=${request.id}`; const requestLink = `/${workspaceSlug}/projects/${projectId}/external-contours?inboxIssueId=${request.id}`;
const targetOptions = getTargetOptionsByProjectId(targetProjectId); const targetOptions = getTargetOptionsByProjectId(targetProjectId);
@ -123,11 +124,12 @@ export const ExternalContoursBoardItem = observer(function ExternalContoursBoard
const projectStateIds = issue.project_id ? getProjectStateIds(issue.project_id) : []; const projectStateIds = issue.project_id ? getProjectStateIds(issue.project_id) : [];
const foregroundClasses = isActive ? "text-[rgb(var(--nodedc-on-card-active-rgb))]" : "text-white"; const foregroundClasses = isActive ? "text-[rgb(var(--nodedc-on-card-active-rgb))]" : "text-white";
const subtleTextClasses = isActive ? "text-[#2F4721]" : "text-[#B3B3B8]"; const subtleTextClasses = isActive ? "text-[#2F4721]" : "text-[#B3B3B8]";
const pillBackgroundClasses = isActive const pillBackgroundClasses =
? "bg-black/10 text-[rgb(var(--nodedc-on-card-active-rgb))]" isActive
: "bg-[rgb(var(--nodedc-card-passive-rgb))] text-white"; ? "bg-black/10 text-[rgb(var(--nodedc-on-card-active-rgb))]"
: "bg-[rgb(var(--nodedc-card-passive-rgb))] text-white";
const iconBubbleClasses = isActive ? "bg-black text-[rgb(var(--nodedc-card-active-rgb))]" : "bg-[#111214] text-white"; const iconBubbleClasses = isActive ? "bg-black text-[rgb(var(--nodedc-card-active-rgb))]" : "bg-[#111214] text-white";
const statusIconColor = getStateGroupColor(selectedState?.group, selectedState?.color); 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"); const dueDateLabel = issue.target_date ? renderFormattedDate(issue.target_date, "d MMM, yyyy") : t("common.none");
if (!issue) return null; if (!issue) return null;
@ -219,7 +221,6 @@ export const ExternalContoursBoardItem = observer(function ExternalContoursBoard
<div className="group/kanban-block relative mb-2"> <div className="group/kanban-block relative mb-2">
<div <div
data-active={isActive} data-active={isActive}
data-priority={issue.priority ?? "none"}
className="nodedc-external-card relative flex min-h-[220px] w-full cursor-pointer flex-col p-4 transition-all hover:bg-white/5" className="nodedc-external-card relative flex min-h-[220px] w-full cursor-pointer flex-col p-4 transition-all hover:bg-white/5"
role="button" role="button"
tabIndex={0} tabIndex={0}
@ -313,13 +314,13 @@ export const ExternalContoursBoardItem = observer(function ExternalContoursBoard
</div> </div>
</div> </div>
<div className={cn("-mt-0.5 truncate pl-8 text-[11px] leading-4 font-medium", subtleTextClasses)}> <div className={cn("truncate -mt-0.5 pl-8 text-[11px] font-medium leading-4", subtleTextClasses)}>
{counterpartContourName || t("common.none")} {counterpartContourName || t("common.none")}
</div> </div>
</div> </div>
<div className="flex flex-1 items-center justify-center px-5 py-4 text-center"> <div className="flex flex-1 items-center justify-center px-5 py-4 text-center">
<div className="text-lg line-clamp-4 max-w-full leading-6 font-semibold">{issue.name}</div> <div className="line-clamp-4 max-w-full text-lg font-semibold leading-6">{issue.name}</div>
</div> </div>
<div className="flex items-center justify-between gap-3" onClick={stopCardPropagation}> <div className="flex items-center justify-between gap-3" onClick={stopCardPropagation}>
@ -332,7 +333,7 @@ export const ExternalContoursBoardItem = observer(function ExternalContoursBoard
disabled={!canEditCard || isUpdating} disabled={!canEditCard || isUpdating}
buttonVariant="transparent-without-text" buttonVariant="transparent-without-text"
button={ button={
<div className={cn(basePillClasses, pillBackgroundClasses, "pr-2 pl-1")}> <div className={cn(basePillClasses, pillBackgroundClasses, "pl-1 pr-2")}>
<ButtonAvatars showTooltip={false} userIds={issue.assignee_ids ?? []} size="sm" /> <ButtonAvatars showTooltip={false} userIds={issue.assignee_ids ?? []} size="sm" />
</div> </div>
} }
@ -350,7 +351,7 @@ export const ExternalContoursBoardItem = observer(function ExternalContoursBoard
}} }}
buttonVariant="transparent-without-text" buttonVariant="transparent-without-text"
button={ button={
<div className={cn(basePillClasses, pillBackgroundClasses, "pr-2 pl-1")}> <div className={cn(basePillClasses, pillBackgroundClasses, "pl-1 pr-2")}>
<ButtonAvatars showTooltip={false} userIds={issue.assignee_ids ?? []} size="sm" /> <ButtonAvatars showTooltip={false} userIds={issue.assignee_ids ?? []} size="sm" />
</div> </div>
} }

View File

@ -17,7 +17,6 @@ import {
StateGroupIcon, StateGroupIcon,
StatePropertyIcon, StatePropertyIcon,
UserCirclePropertyIcon, UserCirclePropertyIcon,
getStateGroupColor,
} from "@plane/propel/icons"; } from "@plane/propel/icons";
import type { import type {
IProject, IProject,
@ -58,11 +57,14 @@ const buildCounterpartyProjects = (requests: TExternalContourRequest[], projectI
if (!project?.id || project.id === projectId || projectMap.has(project.id)) return; if (!project?.id || project.id === projectId || projectMap.has(project.id)) return;
projectMap.set(project.id, { projectMap.set(
id: project.id, project.id,
name: project.name, {
logo_props: project.logo_props, id: project.id,
} as IProject); name: project.name,
logo_props: project.logo_props,
} as IProject
);
}); });
return sortByName(Array.from(projectMap.values())); return sortByName(Array.from(projectMap.values()));
@ -75,18 +77,21 @@ const buildStates = (requests: TExternalContourRequest[]): IState[] => {
const state = request.issue.state_detail; const state = request.issue.state_detail;
if (!state?.id || stateMap.has(state.id)) return; if (!state?.id || stateMap.has(state.id)) return;
stateMap.set(state.id, { stateMap.set(
id: state.id, state.id,
color: getStateGroupColor(state.group, state.color), {
default: false, id: state.id,
description: "", color: state.color,
group: state.group, default: false,
name: state.name, description: "",
order: index + 1, group: state.group,
project_id: request.issue.project_id || request.target_project?.id || request.source_project?.id || "", name: state.name,
sequence: index + 1, order: index + 1,
workspace_id: "", project_id: request.issue.project_id || request.target_project?.id || request.source_project?.id || "",
} as IState); sequence: index + 1,
workspace_id: "",
} as IState
);
}); });
return sortByName(Array.from(stateMap.values())); return sortByName(Array.from(stateMap.values()));
@ -98,15 +103,18 @@ const buildLabels = (requests: TExternalContourRequest[]): IIssueLabel[] => {
requests.forEach((request) => { requests.forEach((request) => {
request.issue.label_details?.forEach((label) => { request.issue.label_details?.forEach((label) => {
if (!label.id || labelMap.has(label.id)) return; if (!label.id || labelMap.has(label.id)) return;
labelMap.set(label.id, { labelMap.set(
id: label.id, label.id,
color: label.color, {
name: label.name, id: label.id,
parent: null, color: label.color,
project_id: request.issue.project_id || "", name: label.name,
sort_order: 0, parent: null,
workspace_id: "", project_id: request.issue.project_id || "",
} as IIssueLabel); sort_order: 0,
workspace_id: "",
} as IIssueLabel
);
}); });
}); });
@ -119,11 +127,14 @@ const buildAssignees = (requests: TExternalContourRequest[]): IUserLite[] => {
requests.forEach((request) => { requests.forEach((request) => {
request.issue.assignee_details?.forEach((assignee) => { request.issue.assignee_details?.forEach((assignee) => {
if (!assignee.id || memberMap.has(assignee.id)) return; if (!assignee.id || memberMap.has(assignee.id)) return;
memberMap.set(assignee.id, { memberMap.set(
id: assignee.id, assignee.id,
avatar_url: assignee.avatar_url, {
display_name: assignee.display_name, id: assignee.id,
} as IUserLite); avatar_url: assignee.avatar_url,
display_name: assignee.display_name,
} as IUserLite
);
}); });
}); });
@ -140,11 +151,14 @@ const buildRequesters = (requests: TExternalContourRequest[]): IUserLite[] => {
if (!requesterId || !requesterName || memberMap.has(requesterId)) return; if (!requesterId || !requesterName || memberMap.has(requesterId)) return;
memberMap.set(requesterId, { memberMap.set(
id: requesterId, requesterId,
avatar_url: request.issue.created_by_detail?.avatar_url, {
display_name: requesterName, id: requesterId,
} as IUserLite); avatar_url: request.issue.created_by_detail?.avatar_url,
display_name: requesterName,
} as IUserLite
);
}); });
return sortByName(Array.from(memberMap.values())); return sortByName(Array.from(memberMap.values()));

View File

@ -59,7 +59,6 @@ export const ExternalContoursListItem = observer(function ExternalContoursListIt
> >
<div <div
data-active={isActive} data-active={isActive}
data-priority={issue.priority ?? "none"}
className={cn( className={cn(
"nodedc-external-card relative flex min-h-[15rem] cursor-pointer flex-col gap-5 px-6 py-5 transition-all hover:bg-white/5", "nodedc-external-card relative flex min-h-[15rem] cursor-pointer flex-col gap-5 px-6 py-5 transition-all hover:bg-white/5",
{ "ring-0": isActive } { "ring-0": isActive }

View File

@ -10,16 +10,13 @@ import { observer } from "mobx-react";
import { cn } from "@plane/utils"; import { cn } from "@plane/utils";
import { AppRailRoot } from "@/components/navigation"; import { AppRailRoot } from "@/components/navigation";
import { useAppRailVisibility } from "@/lib/app-rail"; import { useAppRailVisibility } from "@/lib/app-rail";
import { VoiceTaskerGlobalControl } from "@/components/voice-tasker/global-control";
// local imports // local imports
import { TopNavigationRoot } from "../navigations"; import { TopNavigationRoot } from "../navigations";
export const WorkspaceContentWrapper = observer(function WorkspaceContentWrapper({ export const WorkspaceContentWrapper = observer(function WorkspaceContentWrapper({
children, children,
workspaceSlug,
}: { }: {
children: React.ReactNode; children: React.ReactNode;
workspaceSlug?: string;
}) { }) {
// Use the context to determine if app rail should render // Use the context to determine if app rail should render
const { shouldRenderAppRail } = useAppRailVisibility(); const { shouldRenderAppRail } = useAppRailVisibility();
@ -40,7 +37,6 @@ export const WorkspaceContentWrapper = observer(function WorkspaceContentWrapper
> >
{children} {children}
</div> </div>
{workspaceSlug && <VoiceTaskerGlobalControl workspaceSlug={workspaceSlug} />}
</div> </div>
</div> </div>
); );

View File

@ -8,30 +8,16 @@
import { MAX_FILE_SIZE } from "@plane/constants"; import { MAX_FILE_SIZE } from "@plane/constants";
// hooks // hooks
import { useInstance } from "@/hooks/store/use-instance"; import { useInstance } from "@/hooks/store/use-instance";
import { useWorkspace } from "@/hooks/store/use-workspace";
type TReturnProps = { type TReturnProps = {
maxFileSize: number; maxFileSize: number;
fileSizeLimitEnabled: boolean;
}; };
export const useFileSize = (): TReturnProps => { export const useFileSize = (): TReturnProps => {
// store hooks // store hooks
const { config } = useInstance(); const { config } = useInstance();
const { currentWorkspace } = useWorkspace();
const workspaceLimitEnabled = currentWorkspace?.storage_file_size_limit_enabled ?? true;
const workspaceLimit = currentWorkspace?.storage_file_size_limit;
const fallbackLimit = config?.file_size_limit ?? MAX_FILE_SIZE;
const maxFileSize =
workspaceLimitEnabled && workspaceLimit && workspaceLimit > 0
? workspaceLimit
: workspaceLimitEnabled
? fallbackLimit
: Number.MAX_SAFE_INTEGER;
return { return {
maxFileSize, maxFileSize: config?.file_size_limit ?? MAX_FILE_SIZE,
fileSizeLimitEnabled: workspaceLimitEnabled,
}; };
}; };

View File

@ -22,9 +22,7 @@ function AnalyticsWrapper(props: Props) {
<div className={cn("flex flex-col gap-4 pb-5", className)}> <div className={cn("flex flex-col gap-4 pb-5", className)}>
<div className="nodedc-external-panel flex items-center justify-between gap-3 px-5 py-4 md:px-6"> <div className="nodedc-external-panel flex items-center justify-between gap-3 px-5 py-4 md:px-6">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<span className="text-11 font-medium tracking-[0.24em] text-secondary uppercase"> <span className="text-11 font-medium tracking-[0.24em] text-secondary uppercase">Analytics</span>
{t("workspace_analytics.label")}
</span>
<h1 className="text-20 font-semibold text-primary md:text-[1.45rem]">{t(i18nTitle)}</h1> <h1 className="text-20 font-semibold text-primary md:text-[1.45rem]">{t(i18nTitle)}</h1>
</div> </div>
</div> </div>

View File

@ -65,18 +65,18 @@ export function DataTable<TData, TValue>({ columns, data, searchPlaceholder, act
}); });
return ( return (
<div className="nodedc-analytics-table space-y-4"> <div className="space-y-4">
<div className="nodedc-analytics-table-toolbar flex w-full items-center justify-between"> <div className="flex w-full items-center justify-between">
<div className="relative flex max-w-[300px] items-center gap-4"> <div className="relative flex max-w-[300px] items-center gap-4">
{table.getHeaderGroups()?.[0]?.headers?.[0]?.id && ( {table.getHeaderGroups()?.[0]?.headers?.[0]?.id && (
<div className="nodedc-analytics-table-count flex items-center gap-2 text-13 whitespace-nowrap text-placeholder"> <div className="flex items-center gap-2 text-13 whitespace-nowrap text-placeholder">
{searchPlaceholder} {searchPlaceholder}
</div> </div>
)} )}
{!isSearchOpen && ( {!isSearchOpen && (
<button <button
type="button" type="button"
className="nodedc-analytics-search-button -mr-5 grid place-items-center rounded-sm p-2 text-placeholder hover:bg-layer-1" className="-mr-5 grid place-items-center rounded-sm p-2 text-placeholder hover:bg-layer-1"
onClick={() => { onClick={() => {
setIsSearchOpen(true); setIsSearchOpen(true);
inputRef.current?.focus(); inputRef.current?.focus();
@ -87,7 +87,7 @@ export function DataTable<TData, TValue>({ columns, data, searchPlaceholder, act
)} )}
<div <div
className={cn( className={cn(
"nodedc-analytics-search-field mr-auto flex w-0 items-center justify-start gap-1 overflow-hidden rounded-md border border-transparent bg-surface-1 text-placeholder opacity-0 transition-[width] ease-linear", "mr-auto flex w-0 items-center justify-start gap-1 overflow-hidden rounded-md border border-transparent bg-surface-1 text-placeholder opacity-0 transition-[width] ease-linear",
{ {
"w-64 border-subtle px-2.5 py-1.5 opacity-100": isSearchOpen, "w-64 border-subtle px-2.5 py-1.5 opacity-100": isSearchOpen,
} }
@ -97,7 +97,7 @@ export function DataTable<TData, TValue>({ columns, data, searchPlaceholder, act
<input <input
ref={inputRef} ref={inputRef}
className="w-full max-w-[234px] border-none bg-transparent text-13 text-primary placeholder:text-placeholder focus:outline-none" className="w-full max-w-[234px] border-none bg-transparent text-13 text-primary placeholder:text-placeholder focus:outline-none"
placeholder="Поиск" placeholder="Search"
value={table.getColumn(table.getHeaderGroups()?.[0]?.headers?.[0]?.id)?.getFilterValue() as string} value={table.getColumn(table.getHeaderGroups()?.[0]?.headers?.[0]?.id)?.getFilterValue() as string}
onChange={(e) => { onChange={(e) => {
const columnId = table.getHeaderGroups()?.[0]?.headers?.[0]?.id; const columnId = table.getHeaderGroups()?.[0]?.headers?.[0]?.id;
@ -129,7 +129,7 @@ export function DataTable<TData, TValue>({ columns, data, searchPlaceholder, act
{actions && <div>{actions(table)}</div>} {actions && <div>{actions(table)}</div>}
</div> </div>
<div className="nodedc-analytics-table-surface rounded-md"> <div className="rounded-md">
<Table> <Table>
<TableHeader> <TableHeader>
{table.getHeaderGroups().map((headerGroup) => ( {table.getHeaderGroups().map((headerGroup) => (

View File

@ -39,7 +39,6 @@ export function InsightTable<T extends Exclude<TAnalyticsTabsBase, "overview">>(
actions={(table: Table<AnalyticsTableDataMap[T]>) => ( actions={(table: Table<AnalyticsTableDataMap[T]>) => (
<Button <Button
variant="secondary" variant="secondary"
className="nodedc-analytics-export-button"
prependIcon={<Download className="h-3.5 w-3.5" />} prependIcon={<Download className="h-3.5 w-3.5" />}
onClick={() => onExport?.(table.getFilteredRowModel().rows)} onClick={() => onExport?.(table.getFilteredRowModel().rows)}
> >

View File

@ -1,93 +0,0 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { ChartXAxisProperty, ChartYAxisMetric } from "@plane/types";
const X_AXIS_LABELS: Partial<Record<ChartXAxisProperty, string>> = {
[ChartXAxisProperty.STATES]: "Статус",
[ChartXAxisProperty.STATE_GROUPS]: "Группа статуса",
[ChartXAxisProperty.PRIORITY]: "Приоритет",
[ChartXAxisProperty.LABELS]: "Метка",
[ChartXAxisProperty.ASSIGNEES]: "Исполнитель",
[ChartXAxisProperty.ESTIMATE_POINTS]: "Оценка",
[ChartXAxisProperty.CYCLES]: "Цикл",
[ChartXAxisProperty.MODULES]: "Модуль",
[ChartXAxisProperty.COMPLETED_AT]: "Дата завершения",
[ChartXAxisProperty.TARGET_DATE]: "Срок",
[ChartXAxisProperty.START_DATE]: "Дата начала",
[ChartXAxisProperty.CREATED_AT]: "Дата создания",
[ChartXAxisProperty.CREATED_BY]: "Автор",
[ChartXAxisProperty.WORK_ITEM_TYPES]: "Тип задачи",
[ChartXAxisProperty.PROJECTS]: "Проект",
[ChartXAxisProperty.EPICS]: "Эпик",
};
const Y_AXIS_LABELS: Partial<Record<ChartYAxisMetric, string>> = {
[ChartYAxisMetric.WORK_ITEM_COUNT]: "Количество задач",
[ChartYAxisMetric.ESTIMATE_POINT_COUNT]: "Оценка",
[ChartYAxisMetric.PENDING_WORK_ITEM_COUNT]: "Ожидающие задачи",
[ChartYAxisMetric.COMPLETED_WORK_ITEM_COUNT]: "Завершённые задачи",
[ChartYAxisMetric.IN_PROGRESS_WORK_ITEM_COUNT]: "Задачи в процессе",
[ChartYAxisMetric.WORK_ITEM_DUE_THIS_WEEK_COUNT]: "Срок на этой неделе",
[ChartYAxisMetric.WORK_ITEM_DUE_TODAY_COUNT]: "Срок сегодня",
[ChartYAxisMetric.BLOCKED_WORK_ITEM_COUNT]: "Заблокированные задачи",
[ChartYAxisMetric.EPIC_WORK_ITEM_COUNT]: "Количество эпиков",
};
const VALUE_LABELS: Record<string, string> = {
none: "Нет",
null: "Нет",
undefined: "Нет",
unassigned: "Без исполнителя",
urgent: "Срочно",
high: "Высокий",
medium: "Средний",
low: "Низкий",
backlog: "Бэклог",
unstarted: "Туду",
started: "В процессе",
completed: "Готово",
cancelled: "Отменено",
canceled: "Отменено",
"no priority": "Без приоритета",
"no value": "Нет",
"state name": "Статус",
"state group": "Группа статуса",
priority: "Приоритет",
label: "Метка",
assignee: "Исполнитель",
"estimate point": "Оценка",
cycle: "Цикл",
module: "Модуль",
"completed date": "Дата завершения",
"due date": "Срок",
"start date": "Дата начала",
"created date": "Дата создания",
count: "Количество",
};
const DURATION_LABELS: Record<string, string> = {
yesterday: "Вчера",
last_7_days: "Последние 7 дней",
last_30_days: "Последние 30 дней",
last_3_months: "Последние 3 месяца",
};
export const getAnalyticsXAxisLabel = (value: ChartXAxisProperty | string, fallback?: string) =>
X_AXIS_LABELS[value as ChartXAxisProperty] ?? fallback ?? String(value);
export const getAnalyticsYAxisLabel = (value: ChartYAxisMetric | string, fallback?: string) =>
Y_AXIS_LABELS[value as ChartYAxisMetric] ?? fallback ?? String(value);
export const getAnalyticsDurationLabel = (value: string, fallback?: string) =>
DURATION_LABELS[value] ?? fallback ?? value;
export const getAnalyticsValueLabel = (value: string | number | null | undefined) => {
if (value === null || value === undefined || value === "") return "Нет";
const stringValue = String(value);
return VALUE_LABELS[stringValue.trim().toLowerCase()] ?? stringValue;
};

View File

@ -77,7 +77,7 @@ const ProjectInsights = observer(function ProjectInsights() {
radars={[ radars={[
{ {
key: "count", key: "count",
name: "Количество", name: "Count",
fill: "var(--text-color-accent-primary)", fill: "var(--text-color-accent-primary)",
stroke: "var(--text-color-accent-primary)", stroke: "var(--text-color-accent-primary)",
fillOpacity: 0.6, fillOpacity: 0.6,

View File

@ -16,7 +16,6 @@ import type { IAnalyticsParams } from "@plane/types";
import { ChartYAxisMetric } from "@plane/types"; import { ChartYAxisMetric } from "@plane/types";
import { cn } from "@plane/utils"; import { cn } from "@plane/utils";
// plane web components // plane web components
import { getAnalyticsXAxisLabel, getAnalyticsYAxisLabel } from "../labels";
import { SelectXAxis } from "./select-x-axis"; import { SelectXAxis } from "./select-x-axis";
import { SelectYAxis } from "./select-y-axis"; import { SelectYAxis } from "./select-y-axis";
@ -32,33 +31,17 @@ type Props = {
export const AnalyticsSelectParams = observer(function AnalyticsSelectParams(props: Props) { export const AnalyticsSelectParams = observer(function AnalyticsSelectParams(props: Props) {
const { control, params, classNames, isEpic } = props; const { control, params, classNames, isEpic } = props;
const xAxisOptions = useMemo( const xAxisOptions = useMemo(
() => () => ANALYTICS_X_AXIS_VALUES.filter((option) => option.value !== params.group_by),
ANALYTICS_X_AXIS_VALUES.filter((option) => option.value !== params.group_by).map((option) => ({
...option,
label: getAnalyticsXAxisLabel(option.value, option.label),
})),
[params.group_by] [params.group_by]
); );
const groupByOptions = useMemo( const groupByOptions = useMemo(
() => () => ANALYTICS_X_AXIS_VALUES.filter((option) => option.value !== params.x_axis),
ANALYTICS_X_AXIS_VALUES.filter((option) => option.value !== params.x_axis).map((option) => ({
...option,
label: getAnalyticsXAxisLabel(option.value, option.label),
})),
[params.x_axis] [params.x_axis]
); );
const yAxisOptions = useMemo(
() =>
ANALYTICS_Y_AXIS_VALUES.map((option) => ({
...option,
label: getAnalyticsYAxisLabel(option.value, option.label),
})),
[]
);
return ( return (
<div className={cn("flex w-full justify-between", classNames)}> <div className={cn("flex w-full justify-between", classNames)}>
<div className="nodedc-analytics-filter-row flex items-center gap-2"> <div className={`flex items-center gap-2`}>
<Controller <Controller
name="y_axis" name="y_axis"
control={control} control={control}
@ -68,7 +51,7 @@ export const AnalyticsSelectParams = observer(function AnalyticsSelectParams(pro
onChange={(val: ChartYAxisMetric | null) => { onChange={(val: ChartYAxisMetric | null) => {
onChange(val); onChange(val);
}} }}
options={yAxisOptions} options={ANALYTICS_Y_AXIS_VALUES}
hiddenOptions={[ hiddenOptions={[
ChartYAxisMetric.ESTIMATE_POINT_COUNT, ChartYAxisMetric.ESTIMATE_POINT_COUNT,
isEpic ? ChartYAxisMetric.WORK_ITEM_COUNT : ChartYAxisMetric.EPIC_WORK_ITEM_COUNT, isEpic ? ChartYAxisMetric.WORK_ITEM_COUNT : ChartYAxisMetric.EPIC_WORK_ITEM_COUNT,
@ -89,7 +72,7 @@ export const AnalyticsSelectParams = observer(function AnalyticsSelectParams(pro
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<CalendarLayoutIcon className="h-3 w-3" /> <CalendarLayoutIcon className="h-3 w-3" />
<span className={cn("text-secondary", value && "text-primary")}> <span className={cn("text-secondary", value && "text-primary")}>
{xAxisOptions.find((v) => v.value === value)?.label || "Добавить поле"} {xAxisOptions.find((v) => v.value === value)?.label || "Add Property"}
</span> </span>
</div> </div>
} }
@ -110,12 +93,12 @@ export const AnalyticsSelectParams = observer(function AnalyticsSelectParams(pro
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<SlidersHorizontal className="h-3 w-3" /> <SlidersHorizontal className="h-3 w-3" />
<span className={cn("text-secondary", value && "text-primary")}> <span className={cn("text-secondary", value && "text-primary")}>
{groupByOptions.find((v) => v.value === value)?.label || "Группировка"} {groupByOptions.find((v) => v.value === value)?.label || "Add Property"}
</span> </span>
</div> </div>
} }
options={groupByOptions} options={groupByOptions}
placeholder="Группировка" placeholder="Group By"
allowNoValue allowNoValue
/> />
)} )}

View File

@ -14,7 +14,6 @@ import { useTranslation } from "@plane/i18n";
// types // types
import { SelectionDropdown } from "@/components/common/selection-dropdown"; import { SelectionDropdown } from "@/components/common/selection-dropdown";
import type { TDropdownProps } from "@/components/dropdowns/types"; import type { TDropdownProps } from "@/components/dropdowns/types";
import { getAnalyticsDurationLabel } from "../labels";
type Props = TDropdownProps & { type Props = TDropdownProps & {
value: string | null; value: string | null;
@ -28,12 +27,12 @@ type Props = TDropdownProps & {
tabIndex?: number; tabIndex?: number;
}; };
function DurationDropdown({ placeholder = "Период", onChange, value }: Props) { function DurationDropdown({ placeholder = "Duration", onChange, value }: Props) {
useTranslation(); useTranslation();
const options = ANALYTICS_DURATION_FILTER_OPTIONS.map((option) => ({ const options = ANALYTICS_DURATION_FILTER_OPTIONS.map((option) => ({
key: option.value, key: option.value,
title: getAnalyticsDurationLabel(option.value, option.name), title: option.name,
isChecked: value === option.value, isChecked: value === option.value,
onClick: () => onChange(option.value), onClick: () => onChange(option.value),
})); }));
@ -43,16 +42,10 @@ function DurationDropdown({ placeholder = "Период", onChange, value }: Pro
menuButton={ menuButton={
<div className="flex items-center gap-2 p-1"> <div className="flex items-center gap-2 p-1">
<Calendar className="h-4 w-4" /> <Calendar className="h-4 w-4" />
{value {value ? ANALYTICS_DURATION_FILTER_OPTIONS.find((opt) => opt.value === value)?.name : placeholder}
? getAnalyticsDurationLabel(
value,
ANALYTICS_DURATION_FILTER_OPTIONS.find((opt) => opt.value === value)?.name
)
: placeholder}
</div> </div>
} }
menuButtonWrapperClassName="nodedc-analytics-select-trigger" menuButtonWrapperClassName="flex items-center rounded-full border-0 outline-none"
dropdownClassName="nodedc-analytics-dropdown"
/> />
); );
} }

View File

@ -58,13 +58,13 @@ export const ProjectSelect = observer(function ProjectSelect(props: Props) {
> >
<ProjectIcon className="h-4 w-4" /> <ProjectIcon className="h-4 w-4" />
{value && value.length > 3 {value && value.length > 3
? `3+ проекта` ? `3+ projects`
: value && value.length > 0 : value && value.length > 0
? projectIds ? projectIds
?.filter((p) => value.includes(p)) ?.filter((p) => value.includes(p))
.map((p) => getProjectById(p)?.name) .map((p) => getProjectById(p)?.name)
.join(", ") .join(", ")
: "Все проекты"} : "All projects"}
<ChevronDownIcon className="h-3 w-3" aria-hidden="true" /> <ChevronDownIcon className="h-3 w-3" aria-hidden="true" />
</div> </div>
)} )}

View File

@ -19,18 +19,16 @@ type Props = {
}; };
export function SelectXAxis(props: Props) { export function SelectXAxis(props: Props) {
const { value, onChange, options, placeholder = "Выбрать", hiddenOptions, allowNoValue, label } = props; const { value, onChange, options, hiddenOptions, allowNoValue, label } = props;
return ( return (
<SelectionDropdown <SelectionDropdown
menuButton={label ?? placeholder} menuButton={label ?? "Select"}
menuButtonWrapperClassName="nodedc-analytics-select-trigger"
dropdownClassName="nodedc-analytics-dropdown"
options={[ options={[
...(allowNoValue ...(allowNoValue
? [ ? [
{ {
key: "__none__", key: "__none__",
title: "Без группировки", title: "No value",
isChecked: value == null, isChecked: value == null,
onClick: () => onChange(null), onClick: () => onChange(null),
}, },

View File

@ -45,12 +45,10 @@ export const SelectYAxis = observer(function SelectYAxis({ value, onChange, hidd
return ( return (
<SelectionDropdown <SelectionDropdown
menuButtonWrapperClassName="nodedc-analytics-select-trigger"
dropdownClassName="nodedc-analytics-dropdown"
menuButton={ menuButton={
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ProjectIcon className="h-3 w-3" /> <ProjectIcon className="h-3 w-3" />
<span>{options.find((v) => v.value === value)?.label ?? "Выбрать метрику"}</span> <span>{options.find((v) => v.value === value)?.label ?? "Add Metric"}</span>
</div> </div>
} }
options={options options={options

View File

@ -22,29 +22,6 @@ import InsightCard from "./insight-card";
const analyticsService = new AnalyticsService(); const analyticsService = new AnalyticsService();
const WORK_ITEM_SUMMARY_FIELDS: IInsightField[] = [
{
key: "total_work_items",
i18nKey: "workspace_analytics.total",
},
{
key: "started_work_items",
i18nKey: "workspace_analytics.started_work_items",
},
{
key: "backlog_work_items",
i18nKey: "workspace_analytics.backlog_work_items",
},
{
key: "un_started_work_items",
i18nKey: "workspace_analytics.un_started_work_items",
},
{
key: "completed_work_items",
i18nKey: "workspace_analytics.completed_work_items",
},
];
const getInsightLabel = ( const getInsightLabel = (
analyticsType: TAnalyticsTabsBase, analyticsType: TAnalyticsTabsBase,
item: IInsightField, item: IInsightField,
@ -84,13 +61,6 @@ const TotalInsights = observer(function TotalInsights({
const workspaceSlug = params.workspaceSlug.toString(); const workspaceSlug = params.workspaceSlug.toString();
const { t } = useTranslation(); const { t } = useTranslation();
const { selectedDuration, selectedProjects, selectedCycle, selectedModule, isPeekView, isEpic } = useAnalytics(); const { selectedDuration, selectedProjects, selectedCycle, selectedModule, isPeekView, isEpic } = useAnalytics();
const insightFields =
analyticsType === "work-items"
? ANALYTICS_INSIGHTS_FIELDS[analyticsType]?.length
? ANALYTICS_INSIGHTS_FIELDS[analyticsType]
: WORK_ITEM_SUMMARY_FIELDS
: (ANALYTICS_INSIGHTS_FIELDS[analyticsType] ?? []);
const shouldUseSummaryCard = analyticsType === "work-items";
const { data: totalInsightsData, isLoading } = useSWR( const { data: totalInsightsData, isLoading } = useSWR(
`total-insights-${analyticsType}-${selectedDuration}-${selectedProjects}-${selectedCycle}-${selectedModule}-${isEpic}`, `total-insights-${analyticsType}-${selectedDuration}-${selectedProjects}-${selectedCycle}-${selectedModule}-${isEpic}`,
() => () =>
@ -107,38 +77,18 @@ const TotalInsights = observer(function TotalInsights({
isPeekView isPeekView
) )
); );
if (shouldUseSummaryCard) {
return (
<section
className={cn("nodedc-analytics-summary-card", peekView && "nodedc-analytics-summary-card-peek")}
aria-label="Сводка аналитики"
>
{insightFields.map((item) => (
<div
key={`${analyticsType}-${item.key}`}
className="nodedc-analytics-summary-item flex min-h-[5.75rem] min-w-0 flex-col justify-between gap-3 px-4 py-3.5"
>
<div className="nodedc-analytics-summary-label text-[0.72rem] font-medium text-secondary">
{getInsightLabel(analyticsType, item, isEpic, t)}
</div>
<div className="nodedc-analytics-summary-value text-[1.55rem] font-semibold text-primary">
{totalInsightsData?.[item.key]?.count ?? 0}
</div>
</div>
))}
</section>
);
}
return ( return (
<div <div
className={cn( className={cn(
"grid grid-cols-1 gap-8 sm:grid-cols-2 md:gap-10", "grid grid-cols-1 gap-8 sm:grid-cols-2 md:gap-10",
!peekView ? (insightFields.length % 5 === 0 ? "gap-10 lg:grid-cols-5" : "gap-8 lg:grid-cols-4") : "grid-cols-2" !peekView
? ANALYTICS_INSIGHTS_FIELDS[analyticsType]?.length % 5 === 0
? "gap-10 lg:grid-cols-5"
: "gap-8 lg:grid-cols-4"
: "grid-cols-2"
)} )}
> >
{insightFields.map((item) => ( {ANALYTICS_INSIGHTS_FIELDS[analyticsType]?.map((item) => (
<InsightCard <InsightCard
key={`${analyticsType}-${item.key}`} key={`${analyticsType}-${item.key}`}
isLoading={isLoading} isLoading={isLoading}

View File

@ -65,24 +65,24 @@ const CreatedVsResolved = observer(function CreatedVsResolved() {
() => [ () => [
{ {
key: "completed_issues", key: "completed_issues",
label: "Решено", label: "Resolved",
fill: "rgba(195, 255, 102, 0.18)", fill: "#19803833",
fillOpacity: 1, fillOpacity: 1,
stackId: "bar-one", stackId: "bar-one",
showDot: false, showDot: false,
smoothCurves: true, smoothCurves: true,
strokeColor: "#C3FF66", strokeColor: "#198038",
strokeOpacity: 1, strokeOpacity: 1,
}, },
{ {
key: "created_issues", key: "created_issues",
label: "Создано", label: "Created",
fill: "rgba(245, 247, 251, 0.14)", fill: "#1192E833",
fillOpacity: 1, fillOpacity: 1,
stackId: "bar-one", stackId: "bar-one",
showDot: false, showDot: false,
smoothCurves: true, smoothCurves: true,
strokeColor: "#F5F7FB", strokeColor: "#1192E8",
strokeOpacity: 1, strokeOpacity: 1,
}, },
], ],

View File

@ -26,7 +26,7 @@ export const WorkItemsModalHeader = observer(function WorkItemsModalHeader(props
return ( return (
<div className="flex items-center justify-between gap-4 bg-surface-1 px-5 py-4 text-13"> <div className="flex items-center justify-between gap-4 bg-surface-1 px-5 py-4 text-13">
<h3 className="break-words"> <h3 className="break-words">
Аналитика: {title} {cycle && `в цикле ${cycle.name}`} {module && `в модуле ${module.name}`} Analytics for {title} {cycle && `in ${cycle.name}`} {module && `in ${module.name}`}
</h3> </h3>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button

View File

@ -8,27 +8,27 @@ import { useMemo } from "react";
import type { ColumnDef, Row, RowData, Table } from "@tanstack/react-table"; import type { ColumnDef, Row, RowData, Table } from "@tanstack/react-table";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { useTheme } from "next-themes";
import useSWR from "swr"; import useSWR from "swr";
// plane package imports // plane package imports
import { Download } from "lucide-react"; import { Download } from "lucide-react";
import type { ChartXAxisDateGrouping } from "@plane/constants"; import type { ChartXAxisDateGrouping } from "@plane/constants";
import { EChartModels } from "@plane/constants"; import { ANALYTICS_X_AXIS_VALUES, ANALYTICS_Y_AXIS_VALUES, CHART_COLOR_PALETTES, EChartModels } from "@plane/constants";
import { useTranslation } from "@plane/i18n"; import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button"; import { Button } from "@plane/propel/button";
import { BarChart } from "@plane/propel/charts/bar-chart"; import { BarChart } from "@plane/propel/charts/bar-chart";
import { EmptyStateCompact } from "@plane/propel/empty-state"; import { EmptyStateCompact } from "@plane/propel/empty-state";
import type { TBarItem, TChart, TChartDatum, ChartXAxisProperty, ChartYAxisMetric } from "@plane/types"; import type { TBarItem, TChart, TChartDatum, ChartXAxisProperty, ChartYAxisMetric } from "@plane/types";
// plane web components // plane web components
import { parseChartData } from "@/components/chart/utils"; import { generateExtendedColors, parseChartData } from "@/components/chart/utils";
// hooks // hooks
import { useAnalytics } from "@/hooks/store/use-analytics"; import { useAnalytics } from "@/hooks/store/use-analytics";
import { useProjectState } from "@/hooks/store/use-project-state"; import { useProjectState } from "@/hooks/store/use-project-state";
import { AnalyticsService } from "@/services/analytics.service"; import { AnalyticsService } from "@/services/analytics.service";
import { exportCSV } from "../export"; import { exportCSV } from "../export";
import { DataTable } from "../insight-table/data-table"; import { DataTable } from "../insight-table/data-table";
import { getAnalyticsValueLabel, getAnalyticsXAxisLabel, getAnalyticsYAxisLabel } from "../labels";
import { ChartLoader } from "../loaders"; import { ChartLoader } from "../loaders";
import { generateBarColor, NODEDC_ANALYTICS_COLORS } from "./utils"; import { generateBarColor } from "./utils";
declare module "@tanstack/react-table" { declare module "@tanstack/react-table" {
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
@ -55,6 +55,7 @@ const PriorityChart = observer(function PriorityChart(props: Props) {
// store hooks // store hooks
const { selectedDuration, selectedProjects, selectedCycle, selectedModule, isPeekView, isEpic } = useAnalytics(); const { selectedDuration, selectedProjects, selectedCycle, selectedModule, isPeekView, isEpic } = useAnalytics();
const { workspaceStates } = useProjectState(); const { workspaceStates } = useProjectState();
const { resolvedTheme } = useTheme();
// router // router
const params = useParams(); const params = useParams();
const workspaceSlug = params.workspaceSlug.toString(); const workspaceSlug = params.workspaceSlug.toString();
@ -82,71 +83,70 @@ const PriorityChart = observer(function PriorityChart(props: Props) {
priorityChartData && parseChartData(priorityChartData, props.x_axis, props.group_by, props.x_axis_date_grouping), priorityChartData && parseChartData(priorityChartData, props.x_axis, props.group_by, props.x_axis_date_grouping),
[priorityChartData, props.x_axis, props.group_by, props.x_axis_date_grouping] [priorityChartData, props.x_axis, props.group_by, props.x_axis_date_grouping]
); );
const localizedData = useMemo(() => {
if (!parsedData) return undefined;
const schema = Object.fromEntries(
Object.entries(parsedData.schema).map(([key, value]) => [key, getAnalyticsValueLabel(value)])
);
const data = parsedData.data.map((datum) => ({
...datum,
name: getAnalyticsValueLabel(datum.name),
}));
return { ...parsedData, schema, data };
}, [parsedData]);
const chart_model = props.group_by ? EChartModels.STACKED : EChartModels.BASIC; const chart_model = props.group_by ? EChartModels.STACKED : EChartModels.BASIC;
const bars: TBarItem<string>[] = useMemo(() => { const bars: TBarItem<string>[] = useMemo(() => {
if (!parsedData) return []; if (!parsedData) return [];
let parsedBars: TBarItem<string>[]; let parsedBars: TBarItem<string>[];
const schemaKeys = Object.keys(parsedData.schema); const schemaKeys = Object.keys(parsedData.schema);
const baseColors = NODEDC_ANALYTICS_COLORS; const baseColors = CHART_COLOR_PALETTES[0]?.[resolvedTheme === "dark" ? "dark" : "light"];
const extendedColors = generateExtendedColors(baseColors ?? [], schemaKeys.length);
if (chart_model === EChartModels.BASIC) { if (chart_model === EChartModels.BASIC) {
parsedBars = [ parsedBars = [
{ {
key: "count", key: "count",
label: "Количество", label: "Count",
stackId: "bar-one", stackId: "bar-one",
fill: (payload) => generateBarColor(payload.key, { x_axis, y_axis, group_by }, baseColors, workspaceStates), fill: (payload) => generateBarColor(payload.key, { x_axis, y_axis, group_by }, baseColors, workspaceStates),
textClassName: "", textClassName: "",
showPercentage: false, showPercentage: false,
borderRadius: 11,
showTopBorderRadius: () => true, showTopBorderRadius: () => true,
showBottomBorderRadius: () => true, showBottomBorderRadius: () => true,
}, },
]; ];
} else if (chart_model === EChartModels.STACKED && parsedData.schema) { } else if (chart_model === EChartModels.STACKED && parsedData.schema) {
parsedBars = schemaKeys.map((key) => ({ const parsedExtremes: {
[key: string]: {
top: string | null;
bottom: string | null;
};
} = {};
parsedData.data.forEach((datum) => {
let top = null;
let bottom = null;
for (let i = 0; i < schemaKeys.length; i++) {
const key = schemaKeys[i];
if (datum[key] === 0) continue;
if (!bottom) bottom = key;
top = key;
}
parsedExtremes[datum.key] = { top, bottom };
});
parsedBars = schemaKeys.map((key, index) => ({
key: key, key: key,
label: localizedData?.schema[key] ?? getAnalyticsValueLabel(parsedData.schema[key]), label: parsedData.schema[key],
stackId: "bar-one", stackId: "bar-one",
fill: generateBarColor( fill: extendedColors[index],
key,
{ x_axis: group_by ?? x_axis, y_axis, group_by: undefined },
baseColors,
workspaceStates
),
textClassName: "", textClassName: "",
showPercentage: false, showPercentage: false,
borderRadius: 10, showTopBorderRadius: (value, payload: TChartDatum) => parsedExtremes[payload.key].top === value,
showTopBorderRadius: () => true, showBottomBorderRadius: (value, payload: TChartDatum) => parsedExtremes[payload.key].bottom === value,
showBottomBorderRadius: () => true,
})); }));
} else { } else {
parsedBars = []; parsedBars = [];
} }
return parsedBars; return parsedBars;
}, [chart_model, group_by, localizedData?.schema, parsedData, workspaceStates, x_axis, y_axis]); }, [chart_model, group_by, parsedData, resolvedTheme, workspaceStates, x_axis, y_axis]);
const yAxisLabel = useMemo(() => getAnalyticsYAxisLabel(props.y_axis), [props.y_axis]); const yAxisLabel = useMemo(
const xAxisLabel = useMemo(() => getAnalyticsXAxisLabel(props.x_axis), [props.x_axis]); () => ANALYTICS_Y_AXIS_VALUES.find((item) => item.value === props.y_axis)?.label ?? props.y_axis,
const chartWidth = useMemo(() => { [props.y_axis]
const itemCount = localizedData?.data.length ?? 0; );
const widthPerItem = chart_model === EChartModels.STACKED ? 88 : 100; const xAxisLabel = useMemo(
() => ANALYTICS_X_AXIS_VALUES.find((item) => item.value === props.x_axis)?.label ?? props.x_axis,
return Math.max(560, itemCount * widthPerItem); [props.x_axis]
}, [chart_model, localizedData?.data.length]); );
const defaultColumns: ColumnDef<TChartDatum>[] = useMemo( const defaultColumns: ColumnDef<TChartDatum>[] = useMemo(
() => [ () => [
@ -163,13 +163,13 @@ const PriorityChart = observer(function PriorityChart(props: Props) {
}, },
{ {
accessorKey: "count", accessorKey: "count",
header: () => <div className="text-right">Количество</div>, header: () => <div className="text-right">Count</div>,
cell: ({ row }) => <div className="text-right">{row.original.count}</div>, cell: ({ row }) => <div className="text-right">{row.original.count}</div>,
meta: { meta: {
export: { export: {
key: "Количество", key: "Count",
value: (row) => row.original.count, value: (row) => row.original.count,
label: "Количество", label: "Count",
}, },
}, },
}, },
@ -179,64 +179,55 @@ const PriorityChart = observer(function PriorityChart(props: Props) {
const columns: ColumnDef<TChartDatum>[] = useMemo( const columns: ColumnDef<TChartDatum>[] = useMemo(
() => () =>
localizedData parsedData
? Object.keys(localizedData?.schema ?? {}).map((key) => ({ ? Object.keys(parsedData?.schema ?? {}).map((key) => ({
accessorKey: key, accessorKey: key,
header: () => <div className="text-right">{localizedData.schema[key]}</div>, header: () => <div className="text-right">{parsedData.schema[key]}</div>,
cell: ({ row }) => <div className="text-right">{row.original[key]}</div>, cell: ({ row }) => <div className="text-right">{row.original[key]}</div>,
meta: { meta: {
export: { export: {
key, key,
value: (row) => row.original[key], value: (row) => row.original[key],
label: localizedData.schema[key], label: parsedData.schema[key],
}, },
}, },
})) }))
: [], : [],
[localizedData] [parsedData]
); );
return ( return (
<div className="nodedc-analytics-chart-stack flex flex-col gap-8"> <div className="flex flex-col gap-12">
{priorityChartLoading ? ( {priorityChartLoading ? (
<ChartLoader /> <ChartLoader />
) : localizedData?.data && localizedData.data.length > 0 ? ( ) : parsedData?.data && parsedData.data.length > 0 ? (
<> <>
<div className="nodedc-analytics-chart-viewport"> <BarChart
<div className="nodedc-analytics-chart-inner h-[370px]" style={{ width: `${chartWidth}px` }}> className="h-[370px] w-full"
<BarChart data={parsedData.data}
className="nodedc-analytics-bar-chart h-full w-full" bars={bars}
data={localizedData.data} margin={{
bars={bars} bottom: 30,
barSize={chart_model === EChartModels.STACKED ? 72 : 86} }}
margin={{ xAxis={{
top: 12, key: "name",
right: 16, label: xAxisLabel.replace("_", " "),
bottom: 34, dy: 30,
left: 8, }}
}} yAxis={{
xAxis={{ key: "count",
key: "name", label: t("common.no_of", { entity: yAxisLabel.replace("_", " ") }),
label: xAxisLabel, offset: -60,
dy: 30, dx: -26,
}} }}
yAxis={{ />
key: "count",
label: yAxisLabel,
offset: -60,
dx: -26,
}}
/>
</div>
</div>
<DataTable <DataTable
data={localizedData.data} data={parsedData.data}
columns={[...defaultColumns, ...columns]} columns={[...defaultColumns, ...columns]}
searchPlaceholder={`${localizedData.data.length} ${xAxisLabel}`} searchPlaceholder={`${parsedData.data.length} ${xAxisLabel}`}
actions={(table: Table<TChartDatum>) => ( actions={(table: Table<TChartDatum>) => (
<Button <Button
variant="secondary" variant="secondary"
className="nodedc-analytics-export-button"
prependIcon={<Download className="h-3.5 w-3.5" />} prependIcon={<Download className="h-3.5 w-3.5" />}
onClick={() => exportCSV(table.getRowModel().rows, [...defaultColumns, ...columns], workspaceSlug)} onClick={() => exportCSV(table.getRowModel().rows, [...defaultColumns, ...columns], workspaceSlug)}
> >

View File

@ -5,7 +5,7 @@
*/ */
// plane package imports // plane package imports
import type { ChartYAxisMetric, IState, TStateGroups } from "@plane/types"; import type { ChartYAxisMetric, IState } from "@plane/types";
import { ChartXAxisProperty } from "@plane/types"; import { ChartXAxisProperty } from "@plane/types";
interface ParamsProps { interface ParamsProps {
@ -14,56 +14,26 @@ interface ParamsProps {
group_by?: ChartXAxisProperty; group_by?: ChartXAxisProperty;
} }
export const NODEDC_ANALYTICS_COLORS = [
"#C3FF66",
"#F5F7FB",
"#7C7F85",
"#050505",
"#A6ADBA",
"#DDE3EA",
"#2A2B2E",
"#9BFF38",
];
const STATE_GROUP_COLORS: Record<TStateGroups, string> = {
backlog: "#050505",
unstarted: "#7C7F85",
started: "#FFFFFF",
completed: "#C3FF66",
cancelled: "#050505",
};
const PRIORITY_COLORS: Record<string, string> = {
urgent: "#C3FF66",
high: "#F5F7FB",
medium: "#7C7F85",
low: "#2A2B2E",
none: "#050505",
};
const getFallbackColor = (value: string, baseColors: string[]) => {
const fallbackColors = baseColors.length > 0 ? baseColors : NODEDC_ANALYTICS_COLORS;
const index = Math.abs(value.split("").reduce((acc, char) => acc + char.charCodeAt(0), 0)) % fallbackColors.length;
return fallbackColors[index];
};
export const generateBarColor = ( export const generateBarColor = (
value: string | null | undefined, value: string | null | undefined,
params: ParamsProps, params: ParamsProps,
baseColors: string[], baseColors: string[],
workspaceStates?: IState[] workspaceStates?: IState[]
): string => { ): string => {
if (!value) return baseColors[0] ?? NODEDC_ANALYTICS_COLORS[0]; if (!value) return baseColors[0];
let color = getFallbackColor(value, baseColors); let color = baseColors[0];
// Priority // Priority
if (params.x_axis === ChartXAxisProperty.PRIORITY) { if (params.x_axis === ChartXAxisProperty.PRIORITY) {
color = PRIORITY_COLORS[value] ?? color; color =
} value === "urgent"
? "#ef4444"
// State group : value === "high"
if (params.x_axis === ChartXAxisProperty.STATE_GROUPS) { ? "#f97316"
color = STATE_GROUP_COLORS[value as TStateGroups] ?? color; : value === "medium"
? "#eab308"
: value === "low"
? "#22c55e"
: "#ced4da";
} }
// State // State
@ -71,9 +41,10 @@ export const generateBarColor = (
if (workspaceStates && workspaceStates.length > 0) { if (workspaceStates && workspaceStates.length > 0) {
const state = workspaceStates.find((s) => s.id === value); const state = workspaceStates.find((s) => s.id === value);
if (state) { if (state) {
color = STATE_GROUP_COLORS[state.group] ?? color; color = state.color;
} else { } else {
color = getFallbackColor(value, baseColors); const index = Math.abs(value.split("").reduce((acc, char) => acc + char.charCodeAt(0), 0)) % baseColors.length;
color = baseColors[index];
} }
} }
} }

View File

@ -125,7 +125,7 @@ const WorkItemsInsightTable = observer(function WorkItemsInsightTable() {
)} )}
</div> </div>
)} )}
<span className="break-words text-secondary">{row.original.display_name ?? "Без исполнителя"}</span> <span className="break-words text-secondary">{row.original.display_name ?? t(`Unassigned`)}</span>
</div> </div>
</div> </div>
), ),

View File

@ -7,7 +7,6 @@
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { Combobox } from "@headlessui/react"; import { Combobox } from "@headlessui/react";
// hooks // hooks
import { getStateGroupColor } from "@plane/propel/icons";
import type { ISearchIssueResponse } from "@plane/types"; import type { ISearchIssueResponse } from "@plane/types";
// plane web hooks // plane web hooks
import { IssueIdentifier } from "@/plane-web/components/issues/issue-details/issue-identifier"; import { IssueIdentifier } from "@/plane-web/components/issues/issue-details/issue-identifier";
@ -20,7 +19,7 @@ interface Props {
export const BulkDeleteIssuesModalItem = observer(function BulkDeleteIssuesModalItem(props: Props) { export const BulkDeleteIssuesModalItem = observer(function BulkDeleteIssuesModalItem(props: Props) {
const { issue, canDeleteIssueIds } = props; const { issue, canDeleteIssueIds } = props;
const color = getStateGroupColor(issue.state__group, issue.state__color); const color = issue.state__color;
return ( return (
<Combobox.Option <Combobox.Option

View File

@ -22,7 +22,7 @@ import { useUser } from "@/hooks/store/user";
import { AuthService } from "@/services/auth.service"; import { AuthService } from "@/services/auth.service";
import userService from "@/services/user.service"; import userService from "@/services/user.service";
type Props = { isOpen: boolean; onClose: () => void; isSMTPConfigured?: boolean }; type Props = { isOpen: boolean; onClose: () => void };
type TModalStep = "EMAIL" | "UNIQUE_CODE"; type TModalStep = "EMAIL" | "UNIQUE_CODE";
type TUniqueCodeValuesForm = { email: string; code: string }; type TUniqueCodeValuesForm = { email: string; code: string };
@ -33,7 +33,7 @@ const defaultValues: TUniqueCodeValuesForm = { email: "", code: "" };
const authService = new AuthService(); const authService = new AuthService();
export const ChangeEmailModal = observer(function ChangeEmailModal(props: Props) { export const ChangeEmailModal = observer(function ChangeEmailModal(props: Props) {
const { isOpen, onClose, isSMTPConfigured = true } = props; const { isOpen, onClose } = props;
// states // states
const [currentStep, setCurrentStep] = useState<TModalStep>("EMAIL"); const [currentStep, setCurrentStep] = useState<TModalStep>("EMAIL");
// store hooks // store hooks
@ -107,20 +107,6 @@ export const ChangeEmailModal = observer(function ChangeEmailModal(props: Props)
return; return;
} }
if (!isSMTPConfigured) {
await userService.updateEmailDirect({ email: formData.email });
setToast({
type: TOAST_TYPE.SUCCESS,
title: changeEmailT("toasts.success_title"),
message: changeEmailT("toasts.success_message"),
});
await handleSignOut();
handleClose();
return;
}
// Generate verification code and send to new email // Generate verification code and send to new email
await userService.generateEmailCode({ email: formData.email }); await userService.generateEmailCode({ email: formData.email });
@ -149,9 +135,7 @@ export const ChangeEmailModal = observer(function ChangeEmailModal(props: Props)
<ModalCore isOpen={isOpen} handleClose={handleClose} position={EModalPosition.CENTER} width={EModalWidth.XXL}> <ModalCore isOpen={isOpen} handleClose={handleClose} position={EModalPosition.CENTER} width={EModalWidth.XXL}>
<div className="space-y-0 px-4 py-4"> <div className="space-y-0 px-4 py-4">
<h3 className="text-16 leading-6 font-medium text-primary">{changeEmailT("title")}</h3> <h3 className="text-16 leading-6 font-medium text-primary">{changeEmailT("title")}</h3>
<p className="my-4 text-13 text-secondary"> <p className="my-4 text-13 text-secondary">{changeEmailT("description")}</p>
{isSMTPConfigured ? changeEmailT("description") : changeEmailT("direct_description")}
</p>
</div> </div>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4 px-4" noValidate> <form onSubmit={handleSubmit(onSubmit)} className="space-y-4 px-4" noValidate>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">

View File

@ -11,7 +11,7 @@ import { Combobox } from "@headlessui/react";
import { useTranslation } from "@plane/i18n"; import { useTranslation } from "@plane/i18n";
// types // types
import { Button } from "@plane/propel/button"; import { Button } from "@plane/propel/button";
import { SearchIcon, CloseIcon, getStateGroupColor } from "@plane/propel/icons"; import { SearchIcon, CloseIcon } from "@plane/propel/icons";
import { TOAST_TYPE, setToast } from "@plane/propel/toast"; import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { Tooltip } from "@plane/propel/tooltip"; import { Tooltip } from "@plane/propel/tooltip";
import type { ISearchIssueResponse, TProjectIssuesSearchParams } from "@plane/types"; import type { ISearchIssueResponse, TProjectIssuesSearchParams } from "@plane/types";
@ -263,7 +263,7 @@ export function ExistingIssuesListModal(props: Props) {
<span <span
className="block h-1.5 w-1.5 flex-shrink-0 rounded-full" className="block h-1.5 w-1.5 flex-shrink-0 rounded-full"
style={{ style={{
backgroundColor: getStateGroupColor(issue.state__group, issue.state__color), backgroundColor: issue.state__color,
}} }}
/> />
<span className="flex-shrink-0"> <span className="flex-shrink-0">

View File

@ -81,7 +81,7 @@ export const BlockRow = observer(function BlockRow(props: Props) {
return ( return (
<div <div
className="nodedc-project-gantt-row relative w-max min-w-full" className="relative w-max min-w-full"
onMouseEnter={() => updateActiveBlockId(blockId)} onMouseEnter={() => updateActiveBlockId(blockId)}
onMouseLeave={() => updateActiveBlockId(null)} onMouseLeave={() => updateActiveBlockId(null)}
style={{ style={{
@ -89,18 +89,19 @@ export const BlockRow = observer(function BlockRow(props: Props) {
}} }}
> >
<div <div
className={cn("nodedc-project-gantt-row-bg relative h-full", { className={cn("relative h-full bg-layer-transparent hover:bg-layer-transparent-hover", {
"nodedc-project-gantt-row-peeked": getIsIssuePeeked(block.data.id), "rounded-l-sm border border-r-0 border-accent-strong": getIsIssuePeeked(block.data.id),
"nodedc-project-gantt-row-hovered": isBlockHoveredOn, "bg-layer-transparent-hover": isBlockHoveredOn,
"nodedc-project-gantt-row-selected": isBlockSelected, "bg-accent-primary/5 hover:bg-accent-primary/10": isBlockSelected,
"nodedc-project-gantt-row-focused": isBlockFocused, "bg-accent-primary/10": isBlockSelected && isBlockHoveredOn,
"border border-r-0 border-strong-1": isBlockFocused,
})} })}
> >
{isBlockVisibleOnChart {isBlockVisibleOnChart
? isHidden && ( ? isHidden && (
<button <button
type="button" type="button"
className="nodedc-project-gantt-jump-button sticky z-[5] grid h-8 w-8 translate-y-1.5 cursor-pointer place-items-center text-secondary hover:text-primary" className="sticky z-[5] grid h-8 w-8 translate-y-1.5 cursor-pointer place-items-center rounded-sm border border-strong bg-layer-1 text-secondary hover:text-primary"
style={{ style={{
left: `${SIDEBAR_WIDTH + 4}px`, left: `${SIDEBAR_WIDTH + 4}px`,
}} }}

View File

@ -83,7 +83,7 @@ export const GanttChartBlock = observer(function GanttChartBlock(props: Props) {
horizontalOffset={100} horizontalOffset={100}
verticalOffset={200} verticalOffset={200}
classNames="flex h-full w-full items-center" classNames="flex h-full w-full items-center"
placeholderChildren={<div className="nodedc-project-gantt-block-placeholder h-8 w-full" />} placeholderChildren={<div className="h-8 w-full rounded-sm bg-layer-1" />}
shouldRecordHeights={false} shouldRecordHeights={false}
forceRender={isCurrentDependencyDragging} forceRender={isCurrentDependencyDragging}
> >

View File

@ -5,7 +5,7 @@
*/ */
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { CalendarDays, Expand, Shrink } from "lucide-react"; import { Expand, Shrink } from "lucide-react";
import { useTranslation } from "@plane/i18n"; import { useTranslation } from "@plane/i18n";
// plane // plane
import type { TGanttViews } from "@plane/types"; import type { TGanttViews } from "@plane/types";
@ -25,81 +25,62 @@ type Props = {
handleChartView: (view: TGanttViews) => void; handleChartView: (view: TGanttViews) => void;
handleToday: () => void; handleToday: () => void;
loaderTitle: string; loaderTitle: string;
title: string;
toggleFullScreenMode: () => void; toggleFullScreenMode: () => void;
showToday: boolean; showToday: boolean;
}; };
const GANTT_VIEW_SHORT_LABELS: Record<TGanttViews, string> = {
week: "1W",
month: "1M",
quarter: "3M",
};
export const GanttChartHeader = observer(function GanttChartHeader(props: Props) { export const GanttChartHeader = observer(function GanttChartHeader(props: Props) {
const { t } = useTranslation(); const { t } = useTranslation();
const { const { blockIds, fullScreenMode, handleChartView, handleToday, loaderTitle, toggleFullScreenMode, showToday } =
blockIds, props;
fullScreenMode,
handleChartView,
handleToday,
loaderTitle,
title,
toggleFullScreenMode,
showToday,
} = props;
// chart hook // chart hook
const { currentView } = useTimeLineChartStore(); const { currentView } = useTimeLineChartStore();
return ( return (
<Row <Row
className="nodedc-project-gantt-toolbar relative flex w-full flex-shrink-0 flex-wrap items-center justify-between gap-3 whitespace-nowrap" className="relative flex w-full flex-shrink-0 flex-wrap items-center gap-2 bg-surface-1 py-2 whitespace-nowrap"
style={{ height: `${GANTT_BREADCRUMBS_HEIGHT}px` }} style={{ height: `${GANTT_BREADCRUMBS_HEIGHT}px` }}
> >
<div className="flex min-w-0 items-center gap-3"> <div className="ml-auto">
<div className="nodedc-project-gantt-toolbar-icon"> <div className="ml-auto text-11 font-medium text-tertiary">
<CalendarDays className="size-4" /> {blockIds ? `${blockIds.length} ${loaderTitle}` : t("common.loading")}
</div>
<div className="min-w-0">
<div className="truncate text-14 font-semibold text-primary">{title}</div>
<div className="mt-0.5 truncate text-11 font-medium text-tertiary">
{currentView ? GANTT_VIEW_SHORT_LABELS[currentView] : null}
{currentView ? " / " : null}
{blockIds ? `${blockIds.length} ${loaderTitle}` : t("common.loading")}
</div>
</div> </div>
</div> </div>
<div className="flex flex-wrap items-center justify-end gap-2"> <div className="flex flex-wrap items-center gap-2">
{showToday && (
<button
type="button"
className="nodedc-project-gantt-chip nodedc-project-gantt-chip-live"
onClick={handleToday}
>
Live
</button>
)}
{VIEWS_LIST.map((chartView: any) => ( {VIEWS_LIST.map((chartView: any) => (
<button <div
key={chartView?.key} key={chartView?.key}
type="button" className={cn(
aria-label={t(chartView?.i18n_title)} "cursor-pointer rounded-md bg-layer-transparent p-1 px-2 text-11 hover:bg-layer-transparent-hover",
aria-pressed={currentView === chartView?.key} {
className={cn("nodedc-project-gantt-chip", { "bg-layer-transparent-selected": currentView === chartView?.key,
"nodedc-project-gantt-chip-active": currentView === chartView?.key, }
})} )}
onClick={() => handleChartView(chartView?.key)} onClick={() => handleChartView(chartView?.key)}
> >
{GANTT_VIEW_SHORT_LABELS[chartView?.key as TGanttViews]} {t(chartView?.i18n_title)}
</button> </div>
))} ))}
<button type="button" className="nodedc-project-gantt-round-button" onClick={toggleFullScreenMode}>
{fullScreenMode ? <Shrink className="size-4" /> : <Expand className="size-4" />}
</button>
</div> </div>
{showToday && (
<button
type="button"
className="rounded-md bg-layer-transparent p-1 px-2 text-11 hover:bg-layer-transparent-hover"
onClick={handleToday}
>
{t("common.today")}
</button>
)}
<button
type="button"
className="flex items-center justify-center rounded-md border border-subtle bg-layer-transparent p-1 transition-all hover:bg-layer-transparent-hover"
onClick={toggleFullScreenMode}
>
{fullScreenMode ? <Shrink className="h-4 w-4" /> : <Expand className="h-4 w-4" />}
</button>
</Row> </Row>
); );
}); });

View File

@ -177,7 +177,7 @@ export const GanttChartMainContent = observer(function GanttChartMainContent(pro
// DO NOT REMOVE THE ID // DO NOT REMOVE THE ID
id="gantt-container" id="gantt-container"
className={cn( className={cn(
"nodedc-project-gantt-scroll vertical-scrollbar horizontal-scrollbar flex scrollbar-lg h-full w-full overflow-auto", "vertical-scrollbar horizontal-scrollbar flex scrollbar-lg h-full w-full overflow-auto border-t-[0.5px] border-subtle",
{ {
"mb-8": bottomSpacing, "mb-8": bottomSpacing,
} }
@ -199,11 +199,11 @@ export const GanttChartMainContent = observer(function GanttChartMainContent(pro
showAllBlocks={showAllBlocks} showAllBlocks={showAllBlocks}
isEpic={isEpic} isEpic={isEpic}
/> />
<div className="nodedc-project-gantt-stage relative h-max min-h-full flex-shrink-0 flex-grow"> <div className="relative h-max min-h-full flex-shrink-0 flex-grow">
<ActiveChartView /> <ActiveChartView />
{currentViewData && ( {currentViewData && (
<div <div
className="nodedc-project-gantt-layer relative h-full" className="relative h-full"
style={{ style={{
width: `${itemsContainerWidth}px`, width: `${itemsContainerWidth}px`,
transform: `translateY(${HEADER_HEIGHT}px)`, transform: `translateY(${HEADER_HEIGHT}px)`,

View File

@ -180,8 +180,8 @@ export const ChartViewRoot = observer(function ChartViewRoot(props: ChartViewRoo
const content = ( const content = (
<div <div
className={cn("nodedc-project-gantt-card shadow relative flex h-full flex-col select-none", { className={cn("shadow relative flex h-full flex-col rounded-xs bg-surface-1 select-none", {
"fixed inset-0 z-[25] rounded-none": fullScreenMode, "inset-0 z-[25] bg-surface-1": fullScreenMode,
"border-[0.5px] border-subtle": border, "border-[0.5px] border-subtle": border,
})} })}
> >
@ -192,7 +192,6 @@ export const ChartViewRoot = observer(function ChartViewRoot(props: ChartViewRoo
handleChartView={(key) => updateCurrentViewRenderPayload(null, key)} handleChartView={(key) => updateCurrentViewRenderPayload(null, key)}
handleToday={handleToday} handleToday={handleToday}
loaderTitle={loaderTitle} loaderTitle={loaderTitle}
title={title}
showToday={showToday} showToday={showToday}
/> />
<GanttChartMainContent <GanttChartMainContent

View File

@ -30,12 +30,12 @@ export const MonthChartView = observer(function MonthChartView(_props: any) {
const marginLeftDays = getNumberOfDaysBetweenTwoDates(monthsStartDate, weeksStartDate); const marginLeftDays = getNumberOfDaysBetweenTwoDates(monthsStartDate, weeksStartDate);
return ( return (
<div className="nodedc-project-gantt-calendar absolute top-0 left-0 flex h-max min-h-full w-max"> <div className="absolute top-0 left-0 flex h-max min-h-full w-max">
{currentViewData && ( {currentViewData && (
<div className="nodedc-project-gantt-calendar-group relative flex flex-col"> <div className="relative flex flex-col outline-[0.25px] outline-subtle-1">
{/** Header Div */} {/** Header Div */}
<div <div
className="nodedc-project-gantt-calendar-header sticky top-0 z-[5] w-full flex-shrink-0" className="sticky top-0 z-[5] w-full flex-shrink-0 bg-surface-1"
style={{ style={{
height: `${HEADER_HEIGHT}px`, height: `${HEADER_HEIGHT}px`,
}} }}
@ -45,22 +45,18 @@ export const MonthChartView = observer(function MonthChartView(_props: any) {
{months?.map((monthBlock) => ( {months?.map((monthBlock) => (
<div <div
key={`month-${monthBlock?.month}-${monthBlock?.year}`} key={`month-${monthBlock?.month}-${monthBlock?.year}`}
className="flex" className="flex outline-[0.5px] outline-subtle-1"
style={{ width: `${monthBlock.days * currentViewData?.data.dayWidth}px` }} style={{ width: `${monthBlock.days * currentViewData?.data.dayWidth}px` }}
> >
<div <div
className="nodedc-project-gantt-period-label sticky z-[1] m-1 flex items-center px-3 py-1 text-12 font-semibold whitespace-nowrap text-secondary capitalize" className="sticky z-[1] m-1 flex items-center bg-surface-1 px-3 py-1 text-14 font-regular whitespace-nowrap text-secondary capitalize"
style={{ style={{
left: `${SIDEBAR_WIDTH}px`, left: `${SIDEBAR_WIDTH}px`,
}} }}
> >
{monthBlock?.title} {monthBlock?.title}
{monthBlock.today && ( {monthBlock.today && (
<span <span className={cn("ml-2 rounded-sm bg-accent-primary px-1 text-9 font-medium text-on-color")}>
className={cn(
"ml-2 rounded-full bg-[rgb(var(--nodedc-card-active-rgb))] px-2 py-0.5 text-9 font-semibold text-[rgb(var(--nodedc-on-card-active-rgb))]"
)}
>
Current Current
</span> </span>
)} )}
@ -74,9 +70,9 @@ export const MonthChartView = observer(function MonthChartView(_props: any) {
<div <div
key={`sub-title-${weekBlock.startDate.toString()}-${weekBlock.endDate.toString()}`} key={`sub-title-${weekBlock.startDate.toString()}-${weekBlock.endDate.toString()}`}
className={cn( className={cn(
"nodedc-project-gantt-subcell flex flex-shrink-0 justify-between px-2 py-1 text-center capitalize", "flex flex-shrink-0 justify-between px-2 py-1 text-center capitalize outline-[0.25px] outline-subtle-1",
{ {
"nodedc-project-gantt-subcell-today": weekBlock.today, "bg-accent-primary/20": weekBlock.today,
} }
)} )}
style={{ width: `${currentViewData?.data.dayWidth * 7}px` }} style={{ width: `${currentViewData?.data.dayWidth * 7}px` }}
@ -84,8 +80,7 @@ export const MonthChartView = observer(function MonthChartView(_props: any) {
<div className="space-x-1 text-11 font-medium text-placeholder"> <div className="space-x-1 text-11 font-medium text-placeholder">
<span <span
className={cn({ className={cn({
"rounded-full bg-[rgb(var(--nodedc-card-active-rgb))] px-1.5 text-[rgb(var(--nodedc-on-card-active-rgb))]": "rounded-sm bg-accent-primary px-1 text-on-color": weekBlock.today,
weekBlock.today,
})} })}
> >
{weekBlock.startDate.getDate()}-{weekBlock.endDate.getDate()} {weekBlock.startDate.getDate()}-{weekBlock.endDate.getDate()}
@ -101,8 +96,8 @@ export const MonthChartView = observer(function MonthChartView(_props: any) {
{weeks?.map((weekBlock) => ( {weeks?.map((weekBlock) => (
<div <div
key={`column-${weekBlock.startDate.toString()}-${weekBlock.endDate.toString()}`} key={`column-${weekBlock.startDate.toString()}-${weekBlock.endDate.toString()}`}
className={cn("nodedc-project-gantt-column h-full overflow-hidden", { className={cn("h-full overflow-hidden outline-[0.25px] outline-subtle", {
"nodedc-project-gantt-column-today": weekBlock.today, "bg-accent-primary/20": weekBlock.today,
})} })}
style={{ width: `${currentViewData?.data.dayWidth * 7}px` }} style={{ width: `${currentViewData?.data.dayWidth * 7}px` }}
/> />

View File

@ -21,16 +21,16 @@ export const QuarterChartView = observer(function QuarterChartView(_props: any)
const quarterBlocks: IQuarterMonthBlock[] = groupMonthsToQuarters(monthBlocks); const quarterBlocks: IQuarterMonthBlock[] = groupMonthsToQuarters(monthBlocks);
return ( return (
<div className="nodedc-project-gantt-calendar absolute top-0 left-0 flex h-max min-h-full w-max"> <div className={`absolute top-0 left-0 flex h-max min-h-full w-max`}>
{currentViewData && {currentViewData &&
quarterBlocks?.map((quarterBlock, rootIndex) => ( quarterBlocks?.map((quarterBlock, rootIndex) => (
<div <div
key={`month-${quarterBlock.quarterNumber}-${quarterBlock.year}`} key={`month-${quarterBlock.quarterNumber}-${quarterBlock.year}`}
className="nodedc-project-gantt-calendar-group relative flex flex-col" className="relative flex flex-col outline-[0.25px] outline-subtle-1"
> >
{/** Header Div */} {/** Header Div */}
<div <div
className="nodedc-project-gantt-calendar-header sticky top-0 z-[5] w-full flex-shrink-0" className="sticky top-0 z-[5] w-full flex-shrink-0 bg-surface-1 outline-[1px] outline-subtle-1"
style={{ style={{
height: `${HEADER_HEIGHT}px`, height: `${HEADER_HEIGHT}px`,
}} }}
@ -38,23 +38,19 @@ export const QuarterChartView = observer(function QuarterChartView(_props: any)
{/** Main Quarter Title */} {/** Main Quarter Title */}
<div className="inline-flex h-7 w-full justify-between"> <div className="inline-flex h-7 w-full justify-between">
<div <div
className="nodedc-project-gantt-period-label sticky z-[1] my-1 flex items-center px-3 py-1 text-12 font-semibold whitespace-nowrap text-secondary capitalize" className="sticky z-[1] my-1 flex items-center bg-surface-1 px-3 py-1 text-14 font-regular whitespace-nowrap text-secondary capitalize"
style={{ style={{
left: `${SIDEBAR_WIDTH}px`, left: `${SIDEBAR_WIDTH}px`,
}} }}
> >
{quarterBlock?.title} {quarterBlock?.title}
{quarterBlock.today && ( {quarterBlock.today && (
<span <span className={cn("ml-2 rounded-sm bg-accent-primary px-1 text-9 font-medium text-on-color")}>
className={cn(
"ml-2 rounded-full bg-[rgb(var(--nodedc-card-active-rgb))] px-2 py-0.5 text-9 font-semibold text-[rgb(var(--nodedc-on-card-active-rgb))]"
)}
>
Current Current
</span> </span>
)} )}
</div> </div>
<div className="nodedc-project-gantt-period-meta sticky px-3 py-2 text-11 whitespace-nowrap text-placeholder capitalize"> <div className="sticky px-3 py-2 text-11 whitespace-nowrap text-placeholder capitalize">
{quarterBlock.shortTitle} {quarterBlock.shortTitle}
</div> </div>
</div> </div>
@ -64,9 +60,9 @@ export const QuarterChartView = observer(function QuarterChartView(_props: any)
<div <div
key={`sub-title-${rootIndex}-${index}`} key={`sub-title-${rootIndex}-${index}`}
className={cn( className={cn(
"nodedc-project-gantt-subcell flex flex-shrink-0 justify-center text-center capitalize", "flex flex-shrink-0 justify-center text-center capitalize outline-[0.25px] outline-subtle-1",
{ {
"nodedc-project-gantt-subcell-today": monthBlock.today, "bg-accent-primary/20": monthBlock.today,
} }
)} )}
style={{ width: `${currentViewData?.data.dayWidth * monthBlock.days}px` }} style={{ width: `${currentViewData?.data.dayWidth * monthBlock.days}px` }}
@ -74,8 +70,7 @@ export const QuarterChartView = observer(function QuarterChartView(_props: any)
<div className="flex h-full items-center justify-center space-x-1 text-11 font-medium"> <div className="flex h-full items-center justify-center space-x-1 text-11 font-medium">
<span <span
className={cn({ className={cn({
"rounded-full bg-[rgb(var(--nodedc-card-active-rgb))] px-2 text-[rgb(var(--nodedc-on-card-active-rgb))]": "rounded-lg bg-accent-primary px-2 text-on-color": monthBlock.today,
monthBlock.today,
})} })}
> >
{monthBlock.monthData.shortTitle} {monthBlock.monthData.shortTitle}
@ -90,8 +85,8 @@ export const QuarterChartView = observer(function QuarterChartView(_props: any)
{quarterBlock?.children?.map((monthBlock, index) => ( {quarterBlock?.children?.map((monthBlock, index) => (
<div <div
key={`column-${rootIndex}-${index}`} key={`column-${rootIndex}-${index}`}
className={cn("nodedc-project-gantt-column h-full overflow-hidden", { className={cn("h-full overflow-hidden outline-[0.25px] outline-subtle", {
"nodedc-project-gantt-column-today": monthBlock.today, "bg-accent-primary/20": monthBlock.today,
})} })}
style={{ width: `${currentViewData?.data.dayWidth * monthBlock.days}px` }} style={{ width: `${currentViewData?.data.dayWidth * monthBlock.days}px` }}
/> />

View File

@ -18,16 +18,16 @@ export const WeekChartView = observer(function WeekChartView(_props: any) {
const weekBlocks: IWeekBlock[] = renderView; const weekBlocks: IWeekBlock[] = renderView;
return ( return (
<div className="nodedc-project-gantt-calendar absolute top-0 left-0 flex h-max min-h-full w-max"> <div className={`absolute top-0 left-0 flex h-max min-h-full w-max`}>
{currentViewData && {currentViewData &&
weekBlocks?.map((block, rootIndex) => ( weekBlocks?.map((block, rootIndex) => (
<div <div
key={`month-${block?.startDate.toString()}-${block?.endDate.toString()}`} key={`month-${block?.startDate.toString()}-${block?.endDate.toString()}`}
className="nodedc-project-gantt-calendar-group relative flex flex-col" className="relative flex flex-col outline-[0.25px] outline-subtle-1"
> >
{/** Header Div */} {/** Header Div */}
<div <div
className="nodedc-project-gantt-calendar-header sticky top-0 z-[5] w-full flex-shrink-0" className="sticky top-0 z-[5] w-full flex-shrink-0 bg-surface-1 outline-[1px] outline-subtle-1"
style={{ style={{
height: `${HEADER_HEIGHT}px`, height: `${HEADER_HEIGHT}px`,
}} }}
@ -35,14 +35,14 @@ export const WeekChartView = observer(function WeekChartView(_props: any) {
{/** Main Months Title */} {/** Main Months Title */}
<div className="inline-flex h-7 w-full justify-between"> <div className="inline-flex h-7 w-full justify-between">
<div <div
className="nodedc-project-gantt-period-label sticky z-[1] m-1 flex items-center px-3 py-1 text-12 font-semibold whitespace-nowrap text-secondary capitalize" className="sticky z-[1] m-1 flex items-center bg-surface-1 px-3 py-1 text-13 font-regular whitespace-nowrap text-secondary capitalize"
style={{ style={{
left: `${SIDEBAR_WIDTH}px`, left: `${SIDEBAR_WIDTH}px`,
}} }}
> >
{block?.title} {block?.title}
</div> </div>
<div className="nodedc-project-gantt-period-meta sticky px-3 py-2 text-11 whitespace-nowrap text-placeholder capitalize"> <div className="sticky px-3 py-2 text-11 whitespace-nowrap text-placeholder capitalize">
{block?.weekData?.title} {block?.weekData?.title}
</div> </div>
</div> </div>
@ -52,9 +52,9 @@ export const WeekChartView = observer(function WeekChartView(_props: any) {
<div <div
key={`sub-title-${rootIndex}-${index}`} key={`sub-title-${rootIndex}-${index}`}
className={cn( className={cn(
"nodedc-project-gantt-subcell flex flex-shrink-0 justify-between p-1 text-center capitalize", "flex flex-shrink-0 justify-between p-1 text-center capitalize outline-[0.25px] outline-subtle-1",
{ {
"nodedc-project-gantt-subcell-today": weekDay.today, "bg-accent-primary/20": weekDay.today,
} }
)} )}
style={{ width: `${currentViewData?.data.dayWidth}px` }} style={{ width: `${currentViewData?.data.dayWidth}px` }}
@ -63,8 +63,7 @@ export const WeekChartView = observer(function WeekChartView(_props: any) {
<div className="space-x-1 text-11 font-medium"> <div className="space-x-1 text-11 font-medium">
<span <span
className={cn({ className={cn({
"rounded-full bg-[rgb(var(--nodedc-card-active-rgb))] px-1.5 text-[rgb(var(--nodedc-on-card-active-rgb))]": "rounded-sm bg-accent-primary px-1 text-on-color": weekDay.today,
weekDay.today,
})} })}
> >
{weekDay.date.getDate()} {weekDay.date.getDate()}
@ -75,17 +74,17 @@ export const WeekChartView = observer(function WeekChartView(_props: any) {
</div> </div>
</div> </div>
{/** Day Columns */} {/** Day Columns */}
<div className="flex h-full w-full flex-grow"> <div className="flex h-full w-full flex-grow bg-surface-1">
{block?.children?.map((weekDay, index) => ( {block?.children?.map((weekDay, index) => (
<div <div
key={`column-${rootIndex}-${index}`} key={`column-${rootIndex}-${index}`}
className={cn("nodedc-project-gantt-column h-full overflow-hidden", { className={cn("h-full overflow-hidden outline-[0.25px] outline-subtle", {
"nodedc-project-gantt-column-today": weekDay.today, "bg-accent-primary/20": weekDay.today,
})} })}
style={{ width: `${currentViewData?.data.dayWidth}px` }} style={{ width: `${currentViewData?.data.dayWidth}px` }}
> >
{["sat", "sun"].includes(weekDay?.dayData?.shortTitle) && ( {["sat", "sun"].includes(weekDay?.dayData?.shortTitle) && (
<div className="nodedc-project-gantt-column-weekend h-full" /> <div className="h-full bg-surface-2 outline-[0.25px] outline-strong" />
)} )}
</div> </div>
))} ))}

View File

@ -4,11 +4,11 @@
* See the LICENSE file for details. * See the LICENSE file for details.
*/ */
export const BLOCK_HEIGHT = 46; export const BLOCK_HEIGHT = 44;
export const HEADER_HEIGHT = 56; export const HEADER_HEIGHT = 48;
export const GANTT_BREADCRUMBS_HEIGHT = 60; export const GANTT_BREADCRUMBS_HEIGHT = 40;
export const SIDEBAR_WIDTH = 360; export const SIDEBAR_WIDTH = 360;

View File

@ -39,7 +39,7 @@ export const LeftResizable = observer(function LeftResizable(props: LeftResizabl
<> <>
{(isHovering || isLeftResizing) && dateString && ( {(isHovering || isLeftResizing) && dateString && (
<div className="absolute -left-36 flex h-full w-32 items-center justify-end text-11 font-regular text-tertiary"> <div className="absolute -left-36 flex h-full w-32 items-center justify-end text-11 font-regular text-tertiary">
<div className="nodedc-project-gantt-resize-tooltip px-2 py-1">{dateString}</div> <div className="rounded-sm bg-accent-subtle px-2 py-1">{dateString}</div>
</div> </div>
)} )}
<div <div
@ -56,7 +56,7 @@ export const LeftResizable = observer(function LeftResizable(props: LeftResizabl
/> />
<div <div
className={cn( className={cn(
"nodedc-project-gantt-resize-handle absolute top-1/2 left-1 z-[5] h-7 w-1 -translate-y-1/2 opacity-0 transition-all duration-300 group-hover:opacity-100", "absolute top-1/2 left-1 z-[5] h-7 w-1 -translate-y-1/2 rounded-xs bg-surface-1 opacity-0 transition-all duration-300 group-hover:opacity-100",
{ {
"-left-1.5 opacity-100": isLeftResizing, "-left-1.5 opacity-100": isLeftResizing,
} }

View File

@ -39,7 +39,7 @@ export const RightResizable = observer(function RightResizable(props: RightResiz
<> <>
{(isHovering || isRightResizing) && dateString && ( {(isHovering || isRightResizing) && dateString && (
<div className="absolute -right-36 z-[10] flex h-full w-32 items-center justify-start text-11 font-regular text-tertiary"> <div className="absolute -right-36 z-[10] flex h-full w-32 items-center justify-start text-11 font-regular text-tertiary">
<div className="nodedc-project-gantt-resize-tooltip px-2 py-1">{dateString}</div> <div className="rounded-sm bg-accent-subtle px-2 py-1">{dateString}</div>
</div> </div>
)} )}
<div <div
@ -54,7 +54,7 @@ export const RightResizable = observer(function RightResizable(props: RightResiz
/> />
<div <div
className={cn( className={cn(
"nodedc-project-gantt-resize-handle absolute top-1/2 right-1 z-[5] h-7 w-1 -translate-y-1/2 opacity-0 transition-all duration-300 group-hover:opacity-100", "absolute top-1/2 right-1 z-[5] h-7 w-1 -translate-y-1/2 rounded-xs bg-surface-1 opacity-0 transition-all duration-300 group-hover:opacity-100",
{ {
"-right-1.5 opacity-100": isRightResizing, "-right-1.5 opacity-100": isRightResizing,
} }

View File

@ -43,7 +43,7 @@ export const ChartDraggable = observer(function ChartDraggable(props: Props) {
} = props; } = props;
return ( return (
<div className="nodedc-project-gantt-draggable group relative z-[5] inline-flex h-full w-full cursor-pointer items-center font-medium transition-all"> <div className="group relative z-[5] inline-flex h-full w-full cursor-pointer items-center font-medium transition-all">
{/* left resize drag handle */} {/* left resize drag handle */}
{(typeof enableDependency === "function" ? enableDependency(block.id) : enableDependency) && ( {(typeof enableDependency === "function" ? enableDependency(block.id) : enableDependency) && (
<LeftDependencyDraggable block={block} ganttContainerRef={ganttContainerRef} /> <LeftDependencyDraggable block={block} ganttContainerRef={ganttContainerRef} />
@ -55,7 +55,7 @@ export const ChartDraggable = observer(function ChartDraggable(props: Props) {
position={block.position} position={block.position}
/> />
<div <div
className={cn("nodedc-project-gantt-draggable-shell relative z-[6] flex h-8 w-full items-center", { className={cn("relative z-[6] flex h-8 w-full items-center rounded-sm", {
"pointer-events-none": isMoving, "pointer-events-none": isMoving,
})} })}
onMouseDown={(e) => enableBlockMove && handleBlockDrag(e, "move")} onMouseDown={(e) => enableBlockMove && handleBlockDrag(e, "move")}

View File

@ -1,677 +0,0 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useEffect, useMemo, useRef, useState } from "react";
import { Check, Filter, SlidersHorizontal, X } 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 = {
assignee_ids?: string[] | null;
completed_at?: string | null;
created_at?: string | null;
created_by?: string | null;
id: string;
name: string;
identifier: string;
priority?: string | null;
sort_order?: number | null;
start_date?: string | null;
state_group?: TGanttPreviewStateGroup | 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;
};
type TGanttPreviewStateGroup = "backlog" | "unstarted" | "started" | "completed" | "cancelled";
type TGanttPreviewStatusFilter = "open" | "backlog" | "unstarted" | "started" | "completed" | "cancelled";
type TGanttPreviewDateFilter = "overdue" | "complete-range";
type TGanttPreviewSortMode = "target_date_asc" | "target_date_desc" | "start_date_asc" | "created_at_desc";
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 GANTT_STATUS_FILTER_OPTIONS: {
groups: TGanttPreviewStateGroup[];
key: TGanttPreviewStatusFilter;
label: string;
}[] = [
{ groups: ["backlog", "unstarted", "started"], key: "open", label: "Открытые" },
{ groups: ["backlog"], key: "backlog", label: "Бэклог" },
{ groups: ["unstarted"], key: "unstarted", label: "Todo" },
{ groups: ["started"], key: "started", label: "В процессе" },
{ groups: ["completed"], key: "completed", label: "Закрытые" },
{ groups: ["cancelled"], key: "cancelled", label: "Отмененные" },
];
const GANTT_DATE_FILTER_OPTIONS: { key: TGanttPreviewDateFilter; label: string }[] = [
{ key: "overdue", label: "Просроченные" },
{ key: "complete-range", label: "С началом и сроком" },
];
const GANTT_SORT_OPTIONS: { key: TGanttPreviewSortMode; label: string }[] = [
{ key: "target_date_asc", label: "Ближайший срок" },
{ key: "target_date_desc", label: "Дальний срок" },
{ key: "start_date_asc", label: "Раннее начало" },
{ key: "created_at_desc", label: "Новые сверху" },
];
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,
};
});
};
const getTimelineGridLines = (startDate: Date, endDate: Date, dayWidth: number) => {
const days = getDaysBetween(startDate, endDate);
return Array.from({ length: days + 1 }, (_, index) => {
const date = addDays(startDate, index);
return {
id: date.toISOString(),
isMonthStart: date.getDate() === 1,
left: index * dayWidth,
};
});
};
const getShortDateLabel = (date: Date, locale: string) =>
new Intl.DateTimeFormat(locale || "ru-RU", {
day: "numeric",
month: "short",
})
.format(date)
.toUpperCase();
const getDateSortValue = (value?: string | null, emptyValue = Number.POSITIVE_INFINITY) =>
getDateFromValue(value)?.getTime() ?? emptyValue;
const isOverdueItem = (item: TGanttTimelinePreviewItem, today: Date) => {
const targetDate = getDateFromValue(item.target_date);
if (!targetDate) return false;
return targetDate < today && item.state_group !== "completed" && item.state_group !== "cancelled";
};
const matchesStatusFilters = (item: TGanttTimelinePreviewItem, activeStatusFilters: TGanttPreviewStatusFilter[]) => {
if (activeStatusFilters.length === 0) return true;
const stateGroup = item.state_group;
if (!stateGroup) return false;
return activeStatusFilters.some((filterKey) =>
GANTT_STATUS_FILTER_OPTIONS.find((option) => option.key === filterKey)?.groups.includes(stateGroup)
);
};
const sortPreviewItems = (items: TGanttTimelinePreviewItem[], sortMode: TGanttPreviewSortMode) =>
[...items].sort((firstItem, secondItem) => {
if (sortMode === "target_date_desc") {
return (
getDateSortValue(secondItem.target_date, Number.NEGATIVE_INFINITY) -
getDateSortValue(firstItem.target_date, Number.NEGATIVE_INFINITY)
);
}
if (sortMode === "start_date_asc") {
return getDateSortValue(firstItem.start_date) - getDateSortValue(secondItem.start_date);
}
if (sortMode === "created_at_desc") {
return (
getDateSortValue(secondItem.created_at, Number.NEGATIVE_INFINITY) -
getDateSortValue(firstItem.created_at, Number.NEGATIVE_INFINITY)
);
}
return getDateSortValue(firstItem.target_date) - getDateSortValue(secondItem.target_date);
});
export function GanttTimelinePreview(props: TGanttTimelinePreviewProps) {
const { emptyMessage, isLoading = false, items, locale } = props;
const [activeRange, setActiveRange] = useState<TGanttPreviewRange>("Live");
const [activePanel, setActivePanel] = useState<"filters" | "view" | null>(null);
const [activeDateFilters, setActiveDateFilters] = useState<TGanttPreviewDateFilter[]>([]);
const [activeStatusFilters, setActiveStatusFilters] = useState<TGanttPreviewStatusFilter[]>([]);
const [selectedPreviewItemId, setSelectedPreviewItemId] = useState<string | null>(null);
const [showFullTaskName, setShowFullTaskName] = useState(false);
const [sortMode, setSortMode] = useState<TGanttPreviewSortMode>("target_date_asc");
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
const visibleItems = useMemo(() => {
const today = startOfDay(new Date());
const filteredItems = items.filter((item) => {
if (!matchesStatusFilters(item, activeStatusFilters)) return false;
if (activeDateFilters.includes("overdue") && !isOverdueItem(item, today)) return false;
if (activeDateFilters.includes("complete-range") && (!item.start_date || !item.target_date)) return false;
return true;
});
return sortPreviewItems(filteredItems, sortMode);
}, [activeDateFilters, activeStatusFilters, items, sortMode]);
const timeline = useMemo(() => {
const settings = RANGE_SETTINGS[activeRange];
const { endDate, startDate, today } = getTimelineBounds(visibleItems, 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 = visibleItems
.map((item, index) => {
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,
gridLines: getTimelineGridLines(startDate, endDate, settings.dayWidth),
ticks: getTimelineTicks(startDate, endDate, settings.dayWidth, settings.tickStepDays, locale),
todayLabel: getShortDateLabel(today, locale),
timelineWidth,
todayLeft: getDaysBetween(startDate, today) * settings.dayWidth,
};
}, [activeRange, locale, visibleItems]);
const hiddenItemsCount = Math.max(items.length - timeline.blocks.length, 0);
const activeFilterCount =
activeDateFilters.length + activeStatusFilters.length + (sortMode === "target_date_asc" ? 0 : 1);
const selectedPreviewItem =
timeline.blocks.find((item) => item.id === selectedPreviewItemId) ?? timeline.blocks.find((item) => item.id === items[0]?.id);
const selectedPreviewItemDate = selectedPreviewItem?.target_date
? getDateFromValue(selectedPreviewItem.target_date)
: undefined;
const selectedPreviewItemStartDate = selectedPreviewItem?.start_date
? getDateFromValue(selectedPreviewItem.start_date)
: undefined;
const formatPreviewDate = (date?: Date) => (date ? getShortDateLabel(date, locale) : "Нет");
const toggleStatusFilter = (filterKey: TGanttPreviewStatusFilter) =>
setActiveStatusFilters((currentFilters) =>
currentFilters.includes(filterKey)
? currentFilters.filter((currentFilter) => currentFilter !== filterKey)
: [...currentFilters, filterKey]
);
const toggleDateFilter = (filterKey: TGanttPreviewDateFilter) =>
setActiveDateFilters((currentFilters) =>
currentFilters.includes(filterKey)
? currentFilters.filter((currentFilter) => currentFilter !== filterKey)
: [...currentFilters, filterKey]
);
const resetFilters = () => {
setActiveDateFilters([]);
setActiveStatusFilters([]);
setSortMode("target_date_asc");
};
useEffect(() => {
if (!selectedPreviewItemId) return;
if (timeline.blocks.some((item) => item.id === selectedPreviewItemId)) return;
setSelectedPreviewItemId(null);
}, [selectedPreviewItemId, timeline.blocks]);
useEffect(() => {
const scrollElement = scrollContainerRef.current;
if (!scrollElement || isLoading) return;
const todayCanvasLeft = GANTT_LABEL_COLUMN_WIDTH + GANTT_LABEL_GAP + timeline.todayLeft;
const nextScrollLeft = Math.max(todayCanvasLeft - scrollElement.clientWidth * 0.45, 0);
const frame = window.requestAnimationFrame(() => {
scrollElement.scrollTo({ behavior: "auto", left: nextScrollLeft });
});
return () => window.cancelAnimationFrame(frame);
}, [activeRange, isLoading, items.length, timeline.timelineWidth, timeline.todayLeft]);
return (
<section className="nodedc-home-gantt-card">
<div className="nodedc-home-gantt-toolbar">
<div className="nodedc-home-gantt-toolbar-spacer" aria-hidden="true" />
<div className="nodedc-home-gantt-controls">
<div className="nodedc-home-gantt-range-group" aria-label="Масштаб Ганта">
{GANTT_RANGES.map((item) => (
<button
key={item}
type="button"
aria-pressed={activeRange === item}
className={cn("nodedc-home-gantt-range-button", {
"nodedc-home-gantt-range-button-active": activeRange === item,
})}
onClick={() => setActiveRange(item)}
>
{item}
</button>
))}
</div>
<div className="nodedc-home-gantt-action-group">
<button
type="button"
className={cn("nodedc-home-gantt-round-button", {
"nodedc-home-gantt-round-button-active": showFullTaskName || activePanel === "view",
})}
aria-expanded={activePanel === "view"}
aria-label="Настройки вида Ганта"
aria-pressed={showFullTaskName}
onClick={() => setActivePanel((currentPanel) => (currentPanel === "view" ? null : "view"))}
>
<SlidersHorizontal className="size-4" />
</button>
<button
type="button"
className={cn("nodedc-home-gantt-round-button", {
"nodedc-home-gantt-round-button-active": activeFilterCount > 0 || activePanel === "filters",
"nodedc-home-gantt-filter-button-has-count": activeFilterCount > 0,
})}
aria-expanded={activePanel === "filters"}
aria-label="Фильтры задач Ганта"
aria-pressed={activeFilterCount > 0}
onClick={() => setActivePanel((currentPanel) => (currentPanel === "filters" ? null : "filters"))}
>
<Filter className="size-4" />
{activeFilterCount > 0 && <span className="nodedc-home-gantt-filter-count">{activeFilterCount}</span>}
</button>
{activePanel === "view" && (
<div className="nodedc-home-gantt-popover">
<div className="nodedc-home-gantt-popover-section">
<button
type="button"
className={cn("nodedc-home-gantt-popover-option", {
"nodedc-home-gantt-popover-option-active": showFullTaskName,
})}
onClick={() => setShowFullTaskName((value) => !value)}
>
<span className="nodedc-home-gantt-popover-option-left">
<span className="nodedc-home-gantt-popover-check">
{showFullTaskName && <Check className="size-3" />}
</span>
<span>Показать полное название</span>
</span>
</button>
</div>
</div>
)}
{activePanel === "filters" && (
<div className="nodedc-home-gantt-popover nodedc-home-gantt-popover-wide">
<div className="nodedc-home-gantt-popover-section">
<div className="nodedc-home-gantt-popover-title">Статус</div>
{GANTT_STATUS_FILTER_OPTIONS.map((option) => {
const isActive = activeStatusFilters.includes(option.key);
return (
<button
key={option.key}
type="button"
className={cn("nodedc-home-gantt-popover-option", {
"nodedc-home-gantt-popover-option-active": isActive,
})}
onClick={() => toggleStatusFilter(option.key)}
>
<span className="nodedc-home-gantt-popover-option-left">
<span className="nodedc-home-gantt-popover-check">
{isActive && <Check className="size-3" />}
</span>
<span>{option.label}</span>
</span>
</button>
);
})}
</div>
<div className="nodedc-home-gantt-popover-section">
<div className="nodedc-home-gantt-popover-title">Даты</div>
{GANTT_DATE_FILTER_OPTIONS.map((option) => {
const isActive = activeDateFilters.includes(option.key);
return (
<button
key={option.key}
type="button"
className={cn("nodedc-home-gantt-popover-option", {
"nodedc-home-gantt-popover-option-active": isActive,
})}
onClick={() => toggleDateFilter(option.key)}
>
<span className="nodedc-home-gantt-popover-option-left">
<span className="nodedc-home-gantt-popover-check">
{isActive && <Check className="size-3" />}
</span>
<span>{option.label}</span>
</span>
</button>
);
})}
</div>
<div className="nodedc-home-gantt-popover-section">
<div className="nodedc-home-gantt-popover-title">Сортировка</div>
{GANTT_SORT_OPTIONS.map((option) => {
const isActive = sortMode === option.key;
return (
<button
key={option.key}
type="button"
className={cn("nodedc-home-gantt-popover-option", {
"nodedc-home-gantt-popover-option-active": isActive,
})}
onClick={() => setSortMode(option.key)}
>
<span className="nodedc-home-gantt-popover-option-left">
<span className="nodedc-home-gantt-popover-check">
{isActive && <Check className="size-3" />}
</span>
<span>{option.label}</span>
</span>
</button>
);
})}
</div>
{activeFilterCount > 0 && (
<button type="button" className="nodedc-home-gantt-popover-reset" onClick={resetFilters}>
Сбросить фильтры
</button>
)}
</div>
)}
</div>
</div>
</div>
<div className="nodedc-home-gantt-surface">
<div
ref={scrollContainerRef}
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.gridLines.map((line) => (
<div
key={line.id}
className={cn("nodedc-home-gantt-grid-line", {
"nodedc-home-gantt-grid-line-major": line.isMonthStart,
})}
style={{ left: `${line.left}px` }}
/>
))}
{timeline.ticks.map((tick) => (
<div key={tick.id} className="nodedc-home-gantt-grid-label" style={{ left: `${tick.left}px` }}>
{tick.label}
</div>
))}
<div className="nodedc-home-gantt-today-marker" style={{ left: `${timeline.todayLeft}px` }}>
<span className="nodedc-home-gantt-today-pill">Сегодня · {timeline.todayLabel}</span>
</div>
</div>
{selectedPreviewItemId && selectedPreviewItem && (
<div className="nodedc-home-gantt-inspector">
<button
type="button"
className="nodedc-home-gantt-inspector-close"
aria-label="Закрыть карточку задачи"
onClick={() => setSelectedPreviewItemId(null)}
>
<X className="size-4" />
</button>
<div className="min-w-0">
<div className="text-[11px] font-semibold tracking-[0.18em] text-white/45 uppercase">
{selectedPreviewItem.identifier}
</div>
<div className="mt-2 text-17 font-semibold leading-snug text-white">{selectedPreviewItem.name}</div>
<div className="mt-4 grid grid-cols-2 gap-2 text-12">
<div className="rounded-[1rem] bg-black/20 px-3 py-2">
<div className="text-white/42">Начало</div>
<div className="mt-1 font-semibold text-white">{formatPreviewDate(selectedPreviewItemStartDate)}</div>
</div>
<div className="rounded-[1rem] bg-black/20 px-3 py-2">
<div className="text-white/42">Срок</div>
<div className="mt-1 font-semibold text-white">{formatPreviewDate(selectedPreviewItemDate)}</div>
</div>
</div>
<div className="mt-3 text-12 leading-5 text-white/58">
Клик по строке или полосе Ганта выбирает задачу без перехода в полную карточку.
</div>
</div>
</div>
)}
<div className="relative z-[1] space-y-3 pt-12">
{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-selected": selectedPreviewItemId === item.id,
})}
role="button"
tabIndex={0}
aria-label={`Показать карточку задачи ${item.name}`}
onClick={() => setSelectedPreviewItemId(item.id)}
onKeyDown={(event) => {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
setSelectedPreviewItemId(item.id);
}}
style={{
gridTemplateColumns: `${GANTT_LABEL_COLUMN_WIDTH}px ${timeline.timelineWidth}px`,
}}
>
<div className="nodedc-home-gantt-row-label min-w-0">
<div
className={cn("text-12 font-semibold text-primary", {
"nodedc-home-gantt-row-name-full": showFullTaskName,
truncate: !showFullTaskName,
})}
>
{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}`)}
role="button"
tabIndex={-1}
aria-label={`Показать карточку задачи ${item.name}`}
onClick={(event) => {
event.stopPropagation();
setSelectedPreviewItemId(item.id);
}}
style={{
left: `${item.left}px`,
width: `${item.width}px`,
}}
/>
</div>
</div>
))
) : (
<div className="nodedc-home-gantt-empty">
<div className="text-14 font-semibold text-primary">Нет задач с датами для Ганта</div>
<div className="mt-1 text-12 text-secondary">
{emptyMessage ??
"Добавьте start date или target date, чтобы задача появилась на календарной шкале."}
</div>
</div>
)}
</div>
{!isLoading && hiddenItemsCount > 0 && (
<div className="nodedc-home-gantt-footnote">
{hiddenItemsCount} задач скрыто фильтрами или без подходящего диапазона.
</div>
)}
</div>
</div>
</div>
</section>
);
}

View File

@ -44,19 +44,23 @@ export const IssuesSidebarBlock = observer(function IssuesSidebarBlock(props: Pr
return ( return (
<div <div
className={cn("nodedc-project-gantt-sidebar-block group/list-block", { className={cn("group/list-block", {
"nodedc-project-gantt-sidebar-block-dragging": isDragging, "rounded-sm bg-layer-1": isDragging,
"nodedc-project-gantt-sidebar-block-peeked": getIsIssuePeeked(block.data.id), "rounded-l-sm border border-r-0 border-accent-strong": getIsIssuePeeked(block.data.id),
"nodedc-project-gantt-sidebar-block-focused": isIssueFocused, "border border-r-0 border-strong-1": isIssueFocused,
})} })}
onMouseEnter={() => updateActiveBlockId(block.id)} onMouseEnter={() => updateActiveBlockId(block.id)}
onMouseLeave={() => updateActiveBlockId(null)} onMouseLeave={() => updateActiveBlockId(null)}
> >
<Row <Row
className={cn("nodedc-project-gantt-sidebar-row group flex w-full items-center gap-2 pr-4", { className={cn(
"nodedc-project-gantt-sidebar-row-hovered": isBlockHoveredOn, "group flex w-full items-center gap-2 bg-layer-transparent pr-4 hover:bg-layer-transparent-hover",
"nodedc-project-gantt-sidebar-row-selected": isIssueSelected, {
})} "bg-layer-transparent-hover": isBlockHoveredOn,
"bg-accent-primary/5 hover:bg-accent-primary/10": isIssueSelected,
"bg-accent-primary/10": isIssueSelected && isBlockHoveredOn,
}
)}
style={{ style={{
height: `${BLOCK_HEIGHT}px`, height: `${BLOCK_HEIGHT}px`,
}} }}

View File

@ -77,7 +77,7 @@ export const IssueGanttSidebar = observer(function IssueGanttSidebar(props: Prop
}; };
return ( return (
<div className="nodedc-project-gantt-sidebar-list"> <div>
{blockIds ? ( {blockIds ? (
<> <>
{blockIds.map((blockId, index) => { {blockIds.map((blockId, index) => {
@ -117,7 +117,7 @@ export const IssueGanttSidebar = observer(function IssueGanttSidebar(props: Prop
})} })}
{canLoadMoreBlocks && ( {canLoadMoreBlocks && (
<div ref={setIntersectionElement} className="p-2"> <div ref={setIntersectionElement} className="p-2">
<div className="nodedc-project-gantt-sidebar-loader flex h-10 w-full animate-pulse items-center justify-between gap-1.5 px-4 py-1.5 md:h-8 md:px-1" /> <div className="flex h-10 w-full animate-pulse items-center justify-between gap-1.5 rounded-sm bg-layer-1 px-4 py-1.5 md:h-8 md:px-1" />
</div> </div>
)} )}
</> </>

View File

@ -56,14 +56,14 @@ export const GanttChartSidebar = observer(function GanttChartSidebar(props: Prop
<Row <Row
// DO NOT REMOVE THE ID // DO NOT REMOVE THE ID
id="gantt-sidebar" id="gantt-sidebar"
className="nodedc-project-gantt-sidebar sticky left-0 z-10 h-max min-h-full flex-shrink-0" className="sticky left-0 z-10 h-max min-h-full flex-shrink-0 border-r-[0.5px] border-subtle-1 bg-surface-1"
style={{ style={{
width: `${SIDEBAR_WIDTH}px`, width: `${SIDEBAR_WIDTH}px`,
}} }}
variant={ERowVariant.HUGGING} variant={ERowVariant.HUGGING}
> >
<Row <Row
className="nodedc-project-gantt-sidebar-header group/list-header sticky top-0 z-10 box-border flex flex-shrink-0 items-end justify-between gap-2 pr-4 pb-3 text-12 font-semibold text-tertiary" className="group/list-header sticky top-0 z-10 box-border flex flex-shrink-0 items-end justify-between gap-2 border-b-[0.5px] border-subtle-1 bg-surface-1 pr-4 pb-2 text-13 font-medium text-tertiary"
style={{ style={{
height: `${HEADER_HEIGHT}px`, height: `${HEADER_HEIGHT}px`,
}} }}
@ -88,7 +88,7 @@ export const GanttChartSidebar = observer(function GanttChartSidebar(props: Prop
<h6>{t("common.duration")}</h6> <h6>{t("common.duration")}</h6>
</Row> </Row>
<Row variant={ERowVariant.HUGGING} className="nodedc-project-gantt-sidebar-body h-max min-h-full"> <Row variant={ERowVariant.HUGGING} className="h-max min-h-full bg-surface-1">
{sidebarToRender && {sidebarToRender &&
sidebarToRender({ sidebarToRender({
title, title,

View File

@ -15,7 +15,6 @@ import { cn } from "@plane/utils";
// hooks // hooks
import { useHome } from "@/hooks/store/use-home"; import { useHome } from "@/hooks/store/use-home";
import { useProject } from "@/hooks/store/use-project"; import { useProject } from "@/hooks/store/use-project";
import { useWorkspace } from "@/hooks/store/use-workspace";
// plane web components // plane web components
import { HomePageHeader } from "@/plane-web/components/home/header"; import { HomePageHeader } from "@/plane-web/components/home/header";
import { ProjectService } from "@/services/project"; import { ProjectService } from "@/services/project";
@ -24,7 +23,7 @@ import { WorkspaceService } from "@/services/workspace.service";
import { HomeCardShell } from "./home-card-shell"; import { HomeCardShell } from "./home-card-shell";
import { HomeGanttPreview } from "./home-gantt-preview"; import { HomeGanttPreview } from "./home-gantt-preview";
import { HomeRecentIssueDecks } from "./home-recent-issue-decks"; import { HomeRecentIssueDecks } from "./home-recent-issue-decks";
import { HomeActivityTrendCard, HomeOperationsCard, HomeRhythmRecentOverview } from "./home-project-insights"; import { HomeActivityTrendCard, HomeOperationsOverview } from "./home-project-insights";
import { HomeProjectStack } from "./home-project-stack"; import { HomeProjectStack } from "./home-project-stack";
import { aggregateProjectAnalytics, type THomeProjectData } from "./home.utils"; import { aggregateProjectAnalytics, type THomeProjectData } from "./home.utils";
import { StickiesWidget } from "../stickies/widget"; import { StickiesWidget } from "../stickies/widget";
@ -57,11 +56,6 @@ export const HOME_WIDGETS_LIST: {
fullWidth: false, fullWidth: false,
title: "stickies.title", title: "stickies.title",
}, },
project_latest_issues: {
component: null,
fullWidth: true,
title: "Последние задачи проекта",
},
new_at_plane: { new_at_plane: {
component: null, component: null,
fullWidth: false, fullWidth: false,
@ -87,7 +81,6 @@ export const DashboardWidgets = observer(function DashboardWidgets(props: Dashbo
const pathname = usePathname(); const pathname = usePathname();
// store hooks // store hooks
const { toggleWidgetSettings, widgetsMap, showWidgetSettings, loading } = useHome(); const { toggleWidgetSettings, widgetsMap, showWidgetSettings, loading } = useHome();
const { currentWorkspace } = useWorkspace();
const { loader, joinedProjectIds, getPartialProjectById, fetchProjectAnalyticsCount, getProjectAnalyticsCountById } = const { loader, joinedProjectIds, getPartialProjectById, fetchProjectAnalyticsCount, getProjectAnalyticsCountById } =
useProject(); useProject();
// plane hooks // plane hooks
@ -169,7 +162,6 @@ export const DashboardWidgets = observer(function DashboardWidgets(props: Dashbo
const isRecentsEnabled = !!widgetsMap.recents?.is_enabled; const isRecentsEnabled = !!widgetsMap.recents?.is_enabled;
const isQuickLinksEnabled = !!widgetsMap.quick_links?.is_enabled; const isQuickLinksEnabled = !!widgetsMap.quick_links?.is_enabled;
const isStickiesEnabled = !!widgetsMap.my_stickies?.is_enabled; const isStickiesEnabled = !!widgetsMap.my_stickies?.is_enabled;
const isProjectLatestIssuesEnabled = widgetsMap.project_latest_issues?.is_enabled ?? true;
const hasSecondaryWidgets = isQuickLinksEnabled || isStickiesEnabled; const hasSecondaryWidgets = isQuickLinksEnabled || isStickiesEnabled;
if (!workspaceSlugValue) return null; if (!workspaceSlugValue) return null;
@ -198,40 +190,37 @@ export const DashboardWidgets = observer(function DashboardWidgets(props: Dashbo
].filter(Boolean); ].filter(Boolean);
return ( return (
<div className="nodedc-home-dashboard-shell relative flex h-full w-full flex-col"> <div className="relative flex h-full w-full flex-col gap-6">
<HomePageHeader
currentUser={currentUser}
selectedProject={selectedProject}
selectedProjectAnalytics={selectedProjectAnalytics}
recents={workspaceRecents}
/>
<ManageWidgetsModal <ManageWidgetsModal
workspaceSlug={workspaceSlugValue} workspaceSlug={workspaceSlugValue}
isModalOpen={showWidgetSettings} isModalOpen={showWidgetSettings}
handleOnClose={() => toggleWidgetSettings(false)} handleOnClose={() => toggleWidgetSettings(false)}
/> />
<HomePageHeader <div className="grid gap-5 xl:grid-cols-[minmax(320px,360px)_minmax(0,1fr)] xl:items-stretch">
currentUser={currentUser} <div className="min-w-0">
selectedProject={selectedProject}
selectedProjectAnalytics={selectedProjectAnalytics}
recents={workspaceRecents}
workspaceName={currentWorkspace?.name}
/>
<div className="nodedc-home-dashboard-grid grid xl:grid-cols-[minmax(320px,360px)_minmax(0,1fr)] xl:items-stretch">
<div className="flex min-w-0">
<HomeProjectStack <HomeProjectStack
className="h-full" className="h-full"
projects={homeProjects} projects={homeProjects}
analyticsMap={analyticsMap} analyticsMap={analyticsMap}
recents={workspaceRecents} recents={workspaceRecents}
selectedProjectId={selectedProjectId} selectedProjectId={selectedProjectId}
workspaceSlug={workspaceSlugValue}
onSelectProject={setSelectedProjectId} onSelectProject={setSelectedProjectId}
/> />
</div> </div>
<div className="nodedc-home-main-column min-w-0"> <div className="min-w-0 space-y-5">
<HomeGanttPreview <HomeGanttPreview
project={selectedProject} project={selectedProject}
analytics={selectedProjectAnalytics} analytics={selectedProjectAnalytics}
workspaceSlug={workspaceSlugValue} workspaceSlug={workspaceSlugValue}
/> />
<HomeRhythmRecentOverview <HomeOperationsOverview
project={selectedProject} project={selectedProject}
analytics={selectedProjectAnalytics} analytics={selectedProjectAnalytics}
analyticsCollection={analyticsCollection} analyticsCollection={analyticsCollection}
@ -242,26 +231,15 @@ export const DashboardWidgets = observer(function DashboardWidgets(props: Dashbo
</div> </div>
</div> </div>
<div className="nodedc-home-lower-grid grid xl:grid-cols-[minmax(320px,360px)_minmax(0,1fr)]"> <HomeActivityTrendCard
<HomeOperationsCard project={selectedProject}
project={selectedProject} analytics={selectedProjectAnalytics}
analytics={selectedProjectAnalytics} analyticsCollection={analyticsCollection}
analyticsCollection={analyticsCollection} recents={workspaceRecents}
recents={workspaceRecents} locale={currentLocale}
locale={currentLocale} />
/>
<HomeActivityTrendCard
project={selectedProject}
analytics={selectedProjectAnalytics}
analyticsCollection={analyticsCollection}
recents={workspaceRecents}
locale={currentLocale}
/>
</div>
{isProjectLatestIssuesEnabled && ( <HomeRecentIssueDecks project={selectedProject} workspaceSlug={workspaceSlugValue} />
<HomeRecentIssueDecks project={selectedProject} workspaceSlug={workspaceSlugValue} />
)}
<div className="space-y-5"> <div className="space-y-5">
{!isWikiApp && <NoProjectsEmptyState />} {!isWikiApp && <NoProjectsEmptyState />}

View File

@ -4,18 +4,17 @@
* 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 { import { cn } from "@plane/utils";
GanttTimelinePreview,
type TGanttTimelinePreviewItem,
} from "@/components/gantt-chart/preview/timeline-preview";
import { IssueService } from "@/services/issue"; import { IssueService } from "@/services/issue";
import type { THomeProjectData } from "./home.utils"; import { getCompletionRate, type THomeProjectData } from "./home.utils";
const issueService = new IssueService(); const issueService = new IssueService();
const GANTT_PREVIEW_LIMIT = 30; const GANTT_PREVIEW_LIMIT = 6;
const GANTT_PREVIEW_CURSOR = `${GANTT_PREVIEW_LIMIT}:0:0`; const GANTT_PREVIEW_CURSOR = `${GANTT_PREVIEW_LIMIT}:0:0`;
type HomeGanttPreviewProps = { type HomeGanttPreviewProps = {
@ -24,42 +23,85 @@ type HomeGanttPreviewProps = {
workspaceSlug: string; workspaceSlug: string;
}; };
const getIssueResults = (response: unknown): TIssue[] => { type TGanttPreviewItem = {
if (Array.isArray(response)) return response as TIssue[]; id: string;
if (response && typeof response === "object" && "results" in response) { label: string;
const results = (response as { results?: unknown }).results; subtitle: string;
if (Array.isArray(results)) return results as TIssue[]; start: number;
} width: number;
tone: "accent" | "muted" | "white";
return [];
}; };
const getIssueStateGroup = (issue: TIssue): TGanttTimelinePreviewItem["state_group"] => const GANTT_RANGES = ["Live", "1D", "1W", "1M"] as const;
issue.state__group ?? type TGanttRange = (typeof GANTT_RANGES)[number];
(issue as TIssue & { state_detail?: { group?: TGanttTimelinePreviewItem["state_group"] } }).state_detail?.group ??
null;
const buildPreviewItems = (issues: TIssue[], project: THomeProjectData | undefined): TGanttTimelinePreviewItem[] => const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max);
issues.map((issue, index) => ({
assignee_ids: issue.assignee_ids, const buildSyntheticItems = (project: THomeProjectData | undefined, analytics: TProjectAnalyticsCount | undefined) => {
completed_at: issue.completed_at, const completionRate = getCompletionRate(analytics);
created_at: issue.created_at, const openIssues = Math.max((analytics?.total_issues ?? 0) - (analytics?.completed_issues ?? 0), 0);
created_by: issue.created_by, const baseName = project?.identifier ?? "NODE";
id: issue.id,
identifier: project return [
? `${project.identifier}-${issue.sequence_id ?? index + 1}` {
: `#${issue.sequence_id ?? index + 1}`, id: "synthetic-approval",
name: issue.name, label: "Согласование расходов",
priority: issue.priority, subtitle: `${baseName} / финконтроль`,
sort_order: issue.sort_order, start: 6,
start_date: issue.start_date, width: clamp(34 + completionRate * 0.22, 26, 58),
state_group: getIssueStateGroup(issue), tone: "accent",
target_date: issue.target_date, },
})); {
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) { export function HomeGanttPreview(props: HomeGanttPreviewProps) {
const { project, workspaceSlug } = props; const { analytics, 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,
@ -78,17 +120,139 @@ export function HomeGanttPreview(props: HomeGanttPreviewProps) {
} }
); );
const issues = getIssueResults(issueResponse); const timelineLabels = useMemo(() => {
const previewItems = buildPreviewItems(issues, project); 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 ( return (
<GanttTimelinePreview <section className="nodedc-home-gantt-card">
emptyMessage="На главной показываются только задачи выбранного проекта, у которых заполнены start date или target date." <div className="nodedc-home-gantt-toolbar">
isLoading={isLoading} <div className="flex min-w-0 items-center gap-3">
items={previewItems} <div className="grid size-10 shrink-0 place-items-center rounded-full bg-black text-[rgb(var(--nodedc-card-active-rgb))]">
locale={currentLocale} <CalendarDays className="size-4" />
title="Календарное окно Ганта" </div>
subtitle={project ? `${project.name} / реальные даты задач` : "Выберите проект для календарного окна"} <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>
); );
} }

View File

@ -293,125 +293,111 @@ export function HomeActivityTrendCard(props: HomeProjectInsightsProps) {
); );
} }
export function HomeRhythmCard(props: HomeProjectInsightsProps) { export function HomeOperationsOverview(props: HomeProjectInsightsProps) {
const { recentActivitySlot } = props;
const { const {
completedIssues, completedIssues,
completionRate,
metricCards, metricCards,
openIssues, openIssues,
progressRows,
project, project,
recentTouchpoints, recentTouchpoints,
totalIssues, totalIssues,
} = useHomeProjectInsightData(props); } = useHomeProjectInsightData(props);
return ( return (
<section className="nodedc-home-subpanel nodedc-home-rhythm-card space-y-4 p-5"> <section className="grid gap-4 xl:grid-cols-[minmax(0,0.95fr)_minmax(0,0.95fr)_minmax(300px,1.1fr)]">
<div className="flex items-center justify-between gap-3"> <div className="nodedc-home-subpanel space-y-4 p-5">
<div> <div className="flex items-center gap-3">
<div className="text-15 font-semibold text-primary">Ритм исполнения</div> <div className="grid size-11 place-items-center rounded-2xl bg-[rgba(var(--nodedc-accent-rgb),0.14)] text-[rgb(var(--nodedc-accent-rgb))]">
<div className="text-12 text-secondary">Закрытый объём и открытый остаток по фокусу.</div> <UsersRound className="size-5" />
</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>
</div> <div className="text-15 font-semibold text-primary">Операционный срез</div>
<div className="text-12 text-secondary">Команда, циклы и модули относительно текущего workspace.</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> </div>
<div className="space-y-2"> <div className="space-y-4">
<div className="flex items-center justify-between gap-3 text-12"> {progressRows.map((row) => {
<span className="text-secondary">Открытый остаток</span> const percent = row.max > 0 ? Math.max((row.value / row.max) * 100, row.value > 0 ? 10 : 0) : 0;
<span className="font-semibold text-primary">{openIssues}</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"> return (
<span className="font-semibold text-primary">{project ? project.identifier : "Workspace"}</span> <div key={row.label} className="space-y-2">
<span> держит </span> <div className="flex items-center justify-between gap-3 text-12">
<span className="font-semibold text-primary">{totalIssues}</span> <span className="text-secondary">{row.label}</span>
<span> задач и </span> <span className="font-semibold text-primary">{row.value}</span>
<span className="font-semibold text-primary">{recentTouchpoints}</span> </div>
<span> недавних касаний.</span> <div className="nodedc-home-focus-track">
</div> <div className="nodedc-home-focus-fill" style={{ width: `${Math.min(percent, 100)}%` }} />
</section> </div>
);
}
export function HomeOperationsCard(props: HomeProjectInsightsProps) {
const {
progressRows,
} = useHomeProjectInsightData(props);
return (
<section className="nodedc-home-subpanel nodedc-home-operations-card 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">
{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>
<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="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="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> </div>
</section>
);
}
export function HomeRhythmRecentOverview(props: HomeProjectInsightsProps) {
const { recentActivitySlot } = props;
const { completionRate } = useHomeProjectInsightData(props);
return (
<section className="nodedc-home-ops-recent-grid grid gap-4 xl:grid-cols-[minmax(0,0.95fr)_minmax(320px,1.05fr)]">
<HomeRhythmCard {...props} />
<div className="nodedc-home-subpanel p-5"> <div className="nodedc-home-subpanel p-5">
{recentActivitySlot ? ( {recentActivitySlot ? (
@ -437,15 +423,6 @@ export function HomeRhythmRecentOverview(props: HomeProjectInsightsProps) {
); );
} }
export function HomeOperationsOverview(props: HomeProjectInsightsProps) {
return (
<div className="grid gap-4">
<HomeRhythmRecentOverview {...props} />
<HomeOperationsCard {...props} />
</div>
);
}
export function HomeProjectInsights(props: HomeProjectInsightsProps) { export function HomeProjectInsights(props: HomeProjectInsightsProps) {
return ( return (
<div className="grid gap-5"> <div className="grid gap-5">

View File

@ -5,8 +5,6 @@
*/ */
import { FolderOpenDot, Layers3, Search, UsersRound } from "lucide-react"; import { FolderOpenDot, Layers3, Search, UsersRound } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import type { TActivityEntityData, TProjectAnalyticsCount } from "@plane/types"; import type { TActivityEntityData, TProjectAnalyticsCount } from "@plane/types";
import { Logo } from "@plane/propel/emoji-icon-picker"; import { Logo } from "@plane/propel/emoji-icon-picker";
import { cn } from "@plane/utils"; import { cn } from "@plane/utils";
@ -21,7 +19,6 @@ type HomeProjectStackProps = {
orientation?: "horizontal" | "vertical"; orientation?: "horizontal" | "vertical";
recents?: TActivityEntityData[]; recents?: TActivityEntityData[];
selectedProjectId: string | null; selectedProjectId: string | null;
workspaceSlug: string;
onSelectProject: (projectId: string) => void; onSelectProject: (projectId: string) => void;
}; };
@ -30,7 +27,6 @@ const ACTIVE_CARD_HEIGHT = 248;
const STACK_OFFSET = 88; const STACK_OFFSET = 88;
export function HomeProjectStack(props: HomeProjectStackProps) { export function HomeProjectStack(props: HomeProjectStackProps) {
const router = useRouter();
const { const {
className, className,
projects, projects,
@ -38,7 +34,6 @@ export function HomeProjectStack(props: HomeProjectStackProps) {
orientation = "vertical", orientation = "vertical",
recents, recents,
selectedProjectId, selectedProjectId,
workspaceSlug,
onSelectProject, onSelectProject,
} = props; } = props;
@ -92,26 +87,16 @@ export function HomeProjectStack(props: HomeProjectStackProps) {
const isActive = project.id === selectedProject?.id; const isActive = project.id === selectedProject?.id;
return ( return (
<div <button
key={project.id} key={project.id}
type="button"
className={cn("nodedc-home-project-card text-left", { className={cn("nodedc-home-project-card text-left", {
"nodedc-home-project-card-horizontal shrink-0": horizontal, "nodedc-home-project-card-horizontal shrink-0": horizontal,
"absolute inset-x-0": !horizontal, "absolute inset-x-0": !horizontal,
"cursor-default": isActive, "cursor-default": isActive,
"cursor-pointer": !isActive,
})} })}
data-active={isActive} data-active={isActive}
role="button"
tabIndex={0}
aria-label={`Выбрать проект ${project.name}`}
onClick={() => onSelectProject(project.id)} onClick={() => onSelectProject(project.id)}
onKeyDown={(event) => {
if (event.target !== event.currentTarget) return;
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
onSelectProject(project.id);
}}
style={ style={
horizontal horizontal
? { zIndex: visibleProjects.length - index } ? { zIndex: visibleProjects.length - index }
@ -130,15 +115,10 @@ export function HomeProjectStack(props: HomeProjectStackProps) {
<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="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="relative flex h-full flex-col justify-between p-4">
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<Link <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">
href={`/${workspaceSlug}/projects/${project.id}/issues`}
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 transition hover:bg-black/40 hover:text-white focus-visible:outline focus-visible:outline-2 focus-visible:outline-[rgb(var(--nodedc-accent-rgb))]"
aria-label={`Открыть проект ${project.name}`}
onClick={(event) => event.stopPropagation()}
>
<Logo logo={project.logo_props} size={14} /> <Logo logo={project.logo_props} size={14} />
<span>{project.identifier}</span> <span>{project.identifier}</span>
</Link> </div>
<div <div
className={cn( className={cn(
"rounded-full px-2.5 py-1 text-[11px] font-semibold backdrop-blur-md", "rounded-full px-2.5 py-1 text-[11px] font-semibold backdrop-blur-md",
@ -175,7 +155,7 @@ export function HomeProjectStack(props: HomeProjectStackProps) {
</div> </div>
</div> </div>
</div> </div>
</div> </button>
); );
}; };
@ -203,7 +183,7 @@ export function HomeProjectStack(props: HomeProjectStackProps) {
</div> </div>
)} )}
<div className="nodedc-home-project-quick-section mt-4 rounded-[24px] bg-black/10 p-4 xl:mt-auto"> <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 className="mb-3 flex items-center justify-between gap-3">
<div> <div>
<div className="text-13 font-semibold text-primary">Быстрый выбор</div> <div className="text-13 font-semibold text-primary">Быстрый выбор</div>
@ -215,64 +195,45 @@ export function HomeProjectStack(props: HomeProjectStackProps) {
</div> </div>
</div> </div>
<div className="nodedc-home-project-quick-list"> <div className="flex flex-wrap gap-2">
{orderedProjects.map((project: THomeProjectData) => { {orderedProjects.map((project: THomeProjectData) => {
const analytics = analyticsMap[project.id]; const analytics = analyticsMap[project.id];
const isActive = project.id === selectedProject?.id;
return ( return (
<button <button
key={project.id} key={project.id}
type="button" type="button"
className="nodedc-home-project-quick-button" className={cn("nodedc-toolbar-pill inline-flex items-center gap-2", {
data-active={isActive} "!bg-[rgb(var(--nodedc-card-active-rgb))] !text-[rgb(var(--nodedc-on-card-active-rgb))]":
aria-label={ project.id === selectedProject?.id,
isActive ? `Открыть рабочую область проекта ${project.name}` : `Выбрать проект ${project.name}` })}
} onClick={() => onSelectProject(project.id)}
onClick={() => {
if (isActive) {
router.push(`/${workspaceSlug}/projects/${project.id}/issues`);
return;
}
onSelectProject(project.id);
}}
> >
<span className="nodedc-home-project-quick-main"> <Logo logo={project.logo_props} size={14} />
<span className="nodedc-home-project-quick-logo"> <span>{project.identifier}</span>
<Logo logo={project.logo_props} size={14} /> <span className="text-[11px] opacity-70">{getCompletionRate(analytics)}%</span>
</span>
<span className="truncate">{project.identifier}</span>
</span>
<span className="nodedc-home-project-quick-metric">
<span className="nodedc-home-project-quick-dot" aria-hidden="true" />
<span>{getCompletionRate(analytics)}%</span>
</span>
</button> </button>
); );
})} })}
</div> </div>
{selectedProject && ( {selectedProject && (
<div className="nodedc-home-project-focus-grid mt-4 grid grid-cols-2 gap-3 md:grid-cols-3"> <div className="mt-4 grid grid-cols-2 gap-3 rounded-[22px] bg-white/[0.04] p-3 md:grid-cols-3">
<div className="nodedc-home-project-focus-item px-3 py-2"> <div className="rounded-2xl bg-black/10 px-3 py-2">
<div className="text-[11px] tracking-[0.18em] text-placeholder uppercase">Фокус</div> <div className="text-[11px] tracking-[0.18em] text-placeholder uppercase">Фокус</div>
<div className="nodedc-home-project-focus-value mt-1 text-13 font-semibold text-primary"> <div className="mt-1 text-13 font-semibold text-primary">{selectedProject.identifier}</div>
{selectedProject.identifier}
</div>
</div> </div>
<div className="nodedc-home-project-focus-item px-3 py-2"> <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"> <div className="flex items-center gap-1 text-[11px] tracking-[0.18em] text-placeholder uppercase">
<UsersRound className="size-3.5" /> <UsersRound className="size-3.5" />
<span>Команда</span> <span>Команда</span>
</div> </div>
<div className="nodedc-home-project-focus-value mt-1 text-13 font-semibold text-primary"> <div className="mt-1 text-13 font-semibold text-primary">
{analyticsMap[selectedProject.id]?.total_members ?? 0} {analyticsMap[selectedProject.id]?.total_members ?? 0}
</div> </div>
</div> </div>
<div className="nodedc-home-project-focus-item px-3 py-2"> <div className="rounded-2xl bg-black/10 px-3 py-2">
<div className="text-[11px] tracking-[0.18em] text-placeholder uppercase">Контур</div> <div className="text-[11px] tracking-[0.18em] text-placeholder uppercase">Контур</div>
<div className="nodedc-home-project-focus-value mt-1 text-13 font-semibold text-primary"> <div className="mt-1 text-13 font-semibold text-primary">
{activityCountByProject[selectedProject.id] ?? 0} касаний {activityCountByProject[selectedProject.id] ?? 0} касаний
</div> </div>
</div> </div>

View File

@ -34,13 +34,11 @@ const EXTERNAL_DECK_LIMIT = 10;
const INTERNAL_CURSOR = `${INTERNAL_DECK_LIMIT}:0:0`; const INTERNAL_CURSOR = `${INTERNAL_DECK_LIMIT}:0:0`;
type HomeRecentIssueDecksProps = { type HomeRecentIssueDecksProps = {
compact?: boolean;
project?: THomeProjectData; project?: THomeProjectData;
workspaceSlug: string; workspaceSlug: string;
}; };
type DeckSectionProps = { type DeckSectionProps = {
compact?: boolean;
count: number; count: number;
description: string; description: string;
emptyDescription: string; emptyDescription: string;
@ -51,7 +49,6 @@ type DeckSectionProps = {
}; };
type InternalIssueCardProps = { type InternalIssueCardProps = {
compact?: boolean;
isActive: boolean; isActive: boolean;
issue: TIssue; issue: TIssue;
onSelect: () => void; onSelect: () => void;
@ -59,7 +56,6 @@ type InternalIssueCardProps = {
}; };
type ExternalIssueCardProps = { type ExternalIssueCardProps = {
compact?: boolean;
isActive: boolean; isActive: boolean;
onSelect: () => void; onSelect: () => void;
project: THomeProjectData; project: THomeProjectData;
@ -80,14 +76,14 @@ const sortByRecentCreatedDate = <
}; };
const DeckSection = (props: DeckSectionProps) => { const DeckSection = (props: DeckSectionProps) => {
const { compact = false, count, description, emptyDescription, emptyTitle, isLoading, items, title } = props; const { count, description, emptyDescription, emptyTitle, isLoading, items, title } = props;
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-3"> <div className="flex flex-wrap items-center justify-between gap-3">
<div> <div>
<div className="text-14 font-semibold text-primary">{title}</div> <div className="text-14 font-semibold text-primary">{title}</div>
{!compact && <div className="text-12 text-secondary">{description}</div>} <div className="text-12 text-secondary">{description}</div>
</div> </div>
<div className="nodedc-toolbar-pill inline-flex items-center gap-2"> <div className="nodedc-toolbar-pill inline-flex items-center gap-2">
<Sparkles className="size-3.5" /> <Sparkles className="size-3.5" />
@ -95,24 +91,16 @@ const DeckSection = (props: DeckSectionProps) => {
</div> </div>
</div> </div>
<div <div className="nodedc-home-task-deck-scroller">
className={cn("nodedc-home-task-deck-scroller", { <div className="flex min-h-[236px] items-end px-1 py-4">
"nodedc-home-task-deck-scroller-compact": compact,
})}
>
<div
className={cn("flex items-start px-1 py-2", {
"min-h-[236px] gap-4": !compact,
"nodedc-home-task-deck-row-compact min-h-[172px]": compact,
})}
>
{isLoading {isLoading
? Array.from({ length: 4 }, (_, index) => ( ? Array.from({ length: 4 }, (_, index) => (
<div <div
key={`skeleton-${title}-${index}`} key={`skeleton-${title}-${index}`}
className={cn("nodedc-home-task-card nodedc-home-task-card-skeleton animate-pulse", { className={cn("nodedc-home-task-card nodedc-home-task-card-skeleton animate-pulse", {
"nodedc-home-task-card-compact": compact, "-ml-16": index > 0,
})} })}
style={{ zIndex: 5 - index }}
/> />
)) ))
: items} : items}
@ -130,7 +118,7 @@ const DeckSection = (props: DeckSectionProps) => {
}; };
const HomeInternalContourDeckCard = observer(function HomeInternalContourDeckCard(props: InternalIssueCardProps) { const HomeInternalContourDeckCard = observer(function HomeInternalContourDeckCard(props: InternalIssueCardProps) {
const { compact = false, isActive, issue, onSelect, project } = props; const { isActive, issue, onSelect, project } = props;
const { t } = useTranslation(); const { t } = useTranslation();
const { getProjectById } = useProject(); const { getProjectById } = useProject();
const { getUserDetails } = useMember(); const { getUserDetails } = useMember();
@ -195,7 +183,7 @@ const HomeInternalContourDeckCard = observer(function HomeInternalContourDeckCar
const footer = ( const footer = (
<> <>
<div className={cn("inline-flex min-h-9 items-center rounded-full pr-2 pl-1", pillBackgroundClasses)}> <div className={cn("inline-flex min-h-9 items-center rounded-full pl-1 pr-2", pillBackgroundClasses)}>
{(issue.assignee_ids?.length ?? 0) > 0 ? ( {(issue.assignee_ids?.length ?? 0) > 0 ? (
<ButtonAvatars showTooltip={false} userIds={issue.assignee_ids ?? []} size="sm" /> <ButtonAvatars showTooltip={false} userIds={issue.assignee_ids ?? []} size="sm" />
) : ( ) : (
@ -203,12 +191,7 @@ const HomeInternalContourDeckCard = observer(function HomeInternalContourDeckCar
)} )}
</div> </div>
<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)}>
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" /> <CalendarDays className="h-3.5 w-3.5" />
<span className="truncate">{dueDateLabel}</span> <span className="truncate">{dueDateLabel}</span>
</div> </div>
@ -216,25 +199,14 @@ const HomeInternalContourDeckCard = observer(function HomeInternalContourDeckCar
); );
return ( return (
<button <button type="button" className="nodedc-home-task-card" data-active={isActive} onClick={onSelect} title={issue.name}>
type="button"
className={cn("nodedc-home-task-card", { "nodedc-home-task-card-compact": compact })}
data-active={isActive}
onClick={onSelect}
title={issue.name}
>
<NodedcWorkItemCard <NodedcWorkItemCard
isActive={isActive} isActive={isActive}
priority={issue.priority}
surfaceClassName={cn( surfaceClassName={cn(
"nodedc-home-task-card-surface px-0", "nodedc-home-task-card-surface px-0",
compact && "!rounded-[24px]",
isActive ? "nodedc-home-task-card-surface-active" : "nodedc-home-task-card-surface-passive" isActive ? "nodedc-home-task-card-surface-active" : "nodedc-home-task-card-surface-passive"
)} )}
contentClassName={cn("px-1", compact && "min-h-[168px]")} contentClassName="px-1"
titleContainerClassName={cn(compact && "px-3 py-3")}
titleClassName={cn(compact && "text-base leading-5")}
footerClassName={cn(compact && "gap-2")}
header={header} header={header}
subtitle={sourceContourName} subtitle={sourceContourName}
title={issue.name} title={issue.name}
@ -245,13 +217,15 @@ const HomeInternalContourDeckCard = observer(function HomeInternalContourDeckCar
}); });
const HomeExternalContourDeckCard = observer(function HomeExternalContourDeckCard(props: ExternalIssueCardProps) { const HomeExternalContourDeckCard = observer(function HomeExternalContourDeckCard(props: ExternalIssueCardProps) {
const { compact = false, isActive, onSelect, project, request } = props; const { isActive, onSelect, project, request } = props;
const { t } = useTranslation(); const { t } = useTranslation();
const { getStateById } = useProjectState(); const { getStateById } = useProjectState();
const { iconBubbleClasses, pillBackgroundClasses } = getNodedcWorkItemCardAppearance(isActive); const { iconBubbleClasses, pillBackgroundClasses } = getNodedcWorkItemCardAppearance(isActive);
const issue = request.issue; const issue = request.issue;
const isOutgoing = request.direction ? request.direction === "outgoing" : request.source_project_id === project.id; const isOutgoing = request.direction
? request.direction === "outgoing"
: request.source_project_id === project.id;
const requester = const requester =
request.requested_by?.display_name || request.requested_by?.display_name ||
request.requested_by_name || request.requested_by_name ||
@ -268,29 +242,15 @@ const HomeExternalContourDeckCard = observer(function HomeExternalContourDeckCar
const dueDateLabel = issue.target_date ? renderFormattedDate(issue.target_date, "d MMM, yyyy") : t("common.none"); const dueDateLabel = issue.target_date ? renderFormattedDate(issue.target_date, "d MMM, yyyy") : t("common.none");
return ( return (
<button <button type="button" className="nodedc-home-task-card" data-active={isActive} onClick={onSelect} title={issue.name}>
type="button"
className={cn("nodedc-home-task-card", { "nodedc-home-task-card-compact": compact })}
data-active={isActive}
onClick={onSelect}
title={issue.name}
>
<div <div
data-active={isActive} data-active={isActive}
data-priority={issue.priority ?? "none"}
className={cn( className={cn(
"nodedc-external-card nodedc-home-task-card-surface relative flex w-full flex-col", "nodedc-external-card nodedc-home-task-card-surface relative flex min-h-[220px] w-full flex-col p-4",
compact ? "min-h-[168px] rounded-[24px] p-3" : "min-h-[220px] p-4",
isActive ? "nodedc-home-task-card-surface-active" : "nodedc-home-task-card-surface-passive" isActive ? "nodedc-home-task-card-surface-active" : "nodedc-home-task-card-surface-passive"
)} )}
> >
<div <div className={cn("relative flex min-h-[220px] flex-col px-1", isActive ? "text-[rgb(var(--nodedc-on-card-active-rgb))]" : "text-white")}>
className={cn(
"relative flex flex-col px-1",
compact ? "min-h-[168px]" : "min-h-[220px]",
isActive ? "text-[rgb(var(--nodedc-on-card-active-rgb))]" : "text-white/70"
)}
>
<div className="space-y-0.5"> <div className="space-y-0.5">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 flex-1 items-center gap-3"> <div className="flex min-w-0 flex-1 items-center gap-3">
@ -323,7 +283,7 @@ const HomeExternalContourDeckCard = observer(function HomeExternalContourDeckCar
<div <div
className={cn( className={cn(
"-mt-0.5 truncate pl-8 text-[11px] leading-4 font-medium", "truncate -mt-0.5 pl-8 text-[11px] font-medium leading-4",
isActive ? "text-[rgb(var(--nodedc-on-card-active-rgb))]/72" : "text-[#B3B3B8]" isActive ? "text-[rgb(var(--nodedc-on-card-active-rgb))]/72" : "text-[#B3B3B8]"
)} )}
> >
@ -331,21 +291,12 @@ const HomeExternalContourDeckCard = observer(function HomeExternalContourDeckCar
</div> </div>
</div> </div>
<div <div className="flex flex-1 items-center justify-center px-5 py-4 text-center">
className={cn("flex flex-1 items-center justify-center text-center", compact ? "px-3 py-3" : "px-5 py-4")} <div className="line-clamp-4 max-w-full text-lg font-semibold leading-6">{issue.name}</div>
>
<div
className={cn(
"line-clamp-4 max-w-full font-semibold",
compact ? "text-base leading-5" : "text-lg leading-6"
)}
>
{issue.name}
</div>
</div> </div>
<div className={cn("flex items-center justify-between", compact ? "gap-2" : "gap-3")}> <div className="flex items-center justify-between gap-3">
<div className={cn("inline-flex min-h-9 items-center rounded-full pr-2 pl-1", pillBackgroundClasses)}> <div className={cn("inline-flex min-h-9 items-center rounded-full pl-1 pr-2", pillBackgroundClasses)}>
{(issue.assignee_ids?.length ?? 0) > 0 ? ( {(issue.assignee_ids?.length ?? 0) > 0 ? (
<ButtonAvatars showTooltip={false} userIds={issue.assignee_ids ?? []} size="sm" /> <ButtonAvatars showTooltip={false} userIds={issue.assignee_ids ?? []} size="sm" />
) : ( ) : (
@ -353,12 +304,7 @@ const HomeExternalContourDeckCard = observer(function HomeExternalContourDeckCar
)} )}
</div> </div>
<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)}>
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" /> <CalendarDays className="h-3.5 w-3.5" />
<span className="truncate">{dueDateLabel}</span> <span className="truncate">{dueDateLabel}</span>
</div> </div>
@ -370,7 +316,7 @@ const HomeExternalContourDeckCard = observer(function HomeExternalContourDeckCar
}); });
export const HomeRecentIssueDecks = observer(function HomeRecentIssueDecks(props: HomeRecentIssueDecksProps) { export const HomeRecentIssueDecks = observer(function HomeRecentIssueDecks(props: HomeRecentIssueDecksProps) {
const { compact = false, project, workspaceSlug } = props; const { project, workspaceSlug } = props;
const [selectedInternalIssueId, setSelectedInternalIssueId] = useState<string | null>(null); const [selectedInternalIssueId, setSelectedInternalIssueId] = useState<string | null>(null);
const [selectedExternalRequestId, setSelectedExternalRequestId] = useState<string | null>(null); const [selectedExternalRequestId, setSelectedExternalRequestId] = useState<string | null>(null);
@ -436,13 +382,9 @@ export const HomeRecentIssueDecks = observer(function HomeRecentIssueDecks(props
if (!project) { if (!project) {
return ( return (
<HomeCardShell <HomeCardShell
eyebrow={compact ? "Последние задачи" : "Task Decks"} eyebrow="Task Decks"
title={compact ? "Последние задачи проекта" : "Последние задачи по проекту"} title="Последние задачи по проекту"
description={ description="Выберите проект слева, и здесь появятся колоды последних задач внешнего и внутреннего контуров."
compact
? undefined
: "Выберите проект слева, и здесь появятся колоды последних задач внешнего и внутреннего контуров."
}
> >
<div className="rounded-[24px] border border-white/6 bg-black/10 px-5 py-6 text-13 text-secondary"> <div className="rounded-[24px] border border-white/6 bg-black/10 px-5 py-6 text-13 text-secondary">
Фокус проекта пока не выбран. Фокус проекта пока не выбран.
@ -451,63 +393,61 @@ export const HomeRecentIssueDecks = observer(function HomeRecentIssueDecks(props
); );
} }
const internalIssueCards = internalIssues.map((issue) => ( const internalIssueCards = internalIssues.map((issue, index) => (
<HomeInternalContourDeckCard <div
compact={compact}
key={issue.id} key={issue.id}
issue={issue} className={cn({ "-ml-16": index > 0 })}
isActive={issue.id === selectedInternalIssueId} style={{ zIndex: issue.id === selectedInternalIssueId ? internalIssues.length + 6 : index + 1 }}
onSelect={() => setSelectedInternalIssueId(issue.id)} >
project={project} <HomeInternalContourDeckCard
/> issue={issue}
isActive={issue.id === selectedInternalIssueId}
onSelect={() => setSelectedInternalIssueId(issue.id)}
project={project}
/>
</div>
)); ));
const externalIssueCards = externalRequests.map((request) => ( const externalIssueCards = externalRequests.map((request, index) => (
<HomeExternalContourDeckCard <div
compact={compact}
key={request.id} key={request.id}
isActive={request.id === selectedExternalRequestId} className={cn({ "-ml-16": index > 0 })}
onSelect={() => setSelectedExternalRequestId(request.id)} style={{ zIndex: request.id === selectedExternalRequestId ? externalRequests.length + 6 : index + 1 }}
project={project} >
request={request} <HomeExternalContourDeckCard
/> isActive={request.id === selectedExternalRequestId}
onSelect={() => setSelectedExternalRequestId(request.id)}
project={project}
request={request}
/>
</div>
)); ));
return ( return (
<HomeCardShell <HomeCardShell
eyebrow={compact ? `${project.identifier} • последние задачи` : `${project.identifier} • последние задачи`} eyebrow={`${project.identifier} • последние задачи`}
title={compact ? "Последние задачи проекта" : "Последние задачи проекта"} title="Последние задачи проекта"
description={ description="Ниже собраны две колоды для быстрого просмотра новых карточек во внешнем и внутреннем контурах."
compact contentClassName="space-y-5 p-5"
? undefined
: "Ниже собраны две колоды для быстрого просмотра новых карточек во внешнем и внутреннем контурах."
}
contentClassName={compact ? "space-y-3 p-4" : "grid gap-5 p-5 xl:grid-cols-2"}
> >
<DeckSection <DeckSection
compact={compact}
count={externalRequests.length} count={externalRequests.length}
description={ description="Последние запросы и задачи внешнего контура по текущему проекту."
compact ? "Последние внешние карточки" : "Последние запросы и задачи внешнего контура по текущему проекту."
}
emptyDescription="У проекта пока нет внешних контурных задач. Когда появится первый обмен с контрагентом, он сразу попадет в эту колоду." emptyDescription="У проекта пока нет внешних контурных задач. Когда появится первый обмен с контрагентом, он сразу попадет в эту колоду."
emptyTitle="Внешний контур пока пуст" emptyTitle="Внешний контур пока пуст"
isLoading={isExternalRequestsLoading} isLoading={isExternalRequestsLoading}
items={externalIssueCards} items={externalIssueCards}
title={compact ? "Внешний контур" : "Внешний контур"} title="Последние задачи внешнего контура"
/> />
<DeckSection <DeckSection
compact={compact}
count={internalIssues.length} count={internalIssues.length}
description={ description="Последние добавленные внутренние задачи выбранного проекта."
compact ? "Последние внутренние карточки" : "Последние добавленные внутренние задачи выбранного проекта."
}
emptyDescription="Во внутреннем контуре пока нет задач. Как только в проекте появится новая карточка, она ляжет сюда первой." emptyDescription="Во внутреннем контуре пока нет задач. Как только в проекте появится новая карточка, она ляжет сюда первой."
emptyTitle="Внутренний контур пока пуст" emptyTitle="Внутренний контур пока пуст"
isLoading={isInternalIssuesLoading} isLoading={isInternalIssuesLoading}
items={internalIssueCards} items={internalIssueCards}
title={compact ? "Внутренний контур" : "Внутренний контур"} title="Последние задачи внутреннего контура"
/> />
</HomeCardShell> </HomeCardShell>
); );

View File

@ -57,8 +57,8 @@ export const WorkspaceHomeView = observer(function WorkspaceHomeView() {
)} )}
<> <>
<HomePeekOverviewsRoot /> <HomePeekOverviewsRoot />
<ContentWrapper className="nodedc-home-route-surface mx-auto scrollbar-hide gap-6 px-page-x"> <ContentWrapper className="mx-auto scrollbar-hide gap-6 bg-transparent px-page-x">
<div className="nodedc-workspace-page-shell nodedc-home-page-shell mx-auto w-full"> <div className="nodedc-workspace-page-shell mx-auto w-full max-w-[1480px]">
<DashboardWidgets currentUser={currentUser} /> <DashboardWidgets currentUser={currentUser} />
</div> </div>
</ContentWrapper> </ContentWrapper>

View File

@ -4,20 +4,24 @@
* See the LICENSE file for details. * See the LICENSE file for details.
*/ */
// plane types
import { useTranslation } from "@plane/i18n"; import { useTranslation } from "@plane/i18n";
import type { IUser } from "@plane/types"; import type { IUser, TProjectAnalyticsCount } from "@plane/types";
import { Avatar } from "@plane/ui";
import { getFileURL } from "@plane/utils";
import { useCurrentTime } from "@/hooks/use-current-time"; import { useCurrentTime } from "@/hooks/use-current-time";
import { getCompletionRate, type THomeProjectData } from "./home.utils";
export interface IUserGreetingsView { export interface IUserGreetingsView {
user: IUser; user: IUser;
workspaceName?: string | null; workspaceName?: string | null;
selectedProject?: THomeProjectData;
selectedProjectAnalytics?: TProjectAnalyticsCount;
} }
export function UserGreetingsView(props: IUserGreetingsView) { export function UserGreetingsView(props: IUserGreetingsView) {
const { user, workspaceName } = props; const { user, workspaceName, selectedProject, selectedProjectAnalytics } = props;
// current time hook
const { currentTime } = useCurrentTime(); const { currentTime } = useCurrentTime();
// store hooks
const { t, currentLocale } = useTranslation(); const { t, currentLocale } = useTranslation();
const hour = new Intl.DateTimeFormat(currentLocale, { const hour = new Intl.DateTimeFormat(currentLocale, {
@ -42,37 +46,39 @@ export function UserGreetingsView(props: IUserGreetingsView) {
}).format(currentTime); }).format(currentTime);
const greeting = parseInt(hour, 10) < 12 ? "morning" : parseInt(hour, 10) < 18 ? "afternoon" : "evening"; const greeting = parseInt(hour, 10) < 12 ? "morning" : parseInt(hour, 10) < 18 ? "afternoon" : "evening";
const userName = `${user?.first_name ?? ""} ${user?.last_name ?? ""}`.trim() || user?.email || "Workspace admin"; const completionRate = getCompletionRate(selectedProjectAnalytics);
const userEmail = user?.email || workspaceName || "NODE DC";
return ( return (
<section className="nodedc-home-user-card"> <section className="nodedc-home-card px-5 py-4">
<div className="nodedc-home-user-card-orb" /> <div className="grid items-stretch gap-3 xl:grid-cols-[minmax(0,1fr)_280px_220px]">
<div className="relative z-[1] p-5"> <div className="flex min-w-0 items-center">
<div className="text-[11px] font-semibold tracking-[0.22em] text-white/55 uppercase">
{workspaceName ?? "NODE DC"}
</div>
<div className="mt-5 flex items-start gap-4">
<div className="shrink-0 rounded-[26px] bg-white/14 p-1 backdrop-blur-xl">
<Avatar src={getFileURL(user?.avatar_url ?? "")} name={userName} size="lg" />
</div>
<div className="min-w-0"> <div className="min-w-0">
<div className="text-12 text-white/58">{`${weekDay}, ${date} ${timeString}`}</div> <div className="text-[11px] font-semibold tracking-[0.22em] text-placeholder uppercase">
<h2 className="mt-1 line-clamp-2 text-24 font-semibold leading-tight text-white"> {workspaceName ?? "Workspace Home"}
{`${t("good")} ${t(greeting)}, ${user?.first_name || "DC"}`} </div>
<h2 className="mt-1 truncate text-24 font-semibold text-primary">
{`${t("good")} ${t(greeting)}, ${user?.first_name} ${user?.last_name}`}
</h2> </h2>
<div className="mt-1 text-13 text-secondary">{`${weekDay}, ${date} ${timeString}`}</div>
</div> </div>
</div> </div>
<div className="mt-8 space-y-1"> <div className="nodedc-home-subpanel flex min-h-[108px] flex-col justify-center px-4 py-3">
<div className="text-18 font-semibold text-white">{userName}</div> <div className="text-12 font-medium text-secondary">Текущий фокус</div>
<div className="truncate text-13 text-white/62">{userEmail}</div> <div className="mt-2 line-clamp-2 text-18 font-semibold text-primary">
{selectedProject ? selectedProject.name : "Выберите проект слева"}
</div>
<div className="mt-1 text-12 text-secondary">
{selectedProject ? selectedProject.identifier : "Домашняя сводка перестроится под выбранную карточку."}
</div>
</div> </div>
<div className="mt-5 flex flex-wrap items-center gap-2 text-[11px] font-medium text-white/56"> <div className="nodedc-home-subpanel flex min-h-[108px] flex-col justify-center px-4 py-3">
<div className="rounded-full bg-black/24 px-3 py-1.5 backdrop-blur-md">home admin</div> <div className="text-12 font-medium text-secondary">Прогресс фокуса</div>
<div className="rounded-full bg-black/24 px-3 py-1.5 backdrop-blur-md">{workspaceName ?? "workspace"}</div> <div className="mt-2 text-18 font-semibold text-primary">{selectedProject ? `${completionRate}%` : "—"}</div>
<div className="mt-1 text-12 text-secondary">
{selectedProject ? "Закрытые задачи относительно общего объёма." : "Станет доступен после выбора проекта."}
</div>
</div> </div>
</div> </div>
</section> </section>

View File

@ -31,16 +31,6 @@ import { HOME_WIDGETS_LIST } from "../../home-dashboard-widgets";
import { WidgetItemDragHandle } from "./widget-item-drag-handle"; import { WidgetItemDragHandle } from "./widget-item-drag-handle";
import { getCanDrop, getInstructionFromPayload } from "./widget.helpers"; import { getCanDrop, getInstructionFromPayload } from "./widget.helpers";
const WIDGET_TITLE_FALLBACKS: Record<string, string> = {
"home.project_latest_issues.title": "Последние задачи проекта",
my_stickies: "Ваши стикеры",
new_at_plane: "Новое в NODE.DC",
project_latest_issues: "Последние задачи проекта",
quick_links: "Быстрые ссылки",
quick_tutorial: "Быстрое обучение",
recents: "Недавние",
};
type Props = { type Props = {
widgetId: string; widgetId: string;
isLastChild: boolean; isLastChild: boolean;
@ -63,11 +53,6 @@ export const WidgetItem = observer(function WidgetItem(props: Props) {
// derived values // derived values
const widget = widgetsMap[widgetId]; const widget = widgetsMap[widgetId];
const widgetTitle = HOME_WIDGETS_LIST[widget.key]?.title; const widgetTitle = HOME_WIDGETS_LIST[widget.key]?.title;
const translatedWidgetTitle = widgetTitle ? t(widgetTitle, { count: 1 }) : undefined;
const widgetLabel =
!translatedWidgetTitle || translatedWidgetTitle === widgetTitle
? (WIDGET_TITLE_FALLBACKS[widgetTitle ?? ""] ?? WIDGET_TITLE_FALLBACKS[widget.key] ?? widget.key)
: translatedWidgetTitle;
// drag and drop // drag and drop
useEffect(() => { useEffect(() => {
@ -91,7 +76,7 @@ export const WidgetItem = observer(function WidgetItem(props: Props) {
getOffset: pointerOutsideOfPreview({ x: "0px", y: "0px" }), getOffset: pointerOutsideOfPreview({ x: "0px", y: "0px" }),
render: ({ container }) => { render: ({ container }) => {
const root = createRoot(container); const root = createRoot(container);
root.render(<div className="rounded-sm bg-surface-1 p-1 pr-2 text-13">{widgetLabel}</div>); root.render(<div className="rounded-sm bg-surface-1 p-1 pr-2 text-13">{widget.key}</div>);
return () => root.unmount(); return () => root.unmount();
}, },
nativeSetDragImage, nativeSetDragImage,
@ -133,7 +118,7 @@ export const WidgetItem = observer(function WidgetItem(props: Props) {
}) })
); );
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [elementRef?.current, isDragging, isLastChild, widget.key, widgetLabel]); }, [elementRef?.current, isDragging, isLastChild, widget.key]);
return ( return (
<div className=""> <div className="">
@ -149,7 +134,7 @@ export const WidgetItem = observer(function WidgetItem(props: Props) {
> >
<div className="flex items-center"> <div className="flex items-center">
<WidgetItemDragHandle sort_order={widget.sort_order} isDragging={isDragging} /> <WidgetItemDragHandle sort_order={widget.sort_order} isDragging={isDragging} />
<div>{widgetLabel}</div> <div>{t(widgetTitle, { count: 1 })}</div>
</div> </div>
<ToggleSwitch <ToggleSwitch
value={widget.is_enabled} value={widget.is_enabled}

View File

@ -11,6 +11,9 @@ import { useTranslation } from "@plane/i18n";
// plane types // plane types
import { PageIcon, ProjectIcon, WorkItemsIcon } from "@plane/propel/icons"; import { PageIcon, ProjectIcon, WorkItemsIcon } from "@plane/propel/icons";
import type { TActivityEntityData, THomeWidgetProps, TRecentActivityFilterKeys } from "@plane/types"; import type { TActivityEntityData, THomeWidgetProps, TRecentActivityFilterKeys } from "@plane/types";
// plane ui
// components
import { ContentOverflowWrapper } from "@/components/core/content-overflow-HOC";
// plane web services // plane web services
import { WorkspaceService } from "@/services/workspace.service"; import { WorkspaceService } from "@/services/workspace.service";
import { getActivityProjectId } from "../../home.utils"; import { getActivityProjectId } from "../../home.utils";
@ -102,15 +105,20 @@ export const RecentActivityWidget = observer(function RecentActivityWidget(props
); );
return ( return (
<div className="box-border min-h-[250px]"> <ContentOverflowWrapper
maxHeight={415}
containerClassName="box-border min-h-[250px]"
fallback={<></>}
buttonClassName="nodedc-toolbar-pill justify-center"
>
<div className="mb-2 flex items-center justify-between"> <div className="mb-2 flex items-center justify-between">
<div className="text-14 font-semibold text-tertiary">{t("home.recents.title")}</div> <div className="text-14 font-semibold text-tertiary">{t("home.recents.title")}</div>
{showFilterSelect && <FiltersDropdown filters={filters} activeFilter={filter} setActiveFilter={setFilter} />} {showFilterSelect && <FiltersDropdown filters={filters} activeFilter={filter} setActiveFilter={setFilter} />}
</div> </div>
<div className="flex max-h-[415px] min-h-[250px] flex-col overflow-y-auto"> <div className="flex min-h-[250px] flex-col">
{isLoading && <WidgetLoader widgetKey={WIDGET_KEY} />} {isLoading && <WidgetLoader widgetKey={WIDGET_KEY} />}
{!isLoading && recents.map((activity) => <div key={activity.id}>{resolveRecent(activity)}</div>)} {!isLoading && recents.map((activity) => <div key={activity.id}>{resolveRecent(activity)}</div>)}
</div> </div>
</div> </ContentOverflowWrapper>
); );
}); });

View File

@ -10,7 +10,7 @@ import { useTheme } from "next-themes";
import { Combobox } from "@headlessui/react"; import { Combobox } from "@headlessui/react";
// plane imports // plane imports
import { useTranslation } from "@plane/i18n"; import { useTranslation } from "@plane/i18n";
import { SearchIcon, getStateGroupColor } from "@plane/propel/icons"; import { SearchIcon } from "@plane/propel/icons";
import { TOAST_TYPE, setToast } from "@plane/propel/toast"; import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { ISearchIssueResponse } from "@plane/types"; import type { ISearchIssueResponse } from "@plane/types";
import { Loader, EModalPosition, EModalWidth, ModalCore } from "@plane/ui"; import { Loader, EModalPosition, EModalWidth, ModalCore } from "@plane/ui";
@ -90,7 +90,7 @@ export function SelectDuplicateInboxIssueModal(props: Props) {
{query === "" && <h2 className="mt-4 mb-2 px-3 text-11 font-semibold text-primary">Select work item</h2>} {query === "" && <h2 className="mt-4 mb-2 px-3 text-11 font-semibold text-primary">Select work item</h2>}
<ul className="text-13 text-primary"> <ul className="text-13 text-primary">
{filteredIssues.map((issue) => { {filteredIssues.map((issue) => {
const stateColor = getStateGroupColor(issue.state__group, issue.state__color); const stateColor = issue.state__color || "";
return ( return (
<Combobox.Option <Combobox.Option

View File

@ -74,7 +74,6 @@ export const InboxIssueListItem = observer(function InboxIssueListItem(props: In
> >
<NodedcWorkItemCard <NodedcWorkItemCard
isActive={isActive} isActive={isActive}
priority={issue.priority}
surfaceClassName="transition-transform duration-200 hover:-translate-y-0.5" surfaceClassName="transition-transform duration-200 hover:-translate-y-0.5"
header={ header={
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">

View File

@ -53,7 +53,7 @@ export const IssueAttachmentsDetail = observer(function IssueAttachmentsDetail(p
// derived values // derived values
const attachment = attachmentId ? getAttachmentById(attachmentId) : undefined; const attachment = attachmentId ? getAttachmentById(attachmentId) : undefined;
const fileName = getFileName(attachment?.attributes.name ?? ""); const fileName = getFileName(attachment?.attributes.name ?? "");
const fileExtension = getFileExtension(attachment?.attributes.name ?? ""); const fileExtension = getFileExtension(attachment?.asset_url ?? "");
const fileIcon = getFileIcon(fileExtension, 28); const fileIcon = getFileIcon(fileExtension, 28);
const fileURL = getFileURL(attachment?.asset_url ?? ""); const fileURL = getFileURL(attachment?.asset_url ?? "");
// hooks // hooks

View File

@ -51,140 +51,97 @@ export const IssueAttachmentItemList = observer(function IssueAttachmentItemList
attachment: { getAttachmentsByIssueId }, attachment: { getAttachmentsByIssueId },
attachmentDeleteModalId, attachmentDeleteModalId,
toggleDeleteAttachmentModal, toggleDeleteAttachmentModal,
fetchAttachments,
fetchActivities, fetchActivities,
} = useIssueDetail(issueServiceType); } = useIssueDetail(issueServiceType);
const { operations: attachmentOperations, snapshot: attachmentSnapshot } = attachmentHelpers; const { operations: attachmentOperations, snapshot: attachmentSnapshot } = attachmentHelpers;
const { create: createAttachment } = attachmentOperations; const { create: createAttachment } = attachmentOperations;
const { uploadStatus } = attachmentSnapshot; const { uploadStatus } = attachmentSnapshot;
// file size // file size
const { fileSizeLimitEnabled, maxFileSize } = useFileSize(); const { maxFileSize } = useFileSize();
// derived values // derived values
const issueAttachments = getAttachmentsByIssueId(issueId) ?? []; const issueAttachments = getAttachmentsByIssueId(issueId);
const activeUploads = uploadStatus ?? [];
const hasAttachmentRows = issueAttachments.length > 0 || !!uploadStatus?.length;
const uploadProgress =
activeUploads.length > 0
? Math.round(activeUploads.reduce((progressSum, item) => progressSum + item.progress, 0) / activeUploads.length)
: 0;
// handlers // handlers
const handleFetchPropertyActivities = useCallback(() => { const handleFetchPropertyActivities = useCallback(() => {
fetchActivities(workspaceSlug, projectId, issueId); fetchActivities(workspaceSlug, projectId, issueId);
}, [fetchActivities, workspaceSlug, projectId, issueId]); }, [fetchActivities, workspaceSlug, projectId, issueId]);
const handleRefreshAttachments = useCallback(() => {
if (!workspaceSlug || !projectId || !issueId) return;
fetchAttachments(workspaceSlug, projectId, issueId).catch((error) => {
console.error("Error in refreshing issue attachments after upload:", error);
});
}, [fetchAttachments, workspaceSlug, projectId, issueId]);
const onDrop = useCallback( const onDrop = useCallback(
(acceptedFiles: File[], rejectedFiles: FileRejection[]) => { (acceptedFiles: File[], rejectedFiles: FileRejection[]) => {
if (acceptedFiles.length > 0) { const totalAttachedFiles = acceptedFiles.length + rejectedFiles.length;
if (!workspaceSlug) return;
setIsUploading(true);
Promise.allSettled(acceptedFiles.map((file) => createAttachment(file))).finally(() => { if (rejectedFiles.length === 0) {
handleRefreshAttachments(); const currentFile: File = acceptedFiles[0];
window.setTimeout(handleRefreshAttachments, 1200); if (!currentFile || !workspaceSlug) return;
handleFetchPropertyActivities();
setIsUploading(false); setIsUploading(true);
}); createAttachment(currentFile)
.catch(() => {
setToast({
type: TOAST_TYPE.ERROR,
title: t("toast.error"),
message: t("attachment.error"),
});
})
.finally(() => {
handleFetchPropertyActivities();
setIsUploading(false);
});
return;
} }
if (rejectedFiles.length > 0) setToast({
setToast({ type: TOAST_TYPE.ERROR,
type: TOAST_TYPE.ERROR, title: t("toast.error"),
title: t("toast.error"), message:
message: fileSizeLimitEnabled totalAttachedFiles > 1
? t("attachment.file_size_limit", { size: maxFileSize / 1024 / 1024 }) ? t("attachment.only_one_file_allowed")
: t("attachment.error"), : t("attachment.file_size_limit", { size: maxFileSize / 1024 / 1024 }),
}); });
return; return;
}, },
[ [createAttachment, maxFileSize, workspaceSlug, handleFetchPropertyActivities]
createAttachment,
fileSizeLimitEnabled,
maxFileSize,
workspaceSlug,
handleRefreshAttachments,
handleFetchPropertyActivities,
t,
]
); );
const { getRootProps, getInputProps, isDragActive, isDragReject } = useDropzone({ const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop, onDrop,
maxSize: fileSizeLimitEnabled ? maxFileSize : undefined, maxSize: maxFileSize,
multiple: true, multiple: false,
disabled: isUploading || disabled, disabled: isUploading || disabled,
}); });
return ( return (
<div className="space-y-3"> <>
{attachmentDeleteModalId && ( {uploadStatus?.map((uploadStatus) => (
<IssueAttachmentDeleteModal <IssueAttachmentsUploadItem key={uploadStatus.id} uploadStatus={uploadStatus} />
isOpen={Boolean(attachmentDeleteModalId)} ))}
onClose={() => toggleDeleteAttachmentModal(null)} {issueAttachments && (
attachmentOperations={attachmentOperations} <>
attachmentId={attachmentDeleteModalId} {attachmentDeleteModalId && (
issueServiceType={issueServiceType} <IssueAttachmentDeleteModal
/> isOpen={Boolean(attachmentDeleteModalId)}
)} onClose={() => toggleDeleteAttachmentModal(null)}
attachmentOperations={attachmentOperations}
<div attachmentId={attachmentDeleteModalId}
{...getRootProps()} issueServiceType={issueServiceType}
data-drag-active={isDragActive ? "true" : "false"} />
data-drag-reject={isDragReject ? "true" : "false"} )}
className={`nodedc-attachment-upload flex w-full items-center justify-between gap-4 px-5 py-4 ${
disabled ? "cursor-not-allowed opacity-70" : "cursor-pointer"
}`}
>
<input {...getInputProps()} />
<div className="flex min-w-0 items-center gap-4">
<div className="grid size-11 flex-shrink-0 place-items-center rounded-2xl bg-[rgba(var(--nodedc-accent-rgb),0.14)] text-[rgb(var(--nodedc-accent-rgb))]">
<UploadCloud className="size-5" />
</div>
<div className="min-w-0">
<div className="truncate text-14 font-semibold text-primary">
{isDragActive ? "Отпустите файлы здесь" : "Перетащите файлы сюда или нажмите для выбора"}
</div>
<div className="mt-1 truncate text-12 text-tertiary">
{fileSizeLimitEnabled
? `Любой формат, несколько файлов за раз, до ${maxFileSize / 1024 / 1024} МБ на файл`
: "Любой формат, несколько файлов за раз, без ограничения размера"}
</div>
</div>
</div>
<div className="hidden flex-shrink-0 rounded-full bg-[rgba(var(--nodedc-accent-rgb),0.12)] px-3 py-1 text-12 font-semibold text-[rgb(var(--nodedc-accent-rgb))] sm:block">
Выбрать
</div>
</div>
{activeUploads.length > 0 && (
<div className="overflow-hidden rounded-full bg-white/6">
<div <div
className="h-1 rounded-full bg-[rgb(var(--nodedc-accent-rgb))] transition-[width] duration-200 ease-out" {...getRootProps()}
style={{ width: `${uploadProgress}%` }} className={`relative flex flex-col ${isDragActive && issueAttachments.length < 3 ? "min-h-[200px]" : ""} ${disabled ? "cursor-not-allowed" : "cursor-pointer"}`}
/> >
</div> <input {...getInputProps()} />
)} {isDragActive && (
<div className="absolute top-0 left-0 z-30 flex h-full w-full items-center justify-center bg-surface-2/75">
{hasAttachmentRows && ( <div className="flex items-center justify-center rounded-md bg-surface-1 p-1">
<div className="rounded-[1.35rem] border border-subtle bg-surface-1/60 p-3 shadow-[0_12px_28px_rgba(0,0,0,0.08)]"> <div className="flex flex-col items-center justify-center rounded-md border border-dashed border-strong px-5 py-6">
<div className="mb-2 flex items-center justify-between gap-3 px-1"> <UploadCloud className="size-7" />
<div className="text-12 font-semibold tracking-[0.08em] text-tertiary uppercase">Вложения</div> <span className="text-13 text-tertiary">{t("attachment.drag_and_drop")}</span>
<div className="text-12 text-tertiary"> </div>
{activeUploads.length > 0 ? `Загрузка ${uploadProgress}%` : issueAttachments.length} </div>
</div> </div>
</div> )}
<div className="flex gap-3 overflow-x-auto pb-1"> {issueAttachments?.map((attachmentId) => (
{uploadStatus?.map((status) => (
<IssueAttachmentsUploadItem key={status.id} uploadStatus={status} />
))}
{issueAttachments.map((attachmentId) => (
<IssueAttachmentsListItem <IssueAttachmentsListItem
key={attachmentId} key={attachmentId}
attachmentId={attachmentId} attachmentId={attachmentId}
@ -193,8 +150,8 @@ export const IssueAttachmentItemList = observer(function IssueAttachmentItemList
/> />
))} ))}
</div> </div>
</div> </>
)} )}
</div> </>
); );
}); });

View File

@ -5,7 +5,6 @@
*/ */
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { useState } from "react";
import { useTranslation } from "@plane/i18n"; import { useTranslation } from "@plane/i18n";
import { getIconButtonStyling } from "@plane/propel/icon-button"; import { getIconButtonStyling } from "@plane/propel/icon-button";
@ -15,14 +14,12 @@ import type { TIssueServiceType } from "@plane/types";
import { EIssueServiceType } from "@plane/types"; import { EIssueServiceType } from "@plane/types";
// ui // ui
import type { TContextMenuItem } from "@plane/ui"; import type { TContextMenuItem } from "@plane/ui";
import { ActionDropdown, EModalPosition, EModalWidth, ModalCore } from "@plane/ui"; import { ActionDropdown } from "@plane/ui";
import { convertBytesToSize, getFileExtension, getFileName, getFileURL, renderFormattedDate } from "@plane/utils"; import { convertBytesToSize, getFileExtension, getFileName, getFileURL, renderFormattedDate } from "@plane/utils";
import { Download, ImageIcon, Play, X } from "lucide-react";
// components // components
// //
import { ButtonAvatars } from "@/components/dropdowns/member/avatar"; import { ButtonAvatars } from "@/components/dropdowns/member/avatar";
import { getFileIcon } from "@/components/icons"; import { getFileIcon } from "@/components/icons";
import { IssueAttachmentPdfPreview, IssueAttachmentPdfThumbnail } from "./attachment-pdf-preview";
// helpers // helpers
// hooks // hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail"; import { useIssueDetail } from "@/hooks/store/use-issue-detail";
@ -35,25 +32,6 @@ type TIssueAttachmentsListItem = {
issueServiceType?: TIssueServiceType; issueServiceType?: TIssueServiceType;
}; };
const IMAGE_EXTENSIONS = new Set(["apng", "avif", "bmp", "gif", "jpg", "jpeg", "png", "svg", "webp"]);
const VIDEO_EXTENSIONS = new Set(["avi", "m4v", "mov", "mp4", "mpeg", "mpg", "ogv", "webm"]);
const PDF_EXTENSIONS = new Set(["pdf"]);
const appendSearchParam = (url: string | undefined, key: string, value: string): string => {
if (!url) return "";
const [urlWithoutHash, hash] = url.split("#");
const separator = urlWithoutHash.includes("?") ? "&" : "?";
return `${urlWithoutHash}${separator}${key}=${encodeURIComponent(value)}${hash ? `#${hash}` : ""}`;
};
const getPreviewType = (extension: string) => {
const normalizedExtension = extension.toLowerCase();
if (IMAGE_EXTENSIONS.has(normalizedExtension)) return "image";
if (VIDEO_EXTENSIONS.has(normalizedExtension)) return "video";
if (PDF_EXTENSIONS.has(normalizedExtension)) return "pdf";
return "file";
};
export const IssueAttachmentsListItem = observer(function IssueAttachmentsListItem(props: TIssueAttachmentsListItem) { export const IssueAttachmentsListItem = observer(function IssueAttachmentsListItem(props: TIssueAttachmentsListItem) {
const { t } = useTranslation(); const { t } = useTranslation();
// props // props
@ -68,13 +46,8 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
const attachment = attachmentId ? getAttachmentById(attachmentId) : undefined; const attachment = attachmentId ? getAttachmentById(attachmentId) : undefined;
const fileName = getFileName(attachment?.attributes.name ?? ""); const fileName = getFileName(attachment?.attributes.name ?? "");
const fileExtension = getFileExtension(attachment?.attributes.name ?? ""); const fileExtension = getFileExtension(attachment?.attributes.name ?? "");
const fullFileName = fileExtension ? `${fileName}.${fileExtension}` : fileName; const fileIcon = getFileIcon(fileExtension, 18);
const previewType = getPreviewType(fileExtension);
const fileIcon = getFileIcon(fileExtension, 32);
const fileURL = getFileURL(attachment?.asset_url ?? ""); const fileURL = getFileURL(attachment?.asset_url ?? "");
const previewURL = appendSearchParam(fileURL, "preview", "true");
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
const [isThumbnailError, setIsThumbnailError] = useState(false);
const menuItems: TContextMenuItem[] = [ const menuItems: TContextMenuItem[] = [
{ {
key: "delete", key: "delete",
@ -91,138 +64,56 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
if (!attachment) return <></>; if (!attachment) return <></>;
return ( return (
<> <div
<div className="group flex h-32 w-72 flex-shrink-0 overflow-hidden rounded-2xl border border-subtle bg-surface-2/80 transition hover:border-[rgba(var(--nodedc-accent-rgb),0.45)] hover:bg-surface-2"> role="button"
<button tabIndex={0}
type="button" onClick={(e) => {
className="flex min-w-0 flex-1 items-stretch text-left" e.preventDefault();
onClick={(e) => { e.stopPropagation();
e.preventDefault(); window.open(fileURL, "_blank");
e.stopPropagation(); }}
setIsPreviewOpen(true); onKeyDown={(e) => {
}} if (e.key === "Enter" || e.key === " ") {
> e.preventDefault();
<div className="relative h-full w-28 flex-shrink-0 overflow-hidden bg-surface-1"> e.stopPropagation();
{previewType === "image" && previewURL && !isThumbnailError ? ( window.open(fileURL, "_blank");
<img }
src={previewURL} }}
alt={fullFileName} >
className="h-full w-full object-cover" <div className="group flex h-11 items-center justify-between gap-3 pr-2 pl-9 hover:bg-surface-2">
loading="lazy" <div className="flex items-center gap-3 truncate text-13">
onError={() => setIsThumbnailError(true)} <div className="flex items-center gap-3">{fileIcon}</div>
/> <Tooltip tooltipContent={`${fileName}.${fileExtension}`} isMobile={isMobile}>
) : previewType === "video" && previewURL ? ( <p className="truncate font-medium text-secondary">{`${fileName}.${fileExtension}`}</p>
<> </Tooltip>
<video src={previewURL} className="h-full w-full object-cover" muted playsInline preload="metadata" /> <span className="flex size-1.5 rounded-full bg-layer-1" />
<div className="absolute inset-0 grid place-items-center bg-black/20 text-white"> <span className="flex-shrink-0 text-placeholder">{convertBytesToSize(attachment.attributes.size)}</span>
<Play className="size-6 fill-current" />
</div>
</>
) : previewType === "pdf" && previewURL ? (
<IssueAttachmentPdfThumbnail fileURL={previewURL} />
) : (
<div className="flex h-full w-full items-center justify-center">
{previewType === "file" ? fileIcon : <ImageIcon className="size-8 text-tertiary" />}
</div>
)}
</div> </div>
<div className="flex min-w-0 flex-1 flex-col justify-between p-3"> <div className="flex items-center gap-3">
<div className="min-w-0">
<Tooltip tooltipContent={fullFileName} isMobile={isMobile}>
<p className="line-clamp-2 text-13 font-semibold text-secondary">{fullFileName}</p>
</Tooltip>
<div className="mt-2 flex items-center gap-2 text-11 text-tertiary">
<span>{fileExtension ? fileExtension.toUpperCase() : "FILE"}</span>
<span className="size-1 rounded-full bg-layer-1" />
<span>{convertBytesToSize(attachment.attributes.size)}</span>
</div>
</div>
{attachment?.created_by && ( {attachment?.created_by && (
<Tooltip <>
isMobile={isMobile} <Tooltip
tooltipContent={`${ isMobile={isMobile}
getUserDetails(attachment?.created_by)?.display_name ?? "" tooltipContent={`${
} uploaded on ${renderFormattedDate(attachment.updated_at)}`} getUserDetails(attachment?.created_by)?.display_name ?? ""
> } uploaded on ${renderFormattedDate(attachment.updated_at)}`}
<div className="flex w-fit items-center justify-center"> >
<ButtonAvatars showTooltip userIds={attachment?.created_by} /> <div className="flex items-center justify-center">
</div> <ButtonAvatars showTooltip userIds={attachment?.created_by} />
</Tooltip> </div>
</Tooltip>
</>
)} )}
</div>
</button>
<div className="flex w-10 flex-shrink-0 items-start justify-center pt-2"> <ActionDropdown
<ActionDropdown items={menuItems}
items={menuItems} buttonClassName={getIconButtonStyling("ghost", "sm")}
buttonClassName={getIconButtonStyling("ghost", "sm")} placement="bottom-end"
placement="bottom-end" disabled={!!disabled}
disabled={!!disabled} />
/> </div>
</div>
</div> </div>
</div>
<ModalCore
isOpen={isPreviewOpen}
handleClose={() => setIsPreviewOpen(false)}
position={EModalPosition.CENTER}
width={EModalWidth.VIIXL}
className="h-[calc(100vh-4rem)] max-w-[calc(100vw-4rem)] overflow-hidden border border-subtle bg-surface-1"
>
<div className="relative flex h-full min-h-0 flex-col bg-surface-1 p-4">
<div className="absolute top-4 right-4 z-10 flex items-center gap-2">
{fileURL && (
<a
href={fileURL}
download={fullFileName}
target="_blank"
rel="noopener noreferrer"
className="grid size-10 place-items-center rounded-full bg-[rgb(var(--nodedc-accent-rgb))] text-[rgb(var(--nodedc-on-accent-rgb))] shadow-[0_14px_30px_rgba(0,0,0,0.3)] transition hover:brightness-110"
onClick={(e) => e.stopPropagation()}
>
<Download className="size-5" />
</a>
)}
<button
type="button"
className="grid size-10 place-items-center rounded-full bg-black/35 text-white backdrop-blur-md transition hover:bg-black/50"
onClick={() => setIsPreviewOpen(false)}
>
<X className="size-5" />
</button>
</div>
<div className="flex-shrink-0 pr-24">
<div className="text-15 font-semibold text-primary">{fullFileName}</div>
<div className="mt-1 text-12 text-tertiary">{convertBytesToSize(attachment.attributes.size)}</div>
</div>
<div className="mt-4 min-h-0 flex-1 overflow-hidden rounded-2xl bg-black/20">
{previewType === "image" && previewURL ? (
<div className="flex h-full w-full items-center justify-center">
<img src={previewURL} alt={fullFileName} className="max-h-full max-w-full object-contain" />
</div>
) : previewType === "video" && previewURL ? (
<div className="flex h-full w-full items-center justify-center">
<video src={previewURL} className="max-h-full max-w-full" controls autoPlay>
<track kind="captions" />
</video>
</div>
) : previewType === "pdf" && previewURL ? (
<IssueAttachmentPdfPreview fileURL={previewURL} />
) : (
<div className="flex h-full flex-col items-center justify-center gap-4 p-8 text-center">
<div className="grid size-20 place-items-center rounded-3xl bg-surface-2">{fileIcon}</div>
<div className="max-w-md text-14 text-secondary">
Предпросмотр для этого формата недоступен. Файл можно скачать или открыть в новой вкладке.
</div>
</div>
)}
</div>
</div>
</ModalCore>
</>
); );
}); });

Some files were not shown because too many files have changed in this diff Show More