Compare commits

...

27 Commits

Author SHA1 Message Date
DCCONSTRUCTIONS 8b230d2670 ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: восстановление профиля и локальной аналитики 2026-04-26 12:56:50 +03:00
DCCONSTRUCTIONS 8fde5e9502 UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: тонкая подгонка home-шапки и уведомлений 2026-04-26 12:18:48 +03:00
DCCONSTRUCTIONS 8b5f15333a UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: выравнивание home-шапки и системных уведомлений 2026-04-26 11:52:29 +03:00
DCCONSTRUCTIONS ba996998e8 UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: доработка главной сводки и быстрых проектов 2026-04-25 23:49:44 +03:00
DCCONSTRUCTIONS 7d520c7aaf UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: модальная навигация workspace settings и Voice Tasker 2026-04-25 19:23:56 +03:00
DCCONSTRUCTIONS f060d4dedd UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: устойчивое выделение карточки после drag-drop 2026-04-25 18:46:07 +03:00
DCCONSTRUCTIONS eac010d3d4 АРХ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: устойчивое хранение layout-блоков карточки в detail_layout 2026-04-25 18:06:05 +03:00
DCCONSTRUCTIONS 4ed63cac4e ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: профиль, редиректы и визуальные статусы задач 2026-04-25 14:41:31 +03:00
DCCONSTRUCTIONS c4032e3040 ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: filestorage upload и preview вложений 2026-04-25 13:05:03 +03:00
DCCONSTRUCTIONS 5f2d543cab UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: зона вложений в карточке деталей 2026-04-25 10:34:37 +03:00
DCCONSTRUCTIONS 21a9d2b809 ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: дата старта для задач с дедлайном 2026-04-25 10:34:27 +03:00
DCCONSTRUCTIONS 4fe6d091e0 UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: выравнивание сетки главного Ганта 2026-04-25 09:34:41 +03:00
DCCONSTRUCTIONS a9f2c53e89 ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: hard gate для voice update intent 2026-04-25 09:16:13 +03:00
DCCONSTRUCTIONS 7209d2caab ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: стабилизация контуров, досок и Voice Tasker 2026-04-24 23:34:20 +03:00
DCCONSTRUCTIONS 597480adb9 ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: hardening Voice Tasker routing, сроков и transcript 2026-04-24 21:54:34 +03:00
DCCONSTRUCTIONS d3b47326da ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: resolver, commit и обновление доски Voice Tasker 2026-04-24 18:50:07 +03:00
DCCONSTRUCTIONS 1a20e19a93 ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: OpenAI pipeline и voice sessions 2026-04-24 17:53:26 +03:00
DCCONSTRUCTIONS 3c19c3175f ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: preflight и запись audio для Voice Tasker 2026-04-24 17:21:34 +03:00
DCCONSTRUCTIONS 237c7964cd АРХ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: каркас Voice Tasker settings 2026-04-24 16:47:16 +03:00
DCCONSTRUCTIONS b3c6b37399 UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: заливка перенесенной карточки внутреннего контура 2026-04-24 15:49:20 +03:00
DCCONSTRUCTIONS eff71d7258 UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: фильтры и настройки вида домашнего Ганта 2026-04-24 14:43:13 +03:00
DCCONSTRUCTIONS ad1d9c34ea UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: восстановление компактной сводки аналитики 2026-04-24 14:03:22 +03:00
DCCONSTRUCTIONS 52bd017d82 UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: русификация и редизайн аналитики внутреннего контура 2026-04-24 13:41:06 +03:00
DCCONSTRUCTIONS cf6fca20aa UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: переход в проект из карточки главной 2026-04-24 12:20:15 +03:00
DCCONSTRUCTIONS 2642a522f2 UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: маркер текущего дня в Ганте главной 2026-04-24 12:06:07 +03:00
DCCONSTRUCTIONS 655ff7fc4a UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: выравнивание карточек внутреннего контура 2026-04-24 11:56:23 +03:00
DCCONSTRUCTIONS a8b6f9f9ce ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: календарная привязка Ганта на главной 2026-04-24 03:01:54 +03:00
180 changed files with 11398 additions and 1361 deletions

View File

@ -46,6 +46,10 @@
## Кнопки ## Кнопки
- Все кнопки без жёсткого outline. - Все кнопки без жёсткого outline.
- Текстовые кнопки в модалках не сжимают текст:
- минимальный горизонтальный отступ от текста до края кнопки: `10px`
- для CTA предпочтительно использовать общий padding `1.25rem` или больше
- текст не должен визуально прилипать к радиусу кнопки
- Primary button: - Primary button:
- фон: акцентный или `active_card_rgb` - фон: акцентный или `active_card_rgb`
- текст: определяется автоматически по контрасту заливки - текст: определяется автоматически по контрасту заливки
@ -80,6 +84,24 @@
- единая вертикальная высота для одного класса контролов - единая вертикальная высота для одного класса контролов
- 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 выделяется кругом акцентного цвета, не квадратной плашкой.
@ -220,6 +242,7 @@
## 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,7 +25,8 @@ 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: ${FILE_SIZE_LIMIT:-5242880} FILE_SIZE_LIMIT: ${PROXY_BODY_SIZE_LIMIT:-1073741824}
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,6 +64,7 @@ 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,7 +25,8 @@ 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: ${FILE_SIZE_LIMIT:-5242880} FILE_SIZE_LIMIT: ${PROXY_BODY_SIZE_LIMIT:-1073741824}
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}
@ -47,9 +48,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} WEB_URL: ${WEB_URL:-http://localhost:8090}
DEBUG: ${DEBUG:-0} DEBUG: ${DEBUG:-0}
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS} CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-http://localhost:8090}
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,6 +64,7 @@ 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,6 +31,7 @@ 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
@ -73,6 +74,9 @@ 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,6 +43,7 @@ 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):
@ -512,9 +513,6 @@ 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(
@ -525,6 +523,9 @@ 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,6 +86,8 @@ 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 (
@ -1874,8 +1876,6 @@ 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,6 +1885,8 @@ 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}"
@ -2100,13 +2102,8 @@ class IssueAttachmentDetailAPIEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
) )
storage = S3Storage(request=request) disposition = "inline" if request.GET.get("preview") == "true" else "attachment"
presigned_url = storage.generate_presigned_url( return get_attachment_preview_response(request, asset, disposition=disposition)
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",
@ -2157,6 +2154,11 @@ 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,6 +125,7 @@ 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,6 +192,7 @@ 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,6 +47,7 @@ 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):
@ -124,6 +125,9 @@ 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
@ -799,6 +803,8 @@ 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
@ -852,6 +858,8 @@ 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,
@ -933,6 +941,7 @@ 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

@ -0,0 +1,95 @@
# 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,6 +61,11 @@ 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,6 +23,7 @@ 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,
@ -46,4 +47,5 @@ urlpatterns = [
*webhook_urls, *webhook_urls,
*timezone_urls, *timezone_urls,
*exporter_urls, *exporter_urls,
*voice_tasker_urls,
] ]

View File

@ -44,6 +44,11 @@ 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

@ -0,0 +1,42 @@
# 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,6 +243,14 @@ 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,6 +24,7 @@ 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):
@ -342,12 +343,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}"
@ -541,12 +542,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,7 +10,6 @@ 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
@ -26,6 +25,8 @@ 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):
@ -86,7 +87,13 @@ 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_id=issue_id, workspace__slug=slug, project_id=project_id) issue_attachments = FileAsset.objects.filter(
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)
@ -114,7 +121,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 = min(size, settings.FILE_SIZE_LIMIT) size_limit = resolve_workspace_upload_size_limit(workspace, size)
# Create a File Asset # Create a File Asset
asset = FileAsset.objects.create( asset = FileAsset.objects.create(
@ -179,13 +186,8 @@ class IssueAttachmentV2Endpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
) )
storage = S3Storage(request=request) disposition = "inline" if request.GET.get("preview") == "true" else "attachment"
presigned_url = storage.generate_presigned_url( return get_attachment_preview_response(request, asset, disposition=disposition)
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(
@ -203,6 +205,11 @@ 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,6 +3,7 @@
# 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
@ -50,6 +51,7 @@ 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")
@ -133,6 +135,10 @@ 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.
@ -248,6 +254,33 @@ 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,9 +34,7 @@ class WorkspaceHomePreferenceViewSet(BaseAPIView):
if key not in ["quick_tutorial", "new_at_plane"] if key not in ["quick_tutorial", "new_at_plane"]
] ]
sort_order_counter = 1 for sort_order_counter, preference in enumerate(keys, start=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)
@ -55,7 +53,6 @@ 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,9 +41,10 @@ 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,
@ -108,6 +109,18 @@ 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

@ -0,0 +1,187 @@
# 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

@ -0,0 +1,142 @@
# 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

@ -0,0 +1,23 @@
# 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

@ -0,0 +1,18 @@
# 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,6 +65,7 @@ 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,6 +134,7 @@ 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

@ -0,0 +1,133 @@
# 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,6 +137,8 @@ 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"""
@ -378,6 +380,7 @@ 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,6 +36,7 @@ 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):
@ -277,6 +278,9 @@ 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

@ -0,0 +1,38 @@
# 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

@ -0,0 +1,23 @@
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

@ -0,0 +1,59 @@
# 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,6 +122,16 @@ 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

@ -0,0 +1,24 @@
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 {$FILE_SIZE_LIMIT} max_size {$PROXY_BODY_SIZE_LIMIT:1073741824}
} }
handle /spaces/* { handle /spaces/* {

View File

@ -1,6 +1,6 @@
(plane_proxy) { (plane_proxy) {
request_body { request_body {
max_size {$FILE_SIZE_LIMIT} max_size {$PROXY_BODY_SIZE_LIMIT:1073741824}
} }
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 { copyUrlToClipboard, joinUrlPath } from "@plane/utils"; import { cn, 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,9 +246,15 @@ 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 className="z-20 w-full flex-shrink-0 px-4 pt-4 pb-3"> <div
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

@ -0,0 +1,40 @@
/**
* 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

@ -0,0 +1,31 @@
/**
* 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,6 +10,7 @@ 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) {
@ -19,8 +20,9 @@ export default function WorkspaceLayout(props: Route.ComponentProps) {
<AuthenticationWrapper> <AuthenticationWrapper>
<WorkspaceAuthWrapper> <WorkspaceAuthWrapper>
<AppRailVisibilityProvider> <AppRailVisibilityProvider>
<WorkspaceContentWrapper> <WorkspaceContentWrapper workspaceSlug={workspaceSlug}>
<GlobalModals workspaceSlug={workspaceSlug} /> <GlobalModals workspaceSlug={workspaceSlug} />
<WorkspaceSettingsModal />
<Outlet /> <Outlet />
</WorkspaceContentWrapper> </WorkspaceContentWrapper>
</AppRailVisibilityProvider> </AppRailVisibilityProvider>

View File

@ -289,6 +289,10 @@ 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,10 +14,11 @@ 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 } = props; const { currentUser, selectedProject, selectedProjectAnalytics, recents, workspaceName } = props;
const { currentLocale } = useTranslation(); const { currentLocale } = useTranslation();
const { currentTime } = useCurrentTime(); const { currentTime } = useCurrentTime();
@ -48,6 +49,7 @@ 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">
@ -57,26 +59,19 @@ 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"> <div className="truncate text-24 leading-none font-semibold text-black nodedc-home-market-focus-title">
{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>
@ -86,7 +81,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="mt-2 h-2 overflow-hidden rounded-full bg-black/[0.18]"> <div className="nodedc-home-market-progress">
<div <div
className="h-full rounded-full bg-black" className="h-full rounded-full bg-black"
style={{ style={{
@ -102,6 +97,11 @@ 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,14 +10,9 @@ 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 } from "@plane/propel/icons"; import { PriorityIcon, StateGroupIcon, getStateGroupColor } from "@plane/propel/icons";
import { TOAST_TYPE, setToast } from "@plane/propel/toast"; import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { import type { IState, TExternalContourBoardDirection, TExternalContourRequest, TIssue } from "@plane/types";
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";
@ -56,7 +51,7 @@ const buildSourceStateMap = (
state.id, state.id,
{ {
id: state.id, id: state.id,
color: state.color, color: getStateGroupColor(state.group, state.color),
default: false, default: false,
description: "", description: "",
group: state.group, group: state.group,
@ -69,7 +64,10 @@ const buildSourceStateMap = (
]) ])
); );
const resolveRequestStatus = (issue: TExternalContourRequest["issue"], fallbackStatus: TExternalContourRequest["status"]) => { const resolveRequestStatus = (
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";
@ -83,23 +81,20 @@ 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 { const { fetchBoard, upsertBoardItems } = useProjectExternalContoursBoard();
fetchBoard, const { fetchTargetOptions, getTargetOptionsByProjectId, updateRequest, updateRequestIssue } =
upsertBoardItems, useProjectExternalContours();
} = 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 = request.requested_by?.display_name || request.requested_by_name || issue.created_by_detail?.display_name || "NODE.DC"; const requester =
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"
@ -110,8 +105,12 @@ export const ExternalContoursBoardItem = observer(function ExternalContoursBoard
? getProjectRoleByWorkspaceSlugAndProjectId(workspaceSlug, targetProjectId) ? getProjectRoleByWorkspaceSlugAndProjectId(workspaceSlug, targetProjectId)
: undefined; : undefined;
const canEditTargetIssue = const canEditTargetIssue =
direction === "incoming" && !!targetProjectId && projectRole !== undefined && projectRole !== EUserPermissions.GUEST; direction === "incoming" &&
const canEditSourceRequest = direction === "outgoing" && !!request.capabilities?.can_edit_request && !!targetProjectId; !!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);
@ -124,12 +123,11 @@ 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 = const pillBackgroundClasses = isActive
isActive
? "bg-black/10 text-[rgb(var(--nodedc-on-card-active-rgb))]" ? "bg-black/10 text-[rgb(var(--nodedc-on-card-active-rgb))]"
: "bg-[rgb(var(--nodedc-card-passive-rgb))] text-white"; : "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 = selectedState?.color ?? (isActive ? "rgb(var(--nodedc-on-card-active-rgb))" : "var(--text-color-primary)"); const statusIconColor = getStateGroupColor(selectedState?.group, selectedState?.color);
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;
@ -221,6 +219,7 @@ 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}
@ -314,13 +313,13 @@ export const ExternalContoursBoardItem = observer(function ExternalContoursBoard
</div> </div>
</div> </div>
<div className={cn("truncate -mt-0.5 pl-8 text-[11px] font-medium leading-4", subtleTextClasses)}> <div className={cn("-mt-0.5 truncate pl-8 text-[11px] leading-4 font-medium", 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="line-clamp-4 max-w-full text-lg font-semibold leading-6">{issue.name}</div> <div className="text-lg line-clamp-4 max-w-full leading-6 font-semibold">{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}>
@ -333,7 +332,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, "pl-1 pr-2")}> <div className={cn(basePillClasses, pillBackgroundClasses, "pr-2 pl-1")}>
<ButtonAvatars showTooltip={false} userIds={issue.assignee_ids ?? []} size="sm" /> <ButtonAvatars showTooltip={false} userIds={issue.assignee_ids ?? []} size="sm" />
</div> </div>
} }
@ -351,7 +350,7 @@ export const ExternalContoursBoardItem = observer(function ExternalContoursBoard
}} }}
buttonVariant="transparent-without-text" buttonVariant="transparent-without-text"
button={ button={
<div className={cn(basePillClasses, pillBackgroundClasses, "pl-1 pr-2")}> <div className={cn(basePillClasses, pillBackgroundClasses, "pr-2 pl-1")}>
<ButtonAvatars showTooltip={false} userIds={issue.assignee_ids ?? []} size="sm" /> <ButtonAvatars showTooltip={false} userIds={issue.assignee_ids ?? []} size="sm" />
</div> </div>
} }

View File

@ -17,6 +17,7 @@ import {
StateGroupIcon, StateGroupIcon,
StatePropertyIcon, StatePropertyIcon,
UserCirclePropertyIcon, UserCirclePropertyIcon,
getStateGroupColor,
} from "@plane/propel/icons"; } from "@plane/propel/icons";
import type { import type {
IProject, IProject,
@ -57,14 +58,11 @@ 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( projectMap.set(project.id, {
project.id,
{
id: project.id, id: project.id,
name: project.name, name: project.name,
logo_props: project.logo_props, logo_props: project.logo_props,
} as IProject } as IProject);
);
}); });
return sortByName(Array.from(projectMap.values())); return sortByName(Array.from(projectMap.values()));
@ -77,11 +75,9 @@ 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( stateMap.set(state.id, {
state.id,
{
id: state.id, id: state.id,
color: state.color, color: getStateGroupColor(state.group, state.color),
default: false, default: false,
description: "", description: "",
group: state.group, group: state.group,
@ -90,8 +86,7 @@ const buildStates = (requests: TExternalContourRequest[]): IState[] => {
project_id: request.issue.project_id || request.target_project?.id || request.source_project?.id || "", project_id: request.issue.project_id || request.target_project?.id || request.source_project?.id || "",
sequence: index + 1, sequence: index + 1,
workspace_id: "", workspace_id: "",
} as IState } as IState);
);
}); });
return sortByName(Array.from(stateMap.values())); return sortByName(Array.from(stateMap.values()));
@ -103,9 +98,7 @@ 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( labelMap.set(label.id, {
label.id,
{
id: label.id, id: label.id,
color: label.color, color: label.color,
name: label.name, name: label.name,
@ -113,8 +106,7 @@ const buildLabels = (requests: TExternalContourRequest[]): IIssueLabel[] => {
project_id: request.issue.project_id || "", project_id: request.issue.project_id || "",
sort_order: 0, sort_order: 0,
workspace_id: "", workspace_id: "",
} as IIssueLabel } as IIssueLabel);
);
}); });
}); });
@ -127,14 +119,11 @@ 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( memberMap.set(assignee.id, {
assignee.id,
{
id: assignee.id, id: assignee.id,
avatar_url: assignee.avatar_url, avatar_url: assignee.avatar_url,
display_name: assignee.display_name, display_name: assignee.display_name,
} as IUserLite } as IUserLite);
);
}); });
}); });
@ -151,14 +140,11 @@ const buildRequesters = (requests: TExternalContourRequest[]): IUserLite[] => {
if (!requesterId || !requesterName || memberMap.has(requesterId)) return; if (!requesterId || !requesterName || memberMap.has(requesterId)) return;
memberMap.set( memberMap.set(requesterId, {
requesterId,
{
id: requesterId, id: requesterId,
avatar_url: request.issue.created_by_detail?.avatar_url, avatar_url: request.issue.created_by_detail?.avatar_url,
display_name: requesterName, display_name: requesterName,
} as IUserLite } as IUserLite);
);
}); });
return sortByName(Array.from(memberMap.values())); return sortByName(Array.from(memberMap.values()));

View File

@ -59,6 +59,7 @@ 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,13 +10,16 @@ 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();
@ -37,6 +40,7 @@ export const WorkspaceContentWrapper = observer(function WorkspaceContentWrapper
> >
{children} {children}
</div> </div>
{workspaceSlug && <VoiceTaskerGlobalControl workspaceSlug={workspaceSlug} />}
</div> </div>
</div> </div>
); );

View File

@ -8,16 +8,30 @@
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: config?.file_size_limit ?? MAX_FILE_SIZE, maxFileSize,
fileSizeLimitEnabled: workspaceLimitEnabled,
}; };
}; };

View File

@ -22,7 +22,9 @@ 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">Analytics</span> <span className="text-11 font-medium tracking-[0.24em] text-secondary uppercase">
{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="space-y-4"> <div className="nodedc-analytics-table space-y-4">
<div className="flex w-full items-center justify-between"> <div className="nodedc-analytics-table-toolbar 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="flex items-center gap-2 text-13 whitespace-nowrap text-placeholder"> <div className="nodedc-analytics-table-count flex items-center gap-2 text-13 whitespace-nowrap text-placeholder">
{searchPlaceholder} {searchPlaceholder}
</div> </div>
)} )}
{!isSearchOpen && ( {!isSearchOpen && (
<button <button
type="button" type="button"
className="-mr-5 grid place-items-center rounded-sm p-2 text-placeholder hover:bg-layer-1" className="nodedc-analytics-search-button -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(
"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", "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",
{ {
"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="Search" placeholder="Поиск"
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="rounded-md"> <div className="nodedc-analytics-table-surface rounded-md">
<Table> <Table>
<TableHeader> <TableHeader>
{table.getHeaderGroups().map((headerGroup) => ( {table.getHeaderGroups().map((headerGroup) => (

View File

@ -39,6 +39,7 @@ 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

@ -0,0 +1,93 @@
/**
* 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: "Count", name: "Количество",
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,6 +16,7 @@ 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";
@ -31,17 +32,33 @@ 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={`flex items-center gap-2`}> <div className="nodedc-analytics-filter-row flex items-center gap-2">
<Controller <Controller
name="y_axis" name="y_axis"
control={control} control={control}
@ -51,7 +68,7 @@ export const AnalyticsSelectParams = observer(function AnalyticsSelectParams(pro
onChange={(val: ChartYAxisMetric | null) => { onChange={(val: ChartYAxisMetric | null) => {
onChange(val); onChange(val);
}} }}
options={ANALYTICS_Y_AXIS_VALUES} options={yAxisOptions}
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,
@ -72,7 +89,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 || "Add Property"} {xAxisOptions.find((v) => v.value === value)?.label || "Добавить поле"}
</span> </span>
</div> </div>
} }
@ -93,12 +110,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 || "Add Property"} {groupByOptions.find((v) => v.value === value)?.label || "Группировка"}
</span> </span>
</div> </div>
} }
options={groupByOptions} options={groupByOptions}
placeholder="Group By" placeholder="Группировка"
allowNoValue allowNoValue
/> />
)} )}

View File

@ -14,6 +14,7 @@ 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;
@ -27,12 +28,12 @@ type Props = TDropdownProps & {
tabIndex?: number; tabIndex?: number;
}; };
function DurationDropdown({ placeholder = "Duration", onChange, value }: Props) { function DurationDropdown({ placeholder = "Период", 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: option.name, title: getAnalyticsDurationLabel(option.value, option.name),
isChecked: value === option.value, isChecked: value === option.value,
onClick: () => onChange(option.value), onClick: () => onChange(option.value),
})); }));
@ -42,10 +43,16 @@ function DurationDropdown({ placeholder = "Duration", onChange, value }: Props)
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 ? ANALYTICS_DURATION_FILTER_OPTIONS.find((opt) => opt.value === value)?.name : placeholder} {value
? getAnalyticsDurationLabel(
value,
ANALYTICS_DURATION_FILTER_OPTIONS.find((opt) => opt.value === value)?.name
)
: placeholder}
</div> </div>
} }
menuButtonWrapperClassName="flex items-center rounded-full border-0 outline-none" menuButtonWrapperClassName="nodedc-analytics-select-trigger"
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+ projects` ? `3+ проекта`
: 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,16 +19,18 @@ type Props = {
}; };
export function SelectXAxis(props: Props) { export function SelectXAxis(props: Props) {
const { value, onChange, options, hiddenOptions, allowNoValue, label } = props; const { value, onChange, options, placeholder = "Выбрать", hiddenOptions, allowNoValue, label } = props;
return ( return (
<SelectionDropdown <SelectionDropdown
menuButton={label ?? "Select"} menuButton={label ?? placeholder}
menuButtonWrapperClassName="nodedc-analytics-select-trigger"
dropdownClassName="nodedc-analytics-dropdown"
options={[ options={[
...(allowNoValue ...(allowNoValue
? [ ? [
{ {
key: "__none__", key: "__none__",
title: "No value", title: "Без группировки",
isChecked: value == null, isChecked: value == null,
onClick: () => onChange(null), onClick: () => onChange(null),
}, },

View File

@ -45,10 +45,12 @@ 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 ?? "Add Metric"}</span> <span>{options.find((v) => v.value === value)?.label ?? "Выбрать метрику"}</span>
</div> </div>
} }
options={options options={options

View File

@ -22,6 +22,29 @@ 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,
@ -61,6 +84,13 @@ 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}`,
() => () =>
@ -77,18 +107,38 @@ 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 !peekView ? (insightFields.length % 5 === 0 ? "gap-10 lg:grid-cols-5" : "gap-8 lg:grid-cols-4") : "grid-cols-2"
? ANALYTICS_INSIGHTS_FIELDS[analyticsType]?.length % 5 === 0
? "gap-10 lg:grid-cols-5"
: "gap-8 lg:grid-cols-4"
: "grid-cols-2"
)} )}
> >
{ANALYTICS_INSIGHTS_FIELDS[analyticsType]?.map((item) => ( {insightFields.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: "Resolved", label: "Решено",
fill: "#19803833", fill: "rgba(195, 255, 102, 0.18)",
fillOpacity: 1, fillOpacity: 1,
stackId: "bar-one", stackId: "bar-one",
showDot: false, showDot: false,
smoothCurves: true, smoothCurves: true,
strokeColor: "#198038", strokeColor: "#C3FF66",
strokeOpacity: 1, strokeOpacity: 1,
}, },
{ {
key: "created_issues", key: "created_issues",
label: "Created", label: "Создано",
fill: "#1192E833", fill: "rgba(245, 247, 251, 0.14)",
fillOpacity: 1, fillOpacity: 1,
stackId: "bar-one", stackId: "bar-one",
showDot: false, showDot: false,
smoothCurves: true, smoothCurves: true,
strokeColor: "#1192E8", strokeColor: "#F5F7FB",
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">
Analytics for {title} {cycle && `in ${cycle.name}`} {module && `in ${module.name}`} Аналитика: {title} {cycle && `в цикле ${cycle.name}`} {module && `в модуле ${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 { ANALYTICS_X_AXIS_VALUES, ANALYTICS_Y_AXIS_VALUES, CHART_COLOR_PALETTES, EChartModels } from "@plane/constants"; import { 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 { generateExtendedColors, parseChartData } from "@/components/chart/utils"; import { 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 } from "./utils"; import { generateBarColor, NODEDC_ANALYTICS_COLORS } 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,7 +55,6 @@ 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();
@ -83,70 +82,71 @@ 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 = CHART_COLOR_PALETTES[0]?.[resolvedTheme === "dark" ? "dark" : "light"]; const baseColors = NODEDC_ANALYTICS_COLORS;
const extendedColors = generateExtendedColors(baseColors ?? [], schemaKeys.length);
if (chart_model === EChartModels.BASIC) { if (chart_model === EChartModels.BASIC) {
parsedBars = [ parsedBars = [
{ {
key: "count", key: "count",
label: "Count", label: "Количество",
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) {
const parsedExtremes: { parsedBars = schemaKeys.map((key) => ({
[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: parsedData.schema[key], label: localizedData?.schema[key] ?? getAnalyticsValueLabel(parsedData.schema[key]),
stackId: "bar-one", stackId: "bar-one",
fill: extendedColors[index], fill: generateBarColor(
key,
{ x_axis: group_by ?? x_axis, y_axis, group_by: undefined },
baseColors,
workspaceStates
),
textClassName: "", textClassName: "",
showPercentage: false, showPercentage: false,
showTopBorderRadius: (value, payload: TChartDatum) => parsedExtremes[payload.key].top === value, borderRadius: 10,
showBottomBorderRadius: (value, payload: TChartDatum) => parsedExtremes[payload.key].bottom === value, showTopBorderRadius: () => true,
showBottomBorderRadius: () => true,
})); }));
} else { } else {
parsedBars = []; parsedBars = [];
} }
return parsedBars; return parsedBars;
}, [chart_model, group_by, parsedData, resolvedTheme, workspaceStates, x_axis, y_axis]); }, [chart_model, group_by, localizedData?.schema, parsedData, workspaceStates, x_axis, y_axis]);
const yAxisLabel = useMemo( const yAxisLabel = useMemo(() => getAnalyticsYAxisLabel(props.y_axis), [props.y_axis]);
() => ANALYTICS_Y_AXIS_VALUES.find((item) => item.value === props.y_axis)?.label ?? props.y_axis, const xAxisLabel = useMemo(() => getAnalyticsXAxisLabel(props.x_axis), [props.x_axis]);
[props.y_axis] const chartWidth = useMemo(() => {
); const itemCount = localizedData?.data.length ?? 0;
const xAxisLabel = useMemo( const widthPerItem = chart_model === EChartModels.STACKED ? 88 : 100;
() => ANALYTICS_X_AXIS_VALUES.find((item) => item.value === props.x_axis)?.label ?? props.x_axis,
[props.x_axis] return Math.max(560, itemCount * widthPerItem);
); }, [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">Count</div>, header: () => <div className="text-right">Количество</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: "Count", key: "Количество",
value: (row) => row.original.count, value: (row) => row.original.count,
label: "Count", label: "Количество",
}, },
}, },
}, },
@ -179,55 +179,64 @@ const PriorityChart = observer(function PriorityChart(props: Props) {
const columns: ColumnDef<TChartDatum>[] = useMemo( const columns: ColumnDef<TChartDatum>[] = useMemo(
() => () =>
parsedData localizedData
? Object.keys(parsedData?.schema ?? {}).map((key) => ({ ? Object.keys(localizedData?.schema ?? {}).map((key) => ({
accessorKey: key, accessorKey: key,
header: () => <div className="text-right">{parsedData.schema[key]}</div>, header: () => <div className="text-right">{localizedData.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: parsedData.schema[key], label: localizedData.schema[key],
}, },
}, },
})) }))
: [], : [],
[parsedData] [localizedData]
); );
return ( return (
<div className="flex flex-col gap-12"> <div className="nodedc-analytics-chart-stack flex flex-col gap-8">
{priorityChartLoading ? ( {priorityChartLoading ? (
<ChartLoader /> <ChartLoader />
) : parsedData?.data && parsedData.data.length > 0 ? ( ) : localizedData?.data && localizedData.data.length > 0 ? (
<> <>
<div className="nodedc-analytics-chart-viewport">
<div className="nodedc-analytics-chart-inner h-[370px]" style={{ width: `${chartWidth}px` }}>
<BarChart <BarChart
className="h-[370px] w-full" className="nodedc-analytics-bar-chart h-full w-full"
data={parsedData.data} data={localizedData.data}
bars={bars} bars={bars}
barSize={chart_model === EChartModels.STACKED ? 72 : 86}
margin={{ margin={{
bottom: 30, top: 12,
right: 16,
bottom: 34,
left: 8,
}} }}
xAxis={{ xAxis={{
key: "name", key: "name",
label: xAxisLabel.replace("_", " "), label: xAxisLabel,
dy: 30, dy: 30,
}} }}
yAxis={{ yAxis={{
key: "count", key: "count",
label: t("common.no_of", { entity: yAxisLabel.replace("_", " ") }), label: yAxisLabel,
offset: -60, offset: -60,
dx: -26, dx: -26,
}} }}
/> />
</div>
</div>
<DataTable <DataTable
data={parsedData.data} data={localizedData.data}
columns={[...defaultColumns, ...columns]} columns={[...defaultColumns, ...columns]}
searchPlaceholder={`${parsedData.data.length} ${xAxisLabel}`} searchPlaceholder={`${localizedData.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 } from "@plane/types"; import type { ChartYAxisMetric, IState, TStateGroups } from "@plane/types";
import { ChartXAxisProperty } from "@plane/types"; import { ChartXAxisProperty } from "@plane/types";
interface ParamsProps { interface ParamsProps {
@ -14,26 +14,56 @@ 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]; if (!value) return baseColors[0] ?? NODEDC_ANALYTICS_COLORS[0];
let color = baseColors[0]; let color = getFallbackColor(value, baseColors);
// Priority // Priority
if (params.x_axis === ChartXAxisProperty.PRIORITY) { if (params.x_axis === ChartXAxisProperty.PRIORITY) {
color = color = PRIORITY_COLORS[value] ?? color;
value === "urgent" }
? "#ef4444"
: value === "high" // State group
? "#f97316" if (params.x_axis === ChartXAxisProperty.STATE_GROUPS) {
: value === "medium" color = STATE_GROUP_COLORS[value as TStateGroups] ?? color;
? "#eab308"
: value === "low"
? "#22c55e"
: "#ced4da";
} }
// State // State
@ -41,10 +71,9 @@ 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.color; color = STATE_GROUP_COLORS[state.group] ?? color;
} else { } else {
const index = Math.abs(value.split("").reduce((acc, char) => acc + char.charCodeAt(0), 0)) % baseColors.length; color = getFallbackColor(value, baseColors);
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 ?? t(`Unassigned`)}</span> <span className="break-words text-secondary">{row.original.display_name ?? "Без исполнителя"}</span>
</div> </div>
</div> </div>
), ),

View File

@ -7,6 +7,7 @@
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";
@ -19,7 +20,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 = issue.state__color; const color = getStateGroupColor(issue.state__group, 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 }; type Props = { isOpen: boolean; onClose: () => void; isSMTPConfigured?: boolean };
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 } = props; const { isOpen, onClose, isSMTPConfigured = true } = props;
// states // states
const [currentStep, setCurrentStep] = useState<TModalStep>("EMAIL"); const [currentStep, setCurrentStep] = useState<TModalStep>("EMAIL");
// store hooks // store hooks
@ -107,6 +107,20 @@ 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 });
@ -135,7 +149,9 @@ 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">{changeEmailT("description")}</p> <p className="my-4 text-13 text-secondary">
{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 } from "@plane/propel/icons"; import { SearchIcon, CloseIcon, getStateGroupColor } 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: issue.state__color, backgroundColor: getStateGroupColor(issue.state__group, 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="relative w-max min-w-full" className="nodedc-project-gantt-row relative w-max min-w-full"
onMouseEnter={() => updateActiveBlockId(blockId)} onMouseEnter={() => updateActiveBlockId(blockId)}
onMouseLeave={() => updateActiveBlockId(null)} onMouseLeave={() => updateActiveBlockId(null)}
style={{ style={{
@ -89,19 +89,18 @@ export const BlockRow = observer(function BlockRow(props: Props) {
}} }}
> >
<div <div
className={cn("relative h-full bg-layer-transparent hover:bg-layer-transparent-hover", { className={cn("nodedc-project-gantt-row-bg relative h-full", {
"rounded-l-sm border border-r-0 border-accent-strong": getIsIssuePeeked(block.data.id), "nodedc-project-gantt-row-peeked": getIsIssuePeeked(block.data.id),
"bg-layer-transparent-hover": isBlockHoveredOn, "nodedc-project-gantt-row-hovered": isBlockHoveredOn,
"bg-accent-primary/5 hover:bg-accent-primary/10": isBlockSelected, "nodedc-project-gantt-row-selected": isBlockSelected,
"bg-accent-primary/10": isBlockSelected && isBlockHoveredOn, "nodedc-project-gantt-row-focused": isBlockFocused,
"border border-r-0 border-strong-1": isBlockFocused,
})} })}
> >
{isBlockVisibleOnChart {isBlockVisibleOnChart
? isHidden && ( ? isHidden && (
<button <button
type="button" type="button"
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" 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"
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="h-8 w-full rounded-sm bg-layer-1" />} placeholderChildren={<div className="nodedc-project-gantt-block-placeholder h-8 w-full" />}
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 { Expand, Shrink } from "lucide-react"; import { CalendarDays, 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,62 +25,81 @@ 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 { blockIds, fullScreenMode, handleChartView, handleToday, loaderTitle, toggleFullScreenMode, showToday } = const {
props; blockIds,
fullScreenMode,
handleChartView,
handleToday,
loaderTitle,
title,
toggleFullScreenMode,
showToday,
} = props;
// chart hook // chart hook
const { currentView } = useTimeLineChartStore(); const { currentView } = useTimeLineChartStore();
return ( return (
<Row <Row
className="relative flex w-full flex-shrink-0 flex-wrap items-center gap-2 bg-surface-1 py-2 whitespace-nowrap" className="nodedc-project-gantt-toolbar relative flex w-full flex-shrink-0 flex-wrap items-center justify-between gap-3 whitespace-nowrap"
style={{ height: `${GANTT_BREADCRUMBS_HEIGHT}px` }} style={{ height: `${GANTT_BREADCRUMBS_HEIGHT}px` }}
> >
<div className="ml-auto"> <div className="flex min-w-0 items-center gap-3">
<div className="ml-auto text-11 font-medium text-tertiary"> <div className="nodedc-project-gantt-toolbar-icon">
<CalendarDays className="size-4" />
</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")} {blockIds ? `${blockIds.length} ${loaderTitle}` : t("common.loading")}
</div> </div>
</div> </div>
<div className="flex flex-wrap items-center gap-2">
{VIEWS_LIST.map((chartView: any) => (
<div
key={chartView?.key}
className={cn(
"cursor-pointer rounded-md bg-layer-transparent p-1 px-2 text-11 hover:bg-layer-transparent-hover",
{
"bg-layer-transparent-selected": currentView === chartView?.key,
}
)}
onClick={() => handleChartView(chartView?.key)}
>
{t(chartView?.i18n_title)}
</div>
))}
</div> </div>
<div className="flex flex-wrap items-center justify-end gap-2">
{showToday && ( {showToday && (
<button <button
type="button" type="button"
className="rounded-md bg-layer-transparent p-1 px-2 text-11 hover:bg-layer-transparent-hover" className="nodedc-project-gantt-chip nodedc-project-gantt-chip-live"
onClick={handleToday} onClick={handleToday}
> >
{t("common.today")} Live
</button> </button>
)} )}
{VIEWS_LIST.map((chartView: any) => (
<button <button
key={chartView?.key}
type="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" aria-label={t(chartView?.i18n_title)}
onClick={toggleFullScreenMode} aria-pressed={currentView === chartView?.key}
className={cn("nodedc-project-gantt-chip", {
"nodedc-project-gantt-chip-active": currentView === chartView?.key,
})}
onClick={() => handleChartView(chartView?.key)}
> >
{fullScreenMode ? <Shrink className="h-4 w-4" /> : <Expand className="h-4 w-4" />} {GANTT_VIEW_SHORT_LABELS[chartView?.key as TGanttViews]}
</button> </button>
))}
<button type="button" className="nodedc-project-gantt-round-button" onClick={toggleFullScreenMode}>
{fullScreenMode ? <Shrink className="size-4" /> : <Expand className="size-4" />}
</button>
</div>
</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(
"vertical-scrollbar horizontal-scrollbar flex scrollbar-lg h-full w-full overflow-auto border-t-[0.5px] border-subtle", "nodedc-project-gantt-scroll vertical-scrollbar horizontal-scrollbar flex scrollbar-lg h-full w-full overflow-auto",
{ {
"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="relative h-max min-h-full flex-shrink-0 flex-grow"> <div className="nodedc-project-gantt-stage relative h-max min-h-full flex-shrink-0 flex-grow">
<ActiveChartView /> <ActiveChartView />
{currentViewData && ( {currentViewData && (
<div <div
className="relative h-full" className="nodedc-project-gantt-layer 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("shadow relative flex h-full flex-col rounded-xs bg-surface-1 select-none", { className={cn("nodedc-project-gantt-card shadow relative flex h-full flex-col select-none", {
"inset-0 z-[25] bg-surface-1": fullScreenMode, "fixed inset-0 z-[25] rounded-none": fullScreenMode,
"border-[0.5px] border-subtle": border, "border-[0.5px] border-subtle": border,
})} })}
> >
@ -192,6 +192,7 @@ 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="absolute top-0 left-0 flex h-max min-h-full w-max"> <div className="nodedc-project-gantt-calendar absolute top-0 left-0 flex h-max min-h-full w-max">
{currentViewData && ( {currentViewData && (
<div className="relative flex flex-col outline-[0.25px] outline-subtle-1"> <div className="nodedc-project-gantt-calendar-group relative flex flex-col">
{/** Header Div */} {/** Header Div */}
<div <div
className="sticky top-0 z-[5] w-full flex-shrink-0 bg-surface-1" className="nodedc-project-gantt-calendar-header sticky top-0 z-[5] w-full flex-shrink-0"
style={{ style={{
height: `${HEADER_HEIGHT}px`, height: `${HEADER_HEIGHT}px`,
}} }}
@ -45,18 +45,22 @@ 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 outline-[0.5px] outline-subtle-1" className="flex"
style={{ width: `${monthBlock.days * currentViewData?.data.dayWidth}px` }} style={{ width: `${monthBlock.days * currentViewData?.data.dayWidth}px` }}
> >
<div <div
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" 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"
style={{ style={{
left: `${SIDEBAR_WIDTH}px`, left: `${SIDEBAR_WIDTH}px`,
}} }}
> >
{monthBlock?.title} {monthBlock?.title}
{monthBlock.today && ( {monthBlock.today && (
<span className={cn("ml-2 rounded-sm bg-accent-primary px-1 text-9 font-medium text-on-color")}> <span
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>
)} )}
@ -70,9 +74,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(
"flex flex-shrink-0 justify-between px-2 py-1 text-center capitalize outline-[0.25px] outline-subtle-1", "nodedc-project-gantt-subcell flex flex-shrink-0 justify-between px-2 py-1 text-center capitalize",
{ {
"bg-accent-primary/20": weekBlock.today, "nodedc-project-gantt-subcell-today": weekBlock.today,
} }
)} )}
style={{ width: `${currentViewData?.data.dayWidth * 7}px` }} style={{ width: `${currentViewData?.data.dayWidth * 7}px` }}
@ -80,7 +84,8 @@ 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-sm bg-accent-primary px-1 text-on-color": weekBlock.today, "rounded-full bg-[rgb(var(--nodedc-card-active-rgb))] px-1.5 text-[rgb(var(--nodedc-on-card-active-rgb))]":
weekBlock.today,
})} })}
> >
{weekBlock.startDate.getDate()}-{weekBlock.endDate.getDate()} {weekBlock.startDate.getDate()}-{weekBlock.endDate.getDate()}
@ -96,8 +101,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("h-full overflow-hidden outline-[0.25px] outline-subtle", { className={cn("nodedc-project-gantt-column h-full overflow-hidden", {
"bg-accent-primary/20": weekBlock.today, "nodedc-project-gantt-column-today": 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={`absolute top-0 left-0 flex h-max min-h-full w-max`}> <div className="nodedc-project-gantt-calendar 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="relative flex flex-col outline-[0.25px] outline-subtle-1" className="nodedc-project-gantt-calendar-group relative flex flex-col"
> >
{/** Header Div */} {/** Header Div */}
<div <div
className="sticky top-0 z-[5] w-full flex-shrink-0 bg-surface-1 outline-[1px] outline-subtle-1" className="nodedc-project-gantt-calendar-header sticky top-0 z-[5] w-full flex-shrink-0"
style={{ style={{
height: `${HEADER_HEIGHT}px`, height: `${HEADER_HEIGHT}px`,
}} }}
@ -38,19 +38,23 @@ 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="sticky z-[1] my-1 flex items-center bg-surface-1 px-3 py-1 text-14 font-regular whitespace-nowrap text-secondary capitalize" 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"
style={{ style={{
left: `${SIDEBAR_WIDTH}px`, left: `${SIDEBAR_WIDTH}px`,
}} }}
> >
{quarterBlock?.title} {quarterBlock?.title}
{quarterBlock.today && ( {quarterBlock.today && (
<span className={cn("ml-2 rounded-sm bg-accent-primary px-1 text-9 font-medium text-on-color")}> <span
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="sticky px-3 py-2 text-11 whitespace-nowrap text-placeholder capitalize"> <div className="nodedc-project-gantt-period-meta sticky px-3 py-2 text-11 whitespace-nowrap text-placeholder capitalize">
{quarterBlock.shortTitle} {quarterBlock.shortTitle}
</div> </div>
</div> </div>
@ -60,9 +64,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(
"flex flex-shrink-0 justify-center text-center capitalize outline-[0.25px] outline-subtle-1", "nodedc-project-gantt-subcell flex flex-shrink-0 justify-center text-center capitalize",
{ {
"bg-accent-primary/20": monthBlock.today, "nodedc-project-gantt-subcell-today": monthBlock.today,
} }
)} )}
style={{ width: `${currentViewData?.data.dayWidth * monthBlock.days}px` }} style={{ width: `${currentViewData?.data.dayWidth * monthBlock.days}px` }}
@ -70,7 +74,8 @@ 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-lg bg-accent-primary px-2 text-on-color": monthBlock.today, "rounded-full bg-[rgb(var(--nodedc-card-active-rgb))] px-2 text-[rgb(var(--nodedc-on-card-active-rgb))]":
monthBlock.today,
})} })}
> >
{monthBlock.monthData.shortTitle} {monthBlock.monthData.shortTitle}
@ -85,8 +90,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("h-full overflow-hidden outline-[0.25px] outline-subtle", { className={cn("nodedc-project-gantt-column h-full overflow-hidden", {
"bg-accent-primary/20": monthBlock.today, "nodedc-project-gantt-column-today": 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={`absolute top-0 left-0 flex h-max min-h-full w-max`}> <div className="nodedc-project-gantt-calendar 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="relative flex flex-col outline-[0.25px] outline-subtle-1" className="nodedc-project-gantt-calendar-group relative flex flex-col"
> >
{/** Header Div */} {/** Header Div */}
<div <div
className="sticky top-0 z-[5] w-full flex-shrink-0 bg-surface-1 outline-[1px] outline-subtle-1" className="nodedc-project-gantt-calendar-header sticky top-0 z-[5] w-full flex-shrink-0"
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="sticky z-[1] m-1 flex items-center bg-surface-1 px-3 py-1 text-13 font-regular whitespace-nowrap text-secondary capitalize" 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"
style={{ style={{
left: `${SIDEBAR_WIDTH}px`, left: `${SIDEBAR_WIDTH}px`,
}} }}
> >
{block?.title} {block?.title}
</div> </div>
<div className="sticky px-3 py-2 text-11 whitespace-nowrap text-placeholder capitalize"> <div className="nodedc-project-gantt-period-meta 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(
"flex flex-shrink-0 justify-between p-1 text-center capitalize outline-[0.25px] outline-subtle-1", "nodedc-project-gantt-subcell flex flex-shrink-0 justify-between p-1 text-center capitalize",
{ {
"bg-accent-primary/20": weekDay.today, "nodedc-project-gantt-subcell-today": weekDay.today,
} }
)} )}
style={{ width: `${currentViewData?.data.dayWidth}px` }} style={{ width: `${currentViewData?.data.dayWidth}px` }}
@ -63,7 +63,8 @@ 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-sm bg-accent-primary px-1 text-on-color": weekDay.today, "rounded-full bg-[rgb(var(--nodedc-card-active-rgb))] px-1.5 text-[rgb(var(--nodedc-on-card-active-rgb))]":
weekDay.today,
})} })}
> >
{weekDay.date.getDate()} {weekDay.date.getDate()}
@ -74,17 +75,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 bg-surface-1"> <div className="flex h-full w-full flex-grow">
{block?.children?.map((weekDay, index) => ( {block?.children?.map((weekDay, index) => (
<div <div
key={`column-${rootIndex}-${index}`} key={`column-${rootIndex}-${index}`}
className={cn("h-full overflow-hidden outline-[0.25px] outline-subtle", { className={cn("nodedc-project-gantt-column h-full overflow-hidden", {
"bg-accent-primary/20": weekDay.today, "nodedc-project-gantt-column-today": 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="h-full bg-surface-2 outline-[0.25px] outline-strong" /> <div className="nodedc-project-gantt-column-weekend h-full" />
)} )}
</div> </div>
))} ))}

View File

@ -4,11 +4,11 @@
* See the LICENSE file for details. * See the LICENSE file for details.
*/ */
export const BLOCK_HEIGHT = 44; export const BLOCK_HEIGHT = 46;
export const HEADER_HEIGHT = 48; export const HEADER_HEIGHT = 56;
export const GANTT_BREADCRUMBS_HEIGHT = 40; export const GANTT_BREADCRUMBS_HEIGHT = 60;
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="rounded-sm bg-accent-subtle px-2 py-1">{dateString}</div> <div className="nodedc-project-gantt-resize-tooltip 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(
"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", "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",
{ {
"-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="rounded-sm bg-accent-subtle px-2 py-1">{dateString}</div> <div className="nodedc-project-gantt-resize-tooltip 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(
"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", "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",
{ {
"-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="group relative z-[5] inline-flex h-full w-full cursor-pointer items-center font-medium transition-all"> <div className="nodedc-project-gantt-draggable 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("relative z-[6] flex h-8 w-full items-center rounded-sm", { className={cn("nodedc-project-gantt-draggable-shell relative z-[6] flex h-8 w-full items-center", {
"pointer-events-none": isMoving, "pointer-events-none": isMoving,
})} })}
onMouseDown={(e) => enableBlockMove && handleBlockDrag(e, "move")} onMouseDown={(e) => enableBlockMove && handleBlockDrag(e, "move")}

View File

@ -0,0 +1,677 @@
/**
* 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,23 +44,19 @@ export const IssuesSidebarBlock = observer(function IssuesSidebarBlock(props: Pr
return ( return (
<div <div
className={cn("group/list-block", { className={cn("nodedc-project-gantt-sidebar-block group/list-block", {
"rounded-sm bg-layer-1": isDragging, "nodedc-project-gantt-sidebar-block-dragging": isDragging,
"rounded-l-sm border border-r-0 border-accent-strong": getIsIssuePeeked(block.data.id), "nodedc-project-gantt-sidebar-block-peeked": getIsIssuePeeked(block.data.id),
"border border-r-0 border-strong-1": isIssueFocused, "nodedc-project-gantt-sidebar-block-focused": isIssueFocused,
})} })}
onMouseEnter={() => updateActiveBlockId(block.id)} onMouseEnter={() => updateActiveBlockId(block.id)}
onMouseLeave={() => updateActiveBlockId(null)} onMouseLeave={() => updateActiveBlockId(null)}
> >
<Row <Row
className={cn( className={cn("nodedc-project-gantt-sidebar-row group flex w-full items-center gap-2 pr-4", {
"group flex w-full items-center gap-2 bg-layer-transparent pr-4 hover:bg-layer-transparent-hover", "nodedc-project-gantt-sidebar-row-hovered": isBlockHoveredOn,
{ "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> <div className="nodedc-project-gantt-sidebar-list">
{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="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 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> </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="sticky left-0 z-10 h-max min-h-full flex-shrink-0 border-r-[0.5px] border-subtle-1 bg-surface-1" className="nodedc-project-gantt-sidebar sticky left-0 z-10 h-max min-h-full flex-shrink-0"
style={{ style={{
width: `${SIDEBAR_WIDTH}px`, width: `${SIDEBAR_WIDTH}px`,
}} }}
variant={ERowVariant.HUGGING} variant={ERowVariant.HUGGING}
> >
<Row <Row
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" 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"
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="h-max min-h-full bg-surface-1"> <Row variant={ERowVariant.HUGGING} className="nodedc-project-gantt-sidebar-body h-max min-h-full">
{sidebarToRender && {sidebarToRender &&
sidebarToRender({ sidebarToRender({
title, title,

View File

@ -15,6 +15,7 @@ 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";
@ -23,7 +24,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, HomeOperationsOverview } from "./home-project-insights"; import { HomeActivityTrendCard, HomeOperationsCard, HomeRhythmRecentOverview } 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";
@ -56,6 +57,11 @@ 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,
@ -81,6 +87,7 @@ 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
@ -162,6 +169,7 @@ 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;
@ -190,37 +198,40 @@ export const DashboardWidgets = observer(function DashboardWidgets(props: Dashbo
].filter(Boolean); ].filter(Boolean);
return ( return (
<div className="relative flex h-full w-full flex-col gap-6"> <div className="nodedc-home-dashboard-shell relative flex h-full w-full flex-col">
<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)}
/> />
<div className="grid gap-5 xl:grid-cols-[minmax(320px,360px)_minmax(0,1fr)] xl:items-stretch"> <HomePageHeader
<div className="min-w-0"> currentUser={currentUser}
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="min-w-0 space-y-5"> <div className="nodedc-home-main-column min-w-0">
<HomeGanttPreview <HomeGanttPreview
project={selectedProject} project={selectedProject}
analytics={selectedProjectAnalytics} analytics={selectedProjectAnalytics}
workspaceSlug={workspaceSlugValue} workspaceSlug={workspaceSlugValue}
/> />
<HomeOperationsOverview <HomeRhythmRecentOverview
project={selectedProject} project={selectedProject}
analytics={selectedProjectAnalytics} analytics={selectedProjectAnalytics}
analyticsCollection={analyticsCollection} analyticsCollection={analyticsCollection}
@ -231,6 +242,14 @@ 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)]">
<HomeOperationsCard
project={selectedProject}
analytics={selectedProjectAnalytics}
analyticsCollection={analyticsCollection}
recents={workspaceRecents}
locale={currentLocale}
/>
<HomeActivityTrendCard <HomeActivityTrendCard
project={selectedProject} project={selectedProject}
analytics={selectedProjectAnalytics} analytics={selectedProjectAnalytics}
@ -238,8 +257,11 @@ export const DashboardWidgets = observer(function DashboardWidgets(props: Dashbo
recents={workspaceRecents} recents={workspaceRecents}
locale={currentLocale} 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,17 +4,18 @@
* See the LICENSE file for details. * See the LICENSE file for details.
*/ */
import { useMemo, useState } from "react";
import { CalendarDays, Filter, SlidersHorizontal } from "lucide-react";
import useSWR from "swr"; import useSWR from "swr";
import { useTranslation } from "@plane/i18n"; import { useTranslation } from "@plane/i18n";
import type { TIssue, TProjectAnalyticsCount } from "@plane/types"; import type { TIssue, TProjectAnalyticsCount } from "@plane/types";
import { cn } from "@plane/utils"; import {
GanttTimelinePreview,
type TGanttTimelinePreviewItem,
} from "@/components/gantt-chart/preview/timeline-preview";
import { IssueService } from "@/services/issue"; import { IssueService } from "@/services/issue";
import { getCompletionRate, type THomeProjectData } from "./home.utils"; import type { THomeProjectData } from "./home.utils";
const issueService = new IssueService(); const issueService = new IssueService();
const GANTT_PREVIEW_LIMIT = 6; const GANTT_PREVIEW_LIMIT = 30;
const GANTT_PREVIEW_CURSOR = `${GANTT_PREVIEW_LIMIT}:0:0`; const GANTT_PREVIEW_CURSOR = `${GANTT_PREVIEW_LIMIT}:0:0`;
type HomeGanttPreviewProps = { type HomeGanttPreviewProps = {
@ -23,85 +24,42 @@ type HomeGanttPreviewProps = {
workspaceSlug: string; workspaceSlug: string;
}; };
type TGanttPreviewItem = { const getIssueResults = (response: unknown): TIssue[] => {
id: string; if (Array.isArray(response)) return response as TIssue[];
label: string; if (response && typeof response === "object" && "results" in response) {
subtitle: string; const results = (response as { results?: unknown }).results;
start: number; if (Array.isArray(results)) return results as TIssue[];
width: number; }
tone: "accent" | "muted" | "white";
return [];
}; };
const GANTT_RANGES = ["Live", "1D", "1W", "1M"] as const; const getIssueStateGroup = (issue: TIssue): TGanttTimelinePreviewItem["state_group"] =>
type TGanttRange = (typeof GANTT_RANGES)[number]; issue.state__group ??
(issue as TIssue & { state_detail?: { group?: TGanttTimelinePreviewItem["state_group"] } }).state_detail?.group ??
null;
const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max); const buildPreviewItems = (issues: TIssue[], project: THomeProjectData | undefined): TGanttTimelinePreviewItem[] =>
issues.map((issue, index) => ({
const buildSyntheticItems = (project: THomeProjectData | undefined, analytics: TProjectAnalyticsCount | undefined) => { assignee_ids: issue.assignee_ids,
const completionRate = getCompletionRate(analytics); completed_at: issue.completed_at,
const openIssues = Math.max((analytics?.total_issues ?? 0) - (analytics?.completed_issues ?? 0), 0); created_at: issue.created_at,
const baseName = project?.identifier ?? "NODE"; created_by: issue.created_by,
return [
{
id: "synthetic-approval",
label: "Согласование расходов",
subtitle: `${baseName} / финконтроль`,
start: 6,
width: clamp(34 + completionRate * 0.22, 26, 58),
tone: "accent",
},
{
id: "synthetic-docs",
label: "Контроль документов",
subtitle: `${baseName} / внешний обмен`,
start: 22,
width: clamp(28 + openIssues * 2, 24, 54),
tone: "white",
},
{
id: "synthetic-sync",
label: "Синхронизация статусов",
subtitle: `${baseName} / внутренний контур`,
start: 42,
width: 36,
tone: "muted",
},
{
id: "synthetic-close",
label: "Закрытие остатка",
subtitle: `${baseName} / итог недели`,
start: 58,
width: 28,
tone: "accent",
},
] satisfies TGanttPreviewItem[];
};
const buildIssueItems = (issues: TIssue[], project: THomeProjectData): TGanttPreviewItem[] =>
issues.slice(0, GANTT_PREVIEW_LIMIT).map((issue, index) => {
const createdDate = Date.parse(issue.created_at ?? "") || Date.now();
const targetDate = Date.parse(issue.target_date ?? "") || createdDate + (index + 3) * 24 * 60 * 60 * 1000;
const durationDays = Math.max((targetDate - createdDate) / (24 * 60 * 60 * 1000), 1);
const start = clamp((index * 13 + durationDays * 2) % 68, 4, 72);
const width = clamp(20 + durationDays * 5, 22, 48);
return {
id: issue.id, id: issue.id,
label: issue.name, identifier: project
subtitle: `${project.identifier}-${issue.sequence_id ?? index + 1}`, ? `${project.identifier}-${issue.sequence_id ?? index + 1}`
start, : `#${issue.sequence_id ?? index + 1}`,
width, name: issue.name,
tone: index % 3 === 0 ? "accent" : index % 3 === 1 ? "white" : "muted", priority: issue.priority,
}; sort_order: issue.sort_order,
}); start_date: issue.start_date,
state_group: getIssueStateGroup(issue),
target_date: issue.target_date,
}));
export function HomeGanttPreview(props: HomeGanttPreviewProps) { export function HomeGanttPreview(props: HomeGanttPreviewProps) {
const { analytics, project, workspaceSlug } = props; const { project, workspaceSlug } = props;
const { currentLocale } = useTranslation(); const { currentLocale } = useTranslation();
const [activeRange, setActiveRange] = useState<TGanttRange>("Live");
const [isCompactMode, setIsCompactMode] = useState(false);
const [isFilterActive, setIsFilterActive] = useState(false);
const { data: issueResponse, isLoading } = useSWR( const { data: issueResponse, isLoading } = useSWR(
project ? `HOME_GANTT_PREVIEW_${workspaceSlug}_${project.id}` : null, project ? `HOME_GANTT_PREVIEW_${workspaceSlug}_${project.id}` : null,
@ -120,139 +78,17 @@ export function HomeGanttPreview(props: HomeGanttPreviewProps) {
} }
); );
const timelineLabels = useMemo(() => { const issues = getIssueResults(issueResponse);
const locale = currentLocale || "ru-RU"; const previewItems = buildPreviewItems(issues, project);
const dayFormatter = new Intl.DateTimeFormat(locale, {
day: "numeric",
month: "short",
});
const hourFormatter = new Intl.DateTimeFormat(locale, {
hour: "2-digit",
minute: "2-digit",
});
if (activeRange === "Live" || activeRange === "1D") {
const date = new Date();
const step = activeRange === "Live" ? 2 : 3;
return Array.from({ length: activeRange === "Live" ? 8 : 9 }, (_, index) => {
const labelDate = new Date(date);
labelDate.setHours(date.getHours() + index * step);
return hourFormatter.format(labelDate);
});
}
return Array.from({ length: activeRange === "1W" ? 7 : 8 }, (_, index) => {
const date = new Date();
date.setDate(date.getDate() + index * (activeRange === "1W" ? 1 : 4));
return dayFormatter.format(date);
});
}, [activeRange, currentLocale]);
const previewItems = useMemo(() => {
const issues = issueResponse?.results;
if (project && Array.isArray(issues) && issues.length > 0) return buildIssueItems(issues, project);
return buildSyntheticItems(project, analytics);
}, [analytics, issueResponse, project]);
const visibleItems = isFilterActive ? previewItems.filter((item) => item.tone !== "muted") : previewItems;
const timelineWidth = `${Math.max(timelineLabels.length * 168 + 240, 1080)}px`;
return ( return (
<section className="nodedc-home-gantt-card"> <GanttTimelinePreview
<div className="nodedc-home-gantt-toolbar"> emptyMessage="На главной показываются только задачи выбранного проекта, у которых заполнены start date или target date."
<div className="flex min-w-0 items-center gap-3"> isLoading={isLoading}
<div className="grid size-10 shrink-0 place-items-center rounded-full bg-black text-[rgb(var(--nodedc-card-active-rgb))]"> items={previewItems}
<CalendarDays className="size-4" /> locale={currentLocale}
</div> title="Календарное окно Ганта"
<div className="min-w-0"> subtitle={project ? `${project.name} / реальные даты задач` : "Выберите проект для календарного окна"}
<div className="text-18 leading-none font-semibold text-primary">Календарное окно Ганта</div>
<div className="mt-1 truncate text-12 text-secondary">
{project ? `${project.name} / ближайший рабочий горизонт` : "Выберите проект для живого окна"}
</div>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
{GANTT_RANGES.map((item) => (
<button
key={item}
type="button"
aria-pressed={activeRange === item}
className={cn("nodedc-home-gantt-chip", { "nodedc-home-gantt-chip-active": activeRange === item })}
onClick={() => setActiveRange(item)}
>
{item}
</button>
))}
<button
type="button"
className={cn("nodedc-home-gantt-round-button", {
"nodedc-home-gantt-round-button-active": isCompactMode,
})}
aria-pressed={isCompactMode}
aria-label="Плотный режим Ганта"
onClick={() => setIsCompactMode((value) => !value)}
>
<SlidersHorizontal className="size-4" />
</button>
<button
type="button"
className={cn("nodedc-home-gantt-round-button", {
"nodedc-home-gantt-round-button-active": isFilterActive,
})}
aria-pressed={isFilterActive}
aria-label="Фильтры Ганта"
onClick={() => setIsFilterActive((value) => !value)}
>
<Filter className="size-4" />
</button>
</div>
</div>
<div className="nodedc-home-gantt-surface">
<div className="nodedc-home-gantt-scroll" tabIndex={0} aria-label="Горизонтальная прокрутка окна Ганта">
<div className="nodedc-home-gantt-canvas" style={{ width: timelineWidth }}>
<div
className="nodedc-home-gantt-grid"
style={{ gridTemplateColumns: `repeat(${timelineLabels.length}, minmax(10rem, 1fr))` }}
aria-hidden="true"
>
{timelineLabels.map((label, index) => (
<div key={`${label}-${index}`} className="nodedc-home-gantt-grid-column">
<span>{label}</span>
</div>
))}
</div>
<div className={cn("relative z-[1] space-y-3 pt-12", { "space-y-2": isCompactMode })}>
{isLoading
? Array.from({ length: 4 }, (_, index) => (
<div key={index} className="h-12 animate-pulse rounded-[1.25rem] bg-white/5" />
))
: visibleItems.map((item) => (
<div
key={item.id}
className={cn("nodedc-home-gantt-row", { "nodedc-home-gantt-row-compact": isCompactMode })}
>
<div className="nodedc-home-gantt-row-label min-w-0">
<div className="truncate text-12 font-semibold text-primary">{item.label}</div>
<div className="mt-0.5 truncate text-11 text-placeholder">{item.subtitle}</div>
</div>
<div className="nodedc-home-gantt-track">
<div
className={cn("nodedc-home-gantt-bar", `nodedc-home-gantt-bar-${item.tone}`)}
style={{
left: `${item.start}%`,
width: `${item.width}%`,
}}
/> />
</div>
</div>
))}
</div>
</div>
</div>
</div>
</section>
); );
} }

View File

@ -293,52 +293,18 @@ export function HomeActivityTrendCard(props: HomeProjectInsightsProps) {
); );
} }
export function HomeOperationsOverview(props: HomeProjectInsightsProps) { export function HomeRhythmCard(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="grid gap-4 xl:grid-cols-[minmax(0,0.95fr)_minmax(0,0.95fr)_minmax(300px,1.1fr)]"> <section className="nodedc-home-subpanel nodedc-home-rhythm-card space-y-4 p-5">
<div className="nodedc-home-subpanel space-y-4 p-5">
<div className="flex items-center gap-3">
<div className="grid size-11 place-items-center rounded-2xl bg-[rgba(var(--nodedc-accent-rgb),0.14)] text-[rgb(var(--nodedc-accent-rgb))]">
<UsersRound className="size-5" />
</div>
<div>
<div className="text-15 font-semibold text-primary">Операционный срез</div>
<div className="text-12 text-secondary">Команда, циклы и модули относительно текущего workspace.</div>
</div>
</div>
<div className="space-y-4">
{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 className="flex items-center justify-between gap-3">
<div> <div>
<div className="text-15 font-semibold text-primary">Ритм исполнения</div> <div className="text-15 font-semibold text-primary">Ритм исполнения</div>
@ -397,7 +363,55 @@ export function HomeOperationsOverview(props: HomeProjectInsightsProps) {
<span className="font-semibold text-primary">{recentTouchpoints}</span> <span className="font-semibold text-primary">{recentTouchpoints}</span>
<span> недавних касаний.</span> <span> недавних касаний.</span>
</div> </div>
</section>
);
}
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>
<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>
</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 ? (
@ -423,6 +437,15 @@ export function HomeOperationsOverview(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,6 +5,8 @@
*/ */
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";
@ -19,6 +21,7 @@ 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;
}; };
@ -27,6 +30,7 @@ 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,
@ -34,6 +38,7 @@ export function HomeProjectStack(props: HomeProjectStackProps) {
orientation = "vertical", orientation = "vertical",
recents, recents,
selectedProjectId, selectedProjectId,
workspaceSlug,
onSelectProject, onSelectProject,
} = props; } = props;
@ -87,16 +92,26 @@ export function HomeProjectStack(props: HomeProjectStackProps) {
const isActive = project.id === selectedProject?.id; const isActive = project.id === selectedProject?.id;
return ( return (
<button <div
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 }
@ -115,10 +130,15 @@ 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">
<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"> <Link
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>
</div> </Link>
<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",
@ -155,7 +175,7 @@ export function HomeProjectStack(props: HomeProjectStackProps) {
</div> </div>
</div> </div>
</div> </div>
</button> </div>
); );
}; };
@ -183,7 +203,7 @@ export function HomeProjectStack(props: HomeProjectStackProps) {
</div> </div>
)} )}
<div className="mt-4 rounded-[24px] bg-black/10 p-4 xl:mt-auto"> <div className="nodedc-home-project-quick-section 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>
@ -195,45 +215,64 @@ export function HomeProjectStack(props: HomeProjectStackProps) {
</div> </div>
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="nodedc-home-project-quick-list">
{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={cn("nodedc-toolbar-pill inline-flex items-center gap-2", { className="nodedc-home-project-quick-button"
"!bg-[rgb(var(--nodedc-card-active-rgb))] !text-[rgb(var(--nodedc-on-card-active-rgb))]": data-active={isActive}
project.id === selectedProject?.id, aria-label={
})} 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">
<span className="nodedc-home-project-quick-logo">
<Logo logo={project.logo_props} size={14} /> <Logo logo={project.logo_props} size={14} />
<span>{project.identifier}</span> </span>
<span className="text-[11px] opacity-70">{getCompletionRate(analytics)}%</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="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-grid mt-4 grid grid-cols-2 gap-3 md:grid-cols-3">
<div className="rounded-2xl bg-black/10 px-3 py-2"> <div className="nodedc-home-project-focus-item 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="mt-1 text-13 font-semibold text-primary">{selectedProject.identifier}</div> <div className="nodedc-home-project-focus-value mt-1 text-13 font-semibold text-primary">
{selectedProject.identifier}
</div> </div>
<div className="rounded-2xl bg-black/10 px-3 py-2"> </div>
<div className="nodedc-home-project-focus-item 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="mt-1 text-13 font-semibold text-primary"> <div className="nodedc-home-project-focus-value 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="rounded-2xl bg-black/10 px-3 py-2"> <div className="nodedc-home-project-focus-item 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="mt-1 text-13 font-semibold text-primary"> <div className="nodedc-home-project-focus-value mt-1 text-13 font-semibold text-primary">
{activityCountByProject[selectedProject.id] ?? 0} касаний {activityCountByProject[selectedProject.id] ?? 0} касаний
</div> </div>
</div> </div>

View File

@ -34,11 +34,13 @@ 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;
@ -49,6 +51,7 @@ type DeckSectionProps = {
}; };
type InternalIssueCardProps = { type InternalIssueCardProps = {
compact?: boolean;
isActive: boolean; isActive: boolean;
issue: TIssue; issue: TIssue;
onSelect: () => void; onSelect: () => void;
@ -56,6 +59,7 @@ type InternalIssueCardProps = {
}; };
type ExternalIssueCardProps = { type ExternalIssueCardProps = {
compact?: boolean;
isActive: boolean; isActive: boolean;
onSelect: () => void; onSelect: () => void;
project: THomeProjectData; project: THomeProjectData;
@ -76,14 +80,14 @@ const sortByRecentCreatedDate = <
}; };
const DeckSection = (props: DeckSectionProps) => { const DeckSection = (props: DeckSectionProps) => {
const { count, description, emptyDescription, emptyTitle, isLoading, items, title } = props; const { compact = false, 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>
<div className="text-12 text-secondary">{description}</div> {!compact && <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" />
@ -91,16 +95,24 @@ const DeckSection = (props: DeckSectionProps) => {
</div> </div>
</div> </div>
<div className="nodedc-home-task-deck-scroller"> <div
<div className="flex min-h-[236px] items-end px-1 py-4"> className={cn("nodedc-home-task-deck-scroller", {
"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", {
"-ml-16": index > 0, "nodedc-home-task-card-compact": compact,
})} })}
style={{ zIndex: 5 - index }}
/> />
)) ))
: items} : items}
@ -118,7 +130,7 @@ const DeckSection = (props: DeckSectionProps) => {
}; };
const HomeInternalContourDeckCard = observer(function HomeInternalContourDeckCard(props: InternalIssueCardProps) { const HomeInternalContourDeckCard = observer(function HomeInternalContourDeckCard(props: InternalIssueCardProps) {
const { isActive, issue, onSelect, project } = props; const { compact = false, isActive, issue, onSelect, project } = props;
const { t } = useTranslation(); const { t } = useTranslation();
const { getProjectById } = useProject(); const { getProjectById } = useProject();
const { getUserDetails } = useMember(); const { getUserDetails } = useMember();
@ -183,7 +195,7 @@ const HomeInternalContourDeckCard = observer(function HomeInternalContourDeckCar
const footer = ( const footer = (
<> <>
<div className={cn("inline-flex min-h-9 items-center rounded-full pl-1 pr-2", pillBackgroundClasses)}> <div className={cn("inline-flex min-h-9 items-center rounded-full pr-2 pl-1", 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" />
) : ( ) : (
@ -191,7 +203,12 @@ const HomeInternalContourDeckCard = observer(function HomeInternalContourDeckCar
)} )}
</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)}> <div
className={cn(
"inline-flex min-h-9 items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-medium",
pillBackgroundClasses
)}
>
<CalendarDays className="h-3.5 w-3.5" /> <CalendarDays className="h-3.5 w-3.5" />
<span className="truncate">{dueDateLabel}</span> <span className="truncate">{dueDateLabel}</span>
</div> </div>
@ -199,14 +216,25 @@ const HomeInternalContourDeckCard = observer(function HomeInternalContourDeckCar
); );
return ( return (
<button type="button" className="nodedc-home-task-card" data-active={isActive} onClick={onSelect} title={issue.name}> <button
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="px-1" contentClassName={cn("px-1", compact && "min-h-[168px]")}
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}
@ -217,15 +245,13 @@ const HomeInternalContourDeckCard = observer(function HomeInternalContourDeckCar
}); });
const HomeExternalContourDeckCard = observer(function HomeExternalContourDeckCard(props: ExternalIssueCardProps) { const HomeExternalContourDeckCard = observer(function HomeExternalContourDeckCard(props: ExternalIssueCardProps) {
const { isActive, onSelect, project, request } = props; const { compact = false, 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 const isOutgoing = request.direction ? request.direction === "outgoing" : request.source_project_id === project.id;
? 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 ||
@ -242,15 +268,29 @@ 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 type="button" className="nodedc-home-task-card" data-active={isActive} onClick={onSelect} title={issue.name}> <button
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 min-h-[220px] w-full flex-col p-4", "nodedc-external-card nodedc-home-task-card-surface relative flex w-full flex-col",
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 className={cn("relative flex min-h-[220px] flex-col px-1", isActive ? "text-[rgb(var(--nodedc-on-card-active-rgb))]" : "text-white")}> <div
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">
@ -283,7 +323,7 @@ const HomeExternalContourDeckCard = observer(function HomeExternalContourDeckCar
<div <div
className={cn( className={cn(
"truncate -mt-0.5 pl-8 text-[11px] font-medium leading-4", "-mt-0.5 truncate pl-8 text-[11px] leading-4 font-medium",
isActive ? "text-[rgb(var(--nodedc-on-card-active-rgb))]/72" : "text-[#B3B3B8]" isActive ? "text-[rgb(var(--nodedc-on-card-active-rgb))]/72" : "text-[#B3B3B8]"
)} )}
> >
@ -291,12 +331,21 @@ const HomeExternalContourDeckCard = observer(function HomeExternalContourDeckCar
</div> </div>
</div> </div>
<div className="flex flex-1 items-center justify-center px-5 py-4 text-center"> <div
<div className="line-clamp-4 max-w-full text-lg font-semibold leading-6">{issue.name}</div> className={cn("flex flex-1 items-center justify-center text-center", compact ? "px-3 py-3" : "px-5 py-4")}
>
<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="flex items-center justify-between gap-3"> <div className={cn("flex items-center justify-between", compact ? "gap-2" : "gap-3")}>
<div className={cn("inline-flex min-h-9 items-center rounded-full pl-1 pr-2", pillBackgroundClasses)}> <div className={cn("inline-flex min-h-9 items-center rounded-full pr-2 pl-1", 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" />
) : ( ) : (
@ -304,7 +353,12 @@ const HomeExternalContourDeckCard = observer(function HomeExternalContourDeckCar
)} )}
</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)}> <div
className={cn(
"inline-flex min-h-9 items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-medium",
pillBackgroundClasses
)}
>
<CalendarDays className="h-3.5 w-3.5" /> <CalendarDays className="h-3.5 w-3.5" />
<span className="truncate">{dueDateLabel}</span> <span className="truncate">{dueDateLabel}</span>
</div> </div>
@ -316,7 +370,7 @@ const HomeExternalContourDeckCard = observer(function HomeExternalContourDeckCar
}); });
export const HomeRecentIssueDecks = observer(function HomeRecentIssueDecks(props: HomeRecentIssueDecksProps) { export const HomeRecentIssueDecks = observer(function HomeRecentIssueDecks(props: HomeRecentIssueDecksProps) {
const { project, workspaceSlug } = props; const { compact = false, 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);
@ -382,9 +436,13 @@ export const HomeRecentIssueDecks = observer(function HomeRecentIssueDecks(props
if (!project) { if (!project) {
return ( return (
<HomeCardShell <HomeCardShell
eyebrow="Task Decks" eyebrow={compact ? "Последние задачи" : "Task Decks"}
title="Последние задачи по проекту" title={compact ? "Последние задачи проекта" : "Последние задачи по проекту"}
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">
Фокус проекта пока не выбран. Фокус проекта пока не выбран.
@ -393,61 +451,63 @@ export const HomeRecentIssueDecks = observer(function HomeRecentIssueDecks(props
); );
} }
const internalIssueCards = internalIssues.map((issue, index) => ( const internalIssueCards = internalIssues.map((issue) => (
<div
key={issue.id}
className={cn({ "-ml-16": index > 0 })}
style={{ zIndex: issue.id === selectedInternalIssueId ? internalIssues.length + 6 : index + 1 }}
>
<HomeInternalContourDeckCard <HomeInternalContourDeckCard
compact={compact}
key={issue.id}
issue={issue} issue={issue}
isActive={issue.id === selectedInternalIssueId} isActive={issue.id === selectedInternalIssueId}
onSelect={() => setSelectedInternalIssueId(issue.id)} onSelect={() => setSelectedInternalIssueId(issue.id)}
project={project} project={project}
/> />
</div>
)); ));
const externalIssueCards = externalRequests.map((request, index) => ( const externalIssueCards = externalRequests.map((request) => (
<div
key={request.id}
className={cn({ "-ml-16": index > 0 })}
style={{ zIndex: request.id === selectedExternalRequestId ? externalRequests.length + 6 : index + 1 }}
>
<HomeExternalContourDeckCard <HomeExternalContourDeckCard
compact={compact}
key={request.id}
isActive={request.id === selectedExternalRequestId} isActive={request.id === selectedExternalRequestId}
onSelect={() => setSelectedExternalRequestId(request.id)} onSelect={() => setSelectedExternalRequestId(request.id)}
project={project} project={project}
request={request} request={request}
/> />
</div>
)); ));
return ( return (
<HomeCardShell <HomeCardShell
eyebrow={`${project.identifier} • последние задачи`} eyebrow={compact ? `${project.identifier} • последние задачи` : `${project.identifier} • последние задачи`}
title="Последние задачи проекта" title={compact ? "Последние задачи проекта" : "Последние задачи проекта"}
description="Ниже собраны две колоды для быстрого просмотра новых карточек во внешнем и внутреннем контурах." description={
contentClassName="space-y-5 p-5" compact
? 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="Последние задачи внешнего контура" title={compact ? "Внешний контур" : "Внешний контур"}
/> />
<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="Последние задачи внутреннего контура" title={compact ? "Внутренний контур" : "Внутренний контур"}
/> />
</HomeCardShell> </HomeCardShell>
); );

View File

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

View File

@ -4,24 +4,20 @@
* 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, TProjectAnalyticsCount } from "@plane/types"; import type { IUser } 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, selectedProject, selectedProjectAnalytics } = props; const { user, workspaceName } = 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, {
@ -46,39 +42,37 @@ 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 completionRate = getCompletionRate(selectedProjectAnalytics); const userName = `${user?.first_name ?? ""} ${user?.last_name ?? ""}`.trim() || user?.email || "Workspace admin";
const userEmail = user?.email || workspaceName || "NODE DC";
return ( return (
<section className="nodedc-home-card px-5 py-4"> <section className="nodedc-home-user-card">
<div className="grid items-stretch gap-3 xl:grid-cols-[minmax(0,1fr)_280px_220px]"> <div className="nodedc-home-user-card-orb" />
<div className="flex min-w-0 items-center"> <div className="relative z-[1] p-5">
<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-[11px] font-semibold tracking-[0.22em] text-placeholder uppercase"> <div className="text-12 text-white/58">{`${weekDay}, ${date} ${timeString}`}</div>
{workspaceName ?? "Workspace Home"} <h2 className="mt-1 line-clamp-2 text-24 font-semibold leading-tight text-white">
</div> {`${t("good")} ${t(greeting)}, ${user?.first_name || "DC"}`}
<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="nodedc-home-subpanel flex min-h-[108px] flex-col justify-center px-4 py-3"> <div className="mt-8 space-y-1">
<div className="text-12 font-medium text-secondary">Текущий фокус</div> <div className="text-18 font-semibold text-white">{userName}</div>
<div className="mt-2 line-clamp-2 text-18 font-semibold text-primary"> <div className="truncate text-13 text-white/62">{userEmail}</div>
{selectedProject ? selectedProject.name : "Выберите проект слева"}
</div>
<div className="mt-1 text-12 text-secondary">
{selectedProject ? selectedProject.identifier : "Домашняя сводка перестроится под выбранную карточку."}
</div>
</div> </div>
<div className="nodedc-home-subpanel flex min-h-[108px] flex-col justify-center px-4 py-3"> <div className="mt-5 flex flex-wrap items-center gap-2 text-[11px] font-medium text-white/56">
<div className="text-12 font-medium text-secondary">Прогресс фокуса</div> <div className="rounded-full bg-black/24 px-3 py-1.5 backdrop-blur-md">home admin</div>
<div className="mt-2 text-18 font-semibold text-primary">{selectedProject ? `${completionRate}%` : "—"}</div> <div className="rounded-full bg-black/24 px-3 py-1.5 backdrop-blur-md">{workspaceName ?? "workspace"}</div>
<div className="mt-1 text-12 text-secondary">
{selectedProject ? "Закрытые задачи относительно общего объёма." : "Станет доступен после выбора проекта."}
</div>
</div> </div>
</div> </div>
</section> </section>

View File

@ -31,6 +31,16 @@ 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;
@ -53,6 +63,11 @@ 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(() => {
@ -76,7 +91,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">{widget.key}</div>); root.render(<div className="rounded-sm bg-surface-1 p-1 pr-2 text-13">{widgetLabel}</div>);
return () => root.unmount(); return () => root.unmount();
}, },
nativeSetDragImage, nativeSetDragImage,
@ -118,7 +133,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]); }, [elementRef?.current, isDragging, isLastChild, widget.key, widgetLabel]);
return ( return (
<div className=""> <div className="">
@ -134,7 +149,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>{t(widgetTitle, { count: 1 })}</div> <div>{widgetLabel}</div>
</div> </div>
<ToggleSwitch <ToggleSwitch
value={widget.is_enabled} value={widget.is_enabled}

View File

@ -11,9 +11,6 @@ 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";
@ -105,20 +102,15 @@ export const RecentActivityWidget = observer(function RecentActivityWidget(props
); );
return ( return (
<ContentOverflowWrapper <div className="box-border min-h-[250px]">
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 min-h-[250px] flex-col"> <div className="flex max-h-[415px] min-h-[250px] flex-col overflow-y-auto">
{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>
</ContentOverflowWrapper> </div>
); );
}); });

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 } from "@plane/propel/icons"; import { SearchIcon, getStateGroupColor } 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 = issue.state__color || ""; const stateColor = getStateGroupColor(issue.state__group, issue.state__color);
return ( return (
<Combobox.Option <Combobox.Option

View File

@ -74,6 +74,7 @@ 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?.asset_url ?? ""); const fileExtension = getFileExtension(attachment?.attributes.name ?? "");
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,72 +51,79 @@ 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 { maxFileSize } = useFileSize(); const { fileSizeLimitEnabled, 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[]) => {
const totalAttachedFiles = acceptedFiles.length + rejectedFiles.length; if (acceptedFiles.length > 0) {
if (!workspaceSlug) return;
if (rejectedFiles.length === 0) {
const currentFile: File = acceptedFiles[0];
if (!currentFile || !workspaceSlug) return;
setIsUploading(true); setIsUploading(true);
createAttachment(currentFile)
.catch(() => { Promise.allSettled(acceptedFiles.map((file) => createAttachment(file))).finally(() => {
setToast({ handleRefreshAttachments();
type: TOAST_TYPE.ERROR, window.setTimeout(handleRefreshAttachments, 1200);
title: t("toast.error"),
message: t("attachment.error"),
});
})
.finally(() => {
handleFetchPropertyActivities(); handleFetchPropertyActivities();
setIsUploading(false); 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 } = useDropzone({ const { getRootProps, getInputProps, isDragActive, isDragReject } = useDropzone({
onDrop, onDrop,
maxSize: maxFileSize, maxSize: fileSizeLimitEnabled ? maxFileSize : undefined,
multiple: false, multiple: true,
disabled: isUploading || disabled, disabled: isUploading || disabled,
}); });
return ( return (
<> <div className="space-y-3">
{uploadStatus?.map((uploadStatus) => (
<IssueAttachmentsUploadItem key={uploadStatus.id} uploadStatus={uploadStatus} />
))}
{issueAttachments && (
<>
{attachmentDeleteModalId && ( {attachmentDeleteModalId && (
<IssueAttachmentDeleteModal <IssueAttachmentDeleteModal
isOpen={Boolean(attachmentDeleteModalId)} isOpen={Boolean(attachmentDeleteModalId)}
@ -126,22 +133,58 @@ export const IssueAttachmentItemList = observer(function IssueAttachmentItemList
issueServiceType={issueServiceType} issueServiceType={issueServiceType}
/> />
)} )}
<div <div
{...getRootProps()} {...getRootProps()}
className={`relative flex flex-col ${isDragActive && issueAttachments.length < 3 ? "min-h-[200px]" : ""} ${disabled ? "cursor-not-allowed" : "cursor-pointer"}`} 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()} /> <input {...getInputProps()} />
{isDragActive && ( <div className="flex min-w-0 items-center gap-4">
<div className="absolute top-0 left-0 z-30 flex h-full w-full items-center justify-center bg-surface-2/75"> <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))]">
<div className="flex items-center justify-center rounded-md bg-surface-1 p-1"> <UploadCloud className="size-5" />
<div className="flex flex-col items-center justify-center rounded-md border border-dashed border-strong px-5 py-6"> </div>
<UploadCloud className="size-7" /> <div className="min-w-0">
<span className="text-13 text-tertiary">{t("attachment.drag_and_drop")}</span> <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>
</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
className="h-1 rounded-full bg-[rgb(var(--nodedc-accent-rgb))] transition-[width] duration-200 ease-out"
style={{ width: `${uploadProgress}%` }}
/>
</div>
)} )}
{issueAttachments?.map((attachmentId) => (
{hasAttachmentRows && (
<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="mb-2 flex items-center justify-between gap-3 px-1">
<div className="text-12 font-semibold tracking-[0.08em] text-tertiary uppercase">Вложения</div>
<div className="text-12 text-tertiary">
{activeUploads.length > 0 ? `Загрузка ${uploadProgress}%` : issueAttachments.length}
</div>
</div>
<div className="flex gap-3 overflow-x-auto pb-1">
{uploadStatus?.map((status) => (
<IssueAttachmentsUploadItem key={status.id} uploadStatus={status} />
))}
{issueAttachments.map((attachmentId) => (
<IssueAttachmentsListItem <IssueAttachmentsListItem
key={attachmentId} key={attachmentId}
attachmentId={attachmentId} attachmentId={attachmentId}
@ -150,8 +193,8 @@ export const IssueAttachmentItemList = observer(function IssueAttachmentItemList
/> />
))} ))}
</div> </div>
</> </div>
)} )}
</> </div>
); );
}); });

View File

@ -5,6 +5,7 @@
*/ */
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";
@ -14,12 +15,14 @@ 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 } from "@plane/ui"; import { ActionDropdown, EModalPosition, EModalWidth, ModalCore } 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";
@ -32,6 +35,25 @@ 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
@ -46,8 +68,13 @@ 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 fileIcon = getFileIcon(fileExtension, 18); const fullFileName = fileExtension ? `${fileName}.${fileExtension}` : fileName;
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",
@ -64,48 +91,70 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
if (!attachment) return <></>; if (!attachment) return <></>;
return ( return (
<div <>
role="button" <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">
tabIndex={0} <button
type="button"
className="flex min-w-0 flex-1 items-stretch text-left"
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
window.open(fileURL, "_blank"); setIsPreviewOpen(true);
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
e.stopPropagation();
window.open(fileURL, "_blank");
}
}} }}
> >
<div className="group flex h-11 items-center justify-between gap-3 pr-2 pl-9 hover:bg-surface-2"> <div className="relative h-full w-28 flex-shrink-0 overflow-hidden bg-surface-1">
<div className="flex items-center gap-3 truncate text-13"> {previewType === "image" && previewURL && !isThumbnailError ? (
<div className="flex items-center gap-3">{fileIcon}</div> <img
<Tooltip tooltipContent={`${fileName}.${fileExtension}`} isMobile={isMobile}> src={previewURL}
<p className="truncate font-medium text-secondary">{`${fileName}.${fileExtension}`}</p> alt={fullFileName}
</Tooltip> className="h-full w-full object-cover"
<span className="flex size-1.5 rounded-full bg-layer-1" /> loading="lazy"
<span className="flex-shrink-0 text-placeholder">{convertBytesToSize(attachment.attributes.size)}</span> onError={() => setIsThumbnailError(true)}
/>
) : previewType === "video" && previewURL ? (
<>
<video src={previewURL} className="h-full w-full object-cover" muted playsInline preload="metadata" />
<div className="absolute inset-0 grid place-items-center bg-black/20 text-white">
<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 className="flex min-w-0 flex-1 flex-col justify-between p-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> </div>
<div className="flex items-center gap-3">
{attachment?.created_by && ( {attachment?.created_by && (
<>
<Tooltip <Tooltip
isMobile={isMobile} isMobile={isMobile}
tooltipContent={`${ tooltipContent={`${
getUserDetails(attachment?.created_by)?.display_name ?? "" getUserDetails(attachment?.created_by)?.display_name ?? ""
} uploaded on ${renderFormattedDate(attachment.updated_at)}`} } uploaded on ${renderFormattedDate(attachment.updated_at)}`}
> >
<div className="flex items-center justify-center"> <div className="flex w-fit items-center justify-center">
<ButtonAvatars showTooltip userIds={attachment?.created_by} /> <ButtonAvatars showTooltip userIds={attachment?.created_by} />
</div> </div>
</Tooltip> </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")}
@ -114,6 +163,66 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
/> />
</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>
<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